mirror of
https://github.com/vrtmrz/obsidian-livesync.git
synced 2026-05-31 11:32:55 +00:00
Fixed(cli):
- `ls` and `mirror` commands now provide informative feedback when no documents are found or filters skip all files, resolving the issue where they would exit silently (#860). - The command-line argument `vault` has been renamed to a more appropriate name, `databaseDir`. - The `mirror` command now accepts a `vault` directory, which specifies the location where the actual files are stored. For compatibility reasons, the previous behaviour is still supported. Co-authored-by: Copilot <copilot@github.com>
This commit is contained in:
@@ -5,13 +5,13 @@ 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";
|
||||
import { promptForPassphrase, readStdinAsUtf8, toArrayBuffer, toDatabaseRelativePath } from "./utils";
|
||||
import { collectPeers, openP2PHost, parseTimeoutSeconds, syncWithPeer } from "./p2p";
|
||||
import { performFullScan } from "@lib/serviceFeatures/offlineScanner";
|
||||
import { UnresolvedErrorManager } from "@lib/services/base/UnresolvedErrorManager";
|
||||
|
||||
export async function runCommand(options: CLIOptions, context: CLICommandContext): Promise<boolean> {
|
||||
const { vaultPath, core, settingsPath } = context;
|
||||
const { databasePath, core, settingsPath } = context;
|
||||
|
||||
await core.services.control.activated;
|
||||
if (options.command === "daemon") {
|
||||
@@ -77,16 +77,16 @@ export async function runCommand(options: CLIOptions, context: CLICommandContext
|
||||
throw new Error("push requires two arguments: <src> <dst>");
|
||||
}
|
||||
const sourcePath = path.resolve(options.commandArgs[0]);
|
||||
const destinationVaultPath = toVaultRelativePath(options.commandArgs[1], vaultPath);
|
||||
const destinationDatabasePath = toDatabaseRelativePath(options.commandArgs[1], databasePath);
|
||||
const sourceData = await fs.readFile(sourcePath);
|
||||
const sourceStat = await fs.stat(sourcePath);
|
||||
console.log(`[Command] push ${sourcePath} -> ${destinationVaultPath}`);
|
||||
console.log(`[Command] push ${sourcePath} -> ${destinationDatabasePath}`);
|
||||
|
||||
await core.serviceModules.storageAccess.writeFileAuto(destinationVaultPath, toArrayBuffer(sourceData), {
|
||||
await core.serviceModules.storageAccess.writeFileAuto(destinationDatabasePath, toArrayBuffer(sourceData), {
|
||||
mtime: sourceStat.mtimeMs,
|
||||
ctime: sourceStat.ctimeMs,
|
||||
});
|
||||
const destinationPathWithPrefix = destinationVaultPath as FilePathWithPrefix;
|
||||
const destinationPathWithPrefix = destinationDatabasePath as FilePathWithPrefix;
|
||||
const stored = await core.serviceModules.fileHandler.storeFileToDB(destinationPathWithPrefix, true);
|
||||
return stored;
|
||||
}
|
||||
@@ -95,16 +95,16 @@ export async function runCommand(options: CLIOptions, context: CLICommandContext
|
||||
if (options.commandArgs.length < 2) {
|
||||
throw new Error("pull requires two arguments: <src> <dst>");
|
||||
}
|
||||
const sourceVaultPath = toVaultRelativePath(options.commandArgs[0], vaultPath);
|
||||
const sourceDatabasePath = toDatabaseRelativePath(options.commandArgs[0], databasePath);
|
||||
const destinationPath = path.resolve(options.commandArgs[1]);
|
||||
console.log(`[Command] pull ${sourceVaultPath} -> ${destinationPath}`);
|
||||
console.log(`[Command] pull ${sourceDatabasePath} -> ${destinationPath}`);
|
||||
|
||||
const sourcePathWithPrefix = sourceVaultPath as FilePathWithPrefix;
|
||||
const sourcePathWithPrefix = sourceDatabasePath as FilePathWithPrefix;
|
||||
const restored = await core.serviceModules.fileHandler.dbToStorage(sourcePathWithPrefix, null, true);
|
||||
if (!restored) {
|
||||
return false;
|
||||
}
|
||||
const data = await core.serviceModules.storageAccess.readFileAuto(sourceVaultPath);
|
||||
const data = await core.serviceModules.storageAccess.readFileAuto(sourceDatabasePath);
|
||||
await fs.mkdir(path.dirname(destinationPath), { recursive: true });
|
||||
if (typeof data === "string") {
|
||||
await fs.writeFile(destinationPath, data, "utf-8");
|
||||
@@ -118,16 +118,16 @@ export async function runCommand(options: CLIOptions, context: CLICommandContext
|
||||
if (options.commandArgs.length < 3) {
|
||||
throw new Error("pull-rev requires three arguments: <src> <dst> <rev>");
|
||||
}
|
||||
const sourceVaultPath = toVaultRelativePath(options.commandArgs[0], vaultPath);
|
||||
const sourceDatabasePath = toDatabaseRelativePath(options.commandArgs[0], databasePath);
|
||||
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}`);
|
||||
console.log(`[Command] pull-rev ${sourceDatabasePath}@${rev} -> ${destinationPath}`);
|
||||
|
||||
const source = await core.serviceModules.databaseFileAccess.fetch(
|
||||
sourceVaultPath as FilePathWithPrefix,
|
||||
sourceDatabasePath as FilePathWithPrefix,
|
||||
rev,
|
||||
true
|
||||
);
|
||||
@@ -175,11 +175,11 @@ export async function runCommand(options: CLIOptions, context: CLICommandContext
|
||||
if (options.commandArgs.length < 1) {
|
||||
throw new Error("put requires one argument: <dst>");
|
||||
}
|
||||
const destinationVaultPath = toVaultRelativePath(options.commandArgs[0], vaultPath);
|
||||
const destinationDatabasePath = toDatabaseRelativePath(options.commandArgs[0], databasePath);
|
||||
const content = await readStdinAsUtf8();
|
||||
console.log(`[Command] put stdin -> ${destinationVaultPath}`);
|
||||
console.log(`[Command] put stdin -> ${destinationDatabasePath}`);
|
||||
return await core.serviceModules.databaseFileAccess.storeContent(
|
||||
destinationVaultPath as FilePathWithPrefix,
|
||||
destinationDatabasePath as FilePathWithPrefix,
|
||||
content
|
||||
);
|
||||
}
|
||||
@@ -188,10 +188,10 @@ export async function runCommand(options: CLIOptions, context: CLICommandContext
|
||||
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 sourceDatabasePath = toDatabaseRelativePath(options.commandArgs[0], databasePath);
|
||||
console.error(`[Command] cat ${sourceDatabasePath}`);
|
||||
const source = await core.serviceModules.databaseFileAccess.fetch(
|
||||
sourceVaultPath as FilePathWithPrefix,
|
||||
sourceDatabasePath as FilePathWithPrefix,
|
||||
undefined,
|
||||
true
|
||||
);
|
||||
@@ -212,14 +212,14 @@ export async function runCommand(options: CLIOptions, context: CLICommandContext
|
||||
if (options.commandArgs.length < 2) {
|
||||
throw new Error("cat-rev requires two arguments: <src> <rev>");
|
||||
}
|
||||
const sourceVaultPath = toVaultRelativePath(options.commandArgs[0], vaultPath);
|
||||
const sourceDatabasePath = toDatabaseRelativePath(options.commandArgs[0], databasePath);
|
||||
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}`);
|
||||
console.error(`[Command] cat-rev ${sourceDatabasePath} @ ${rev}`);
|
||||
const source = await core.serviceModules.databaseFileAccess.fetch(
|
||||
sourceVaultPath as FilePathWithPrefix,
|
||||
sourceDatabasePath as FilePathWithPrefix,
|
||||
rev,
|
||||
true
|
||||
);
|
||||
@@ -239,7 +239,7 @@ export async function runCommand(options: CLIOptions, context: CLICommandContext
|
||||
if (options.command === "ls") {
|
||||
const prefix =
|
||||
options.commandArgs.length > 0 && options.commandArgs[0].trim() !== ""
|
||||
? toVaultRelativePath(options.commandArgs[0], vaultPath)
|
||||
? toDatabaseRelativePath(options.commandArgs[0], databasePath)
|
||||
: "";
|
||||
const rows: { path: string; line: string }[] = [];
|
||||
|
||||
@@ -261,6 +261,8 @@ export async function runCommand(options: CLIOptions, context: CLICommandContext
|
||||
rows.sort((a, b) => a.path.localeCompare(b.path));
|
||||
if (rows.length > 0) {
|
||||
process.stdout.write(rows.map((e) => e.line).join("\n") + "\n");
|
||||
} else {
|
||||
process.stderr.write("[Info] No documents found in the local database.\n");
|
||||
}
|
||||
return true;
|
||||
}
|
||||
@@ -269,7 +271,7 @@ export async function runCommand(options: CLIOptions, context: CLICommandContext
|
||||
if (options.commandArgs.length < 1) {
|
||||
throw new Error("info requires one argument: <path>");
|
||||
}
|
||||
const targetPath = toVaultRelativePath(options.commandArgs[0], vaultPath);
|
||||
const targetPath = toDatabaseRelativePath(options.commandArgs[0], databasePath);
|
||||
|
||||
for await (const doc of core.services.database.localDatabase.findAllNormalDocs({ conflicts: true })) {
|
||||
if (doc._deleted || doc.deleted) continue;
|
||||
@@ -313,7 +315,7 @@ export async function runCommand(options: CLIOptions, context: CLICommandContext
|
||||
if (options.commandArgs.length < 1) {
|
||||
throw new Error("rm requires one argument: <path>");
|
||||
}
|
||||
const targetPath = toVaultRelativePath(options.commandArgs[0], vaultPath);
|
||||
const targetPath = toDatabaseRelativePath(options.commandArgs[0], databasePath);
|
||||
console.error(`[Command] rm ${targetPath}`);
|
||||
return await core.serviceModules.databaseFileAccess.delete(targetPath as FilePathWithPrefix);
|
||||
}
|
||||
@@ -322,7 +324,7 @@ export async function runCommand(options: CLIOptions, context: CLICommandContext
|
||||
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 targetPath = toDatabaseRelativePath(options.commandArgs[0], databasePath) as FilePathWithPrefix;
|
||||
const revisionToKeep = options.commandArgs[1].trim();
|
||||
if (revisionToKeep === "") {
|
||||
throw new Error("resolve requires a non-empty revision-to-keep");
|
||||
|
||||
@@ -58,7 +58,7 @@ async function createSetupURI(passphrase: string): Promise<string> {
|
||||
|
||||
describe("runCommand abnormal cases", () => {
|
||||
const context = {
|
||||
vaultPath: "/tmp/vault",
|
||||
databasePath: "/tmp/vault",
|
||||
settingsPath: "/tmp/vault/.livesync/settings.json",
|
||||
} as any;
|
||||
|
||||
|
||||
@@ -32,7 +32,7 @@ export interface CLIOptions {
|
||||
}
|
||||
|
||||
export interface CLICommandContext {
|
||||
vaultPath: string;
|
||||
databasePath: string;
|
||||
core: LiveSyncBaseCore<ServiceContext, any>;
|
||||
settingsPath: string;
|
||||
}
|
||||
|
||||
@@ -5,19 +5,19 @@ 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 {
|
||||
export function toDatabaseRelativePath(inputPath: string, databasePath: string): string {
|
||||
const stripped = inputPath.replace(/^[/\\]+/, "");
|
||||
if (!path.isAbsolute(inputPath)) {
|
||||
const normalized = stripped.replace(/\\/g, "/");
|
||||
const resolved = path.resolve(vaultPath, normalized);
|
||||
const rel = path.relative(vaultPath, resolved);
|
||||
const resolved = path.resolve(databasePath, normalized);
|
||||
const rel = path.relative(databasePath, resolved);
|
||||
if (rel.startsWith("..") || path.isAbsolute(rel)) {
|
||||
throw new Error(`Path ${inputPath} is outside of the local database directory`);
|
||||
}
|
||||
return rel.replace(/\\/g, "/");
|
||||
}
|
||||
const resolved = path.resolve(inputPath);
|
||||
const rel = path.relative(vaultPath, resolved);
|
||||
const rel = path.relative(databasePath, resolved);
|
||||
if (rel.startsWith("..") || path.isAbsolute(rel)) {
|
||||
throw new Error(`Path ${inputPath} is outside of the local database directory`);
|
||||
}
|
||||
@@ -25,15 +25,15 @@ export function toVaultRelativePath(inputPath: string, vaultPath: string): strin
|
||||
}
|
||||
|
||||
export async function readStdinAsUtf8(): Promise<string> {
|
||||
const chunks: Buffer[] = [];
|
||||
const chunks = [];
|
||||
for await (const chunk of process.stdin) {
|
||||
if (typeof chunk === "string") {
|
||||
chunks.push(Buffer.from(chunk, "utf-8"));
|
||||
} else {
|
||||
chunks.push(chunk);
|
||||
chunks.push(chunk as Buffer);
|
||||
}
|
||||
}
|
||||
return Buffer.concat(chunks).toString("utf-8");
|
||||
return Buffer.concat(chunks as Uint8Array[]).toString("utf-8");
|
||||
}
|
||||
|
||||
export async function promptForPassphrase(prompt = "Enter setup URI passphrase: "): Promise<string> {
|
||||
|
||||
@@ -1,29 +1,33 @@
|
||||
import * as path from "path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { toVaultRelativePath } from "./utils";
|
||||
import { toDatabaseRelativePath } from "./utils";
|
||||
|
||||
describe("toVaultRelativePath", () => {
|
||||
const vaultPath = path.resolve("/tmp/livesync-vault");
|
||||
describe("toDatabaseRelativePath", () => {
|
||||
const databasePath = path.resolve("/tmp/livesync-vault");
|
||||
|
||||
it("rejects absolute paths outside vault", () => {
|
||||
expect(() => toVaultRelativePath("/etc/passwd", vaultPath)).toThrow("outside of the local database directory");
|
||||
expect(() => toDatabaseRelativePath("/etc/passwd", databasePath)).toThrow(
|
||||
"outside of the local database directory"
|
||||
);
|
||||
});
|
||||
|
||||
it("normalizes leading slash for absolute path inside vault", () => {
|
||||
const absoluteInsideVault = path.join(vaultPath, "notes", "foo.md");
|
||||
expect(toVaultRelativePath(absoluteInsideVault, vaultPath)).toBe("notes/foo.md");
|
||||
const absoluteInsideVault = path.join(databasePath, "notes", "foo.md");
|
||||
expect(toDatabaseRelativePath(absoluteInsideVault, databasePath)).toBe("notes/foo.md");
|
||||
});
|
||||
|
||||
it("normalizes Windows-style separators", () => {
|
||||
expect(toVaultRelativePath("notes\\daily\\2026-03-12.md", vaultPath)).toBe("notes/daily/2026-03-12.md");
|
||||
expect(toDatabaseRelativePath("notes\\daily\\2026-03-12.md", databasePath)).toBe("notes/daily/2026-03-12.md");
|
||||
});
|
||||
|
||||
it("returns vault-relative path for another absolute path inside vault", () => {
|
||||
const absoluteInsideVault = path.join(vaultPath, "docs", "inside.md");
|
||||
expect(toVaultRelativePath(absoluteInsideVault, vaultPath)).toBe("docs/inside.md");
|
||||
const absoluteInsideVault = path.join(databasePath, "docs", "inside.md");
|
||||
expect(toDatabaseRelativePath(absoluteInsideVault, databasePath)).toBe("docs/inside.md");
|
||||
});
|
||||
|
||||
it("rejects relative path traversal that escapes vault", () => {
|
||||
expect(() => toVaultRelativePath("../escape.md", vaultPath)).toThrow("outside of the local database directory");
|
||||
expect(() => toDatabaseRelativePath("../escape.md", databasePath)).toThrow(
|
||||
"outside of the local database directory"
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user