mirror of
https://github.com/vrtmrz/obsidian-livesync.git
synced 2026-05-13 19:11:15 +00:00
IgnoreRules (src/apps/cli/serviceModules/IgnoreRules.ts): - Reads .livesync/ignore for user-defined glob patterns - Applies gitignore matchBase semantics: patterns without / get **/ prefix, patterns ending with / get ** appended for directory contents - Supports `import: .gitignore` directive to merge gitignore patterns - Rejects negation patterns with a warning (not fully supportable) - Integrated into both daemon and mirror commands via isTargetFile handler Wiring: - IgnoreRules loaded before LiveSyncBaseCore construction so beginWatch() receives rules when it fires during onLoad/onFirstInitialise - Passed through initialiseServiceModulesCLI -> StorageEventManagerCLI -> CLIStorageEventManagerAdapter -> CLIWatchAdapter Deployment: - src/apps/cli/deploy/livesync-cli.service - systemd unit template - src/apps/cli/deploy/install.sh - user/system install script Testing: - src/apps/cli/test/test-daemon-linux.sh - e2e tests for ignore rules - src/apps/cli/serviceModules/IgnoreRules.unit.spec.ts - 15 unit tests - src/apps/cli/commands/daemonCommand.unit.spec.ts - 7 unit tests
513 lines
20 KiB
TypeScript
513 lines
20 KiB
TypeScript
/**
|
|
* Self-hosted LiveSync CLI
|
|
* Command-line version of Self-hosted LiveSync plugin for syncing vaults without Obsidian
|
|
*/
|
|
|
|
import * as fs from "fs/promises";
|
|
import * as path from "path";
|
|
import { NodeServiceContext, NodeServiceHub } from "./services/NodeServiceHub";
|
|
import { configureNodeLocalStorage, ensureGlobalNodeLocalStorage } from "./services/NodeLocalStorage";
|
|
import { LiveSyncBaseCore } from "../../LiveSyncBaseCore";
|
|
import { ModuleReplicatorP2P } from "../../modules/core/ModuleReplicatorP2P";
|
|
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";
|
|
import { IgnoreRules } from "./serviceModules/IgnoreRules";
|
|
|
|
const SETTINGS_FILE = ".livesync/settings.json";
|
|
ensureGlobalNodeLocalStorage();
|
|
defaultLoggerEnv.minLogLevel = LOG_LEVEL_DEBUG;
|
|
|
|
function printHelp(): void {
|
|
console.log(`
|
|
Self-hosted LiveSync CLI
|
|
|
|
Usage:
|
|
livesync-cli <database-path> [options] <command> [command-args]
|
|
livesync-cli init-settings [path]
|
|
|
|
Arguments:
|
|
database-path Path to the local database directory
|
|
|
|
Commands:
|
|
daemon (default) Run mirror scan then continuously sync CouchDB <-> local filesystem
|
|
sync Run one replication cycle and exit
|
|
p2p-peers <timeout> Show discovered peers as [peer]\t<peer-id>\t<peer-name>
|
|
p2p-sync <peer> <timeout>
|
|
Sync with the specified peer-id or peer-name
|
|
p2p-host Start P2P host mode and wait until interrupted
|
|
push <src> <dst> Push local file <src> into local database path <dst>
|
|
pull <src> <dst> Pull file <src> from local database into local file <dst>
|
|
pull-rev <src> <dst> <rev> Pull file <src> at specific revision <rev> into local file <dst>
|
|
setup <setupURI> Apply setup URI to settings file
|
|
put <dst> Read UTF-8 content from stdin and write to local database path <dst>
|
|
cat <src> Read file <src> from local database and write to stdout
|
|
cat-rev <src> <rev> Read file <src> at specific revision <rev> and write to stdout
|
|
ls [prefix] List DB files as path\tsize\tmtime\trevision[*]
|
|
info <path> Show detailed metadata for a file (ID, revision, conflicts, chunks)
|
|
rm <path> Mark a file as deleted in local database
|
|
resolve <path> <rev> Resolve conflicts by keeping <rev> and deleting others
|
|
mirror [vault-path] Mirror database contents to the local file system (vault-path defaults to database-path)
|
|
|
|
Options:
|
|
--interval <N>, -i <N> (daemon only) Poll CouchDB every N seconds instead of using the _changes feed
|
|
|
|
Examples:
|
|
livesync-cli ./my-database Run daemon (LiveSync mode)
|
|
livesync-cli ./my-database --interval 30 Run daemon (polling every 30s)
|
|
livesync-cli ./my-database sync
|
|
livesync-cli ./my-database p2p-peers 5
|
|
livesync-cli ./my-database p2p-sync my-peer-name 15
|
|
livesync-cli ./my-database p2p-host
|
|
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 interval: number | undefined;
|
|
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 "--interval":
|
|
case "-i": {
|
|
i++;
|
|
if (!args[i]) {
|
|
console.error(`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]}'`);
|
|
process.exit(1);
|
|
}
|
|
interval = n;
|
|
break;
|
|
}
|
|
case "--debug":
|
|
case "-d":
|
|
// debugging automatically enables verbose logging, as it is intended for debugging issues.
|
|
debug = true;
|
|
// falls through
|
|
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,
|
|
interval,
|
|
};
|
|
}
|
|
|
|
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();
|
|
if (options.interval && options.command !== "daemon") {
|
|
console.error(`Warning: --interval is only used in daemon mode, ignored for '${options.command}'`);
|
|
}
|
|
const avoidStdoutNoise =
|
|
options.command === "cat" ||
|
|
options.command === "cat-rev" ||
|
|
options.command === "ls" ||
|
|
options.command === "p2p-peers" ||
|
|
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 database path
|
|
const databasePath = path.resolve(options.databasePath!);
|
|
// Check if database directory exists
|
|
try {
|
|
const stat = await fs.stat(databasePath);
|
|
if (!stat.isDirectory()) {
|
|
console.error(`Error: ${databasePath} is not a directory`);
|
|
process.exit(1);
|
|
}
|
|
} catch (error) {
|
|
console.error(`Error: Database directory ${databasePath} does not exist`);
|
|
process.exit(1);
|
|
}
|
|
|
|
// Resolve settings path
|
|
const settingsPath = options.settingsPath
|
|
? path.resolve(options.settingsPath)
|
|
: path.join(databasePath, SETTINGS_FILE);
|
|
configureNodeLocalStorage(path.join(databasePath, ".livesync", "runtime", "local-storage.json"));
|
|
|
|
infoLog(`Self-hosted LiveSync CLI`);
|
|
infoLog(`Database Path: ${databasePath}`);
|
|
infoLog(`Settings: ${settingsPath}`);
|
|
infoLog("");
|
|
|
|
// For daemon and mirror mode, load ignore rules before the core is constructed so that
|
|
// chokidar's ignored option is populated when beginWatch() fires during onLoad().
|
|
const watchEnabled = options.command === "daemon";
|
|
const vaultPath =
|
|
options.command === "mirror" && options.commandArgs[0]
|
|
? path.resolve(options.commandArgs[0])
|
|
: databasePath;
|
|
let ignoreRules: IgnoreRules | undefined;
|
|
if (options.command === "daemon" || options.command === "mirror") {
|
|
ignoreRules = new IgnoreRules(vaultPath);
|
|
await ignoreRules.load();
|
|
}
|
|
|
|
|
|
// Create service context and hub
|
|
const context = new NodeServiceContext(databasePath);
|
|
const serviceHubInstance = new NodeServiceHub<NodeServiceContext>(databasePath, 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 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.`);
|
|
return await Promise.resolve(true);
|
|
}, -100);
|
|
}
|
|
|
|
// Setup settings handlers
|
|
const settingService = serviceHubInstance.setting;
|
|
|
|
(settingService as InjectableSettingService<NodeServiceContext>).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<NodeServiceContext>).loadData.setHandler(
|
|
async (): Promise<ObsidianLiveSyncSettings | undefined> => {
|
|
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<NodeServiceContext, any>, serviceHub: InjectableServiceHub<NodeServiceContext>) => {
|
|
return initialiseServiceModulesCLI(vaultPath, core, serviceHub, ignoreRules, watchEnabled);
|
|
},
|
|
(core) => [
|
|
// No modules need to be registered for P2P replication in CLI. Directly using Replicators in p2p.ts
|
|
// new ModuleReplicatorP2P(core),
|
|
],
|
|
() => [], // No add-ons
|
|
(core) => {
|
|
// Add target filter to prevent internal files are handled
|
|
core.services.vault.isTargetFile.addHandler(async (target) => {
|
|
const targetPath = stripAllPrefixes(getPathFromUXFileInfo(target));
|
|
const parts = targetPath.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);
|
|
}
|
|
// PouchDB LevelDB database directory lives in the vault directory.
|
|
if (parts[0]?.endsWith("-livesync-v2")) {
|
|
return await Promise.resolve(false);
|
|
}
|
|
return await Promise.resolve(true);
|
|
}, -1 /* highest priority */);
|
|
|
|
// Apply user-defined ignore rules for daemon mode (lower priority, runs after dotfile check).
|
|
if (ignoreRules) {
|
|
const rules = ignoreRules;
|
|
core.services.vault.isTargetFile.addHandler(async (target) => {
|
|
const targetPath = stripAllPrefixes(getPathFromUXFileInfo(target));
|
|
if (rules.shouldIgnore(targetPath)) {
|
|
return false;
|
|
}
|
|
// undefined = pass through to next handler in chain
|
|
return undefined;
|
|
}, 0);
|
|
}
|
|
}
|
|
);
|
|
|
|
// 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"));
|
|
|
|
// Save the settings file before any lifecycle events can mutate and persist them.
|
|
// suspendAllSync and other lifecycle hooks clobber sync settings in memory, and
|
|
// various code paths persist the clobbered state to disk. We restore on shutdown.
|
|
const settingsBackup = await fs.readFile(settingsPath, "utf-8").catch(() => null);
|
|
|
|
// Restore settings file on any exit to undo lifecycle mutations.
|
|
// Write to a temp path first so a crash mid-write doesn't leave a truncated file.
|
|
process.on("exit", () => {
|
|
if (settingsBackup) {
|
|
const tmpPath = settingsPath + ".tmp";
|
|
try {
|
|
require("fs").writeFileSync(tmpPath, settingsBackup, "utf-8");
|
|
require("fs").renameSync(tmpPath, settingsPath);
|
|
} catch (err) {
|
|
console.error("[Settings] Failed to restore settings on exit:", err);
|
|
}
|
|
}
|
|
});
|
|
|
|
// 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);
|
|
}
|
|
// Capture sync settings before suspendAllSync() clobbers them.
|
|
// Used by daemon mode to restore the correct sync behaviour after the mirror scan.
|
|
const settingsBeforeSuspend = core.services.setting.currentSettings();
|
|
const originalSyncSettings = {
|
|
liveSync: settingsBeforeSuspend.liveSync,
|
|
syncOnStart: settingsBeforeSuspend.syncOnStart,
|
|
periodicReplication: settingsBeforeSuspend.periodicReplication,
|
|
syncOnSave: settingsBeforeSuspend.syncOnSave,
|
|
syncOnEditorSave: settingsBeforeSuspend.syncOnEditorSave,
|
|
syncOnFileOpen: settingsBeforeSuspend.syncOnFileOpen,
|
|
syncAfterMerge: settingsBeforeSuspend.syncAfterMerge,
|
|
};
|
|
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, { databasePath, core, settingsPath, originalSyncSettings });
|
|
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" && result) {
|
|
// 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);
|
|
}
|
|
// To prevent unexpected hanging in webRTC connections.
|
|
process.exit(process.exitCode ?? 0);
|
|
}
|