mirror of
https://github.com/vrtmrz/obsidian-livesync.git
synced 2026-05-13 11:01:16 +00:00
Compare commits
3 Commits
address_co
...
test_real_
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
13bb44c9bb | ||
|
|
eeb508ed32 | ||
|
|
edf85184c1 |
6
.gitignore
vendored
6
.gitignore
vendored
@@ -28,4 +28,8 @@ data.json
|
||||
cov_profile/**
|
||||
|
||||
coverage
|
||||
src/apps/cli/dist/*
|
||||
src/apps/cli/dist/*
|
||||
|
||||
# Obsidian E2E test artefacts
|
||||
test_e2e/playwright-report/
|
||||
test_e2e/test-results/
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import esbuild from "esbuild";
|
||||
import process from "process";
|
||||
import builtins from "builtin-modules";
|
||||
import sveltePlugin from "esbuild-svelte";
|
||||
import { sveltePreprocess } from "svelte-preprocess";
|
||||
import fs from "node:fs";
|
||||
|
||||
@@ -1,83 +1,103 @@
|
||||
import typescriptEslint from "@typescript-eslint/eslint-plugin";
|
||||
import svelte from "eslint-plugin-svelte";
|
||||
import _import from "eslint-plugin-import";
|
||||
import { fixupPluginRules } from "@eslint/compat";
|
||||
import tsParser from "@typescript-eslint/parser";
|
||||
import obsidianmd from "eslint-plugin-obsidianmd";
|
||||
import globals from "globals";
|
||||
import { defineConfig, globalIgnores } from "eslint/config";
|
||||
import * as sveltePlugin from "eslint-plugin-svelte";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import js from "@eslint/js";
|
||||
import { FlatCompat } from "@eslint/eslintrc";
|
||||
|
||||
export default defineConfig([
|
||||
globalIgnores([
|
||||
"**/node_modules/*",
|
||||
"**/jest.config.js",
|
||||
"src/lib/coverage",
|
||||
"src/lib/browsertest",
|
||||
"**/test.ts",
|
||||
"**/tests.ts",
|
||||
"**/**test.ts",
|
||||
"**/**.test.ts",
|
||||
"**/*.unit.spec.ts",
|
||||
"**/esbuild.*.mjs",
|
||||
"**/terser.*.mjs",
|
||||
"**/node_modules",
|
||||
"**/build",
|
||||
"**/.eslintrc.js.bak",
|
||||
"src/lib/src/patches/pouchdb-utils",
|
||||
"**/esbuild.config.mjs",
|
||||
"**/rollup.config.js",
|
||||
"modules/octagonal-wheels/rollup.config.js",
|
||||
"modules/octagonal-wheels/dist/**/*",
|
||||
"src/lib/test",
|
||||
"src/lib/_tools",
|
||||
"src/lib/src/cli",
|
||||
"**/main.js",
|
||||
"src/apps/**/*",
|
||||
".prettierrc.*.mjs",
|
||||
".prettierrc.mjs",
|
||||
"*.config.mjs",
|
||||
"src/apps/**/*",
|
||||
"src/lib/src/services/implements/browser/**",
|
||||
"src/lib/src/services/implements/headless/**",
|
||||
"src/lib/src/API",
|
||||
]),
|
||||
...sveltePlugin.configs["flat/base"],
|
||||
...obsidianmd.configs.recommended,
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
const compat = new FlatCompat({
|
||||
baseDirectory: __dirname,
|
||||
recommendedConfig: js.configs.recommended,
|
||||
allConfig: js.configs.all,
|
||||
});
|
||||
|
||||
export default [
|
||||
{
|
||||
files: ["**/*.ts"],
|
||||
ignores: [
|
||||
"**/node_modules/*",
|
||||
"**/jest.config.js",
|
||||
"src/lib/coverage",
|
||||
"src/lib/browsertest",
|
||||
"**/test.ts",
|
||||
"**/tests.ts",
|
||||
"**/**test.ts",
|
||||
"**/**.test.ts",
|
||||
"**/esbuild.*.mjs",
|
||||
"**/terser.*.mjs",
|
||||
"**/node_modules",
|
||||
"**/build",
|
||||
"**/.eslintrc.js.bak",
|
||||
"src/lib/src/patches/pouchdb-utils",
|
||||
"**/esbuild.config.mjs",
|
||||
"**/rollup.config.js",
|
||||
"modules/octagonal-wheels/rollup.config.js",
|
||||
"modules/octagonal-wheels/dist/**/*",
|
||||
"src/lib/test",
|
||||
"src/lib/_tools",
|
||||
"src/lib/src/cli",
|
||||
"**/main.js",
|
||||
"src/apps/**/*",
|
||||
".prettierrc.*.mjs",
|
||||
".prettierrc.mjs",
|
||||
"*.config.mjs"
|
||||
],
|
||||
},
|
||||
...compat.extends(
|
||||
"eslint:recommended",
|
||||
"plugin:@typescript-eslint/eslint-recommended",
|
||||
"plugin:@typescript-eslint/recommended"
|
||||
),
|
||||
{
|
||||
plugins: {
|
||||
"@typescript-eslint": typescriptEslint,
|
||||
svelte,
|
||||
import: fixupPluginRules(_import),
|
||||
},
|
||||
|
||||
languageOptions: {
|
||||
globals: { ...globals.browser },
|
||||
parser: tsParser,
|
||||
ecmaVersion: 5,
|
||||
sourceType: "module",
|
||||
|
||||
parserOptions: {
|
||||
project: "./tsconfig.json",
|
||||
project: ["tsconfig.json"],
|
||||
},
|
||||
},
|
||||
|
||||
rules: {
|
||||
"no-unused-vars": "off",
|
||||
"@typescript-eslint/no-unused-vars": ["error", { args: "none" }],
|
||||
|
||||
"@typescript-eslint/no-unused-vars": [
|
||||
"error",
|
||||
{
|
||||
args: "none",
|
||||
},
|
||||
],
|
||||
|
||||
"no-unused-labels": "off",
|
||||
"@typescript-eslint/ban-ts-comment": "off",
|
||||
"no-prototype-builtins": "off",
|
||||
"@typescript-eslint/no-empty-function": "off",
|
||||
"require-await": "error",
|
||||
"obsidianmd/rule-custom-message": "off", // Temporary
|
||||
"obsidianmd/ui/sentence-case": "off", // Temporary
|
||||
"@typescript-eslint/require-await": "warn",
|
||||
"@typescript-eslint/no-misused-promises": "warn",
|
||||
"@typescript-eslint/no-floating-promises": "warn",
|
||||
"no-async-promise-executor": "warn",
|
||||
"@typescript-eslint/no-explicit-any": "off",
|
||||
"@typescript-eslint/no-unnecessary-type-assertion": "error",
|
||||
"no-constant-condition": ["error", { checkLoops: false }],
|
||||
|
||||
"no-constant-condition": [
|
||||
"error",
|
||||
{
|
||||
checkLoops: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
files: ["**/*.svelte"],
|
||||
languageOptions: {
|
||||
parserOptions: {
|
||||
parser: tsParser,
|
||||
},
|
||||
},
|
||||
rules: {
|
||||
"no-unused-vars": ["error", { argsIgnorePattern: "^_", varsIgnorePattern: "^_" }],
|
||||
"obsidianmd/no-plugin-as-component": "off", // Temporary
|
||||
},
|
||||
},
|
||||
]);
|
||||
];
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"id": "obsidian-livesync",
|
||||
"name": "Self-hosted LiveSync",
|
||||
"version": "0.25.60",
|
||||
"minAppVersion": "1.7.2",
|
||||
"minAppVersion": "0.9.12",
|
||||
"description": "Community implementation of self-hosted livesync. Reflect your vault changes to some other devices immediately. Please make sure to disable other synchronize solutions to avoid content corruption or duplication.",
|
||||
"author": "vorotamoroz",
|
||||
"authorUrl": "https://github.com/vrtmrz",
|
||||
|
||||
1352
package-lock.json
generated
1352
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
19
package.json
19
package.json
@@ -54,20 +54,24 @@
|
||||
"test:docker-all:start": "npm run test:docker-all:up && sleep 5 && npm run test:docker-all:init",
|
||||
"test:docker-all:stop": "npm run test:docker-all:down",
|
||||
"test:full": "npm run test:docker-all:start && vitest run --coverage && npm run test:docker-all:stop",
|
||||
"test:p2p": "bash test/suitep2p/run-p2p-tests.sh"
|
||||
"test:p2p": "bash test/suitep2p/run-p2p-tests.sh",
|
||||
"test:obsidian:e2e": "npx playwright test --config test_e2e/playwright.config.ts",
|
||||
"test:obsidian:e2e:headed": "npx playwright test --config test_e2e/playwright.config.ts --headed",
|
||||
"test:obsidian:build-and-e2e": "npm run buildDev && npm run test:obsidian:e2e"
|
||||
},
|
||||
"keywords": [],
|
||||
"author": "vorotamoroz",
|
||||
"license": "MIT",
|
||||
"devDependencies": {
|
||||
"@chialab/esbuild-plugin-worker": "^0.19.0",
|
||||
"@eslint/compat": "^2.0.2",
|
||||
"@eslint/eslintrc": "^3.3.4",
|
||||
"@eslint/js": "^9.39.3",
|
||||
"@sveltejs/vite-plugin-svelte": "^6.2.4",
|
||||
"@tsconfig/svelte": "^5.0.8",
|
||||
"@types/deno": "^2.5.0",
|
||||
"@types/diff-match-patch": "^1.0.36",
|
||||
"@types/markdown-it": "^14.1.2",
|
||||
"@types/micromatch": "^4.0.10",
|
||||
"@types/node": "^24.10.13",
|
||||
"@types/pouchdb": "^6.4.2",
|
||||
"@types/pouchdb-adapter-http": "^6.1.6",
|
||||
@@ -82,15 +86,18 @@
|
||||
"@vitest/browser": "^4.1.1",
|
||||
"@vitest/browser-playwright": "^4.1.1",
|
||||
"@vitest/coverage-v8": "^4.1.1",
|
||||
"builtin-modules": "5.0.0",
|
||||
"dotenv": "^17.3.1",
|
||||
"dotenv-cli": "^11.0.0",
|
||||
"esbuild": "0.25.0",
|
||||
"esbuild-plugin-inline-worker": "^0.1.1",
|
||||
"esbuild-svelte": "^0.9.4",
|
||||
"eslint": "^9.39.3",
|
||||
"eslint-plugin-obsidianmd": "^0.3.0",
|
||||
"eslint-plugin-import": "^2.32.0",
|
||||
"eslint-plugin-svelte": "^3.15.0",
|
||||
"events": "^3.3.0",
|
||||
"globals": "^14.0.0",
|
||||
"glob": "^13.0.6",
|
||||
"obsidian": "^1.12.3",
|
||||
"playwright": "^1.58.2",
|
||||
"postcss": "^8.5.6",
|
||||
"postcss-load-config": "^6.0.1",
|
||||
@@ -111,7 +118,6 @@
|
||||
"svelte-check": "^4.4.3",
|
||||
"svelte-preprocess": "^6.0.3",
|
||||
"terser": "^5.39.0",
|
||||
"tinyglobby": "^0.2.15",
|
||||
"transform-pouch": "^2.0.0",
|
||||
"tslib": "^2.8.1",
|
||||
"tsx": "^4.21.0",
|
||||
@@ -130,14 +136,11 @@
|
||||
"@smithy/protocol-http": "^5.3.9",
|
||||
"@smithy/querystring-builder": "^4.2.9",
|
||||
"@trystero-p2p/nostr": "^0.23.0",
|
||||
"chokidar": "^4.0.0",
|
||||
"commander": "^14.0.3",
|
||||
"obsidian": "^1.12.3",
|
||||
"diff-match-patch": "^1.0.5",
|
||||
"fflate": "^0.8.2",
|
||||
"idb": "^8.0.3",
|
||||
"markdown-it": "^14.1.1",
|
||||
"micromatch": "^4.0.0",
|
||||
"minimatch": "^10.2.2",
|
||||
"octagonal-wheels": "^0.1.45",
|
||||
"pouchdb-adapter-leveldb": "^9.0.0",
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { LOG_LEVEL_INFO } from "octagonal-wheels/common/logger";
|
||||
import type PouchDB from "pouchdb-core";
|
||||
import type { SimpleStore } from "octagonal-wheels/databases/SimpleStoreBase";
|
||||
import type { HasSettings, ObsidianLiveSyncSettings, EntryDoc } from "./lib/src/common/types";
|
||||
import { __$checkInstanceBinding } from "./lib/src/dev/checks";
|
||||
@@ -35,11 +34,12 @@ export class LiveSyncBaseCore<
|
||||
TCommands extends IMinimumLiveSyncCommands = IMinimumLiveSyncCommands,
|
||||
>
|
||||
implements
|
||||
LiveSyncLocalDBEnv,
|
||||
LiveSyncReplicatorEnv,
|
||||
LiveSyncJournalReplicatorEnv,
|
||||
LiveSyncCouchDBReplicatorEnv,
|
||||
HasSettings<ObsidianLiveSyncSettings> {
|
||||
LiveSyncLocalDBEnv,
|
||||
LiveSyncReplicatorEnv,
|
||||
LiveSyncJournalReplicatorEnv,
|
||||
LiveSyncCouchDBReplicatorEnv,
|
||||
HasSettings<ObsidianLiveSyncSettings>
|
||||
{
|
||||
addOns = [] as TCommands[];
|
||||
|
||||
/**
|
||||
@@ -123,7 +123,7 @@ export class LiveSyncBaseCore<
|
||||
for (const module of this.modules) {
|
||||
if (module.constructor === constructor) return module as T;
|
||||
}
|
||||
throw new Error(`Module ${constructor.name} not found or not loaded.`);
|
||||
throw new Error(`Module ${constructor} not found or not loaded.`);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -160,10 +160,8 @@ export class LiveSyncBaseCore<
|
||||
module.onBindFunction(this, this.services);
|
||||
__$checkInstanceBinding(module); // Check if all functions are properly bound, and log warnings if not.
|
||||
} else {
|
||||
// module should not be never.
|
||||
const moduleName = (module as unknown)?.constructor?.name ?? "unknown";
|
||||
this.services.API.addLog(
|
||||
`Module ${moduleName} does not have onBindFunction, skipping binding.`,
|
||||
`Module ${(module as any)?.constructor?.name ?? "unknown"} does not have onBindFunction, skipping binding.`,
|
||||
LOG_LEVEL_INFO
|
||||
);
|
||||
}
|
||||
|
||||
@@ -92,39 +92,39 @@ livesync-cli ./my-db pull folder/note.md ./note.md
|
||||
|
||||
## Installation
|
||||
|
||||
### Build from source
|
||||
### Build from source
|
||||
|
||||
```bash
|
||||
# Clone with submodules, because the shared core lives in src/lib
|
||||
git clone --recurse-submodules <repository-url>
|
||||
cd obsidian-livesync
|
||||
|
||||
# If you already cloned without submodules, run this once instead
|
||||
git submodule update --init --recursive
|
||||
|
||||
# Install dependencies from the repository root
|
||||
npm install
|
||||
|
||||
# Build the CLI from its package directory
|
||||
cd src/apps/cli
|
||||
npm run build
|
||||
```
|
||||
|
||||
If `src/lib` is missing, `npm run build` now stops early with a targeted message
|
||||
instead of a low-level Vite `ENOENT` error.
|
||||
|
||||
```bash
|
||||
# Clone with submodules, because the shared core lives in src/lib
|
||||
git clone --recurse-submodules <repository-url>
|
||||
cd obsidian-livesync
|
||||
|
||||
# If you already cloned without submodules, run this once instead
|
||||
git submodule update --init --recursive
|
||||
|
||||
# Install dependencies from the repository root
|
||||
npm install
|
||||
|
||||
# Build the CLI from its package directory
|
||||
cd src/apps/cli
|
||||
npm run build
|
||||
```
|
||||
|
||||
If `src/lib` is missing, `npm run build` now stops early with a targeted message
|
||||
instead of a low-level Vite `ENOENT` error.
|
||||
|
||||
Run the CLI:
|
||||
|
||||
```bash
|
||||
# Run with npm script (from repository root)
|
||||
npm run --silent cli -- [database-path] [command] [args...]
|
||||
Run the CLI:
|
||||
|
||||
```bash
|
||||
# Run with npm script (from repository root)
|
||||
npm run --silent cli -- [database-path] [command] [args...]
|
||||
# Run the built executable directly
|
||||
node src/apps/cli/dist/index.cjs [database-path] [command] [args...]
|
||||
```
|
||||
|
||||
### Docker
|
||||
|
||||
A Docker image is provided for headless / server deployments. Build from the repository root:
|
||||
### Docker
|
||||
|
||||
A Docker image is provided for headless / server deployments. Build from the repository root:
|
||||
|
||||
```bash
|
||||
docker build -f src/apps/cli/Dockerfile -t livesync-cli .
|
||||
@@ -297,11 +297,9 @@ Options:
|
||||
--force, -f 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
|
||||
--help, -h Show this help message
|
||||
--help, -h Show help message
|
||||
|
||||
Commands:
|
||||
daemon (default) Run mirror scan then continuously sync CouchDB <-> local filesystem
|
||||
init-settings [path] Create settings JSON from DEFAULT_SETTINGS
|
||||
sync Run one replication cycle and exit
|
||||
p2p-peers <timeout> Show discovered peers as [peer]<TAB><peer-id><TAB><peer-name>
|
||||
@@ -408,86 +406,6 @@ In other words, it performs the following actions:
|
||||
|
||||
Note: `mirror` does not respect file deletions. If a file is deleted in storage, it will be restored on the next `mirror` run. To delete a file, use the `rm` command instead. This is a little inconvenient, but it is intentional behaviour (if we handle this automatically in `mirror`, we should be against a ton of edge cases).
|
||||
|
||||
##### daemon
|
||||
|
||||
`daemon` is the default command when no command is specified. It runs an initial mirror scan and then continuously syncs changes in both directions:
|
||||
|
||||
- **CouchDB → local filesystem**: via the `_changes` feed (LiveSync mode, default) or periodic polling (`--interval N`).
|
||||
- **local filesystem → CouchDB**: via chokidar file watching. Any file created, modified, or deleted in the vault directory is pushed to CouchDB.
|
||||
|
||||
In **LiveSync mode** the `_changes` feed delivers remote changes as they arrive, with sub-second latency. In **polling mode** (`--interval N`) the CLI polls CouchDB every N seconds. Use polling mode if your CouchDB instance does not support long-lived HTTP connections, or if you need predictable network usage.
|
||||
|
||||
The daemon exits cleanly on `SIGINT` or `SIGTERM`.
|
||||
|
||||
```bash
|
||||
# LiveSync mode (default — _changes feed, near-real-time)
|
||||
livesync-cli /path/to/vault
|
||||
|
||||
# Polling mode — poll every 60 seconds
|
||||
livesync-cli /path/to/vault --interval 60
|
||||
```
|
||||
|
||||
### .livesync/ignore
|
||||
|
||||
Place a `.livesync/ignore` file in your vault root to exclude files from sync in both directions (local → CouchDB and CouchDB → local).
|
||||
|
||||
**Format:**
|
||||
|
||||
- Lines beginning with `#` are comments.
|
||||
- Blank lines are ignored.
|
||||
- All other lines are [minimatch](https://github.com/isaacs/minimatch) glob patterns, relative to the vault root.
|
||||
- The directive `import: .gitignore` (exactly this string) reads `.gitignore` from the vault root and merges its non-comment, non-blank lines into the ignore rules.
|
||||
- Negation patterns (lines starting with `!`) are not supported and will cause an error on load.
|
||||
|
||||
**Example `.livesync/ignore`:**
|
||||
|
||||
```
|
||||
# Ignore temporary files
|
||||
*.tmp
|
||||
*.swp
|
||||
|
||||
# Ignore build output
|
||||
build/
|
||||
dist/
|
||||
|
||||
# Merge patterns from .gitignore
|
||||
import: .gitignore
|
||||
```
|
||||
|
||||
Patterns apply in both directions: the chokidar watcher will not emit events for matched files, and the `isTargetFile` filter will exclude them from CouchDB → local sync.
|
||||
|
||||
Changes to this file require a daemon restart to take effect.
|
||||
|
||||
### Systemd Installation
|
||||
|
||||
The `deploy/` directory contains a systemd unit template and an install script.
|
||||
|
||||
**Automated install (user service, recommended):**
|
||||
|
||||
```bash
|
||||
bash src/apps/cli/deploy/install.sh --vault /path/to/vault
|
||||
```
|
||||
|
||||
**With polling interval:**
|
||||
|
||||
```bash
|
||||
bash src/apps/cli/deploy/install.sh --vault /path/to/vault --interval 60
|
||||
```
|
||||
|
||||
**System-wide install** (requires root / sudo for `/etc/systemd/system/`):
|
||||
|
||||
```bash
|
||||
bash src/apps/cli/deploy/install.sh --system --vault /path/to/vault
|
||||
```
|
||||
|
||||
The script:
|
||||
1. Builds the CLI (`npm install` + `npm run build`).
|
||||
2. Installs the binary to `~/.local/bin/livesync-cli` (user) or `/usr/local/bin/livesync-cli` (system).
|
||||
3. Writes the unit file to `~/.config/systemd/user/livesync-cli.service` (user) or `/etc/systemd/system/livesync-cli.service` (system).
|
||||
4. Runs `systemctl [--user] daemon-reload && systemctl [--user] enable --now livesync-cli`.
|
||||
|
||||
**Manual setup** — if you prefer to manage the unit yourself, copy `deploy/livesync-cli.service`, replace `LIVESYNC_BIN` and `LIVESYNC_VAULT_PATH` with the actual binary path and vault path, then install to the appropriate systemd directory.
|
||||
|
||||
### Planned options:
|
||||
|
||||
- `--immediate`: Perform sync after the command (e.g. `push`, `pull`, `put`, `rm`).
|
||||
|
||||
@@ -39,6 +39,12 @@ export class NodeFileSystemAdapter implements IFileSystemAdapter<NodeFile, NodeF
|
||||
|
||||
async getAbstractFileByPath(p: FilePath | string): Promise<NodeFile | null> {
|
||||
const pathStr = this.normalisePath(p);
|
||||
|
||||
const cached = this.fileCache.get(pathStr);
|
||||
if (cached) {
|
||||
return cached;
|
||||
}
|
||||
|
||||
return await this.refreshFile(pathStr);
|
||||
}
|
||||
|
||||
@@ -98,15 +104,14 @@ export class NodeFileSystemAdapter implements IFileSystemAdapter<NodeFile, NodeF
|
||||
path: pathStr as FilePath,
|
||||
stat: {
|
||||
size: stat.size,
|
||||
mtime: Math.floor(stat.mtimeMs),
|
||||
ctime: Math.floor(stat.ctimeMs),
|
||||
mtime: stat.mtimeMs,
|
||||
ctime: stat.ctimeMs,
|
||||
type: "file",
|
||||
},
|
||||
};
|
||||
this.fileCache.set(pathStr, file);
|
||||
return file;
|
||||
} catch {
|
||||
// Evict so a deleted file is not returned by subsequent cache scans.
|
||||
this.fileCache.delete(pathStr);
|
||||
return null;
|
||||
}
|
||||
@@ -132,8 +137,8 @@ export class NodeFileSystemAdapter implements IFileSystemAdapter<NodeFile, NodeF
|
||||
path: entryRelativePath as FilePath,
|
||||
stat: {
|
||||
size: stat.size,
|
||||
mtime: Math.floor(stat.mtimeMs),
|
||||
ctime: Math.floor(stat.ctimeMs),
|
||||
mtime: stat.mtimeMs,
|
||||
ctime: stat.ctimeMs,
|
||||
type: "file",
|
||||
},
|
||||
};
|
||||
|
||||
@@ -28,8 +28,8 @@ export class NodeStorageAdapter implements IStorageAdapter<NodeStat> {
|
||||
const stat = await fs.stat(this.resolvePath(p));
|
||||
return {
|
||||
size: stat.size,
|
||||
mtime: Math.floor(stat.mtimeMs),
|
||||
ctime: Math.floor(stat.ctimeMs),
|
||||
mtime: stat.mtimeMs,
|
||||
ctime: stat.ctimeMs,
|
||||
type: stat.isDirectory() ? "folder" : "file",
|
||||
};
|
||||
} catch {
|
||||
|
||||
@@ -15,12 +15,7 @@ export class NodeVaultAdapter implements IVaultAdapter<NodeFile> {
|
||||
}
|
||||
|
||||
async read(file: NodeFile): Promise<string> {
|
||||
const content = await fs.readFile(this.resolvePath(file.path), "utf-8");
|
||||
// Correct stale stat.size — chokidar stats may be from a poll before the final write.
|
||||
// The downstream document integrity check compares stat.size to content length, so
|
||||
// they must agree or other clients reject the file as corrupted.
|
||||
file.stat.size = Buffer.byteLength(content, "utf-8");
|
||||
return content;
|
||||
return await fs.readFile(this.resolvePath(file.path), "utf-8");
|
||||
}
|
||||
|
||||
async cachedRead(file: NodeFile): Promise<string> {
|
||||
@@ -30,8 +25,6 @@ export class NodeVaultAdapter implements IVaultAdapter<NodeFile> {
|
||||
|
||||
async readBinary(file: NodeFile): Promise<ArrayBuffer> {
|
||||
const buffer = await fs.readFile(this.resolvePath(file.path));
|
||||
// Same correction as read() — ensure stat.size matches actual byte length.
|
||||
file.stat.size = buffer.length;
|
||||
return buffer.buffer.slice(buffer.byteOffset, buffer.byteOffset + buffer.byteLength) as ArrayBuffer;
|
||||
}
|
||||
|
||||
@@ -73,8 +66,8 @@ export class NodeVaultAdapter implements IVaultAdapter<NodeFile> {
|
||||
path: p as any,
|
||||
stat: {
|
||||
size: stat.size,
|
||||
mtime: Math.floor(stat.mtimeMs),
|
||||
ctime: Math.floor(stat.ctimeMs),
|
||||
mtime: stat.mtimeMs,
|
||||
ctime: stat.ctimeMs,
|
||||
type: "file",
|
||||
},
|
||||
};
|
||||
@@ -96,8 +89,8 @@ export class NodeVaultAdapter implements IVaultAdapter<NodeFile> {
|
||||
path: p as any,
|
||||
stat: {
|
||||
size: stat.size,
|
||||
mtime: Math.floor(stat.mtimeMs),
|
||||
ctime: Math.floor(stat.ctimeMs),
|
||||
mtime: stat.mtimeMs,
|
||||
ctime: stat.ctimeMs,
|
||||
type: "file",
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,312 +0,0 @@
|
||||
import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
|
||||
import { runCommand } from "./runCommand";
|
||||
import type { CLIOptions } from "./types";
|
||||
|
||||
// Mock performFullScan so daemon tests don't require a real CouchDB connection.
|
||||
vi.mock("@lib/serviceFeatures/offlineScanner", () => ({
|
||||
performFullScan: vi.fn(async () => true),
|
||||
}));
|
||||
|
||||
// Mock UnresolvedErrorManager to avoid event-hub side effects.
|
||||
vi.mock("@lib/services/base/UnresolvedErrorManager", () => ({
|
||||
UnresolvedErrorManager: class UnresolvedErrorManager {
|
||||
showError() {}
|
||||
clearError() {}
|
||||
clearErrors() {}
|
||||
},
|
||||
}));
|
||||
|
||||
import * as offlineScanner from "@lib/serviceFeatures/offlineScanner";
|
||||
|
||||
function createCoreMock() {
|
||||
return {
|
||||
services: {
|
||||
control: {
|
||||
activated: Promise.resolve(),
|
||||
applySettings: vi.fn(async () => {}),
|
||||
},
|
||||
setting: {
|
||||
applyPartial: vi.fn(async () => {}),
|
||||
currentSettings: vi.fn(() => ({ liveSync: true, syncOnStart: false })),
|
||||
},
|
||||
replication: {
|
||||
replicate: vi.fn(async () => true),
|
||||
},
|
||||
appLifecycle: {
|
||||
onUnload: {
|
||||
addHandler: vi.fn(),
|
||||
},
|
||||
},
|
||||
},
|
||||
serviceModules: {
|
||||
fileHandler: {
|
||||
dbToStorage: vi.fn(async () => true),
|
||||
storeFileToDB: vi.fn(async () => true),
|
||||
},
|
||||
storageAccess: {
|
||||
readFileAuto: vi.fn(async () => ""),
|
||||
writeFileAuto: vi.fn(async () => {}),
|
||||
},
|
||||
databaseFileAccess: {
|
||||
fetch: vi.fn(async () => undefined),
|
||||
},
|
||||
},
|
||||
} as any;
|
||||
}
|
||||
|
||||
function makeDaemonOptions(interval?: number): CLIOptions {
|
||||
return {
|
||||
command: "daemon",
|
||||
commandArgs: [],
|
||||
databasePath: "/tmp/vault",
|
||||
verbose: false,
|
||||
force: false,
|
||||
interval,
|
||||
};
|
||||
}
|
||||
|
||||
const baseContext = {
|
||||
vaultPath: "/tmp/vault",
|
||||
settingsPath: "/tmp/vault/.livesync/settings.json",
|
||||
originalSyncSettings: {
|
||||
liveSync: true,
|
||||
syncOnStart: false,
|
||||
periodicReplication: false,
|
||||
syncOnSave: false,
|
||||
syncOnEditorSave: false,
|
||||
syncOnFileOpen: false,
|
||||
syncAfterMerge: false,
|
||||
},
|
||||
} as any;
|
||||
|
||||
describe("daemon command", () => {
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("calls performFullScan during startup", async () => {
|
||||
const core = createCoreMock();
|
||||
vi.mocked(offlineScanner.performFullScan).mockResolvedValue(true);
|
||||
|
||||
await runCommand(makeDaemonOptions(), { ...baseContext, core });
|
||||
|
||||
expect(offlineScanner.performFullScan).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("returns false when performFullScan fails", async () => {
|
||||
const core = createCoreMock();
|
||||
vi.mocked(offlineScanner.performFullScan).mockResolvedValue(false);
|
||||
|
||||
const result = await runCommand(makeDaemonOptions(), { ...baseContext, core });
|
||||
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it("polling mode: calls setTimeout when interval option is set", async () => {
|
||||
const core = createCoreMock();
|
||||
vi.mocked(offlineScanner.performFullScan).mockResolvedValue(true);
|
||||
const setTimeoutSpy = vi.spyOn(globalThis, "setTimeout");
|
||||
|
||||
await runCommand(makeDaemonOptions(30), { ...baseContext, core });
|
||||
|
||||
expect(setTimeoutSpy).toHaveBeenCalledTimes(1);
|
||||
// Interval should be in milliseconds (30s → 30000ms)
|
||||
expect(setTimeoutSpy).toHaveBeenCalledWith(expect.any(Function), 30000);
|
||||
});
|
||||
|
||||
it("polling mode: applies settings with suspendFileWatching=false before setting interval", async () => {
|
||||
const core = createCoreMock();
|
||||
vi.mocked(offlineScanner.performFullScan).mockResolvedValue(true);
|
||||
|
||||
await runCommand(makeDaemonOptions(10), { ...baseContext, core });
|
||||
|
||||
expect(core.services.setting.applyPartial).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ suspendFileWatching: false }),
|
||||
true
|
||||
);
|
||||
expect(core.services.control.applySettings).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("liveSync mode: calls applyPartial and applySettings", async () => {
|
||||
const core = createCoreMock();
|
||||
vi.mocked(offlineScanner.performFullScan).mockResolvedValue(true);
|
||||
|
||||
await runCommand(makeDaemonOptions(), { ...baseContext, core });
|
||||
|
||||
expect(core.services.setting.applyPartial).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
...baseContext.originalSyncSettings,
|
||||
suspendFileWatching: false,
|
||||
}),
|
||||
true
|
||||
);
|
||||
expect(core.services.control.applySettings).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("liveSync mode: logs warning when both liveSync and syncOnStart are false", async () => {
|
||||
const core = createCoreMock();
|
||||
core.services.setting.currentSettings = vi.fn(() => ({
|
||||
liveSync: false,
|
||||
syncOnStart: false,
|
||||
}));
|
||||
vi.mocked(offlineScanner.performFullScan).mockResolvedValue(true);
|
||||
const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
|
||||
const result = await runCommand(makeDaemonOptions(), { ...baseContext, core });
|
||||
|
||||
expect(result).toBe(true);
|
||||
const warningCalls = consoleSpy.mock.calls.filter(
|
||||
(args) => typeof args[0] === "string" && args[0].includes("liveSync and syncOnStart are both disabled")
|
||||
);
|
||||
expect(warningCalls.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("liveSync mode: no warning when liveSync is true", async () => {
|
||||
const core = createCoreMock();
|
||||
core.services.setting.currentSettings = vi.fn(() => ({
|
||||
liveSync: true,
|
||||
syncOnStart: false,
|
||||
}));
|
||||
vi.mocked(offlineScanner.performFullScan).mockResolvedValue(true);
|
||||
const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
|
||||
await runCommand(makeDaemonOptions(), { ...baseContext, core });
|
||||
|
||||
const warningCalls = consoleSpy.mock.calls.filter(
|
||||
(args) => typeof args[0] === "string" && args[0].includes("liveSync and syncOnStart are both disabled")
|
||||
);
|
||||
expect(warningCalls.length).toBe(0);
|
||||
});
|
||||
|
||||
it("calls replicate before performFullScan", async () => {
|
||||
const core = createCoreMock();
|
||||
const callOrder: string[] = [];
|
||||
core.services.replication.replicate = vi.fn(async () => {
|
||||
callOrder.push("replicate");
|
||||
return true;
|
||||
});
|
||||
vi.mocked(offlineScanner.performFullScan).mockImplementation(async () => {
|
||||
callOrder.push("performFullScan");
|
||||
return true;
|
||||
});
|
||||
|
||||
await runCommand(makeDaemonOptions(), { ...baseContext, core });
|
||||
|
||||
expect(callOrder).toEqual(["replicate", "performFullScan"]);
|
||||
});
|
||||
|
||||
it("returns false when initial replication fails", async () => {
|
||||
const core = createCoreMock();
|
||||
core.services.replication.replicate = vi.fn(async () => false);
|
||||
vi.mocked(offlineScanner.performFullScan).mockClear();
|
||||
|
||||
const result = await runCommand(makeDaemonOptions(), { ...baseContext, core });
|
||||
|
||||
expect(result).toBe(false);
|
||||
// performFullScan should NOT have been called
|
||||
expect(offlineScanner.performFullScan).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("polling mode: registers onUnload handler that clears timeout", async () => {
|
||||
const core = createCoreMock();
|
||||
vi.mocked(offlineScanner.performFullScan).mockResolvedValue(true);
|
||||
|
||||
await runCommand(makeDaemonOptions(10), { ...baseContext, core });
|
||||
|
||||
// onUnload handler should have been registered
|
||||
expect(core.services.appLifecycle.onUnload.addHandler).toHaveBeenCalledTimes(1);
|
||||
const handler = core.services.appLifecycle.onUnload.addHandler.mock.calls[0][0];
|
||||
|
||||
// Get the timeout ID that was created
|
||||
const clearTimeoutSpy = vi.spyOn(globalThis, "clearTimeout");
|
||||
await handler();
|
||||
expect(clearTimeoutSpy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("polling backoff: interval escalates on failure, caps at 300000ms, then halves on recovery", async () => {
|
||||
const core = createCoreMock();
|
||||
vi.mocked(offlineScanner.performFullScan).mockResolvedValue(true);
|
||||
vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
|
||||
// startup replicate (call 1) succeeds; poll calls 2–7 fail; call 8 succeeds.
|
||||
let callCount = 0;
|
||||
core.services.replication.replicate = vi.fn(async () => {
|
||||
callCount++;
|
||||
if (callCount === 1) return true; // initial startup replicate
|
||||
if (callCount <= 7) throw new Error("network failure");
|
||||
return true; // recovery
|
||||
});
|
||||
|
||||
const baseMs = 30 * 1000;
|
||||
const setTimeoutSpy = vi.spyOn(globalThis, "setTimeout");
|
||||
|
||||
await runCommand(makeDaemonOptions(30), { ...baseContext, core });
|
||||
|
||||
// After runCommand returns the first setTimeout has been scheduled.
|
||||
// setTimeoutSpy.mock.calls[0] is the initial schedule (baseMs).
|
||||
expect(setTimeoutSpy.mock.calls[0][1]).toBe(baseMs);
|
||||
|
||||
// Advance through 6 failure polls. After each failure the next setTimeout
|
||||
// should be scheduled with a larger (or capped) interval.
|
||||
// formula: min(base * 2^n, 300000). base=30000ms.
|
||||
// failure 1: 30000*2=60000, failure 2: 30000*4=120000,
|
||||
// failure 3: 30000*8=240000, failure 4: 30000*16=480000→capped, 5→cap, 6→cap
|
||||
const expectedIntervals = [
|
||||
baseMs * 2, // after failure 1: 60000
|
||||
baseMs * 4, // after failure 2: 120000
|
||||
baseMs * 8, // after failure 3: 240000
|
||||
300_000, // after failure 4 (would be 480000, capped)
|
||||
300_000, // after failure 5 (cap)
|
||||
300_000, // after failure 6 (cap)
|
||||
];
|
||||
|
||||
for (const expected of expectedIntervals) {
|
||||
const prevCallCount = setTimeoutSpy.mock.calls.length;
|
||||
await vi.advanceTimersByTimeAsync(setTimeoutSpy.mock.calls[prevCallCount - 1][1] as number);
|
||||
const newCallCount = setTimeoutSpy.mock.calls.length;
|
||||
expect(newCallCount).toBeGreaterThan(prevCallCount);
|
||||
expect(setTimeoutSpy.mock.calls[newCallCount - 1][1]).toBe(expected);
|
||||
}
|
||||
|
||||
// Now trigger the success poll — interval should halve each time toward base.
|
||||
// After failure 6, consecutiveFailures=6, currentIntervalMs=300000.
|
||||
// On success: consecutiveFailures=5, currentIntervalMs=150000.
|
||||
const prevCallCount = setTimeoutSpy.mock.calls.length;
|
||||
await vi.advanceTimersByTimeAsync(setTimeoutSpy.mock.calls[prevCallCount - 1][1] as number);
|
||||
const afterSuccessCallCount = setTimeoutSpy.mock.calls.length;
|
||||
expect(afterSuccessCallCount).toBeGreaterThan(prevCallCount);
|
||||
// The interval after one success should be halved (300000 / 2 = 150000).
|
||||
expect(setTimeoutSpy.mock.calls[afterSuccessCallCount - 1][1]).toBe(150_000);
|
||||
});
|
||||
|
||||
it("polling error handling: replicate rejection is caught and console.error is called", async () => {
|
||||
const core = createCoreMock();
|
||||
vi.mocked(offlineScanner.performFullScan).mockResolvedValue(true);
|
||||
const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
|
||||
// Make replicate succeed on the initial call (startup), then fail on the poll.
|
||||
let callCount = 0;
|
||||
core.services.replication.replicate = vi.fn(async () => {
|
||||
callCount++;
|
||||
if (callCount === 1) return true; // startup replicate
|
||||
throw new Error("network failure");
|
||||
});
|
||||
|
||||
const intervalMs = 30 * 1000;
|
||||
await runCommand(makeDaemonOptions(30), { ...baseContext, core });
|
||||
|
||||
// Advance time to trigger the first poll callback and flush its async work.
|
||||
await vi.advanceTimersByTimeAsync(intervalMs);
|
||||
|
||||
// No unhandled rejection — the error was caught internally.
|
||||
const errorCalls = consoleSpy.mock.calls.filter(
|
||||
(args) => typeof args[0] === "string" && args[0].includes("Poll error")
|
||||
);
|
||||
expect(errorCalls.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
@@ -15,96 +15,6 @@ export async function runCommand(options: CLIOptions, context: CLICommandContext
|
||||
|
||||
await core.services.control.activated;
|
||||
if (options.command === "daemon") {
|
||||
const log = (msg: unknown) => console.error(`[Daemon] ${msg}`);
|
||||
|
||||
// Skip the config mismatch dialog — the daemon cannot resolve it interactively
|
||||
// and the default "Dismiss" action would block replication. The daemon should
|
||||
// accept whatever configuration the remote has.
|
||||
await core.services.setting.applyPartial({ disableCheckingConfigMismatch: true }, true);
|
||||
|
||||
// 1. Replicate CouchDB → local PouchDB so the mirror scan has content to work with.
|
||||
log("Replicating from CouchDB...");
|
||||
const replResult = await core.services.replication.replicate(true);
|
||||
if (!replResult) {
|
||||
console.error("[Daemon] Initial CouchDB replication failed, cannot continue");
|
||||
return false;
|
||||
}
|
||||
log("CouchDB replication complete");
|
||||
|
||||
// 2. Mirror scan to reconcile PouchDB ↔ local filesystem.
|
||||
const errorManager = new UnresolvedErrorManager(core.services.appLifecycle);
|
||||
log("Running mirror scan...");
|
||||
const scanOk = await performFullScan(core as any, log, errorManager, false, true);
|
||||
if (!scanOk) {
|
||||
console.error("[Daemon] Mirror scan failed, cannot continue");
|
||||
return false;
|
||||
}
|
||||
log("Mirror scan complete");
|
||||
|
||||
// 3. Re-enable sync.
|
||||
const restoreSyncSettings = async () => {
|
||||
await core.services.setting.applyPartial({
|
||||
...context.originalSyncSettings,
|
||||
suspendFileWatching: false,
|
||||
}, true);
|
||||
// applySettings fires the full lifecycle: onSuspending → onResumed.
|
||||
// ModuleReplicatorCouchDB starts continuous replication on onResumed
|
||||
// via fireAndForget.
|
||||
await core.services.control.applySettings();
|
||||
// Lifecycle events (onSuspending) may re-enable suspension flags.
|
||||
// Clear them explicitly after the lifecycle completes. applyPartial
|
||||
// with true is a direct store write — it does not re-trigger lifecycle.
|
||||
await core.services.setting.applyPartial({
|
||||
suspendFileWatching: false,
|
||||
suspendParseReplicationResult: false,
|
||||
}, true);
|
||||
};
|
||||
if (options.interval) {
|
||||
log(`Polling mode: syncing every ${options.interval}s`);
|
||||
await restoreSyncSettings();
|
||||
const baseIntervalMs = options.interval * 1000;
|
||||
let currentIntervalMs = baseIntervalMs;
|
||||
let consecutiveFailures = 0;
|
||||
const maxIntervalMs = 5 * 60 * 1000; // 5 minutes cap
|
||||
|
||||
const poll = async () => {
|
||||
try {
|
||||
await core.services.replication.replicate(true);
|
||||
if (consecutiveFailures > 0) {
|
||||
consecutiveFailures--;
|
||||
currentIntervalMs = Math.max(currentIntervalMs / 2, baseIntervalMs);
|
||||
log(`Replication recovered`);
|
||||
}
|
||||
} catch (err) {
|
||||
consecutiveFailures++;
|
||||
currentIntervalMs = Math.min(baseIntervalMs * Math.pow(2, consecutiveFailures), maxIntervalMs);
|
||||
console.error(`[Daemon] Poll error (${consecutiveFailures} consecutive):`, err);
|
||||
if (consecutiveFailures >= 5) {
|
||||
console.error(`[Daemon] Warning: ${consecutiveFailures} consecutive failures, backing off to ${Math.round(currentIntervalMs / 1000)}s`);
|
||||
}
|
||||
}
|
||||
pollTimer = setTimeout(poll, currentIntervalMs);
|
||||
};
|
||||
let pollTimer: ReturnType<typeof setTimeout> = setTimeout(poll, currentIntervalMs);
|
||||
core.services.appLifecycle.onUnload.addHandler(async () => {
|
||||
clearTimeout(pollTimer);
|
||||
return true;
|
||||
});
|
||||
} else {
|
||||
log("LiveSync mode: restoring sync settings and starting _changes feed");
|
||||
await restoreSyncSettings();
|
||||
// The applySettings() lifecycle fires onResumed → ModuleReplicatorCouchDB which
|
||||
// starts continuous replication via fireAndForget(openReplication). Don't call
|
||||
// openReplication directly — it races with the handler and causes dedup/termination.
|
||||
log("LiveSync active");
|
||||
const currentSettings = core.services.setting.currentSettings();
|
||||
if (!currentSettings.liveSync && !currentSettings.syncOnStart) {
|
||||
console.error("[Daemon] Warning: liveSync and syncOnStart are both disabled in settings. " +
|
||||
"No sync will occur. Set liveSync=true in your settings file for continuous sync, " +
|
||||
"or use --interval for polling mode.");
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -173,8 +83,8 @@ export async function runCommand(options: CLIOptions, context: CLICommandContext
|
||||
console.log(`[Command] push ${sourcePath} -> ${destinationDatabasePath}`);
|
||||
|
||||
await core.serviceModules.storageAccess.writeFileAuto(destinationDatabasePath, toArrayBuffer(sourceData), {
|
||||
mtime: Math.floor(sourceStat.mtimeMs),
|
||||
ctime: Math.floor(sourceStat.ctimeMs),
|
||||
mtime: sourceStat.mtimeMs,
|
||||
ctime: sourceStat.ctimeMs,
|
||||
});
|
||||
const destinationPathWithPrefix = destinationDatabasePath as FilePathWithPrefix;
|
||||
const stored = await core.serviceModules.fileHandler.storeFileToDB(destinationPathWithPrefix, true);
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { LiveSyncBaseCore } from "../../../LiveSyncBaseCore";
|
||||
import { ServiceContext } from "@lib/services/base/ServiceBase";
|
||||
import type { ObsidianLiveSyncSettings } from "@lib/common/types";
|
||||
|
||||
export type CLICommand =
|
||||
| "daemon"
|
||||
@@ -30,18 +29,15 @@ export interface CLIOptions {
|
||||
force?: boolean;
|
||||
command: CLICommand;
|
||||
commandArgs: string[];
|
||||
interval?: number;
|
||||
}
|
||||
|
||||
export interface CLICommandContext {
|
||||
databasePath: string;
|
||||
core: LiveSyncBaseCore<ServiceContext, any>;
|
||||
settingsPath: string;
|
||||
originalSyncSettings: Pick<ObsidianLiveSyncSettings, "liveSync" | "syncOnStart" | "periodicReplication" | "syncOnSave" | "syncOnEditorSave" | "syncOnFileOpen" | "syncAfterMerge">;
|
||||
}
|
||||
|
||||
export const VALID_COMMANDS = new Set([
|
||||
"daemon",
|
||||
"sync",
|
||||
"p2p-peers",
|
||||
"p2p-sync",
|
||||
|
||||
@@ -1,187 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# install.sh — install livesync-cli as a systemd service
|
||||
#
|
||||
# Usage:
|
||||
# install.sh [--user] [--system] [--vault <path>] [--interval <N>]
|
||||
#
|
||||
# Defaults: user install, prompts for vault path if not supplied.
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
|
||||
REPO_ROOT="$(cd -- "$SCRIPT_DIR/../../.." && pwd)"
|
||||
CLI_DIR="$REPO_ROOT/src/apps/cli"
|
||||
SERVICE_TEMPLATE="$SCRIPT_DIR/livesync-cli.service"
|
||||
|
||||
# ── Argument parsing ────────────────────────────────────────────────────────
|
||||
INSTALL_MODE="user"
|
||||
VAULT_PATH=""
|
||||
INTERVAL=""
|
||||
FORCE=0
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--user)
|
||||
INSTALL_MODE="user"
|
||||
shift
|
||||
;;
|
||||
--system)
|
||||
INSTALL_MODE="system"
|
||||
shift
|
||||
;;
|
||||
--vault)
|
||||
if [[ -z "${2:-}" ]]; then
|
||||
echo "Error: --vault requires a path argument" >&2
|
||||
exit 1
|
||||
fi
|
||||
VAULT_PATH="$2"
|
||||
shift 2
|
||||
;;
|
||||
--interval)
|
||||
if [[ -z "${2:-}" ]]; then
|
||||
echo "Error: --interval requires a numeric argument" >&2
|
||||
exit 1
|
||||
fi
|
||||
INTERVAL="$2"
|
||||
if ! [[ "$INTERVAL" =~ ^[1-9][0-9]*$ ]]; then
|
||||
echo "Error: --interval requires a positive integer, got '$INTERVAL'" >&2
|
||||
exit 1
|
||||
fi
|
||||
shift 2
|
||||
;;
|
||||
--force|-f)
|
||||
FORCE=1
|
||||
shift
|
||||
;;
|
||||
--help|-h)
|
||||
cat <<EOF
|
||||
Usage: install.sh [--user|--system] [--vault <path>] [--interval <N>] [--force]
|
||||
|
||||
--user Install as a user systemd service (default, ~/.config/systemd/user/)
|
||||
--system Install as a system systemd service (/etc/systemd/system/)
|
||||
--vault Path to the vault directory (prompted if omitted)
|
||||
--interval Poll CouchDB every N seconds instead of using the _changes feed
|
||||
--force Overwrite existing service unit without prompting
|
||||
EOF
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
echo "Error: Unknown argument: $1" >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
# ── Vault path ──────────────────────────────────────────────────────────────
|
||||
if [[ -z "$VAULT_PATH" ]]; then
|
||||
if [ ! -t 0 ]; then
|
||||
echo "Error: --vault is required in non-interactive mode" >&2
|
||||
exit 1
|
||||
fi
|
||||
printf 'Vault path: '
|
||||
read -r VAULT_PATH
|
||||
fi
|
||||
|
||||
_orig_vault="$VAULT_PATH"
|
||||
if ! VAULT_PATH="$(cd -- "$VAULT_PATH" 2>/dev/null && pwd)"; then
|
||||
echo "Error: vault directory does not exist: $_orig_vault" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "[INFO] Vault: $VAULT_PATH"
|
||||
echo "[INFO] Install mode: $INSTALL_MODE"
|
||||
|
||||
# ── Build ────────────────────────────────────────────────────────────────────
|
||||
echo "[INFO] Building CLI from $REPO_ROOT..."
|
||||
(cd "$REPO_ROOT" && npm install --silent)
|
||||
(cd "$CLI_DIR" && npm run build)
|
||||
|
||||
BUILT_CJS="$CLI_DIR/dist/index.cjs"
|
||||
if [[ ! -f "$BUILT_CJS" ]]; then
|
||||
echo "Error: build output not found: $BUILT_CJS" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# ── Install binary ───────────────────────────────────────────────────────────
|
||||
if [[ "$INSTALL_MODE" == "user" ]]; then
|
||||
BIN_DIR="$HOME/.local/bin"
|
||||
UNIT_DIR="$HOME/.config/systemd/user"
|
||||
SYSTEMCTL_FLAGS="--user"
|
||||
else
|
||||
BIN_DIR="/usr/local/bin"
|
||||
UNIT_DIR="/etc/systemd/system"
|
||||
SYSTEMCTL_FLAGS=""
|
||||
fi
|
||||
|
||||
mkdir -p "$BIN_DIR"
|
||||
|
||||
LIVESYNC_BIN="$BIN_DIR/livesync-cli"
|
||||
LIVESYNC_JS="$BIN_DIR/livesync-cli.js"
|
||||
|
||||
# Copy the CJS bundle so the wrapper is self-contained and independent of the
|
||||
# build directory location.
|
||||
cp "$BUILT_CJS" "$LIVESYNC_JS"
|
||||
|
||||
# Write a bash wrapper that invokes node on the installed bundle.
|
||||
cat > "$LIVESYNC_BIN" <<WRAPPER
|
||||
#!/usr/bin/env bash
|
||||
exec node "$LIVESYNC_JS" "\$@"
|
||||
WRAPPER
|
||||
chmod +x "$LIVESYNC_BIN"
|
||||
echo "[INFO] Installed bundle: $LIVESYNC_JS"
|
||||
echo "[INFO] Installed binary: $LIVESYNC_BIN"
|
||||
|
||||
# ── Write systemd unit ───────────────────────────────────────────────────────
|
||||
mkdir -p "$UNIT_DIR"
|
||||
UNIT_PATH="$UNIT_DIR/livesync-cli.service"
|
||||
|
||||
EXEC_START="\"$LIVESYNC_BIN\" \"$VAULT_PATH\""
|
||||
if [[ -n "$INTERVAL" ]]; then
|
||||
EXEC_START="\"$LIVESYNC_BIN\" \"$VAULT_PATH\" --interval $INTERVAL"
|
||||
fi
|
||||
|
||||
# Check for existing service and offer to overwrite.
|
||||
if [[ -f "$UNIT_PATH" ]] && [[ "$FORCE" -eq 0 ]]; then
|
||||
if [ ! -t 0 ]; then
|
||||
echo "Error: service unit already exists at $UNIT_PATH; use --force to overwrite" >&2
|
||||
exit 1
|
||||
fi
|
||||
printf 'Service unit already exists at %s. Overwrite? [y/N]: ' "$UNIT_PATH"
|
||||
read -r CONFIRM
|
||||
case "$CONFIRM" in
|
||||
[yY]|[yY][eE][sS]) : ;;
|
||||
*)
|
||||
echo "[INFO] Aborted. Existing unit left in place."
|
||||
exit 0
|
||||
;;
|
||||
esac
|
||||
fi
|
||||
|
||||
# In awk gsub(), '&' in the replacement means "matched text"; escape any literal '&'
|
||||
# in path variables before passing them as awk replacement strings.
|
||||
AWK_BIN="${LIVESYNC_BIN//&/\\&}"
|
||||
AWK_VAULT="${VAULT_PATH//&/\\&}"
|
||||
awk -v bin="$AWK_BIN" -v vault="$AWK_VAULT" -v exec_start="ExecStart=$EXEC_START" \
|
||||
'/^ExecStart=/ { print exec_start; next } {gsub("LIVESYNC_BIN", bin); gsub("LIVESYNC_VAULT_PATH", vault); print}' \
|
||||
"$SERVICE_TEMPLATE" > "$UNIT_PATH"
|
||||
|
||||
echo "[INFO] Installed unit: $UNIT_PATH"
|
||||
|
||||
# ── Enable service ───────────────────────────────────────────────────────────
|
||||
if ! command -v systemctl >/dev/null 2>&1; then
|
||||
echo "[WARN] systemctl not found — skipping service activation"
|
||||
echo "[INFO] To enable manually, copy $UNIT_PATH to the correct systemd directory and run:"
|
||||
echo " systemctl $SYSTEMCTL_FLAGS daemon-reload"
|
||||
echo " systemctl $SYSTEMCTL_FLAGS enable --now livesync-cli"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# shellcheck disable=SC2086
|
||||
systemctl $SYSTEMCTL_FLAGS daemon-reload
|
||||
# shellcheck disable=SC2086
|
||||
systemctl $SYSTEMCTL_FLAGS enable --now livesync-cli
|
||||
|
||||
echo ""
|
||||
echo "[Done] livesync-cli service installed and started."
|
||||
echo ""
|
||||
# shellcheck disable=SC2086
|
||||
systemctl $SYSTEMCTL_FLAGS status livesync-cli --no-pager || true
|
||||
@@ -1,17 +0,0 @@
|
||||
[Unit]
|
||||
Description=Self-hosted LiveSync CLI Daemon
|
||||
After=network-online.target
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
ExecStart=LIVESYNC_BIN LIVESYNC_VAULT_PATH
|
||||
Restart=on-failure
|
||||
RestartSec=10
|
||||
TimeoutStartSec=300
|
||||
StandardOutput=journal
|
||||
StandardError=journal
|
||||
LimitNOFILE=65536
|
||||
|
||||
[Install]
|
||||
WantedBy=default.target
|
||||
@@ -26,7 +26,6 @@ import { VALID_COMMANDS } from "./commands/types";
|
||||
import type { CLICommand, CLIOptions } from "./commands/types";
|
||||
import { getPathFromUXFileInfo } from "@lib/common/typeUtils";
|
||||
import { stripAllPrefixes } from "@lib/string_and_binary/path";
|
||||
import { IgnoreRules } from "./serviceModules/IgnoreRules";
|
||||
|
||||
const SETTINGS_FILE = ".livesync/settings.json";
|
||||
ensureGlobalNodeLocalStorage();
|
||||
@@ -44,8 +43,7 @@ Arguments:
|
||||
database-path Path to the local database directory
|
||||
|
||||
Commands:
|
||||
daemon (default) Run mirror scan then continuously sync CouchDB <-> local filesystem
|
||||
sync Run one replication cycle and exit
|
||||
sync Run one replication cycle and exit
|
||||
p2p-peers <timeout> Show discovered peers as [peer]\t<peer-id>\t<peer-name>
|
||||
p2p-sync <peer> <timeout>
|
||||
Sync with the specified peer-id or peer-name
|
||||
@@ -62,30 +60,24 @@ Commands:
|
||||
rm <path> Mark a file as deleted in local database
|
||||
resolve <path> <rev> Resolve conflicts by keeping <rev> and deleting others
|
||||
mirror [vault-path] Mirror database contents to the local file system (vault-path defaults to database-path)
|
||||
|
||||
Options:
|
||||
--interval <N>, -i <N> (daemon only) Poll CouchDB every N seconds instead of using the _changes feed
|
||||
|
||||
Examples:
|
||||
livesync-cli ./my-database Run daemon (LiveSync mode)
|
||||
livesync-cli ./my-database --interval 30 Run daemon (polling every 30s)
|
||||
livesync-cli ./my-database sync
|
||||
livesync-cli ./my-database p2p-peers 5
|
||||
livesync-cli ./my-database p2p-sync my-peer-name 15
|
||||
livesync-cli ./my-database p2p-host
|
||||
livesync-cli ./my-database --settings ./custom-settings.json push ./note.md folder/note.md
|
||||
livesync-cli ./my-database pull folder/note.md ./exports/note.md
|
||||
livesync-cli ./my-database pull-rev folder/note.md ./exports/note.old.md 3-abcdef
|
||||
livesync-cli ./my-database setup "obsidian://setuplivesync?settings=..."
|
||||
echo "Hello" | livesync-cli ./my-database put notes/hello.md
|
||||
livesync-cli ./my-database cat notes/hello.md
|
||||
livesync-cli ./my-database cat-rev notes/hello.md 3-abcdef
|
||||
livesync-cli ./my-database ls notes/
|
||||
livesync-cli ./my-database info notes/hello.md
|
||||
livesync-cli ./my-database rm notes/hello.md
|
||||
livesync-cli ./my-database resolve notes/hello.md 3-abcdef
|
||||
livesync-cli init-settings ./data.json
|
||||
livesync-cli ./my-database --verbose
|
||||
livesync-cli ./my-database sync
|
||||
livesync-cli ./my-database p2p-peers 5
|
||||
livesync-cli ./my-database p2p-sync my-peer-name 15
|
||||
livesync-cli ./my-database p2p-host
|
||||
livesync-cli ./my-database --settings ./custom-settings.json push ./note.md folder/note.md
|
||||
livesync-cli ./my-database pull folder/note.md ./exports/note.md
|
||||
livesync-cli ./my-database pull-rev folder/note.md ./exports/note.old.md 3-abcdef
|
||||
livesync-cli ./my-database setup "obsidian://setuplivesync?settings=..."
|
||||
echo "Hello" | livesync-cli ./my-database put notes/hello.md
|
||||
livesync-cli ./my-database cat notes/hello.md
|
||||
livesync-cli ./my-database cat-rev notes/hello.md 3-abcdef
|
||||
livesync-cli ./my-database ls notes/
|
||||
livesync-cli ./my-database info notes/hello.md
|
||||
livesync-cli ./my-database rm notes/hello.md
|
||||
livesync-cli ./my-database resolve notes/hello.md 3-abcdef
|
||||
livesync-cli init-settings ./data.json
|
||||
livesync-cli ./my-database --verbose
|
||||
`);
|
||||
}
|
||||
|
||||
@@ -102,7 +94,6 @@ export function parseArgs(): CLIOptions {
|
||||
let verbose = false;
|
||||
let debug = false;
|
||||
let force = false;
|
||||
let interval: number | undefined;
|
||||
let command: CLICommand = "daemon";
|
||||
const commandArgs: string[] = [];
|
||||
|
||||
@@ -119,21 +110,6 @@ export function parseArgs(): CLIOptions {
|
||||
settingsPath = args[i];
|
||||
break;
|
||||
}
|
||||
case "--interval":
|
||||
case "-i": {
|
||||
i++;
|
||||
if (!args[i]) {
|
||||
console.error(`Error: Missing value for ${token}`);
|
||||
process.exit(1);
|
||||
}
|
||||
const n = parseInt(args[i], 10);
|
||||
if (!Number.isInteger(n) || n <= 0) {
|
||||
console.error(`Error: --interval requires a positive integer, got '${args[i]}'`);
|
||||
process.exit(1);
|
||||
}
|
||||
interval = n;
|
||||
break;
|
||||
}
|
||||
case "--debug":
|
||||
case "-d":
|
||||
// debugging automatically enables verbose logging, as it is intended for debugging issues.
|
||||
@@ -188,7 +164,6 @@ export function parseArgs(): CLIOptions {
|
||||
force,
|
||||
command,
|
||||
commandArgs,
|
||||
interval,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -222,9 +197,6 @@ async function createDefaultSettingsFile(options: CLIOptions) {
|
||||
|
||||
export async function main() {
|
||||
const options = parseArgs();
|
||||
if (options.interval && options.command !== "daemon") {
|
||||
console.error(`Warning: --interval is only used in daemon mode, ignored for '${options.command}'`);
|
||||
}
|
||||
const avoidStdoutNoise =
|
||||
options.command === "cat" ||
|
||||
options.command === "cat-rev" ||
|
||||
@@ -276,20 +248,6 @@ export async function main() {
|
||||
infoLog(`Settings: ${settingsPath}`);
|
||||
infoLog("");
|
||||
|
||||
// For daemon and mirror mode, load ignore rules before the core is constructed so that
|
||||
// chokidar's ignored option is populated when beginWatch() fires during onLoad().
|
||||
const watchEnabled = options.command === "daemon";
|
||||
const vaultPath =
|
||||
options.command === "mirror" && options.commandArgs[0]
|
||||
? path.resolve(options.commandArgs[0])
|
||||
: databasePath;
|
||||
let ignoreRules: IgnoreRules | undefined;
|
||||
if (options.command === "daemon" || options.command === "mirror") {
|
||||
ignoreRules = new IgnoreRules(vaultPath);
|
||||
await ignoreRules.load();
|
||||
}
|
||||
|
||||
|
||||
// Create service context and hub
|
||||
const context = new NodeServiceContext(databasePath);
|
||||
const serviceHubInstance = new NodeServiceHub<NodeServiceContext>(databasePath, context);
|
||||
@@ -320,14 +278,11 @@ export async function main() {
|
||||
}
|
||||
console.error(`${prefix} ${message}`);
|
||||
});
|
||||
// Prevent replication result from being processed automatically in non-daemon commands.
|
||||
// In daemon mode the default handler must run so changes are applied to the filesystem.
|
||||
if (options.command !== "daemon") {
|
||||
serviceHubInstance.replication.processSynchroniseResult.addHandler(async () => {
|
||||
console.error(`[Info] Replication result received, but not processed automatically in CLI mode.`);
|
||||
return await Promise.resolve(true);
|
||||
}, -100);
|
||||
}
|
||||
// Prevent replication result to be processed automatically.
|
||||
serviceHubInstance.replication.processSynchroniseResult.addHandler(async () => {
|
||||
console.error(`[Info] Replication result received, but not processed automatically in CLI mode.`);
|
||||
return await Promise.resolve(true);
|
||||
}, -100);
|
||||
|
||||
// Setup settings handlers
|
||||
const settingService = serviceHubInstance.setting;
|
||||
@@ -369,7 +324,11 @@ export async function main() {
|
||||
const core = new LiveSyncBaseCore(
|
||||
serviceHubInstance,
|
||||
(core: LiveSyncBaseCore<NodeServiceContext, any>, serviceHub: InjectableServiceHub<NodeServiceContext>) => {
|
||||
return initialiseServiceModulesCLI(vaultPath, core, serviceHub, ignoreRules, watchEnabled);
|
||||
const mirrorVaultPath =
|
||||
options.command === "mirror" && options.commandArgs[0]
|
||||
? path.resolve(options.commandArgs[0])
|
||||
: databasePath;
|
||||
return initialiseServiceModulesCLI(mirrorVaultPath, core, serviceHub);
|
||||
},
|
||||
(core) => [
|
||||
// No modules need to be registered for P2P replication in CLI. Directly using Replicators in p2p.ts
|
||||
@@ -385,25 +344,8 @@ export async function main() {
|
||||
if (parts.some((part) => part.startsWith("."))) {
|
||||
return await Promise.resolve(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);
|
||||
}, -1 /* highest priority */);
|
||||
|
||||
// Apply user-defined ignore rules for daemon mode (lower priority, runs after dotfile check).
|
||||
if (ignoreRules) {
|
||||
const rules = ignoreRules;
|
||||
core.services.vault.isTargetFile.addHandler(async (target) => {
|
||||
const targetPath = stripAllPrefixes(getPathFromUXFileInfo(target));
|
||||
if (rules.shouldIgnore(targetPath)) {
|
||||
return false;
|
||||
}
|
||||
// undefined = pass through to next handler in chain
|
||||
return undefined;
|
||||
}, 0);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
@@ -424,25 +366,6 @@ export async function main() {
|
||||
process.on("SIGINT", () => shutdown("SIGINT"));
|
||||
process.on("SIGTERM", () => shutdown("SIGTERM"));
|
||||
|
||||
// Save the settings file before any lifecycle events can mutate and persist them.
|
||||
// suspendAllSync and other lifecycle hooks clobber sync settings in memory, and
|
||||
// various code paths persist the clobbered state to disk. We restore on shutdown.
|
||||
const settingsBackup = await fs.readFile(settingsPath, "utf-8").catch(() => null);
|
||||
|
||||
// Restore settings file on any exit to undo lifecycle mutations.
|
||||
// Write to a temp path first so a crash mid-write doesn't leave a truncated file.
|
||||
process.on("exit", () => {
|
||||
if (settingsBackup) {
|
||||
const tmpPath = settingsPath + ".tmp";
|
||||
try {
|
||||
require("fs").writeFileSync(tmpPath, settingsBackup, "utf-8");
|
||||
require("fs").renameSync(tmpPath, settingsPath);
|
||||
} catch (err) {
|
||||
console.error("[Settings] Failed to restore settings on exit:", err);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Start the core
|
||||
try {
|
||||
infoLog(`[Starting] Initializing LiveSync...`);
|
||||
@@ -452,18 +375,6 @@ export async function main() {
|
||||
console.error(`[Error] Failed to initialize LiveSync`);
|
||||
process.exit(1);
|
||||
}
|
||||
// Capture sync settings before suspendAllSync() clobbers them.
|
||||
// Used by daemon mode to restore the correct sync behaviour after the mirror scan.
|
||||
const settingsBeforeSuspend = core.services.setting.currentSettings();
|
||||
const originalSyncSettings = {
|
||||
liveSync: settingsBeforeSuspend.liveSync,
|
||||
syncOnStart: settingsBeforeSuspend.syncOnStart,
|
||||
periodicReplication: settingsBeforeSuspend.periodicReplication,
|
||||
syncOnSave: settingsBeforeSuspend.syncOnSave,
|
||||
syncOnEditorSave: settingsBeforeSuspend.syncOnEditorSave,
|
||||
syncOnFileOpen: settingsBeforeSuspend.syncOnFileOpen,
|
||||
syncAfterMerge: settingsBeforeSuspend.syncAfterMerge,
|
||||
};
|
||||
await core.services.setting.suspendAllSync();
|
||||
await core.services.control.onReady();
|
||||
|
||||
@@ -489,7 +400,7 @@ export async function main() {
|
||||
infoLog("");
|
||||
}
|
||||
|
||||
const result = await runCommand(options, { databasePath, core, settingsPath, originalSyncSettings });
|
||||
const result = await runCommand(options, { databasePath, core, settingsPath });
|
||||
if (!result) {
|
||||
console.error(`[Error] Command '${options.command}' failed`);
|
||||
process.exitCode = 1;
|
||||
@@ -497,7 +408,7 @@ export async function main() {
|
||||
infoLog(`[Done] Command '${options.command}' completed`);
|
||||
}
|
||||
|
||||
if (options.command === "daemon" && result) {
|
||||
if (options.command === "daemon") {
|
||||
// Keep the process running
|
||||
await new Promise(() => {});
|
||||
} else {
|
||||
|
||||
@@ -85,67 +85,4 @@ describe("CLI parseArgs", () => {
|
||||
expect(parsed.command).toBe("p2p-host");
|
||||
expect(parsed.commandArgs).toEqual([]);
|
||||
});
|
||||
|
||||
it("parses --interval flag with valid integer", () => {
|
||||
process.argv = ["node", "livesync-cli", "./vault", "--interval", "30"];
|
||||
const parsed = parseArgs();
|
||||
expect(parsed.command).toBe("daemon");
|
||||
expect(parsed.interval).toBe(30);
|
||||
});
|
||||
|
||||
it("parses -i shorthand for --interval", () => {
|
||||
process.argv = ["node", "livesync-cli", "./vault", "-i", "10"];
|
||||
const parsed = parseArgs();
|
||||
expect(parsed.interval).toBe(10);
|
||||
});
|
||||
|
||||
it("exits 1 when --interval has no value", () => {
|
||||
process.argv = ["node", "livesync-cli", "./vault", "--interval"];
|
||||
const exitMock = mockProcessExit();
|
||||
vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
expect(() => parseArgs()).toThrowError("__EXIT__:1");
|
||||
expect(exitMock).toHaveBeenCalledWith(1);
|
||||
});
|
||||
|
||||
it("exits 1 when --interval is not a positive integer", () => {
|
||||
process.argv = ["node", "livesync-cli", "./vault", "--interval", "0"];
|
||||
const exitMock = mockProcessExit();
|
||||
vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
expect(() => parseArgs()).toThrowError("__EXIT__:1");
|
||||
expect(exitMock).toHaveBeenCalledWith(1);
|
||||
});
|
||||
|
||||
it("exits 1 when --interval is negative", () => {
|
||||
process.argv = ["node", "livesync-cli", "./vault", "--interval", "-5"];
|
||||
const exitMock = mockProcessExit();
|
||||
vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
expect(() => parseArgs()).toThrowError("__EXIT__:1");
|
||||
});
|
||||
|
||||
it("exits 1 when --interval is not numeric", () => {
|
||||
process.argv = ["node", "livesync-cli", "./vault", "--interval", "abc"];
|
||||
const exitMock = mockProcessExit();
|
||||
vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
expect(() => parseArgs()).toThrowError("__EXIT__:1");
|
||||
});
|
||||
|
||||
it("parses explicit daemon command", () => {
|
||||
process.argv = ["node", "livesync-cli", "./vault", "daemon"];
|
||||
const parsed = parseArgs();
|
||||
expect(parsed.command).toBe("daemon");
|
||||
expect(parsed.databasePath).toBe("./vault");
|
||||
});
|
||||
|
||||
it("defaults to daemon when no command specified", () => {
|
||||
process.argv = ["node", "livesync-cli", "./vault"];
|
||||
const parsed = parseArgs();
|
||||
expect(parsed.command).toBe("daemon");
|
||||
});
|
||||
|
||||
it("parses explicit daemon command with --interval", () => {
|
||||
process.argv = ["node", "livesync-cli", "./vault", "daemon", "--interval", "30"];
|
||||
const parsed = parseArgs();
|
||||
expect(parsed.command).toBe("daemon");
|
||||
expect(parsed.interval).toBe(30);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -11,11 +11,8 @@ import type {
|
||||
} from "@lib/managers/adapters";
|
||||
import type { FileEventItemSentinel } from "@lib/managers/StorageEventManager";
|
||||
import type { NodeFile, NodeFolder } from "../adapters/NodeTypes";
|
||||
import type { Stats } from "fs";
|
||||
import * as fs from "fs/promises";
|
||||
import * as path from "path";
|
||||
import { watch as chokidarWatch, type FSWatcher } from "chokidar";
|
||||
import type { IgnoreRules } from "../serviceModules/IgnoreRules";
|
||||
|
||||
/**
|
||||
* CLI-specific type guard adapter
|
||||
@@ -59,11 +56,22 @@ class CLIPersistenceAdapter implements IStorageEventPersistenceAdapter {
|
||||
}
|
||||
|
||||
/**
|
||||
* CLI-specific status adapter (no-op — daemon uses journald for status)
|
||||
* CLI-specific status adapter (console logging)
|
||||
*/
|
||||
class CLIStatusAdapter implements IStorageEventStatusAdapter {
|
||||
updateStatus(_status: { batched: number; processing: number; totalQueued: number }): void {
|
||||
// intentional no-op
|
||||
private lastUpdate = 0;
|
||||
private updateInterval = 5000; // Update every 5 seconds
|
||||
|
||||
updateStatus(status: { batched: number; processing: number; totalQueued: number }): void {
|
||||
const now = Date.now();
|
||||
if (now - this.lastUpdate > this.updateInterval) {
|
||||
if (status.totalQueued > 0 || status.processing > 0) {
|
||||
// console.log(
|
||||
// `[StorageEventManager] Batched: ${status.batched}, Processing: ${status.processing}, Total Queued: ${status.totalQueued}`
|
||||
// );
|
||||
}
|
||||
this.lastUpdate = now;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -92,97 +100,15 @@ class CLIConverterAdapter implements IStorageEventConverterAdapter<NodeFile> {
|
||||
}
|
||||
|
||||
/**
|
||||
* CLI-specific watch adapter using chokidar for real-time filesystem monitoring.
|
||||
* CLI-specific watch adapter (optional file watching with chokidar)
|
||||
*/
|
||||
class CLIWatchAdapter implements IStorageEventWatchAdapter {
|
||||
private _watcher: FSWatcher | undefined;
|
||||
|
||||
constructor(private basePath: string, private ignoreRules?: IgnoreRules, private watchEnabled: boolean = false) {}
|
||||
|
||||
private _toNodeFile(filePath: string, stats: Stats | undefined): NodeFile {
|
||||
return {
|
||||
path: path.relative(this.basePath, filePath) as FilePath,
|
||||
stat: {
|
||||
ctime: stats?.ctimeMs ?? Date.now(),
|
||||
mtime: stats?.mtimeMs ?? Date.now(),
|
||||
size: stats?.size ?? 0,
|
||||
type: "file",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
private _toNodeFolder(dirPath: string): NodeFolder {
|
||||
return {
|
||||
path: path.relative(this.basePath, dirPath) as FilePath,
|
||||
isFolder: true,
|
||||
};
|
||||
}
|
||||
constructor(private basePath: string) {}
|
||||
|
||||
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;
|
||||
|
||||
const watcher = chokidarWatch(this.basePath, {
|
||||
ignored,
|
||||
ignoreInitial: true,
|
||||
persistent: true,
|
||||
awaitWriteFinish: {
|
||||
stabilityThreshold: 500,
|
||||
pollInterval: 100,
|
||||
},
|
||||
});
|
||||
|
||||
watcher.on("add", (filePath, stats) => {
|
||||
const nodeFile = this._toNodeFile(filePath, stats);
|
||||
handlers.onCreate(nodeFile);
|
||||
});
|
||||
|
||||
watcher.on("change", (filePath, stats) => {
|
||||
const nodeFile = this._toNodeFile(filePath, stats);
|
||||
handlers.onChange(nodeFile);
|
||||
});
|
||||
|
||||
watcher.on("unlink", (filePath) => {
|
||||
const nodeFile = this._toNodeFile(filePath, undefined);
|
||||
handlers.onDelete(nodeFile);
|
||||
});
|
||||
|
||||
watcher.on("addDir", (dirPath) => {
|
||||
const nodeFolder = this._toNodeFolder(dirPath);
|
||||
handlers.onCreate(nodeFolder);
|
||||
});
|
||||
|
||||
watcher.on("unlinkDir", (dirPath) => {
|
||||
const nodeFolder = this._toNodeFolder(dirPath);
|
||||
handlers.onDelete(nodeFolder);
|
||||
});
|
||||
|
||||
watcher.on("error", (err) => {
|
||||
console.error("[CLIWatchAdapter] Fatal watcher error — file watching stopped:", err);
|
||||
console.error("[CLIWatchAdapter] Exiting for systemd restart.");
|
||||
void watcher.close();
|
||||
this._watcher = undefined;
|
||||
// Use exit(1) rather than SIGTERM so systemd Restart=on-failure engages.
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
await new Promise<void>((resolve) => watcher.once("ready", resolve));
|
||||
this._watcher = watcher;
|
||||
}
|
||||
|
||||
close(): Promise<void> {
|
||||
if (this._watcher) {
|
||||
return this._watcher.close();
|
||||
}
|
||||
// File watching is not activated in the CLI.
|
||||
// Because the CLI is designed for push/pull operations, not real-time sync.
|
||||
// console.error("[CLIWatchAdapter] File watching is not enabled in CLI version");
|
||||
return Promise.resolve();
|
||||
}
|
||||
}
|
||||
@@ -197,15 +123,11 @@ export class CLIStorageEventManagerAdapter implements IStorageEventManagerAdapte
|
||||
readonly status: CLIStatusAdapter;
|
||||
readonly converter: CLIConverterAdapter;
|
||||
|
||||
constructor(basePath: string, ignoreRules?: IgnoreRules, watchEnabled: boolean = false) {
|
||||
constructor(basePath: string) {
|
||||
this.typeGuard = new CLITypeGuardAdapter();
|
||||
this.persistence = new CLIPersistenceAdapter(basePath);
|
||||
this.watch = new CLIWatchAdapter(basePath, ignoreRules, watchEnabled);
|
||||
this.watch = new CLIWatchAdapter(basePath);
|
||||
this.status = new CLIStatusAdapter();
|
||||
this.converter = new CLIConverterAdapter();
|
||||
}
|
||||
|
||||
close(): Promise<void> {
|
||||
return this.watch.close();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,126 +0,0 @@
|
||||
import { describe, expect, it, vi, beforeEach } from "vitest";
|
||||
import type { IStorageEventWatchHandlers } from "@lib/managers/adapters";
|
||||
import type { NodeFile } from "../adapters/NodeTypes";
|
||||
|
||||
// ── chokidar mock ──────────────────────────────────────────────────────────────
|
||||
// Must be hoisted before imports that pull in chokidar.
|
||||
|
||||
const mockWatcher = {
|
||||
on: vi.fn().mockReturnThis(),
|
||||
once: vi.fn((event: string, cb: () => void) => {
|
||||
if (event === "ready") cb();
|
||||
return mockWatcher;
|
||||
}),
|
||||
close: vi.fn(() => Promise.resolve()),
|
||||
};
|
||||
|
||||
vi.mock("chokidar", () => ({
|
||||
watch: vi.fn(() => mockWatcher),
|
||||
}));
|
||||
|
||||
import * as chokidar from "chokidar";
|
||||
import { CLIStorageEventManagerAdapter } from "./CLIStorageEventManagerAdapter";
|
||||
|
||||
// ── helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
function makeHandlers(): IStorageEventWatchHandlers {
|
||||
return {
|
||||
onCreate: vi.fn(),
|
||||
onChange: vi.fn(),
|
||||
onDelete: vi.fn(),
|
||||
onRename: vi.fn(),
|
||||
} as any;
|
||||
}
|
||||
|
||||
// ── tests ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("CLIStorageEventManagerAdapter", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
// Restore the default once() behaviour (ready fires synchronously).
|
||||
mockWatcher.once.mockImplementation((event: string, cb: () => void) => {
|
||||
if (event === "ready") cb();
|
||||
return mockWatcher;
|
||||
});
|
||||
});
|
||||
|
||||
it("beginWatch is no-op when watchEnabled=false", async () => {
|
||||
const adapter = new CLIStorageEventManagerAdapter("/base", undefined, false);
|
||||
const handlers = makeHandlers();
|
||||
|
||||
await adapter.watch.beginWatch(handlers);
|
||||
|
||||
expect(chokidar.watch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("beginWatch calls chokidar.watch when watchEnabled=true", async () => {
|
||||
const adapter = new CLIStorageEventManagerAdapter("/base", undefined, true);
|
||||
const handlers = makeHandlers();
|
||||
|
||||
await adapter.watch.beginWatch(handlers);
|
||||
|
||||
expect(chokidar.watch).toHaveBeenCalledTimes(1);
|
||||
expect(chokidar.watch).toHaveBeenCalledWith(
|
||||
"/base",
|
||||
expect.objectContaining({ ignoreInitial: true })
|
||||
);
|
||||
});
|
||||
|
||||
it("add event produces NodeFile with correct relative path via onCreate", async () => {
|
||||
const basePath = "/vault/base";
|
||||
const adapter = new CLIStorageEventManagerAdapter(basePath, undefined, true);
|
||||
const handlers = makeHandlers();
|
||||
|
||||
await adapter.watch.beginWatch(handlers);
|
||||
|
||||
// Find the callback registered for the "add" event.
|
||||
const addCall = mockWatcher.on.mock.calls.find(([event]) => event === "add");
|
||||
expect(addCall).toBeDefined();
|
||||
const addCallback = addCall![1] as (filePath: string, stats: any) => void;
|
||||
|
||||
const fakeStats = { ctimeMs: 1000, mtimeMs: 2000, size: 42 };
|
||||
addCallback(`${basePath}/subdir/note.md`, fakeStats);
|
||||
|
||||
expect(handlers.onCreate).toHaveBeenCalledTimes(1);
|
||||
const created = (handlers.onCreate as ReturnType<typeof vi.fn>).mock.calls[0][0] as NodeFile;
|
||||
expect(created.path).toBe("subdir/note.md");
|
||||
expect(created.stat?.size).toBe(42);
|
||||
});
|
||||
|
||||
it("close() calls watcher.close()", async () => {
|
||||
const adapter = new CLIStorageEventManagerAdapter("/base", undefined, true);
|
||||
const handlers = makeHandlers();
|
||||
|
||||
await adapter.watch.beginWatch(handlers);
|
||||
await adapter.close();
|
||||
|
||||
expect(mockWatcher.close).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("close() is safe when no watcher was started", async () => {
|
||||
const adapter = new CLIStorageEventManagerAdapter("/base", undefined, false);
|
||||
|
||||
// Should not throw.
|
||||
await expect(adapter.close()).resolves.toBeUndefined();
|
||||
expect(mockWatcher.close).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("error event triggers process.exit(1)", async () => {
|
||||
const adapter = new CLIStorageEventManagerAdapter("/base", undefined, true);
|
||||
const handlers = makeHandlers();
|
||||
|
||||
await adapter.watch.beginWatch(handlers);
|
||||
|
||||
const processExitSpy = vi.spyOn(process, "exit").mockImplementation((() => {}) as any);
|
||||
|
||||
const errorCall = mockWatcher.on.mock.calls.find(([event]) => event === "error");
|
||||
expect(errorCall).toBeDefined();
|
||||
const errorCallback = errorCall![1] as (err: Error) => void;
|
||||
|
||||
errorCallback(new Error("disk failure"));
|
||||
|
||||
expect(processExitSpy).toHaveBeenCalledWith(1);
|
||||
|
||||
processExitSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
@@ -2,7 +2,6 @@ import { StorageEventManagerBase, type StorageEventManagerBaseDependencies } fro
|
||||
import { CLIStorageEventManagerAdapter } from "./CLIStorageEventManagerAdapter";
|
||||
import type { IMinimumLiveSyncCommands, LiveSyncBaseCore } from "../../../LiveSyncBaseCore";
|
||||
import type { ServiceContext } from "@lib/services/base/ServiceBase";
|
||||
import type { IgnoreRules } from "../serviceModules/IgnoreRules";
|
||||
// import type { IMinimumLiveSyncCommands } from "@lib/services/base/IService";
|
||||
|
||||
export class StorageEventManagerCLI extends StorageEventManagerBase<CLIStorageEventManagerAdapter> {
|
||||
@@ -11,11 +10,9 @@ export class StorageEventManagerCLI extends StorageEventManagerBase<CLIStorageEv
|
||||
constructor(
|
||||
basePath: string,
|
||||
core: LiveSyncBaseCore<ServiceContext, IMinimumLiveSyncCommands>,
|
||||
dependencies: StorageEventManagerBaseDependencies,
|
||||
ignoreRules?: IgnoreRules,
|
||||
watchEnabled?: boolean
|
||||
dependencies: StorageEventManagerBaseDependencies
|
||||
) {
|
||||
const adapter = new CLIStorageEventManagerAdapter(basePath, ignoreRules, watchEnabled);
|
||||
const adapter = new CLIStorageEventManagerAdapter(basePath);
|
||||
super(adapter, dependencies);
|
||||
this.core = core;
|
||||
}
|
||||
@@ -28,11 +25,4 @@ export class StorageEventManagerCLI extends StorageEventManagerBase<CLIStorageEv
|
||||
// No-op in CLI version
|
||||
// Internal file handling is not needed
|
||||
}
|
||||
|
||||
/**
|
||||
* Close the file watcher. Call this during graceful shutdown.
|
||||
*/
|
||||
close(): Promise<void> {
|
||||
return this.adapter.close();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,7 +4,6 @@
|
||||
"version": "0.0.0",
|
||||
"description": "Runtime dependencies for Self-hosted LiveSync CLI Docker image",
|
||||
"dependencies": {
|
||||
"chokidar": "^4.0.0",
|
||||
"commander": "^14.0.3",
|
||||
"werift": "^0.22.9",
|
||||
"pouchdb-adapter-http": "^9.0.0",
|
||||
|
||||
@@ -9,7 +9,6 @@ import { ServiceFileAccessCLI } from "./ServiceFileAccessImpl";
|
||||
import { ServiceDatabaseFileAccessCLI } from "./DatabaseFileAccess";
|
||||
import { StorageEventManagerCLI } from "../managers/StorageEventManagerCLI";
|
||||
import type { ServiceModules } from "@lib/interfaces/ServiceModule";
|
||||
import type { IgnoreRules } from "./IgnoreRules";
|
||||
|
||||
/**
|
||||
* Initialize service modules for CLI version
|
||||
@@ -23,9 +22,7 @@ import type { IgnoreRules } from "./IgnoreRules";
|
||||
export function initialiseServiceModulesCLI(
|
||||
basePath: string,
|
||||
core: LiveSyncBaseCore<ServiceContext, any>,
|
||||
services: InjectableServiceHub<ServiceContext>,
|
||||
ignoreRules?: IgnoreRules,
|
||||
watchEnabled: boolean = false,
|
||||
services: InjectableServiceHub<ServiceContext>
|
||||
): ServiceModules {
|
||||
const storageAccessManager = new StorageAccessManager();
|
||||
|
||||
@@ -45,12 +42,6 @@ export function initialiseServiceModulesCLI(
|
||||
vaultService: services.vault,
|
||||
storageAccessManager: storageAccessManager,
|
||||
APIService: services.API,
|
||||
}, ignoreRules, watchEnabled);
|
||||
|
||||
// Close the file watcher during graceful shutdown so the process can exit cleanly.
|
||||
services.appLifecycle.onUnload.addHandler(async () => {
|
||||
await storageEventManager.close();
|
||||
return true;
|
||||
});
|
||||
|
||||
// Storage access using CLI file system adapter
|
||||
|
||||
@@ -1,129 +0,0 @@
|
||||
import * as fs from "fs/promises";
|
||||
import * as path from "path";
|
||||
|
||||
import { minimatch } from "minimatch";
|
||||
|
||||
/**
|
||||
* Loads and evaluates ignore rules from `.livesync/ignore` inside the vault.
|
||||
*
|
||||
* File format:
|
||||
* - Lines starting with `#` are comments.
|
||||
* - Blank lines are ignored.
|
||||
* - `import: .gitignore` (exactly) — merges patterns from the vault's `.gitignore`.
|
||||
* - All other lines are minimatch glob patterns relative to the vault root.
|
||||
*
|
||||
* Negation patterns (lines starting with `!`) are not supported. Loading a
|
||||
* ruleset containing them throws an error — use separate include/exclude files
|
||||
* instead.
|
||||
*
|
||||
* Missing files (`.livesync/ignore` or `.gitignore`) are silently skipped.
|
||||
*/
|
||||
export class IgnoreRules {
|
||||
private patterns: string[] = [];
|
||||
|
||||
constructor(private vaultPath: string) {}
|
||||
|
||||
/**
|
||||
* Reads `.livesync/ignore` (and optionally `.gitignore`) and populates the
|
||||
* pattern list. Safe to call multiple times — each call replaces the
|
||||
* previous state. Does not throw if files are absent.
|
||||
*
|
||||
* @throws if any pattern line begins with `!` (negation is unsupported).
|
||||
*/
|
||||
async load(): Promise<void> {
|
||||
this.patterns = [];
|
||||
const ignorePath = path.join(this.vaultPath, ".livesync", "ignore");
|
||||
let rawLines: string[];
|
||||
try {
|
||||
const content = await fs.readFile(ignorePath, "utf-8");
|
||||
rawLines = content.split(/\r?\n/);
|
||||
} catch {
|
||||
// File absent or unreadable — treat as empty ruleset.
|
||||
return;
|
||||
}
|
||||
|
||||
for (const line of rawLines) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed || trimmed.startsWith("#")) {
|
||||
continue;
|
||||
}
|
||||
// NOTE: Only the exact string "import: .gitignore" is recognised.
|
||||
// Any future generalisation of this directive must validate that
|
||||
// the resolved path stays within the vault directory.
|
||||
if (trimmed === "import: .gitignore") {
|
||||
await this._importGitignore();
|
||||
continue;
|
||||
}
|
||||
if (trimmed.startsWith("import:")) {
|
||||
console.error(`[IgnoreRules] Warning: unrecognised directive '${trimmed}' — only 'import: .gitignore' is supported`);
|
||||
continue;
|
||||
}
|
||||
this._addPattern(trimmed);
|
||||
}
|
||||
if (this.patterns.length > 0) {
|
||||
console.error(`[IgnoreRules] Loaded ${this.patterns.length} ignore patterns`);
|
||||
}
|
||||
}
|
||||
|
||||
// Normalises a single gitignore-style pattern:
|
||||
// - Patterns ending with `/` (directory patterns like `build/`) are
|
||||
// converted to `build/**` so they match all files inside that directory.
|
||||
// - Patterns without a `/` are prefixed with `**/` to give them matchBase
|
||||
// semantics (e.g. `*.tmp` → `**/*.tmp`), matching the basename in any
|
||||
// subdirectory as gitignore does.
|
||||
// - Patterns that already contain a `/` (but don't end with one) are
|
||||
// path-specific and used as-is.
|
||||
private _normalisePattern(pattern: string): string {
|
||||
if (pattern.endsWith("/")) {
|
||||
return "**/" + pattern + "**";
|
||||
} else if (!pattern.includes("/")) {
|
||||
return "**/" + pattern;
|
||||
}
|
||||
return pattern;
|
||||
}
|
||||
|
||||
private async _importGitignore(): Promise<void> {
|
||||
const gitignorePath = path.join(this.vaultPath, ".gitignore");
|
||||
let content: string;
|
||||
try {
|
||||
content = await fs.readFile(gitignorePath, "utf-8");
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
this._parseLines(content);
|
||||
}
|
||||
|
||||
private _parseLines(content: string): void {
|
||||
for (const line of content.split(/\r?\n/)) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed || trimmed.startsWith("#")) continue;
|
||||
this._addPattern(trimmed);
|
||||
}
|
||||
}
|
||||
|
||||
private _addPattern(raw: string): void {
|
||||
if (raw.startsWith("!")) {
|
||||
throw new Error(
|
||||
`[IgnoreRules] Negation pattern '${raw}' is not supported. ` +
|
||||
`Remove it from .livesync/ignore or use a separate include/exclude file.`
|
||||
);
|
||||
}
|
||||
this.patterns.push(this._normalisePattern(raw));
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns `true` if the given vault-relative path matches any loaded
|
||||
* ignore pattern.
|
||||
*
|
||||
* @param relativePath - Path relative to the vault root, using forward
|
||||
* slashes or the OS separator.
|
||||
*/
|
||||
shouldIgnore(relativePath: string): boolean {
|
||||
if (this.patterns.length === 0) {
|
||||
return false;
|
||||
}
|
||||
// Normalise to forward slashes for minimatch.
|
||||
const normalised = relativePath.replace(/\\/g, "/");
|
||||
return this.patterns.some((p) => minimatch(normalised, p, { dot: true }));
|
||||
}
|
||||
}
|
||||
@@ -1,172 +0,0 @@
|
||||
import * as fs from "node:fs/promises";
|
||||
import * as os from "node:os";
|
||||
import * as path from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { IgnoreRules } from "./IgnoreRules";
|
||||
|
||||
describe("IgnoreRules", () => {
|
||||
const tempDirs: string[] = [];
|
||||
|
||||
async function createVault(): Promise<string> {
|
||||
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "livesync-ignorerules-"));
|
||||
tempDirs.push(tempDir);
|
||||
return tempDir;
|
||||
}
|
||||
|
||||
async function writeIgnoreFile(vaultPath: string, content: string): Promise<void> {
|
||||
const ignoreDir = path.join(vaultPath, ".livesync");
|
||||
await fs.mkdir(ignoreDir, { recursive: true });
|
||||
await fs.writeFile(path.join(ignoreDir, "ignore"), content, "utf-8");
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(tempDirs.splice(0).map((dir) => fs.rm(dir, { recursive: true, force: true })));
|
||||
});
|
||||
|
||||
describe("pattern normalisation", () => {
|
||||
it("adds **/ prefix to basename patterns (no slash)", async () => {
|
||||
const vaultPath = await createVault();
|
||||
await writeIgnoreFile(vaultPath, "*.tmp\n");
|
||||
const rules = new IgnoreRules(vaultPath);
|
||||
await rules.load();
|
||||
expect(rules.shouldIgnore("notes/scratch.tmp")).toBe(true);
|
||||
expect(rules.shouldIgnore("scratch.tmp")).toBe(true);
|
||||
expect(rules.shouldIgnore("deep/nested/file.tmp")).toBe(true);
|
||||
});
|
||||
|
||||
it("appends ** to directory patterns ending with / and prepends **/", async () => {
|
||||
const vaultPath = await createVault();
|
||||
await writeIgnoreFile(vaultPath, "build/\n");
|
||||
const rules = new IgnoreRules(vaultPath);
|
||||
await rules.load();
|
||||
expect(rules.shouldIgnore("build/output.js")).toBe(true);
|
||||
expect(rules.shouldIgnore("build/nested/file.js")).toBe(true);
|
||||
expect(rules.shouldIgnore("subproject/build/output.js")).toBe(true);
|
||||
});
|
||||
|
||||
it("leaves patterns containing / as-is", async () => {
|
||||
const vaultPath = await createVault();
|
||||
await writeIgnoreFile(vaultPath, "docs/private.md\n");
|
||||
const rules = new IgnoreRules(vaultPath);
|
||||
await rules.load();
|
||||
expect(rules.shouldIgnore("docs/private.md")).toBe(true);
|
||||
expect(rules.shouldIgnore("other/docs/private.md")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("shouldIgnore", () => {
|
||||
it("matches **/*.tmp against notes/scratch.tmp", async () => {
|
||||
const vaultPath = await createVault();
|
||||
await writeIgnoreFile(vaultPath, "*.tmp\n");
|
||||
const rules = new IgnoreRules(vaultPath);
|
||||
await rules.load();
|
||||
expect(rules.shouldIgnore("notes/scratch.tmp")).toBe(true);
|
||||
});
|
||||
|
||||
it("does not match notes/readme.md against **/*.tmp", async () => {
|
||||
const vaultPath = await createVault();
|
||||
await writeIgnoreFile(vaultPath, "*.tmp\n");
|
||||
const rules = new IgnoreRules(vaultPath);
|
||||
await rules.load();
|
||||
expect(rules.shouldIgnore("notes/readme.md")).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false when no patterns are loaded", async () => {
|
||||
const vaultPath = await createVault();
|
||||
const rules = new IgnoreRules(vaultPath);
|
||||
// No load() call — patterns are empty
|
||||
expect(rules.shouldIgnore("anything.md")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("negation patterns", () => {
|
||||
it("throws when a negation pattern is encountered", async () => {
|
||||
const vaultPath = await createVault();
|
||||
await writeIgnoreFile(vaultPath, "*.tmp\n!important.tmp\n");
|
||||
const rules = new IgnoreRules(vaultPath);
|
||||
await expect(rules.load()).rejects.toThrow(/Negation pattern/);
|
||||
});
|
||||
|
||||
it("throws when a .gitignore imported via directive contains negation", async () => {
|
||||
const vaultPath = await createVault();
|
||||
await writeIgnoreFile(vaultPath, "import: .gitignore\n");
|
||||
await fs.writeFile(path.join(vaultPath, ".gitignore"), "*.log\n!keep.log\n", "utf-8");
|
||||
const rules = new IgnoreRules(vaultPath);
|
||||
await expect(rules.load()).rejects.toThrow(/Negation pattern/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("unrecognised import: directives", () => {
|
||||
it("warns and skips unrecognised import: forms (does not add as literal pattern)", async () => {
|
||||
const vaultPath = await createVault();
|
||||
// Typo: "import:.gitignore" instead of "import: .gitignore"
|
||||
await writeIgnoreFile(vaultPath, "*.tmp\nimport:.gitignore\n");
|
||||
const rules = new IgnoreRules(vaultPath);
|
||||
await rules.load();
|
||||
// *.tmp still loaded; import:.gitignore is skipped (not treated as a literal pattern)
|
||||
expect(rules.shouldIgnore("scratch.tmp")).toBe(true);
|
||||
expect(rules.shouldIgnore("import:.gitignore")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("load() with missing file", () => {
|
||||
it("returns without error when .livesync/ignore is absent", async () => {
|
||||
const vaultPath = await createVault();
|
||||
// No ignore file created
|
||||
const rules = new IgnoreRules(vaultPath);
|
||||
await expect(rules.load()).resolves.toBeUndefined();
|
||||
expect(rules.shouldIgnore("anything.md")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("load() with comments and blank lines", () => {
|
||||
it("skips # comment lines and blank lines", async () => {
|
||||
const vaultPath = await createVault();
|
||||
await writeIgnoreFile(
|
||||
vaultPath,
|
||||
"# This is a comment\n\n \n*.tmp\n# another comment\nbuild/\n"
|
||||
);
|
||||
const rules = new IgnoreRules(vaultPath);
|
||||
await rules.load();
|
||||
expect(rules.shouldIgnore("scratch.tmp")).toBe(true);
|
||||
expect(rules.shouldIgnore("build/output.js")).toBe(true);
|
||||
expect(rules.shouldIgnore("readme.md")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("import: .gitignore directive", () => {
|
||||
it("reads and normalises patterns from .gitignore", async () => {
|
||||
const vaultPath = await createVault();
|
||||
await writeIgnoreFile(vaultPath, "import: .gitignore\n");
|
||||
await fs.writeFile(path.join(vaultPath, ".gitignore"), "*.log\nnode_modules/\n", "utf-8");
|
||||
const rules = new IgnoreRules(vaultPath);
|
||||
await rules.load();
|
||||
expect(rules.shouldIgnore("app.log")).toBe(true);
|
||||
expect(rules.shouldIgnore("node_modules/package.json")).toBe(true);
|
||||
expect(rules.shouldIgnore("src/node_modules/package.json")).toBe(true);
|
||||
expect(rules.shouldIgnore("src/index.ts")).toBe(false);
|
||||
});
|
||||
|
||||
it("merges .gitignore patterns with other patterns", async () => {
|
||||
const vaultPath = await createVault();
|
||||
await writeIgnoreFile(vaultPath, "*.tmp\nimport: .gitignore\n");
|
||||
await fs.writeFile(path.join(vaultPath, ".gitignore"), "*.log\n", "utf-8");
|
||||
const rules = new IgnoreRules(vaultPath);
|
||||
await rules.load();
|
||||
expect(rules.shouldIgnore("scratch.tmp")).toBe(true);
|
||||
expect(rules.shouldIgnore("error.log")).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("import: .gitignore with missing .gitignore", () => {
|
||||
it("does not throw when .gitignore is absent", async () => {
|
||||
const vaultPath = await createVault();
|
||||
await writeIgnoreFile(vaultPath, "*.tmp\nimport: .gitignore\n");
|
||||
// No .gitignore created
|
||||
const rules = new IgnoreRules(vaultPath);
|
||||
await expect(rules.load()).resolves.toBeUndefined();
|
||||
// The *.tmp pattern from the ignore file still works
|
||||
expect(rules.shouldIgnore("scratch.tmp")).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,166 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# Test: daemon-related ignore rules behaviour
|
||||
#
|
||||
# Tests that are runnable without a long-running daemon process are exercised
|
||||
# here using the `mirror` command, which calls the same `isTargetFile` handler
|
||||
# stack that the daemon uses.
|
||||
#
|
||||
# Covered cases:
|
||||
# 1. .livesync/ignore with *.tmp pattern → ignored file is NOT synced to DB
|
||||
# 2. .livesync/ignore missing → no error, normal sync continues
|
||||
# 3. import: .gitignore directive → patterns from .gitignore are merged
|
||||
#
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
|
||||
CLI_DIR="$(cd -- "$SCRIPT_DIR/.." && pwd)"
|
||||
cd "$CLI_DIR"
|
||||
source "$SCRIPT_DIR/test-helpers.sh"
|
||||
display_test_info
|
||||
|
||||
RUN_BUILD="${RUN_BUILD:-1}"
|
||||
cli_test_init_cli_cmd
|
||||
|
||||
WORK_DIR="$(mktemp -d "${TMPDIR:-/tmp}/livesync-cli-daemon-test.XXXXXX")"
|
||||
trap 'rm -rf "$WORK_DIR"' EXIT
|
||||
|
||||
SETTINGS_FILE="$WORK_DIR/data.json"
|
||||
VAULT_DIR="$WORK_DIR/vault"
|
||||
mkdir -p "$VAULT_DIR/notes"
|
||||
|
||||
if [[ "$RUN_BUILD" == "1" ]]; then
|
||||
echo "[INFO] building CLI..."
|
||||
npm run build
|
||||
fi
|
||||
|
||||
echo "[INFO] generating settings -> $SETTINGS_FILE"
|
||||
cli_test_init_settings_file "$SETTINGS_FILE"
|
||||
cli_test_mark_settings_configured "$SETTINGS_FILE"
|
||||
|
||||
PASS=0
|
||||
FAIL=0
|
||||
|
||||
assert_pass() { echo "[PASS] $1"; PASS=$((PASS + 1)); }
|
||||
assert_fail() { echo "[FAIL] $1" >&2; FAIL=$((FAIL + 1)); }
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# Case 1: .livesync/ignore with *.tmp → matched file should NOT appear in DB
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
echo ""
|
||||
echo "=== Case 1: .livesync/ignore *.tmp → ignored file not synced to DB ==="
|
||||
|
||||
mkdir -p "$VAULT_DIR/.livesync"
|
||||
printf '*.tmp\n' > "$VAULT_DIR/.livesync/ignore"
|
||||
|
||||
# Also write a normal file so we can confirm mirror ran at all.
|
||||
printf 'normal content\n' > "$VAULT_DIR/notes/normal.md"
|
||||
# Write the file that should be ignored.
|
||||
printf 'tmp content\n' > "$VAULT_DIR/notes/scratch.tmp"
|
||||
|
||||
run_cli "$VAULT_DIR" --settings "$SETTINGS_FILE" mirror
|
||||
|
||||
# The normal file should be in the DB.
|
||||
RESULT_NORMAL="$WORK_DIR/case1-normal.txt"
|
||||
if run_cli "$VAULT_DIR" --settings "$SETTINGS_FILE" pull notes/normal.md "$RESULT_NORMAL" 2>/dev/null; then
|
||||
if cmp -s "$VAULT_DIR/notes/normal.md" "$RESULT_NORMAL"; then
|
||||
assert_pass "normal.md was synced to DB"
|
||||
else
|
||||
assert_fail "normal.md content mismatch after mirror"
|
||||
fi
|
||||
else
|
||||
assert_fail "normal.md was not found in DB after mirror"
|
||||
fi
|
||||
|
||||
# The .tmp file should NOT be in the DB.
|
||||
DB_LIST="$WORK_DIR/case1-ls.txt"
|
||||
run_cli "$VAULT_DIR" --settings "$SETTINGS_FILE" ls > "$DB_LIST"
|
||||
if grep -q "scratch.tmp" "$DB_LIST"; then
|
||||
assert_fail "scratch.tmp (ignored) was unexpectedly synced to DB"
|
||||
echo "--- DB listing ---" >&2; cat "$DB_LIST" >&2
|
||||
else
|
||||
assert_pass "scratch.tmp (*.tmp pattern) was NOT synced to DB"
|
||||
fi
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# Case 2: .livesync/ignore absent → no error, normal sync continues
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
echo ""
|
||||
echo "=== Case 2: .livesync/ignore absent → no error, sync continues ==="
|
||||
|
||||
VAULT_DIR2="$WORK_DIR/vault2"
|
||||
mkdir -p "$VAULT_DIR2/notes"
|
||||
SETTINGS_FILE2="$WORK_DIR/data2.json"
|
||||
cli_test_init_settings_file "$SETTINGS_FILE2"
|
||||
cli_test_mark_settings_configured "$SETTINGS_FILE2"
|
||||
|
||||
# No .livesync directory at all.
|
||||
printf 'hello\n' > "$VAULT_DIR2/notes/hello.md"
|
||||
|
||||
# mirror should succeed without error.
|
||||
set +e
|
||||
MIRROR_OUTPUT="$WORK_DIR/case2-mirror.txt"
|
||||
run_cli "$VAULT_DIR2" --settings "$SETTINGS_FILE2" mirror >"$MIRROR_OUTPUT" 2>&1
|
||||
MIRROR_EXIT=$?
|
||||
set -e
|
||||
|
||||
if [[ "$MIRROR_EXIT" -ne 0 ]]; then
|
||||
assert_fail "mirror exited non-zero ($MIRROR_EXIT) when .livesync/ignore is absent"
|
||||
cat "$MIRROR_OUTPUT" >&2
|
||||
else
|
||||
assert_pass "mirror succeeded when .livesync/ignore is absent"
|
||||
fi
|
||||
|
||||
# The normal file should have been synced.
|
||||
RESULT_HELLO="$WORK_DIR/case2-hello.txt"
|
||||
if run_cli "$VAULT_DIR2" --settings "$SETTINGS_FILE2" pull notes/hello.md "$RESULT_HELLO" 2>/dev/null; then
|
||||
assert_pass "file synced normally when .livesync/ignore is absent"
|
||||
else
|
||||
assert_fail "file was not synced when .livesync/ignore is absent"
|
||||
fi
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# Case 3: import: .gitignore merges patterns
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
echo ""
|
||||
echo "=== Case 3: import: .gitignore directive merges patterns ==="
|
||||
|
||||
VAULT_DIR3="$WORK_DIR/vault3"
|
||||
mkdir -p "$VAULT_DIR3/notes"
|
||||
SETTINGS_FILE3="$WORK_DIR/data3.json"
|
||||
cli_test_init_settings_file "$SETTINGS_FILE3"
|
||||
cli_test_mark_settings_configured "$SETTINGS_FILE3"
|
||||
|
||||
mkdir -p "$VAULT_DIR3/.livesync"
|
||||
printf 'import: .gitignore\n' > "$VAULT_DIR3/.livesync/ignore"
|
||||
printf '# gitignore comment\n*.log\nbuild/\n' > "$VAULT_DIR3/.gitignore"
|
||||
|
||||
printf 'regular note\n' > "$VAULT_DIR3/notes/regular.md"
|
||||
printf 'log content\n' > "$VAULT_DIR3/notes/debug.log"
|
||||
|
||||
run_cli "$VAULT_DIR3" --settings "$SETTINGS_FILE3" mirror
|
||||
|
||||
DB_LIST3="$WORK_DIR/case3-ls.txt"
|
||||
run_cli "$VAULT_DIR3" --settings "$SETTINGS_FILE3" ls > "$DB_LIST3"
|
||||
|
||||
if grep -q "debug.log" "$DB_LIST3"; then
|
||||
assert_fail "debug.log (ignored via .gitignore import) was unexpectedly synced to DB"
|
||||
echo "--- DB listing ---" >&2; cat "$DB_LIST3" >&2
|
||||
else
|
||||
assert_pass "debug.log (*.log from imported .gitignore) was NOT synced to DB"
|
||||
fi
|
||||
|
||||
# regular.md should still be present.
|
||||
if grep -q "regular.md" "$DB_LIST3"; then
|
||||
assert_pass "regular.md was synced normally alongside .gitignore import rules"
|
||||
else
|
||||
assert_fail "regular.md was NOT synced — .gitignore import may have been too broad"
|
||||
fi
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# Summary
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
echo ""
|
||||
echo "Results: PASS=$PASS FAIL=$FAIL"
|
||||
if [[ "$FAIL" -gt 0 ]]; then
|
||||
exit 1
|
||||
fi
|
||||
@@ -29,8 +29,7 @@ export async function runScenario(remoteType: RemoteType, encrypt: boolean): Pro
|
||||
const dbPrefix = remoteType === "COUCHDB" ? requireEnv("COUCHDB_DBNAME", "dbname") : "";
|
||||
const dbname = remoteType === "COUCHDB" ? `${dbPrefix}-${dbSuffix}` : "";
|
||||
|
||||
const minioEndpoint =
|
||||
remoteType === "MINIO" ? requireEnv("MINIO_ENDPOINT", "minioEndpoint").replace(/\/$/, "") : "";
|
||||
const minioEndpoint = remoteType === "MINIO" ? requireEnv("MINIO_ENDPOINT", "minioEndpoint").replace(/\/$/, "") : "";
|
||||
const minioAccessKey = remoteType === "MINIO" ? requireEnv("MINIO_ACCESS_KEY", "accessKey") : "";
|
||||
const minioSecretKey = remoteType === "MINIO" ? requireEnv("MINIO_SECRET_KEY", "secretKey") : "";
|
||||
const minioBucketBase = remoteType === "MINIO" ? requireEnv("MINIO_BUCKET_NAME", "bucketName") : "";
|
||||
|
||||
@@ -11,54 +11,11 @@ const defaultExternal = [
|
||||
"crypto",
|
||||
"pouchdb-adapter-leveldb",
|
||||
"commander",
|
||||
"chokidar",
|
||||
"punycode",
|
||||
"werift",
|
||||
];
|
||||
// Polyfill FileReader at the very top of the CJS bundle. octagonal-wheels uses
|
||||
// FileReader for base64 conversion when Uint8Array.toBase64 (TC39 proposal) is
|
||||
// unavailable. Node.js has neither, so we inject a minimal FileReader shim before
|
||||
// any module-scope code evaluates.
|
||||
const fileReaderPolyfillBanner = `
|
||||
if (typeof globalThis.FileReader === "undefined") {
|
||||
globalThis.FileReader = class FileReader {
|
||||
constructor() { this.result = null; this.onload = null; this.onerror = null; }
|
||||
readAsDataURL(blob) {
|
||||
blob.arrayBuffer().then((buf) => {
|
||||
var b64 = require("buffer").Buffer.from(buf).toString("base64");
|
||||
this.result = "data:" + (blob.type || "application/octet-stream") + ";base64," + b64;
|
||||
if (this.onload) this.onload({ target: this });
|
||||
}).catch((err) => { if (this.onerror) this.onerror({ target: this, error: err }); });
|
||||
}
|
||||
readAsArrayBuffer() { throw new Error("FileReader.readAsArrayBuffer is not implemented in this polyfill"); }
|
||||
readAsBinaryString() { throw new Error("FileReader.readAsBinaryString is not implemented in this polyfill"); }
|
||||
readAsText() { throw new Error("FileReader.readAsText is not implemented in this polyfill"); }
|
||||
abort() { throw new Error("FileReader.abort is not implemented in this polyfill"); }
|
||||
};
|
||||
}
|
||||
`;
|
||||
|
||||
function injectBanner(): import("vite").Plugin {
|
||||
return {
|
||||
name: "inject-banner",
|
||||
generateBundle(_options, bundle) {
|
||||
for (const chunk of Object.values(bundle)) {
|
||||
if (chunk.type === "chunk" && chunk.fileName.startsWith("entrypoint")) {
|
||||
// Insert after the shebang line if present, otherwise at the top.
|
||||
if (chunk.code.startsWith("#!")) {
|
||||
const newline = chunk.code.indexOf("\n");
|
||||
chunk.code = chunk.code.slice(0, newline + 1) + fileReaderPolyfillBanner + chunk.code.slice(newline + 1);
|
||||
} else {
|
||||
chunk.code = fileReaderPolyfillBanner + chunk.code;
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [svelte(), injectBanner()],
|
||||
plugins: [svelte()],
|
||||
resolve: {
|
||||
alias: {
|
||||
"@lib/worker/bgWorker.ts": "../../lib/src/worker/bgWorker.mock.ts",
|
||||
|
||||
@@ -41,7 +41,7 @@ async function renderHistoryList(): Promise<VaultHistoryItem[]> {
|
||||
|
||||
const [items, lastUsedId] = await Promise.all([historyStore.getVaultHistory(), historyStore.getLastUsedVaultId()]);
|
||||
|
||||
listEl.replaceChildren();
|
||||
listEl.innerHTML = "";
|
||||
emptyEl.classList.toggle("is-hidden", items.length > 0);
|
||||
|
||||
for (const item of items) {
|
||||
|
||||
2
src/lib
2
src/lib
Submodule src/lib updated: 6c53e748eb...97530553a6
@@ -1,6 +1,6 @@
|
||||
import { TFile, Modal, App, DIFF_DELETE, DIFF_EQUAL, DIFF_INSERT, diff_match_patch } from "../../../deps.ts";
|
||||
import { getPathFromTFile, isValidPath } from "../../../common/utils.ts";
|
||||
import { decodeBinary, readString } from "../../../lib/src/string_and_binary/convert.ts";
|
||||
import { decodeBinary, escapeStringToHTML, readString } from "../../../lib/src/string_and_binary/convert.ts";
|
||||
import ObsidianLiveSyncPlugin from "../../../main.ts";
|
||||
import {
|
||||
type DocumentID,
|
||||
@@ -145,66 +145,22 @@ export class DocumentHistoryModal extends Modal {
|
||||
return v;
|
||||
}
|
||||
|
||||
prepareContentView(usePreformatted = true) {
|
||||
this.contentView.empty();
|
||||
this.contentView.toggleClass("op-pre", usePreformatted);
|
||||
}
|
||||
|
||||
appendTextDiff(diff: [number, string][]) {
|
||||
for (const [operation, text] of diff) {
|
||||
if (operation == DIFF_DELETE) {
|
||||
this.contentView.createSpan({ text, cls: "history-deleted" });
|
||||
} else if (operation == DIFF_EQUAL) {
|
||||
this.contentView.createSpan({ text, cls: "history-normal" });
|
||||
} else if (operation == DIFF_INSERT) {
|
||||
this.contentView.createSpan({ text, cls: "history-added" });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
appendImageDiff(baseSrc: string, overlaySrc?: string) {
|
||||
const wrap = this.contentView.createDiv({ cls: "ls-imgdiff-wrap" });
|
||||
const overlay = wrap.createDiv({ cls: "overlay" });
|
||||
overlay.createEl("img", { cls: "img-base" }, (img) => {
|
||||
img.src = baseSrc;
|
||||
});
|
||||
if (overlaySrc) {
|
||||
overlay.createEl("img", { cls: "img-overlay" }, (img) => {
|
||||
img.src = overlaySrc;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
appendDeletedNotice(usePreformatted = true) {
|
||||
const notice = "(At this revision, the file has been deleted)";
|
||||
if (usePreformatted) {
|
||||
this.contentView.appendText(`${notice}\n`);
|
||||
} else {
|
||||
this.contentView.createDiv({ text: notice });
|
||||
}
|
||||
}
|
||||
|
||||
async showExactRev(rev: string) {
|
||||
const db = this.core.localDatabase;
|
||||
const w = await db.getDBEntry(this.file, { rev: rev }, false, false, true);
|
||||
this.currentText = "";
|
||||
this.currentDeleted = false;
|
||||
this.prepareContentView();
|
||||
if (w === false) {
|
||||
this.currentDeleted = true;
|
||||
this.info.empty();
|
||||
this.contentView.appendText("Could not read this revision");
|
||||
this.contentView.createEl("br");
|
||||
this.contentView.appendText(`(${rev})`);
|
||||
this.info.innerHTML = "";
|
||||
this.contentView.innerHTML = `Could not read this revision<br>(${rev})`;
|
||||
} else {
|
||||
this.currentDoc = w;
|
||||
this.info.setText(`Modified:${new Date(w.mtime).toLocaleString()}`);
|
||||
this.info.innerHTML = `Modified:${new Date(w.mtime).toLocaleString()}`;
|
||||
let result = undefined;
|
||||
const w1data = readDocument(w);
|
||||
this.currentDeleted = !!w.deleted;
|
||||
if (typeof w1data == "string") {
|
||||
this.currentText = w1data;
|
||||
}
|
||||
let rendered = false;
|
||||
// this.currentText = w1data;
|
||||
if (this.showDiff) {
|
||||
const prevRevIdx = this.revs_info.length - 1 - ((this.range.value as any) / 1 - 1);
|
||||
if (prevRevIdx >= 0 && prevRevIdx < this.revs_info.length) {
|
||||
@@ -212,55 +168,58 @@ export class DocumentHistoryModal extends Modal {
|
||||
const w2 = await db.getDBEntry(this.file, { rev: oldRev }, false, false, true);
|
||||
if (w2 != false) {
|
||||
if (typeof w1data == "string") {
|
||||
const w2data = readDocument(w2);
|
||||
if (typeof w2data == "string") {
|
||||
const dmp = new diff_match_patch();
|
||||
const diff = dmp.diff_main(w2data, w1data);
|
||||
dmp.diff_cleanupSemantic(diff);
|
||||
if (this.currentDeleted) {
|
||||
this.appendDeletedNotice();
|
||||
result = "";
|
||||
const dmp = new diff_match_patch();
|
||||
const w2data = readDocument(w2) as string;
|
||||
const diff = dmp.diff_main(w2data, w1data);
|
||||
dmp.diff_cleanupSemantic(diff);
|
||||
for (const v of diff) {
|
||||
const x1 = v[0];
|
||||
const x2 = v[1];
|
||||
if (x1 == DIFF_DELETE) {
|
||||
result += "<span class='history-deleted'>" + escapeStringToHTML(x2) + "</span>";
|
||||
} else if (x1 == DIFF_EQUAL) {
|
||||
result += "<span class='history-normal'>" + escapeStringToHTML(x2) + "</span>";
|
||||
} else if (x1 == DIFF_INSERT) {
|
||||
result += "<span class='history-added'>" + escapeStringToHTML(x2) + "</span>";
|
||||
}
|
||||
this.appendTextDiff(diff);
|
||||
rendered = true;
|
||||
}
|
||||
result = result.replace(/\n/g, "<br>");
|
||||
} else if (isImage(this.file)) {
|
||||
const src = this.generateBlobURL("base", w1data);
|
||||
const overlay = this.generateBlobURL(
|
||||
"overlay",
|
||||
readDocument(w2) as Uint8Array<ArrayBuffer>
|
||||
);
|
||||
this.prepareContentView(false);
|
||||
if (this.currentDeleted) {
|
||||
this.appendDeletedNotice(false);
|
||||
}
|
||||
this.appendImageDiff(src, overlay);
|
||||
rendered = true;
|
||||
result = `<div class='ls-imgdiff-wrap'>
|
||||
<div class='overlay'>
|
||||
<img class='img-base' src="${src}">
|
||||
<img class='img-overlay' src='${overlay}'>
|
||||
</div>
|
||||
</div>`;
|
||||
this.contentView.removeClass("op-pre");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!rendered) {
|
||||
if (result == undefined) {
|
||||
if (typeof w1data != "string") {
|
||||
if (isImage(this.file)) {
|
||||
const src = this.generateBlobURL("base", w1data);
|
||||
this.prepareContentView(false);
|
||||
if (this.currentDeleted) {
|
||||
this.appendDeletedNotice(false);
|
||||
}
|
||||
this.appendImageDiff(src);
|
||||
} else {
|
||||
if (this.currentDeleted) {
|
||||
this.appendDeletedNotice();
|
||||
}
|
||||
this.contentView.appendText("Binary file");
|
||||
result = `<div class='ls-imgdiff-wrap'>
|
||||
<div class='overlay'>
|
||||
<img class='img-base' src="${src}">
|
||||
</div>
|
||||
</div>`;
|
||||
this.contentView.removeClass("op-pre");
|
||||
}
|
||||
} else {
|
||||
if (this.currentDeleted) {
|
||||
this.appendDeletedNotice();
|
||||
}
|
||||
this.contentView.appendText(w1data);
|
||||
result = escapeStringToHTML(w1data);
|
||||
}
|
||||
}
|
||||
if (result == undefined) result = typeof w1data == "string" ? escapeStringToHTML(w1data) : "Binary file";
|
||||
this.contentView.innerHTML =
|
||||
(this.currentDeleted ? "(At this revision, the file has been deleted)\n" : "") + result;
|
||||
}
|
||||
// Reset diff navigation after content changes
|
||||
this.resetDiffNavigation();
|
||||
@@ -286,7 +245,8 @@ export class DocumentHistoryModal extends Modal {
|
||||
if (direction === "next") {
|
||||
this.currentDiffIndex = (this.currentDiffIndex + 1) % diffElements.length;
|
||||
} else {
|
||||
this.currentDiffIndex = this.currentDiffIndex <= 0 ? diffElements.length - 1 : this.currentDiffIndex - 1;
|
||||
this.currentDiffIndex =
|
||||
this.currentDiffIndex <= 0 ? diffElements.length - 1 : this.currentDiffIndex - 1;
|
||||
}
|
||||
|
||||
const target = diffElements[this.currentDiffIndex];
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { App, Modal } from "../../../deps.ts";
|
||||
import { DIFF_DELETE, DIFF_EQUAL, DIFF_INSERT } from "diff-match-patch";
|
||||
import { CANCELLED, LEAVE_TO_SUBSEQUENT, type diff_result } from "../../../lib/src/common/types.ts";
|
||||
import { escapeStringToHTML } from "../../../lib/src/string_and_binary/convert.ts";
|
||||
import { delay } from "../../../lib/src/common/utils.ts";
|
||||
import { eventHub } from "../../../common/events.ts";
|
||||
import { globalSlipBoard } from "../../../lib/src/bureau/bureau.ts";
|
||||
@@ -43,25 +44,6 @@ export class ConflictResolveModal extends Modal {
|
||||
// sendValue("close-resolve-conflict:" + this.filename, false);
|
||||
}
|
||||
|
||||
appendDiffFragment(container: HTMLDivElement, text: string, cls: string) {
|
||||
const lines = text.split("\n");
|
||||
lines.forEach((line, index) => {
|
||||
const span = container.createSpan({ cls });
|
||||
span.textContent = line;
|
||||
if (index < lines.length - 1) {
|
||||
container.createSpan({ cls: "ls-mark-cr" });
|
||||
container.createEl("br");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
appendVersionInfo(container: HTMLDivElement, cls: string, name: string, date: string) {
|
||||
const line = container.createSpan({ cls });
|
||||
line.createSpan({ text: name, cls: "conflict-dev-name" });
|
||||
line.appendText(`: ${date}`);
|
||||
container.createEl("br");
|
||||
}
|
||||
|
||||
override onOpen() {
|
||||
const { contentEl } = this;
|
||||
// Send cancel signal for the previous merge dialogue
|
||||
@@ -82,21 +64,25 @@ export class ConflictResolveModal extends Modal {
|
||||
const div = contentEl.createDiv("");
|
||||
div.addClass("op-scrollable");
|
||||
div.addClass("ls-dialog");
|
||||
let diffLength = 0;
|
||||
let diff = "";
|
||||
for (const v of this.result.diff) {
|
||||
const x1 = v[0];
|
||||
const x2 = v[1];
|
||||
diffLength += x2.length;
|
||||
if (diffLength > 100 * 1024) {
|
||||
continue;
|
||||
}
|
||||
if (x1 == DIFF_DELETE) {
|
||||
this.appendDiffFragment(div, x2, "deleted");
|
||||
div.createEl("span", { text: x2, cls: "deleted normal conflict-dev-name" });
|
||||
diff +=
|
||||
"<span class='deleted'>" +
|
||||
escapeStringToHTML(x2).replace(/\n/g, "<span class='ls-mark-cr'></span>\n") +
|
||||
"</span>";
|
||||
} else if (x1 == DIFF_EQUAL) {
|
||||
this.appendDiffFragment(div, x2, "normal");
|
||||
diff +=
|
||||
"<span class='normal'>" +
|
||||
escapeStringToHTML(x2).replace(/\n/g, "<span class='ls-mark-cr'></span>\n") +
|
||||
"</span>";
|
||||
} else if (x1 == DIFF_INSERT) {
|
||||
this.appendDiffFragment(div, x2, "added");
|
||||
diff +=
|
||||
"<span class='added'>" +
|
||||
escapeStringToHTML(x2).replace(/\n/g, "<span class='ls-mark-cr'></span>\n") +
|
||||
"</span>";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -106,8 +92,8 @@ export class ConflictResolveModal extends Modal {
|
||||
new Date(this.result.left.mtime).toLocaleString() + (this.result.left.deleted ? " (Deleted)" : "");
|
||||
const date2 =
|
||||
new Date(this.result.right.mtime).toLocaleString() + (this.result.right.deleted ? " (Deleted)" : "");
|
||||
this.appendVersionInfo(div2, "deleted", this.localName, date1);
|
||||
this.appendVersionInfo(div2, "added", this.remoteName, date2);
|
||||
div2.innerHTML = `<span class='deleted'><span class='conflict-dev-name'>${this.localName}</span>: ${date1}</span><br>
|
||||
<span class='added'><span class='conflict-dev-name'>${this.remoteName}</span>: ${date2}</span><br>`;
|
||||
contentEl.createEl("button", { text: `Use ${this.localName}` }, (e) =>
|
||||
e.addEventListener("click", () => this.sendResponse(this.result.right.rev))
|
||||
).style.marginRight = "4px";
|
||||
@@ -122,9 +108,11 @@ export class ConflictResolveModal extends Modal {
|
||||
contentEl.createEl("button", { text: !this.pluginPickMode ? "Not now" : "Cancel" }, (e) =>
|
||||
e.addEventListener("click", () => this.sendResponse(CANCELLED))
|
||||
).style.marginRight = "4px";
|
||||
if (diffLength > 100 * 1024) {
|
||||
div.empty();
|
||||
diff = diff.replace(/\n/g, "<br>");
|
||||
if (diff.length > 100 * 1024) {
|
||||
div.innerText = "(Too large diff to display)";
|
||||
} else {
|
||||
div.innerHTML = diff;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -43,13 +43,10 @@ export function paneChangeLog(this: ObsidianLiveSyncSettingTab, paneEl: HTMLElem
|
||||
// tmpDiv.addClass("sls-header-button");
|
||||
tmpDiv.addClass("op-warn-info");
|
||||
|
||||
tmpDiv.createEl("p", { text: $msg("obsidianLiveSyncSettingTab.msgNewVersionNote") });
|
||||
const readEverythingButton = tmpDiv.createEl("button", {
|
||||
text: $msg("obsidianLiveSyncSettingTab.optionOkReadEverything"),
|
||||
});
|
||||
tmpDiv.innerHTML = `<p>${$msg("obsidianLiveSyncSettingTab.msgNewVersionNote")}</p><button>${$msg("obsidianLiveSyncSettingTab.optionOkReadEverything")}</button>`;
|
||||
if (lastVersion > (this.editingSettings?.lastReadUpdates || 0)) {
|
||||
const informationButtonDiv = informationDivEl.appendChild(tmpDiv);
|
||||
readEverythingButton.addEventListener("click", () => {
|
||||
informationButtonDiv.querySelector("button")?.addEventListener("click", () => {
|
||||
fireAndForget(async () => {
|
||||
this.editingSettings.lastReadUpdates = lastVersion;
|
||||
await this.saveAllDirtySettings();
|
||||
|
||||
@@ -121,13 +121,13 @@ export function paneSetup(
|
||||
const repo = "vrtmrz/obsidian-livesync";
|
||||
const topPath = $msg("obsidianLiveSyncSettingTab.linkTroubleshooting");
|
||||
const rawRepoURI = `https://raw.githubusercontent.com/${repo}/main`;
|
||||
this.createEl(paneEl, "div", "", (el) => {
|
||||
el.createEl("a", { text: $msg("obsidianLiveSyncSettingTab.linkOpenInBrowser") }, (anchor) => {
|
||||
anchor.href = `https://github.com/${repo}/blob/main${topPath}`;
|
||||
anchor.target = "_blank";
|
||||
anchor.rel = "noopener";
|
||||
});
|
||||
});
|
||||
this.createEl(
|
||||
paneEl,
|
||||
"div",
|
||||
"",
|
||||
(el) =>
|
||||
(el.innerHTML = `<a href='https://github.com/${repo}/blob/main${topPath}' target="_blank">${$msg("obsidianLiveSyncSettingTab.linkOpenInBrowser")}</a>`)
|
||||
);
|
||||
const troubleShootEl = this.createEl(paneEl, "div", {
|
||||
text: "",
|
||||
cls: "sls-troubleshoot-preview",
|
||||
|
||||
@@ -13,7 +13,7 @@ export const checkConfig = async (
|
||||
Logger($msg("obsidianLiveSyncSettingTab.logCheckingDbConfig"), LOG_LEVEL_INFO);
|
||||
let isSuccessful = true;
|
||||
const emptyDiv = createDiv();
|
||||
emptyDiv.createSpan();
|
||||
emptyDiv.innerHTML = "<span></span>";
|
||||
checkResultDiv?.replaceChildren(...[emptyDiv]);
|
||||
const addResult = (msg: string, classes?: string[]) => {
|
||||
const tmpDiv = createDiv();
|
||||
@@ -21,7 +21,7 @@ export const checkConfig = async (
|
||||
if (classes) {
|
||||
tmpDiv.addClasses(classes);
|
||||
}
|
||||
tmpDiv.textContent = msg;
|
||||
tmpDiv.innerHTML = `${msg}`;
|
||||
checkResultDiv?.appendChild(tmpDiv);
|
||||
};
|
||||
try {
|
||||
@@ -47,10 +47,9 @@ export const checkConfig = async (
|
||||
if (!checkResultDiv) return;
|
||||
const tmpDiv = createDiv();
|
||||
tmpDiv.addClass("ob-btn-config-fix");
|
||||
tmpDiv.createEl("label", { text: title });
|
||||
const fixButton = tmpDiv.createEl("button", { text: $msg("obsidianLiveSyncSettingTab.btnFix") });
|
||||
tmpDiv.innerHTML = `<label>${title}</label><button>${$msg("obsidianLiveSyncSettingTab.btnFix")}</button>`;
|
||||
const x = checkResultDiv.appendChild(tmpDiv);
|
||||
fixButton.addEventListener("click", () => {
|
||||
x.querySelector("button")?.addEventListener("click", () => {
|
||||
fireAndForget(async () => {
|
||||
Logger($msg("obsidianLiveSyncSettingTab.logCouchDbConfigSet", { title, key, value }));
|
||||
const res = await requestToCouchDBWithCredentials(
|
||||
|
||||
@@ -4,10 +4,10 @@
|
||||
import Decision from "@/lib/src/UI/components/Decision.svelte";
|
||||
import Instruction from "@/lib/src/UI/components/Instruction.svelte";
|
||||
import UserDecisions from "@/lib/src/UI/components/UserDecisions.svelte";
|
||||
const TYPE_CLOSE = "close";
|
||||
const TYPE_CLOSE = "close";
|
||||
type ResultType = typeof TYPE_CLOSE;
|
||||
type Props = {
|
||||
setResult: (_result: ResultType) => void;
|
||||
setResult: (result: ResultType) => void;
|
||||
};
|
||||
const { setResult }: Props = $props();
|
||||
</script>
|
||||
|
||||
@@ -61,12 +61,10 @@ export class ModuleLiveSyncMain extends AbstractModule {
|
||||
eventHub.onEvent(EVENT_SETTING_SAVED, (settings: ObsidianLiveSyncSettings) => {
|
||||
fireAndForget(async () => {
|
||||
try {
|
||||
const lang = this.core.services.setting.currentSettings()?.displayLanguage;
|
||||
await this.core.services.control.applySettings();
|
||||
const lang = this.core.services.setting.currentSettings()?.displayLanguage ?? undefined;
|
||||
if (lang !== undefined) {
|
||||
setLang(lang);
|
||||
}
|
||||
if (this.core.services.database.isDatabaseReady()) {
|
||||
await this.core.services.control.applySettings();
|
||||
setLang(this.core.services.setting.currentSettings()?.displayLanguage);
|
||||
}
|
||||
eventHub.emitEvent(EVENT_REQUEST_RELOAD_SETTING_TAB);
|
||||
} catch (e) {
|
||||
|
||||
@@ -3,22 +3,11 @@ import { createServiceFeature } from "@lib/interfaces/ServiceModule";
|
||||
import { SUPPORTED_I18N_LANGS, type I18N_LANGS } from "@lib/common/rosetta";
|
||||
import { $msg, setLang } from "@lib/common/i18n";
|
||||
|
||||
function tryGetLanguage() {
|
||||
try {
|
||||
// Note: 1.8.7+ is required. but it is 18, Feb., 2025. we want to fallback on earlier versions, so we catch the error here.
|
||||
// eslint-disable-next-line obsidianmd/no-unsupported-api
|
||||
return getLanguage();
|
||||
} catch (e) {
|
||||
console.error("Failed to get Obsidian language, defaulting to 'def'", e);
|
||||
return "en";
|
||||
}
|
||||
}
|
||||
|
||||
export const enableI18nFeature = createServiceFeature(async ({ services: { setting, API } }) => {
|
||||
let isChanged = false;
|
||||
const settings = setting.currentSettings();
|
||||
if (settings.displayLanguage == "") {
|
||||
const obsidianLanguage = tryGetLanguage();
|
||||
const obsidianLanguage = getLanguage();
|
||||
if (
|
||||
SUPPORTED_I18N_LANGS.indexOf(obsidianLanguage) !== -1 && // Check if the language is supported
|
||||
obsidianLanguage != settings.displayLanguage // Check if the language is different from the current setting
|
||||
|
||||
@@ -5,7 +5,6 @@ import { type UseP2PReplicatorResult } from "@/lib/src/replication/trystero/UseP
|
||||
import { P2PLogCollector } from "@/lib/src/replication/trystero/P2PLogCollector";
|
||||
import { P2PReplicatorPaneView, VIEW_TYPE_P2P } from "@/features/P2PSync/P2PReplicator/P2PReplicatorPaneView";
|
||||
import type { LiveSyncCore } from "@/main";
|
||||
import type { WorkspaceLeaf } from "@/deps";
|
||||
|
||||
/**
|
||||
* ServiceFeature: P2P Replicator lifecycle management.
|
||||
@@ -44,7 +43,7 @@ export function useP2PReplicatorUI(
|
||||
|
||||
// Register view, commands and ribbon if a view factory is provided
|
||||
const viewType = VIEW_TYPE_P2P;
|
||||
const factory = (leaf: WorkspaceLeaf) => {
|
||||
const factory = (leaf: any) => {
|
||||
return new P2PReplicatorPaneView(leaf, core, {
|
||||
replicator: getReplicator(),
|
||||
p2pLogCollector,
|
||||
|
||||
@@ -7,7 +7,7 @@ import type { TFile, App, TFolder } from "obsidian";
|
||||
* Vault adapter implementation for Obsidian
|
||||
*/
|
||||
export class ObsidianVaultAdapter implements IVaultAdapter<TFile> {
|
||||
constructor(private app: App) { }
|
||||
constructor(private app: App) {}
|
||||
|
||||
async read(file: TFile): Promise<string> {
|
||||
return await this.app.vault.read(file);
|
||||
@@ -38,20 +38,10 @@ export class ObsidianVaultAdapter implements IVaultAdapter<TFile> {
|
||||
}
|
||||
|
||||
async delete(file: TFile | TFolder, force = false): Promise<void> {
|
||||
// if ("trashFile" in this.app.fileManager) {
|
||||
// // eslint-disable-next-line obsidianmd/no-unsupported-api
|
||||
// return await this.app.fileManager.trashFile(file);
|
||||
// }
|
||||
//TODO: need fix
|
||||
return await this.app.vault.delete(file, force);
|
||||
}
|
||||
|
||||
async trash(file: TFile | TFolder, force = false): Promise<void> {
|
||||
// if ("trashFile" in this.app.fileManager) {
|
||||
// // eslint-disable-next-line obsidianmd/no-unsupported-api
|
||||
// return await this.app.fileManager.trashFile(file);
|
||||
// }
|
||||
//TODO: need fix
|
||||
return await this.app.vault.trash(file, force);
|
||||
}
|
||||
|
||||
|
||||
223
test_e2e/helpers/obsidian.ts
Normal file
223
test_e2e/helpers/obsidian.ts
Normal file
@@ -0,0 +1,223 @@
|
||||
/**
|
||||
* helpers/obsidian.ts
|
||||
*
|
||||
* Launch / teardown helpers for the Obsidian Electron application and
|
||||
* common UI interactions needed across test files.
|
||||
*
|
||||
* Launch strategy
|
||||
* ---------------
|
||||
* Playwright's `_electron.launch()` cannot reliably connect to Obsidian.exe
|
||||
* via CDP because Obsidian's startup sequence does not expose the DevTools
|
||||
* URL on stdout/stderr in a way Playwright can detect. Instead, we:
|
||||
* 1. Spawn Obsidian with a fixed `--remote-debugging-port`.
|
||||
* 2. Poll `http://127.0.0.1:<port>/json/version` until the port is ready.
|
||||
* 3. Connect with `chromium.connectOverCDP()`.
|
||||
*/
|
||||
|
||||
import { chromium } from "playwright";
|
||||
import { spawn } from "node:child_process";
|
||||
import http from "node:http";
|
||||
import path from "node:path";
|
||||
import os from "node:os";
|
||||
|
||||
import type { Browser, Page } from "playwright";
|
||||
import type { ChildProcess } from "node:child_process";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Executable path resolution
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function defaultObsidianPath(): string {
|
||||
switch (os.platform()) {
|
||||
case "win32":
|
||||
return path.join(os.homedir(), "AppData", "Local", "Obsidian", "Obsidian.exe");
|
||||
case "darwin":
|
||||
return "/Applications/Obsidian.app/Contents/MacOS/Obsidian";
|
||||
default:
|
||||
return process.env["OBSIDIAN_PATH"] ?? "/usr/bin/obsidian";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Path to the Obsidian executable.
|
||||
* Override with the `OBSIDIAN_PATH` environment variable if needed.
|
||||
*/
|
||||
export const OBSIDIAN_EXECUTABLE: string = process.env["OBSIDIAN_PATH"] ?? defaultObsidianPath();
|
||||
|
||||
/** Fixed CDP port used for all test runs (workers: 1, so no collisions). */
|
||||
const CDP_PORT = 19222;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Launch
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Handle returned by `launchObsidian`. Provides just enough surface to drive
|
||||
* the Obsidian window and shut it down cleanly.
|
||||
*/
|
||||
export interface ObsidianHandle {
|
||||
/** Returns the main Obsidian renderer page. */
|
||||
firstWindow(): Promise<Page>;
|
||||
/** Closes the CDP connection and kills the Obsidian process. */
|
||||
close(): Promise<void>;
|
||||
}
|
||||
|
||||
/** Poll `http://127.0.0.1:<port>/json/version` until Obsidian is ready. */
|
||||
async function waitForCDP(port: number, timeoutMs: number): Promise<void> {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
while (Date.now() < deadline) {
|
||||
const ready = await new Promise<boolean>((resolve) => {
|
||||
const req = http.get(`http://127.0.0.1:${port}/json/version`, (res: any) => {
|
||||
res.resume();
|
||||
resolve(res.statusCode === 200);
|
||||
});
|
||||
req.on("error", () => resolve(false));
|
||||
req.setTimeout(1_000, () => {
|
||||
req.destroy();
|
||||
resolve(false);
|
||||
});
|
||||
});
|
||||
if (ready) return;
|
||||
await new Promise((r) => setTimeout(r, 500));
|
||||
}
|
||||
throw new Error(`Obsidian CDP port ${port} was not ready within ${timeoutMs}ms`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Launches Obsidian with an isolated user-data directory and opens the
|
||||
* given vault via the `obsidian://open` URI scheme.
|
||||
*
|
||||
* Uses a fixed `--remote-debugging-port` so we can poll and connect via
|
||||
* `chromium.connectOverCDP()` without relying on Playwright's electron
|
||||
* startup detection, which does not work with Obsidian.exe.
|
||||
*/
|
||||
export async function launchObsidian(fakeAppData: string, vaultDir: string): Promise<ObsidianHandle> {
|
||||
const proc: ChildProcess = spawn(
|
||||
OBSIDIAN_EXECUTABLE,
|
||||
[
|
||||
`--remote-debugging-port=${CDP_PORT}`,
|
||||
`--user-data-dir=${fakeAppData}`,
|
||||
"--no-sandbox",
|
||||
"--lang=en",
|
||||
`obsidian://open?path=${encodeURIComponent(vaultDir)}`,
|
||||
],
|
||||
{ env: { ...process.env, LIBGL_ALWAYS_SOFTWARE: "1" } }
|
||||
);
|
||||
|
||||
proc.on("error", (err: Error) => {
|
||||
console.error("[launchObsidian] spawn error:", err.message);
|
||||
});
|
||||
|
||||
await waitForCDP(CDP_PORT, 60_000);
|
||||
|
||||
const browser: Browser = await chromium.connectOverCDP(`http://127.0.0.1:${CDP_PORT}`);
|
||||
|
||||
return {
|
||||
close: async () => {
|
||||
try {
|
||||
await browser.close();
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
try {
|
||||
proc.kill();
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
},
|
||||
firstWindow: async (): Promise<Page> => {
|
||||
const deadline = Date.now() + 30_000;
|
||||
while (Date.now() < deadline) {
|
||||
for (const ctx of browser.contexts()) {
|
||||
const pages = ctx.pages().filter((p: Page) => !p.isClosed());
|
||||
if (pages.length > 0) return pages[0];
|
||||
}
|
||||
await new Promise((r) => setTimeout(r, 300));
|
||||
}
|
||||
throw new Error("No Obsidian window found after 30s");
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Window helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Returns the main Obsidian window and waits for its DOM to be ready.
|
||||
*/
|
||||
export async function getMainWindow(app: ObsidianHandle): Promise<Page> {
|
||||
const page = await app.firstWindow();
|
||||
await page.waitForLoadState("domcontentloaded", { timeout: 30_000 });
|
||||
return page;
|
||||
}
|
||||
|
||||
/**
|
||||
* Waits until the Obsidian vault workspace has finished loading.
|
||||
*
|
||||
* Handles the 'Trust author and enable plugins' prompt and the
|
||||
* community-plugins information modal that appear on a first-time vault open.
|
||||
*/
|
||||
export async function waitForVaultReady(page: Page): Promise<void> {
|
||||
// Trust prompt — must be dismissed before the workspace renders.
|
||||
const trustButton = page.getByRole("button", { name: /trust author and enable plugins/i });
|
||||
try {
|
||||
await trustButton.waitFor({ state: "visible", timeout: 15_000 });
|
||||
await trustButton.click();
|
||||
await page.waitForTimeout(1_500);
|
||||
} catch {
|
||||
// Not shown — vault already trusted or safe mode off.
|
||||
}
|
||||
|
||||
// Community-plugins modal — dismiss with Escape.
|
||||
try {
|
||||
const modal = page.locator(".modal-container").filter({ hasText: /community plugins/i });
|
||||
await modal.waitFor({ state: "visible", timeout: 5_000 });
|
||||
await page.keyboard.press("Escape");
|
||||
await page.waitForTimeout(300);
|
||||
} catch {
|
||||
// Modal not shown.
|
||||
}
|
||||
|
||||
await page.waitForSelector(".workspace-ribbon", { timeout: 60_000 });
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Settings modal helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Opens the Obsidian Settings modal via the standard keyboard shortcut and
|
||||
* waits for the navigation panel to become visible.
|
||||
*/
|
||||
export async function openSettings(page: Page): Promise<void> {
|
||||
await page.keyboard.press("Control+,");
|
||||
await page.waitForSelector(".modal-container .vertical-tab-nav-item", { timeout: 15_000 });
|
||||
}
|
||||
|
||||
/**
|
||||
* Clicks a settings navigation tab identified by its visible text label.
|
||||
*/
|
||||
export async function clickSettingsTab(page: Page, label: string): Promise<void> {
|
||||
const tab = page.locator(".vertical-tab-nav-item", { hasText: label });
|
||||
await tab.first().click();
|
||||
await page.waitForTimeout(300);
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens Settings and navigates directly to the Self-hosted LiveSync tab.
|
||||
*/
|
||||
export async function openLiveSyncSettings(page: Page): Promise<void> {
|
||||
await openSettings(page);
|
||||
await clickSettingsTab(page, "Self-hosted LiveSync");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Selectors
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** CSS selector for the settings-tab content area. */
|
||||
export const SELECTOR_SETTINGS_CONTENT = ".vertical-tab-content-container";
|
||||
|
||||
/** CSS selector for Obsidian notice toasts. */
|
||||
export const SELECTOR_NOTICE = ".notice-container .notice";
|
||||
109
test_e2e/helpers/vault.ts
Normal file
109
test_e2e/helpers/vault.ts
Normal file
@@ -0,0 +1,109 @@
|
||||
/**
|
||||
* helpers/vault.ts
|
||||
*
|
||||
* Creates a fully-isolated, throwaway Obsidian vault for each test run.
|
||||
*
|
||||
* Directory layout produced by `setupTestVault()`:
|
||||
*
|
||||
* <tmpdir>/livesync-e2e-<id>/
|
||||
* obsidian.json <- registered vault list (Obsidian userData config)
|
||||
* vault/
|
||||
* .obsidian/
|
||||
* app.json <- safe-mode disabled
|
||||
* community-plugins.json
|
||||
* plugins/
|
||||
* obsidian-livesync/
|
||||
* main.js <- built plugin (copied from repo root)
|
||||
* manifest.json
|
||||
* styles.css
|
||||
*/
|
||||
|
||||
import { copyFileSync, existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { randomBytes } from "node:crypto";
|
||||
import path from "node:path";
|
||||
import os from "node:os";
|
||||
|
||||
/** Absolute path to the repository root (two levels above helpers/). */
|
||||
const REPO_ROOT = path.resolve(__dirname, "../..");
|
||||
|
||||
export interface VaultSetupResult {
|
||||
/** The vault directory that Obsidian will open. */
|
||||
vaultDir: string;
|
||||
/**
|
||||
* The directory used as `--user-data-dir` for the Obsidian process.
|
||||
* Obsidian reads its vault registry from `<fakeAppData>/obsidian.json`.
|
||||
*/
|
||||
fakeAppData: string;
|
||||
/** Removes the entire temporary tree. */
|
||||
cleanup: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a throw-away vault with the built plugin pre-installed and
|
||||
* registered in an isolated Obsidian configuration directory.
|
||||
*
|
||||
* Call `cleanup()` (or use `test.afterAll`) to delete the temporary files.
|
||||
*/
|
||||
export function setupTestVault(): VaultSetupResult {
|
||||
const id = randomBytes(4).toString("hex");
|
||||
const baseDir = path.join(os.tmpdir(), `livesync-e2e-${id}`);
|
||||
const fakeAppData = baseDir;
|
||||
const vaultDir = path.join(baseDir, "vault");
|
||||
|
||||
// ------------------------------------------------------------------ vault
|
||||
const dotObsidian = path.join(vaultDir, ".obsidian");
|
||||
const pluginDir = path.join(dotObsidian, "plugins", "obsidian-livesync");
|
||||
mkdirSync(pluginDir, { recursive: true });
|
||||
|
||||
// Copy the built plugin artefacts from the repository root.
|
||||
for (const file of ["main.js", "manifest.json", "styles.css"]) {
|
||||
const src = path.join(REPO_ROOT, file);
|
||||
if (existsSync(src)) {
|
||||
copyFileSync(src, path.join(pluginDir, file));
|
||||
} else {
|
||||
console.warn(`[vault setup] Expected file not found: ${src}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Disable Obsidian safe mode so community plugins are allowed to load.
|
||||
writeFileSync(path.join(dotObsidian, "app.json"), JSON.stringify({ promptDelete: false }, null, 2), "utf-8");
|
||||
|
||||
// Tell Obsidian which community plugins are enabled.
|
||||
writeFileSync(
|
||||
path.join(dotObsidian, "community-plugins.json"),
|
||||
JSON.stringify(["obsidian-livesync"], null, 2),
|
||||
"utf-8"
|
||||
);
|
||||
|
||||
// ------------------------------------------------ Obsidian global config
|
||||
// With --user-data-dir=<fakeAppData>, Obsidian reads its vault registry
|
||||
// directly from <fakeAppData>/obsidian.json.
|
||||
mkdirSync(fakeAppData, { recursive: true });
|
||||
|
||||
const vaultId = randomBytes(8).toString("hex");
|
||||
|
||||
writeFileSync(
|
||||
path.join(fakeAppData, "obsidian.json"),
|
||||
JSON.stringify(
|
||||
{
|
||||
vaults: {
|
||||
[vaultId]: {
|
||||
path: vaultDir,
|
||||
ts: Date.now(),
|
||||
open: true,
|
||||
},
|
||||
},
|
||||
updateDisabled: true,
|
||||
},
|
||||
null,
|
||||
2
|
||||
),
|
||||
"utf-8"
|
||||
);
|
||||
|
||||
return {
|
||||
vaultDir,
|
||||
fakeAppData,
|
||||
cleanup: () => rmSync(baseDir, { recursive: true, force: true }),
|
||||
};
|
||||
}
|
||||
3
test_e2e/package.json
Normal file
3
test_e2e/package.json
Normal file
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"type": "commonjs"
|
||||
}
|
||||
25
test_e2e/playwright.config.ts
Normal file
25
test_e2e/playwright.config.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { defineConfig } from "playwright/test";
|
||||
import path from "node:path";
|
||||
|
||||
export default defineConfig({
|
||||
testDir: path.join(__dirname, "tests"),
|
||||
outputDir: path.join(__dirname, "test-results"),
|
||||
|
||||
// Each test may need to cold-start Obsidian and wait for the vault to load.
|
||||
timeout: 120_000,
|
||||
expect: { timeout: 20_000 },
|
||||
|
||||
// Tests are stateful (one Obsidian process per test file), so no parallelism.
|
||||
fullyParallel: false,
|
||||
workers: 1,
|
||||
retries: 0,
|
||||
|
||||
reporter: [["list"], ["html", { open: "never", outputFolder: path.join(__dirname, "playwright-report") }]],
|
||||
|
||||
use: {
|
||||
// Artefacts are kept only when a test fails.
|
||||
screenshot: "only-on-failure",
|
||||
video: "retain-on-failure",
|
||||
trace: "retain-on-failure",
|
||||
},
|
||||
});
|
||||
82
test_e2e/tests/basic.spec.ts
Normal file
82
test_e2e/tests/basic.spec.ts
Normal file
@@ -0,0 +1,82 @@
|
||||
/**
|
||||
* tests/basic.spec.ts
|
||||
*
|
||||
* Smoke tests for the Self-hosted LiveSync plugin running inside the real
|
||||
* Obsidian desktop application.
|
||||
*
|
||||
* What these tests verify
|
||||
* -----------------------
|
||||
* 1. Obsidian can launch with a fresh vault that has the plugin pre-installed.
|
||||
* 2. The vault workspace loads without errors.
|
||||
* 3. The plugin's settings tab is reachable via Settings > Self-hosted LiveSync.
|
||||
* 4. The initial (unconfigured) setup screen is displayed on the first open.
|
||||
*
|
||||
* Prerequisites
|
||||
* -------------
|
||||
* - `main.js` must exist at the repository root (run `npm run buildDev` first).
|
||||
* - Obsidian must be installed at the default path, or `OBSIDIAN_PATH` must be set.
|
||||
*
|
||||
* How to run
|
||||
* ----------
|
||||
* npm run test:obsidian:e2e
|
||||
* npm run test:obsidian:e2e:headed
|
||||
*/
|
||||
|
||||
import { test, expect } from "playwright/test";
|
||||
import { setupTestVault } from "../helpers/vault";
|
||||
import type { VaultSetupResult } from "../helpers/vault";
|
||||
import {
|
||||
launchObsidian,
|
||||
getMainWindow,
|
||||
waitForVaultReady,
|
||||
openLiveSyncSettings,
|
||||
SELECTOR_SETTINGS_CONTENT,
|
||||
} from "../helpers/obsidian";
|
||||
import type { ObsidianHandle } from "../helpers/obsidian";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Shared state
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
let app: ObsidianHandle;
|
||||
let vault: VaultSetupResult;
|
||||
|
||||
test.beforeAll(async () => {
|
||||
vault = setupTestVault();
|
||||
app = await launchObsidian(vault.fakeAppData, vault.vaultDir);
|
||||
});
|
||||
|
||||
test.afterAll(async () => {
|
||||
if (app) {
|
||||
await app.close().catch(() => {});
|
||||
}
|
||||
vault?.cleanup();
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Test 1 – basic launch
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test("Obsidian launches and vault workspace loads", async () => {
|
||||
const page = await getMainWindow(app);
|
||||
await waitForVaultReady(page);
|
||||
|
||||
const title = await page.title();
|
||||
expect(title).toBeTruthy();
|
||||
expect(title.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Test 2 – settings tab
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test("Self-hosted LiveSync settings tab is accessible", async () => {
|
||||
const page = await getMainWindow(app);
|
||||
await waitForVaultReady(page);
|
||||
|
||||
await openLiveSyncSettings(page);
|
||||
|
||||
const content = page.locator(SELECTOR_SETTINGS_CONTENT);
|
||||
await expect(content).toBeVisible();
|
||||
await expect(content.filter({ hasText: "Self-hosted LiveSync" })).toBeVisible({ timeout: 10_000 });
|
||||
});
|
||||
12
updates.md
12
updates.md
@@ -3,18 +3,6 @@ Since 19th July, 2025 (beta1 in 0.25.0-beta1, 13th July, 2025)
|
||||
|
||||
The head note of 0.25 is now in [updates_old.md](https://github.com/vrtmrz/obsidian-livesync/blob/main/updates_old.md). Because 0.25 got a lot of updates, thankfully, compatibility is kept and we do not need breaking changes! In other words, when get enough stabled. The next version will be v1.0.0. Even though it my hope.
|
||||
|
||||
## Unreleased
|
||||
|
||||
### Improved
|
||||
|
||||
- P2P synchronisation has been made more robust
|
||||
Now the foundation for P2P synchronisation has been rewritten, and the unit tests have been added. The foundation has been separated into the transport layer, signalling-and-connection layer, and, an RPC layers. And each layer has been unit-tested. As the result, the P2P synchronisation now uses the robust shim that uses RPC-ed PouchDB synchronisation in contrast to previous implementation.
|
||||
This P2P synchronisation is not compatible with previous versions in terms of connectivity. All devices must be updated.
|
||||
|
||||
### Fixed
|
||||
|
||||
- No longer baffling errors occur when setting-update is triggered during the early stage of initialisation.
|
||||
|
||||
## 0.25.60
|
||||
|
||||
29th April, 2026
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
{
|
||||
"0.25.60": "1.7.2",
|
||||
"1.0.1": "0.9.12",
|
||||
"1.0.0": "0.9.7"
|
||||
}
|
||||
|
||||
@@ -2,8 +2,7 @@ import { defineConfig, mergeConfig } from "vitest/config";
|
||||
import { playwright } from "@vitest/browser-playwright";
|
||||
import viteConfig from "./vitest.config.common";
|
||||
import path from "path";
|
||||
import { existsSync, readFileSync } from "node:fs";
|
||||
import { parseEnv } from "node:util";
|
||||
import dotenv from "dotenv";
|
||||
import { grantClipboardPermissions, writeHandoffFile, readHandoffFile } from "./test/lib/commands";
|
||||
|
||||
// P2P test environment variables
|
||||
@@ -23,9 +22,8 @@ import { grantClipboardPermissions, writeHandoffFile, readHandoffFile } from "./
|
||||
// General test options (also read from env):
|
||||
// ENABLE_DEBUGGER - Set to "true" to attach a debugger and pause before tests
|
||||
// ENABLE_UI - Set to "true" to open a visible browser window during tests
|
||||
const loadEnvFile = (path: string) => (existsSync(path) ? parseEnv(readFileSync(path, "utf-8")) : undefined);
|
||||
const defEnv = loadEnvFile(".env");
|
||||
const testEnv = loadEnvFile(".test.env");
|
||||
const defEnv = dotenv.config({ path: ".env" }).parsed;
|
||||
const testEnv = dotenv.config({ path: ".test.env" }).parsed;
|
||||
// Merge: dotenv files < process.env (so shell-injected vars like P2P_TEST_* take precedence)
|
||||
const p2pEnv: Record<string, string> = {};
|
||||
if (process.env.P2P_TEST_ROOM_ID) p2pEnv.P2P_TEST_ROOM_ID = process.env.P2P_TEST_ROOM_ID;
|
||||
|
||||
@@ -1,30 +0,0 @@
|
||||
import { defineConfig, mergeConfig } from "vitest/config";
|
||||
import viteConfig from "./vitest.config.common";
|
||||
|
||||
export default mergeConfig(
|
||||
viteConfig,
|
||||
defineConfig({
|
||||
resolve: {
|
||||
alias: {
|
||||
obsidian: "",
|
||||
},
|
||||
},
|
||||
test: {
|
||||
name: "rpc-unit-tests",
|
||||
include: ["src/lib/src/rpc/**/*.unit.spec.ts"],
|
||||
exclude: ["test/**"],
|
||||
coverage: {
|
||||
include: ["src/lib/src/rpc/**/*.ts"],
|
||||
exclude: ["**/*.unit.spec.ts", "**/index.ts"],
|
||||
provider: "v8",
|
||||
reporter: ["text", "json", "html", ["text", { file: "coverage-rpc-text.txt" }]],
|
||||
thresholds: {
|
||||
lines: 90,
|
||||
functions: 90,
|
||||
branches: 75,
|
||||
statements: 90,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
);
|
||||
@@ -2,13 +2,10 @@ import { defineConfig, mergeConfig } from "vitest/config";
|
||||
import { playwright } from "@vitest/browser-playwright";
|
||||
import viteConfig from "./vitest.config.common";
|
||||
import path from "path";
|
||||
import { existsSync, readFileSync } from "node:fs";
|
||||
import { parseEnv } from "node:util";
|
||||
import dotenv from "dotenv";
|
||||
import { grantClipboardPermissions, openWebPeer, closeWebPeer, acceptWebPeer } from "./test/lib/commands";
|
||||
|
||||
const loadEnvFile = (path: string) => (existsSync(path) ? parseEnv(readFileSync(path, "utf-8")) : undefined);
|
||||
const defEnv = loadEnvFile(".env");
|
||||
const testEnv = loadEnvFile(".test.env");
|
||||
const defEnv = dotenv.config({ path: ".env" }).parsed;
|
||||
const testEnv = dotenv.config({ path: ".test.env" }).parsed;
|
||||
const env = Object.assign({}, defEnv, testEnv);
|
||||
const debuggerEnabled = env?.ENABLE_DEBUGGER === "true";
|
||||
const enableUI = env?.ENABLE_UI === "true";
|
||||
|
||||
Reference in New Issue
Block a user