/** * Self-hosted LiveSync CLI * Command-line version of Self-hosted LiveSync plugin for syncing vaults without Obsidian */ if (!("localStorage" in globalThis)) { const store = new Map(); (globalThis as any).localStorage = { getItem: (key: string) => (store.has(key) ? store.get(key)! : null), setItem: (key: string, value: string) => { store.set(key, value); }, removeItem: (key: string) => { store.delete(key); }, clear: () => { store.clear(); }, }; } import * as fs from "fs/promises"; import * as path from "path"; import { NodeServiceContext, NodeServiceHub } from "./services/NodeServiceHub"; import { LiveSyncBaseCore } from "../../LiveSyncBaseCore"; import { initialiseServiceModulesCLI } from "./serviceModules/CLIServiceModules"; import { DEFAULT_SETTINGS, LOG_LEVEL_VERBOSE, type LOG_LEVEL, type ObsidianLiveSyncSettings } from "@lib/common/types"; import type { InjectableServiceHub } from "@lib/services/implements/injectable/InjectableServiceHub"; import type { InjectableSettingService } from "@/lib/src/services/implements/injectable/InjectableSettingService"; import { LOG_LEVEL_DEBUG, setGlobalLogFunction, defaultLoggerEnv, LOG_LEVEL_INFO, LOG_LEVEL_URGENT, LOG_LEVEL_NOTICE } from "octagonal-wheels/common/logger"; import { runCommand } from "./commands/runCommand"; import { VALID_COMMANDS } from "./commands/types"; import type { CLICommand, CLIOptions } from "./commands/types"; import { getPathFromUXFileInfo } from "@lib/common/typeUtils"; import { stripAllPrefixes } from "@lib/string_and_binary/path"; const SETTINGS_FILE = ".livesync/settings.json"; defaultLoggerEnv.minLogLevel = LOG_LEVEL_DEBUG; // DI the log again. // const recentLogEntries = reactiveSource([]); // const globalLogFunction = (message: any, level?: number, key?: string) => { // const messageX = // message instanceof Error // ? new LiveSyncError("[Error Logged]: " + message.message, { cause: message }) // : message; // const entry = { message: messageX, level, key } as LogEntry; // recentLogEntries.value = [...recentLogEntries.value, entry]; // }; // setGlobalLogFunction((msg, level) => { // console.error(`[${level}] ${typeof msg === "string" ? msg : JSON.stringify(msg)}`); // if (msg instanceof Error) { // console.error(msg); // } // }); function printHelp(): void { console.log(` Self-hosted LiveSync CLI Usage: livesync-cli [database-path] [options] [command] [command-args] Arguments: database-path Path to the local database directory (required) Commands: sync Run one replication cycle and exit push Push local file into local database path pull Pull file from local database into local file pull-rev Pull file at specific revision into local file setup Apply setup URI to settings file put Read UTF-8 content from stdin and write to local database path cat Read file from local database and write to stdout cat-rev Read file at specific revision and write to stdout ls [prefix] List DB files as pathsizemtimerevision[*] info Show detailed metadata for a file (ID, revision, conflicts, chunks) rm Mark a file as deleted in local database resolve Resolve conflicts by keeping and deleting others Examples: livesync-cli ./my-database sync livesync-cli ./my-database --settings ./custom-settings.json push ./note.md folder/note.md livesync-cli ./my-database pull folder/note.md ./exports/note.md livesync-cli ./my-database pull-rev folder/note.md ./exports/note.old.md 3-abcdef livesync-cli ./my-database setup "obsidian://setuplivesync?settings=..." echo "Hello" | livesync-cli ./my-database put notes/hello.md livesync-cli ./my-database cat notes/hello.md livesync-cli ./my-database cat-rev notes/hello.md 3-abcdef livesync-cli ./my-database ls notes/ livesync-cli ./my-database info notes/hello.md livesync-cli ./my-database rm notes/hello.md livesync-cli ./my-database resolve notes/hello.md 3-abcdef livesync-cli init-settings ./data.json livesync-cli ./my-database --verbose `); } export function parseArgs(): CLIOptions { const args = process.argv.slice(2); if (args.length === 0 || args.includes("--help") || args.includes("-h")) { printHelp(); process.exit(0); } let databasePath: string | undefined; let settingsPath: string | undefined; let verbose = false; let debug = false; let force = false; let command: CLICommand = "daemon"; const commandArgs: string[] = []; for (let i = 0; i < args.length; i++) { const token = args[i]; switch (token) { case "--settings": case "-s": { i++; if (!args[i]) { console.error(`Error: Missing value for ${token}`); process.exit(1); } settingsPath = args[i]; break; } case "--debug": case "-d": // debugging automatically enables verbose logging, as it is intended for debugging issues. debug = true; case "--verbose": case "-v": verbose = true; break; case "--force": case "-f": force = true; break; default: { if (!databasePath) { if (command === "daemon" && VALID_COMMANDS.has(token as any)) { command = token as CLICommand; break; } if (command === "init-settings") { commandArgs.push(token); break; } databasePath = token; break; } if (command === "daemon" && VALID_COMMANDS.has(token as any)) { command = token as CLICommand; break; } commandArgs.push(token); break; } } } if (!databasePath && command !== "init-settings") { console.error("Error: database-path is required"); process.exit(1); } if (command === "daemon" && commandArgs.length > 0) { console.error(`Error: Unknown command '${commandArgs[0]}'`); process.exit(1); } return { databasePath, settingsPath, verbose, debug, force, command, commandArgs, }; } async function createDefaultSettingsFile(options: CLIOptions) { const targetPath = options.settingsPath ? path.resolve(options.settingsPath) : options.commandArgs[0] ? path.resolve(options.commandArgs[0]) : path.resolve(process.cwd(), "data.json"); if (!options.force) { try { await fs.stat(targetPath); throw new Error(`Settings file already exists: ${targetPath} (use --force to overwrite)`); } catch (ex: any) { if (!(ex && ex?.code === "ENOENT")) { throw ex; } } } const settings = { ...DEFAULT_SETTINGS, useIndexedDBAdapter: false, } as ObsidianLiveSyncSettings; 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}`); } export async function main() { const options = parseArgs(); const avoidStdoutNoise = options.command === "cat" || options.command === "cat-rev" || options.command === "ls" || options.command === "info" || options.command === "rm" || options.command === "resolve"; const infoLog = avoidStdoutNoise ? console.error : console.log; if(options.debug){ setGlobalLogFunction((msg, level) => { console.error(`[${level}] ${typeof msg === "string" ? msg : JSON.stringify(msg)}`); if (msg instanceof Error) { console.error(msg); } }); }else{ setGlobalLogFunction((msg, level) => { // NO OP, leave it to logFunction }) } if (options.command === "init-settings") { await createDefaultSettingsFile(options); return; } // Resolve vault path const vaultPath = path.resolve(options.databasePath!); // Check if vault directory exists try { const stat = await fs.stat(vaultPath); if (!stat.isDirectory()) { console.error(`Error: ${vaultPath} is not a directory`); process.exit(1); } } catch (error) { console.error(`Error: Vault directory ${vaultPath} does not exist`); process.exit(1); } // Resolve settings path const settingsPath = options.settingsPath ? path.resolve(options.settingsPath) : path.join(vaultPath, SETTINGS_FILE); infoLog(`Self-hosted LiveSync CLI`); infoLog(`Vault: ${vaultPath}`); infoLog(`Settings: ${settingsPath}`); infoLog(""); // Create service context and hub const context = new NodeServiceContext(vaultPath); const serviceHubInstance = new NodeServiceHub(vaultPath, context); serviceHubInstance.API.addLog.setHandler((message: string, level: LOG_LEVEL) => { let levelStr = ""; switch (level) { case LOG_LEVEL_DEBUG: levelStr = "debug"; break; case LOG_LEVEL_VERBOSE: levelStr = "Verbose"; break; case LOG_LEVEL_INFO: levelStr = "Info"; break; case LOG_LEVEL_NOTICE: levelStr = "Notice"; break; case LOG_LEVEL_URGENT: levelStr = "Urgent"; break; default: levelStr = `${level}`; } const prefix = `(${levelStr})`; if (level <= LOG_LEVEL_INFO) { if (!options.verbose) return; } console.error(`${prefix} ${message}`); }); // Prevent replication result to be processed automatically. serviceHubInstance.replication.processSynchroniseResult.addHandler(async () => { console.error(`[Info] Replication result received, but not processed automatically in CLI mode.`); return await Promise.resolve(true); }, -100); // Setup settings handlers const settingService = serviceHubInstance.setting; (settingService as InjectableSettingService).saveData.setHandler( async (data: ObsidianLiveSyncSettings) => { try { await fs.writeFile(settingsPath, JSON.stringify(data, null, 2), "utf-8"); if (options.verbose) { console.error(`[Settings] Saved to ${settingsPath}`); } } catch (error) { console.error(`[Settings] Failed to save:`, error); } } ); (settingService as InjectableSettingService).loadData.setHandler( async (): Promise => { try { const content = await fs.readFile(settingsPath, "utf-8"); const data = JSON.parse(content); if (options.verbose) { console.error(`[Settings] Loaded from ${settingsPath}`); } // Force disable IndexedDB adapter in CLI environment data.useIndexedDBAdapter = false; return data; } catch (error) { if (options.verbose) { console.error(`[Settings] File not found, using defaults`); } return undefined; } } ); // Create LiveSync core const core = new LiveSyncBaseCore( serviceHubInstance, (core: LiveSyncBaseCore, serviceHub: InjectableServiceHub) => { return initialiseServiceModulesCLI(vaultPath, core, serviceHub); }, () => [], // No extra modules () => [], // No add-ons (core) => { // Add target filter to prevent internal files are handled core.services.vault.isTargetFile.addHandler(async (target) => { const vaultPath = stripAllPrefixes(getPathFromUXFileInfo(target)); const parts = vaultPath.split(path.sep); // if some part of the path starts with dot, treat it as internal file and ignore. if (parts.some((part) => part.startsWith("."))) { return await Promise.resolve(false); } return await Promise.resolve(true); }, -1 /* highest priority */); } ); // Setup signal handlers for graceful shutdown const shutdown = async (signal: string) => { console.log(); console.log(`[Shutdown] Received ${signal}, shutting down gracefully...`); try { await core.services.control.onUnload(); console.log(`[Shutdown] Complete`); process.exit(0); } catch (error) { console.error(`[Shutdown] Error:`, error); process.exit(1); } }; process.on("SIGINT", () => shutdown("SIGINT")); process.on("SIGTERM", () => shutdown("SIGTERM")); // Start the core try { infoLog(`[Starting] Initializing LiveSync...`); const loadResult = await core.services.control.onLoad(); if (!loadResult) { console.error(`[Error] Failed to initialize LiveSync`); process.exit(1); } await core.services.setting.suspendAllSync(); await core.services.control.onReady(); infoLog(`[Ready] LiveSync is running`); infoLog(`[Ready] Press Ctrl+C to stop`); infoLog(""); // 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(); } else { infoLog(`[Info] LiveSync is configured and ready`); infoLog(`[Info] Database: ${settings.couchDB_URI}/${settings.couchDB_DBNAME}`); infoLog(""); } const result = await runCommand(options, { vaultPath, core, settingsPath }); if (!result) { console.error(`[Error] Command '${options.command}' failed`); process.exitCode = 1; } else if (options.command !== "daemon") { infoLog(`[Done] Command '${options.command}' completed`); } if (options.command === "daemon") { // Keep the process running await new Promise(() => {}); } else { await core.services.control.onUnload(); } } catch (error) { console.error(`[Error] Failed to start:`, error); process.exit(1); } }