From 97964fddf18e189751d2a24343321c54ce19982f Mon Sep 17 00:00:00 2001 From: vorotamoroz Date: Fri, 17 Jul 2026 16:01:48 +0000 Subject: [PATCH] refactor: inject CLI I/O and diagnostics --- ...2026_07_common_library_package_boundary.md | 14 +- src/apps/cli/README.md | 11 +- .../cli/adapters/NodeFileSystemAdapter.ts | 10 +- .../adapters/NodeVaultAdapter.unit.spec.ts | 20 ++- src/apps/cli/cliOutput.ts | 30 ++++ .../cli/commands/daemonCommand.unit.spec.ts | 28 ++-- src/apps/cli/commands/runCommand.ts | 145 ++++++++++-------- src/apps/cli/commands/runCommand.unit.spec.ts | 108 ++++++++++--- src/apps/cli/commands/types.ts | 4 +- src/apps/cli/commands/utils.ts | 27 +--- src/apps/cli/entrypoint.ts | 8 +- src/apps/cli/main.ts | 124 ++++++++------- src/apps/cli/main.unit.spec.ts | 42 +++-- .../managers/CLIStorageEventManagerAdapter.ts | 28 ++-- ...CLIStorageEventManagerAdapter.unit.spec.ts | 8 +- .../cli/managers/StorageEventManagerCLI.ts | 8 +- src/apps/cli/serviceModules/FileAccessCLI.ts | 8 +- src/apps/cli/serviceModules/IgnoreRules.ts | 10 +- .../serviceModules/IgnoreRules.unit.spec.ts | 4 +- src/apps/cli/services/NodeServiceContext.ts | 7 +- .../services/NodeServiceContext.unit.spec.ts | 10 +- src/apps/cli/services/NodeServiceHub.ts | 2 +- updates.md | 2 + 23 files changed, 422 insertions(+), 236 deletions(-) create mode 100644 src/apps/cli/cliOutput.ts diff --git a/docs/adr/2026_07_common_library_package_boundary.md b/docs/adr/2026_07_common_library_package_boundary.md index 61d5f177..b73487c0 100644 --- a/docs/adr/2026_07_common_library_package_boundary.md +++ b/docs/adr/2026_07_common_library_package_boundary.md @@ -158,6 +158,16 @@ The paired adapters run against the same contract suite for metadata, text and b The Node entry also centralises direct Node built-in access needed by trusted headless application code. This is a package and scanner boundary, not an assertion that Node and browser APIs are interchangeable. Cross-platform behaviour belongs in a shared contract with separate implementations, as demonstrated by rooted storage. +### Standard input and output + +Commonlib's `context` entry exposes a narrow `StandardIo` contract for command-line composition. It reads UTF-8 standard input, asks one line-oriented question, and writes text or binary chunks to standard output and standard error without adding delimiters. Commonlib's `node` entry supplies `createNodeStandardIo()`, which binds that contract to host-selected Node streams and defaults to the current process streams. + +Self-hosted LiveSync constructs the Node implementation at the CLI composition root and places the exact instance on `NodeServiceContext`. `NodeServiceHub` must receive that Context rather than silently constructing one. Command handlers reach input, prompts, and protocol output through the Context, so unit tests can inject memory I/O without replacing process globals. Obsidian and browser Contexts do not acquire a fictitious terminal capability, and the base `ServiceContextContract` remains limited to capabilities required by every composition. + +Standard I/O does not own command-line arguments, exit codes, signals, stream lifecycle, log levels, or log persistence. Diagnostic logging remains the responsibility of the existing API and Logger composition. CLI adapters receive a narrow diagnostic callback wired to the service logging API instead of calling `console` directly. A CLI host may render selected logs on standard error, but Logger is not added to `StandardIo` or made mandatory on the base Context. + +Commonlib verifies memory injection, split UTF-8 decoding, object-mode rejection, text and binary output, and prompting against injected Node streams. The downstream CLI verifies Context identity and exact injected I/O in unit tests, then runs the built Node artefact through its Deno command E2E. + ### Context and result compatibility `ServiceContextContract` is the minimum host-neutral composition contract. It supplies an event channel and message translator selected by the host. Obsidian and CLI contexts extend the default implementation with their own capabilities; the Webapp currently uses the default implementation. Every Service Hub and every service in one composition must retain the exact Context object supplied by that host. @@ -237,11 +247,11 @@ Every retained barrel must correspond to an explicit `exports` entry, list named The local package proof builds Commonlib as one compiled ESM package with a small root, `context`, `browser`, `node`, and `rpc` entries, plus the explicit compatibility exports required by the current downstream migration. It publishes neither raw TypeScript nor Svelte source. The generated tarball can be installed into a clean consumer, imported in Node, type-checked from declarations, and bundled independently for browser context, browser storage, browser services, and workers. Release validation records the immutable registry version and checksum separately. -The proof found and fixed three boundary defects which source-alias consumption had hidden: compiled JSON imports required explicit output extensions, precompiled Svelte output could not safely be treated as source by the downstream Svelte pipeline, and Vite's default client conditions selected Commonlib's browser worker while building the Node CLI. Packed-consumer regressions cover the first two. The CLI now uses Vite's server conditions and treats every Node built-in reported by Commonlib's Node entry as external; the built CLI is exercised through Deno E2E. Importing root or context also no longer patches DOM prototypes, and translator injection prevents the context entry from loading the complete language catalogue. +The proof found and fixed three boundary defects which source-alias consumption had hidden: compiled JSON imports required explicit output extensions, precompiled Svelte output could not safely be treated as source by the downstream Svelte pipeline, and Vite's default client conditions selected Commonlib's browser worker while building the Node CLI. Packed-consumer regressions cover the first two. The CLI now uses Vite's server conditions and treats every Node built-in reported by Commonlib's Node entry as external; the built CLI is exercised through Deno E2E. Importing root or context also no longer patches DOM prototypes, translator injection prevents the context entry from loading the complete language catalogue, and standard input and protocol output are supplied by the package-owned host contract rather than direct stream access in command handlers. Self-hosted LiveSync, its CLI, Webapp, WebPeer, plug-in source, and tests compile against that exact local tarball without `@lib/*`. Focused downstream storage tests pass against the package-owned Node and File System Access API implementations. The old `src/lib` Git submodule, generated `_types` fallback, type-generation scripts, and source aliases are removed by the proof branch. -The Commonlib contract suite passes 16 tests covering Context results and both platform storage implementations; its complete suite passes 1,087 tests across 56 files. Self-hosted LiveSync's three host-composition contract tests and complete 333-test unit suite pass against the tarball. The plug-in, CLI, Webapp, and WebPeer production builds also pass. The CLI contract command completes its nine-step Deno workflow against the built Node artefact. +The Commonlib contract suite passes 21 tests covering Context results, both platform storage implementations, and standard I/O; its complete suite passes 1,092 tests across 57 files. Self-hosted LiveSync's three host-composition contract tests and complete 339-test unit suite pass against the tarball. The plug-in, CLI, Webapp, and WebPeer production builds also pass. The CLI contract command completes its nine-step Deno workflow against the built Node artefact. The local real-Obsidian suite verifies the actual loaded `ObsidianServiceContext`, all 18 services, Vault reflection, CouchDB and Object Storage transfer, remote-activity accounting, CLI-to-Obsidian encrypted synchronisation, startup scanning, two-Vault create, update, delete, ordinary rename, case-only rename, target mismatch, Hidden File Sync, Customisation Sync, and setting Markdown export. These checks establish observable results and host composition rather than relying on declaration compatibility alone. diff --git a/src/apps/cli/README.md b/src/apps/cli/README.md index e1a2bfb2..735afe6f 100644 --- a/src/apps/cli/README.md +++ b/src/apps/cli/README.md @@ -15,9 +15,10 @@ This CLI version is built using the same core as the Obsidian plug-in: ``` CLI Main - └─ LiveSyncBaseCore - ├─ NodeServiceHub (All services without Obsidian dependencies) - └─ ServiceModules (wired by initialiseServiceModulesCLI) + └─ NodeServiceContext (events, translation, database root, and injected standard I/O) + └─ LiveSyncBaseCore + ├─ 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`) diff --git a/src/apps/cli/adapters/NodeFileSystemAdapter.ts b/src/apps/cli/adapters/NodeFileSystemAdapter.ts index 34e6b1d0..85a387f8 100644 --- a/src/apps/cli/adapters/NodeFileSystemAdapter.ts +++ b/src/apps/cli/adapters/NodeFileSystemAdapter.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(); - 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 { @@ -169,7 +173,7 @@ export class NodeFileSystemAdapter implements IFileSystemAdapter { 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 }); + } + }); }); diff --git a/src/apps/cli/cliOutput.ts b/src/apps/cli/cliOutput.ts new file mode 100644 index 00000000..6b7e6959 --- /dev/null +++ b/src/apps/cli/cliOutput.ts @@ -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)); +} diff --git a/src/apps/cli/commands/daemonCommand.unit.spec.ts b/src/apps/cli/commands/daemonCommand.unit.spec.ts index 3e0e2068..5e448ff0 100644 --- a/src/apps/cli/commands/daemonCommand.unit.spec.ts +++ b/src/apps/cli/commands/daemonCommand.unit.spec.ts @@ -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 2–7 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); }); diff --git a/src/apps/cli/commands/runCommand.ts b/src/apps/cli/commands/runCommand.ts index a00c49b6..919c875e 100644 --- a/src/apps/cli/commands/runCommand.ts +++ b/src/apps/cli/commands/runCommand.ts @@ -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 { + 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 { 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: "); } 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 "); } 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: "); } 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: "); } 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: "); } 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; } diff --git a/src/apps/cli/commands/runCommand.unit.spec.ts b/src/apps/cli/commands/runCommand.unit.spec.ts index f91b4875..f3301575 100644 --- a/src/apps/cli/commands/runCommand.unit.spec.ts +++ b/src/apps/cli/commands/runCommand.unit.spec.ts @@ -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 { 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) { + 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); diff --git a/src/apps/cli/commands/types.ts b/src/apps/cli/commands/types.ts index a405315c..504334c2 100644 --- a/src/apps/cli/commands/types.ts +++ b/src/apps/cli/commands/types.ts @@ -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; + core: LiveSyncBaseCore; settingsPath: string; originalSyncSettings: Pick< ObsidianLiveSyncSettings, diff --git a/src/apps/cli/commands/utils.ts b/src/apps/cli/commands/utils.ts index ec0f6e15..4163d3f0 100644 --- a/src/apps/cli/commands/utils.ts +++ b/src/apps/cli/commands/utils.ts @@ -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 { - 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 { - 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(); - } -} diff --git a/src/apps/cli/entrypoint.ts b/src/apps/cli/entrypoint.ts index 454a4ab3..ff49ced7 100644 --- a/src/apps/cli/entrypoint.ts +++ b/src/apps/cli/entrypoint.ts @@ -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).RTCPeerConnection === "undefined" && @@ -11,7 +13,9 @@ if ( (compatGlobal as unknown as Record).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); }); diff --git a/src/apps/cli/main.ts b/src/apps/cli/main.ts index 06c93962..e9f70a0d 100644 --- a/src/apps/cli/main.ts +++ b/src/apps/cli/main.ts @@ -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(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 => { 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. diff --git a/src/apps/cli/main.unit.spec.ts b/src/apps/cli/main.unit.spec.ts index 63c1633b..20f32fb5 100644 --- a/src/apps/cli/main.unit.spec.ts +++ b/src/apps/cli/main.unit.spec.ts @@ -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; + + 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 [options] [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"); }); diff --git a/src/apps/cli/managers/CLIStorageEventManagerAdapter.ts b/src/apps/cli/managers/CLIStorageEventManagerAdapter.ts index 7ddcf9c8..1b59a6f6 100644 --- a/src/apps/cli/managers/CLIStorageEventManagerAdapter.ts +++ b/src/apps/cli/managers/CLIStorageEventManagerAdapter.ts @@ -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 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(); } diff --git a/src/apps/cli/managers/CLIStorageEventManagerAdapter.unit.spec.ts b/src/apps/cli/managers/CLIStorageEventManagerAdapter.unit.spec.ts index 11d9d953..6d9c333d 100644 --- a/src/apps/cli/managers/CLIStorageEventManagerAdapter.unit.spec.ts +++ b/src/apps/cli/managers/CLIStorageEventManagerAdapter.unit.spec.ts @@ -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(); }); diff --git a/src/apps/cli/managers/StorageEventManagerCLI.ts b/src/apps/cli/managers/StorageEventManagerCLI.ts index 327fd5db..16644904 100644 --- a/src/apps/cli/managers/StorageEventManagerCLI.ts +++ b/src/apps/cli/managers/StorageEventManagerCLI.ts @@ -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 { @@ -15,7 +16,12 @@ export class StorageEventManagerCLI extends StorageEventManagerBase { + dependencies.APIService.addLog(message, LOG_LEVEL_NOTICE); + if (detail !== undefined) { + dependencies.APIService.addLog(detail, LOG_LEVEL_NOTICE); + } + }); super(adapter, dependencies); this.core = core; } diff --git a/src/apps/cli/serviceModules/FileAccessCLI.ts b/src/apps/cli/serviceModules/FileAccessCLI.ts index 865c2fc0..7a258c30 100644 --- a/src/apps/cli/serviceModules/FileAccessCLI.ts +++ b/src/apps/cli/serviceModules/FileAccessCLI.ts @@ -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 { 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); } diff --git a/src/apps/cli/serviceModules/IgnoreRules.ts b/src/apps/cli/serviceModules/IgnoreRules.ts index 2f6a6b54..8998309f 100644 --- a/src/apps/cli/serviceModules/IgnoreRules.ts +++ b/src/apps/cli/serviceModules/IgnoreRules.ts @@ -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`); } } diff --git a/src/apps/cli/serviceModules/IgnoreRules.unit.spec.ts b/src/apps/cli/serviceModules/IgnoreRules.unit.spec.ts index 4188137d..167c4156 100644 --- a/src/apps/cli/serviceModules/IgnoreRules.unit.spec.ts +++ b/src/apps/cli/serviceModules/IgnoreRules.unit.spec.ts @@ -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")); }); }); diff --git a/src/apps/cli/services/NodeServiceContext.ts b/src/apps/cli/services/NodeServiceContext.ts index d0a28c86..fef43d91 100644 --- a/src/apps/cli/services/NodeServiceContext.ts +++ b/src/apps/cli/services/NodeServiceContext.ts @@ -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 }); } } diff --git a/src/apps/cli/services/NodeServiceContext.unit.spec.ts b/src/apps/cli/services/NodeServiceContext.unit.spec.ts index 9b15930a..4a329dc5 100644 --- a/src/apps/cli/services/NodeServiceContext.unit.spec.ts +++ b/src/apps/cli/services/NodeServiceContext.unit.spec.ts @@ -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); diff --git a/src/apps/cli/services/NodeServiceHub.ts b/src/apps/cli/services/NodeServiceHub.ts index ba019947..34b1e753 100644 --- a/src/apps/cli/services/NodeServiceHub.ts +++ b/src/apps/cli/services/NodeServiceHub.ts @@ -86,7 +86,7 @@ class NodeUIService extends UIService { } export class NodeServiceHub extends InjectableServiceHub { - 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"); diff --git a/updates.md b/updates.md index 209ba551..1b509aad 100644 --- a/updates.md +++ b/updates.md @@ -12,11 +12,13 @@ The head note of 0.25 is now in [updates_old.md](https://github.com/vrtmrz/obsid ### Miscellaneous - Replaced the embedded Commonlib source and generated fallback declarations with a locked compiled package, reducing duplicated release and repository-scanner inputs without changing synchronisation behaviour. +- Moved CLI standard input, prompting, and protocol output behind a host-injected Commonlib contract, and routed adapter diagnostics through the service logging API, without changing command output formats. ### Testing - Added packed-package and downstream checks for Commonlib entry points, including isolated Node and browser File System Access API storage implementations. - Added reusable Context result contracts for Obsidian, CLI, and Webapp compositions, including a real-Obsidian smoke assertion that every service retains the host-provided Context. +- Added Commonlib stream-contract tests and downstream CLI unit and Deno E2E coverage for injected text, binary, prompt, and error channels. ## 0.25.83