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