Implement commands

This commit is contained in:
vorotamoroz
2026-03-12 02:56:30 +09:00
parent 16c0dfef4c
commit 5872cad1e5
10 changed files with 921 additions and 151 deletions
+118 -18
View File
@@ -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> 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 <storagePath> <vaultPath> Push file to local database
pull <vaultPath> <storagePath> Pull file from local database
Commands:
init-settings [path] Create settings JSON from DEFAULT_SETTINGS
sync Run one replication cycle and exit
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> <revision> Pull specific revision into local file <dst>
setup <setupURI> Apply setup URI to settings file
put <vaultPath> Read text from standard input and write to local database
cat <vaultPath> Write latest file content from local database to standard output
cat-rev <vaultPath> <revision> Write specific revision content from local database to standard output
ls <prefix> List files as path<TAB>size<TAB>mtime<TAB>revision[*]
info <vaultPath> Show file metadata including current and past revisions, conflicts, and chunk list
rm <vaultPath> Mark file as deleted in local database
resolve <vaultPath> <revision> 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 <vaultPath>`: Add/update file in local database from standard input
- `cat <vaultPath>`: Output file content to standard output
- `info <vaultPath>`: Show file metadata, conflicts, and, other information
- `ls <prefix>`: List files in local database with optional prefix filter
- `resolve <vaultPath> <revision>`: Resolve conflict for a file by choosing a specific revision
- `rm <vaultPath>`: 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 <vaultPath>`: 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
+315
View File
@@ -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<boolean> {
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: <src> <dst>");
}
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: <src> <dst>");
}
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: <src> <dst> <rev>");
}
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: <setupURI>");
}
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: <dst>");
}
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: <src>");
}
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: <src> <rev>");
}
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: <path>");
}
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<any>(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: <path>");
}
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: <path> <revision-to-keep>");
}
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}`);
}
+49
View File
@@ -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<ServiceContext, any>;
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);
+44
View File
@@ -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<string> {
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<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();
}
}
+50 -127
View File
@@ -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<LogEntry[]>([]);
@@ -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 <src> <dst> Push local file <src> into local database path <dst>
pull <src> <dst> Pull file <src> from local database into local file <dst>
init-settings [path] Create settings JSON from DEFAULT_SETTINGS
Options:
--settings, -s <path> 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 <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<TAB>size<TAB>mtime<TAB>revision[*]
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
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<ServiceContext, any>
): Promise<boolean> {
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: <src> <dst>");
}
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: <src> <dst>");
}
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") {
@@ -108,7 +108,7 @@ class CLIWatchAdapter implements IStorageEventWatchAdapter {
async beginWatch(handlers: IStorageEventWatchHandlers): Promise<void> {
// 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();
}
}
+339
View File
@@ -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
+3 -3
View File
@@ -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.
+1 -1
View File
@@ -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",
+1 -1
Submodule src/lib updated: 83e2704c81...3ce1f81a21