import { spawn } from "node:child_process"; import { access, mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { dirname, join, resolve } from "node:path"; import { assertCouchDbReachable, createCouchDbDatabase, deleteCouchDbDatabase, loadCouchDbConfig, makeUniqueDatabaseName, waitForCouchDbDocs, } from "../runner/couchdb.ts"; import { discoverObsidianCli, requireObsidianBinary } from "../runner/environment.ts"; import { assertEqual, configureCouchDb, prepareRemote, pushLocalChanges, waitForLiveSyncCoreReady, } from "../runner/liveSyncWorkflow.ts"; import { startObsidianLiveSyncSession, type ObsidianLiveSyncSession } from "../runner/session.ts"; import { createTemporaryVault } from "../runner/vault.ts"; process.env.E2E_OBSIDIAN_CLI_TIMEOUT_MS ??= "30000"; process.env.E2E_OBSIDIAN_COUCHDB_TIMEOUT_MS ??= "30000"; process.env.E2E_OBSIDIAN_FILE_TIMEOUT_MS ??= "30000"; const liveSyncCli = resolve("src/apps/cli/dist/index.cjs"); const notePath = "E2E/cli-to-obsidian.md"; const noteContent = [ "# CLI to real Obsidian", "", "This note was created by the Self-hosted LiveSync CLI.", "The real Obsidian plug-in must retrieve the same content from CouchDB.", "0123456789 abcdefghijklmnopqrstuvwxyz 0123456789 abcdefghijklmnopqrstuvwxyz", "", ].join("\n"); const e2eePassphrase = "real-obsidian-cli-compatibility-e2e"; type LiveSyncCliCommand = { executable: string; prefixArgs: string[]; }; type CliResult = { stdout: string; stderr: string; }; type CliFileInfo = { id: string; children: string[]; }; function parseCommandLine(value: string): string[] { const trimmed = value.trim(); if (trimmed.startsWith("[")) { const parsed = JSON.parse(trimmed) as unknown; if (!Array.isArray(parsed) || parsed.length === 0 || parsed.some((part) => typeof part !== "string")) { throw new Error("LIVESYNC_CLI_COMMAND JSON form must be a non-empty array of strings."); } return parsed; } const parts: string[] = []; let current = ""; let quote: "'" | '"' | undefined; let tokenStarted = false; for (let index = 0; index < trimmed.length; index++) { const character = trimmed[index]; if (quote) { if (character === quote) { quote = undefined; continue; } if (character === "\\" && quote === '"' && ['"', "\\"].includes(trimmed[index + 1] ?? "")) { current += trimmed[++index]; continue; } current += character; continue; } if (character === "'" || character === '"') { quote = character; tokenStarted = true; continue; } if (character === "\\" && ["'", '"', "\\", " ", "\t"].includes(trimmed[index + 1] ?? "")) { current += trimmed[++index]; tokenStarted = true; continue; } if (/\s/u.test(character)) { if (tokenStarted) { parts.push(current); current = ""; tokenStarted = false; } continue; } current += character; tokenStarted = true; } if (quote) { throw new Error("LIVESYNC_CLI_COMMAND contains an unterminated quoted value."); } if (tokenStarted) { parts.push(current); } if (parts.length === 0) { throw new Error("LIVESYNC_CLI_COMMAND must not be empty."); } return parts; } function resolveLiveSyncCliCommand(): LiveSyncCliCommand { const override = process.env.LIVESYNC_CLI_COMMAND; if (override !== undefined) { const [executable, ...prefixArgs] = parseCommandLine(override); return { executable, prefixArgs }; } return { executable: process.execPath, prefixArgs: [liveSyncCli] }; } async function runLiveSyncCli(command: LiveSyncCliCommand, args: string[]): Promise { return await new Promise((resolvePromise, reject) => { const timeoutMs = Number(process.env.E2E_LIVESYNC_CLI_TIMEOUT_MS ?? 60000); const child = spawn(command.executable, [...command.prefixArgs, ...args], { cwd: process.cwd(), env: process.env, stdio: ["ignore", "pipe", "pipe"], }); let stdout = ""; let stderr = ""; child.stdout.on("data", (chunk: Buffer) => { stdout += chunk.toString("utf-8"); }); child.stderr.on("data", (chunk: Buffer) => { stderr += chunk.toString("utf-8"); }); let timedOut = false; const timeout = setTimeout(() => { timedOut = true; child.kill("SIGTERM"); }, timeoutMs); child.on("error", (error) => { clearTimeout(timeout); reject(error); }); child.on("exit", (code, signal) => { clearTimeout(timeout); const result = { stdout, stderr, }; if (timedOut) { reject( new Error( `LiveSync CLI timed out after ${timeoutMs} ms\nstdout:\n${result.stdout}\nstderr:\n${result.stderr}` ) ); return; } if (code === 0) { resolvePromise(result); return; } reject( new Error( `LiveSync CLI failed with ${signal ? `signal ${signal}` : `exit code ${String(code)}`}\n` + `stdout:\n${result.stdout}\nstderr:\n${result.stderr}` ) ); }); }); } async function configureLiveSyncCli( command: LiveSyncCliCommand, settingsPath: string, couchDb: Awaited>, dbName: string ): Promise { await runLiveSyncCli(command, ["init-settings", "--force", settingsPath]); const settings = JSON.parse(await readFile(settingsPath, "utf-8")) as Record; Object.assign(settings, { couchDB_URI: couchDb.uri, couchDB_USER: couchDb.username, couchDB_PASSWORD: couchDb.password, couchDB_DBNAME: dbName, remoteType: "", liveSync: false, syncOnStart: false, syncOnSave: false, usePluginSync: false, usePluginSyncV2: true, useEden: false, customChunkSize: 60, sendChunksBulk: false, sendChunksBulkMaxSize: 1, chunkSplitterVersion: "v3-rabin-karp", readChunksOnline: true, disableCheckingConfigMismatch: false, enableCompression: false, hashAlg: "xxhash64", handleFilenameCaseSensitive: false, doNotUseFixedRevisionForChunks: true, E2EEAlgorithm: "v2", encrypt: true, passphrase: e2eePassphrase, usePathObfuscation: true, doctorProcessedVersion: "0.25.27", isConfigured: true, }); await writeFile(settingsPath, `${JSON.stringify(settings, null, 2)}\n`, "utf-8"); } async function writeCliNote( command: LiveSyncCliCommand, databasePath: string, settingsPath: string, sourcePath: string ): Promise { await mkdir(dirname(sourcePath), { recursive: true }); await writeFile(sourcePath, noteContent, "utf-8"); await runLiveSyncCli(command, [databasePath, "--settings", settingsPath, "push", sourcePath, notePath]); const info = await runLiveSyncCli(command, [databasePath, "--settings", settingsPath, "info", notePath]); const fileInfo = JSON.parse(info.stdout) as CliFileInfo; if (!fileInfo.id || !Array.isArray(fileInfo.children) || fileInfo.children.length === 0) { throw new Error(`LiveSync CLI did not create complete metadata for ${notePath}: ${info.stdout}`); } await runLiveSyncCli(command, [databasePath, "--settings", settingsPath, "sync"]); return fileInfo; } async function waitForVaultContent( vaultPath: string, path: string, timeoutMs = Number(process.env.E2E_OBSIDIAN_FILE_TIMEOUT_MS) ): Promise { const fullPath = join(vaultPath, path); const deadline = Date.now() + timeoutMs; let lastContent = ""; while (Date.now() < deadline) { try { lastContent = await readFile(fullPath, "utf-8"); if (lastContent === noteContent) { return lastContent; } } catch (error) { if ((error as NodeJS.ErrnoException).code !== "ENOENT") { throw error; } } await new Promise((resolvePromise) => setTimeout(resolvePromise, 250)); } throw new Error(`Timed out waiting for CLI-created note at ${fullPath}. Last content:\n${lastContent}`); } async function main(): Promise { const liveSyncCliCommand = resolveLiveSyncCliCommand(); if (process.env.LIVESYNC_CLI_COMMAND === undefined) { await access(liveSyncCli).catch(() => { throw new Error( `Built LiveSync CLI was not found at ${liveSyncCli}. Run 'npm run build -w self-hosted-livesync-cli' first, or set LIVESYNC_CLI_COMMAND.` ); }); } const binary = requireObsidianBinary(); const obsidianCli = discoverObsidianCli(); if (!obsidianCli.binary) { throw new Error(`Could not find obsidian-cli. Checked paths: ${obsidianCli.checked.join(", ")}`); } const couchDb = await loadCouchDbConfig(); const dbName = makeUniqueDatabaseName(couchDb.dbPrefix, "cli-to-obsidian"); const cliState = await mkdtemp(join(tmpdir(), "livesync-cli-to-obsidian-e2e-")); const cliDatabasePath = join(cliState, "database"); const cliSettingsPath = join(cliState, "settings.json"); const cliSourcePath = join(cliState, "source", "cli-to-obsidian.md"); const vault = await createTemporaryVault(); let session: ObsidianLiveSyncSession | undefined; try { await assertCouchDbReachable(couchDb); await createCouchDbDatabase(couchDb, dbName); await mkdir(cliDatabasePath, { recursive: true }); await configureLiveSyncCli(liveSyncCliCommand, cliSettingsPath, couchDb, dbName); if (process.env.LIVESYNC_CLI_COMMAND === undefined) { console.log(`Using locally built LiveSync CLI: ${liveSyncCli}`); } else { console.log( `Using LiveSync CLI command override: ${JSON.stringify(liveSyncCliCommand.executable)} ` + `with ${liveSyncCliCommand.prefixArgs.length} prefix argument(s)` ); } console.log(`Using Obsidian executable: ${binary}`); console.log(`Temporary Obsidian vault: ${vault.path}`); console.log(`Temporary CouchDB database: ${dbName}`); const cliFileInfo = await writeCliNote(liveSyncCliCommand, cliDatabasePath, cliSettingsPath, cliSourcePath); await waitForCouchDbDocs(couchDb, dbName, (docs) => { const ids = new Set(docs.map((doc) => doc._id)); return ids.has(cliFileInfo.id) && cliFileInfo.children.every((childId) => ids.has(childId)); }); session = await startObsidianLiveSyncSession({ binary, cliBinary: obsidianCli.binary, vault, startupGraceMs: Number(process.env.E2E_OBSIDIAN_STARTUP_GRACE_MS ?? 1000), }); await waitForLiveSyncCoreReady(obsidianCli.binary, session.cliEnv); await configureCouchDb( obsidianCli.binary, session.cliEnv, { uri: couchDb.uri, username: couchDb.username, password: couchDb.password, dbName, }, { encrypt: true, passphrase: e2eePassphrase, usePathObfuscation: true, E2EEAlgorithm: "v2", } ); await waitForLiveSyncCoreReady(obsidianCli.binary, session.cliEnv); await prepareRemote(obsidianCli.binary, session.cliEnv); await pushLocalChanges(obsidianCli.binary, session.cliEnv); const received = await waitForVaultContent(vault.path, notePath); assertEqual(received, noteContent, "The real Obsidian plug-in did not materialise the CLI-created note."); console.log("CLI-created encrypted note was retrieved by the real Obsidian plug-in with identical content."); } finally { if (session) { await session.app.stop(); } await vault.dispose(); await rm(cliState, { recursive: true, force: true }); if (process.env.E2E_OBSIDIAN_KEEP_COUCHDB !== "true") { await deleteCouchDbDatabase(couchDb, dbName).catch((error: unknown) => { console.warn(error instanceof Error ? error.message : error); }); } } } main().catch((error: unknown) => { console.error(error instanceof Error ? error.stack : error); process.exit(1); });