mirror of
https://github.com/vrtmrz/obsidian-livesync.git
synced 2026-05-31 03:22:57 +00:00
Implement commands
This commit is contained in:
+118
-18
@@ -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
|
||||
|
||||
@@ -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}`);
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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
@@ -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
@@ -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
|
||||
@@ -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.
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user