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
+7 -4
View File
@@ -15,9 +15,10 @@ This CLI version is built using the same core as the Obsidian plug-in:
```
CLI Main
└─ LiveSyncBaseCore<ServiceContext, IMinimumLiveSyncCommands>
NodeServiceHub (All services without Obsidian dependencies)
└─ ServiceModules (wired by initialiseServiceModulesCLI)
└─ NodeServiceContext (events, translation, database root, and injected standard I/O)
LiveSyncBaseCore<NodeServiceContext, IMinimumLiveSyncCommands>
├─ NodeServiceHub (All services without Obsidian dependencies)
└─ ServiceModules (wired by initialiseServiceModulesCLI)
├─ FileAccessCLI (Node.js FileSystemAdapter)
├─ StorageEventManagerCLI
├─ ServiceFileAccessCLI
@@ -37,7 +38,9 @@ CLI Main
- All core sync functionality preserved
3. **Service Hub and Settings Services** (`services/`)
- `NodeServiceHub` provides the CLI service context
- `NodeServiceContext` owns the host-selected database root and standard input/output implementation
- `NodeServiceHub` receives that exact Context instead of constructing platform capabilities implicitly
- Internal adapter diagnostics use injected callbacks wired to the service logging API
- Node-specific settings and key-value services are provided without Obsidian dependencies
4. **Main Entry Point** (`main.ts`)
@@ -7,6 +7,7 @@ import { NodeStorageAdapter } from "@vrtmrz/livesync-commonlib/node";
import { NodeVaultAdapter } from "./NodeVaultAdapter";
import type { NodeFile, NodeFolder, NodeStat } from "./NodeTypes";
import { fsPromises as fs, path } from "@vrtmrz/livesync-commonlib/node";
import type { CliDiagnosticReporter } from "@/apps/cli/cliOutput";
/**
* Complete file system adapter implementation for Node.js
@@ -20,7 +21,10 @@ export class NodeFileSystemAdapter implements IFileSystemAdapter<NodeFile, NodeF
private fileCache = new Map<string, NodeFile>();
constructor(private basePath: string) {
constructor(
private basePath: string,
private reportDiagnostic: CliDiagnosticReporter = () => undefined
) {
this.path = new NodePathAdapter();
this.typeGuard = new NodeTypeGuardAdapter();
this.conversion = new NodeConversionAdapter();
@@ -33,7 +37,7 @@ export class NodeFileSystemAdapter implements IFileSystemAdapter<NodeFile, NodeF
}
private normalisePath(p: FilePath | string): string {
return this.path.normalisePath(p as string);
return this.path.normalisePath(p);
}
private async hasExactPathCase(pathStr: string): Promise<boolean> {
@@ -169,7 +173,7 @@ export class NodeFileSystemAdapter implements IFileSystemAdapter<NodeFile, NodeF
}
} catch (error) {
// Directory doesn't exist or is not readable
console.error(`Error scanning directory ${fullPath}:`, error);
this.reportDiagnostic(`Error scanning directory ${fullPath}:`, error);
}
}
}
@@ -1,5 +1,5 @@
import { fsPromises, os, path } from "@vrtmrz/livesync-commonlib/node";
import { describe, expect, it } from "vitest";
import { describe, expect, it, vi } from "vitest";
import type { FilePath } from "@vrtmrz/livesync-commonlib/compat/common/types";
import { NodeFileSystemAdapter } from "./NodeFileSystemAdapter";
import { NodeVaultAdapter } from "./NodeVaultAdapter";
@@ -46,4 +46,22 @@ describe("NodeFileSystemAdapter path case", () => {
await fsPromises.rm(directory, { recursive: true, force: true });
}
});
it("reports directory scan failures through the injected diagnostic callback", async () => {
const directory = await fsPromises.mkdtemp(path.join(os.tmpdir(), "livesync-scan-diagnostic-"));
const missingDirectory = path.join(directory, "missing");
const reportDiagnostic = vi.fn();
try {
const adapter = new NodeFileSystemAdapter(missingDirectory, reportDiagnostic);
await adapter.scanDirectory();
expect(reportDiagnostic).toHaveBeenCalledWith(
`Error scanning directory ${missingDirectory}:`,
expect.any(Error)
);
} finally {
await fsPromises.rm(directory, { recursive: true, force: true });
}
});
});
+30
View File
@@ -0,0 +1,30 @@
import type { StandardIo } from "@vrtmrz/livesync-commonlib/context";
/** Report a CLI-owned diagnostic without selecting its final presentation channel. */
export type CliDiagnosticReporter = (message: string, detail?: unknown) => void;
function formatValue(value: unknown): string {
if (typeof value === "string") return value;
if (value instanceof Error) return value.stack ?? value.message;
try {
const encoded = JSON.stringify(value);
if (encoded !== undefined) return encoded;
} catch {
// Fall through to the host-independent string conversion.
}
return String(value);
}
function formatLine(values: readonly unknown[]): string {
return `${values.map(formatValue).join(" ")}\n`;
}
/** Render one user-facing line on standard output. */
export function writeStdoutLine(standardIo: StandardIo, ...values: readonly unknown[]): void {
standardIo.writeStdout(formatLine(values));
}
/** Render one user-facing or diagnostic line on standard error. */
export function writeStderrLine(standardIo: StandardIo, ...values: readonly unknown[]): void {
standardIo.writeStderr(formatLine(values));
}
@@ -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();
}
}
+6 -2
View File
@@ -2,6 +2,8 @@
import { RTCPeerConnection } from "werift";
import { main } from "./main";
import { compatGlobal } from "@vrtmrz/livesync-commonlib/compat/common/coreEnvFunctions";
import { createNodeStandardIo } from "@vrtmrz/livesync-commonlib/node";
import { writeStderrLine } from "./cliOutput";
if (
typeof (compatGlobal as unknown as Record<string, unknown>).RTCPeerConnection === "undefined" &&
@@ -11,7 +13,9 @@ if (
(compatGlobal as unknown as Record<string, unknown>).RTCPeerConnection = RTCPeerConnection;
}
main().catch((error) => {
console.error(`[Fatal Error]`, error);
const standardIo = createNodeStandardIo();
main(standardIo).catch((error) => {
writeStderrLine(standardIo, `[Fatal Error]`, error);
process.exit(1);
});
+67 -57
View File
@@ -25,14 +25,18 @@ import { getPathFromUXFileInfo } from "@vrtmrz/livesync-commonlib/compat/common/
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 { fsPromises as fs, path, fs as fsSync } from "@vrtmrz/livesync-commonlib/node";
import { createNodeStandardIo, fsPromises as fs, path, fs as fsSync } from "@vrtmrz/livesync-commonlib/node";
import type { StandardIo } from "@vrtmrz/livesync-commonlib/context";
import { writeStderrLine, writeStdoutLine } from "./cliOutput";
const SETTINGS_FILE = ".livesync/settings.json";
ensureGlobalNodeLocalStorage();
defaultLoggerEnv.minLogLevel = LOG_LEVEL_DEBUG;
function printHelp(): void {
console.log(`
function printHelp(standardIo: StandardIo): void {
writeStdoutLine(
standardIo,
`
Self-hosted LiveSync CLI
Usage:
@@ -115,14 +119,15 @@ Examples:
livesync-cli ./my-database remote-status remote-abc123
livesync-cli init-settings ./data.json
livesync-cli ./my-database --verbose
`);
`
);
}
export function parseArgs(): CLIOptions {
export function parseArgs(standardIo: StandardIo = createNodeStandardIo()): CLIOptions {
const args = process.argv.slice(2);
if (args.length === 0 || args.includes("--help") || args.includes("-h")) {
printHelp();
printHelp(standardIo);
process.exit(0);
}
@@ -143,7 +148,7 @@ export function parseArgs(): CLIOptions {
case "-V": {
i++;
if (!args[i]) {
console.error(`Error: Missing value for ${token}`);
writeStderrLine(standardIo, `Error: Missing value for ${token}`);
process.exit(1);
}
vaultPath = args[i];
@@ -153,7 +158,7 @@ export function parseArgs(): CLIOptions {
case "-s": {
i++;
if (!args[i]) {
console.error(`Error: Missing value for ${token}`);
writeStderrLine(standardIo, `Error: Missing value for ${token}`);
process.exit(1);
}
settingsPath = args[i];
@@ -163,12 +168,12 @@ export function parseArgs(): CLIOptions {
case "-i": {
i++;
if (!args[i]) {
console.error(`Error: Missing value for ${token}`);
writeStderrLine(standardIo, `Error: Missing value for ${token}`);
process.exit(1);
}
const n = parseInt(args[i], 10);
if (!Number.isInteger(n) || n <= 0) {
console.error(`Error: --interval requires a positive integer, got '${args[i]}'`);
writeStderrLine(standardIo, `Error: --interval requires a positive integer, got '${args[i]}'`);
process.exit(1);
}
interval = n;
@@ -212,12 +217,12 @@ export function parseArgs(): CLIOptions {
}
if (!databasePath && command !== "init-settings") {
console.error("Error: database-path is required");
writeStderrLine(standardIo, "Error: database-path is required");
process.exit(1);
}
if (command === "daemon" && commandArgs.length > 0) {
console.error(`Error: Unknown command '${commandArgs[0]}'`);
writeStderrLine(standardIo, `Error: Unknown command '${commandArgs[0]}'`);
process.exit(1);
}
@@ -234,7 +239,7 @@ export function parseArgs(): CLIOptions {
};
}
async function createDefaultSettingsFile(options: CLIOptions) {
async function createDefaultSettingsFile(options: CLIOptions, standardIo: StandardIo) {
const targetPath = options.settingsPath
? path.resolve(options.settingsPath)
: options.commandArgs[0]
@@ -259,13 +264,13 @@ async function createDefaultSettingsFile(options: CLIOptions) {
await fs.mkdir(path.dirname(targetPath), { recursive: true });
await fs.writeFile(targetPath, JSON.stringify(settings, null, 2), "utf-8");
console.log(`[Done] Created settings file: ${targetPath}`);
writeStdoutLine(standardIo, `[Done] Created settings file: ${targetPath}`);
}
export async function main() {
const options = parseArgs();
export async function main(standardIo: StandardIo = createNodeStandardIo()) {
const options = parseArgs(standardIo);
if (options.interval && options.command !== "daemon") {
console.error(`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" ||
@@ -282,13 +287,13 @@ export async function main() {
options.command === "unlock-remote" ||
options.command === "lock-remote" ||
options.command === "remote-status";
const infoLog = avoidStdoutNoise ? console.error : console.log;
const infoLog = (...values: readonly unknown[]) => {
const writeLine = avoidStdoutNoise ? writeStderrLine : writeStdoutLine;
writeLine(standardIo, ...values);
};
if (options.debug) {
setGlobalLogFunction((msg, level) => {
console.error(`[${level}] ${typeof msg === "string" ? msg : JSON.stringify(msg)}`);
if (msg instanceof Error) {
console.error(msg);
}
writeStderrLine(standardIo, `[${level}]`, msg);
});
} else {
setGlobalLogFunction((msg, level) => {
@@ -296,7 +301,7 @@ export async function main() {
});
}
if (options.command === "init-settings") {
await createDefaultSettingsFile(options);
await createDefaultSettingsFile(options, standardIo);
return;
}
@@ -306,11 +311,11 @@ export async function main() {
try {
const stat = await fs.stat(databasePath);
if (!stat.isDirectory()) {
console.error(`Error: ${databasePath} is not a directory`);
writeStderrLine(standardIo, `Error: ${databasePath} is not a directory`);
process.exit(1);
}
} catch {
console.error(`Error: Database directory ${databasePath} does not exist`);
writeStderrLine(standardIo, `Error: Database directory ${databasePath} does not exist`);
process.exit(1);
}
@@ -336,11 +341,11 @@ export async function main() {
try {
const stat = await fs.stat(vaultPath);
if (!stat.isDirectory()) {
console.error(`Error: Vault path ${vaultPath} is not a directory`);
writeStderrLine(standardIo, `Error: Vault path ${vaultPath} is not a directory`);
process.exit(1);
}
} catch {
console.error(`Error: Vault directory ${vaultPath} does not exist`);
writeStderrLine(standardIo, `Error: Vault directory ${vaultPath} does not exist`);
process.exit(1);
}
@@ -351,12 +356,18 @@ export async function main() {
infoLog("");
let ignoreRules: IgnoreRules | undefined;
if (options.command === "daemon" || options.command === "mirror") {
ignoreRules = new IgnoreRules(vaultPath);
ignoreRules = new IgnoreRules(vaultPath, (message, detail) => {
if (detail === undefined) {
writeStderrLine(standardIo, message);
} else {
writeStderrLine(standardIo, message, detail);
}
});
await ignoreRules.load();
}
// Create service context and hub
const context = new NodeServiceContext(databasePath);
const context = new NodeServiceContext(databasePath, standardIo);
const serviceHubInstance = new NodeServiceHub<NodeServiceContext>(databasePath, context);
serviceHubInstance.API.addLog.setHandler((message: unknown, level: LOG_LEVEL) => {
let levelStr = "";
@@ -377,19 +388,19 @@ export async function main() {
levelStr = "Urgent";
break;
default:
levelStr = `${level}`;
levelStr = String(level);
}
const prefix = `(${levelStr})`;
if (level <= LOG_LEVEL_INFO) {
if (!options.verbose) return;
}
console.error(`${prefix} ${message}`);
writeStderrLine(standardIo, prefix, message);
});
// Prevent replication result from being processed automatically in non-daemon commands.
// In daemon mode the default handler must run so changes are applied to the filesystem.
if (options.command !== "daemon") {
serviceHubInstance.replication.processSynchroniseResult.addHandler(async () => {
console.error(`[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);
}
@@ -402,10 +413,10 @@ export async function main() {
try {
await fs.writeFile(settingsPath, JSON.stringify(data, null, 2), "utf-8");
if (options.verbose) {
console.error(`[Settings] Saved to ${settingsPath}`);
writeStderrLine(standardIo, `[Settings] Saved to ${settingsPath}`);
}
} catch (error) {
console.error(`[Settings] Failed to save:`, error);
writeStderrLine(standardIo, `[Settings] Failed to save:`, error);
}
}
);
@@ -414,16 +425,15 @@ export async function main() {
async (): Promise<ObsidianLiveSyncSettings | undefined> => {
try {
const content = await fs.readFile(settingsPath, "utf-8");
const data = JSON.parse(content);
const data = JSON.parse(content) as ObsidianLiveSyncSettings;
if (options.verbose) {
console.error(`[Settings] Loaded from ${settingsPath}`);
writeStderrLine(standardIo, `[Settings] Loaded from ${settingsPath}`);
}
// Force disable IndexedDB adapter in CLI environment
data.useIndexedDBAdapter = false;
return data;
// Force disable IndexedDB adapter in CLI environment without mutating the loaded settings object.
return { ...data, useIndexedDBAdapter: false };
} catch {
if (options.verbose) {
console.error(`[Settings] File not found, using defaults`);
writeStderrLine(standardIo, `[Settings] File not found, using defaults`);
}
return undefined;
}
@@ -473,14 +483,14 @@ export async function main() {
// Setup signal handlers for graceful shutdown
const shutdown = async (signal: string) => {
console.log();
console.log(`[Shutdown] Received ${signal}, shutting down gracefully...`);
writeStdoutLine(standardIo);
writeStdoutLine(standardIo, `[Shutdown] Received ${signal}, shutting down gracefully...`);
try {
await core.services.control.onUnload();
console.log(`[Shutdown] Complete`);
writeStdoutLine(standardIo, `[Shutdown] Complete`);
process.exit(0);
} catch (error) {
console.error(`[Shutdown] Error:`, error);
writeStderrLine(standardIo, `[Shutdown] Error:`, error);
process.exit(1);
}
};
@@ -502,7 +512,7 @@ export async function main() {
fsSync.writeFileSync(tmpPath, settingsBackup, "utf-8");
fsSync.renameSync(tmpPath, settingsPath);
} catch (err) {
console.error("[Settings] Failed to restore settings on exit:", err);
writeStderrLine(standardIo, "[Settings] Failed to restore settings on exit:", err);
}
}
});
@@ -513,7 +523,7 @@ export async function main() {
const loadResult = await core.services.control.onLoad();
if (!loadResult) {
console.error(`[Error] Failed to initialize LiveSync`);
writeStderrLine(standardIo, `[Error] Failed to initialize LiveSync`);
process.exit(1);
}
// Capture sync settings before suspendAllSync() clobbers them.
@@ -538,15 +548,15 @@ export async function main() {
// Check if configured
const settings = core.services.setting.currentSettings();
if (!settings.isConfigured) {
console.warn(`[Warning] LiveSync is not configured yet`);
console.warn(`[Warning] Please edit ${settingsPath} to configure CouchDB connection`);
console.warn();
console.warn(`Required settings:`);
console.warn(` - couchDB_URI: CouchDB server URL`);
console.warn(` - couchDB_USER: CouchDB username`);
console.warn(` - couchDB_PASSWORD: CouchDB password`);
console.warn(` - couchDB_DBNAME: Database name`);
console.warn();
writeStderrLine(standardIo, `[Warning] LiveSync is not configured yet`);
writeStderrLine(standardIo, `[Warning] Please edit ${settingsPath} to configure CouchDB connection`);
writeStderrLine(standardIo);
writeStderrLine(standardIo, `Required settings:`);
writeStderrLine(standardIo, ` - couchDB_URI: CouchDB server URL`);
writeStderrLine(standardIo, ` - couchDB_USER: CouchDB username`);
writeStderrLine(standardIo, ` - couchDB_PASSWORD: CouchDB password`);
writeStderrLine(standardIo, ` - couchDB_DBNAME: Database name`);
writeStderrLine(standardIo);
} else {
infoLog(`[Info] LiveSync is configured and ready`);
infoLog(`[Info] Database: ${settings.couchDB_URI}/${settings.couchDB_DBNAME}`);
@@ -555,7 +565,7 @@ export async function main() {
const result = await runCommand(options, { databasePath, vaultPath, core, settingsPath, originalSyncSettings });
if (!result) {
console.error(`[Error] Command '${options.command}' failed`);
writeStderrLine(standardIo, `[Error] Command '${options.command}' failed`);
process.exitCode = 1;
} else if (options.command !== "daemon") {
infoLog(`[Done] Command '${options.command}' completed`);
@@ -568,7 +578,7 @@ export async function main() {
await core.services.control.onUnload();
}
} catch (error) {
console.error(`[Error] Failed to start:`, error);
writeStderrLine(standardIo, `[Error] Failed to start:`, error);
process.exit(1);
}
// To prevent unexpected hanging in webRTC connections.
+25 -17
View File
@@ -1,5 +1,14 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { parseArgs } from "./main";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { parseArgs as parseCliArgs } from "./main";
function createStandardIoMock() {
return {
readStdin: vi.fn(async () => ""),
prompt: vi.fn(async () => ""),
writeStdout: vi.fn(),
writeStderr: vi.fn(),
};
}
function mockProcessExit() {
const exitMock = vi.spyOn(process, "exit").mockImplementation(((code?: number) => {
@@ -10,6 +19,13 @@ function mockProcessExit() {
describe("CLI parseArgs", () => {
const originalArgv = process.argv.slice();
let standardIo: ReturnType<typeof createStandardIoMock>;
beforeEach(() => {
standardIo = createStandardIoMock();
});
const parseArgs = () => parseCliArgs(standardIo);
afterEach(() => {
process.argv = originalArgv.slice();
@@ -19,42 +35,38 @@ describe("CLI parseArgs", () => {
it("exits 1 when --settings has no value", () => {
process.argv = ["node", "livesync-cli", "./databasePath", "--settings"];
const exitMock = mockProcessExit();
const stderr = vi.spyOn(console, "error").mockImplementation(() => {});
expect(() => parseArgs()).toThrowError("__EXIT__:1");
expect(exitMock).toHaveBeenCalledWith(1);
expect(stderr).toHaveBeenCalledWith("Error: Missing value for --settings");
expect(standardIo.writeStderr).toHaveBeenCalledWith("Error: Missing value for --settings\n");
});
it("exits 1 when database-path is missing", () => {
process.argv = ["node", "livesync-cli", "sync"];
const exitMock = mockProcessExit();
const stderr = vi.spyOn(console, "error").mockImplementation(() => {});
expect(() => parseArgs()).toThrowError("__EXIT__:1");
expect(exitMock).toHaveBeenCalledWith(1);
expect(stderr).toHaveBeenCalledWith("Error: database-path is required");
expect(standardIo.writeStderr).toHaveBeenCalledWith("Error: database-path is required\n");
});
it("exits 1 for unknown command after database-path", () => {
process.argv = ["node", "livesync-cli", "./databasePath", "unknown-cmd"];
const exitMock = mockProcessExit();
const stderr = vi.spyOn(console, "error").mockImplementation(() => {});
expect(() => parseArgs()).toThrowError("__EXIT__:1");
expect(exitMock).toHaveBeenCalledWith(1);
expect(stderr).toHaveBeenCalledWith("Error: Unknown command 'unknown-cmd'");
expect(standardIo.writeStderr).toHaveBeenCalledWith("Error: Unknown command 'unknown-cmd'\n");
});
it("exits 0 and prints help for --help", () => {
process.argv = ["node", "livesync-cli", "--help"];
const exitMock = mockProcessExit();
const stdout = vi.spyOn(console, "log").mockImplementation(() => {});
expect(() => parseArgs()).toThrowError("__EXIT__:0");
expect(exitMock).toHaveBeenCalledWith(0);
expect(stdout).toHaveBeenCalled();
const combined = stdout.mock.calls.flat().join("\n");
expect(standardIo.writeStdout).toHaveBeenCalled();
const combined = standardIo.writeStdout.mock.calls.flat().join("");
expect(combined).toContain("Usage:");
expect(combined).toContain("livesync-cli <database-path> [options] <command> [command-args]");
});
@@ -152,7 +164,6 @@ describe("CLI parseArgs", () => {
it("exits 1 when --interval has no value", () => {
process.argv = ["node", "livesync-cli", "./vault", "--interval"];
const exitMock = mockProcessExit();
vi.spyOn(console, "error").mockImplementation(() => {});
expect(() => parseArgs()).toThrowError("__EXIT__:1");
expect(exitMock).toHaveBeenCalledWith(1);
});
@@ -160,22 +171,19 @@ describe("CLI parseArgs", () => {
it("exits 1 when --interval is not a positive integer", () => {
process.argv = ["node", "livesync-cli", "./vault", "--interval", "0"];
const exitMock = mockProcessExit();
vi.spyOn(console, "error").mockImplementation(() => {});
expect(() => parseArgs()).toThrowError("__EXIT__:1");
expect(exitMock).toHaveBeenCalledWith(1);
});
it("exits 1 when --interval is negative", () => {
process.argv = ["node", "livesync-cli", "./vault", "--interval", "-5"];
const exitMock = mockProcessExit();
vi.spyOn(console, "error").mockImplementation(() => {});
mockProcessExit();
expect(() => parseArgs()).toThrowError("__EXIT__:1");
});
it("exits 1 when --interval is not numeric", () => {
process.argv = ["node", "livesync-cli", "./vault", "--interval", "abc"];
const exitMock = mockProcessExit();
vi.spyOn(console, "error").mockImplementation(() => {});
mockProcessExit();
expect(() => parseArgs()).toThrowError("__EXIT__:1");
});
@@ -14,6 +14,7 @@ import type { NodeFile, NodeFolder } from "@/apps/cli/adapters/NodeTypes";
import { watch as chokidarWatch, type FSWatcher } from "chokidar";
import type { IgnoreRules } from "@/apps/cli/serviceModules/IgnoreRules";
import { fsPromises as fs, path, type Stats } from "@vrtmrz/livesync-commonlib/node";
import type { CliDiagnosticReporter } from "@/apps/cli/cliOutput";
/**
* CLI-specific type guard adapter
@@ -45,7 +46,10 @@ class CLITypeGuardAdapter implements IStorageEventTypeGuardAdapter<NodeFile, Nod
class CLIPersistenceAdapter implements IStorageEventPersistenceAdapter {
private snapshotPath: string;
constructor(basePath: string) {
constructor(
basePath: string,
private reportDiagnostic: CliDiagnosticReporter = () => undefined
) {
this.snapshotPath = path.join(basePath, ".livesync-snapshot.json");
}
@@ -53,14 +57,14 @@ class CLIPersistenceAdapter implements IStorageEventPersistenceAdapter {
try {
await fs.writeFile(this.snapshotPath, JSON.stringify(snapshot, null, 2), "utf-8");
} catch (error) {
console.error("Failed to save snapshot:", error);
this.reportDiagnostic("Failed to save snapshot:", error);
}
}
async loadSnapshot(): Promise<(FileEventItem | FileEventItemSentinel)[] | null> {
try {
const content = await fs.readFile(this.snapshotPath, "utf-8");
return JSON.parse(content);
return JSON.parse(content) as (FileEventItem | FileEventItemSentinel)[];
} catch {
return null;
}
@@ -109,7 +113,8 @@ class CLIWatchAdapter implements IStorageEventWatchAdapter {
constructor(
private basePath: string,
private ignoreRules?: IgnoreRules,
private watchEnabled: boolean = false
private watchEnabled: boolean = false,
private reportDiagnostic: CliDiagnosticReporter = () => undefined
) {}
private _toNodeFile(filePath: string, stats: Stats | undefined): NodeFile {
@@ -180,8 +185,8 @@ class CLIWatchAdapter implements IStorageEventWatchAdapter {
});
watcher.on("error", (err) => {
console.error("[CLIWatchAdapter] Fatal watcher error — file watching stopped:", err);
console.error("[CLIWatchAdapter] Exiting for systemd restart.");
this.reportDiagnostic("[CLIWatchAdapter] Fatal watcher error — file watching stopped:", err);
this.reportDiagnostic("[CLIWatchAdapter] Exiting for systemd restart.");
void watcher.close();
this._watcher = undefined;
// Use exit(1) rather than SIGTERM so systemd Restart=on-failure engages.
@@ -210,10 +215,15 @@ export class CLIStorageEventManagerAdapter implements IStorageEventManagerAdapte
readonly status: CLIStatusAdapter;
readonly converter: CLIConverterAdapter;
constructor(basePath: string, ignoreRules?: IgnoreRules, watchEnabled: boolean = false) {
constructor(
basePath: string,
ignoreRules?: IgnoreRules,
watchEnabled: boolean = false,
reportDiagnostic: CliDiagnosticReporter = () => undefined
) {
this.typeGuard = new CLITypeGuardAdapter();
this.persistence = new CLIPersistenceAdapter(basePath);
this.watch = new CLIWatchAdapter(basePath, ignoreRules, watchEnabled);
this.persistence = new CLIPersistenceAdapter(basePath, reportDiagnostic);
this.watch = new CLIWatchAdapter(basePath, ignoreRules, watchEnabled, reportDiagnostic);
this.status = new CLIStatusAdapter();
this.converter = new CLIConverterAdapter();
}
@@ -103,7 +103,8 @@ describe("CLIStorageEventManagerAdapter", () => {
});
it("error event triggers process.exit(1)", async () => {
const adapter = new CLIStorageEventManagerAdapter("/base", undefined, true);
const reportDiagnostic = vi.fn();
const adapter = new CLIStorageEventManagerAdapter("/base", undefined, true, reportDiagnostic);
const handlers = makeHandlers();
await adapter.watch.beginWatch(handlers);
@@ -117,6 +118,11 @@ describe("CLIStorageEventManagerAdapter", () => {
errorCallback(new Error("disk failure"));
expect(processExitSpy).toHaveBeenCalledWith(1);
expect(reportDiagnostic).toHaveBeenCalledWith(
"[CLIWatchAdapter] Fatal watcher error — file watching stopped:",
expect.any(Error)
);
expect(reportDiagnostic).toHaveBeenCalledWith("[CLIWatchAdapter] Exiting for systemd restart.");
processExitSpy.mockRestore();
});
@@ -3,6 +3,7 @@ import { CLIStorageEventManagerAdapter } from "./CLIStorageEventManagerAdapter";
import type { IMinimumLiveSyncCommands, LiveSyncBaseCore } from "@/LiveSyncBaseCore";
import type { ServiceContext } from "@vrtmrz/livesync-commonlib/compat/services/base/ServiceBase";
import type { IgnoreRules } from "@/apps/cli/serviceModules/IgnoreRules";
import { LOG_LEVEL_NOTICE } from "octagonal-wheels/common/logger";
// import type { IMinimumLiveSyncCommands } from "@vrtmrz/livesync-commonlib/compat/services/base/IService";
export class StorageEventManagerCLI extends StorageEventManagerBase<CLIStorageEventManagerAdapter> {
@@ -15,7 +16,12 @@ export class StorageEventManagerCLI extends StorageEventManagerBase<CLIStorageEv
ignoreRules?: IgnoreRules,
watchEnabled?: boolean
) {
const adapter = new CLIStorageEventManagerAdapter(basePath, ignoreRules, watchEnabled);
const adapter = new CLIStorageEventManagerAdapter(basePath, ignoreRules, watchEnabled, (message, detail) => {
dependencies.APIService.addLog(message, LOG_LEVEL_NOTICE);
if (detail !== undefined) {
dependencies.APIService.addLog(detail, LOG_LEVEL_NOTICE);
}
});
super(adapter, dependencies);
this.core = core;
}
+7 -1
View File
@@ -1,5 +1,6 @@
import { FileAccessBase, type FileAccessBaseDependencies } from "@vrtmrz/livesync-commonlib/compat/serviceModules/FileAccessBase";
import { NodeFileSystemAdapter } from "@/apps/cli/adapters/NodeFileSystemAdapter";
import { LOG_LEVEL_NOTICE } from "octagonal-wheels/common/logger";
/**
* CLI-specific implementation of FileAccessBase
@@ -7,7 +8,12 @@ import { NodeFileSystemAdapter } from "@/apps/cli/adapters/NodeFileSystemAdapter
*/
export class FileAccessCLI extends FileAccessBase<NodeFileSystemAdapter> {
constructor(basePath: string, dependencies: FileAccessBaseDependencies) {
const adapter = new NodeFileSystemAdapter(basePath);
const adapter = new NodeFileSystemAdapter(basePath, (message, detail) => {
dependencies.APIService.addLog(message, LOG_LEVEL_NOTICE);
if (detail !== undefined) {
dependencies.APIService.addLog(detail, LOG_LEVEL_NOTICE);
}
});
super(adapter, dependencies);
}
+7 -3
View File
@@ -1,5 +1,6 @@
import { Minimatch } from "minimatch";
import { fsPromises as fs, path } from "@vrtmrz/livesync-commonlib/node";
import type { CliDiagnosticReporter } from "@/apps/cli/cliOutput";
/**
* Loads and evaluates ignore rules from `.livesync/ignore` inside the vault.
@@ -19,7 +20,10 @@ import { fsPromises as fs, path } from "@vrtmrz/livesync-commonlib/node";
export class IgnoreRules {
private patterns: Minimatch[] = [];
constructor(private vaultPath: string) {}
constructor(
private vaultPath: string,
private reportDiagnostic: CliDiagnosticReporter = () => undefined
) {}
/**
* Reads `.livesync/ignore` (and optionally `.gitignore`) and populates the
@@ -53,7 +57,7 @@ export class IgnoreRules {
continue;
}
if (trimmed.startsWith("import:")) {
console.error(
this.reportDiagnostic(
`[IgnoreRules] Warning: unrecognised directive '${trimmed}' — only 'import: .gitignore' is supported`
);
continue;
@@ -61,7 +65,7 @@ export class IgnoreRules {
this._addPattern(trimmed);
}
if (this.patterns.length > 0) {
console.error(`[IgnoreRules] Loaded ${this.patterns.length} ignore patterns`);
this.reportDiagnostic(`[IgnoreRules] Loaded ${this.patterns.length} ignore patterns`);
}
}
@@ -138,11 +138,13 @@ describe("IgnoreRules", () => {
const vaultPath = await createVault();
// Typo: "import:.gitignore" instead of "import: .gitignore"
await writeIgnoreFile(vaultPath, "*.tmp\nimport:.gitignore\n");
const rules = new IgnoreRules(vaultPath);
const reportDiagnostic = vi.fn();
const rules = new IgnoreRules(vaultPath, reportDiagnostic);
await rules.load();
// *.tmp still loaded; import:.gitignore is skipped (not treated as a literal pattern)
expect(rules.shouldIgnore("scratch.tmp")).toBe(true);
expect(rules.shouldIgnore("import:.gitignore")).toBe(false);
expect(reportDiagnostic).toHaveBeenCalledWith(expect.stringContaining("unrecognised directive"));
});
});
+5 -2
View File
@@ -1,10 +1,13 @@
import { eventHub } from "@/common/events";
import { translateLiveSyncMessage } from "@/common/translation";
import { ServiceContext } from "@vrtmrz/livesync-commonlib/context";
import { ServiceContext, type StandardIo } from "@vrtmrz/livesync-commonlib/context";
/** Host capabilities owned by one Self-hosted LiveSync CLI composition. */
export class NodeServiceContext extends ServiceContext {
constructor(readonly databasePath: string) {
constructor(
readonly databasePath: string,
readonly standardIo: StandardIo
) {
super({ events: eventHub, translate: translateLiveSyncMessage });
}
}
@@ -9,12 +9,19 @@ import {
} from "../../../../test/contracts/serviceContext";
import { NodeServiceContext } from "./NodeServiceContext";
import { NodeServiceHub } from "./NodeServiceHub";
import type { StandardIo } from "@vrtmrz/livesync-commonlib/context";
const TRANSLATION_KEY = "Replicator.Message.InitialiseFatalError";
describe("NodeServiceContext contract", () => {
it("preserves the CLI capabilities and host-neutral API results", () => {
const context = new NodeServiceContext("/tmp/livesync-context-contract");
const standardIo: StandardIo = {
readStdin: async () => "input",
prompt: async () => "answer",
writeStdout: () => undefined,
writeStderr: () => undefined,
};
const context = new NodeServiceContext("/tmp/livesync-context-contract", standardIo);
expect(observeServiceContext(context, TRANSLATION_KEY)).toEqual({
translation: translateLiveSyncMessage(TRANSLATION_KEY),
@@ -22,6 +29,7 @@ describe("NodeServiceContext contract", () => {
});
expect(context.events).toBe(eventHub);
expect(context.databasePath).toBe("/tmp/livesync-context-contract");
expect(context.standardIo).toBe(standardIo);
const hub = new NodeServiceHub(context.databasePath, context);
const composition = observeServiceComposition(hub, context);
+1 -1
View File
@@ -86,7 +86,7 @@ class NodeUIService<T extends ServiceContext> extends UIService<T> {
}
export class NodeServiceHub<T extends NodeServiceContext> extends InjectableServiceHub<T> {
constructor(basePath: string, context: T = new NodeServiceContext(basePath) as T) {
constructor(basePath: string, context: T) {
const runtimeDir = nodePath.join(basePath, ".livesync", "runtime");
const localStoragePath = nodePath.join(runtimeDir, "local-storage.json");
const keyValueDBPath = nodePath.join(runtimeDir, "keyvalue-db.json");