mirror of
https://github.com/vrtmrz/obsidian-livesync.git
synced 2026-10-23 09:33:20 +00:00
Add opt-in Unicode NFC path identity for CouchDB
This commit is contained in:
1 parent
72dc88c7ef
commit
ddb4d52931
39 files changed
+2245
-97
No files matched your search
+45
-3
@@ -64,9 +64,10 @@ livesync-cli [database-path] [command] [args...]
|
||||
|
||||
- `database-path`: Path to the directory where `.livesync` folder and `settings.json` are (or will be) located.
|
||||
- Note: In previous versions, this was referred to as the "vault" path. Now it is clearly distinguished from the actual vault (the directory containing your `.md` files).
|
||||
- `--vault <path>` / `-V <path>`: (daemon/mirror only) Path to the vault directory containing `.md` files.
|
||||
- `--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`.
|
||||
- `--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.
|
||||
- All remaining commands leave the settings file unchanged by default.
|
||||
@@ -82,6 +83,9 @@ livesync-cli [database-path] [command] [args...]
|
||||
- If `vault-path` is provided, the CLI will synchronise the database with files in the vault directory.
|
||||
- If `vault-path` is omitted, it defaults to `database-path` (compatibility mode).
|
||||
- Use this command to keep your local `.md` files in sync with the database.
|
||||
- `rebuild`: Rebuild the local and remote databases from the selected Vault. This replaces existing local and remote database state and requires `--force`.
|
||||
- This command may select a new local database suffix. Add `--write-settings` if later CLI commands should use the resulting database selection; otherwise the existing settings file remains unchanged.
|
||||
- `fetch`: Replace the local database with the configured remote state, then reflect fetched files to the selected Vault. Existing local database state is discarded, so this command requires `--force`.
|
||||
- `ls [prefix]`: List files currently stored in the local database.
|
||||
- `push <src> <dst>`: Push a local file `<src>` into the database at path `<dst>`.
|
||||
- `pull <src> <dst>`: Pull a file `<src>` from the database into local file `<dst>`.
|
||||
@@ -110,6 +114,12 @@ livesync-cli ./my-db sync
|
||||
# Mirroring to your actual Obsidian vault
|
||||
livesync-cli ./my-db mirror /path/to/obsidian-vault
|
||||
|
||||
# Rebuild local and remote databases from an authoritative Vault
|
||||
livesync-cli ./my-db --vault /path/to/obsidian-vault rebuild --force --write-settings
|
||||
|
||||
# Replace the local database and Vault files from the configured remote
|
||||
livesync-cli ./my-db --vault /path/to/obsidian-vault fetch --force --write-settings
|
||||
|
||||
# Manual file operations
|
||||
livesync-cli ./my-db push ./note.md folder/note.md
|
||||
livesync-cli ./my-db pull folder/note.md ./note.md
|
||||
@@ -369,6 +379,36 @@ The CLI uses the same settings format as the Obsidian plug-in. Create a `.livesy
|
||||
- `couchDB_DBNAME`: Database name
|
||||
- `isConfigured`: Set to `true` after configuration
|
||||
|
||||
#### Unicode path identity
|
||||
|
||||
The `pathNormalizationVersion` setting controls how file paths identify
|
||||
Metadata documents when using CouchDB. An absent value or `0` keeps the legacy
|
||||
identity. Version `1` uses Unicode Normalization Form C (NFC) for Metadata IDs
|
||||
and path comparison keys. It does not rewrite `Metadata.path`, rename existing
|
||||
files, or change Chunks or file content. `handleFilenameCaseSensitive` remains
|
||||
an independent case-handling setting; the CLI resolves NFC-equivalent names
|
||||
even when case-sensitive handling is enabled.
|
||||
|
||||
When the CLI resolves an existing path, it uses the actual spelling of each
|
||||
matching file or directory for reads, writes, deletes, `stat` calls, directory
|
||||
creation, and renames. If a path component is missing, it uses the actual
|
||||
existing parent and preserves the spelling received for the new component. If
|
||||
a file or any parent directory has multiple candidates for the same logical
|
||||
key after NFC and the existing case rule, the CLI warns and refuses the
|
||||
operation. It does not choose a candidate based on directory order, merge
|
||||
entries, or rename them.
|
||||
|
||||
Version `1` requires a coordinated Rebuild and Fetch; changing the setting by
|
||||
itself does not migrate a database. Update every participating client, stop
|
||||
synchronisation, and back up a complete authoritative Vault. Set
|
||||
`pathNormalizationVersion` to `1` on the source client, then run `rebuild`
|
||||
with `--force --write-settings`. Set the same value on each remaining CLI
|
||||
client, then run `fetch` with `--force --write-settings`. Use **Overwrite
|
||||
Server Data with This Device's Files** and **Reset Synchronisation on This
|
||||
Device** for the corresponding Obsidian Rebuild and Fetch operations. Resolve
|
||||
any ambiguous names before reconstruction. Version `1` is supported with
|
||||
CouchDB only; do not resume a legacy client against the rebuilt database.
|
||||
|
||||
### Command-line Reference
|
||||
|
||||
```
|
||||
@@ -381,11 +421,11 @@ Arguments:
|
||||
|
||||
Options:
|
||||
--settings, -s <path> Path to settings file (default: .livesync/settings.json in local database directory)
|
||||
--force, -f Overwrite existing file on init-settings
|
||||
--force, -f Required by rebuild/fetch; overwrite existing file on init-settings
|
||||
--verbose, -v Enable verbose logging
|
||||
--debug, -d Enable debug logging (includes verbose)
|
||||
--interval <N>, -i <N> (daemon only) Poll CouchDB every N seconds instead of using the _changes feed
|
||||
--vault <path>, -V <path> (daemon/mirror) Path to vault directory, decoupled from database-path
|
||||
--vault <path>, -V <path> (daemon/mirror/rebuild/fetch) Path to vault directory, decoupled from database-path
|
||||
--write-settings Write setting changes after a successful command
|
||||
--compat-remote-admin-exit-zero
|
||||
Preserve the former zero exit code when remote-administration verification fails
|
||||
@@ -398,6 +438,8 @@ Commands:
|
||||
p2p-peers <timeout> Show discovered peers as [peer]<TAB><peer-id><TAB><peer-name>
|
||||
p2p-sync <peer> <timeout> Synchronise with specified peer-id or peer-name
|
||||
p2p-host Start P2P host mode and wait until interrupted (Ctrl+C)
|
||||
rebuild Rebuild local and remote databases from the selected Vault (requires --force)
|
||||
fetch Replace the local database with remote state and reflect it to the Vault (requires --force)
|
||||
push <src> <dst> Push local file <src> into local database path <dst>
|
||||
pull <src> <dst> Pull file <src> from local database into local file <dst>
|
||||
pull-rev <src> <dst> <rev> Pull specific revision <rev> into local file <dst>
|
||||
|
||||
@@ -3,7 +3,7 @@ import type { IFileSystemAdapter } from "@vrtmrz/livesync-commonlib/compat/servi
|
||||
import { NodePathAdapter } from "./NodePathAdapter";
|
||||
import { NodeTypeGuardAdapter } from "./NodeTypeGuardAdapter";
|
||||
import { NodeConversionAdapter } from "./NodeConversionAdapter";
|
||||
import { NodeStorageAdapter } from "@vrtmrz/livesync-commonlib/node";
|
||||
import { AmbiguousNormalisedPathError, UnicodeResolvingNodeStorageAdapter } from "./UnicodeResolvingNodeStorageAdapter";
|
||||
import { NodeVaultAdapter } from "./NodeVaultAdapter";
|
||||
import type { NodeFile, NodeFolder, NodeStat } from "./NodeTypes";
|
||||
import { path } from "@vrtmrz/livesync-commonlib/node";
|
||||
@@ -16,19 +16,21 @@ export class NodeFileSystemAdapter implements IFileSystemAdapter<NodeFile, NodeF
|
||||
readonly path: NodePathAdapter;
|
||||
readonly typeGuard: NodeTypeGuardAdapter;
|
||||
readonly conversion: NodeConversionAdapter;
|
||||
readonly storage: NodeStorageAdapter;
|
||||
readonly storage: UnicodeResolvingNodeStorageAdapter;
|
||||
readonly vault: NodeVaultAdapter;
|
||||
|
||||
private fileCache = new Map<string, NodeFile>();
|
||||
|
||||
constructor(
|
||||
private basePath: string,
|
||||
private reportDiagnostic: CliDiagnosticReporter = () => undefined
|
||||
private reportDiagnostic: CliDiagnosticReporter = () => undefined,
|
||||
getPathNormalizationVersion: () => number = () => 0,
|
||||
isCaseInsensitive: () => boolean = () => false
|
||||
) {
|
||||
this.path = new NodePathAdapter();
|
||||
this.typeGuard = new NodeTypeGuardAdapter();
|
||||
this.conversion = new NodeConversionAdapter();
|
||||
this.storage = new NodeStorageAdapter(basePath);
|
||||
this.storage = new UnicodeResolvingNodeStorageAdapter(basePath, getPathNormalizationVersion, isCaseInsensitive);
|
||||
this.vault = new NodeVaultAdapter(this.storage);
|
||||
}
|
||||
|
||||
@@ -58,6 +60,14 @@ export class NodeFileSystemAdapter implements IFileSystemAdapter<NodeFile, NodeF
|
||||
|
||||
async getAbstractFileByPath(p: FilePath | string): Promise<NodeFile | null> {
|
||||
const pathStr = this.normalisePath(p);
|
||||
if (this.storage.isUnicodePathNormalizationEnabled) {
|
||||
const resolvedPath = await this.storage.resolveLogicalPath(pathStr, false, false);
|
||||
if (resolvedPath === null) {
|
||||
this.fileCache.delete(pathStr);
|
||||
return null;
|
||||
}
|
||||
return await this.refreshResolvedFile(resolvedPath);
|
||||
}
|
||||
if (!this.fileCache.has(pathStr) && !(await this.hasExactPathCase(pathStr))) {
|
||||
this.fileCache.delete(pathStr);
|
||||
return null;
|
||||
@@ -68,6 +78,15 @@ export class NodeFileSystemAdapter implements IFileSystemAdapter<NodeFile, NodeF
|
||||
async getAbstractFileByPathInsensitive(p: FilePath | string): Promise<NodeFile | null> {
|
||||
const pathStr = this.normalisePath(p);
|
||||
|
||||
if (this.storage.isUnicodePathNormalizationEnabled) {
|
||||
const resolvedPath = await this.storage.resolveLogicalPath(pathStr, true, false);
|
||||
if (resolvedPath === null) {
|
||||
this.fileCache.delete(pathStr);
|
||||
return null;
|
||||
}
|
||||
return await this.refreshResolvedFile(resolvedPath);
|
||||
}
|
||||
|
||||
const exact = await this.getAbstractFileByPath(pathStr);
|
||||
if (exact) {
|
||||
return exact;
|
||||
@@ -115,17 +134,34 @@ export class NodeFileSystemAdapter implements IFileSystemAdapter<NodeFile, NodeF
|
||||
// This is used by Obsidian to sync internal file metadata
|
||||
}
|
||||
|
||||
async refreshFile(p: string): Promise<NodeFile | null> {
|
||||
async refreshFile(p: string, caseInsensitive = false): Promise<NodeFile | null> {
|
||||
const pathStr = this.normalisePath(p);
|
||||
try {
|
||||
const stat = await this.storage.stat(pathStr);
|
||||
if (stat?.type !== "file") {
|
||||
const resolvedPath = this.storage.isUnicodePathNormalizationEnabled
|
||||
? await this.storage.resolveLogicalPath(pathStr, caseInsensitive, false)
|
||||
: pathStr;
|
||||
if (resolvedPath === null) {
|
||||
this.fileCache.delete(pathStr);
|
||||
return null;
|
||||
}
|
||||
return await this.refreshResolvedFile(resolvedPath);
|
||||
} catch (error) {
|
||||
if (error instanceof AmbiguousNormalisedPathError) throw error;
|
||||
this.fileCache.delete(pathStr);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private async refreshResolvedFile(resolvedPath: string): Promise<NodeFile | null> {
|
||||
try {
|
||||
const stat = await this.storage.statExact(resolvedPath);
|
||||
if (stat?.type !== "file") {
|
||||
this.fileCache.delete(resolvedPath);
|
||||
return null;
|
||||
}
|
||||
|
||||
const file: NodeFile = {
|
||||
path: pathStr as FilePath,
|
||||
path: resolvedPath as FilePath,
|
||||
stat: {
|
||||
size: stat.size,
|
||||
mtime: stat.mtime,
|
||||
@@ -133,11 +169,12 @@ export class NodeFileSystemAdapter implements IFileSystemAdapter<NodeFile, NodeF
|
||||
type: "file",
|
||||
},
|
||||
};
|
||||
this.fileCache.set(pathStr, file);
|
||||
this.fileCache.set(resolvedPath, file);
|
||||
return file;
|
||||
} catch {
|
||||
} catch (error) {
|
||||
if (error instanceof AmbiguousNormalisedPathError) throw error;
|
||||
// Evict so a deleted file is not returned by subsequent cache scans.
|
||||
this.fileCache.delete(pathStr);
|
||||
this.fileCache.delete(resolvedPath);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -146,18 +183,21 @@ export class NodeFileSystemAdapter implements IFileSystemAdapter<NodeFile, NodeF
|
||||
* Helper method to recursively scan directory and populate file cache
|
||||
*/
|
||||
async scanDirectory(relativePath: string = ""): Promise<void> {
|
||||
await this.scanDirectoryInto(relativePath, this.fileCache);
|
||||
const resolvedPath = await this.storage.resolveLogicalPath(relativePath, false, false);
|
||||
if (resolvedPath !== null) await this.scanDirectoryInto(resolvedPath, this.fileCache);
|
||||
}
|
||||
|
||||
private async scanDirectoryInto(relativePath: string, files: Map<string, NodeFile>): Promise<void> {
|
||||
const fullPath = this.resolvePath(relativePath);
|
||||
try {
|
||||
const directoryStat = await this.storage.stat(relativePath);
|
||||
const directoryStat = this.storage.isUnicodePathNormalizationEnabled
|
||||
? await this.storage.statExact(relativePath)
|
||||
: await this.storage.stat(relativePath, false);
|
||||
if (directoryStat?.type !== "folder") throw new Error(`Directory does not exist: ${fullPath}`);
|
||||
const entries = await this.storage.list(relativePath);
|
||||
const entries = await this.storage.listExact(relativePath);
|
||||
|
||||
for (const entryPath of entries.files) {
|
||||
const stat = await this.storage.stat(entryPath);
|
||||
const stat = await this.storage.statExact(entryPath);
|
||||
if (stat?.type !== "file") continue;
|
||||
const file: NodeFile = {
|
||||
path: entryPath as FilePath,
|
||||
@@ -169,6 +209,7 @@ export class NodeFileSystemAdapter implements IFileSystemAdapter<NodeFile, NodeF
|
||||
await this.scanDirectoryInto(entryPath, files);
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof AmbiguousNormalisedPathError) throw error;
|
||||
// Directory doesn't exist or is not readable
|
||||
this.reportDiagnostic(`Error scanning directory ${fullPath}:`, error);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { fsPromises as fs, os, path } from "@vrtmrz/livesync-commonlib/node";
|
||||
import { fsPromises as fs, NodeStorageAdapter, os, path } from "@vrtmrz/livesync-commonlib/node";
|
||||
import { DEFAULT_SETTINGS, type FilePath } from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
import {
|
||||
ServiceRebuilder,
|
||||
type ServiceRebuilderDependencies,
|
||||
} from "@vrtmrz/livesync-commonlib/compat/serviceModules/Rebuilder";
|
||||
import { PathServiceCompat } from "@vrtmrz/livesync-commonlib/compat/services/implements/injectable/InjectablePathService";
|
||||
import { ServiceContext } from "@vrtmrz/livesync-commonlib/compat/services/base/ServiceBase";
|
||||
import { NodeFileSystemAdapter } from "./NodeFileSystemAdapter";
|
||||
|
||||
describe("NodeFileSystemAdapter file enumeration", () => {
|
||||
@@ -116,3 +123,382 @@ describe("NodeFileSystemAdapter file enumeration", () => {
|
||||
await expect(adapter.getFiles()).resolves.toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("NodeFileSystemAdapter Unicode path resolution", () => {
|
||||
const tempDirs: string[] = [];
|
||||
|
||||
async function createVault() {
|
||||
const directory = await fs.mkdtemp(path.join(os.tmpdir(), "livesync-cli-unicode-"));
|
||||
tempDirs.push(directory);
|
||||
return directory;
|
||||
}
|
||||
|
||||
function createNormalisingAdapter(directory: string, caseInsensitive = false) {
|
||||
return new NodeFileSystemAdapter(
|
||||
directory,
|
||||
undefined,
|
||||
() => 1,
|
||||
() => caseInsensitive
|
||||
);
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(tempDirs.splice(0).map((directory) => fs.rm(directory, { recursive: true, force: true })));
|
||||
});
|
||||
|
||||
it.each([0, 1])("keeps an NFD-only file in the storage listing in identity mode %s", async (version) => {
|
||||
const directory = await createVault();
|
||||
const originalPath = "café.md".normalize("NFD");
|
||||
await fs.writeFile(path.join(directory, originalPath), "original");
|
||||
const report = vi.fn();
|
||||
const adapter = new NodeFileSystemAdapter(directory, report, () => version);
|
||||
|
||||
expect((await adapter.getFiles()).map((file) => file.path)).toEqual([originalPath]);
|
||||
expect(report).not.toHaveBeenCalled();
|
||||
await expect(fs.readFile(path.join(directory, originalPath), "utf8")).resolves.toBe("original");
|
||||
expect(await fs.readdir(directory)).toEqual([originalPath]);
|
||||
});
|
||||
|
||||
it.each([false, true])(
|
||||
"enumerates nested mixed names once per directory with case-insensitive matching %s",
|
||||
async (caseInsensitive) => {
|
||||
const directory = await createVault();
|
||||
const parent = "café".normalize("NFD");
|
||||
const child = `${parent}/Étage`;
|
||||
await fs.mkdir(path.join(directory, child), { recursive: true });
|
||||
const names = Array.from(
|
||||
{ length: 12 },
|
||||
(_, index) =>
|
||||
`${child}/${index}-résumé.md`
|
||||
.normalize(index % 2 ? "NFC" : "NFD")
|
||||
.split("/")
|
||||
.pop()!
|
||||
);
|
||||
for (const name of names) await fs.writeFile(path.join(directory, child, name), "content");
|
||||
const adapter = createNormalisingAdapter(directory, caseInsensitive);
|
||||
const list = vi.spyOn(NodeStorageAdapter.prototype, "list");
|
||||
try {
|
||||
const files = await adapter.getFiles();
|
||||
expect(files.map((file) => file.path).sort()).toEqual(names.map((name) => `${child}/${name}`).sort());
|
||||
expect(list).toHaveBeenCalledTimes(3);
|
||||
} finally {
|
||||
list.mockRestore();
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
it("uses the existing mixed parent during directory preparation and preserves the new leaf spelling", async () => {
|
||||
const directory = await createVault();
|
||||
const parent = "café".normalize("NFD");
|
||||
const child = `${parent}/Étage`;
|
||||
await fs.mkdir(path.join(directory, child), { recursive: true });
|
||||
const adapter = createNormalisingAdapter(directory);
|
||||
await adapter.storage.mkdir("café");
|
||||
await adapter.storage.mkdir("café/Étage");
|
||||
const newName = "résumé.md".normalize("NFD");
|
||||
const file = await adapter.vault.create(`café/Étage/${newName}`, "created");
|
||||
|
||||
expect(file.path).toBe(`${child}/${newName}`);
|
||||
expect(await fs.readdir(directory)).toEqual([parent]);
|
||||
expect(await fs.readdir(path.join(directory, parent))).toEqual(["Étage"]);
|
||||
expect(await fs.readdir(path.join(directory, child))).toEqual([newName]);
|
||||
await expect(adapter.storage.stat(`café/Étage/${newName.normalize("NFC")}`)).resolves.toMatchObject({
|
||||
size: 7,
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects an NFC-equivalent file and directory before scanning or writing a child", async () => {
|
||||
const directory = await createVault();
|
||||
const composed = "café";
|
||||
const decomposed = composed.normalize("NFD");
|
||||
await fs.writeFile(path.join(directory, composed), "file");
|
||||
await fs.mkdir(path.join(directory, decomposed));
|
||||
const adapter = createNormalisingAdapter(directory);
|
||||
|
||||
await expect(adapter.getFiles()).rejects.toThrow(/ambiguous/i);
|
||||
await expect(adapter.storage.write(`${composed}/new.md`, "new")).rejects.toThrow(/ambiguous/i);
|
||||
await expect(fs.readFile(path.join(directory, composed), "utf8")).resolves.toBe("file");
|
||||
expect(await fs.readdir(path.join(directory, decomposed))).toEqual([]);
|
||||
});
|
||||
|
||||
it.each([false, true])(
|
||||
"enumerates each directory once for a flat NFC scan with case-insensitive matching %s",
|
||||
async (caseInsensitive) => {
|
||||
const directory = await createVault();
|
||||
for (let index = 0; index < 12; index++) {
|
||||
await fs.writeFile(path.join(directory, `${index}.md`), "content");
|
||||
}
|
||||
const adapter = createNormalisingAdapter(directory, caseInsensitive);
|
||||
const list = vi.spyOn(NodeStorageAdapter.prototype, "list");
|
||||
try {
|
||||
expect(await adapter.getFiles()).toHaveLength(12);
|
||||
expect(list).toHaveBeenCalledTimes(1);
|
||||
} finally {
|
||||
list.mockRestore();
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
it("reads, updates, and deletes an NFD file through its NFC path", async () => {
|
||||
const directory = await createVault();
|
||||
const nfcFolder = "café";
|
||||
const nfdFolder = nfcFolder.normalize("NFD");
|
||||
const nfcName = "Ångström.md";
|
||||
const nfdName = nfcName.normalize("NFD");
|
||||
await fs.mkdir(path.join(directory, nfdFolder));
|
||||
await fs.writeFile(path.join(directory, nfdFolder, nfdName), "before", "utf8");
|
||||
const adapter = createNormalisingAdapter(directory);
|
||||
|
||||
const file = await adapter.getAbstractFileByPath(`${nfcFolder}/${nfcName}`);
|
||||
expect(file?.path).toBe(`${nfdFolder}/${nfdName}`);
|
||||
if (!file) throw new Error("Expected the NFC path to resolve to the NFD file");
|
||||
await expect(adapter.vault.read(file)).resolves.toBe("before");
|
||||
await adapter.vault.modify(file, "after");
|
||||
await expect(fs.readFile(path.join(directory, nfdFolder, nfdName), "utf8")).resolves.toBe("after");
|
||||
expect(await fs.readdir(directory)).toEqual([nfdFolder]);
|
||||
expect(await fs.readdir(path.join(directory, nfdFolder))).toEqual([nfdName]);
|
||||
|
||||
await adapter.vault.delete(file);
|
||||
|
||||
await expect(fs.readdir(path.join(directory, nfdFolder))).resolves.toEqual([]);
|
||||
});
|
||||
|
||||
it("creates a new file inside an existing NFD directory using that directory spelling", async () => {
|
||||
const directory = await createVault();
|
||||
const nfdFolder = "café".normalize("NFD");
|
||||
await fs.mkdir(path.join(directory, nfdFolder));
|
||||
const adapter = createNormalisingAdapter(directory);
|
||||
|
||||
const file = await adapter.vault.create("café/new.md", "content");
|
||||
|
||||
expect(file.path).toBe(`${nfdFolder}/new.md`);
|
||||
await expect(fs.readFile(path.join(directory, nfdFolder, "new.md"), "utf8")).resolves.toBe("content");
|
||||
expect(await fs.readdir(directory)).toEqual([nfdFolder]);
|
||||
});
|
||||
|
||||
it("renames a file by case inside an NFD directory while preserving that directory spelling", async () => {
|
||||
const directory = await createVault();
|
||||
const nfcFolder = "Caf\u00e9";
|
||||
const nfdFolder = nfcFolder.normalize("NFD");
|
||||
await fs.mkdir(path.join(directory, nfdFolder));
|
||||
await fs.writeFile(path.join(directory, nfdFolder, "Note.md"), "content", "utf8");
|
||||
const adapter = createNormalisingAdapter(directory, true);
|
||||
const file = await adapter.getAbstractFileByPathInsensitive(`${nfcFolder}/Note.md`);
|
||||
if (!file) throw new Error("Expected the NFC path to resolve to the NFD file");
|
||||
|
||||
const renamedFile = await adapter.renameFile(file, `${nfcFolder}/note.md`);
|
||||
|
||||
expect(renamedFile.path).toBe(`${nfdFolder}/note.md`);
|
||||
expect(await fs.readdir(directory)).toEqual([nfdFolder]);
|
||||
await expect(fs.readdir(path.join(directory, nfdFolder))).resolves.toEqual(["note.md"]);
|
||||
await expect(fs.readFile(path.join(directory, nfdFolder, "note.md"), "utf8")).resolves.toBe("content");
|
||||
});
|
||||
|
||||
it("rejects an NFC/NFD file collision for lookup and vault mutations", async () => {
|
||||
const directory = await createVault();
|
||||
const nfcName = "café.md";
|
||||
const nfdName = nfcName.normalize("NFD");
|
||||
await fs.writeFile(path.join(directory, nfcName), "NFC", "utf8");
|
||||
await fs.writeFile(path.join(directory, nfdName), "NFD", "utf8");
|
||||
const adapter = createNormalisingAdapter(directory);
|
||||
const exactFile = {
|
||||
path: nfcName as FilePath,
|
||||
stat: { ctime: 1, mtime: 2, size: 3, type: "file" as const },
|
||||
};
|
||||
|
||||
await expect(adapter.getAbstractFileByPath(nfcName)).rejects.toThrow(/ambiguous/i);
|
||||
await expect(adapter.vault.modify(exactFile, "changed")).rejects.toThrow(/ambiguous/i);
|
||||
await expect(adapter.vault.delete(exactFile)).rejects.toThrow(/ambiguous/i);
|
||||
|
||||
await expect(fs.readFile(path.join(directory, nfcName), "utf8")).resolves.toBe("NFC");
|
||||
await expect(fs.readFile(path.join(directory, nfdName), "utf8")).resolves.toBe("NFD");
|
||||
expect((await fs.readdir(directory)).sort()).toEqual([nfcName, nfdName].sort());
|
||||
});
|
||||
|
||||
it("rejects an NFC/NFD directory collision before creating a child", async () => {
|
||||
const directory = await createVault();
|
||||
const nfcFolder = "café";
|
||||
const nfdFolder = nfcFolder.normalize("NFD");
|
||||
await fs.mkdir(path.join(directory, nfcFolder));
|
||||
await fs.mkdir(path.join(directory, nfdFolder));
|
||||
await fs.writeFile(path.join(directory, nfcFolder, "inside.md"), "NFC", "utf8");
|
||||
await fs.writeFile(path.join(directory, nfdFolder, "inside.md"), "NFD", "utf8");
|
||||
const adapter = createNormalisingAdapter(directory);
|
||||
|
||||
await expect(adapter.getAbstractFileByPath(`${nfcFolder}/inside.md`)).rejects.toThrow(/ambiguous/i);
|
||||
await expect(adapter.storage.write(`${nfcFolder}/created.md`, "created")).rejects.toThrow(/ambiguous/i);
|
||||
|
||||
await expect(fs.readdir(path.join(directory, nfcFolder))).resolves.toEqual(["inside.md"]);
|
||||
await expect(fs.readdir(path.join(directory, nfdFolder))).resolves.toEqual(["inside.md"]);
|
||||
await expect(fs.readFile(path.join(directory, nfcFolder, "inside.md"), "utf8")).resolves.toBe("NFC");
|
||||
await expect(fs.readFile(path.join(directory, nfdFolder, "inside.md"), "utf8")).resolves.toBe("NFD");
|
||||
});
|
||||
|
||||
it("rejects enumeration when NFC-equivalent directories contain different files", async () => {
|
||||
const directory = await createVault();
|
||||
const nfcFolder = "café";
|
||||
const nfdFolder = nfcFolder.normalize("NFD");
|
||||
await fs.mkdir(path.join(directory, nfcFolder));
|
||||
await fs.mkdir(path.join(directory, nfdFolder));
|
||||
await fs.writeFile(path.join(directory, nfcFolder, "a.md"), "NFC", "utf8");
|
||||
await fs.writeFile(path.join(directory, nfdFolder, "b.md"), "NFD", "utf8");
|
||||
const adapter = createNormalisingAdapter(directory);
|
||||
|
||||
await expect(adapter.getFiles()).rejects.toThrow(/ambiguous/i);
|
||||
await expect(adapter.scanDirectory()).rejects.toThrow(/ambiguous/i);
|
||||
|
||||
await expect(fs.readFile(path.join(directory, nfcFolder, "a.md"), "utf8")).resolves.toBe("NFC");
|
||||
await expect(fs.readFile(path.join(directory, nfdFolder, "b.md"), "utf8")).resolves.toBe("NFD");
|
||||
});
|
||||
|
||||
it("rechecks the directory after a unique file is cached", async () => {
|
||||
const directory = await createVault();
|
||||
const nfcName = "café.md";
|
||||
const nfdName = nfcName.normalize("NFD");
|
||||
await fs.writeFile(path.join(directory, nfdName), "NFD", "utf8");
|
||||
const adapter = createNormalisingAdapter(directory);
|
||||
|
||||
await expect(adapter.getAbstractFileByPath(nfcName)).resolves.toMatchObject({ path: nfdName });
|
||||
await fs.writeFile(path.join(directory, nfcName), "NFC", "utf8");
|
||||
|
||||
await expect(adapter.getAbstractFileByPath(nfcName)).rejects.toThrow(/ambiguous/i);
|
||||
await expect(fs.readFile(path.join(directory, nfcName), "utf8")).resolves.toBe("NFC");
|
||||
await expect(fs.readFile(path.join(directory, nfdName), "utf8")).resolves.toBe("NFD");
|
||||
});
|
||||
|
||||
it.each([
|
||||
["Foo", "foo"],
|
||||
["Café", "café".normalize("NFD")],
|
||||
])("rejects case-insensitive parent aliases %s and %s containing different files", async (first, second) => {
|
||||
const directory = await createVault();
|
||||
for (const [parent, name] of [
|
||||
[first, "one.md"],
|
||||
[second, "two.md"],
|
||||
]) {
|
||||
await fs.mkdir(path.join(directory, parent));
|
||||
await fs.writeFile(path.join(directory, parent, name), name);
|
||||
}
|
||||
const adapter = createNormalisingAdapter(directory, true);
|
||||
|
||||
await expect(adapter.getFiles()).rejects.toThrow(/ambiguous/i);
|
||||
await expect(adapter.scanDirectory()).rejects.toThrow(/ambiguous/i);
|
||||
await expect(fs.readFile(path.join(directory, first, "one.md"), "utf8")).resolves.toBe("one.md");
|
||||
await expect(fs.readFile(path.join(directory, second, "two.md"), "utf8")).resolves.toBe("two.md");
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ version: 0, caseInsensitive: true },
|
||||
{ version: 1, caseInsensitive: false },
|
||||
])(
|
||||
"compatibility: retains distinct case paths in mode $version with case-insensitive matching $caseInsensitive",
|
||||
async ({ version, caseInsensitive }) => {
|
||||
const directory = await createVault();
|
||||
const names = ["Café/one.md", "café/two.md".normalize("NFD")];
|
||||
for (const name of names) {
|
||||
await fs.mkdir(path.dirname(path.join(directory, name)));
|
||||
await fs.writeFile(path.join(directory, name), name);
|
||||
}
|
||||
const adapter = new NodeFileSystemAdapter(
|
||||
directory,
|
||||
undefined,
|
||||
() => version,
|
||||
() => caseInsensitive
|
||||
);
|
||||
|
||||
expect((await adapter.getFiles()).map((file) => file.path).sort()).toEqual([...names].sort());
|
||||
for (const name of names) await expect(adapter.storage.read(name)).resolves.toBe(name);
|
||||
}
|
||||
);
|
||||
|
||||
it("refuses reconstruction before starting remote activity when parent aliases are ambiguous", async () => {
|
||||
const directory = await createVault();
|
||||
for (const name of ["Café/one.md", "café/two.md".normalize("NFD")]) {
|
||||
await fs.mkdir(path.dirname(path.join(directory, name)));
|
||||
await fs.writeFile(path.join(directory, name), name);
|
||||
}
|
||||
const adapter = createNormalisingAdapter(directory, true);
|
||||
const settings = {
|
||||
...DEFAULT_SETTINGS,
|
||||
pathNormalizationVersion: 1 as const,
|
||||
handleFilenameCaseSensitive: false,
|
||||
};
|
||||
const setting = { currentSettings: () => settings, suspendAllSync: { addHandler: vi.fn() } };
|
||||
const runBoundedRemoteActivity = vi.fn(async () => undefined);
|
||||
const rebuilder = new ServiceRebuilder({
|
||||
setting,
|
||||
storageAccess: { getFileNames: async () => (await adapter.getFiles()).map((file) => file.path) },
|
||||
database: {
|
||||
onDatabaseReset: { addHandler: vi.fn() },
|
||||
isDatabaseReady: () => true,
|
||||
localDatabase: { findAllDocs: async function* () {} },
|
||||
},
|
||||
vault: { isTargetFile: async () => true },
|
||||
path: new PathServiceCompat(new ServiceContext(), { settingService: setting as never }),
|
||||
replicator: { runBoundedRemoteActivity },
|
||||
UI: { showMarkdownDialog: vi.fn(async () => undefined) },
|
||||
} as unknown as ServiceRebuilderDependencies);
|
||||
|
||||
await expect(rebuilder.$rebuildEverything()).rejects.toThrow(/ambiguous/i);
|
||||
expect(runBoundedRemoteActivity).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("uses fresh listings after external additions, renames, deletions, and collisions", async () => {
|
||||
const directory = await createVault();
|
||||
const nfdName = "café.md".normalize("NFD");
|
||||
await fs.writeFile(path.join(directory, nfdName), "original");
|
||||
const adapter = createNormalisingAdapter(directory);
|
||||
expect((await adapter.getFiles()).map((file) => file.path)).toEqual([nfdName]);
|
||||
|
||||
const renamed = "résumé.md".normalize("NFD");
|
||||
await fs.rename(path.join(directory, nfdName), path.join(directory, renamed));
|
||||
await fs.writeFile(path.join(directory, "new.md"), "new");
|
||||
expect((await adapter.getFiles()).map((file) => file.path).sort()).toEqual(["new.md", renamed]);
|
||||
|
||||
await fs.rm(path.join(directory, "new.md"));
|
||||
expect((await adapter.getFiles()).map((file) => file.path)).toEqual([renamed]);
|
||||
await fs.writeFile(path.join(directory, renamed.normalize("NFC")), "collision");
|
||||
await expect(adapter.getFiles()).rejects.toThrow(/ambiguous/i);
|
||||
});
|
||||
|
||||
it("retains the storage boundary for exact metadata reads and directory scans", async () => {
|
||||
const directory = await createVault();
|
||||
const outside = await createVault();
|
||||
await fs.writeFile(path.join(outside, "note.md"), "outside");
|
||||
await fs.symlink(outside, path.join(directory, "linked"), "dir");
|
||||
const adapter = createNormalisingAdapter(directory);
|
||||
|
||||
await expect(adapter.getFiles()).resolves.toEqual([]);
|
||||
await expect(adapter.storage.statExact("linked/note.md")).rejects.toThrow(/symbolic links/i);
|
||||
await expect(adapter.storage.write("linked/note.md", "changed")).rejects.toThrow(/symbolic links/i);
|
||||
await expect(fs.readFile(path.join(outside, "note.md"), "utf8")).resolves.toBe("outside");
|
||||
});
|
||||
|
||||
it("detects NFC and case collisions when lookup is case-insensitive", async () => {
|
||||
const directory = await createVault();
|
||||
const upperName = "Café.md";
|
||||
const lowerNfdName = "café.md".normalize("NFD");
|
||||
await fs.writeFile(path.join(directory, upperName), "upper", "utf8");
|
||||
await fs.writeFile(path.join(directory, lowerNfdName), "lower", "utf8");
|
||||
const adapter = createNormalisingAdapter(directory, true);
|
||||
|
||||
await expect(adapter.getAbstractFileByPathInsensitive("CAFÉ.md")).rejects.toThrow(/ambiguous/i);
|
||||
await expect(adapter.getFiles()).rejects.toThrow(/ambiguous/i);
|
||||
await expect(adapter.scanDirectory()).rejects.toThrow(/ambiguous/i);
|
||||
await expect(fs.readFile(path.join(directory, upperName), "utf8")).resolves.toBe("upper");
|
||||
await expect(fs.readFile(path.join(directory, lowerNfdName), "utf8")).resolves.toBe("lower");
|
||||
});
|
||||
|
||||
it("retains exact-path behaviour when path normalisation is disabled", async () => {
|
||||
const directory = await createVault();
|
||||
const nfcName = "café.md";
|
||||
const nfdName = nfcName.normalize("NFD");
|
||||
await fs.writeFile(path.join(directory, nfdName), "NFD", "utf8");
|
||||
const adapter = new NodeFileSystemAdapter(directory);
|
||||
|
||||
await expect(adapter.getAbstractFileByPath(nfcName)).resolves.toBeNull();
|
||||
await adapter.storage.write(nfcName, "NFC");
|
||||
|
||||
await expect(fs.readFile(path.join(directory, nfcName), "utf8")).resolves.toBe("NFC");
|
||||
await expect(fs.readFile(path.join(directory, nfdName), "utf8")).resolves.toBe("NFD");
|
||||
});
|
||||
});
|
||||
@@ -2,6 +2,7 @@ import type { FilePath, UXDataWriteOptions } from "@vrtmrz/livesync-commonlib/co
|
||||
import type { IVaultAdapter } from "@vrtmrz/livesync-commonlib/compat/serviceModules/adapters";
|
||||
import type { NodeFile, NodeFolder } from "./NodeTypes";
|
||||
import { NodeStorageAdapter } from "@vrtmrz/livesync-commonlib/node";
|
||||
import { UnicodeResolvingNodeStorageAdapter } from "./UnicodeResolvingNodeStorageAdapter";
|
||||
|
||||
/**
|
||||
* Vault adapter implementation for Node.js
|
||||
@@ -73,8 +74,16 @@ export class NodeVaultAdapter implements IVaultAdapter<NodeFile> {
|
||||
}
|
||||
|
||||
private async toNodeFile(path: string): Promise<NodeFile> {
|
||||
const stat = await this.storage.stat(path);
|
||||
const resolvedPath =
|
||||
this.storage instanceof UnicodeResolvingNodeStorageAdapter
|
||||
? await this.storage.resolveLogicalPath(path, undefined, false)
|
||||
: path;
|
||||
if (resolvedPath === null) throw new Error(`Could not read created file metadata: ${path}`);
|
||||
const stat =
|
||||
this.storage instanceof UnicodeResolvingNodeStorageAdapter
|
||||
? await this.storage.statExact(resolvedPath)
|
||||
: await this.storage.stat(resolvedPath);
|
||||
if (stat?.type !== "file") throw new Error(`Could not read created file metadata: ${path}`);
|
||||
return { path: path as FilePath, stat };
|
||||
return { path: resolvedPath as FilePath, stat };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,205 @@
|
||||
import { NodeStorageAdapter, validateStoragePath } from "@vrtmrz/livesync-commonlib/node";
|
||||
|
||||
export class AmbiguousNormalisedPathError extends Error {
|
||||
constructor(path: string) {
|
||||
super(`Ambiguous path after Unicode normalisation: ${path}`);
|
||||
this.name = "AmbiguousNormalisedPathError";
|
||||
}
|
||||
}
|
||||
|
||||
/** Resolves NFC-equivalent logical paths against current Node storage listings. */
|
||||
export class UnicodeResolvingNodeStorageAdapter extends NodeStorageAdapter {
|
||||
constructor(
|
||||
rootPath: string,
|
||||
private readonly getPathNormalizationVersion: () => number = () => 0,
|
||||
private readonly isCaseInsensitive: () => boolean = () => false
|
||||
) {
|
||||
super(rootPath);
|
||||
}
|
||||
|
||||
get isUnicodePathNormalizationEnabled(): boolean {
|
||||
return this.getPathNormalizationVersion() === 1;
|
||||
}
|
||||
|
||||
/** Read metadata for an entry already resolved or obtained from a storage listing. */
|
||||
async statExact(path: string) {
|
||||
return await super.trystat(path);
|
||||
}
|
||||
|
||||
/** Inspect a physical directory once before descending into its enumerated entries. */
|
||||
async listExact(path: string) {
|
||||
const entries = await super.list(path);
|
||||
if (this.isUnicodePathNormalizationEnabled) {
|
||||
const caseInsensitive = this.isCaseInsensitive();
|
||||
const names = new Map<string, string>();
|
||||
for (const entry of [...entries.files, ...entries.folders]) {
|
||||
const key = this.comparisonKey(entry, caseInsensitive);
|
||||
if (names.has(key)) throw new AmbiguousNormalisedPathError(entry);
|
||||
names.set(key, entry);
|
||||
}
|
||||
}
|
||||
return entries;
|
||||
}
|
||||
|
||||
async resolveLogicalPath(
|
||||
path: string,
|
||||
caseInsensitive = this.isCaseInsensitive(),
|
||||
allowMissing = true
|
||||
): Promise<string | null> {
|
||||
if (!this.isUnicodePathNormalizationEnabled) return path;
|
||||
|
||||
validateStoragePath(path, true);
|
||||
if (path === "") return "";
|
||||
|
||||
const segments = path.split("/");
|
||||
let resolvedParent = "";
|
||||
|
||||
for (let index = 0; index < segments.length; index++) {
|
||||
const segment = segments[index];
|
||||
const entries = await super.list(resolvedParent);
|
||||
const candidates = [...entries.files, ...entries.folders];
|
||||
const requestedKey = this.comparisonKey(segment, caseInsensitive);
|
||||
const matches = candidates.filter((entryPath) => {
|
||||
const entryName = entryPath.slice(resolvedParent === "" ? 0 : resolvedParent.length + 1);
|
||||
return this.comparisonKey(entryName, caseInsensitive) === requestedKey;
|
||||
});
|
||||
|
||||
if (matches.length > 1) throw new AmbiguousNormalisedPathError(path);
|
||||
if (matches.length === 1) {
|
||||
resolvedParent = matches[0];
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!allowMissing) return null;
|
||||
const remainingPath = segments.slice(index).join("/");
|
||||
return resolvedParent === "" ? remainingPath : `${resolvedParent}/${remainingPath}`;
|
||||
}
|
||||
|
||||
return resolvedParent;
|
||||
}
|
||||
|
||||
override async exists(path: string, caseInsensitive = this.isCaseInsensitive()): Promise<boolean> {
|
||||
const resolvedPath = await this.resolveLogicalPath(path, caseInsensitive, false);
|
||||
if (resolvedPath === null) return false;
|
||||
return await super.exists(resolvedPath);
|
||||
}
|
||||
|
||||
override async trystat(path: string, caseInsensitive = this.isCaseInsensitive()) {
|
||||
const resolvedPath = await this.resolveLogicalPath(path, caseInsensitive, false);
|
||||
if (resolvedPath === null) return null;
|
||||
return await super.trystat(resolvedPath);
|
||||
}
|
||||
|
||||
override async stat(path: string, caseInsensitive = this.isCaseInsensitive()) {
|
||||
const resolvedPath = await this.resolveLogicalPath(path, caseInsensitive, false);
|
||||
if (resolvedPath === null) return null;
|
||||
return await super.trystat(resolvedPath);
|
||||
}
|
||||
|
||||
override async mkdir(path: string, caseInsensitive = this.isCaseInsensitive()): Promise<void> {
|
||||
const resolvedPath = await this.resolveLogicalPath(path, caseInsensitive, true);
|
||||
if (resolvedPath === null) throw new Error(`Could not resolve path: ${path}`);
|
||||
await super.mkdir(resolvedPath);
|
||||
}
|
||||
|
||||
override async remove(path: string, caseInsensitive = this.isCaseInsensitive()): Promise<void> {
|
||||
const resolvedPath = await this.resolveLogicalPath(path, caseInsensitive, false);
|
||||
await super.remove(resolvedPath ?? path);
|
||||
}
|
||||
|
||||
override async read(path: string, caseInsensitive = this.isCaseInsensitive()): Promise<string> {
|
||||
const resolvedPath = await this.resolveLogicalPath(path, caseInsensitive, false);
|
||||
return await super.read(resolvedPath ?? path);
|
||||
}
|
||||
|
||||
override async readBinary(path: string, caseInsensitive = this.isCaseInsensitive()): Promise<ArrayBuffer> {
|
||||
const resolvedPath = await this.resolveLogicalPath(path, caseInsensitive, false);
|
||||
return await super.readBinary(resolvedPath ?? path);
|
||||
}
|
||||
|
||||
override async write(
|
||||
path: string,
|
||||
data: string,
|
||||
options?: Parameters<NodeStorageAdapter["write"]>[2],
|
||||
caseInsensitive = this.isCaseInsensitive()
|
||||
): Promise<void> {
|
||||
const resolvedPath = await this.resolveLogicalPath(path, caseInsensitive, true);
|
||||
if (resolvedPath === null) throw new Error(`Could not resolve path: ${path}`);
|
||||
await super.write(resolvedPath, data, options);
|
||||
}
|
||||
|
||||
override async writeBinary(
|
||||
path: string,
|
||||
data: ArrayBuffer,
|
||||
options?: Parameters<NodeStorageAdapter["writeBinary"]>[2],
|
||||
caseInsensitive = this.isCaseInsensitive()
|
||||
): Promise<void> {
|
||||
const resolvedPath = await this.resolveLogicalPath(path, caseInsensitive, true);
|
||||
if (resolvedPath === null) throw new Error(`Could not resolve path: ${path}`);
|
||||
await super.writeBinary(resolvedPath, data, options);
|
||||
}
|
||||
|
||||
override async append(
|
||||
path: string,
|
||||
data: string,
|
||||
options?: Parameters<NodeStorageAdapter["append"]>[2],
|
||||
caseInsensitive = this.isCaseInsensitive()
|
||||
): Promise<void> {
|
||||
const resolvedPath = await this.resolveLogicalPath(path, caseInsensitive, true);
|
||||
if (resolvedPath === null) throw new Error(`Could not resolve path: ${path}`);
|
||||
await super.append(resolvedPath, data, options);
|
||||
}
|
||||
|
||||
override async rename(
|
||||
sourcePath: string,
|
||||
targetPath: string,
|
||||
caseInsensitive = this.isCaseInsensitive()
|
||||
): Promise<void> {
|
||||
const resolvedSource = await this.resolveLogicalPath(sourcePath, caseInsensitive, false);
|
||||
if (resolvedSource === null) return await super.rename(sourcePath, targetPath);
|
||||
|
||||
const resolvedTarget = await this.resolveLogicalPath(targetPath, caseInsensitive, true);
|
||||
if (resolvedTarget === null) throw new Error(`Could not resolve path: ${targetPath}`);
|
||||
|
||||
// Preserve explicit case-only or Unicode-spelling renames when the target resolves to the source.
|
||||
const sourceKey = this.comparisonKey(resolvedSource, caseInsensitive);
|
||||
const targetKey = this.comparisonKey(resolvedTarget, caseInsensitive);
|
||||
const target =
|
||||
sourceKey === targetKey && resolvedTarget === resolvedSource && targetPath !== resolvedSource
|
||||
? await this.resolveRenameTargetSpelling(targetPath, caseInsensitive)
|
||||
: resolvedTarget;
|
||||
|
||||
await super.rename(resolvedSource, target);
|
||||
}
|
||||
|
||||
override async list(basePath: string, caseInsensitive = this.isCaseInsensitive()) {
|
||||
const resolvedPath = await this.resolveLogicalPath(basePath, caseInsensitive, false);
|
||||
if (resolvedPath === null) return { files: [], folders: [] };
|
||||
return await super.list(resolvedPath);
|
||||
}
|
||||
|
||||
private async resolveRenameTargetSpelling(path: string, caseInsensitive: boolean): Promise<string> {
|
||||
validateStoragePath(path, false);
|
||||
const segments = path.split("/");
|
||||
const leaf = segments.pop();
|
||||
if (leaf === undefined) throw new Error(`Could not resolve path: ${path}`);
|
||||
const parentPath = segments.join("/");
|
||||
const resolvedParent = await this.resolveLogicalPath(parentPath, caseInsensitive, true);
|
||||
if (resolvedParent === null) throw new Error(`Could not resolve path: ${path}`);
|
||||
|
||||
const entries = await super.list(resolvedParent);
|
||||
const requestedKey = this.comparisonKey(leaf, caseInsensitive);
|
||||
const matches = [...entries.files, ...entries.folders].filter((entryPath) => {
|
||||
const entryName = entryPath.slice(resolvedParent === "" ? 0 : resolvedParent.length + 1);
|
||||
return this.comparisonKey(entryName, caseInsensitive) === requestedKey;
|
||||
});
|
||||
if (matches.length > 1) throw new AmbiguousNormalisedPathError(path);
|
||||
|
||||
return resolvedParent === "" ? leaf : `${resolvedParent}/${leaf}`;
|
||||
}
|
||||
|
||||
private comparisonKey(segment: string, caseInsensitive: boolean): string {
|
||||
const normalisedSegment = segment.normalize("NFC");
|
||||
return caseInsensitive ? normalisedSegment.toLowerCase() : normalisedSegment;
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
type ObsidianLiveSyncSettings,
|
||||
type EntryDoc,
|
||||
} from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
import { isRemediationModeActive } from "@vrtmrz/livesync-commonlib/compat/common/utils";
|
||||
import { ConnectionStringParser } from "@vrtmrz/livesync-commonlib/compat/common/ConnectionString";
|
||||
import {
|
||||
activateRemoteConfiguration,
|
||||
@@ -13,7 +14,7 @@ import {
|
||||
} from "@vrtmrz/livesync-commonlib/remote-configurations";
|
||||
import { stripAllPrefixes } from "@vrtmrz/livesync-commonlib/compat/string_and_binary/path";
|
||||
import type { CLICommandContext, CLIOptions } from "./types";
|
||||
import { toArrayBuffer, toDatabaseRelativePath } from "./utils";
|
||||
import { getForceRequirementError, toArrayBuffer, toDatabaseRelativePath } from "./utils";
|
||||
import { collectPeers, openP2PHost, parseTimeoutSeconds, syncWithPeer } from "./p2p";
|
||||
import { compatGlobal } from "@vrtmrz/livesync-commonlib/compat/common/coreEnvFunctions";
|
||||
import { fsPromises as fs, getNodeFileCreationTime, path } from "@vrtmrz/livesync-commonlib/node";
|
||||
@@ -37,8 +38,25 @@ function redactConnectionString(uri: string): string {
|
||||
}
|
||||
|
||||
export async function runCommand(options: CLIOptions, context: CLICommandContext): Promise<boolean> {
|
||||
const { databasePath, core, replicationScheduling, settingsPath } = context;
|
||||
const { core } = context;
|
||||
const { standardIo } = core.services.context;
|
||||
const forceRequirementError = getForceRequirementError(options.command, options.force);
|
||||
if (forceRequirementError) {
|
||||
writeStderrLine(standardIo, `[Error] ${forceRequirementError}`);
|
||||
return false;
|
||||
}
|
||||
if (
|
||||
options.command === "fetch" &&
|
||||
isRemediationModeActive(core.services.setting.currentSettings())
|
||||
) {
|
||||
writeStderrLine(
|
||||
standardIo,
|
||||
`[Error] The fetch command cannot run while remediation mode is active. Clear the modification-time limit before fetching.`
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
const { databasePath, replicationScheduling, settingsPath } = context;
|
||||
const vaultPath = context.vaultPath || databasePath;
|
||||
|
||||
await core.services.control.activated;
|
||||
@@ -519,6 +537,29 @@ export async function runCommand(options: CLIOptions, context: CLICommandContext
|
||||
return true;
|
||||
}
|
||||
|
||||
if (options.command === "rebuild") {
|
||||
writeStderrLine(standardIo, "[Command] rebuild");
|
||||
try {
|
||||
await core.serviceModules.rebuilder.$rebuildEverything();
|
||||
return true;
|
||||
} catch (error) {
|
||||
writeStderrLine(standardIo, "[Error] Rebuild failed:", error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if (options.command === "fetch") {
|
||||
writeStderrLine(standardIo, "[Command] fetch");
|
||||
try {
|
||||
// Fetch replaces the local database from remote; do not persist legacy local files first.
|
||||
await core.serviceModules.rebuilder.$fetchLocal(false, true);
|
||||
return true;
|
||||
} catch (error) {
|
||||
writeStderrLine(standardIo, "[Error] Fetch failed:", error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if (options.command === "remote-add") {
|
||||
if (options.commandArgs.length < 2) {
|
||||
throw new Error("remote-add requires two arguments: <name> <connstr>");
|
||||
|
||||
@@ -118,6 +118,10 @@ function createCoreMock() {
|
||||
fetch: vi.fn(async () => undefined),
|
||||
storeContent: vi.fn(async () => true),
|
||||
},
|
||||
rebuilder: {
|
||||
$rebuildEverything: vi.fn(async () => undefined),
|
||||
$fetchLocal: vi.fn(async () => undefined),
|
||||
},
|
||||
},
|
||||
} as any;
|
||||
}
|
||||
@@ -268,6 +272,73 @@ describe("runCommand abnormal cases", () => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it.each(["rebuild", "fetch"] as const)("requires --force before activating for %s", async (command) => {
|
||||
const core = createCoreMock();
|
||||
const activated = vi.fn(() => Promise.resolve());
|
||||
Object.defineProperty(core.services.control, "activated", { get: activated });
|
||||
|
||||
await expect(runCommand(makeOptions(command, []), { ...context, core })).resolves.toBe(false);
|
||||
|
||||
expect(activated).not.toHaveBeenCalled();
|
||||
expect(core.serviceModules.rebuilder.$rebuildEverything).not.toHaveBeenCalled();
|
||||
expect(core.serviceModules.rebuilder.$fetchLocal).not.toHaveBeenCalled();
|
||||
expect(core.services.context.standardIo.writeStderr).toHaveBeenCalledWith(
|
||||
expect.stringContaining("requires --force")
|
||||
);
|
||||
});
|
||||
|
||||
it("dispatches a forced rebuild to the existing Rebuilder service", async () => {
|
||||
const core = createCoreMock();
|
||||
const options = makeOptions("rebuild", []);
|
||||
options.force = true;
|
||||
|
||||
await expect(runCommand(options, { ...context, core })).resolves.toBe(true);
|
||||
|
||||
expect(core.serviceModules.rebuilder.$rebuildEverything).toHaveBeenCalledOnce();
|
||||
expect(core.serviceModules.rebuilder.$fetchLocal).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("dispatches a forced fetch without saving legacy Vault files first", async () => {
|
||||
const core = createCoreMock();
|
||||
const options = makeOptions("fetch", []);
|
||||
options.force = true;
|
||||
|
||||
await expect(runCommand(options, { ...context, core })).resolves.toBe(true);
|
||||
|
||||
expect(core.serviceModules.rebuilder.$fetchLocal).toHaveBeenCalledOnce();
|
||||
expect(core.serviceModules.rebuilder.$fetchLocal).toHaveBeenCalledWith(false, true);
|
||||
expect(core.serviceModules.rebuilder.$rebuildEverything).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects fetch in remediation mode even when forced", async () => {
|
||||
const core = createCoreMock();
|
||||
core.services.setting.currentSettings().maxMTimeForReflectEvents = 1;
|
||||
const options = makeOptions("fetch", []);
|
||||
options.force = true;
|
||||
|
||||
await expect(runCommand(options, { ...context, core })).resolves.toBe(false);
|
||||
|
||||
expect(core.serviceModules.rebuilder.$fetchLocal).not.toHaveBeenCalled();
|
||||
expect(core.services.context.standardIo.writeStderr).toHaveBeenCalledWith(
|
||||
expect.stringContaining("remediation mode")
|
||||
);
|
||||
});
|
||||
|
||||
it("surfaces an internal rebuild refusal as a failed command", async () => {
|
||||
const core = createCoreMock();
|
||||
core.serviceModules.rebuilder.$rebuildEverything.mockRejectedValue(
|
||||
new Error("Rebuilding is not available while remediation mode is active.")
|
||||
);
|
||||
const options = makeOptions("rebuild", []);
|
||||
options.force = true;
|
||||
|
||||
await expect(runCommand(options, { ...context, core })).resolves.toBe(false);
|
||||
|
||||
expect(core.services.context.standardIo.writeStderr).toHaveBeenCalledWith(
|
||||
expect.stringContaining("Rebuilding is not available while remediation mode is active")
|
||||
);
|
||||
});
|
||||
|
||||
it("retains visible progress for the interactive sync command", async () => {
|
||||
const core = createCoreMock();
|
||||
|
||||
|
||||
@@ -22,6 +22,8 @@ export type CLICommand =
|
||||
| "rm"
|
||||
| "resolve"
|
||||
| "mirror"
|
||||
| "rebuild"
|
||||
| "fetch"
|
||||
| "remote-add"
|
||||
| "remote-rm"
|
||||
| "remote-ls"
|
||||
@@ -100,6 +102,8 @@ export const VALID_COMMANDS = new Set([
|
||||
"rm",
|
||||
"resolve",
|
||||
"mirror",
|
||||
"rebuild",
|
||||
"fetch",
|
||||
"remote-add",
|
||||
"remote-rm",
|
||||
"remote-ls",
|
||||
|
||||
@@ -1,4 +1,16 @@
|
||||
import { path } from "@vrtmrz/livesync-commonlib/node";
|
||||
import type { CLICommand } from "./types";
|
||||
|
||||
export function getForceRequirementError(command: CLICommand, force = false): string | undefined {
|
||||
if (force) return undefined;
|
||||
if (command === "rebuild") {
|
||||
return "Command 'rebuild' requires --force because it replaces the local and remote databases.";
|
||||
}
|
||||
if (command === "fetch") {
|
||||
return "Command 'fetch' requires --force because it replaces the local database with remote state.";
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function toArrayBuffer(data: Buffer): ArrayBuffer {
|
||||
return data.buffer.slice(data.byteOffset, data.byteOffset + data.byteLength) as ArrayBuffer;
|
||||
|
||||
@@ -1,6 +1,20 @@
|
||||
import { path } from "@vrtmrz/livesync-commonlib/node";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { toDatabaseRelativePath } from "./utils";
|
||||
import { getForceRequirementError, toDatabaseRelativePath } from "./utils";
|
||||
|
||||
describe("getForceRequirementError", () => {
|
||||
it.each([
|
||||
["rebuild", "Command 'rebuild' requires --force because it replaces the local and remote databases."],
|
||||
["fetch", "Command 'fetch' requires --force because it replaces the local database with remote state."],
|
||||
] as const)("requires force for %s", (command, expected) => {
|
||||
expect(getForceRequirementError(command, false)).toBe(expected);
|
||||
expect(getForceRequirementError(command, true)).toBeUndefined();
|
||||
});
|
||||
|
||||
it("does not require force for ordinary commands", () => {
|
||||
expect(getForceRequirementError("sync", false)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("toDatabaseRelativePath", () => {
|
||||
const databasePath = path.resolve("/tmp/livesync-vault");
|
||||
|
||||
@@ -3,6 +3,7 @@ import { appendFile, mkdtemp, mkdir, readFile, rm, stat, writeFile } from "node:
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import * as chokidar from "chokidar";
|
||||
import * as offlineScanner from "@vrtmrz/livesync-commonlib/compat/serviceFeatures/offlineScanner";
|
||||
import { ControlService } from "@vrtmrz/livesync-commonlib/compat/services/base/ControlService";
|
||||
import {
|
||||
AutoAccepting,
|
||||
@@ -20,6 +21,7 @@ import { cloneSettings } from "./settingsPersistence";
|
||||
import type { CLICommand } from "./commands/types";
|
||||
|
||||
vi.mock("chokidar", { spy: true });
|
||||
vi.mock("@vrtmrz/livesync-commonlib/compat/serviceFeatures/offlineScanner", { spy: true });
|
||||
|
||||
function createStandardIoMock() {
|
||||
return {
|
||||
@@ -41,6 +43,7 @@ describe("CLI database preparation", () => {
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.mocked(chokidar.watch).mockClear();
|
||||
vi.mocked(offlineScanner.useOfflineScanner).mockClear();
|
||||
directory = await mkdtemp(join(tmpdir(), "livesync-cli-bootstrap-"));
|
||||
vaultPath = join(directory, "vault");
|
||||
settingsPath = join(directory, "settings.json");
|
||||
@@ -69,8 +72,18 @@ describe("CLI database preparation", () => {
|
||||
await rm(directory, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
async function start(command: CLICommand, runner: CliCommandRunner, exitCode = 1) {
|
||||
process.argv = ["node", "livesync-cli", directory, "--vault", vaultPath, "--settings", settingsPath, command];
|
||||
async function start(command: CLICommand, runner: CliCommandRunner, exitCode = 1, force = false) {
|
||||
process.argv = [
|
||||
"node",
|
||||
"livesync-cli",
|
||||
directory,
|
||||
...(force ? ["--force"] : []),
|
||||
"--vault",
|
||||
vaultPath,
|
||||
"--settings",
|
||||
settingsPath,
|
||||
command,
|
||||
];
|
||||
// Daemon probes return false so the real core unloads without keeping a daemon alive.
|
||||
await expect(main(standardIo, runner)).rejects.toThrow(`__EXIT__:${exitCode}`);
|
||||
}
|
||||
@@ -255,6 +268,49 @@ describe("CLI database preparation", () => {
|
||||
expect(watch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each(["rebuild", "fetch"] as const)("does not scan or mirror legacy Vault files before %s", async (command) => {
|
||||
const storedPaths: string[] = [];
|
||||
const runner = vi.fn<CliCommandRunner>(async (_options, { core }) => {
|
||||
for await (const doc of core.services.database.localDatabase.findAllNormalDocs()) {
|
||||
storedPaths.push(doc.path);
|
||||
}
|
||||
return false;
|
||||
});
|
||||
|
||||
await start(command, runner, 1, true);
|
||||
|
||||
expect(runner).toHaveBeenCalledOnce();
|
||||
expect(storedPaths).toEqual([]);
|
||||
expect(await readFile(join(vaultPath, "notes/local.md"), "utf-8")).toBe("local content");
|
||||
expect(vi.mocked(chokidar.watch)).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("registers the Rebuild scanner after readiness and makes its Vault scan available", async () => {
|
||||
const enumerate = vi.spyOn(ServiceFileAccessCLI.prototype, "getFiles");
|
||||
const useScanner = vi.mocked(offlineScanner.useOfflineScanner);
|
||||
|
||||
let scansBeforeCommand = -1;
|
||||
let scannerRegistrationsBeforeCommand = -1;
|
||||
let storedPaths: string[] = [];
|
||||
const runner = vi.fn<CliCommandRunner>(async (_options, { core }) => {
|
||||
scansBeforeCommand = enumerate.mock.calls.length;
|
||||
scannerRegistrationsBeforeCommand = useScanner.mock.calls.length;
|
||||
await core.services.vault.scanVault(true, true);
|
||||
for await (const doc of core.services.database.localDatabase.findAllNormalDocs()) {
|
||||
storedPaths.push(doc.path);
|
||||
}
|
||||
return false;
|
||||
});
|
||||
|
||||
await start("rebuild", runner, 1, true);
|
||||
|
||||
expect(useScanner).toHaveBeenCalledOnce();
|
||||
expect(scannerRegistrationsBeforeCommand).toBe(1);
|
||||
expect(scansBeforeCommand).toBe(0);
|
||||
expect(enumerate).toHaveBeenCalledOnce();
|
||||
expect(storedPaths).toEqual(["notes/local.md"]);
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ command: "daemon" as const, commandRuns: true },
|
||||
{ command: "mirror" as const, commandRuns: false },
|
||||
|
||||
+29
-2
@@ -21,6 +21,7 @@ import {
|
||||
import { runCommand } from "./commands/runCommand";
|
||||
import { isCLICommand } from "./commands/types";
|
||||
import type { CLICommand, CLICommandContext, CLIOptions } from "./commands/types";
|
||||
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";
|
||||
@@ -47,6 +48,7 @@ const SETTINGS_FILE = ".livesync/settings.json";
|
||||
interface CLIVaultSyncMode {
|
||||
readonly watchFiles: boolean;
|
||||
readonly reflectReplicationResults: boolean;
|
||||
readonly scanOnStartup?: boolean;
|
||||
readonly startupDatabaseOptions: StartupDatabaseOptions;
|
||||
}
|
||||
|
||||
@@ -62,6 +64,18 @@ const VAULT_SYNC_MODES: Readonly<Partial<Record<CLICommand, CLIVaultSyncMode>>>
|
||||
reflectReplicationResults: false,
|
||||
startupDatabaseOptions: { ignoreSuspending: true, continueOnFileFailure: false },
|
||||
},
|
||||
rebuild: {
|
||||
watchFiles: false,
|
||||
reflectReplicationResults: false,
|
||||
scanOnStartup: false,
|
||||
startupDatabaseOptions: { ignoreSuspending: false, continueOnFileFailure: false },
|
||||
},
|
||||
fetch: {
|
||||
watchFiles: false,
|
||||
reflectReplicationResults: true,
|
||||
scanOnStartup: false,
|
||||
startupDatabaseOptions: { ignoreSuspending: false, continueOnFileFailure: false },
|
||||
},
|
||||
};
|
||||
|
||||
ensureGlobalNodeLocalStorage();
|
||||
@@ -90,6 +104,8 @@ Commands:
|
||||
p2p-sync <peer> <timeout>
|
||||
Sync with the specified peer-id or peer-name
|
||||
p2p-host Start P2P host mode and wait until interrupted
|
||||
rebuild Rebuild local and remote databases from the selected Vault (requires --force)
|
||||
fetch Replace the local database with remote state and reflect it to the Vault (requires --force)
|
||||
push <src> <dst> Push local file <src> into local database path <dst>
|
||||
pull <src> <dst> Pull file <src> from local database into local file <dst>
|
||||
pull-rev <src> <dst> <rev> Pull file <src> at specific revision <rev> into local file <dst>
|
||||
@@ -122,8 +138,9 @@ Commands:
|
||||
Show remote database status
|
||||
|
||||
Options:
|
||||
--vault <path>, -V <path> (daemon/mirror) Path to the vault directory containing .md files
|
||||
--vault <path>, -V <path> (daemon/mirror/rebuild/fetch) Path to the vault directory containing .md files
|
||||
(defaults to database-path; allows separate PouchDB and vault dirs)
|
||||
--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
|
||||
--compat-remote-admin-exit-zero
|
||||
@@ -319,6 +336,12 @@ export async function main(
|
||||
commandRunner: CliCommandRunner = runCommand
|
||||
) {
|
||||
const options = parseArgs(standardIo);
|
||||
const forceRequirementError = getForceRequirementError(options.command, options.force);
|
||||
if (forceRequirementError) {
|
||||
writeStderrLine(standardIo, `[Error] ${forceRequirementError}`);
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
const vaultSyncMode = VAULT_SYNC_MODES[options.command];
|
||||
if (options.interval && options.command !== "daemon") {
|
||||
writeStderrLine(
|
||||
@@ -521,7 +544,7 @@ export async function main(
|
||||
() => [], // No add-ons
|
||||
(core, coreFeatureViews) => {
|
||||
replicationScheduling = coreFeatureViews.replicationScheduling;
|
||||
if (vaultSyncMode) {
|
||||
if (vaultSyncMode && vaultSyncMode.scanOnStartup !== false) {
|
||||
useOfflineScanner(core);
|
||||
}
|
||||
// Register P2P replicator feature.
|
||||
@@ -641,6 +664,10 @@ export async function main(
|
||||
if (!readyResult) {
|
||||
throw new Error("Failed to initialise LiveSync.");
|
||||
}
|
||||
if (vaultSyncMode?.scanOnStartup === false) {
|
||||
// Keep the startup reset clean, then enable scans for the explicit Rebuild or Fetch operation.
|
||||
useOfflineScanner(core);
|
||||
}
|
||||
const settingsBeforeCommand = cloneSettings(core.services.setting.currentSettings());
|
||||
const transientSettingKeys = changedSettingKeys(settingsBeforeSuspend, settingsAfterSuspend);
|
||||
for (const key of CLI_RUNTIME_ONLY_SETTING_KEYS) {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { parseArgs as parseCliArgs } from "./main";
|
||||
import { main as runCliMain, parseArgs as parseCliArgs } from "./main";
|
||||
|
||||
function createStandardIoMock() {
|
||||
return {
|
||||
@@ -19,6 +19,7 @@ function mockProcessExit() {
|
||||
|
||||
describe("CLI parseArgs", () => {
|
||||
const originalArgv = process.argv.slice();
|
||||
const originalExitCode = process.exitCode;
|
||||
let standardIo: ReturnType<typeof createStandardIoMock>;
|
||||
|
||||
beforeEach(() => {
|
||||
@@ -29,6 +30,7 @@ describe("CLI parseArgs", () => {
|
||||
|
||||
afterEach(() => {
|
||||
process.argv = originalArgv.slice();
|
||||
process.exitCode = originalExitCode;
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
@@ -99,6 +101,28 @@ describe("CLI parseArgs", () => {
|
||||
expect(parsed.commandArgs).toEqual([]);
|
||||
});
|
||||
|
||||
it.each(["rebuild", "fetch"] as const)("parses %s and its force flag", (command) => {
|
||||
process.argv = ["node", "livesync-cli", "./databasePath", "--force", command];
|
||||
const parsed = parseArgs();
|
||||
|
||||
expect(parsed.command).toBe(command);
|
||||
expect(parsed.force).toBe(true);
|
||||
expect(parsed.commandArgs).toEqual([]);
|
||||
});
|
||||
|
||||
it.each(["rebuild", "fetch"] as const)("rejects %s without force before database access", async (command) => {
|
||||
process.argv = ["node", "livesync-cli", "./missing-database", command];
|
||||
process.exitCode = undefined;
|
||||
const runner = vi.fn(async () => true);
|
||||
|
||||
await expect(runCliMain(standardIo, runner as any)).resolves.toBeUndefined();
|
||||
|
||||
expect(runner).not.toHaveBeenCalled();
|
||||
expect(process.exitCode).toBe(1);
|
||||
expect(standardIo.writeStderr).toHaveBeenCalledWith(expect.stringContaining("requires --force"));
|
||||
expect(standardIo.writeStderr).not.toHaveBeenCalledWith(expect.stringContaining("Database directory"));
|
||||
});
|
||||
|
||||
it("parses remote-add command", () => {
|
||||
process.argv = [
|
||||
"node",
|
||||
|
||||
@@ -99,6 +99,7 @@ export function initialiseServiceModulesCLI(
|
||||
|
||||
// Rebuilder (platform-independent)
|
||||
const rebuilder = new ServiceRebuilder({
|
||||
path: services.path,
|
||||
events: services.context.events,
|
||||
API: services.API,
|
||||
database: services.database,
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import { FileAccessBase, type FileAccessBaseDependencies } from "@vrtmrz/livesync-commonlib/compat/serviceModules/FileAccessBase";
|
||||
import {
|
||||
FileAccessBase,
|
||||
type FileAccessBaseDependencies,
|
||||
} from "@vrtmrz/livesync-commonlib/compat/serviceModules/FileAccessBase";
|
||||
import { NodeFileSystemAdapter } from "@/apps/cli/adapters/NodeFileSystemAdapter";
|
||||
import { LOG_LEVEL_NOTICE } from "octagonal-wheels/common/logger";
|
||||
import { getPathNormalizationVersion } from "@vrtmrz/livesync-commonlib/settings";
|
||||
|
||||
/**
|
||||
* CLI-specific implementation of FileAccessBase
|
||||
@@ -8,12 +12,23 @@ import { LOG_LEVEL_NOTICE } from "octagonal-wheels/common/logger";
|
||||
*/
|
||||
export class FileAccessCLI extends FileAccessBase<NodeFileSystemAdapter> {
|
||||
constructor(basePath: string, dependencies: FileAccessBaseDependencies) {
|
||||
const adapter = new NodeFileSystemAdapter(basePath, (message, detail) => {
|
||||
dependencies.APIService.addLog(message, LOG_LEVEL_NOTICE);
|
||||
if (detail !== undefined) {
|
||||
dependencies.APIService.addLog(detail, LOG_LEVEL_NOTICE);
|
||||
}
|
||||
});
|
||||
const pathNormalizationVersion = () =>
|
||||
getPathNormalizationVersion(dependencies.settingService.currentSettings());
|
||||
const isCaseInsensitive = () => {
|
||||
const settings = dependencies.settingService.currentSettings();
|
||||
return !settings.handleFilenameCaseSensitive || dependencies.vaultService.isStorageInsensitive();
|
||||
};
|
||||
const adapter = new NodeFileSystemAdapter(
|
||||
basePath,
|
||||
(message, detail) => {
|
||||
dependencies.APIService.addLog(message, LOG_LEVEL_NOTICE);
|
||||
if (detail !== undefined) {
|
||||
dependencies.APIService.addLog(detail, LOG_LEVEL_NOTICE);
|
||||
}
|
||||
},
|
||||
pathNormalizationVersion,
|
||||
isCaseInsensitive
|
||||
);
|
||||
super(adapter, dependencies);
|
||||
}
|
||||
|
||||
|
||||
@@ -90,6 +90,7 @@ export function initialiseServiceModulesFSAPI(
|
||||
|
||||
// Rebuilder (platform-independent)
|
||||
const rebuilder = new ServiceRebuilder({
|
||||
path: services.path,
|
||||
events: services.context.events,
|
||||
API: services.API,
|
||||
database: services.database,
|
||||
|
||||
Reference in new issue
Block a user