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:
vorotamoroz committed 2026-11-12 08:51:11 +00:00
1 parent 78ebc2d298
commit 116c2dfbfb
20 files changed
+1703 -107

No files matched your search

+26
View File
@@ -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).
+92
View File
@@ -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);
}
+138
View File
@@ -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);
});
});
+2
View File
@@ -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;
+124 -22
View File
@@ -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
View File
@@ -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);
}
+16
View File
@@ -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.
}
/**
+1 -1
View File
@@ -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.