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
@@ -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. 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 ### 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. `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 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. 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. 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.
+7 -4
View File
@@ -15,9 +15,10 @@ This CLI version is built using the same core as the Obsidian plug-in:
``` ```
CLI Main CLI Main
└─ LiveSyncBaseCore<ServiceContext, IMinimumLiveSyncCommands> └─ NodeServiceContext (events, translation, database root, and injected standard I/O)
├─ NodeServiceHub (All services without Obsidian dependencies) └─ LiveSyncBaseCore<NodeServiceContext, IMinimumLiveSyncCommands>
└─ ServiceModules (wired by initialiseServiceModulesCLI) ├─ NodeServiceHub (All services without Obsidian dependencies)
└─ ServiceModules (wired by initialiseServiceModulesCLI)
├─ FileAccessCLI (Node.js FileSystemAdapter) ├─ FileAccessCLI (Node.js FileSystemAdapter)
├─ StorageEventManagerCLI ├─ StorageEventManagerCLI
├─ ServiceFileAccessCLI ├─ ServiceFileAccessCLI
@@ -37,7 +38,9 @@ CLI Main
- All core sync functionality preserved - All core sync functionality preserved
3. **Service Hub and Settings Services** (`services/`) 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 - Node-specific settings and key-value services are provided without Obsidian dependencies
4. **Main Entry Point** (`main.ts`) 4. **Main Entry Point** (`main.ts`)
@@ -7,6 +7,7 @@ import { NodeStorageAdapter } from "@vrtmrz/livesync-commonlib/node";
import { NodeVaultAdapter } from "./NodeVaultAdapter"; import { NodeVaultAdapter } from "./NodeVaultAdapter";
import type { NodeFile, NodeFolder, NodeStat } from "./NodeTypes"; import type { NodeFile, NodeFolder, NodeStat } from "./NodeTypes";
import { fsPromises as fs, path } from "@vrtmrz/livesync-commonlib/node"; 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 * 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>(); private fileCache = new Map<string, NodeFile>();
constructor(private basePath: string) { constructor(
private basePath: string,
private reportDiagnostic: CliDiagnosticReporter = () => undefined
) {
this.path = new NodePathAdapter(); this.path = new NodePathAdapter();
this.typeGuard = new NodeTypeGuardAdapter(); this.typeGuard = new NodeTypeGuardAdapter();
this.conversion = new NodeConversionAdapter(); this.conversion = new NodeConversionAdapter();
@@ -33,7 +37,7 @@ export class NodeFileSystemAdapter implements IFileSystemAdapter<NodeFile, NodeF
} }
private normalisePath(p: FilePath | string): string { 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> { private async hasExactPathCase(pathStr: string): Promise<boolean> {
@@ -169,7 +173,7 @@ export class NodeFileSystemAdapter implements IFileSystemAdapter<NodeFile, NodeF
} }
} catch (error) { } catch (error) {
// Directory doesn't exist or is not readable // 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 { 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 type { FilePath } from "@vrtmrz/livesync-commonlib/compat/common/types";
import { NodeFileSystemAdapter } from "./NodeFileSystemAdapter"; import { NodeFileSystemAdapter } from "./NodeFileSystemAdapter";
import { NodeVaultAdapter } from "./NodeVaultAdapter"; import { NodeVaultAdapter } from "./NodeVaultAdapter";
@@ -46,4 +46,22 @@ describe("NodeFileSystemAdapter path case", () => {
await fsPromises.rm(directory, { recursive: true, force: true }); 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"; import * as offlineScanner from "@vrtmrz/livesync-commonlib/compat/serviceFeatures/offlineScanner";
function createCoreMock() { 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 { return {
services: { services: {
context: createServiceContext(), context: Object.assign(createServiceContext(), { standardIo }),
control: { control: {
activated: Promise.resolve(), activated: Promise.resolve(),
applySettings: vi.fn(async () => {}), applySettings: vi.fn(async () => {}),
@@ -157,13 +163,13 @@ describe("daemon command", () => {
syncOnStart: false, syncOnStart: false,
})); }));
vi.mocked(offlineScanner.performFullScan).mockResolvedValue(true); vi.mocked(offlineScanner.performFullScan).mockResolvedValue(true);
const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {});
const result = await runCommand(makeDaemonOptions(), { ...baseContext, core }); const result = await runCommand(makeDaemonOptions(), { ...baseContext, core });
expect(result).toBe(true); expect(result).toBe(true);
const warningCalls = consoleSpy.mock.calls.filter( const warningCalls = core.services.context.standardIo.writeStderr.mock.calls.filter(
(args) => typeof args[0] === "string" && args[0].includes("liveSync and syncOnStart are both disabled") ([chunk]: [string | Uint8Array]) =>
typeof chunk === "string" && chunk.includes("liveSync and syncOnStart are both disabled")
); );
expect(warningCalls.length).toBeGreaterThan(0); expect(warningCalls.length).toBeGreaterThan(0);
}); });
@@ -175,12 +181,12 @@ describe("daemon command", () => {
syncOnStart: false, syncOnStart: false,
})); }));
vi.mocked(offlineScanner.performFullScan).mockResolvedValue(true); vi.mocked(offlineScanner.performFullScan).mockResolvedValue(true);
const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {});
await runCommand(makeDaemonOptions(), { ...baseContext, core }); await runCommand(makeDaemonOptions(), { ...baseContext, core });
const warningCalls = consoleSpy.mock.calls.filter( const warningCalls = core.services.context.standardIo.writeStderr.mock.calls.filter(
(args) => typeof args[0] === "string" && args[0].includes("liveSync and syncOnStart are both disabled") ([chunk]: [string | Uint8Array]) =>
typeof chunk === "string" && chunk.includes("liveSync and syncOnStart are both disabled")
); );
expect(warningCalls.length).toBe(0); 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 () => { it("polling backoff: interval escalates on failure, caps at 300000ms, then halves on recovery", async () => {
const core = createCoreMock(); const core = createCoreMock();
vi.mocked(offlineScanner.performFullScan).mockResolvedValue(true); vi.mocked(offlineScanner.performFullScan).mockResolvedValue(true);
vi.spyOn(console, "error").mockImplementation(() => {});
// startup replicate (call 1) succeeds; poll calls 2–7 fail; call 8 succeeds. // startup replicate (call 1) succeeds; poll calls 2–7 fail; call 8 succeeds.
let callCount = 0; let callCount = 0;
@@ -286,10 +291,9 @@ describe("daemon command", () => {
expect(setTimeoutSpy.mock.calls[afterSuccessCallCount - 1][1]).toBe(150_000); 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(); const core = createCoreMock();
vi.mocked(offlineScanner.performFullScan).mockResolvedValue(true); 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. // Make replicate succeed on the initial call (startup), then fail on the poll.
let callCount = 0; let callCount = 0;
@@ -306,8 +310,8 @@ describe("daemon command", () => {
await vi.advanceTimersByTimeAsync(intervalMs); await vi.advanceTimersByTimeAsync(intervalMs);
// No unhandled rejection — the error was caught internally. // No unhandled rejection — the error was caught internally.
const errorCalls = consoleSpy.mock.calls.filter( const errorCalls = core.services.context.standardIo.writeStderr.mock.calls.filter(
(args) => typeof args[0] === "string" && args[0].includes("Poll error") ([chunk]: [string | Uint8Array]) => typeof chunk === "string" && chunk.includes("Poll error")
); );
expect(errorCalls.length).toBeGreaterThan(0); 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 { activateRemoteConfiguration, createRemoteConfigurationId } from "@vrtmrz/livesync-commonlib/compat/serviceFeatures/remoteConfig";
import { stripAllPrefixes } from "@vrtmrz/livesync-commonlib/compat/string_and_binary/path"; import { stripAllPrefixes } from "@vrtmrz/livesync-commonlib/compat/string_and_binary/path";
import type { CLICommandContext, CLIOptions } from "./types"; 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 { collectPeers, openP2PHost, parseTimeoutSeconds, syncWithPeer } from "./p2p";
import { performFullScan } from "@vrtmrz/livesync-commonlib/compat/serviceFeatures/offlineScanner"; import { performFullScan } from "@vrtmrz/livesync-commonlib/compat/serviceFeatures/offlineScanner";
import { UnresolvedErrorManager } from "@vrtmrz/livesync-commonlib/compat/services/base/UnresolvedErrorManager"; 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 { fsPromises as fs, path } from "@vrtmrz/livesync-commonlib/node";
import type { LiveSyncCouchDBReplicator } from "@vrtmrz/livesync-commonlib/compat/replication/couchdb/LiveSyncReplicator"; import type { LiveSyncCouchDBReplicator } from "@vrtmrz/livesync-commonlib/compat/replication/couchdb/LiveSyncReplicator";
import type { LiveSyncJournalReplicator } from "@vrtmrz/livesync-commonlib/compat/replication/journal/LiveSyncJournalReplicator"; import type { LiveSyncJournalReplicator } from "@vrtmrz/livesync-commonlib/compat/replication/journal/LiveSyncJournalReplicator";
import { writeStderrLine, writeStdoutLine } from "../cliOutput";
function redactConnectionString(uri: string): string { function redactConnectionString(uri: string): string {
return uri.replace(/\/\/([^@/]+)@/u, "//***@"); return uri.replace(/\/\/([^@/]+)@/u, "//***@");
@@ -31,9 +32,10 @@ async function verifyRemoteState(
core: CLICommandContext["core"], core: CLICommandContext["core"],
settings: ObsidianLiveSyncSettings settings: ObsidianLiveSyncSettings
): Promise<boolean> { ): Promise<boolean> {
const { standardIo } = core.services.context;
const replicator = core.services.replicator.getActiveReplicator(); const replicator = core.services.replicator.getActiveReplicator();
if (!replicator) { if (!replicator) {
process.stderr.write("[Verification] No active replicator found\n"); standardIo.writeStderr("[Verification] No active replicator found\n");
return false; return false;
} }
@@ -50,7 +52,7 @@ async function verifyRemoteState(
true true
); );
if (typeof dbRet === "string") { 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; return false;
} }
milestone = await dbRet.db.get(MILESTONE_DOCID); milestone = await dbRet.db.get(MILESTONE_DOCID);
@@ -61,29 +63,30 @@ async function verifyRemoteState(
if (milestone) { if (milestone) {
const isLocked = !!milestone.locked; const isLocked = !!milestone.locked;
const isAccepted = !!milestone.accepted_nodes?.includes(replicator.nodeid); const isAccepted = !!milestone.accepted_nodes?.includes(replicator.nodeid);
process.stderr.write(`[Verification] Remote Database: ${isLocked ? "LOCKED" : "UNLOCKED"}\n`); standardIo.writeStderr(`[Verification] Remote Database: ${isLocked ? "LOCKED" : "UNLOCKED"}\n`);
process.stderr.write( standardIo.writeStderr(
`[Verification] Current Device Node ID (${replicator.nodeid}): ${isAccepted ? "ACCEPTED" : "NOT ACCEPTED"}\n` `[Verification] Current Device Node ID (${replicator.nodeid}): ${isAccepted ? "ACCEPTED" : "NOT ACCEPTED"}\n`
); );
return true; return true;
} else { } else {
process.stderr.write("[Verification] Milestone document not found on remote.\n"); standardIo.writeStderr("[Verification] Milestone document not found on remote.\n");
return false; return false;
} }
} catch (e) { } catch (e) {
const message = e instanceof Error ? e.message : String(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; return false;
} }
} }
export async function runCommand(options: CLIOptions, context: CLICommandContext): Promise<boolean> { export async function runCommand(options: CLIOptions, context: CLICommandContext): Promise<boolean> {
const { databasePath, core, settingsPath } = context; const { databasePath, core, settingsPath } = context;
const { standardIo } = core.services.context;
const vaultPath = context.vaultPath || databasePath; const vaultPath = context.vaultPath || databasePath;
await core.services.control.activated; await core.services.control.activated;
if (options.command === "daemon") { 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 // Skip the config mismatch dialog — the daemon cannot resolve it interactively
// and the default "Dismiss" action would block replication. The daemon should // 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..."); log("Replicating from CouchDB...");
const replResult = await core.services.replication.replicate(true); const replResult = await core.services.replication.replicate(true);
if (!replResult) { if (!replResult) {
console.error("[Daemon] Initial CouchDB replication failed, cannot continue"); writeStderrLine(standardIo, "[Daemon] Initial CouchDB replication failed, cannot continue");
return false; return false;
} }
log("CouchDB replication complete"); log("CouchDB replication complete");
@@ -104,7 +107,7 @@ export async function runCommand(options: CLIOptions, context: CLICommandContext
log("Running mirror scan..."); log("Running mirror scan...");
const scanOk = await performFullScan(core, log, errorManager, false, true); const scanOk = await performFullScan(core, log, errorManager, false, true);
if (!scanOk) { if (!scanOk) {
console.error("[Daemon] Mirror scan failed, cannot continue"); writeStderrLine(standardIo, "[Daemon] Mirror scan failed, cannot continue");
return false; return false;
} }
log("Mirror scan complete"); log("Mirror scan complete");
@@ -152,9 +155,10 @@ export async function runCommand(options: CLIOptions, context: CLICommandContext
} catch (err) { } catch (err) {
consecutiveFailures++; consecutiveFailures++;
currentIntervalMs = Math.min(baseIntervalMs * Math.pow(2, consecutiveFailures), maxIntervalMs); 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) { if (consecutiveFailures >= 5) {
console.error( writeStderrLine(
standardIo,
`[Daemon] Warning: ${consecutiveFailures} consecutive failures, backing off to ${Math.round(currentIntervalMs / 1000)}s` `[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"); log("LiveSync active");
const currentSettings = core.services.setting.currentSettings(); const currentSettings = core.services.setting.currentSettings();
if (!currentSettings.liveSync && !currentSettings.syncOnStart) { if (!currentSettings.liveSync && !currentSettings.syncOnStart) {
console.error( writeStderrLine(
standardIo,
"[Daemon] Warning: liveSync and syncOnStart are both disabled in settings. " + "[Daemon] Warning: liveSync and syncOnStart are both disabled in settings. " +
"No sync will occur. Set liveSync=true in your settings file for continuous sync, " + "No sync will occur. Set liveSync=true in your settings file for continuous sync, " +
"or use --interval for polling mode." "or use --interval for polling mode."
@@ -191,7 +196,7 @@ export async function runCommand(options: CLIOptions, context: CLICommandContext
} }
if (options.command === "sync") { if (options.command === "sync") {
console.log("[Command] sync"); writeStdoutLine(standardIo, "[Command] sync");
const result = await core.services.replication.replicate(true); const result = await core.services.replication.replicate(true);
if (!result) { if (!result) {
// TODO: Standardise the logic for identifying the cause of replication // 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. // error, etc.) is surfaced with a CLI-specific actionable message.
const replicator = core.services.replicator.getActiveReplicator(); const replicator = core.services.replicator.getActiveReplicator();
if (replicator?.remoteLockedAndDeviceNotAccepted) { if (replicator?.remoteLockedAndDeviceNotAccepted) {
console.error( writeStderrLine(
standardIo,
`[Error] The remote database is locked and this device is not yet accepted.\n` + `[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.` `[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>"); throw new Error("p2p-peers requires one argument: <timeout>");
} }
const timeoutSec = parseTimeoutSeconds(options.commandArgs[0], "p2p-peers"); 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); const peers = await collectPeers(core, timeoutSec);
if (peers.length > 0) { 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; return true;
} }
@@ -230,16 +236,16 @@ export async function runCommand(options: CLIOptions, context: CLICommandContext
throw new Error("p2p-sync requires a non-empty <peer>"); throw new Error("p2p-sync requires a non-empty <peer>");
} }
const timeoutSec = parseTimeoutSeconds(options.commandArgs[1], "p2p-sync"); 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); 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; return true;
} }
if (options.command === "p2p-host") { if (options.command === "p2p-host") {
console.error("[Command] p2p-host"); writeStderrLine(standardIo, "[Command] p2p-host");
await openP2PHost(core); 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(() => {}); await new Promise(() => {});
return true; return true;
} }
@@ -252,7 +258,7 @@ export async function runCommand(options: CLIOptions, context: CLICommandContext
const destinationDatabasePath = toDatabaseRelativePath(options.commandArgs[1], vaultPath); const destinationDatabasePath = toDatabaseRelativePath(options.commandArgs[1], vaultPath);
const sourceData = await fs.readFile(sourcePath); const sourceData = await fs.readFile(sourcePath);
const sourceStat = await fs.stat(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), { await core.serviceModules.storageAccess.writeFileAuto(destinationDatabasePath, toArrayBuffer(sourceData), {
mtime: Math.floor(sourceStat.mtimeMs), 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 sourceDatabasePath = toDatabaseRelativePath(options.commandArgs[0], vaultPath);
const destinationPath = path.resolve(options.commandArgs[1]); 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 sourcePathWithPrefix = sourceDatabasePath as FilePathWithPrefix;
const restored = await core.serviceModules.fileHandler.dbToStorage(sourcePathWithPrefix, null, true); const restored = await core.serviceModules.fileHandler.dbToStorage(sourcePathWithPrefix, null, true);
@@ -296,7 +302,7 @@ export async function runCommand(options: CLIOptions, context: CLICommandContext
if (!rev) { if (!rev) {
throw new Error("pull-rev requires a non-empty revision"); 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( const source = await core.serviceModules.databaseFileAccess.fetch(
sourceDatabasePath as FilePathWithPrefix, sourceDatabasePath as FilePathWithPrefix,
@@ -325,7 +331,10 @@ export async function runCommand(options: CLIOptions, context: CLICommandContext
if (!setupURI.startsWith(configURIBase)) { if (!setupURI.startsWith(configURIBase)) {
throw new Error(`setup URI must start with ${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); const decoded = await decodeSettingsFromSetupURI(setupURI, passphrase);
if (!decoded) { if (!decoded) {
throw new Error("Failed to decode settings from setup URI"); throw new Error("Failed to decode settings from setup URI");
@@ -337,7 +346,7 @@ export async function runCommand(options: CLIOptions, context: CLICommandContext
isConfigured: true, isConfigured: true,
} as ObsidianLiveSyncSettings; } as ObsidianLiveSyncSettings;
console.log(`[Command] setup -> ${settingsPath}`); writeStdoutLine(standardIo, `[Command] setup -> ${settingsPath}`);
await core.services.setting.applyExternalSettings(nextSettings, true); await core.services.setting.applyExternalSettings(nextSettings, true);
await core.services.control.applySettings(); await core.services.control.applySettings();
return true; return true;
@@ -348,8 +357,8 @@ export async function runCommand(options: CLIOptions, context: CLICommandContext
throw new Error("put requires one argument: <dst>"); throw new Error("put requires one argument: <dst>");
} }
const destinationDatabasePath = toDatabaseRelativePath(options.commandArgs[0], vaultPath); const destinationDatabasePath = toDatabaseRelativePath(options.commandArgs[0], vaultPath);
const content = await readStdinAsUtf8(); const content = await standardIo.readStdin();
console.log(`[Command] put stdin -> ${destinationDatabasePath}`); writeStdoutLine(standardIo, `[Command] put stdin -> ${destinationDatabasePath}`);
return await core.serviceModules.databaseFileAccess.storeContent( return await core.serviceModules.databaseFileAccess.storeContent(
destinationDatabasePath as FilePathWithPrefix, destinationDatabasePath as FilePathWithPrefix,
content content
@@ -361,7 +370,7 @@ export async function runCommand(options: CLIOptions, context: CLICommandContext
throw new Error("cat requires one argument: <src>"); throw new Error("cat requires one argument: <src>");
} }
const sourceDatabasePath = toDatabaseRelativePath(options.commandArgs[0], vaultPath); const sourceDatabasePath = toDatabaseRelativePath(options.commandArgs[0], vaultPath);
console.error(`[Command] cat ${sourceDatabasePath}`); writeStderrLine(standardIo, `[Command] cat ${sourceDatabasePath}`);
const source = await core.serviceModules.databaseFileAccess.fetch( const source = await core.serviceModules.databaseFileAccess.fetch(
sourceDatabasePath as FilePathWithPrefix, sourceDatabasePath as FilePathWithPrefix,
undefined, undefined,
@@ -372,10 +381,10 @@ export async function runCommand(options: CLIOptions, context: CLICommandContext
} }
const body = source.body; const body = source.body;
if (body.type === "text/plain") { if (body.type === "text/plain") {
process.stdout.write(await body.text()); standardIo.writeStdout(await body.text());
} else { } else {
const buffer = Buffer.from(await body.arrayBuffer()); const buffer = Buffer.from(await body.arrayBuffer());
process.stdout.write(new Uint8Array(buffer)); standardIo.writeStdout(new Uint8Array(buffer));
} }
return true; return true;
} }
@@ -389,7 +398,7 @@ export async function runCommand(options: CLIOptions, context: CLICommandContext
if (!rev) { if (!rev) {
throw new Error("cat-rev requires a non-empty revision"); 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( const source = await core.serviceModules.databaseFileAccess.fetch(
sourceDatabasePath as FilePathWithPrefix, sourceDatabasePath as FilePathWithPrefix,
rev, rev,
@@ -400,10 +409,10 @@ export async function runCommand(options: CLIOptions, context: CLICommandContext
} }
const body = source.body; const body = source.body;
if (body.type === "text/plain") { if (body.type === "text/plain") {
process.stdout.write(await body.text()); standardIo.writeStdout(await body.text());
} else { } else {
const buffer = Buffer.from(await body.arrayBuffer()); const buffer = Buffer.from(await body.arrayBuffer());
process.stdout.write(new Uint8Array(buffer)); standardIo.writeStdout(new Uint8Array(buffer));
} }
return true; return true;
} }
@@ -432,9 +441,9 @@ export async function runCommand(options: CLIOptions, context: CLICommandContext
rows.sort((a, b) => a.path.localeCompare(b.path)); rows.sort((a, b) => a.path.localeCompare(b.path));
if (rows.length > 0) { 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 { } 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; return true;
} }
@@ -475,11 +484,11 @@ export async function runCommand(options: CLIOptions, context: CLICommandContext
chunks: children.length, chunks: children.length,
children: children, children: children,
}; };
process.stdout.write(JSON.stringify(out, null, 2) + "\n"); standardIo.writeStdout(JSON.stringify(out, null, 2) + "\n");
return true; return true;
} }
process.stderr.write(`[Info] File not found: ${targetPath}\n`); standardIo.writeStderr(`[Info] File not found: ${targetPath}\n`);
return false; return false;
} }
@@ -488,7 +497,7 @@ export async function runCommand(options: CLIOptions, context: CLICommandContext
throw new Error("rm requires one argument: <path>"); throw new Error("rm requires one argument: <path>");
} }
const targetPath = toDatabaseRelativePath(options.commandArgs[0], vaultPath); 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); 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); const currentMeta = await core.serviceModules.databaseFileAccess.fetchEntryMeta(targetPath, undefined, true);
if (currentMeta === false || currentMeta._deleted || currentMeta.deleted) { 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; return false;
} }
const conflicts = await core.serviceModules.databaseFileAccess.getConflictedRevs(targetPath); const conflicts = await core.serviceModules.databaseFileAccess.getConflictedRevs(targetPath);
const candidateRevisions = [currentMeta._rev, ...conflicts]; const candidateRevisions = [currentMeta._rev, ...conflicts];
if (!candidateRevisions.includes(revisionToKeep)) { 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; return false;
} }
if (conflicts.length === 0 && currentMeta._rev === revisionToKeep) { 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; return true;
} }
console.error(`[Command] resolve ${targetPath} keep ${revisionToKeep}`); writeStderrLine(standardIo, `[Command] resolve ${targetPath} keep ${revisionToKeep}`);
for (const revision of candidateRevisions) { for (const revision of candidateRevisions) {
if (revision === revisionToKeep) { if (revision === revisionToKeep) {
continue; continue;
} }
const resolved = await core.services.conflict.resolveByDeletingRevision(targetPath, revision ?? "", "CLI"); const resolved = await core.services.conflict.resolveByDeletingRevision(targetPath, revision ?? "", "CLI");
if (!resolved) { 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; return false;
} }
} }
@@ -535,8 +544,8 @@ export async function runCommand(options: CLIOptions, context: CLICommandContext
} }
if (options.command === "mirror") { if (options.command === "mirror") {
console.error("[Command] mirror"); writeStderrLine(standardIo, "[Command] mirror");
const log = (msg: unknown) => console.error(`[Mirror] ${msg}`); const log = (msg: unknown) => writeStderrLine(standardIo, `[Mirror] ${String(msg)}`);
const errorManager = new UnresolvedErrorManager(core.services.appLifecycle, core.services.context.events); const errorManager = new UnresolvedErrorManager(core.services.appLifecycle, core.services.context.events);
return await performFullScan(core, log, errorManager, false, true); 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(); 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; return true;
} }
@@ -594,7 +603,7 @@ export async function runCommand(options: CLIOptions, context: CLICommandContext
const current = core.services.setting.currentSettings(); const current = core.services.setting.currentSettings();
if (!current.remoteConfigurations?.[id]) { 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; return false;
} }
@@ -624,7 +633,7 @@ export async function runCommand(options: CLIOptions, context: CLICommandContext
await core.services.control.applySettings(); await core.services.control.applySettings();
} }
console.error(`[Command] remote-rm ${id}`); writeStderrLine(standardIo, `[Command] remote-rm ${id}`);
return true; return true;
} }
@@ -634,7 +643,7 @@ export async function runCommand(options: CLIOptions, context: CLICommandContext
configs.sort((a, b) => a.name.localeCompare(b.name)); configs.sort((a, b) => a.name.localeCompare(b.name));
if (configs.length === 0) { if (configs.length === 0) {
process.stderr.write("[Info] No remote configurations found.\n"); standardIo.writeStderr("[Info] No remote configurations found.\n");
return true; return true;
} }
@@ -642,7 +651,7 @@ export async function runCommand(options: CLIOptions, context: CLICommandContext
const status = config.id === settings.activeConfigurationId ? "active" : "inactive"; const status = config.id === settings.activeConfigurationId ? "active" : "inactive";
return `${config.id}\t${config.name}\t${status}\t${redactConnectionString(config.uri)}`; 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; return true;
} }
@@ -657,11 +666,11 @@ export async function runCommand(options: CLIOptions, context: CLICommandContext
const config = core.services.setting.currentSettings().remoteConfigurations?.[id]; const config = core.services.setting.currentSettings().remoteConfigurations?.[id];
if (!config) { if (!config) {
process.stderr.write(`[Info] Remote configuration not found: ${id}\n`); standardIo.writeStderr(`[Info] Remote configuration not found: ${id}\n`);
return false; return false;
} }
process.stdout.write(`${config.uri}\n`); standardIo.writeStdout(`${config.uri}\n`);
return true; return true;
} }
@@ -701,7 +710,7 @@ export async function runCommand(options: CLIOptions, context: CLICommandContext
const updated = core.services.setting.currentSettings().remoteConfigurations?.[id]; const updated = core.services.setting.currentSettings().remoteConfigurations?.[id];
if (!updated) { if (!updated) {
process.stderr.write(`[Info] Remote configuration not found: ${id}\n`); standardIo.writeStderr(`[Info] Remote configuration not found: ${id}\n`);
return false; return false;
} }
@@ -709,7 +718,7 @@ export async function runCommand(options: CLIOptions, context: CLICommandContext
await core.services.control.applySettings(); await core.services.control.applySettings();
} }
console.error(`[Command] remote-set ${id}`); writeStderrLine(standardIo, `[Command] remote-set ${id}`);
return true; return true;
} }
if (options.command === "remote-activate") { if (options.command === "remote-activate") {
@@ -732,12 +741,12 @@ export async function runCommand(options: CLIOptions, context: CLICommandContext
}, true); }, true);
if (!switched) { 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; return false;
} }
await core.services.control.applySettings(); await core.services.control.applySettings();
console.error(`[Command] remote-activate ${id}`); writeStderrLine(standardIo, `[Command] remote-activate ${id}`);
return true; return true;
} }
@@ -755,14 +764,14 @@ export async function runCommand(options: CLIOptions, context: CLICommandContext
}, false); }, false);
if (!switched) { 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; return false;
} }
await core.services.control.applySettings(); await core.services.control.applySettings();
} }
console.error(`[Command] mark-resolved${id ? ` ${id}` : ""}`); writeStderrLine(standardIo, `[Command] mark-resolved${id ? ` ${id}` : ""}`);
await core.services.replication.markResolved(); await core.services.replication.markResolved();
const settings = core.services.setting.currentSettings(); const settings = core.services.setting.currentSettings();
await verifyRemoteState(core, settings); await verifyRemoteState(core, settings);
@@ -783,14 +792,14 @@ export async function runCommand(options: CLIOptions, context: CLICommandContext
}, false); }, false);
if (!switched) { 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; return false;
} }
await core.services.control.applySettings(); await core.services.control.applySettings();
} }
console.error(`[Command] unlock-remote${id ? ` ${id}` : ""}`); writeStderrLine(standardIo, `[Command] unlock-remote${id ? ` ${id}` : ""}`);
await core.services.replication.markUnlocked(); await core.services.replication.markUnlocked();
const settings = core.services.setting.currentSettings(); const settings = core.services.setting.currentSettings();
await verifyRemoteState(core, settings); await verifyRemoteState(core, settings);
@@ -811,14 +820,14 @@ export async function runCommand(options: CLIOptions, context: CLICommandContext
}, false); }, false);
if (!switched) { 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; return false;
} }
await core.services.control.applySettings(); await core.services.control.applySettings();
} }
console.error(`[Command] lock-remote${id ? ` ${id}` : ""}`); writeStderrLine(standardIo, `[Command] lock-remote${id ? ` ${id}` : ""}`);
await core.services.replication.markLocked(); await core.services.replication.markLocked();
const settings = core.services.setting.currentSettings(); const settings = core.services.setting.currentSettings();
await verifyRemoteState(core, settings); await verifyRemoteState(core, settings);
@@ -839,26 +848,26 @@ export async function runCommand(options: CLIOptions, context: CLICommandContext
}, false); }, false);
if (!switched) { 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; return false;
} }
await core.services.control.applySettings(); 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(); const replicator = core.services.replicator.getActiveReplicator();
if (!replicator) { if (!replicator) {
process.stderr.write("[Error] No active replicator found\n"); standardIo.writeStderr("[Error] No active replicator found\n");
return false; return false;
} }
const settings = core.services.setting.currentSettings(); const settings = core.services.setting.currentSettings();
const status = await replicator.getRemoteStatus(settings); const status = await replicator.getRemoteStatus(settings);
if (status === false) { if (status === false) {
process.stderr.write("[Error] Failed to fetch remote status\n"); standardIo.writeStderr("[Error] Failed to fetch remote status\n");
return false; return false;
} }
process.stdout.write(JSON.stringify(status, null, 2) + "\n"); standardIo.writeStdout(JSON.stringify(status, null, 2) + "\n");
return true; 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 { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
import { runCommand } from "./runCommand"; import { runCommand } from "./runCommand";
import type { CLIOptions } from "./types"; 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() { function createCoreMock() {
const liveSettings = { const liveSettings = {
@@ -17,6 +25,9 @@ function createCoreMock() {
} as any; } as any;
return { return {
services: { services: {
context: {
standardIo: createStandardIoMock(),
},
control: { control: {
activated: Promise.resolve(), activated: Promise.resolve(),
applySettings: vi.fn(async () => {}), applySettings: vi.fn(async () => {}),
@@ -69,6 +80,7 @@ function createCoreMock() {
}, },
databaseFileAccess: { databaseFileAccess: {
fetch: vi.fn(async () => undefined), fetch: vi.fn(async () => undefined),
storeContent: vi.fn(async () => true),
}, },
}, },
} as any; } as any;
@@ -96,20 +108,20 @@ async function createSetupURI(passphrase: string): Promise<string> {
return await processSetting.encodeSettingsToSetupURI(settings, passphrase); return await processSetting.encodeSettingsToSetupURI(settings, passphrase);
} }
function captureStdout() { function captureStdout(core: ReturnType<typeof createCoreMock>) {
const writes: string[] = []; const spy = core.services.context.standardIo.writeStdout;
const spy = vi.spyOn(process.stdout, "write").mockImplementation((chunk: any) => { spy.mockClear();
writes.push(typeof chunk === "string" ? chunk : String(chunk));
return true;
});
return { return {
spy, spy,
lines: () => lines: () =>
writes spy.mock.calls
.map(([chunk]: [string | Uint8Array]) =>
typeof chunk === "string" ? chunk : new TextDecoder().decode(chunk)
)
.join("") .join("")
.split("\n") .split("\n")
.map((e) => e.trim()) .map((entry: string) => entry.trim())
.filter((e) => e.length > 0), .filter((entry: string) => entry.length > 0),
}; };
} }
@@ -304,7 +316,7 @@ describe("runCommand abnormal cases", () => {
it("setup rejects empty passphrase", async () => { it("setup rejects empty passphrase", async () => {
const core = createCoreMock(); const core = createCoreMock();
vi.spyOn(commandUtils, "promptForPassphrase").mockRejectedValue(new Error("Passphrase is required")); core.services.context.standardIo.prompt.mockResolvedValue("");
await expect( await expect(
runCommand(makeOptions("setup", [`${configURIBase}dummy`]), { runCommand(makeOptions("setup", [`${configURIBase}dummy`]), {
@@ -318,7 +330,7 @@ describe("runCommand abnormal cases", () => {
const core = createCoreMock(); const core = createCoreMock();
const passphrase = "correct-passphrase"; const passphrase = "correct-passphrase";
const setupURI = await createSetupURI(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]), { const result = await runCommand(makeOptions("setup", [setupURI]), {
...context, ...context,
@@ -339,7 +351,7 @@ describe("runCommand abnormal cases", () => {
it("setup rejects encoded URI when passphrase is wrong", async () => { it("setup rejects encoded URI when passphrase is wrong", async () => {
const core = createCoreMock(); const core = createCoreMock();
const setupURI = await createSetupURI("correct-passphrase"); const setupURI = await createSetupURI("correct-passphrase");
vi.spyOn(commandUtils, "promptForPassphrase").mockResolvedValue("wrong-passphrase"); core.services.context.standardIo.prompt.mockResolvedValue("wrong-passphrase");
await expect( await expect(
runCommand(makeOptions("setup", [setupURI]), { runCommand(makeOptions("setup", [setupURI]), {
@@ -352,9 +364,61 @@ describe("runCommand abnormal cases", () => {
expect(core.services.control.applySettings).not.toHaveBeenCalled(); 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 () => { it("remote-add stores canonical URI and prints the created id", async () => {
const core = createCoreMock(); 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"]), { const result = await runCommand(makeOptions("remote-add", ["my-remote", "sls+https://example.com/db"]), {
...context, ...context,
@@ -435,7 +499,7 @@ describe("runCommand abnormal cases", () => {
uri: "sls+https://example.com/db?db=vault", uri: "sls+https://example.com/db?db=vault",
isEncrypted: false, isEncrypted: false,
}; };
const stdout = captureStdout(); const stdout = captureStdout(core);
const result = await runCommand(makeOptions("remote-export", ["r1"]), { const result = await runCommand(makeOptions("remote-export", ["r1"]), {
...context, ...context,
@@ -565,7 +629,7 @@ describe("runCommand abnormal cases", () => {
])("remote command round-trip works for %s", async (_protocol, initialConnStr) => { ])("remote command round-trip works for %s", async (_protocol, initialConnStr) => {
const core = createCoreMock(); const core = createCoreMock();
const addOut = captureStdout(); const addOut = captureStdout(core);
const addResult = await runCommand(makeOptions("remote-add", ["rt", initialConnStr]), { const addResult = await runCommand(makeOptions("remote-add", ["rt", initialConnStr]), {
...context, ...context,
core, core,
@@ -574,7 +638,7 @@ describe("runCommand abnormal cases", () => {
const remoteId = parseAddedRemoteIdFromLines(addOut.lines()); const remoteId = parseAddedRemoteIdFromLines(addOut.lines());
expect(remoteId).not.toBe(""); expect(remoteId).not.toBe("");
const export1Out = captureStdout(); const export1Out = captureStdout(core);
const export1Result = await runCommand(makeOptions("remote-export", [remoteId]), { const export1Result = await runCommand(makeOptions("remote-export", [remoteId]), {
...context, ...context,
core, core,
@@ -591,7 +655,7 @@ describe("runCommand abnormal cases", () => {
}); });
expect(setResult).toBe(true); expect(setResult).toBe(true);
const export2Out = captureStdout(); const export2Out = captureStdout(core);
const export2Result = await runCommand(makeOptions("remote-export", [remoteId]), { const export2Result = await runCommand(makeOptions("remote-export", [remoteId]), {
...context, ...context,
core, core,
@@ -740,13 +804,13 @@ describe("runCommand abnormal cases", () => {
it("remote-status without args outputs status of active remote configuration", async () => { it("remote-status without args outputs status of active remote configuration", async () => {
const core = createCoreMock(); const core = createCoreMock();
const stdout = captureStdout(); const stdout = captureStdout(core);
const result = await runCommand(makeOptions("remote-status", []), { const result = await runCommand(makeOptions("remote-status", []), {
...context, ...context,
core, core,
}); });
expect(result).toBe(true); 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); const parsedStatus = JSON.parse(fullOutput);
expect(parsedStatus.db_name).toBe("test-db"); expect(parsedStatus.db_name).toBe("test-db");
expect(parsedStatus.doc_count).toBe(42); expect(parsedStatus.doc_count).toBe(42);
@@ -761,13 +825,13 @@ describe("runCommand abnormal cases", () => {
uri: "sls+https://example.com/db1", uri: "sls+https://example.com/db1",
isEncrypted: false, isEncrypted: false,
}; };
const stdout = captureStdout(); const stdout = captureStdout(core);
const result = await runCommand(makeOptions("remote-status", ["r1"]), { const result = await runCommand(makeOptions("remote-status", ["r1"]), {
...context, ...context,
core, core,
}); });
expect(result).toBe(true); 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); const parsedStatus = JSON.parse(fullOutput);
expect(parsedStatus.db_name).toBe("test-db"); expect(parsedStatus.db_name).toBe("test-db");
expect(parsedStatus.doc_count).toBe(42); expect(parsedStatus.doc_count).toBe(42);
+2 -2
View File
@@ -1,6 +1,6 @@
import { LiveSyncBaseCore } from "@/LiveSyncBaseCore"; 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 { ObsidianLiveSyncSettings } from "@vrtmrz/livesync-commonlib/compat/common/types";
import type { NodeServiceContext } from "@/apps/cli/services/NodeServiceContext";
export type CLICommand = export type CLICommand =
| "daemon" | "daemon"
@@ -47,7 +47,7 @@ export interface CLIOptions {
export interface CLICommandContext { export interface CLICommandContext {
databasePath: string; databasePath: string;
vaultPath: string; vaultPath: string;
core: LiveSyncBaseCore<ServiceContext, never>; core: LiveSyncBaseCore<NodeServiceContext, never>;
settingsPath: string; settingsPath: string;
originalSyncSettings: Pick< originalSyncSettings: Pick<
ObsidianLiveSyncSettings, 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 { export function toArrayBuffer(data: Buffer): ArrayBuffer {
return data.buffer.slice(data.byteOffset, data.byteOffset + data.byteLength) as 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, "/"); 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 { RTCPeerConnection } from "werift";
import { main } from "./main"; import { main } from "./main";
import { compatGlobal } from "@vrtmrz/livesync-commonlib/compat/common/coreEnvFunctions"; import { compatGlobal } from "@vrtmrz/livesync-commonlib/compat/common/coreEnvFunctions";
import { createNodeStandardIo } from "@vrtmrz/livesync-commonlib/node";
import { writeStderrLine } from "./cliOutput";
if ( if (
typeof (compatGlobal as unknown as Record<string, unknown>).RTCPeerConnection === "undefined" && typeof (compatGlobal as unknown as Record<string, unknown>).RTCPeerConnection === "undefined" &&
@@ -11,7 +13,9 @@ if (
(compatGlobal as unknown as Record<string, unknown>).RTCPeerConnection = RTCPeerConnection; (compatGlobal as unknown as Record<string, unknown>).RTCPeerConnection = RTCPeerConnection;
} }
main().catch((error) => { const standardIo = createNodeStandardIo();
console.error(`[Fatal Error]`, error);
main(standardIo).catch((error) => {
writeStderrLine(standardIo, `[Fatal Error]`, error);
process.exit(1); 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 { stripAllPrefixes } from "@vrtmrz/livesync-commonlib/compat/string_and_binary/path";
import { IgnoreRules } from "./serviceModules/IgnoreRules"; import { IgnoreRules } from "./serviceModules/IgnoreRules";
import { useP2PReplicatorFeature } from "@vrtmrz/livesync-commonlib/compat/replication/trystero/useP2PReplicatorFeature"; 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"; const SETTINGS_FILE = ".livesync/settings.json";
ensureGlobalNodeLocalStorage(); ensureGlobalNodeLocalStorage();
defaultLoggerEnv.minLogLevel = LOG_LEVEL_DEBUG; defaultLoggerEnv.minLogLevel = LOG_LEVEL_DEBUG;
function printHelp(): void { function printHelp(standardIo: StandardIo): void {
console.log(` writeStdoutLine(
standardIo,
`
Self-hosted LiveSync CLI Self-hosted LiveSync CLI
Usage: Usage:
@@ -115,14 +119,15 @@ Examples:
livesync-cli ./my-database remote-status remote-abc123 livesync-cli ./my-database remote-status remote-abc123
livesync-cli init-settings ./data.json livesync-cli init-settings ./data.json
livesync-cli ./my-database --verbose livesync-cli ./my-database --verbose
`); `
);
} }
export function parseArgs(): CLIOptions { export function parseArgs(standardIo: StandardIo = createNodeStandardIo()): CLIOptions {
const args = process.argv.slice(2); const args = process.argv.slice(2);
if (args.length === 0 || args.includes("--help") || args.includes("-h")) { if (args.length === 0 || args.includes("--help") || args.includes("-h")) {
printHelp(); printHelp(standardIo);
process.exit(0); process.exit(0);
} }
@@ -143,7 +148,7 @@ export function parseArgs(): CLIOptions {
case "-V": { case "-V": {
i++; i++;
if (!args[i]) { if (!args[i]) {
console.error(`Error: Missing value for ${token}`); writeStderrLine(standardIo, `Error: Missing value for ${token}`);
process.exit(1); process.exit(1);
} }
vaultPath = args[i]; vaultPath = args[i];
@@ -153,7 +158,7 @@ export function parseArgs(): CLIOptions {
case "-s": { case "-s": {
i++; i++;
if (!args[i]) { if (!args[i]) {
console.error(`Error: Missing value for ${token}`); writeStderrLine(standardIo, `Error: Missing value for ${token}`);
process.exit(1); process.exit(1);
} }
settingsPath = args[i]; settingsPath = args[i];
@@ -163,12 +168,12 @@ export function parseArgs(): CLIOptions {
case "-i": { case "-i": {
i++; i++;
if (!args[i]) { if (!args[i]) {
console.error(`Error: Missing value for ${token}`); writeStderrLine(standardIo, `Error: Missing value for ${token}`);
process.exit(1); process.exit(1);
} }
const n = parseInt(args[i], 10); const n = parseInt(args[i], 10);
if (!Number.isInteger(n) || n <= 0) { 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); process.exit(1);
} }
interval = n; interval = n;
@@ -212,12 +217,12 @@ export function parseArgs(): CLIOptions {
} }
if (!databasePath && command !== "init-settings") { if (!databasePath && command !== "init-settings") {
console.error("Error: database-path is required"); writeStderrLine(standardIo, "Error: database-path is required");
process.exit(1); process.exit(1);
} }
if (command === "daemon" && commandArgs.length > 0) { if (command === "daemon" && commandArgs.length > 0) {
console.error(`Error: Unknown command '${commandArgs[0]}'`); writeStderrLine(standardIo, `Error: Unknown command '${commandArgs[0]}'`);
process.exit(1); 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 const targetPath = options.settingsPath
? path.resolve(options.settingsPath) ? path.resolve(options.settingsPath)
: options.commandArgs[0] : options.commandArgs[0]
@@ -259,13 +264,13 @@ async function createDefaultSettingsFile(options: CLIOptions) {
await fs.mkdir(path.dirname(targetPath), { recursive: true }); await fs.mkdir(path.dirname(targetPath), { recursive: true });
await fs.writeFile(targetPath, JSON.stringify(settings, null, 2), "utf-8"); 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() { export async function main(standardIo: StandardIo = createNodeStandardIo()) {
const options = parseArgs(); const options = parseArgs(standardIo);
if (options.interval && options.command !== "daemon") { 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 = const avoidStdoutNoise =
options.command === "cat" || options.command === "cat" ||
@@ -282,13 +287,13 @@ export async function main() {
options.command === "unlock-remote" || options.command === "unlock-remote" ||
options.command === "lock-remote" || options.command === "lock-remote" ||
options.command === "remote-status"; 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) { if (options.debug) {
setGlobalLogFunction((msg, level) => { setGlobalLogFunction((msg, level) => {
console.error(`[${level}] ${typeof msg === "string" ? msg : JSON.stringify(msg)}`); writeStderrLine(standardIo, `[${level}]`, msg);
if (msg instanceof Error) {
console.error(msg);
}
}); });
} else { } else {
setGlobalLogFunction((msg, level) => { setGlobalLogFunction((msg, level) => {
@@ -296,7 +301,7 @@ export async function main() {
}); });
} }
if (options.command === "init-settings") { if (options.command === "init-settings") {
await createDefaultSettingsFile(options); await createDefaultSettingsFile(options, standardIo);
return; return;
} }
@@ -306,11 +311,11 @@ export async function main() {
try { try {
const stat = await fs.stat(databasePath); const stat = await fs.stat(databasePath);
if (!stat.isDirectory()) { if (!stat.isDirectory()) {
console.error(`Error: ${databasePath} is not a directory`); writeStderrLine(standardIo, `Error: ${databasePath} is not a directory`);
process.exit(1); process.exit(1);
} }
} catch { } catch {
console.error(`Error: Database directory ${databasePath} does not exist`); writeStderrLine(standardIo, `Error: Database directory ${databasePath} does not exist`);
process.exit(1); process.exit(1);
} }
@@ -336,11 +341,11 @@ export async function main() {
try { try {
const stat = await fs.stat(vaultPath); const stat = await fs.stat(vaultPath);
if (!stat.isDirectory()) { 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); process.exit(1);
} }
} catch { } catch {
console.error(`Error: Vault directory ${vaultPath} does not exist`); writeStderrLine(standardIo, `Error: Vault directory ${vaultPath} does not exist`);
process.exit(1); process.exit(1);
} }
@@ -351,12 +356,18 @@ export async function main() {
infoLog(""); infoLog("");
let ignoreRules: IgnoreRules | undefined; let ignoreRules: IgnoreRules | undefined;
if (options.command === "daemon" || options.command === "mirror") { 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(); await ignoreRules.load();
} }
// Create service context and hub // Create service context and hub
const context = new NodeServiceContext(databasePath); const context = new NodeServiceContext(databasePath, standardIo);
const serviceHubInstance = new NodeServiceHub<NodeServiceContext>(databasePath, context); const serviceHubInstance = new NodeServiceHub<NodeServiceContext>(databasePath, context);
serviceHubInstance.API.addLog.setHandler((message: unknown, level: LOG_LEVEL) => { serviceHubInstance.API.addLog.setHandler((message: unknown, level: LOG_LEVEL) => {
let levelStr = ""; let levelStr = "";
@@ -377,19 +388,19 @@ export async function main() {
levelStr = "Urgent"; levelStr = "Urgent";
break; break;
default: default:
levelStr = `${level}`; levelStr = String(level);
} }
const prefix = `(${levelStr})`; const prefix = `(${levelStr})`;
if (level <= LOG_LEVEL_INFO) { if (level <= LOG_LEVEL_INFO) {
if (!options.verbose) return; if (!options.verbose) return;
} }
console.error(`${prefix} ${message}`); writeStderrLine(standardIo, prefix, message);
}); });
// Prevent replication result from being processed automatically in non-daemon commands. // 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. // In daemon mode the default handler must run so changes are applied to the filesystem.
if (options.command !== "daemon") { if (options.command !== "daemon") {
serviceHubInstance.replication.processSynchroniseResult.addHandler(async () => { 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); return await Promise.resolve(true);
}, -100); }, -100);
} }
@@ -402,10 +413,10 @@ export async function main() {
try { try {
await fs.writeFile(settingsPath, JSON.stringify(data, null, 2), "utf-8"); await fs.writeFile(settingsPath, JSON.stringify(data, null, 2), "utf-8");
if (options.verbose) { if (options.verbose) {
console.error(`[Settings] Saved to ${settingsPath}`); writeStderrLine(standardIo, `[Settings] Saved to ${settingsPath}`);
} }
} catch (error) { } 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> => { async (): Promise<ObsidianLiveSyncSettings | undefined> => {
try { try {
const content = await fs.readFile(settingsPath, "utf-8"); const content = await fs.readFile(settingsPath, "utf-8");
const data = JSON.parse(content); const data = JSON.parse(content) as ObsidianLiveSyncSettings;
if (options.verbose) { if (options.verbose) {
console.error(`[Settings] Loaded from ${settingsPath}`); writeStderrLine(standardIo, `[Settings] Loaded from ${settingsPath}`);
} }
// Force disable IndexedDB adapter in CLI environment // Force disable IndexedDB adapter in CLI environment without mutating the loaded settings object.
data.useIndexedDBAdapter = false; return { ...data, useIndexedDBAdapter: false };
return data;
} catch { } catch {
if (options.verbose) { if (options.verbose) {
console.error(`[Settings] File not found, using defaults`); writeStderrLine(standardIo, `[Settings] File not found, using defaults`);
} }
return undefined; return undefined;
} }
@@ -473,14 +483,14 @@ export async function main() {
// Setup signal handlers for graceful shutdown // Setup signal handlers for graceful shutdown
const shutdown = async (signal: string) => { const shutdown = async (signal: string) => {
console.log(); writeStdoutLine(standardIo);
console.log(`[Shutdown] Received ${signal}, shutting down gracefully...`); writeStdoutLine(standardIo, `[Shutdown] Received ${signal}, shutting down gracefully...`);
try { try {
await core.services.control.onUnload(); await core.services.control.onUnload();
console.log(`[Shutdown] Complete`); writeStdoutLine(standardIo, `[Shutdown] Complete`);
process.exit(0); process.exit(0);
} catch (error) { } catch (error) {
console.error(`[Shutdown] Error:`, error); writeStderrLine(standardIo, `[Shutdown] Error:`, error);
process.exit(1); process.exit(1);
} }
}; };
@@ -502,7 +512,7 @@ export async function main() {
fsSync.writeFileSync(tmpPath, settingsBackup, "utf-8"); fsSync.writeFileSync(tmpPath, settingsBackup, "utf-8");
fsSync.renameSync(tmpPath, settingsPath); fsSync.renameSync(tmpPath, settingsPath);
} catch (err) { } 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(); const loadResult = await core.services.control.onLoad();
if (!loadResult) { if (!loadResult) {
console.error(`[Error] Failed to initialize LiveSync`); writeStderrLine(standardIo, `[Error] Failed to initialize LiveSync`);
process.exit(1); process.exit(1);
} }
// Capture sync settings before suspendAllSync() clobbers them. // Capture sync settings before suspendAllSync() clobbers them.
@@ -538,15 +548,15 @@ export async function main() {
// Check if configured // Check if configured
const settings = core.services.setting.currentSettings(); const settings = core.services.setting.currentSettings();
if (!settings.isConfigured) { if (!settings.isConfigured) {
console.warn(`[Warning] LiveSync is not configured yet`); writeStderrLine(standardIo, `[Warning] LiveSync is not configured yet`);
console.warn(`[Warning] Please edit ${settingsPath} to configure CouchDB connection`); writeStderrLine(standardIo, `[Warning] Please edit ${settingsPath} to configure CouchDB connection`);
console.warn(); writeStderrLine(standardIo);
console.warn(`Required settings:`); writeStderrLine(standardIo, `Required settings:`);
console.warn(` - couchDB_URI: CouchDB server URL`); writeStderrLine(standardIo, ` - couchDB_URI: CouchDB server URL`);
console.warn(` - couchDB_USER: CouchDB username`); writeStderrLine(standardIo, ` - couchDB_USER: CouchDB username`);
console.warn(` - couchDB_PASSWORD: CouchDB password`); writeStderrLine(standardIo, ` - couchDB_PASSWORD: CouchDB password`);
console.warn(` - couchDB_DBNAME: Database name`); writeStderrLine(standardIo, ` - couchDB_DBNAME: Database name`);
console.warn(); writeStderrLine(standardIo);
} else { } else {
infoLog(`[Info] LiveSync is configured and ready`); infoLog(`[Info] LiveSync is configured and ready`);
infoLog(`[Info] Database: ${settings.couchDB_URI}/${settings.couchDB_DBNAME}`); 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 }); const result = await runCommand(options, { databasePath, vaultPath, core, settingsPath, originalSyncSettings });
if (!result) { if (!result) {
console.error(`[Error] Command '${options.command}' failed`); writeStderrLine(standardIo, `[Error] Command '${options.command}' failed`);
process.exitCode = 1; process.exitCode = 1;
} else if (options.command !== "daemon") { } else if (options.command !== "daemon") {
infoLog(`[Done] Command '${options.command}' completed`); infoLog(`[Done] Command '${options.command}' completed`);
@@ -568,7 +578,7 @@ export async function main() {
await core.services.control.onUnload(); await core.services.control.onUnload();
} }
} catch (error) { } catch (error) {
console.error(`[Error] Failed to start:`, error); writeStderrLine(standardIo, `[Error] Failed to start:`, error);
process.exit(1); process.exit(1);
} }
// To prevent unexpected hanging in webRTC connections. // To prevent unexpected hanging in webRTC connections.
+25 -17
View File
@@ -1,5 +1,14 @@
import { afterEach, describe, expect, it, vi } from "vitest"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { parseArgs } from "./main"; 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() { function mockProcessExit() {
const exitMock = vi.spyOn(process, "exit").mockImplementation(((code?: number) => { const exitMock = vi.spyOn(process, "exit").mockImplementation(((code?: number) => {
@@ -10,6 +19,13 @@ function mockProcessExit() {
describe("CLI parseArgs", () => { describe("CLI parseArgs", () => {
const originalArgv = process.argv.slice(); const originalArgv = process.argv.slice();
let standardIo: ReturnType<typeof createStandardIoMock>;
beforeEach(() => {
standardIo = createStandardIoMock();
});
const parseArgs = () => parseCliArgs(standardIo);
afterEach(() => { afterEach(() => {
process.argv = originalArgv.slice(); process.argv = originalArgv.slice();
@@ -19,42 +35,38 @@ describe("CLI parseArgs", () => {
it("exits 1 when --settings has no value", () => { it("exits 1 when --settings has no value", () => {
process.argv = ["node", "livesync-cli", "./databasePath", "--settings"]; process.argv = ["node", "livesync-cli", "./databasePath", "--settings"];
const exitMock = mockProcessExit(); const exitMock = mockProcessExit();
const stderr = vi.spyOn(console, "error").mockImplementation(() => {});
expect(() => parseArgs()).toThrowError("__EXIT__:1"); expect(() => parseArgs()).toThrowError("__EXIT__:1");
expect(exitMock).toHaveBeenCalledWith(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", () => { it("exits 1 when database-path is missing", () => {
process.argv = ["node", "livesync-cli", "sync"]; process.argv = ["node", "livesync-cli", "sync"];
const exitMock = mockProcessExit(); const exitMock = mockProcessExit();
const stderr = vi.spyOn(console, "error").mockImplementation(() => {});
expect(() => parseArgs()).toThrowError("__EXIT__:1"); expect(() => parseArgs()).toThrowError("__EXIT__:1");
expect(exitMock).toHaveBeenCalledWith(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", () => { it("exits 1 for unknown command after database-path", () => {
process.argv = ["node", "livesync-cli", "./databasePath", "unknown-cmd"]; process.argv = ["node", "livesync-cli", "./databasePath", "unknown-cmd"];
const exitMock = mockProcessExit(); const exitMock = mockProcessExit();
const stderr = vi.spyOn(console, "error").mockImplementation(() => {});
expect(() => parseArgs()).toThrowError("__EXIT__:1"); expect(() => parseArgs()).toThrowError("__EXIT__:1");
expect(exitMock).toHaveBeenCalledWith(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", () => { it("exits 0 and prints help for --help", () => {
process.argv = ["node", "livesync-cli", "--help"]; process.argv = ["node", "livesync-cli", "--help"];
const exitMock = mockProcessExit(); const exitMock = mockProcessExit();
const stdout = vi.spyOn(console, "log").mockImplementation(() => {});
expect(() => parseArgs()).toThrowError("__EXIT__:0"); expect(() => parseArgs()).toThrowError("__EXIT__:0");
expect(exitMock).toHaveBeenCalledWith(0); expect(exitMock).toHaveBeenCalledWith(0);
expect(stdout).toHaveBeenCalled(); expect(standardIo.writeStdout).toHaveBeenCalled();
const combined = stdout.mock.calls.flat().join("\n"); const combined = standardIo.writeStdout.mock.calls.flat().join("");
expect(combined).toContain("Usage:"); expect(combined).toContain("Usage:");
expect(combined).toContain("livesync-cli <database-path> [options] <command> [command-args]"); 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", () => { it("exits 1 when --interval has no value", () => {
process.argv = ["node", "livesync-cli", "./vault", "--interval"]; process.argv = ["node", "livesync-cli", "./vault", "--interval"];
const exitMock = mockProcessExit(); const exitMock = mockProcessExit();
vi.spyOn(console, "error").mockImplementation(() => {});
expect(() => parseArgs()).toThrowError("__EXIT__:1"); expect(() => parseArgs()).toThrowError("__EXIT__:1");
expect(exitMock).toHaveBeenCalledWith(1); expect(exitMock).toHaveBeenCalledWith(1);
}); });
@@ -160,22 +171,19 @@ describe("CLI parseArgs", () => {
it("exits 1 when --interval is not a positive integer", () => { it("exits 1 when --interval is not a positive integer", () => {
process.argv = ["node", "livesync-cli", "./vault", "--interval", "0"]; process.argv = ["node", "livesync-cli", "./vault", "--interval", "0"];
const exitMock = mockProcessExit(); const exitMock = mockProcessExit();
vi.spyOn(console, "error").mockImplementation(() => {});
expect(() => parseArgs()).toThrowError("__EXIT__:1"); expect(() => parseArgs()).toThrowError("__EXIT__:1");
expect(exitMock).toHaveBeenCalledWith(1); expect(exitMock).toHaveBeenCalledWith(1);
}); });
it("exits 1 when --interval is negative", () => { it("exits 1 when --interval is negative", () => {
process.argv = ["node", "livesync-cli", "./vault", "--interval", "-5"]; process.argv = ["node", "livesync-cli", "./vault", "--interval", "-5"];
const exitMock = mockProcessExit(); mockProcessExit();
vi.spyOn(console, "error").mockImplementation(() => {});
expect(() => parseArgs()).toThrowError("__EXIT__:1"); expect(() => parseArgs()).toThrowError("__EXIT__:1");
}); });
it("exits 1 when --interval is not numeric", () => { it("exits 1 when --interval is not numeric", () => {
process.argv = ["node", "livesync-cli", "./vault", "--interval", "abc"]; process.argv = ["node", "livesync-cli", "./vault", "--interval", "abc"];
const exitMock = mockProcessExit(); mockProcessExit();
vi.spyOn(console, "error").mockImplementation(() => {});
expect(() => parseArgs()).toThrowError("__EXIT__:1"); 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 { watch as chokidarWatch, type FSWatcher } from "chokidar";
import type { IgnoreRules } from "@/apps/cli/serviceModules/IgnoreRules"; import type { IgnoreRules } from "@/apps/cli/serviceModules/IgnoreRules";
import { fsPromises as fs, path, type Stats } from "@vrtmrz/livesync-commonlib/node"; import { fsPromises as fs, path, type Stats } from "@vrtmrz/livesync-commonlib/node";
import type { CliDiagnosticReporter } from "@/apps/cli/cliOutput";
/** /**
* CLI-specific type guard adapter * CLI-specific type guard adapter
@@ -45,7 +46,10 @@ class CLITypeGuardAdapter implements IStorageEventTypeGuardAdapter<NodeFile, Nod
class CLIPersistenceAdapter implements IStorageEventPersistenceAdapter { class CLIPersistenceAdapter implements IStorageEventPersistenceAdapter {
private snapshotPath: string; private snapshotPath: string;
constructor(basePath: string) { constructor(
basePath: string,
private reportDiagnostic: CliDiagnosticReporter = () => undefined
) {
this.snapshotPath = path.join(basePath, ".livesync-snapshot.json"); this.snapshotPath = path.join(basePath, ".livesync-snapshot.json");
} }
@@ -53,14 +57,14 @@ class CLIPersistenceAdapter implements IStorageEventPersistenceAdapter {
try { try {
await fs.writeFile(this.snapshotPath, JSON.stringify(snapshot, null, 2), "utf-8"); await fs.writeFile(this.snapshotPath, JSON.stringify(snapshot, null, 2), "utf-8");
} catch (error) { } catch (error) {
console.error("Failed to save snapshot:", error); this.reportDiagnostic("Failed to save snapshot:", error);
} }
} }
async loadSnapshot(): Promise<(FileEventItem | FileEventItemSentinel)[] | null> { async loadSnapshot(): Promise<(FileEventItem | FileEventItemSentinel)[] | null> {
try { try {
const content = await fs.readFile(this.snapshotPath, "utf-8"); const content = await fs.readFile(this.snapshotPath, "utf-8");
return JSON.parse(content); return JSON.parse(content) as (FileEventItem | FileEventItemSentinel)[];
} catch { } catch {
return null; return null;
} }
@@ -109,7 +113,8 @@ class CLIWatchAdapter implements IStorageEventWatchAdapter {
constructor( constructor(
private basePath: string, private basePath: string,
private ignoreRules?: IgnoreRules, private ignoreRules?: IgnoreRules,
private watchEnabled: boolean = false private watchEnabled: boolean = false,
private reportDiagnostic: CliDiagnosticReporter = () => undefined
) {} ) {}
private _toNodeFile(filePath: string, stats: Stats | undefined): NodeFile { private _toNodeFile(filePath: string, stats: Stats | undefined): NodeFile {
@@ -180,8 +185,8 @@ class CLIWatchAdapter implements IStorageEventWatchAdapter {
}); });
watcher.on("error", (err) => { watcher.on("error", (err) => {
console.error("[CLIWatchAdapter] Fatal watcher error — file watching stopped:", err); this.reportDiagnostic("[CLIWatchAdapter] Fatal watcher error — file watching stopped:", err);
console.error("[CLIWatchAdapter] Exiting for systemd restart."); this.reportDiagnostic("[CLIWatchAdapter] Exiting for systemd restart.");
void watcher.close(); void watcher.close();
this._watcher = undefined; this._watcher = undefined;
// Use exit(1) rather than SIGTERM so systemd Restart=on-failure engages. // 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 status: CLIStatusAdapter;
readonly converter: CLIConverterAdapter; 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.typeGuard = new CLITypeGuardAdapter();
this.persistence = new CLIPersistenceAdapter(basePath); this.persistence = new CLIPersistenceAdapter(basePath, reportDiagnostic);
this.watch = new CLIWatchAdapter(basePath, ignoreRules, watchEnabled); this.watch = new CLIWatchAdapter(basePath, ignoreRules, watchEnabled, reportDiagnostic);
this.status = new CLIStatusAdapter(); this.status = new CLIStatusAdapter();
this.converter = new CLIConverterAdapter(); this.converter = new CLIConverterAdapter();
} }
@@ -103,7 +103,8 @@ describe("CLIStorageEventManagerAdapter", () => {
}); });
it("error event triggers process.exit(1)", async () => { 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(); const handlers = makeHandlers();
await adapter.watch.beginWatch(handlers); await adapter.watch.beginWatch(handlers);
@@ -117,6 +118,11 @@ describe("CLIStorageEventManagerAdapter", () => {
errorCallback(new Error("disk failure")); errorCallback(new Error("disk failure"));
expect(processExitSpy).toHaveBeenCalledWith(1); 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(); processExitSpy.mockRestore();
}); });
@@ -3,6 +3,7 @@ import { CLIStorageEventManagerAdapter } from "./CLIStorageEventManagerAdapter";
import type { IMinimumLiveSyncCommands, LiveSyncBaseCore } from "@/LiveSyncBaseCore"; import type { IMinimumLiveSyncCommands, LiveSyncBaseCore } from "@/LiveSyncBaseCore";
import type { ServiceContext } from "@vrtmrz/livesync-commonlib/compat/services/base/ServiceBase"; import type { ServiceContext } from "@vrtmrz/livesync-commonlib/compat/services/base/ServiceBase";
import type { IgnoreRules } from "@/apps/cli/serviceModules/IgnoreRules"; 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"; // import type { IMinimumLiveSyncCommands } from "@vrtmrz/livesync-commonlib/compat/services/base/IService";
export class StorageEventManagerCLI extends StorageEventManagerBase<CLIStorageEventManagerAdapter> { export class StorageEventManagerCLI extends StorageEventManagerBase<CLIStorageEventManagerAdapter> {
@@ -15,7 +16,12 @@ export class StorageEventManagerCLI extends StorageEventManagerBase<CLIStorageEv
ignoreRules?: IgnoreRules, ignoreRules?: IgnoreRules,
watchEnabled?: boolean 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); super(adapter, dependencies);
this.core = core; this.core = core;
} }
+7 -1
View File
@@ -1,5 +1,6 @@
import { FileAccessBase, type FileAccessBaseDependencies } from "@vrtmrz/livesync-commonlib/compat/serviceModules/FileAccessBase"; import { FileAccessBase, type FileAccessBaseDependencies } from "@vrtmrz/livesync-commonlib/compat/serviceModules/FileAccessBase";
import { NodeFileSystemAdapter } from "@/apps/cli/adapters/NodeFileSystemAdapter"; import { NodeFileSystemAdapter } from "@/apps/cli/adapters/NodeFileSystemAdapter";
import { LOG_LEVEL_NOTICE } from "octagonal-wheels/common/logger";
/** /**
* CLI-specific implementation of FileAccessBase * CLI-specific implementation of FileAccessBase
@@ -7,7 +8,12 @@ import { NodeFileSystemAdapter } from "@/apps/cli/adapters/NodeFileSystemAdapter
*/ */
export class FileAccessCLI extends FileAccessBase<NodeFileSystemAdapter> { export class FileAccessCLI extends FileAccessBase<NodeFileSystemAdapter> {
constructor(basePath: string, dependencies: FileAccessBaseDependencies) { 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); super(adapter, dependencies);
} }
+7 -3
View File
@@ -1,5 +1,6 @@
import { Minimatch } from "minimatch"; import { Minimatch } from "minimatch";
import { fsPromises as fs, path } from "@vrtmrz/livesync-commonlib/node"; 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. * 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 { export class IgnoreRules {
private patterns: Minimatch[] = []; private patterns: Minimatch[] = [];
constructor(private vaultPath: string) {} constructor(
private vaultPath: string,
private reportDiagnostic: CliDiagnosticReporter = () => undefined
) {}
/** /**
* Reads `.livesync/ignore` (and optionally `.gitignore`) and populates the * Reads `.livesync/ignore` (and optionally `.gitignore`) and populates the
@@ -53,7 +57,7 @@ export class IgnoreRules {
continue; continue;
} }
if (trimmed.startsWith("import:")) { if (trimmed.startsWith("import:")) {
console.error( this.reportDiagnostic(
`[IgnoreRules] Warning: unrecognised directive '${trimmed}' — only 'import: .gitignore' is supported` `[IgnoreRules] Warning: unrecognised directive '${trimmed}' — only 'import: .gitignore' is supported`
); );
continue; continue;
@@ -61,7 +65,7 @@ export class IgnoreRules {
this._addPattern(trimmed); this._addPattern(trimmed);
} }
if (this.patterns.length > 0) { 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(); const vaultPath = await createVault();
// Typo: "import:.gitignore" instead of "import: .gitignore" // Typo: "import:.gitignore" instead of "import: .gitignore"
await writeIgnoreFile(vaultPath, "*.tmp\nimport:.gitignore\n"); 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(); await rules.load();
// *.tmp still loaded; import:.gitignore is skipped (not treated as a literal pattern) // *.tmp still loaded; import:.gitignore is skipped (not treated as a literal pattern)
expect(rules.shouldIgnore("scratch.tmp")).toBe(true); expect(rules.shouldIgnore("scratch.tmp")).toBe(true);
expect(rules.shouldIgnore("import:.gitignore")).toBe(false); 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 { eventHub } from "@/common/events";
import { translateLiveSyncMessage } from "@/common/translation"; 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. */ /** Host capabilities owned by one Self-hosted LiveSync CLI composition. */
export class NodeServiceContext extends ServiceContext { export class NodeServiceContext extends ServiceContext {
constructor(readonly databasePath: string) { constructor(
readonly databasePath: string,
readonly standardIo: StandardIo
) {
super({ events: eventHub, translate: translateLiveSyncMessage }); super({ events: eventHub, translate: translateLiveSyncMessage });
} }
} }
@@ -9,12 +9,19 @@ import {
} from "../../../../test/contracts/serviceContext"; } from "../../../../test/contracts/serviceContext";
import { NodeServiceContext } from "./NodeServiceContext"; import { NodeServiceContext } from "./NodeServiceContext";
import { NodeServiceHub } from "./NodeServiceHub"; import { NodeServiceHub } from "./NodeServiceHub";
import type { StandardIo } from "@vrtmrz/livesync-commonlib/context";
const TRANSLATION_KEY = "Replicator.Message.InitialiseFatalError"; const TRANSLATION_KEY = "Replicator.Message.InitialiseFatalError";
describe("NodeServiceContext contract", () => { describe("NodeServiceContext contract", () => {
it("preserves the CLI capabilities and host-neutral API results", () => { 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({ expect(observeServiceContext(context, TRANSLATION_KEY)).toEqual({
translation: translateLiveSyncMessage(TRANSLATION_KEY), translation: translateLiveSyncMessage(TRANSLATION_KEY),
@@ -22,6 +29,7 @@ describe("NodeServiceContext contract", () => {
}); });
expect(context.events).toBe(eventHub); expect(context.events).toBe(eventHub);
expect(context.databasePath).toBe("/tmp/livesync-context-contract"); expect(context.databasePath).toBe("/tmp/livesync-context-contract");
expect(context.standardIo).toBe(standardIo);
const hub = new NodeServiceHub(context.databasePath, context); const hub = new NodeServiceHub(context.databasePath, context);
const composition = observeServiceComposition(hub, 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> { 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 runtimeDir = nodePath.join(basePath, ".livesync", "runtime");
const localStoragePath = nodePath.join(runtimeDir, "local-storage.json"); const localStoragePath = nodePath.join(runtimeDir, "local-storage.json");
const keyValueDBPath = nodePath.join(runtimeDir, "keyvalue-db.json"); const keyValueDBPath = nodePath.join(runtimeDir, "keyvalue-db.json");
+2
View File
@@ -12,11 +12,13 @@ The head note of 0.25 is now in [updates_old.md](https://github.com/vrtmrz/obsid
### Miscellaneous ### 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. - 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 ### Testing
- Added packed-package and downstream checks for Commonlib entry points, including isolated Node and browser File System Access API storage implementations. - 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 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 ## 0.25.83