diff --git a/src/apps/cli/README.md b/src/apps/cli/README.md index 890eb7f..8569075 100644 --- a/src/apps/cli/README.md +++ b/src/apps/cli/README.md @@ -1,10 +1,10 @@ # Self-hosted LiveSync CLI -Command-line version of Obsidian LiveSync plugin for syncing vaults without Obsidian. +Command-line version of Self-hosted LiveSync plugin for syncing vaults without Obsidian. ## Features - ✅ Sync Obsidian vaults using CouchDB without running Obsidian -- ✅ Compatible with Obsidian LiveSync plugin settings +- ✅ Compatible with Self-hosted LiveSync plugin settings - ✅ Supports all core sync features (encryption, conflict resolution, etc.) - ✅ Lightweight and headless operation - ✅ Cross-platform (Windows, macOS, Linux) @@ -59,16 +59,43 @@ As you know, the CLI is designed to be used in a headless environment. Hence all ```bash # Sync local database with CouchDB (no files will be changed). -node dist/cli/index.js /path/to/your-local-database --settings /path/to/settings.json sync +node dist/index.cjs /path/to/your-local-database --settings /path/to/settings.json sync # Push files to local database -node dist/cli/index.js /path/to/your-local-database --settings /path/to/settings.json push /your/storage/file.md /vault/path/file.md +node dist/index.cjs /path/to/your-local-database --settings /path/to/settings.json push /your/storage/file.md /vault/path/file.md # Pull files from local database -node dist/cli/index.js /path/to/your-local-database --settings /path/to/settings.json pull /vault/path/file.md /your/storage/file.md +node dist/index.cjs /path/to/your-local-database --settings /path/to/settings.json pull /vault/path/file.md /your/storage/file.md # Verbose logging -node dist/cli/index.js /path/to/your-local-database --settings /path/to/settings.json --verbose +node dist/index.cjs /path/to/your-local-database --settings /path/to/settings.json --verbose + +# Apply setup URI to settings file (settings only; does not run synchronisation) +node dist/index.cjs /path/to/your-local-database --settings /path/to/settings.json setup "obsidian://setuplivesync?settings=..." + +# Put text from stdin into local database +echo "Hello from stdin" | node dist/index.cjs /path/to/your-local-database --settings /path/to/settings.json put /vault/path/file.md + +# Output a file from local database to stdout +node dist/index.cjs /path/to/your-local-database --settings /path/to/settings.json cat /vault/path/file.md + +# Output a specific revision of a file from local database +node dist/index.cjs /path/to/your-local-database --settings /path/to/settings.json cat-rev /vault/path/file.md 3-abcdef + +# Pull a specific revision of a file from local database to local storage +node dist/index.cjs /path/to/your-local-database --settings /path/to/settings.json pull-rev /vault/path/file.md /your/storage/file.old.md 3-abcdef + +# List files in local database +node dist/index.cjs /path/to/your-local-database --settings /path/to/settings.json ls /vault/path/ + +# Show metadata for a file in local database +node dist/index.cjs /path/to/your-local-database --settings /path/to/settings.json info /vault/path/file.md + +# Mark a file as deleted in local database +node dist/index.cjs /path/to/your-local-database --settings /path/to/settings.json rm /vault/path/file.md + +# Resolve conflict by keeping a specific revision +node dist/index.cjs /path/to/your-local-database --settings /path/to/settings.json resolve /vault/path/file.md 3-abcdef ``` ### Configuration @@ -99,43 +126,116 @@ The CLI uses the same settings format as the Obsidian plugin. Create a `.livesyn - `couchDB_DBNAME`: Database name - `isConfigured`: Set to `true` after configuration -### Command-line Options +### Command-line Reference ``` Usage: - livesync-cli [database-path] [options] + livesync-cli [database-path] [options] [command] [command-args] Arguments: database-path Path to the local database directory (required) Options: --settings, -s Path to settings file (default: .livesync/settings.json in local database directory) + --force, -f Overwrite existing file on init-settings --verbose, -v Enable verbose logging --help, -h Show this help message - sync Sync local database with CouchDB or Bucket - push Push file to local database - pull Pull file from local database + +Commands: + init-settings [path] Create settings JSON from DEFAULT_SETTINGS + 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 specific revision into local file + setup Apply setup URI to settings file + put Read text from standard input and write to local database + cat Write latest file content from local database to standard output + cat-rev Write specific revision content from local database to standard output + ls List files as pathsizemtimerevision[*] + info Show file metadata including current and past revisions, conflicts, and chunk list + rm Mark file as deleted in local database + resolve Resolve conflict by keeping the specified revision ``` +`info` output fields: + +- `ID`: Document ID +- `Revision`: Current revision +- `Conflicts`: Conflicted revisions, or `N/A` +- `Filename`: Basename of path +- `Path`: Vault-relative path +- `Size`: Size in bytes +- `PastRevisions`: Available non-current revisions +- `Chunks`: Number of chunk IDs +- `child: ...`: Chunk ID list + ### Planned options: -- `put `: Add/update file in local database from standard input -- `cat `: Output file content to standard output -- `info `: Show file metadata, conflicts, and, other information -- `ls `: List files in local database with optional prefix filter -- `resolve `: Resolve conflict for a file by choosing a specific revision -- `rm `: Remove file from local database. +TODO: Conflict and resolution checks for real local databases. + - `--immediate`: Perform sync after the command (e.g. `push`, `pull`, `put`, `rm`). - `serve`: Start CLI in server mode, exposing REST APIs for remote, and batch operations. - +- `cause-conflicted `: Mark a file as conflicted without changing its content, to trigger conflict resolution in Obsidian. ## Use Cases +### 1. Bootstrap a new headless vault + +Create default settings, apply a setup URI, then run one sync cycle. + +```bash +node dist/index.cjs init-settings /data/livesync-settings.json +printf '%s\n' "$SETUP_PASSPHRASE" | node dist/index.cjs /data/vault --settings /data/livesync-settings.json setup "$SETUP_URI" +node dist/index.cjs /data/vault --settings /data/livesync-settings.json sync +``` + +### 2. Scripted import and export + +Push local files into the database from automation, and pull them back for export or backup. + +```bash +node dist/index.cjs /data/vault --settings /data/livesync-settings.json push ./note.md notes/note.md +node dist/index.cjs /data/vault --settings /data/livesync-settings.json pull notes/note.md ./exports/note.md +``` + +### 3. Revision inspection and restore + +List metadata, find an older revision, then restore it by content (`cat-rev`) or file output (`pull-rev`). + +```bash +node dist/index.cjs /data/vault --settings /data/livesync-settings.json info notes/note.md +node dist/index.cjs /data/vault --settings /data/livesync-settings.json cat-rev notes/note.md 3-abcdef +node dist/index.cjs /data/vault --settings /data/livesync-settings.json pull-rev notes/note.md ./restore/note.old.md 3-abcdef +``` + +### 4. Conflict and cleanup workflow + +Inspect conflicted revisions, resolve by keeping one revision, then delete obsolete files. + +```bash +node dist/index.cjs /data/vault --settings /data/livesync-settings.json info notes/note.md +node dist/index.cjs /data/vault --settings /data/livesync-settings.json resolve notes/note.md 3-abcdef +node dist/index.cjs /data/vault --settings /data/livesync-settings.json rm notes/obsolete.md +``` + +### 5. CI smoke test for content round-trip + +Validate that `put`/`cat` is behaving as expected in a pipeline. + +```bash +echo "hello-ci" | node dist/index.cjs /data/vault --settings /data/livesync-settings.json put ci/test.md +node dist/index.cjs /data/vault --settings /data/livesync-settings.json cat ci/test.md +``` + ## Development ### Project Structure ``` src/apps/cli/ +├── commands/ # Command dispatcher and command utilities +│ ├── runCommand.ts +│ ├── types.ts +│ └── utils.ts ├── adapters/ # Node.js FileSystem Adapter │ ├── NodeFileSystemAdapter.ts │ ├── NodePathAdapter.ts diff --git a/src/apps/cli/commands/runCommand.ts b/src/apps/cli/commands/runCommand.ts new file mode 100644 index 0000000..637f9a5 --- /dev/null +++ b/src/apps/cli/commands/runCommand.ts @@ -0,0 +1,315 @@ +import * as fs from "fs/promises"; +import * as path from "path"; +import { decodeSettingsFromSetupURI } from "@lib/API/processSetting"; +import { configURIBase } from "@lib/common/models/shared.const"; +import { DEFAULT_SETTINGS, type FilePathWithPrefix, type ObsidianLiveSyncSettings } from "@lib/common/types"; +import { stripAllPrefixes } from "@lib/string_and_binary/path"; +import type { CLICommandContext, CLIOptions } from "./types"; +import { promptForPassphrase, readStdinAsUtf8, toArrayBuffer, toVaultRelativePath } from "./utils"; + +export async function runCommand(options: CLIOptions, context: CLICommandContext): Promise { + const { vaultPath, core, settingsPath } = context; + + await core.services.control.activated; + if (options.command === "daemon") { + return true; + } + + if (options.command === "sync") { + console.log("[Command] sync"); + const result = await core.services.replication.replicate(true); + return !!result; + } + + if (options.command === "push") { + if (options.commandArgs.length < 2) { + throw new Error("push requires two arguments: "); + } + const sourcePath = path.resolve(options.commandArgs[0]); + const destinationVaultPath = toVaultRelativePath(options.commandArgs[1], vaultPath); + const sourceData = await fs.readFile(sourcePath); + const sourceStat = await fs.stat(sourcePath); + console.log(`[Command] push ${sourcePath} -> ${destinationVaultPath}`); + + await core.serviceModules.storageAccess.writeFileAuto(destinationVaultPath, toArrayBuffer(sourceData), { + mtime: sourceStat.mtimeMs, + ctime: sourceStat.ctimeMs, + }); + const destinationPathWithPrefix = destinationVaultPath as FilePathWithPrefix; + const stored = await core.serviceModules.fileHandler.storeFileToDB(destinationPathWithPrefix, true); + return stored; + } + + if (options.command === "pull") { + if (options.commandArgs.length < 2) { + throw new Error("pull requires two arguments: "); + } + const sourceVaultPath = toVaultRelativePath(options.commandArgs[0], vaultPath); + const destinationPath = path.resolve(options.commandArgs[1]); + console.log(`[Command] pull ${sourceVaultPath} -> ${destinationPath}`); + + const sourcePathWithPrefix = sourceVaultPath as FilePathWithPrefix; + const restored = await core.serviceModules.fileHandler.dbToStorage(sourcePathWithPrefix, null, true); + if (!restored) { + return false; + } + const data = await core.serviceModules.storageAccess.readFileAuto(sourceVaultPath); + await fs.mkdir(path.dirname(destinationPath), { recursive: true }); + if (typeof data === "string") { + await fs.writeFile(destinationPath, data, "utf-8"); + } else { + await fs.writeFile(destinationPath, new Uint8Array(data)); + } + return true; + } + + if (options.command === "pull-rev") { + if (options.commandArgs.length < 3) { + throw new Error("pull-rev requires three arguments: "); + } + const sourceVaultPath = toVaultRelativePath(options.commandArgs[0], vaultPath); + const destinationPath = path.resolve(options.commandArgs[1]); + const rev = options.commandArgs[2].trim(); + if (!rev) { + throw new Error("pull-rev requires a non-empty revision"); + } + console.log(`[Command] pull-rev ${sourceVaultPath}@${rev} -> ${destinationPath}`); + + const source = await core.serviceModules.databaseFileAccess.fetch( + sourceVaultPath as FilePathWithPrefix, + rev, + true + ); + if (!source || source.deleted) { + return false; + } + + await fs.mkdir(path.dirname(destinationPath), { recursive: true }); + const body = source.body; + if (body.type === "text/plain") { + await fs.writeFile(destinationPath, await body.text(), "utf-8"); + } else { + await fs.writeFile(destinationPath, new Uint8Array(await body.arrayBuffer())); + } + return true; + } + + if (options.command === "setup") { + if (options.commandArgs.length < 1) { + throw new Error("setup requires one argument: "); + } + const setupURI = options.commandArgs[0].trim(); + if (!setupURI.startsWith(configURIBase)) { + throw new Error(`setup URI must start with ${configURIBase}`); + } + const passphrase = await promptForPassphrase(); + const decoded = await decodeSettingsFromSetupURI(setupURI, passphrase); + if (!decoded) { + throw new Error("Failed to decode settings from setup URI"); + } + const nextSettings = { + ...DEFAULT_SETTINGS, + ...decoded, + useIndexedDBAdapter: false, + isConfigured: true, + } as ObsidianLiveSyncSettings; + + console.log(`[Command] setup -> ${settingsPath}`); + await core.services.setting.applyPartial(nextSettings, true); + await core.services.control.applySettings(); + return true; + } + + if (options.command === "put") { + if (options.commandArgs.length < 1) { + throw new Error("put requires one argument: "); + } + const destinationVaultPath = toVaultRelativePath(options.commandArgs[0], vaultPath); + const content = await readStdinAsUtf8(); + console.log(`[Command] put stdin -> ${destinationVaultPath}`); + return await core.serviceModules.databaseFileAccess.storeContent( + destinationVaultPath as FilePathWithPrefix, + content + ); + } + + if (options.command === "cat") { + if (options.commandArgs.length < 1) { + throw new Error("cat requires one argument: "); + } + const sourceVaultPath = toVaultRelativePath(options.commandArgs[0], vaultPath); + console.error(`[Command] cat ${sourceVaultPath}`); + const source = await core.serviceModules.databaseFileAccess.fetch( + sourceVaultPath as FilePathWithPrefix, + undefined, + true + ); + if (!source || source.deleted) { + return false; + } + const body = source.body; + if (body.type === "text/plain") { + process.stdout.write(await body.text()); + } else { + process.stdout.write(Buffer.from(await body.arrayBuffer())); + } + return true; + } + + if (options.command === "cat-rev") { + if (options.commandArgs.length < 2) { + throw new Error("cat-rev requires two arguments: "); + } + const sourceVaultPath = toVaultRelativePath(options.commandArgs[0], vaultPath); + const rev = options.commandArgs[1].trim(); + if (!rev) { + throw new Error("cat-rev requires a non-empty revision"); + } + console.error(`[Command] cat-rev ${sourceVaultPath} @ ${rev}`); + const source = await core.serviceModules.databaseFileAccess.fetch( + sourceVaultPath as FilePathWithPrefix, + rev, + true + ); + if (!source || source.deleted) { + return false; + } + const body = source.body; + if (body.type === "text/plain") { + process.stdout.write(await body.text()); + } else { + process.stdout.write(Buffer.from(await body.arrayBuffer())); + } + return true; + } + + if (options.command === "ls") { + const prefix = + options.commandArgs.length > 0 && options.commandArgs[0].trim() !== "" + ? toVaultRelativePath(options.commandArgs[0], vaultPath) + : ""; + const rows: { path: string; line: string }[] = []; + + for await (const doc of core.services.database.localDatabase.findAllNormalDocs({ conflicts: true })) { + if (doc._deleted || doc.deleted) { + continue; + } + const docPath = stripAllPrefixes(doc.path); + if (prefix !== "" && !docPath.startsWith(prefix)) { + continue; + } + const revision = `${doc._rev ?? ""}${(doc._conflicts?.length ?? 0) > 0 ? "*" : ""}`; + rows.push({ + path: docPath, + line: `${docPath}\t${doc.size}\t${doc.mtime}\t${revision}`, + }); + } + + rows.sort((a, b) => a.path.localeCompare(b.path)); + if (rows.length > 0) { + process.stdout.write(rows.map((e) => e.line).join("\n") + "\n"); + } + return true; + } + + if (options.command === "info") { + if (options.commandArgs.length < 1) { + throw new Error("info requires one argument: "); + } + const targetPath = toVaultRelativePath(options.commandArgs[0], vaultPath); + + for await (const doc of core.services.database.localDatabase.findAllNormalDocs({ conflicts: true })) { + if (doc._deleted || doc.deleted) continue; + const docPath = stripAllPrefixes(doc.path); + if (docPath !== targetPath) continue; + + const filename = path.basename(docPath); + const conflictsText = (doc._conflicts?.length ?? 0) > 0 ? doc._conflicts.join("\n ") : "N/A"; + const children = "children" in doc ? doc.children : []; + const rawDoc = await core.services.database.localDatabase.getRaw(doc._id, { + revs_info: true, + }); + const pastRevisions = (rawDoc._revs_info ?? []) + .filter((entry: { rev?: string; status?: string }) => { + if (!entry.rev) return false; + if (entry.rev === doc._rev) return false; + return entry.status === "available"; + }) + .map((entry: { rev: string }) => entry.rev); + const pastRevisionsText = + pastRevisions.length > 0 ? pastRevisions.map((rev: string) => ` rev: ${rev}`) : [" N/A"]; + + const out = + [ + `ID: ${doc._id}`, + `Revision: ${doc._rev ?? ""}`, + `Conflicts: ${conflictsText}`, + `Filename: ${filename}`, + `Path: ${docPath}`, + `Size: ${doc.size}`, + `PastRevisions:`, + ...pastRevisionsText, + `Chunks: ${children.length}`, + ...children.map((id) => ` child: ${id}`), + ].join("\n") + "\n"; + process.stdout.write(out); + return true; + } + + process.stderr.write(`[Info] File not found: ${targetPath}\n`); + return false; + } + + if (options.command === "rm") { + if (options.commandArgs.length < 1) { + throw new Error("rm requires one argument: "); + } + const targetPath = toVaultRelativePath(options.commandArgs[0], vaultPath); + console.error(`[Command] rm ${targetPath}`); + return await core.serviceModules.databaseFileAccess.delete(targetPath as FilePathWithPrefix); + } + + if (options.command === "resolve") { + if (options.commandArgs.length < 2) { + throw new Error("resolve requires two arguments: "); + } + const targetPath = toVaultRelativePath(options.commandArgs[0], vaultPath) as FilePathWithPrefix; + const revisionToKeep = options.commandArgs[1].trim(); + if (revisionToKeep === "") { + throw new Error("resolve requires a non-empty revision-to-keep"); + } + + 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`); + 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`); + return false; + } + + if (conflicts.length === 0 && currentMeta._rev === revisionToKeep) { + console.error(`[Command] resolve ${targetPath} keep ${revisionToKeep} (already resolved)`); + return true; + } + + console.error(`[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`); + return false; + } + } + return true; + } + + throw new Error(`Unsupported command: ${options.command}`); +} diff --git a/src/apps/cli/commands/types.ts b/src/apps/cli/commands/types.ts new file mode 100644 index 0000000..9182fd7 --- /dev/null +++ b/src/apps/cli/commands/types.ts @@ -0,0 +1,49 @@ +import { LiveSyncBaseCore } from "../../../LiveSyncBaseCore"; +import { ServiceContext } from "@lib/services/base/ServiceBase"; + +export type CLICommand = + | "daemon" + | "sync" + | "push" + | "pull" + | "pull-rev" + | "setup" + | "put" + | "cat" + | "cat-rev" + | "ls" + | "info" + | "rm" + | "resolve" + | "init-settings"; + +export interface CLIOptions { + databasePath?: string; + settingsPath?: string; + verbose?: boolean; + force?: boolean; + command: CLICommand; + commandArgs: string[]; +} + +export interface CLICommandContext { + vaultPath: string; + core: LiveSyncBaseCore; + settingsPath: string; +} + +export const VALID_COMMANDS = new Set([ + "sync", + "push", + "pull", + "pull-rev", + "setup", + "put", + "cat", + "cat-rev", + "ls", + "info", + "rm", + "resolve", + "init-settings", +] as const); diff --git a/src/apps/cli/commands/utils.ts b/src/apps/cli/commands/utils.ts new file mode 100644 index 0000000..d085822 --- /dev/null +++ b/src/apps/cli/commands/utils.ts @@ -0,0 +1,44 @@ +import * as path from "path"; +import * as readline from "node:readline/promises"; + +export function toArrayBuffer(data: Buffer): ArrayBuffer { + return data.buffer.slice(data.byteOffset, data.byteOffset + data.byteLength) as ArrayBuffer; +} + +export function toVaultRelativePath(inputPath: string, vaultPath: string): string { + const stripped = inputPath.replace(/^[/\\]+/, ""); + if (!path.isAbsolute(inputPath)) { + return stripped.replace(/\\/g, "/"); + } + const resolved = path.resolve(inputPath); + const rel = path.relative(vaultPath, resolved); + if (rel.startsWith("..") || path.isAbsolute(rel)) { + throw new Error(`Path ${inputPath} is outside of the local database directory`); + } + return rel.replace(/\\/g, "/"); +} + +export async function readStdinAsUtf8(): Promise { + const chunks: Buffer[] = []; + for await (const chunk of process.stdin) { + if (typeof chunk === "string") { + chunks.push(Buffer.from(chunk, "utf-8")); + } else { + chunks.push(chunk); + } + } + return Buffer.concat(chunks).toString("utf-8"); +} + +export async function promptForPassphrase(prompt = "Enter setup URI passphrase: "): Promise { + 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(); + } +} diff --git a/src/apps/cli/main.ts b/src/apps/cli/main.ts index 649f245..64b3578 100644 --- a/src/apps/cli/main.ts +++ b/src/apps/cli/main.ts @@ -1,7 +1,7 @@ #!/usr/bin/env node /** * Self-hosted LiveSync CLI - * Command-line version of Obsidian LiveSync plugin for syncing vaults without Obsidian + * Command-line version of Self-hosted LiveSync plugin for syncing vaults without Obsidian */ if (!("localStorage" in globalThis)) { @@ -24,24 +24,16 @@ import * as fs from "fs/promises"; import * as path from "path"; import { NodeServiceContext, NodeServiceHub } from "./services/NodeServiceHub"; import { LiveSyncBaseCore } from "../../LiveSyncBaseCore"; -import { ServiceContext } from "@lib/services/base/ServiceBase"; import { initialiseServiceModulesCLI } from "./serviceModules/CLIServiceModules"; -import { - DEFAULT_SETTINGS, - LOG_LEVEL_VERBOSE, - type LOG_LEVEL, - type ObsidianLiveSyncSettings, - type FilePathWithPrefix, -} from "@lib/common/types"; +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 } from "octagonal-wheels/common/logger"; -import PouchDb from "pouchdb-core"; +import { runCommand } from "./commands/runCommand"; +import { VALID_COMMANDS } from "./commands/types"; +import type { CLICommand, CLIOptions } from "./commands/types"; const SETTINGS_FILE = ".livesync/settings.json"; -const VALID_COMMANDS = new Set(["sync", "push", "pull", "init-settings"] as const); - -type CLICommand = "daemon" | "sync" | "push" | "pull" | "init-settings"; defaultLoggerEnv.minLogLevel = LOG_LEVEL_DEBUG; // DI the log again. // const recentLogEntries = reactiveSource([]); @@ -55,20 +47,11 @@ defaultLoggerEnv.minLogLevel = LOG_LEVEL_DEBUG; // }; setGlobalLogFunction((msg, level) => { - console.log(`[${level}] ${typeof msg === "string" ? msg : JSON.stringify(msg)}`); + console.error(`[${level}] ${typeof msg === "string" ? msg : JSON.stringify(msg)}`); if (msg instanceof Error) { console.error(msg); } }); -interface CLIOptions { - databasePath?: string; - settingsPath?: string; - verbose?: boolean; - force?: boolean; - command: CLICommand; - commandArgs: string[]; -} - function printHelp(): void { console.log(` Self-hosted LiveSync CLI @@ -83,18 +66,28 @@ 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 - init-settings [path] Create settings JSON from DEFAULT_SETTINGS - -Options: - --settings, -s Path to settings file (default: .livesync/settings.json in local database directory) - --force, -f Overwrite existing file on init-settings - --verbose, -v Enable verbose logging - --help, -h Show this help message - + 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 `); @@ -202,86 +195,16 @@ async function createDefaultSettingsFile(options: CLIOptions) { console.log(`[Done] Created settings file: ${targetPath}`); } -function toArrayBuffer(data: Buffer): ArrayBuffer { - return data.buffer.slice(data.byteOffset, data.byteOffset + data.byteLength) as ArrayBuffer; -} - -function toVaultRelativePath(inputPath: string, vaultPath: string): string { - const stripped = inputPath.replace(/^[/\\]+/, ""); - if (!path.isAbsolute(inputPath)) { - return stripped.replace(/\\/g, "/"); - } - const resolved = path.resolve(inputPath); - const rel = path.relative(vaultPath, resolved); - if (rel.startsWith("..") || path.isAbsolute(rel)) { - throw new Error(`Path ${inputPath} is outside of the local database directory`); - } - return rel.replace(/\\/g, "/"); -} - -async function runCommand( - options: CLIOptions, - vaultPath: string, - core: LiveSyncBaseCore -): Promise { - await core.services.control.activated; - if (options.command === "daemon") { - return true; - } - - if (options.command === "sync") { - console.log("[Command] sync"); - const result = await core.services.replication.replicate(true); - return !!result; - } - - if (options.command === "push") { - if (options.commandArgs.length < 2) { - throw new Error("push requires two arguments: "); - } - const sourcePath = path.resolve(options.commandArgs[0]); - const destinationVaultPath = toVaultRelativePath(options.commandArgs[1], vaultPath); - const sourceData = await fs.readFile(sourcePath); - const sourceStat = await fs.stat(sourcePath); - console.log(`[Command] push ${sourcePath} -> ${destinationVaultPath}`); - - await core.serviceModules.storageAccess.writeFileAuto(destinationVaultPath, toArrayBuffer(sourceData), { - mtime: sourceStat.mtimeMs, - ctime: sourceStat.ctimeMs, - }); - const destinationPathWithPrefix = destinationVaultPath as FilePathWithPrefix; - const stored = await core.serviceModules.fileHandler.storeFileToDB(destinationPathWithPrefix, true); - return stored; - } - - if (options.command === "pull") { - if (options.commandArgs.length < 2) { - throw new Error("pull requires two arguments: "); - } - const sourceVaultPath = toVaultRelativePath(options.commandArgs[0], vaultPath); - const destinationPath = path.resolve(options.commandArgs[1]); - console.log(`[Command] pull ${sourceVaultPath} -> ${destinationPath}`); - - const sourcePathWithPrefix = sourceVaultPath as FilePathWithPrefix; - const restored = await core.serviceModules.fileHandler.dbToStorage(sourcePathWithPrefix, null, true); - if (!restored) { - return false; - } - const data = await core.serviceModules.storageAccess.readFileAuto(sourceVaultPath); - await fs.mkdir(path.dirname(destinationPath), { recursive: true }); - if (typeof data === "string") { - await fs.writeFile(destinationPath, data, "utf-8"); - } else { - await fs.writeFile(destinationPath, new Uint8Array(data)); - } - return true; - } - - throw new Error(`Unsupported command: ${options.command}`); -} - 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.command === "init-settings") { await createDefaultSettingsFile(options); @@ -307,10 +230,10 @@ async function main() { ? path.resolve(options.settingsPath) : path.join(vaultPath, SETTINGS_FILE); - console.log(`Self-hosted LiveSync CLI`); - console.log(`Vault: ${vaultPath}`); - console.log(`Settings: ${settingsPath}`); - console.log(); + infoLog(`Self-hosted LiveSync CLI`); + infoLog(`Vault: ${vaultPath}`); + infoLog(`Settings: ${settingsPath}`); + infoLog(""); // Create service context and hub const context = new NodeServiceContext(vaultPath); @@ -320,11 +243,11 @@ async function main() { if (level <= LOG_LEVEL_VERBOSE) { if (!options.verbose) return; } - console.log(`${prefix} ${message}`); + console.error(`${prefix} ${message}`); }); // Prevent replication result to be processed automatically. serviceHubInstance.replication.processSynchroniseResult.addHandler(async () => { - console.log(`[Info] Replication result received, but not processed automatically in CLI mode.`); + console.error(`[Info] Replication result received, but not processed automatically in CLI mode.`); return await Promise.resolve(true); }, -100); // Setup settings handlers @@ -335,7 +258,7 @@ async function main() { try { await fs.writeFile(settingsPath, JSON.stringify(data, null, 2), "utf-8"); if (options.verbose) { - console.log(`[Settings] Saved to ${settingsPath}`); + console.error(`[Settings] Saved to ${settingsPath}`); } } catch (error) { console.error(`[Settings] Failed to save:`, error); @@ -349,14 +272,14 @@ async function main() { const content = await fs.readFile(settingsPath, "utf-8"); const data = JSON.parse(content); if (options.verbose) { - console.log(`[Settings] Loaded from ${settingsPath}`); + console.error(`[Settings] Loaded from ${settingsPath}`); } // Force disable IndexedDB adapter in CLI environment data.useIndexedDBAdapter = false; return data; } catch (error) { if (options.verbose) { - console.log(`[Settings] File not found, using defaults`); + console.error(`[Settings] File not found, using defaults`); } return undefined; } @@ -393,7 +316,7 @@ async function main() { // Start the core try { - console.log(`[Starting] Initializing LiveSync...`); + infoLog(`[Starting] Initializing LiveSync...`); const loadResult = await core.services.control.onLoad(); if (!loadResult) { @@ -403,9 +326,9 @@ async function main() { await core.services.control.onReady(); - console.log(`[Ready] LiveSync is running`); - console.log(`[Ready] Press Ctrl+C to stop`); - console.log(); + infoLog(`[Ready] LiveSync is running`); + infoLog(`[Ready] Press Ctrl+C to stop`); + infoLog(""); // Check if configured const settings = core.services.setting.currentSettings(); @@ -420,17 +343,17 @@ async function main() { console.warn(` - couchDB_DBNAME: Database name`); console.warn(); } else { - console.log(`[Info] LiveSync is configured and ready`); - console.log(`[Info] Database: ${settings.couchDB_URI}/${settings.couchDB_DBNAME}`); - console.log(); + infoLog(`[Info] LiveSync is configured and ready`); + infoLog(`[Info] Database: ${settings.couchDB_URI}/${settings.couchDB_DBNAME}`); + infoLog(""); } - const result = await runCommand(options, vaultPath, core); + 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") { - console.log(`[Done] Command '${options.command}' completed`); + infoLog(`[Done] Command '${options.command}' completed`); } if (options.command === "daemon") { diff --git a/src/apps/cli/managers/CLIStorageEventManagerAdapter.ts b/src/apps/cli/managers/CLIStorageEventManagerAdapter.ts index 5cb8509..61d214b 100644 --- a/src/apps/cli/managers/CLIStorageEventManagerAdapter.ts +++ b/src/apps/cli/managers/CLIStorageEventManagerAdapter.ts @@ -108,7 +108,7 @@ class CLIWatchAdapter implements IStorageEventWatchAdapter { async beginWatch(handlers: IStorageEventWatchHandlers): Promise { // File watching is not activated in the CLI. // Because the CLI is designed for push/pull operations, not real-time sync. - console.log("[CLIWatchAdapter] File watching is not enabled in CLI version"); + console.error("[CLIWatchAdapter] File watching is not enabled in CLI version"); return Promise.resolve(); } } diff --git a/src/apps/cli/test/test-setup-put-cat-linux.sh b/src/apps/cli/test/test-setup-put-cat-linux.sh new file mode 100755 index 0000000..eff5a84 --- /dev/null +++ b/src/apps/cli/test/test-setup-put-cat-linux.sh @@ -0,0 +1,339 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" +CLI_DIR="$(cd -- "$SCRIPT_DIR/.." && pwd)" +REPO_ROOT="$(cd -- "$CLI_DIR/../../.." && pwd)" +cd "$CLI_DIR" + +CLI_ENTRY="${CLI_ENTRY:-$CLI_DIR/dist/index.cjs}" +RUN_BUILD="${RUN_BUILD:-1}" +REMOTE_PATH="${REMOTE_PATH:-test/setup-put-cat.txt}" +SETUP_PASSPHRASE="${SETUP_PASSPHRASE:-setup-passphrase}" + +WORK_DIR="$(mktemp -d "${TMPDIR:-/tmp}/livesync-cli-test.XXXXXX")" +trap 'rm -rf "$WORK_DIR"' EXIT + +SETTINGS_FILE="${1:-$WORK_DIR/data.json}" + +if [[ "$RUN_BUILD" == "1" ]]; then + echo "[INFO] building CLI..." + npm run build +fi + +if [[ ! -f "$CLI_ENTRY" ]]; then + echo "[ERROR] CLI entry not found: $CLI_ENTRY" >&2 + exit 1 +fi + +echo "[INFO] generating settings from DEFAULT_SETTINGS -> $SETTINGS_FILE" +node "$CLI_ENTRY" init-settings --force "$SETTINGS_FILE" + +echo "[INFO] creating setup URI from settings" +SETUP_URI="$( + REPO_ROOT="$REPO_ROOT" SETTINGS_FILE="$SETTINGS_FILE" SETUP_PASSPHRASE="$SETUP_PASSPHRASE" npx tsx -e ' +import fs from "node:fs"; +(async () => { + const { encodeSettingsToSetupURI } = await import(process.env.REPO_ROOT + "/src/lib/src/API/processSetting.ts"); + const settingsPath = process.env.SETTINGS_FILE; + const setupPassphrase = process.env.SETUP_PASSPHRASE; + const settings = JSON.parse(fs.readFileSync(settingsPath, "utf-8")); + settings.couchDB_DBNAME = "setup-put-cat-db"; + settings.couchDB_URI = "http://127.0.0.1:5999"; + settings.couchDB_USER = "dummy"; + settings.couchDB_PASSWORD = "dummy"; + settings.liveSync = false; + settings.syncOnStart = false; + settings.syncOnSave = false; + const uri = await encodeSettingsToSetupURI(settings, setupPassphrase); + process.stdout.write(uri.trim()); +})(); +' +)" + +VAULT_DIR="$WORK_DIR/vault" +mkdir -p "$VAULT_DIR/test" + +echo "[INFO] applying setup URI" +SETUP_LOG="$WORK_DIR/setup-output.log" +set +e +printf '%s\n' "$SETUP_PASSPHRASE" | node "$CLI_ENTRY" "$VAULT_DIR" --settings "$SETTINGS_FILE" setup "$SETUP_URI" \ + >"$SETUP_LOG" 2>&1 +SETUP_EXIT=$? +set -e +cat "$SETUP_LOG" +if [[ "$SETUP_EXIT" -ne 0 ]]; then + echo "[FAIL] setup command exited with $SETUP_EXIT" >&2 + exit 1 +fi + +if grep -Fq "[Command] setup ->" "$SETUP_LOG"; then + echo "[PASS] setup command executed" +else + echo "[FAIL] setup command did not execute expected code path" >&2 + exit 1 +fi + +SRC_FILE="$WORK_DIR/put-source.txt" +printf 'setup-put-cat-test %s\nline-2\n' "$(date -u +%Y-%m-%dT%H:%M:%SZ)" > "$SRC_FILE" + +echo "[INFO] put -> $REMOTE_PATH" +cat "$SRC_FILE" | node "$CLI_ENTRY" "$VAULT_DIR" --settings "$SETTINGS_FILE" put "$REMOTE_PATH" + +echo "[INFO] cat <- $REMOTE_PATH" +CAT_OUTPUT="$WORK_DIR/cat-output.txt" +node "$CLI_ENTRY" "$VAULT_DIR" --settings "$SETTINGS_FILE" cat "$REMOTE_PATH" > "$CAT_OUTPUT" + +CAT_OUTPUT_CLEAN="$WORK_DIR/cat-output-clean.txt" +grep -v '^\[CLIWatchAdapter\] File watching is not enabled in CLI version$' "$CAT_OUTPUT" > "$CAT_OUTPUT_CLEAN" || true + +if cmp -s "$SRC_FILE" "$CAT_OUTPUT_CLEAN"; then + echo "[PASS] setup/put/cat roundtrip matched" +else + echo "[FAIL] setup/put/cat roundtrip mismatch" >&2 + echo "--- source ---" >&2 + cat "$SRC_FILE" >&2 + echo "--- cat-output ---" >&2 + cat "$CAT_OUTPUT_CLEAN" >&2 + exit 1 +fi + +echo "[INFO] ls $REMOTE_PATH" +LS_OUTPUT="$WORK_DIR/ls-output.txt" +node "$CLI_ENTRY" "$VAULT_DIR" --settings "$SETTINGS_FILE" ls "$REMOTE_PATH" > "$LS_OUTPUT" + +LS_LINE="$(grep -F "$REMOTE_PATH" "$LS_OUTPUT" | head -n 1 || true)" +if [[ -z "$LS_LINE" ]]; then + echo "[FAIL] ls output did not include target path" >&2 + cat "$LS_OUTPUT" >&2 + exit 1 +fi + +IFS=$'\t' read -r LS_PATH LS_SIZE LS_MTIME LS_REV <<< "$LS_LINE" +if [[ "$LS_PATH" != "$REMOTE_PATH" ]]; then + echo "[FAIL] ls path column mismatch: $LS_PATH" >&2 + exit 1 +fi +if [[ ! "$LS_SIZE" =~ ^[0-9]+$ ]]; then + echo "[FAIL] ls size column is not numeric: $LS_SIZE" >&2 + exit 1 +fi +if [[ ! "$LS_MTIME" =~ ^[0-9]+$ ]]; then + echo "[FAIL] ls mtime column is not numeric: $LS_MTIME" >&2 + exit 1 +fi +if [[ -z "$LS_REV" ]]; then + echo "[FAIL] ls revision column is empty" >&2 + exit 1 +fi +echo "[PASS] ls output format matched" + +echo "[INFO] adding more files for ls test cases" +printf 'file-a\n' | node "$CLI_ENTRY" "$VAULT_DIR" --settings "$SETTINGS_FILE" put test/a-first.txt >/dev/null +printf 'file-z\n' | node "$CLI_ENTRY" "$VAULT_DIR" --settings "$SETTINGS_FILE" put test/z-last.txt >/dev/null + +echo "[INFO] ls test/ (prefix filter and sorting)" +LS_PREFIX_OUTPUT="$WORK_DIR/ls-prefix-output.txt" +node "$CLI_ENTRY" "$VAULT_DIR" --settings "$SETTINGS_FILE" ls test/ > "$LS_PREFIX_OUTPUT" + +if [[ "$(wc -l < "$LS_PREFIX_OUTPUT")" -lt 3 ]]; then + echo "[FAIL] ls prefix output expected at least 3 rows" >&2 + cat "$LS_PREFIX_OUTPUT" >&2 + exit 1 +fi + +FIRST_PATH="$(cut -f1 "$LS_PREFIX_OUTPUT" | sed -n '1p')" +SECOND_PATH="$(cut -f1 "$LS_PREFIX_OUTPUT" | sed -n '2p')" +if [[ "$FIRST_PATH" > "$SECOND_PATH" ]]; then + echo "[FAIL] ls output is not sorted by path" >&2 + cat "$LS_PREFIX_OUTPUT" >&2 + exit 1 +fi + +if ! grep -Fq $'test/a-first.txt\t' "$LS_PREFIX_OUTPUT"; then + echo "[FAIL] ls prefix output missing test/a-first.txt" >&2 + cat "$LS_PREFIX_OUTPUT" >&2 + exit 1 +fi +if ! grep -Fq $'test/z-last.txt\t' "$LS_PREFIX_OUTPUT"; then + echo "[FAIL] ls prefix output missing test/z-last.txt" >&2 + cat "$LS_PREFIX_OUTPUT" >&2 + exit 1 +fi +echo "[PASS] ls prefix and sorting matched" + +echo "[INFO] ls no-match prefix" +LS_EMPTY_OUTPUT="$WORK_DIR/ls-empty-output.txt" +node "$CLI_ENTRY" "$VAULT_DIR" --settings "$SETTINGS_FILE" ls no-such-prefix/ > "$LS_EMPTY_OUTPUT" +if [[ -s "$LS_EMPTY_OUTPUT" ]]; then + echo "[FAIL] ls no-match prefix should produce empty output" >&2 + cat "$LS_EMPTY_OUTPUT" >&2 + exit 1 +fi +echo "[PASS] ls no-match prefix matched" + +echo "[INFO] info $REMOTE_PATH" +INFO_OUTPUT="$WORK_DIR/info-output.txt" +node "$CLI_ENTRY" "$VAULT_DIR" --settings "$SETTINGS_FILE" info "$REMOTE_PATH" > "$INFO_OUTPUT" + +# Check required label lines +for label in "ID:" "Revision:" "Conflicts:" "Filename:" "Path:" "Size:" "Chunks:"; do + if ! grep -q "^$label" "$INFO_OUTPUT"; then + echo "[FAIL] info output missing label: $label" >&2 + cat "$INFO_OUTPUT" >&2 + exit 1 + fi +done + +# Path value must match +INFO_PATH="$(grep '^Path:' "$INFO_OUTPUT" | sed 's/^Path:[[:space:]]*//')" +if [[ "$INFO_PATH" != "$REMOTE_PATH" ]]; then + echo "[FAIL] info Path mismatch: $INFO_PATH" >&2 + exit 1 +fi + +# Filename must be the basename +INFO_FILENAME="$(grep '^Filename:' "$INFO_OUTPUT" | sed 's/^Filename:[[:space:]]*//')" +EXPECTED_FILENAME="$(basename "$REMOTE_PATH")" +if [[ "$INFO_FILENAME" != "$EXPECTED_FILENAME" ]]; then + echo "[FAIL] info Filename mismatch: $INFO_FILENAME != $EXPECTED_FILENAME" >&2 + exit 1 +fi + +# Size must be numeric +INFO_SIZE="$(grep '^Size:' "$INFO_OUTPUT" | sed 's/^Size:[[:space:]]*//')" +if [[ ! "$INFO_SIZE" =~ ^[0-9]+$ ]]; then + echo "[FAIL] info Size is not numeric: $INFO_SIZE" >&2 + exit 1 +fi + +# Chunks count must be numeric and ≥1 +INFO_CHUNKS="$(grep '^Chunks:' "$INFO_OUTPUT" | sed 's/^Chunks:[[:space:]]*//')" +if [[ ! "$INFO_CHUNKS" =~ ^[0-9]+$ ]] || [[ "$INFO_CHUNKS" -lt 1 ]]; then + echo "[FAIL] info Chunks is not a positive integer: $INFO_CHUNKS" >&2 + exit 1 +fi + +# Conflicts should be N/A (no live CouchDB) +INFO_CONFLICTS="$(grep '^Conflicts:' "$INFO_OUTPUT" | sed 's/^Conflicts:[[:space:]]*//')" +if [[ "$INFO_CONFLICTS" != "N/A" ]]; then + echo "[FAIL] info Conflicts expected N/A, got: $INFO_CONFLICTS" >&2 + exit 1 +fi + +echo "[PASS] info output format matched" + +echo "[INFO] info non-existent path" +INFO_MISSING_EXIT=0 +node "$CLI_ENTRY" "$VAULT_DIR" --settings "$SETTINGS_FILE" info no-such-file.md > /dev/null || INFO_MISSING_EXIT=$? +if [[ "$INFO_MISSING_EXIT" -eq 0 ]]; then + echo "[FAIL] info on non-existent file should exit non-zero" >&2 + exit 1 +fi +echo "[PASS] info non-existent path returns non-zero" + +echo "[INFO] rm test/z-last.txt" +node "$CLI_ENTRY" "$VAULT_DIR" --settings "$SETTINGS_FILE" rm test/z-last.txt > /dev/null + +RM_CAT_EXIT=0 +node "$CLI_ENTRY" "$VAULT_DIR" --settings "$SETTINGS_FILE" cat test/z-last.txt > /dev/null || RM_CAT_EXIT=$? +if [[ "$RM_CAT_EXIT" -eq 0 ]]; then + echo "[FAIL] rm target should not be readable by cat" >&2 + exit 1 +fi + +LS_AFTER_RM="$WORK_DIR/ls-after-rm.txt" +node "$CLI_ENTRY" "$VAULT_DIR" --settings "$SETTINGS_FILE" ls test/ > "$LS_AFTER_RM" +if grep -Fq $'test/z-last.txt\t' "$LS_AFTER_RM"; then + echo "[FAIL] rm target should not appear in ls output" >&2 + cat "$LS_AFTER_RM" >&2 + exit 1 +fi +echo "[PASS] rm removed target from visible entries" + +echo "[INFO] resolve test/a-first.txt using current revision" +RESOLVE_LS_LINE="$(node "$CLI_ENTRY" "$VAULT_DIR" --settings "$SETTINGS_FILE" ls test/a-first.txt | head -n 1)" +if [[ -z "$RESOLVE_LS_LINE" ]]; then + echo "[FAIL] could not fetch revision for resolve test" >&2 + exit 1 +fi +IFS=$'\t' read -r _ _ _ RESOLVE_REV <<< "$RESOLVE_LS_LINE" +if [[ -z "$RESOLVE_REV" ]]; then + echo "[FAIL] revision was empty for resolve test" >&2 + exit 1 +fi + +node "$CLI_ENTRY" "$VAULT_DIR" --settings "$SETTINGS_FILE" resolve test/a-first.txt "$RESOLVE_REV" > /dev/null +echo "[PASS] resolve accepted current revision" + +echo "[INFO] resolve with non-existent revision" +RESOLVE_BAD_EXIT=0 +node "$CLI_ENTRY" "$VAULT_DIR" --settings "$SETTINGS_FILE" resolve test/a-first.txt 9-no-such-rev > /dev/null || RESOLVE_BAD_EXIT=$? +if [[ "$RESOLVE_BAD_EXIT" -eq 0 ]]; then + echo "[FAIL] resolve with non-existent revision should exit non-zero" >&2 + exit 1 +fi +echo "[PASS] resolve non-existent revision returns non-zero" + +echo "[INFO] preparing revision history for cat-rev test" +REV_PATH="test/revision-history.txt" +REV_V1_FILE="$WORK_DIR/rev-v1.txt" +REV_V2_FILE="$WORK_DIR/rev-v2.txt" +REV_V3_FILE="$WORK_DIR/rev-v3.txt" + +printf 'revision-v1\n' > "$REV_V1_FILE" +printf 'revision-v2\n' > "$REV_V2_FILE" +printf 'revision-v3\n' > "$REV_V3_FILE" + +cat "$REV_V1_FILE" | node "$CLI_ENTRY" "$VAULT_DIR" --settings "$SETTINGS_FILE" put "$REV_PATH" > /dev/null +cat "$REV_V2_FILE" | node "$CLI_ENTRY" "$VAULT_DIR" --settings "$SETTINGS_FILE" put "$REV_PATH" > /dev/null +cat "$REV_V3_FILE" | node "$CLI_ENTRY" "$VAULT_DIR" --settings "$SETTINGS_FILE" put "$REV_PATH" > /dev/null + +echo "[INFO] info $REV_PATH (past revisions)" +REV_INFO_OUTPUT="$WORK_DIR/rev-info-output.txt" +node "$CLI_ENTRY" "$VAULT_DIR" --settings "$SETTINGS_FILE" info "$REV_PATH" > "$REV_INFO_OUTPUT" + +PAST_REV="$(grep '^ rev: ' "$REV_INFO_OUTPUT" | head -n 1 | sed 's/^ rev: //')" +if [[ -z "$PAST_REV" ]]; then + echo "[FAIL] info output did not include any past revision" >&2 + cat "$REV_INFO_OUTPUT" >&2 + exit 1 +fi + +echo "[INFO] cat-rev $REV_PATH @ $PAST_REV" +REV_CAT_OUTPUT="$WORK_DIR/rev-cat-output.txt" +node "$CLI_ENTRY" "$VAULT_DIR" --settings "$SETTINGS_FILE" cat-rev "$REV_PATH" "$PAST_REV" > "$REV_CAT_OUTPUT" + +if cmp -s "$REV_CAT_OUTPUT" "$REV_V1_FILE" || cmp -s "$REV_CAT_OUTPUT" "$REV_V2_FILE"; then + echo "[PASS] cat-rev matched one of the past revisions from info" +else + echo "[FAIL] cat-rev output did not match expected past revisions" >&2 + echo "--- info output ---" >&2 + cat "$REV_INFO_OUTPUT" >&2 + echo "--- cat-rev output ---" >&2 + cat "$REV_CAT_OUTPUT" >&2 + echo "--- expected v1 ---" >&2 + cat "$REV_V1_FILE" >&2 + echo "--- expected v2 ---" >&2 + cat "$REV_V2_FILE" >&2 + exit 1 +fi + +echo "[INFO] pull-rev $REV_PATH @ $PAST_REV" +REV_PULL_OUTPUT="$WORK_DIR/rev-pull-output.txt" +node "$CLI_ENTRY" "$VAULT_DIR" --settings "$SETTINGS_FILE" pull-rev "$REV_PATH" "$REV_PULL_OUTPUT" "$PAST_REV" > /dev/null + +if cmp -s "$REV_PULL_OUTPUT" "$REV_V1_FILE" || cmp -s "$REV_PULL_OUTPUT" "$REV_V2_FILE"; then + echo "[PASS] pull-rev matched one of the past revisions from info" +else + echo "[FAIL] pull-rev output did not match expected past revisions" >&2 + echo "--- info output ---" >&2 + cat "$REV_INFO_OUTPUT" >&2 + echo "--- pull-rev output ---" >&2 + cat "$REV_PULL_OUTPUT" >&2 + echo "--- expected v1 ---" >&2 + cat "$REV_V1_FILE" >&2 + echo "--- expected v2 ---" >&2 + cat "$REV_V2_FILE" >&2 + exit 1 +fi diff --git a/src/apps/webapp/README.md b/src/apps/webapp/README.md index b0aba0a..5fc34e7 100644 --- a/src/apps/webapp/README.md +++ b/src/apps/webapp/README.md @@ -1,12 +1,12 @@ # LiveSync WebApp -Browser-based implementation of Obsidian LiveSync using the FileSystem API. +Browser-based implementation of Self-hosted LiveSync using the FileSystem API. Note: (I vrtmrz have not tested this so much yet). ## Features - 🌐 Runs entirely in the browser - 📁 Uses FileSystem API to access your local vault -- 🔄 Syncs with CouchDB, Object Storage server (compatible with Obsidian LiveSync plugin) +- 🔄 Syncs with CouchDB, Object Storage server (compatible with Self-hosted LiveSync plugin) - 🚫 No server-side code required!! - 💾 Settings stored in `.livesync/settings.json` within your vault - 👁️ Real-time file watching (Chrome 124+ with FileSystemObserver) @@ -178,4 +178,4 @@ Uses `BrowserServiceHub` which provides: ## License -Same as the main Obsidian LiveSync project. +Same as the main Self-hosted LiveSync project. diff --git a/src/apps/webapp/package.json b/src/apps/webapp/package.json index 403e8ad..414c3e7 100644 --- a/src/apps/webapp/package.json +++ b/src/apps/webapp/package.json @@ -3,7 +3,7 @@ "private": true, "version": "0.0.1", "type": "module", - "description": "Browser-based Obsidian LiveSync using FileSystem API", + "description": "Browser-based Self-hosted LiveSync using FileSystem API", "scripts": { "dev": "vite", "build": "vite build", diff --git a/src/lib b/src/lib index 83e2704..3ce1f81 160000 --- a/src/lib +++ b/src/lib @@ -1 +1 @@ -Subproject commit 83e2704c818c9563c4649ce3d9c13ed11a774d37 +Subproject commit 3ce1f81a21d6a5278ff876478a89fca5ca6fbbeb