mirror of
https://github.com/vrtmrz/obsidian-livesync.git
synced 2026-11-15 04:30:12 +00:00
fix(cli): share Vault-relative path exclusions
Apply watcher and logical-target exclusions within the selected Vault so hidden or database-like parent directories do not suppress monitoring. Protect primary, previous, and view databases, and provide a runtime opt-in for ordinary database-like directories without changing database ownership. Cover hidden roots, symlink restarts, exclusion parity, and encrypted CouchDB create, modify, and delete operations with regression tests. Refs #1340
This commit is contained in:
1 parent
78ebc2d298
commit
116c2dfbfb
20 files changed
+1703
-107
No files matched your search
@@ -71,6 +71,7 @@ livesync-cli [database-path] [command] [args...]
|
||||
- `--vault <path>` / `-V <path>`: (daemon/mirror/rebuild/fetch) Path to the Vault directory containing `.md` files.
|
||||
- Allows the PouchDB database directory and the actual vault directory to be different locations.
|
||||
- For `mirror` command, the positional `[vault-path]` argument takes precedence over `--vault`.
|
||||
- `--include-db-like-dirs`: Include ordinary directories whose names match the default database-name exclusions. See [database-like directory exclusions](#database-like-directory-exclusions). Hidden entries and user ignore rules remain excluded.
|
||||
- `--force` / `-f`: Required by `rebuild` and `fetch` because they replace a database. It also retains its existing `init-settings` overwrite behaviour.
|
||||
- `--write-settings`: Write setting migrations and other lasting changes after the command succeeds.
|
||||
- `init-settings` writes its target file. `setup`, `remote-add`, `remote-rm`, `remote-set`, and `remote-activate` write their settings changes without this option.
|
||||
@@ -675,6 +676,31 @@ livesync-cli /path/to/vault
|
||||
livesync-cli /path/to/vault --interval 60
|
||||
```
|
||||
|
||||
### Database-like directory exclusions
|
||||
|
||||
By default, Vault synchronisation excludes path components ending in
|
||||
`-livesync-v2` or containing `-livesync-v2-mrview-` at every depth. These names
|
||||
cover the CLI's primary PouchDB directories and their view indexes. The same
|
||||
policy applies during startup scans, live monitoring, and target checks for
|
||||
received files.
|
||||
|
||||
If an ordinary directory uses one of these names, specify `--include-db-like-dirs`
|
||||
on each invocation that should synchronise it:
|
||||
|
||||
```bash
|
||||
livesync-cli ./database --vault ./vault --include-db-like-dirs daemon
|
||||
```
|
||||
|
||||
The option does not override hidden-entry exclusions or `.livesync/ignore`.
|
||||
Direct database-like entries inside the configured `database-path` also remain
|
||||
excluded, including previous databases and view indexes. When `database-path`
|
||||
and the Vault are the same directory, the option can include matching names
|
||||
below ordinary subdirectories, but direct database-like entries remain protected.
|
||||
|
||||
The option changes a naming rule; it does not inspect arbitrary directory
|
||||
contents. An unrelated database outside the configured database storage area
|
||||
can also be included by opting in. The option is not saved to settings.
|
||||
|
||||
### .livesync/ignore
|
||||
|
||||
Place a `.livesync/ignore` file in your vault root to exclude files from sync in both directions (local → CouchDB and CouchDB → local).
|
||||
|
||||
@@ -1,3 +1,95 @@
|
||||
import { path } from "@vrtmrz/livesync-commonlib/node";
|
||||
import { SuffixDatabaseName } from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
import { parseFilePath, type FilePath } from "@vrtmrz/livesync-commonlib/storage";
|
||||
import type { IgnoreRules } from "./serviceModules/IgnoreRules";
|
||||
|
||||
declare const vaultRelativePathBrand: unique symbol;
|
||||
|
||||
/**
|
||||
* A checked lexical path relative to the selected Vault. Filesystem existence,
|
||||
* descendant symlink targets, and the identity of the Vault require separate checks.
|
||||
*/
|
||||
export type VaultRelativePath = FilePath & {
|
||||
readonly [vaultRelativePathBrand]: true;
|
||||
};
|
||||
|
||||
export interface CLIPathFilterOptions {
|
||||
/** Relax the naming heuristic while retaining storage protection and user exclusions. */
|
||||
readonly includeDbLikeDirs?: boolean;
|
||||
/** Configured DB root relative to the Vault: shared roots use ''; an outside root is undefined. */
|
||||
readonly databaseRoot?: VaultRelativePath;
|
||||
}
|
||||
|
||||
export function isHiddenPath(relativePath: string): boolean {
|
||||
return relativePath.split("/").some((part) => part.startsWith("."));
|
||||
}
|
||||
|
||||
/**
|
||||
* The sole constructor for watcher-relative paths. Rebase before filtering so
|
||||
* names above the selected Vault cannot exclude the entire watch root.
|
||||
*/
|
||||
export function toVaultRelativePath(vaultRoot: string, candidatePath: string): VaultRelativePath | undefined {
|
||||
// Relative inputs would make the result depend on the process working directory.
|
||||
if (!path.isAbsolute(vaultRoot) || !path.isAbsolute(candidatePath)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const relativePath = path.relative(vaultRoot, candidatePath).replace(/\\/g, "/");
|
||||
try {
|
||||
// Preserve spelling and the empty root; reject outside/traversal and drive-qualified results.
|
||||
return parseFilePath(relativePath) as VaultRelativePath;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
// PouchDB creates view indexes beside the primary DB using its name plus '-mrview-'.
|
||||
// Both forms are conservative name matches, rather than proof of a directory's contents.
|
||||
function isDbLikeDirectoryName(name: string): boolean {
|
||||
return name.endsWith(SuffixDatabaseName) || name.includes(`${SuffixDatabaseName}-mrview-`);
|
||||
}
|
||||
|
||||
/** Protect current, previous, and view DB entries even when the naming heuristic is relaxed. */
|
||||
function isDatabaseStoragePath(relativePath: FilePath, databaseRoot: VaultRelativePath | undefined): boolean {
|
||||
if (databaseRoot === undefined) {
|
||||
return false;
|
||||
}
|
||||
// Include the separator so a sibling such as 'state-copy' cannot enter the 'state' namespace.
|
||||
const prefix = databaseRoot === "" ? "" : `${databaseRoot}/`;
|
||||
if (!relativePath.startsWith(prefix)) {
|
||||
return false;
|
||||
}
|
||||
// Protect the direct DB entry and all descendants. Nested user-directory names
|
||||
// elsewhere still belong to the optional naming rule.
|
||||
const entry = relativePath.slice(prefix.length).split("/")[0];
|
||||
return isDbLikeDirectoryName(entry);
|
||||
}
|
||||
|
||||
/** Shared exclusions for logical targets and watcher paths already rebased to the Vault. */
|
||||
export function isIgnoredFilePath(
|
||||
relativePath: FilePath,
|
||||
rules?: Pick<IgnoreRules, "shouldIgnore">,
|
||||
options: CLIPathFilterOptions = {}
|
||||
): boolean {
|
||||
// Keep the root traversable even when a user rule excludes every entry beneath it.
|
||||
if (relativePath === "") {
|
||||
return false;
|
||||
}
|
||||
|
||||
// The opt-in bypasses only the naming heuristic; protected storage and explicit ignores still apply.
|
||||
return (
|
||||
isHiddenPath(relativePath) ||
|
||||
isDatabaseStoragePath(relativePath, options.databaseRoot) ||
|
||||
(!options.includeDbLikeDirs && relativePath.split("/").some(isDbLikeDirectoryName)) ||
|
||||
(rules?.shouldIgnore(relativePath) ?? false)
|
||||
);
|
||||
}
|
||||
|
||||
/** Keep raw filesystem candidates out of the shared policy until conversion has validated them. */
|
||||
export function isIgnoredVaultPath(
|
||||
relativePath: VaultRelativePath,
|
||||
rules?: Pick<IgnoreRules, "shouldIgnore">,
|
||||
options: CLIPathFilterOptions = {}
|
||||
): boolean {
|
||||
return isIgnoredFilePath(relativePath, rules, options);
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
import { describe, expect, expectTypeOf, it, vi } from "vitest";
|
||||
import { path } from "@vrtmrz/livesync-commonlib/node";
|
||||
import { parseFilePath, type FilePath } from "@vrtmrz/livesync-commonlib/storage";
|
||||
import {
|
||||
isHiddenPath,
|
||||
isIgnoredFilePath,
|
||||
isIgnoredVaultPath,
|
||||
toVaultRelativePath,
|
||||
type VaultRelativePath,
|
||||
} from "./cliPathFilter";
|
||||
|
||||
const vaultRoot = path.resolve(path.sep, "vault");
|
||||
|
||||
function checkedRelativePath(relativePath: string): VaultRelativePath {
|
||||
const converted = toVaultRelativePath(vaultRoot, path.join(vaultRoot, relativePath));
|
||||
if (converted === undefined) {
|
||||
throw new Error(`Expected a valid Vault-relative fixture: ${relativePath}`);
|
||||
}
|
||||
return converted;
|
||||
}
|
||||
|
||||
describe("toVaultRelativePath", () => {
|
||||
it.each(["", "note.md", "notes/note.md", "..draft.md", "Notes/e\u0301.md"])(
|
||||
"retains the relative spelling of %j without requiring a filesystem entry",
|
||||
(relativePath) => {
|
||||
expect(toVaultRelativePath(vaultRoot, path.join(vaultRoot, relativePath))).toBe(relativePath);
|
||||
}
|
||||
);
|
||||
|
||||
it("uses the selected root even when it or its parents have excluded names", () => {
|
||||
const root = path.resolve(path.sep, ".hidden", "archive-livesync-v2", ".vault");
|
||||
const converted = toVaultRelativePath(root, path.join(root, "note.md"));
|
||||
expect(converted).toBe("note.md");
|
||||
expect(converted !== undefined && isIgnoredVaultPath(converted)).toBe(false);
|
||||
});
|
||||
|
||||
it.each([
|
||||
[".", path.join(vaultRoot, "note.md")],
|
||||
[vaultRoot, "note.md"],
|
||||
[vaultRoot, path.resolve(vaultRoot, "..", "outside.md")],
|
||||
[vaultRoot, path.join(vaultRoot + "-sibling", "note.md")],
|
||||
[vaultRoot, path.dirname(vaultRoot)],
|
||||
])("rejects a non-Vault candidate (%s, %s)", (root, candidate) => {
|
||||
expect(toVaultRelativePath(root, candidate)).toBeUndefined();
|
||||
});
|
||||
|
||||
it.skipIf(process.platform !== "win32")("rejects a candidate on another drive", () => {
|
||||
expect(toVaultRelativePath("C:\\vault", "D:\\note.md")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("keeps the refinement compatible with existing file paths and rejects unconverted inputs", () => {
|
||||
expectTypeOf<VaultRelativePath>().toMatchTypeOf<FilePath>();
|
||||
expectTypeOf<Parameters<typeof isIgnoredVaultPath>[0]>().toEqualTypeOf<VaultRelativePath>();
|
||||
expectTypeOf<string>().not.toMatchTypeOf<Parameters<typeof isIgnoredVaultPath>[0]>();
|
||||
expectTypeOf<FilePath>().not.toMatchTypeOf<Parameters<typeof isIgnoredVaultPath>[0]>();
|
||||
expectTypeOf<Parameters<typeof isIgnoredFilePath>[0]>().toEqualTypeOf<FilePath>();
|
||||
expectTypeOf<string>().not.toMatchTypeOf<Parameters<typeof isIgnoredFilePath>[0]>();
|
||||
});
|
||||
});
|
||||
|
||||
describe("isIgnoredVaultPath", () => {
|
||||
it.each([
|
||||
[".hidden.md", true],
|
||||
["notes/.hidden.md", true],
|
||||
[".git/config", true],
|
||||
["local-livesync-v2/state", true],
|
||||
["notes/local-livesync-v2/state", true],
|
||||
["notes/local-livesync-v2-mrview-0123456789abcdef/CURRENT", true],
|
||||
["-livesync-v2", true],
|
||||
["notes/note.md", false],
|
||||
["local-livesync-v2.md", false],
|
||||
["local-livesync-v2-copy/state", false],
|
||||
])("filters %s within the selected Vault", (candidate, ignored) => {
|
||||
expect(isIgnoredVaultPath(checkedRelativePath(candidate))).toBe(ignored);
|
||||
expect(isIgnoredFilePath(parseFilePath(candidate))).toBe(ignored);
|
||||
});
|
||||
|
||||
it("keeps the root watchable even when user rules exclude every entry", () => {
|
||||
const rules = { shouldIgnore: vi.fn(() => true) };
|
||||
expect(isIgnoredVaultPath(checkedRelativePath(""), rules)).toBe(false);
|
||||
expect(rules.shouldIgnore).not.toHaveBeenCalled();
|
||||
expect(isIgnoredVaultPath(checkedRelativePath("note.md"), rules)).toBe(true);
|
||||
});
|
||||
|
||||
it("passes only the relative path to user rules", () => {
|
||||
const rules = { shouldIgnore: vi.fn((candidate: string) => candidate === "private/note.md") };
|
||||
expect(isIgnoredVaultPath(checkedRelativePath("private/note.md"), rules)).toBe(true);
|
||||
expect(rules.shouldIgnore).toHaveBeenCalledWith("private/note.md");
|
||||
expect(isIgnoredVaultPath(checkedRelativePath("public/note.md"), rules)).toBe(false);
|
||||
});
|
||||
|
||||
it("includes DB-like entries outside the configured database namespace when requested", () => {
|
||||
const options = { includeDbLikeDirs: true, databaseRoot: checkedRelativePath("state") };
|
||||
for (const candidate of ["archive-livesync-v2/note.md", "notes/archive-livesync-v2/note.md"]) {
|
||||
expect(isIgnoredVaultPath(checkedRelativePath(candidate), undefined, options)).toBe(false);
|
||||
expect(isIgnoredFilePath(parseFilePath(candidate), undefined, options)).toBe(false);
|
||||
}
|
||||
for (const candidate of [
|
||||
"state/current-livesync-v2/CURRENT",
|
||||
"state/previous-livesync-v2/CURRENT",
|
||||
"state/current-livesync-v2-mrview-index/CURRENT",
|
||||
"state/previous-livesync-v2-mrview-temp/CURRENT",
|
||||
]) {
|
||||
expect(isIgnoredVaultPath(checkedRelativePath(candidate), undefined, options)).toBe(true);
|
||||
}
|
||||
expect(isIgnoredVaultPath(checkedRelativePath("state"), undefined, options)).toBe(false);
|
||||
expect(
|
||||
isIgnoredVaultPath(checkedRelativePath("state-copy/archive-livesync-v2/note.md"), undefined, options)
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("protects colocated database entries while allowing nested DB-like user directories", () => {
|
||||
const options = { includeDbLikeDirs: true, databaseRoot: checkedRelativePath("") };
|
||||
expect(isIgnoredVaultPath(checkedRelativePath("local-livesync-v2/CURRENT"), undefined, options)).toBe(true);
|
||||
expect(
|
||||
isIgnoredVaultPath(checkedRelativePath("local-livesync-v2-mrview-index/CURRENT"), undefined, options)
|
||||
).toBe(true);
|
||||
expect(isIgnoredVaultPath(checkedRelativePath("notes/local-livesync-v2/note.md"), undefined, options)).toBe(
|
||||
false
|
||||
);
|
||||
expect(isIgnoredVaultPath(checkedRelativePath(""), undefined, options)).toBe(false);
|
||||
});
|
||||
|
||||
it("keeps hidden entries and user rules excluded when DB-like entries are included", () => {
|
||||
const options = { includeDbLikeDirs: true };
|
||||
const rules = { shouldIgnore: vi.fn((candidate: string) => candidate.endsWith(".tmp")) };
|
||||
expect(isIgnoredVaultPath(checkedRelativePath(".livesync/settings.json"), rules, options)).toBe(true);
|
||||
expect(isIgnoredVaultPath(checkedRelativePath("notes/.private/note.md"), rules, options)).toBe(true);
|
||||
expect(isIgnoredVaultPath(checkedRelativePath("archive-livesync-v2/note.tmp"), rules, options)).toBe(true);
|
||||
expect(rules.shouldIgnore).toHaveBeenCalledWith("archive-livesync-v2/note.tmp");
|
||||
expect(isIgnoredVaultPath(checkedRelativePath("archive-livesync-v2/note.md"), rules, options)).toBe(false);
|
||||
});
|
||||
|
||||
it("preserves the existing hidden-path predicate for its current callers", () => {
|
||||
expect(isHiddenPath("notes/.draft.md")).toBe(true);
|
||||
expect(isHiddenPath("notes/draft.md")).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -47,6 +47,8 @@ export interface CLIOptions {
|
||||
writeSettings?: boolean;
|
||||
/** Restore the former zero exit code after a returned remote-administration verification failure. */
|
||||
compatRemoteAdminExitZero?: boolean;
|
||||
/** Per-invocation opt-in; configured DB protection remains effective and no setting is saved. */
|
||||
includeDbLikeDirs?: boolean;
|
||||
command: CLICommand;
|
||||
commandArgs: string[];
|
||||
interval?: number;
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { appendFile, mkdtemp, mkdir, readFile, rm, stat, writeFile } from "node:fs/promises";
|
||||
import { appendFile, mkdtemp, mkdir, readFile, readdir, rm, stat, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import * as chokidar from "chokidar";
|
||||
import { path } from "@vrtmrz/livesync-commonlib/node";
|
||||
import { parseFilePath } from "@vrtmrz/livesync-commonlib/storage";
|
||||
import * as offlineScanner from "@vrtmrz/livesync-commonlib/compat/serviceFeatures/offlineScanner";
|
||||
import { ReplicatorService } from "@vrtmrz/livesync-commonlib/compat/services/base/ReplicatorService";
|
||||
import { ControlService } from "@vrtmrz/livesync-commonlib/compat/services/base/ControlService";
|
||||
@@ -85,7 +86,8 @@ describe("CLI database preparation", () => {
|
||||
runner: CliCommandRunner,
|
||||
exitCode = 1,
|
||||
force = false,
|
||||
writeSettings = false
|
||||
writeSettings = false,
|
||||
extraArgs: readonly string[] = []
|
||||
) {
|
||||
process.exitCode = undefined;
|
||||
process.argv = [
|
||||
@@ -98,6 +100,7 @@ describe("CLI database preparation", () => {
|
||||
vaultPath,
|
||||
"--settings",
|
||||
settingsPath,
|
||||
...extraArgs,
|
||||
command,
|
||||
];
|
||||
// Daemon probes return false so the real core unloads without keeping a daemon alive.
|
||||
@@ -114,28 +117,31 @@ describe("CLI database preparation", () => {
|
||||
expect(restore).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each(["fetch", "rebuild"] as const)("invalidates old reflection work after the %s database reset", async (command) => {
|
||||
const snapshotKey = "replicationResultProcessorSnapshot";
|
||||
const oldSnapshot = { queued: [{ _id: "old", _rev: "1-old" }], processing: [] };
|
||||
let retainedBeforeReset: unknown;
|
||||
let retainedAfterReset: unknown;
|
||||
let reset: boolean | undefined;
|
||||
const runner = vi.fn<CliCommandRunner>(async (_options, { core }) => {
|
||||
await core.services.keyValueDB.kvDB.set(snapshotKey, oldSnapshot);
|
||||
retainedBeforeReset = await core.services.keyValueDB.kvDB.get(snapshotKey);
|
||||
reset = await core.services.database.resetDatabase();
|
||||
retainedAfterReset = await core.services.keyValueDB.kvDB.get(snapshotKey);
|
||||
return false;
|
||||
});
|
||||
it.each(["fetch", "rebuild"] as const)(
|
||||
"invalidates old reflection work after the %s database reset",
|
||||
async (command) => {
|
||||
const snapshotKey = "replicationResultProcessorSnapshot";
|
||||
const oldSnapshot = { queued: [{ _id: "old", _rev: "1-old" }], processing: [] };
|
||||
let retainedBeforeReset: unknown;
|
||||
let retainedAfterReset: unknown;
|
||||
let reset: boolean | undefined;
|
||||
const runner = vi.fn<CliCommandRunner>(async (_options, { core }) => {
|
||||
await core.services.keyValueDB.kvDB.set(snapshotKey, oldSnapshot);
|
||||
retainedBeforeReset = await core.services.keyValueDB.kvDB.get(snapshotKey);
|
||||
reset = await core.services.database.resetDatabase();
|
||||
retainedAfterReset = await core.services.keyValueDB.kvDB.get(snapshotKey);
|
||||
return false;
|
||||
});
|
||||
|
||||
await start(command, runner, 1, true);
|
||||
await start(command, runner, 1, true);
|
||||
|
||||
expect(runner).toHaveBeenCalledOnce();
|
||||
expect(reset).toBe(true);
|
||||
expect(retainedBeforeReset).toEqual(oldSnapshot);
|
||||
expect(retainedAfterReset).toEqual({ queued: [], processing: [] });
|
||||
expect(await readFile(join(vaultPath, "notes/local.md"), "utf8")).toBe("local content");
|
||||
});
|
||||
expect(runner).toHaveBeenCalledOnce();
|
||||
expect(reset).toBe(true);
|
||||
expect(retainedBeforeReset).toEqual(oldSnapshot);
|
||||
expect(retainedAfterReset).toEqual({ queued: [], processing: [] });
|
||||
expect(await readFile(join(vaultPath, "notes/local.md"), "utf8")).toBe("local content");
|
||||
}
|
||||
);
|
||||
|
||||
it.each([
|
||||
{ handleFilenameCaseSensitive: true, pathNormalizationVersion: 0 as const },
|
||||
@@ -355,6 +361,102 @@ describe("CLI database preparation", () => {
|
||||
}
|
||||
);
|
||||
|
||||
it.each([false, true])(
|
||||
"uses the same database-directory exclusions for scans and watching (include: %s)",
|
||||
async (includeDbLikeDirs) => {
|
||||
const databaseEntry = parseFilePath("notes/archive-livesync-v2/state");
|
||||
await mkdir(join(vaultPath, "notes/archive-livesync-v2"));
|
||||
await writeFile(join(vaultPath, databaseEntry), "database-directory fixture");
|
||||
const storedPaths: string[] = [];
|
||||
let isTarget: boolean | undefined;
|
||||
let watcherIgnored: boolean | undefined;
|
||||
const runner = vi.fn<CliCommandRunner>(async (_options, { core }) => {
|
||||
isTarget = await core.services.vault.isTargetFile(databaseEntry);
|
||||
const watcherCalls = vi.mocked(chokidar.watch).mock.calls;
|
||||
const ignored = watcherCalls[watcherCalls.length - 1]?.[1]?.ignored;
|
||||
if (typeof ignored !== "function") {
|
||||
throw new Error("Expected the CLI watcher path policy");
|
||||
}
|
||||
watcherIgnored = ignored(join(vaultPath, databaseEntry));
|
||||
for await (const doc of core.services.database.localDatabase.findAllNormalDocs()) {
|
||||
storedPaths.push(doc.path);
|
||||
}
|
||||
return false;
|
||||
});
|
||||
|
||||
await start("daemon", runner, 1, false, false, includeDbLikeDirs ? ["--include-db-like-dirs"] : []);
|
||||
|
||||
expect(runner).toHaveBeenCalledOnce();
|
||||
expect(watcherIgnored).toBe(!includeDbLikeDirs);
|
||||
expect(isTarget).toBe(includeDbLikeDirs);
|
||||
expect(storedPaths.sort()).toEqual(
|
||||
includeDbLikeDirs ? [databaseEntry, "notes/local.md"] : ["notes/local.md"]
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
it("keeps actual, old, and derived database files excluded with the compatibility option", async () => {
|
||||
const databaseRoot = join(vaultPath, "state");
|
||||
// Retained entries simulate older suffixes; the opt-in must protect them
|
||||
// alongside the active DB and the real view index created below.
|
||||
for (const entry of ["previous-livesync-v2", "previous-livesync-v2-mrview-index"]) {
|
||||
await mkdir(join(databaseRoot, entry), { recursive: true });
|
||||
await writeFile(join(databaseRoot, entry, "CURRENT"), "retained database fixture");
|
||||
}
|
||||
const protectedEntries: { path: string; isTarget: boolean; ignored: boolean }[] = [];
|
||||
const storedPaths: string[] = [];
|
||||
let hasDerivedDatabase = false;
|
||||
const runner = vi.fn<CliCommandRunner>(async (_options, { core }) => {
|
||||
const database = core.services.database.localDatabase.localDatabase;
|
||||
await database.createIndex({ index: { fields: ["path"], ddoc: "path-protection", name: "by-path" } });
|
||||
const entries = (await readdir(databaseRoot)).filter((entry) => entry !== ".livesync");
|
||||
hasDerivedDatabase = entries.some(
|
||||
(entry) => entry.includes("-mrview-") && entry !== "previous-livesync-v2-mrview-index"
|
||||
);
|
||||
const watcherCalls = vi.mocked(chokidar.watch).mock.calls;
|
||||
const ignored = watcherCalls[watcherCalls.length - 1]?.[1]?.ignored;
|
||||
if (typeof ignored !== "function") {
|
||||
throw new Error("Expected the CLI watcher path policy");
|
||||
}
|
||||
for (const entry of entries) {
|
||||
const candidate = parseFilePath(`state/${entry}/CURRENT`);
|
||||
protectedEntries.push({
|
||||
path: candidate,
|
||||
isTarget: await core.services.vault.isTargetFile(candidate),
|
||||
ignored: ignored(join(vaultPath, candidate)),
|
||||
});
|
||||
}
|
||||
for await (const doc of core.services.database.localDatabase.findAllNormalDocs()) {
|
||||
storedPaths.push(doc.path);
|
||||
}
|
||||
return false;
|
||||
});
|
||||
process.argv = [
|
||||
"node",
|
||||
"livesync-cli",
|
||||
databaseRoot,
|
||||
"--vault",
|
||||
vaultPath,
|
||||
"--settings",
|
||||
settingsPath,
|
||||
"--include-db-like-dirs",
|
||||
"daemon",
|
||||
];
|
||||
|
||||
await expect(main(standardIo, runner)).rejects.toThrow("__EXIT__:1");
|
||||
|
||||
expect(runner).toHaveBeenCalledOnce();
|
||||
expect(hasDerivedDatabase).toBe(true);
|
||||
expect(protectedEntries).toEqual(
|
||||
expect.arrayContaining([
|
||||
{ path: "state/previous-livesync-v2/CURRENT", isTarget: false, ignored: true },
|
||||
{ path: "state/previous-livesync-v2-mrview-index/CURRENT", isTarget: false, ignored: true },
|
||||
])
|
||||
);
|
||||
expect(protectedEntries.every((entry) => !entry.isTarget && entry.ignored)).toBe(true);
|
||||
expect(storedPaths).toEqual(["notes/local.md"]);
|
||||
});
|
||||
|
||||
it("retains filesystem creation time in database Metadata after an in-place edit", async (context) => {
|
||||
const note = join(vaultPath, "notes/local.md");
|
||||
const initial = await stat(note);
|
||||
|
||||
+28
-13
@@ -24,7 +24,8 @@ import { getForceRequirementError } from "./commands/utils";
|
||||
import { getPathFromUXFileInfo } from "@vrtmrz/livesync-commonlib/compat/common/typeUtils";
|
||||
import { stripAllPrefixes } from "@vrtmrz/livesync-commonlib/compat/string_and_binary/path";
|
||||
import { IgnoreRules } from "./serviceModules/IgnoreRules";
|
||||
import { isHiddenPath } from "./cliPathFilter";
|
||||
import { isIgnoredFilePath, toVaultRelativePath, type CLIPathFilterOptions } from "./cliPathFilter";
|
||||
import { parseFilePath, type FilePath } from "@vrtmrz/livesync-commonlib/storage";
|
||||
import { useP2PReplicatorFeature, type UseP2PReplicatorResult } from "@vrtmrz/livesync-commonlib/p2p";
|
||||
import { useOfflineScanner } from "@vrtmrz/livesync-commonlib/compat/serviceFeatures/offlineScanner";
|
||||
import type { ReplicationSchedulingControl } from "@/serviceFeatures/replicationScheduling";
|
||||
@@ -162,6 +163,8 @@ Options:
|
||||
--force, -f Required for rebuild and fetch because they replace databases
|
||||
--interval <N>, -i <N> (daemon only) Poll CouchDB every N seconds instead of using the _changes feed
|
||||
--write-settings Write setting changes after a successful command
|
||||
--include-db-like-dirs Include user directories whose names resemble database directories
|
||||
CLI database storage entries, hidden entries, and user ignore rules remain excluded
|
||||
--compat-remote-admin-exit-zero
|
||||
Preserve the former zero exit code when remote-administration verification fails
|
||||
|
||||
@@ -215,6 +218,7 @@ export function parseArgs(standardIo: StandardIo = createNodeStandardIo()): CLIO
|
||||
let force = false;
|
||||
let writeSettings = false;
|
||||
let compatRemoteAdminExitZero = false;
|
||||
let includeDbLikeDirs = false;
|
||||
let interval: number | undefined;
|
||||
let command: CLICommand = "daemon";
|
||||
const commandArgs: string[] = [];
|
||||
@@ -277,6 +281,9 @@ export function parseArgs(standardIo: StandardIo = createNodeStandardIo()): CLIO
|
||||
case "--compat-remote-admin-exit-zero":
|
||||
compatRemoteAdminExitZero = true;
|
||||
break;
|
||||
case "--include-db-like-dirs":
|
||||
includeDbLikeDirs = true;
|
||||
break;
|
||||
default: {
|
||||
if (!databasePath) {
|
||||
if (command === "daemon" && isCLICommand(token)) {
|
||||
@@ -319,6 +326,7 @@ export function parseArgs(standardIo: StandardIo = createNodeStandardIo()): CLIO
|
||||
force,
|
||||
writeSettings,
|
||||
compatRemoteAdminExitZero,
|
||||
includeDbLikeDirs,
|
||||
command,
|
||||
commandArgs,
|
||||
interval,
|
||||
@@ -469,6 +477,13 @@ export async function main(
|
||||
await ignoreRules.load();
|
||||
}
|
||||
|
||||
// Derive protection from the canonical startup paths and share it with both
|
||||
// target checks and watching. The runtime opt-in never changes the protected DB root.
|
||||
const pathFilterOptions: CLIPathFilterOptions = {
|
||||
includeDbLikeDirs: options.includeDbLikeDirs,
|
||||
databaseRoot: toVaultRelativePath(vaultPath, databasePath),
|
||||
};
|
||||
|
||||
// Create service context and hub
|
||||
const context = new NodeServiceContext(databasePath, standardIo, vaultPath);
|
||||
const serviceHubInstance = new NodeServiceHub<NodeServiceContext>(databasePath, context);
|
||||
@@ -566,7 +581,8 @@ export async function main(
|
||||
core,
|
||||
serviceHub,
|
||||
ignoreRules,
|
||||
vaultSyncMode?.watchFiles ?? false
|
||||
vaultSyncMode?.watchFiles ?? false,
|
||||
pathFilterOptions
|
||||
);
|
||||
},
|
||||
(core) => [],
|
||||
@@ -580,21 +596,20 @@ export async function main(
|
||||
p2pReplicator = useP2PReplicatorFeature(core, undefined, undefined, {
|
||||
prepareP2PSettings: useP2PSettingsPreparation(core.services.API.webCompatFetch.bind(core.services.API)),
|
||||
});
|
||||
// Add target filter to prevent internal files are handled
|
||||
// Logical targets are already Vault-relative. Validate without filesystem
|
||||
// resolution, then use the same exclusions as startup scans and live events.
|
||||
core.services.vault.isTargetFile.addHandler(async (target) => {
|
||||
const targetPath = stripAllPrefixes(getPathFromUXFileInfo(target));
|
||||
const parts = targetPath.split("/");
|
||||
if (isHiddenPath(targetPath)) {
|
||||
return await Promise.resolve(false);
|
||||
let relativePath: FilePath;
|
||||
try {
|
||||
relativePath = parseFilePath(targetPath);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
// PouchDB LevelDB database directory lives in the vault directory.
|
||||
if (parts[0]?.endsWith("-livesync-v2")) {
|
||||
return await Promise.resolve(false);
|
||||
}
|
||||
return await Promise.resolve(true);
|
||||
return !isIgnoredFilePath(relativePath, undefined, pathFilterOptions);
|
||||
}, -1 /* highest priority */);
|
||||
|
||||
// Apply user-defined ignore rules after the dotfile check.
|
||||
// Keep user rules as a later veto; they cannot re-admit paths rejected by the shared policy.
|
||||
if (ignoreRules) {
|
||||
const rules = ignoreRules;
|
||||
core.services.vault.isTargetFile.addHandler(async (target) => {
|
||||
@@ -602,7 +617,7 @@ export async function main(
|
||||
if (rules.shouldIgnore(targetPath)) {
|
||||
return false;
|
||||
}
|
||||
// At least this handler think it is a target file, but other handlers may still veto it.
|
||||
// This handler admits the path, but later handlers may still veto it.
|
||||
return true;
|
||||
}, 0);
|
||||
}
|
||||
|
||||
@@ -72,9 +72,25 @@ describe("CLI parseArgs", () => {
|
||||
expect(combined).toContain("Usage:");
|
||||
expect(combined).toContain("livesync-cli <database-path> [options] <command> [command-args]");
|
||||
expect(combined).toContain("--compat-remote-admin-exit-zero");
|
||||
expect(combined).toContain("--include-db-like-dirs");
|
||||
expect(combined).toContain("journal-skip <journal-key>");
|
||||
});
|
||||
|
||||
it.each([false, true])("parses the DB-like directory compatibility option (%s)", (enabled) => {
|
||||
process.argv = [
|
||||
"node",
|
||||
"livesync-cli",
|
||||
"./databasePath",
|
||||
...(enabled ? ["--include-db-like-dirs"] : []),
|
||||
"daemon",
|
||||
];
|
||||
mockProcessExit();
|
||||
const parsed = parseArgs();
|
||||
expect(parsed.includeDbLikeDirs).toBe(enabled);
|
||||
expect(parsed.command).toBe("daemon");
|
||||
expect(parsed.commandArgs).toEqual([]);
|
||||
});
|
||||
|
||||
it("parses p2p-peers command and timeout", () => {
|
||||
process.argv = ["node", "livesync-cli", "./databasePath", "p2p-peers", "5"];
|
||||
const parsed = parseArgs();
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { execFile } from "node:child_process";
|
||||
import { mkdir, mkdtemp, readdir, rename, rm, unlink, writeFile } from "node:fs/promises";
|
||||
import { mkdir, mkdtemp, readFile, readdir, realpath, rename, rm, symlink, unlink, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { promisify } from "node:util";
|
||||
@@ -66,6 +66,118 @@ for (let directoryIndex = 0; directoryIndex < 40; directoryIndex++) {
|
||||
`;
|
||||
|
||||
describe("CLI filesystem watcher with a real filesystem", () => {
|
||||
it.each([
|
||||
{ name: "an ordinary Vault", parent: "notes", vault: "vault", alias: false, resolveAlias: true },
|
||||
{
|
||||
name: "an ordinary Vault through a symlink",
|
||||
parent: "notes",
|
||||
vault: "vault",
|
||||
alias: true,
|
||||
resolveAlias: true,
|
||||
},
|
||||
{
|
||||
name: "a hidden-parent Vault through a preserved symlink",
|
||||
parent: ".obsidian",
|
||||
vault: "vault",
|
||||
alias: true,
|
||||
resolveAlias: false,
|
||||
},
|
||||
{
|
||||
name: "a hidden-parent Vault by physical path",
|
||||
parent: ".obsidian",
|
||||
vault: "vault",
|
||||
alias: false,
|
||||
resolveAlias: true,
|
||||
},
|
||||
{
|
||||
name: "a hidden-parent Vault through a resolved symlink",
|
||||
parent: ".obsidian",
|
||||
vault: "vault",
|
||||
alias: true,
|
||||
resolveAlias: true,
|
||||
},
|
||||
{ name: "a dot-prefixed Vault root", parent: "notes", vault: ".vault", alias: false, resolveAlias: true },
|
||||
{
|
||||
name: "a Vault below a database-like parent",
|
||||
parent: "archive-livesync-v2",
|
||||
vault: "vault",
|
||||
alias: false,
|
||||
resolveAlias: true,
|
||||
},
|
||||
{
|
||||
name: "a database-like Vault root",
|
||||
parent: "notes",
|
||||
vault: "vault-livesync-v2",
|
||||
alias: false,
|
||||
resolveAlias: true,
|
||||
},
|
||||
])(
|
||||
"keeps monitoring files in $name",
|
||||
async ({ parent, vault, alias, resolveAlias }) => {
|
||||
const fixture = await mkdtemp(join(tmpdir(), "livesync-watch-root-"));
|
||||
const physicalVault = join(fixture, parent, vault);
|
||||
const aliasPath = join(fixture, "livesync-vault");
|
||||
const events: WatchEvent[] = [];
|
||||
const diagnostics: unknown[] = [];
|
||||
await mkdir(physicalVault, { recursive: true });
|
||||
await symlink(physicalVault, aliasPath, "dir");
|
||||
const suppliedPath = alias ? aliasPath : physicalVault;
|
||||
const watchRoot = resolveAlias ? await realpath(suppliedPath) : suppliedPath;
|
||||
const adapter = new CLIStorageEventManagerAdapter(watchRoot, undefined, true, (message, detail) =>
|
||||
diagnostics.push({ message, detail })
|
||||
);
|
||||
|
||||
try {
|
||||
await adapter.watch.beginWatch(makeHandlers(events));
|
||||
const noteName = "new-note.md";
|
||||
const notePath = join(physicalVault, noteName);
|
||||
await writeFile(notePath, "created after watcher readiness");
|
||||
const created = await waitFor(() =>
|
||||
events.some((event) => event.type === "create" && !event.folder && event.path === noteName)
|
||||
);
|
||||
expect(await readFile(notePath, "utf8")).toBe("created after watcher readiness");
|
||||
expect(diagnostics).toEqual([]);
|
||||
expect(created, `Expected a new-file event below ${watchRoot}`).toBe(true);
|
||||
|
||||
await writeFile(notePath, "modified after receiving the creation event");
|
||||
expect(
|
||||
await waitFor(() => events.some((event) => event.type === "change" && event.path === noteName))
|
||||
).toBe(true);
|
||||
await unlink(notePath);
|
||||
expect(
|
||||
await waitFor(() => events.some((event) => event.type === "delete" && event.path === noteName))
|
||||
).toBe(true);
|
||||
} finally {
|
||||
await adapter.close();
|
||||
await rm(fixture, { recursive: true, force: true });
|
||||
}
|
||||
},
|
||||
20_000
|
||||
);
|
||||
|
||||
it("preserves monitoring through a descendant symlink to another directory", async () => {
|
||||
const fixture = await mkdtemp(join(tmpdir(), "livesync-watch-child-link-"));
|
||||
const root = join(fixture, "vault");
|
||||
const target = join(fixture, "external");
|
||||
await mkdir(root);
|
||||
await mkdir(target);
|
||||
await symlink(target, join(root, "linked"), "dir");
|
||||
const events: WatchEvent[] = [];
|
||||
const adapter = new CLIStorageEventManagerAdapter(root, undefined, true);
|
||||
try {
|
||||
await adapter.watch.beginWatch(makeHandlers(events));
|
||||
await writeFile(join(target, "linked-note.md"), "written through the physical target");
|
||||
expect(
|
||||
await waitFor(() =>
|
||||
events.some((event) => event.type === "create" && event.path === "linked/linked-note.md")
|
||||
)
|
||||
).toBe(true);
|
||||
} finally {
|
||||
await adapter.close();
|
||||
await rm(fixture, { recursive: true, force: true });
|
||||
}
|
||||
}, 15_000);
|
||||
|
||||
it("reports all 400 files when another process creates directories before dot-temp renames", async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), "livesync-issue1328-burst-"));
|
||||
const events: WatchEvent[] = [];
|
||||
@@ -111,40 +223,48 @@ describe("CLI filesystem watcher with a real filesystem", () => {
|
||||
}
|
||||
}, 30_000);
|
||||
|
||||
it("ignores initial, hidden, and custom-ignored files while watching visible files", async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), "livesync-real-watch-ignore-"));
|
||||
const events: WatchEvent[] = [];
|
||||
const ignoreDirectory = join(root, ".livesync");
|
||||
await mkdir(ignoreDirectory);
|
||||
await writeFile(join(ignoreDirectory, "ignore"), "private/\n");
|
||||
const ignoreRules = new IgnoreRules(root);
|
||||
await ignoreRules.load();
|
||||
await mkdir(join(root, "private"));
|
||||
await writeFile(join(root, "present-before-watch.md"), "initial file");
|
||||
await writeFile(join(root, ".hidden-before-watch.md"), "initial hidden file");
|
||||
const adapter = new CLIStorageEventManagerAdapter(root, ignoreRules, true);
|
||||
it.each(["visible-parent", ".hidden-parent"])(
|
||||
"ignores hidden, internal, and custom-ignored entries below %s",
|
||||
async (parent) => {
|
||||
const fixture = await mkdtemp(join(tmpdir(), "livesync-real-watch-ignore-"));
|
||||
const root = join(fixture, parent, "vault");
|
||||
await mkdir(root, { recursive: true });
|
||||
const events: WatchEvent[] = [];
|
||||
const ignoreDirectory = join(root, ".livesync");
|
||||
await mkdir(ignoreDirectory);
|
||||
await writeFile(join(ignoreDirectory, "ignore"), "private/\n");
|
||||
const ignoreRules = new IgnoreRules(root);
|
||||
await ignoreRules.load();
|
||||
await mkdir(join(root, "private"));
|
||||
await mkdir(join(root, "local-livesync-v2"));
|
||||
await writeFile(join(root, "present-before-watch.md"), "initial file");
|
||||
await writeFile(join(root, ".hidden-before-watch.md"), "initial hidden file");
|
||||
const adapter = new CLIStorageEventManagerAdapter(root, ignoreRules, true);
|
||||
|
||||
try {
|
||||
await adapter.watch.beginWatch(makeHandlers(events));
|
||||
await writeFile(join(root, "visible-after-watch.md"), "visible");
|
||||
await writeFile(join(root, ".hidden-after-watch.md"), "hidden");
|
||||
await writeFile(join(root, "private", "ignored-after-watch.md"), "custom ignored");
|
||||
try {
|
||||
await adapter.watch.beginWatch(makeHandlers(events));
|
||||
await writeFile(join(root, "visible-after-watch.md"), "visible");
|
||||
await writeFile(join(root, ".hidden-after-watch.md"), "hidden");
|
||||
await writeFile(join(root, "private", "ignored-after-watch.md"), "custom ignored");
|
||||
await writeFile(join(root, "local-livesync-v2", "state"), "internal database");
|
||||
|
||||
expect(
|
||||
await waitFor(() =>
|
||||
events.some((event) => event.type === "create" && event.path === "visible-after-watch.md")
|
||||
)
|
||||
).toBe(true);
|
||||
await delay(700);
|
||||
expect(
|
||||
await waitFor(() =>
|
||||
events.some((event) => event.type === "create" && event.path === "visible-after-watch.md")
|
||||
)
|
||||
).toBe(true);
|
||||
await delay(700);
|
||||
|
||||
expect(events.filter((event) => !event.folder)).toEqual([
|
||||
{ type: "create", path: "visible-after-watch.md", folder: false },
|
||||
]);
|
||||
} finally {
|
||||
await adapter.close();
|
||||
await rm(root, { recursive: true, force: true });
|
||||
}
|
||||
}, 15_000);
|
||||
expect(events.filter((event) => !event.folder)).toEqual([
|
||||
{ type: "create", path: "visible-after-watch.md", folder: false },
|
||||
]);
|
||||
} finally {
|
||||
await adapter.close();
|
||||
await rm(fixture, { recursive: true, force: true });
|
||||
}
|
||||
},
|
||||
15_000
|
||||
);
|
||||
|
||||
it("reports changes, deletions, rename paths, and stops after close", async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), "livesync-real-watch-lifecycle-"));
|
||||
|
||||
@@ -16,6 +16,12 @@ import { fsPromises as fs, getNodeFileCreationTime, path, type Stats } from "@vr
|
||||
import type { CliDiagnosticReporter } from "@/apps/cli/cliOutput";
|
||||
import { NodeTypeGuardAdapter } from "@/apps/cli/adapters/NodeTypeGuardAdapter";
|
||||
import { writeFileAtomically } from "@/apps/cli/runtimeState";
|
||||
import {
|
||||
isIgnoredVaultPath,
|
||||
toVaultRelativePath,
|
||||
type VaultRelativePath,
|
||||
type CLIPathFilterOptions,
|
||||
} from "@/apps/cli/cliPathFilter";
|
||||
|
||||
/**
|
||||
* CLI-specific persistence adapter (file-based snapshot)
|
||||
@@ -86,18 +92,43 @@ class CLIConverterAdapter implements IStorageEventConverterAdapter<NodeFile> {
|
||||
* CLI-specific watch adapter using chokidar for real-time filesystem monitoring.
|
||||
*/
|
||||
class CLIWatchAdapter implements IStorageEventWatchAdapter {
|
||||
/** Retain the handle owned by this adapter so normal unload can close its watcher. */
|
||||
private _watcher: FSWatcher | undefined;
|
||||
|
||||
constructor(
|
||||
private basePath: string,
|
||||
private readonly basePath: string,
|
||||
private ignoreRules?: IgnoreRules,
|
||||
private watchEnabled: boolean = false,
|
||||
private reportDiagnostic: CliDiagnosticReporter = () => undefined
|
||||
private reportDiagnostic: CliDiagnosticReporter = () => undefined,
|
||||
private readonly pathFilterOptions: CLIPathFilterOptions = {}
|
||||
) {}
|
||||
|
||||
private _toNodeFile(filePath: string, stats: Stats | undefined): NodeFile {
|
||||
// Bind conversion to the selected root; event callbacks cannot substitute a different root.
|
||||
private _toVaultRelativePath(candidatePath: string): VaultRelativePath | undefined {
|
||||
return toVaultRelativePath(this.basePath, candidatePath);
|
||||
}
|
||||
|
||||
// Unlike an ignored-callback probe, an invalid emitted event is unexpected.
|
||||
// Report it and let the caller omit that event while later valid events continue.
|
||||
private _getEventPath(candidatePath: string): VaultRelativePath | undefined {
|
||||
const relativePath = this._toVaultRelativePath(candidatePath);
|
||||
if (relativePath === undefined) {
|
||||
this.reportDiagnostic(
|
||||
"[CLIWatchAdapter] File watcher event was ignored because its path is not valid within the selected Vault.",
|
||||
{ path: candidatePath, vaultPath: this.basePath }
|
||||
);
|
||||
}
|
||||
return relativePath;
|
||||
}
|
||||
|
||||
// Deleted files still have a lexical event path, so conversion must not require filesystem existence.
|
||||
private _toNodeFile(filePath: string, stats: Stats | undefined): NodeFile | false {
|
||||
const relativePath = this._getEventPath(filePath);
|
||||
if (relativePath === undefined) {
|
||||
return false;
|
||||
}
|
||||
return {
|
||||
path: path.relative(this.basePath, filePath).replace(/\\/g, "/") as FilePath,
|
||||
path: relativePath,
|
||||
stat: {
|
||||
// Floor to integer milliseconds; Linux fs.Stats.*Ms carry sub-millisecond
|
||||
// precision, and timestamps are stored as integer ms everywhere else.
|
||||
@@ -109,25 +140,28 @@ class CLIWatchAdapter implements IStorageEventWatchAdapter {
|
||||
};
|
||||
}
|
||||
|
||||
private _toNodeFolder(dirPath: string): NodeFolder {
|
||||
private _toNodeFolder(dirPath: string): NodeFolder | false {
|
||||
const relativePath = this._getEventPath(dirPath);
|
||||
// The empty relative path is a valid root-folder event, so test the failure sentinel explicitly.
|
||||
if (relativePath === undefined) {
|
||||
return false;
|
||||
}
|
||||
return {
|
||||
path: path.relative(this.basePath, dirPath).replace(/\\/g, "/") as FilePath,
|
||||
path: relativePath,
|
||||
isFolder: true,
|
||||
};
|
||||
}
|
||||
|
||||
async beginWatch(handlers: IStorageEventWatchHandlers): Promise<void> {
|
||||
if (!this.watchEnabled) return;
|
||||
const baseIgnored: Array<RegExp | string | ((p: string) => boolean)> = [
|
||||
/(^|[/\\])\./,
|
||||
/(^|[/\\])[^/\\]*-livesync-v2([/\\]|$)/,
|
||||
];
|
||||
// Bind rules to a local const before the closure — chokidar v4 requires a
|
||||
// MatchFunction, not glob strings, for custom patterns.
|
||||
const rules = this.ignoreRules;
|
||||
const ignored = rules
|
||||
? [...baseIgnored, (p: string) => rules.shouldIgnore(path.relative(this.basePath, p))]
|
||||
: baseIgnored;
|
||||
// Chokidar may probe ancestors of the root; exclude those candidates quietly.
|
||||
// Entry exclusions see only the rebased path, preserving Vaults beneath hidden parents.
|
||||
const ignored = (candidatePath: string): boolean => {
|
||||
const relativePath = this._toVaultRelativePath(candidatePath);
|
||||
return (
|
||||
relativePath === undefined || isIgnoredVaultPath(relativePath, this.ignoreRules, this.pathFilterOptions)
|
||||
);
|
||||
};
|
||||
|
||||
const watcher = chokidarWatch(this.basePath, {
|
||||
ignored,
|
||||
@@ -141,27 +175,37 @@ class CLIWatchAdapter implements IStorageEventWatchAdapter {
|
||||
|
||||
watcher.on("add", (filePath, stats) => {
|
||||
const nodeFile = this._toNodeFile(filePath, stats);
|
||||
handlers.onCreate(nodeFile);
|
||||
if (nodeFile !== false) {
|
||||
handlers.onCreate(nodeFile);
|
||||
}
|
||||
});
|
||||
|
||||
watcher.on("change", (filePath, stats) => {
|
||||
const nodeFile = this._toNodeFile(filePath, stats);
|
||||
handlers.onChange(nodeFile);
|
||||
if (nodeFile !== false) {
|
||||
handlers.onChange(nodeFile);
|
||||
}
|
||||
});
|
||||
|
||||
watcher.on("unlink", (filePath) => {
|
||||
const nodeFile = this._toNodeFile(filePath, undefined);
|
||||
handlers.onDelete(nodeFile);
|
||||
if (nodeFile !== false) {
|
||||
handlers.onDelete(nodeFile);
|
||||
}
|
||||
});
|
||||
|
||||
watcher.on("addDir", (dirPath) => {
|
||||
const nodeFolder = this._toNodeFolder(dirPath);
|
||||
handlers.onCreate(nodeFolder);
|
||||
if (nodeFolder !== false) {
|
||||
handlers.onCreate(nodeFolder);
|
||||
}
|
||||
});
|
||||
|
||||
watcher.on("unlinkDir", (dirPath) => {
|
||||
const nodeFolder = this._toNodeFolder(dirPath);
|
||||
handlers.onDelete(nodeFolder);
|
||||
if (nodeFolder !== false) {
|
||||
handlers.onDelete(nodeFolder);
|
||||
}
|
||||
});
|
||||
|
||||
watcher.on("error", (err) => {
|
||||
@@ -173,6 +217,7 @@ class CLIWatchAdapter implements IStorageEventWatchAdapter {
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
// Complete watch startup only after Chokidar has finished its initial discovery.
|
||||
await new Promise<void>((resolve) => watcher.once("ready", resolve));
|
||||
this._watcher = watcher;
|
||||
}
|
||||
@@ -200,11 +245,12 @@ export class CLIStorageEventManagerAdapter implements IStorageEventManagerAdapte
|
||||
ignoreRules?: IgnoreRules,
|
||||
watchEnabled: boolean = false,
|
||||
reportDiagnostic: CliDiagnosticReporter = () => undefined,
|
||||
snapshotPath?: () => string
|
||||
snapshotPath?: () => string,
|
||||
pathFilterOptions?: CLIPathFilterOptions
|
||||
) {
|
||||
this.typeGuard = new NodeTypeGuardAdapter();
|
||||
this.persistence = new CLIPersistenceAdapter(basePath, reportDiagnostic, snapshotPath);
|
||||
this.watch = new CLIWatchAdapter(basePath, ignoreRules, watchEnabled, reportDiagnostic);
|
||||
this.watch = new CLIWatchAdapter(basePath, ignoreRules, watchEnabled, reportDiagnostic, pathFilterOptions);
|
||||
this.status = new CLIStatusAdapter();
|
||||
this.converter = new CLIConverterAdapter();
|
||||
}
|
||||
|
||||
@@ -67,6 +67,69 @@ describe("CLIStorageEventManagerAdapter", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("filters only checked Vault-relative candidates and keeps its root watchable", async () => {
|
||||
const base = path.resolve(path.sep, ".parent", "vault-livesync-v2");
|
||||
const reporter = vi.fn();
|
||||
const adapter = new CLIStorageEventManagerAdapter(base, undefined, true, reporter);
|
||||
await adapter.watch.beginWatch(makeHandlers());
|
||||
const options = vi.mocked(chokidar.watch).mock.calls[0][1];
|
||||
const ignored = options?.ignored;
|
||||
if (typeof ignored !== "function") {
|
||||
throw new Error("Expected the typed watcher filter");
|
||||
}
|
||||
expect(ignored(base)).toBe(false);
|
||||
expect(ignored(path.join(base, "note.md"))).toBe(false);
|
||||
expect(ignored(path.join(base, ".hidden.md"))).toBe(true);
|
||||
expect(ignored(path.join(base, "local-livesync-v2", "state"))).toBe(true);
|
||||
expect(ignored(path.dirname(base))).toBe(true);
|
||||
expect(ignored(path.join(base + "-sibling", "note.md"))).toBe(true);
|
||||
expect(ignored("relative.md")).toBe(true);
|
||||
expect(reporter).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each([
|
||||
["add", "onCreate"],
|
||||
["change", "onChange"],
|
||||
["unlink", "onDelete"],
|
||||
["addDir", "onCreate"],
|
||||
["unlinkDir", "onDelete"],
|
||||
] as const)("omits an invalid %s event and still delivers the next valid event", async (event, handler) => {
|
||||
const base = path.resolve(path.sep, "vault");
|
||||
const reporter = vi.fn();
|
||||
const adapter = new CLIStorageEventManagerAdapter(base, undefined, true, reporter);
|
||||
const handlers = makeHandlers();
|
||||
await adapter.watch.beginWatch(handlers);
|
||||
const registration = mockWatcher.on.mock.calls.find(([name]) => name === event);
|
||||
const callback = registration?.[1] as ((candidate: string) => void) | undefined;
|
||||
if (!callback) {
|
||||
throw new Error(`Expected the ${event} handler`);
|
||||
}
|
||||
const outside = path.resolve(base, "..", "outside");
|
||||
callback(outside);
|
||||
expect(handlers[handler]).not.toHaveBeenCalled();
|
||||
expect(reporter).toHaveBeenCalledWith(expect.stringContaining("ignored"), {
|
||||
path: outside,
|
||||
vaultPath: base,
|
||||
});
|
||||
|
||||
callback(path.join(base, "valid"));
|
||||
expect(handlers[handler]).toHaveBeenCalledTimes(1);
|
||||
expect(handlers[handler]).toHaveBeenCalledWith(expect.objectContaining({ path: "valid" }));
|
||||
expect(reporter).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("preserves folder events for the Vault root", async () => {
|
||||
const base = path.resolve(path.sep, "vault");
|
||||
const adapter = new CLIStorageEventManagerAdapter(base, undefined, true);
|
||||
const handlers = makeHandlers();
|
||||
await adapter.watch.beginWatch(handlers);
|
||||
const callback = mockWatcher.on.mock.calls.find(([event]) => event === "unlinkDir")?.[1] as (
|
||||
candidate: string
|
||||
) => void;
|
||||
callback(base);
|
||||
expect(handlers.onDelete).toHaveBeenCalledWith(expect.objectContaining({ path: "", isFolder: true }));
|
||||
});
|
||||
|
||||
it("beginWatch is no-op when watchEnabled=false", async () => {
|
||||
const adapter = new CLIStorageEventManagerAdapter("/base", undefined, false);
|
||||
const handlers = makeHandlers();
|
||||
|
||||
@@ -6,11 +6,11 @@ import { CLIStorageEventManagerAdapter } from "./CLIStorageEventManagerAdapter";
|
||||
import type { IMinimumLiveSyncCommands, LiveSyncBaseCore } from "@/LiveSyncBaseCore";
|
||||
import type { ServiceContext } from "@vrtmrz/livesync-commonlib/context";
|
||||
import type { IgnoreRules } from "@/apps/cli/serviceModules/IgnoreRules";
|
||||
import type { CLIPathFilterOptions } from "@/apps/cli/cliPathFilter";
|
||||
import { LOG_LEVEL_NOTICE } from "octagonal-wheels/common/logger";
|
||||
import { runtimeScopeDirectory } from "@/apps/cli/runtimeState";
|
||||
import type { NodeServiceContext } from "@/apps/cli/services/NodeServiceContext";
|
||||
import { path as nodePath } from "@vrtmrz/livesync-commonlib/node";
|
||||
// import type { IMinimumLiveSyncCommands } from "@vrtmrz/livesync-commonlib/compat/services/base/IService";
|
||||
|
||||
export class StorageEventManagerCLI extends StorageEventManagerBase<CLIStorageEventManagerAdapter> {
|
||||
core: LiveSyncBaseCore<ServiceContext, IMinimumLiveSyncCommands>;
|
||||
@@ -20,7 +20,8 @@ export class StorageEventManagerCLI extends StorageEventManagerBase<CLIStorageEv
|
||||
core: LiveSyncBaseCore<ServiceContext, IMinimumLiveSyncCommands>,
|
||||
dependencies: StorageEventManagerBaseDependencies,
|
||||
ignoreRules?: IgnoreRules,
|
||||
watchEnabled?: boolean
|
||||
watchEnabled?: boolean,
|
||||
pathFilterOptions?: CLIPathFilterOptions
|
||||
) {
|
||||
const context = core.services.context as NodeServiceContext;
|
||||
const adapter = new CLIStorageEventManagerAdapter(
|
||||
@@ -33,23 +34,22 @@ export class StorageEventManagerCLI extends StorageEventManagerBase<CLIStorageEv
|
||||
dependencies.APIService.addLog(detail, LOG_LEVEL_NOTICE);
|
||||
}
|
||||
},
|
||||
// Fetch/rebuild can change the selected suffix after construction; resolve
|
||||
// the snapshot location when used so it follows the current DB/Vault scope.
|
||||
() =>
|
||||
nodePath.join(
|
||||
runtimeScopeDirectory(context.databasePath, dependencies.vaultService.getVaultName(), basePath),
|
||||
"storage-events.json"
|
||||
)
|
||||
),
|
||||
pathFilterOptions
|
||||
);
|
||||
super(adapter, dependencies);
|
||||
this.core = core;
|
||||
}
|
||||
|
||||
/**
|
||||
* Override _watchVaultRawEvents for CLI-specific logic
|
||||
* In CLI, we don't have internal files like Obsidian's .obsidian folder
|
||||
*/
|
||||
/** The CLI uses shared exclusions instead of the plug-in's raw internal-file event processing. */
|
||||
protected override async _watchVaultRawEvents(path: string) {
|
||||
// No-op in CLI version
|
||||
// Internal file handling is not needed
|
||||
// Hidden and internal entries are intentionally not queued as raw Vault events.
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
"buildRun": "npm run build && npm run cli --",
|
||||
"build:docker": "docker build -f Dockerfile -t livesync-cli ../../..",
|
||||
"check": "tsc -p tsconfig.json",
|
||||
"test:unit": "cd ../../.. && npx vitest run --config vitest.config.unit.ts src/apps/cli/main.unit.spec.ts src/apps/cli/main.bootstrap.unit.spec.ts src/apps/cli/settingsPersistence.unit.spec.ts src/apps/cli/runtimeState.unit.spec.ts src/apps/cli/services/NodeKeyValueDBService.unit.spec.ts src/apps/cli/services/NodeLocalStorage.unit.spec.ts src/apps/cli/services/storageCaseSensitivity.unit.spec.ts src/apps/cli/managers/CLIStorageEventManagerAdapter.unit.spec.ts src/apps/cli/managers/CLIStorageEventManagerAdapter.real-watch.unit.spec.ts src/apps/cli/commands/utils.unit.spec.ts src/apps/cli/commands/runCommand.unit.spec.ts src/apps/cli/commands/journalSkip.unit.spec.ts src/apps/cli/commands/daemonCommand.unit.spec.ts src/apps/cli/commands/p2p.unit.spec.ts src/apps/cli/deploy/install.unit.spec.ts src/apps/cli/adapters/NodeFileSystemAdapter.unit.spec.ts",
|
||||
"test:unit": "cd ../../.. && npx vitest run --config vitest.config.unit.ts src/apps/cli/main.unit.spec.ts src/apps/cli/cliPathFilter.unit.spec.ts src/apps/cli/main.bootstrap.unit.spec.ts src/apps/cli/settingsPersistence.unit.spec.ts src/apps/cli/runtimeState.unit.spec.ts src/apps/cli/services/NodeKeyValueDBService.unit.spec.ts src/apps/cli/services/NodeLocalStorage.unit.spec.ts src/apps/cli/services/storageCaseSensitivity.unit.spec.ts src/apps/cli/managers/CLIStorageEventManagerAdapter.unit.spec.ts src/apps/cli/managers/CLIStorageEventManagerAdapter.real-watch.unit.spec.ts src/apps/cli/commands/utils.unit.spec.ts src/apps/cli/commands/runCommand.unit.spec.ts src/apps/cli/commands/journalSkip.unit.spec.ts src/apps/cli/commands/daemonCommand.unit.spec.ts src/apps/cli/commands/p2p.unit.spec.ts src/apps/cli/deploy/install.unit.spec.ts src/apps/cli/adapters/NodeFileSystemAdapter.unit.spec.ts",
|
||||
"test:e2e:two-vaults": "bash test/test-e2e-two-vaults-with-docker-linux.sh",
|
||||
"test:e2e:two-vaults:common": "bash test/test-e2e-two-vaults-common.sh",
|
||||
"test:e2e:two-vaults:matrix": "bash test/test-e2e-two-vaults-matrix.sh",
|
||||
|
||||
@@ -8,23 +8,27 @@ import { ServiceDatabaseFileAccessCLI } from "./DatabaseFileAccess";
|
||||
import { StorageEventManagerCLI } from "@/apps/cli/managers/StorageEventManagerCLI";
|
||||
import type { ServiceModules } from "@vrtmrz/livesync-commonlib/compat/interfaces/ServiceModule";
|
||||
import type { IgnoreRules } from "./IgnoreRules";
|
||||
import type { CLIPathFilterOptions } from "@/apps/cli/cliPathFilter";
|
||||
import { composeSharedServiceModules } from "@/apps/serviceModules/composeSharedServiceModules";
|
||||
|
||||
/**
|
||||
* Initialize service modules for CLI version
|
||||
* This is the CLI equivalent of ObsidianLiveSyncPlugin.initialiseServiceModules
|
||||
* Compose CLI storage, watching, and database access with the shared service modules.
|
||||
*
|
||||
* @param basePath - The base path of the vault directory
|
||||
* @param basePath - Selected Vault root used for storage and event paths
|
||||
* @param core - The LiveSyncBaseCore instance
|
||||
* @param services - The service hub
|
||||
* @returns ServiceModules containing all initialized service modules
|
||||
* @param ignoreRules - User exclusions loaded before watch startup
|
||||
* @param watchEnabled - Whether this command owns a live filesystem watcher
|
||||
* @param pathFilterOptions - The same runtime exclusions used by logical target checks
|
||||
* @returns CLI service modules bound to this Vault
|
||||
*/
|
||||
export function initialiseServiceModulesCLI(
|
||||
basePath: string,
|
||||
core: LiveSyncBaseCore<ServiceContext, never>,
|
||||
services: InjectableServiceHub<ServiceContext>,
|
||||
ignoreRules?: IgnoreRules,
|
||||
watchEnabled: boolean = false
|
||||
watchEnabled: boolean = false,
|
||||
pathFilterOptions?: CLIPathFilterOptions
|
||||
): ServiceModules {
|
||||
const storageAccessManager = new StorageAccessManager();
|
||||
|
||||
@@ -37,7 +41,7 @@ export function initialiseServiceModulesCLI(
|
||||
pathService: services.path,
|
||||
});
|
||||
|
||||
// CLI-specific storage event manager
|
||||
// Live monitoring receives the same runtime policy as the core target checks.
|
||||
const storageEventManager = new StorageEventManagerCLI(
|
||||
basePath,
|
||||
core,
|
||||
@@ -49,7 +53,8 @@ export function initialiseServiceModulesCLI(
|
||||
APIService: services.API,
|
||||
},
|
||||
ignoreRules,
|
||||
watchEnabled
|
||||
watchEnabled,
|
||||
pathFilterOptions
|
||||
);
|
||||
|
||||
// Close the file watcher during graceful shutdown so the process can exit cleanly.
|
||||
|
||||
Reference in new issue
Block a user