mirror of
https://github.com/vrtmrz/obsidian-livesync.git
synced 2026-04-05 16:45:20 +00:00
Compare commits
18 Commits
0.25.56
...
0.25.56+pa
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3e03d1dbd5 | ||
|
|
0dbf4cface | ||
|
|
bc22d61a3a | ||
|
|
d709bcc1d0 | ||
|
|
d7088be8af | ||
|
|
f17f1ecd93 | ||
|
|
bf556bd9f4 | ||
|
|
8b40969fa3 | ||
|
|
6cce931a88 | ||
|
|
216861f2c3 | ||
|
|
6ce724afb4 | ||
|
|
2e3e106fb2 | ||
|
|
00f2606a2f | ||
|
|
3c94a44285 | ||
|
|
4c0908acde | ||
|
|
cda27fb7f8 | ||
|
|
b1efbf74c7 | ||
|
|
2de9899a99 |
31
.dockerignore
Normal file
31
.dockerignore
Normal file
@@ -0,0 +1,31 @@
|
||||
# Git history
|
||||
.git/
|
||||
.gitignore
|
||||
|
||||
# Dependencies — re-installed inside Docker
|
||||
node_modules/
|
||||
src/apps/cli/node_modules/
|
||||
|
||||
# Pre-built CLI output — rebuilt inside Docker
|
||||
src/apps/cli/dist/
|
||||
|
||||
# Obsidian plugin build outputs
|
||||
main.js
|
||||
main_org.js
|
||||
pouchdb-browser.js
|
||||
production/
|
||||
|
||||
# Test coverage and reports
|
||||
coverage/
|
||||
|
||||
# Local environment / secrets
|
||||
.env
|
||||
*.env
|
||||
.test.env
|
||||
|
||||
# local config files
|
||||
*.local
|
||||
|
||||
# OS artefacts
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
1
.gitattributes
vendored
Normal file
1
.gitattributes
vendored
Normal file
@@ -0,0 +1 @@
|
||||
*.sh text eol=lf
|
||||
101
.github/workflows/cli-docker.yml
vendored
Normal file
101
.github/workflows/cli-docker.yml
vendored
Normal file
@@ -0,0 +1,101 @@
|
||||
# Build and push the CLI Docker image to GitHub Container Registry (GHCR).#
|
||||
# Image tag format: <manifest-version>-<unix-epoch>-cli
|
||||
# Example: 0.25.56-1743500000-cli
|
||||
#
|
||||
# The image is also tagged 'latest' for convenience.
|
||||
# Image name: ghcr.io/<owner>/livesync-cli
|
||||
name: Build and Push CLI Docker Image
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- "*.*.*-cli"
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
dry_run:
|
||||
description: Build only (do not push image to GHCR)
|
||||
required: false
|
||||
type: boolean
|
||||
default: true
|
||||
force:
|
||||
description: Continue to build/push even if CLI E2E fails (workflow_dispatch only)
|
||||
required: false
|
||||
type: boolean
|
||||
default: false
|
||||
|
||||
jobs:
|
||||
build-and-push:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 90
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
submodules: recursive
|
||||
|
||||
- name: Derive image tag
|
||||
id: meta
|
||||
run: |
|
||||
VERSION=$(jq -r '.version' manifest.json)
|
||||
EPOCH=$(date +%s)
|
||||
TAG="${VERSION}-${EPOCH}-cli"
|
||||
IMAGE="ghcr.io/${{ github.repository_owner }}/livesync-cli"
|
||||
echo "tag=${TAG}" >> $GITHUB_OUTPUT
|
||||
echo "image=${IMAGE}" >> $GITHUB_OUTPUT
|
||||
echo "full=${IMAGE}:${TAG}" >> $GITHUB_OUTPUT
|
||||
echo "version=${IMAGE}:${VERSION}-cli" >> $GITHUB_OUTPUT
|
||||
echo "latest=${IMAGE}:latest" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Log in to GitHub Container Registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "24.x"
|
||||
cache: "npm"
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Run CLI E2E (docker)
|
||||
id: e2e
|
||||
continue-on-error: ${{ github.event_name == 'workflow_dispatch' && inputs.force }}
|
||||
working-directory: src/apps/cli
|
||||
env:
|
||||
CI: true
|
||||
run: npm run test:e2e:docker:all
|
||||
|
||||
- name: Stop test containers (safety net)
|
||||
if: always()
|
||||
working-directory: src/apps/cli
|
||||
run: |
|
||||
# Keep this as a safety net for future suites/steps that may leave containers running.
|
||||
bash ./util/couchdb-stop.sh >/dev/null 2>&1 || true
|
||||
bash ./util/minio-stop.sh >/dev/null 2>&1 || true
|
||||
bash ./util/p2p-stop.sh >/dev/null 2>&1 || true
|
||||
|
||||
- name: Build and push
|
||||
if: ${{ steps.e2e.outcome == 'success' || (github.event_name == 'workflow_dispatch' && inputs.force) }}
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
file: src/apps/cli/Dockerfile
|
||||
push: ${{ !(github.event_name == 'workflow_dispatch' && inputs.dry_run) }}
|
||||
tags: |
|
||||
${{ steps.meta.outputs.full }}
|
||||
${{ steps.meta.outputs.version }}
|
||||
${{ steps.meta.outputs.latest }}
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
206
docs/design_docs_of_remote_configurations.md
Normal file
206
docs/design_docs_of_remote_configurations.md
Normal file
@@ -0,0 +1,206 @@
|
||||
# The design document of remote configuration management
|
||||
|
||||
## Goal
|
||||
|
||||
- Allow us to manage multiple remote connections in a single vault.
|
||||
- Keep the existing synchronisation implementations working without requiring a large rewrite.
|
||||
- Provide a safe migration path from the previous single-remote configuration model.
|
||||
- Allow connections to be imported and exported in a compact and reusable format.
|
||||
|
||||
## Motivation
|
||||
|
||||
Historically, Self-hosted LiveSync stored one effective remote configuration directly in the main settings. This was simple, but it had several limitations.
|
||||
|
||||
- We could only keep one CouchDB, one bucket, or one Peer-to-Peer target as the effective configuration at a time.
|
||||
- Switching between same-type-remotes required manually rewriting the active settings.
|
||||
- Setup URI, QR code, CLI setup, and similar entry points all restored settings differently, which made migration logic easy to miss.
|
||||
- The internal settings shape had gradually become a mix of user-facing settings, transport-specific credentials, and compatibility-oriented values.
|
||||
|
||||
Once multiple remotes of the same type became desirable, the previous model no longer scaled well enough. We therefore needed a structure that could store many remotes, still expose one effective remote to the replication logic, and keep migration and import behaviour consistent.
|
||||
|
||||
## Prerequisite
|
||||
|
||||
- Existing synchronisation features must continue to read an effective remote configuration from the current settings.
|
||||
- Existing vaults must continue to work without requiring manual reconfiguration.
|
||||
- Setup URI, QR code, CLI setup, protocol handlers, and other imported settings must be normalised in the same way.
|
||||
- Import and export must be compact enough to be shared easily.
|
||||
- We must be explicit that exported connection strings may contain credentials or secrets.
|
||||
|
||||
## Outlined methods and implementation plans
|
||||
|
||||
### Abstract
|
||||
|
||||
The current settings now have two layers for remote configuration.
|
||||
|
||||
1. A stored collection of named remotes.
|
||||
2. One active remote projected into the legacy flat settings fields.
|
||||
|
||||
This means the replication and database layers can continue to read the effective remote from the existing settings fields, while the settings dialogue and migration logic can manage many stored remotes.
|
||||
|
||||
In short, the list is the source of truth for saved remotes, and the legacy fields remain the runtime compatibility layer.
|
||||
|
||||
### Data model
|
||||
|
||||
The main settings now contain the following properties.
|
||||
|
||||
```typescript
|
||||
type RemoteConfiguration = {
|
||||
id: string;
|
||||
name: string;
|
||||
uri: string;
|
||||
isEncrypted: boolean;
|
||||
};
|
||||
|
||||
type RemoteConfigurations = {
|
||||
remoteConfigurations: Record<string, RemoteConfiguration>;
|
||||
activeConfigurationId: string;
|
||||
};
|
||||
```
|
||||
|
||||
Each entry stores a connection string in `uri`.
|
||||
|
||||
- `sls+http://` or `sls+https://` for CouchDB-compatible remotes
|
||||
- `sls+s3://` for bucket-style remotes
|
||||
- `sls+p2p://` for Peer-to-Peer remotes
|
||||
|
||||
This structure allows multiple remotes of the same type to be stored without adding a large number of duplicated settings fields.
|
||||
|
||||
### Runtime compatibility
|
||||
|
||||
The replication logic still reads the effective remote from legacy flat settings such as the following.
|
||||
|
||||
- `remoteType`
|
||||
- `couchDB_URI`, `couchDB_USER`, `couchDB_PASSWORD`, `couchDB_DBNAME`
|
||||
- `endpoint`, `bucket`, `accessKey`, `secretKey`, and related bucket fields
|
||||
- `P2P_roomID`, `P2P_passphrase`, and related Peer-to-Peer fields
|
||||
|
||||
When a remote is activated, its connection string is parsed and projected into these legacy fields. Therefore, existing services do not need to know whether the remote came from an old vault, a Setup URI, or the new remote list.
|
||||
|
||||
This projection is intentionally one-way at runtime. The stored remote list is the persistent catalogue, while the flat fields describe the remote currently in use.
|
||||
|
||||
### Connection string format
|
||||
|
||||
The connection string is the transport-neutral storage format for a remote entry.
|
||||
|
||||
Benefits:
|
||||
|
||||
- It is compact enough for clipboard-based workflows.
|
||||
- It can be used for import and export in the settings dialogue.
|
||||
- It avoids introducing a separate serialisation format only for the remote list.
|
||||
- It can be parsed into the legacy settings shape whenever the active remote changes.
|
||||
|
||||
This is not equivalent to Setup URI.
|
||||
|
||||
- Setup URI represents a broader settings transfer workflow.
|
||||
- A remote connection string represents one remote only.
|
||||
|
||||
### Import and export
|
||||
|
||||
The settings dialogue now supports the following workflows.
|
||||
|
||||
- Add connection: create a new remote by using the remote setup dialogues.
|
||||
- Import connection: paste a connection string, validate it, and save it as a named remote.
|
||||
- Export: copy a stored remote connection string to the clipboard.
|
||||
|
||||
Import normalises the string by parsing and serialising it again before saving. This ensures that equivalent but differently formatted URIs are saved in a canonical form.
|
||||
|
||||
Export is intentionally simple. It copies the connection string itself, because this is the most direct representation of one remote entry.
|
||||
|
||||
### Security note
|
||||
|
||||
Connection strings may include credentials, secrets, JWT-related values, or Peer-to-Peer passphrases.
|
||||
|
||||
Therefore:
|
||||
|
||||
- Export is a deliberate clipboard operation.
|
||||
- Import trusts the supplied connection string as-is after parsing.
|
||||
- We should regard exported connection strings as sensitive information, much like Setup URI or a credentials-bearing configuration file.
|
||||
|
||||
The `isEncrypted` field is currently reserved for future expansion. At present, the connection string itself is stored plainly inside the settings data, in the same sense that the effective runtime configuration can contain usable remote credentials.
|
||||
|
||||
### Migration strategy
|
||||
|
||||
Older vaults store only one effective remote in the flat settings fields. The migration creates a first remote list from those values.
|
||||
|
||||
Rules:
|
||||
|
||||
- If no remote list exists and the legacy fields contain a CouchDB configuration, create `legacy-couchdb`.
|
||||
- If no remote list exists and the legacy fields contain a bucket configuration, create `legacy-s3`.
|
||||
- If no remote list exists and the legacy fields contain a Peer-to-Peer configuration, create `legacy-p2p`.
|
||||
- If more than one legacy remote is populated, create all possible entries and select the active one according to `remoteType`.
|
||||
|
||||
This migration is intentionally additive. It does not remove the flat fields because they remain necessary as the active runtime projection.
|
||||
|
||||
### Normalisation and application paths
|
||||
|
||||
One important design lesson from this work is that migration cannot rely only on loading `data.json`.
|
||||
|
||||
Settings may enter the system from several routes:
|
||||
|
||||
- normal settings load
|
||||
- Setup URI
|
||||
- QR code
|
||||
- protocol handler
|
||||
- CLI setup
|
||||
- Peer-to-Peer remote configuration retrieval
|
||||
- red flag based remote adjustment
|
||||
- settings markdown import
|
||||
|
||||
To keep behaviour consistent, normalisation is centralised in the settings service.
|
||||
|
||||
- `adjustSettings` is responsible for in-place normalisation and migration of a settings object.
|
||||
- `applyExternalSettings` is responsible for applying imported or externally supplied settings after passing them through the same normalisation flow.
|
||||
|
||||
This ensures that imported settings can migrate to the current remote list model even if they never passed through the ordinary `loadSettings` path.
|
||||
|
||||
### Why not store only the remote list
|
||||
|
||||
It would be possible to let all consumers parse the active remote every time and stop using the flat fields entirely. However, this would require broader changes across replication, diagnostics, and compatibility layers.
|
||||
|
||||
The current design keeps the change set limited.
|
||||
|
||||
- The remote list improves storage and UX.
|
||||
- The flat fields preserve compatibility and reduce migration risk.
|
||||
|
||||
This is a pragmatic transitional architecture, not an accidental duplication.
|
||||
|
||||
## Test strategy
|
||||
|
||||
The feature should be tested from four viewpoints.
|
||||
|
||||
1. Migration from old settings.
|
||||
- A vault with only legacy flat remote settings should gain a remote list automatically.
|
||||
- The correct active remote should be selected according to `remoteType`.
|
||||
|
||||
2. Runtime activation.
|
||||
- Activating a stored remote should correctly project its values into the effective flat settings.
|
||||
|
||||
3. External import paths.
|
||||
- Setup URI, QR code, CLI setup, Peer-to-Peer remote config, red flag adjustment, and settings markdown import should all pass through the same normalisation path.
|
||||
|
||||
4. Import and export.
|
||||
- Imported connection strings should be parsed, canonicalised, named, and stored correctly.
|
||||
- Export should copy the exact saved connection string.
|
||||
|
||||
## Documentation strategy
|
||||
|
||||
- This document explains the design and compatibility model of remote configuration management.
|
||||
- User-facing setup documents should explain only how to add, import, export, and activate remotes.
|
||||
- Release notes may refer to this document when changes in remote handling are significant.
|
||||
|
||||
## Outlook
|
||||
|
||||
Import/export configuration strings should also be encrypted in the future, but this is a separate feature that can be added on top of the current design.
|
||||
|
||||
## Consideration and conclusion
|
||||
|
||||
The remote configuration list solves the practical need to manage multiple remotes without forcing the whole codebase to abandon the previous effective-settings model at once.
|
||||
|
||||
Its core idea is modest but effective.
|
||||
|
||||
- Store named remotes as connection strings.
|
||||
- Select one active remote.
|
||||
- Project it into the legacy settings for runtime use.
|
||||
- Normalise every imported settings object through the same path.
|
||||
|
||||
This keeps the implementation understandable and migration-friendly. It also opens the door for future work, such as encrypted per-remote storage, richer remote metadata, or remote-scoped options, without forcing another large redesign of how remotes are represented.
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"id": "obsidian-livesync",
|
||||
"name": "Self-hosted LiveSync",
|
||||
"version": "0.25.56",
|
||||
"version": "0.25.56+patched2",
|
||||
"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",
|
||||
|
||||
4170
package-lock.json
generated
4170
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "obsidian-livesync",
|
||||
"version": "0.25.56",
|
||||
"version": "0.25.56+patched2",
|
||||
"description": "Reflect your vault changes to some other devices immediately. Please make sure to disable other synchronize solutions to avoid content corruption or duplication.",
|
||||
"main": "main.js",
|
||||
"type": "module",
|
||||
@@ -132,17 +132,17 @@
|
||||
"@smithy/middleware-apply-body-checksum": "^4.3.9",
|
||||
"@smithy/protocol-http": "^5.3.9",
|
||||
"@smithy/querystring-builder": "^4.2.9",
|
||||
"@trystero-p2p/nostr": "^0.23.0",
|
||||
"commander": "^14.0.3",
|
||||
"diff-match-patch": "^1.0.5",
|
||||
"fflate": "^0.8.2",
|
||||
"idb": "^8.0.3",
|
||||
"markdown-it": "^14.1.1",
|
||||
"minimatch": "^10.2.2",
|
||||
"node-datachannel": "^0.32.1",
|
||||
"octagonal-wheels": "^0.1.45",
|
||||
"pouchdb-adapter-leveldb": "^9.0.0",
|
||||
"qrcode-generator": "^1.4.4",
|
||||
"trystero": "^0.22.0",
|
||||
"werift": "^0.22.9",
|
||||
"xxhash-wasm-102": "npm:xxhash-wasm@^1.0.2"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ import type { CheckPointInfo } from "./lib/src/replication/journal/JournalSyncTy
|
||||
import type { LiveSyncJournalReplicatorEnv } from "./lib/src/replication/journal/LiveSyncJournalReplicatorEnv";
|
||||
import type { LiveSyncReplicatorEnv } from "./lib/src/replication/LiveSyncAbstractReplicator";
|
||||
import { useTargetFilters } from "./lib/src/serviceFeatures/targetFilter";
|
||||
import { useRemoteConfigurationMigration } from "./lib/src/serviceFeatures/remoteConfig";
|
||||
import type { ServiceContext } from "./lib/src/services/base/ServiceBase";
|
||||
import type { InjectableServiceHub } from "./lib/src/services/InjectableServices";
|
||||
import { AbstractModule } from "./modules/AbstractModule";
|
||||
@@ -272,6 +273,8 @@ export class LiveSyncBaseCore<
|
||||
useTargetFilters(this);
|
||||
// enable target filter feature.
|
||||
usePrepareDatabaseForUse(this);
|
||||
// Migration to multiple remote configurations
|
||||
useRemoteConfigurationMigration(this);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
9
src/apps/cli/.gitignore
vendored
9
src/apps/cli/.gitignore
vendored
@@ -1,5 +1,6 @@
|
||||
.livesync
|
||||
test/*
|
||||
!test/*.sh
|
||||
node_modules
|
||||
.livesync
|
||||
test/*
|
||||
!test/*.sh
|
||||
test/test-init.local.sh
|
||||
node_modules
|
||||
.*.json
|
||||
111
src/apps/cli/Dockerfile
Normal file
111
src/apps/cli/Dockerfile
Normal file
@@ -0,0 +1,111 @@
|
||||
# syntax=docker/dockerfile:1
|
||||
#
|
||||
# Self-hosted LiveSync CLI — Docker image
|
||||
#
|
||||
# Build (from the repository root):
|
||||
# docker build -f src/apps/cli/Dockerfile -t livesync-cli .
|
||||
#
|
||||
# Run:
|
||||
# docker run --rm -v /path/to/your/vault:/data livesync-cli sync
|
||||
# docker run --rm -v /path/to/your/vault:/data livesync-cli ls
|
||||
# docker run --rm -v /path/to/your/vault:/data livesync-cli init-settings
|
||||
# docker run --rm -v /path/to/your/vault:/data livesync-cli --help
|
||||
#
|
||||
# The first positional argument (database-path) is automatically set to /data.
|
||||
# Mount your vault at /data, or override with: -e LIVESYNC_DB_PATH=/other/path
|
||||
#
|
||||
# P2P (WebRTC) networking — important notes
|
||||
# -----------------------------------------
|
||||
# The P2P replicator (p2p-host / p2p-sync / p2p-peers) uses WebRTC, which
|
||||
# generates ICE candidates of three kinds:
|
||||
#
|
||||
# host — the container's bridge IP (172.17.x.x). Unreachable from outside
|
||||
# the Docker bridge, so LAN peers cannot connect via this candidate.
|
||||
# srflx — the host's public IP, obtained via STUN reflection. Works fine
|
||||
# over the internet even with the default bridge network.
|
||||
# relay — traffic relayed through a TURN server. Always reachable regardless
|
||||
# of network mode.
|
||||
#
|
||||
# Recommended network modes per use-case:
|
||||
#
|
||||
# LAN P2P (Linux only)
|
||||
# docker run --network host ...
|
||||
# This exposes the real host IP as the 'host' candidate so LAN peers can
|
||||
# connect directly. --network host is not available on Docker Desktop for
|
||||
# macOS or Windows.
|
||||
#
|
||||
# LAN P2P (macOS / Windows Docker Desktop)
|
||||
# Configure a TURN server in settings (P2P_turnServers / P2P_turnUsername /
|
||||
# P2P_turnCredential). All data is then relayed through the TURN server,
|
||||
# bypassing the bridge-network limitation.
|
||||
#
|
||||
# Internet P2P
|
||||
# Default bridge network is sufficient; the srflx candidate carries the
|
||||
# host's public IP and peers can connect normally.
|
||||
#
|
||||
# CouchDB sync only (no P2P)
|
||||
# Default bridge network. No special configuration required.
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# Stage 1 — builder
|
||||
# Full Node.js environment to compile native modules and bundle the CLI.
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
FROM node:22-slim AS builder
|
||||
|
||||
# Build tools required by native Node.js addons (mainly leveldown)
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends python3 make g++ \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
WORKDIR /build
|
||||
|
||||
# Install workspace dependencies first (layer-cache friendly)
|
||||
COPY package.json ./
|
||||
RUN npm install
|
||||
|
||||
# Copy the full source tree and build the CLI bundle
|
||||
COPY . .
|
||||
RUN cd src/apps/cli && npm run build
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# Stage 2 — runtime-deps
|
||||
# Install only the external (unbundled) packages that the CLI requires at
|
||||
# runtime. Native addons are compiled here against the same base image that
|
||||
# the final runtime stage uses.
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
FROM node:22-slim AS runtime-deps
|
||||
|
||||
# Build tools required to compile native addons
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends python3 make g++ \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
WORKDIR /deps
|
||||
|
||||
# runtime-package.json lists only the packages that Vite leaves external
|
||||
COPY src/apps/cli/runtime-package.json ./package.json
|
||||
RUN npm install --omit=dev
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# Stage 3 — runtime
|
||||
# Minimal image: pre-compiled native modules + CLI bundle only.
|
||||
# No build tools are included, keeping the image small.
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
FROM node:22-slim
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Copy pre-compiled external node_modules from runtime-deps stage
|
||||
COPY --from=runtime-deps /deps/node_modules ./node_modules
|
||||
|
||||
# Copy the built CLI bundle from builder stage
|
||||
COPY --from=builder /build/src/apps/cli/dist ./dist
|
||||
|
||||
# Install entrypoint wrapper
|
||||
COPY src/apps/cli/docker-entrypoint.sh /usr/local/bin/livesync-cli
|
||||
RUN chmod +x /usr/local/bin/livesync-cli
|
||||
|
||||
# Mount your vault / local database directory here
|
||||
VOLUME ["/data"]
|
||||
|
||||
ENTRYPOINT ["livesync-cli"]
|
||||
@@ -1,362 +1,420 @@
|
||||
# Self-hosted LiveSync CLI
|
||||
Command-line version of Self-hosted LiveSync plugin for syncing vaults without Obsidian.
|
||||
|
||||
## Features
|
||||
|
||||
- ✅ Sync Obsidian vaults using CouchDB without running Obsidian
|
||||
- ✅ Compatible with Self-hosted LiveSync plugin settings
|
||||
- ✅ Supports all core sync features (encryption, conflict resolution, etc.)
|
||||
- ✅ Lightweight and headless operation
|
||||
- ✅ Cross-platform (Windows, macOS, Linux)
|
||||
|
||||
## Architecture
|
||||
|
||||
This CLI version is built using the same core as the Obsidian plugin:
|
||||
|
||||
```
|
||||
CLI Main
|
||||
└─ LiveSyncBaseCore<ServiceContext, IMinimumLiveSyncCommands>
|
||||
├─ NodeServiceHub (All services without Obsidian dependencies)
|
||||
└─ ServiceModules (wired by initialiseServiceModulesCLI)
|
||||
├─ FileAccessCLI (Node.js FileSystemAdapter)
|
||||
├─ StorageEventManagerCLI
|
||||
├─ ServiceFileAccessCLI
|
||||
├─ ServiceDatabaseFileAccessCLI
|
||||
├─ ServiceFileHandler
|
||||
└─ ServiceRebuilder
|
||||
```
|
||||
|
||||
### Key Components
|
||||
|
||||
1. **Node.js FileSystem Adapter** (`adapters/`)
|
||||
- Platform-agnostic file operations using Node.js `fs/promises`
|
||||
- Implements same interface as Obsidian's file system
|
||||
|
||||
2. **Service Modules** (`serviceModules/`)
|
||||
- Initialised by `initialiseServiceModulesCLI`
|
||||
- All core sync functionality preserved
|
||||
|
||||
3. **Service Hub and Settings Services** (`services/`)
|
||||
- `NodeServiceHub` provides the CLI service context
|
||||
- Node-specific settings and key-value services are provided without Obsidian dependencies
|
||||
|
||||
4. **Main Entry Point** (`main.ts`)
|
||||
- Command-line interface
|
||||
- Settings management (JSON file)
|
||||
- Graceful shutdown handling
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
# Install dependencies (ensure you are in repository root directory, not src/apps/cli)
|
||||
# due to shared dependencies with webapp and main library
|
||||
npm install
|
||||
# Build the project (ensure you are in `src/apps/cli` directory)
|
||||
npm run build
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
### Basic Usage
|
||||
|
||||
As you know, the CLI is designed to be used in a headless environment. Hence all operations are performed against a local vault directory and a settings file. Here are some example commands:
|
||||
|
||||
```bash
|
||||
# Sync local database with CouchDB (no files will be changed).
|
||||
npm run --silent cli -- /path/to/your-local-database --settings /path/to/settings.json sync
|
||||
|
||||
# Push files to local database
|
||||
npm run --silent cli -- /path/to/your-local-database --settings /path/to/settings.json push /your/storage/file.md /vault/path/file.md
|
||||
|
||||
# Pull files from local database
|
||||
npm run --silent cli -- /path/to/your-local-database --settings /path/to/settings.json pull /vault/path/file.md /your/storage/file.md
|
||||
|
||||
# Verbose logging
|
||||
npm run --silent cli -- /path/to/your-local-database --settings /path/to/settings.json --verbose
|
||||
|
||||
# Apply setup URI to settings file (settings only; does not run synchronisation)
|
||||
npm run --silent cli -- /path/to/your-local-database --settings /path/to/settings.json setup "obsidian://setuplivesync?settings=..."
|
||||
|
||||
# Put text from stdin into local database
|
||||
echo "Hello from stdin" | npm run --silent cli -- /path/to/your-local-database --settings /path/to/settings.json put /vault/path/file.md
|
||||
|
||||
# Output a file from local database to stdout
|
||||
npm run --silent cli -- /path/to/your-local-database --settings /path/to/settings.json cat /vault/path/file.md
|
||||
|
||||
# Output a specific revision of a file from local database
|
||||
npm run --silent cli -- /path/to/your-local-database --settings /path/to/settings.json cat-rev /vault/path/file.md 3-abcdef
|
||||
|
||||
# Pull a specific revision of a file from local database to local storage
|
||||
npm run --silent cli -- /path/to/your-local-database --settings /path/to/settings.json pull-rev /vault/path/file.md /your/storage/file.old.md 3-abcdef
|
||||
|
||||
# List files in local database
|
||||
npm run --silent cli -- /path/to/your-local-database --settings /path/to/settings.json ls /vault/path/
|
||||
|
||||
# Show metadata for a file in local database
|
||||
npm run --silent cli -- /path/to/your-local-database --settings /path/to/settings.json info /vault/path/file.md
|
||||
|
||||
# Mark a file as deleted in local database
|
||||
npm run --silent cli -- /path/to/your-local-database --settings /path/to/settings.json rm /vault/path/file.md
|
||||
|
||||
# Resolve conflict by keeping a specific revision
|
||||
npm run --silent cli -- /path/to/your-local-database --settings /path/to/settings.json resolve /vault/path/file.md 3-abcdef
|
||||
```
|
||||
|
||||
### Configuration
|
||||
|
||||
The CLI uses the same settings format as the Obsidian plugin. Create a `.livesync/settings.json` file in your vault directory:
|
||||
|
||||
```json
|
||||
{
|
||||
"couchDB_URI": "http://localhost:5984",
|
||||
"couchDB_USER": "admin",
|
||||
"couchDB_PASSWORD": "password",
|
||||
"couchDB_DBNAME": "obsidian-livesync",
|
||||
"liveSync": true,
|
||||
"syncOnSave": true,
|
||||
"syncOnStart": true,
|
||||
"encrypt": true,
|
||||
"passphrase": "your-encryption-passphrase",
|
||||
"usePluginSync": false,
|
||||
"isConfigured": true
|
||||
}
|
||||
```
|
||||
|
||||
**Minimum required settings:**
|
||||
|
||||
- `couchDB_URI`: CouchDB server URL
|
||||
- `couchDB_USER`: CouchDB username
|
||||
- `couchDB_PASSWORD`: CouchDB password
|
||||
- `couchDB_DBNAME`: Database name
|
||||
- `isConfigured`: Set to `true` after configuration
|
||||
|
||||
### Command-line Reference
|
||||
|
||||
```
|
||||
Usage:
|
||||
livesync-cli [database-path] [options] [command] [command-args]
|
||||
|
||||
Arguments:
|
||||
database-path Path to the local database directory (required except for init-settings)
|
||||
|
||||
Options:
|
||||
--settings, -s <path> Path to settings file (default: .livesync/settings.json in local database directory)
|
||||
--force, -f Overwrite existing file on init-settings
|
||||
--verbose, -v Enable verbose logging
|
||||
--help, -h Show this help message
|
||||
|
||||
Commands:
|
||||
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>
|
||||
p2p-sync <peer> <timeout> Synchronise with specified peer-id or peer-name
|
||||
p2p-host Start P2P host mode and wait until interrupted (Ctrl+C)
|
||||
push <src> <dst> Push local file <src> into local database path <dst>
|
||||
pull <src> <dst> Pull file <src> from local database into local file <dst>
|
||||
pull-rev <src> <dst> <revision> Pull specific revision into local file <dst>
|
||||
setup <setupURI> Apply setup URI to settings file
|
||||
put <vaultPath> Read text from standard input and write to local database
|
||||
cat <vaultPath> Write latest file content from local database to standard output
|
||||
cat-rev <vaultPath> <revision> Write specific revision content from local database to standard output
|
||||
ls [prefix] List files as path<TAB>size<TAB>mtime<TAB>revision[*]
|
||||
info <vaultPath> Show file metadata including current and past revisions, conflicts, and chunk list
|
||||
rm <vaultPath> Mark file as deleted in local database
|
||||
resolve <vaultPath> <revision> Resolve conflict by keeping the specified revision
|
||||
mirror <storagePath> <vaultPath> Mirror local file into local database.
|
||||
```
|
||||
|
||||
Run via npm script:
|
||||
|
||||
```bash
|
||||
npm run --silent cli -- [database-path] [options] [command] [command-args]
|
||||
```
|
||||
|
||||
#### Detailed Command Descriptions
|
||||
|
||||
##### ls
|
||||
`ls` lists files in the local database with optional prefix filtering. Output format is:
|
||||
|
||||
```vault/path/file.md<TAB>size<TAB>mtime<TAB>revision[*]
|
||||
```
|
||||
Note: `*` indicates if the file has conflicts.
|
||||
|
||||
##### p2p-peers
|
||||
|
||||
`p2p-peers <timeout>` waits for the specified number of seconds, then prints each discovered peer on a separate line:
|
||||
|
||||
```text
|
||||
[peer]<TAB><peer-id><TAB><peer-name>
|
||||
```
|
||||
|
||||
Use this command to select a target for `p2p-sync`.
|
||||
|
||||
##### p2p-sync
|
||||
|
||||
`p2p-sync <peer> <timeout>` discovers peers up to the specified timeout and synchronises with the selected peer.
|
||||
|
||||
- `<peer>` accepts either `peer-id` or `peer-name` from `p2p-peers` output.
|
||||
- On success, the command prints a completion message to standard error and exits with status code `0`.
|
||||
- On failure, the command prints an error message and exits non-zero.
|
||||
|
||||
##### p2p-host
|
||||
|
||||
`p2p-host` starts the local P2P host and keeps running until interrupted.
|
||||
|
||||
- Other peers can discover and synchronise with this host while it is running.
|
||||
- Stop the host with `Ctrl+C`.
|
||||
- In CLI mode, behaviour is non-interactive and acceptance follows settings.
|
||||
|
||||
##### info
|
||||
|
||||
`info` output fields:
|
||||
|
||||
- `id`: Document ID
|
||||
- `revision`: Current revision
|
||||
- `conflicts`: Conflicted revisions, or `N/A`
|
||||
- `filename`: Basename of path
|
||||
- `path`: Vault-relative path
|
||||
- `size`: Size in bytes
|
||||
- `revisions`: Available non-current revisions
|
||||
- `chunks`: Number of chunk IDs
|
||||
- `children`: Chunk ID list
|
||||
|
||||
##### mirror
|
||||
|
||||
`mirror` is a command that synchronises your storage with your local vault. It is essentially a process that runs upon startup in Obsidian.
|
||||
|
||||
In other words, it performs the following actions:
|
||||
|
||||
1. **Precondition checks** — Aborts early if any of the following conditions are not met:
|
||||
- Settings must be configured (`isConfigured: true`).
|
||||
- File watching must not be suspended (`suspendFileWatching: false`).
|
||||
- Remediation mode must be inactive (`maxMTimeForReflectEvents: 0`).
|
||||
|
||||
2. **State restoration** — On subsequent runs (after the first successful scan), restores the previous storage state before proceeding.
|
||||
|
||||
3. **Expired deletion cleanup** — If `automaticallyDeleteMetadataOfDeletedFiles` is set to a positive number of days, any document that is marked deleted and whose `mtime` is older than the retention period is permanently removed from the local database.
|
||||
|
||||
4. **File collection** — Enumerates files from two sources:
|
||||
- **Storage**: all files under the vault path that pass `isTargetFile`.
|
||||
- **Local database**: all normal documents (fetched with conflict information) whose paths are valid and pass `isTargetFile`.
|
||||
- Both collections build case-insensitive ↔ case-sensitive path maps, controlled by `handleFilenameCaseSensitive`.
|
||||
|
||||
5. **Categorisation and synchronisation** — The union of both file sets is split into three groups and processed concurrently (up to 10 files at a time):
|
||||
|
||||
| Group | Condition | Action |
|
||||
|---|---|---|
|
||||
| **UPDATE DATABASE** | File exists in storage only | Store the file into the local database. |
|
||||
| **UPDATE STORAGE** | File exists in database only | If the entry is active (not deleted) and not conflicted, restore the file from the database to storage. Deleted entries and conflicted entries are skipped. |
|
||||
| **SYNC DATABASE AND STORAGE** | File exists in both | Compare `mtime` freshness. If storage is newer → write to database (`STORAGE → DB`). If database is newer → restore to storage (`STORAGE ← DB`). If equal → do nothing. Conflicted documents and files exceeding the size limit are always skipped. |
|
||||
|
||||
6. **Initialisation flag** — On the very first successful run, writes `initialized = true` to the key-value database so that subsequent runs can restore state in step 2.
|
||||
|
||||
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).
|
||||
|
||||
### Planned options:
|
||||
|
||||
- `--immediate`: Perform sync after the command (e.g. `push`, `pull`, `put`, `rm`).
|
||||
- `serve`: Start CLI in server mode, exposing REST APIs for remote, and batch operations.
|
||||
- `cause-conflicted <vaultPath>`: Mark a file as conflicted without changing its content, to trigger conflict resolution in Obsidian.
|
||||
|
||||
## Use Cases
|
||||
|
||||
### 1. Bootstrap a new headless vault
|
||||
|
||||
Create default settings, apply a setup URI, then run one sync cycle.
|
||||
|
||||
```bash
|
||||
npm run --silent cli -- init-settings /data/livesync-settings.json
|
||||
printf '%s\n' "$SETUP_PASSPHRASE" | npm run --silent cli -- /data/vault --settings /data/livesync-settings.json setup "$SETUP_URI"
|
||||
npm run --silent cli -- /data/vault --settings /data/livesync-settings.json sync
|
||||
```
|
||||
|
||||
### 2. Scripted import and export
|
||||
|
||||
Push local files into the database from automation, and pull them back for export or backup.
|
||||
|
||||
```bash
|
||||
npm run --silent cli -- /data/vault --settings /data/livesync-settings.json push ./note.md notes/note.md
|
||||
npm run --silent cli -- /data/vault --settings /data/livesync-settings.json pull notes/note.md ./exports/note.md
|
||||
```
|
||||
|
||||
### 3. Revision inspection and restore
|
||||
|
||||
List metadata, find an older revision, then restore it by content (`cat-rev`) or file output (`pull-rev`).
|
||||
|
||||
```bash
|
||||
npm run --silent cli -- /data/vault --settings /data/livesync-settings.json info notes/note.md
|
||||
npm run --silent cli -- /data/vault --settings /data/livesync-settings.json cat-rev notes/note.md 3-abcdef
|
||||
npm run --silent cli -- /data/vault --settings /data/livesync-settings.json pull-rev notes/note.md ./restore/note.old.md 3-abcdef
|
||||
```
|
||||
|
||||
### 4. Conflict and cleanup workflow
|
||||
|
||||
Inspect conflicted revisions, resolve by keeping one revision, then delete obsolete files.
|
||||
|
||||
```bash
|
||||
npm run --silent cli -- /data/vault --settings /data/livesync-settings.json info notes/note.md
|
||||
npm run --silent cli -- /data/vault --settings /data/livesync-settings.json resolve notes/note.md 3-abcdef
|
||||
npm run --silent cli -- /data/vault --settings /data/livesync-settings.json rm notes/obsolete.md
|
||||
```
|
||||
|
||||
### 5. CI smoke test for content round-trip
|
||||
|
||||
Validate that `put`/`cat` is behaving as expected in a pipeline.
|
||||
|
||||
```bash
|
||||
echo "hello-ci" | npm run --silent cli -- /data/vault --settings /data/livesync-settings.json put ci/test.md
|
||||
npm run --silent cli -- /data/vault --settings /data/livesync-settings.json cat ci/test.md
|
||||
```
|
||||
|
||||
## Development
|
||||
|
||||
### Project Structure
|
||||
|
||||
```
|
||||
src/apps/cli/
|
||||
├── commands/ # Command dispatcher and command utilities
|
||||
│ ├── runCommand.ts
|
||||
│ ├── runCommand.unit.spec.ts
|
||||
│ ├── types.ts
|
||||
│ ├── utils.ts
|
||||
│ └── utils.unit.spec.ts
|
||||
├── adapters/ # Node.js FileSystem Adapter
|
||||
│ ├── NodeConversionAdapter.ts
|
||||
│ ├── NodeFileSystemAdapter.ts
|
||||
│ ├── NodePathAdapter.ts
|
||||
│ ├── NodeStorageAdapter.ts
|
||||
│ ├── NodeStorageAdapter.unit.spec.ts
|
||||
│ ├── NodeTypeGuardAdapter.ts
|
||||
│ ├── NodeTypes.ts
|
||||
│ └── NodeVaultAdapter.ts
|
||||
├── lib/
|
||||
│ └── pouchdb-node.ts
|
||||
├── managers/ # CLI-specific managers
|
||||
│ ├── CLIStorageEventManagerAdapter.ts
|
||||
│ └── StorageEventManagerCLI.ts
|
||||
├── serviceModules/ # Service modules (ported from main.ts)
|
||||
│ ├── CLIServiceModules.ts
|
||||
│ ├── DatabaseFileAccess.ts
|
||||
│ ├── FileAccessCLI.ts
|
||||
│ └── ServiceFileAccessImpl.ts
|
||||
├── services/
|
||||
│ ├── NodeKeyValueDBService.ts
|
||||
│ ├── NodeServiceHub.ts
|
||||
│ └── NodeSettingService.ts
|
||||
├── test/
|
||||
│ ├── test-e2e-two-vaults-common.sh
|
||||
│ ├── test-e2e-two-vaults-matrix.sh
|
||||
│ ├── test-e2e-two-vaults-with-docker-linux.sh
|
||||
│ ├── test-push-pull-linux.sh
|
||||
│ ├── test-setup-put-cat-linux.sh
|
||||
│ └── test-sync-two-local-databases-linux.sh
|
||||
├── .gitignore
|
||||
├── entrypoint.ts # CLI executable entry point (shebang)
|
||||
├── main.ts # CLI entry point
|
||||
├── main.unit.spec.ts
|
||||
├── package.json
|
||||
├── README.md # This file
|
||||
├── tsconfig.json
|
||||
├── util/ # Test and local utility scripts
|
||||
└── vite.config.ts
|
||||
```
|
||||
# Self-hosted LiveSync CLI
|
||||
Command-line version of Self-hosted LiveSync plugin for syncing vaults without Obsidian.
|
||||
|
||||
## Features
|
||||
|
||||
- ✅ Sync Obsidian vaults using CouchDB without running Obsidian
|
||||
- ✅ Compatible with Self-hosted LiveSync plugin settings
|
||||
- ✅ Supports all core sync features (encryption, conflict resolution, etc.)
|
||||
- ✅ Lightweight and headless operation
|
||||
- ✅ Cross-platform (Windows, macOS, Linux)
|
||||
|
||||
## Architecture
|
||||
|
||||
This CLI version is built using the same core as the Obsidian plugin:
|
||||
|
||||
```
|
||||
CLI Main
|
||||
└─ LiveSyncBaseCore<ServiceContext, IMinimumLiveSyncCommands>
|
||||
├─ NodeServiceHub (All services without Obsidian dependencies)
|
||||
└─ ServiceModules (wired by initialiseServiceModulesCLI)
|
||||
├─ FileAccessCLI (Node.js FileSystemAdapter)
|
||||
├─ StorageEventManagerCLI
|
||||
├─ ServiceFileAccessCLI
|
||||
├─ ServiceDatabaseFileAccessCLI
|
||||
├─ ServiceFileHandler
|
||||
└─ ServiceRebuilder
|
||||
```
|
||||
|
||||
### Key Components
|
||||
|
||||
1. **Node.js FileSystem Adapter** (`adapters/`)
|
||||
- Platform-agnostic file operations using Node.js `fs/promises`
|
||||
- Implements same interface as Obsidian's file system
|
||||
|
||||
2. **Service Modules** (`serviceModules/`)
|
||||
- Initialised by `initialiseServiceModulesCLI`
|
||||
- All core sync functionality preserved
|
||||
|
||||
3. **Service Hub and Settings Services** (`services/`)
|
||||
- `NodeServiceHub` provides the CLI service context
|
||||
- Node-specific settings and key-value services are provided without Obsidian dependencies
|
||||
|
||||
4. **Main Entry Point** (`main.ts`)
|
||||
- Command-line interface
|
||||
- Settings management (JSON file)
|
||||
- Graceful shutdown handling
|
||||
|
||||
## Something I realised later that could lead to misunderstandings
|
||||
|
||||
The term `vault` in this README refers to the directory containing your local database and settings file. Not the actual files you want to sync. I will fix this later, but please be mind this for now.
|
||||
|
||||
## 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 .
|
||||
```
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
# Sync with CouchDB
|
||||
docker run --rm -v /path/to/your/vault:/data livesync-cli sync
|
||||
|
||||
# List files in the local database
|
||||
docker run --rm -v /path/to/your/vault:/data livesync-cli ls
|
||||
|
||||
# Generate a default settings file
|
||||
docker run --rm -v /path/to/your/vault:/data livesync-cli init-settings
|
||||
```
|
||||
|
||||
The vault directory is mounted at `/data` by default. Override with `-e LIVESYNC_DB_PATH=/other/path`.
|
||||
|
||||
### P2P (WebRTC) and Docker networking
|
||||
|
||||
The P2P replicator (`p2p-host`, `p2p-sync`, `p2p-peers`) uses WebRTC and generates
|
||||
three kinds of ICE candidates. The default Docker bridge network affects which
|
||||
candidates are usable:
|
||||
|
||||
| Candidate type | Description | Bridge network |
|
||||
|---|---|---|
|
||||
| `host` | Container bridge IP (`172.17.x.x`) | Unreachable from LAN peers |
|
||||
| `srflx` | Host public IP via STUN reflection | Works over the internet |
|
||||
| `relay` | Traffic relayed via TURN server | Always reachable |
|
||||
|
||||
**LAN P2P on Linux** — use `--network host` so that the real host IP is
|
||||
advertised as the `host` candidate:
|
||||
|
||||
```bash
|
||||
docker run --rm --network host -v /path/to/your/vault:/data livesync-cli p2p-host
|
||||
```
|
||||
|
||||
> `--network host` is not available on Docker Desktop for macOS or Windows.
|
||||
|
||||
**LAN P2P on macOS / Windows Docker Desktop** — configure a TURN server in the
|
||||
settings file (`P2P_turnServers`, `P2P_turnUsername`, `P2P_turnCredential`).
|
||||
All P2P traffic will then be relayed through the TURN server, bypassing the
|
||||
bridge-network limitation.
|
||||
|
||||
**Internet P2P** — the default bridge network is sufficient. The `srflx`
|
||||
candidate carries the host's public IP and peers can connect normally.
|
||||
|
||||
**CouchDB sync only (no P2P)** — no special network configuration is required.
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
# Install dependencies (ensure you are in repository root directory, not src/apps/cli)
|
||||
# due to shared dependencies with webapp and main library
|
||||
npm install
|
||||
# Build the project (ensure you are in `src/apps/cli` directory)
|
||||
npm run build
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
### Basic Usage
|
||||
|
||||
As you know, the CLI is designed to be used in a headless environment. Hence all operations are performed against a local vault directory and a settings file. Here are some example commands:
|
||||
|
||||
```bash
|
||||
# Sync local database with CouchDB (no files will be changed).
|
||||
npm run --silent cli -- /path/to/your-local-database --settings /path/to/settings.json sync
|
||||
|
||||
# Push files to local database
|
||||
npm run --silent cli -- /path/to/your-local-database --settings /path/to/settings.json push /your/storage/file.md /vault/path/file.md
|
||||
|
||||
# Pull files from local database
|
||||
npm run --silent cli -- /path/to/your-local-database --settings /path/to/settings.json pull /vault/path/file.md /your/storage/file.md
|
||||
|
||||
# Verbose logging
|
||||
npm run --silent cli -- /path/to/your-local-database --settings /path/to/settings.json --verbose
|
||||
|
||||
# Apply setup URI to settings file (settings only; does not run synchronisation)
|
||||
npm run --silent cli -- /path/to/your-local-database --settings /path/to/settings.json setup "obsidian://setuplivesync?settings=..."
|
||||
|
||||
# Put text from stdin into local database
|
||||
echo "Hello from stdin" | npm run --silent cli -- /path/to/your-local-database --settings /path/to/settings.json put /vault/path/file.md
|
||||
|
||||
# Output a file from local database to stdout
|
||||
npm run --silent cli -- /path/to/your-local-database --settings /path/to/settings.json cat /vault/path/file.md
|
||||
|
||||
# Output a specific revision of a file from local database
|
||||
npm run --silent cli -- /path/to/your-local-database --settings /path/to/settings.json cat-rev /vault/path/file.md 3-abcdef
|
||||
|
||||
# Pull a specific revision of a file from local database to local storage
|
||||
npm run --silent cli -- /path/to/your-local-database --settings /path/to/settings.json pull-rev /vault/path/file.md /your/storage/file.old.md 3-abcdef
|
||||
|
||||
# List files in local database
|
||||
npm run --silent cli -- /path/to/your-local-database --settings /path/to/settings.json ls /vault/path/
|
||||
|
||||
# Show metadata for a file in local database
|
||||
npm run --silent cli -- /path/to/your-local-database --settings /path/to/settings.json info /vault/path/file.md
|
||||
|
||||
# Mark a file as deleted in local database
|
||||
npm run --silent cli -- /path/to/your-local-database --settings /path/to/settings.json rm /vault/path/file.md
|
||||
|
||||
# Resolve conflict by keeping a specific revision
|
||||
npm run --silent cli -- /path/to/your-local-database --settings /path/to/settings.json resolve /vault/path/file.md 3-abcdef
|
||||
```
|
||||
|
||||
### Configuration
|
||||
|
||||
The CLI uses the same settings format as the Obsidian plugin. Create a `.livesync/settings.json` file in your vault directory:
|
||||
|
||||
```json
|
||||
{
|
||||
"couchDB_URI": "http://localhost:5984",
|
||||
"couchDB_USER": "admin",
|
||||
"couchDB_PASSWORD": "password",
|
||||
"couchDB_DBNAME": "obsidian-livesync",
|
||||
"liveSync": true,
|
||||
"syncOnSave": true,
|
||||
"syncOnStart": true,
|
||||
"encrypt": true,
|
||||
"passphrase": "your-encryption-passphrase",
|
||||
"usePluginSync": false,
|
||||
"isConfigured": true
|
||||
}
|
||||
```
|
||||
|
||||
**Minimum required settings:**
|
||||
|
||||
- `couchDB_URI`: CouchDB server URL
|
||||
- `couchDB_USER`: CouchDB username
|
||||
- `couchDB_PASSWORD`: CouchDB password
|
||||
- `couchDB_DBNAME`: Database name
|
||||
- `isConfigured`: Set to `true` after configuration
|
||||
|
||||
### Command-line Reference
|
||||
|
||||
```
|
||||
Usage:
|
||||
livesync-cli [database-path] [options] [command] [command-args]
|
||||
|
||||
Arguments:
|
||||
database-path Path to the local database directory (required except for init-settings)
|
||||
|
||||
Options:
|
||||
--settings, -s <path> Path to settings file (default: .livesync/settings.json in local database directory)
|
||||
--force, -f Overwrite existing file on init-settings
|
||||
--verbose, -v Enable verbose logging
|
||||
--help, -h Show this help message
|
||||
|
||||
Commands:
|
||||
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>
|
||||
p2p-sync <peer> <timeout> Synchronise with specified peer-id or peer-name
|
||||
p2p-host Start P2P host mode and wait until interrupted (Ctrl+C)
|
||||
push <src> <dst> Push local file <src> into local database path <dst>
|
||||
pull <src> <dst> Pull file <src> from local database into local file <dst>
|
||||
pull-rev <src> <dst> <revision> Pull specific revision into local file <dst>
|
||||
setup <setupURI> Apply setup URI to settings file
|
||||
put <vaultPath> Read text from standard input and write to local database
|
||||
cat <vaultPath> Write latest file content from local database to standard output
|
||||
cat-rev <vaultPath> <revision> Write specific revision content from local database to standard output
|
||||
ls [prefix] List files as path<TAB>size<TAB>mtime<TAB>revision[*]
|
||||
info <vaultPath> Show file metadata including current and past revisions, conflicts, and chunk list
|
||||
rm <vaultPath> Mark file as deleted in local database
|
||||
resolve <vaultPath> <revision> Resolve conflict by keeping the specified revision
|
||||
mirror <storagePath> <vaultPath> Mirror local file into local database.
|
||||
```
|
||||
|
||||
Run via npm script:
|
||||
|
||||
```bash
|
||||
npm run --silent cli -- [database-path] [options] [command] [command-args]
|
||||
```
|
||||
|
||||
#### Detailed Command Descriptions
|
||||
|
||||
##### ls
|
||||
`ls` lists files in the local database with optional prefix filtering. Output format is:
|
||||
|
||||
```vault/path/file.md<TAB>size<TAB>mtime<TAB>revision[*]
|
||||
```
|
||||
Note: `*` indicates if the file has conflicts.
|
||||
|
||||
##### p2p-peers
|
||||
|
||||
`p2p-peers <timeout>` waits for the specified number of seconds, then prints each discovered peer on a separate line:
|
||||
|
||||
```text
|
||||
[peer]<TAB><peer-id><TAB><peer-name>
|
||||
```
|
||||
|
||||
Use this command to select a target for `p2p-sync`.
|
||||
|
||||
##### p2p-sync
|
||||
|
||||
`p2p-sync <peer> <timeout>` discovers peers up to the specified timeout and synchronises with the selected peer.
|
||||
|
||||
- `<peer>` accepts either `peer-id` or `peer-name` from `p2p-peers` output.
|
||||
- On success, the command prints a completion message to standard error and exits with status code `0`.
|
||||
- On failure, the command prints an error message and exits non-zero.
|
||||
|
||||
##### p2p-host
|
||||
|
||||
`p2p-host` starts the local P2P host and keeps running until interrupted.
|
||||
|
||||
- Other peers can discover and synchronise with this host while it is running.
|
||||
- Stop the host with `Ctrl+C`.
|
||||
- In CLI mode, behaviour is non-interactive and acceptance follows settings.
|
||||
|
||||
##### info
|
||||
|
||||
`info` output fields:
|
||||
|
||||
- `id`: Document ID
|
||||
- `revision`: Current revision
|
||||
- `conflicts`: Conflicted revisions, or `N/A`
|
||||
- `filename`: Basename of path
|
||||
- `path`: Vault-relative path
|
||||
- `size`: Size in bytes
|
||||
- `revisions`: Available non-current revisions
|
||||
- `chunks`: Number of chunk IDs
|
||||
- `children`: Chunk ID list
|
||||
|
||||
##### mirror
|
||||
|
||||
`mirror` is a command that synchronises your storage with your local vault. It is essentially a process that runs upon startup in Obsidian.
|
||||
|
||||
In other words, it performs the following actions:
|
||||
|
||||
1. **Precondition checks** — Aborts early if any of the following conditions are not met:
|
||||
- Settings must be configured (`isConfigured: true`).
|
||||
- File watching must not be suspended (`suspendFileWatching: false`).
|
||||
- Remediation mode must be inactive (`maxMTimeForReflectEvents: 0`).
|
||||
|
||||
2. **State restoration** — On subsequent runs (after the first successful scan), restores the previous storage state before proceeding.
|
||||
|
||||
3. **Expired deletion cleanup** — If `automaticallyDeleteMetadataOfDeletedFiles` is set to a positive number of days, any document that is marked deleted and whose `mtime` is older than the retention period is permanently removed from the local database.
|
||||
|
||||
4. **File collection** — Enumerates files from two sources:
|
||||
- **Storage**: all files under the vault path that pass `isTargetFile`.
|
||||
- **Local database**: all normal documents (fetched with conflict information) whose paths are valid and pass `isTargetFile`.
|
||||
- Both collections build case-insensitive ↔ case-sensitive path maps, controlled by `handleFilenameCaseSensitive`.
|
||||
|
||||
5. **Categorisation and synchronisation** — The union of both file sets is split into three groups and processed concurrently (up to 10 files at a time):
|
||||
|
||||
| Group | Condition | Action |
|
||||
|---|---|---|
|
||||
| **UPDATE DATABASE** | File exists in storage only | Store the file into the local database. |
|
||||
| **UPDATE STORAGE** | File exists in database only | If the entry is active (not deleted) and not conflicted, restore the file from the database to storage. Deleted entries and conflicted entries are skipped. |
|
||||
| **SYNC DATABASE AND STORAGE** | File exists in both | Compare `mtime` freshness. If storage is newer → write to database (`STORAGE → DB`). If database is newer → restore to storage (`STORAGE ← DB`). If equal → do nothing. Conflicted documents and files exceeding the size limit are always skipped. |
|
||||
|
||||
6. **Initialisation flag** — On the very first successful run, writes `initialized = true` to the key-value database so that subsequent runs can restore state in step 2.
|
||||
|
||||
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).
|
||||
|
||||
### Planned options:
|
||||
|
||||
- `--immediate`: Perform sync after the command (e.g. `push`, `pull`, `put`, `rm`).
|
||||
- `serve`: Start CLI in server mode, exposing REST APIs for remote, and batch operations.
|
||||
- `cause-conflicted <vaultPath>`: Mark a file as conflicted without changing its content, to trigger conflict resolution in Obsidian.
|
||||
|
||||
## Use Cases
|
||||
|
||||
### 1. Bootstrap a new headless vault
|
||||
|
||||
Create default settings, apply a setup URI, then run one sync cycle.
|
||||
|
||||
```bash
|
||||
npm run --silent cli -- init-settings /data/livesync-settings.json
|
||||
printf '%s\n' "$SETUP_PASSPHRASE" | npm run --silent cli -- /data/vault --settings /data/livesync-settings.json setup "$SETUP_URI"
|
||||
npm run --silent cli -- /data/vault --settings /data/livesync-settings.json sync
|
||||
```
|
||||
|
||||
### 2. Scripted import and export
|
||||
|
||||
Push local files into the database from automation, and pull them back for export or backup.
|
||||
|
||||
```bash
|
||||
npm run --silent cli -- /data/vault --settings /data/livesync-settings.json push ./note.md notes/note.md
|
||||
npm run --silent cli -- /data/vault --settings /data/livesync-settings.json pull notes/note.md ./exports/note.md
|
||||
```
|
||||
|
||||
### 3. Revision inspection and restore
|
||||
|
||||
List metadata, find an older revision, then restore it by content (`cat-rev`) or file output (`pull-rev`).
|
||||
|
||||
```bash
|
||||
npm run --silent cli -- /data/vault --settings /data/livesync-settings.json info notes/note.md
|
||||
npm run --silent cli -- /data/vault --settings /data/livesync-settings.json cat-rev notes/note.md 3-abcdef
|
||||
npm run --silent cli -- /data/vault --settings /data/livesync-settings.json pull-rev notes/note.md ./restore/note.old.md 3-abcdef
|
||||
```
|
||||
|
||||
### 4. Conflict and cleanup workflow
|
||||
|
||||
Inspect conflicted revisions, resolve by keeping one revision, then delete obsolete files.
|
||||
|
||||
```bash
|
||||
npm run --silent cli -- /data/vault --settings /data/livesync-settings.json info notes/note.md
|
||||
npm run --silent cli -- /data/vault --settings /data/livesync-settings.json resolve notes/note.md 3-abcdef
|
||||
npm run --silent cli -- /data/vault --settings /data/livesync-settings.json rm notes/obsolete.md
|
||||
```
|
||||
|
||||
### 5. CI smoke test for content round-trip
|
||||
|
||||
Validate that `put`/`cat` is behaving as expected in a pipeline.
|
||||
|
||||
```bash
|
||||
echo "hello-ci" | npm run --silent cli -- /data/vault --settings /data/livesync-settings.json put ci/test.md
|
||||
npm run --silent cli -- /data/vault --settings /data/livesync-settings.json cat ci/test.md
|
||||
```
|
||||
|
||||
## Development
|
||||
|
||||
### Project Structure
|
||||
|
||||
```
|
||||
src/apps/cli/
|
||||
├── commands/ # Command dispatcher and command utilities
|
||||
│ ├── runCommand.ts
|
||||
│ ├── runCommand.unit.spec.ts
|
||||
│ ├── types.ts
|
||||
│ ├── utils.ts
|
||||
│ └── utils.unit.spec.ts
|
||||
├── adapters/ # Node.js FileSystem Adapter
|
||||
│ ├── NodeConversionAdapter.ts
|
||||
│ ├── NodeFileSystemAdapter.ts
|
||||
│ ├── NodePathAdapter.ts
|
||||
│ ├── NodeStorageAdapter.ts
|
||||
│ ├── NodeStorageAdapter.unit.spec.ts
|
||||
│ ├── NodeTypeGuardAdapter.ts
|
||||
│ ├── NodeTypes.ts
|
||||
│ └── NodeVaultAdapter.ts
|
||||
├── lib/
|
||||
│ └── pouchdb-node.ts
|
||||
├── managers/ # CLI-specific managers
|
||||
│ ├── CLIStorageEventManagerAdapter.ts
|
||||
│ └── StorageEventManagerCLI.ts
|
||||
├── serviceModules/ # Service modules (ported from main.ts)
|
||||
│ ├── CLIServiceModules.ts
|
||||
│ ├── DatabaseFileAccess.ts
|
||||
│ ├── FileAccessCLI.ts
|
||||
│ └── ServiceFileAccessImpl.ts
|
||||
├── services/
|
||||
│ ├── NodeKeyValueDBService.ts
|
||||
│ ├── NodeServiceHub.ts
|
||||
│ └── NodeSettingService.ts
|
||||
├── test/
|
||||
│ ├── test-e2e-two-vaults-common.sh
|
||||
│ ├── test-e2e-two-vaults-matrix.sh
|
||||
│ ├── test-e2e-two-vaults-with-docker-linux.sh
|
||||
│ ├── test-push-pull-linux.sh
|
||||
│ ├── test-setup-put-cat-linux.sh
|
||||
│ └── test-sync-two-local-databases-linux.sh
|
||||
├── .gitignore
|
||||
├── entrypoint.ts # CLI executable entry point (shebang)
|
||||
├── main.ts # CLI entry point
|
||||
├── main.unit.spec.ts
|
||||
├── package.json
|
||||
├── README.md # This file
|
||||
├── tsconfig.json
|
||||
├── util/ # Test and local utility scripts
|
||||
└── vite.config.ts
|
||||
```
|
||||
|
||||
@@ -166,7 +166,7 @@ export async function runCommand(options: CLIOptions, context: CLICommandContext
|
||||
} as ObsidianLiveSyncSettings;
|
||||
|
||||
console.log(`[Command] setup -> ${settingsPath}`);
|
||||
await core.services.setting.applyPartial(nextSettings, true);
|
||||
await core.services.setting.applyExternalSettings(nextSettings, true);
|
||||
await core.services.control.applySettings();
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ function createCoreMock() {
|
||||
applySettings: vi.fn(async () => {}),
|
||||
},
|
||||
setting: {
|
||||
applyExternalSettings: vi.fn(async () => {}),
|
||||
applyPartial: vi.fn(async () => {}),
|
||||
},
|
||||
},
|
||||
@@ -176,9 +177,9 @@ describe("runCommand abnormal cases", () => {
|
||||
});
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(core.services.setting.applyPartial).toHaveBeenCalledTimes(1);
|
||||
expect(core.services.setting.applyExternalSettings).toHaveBeenCalledTimes(1);
|
||||
expect(core.services.control.applySettings).toHaveBeenCalledTimes(1);
|
||||
const [appliedSettings, saveImmediately] = core.services.setting.applyPartial.mock.calls[0];
|
||||
const [appliedSettings, saveImmediately] = core.services.setting.applyExternalSettings.mock.calls[0];
|
||||
expect(saveImmediately).toBe(true);
|
||||
expect(appliedSettings.couchDB_URI).toBe("http://127.0.0.1:5984");
|
||||
expect(appliedSettings.couchDB_DBNAME).toBe("livesync-test-db");
|
||||
@@ -198,7 +199,7 @@ describe("runCommand abnormal cases", () => {
|
||||
})
|
||||
).rejects.toThrow();
|
||||
|
||||
expect(core.services.setting.applyPartial).not.toHaveBeenCalled();
|
||||
expect(core.services.setting.applyExternalSettings).not.toHaveBeenCalled();
|
||||
expect(core.services.control.applySettings).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
25
src/apps/cli/docker-entrypoint.sh
Normal file
25
src/apps/cli/docker-entrypoint.sh
Normal file
@@ -0,0 +1,25 @@
|
||||
#!/bin/sh
|
||||
# Entrypoint wrapper for the Self-hosted LiveSync CLI Docker image.
|
||||
#
|
||||
# By default, /data is used as the database-path (the vault mount point).
|
||||
# Override this via the LIVESYNC_DB_PATH environment variable.
|
||||
#
|
||||
# Examples:
|
||||
# docker run -v /path/to/vault:/data livesync-cli sync
|
||||
# docker run -v /path/to/vault:/data livesync-cli --settings /data/.livesync/settings.json sync
|
||||
# docker run -v /path/to/vault:/data livesync-cli init-settings
|
||||
# docker run -e LIVESYNC_DB_PATH=/vault -v /path/to/vault:/vault livesync-cli sync
|
||||
|
||||
set -e
|
||||
|
||||
case "${1:-}" in
|
||||
init-settings | --help | -h | "")
|
||||
# Commands that do not require a leading database-path argument
|
||||
exec node /app/dist/index.cjs "$@"
|
||||
;;
|
||||
*)
|
||||
# All other commands: prepend the database-path so users only need
|
||||
# to supply the command and its options.
|
||||
exec node /app/dist/index.cjs "${LIVESYNC_DB_PATH:-/data}" "$@"
|
||||
;;
|
||||
esac
|
||||
@@ -1,10 +1,11 @@
|
||||
#!/usr/bin/env node
|
||||
import polyfill from "node-datachannel/polyfill";
|
||||
import * as polyfill from "werift";
|
||||
import { main } from "./main";
|
||||
|
||||
for (const prop in polyfill) {
|
||||
// @ts-ignore Applying polyfill to globalThis
|
||||
globalThis[prop] = (polyfill as any)[prop];
|
||||
const rtcPolyfillCtor = (polyfill as any).RTCPeerConnection;
|
||||
if (typeof (globalThis as any).RTCPeerConnection === "undefined" && typeof rtcPolyfillCtor === "function") {
|
||||
// Fill only the standard WebRTC global in Node CLI runtime.
|
||||
(globalThis as any).RTCPeerConnection = rtcPolyfillCtor;
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
|
||||
@@ -3,25 +3,10 @@
|
||||
* Command-line version of Self-hosted LiveSync plugin for syncing vaults without Obsidian
|
||||
*/
|
||||
|
||||
if (!("localStorage" in globalThis) || typeof (globalThis as any).localStorage?.getItem !== "function") {
|
||||
const store = new Map<string, string>();
|
||||
(globalThis as any).localStorage = {
|
||||
getItem: (key: string) => (store.has(key) ? store.get(key)! : null),
|
||||
setItem: (key: string, value: string) => {
|
||||
store.set(key, value);
|
||||
},
|
||||
removeItem: (key: string) => {
|
||||
store.delete(key);
|
||||
},
|
||||
clear: () => {
|
||||
store.clear();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
import * as fs from "fs/promises";
|
||||
import * as path from "path";
|
||||
import { NodeServiceContext, NodeServiceHub } from "./services/NodeServiceHub";
|
||||
import { configureNodeLocalStorage, ensureGlobalNodeLocalStorage } from "./services/NodeLocalStorage";
|
||||
import { LiveSyncBaseCore } from "../../LiveSyncBaseCore";
|
||||
import { ModuleReplicatorP2P } from "../../modules/core/ModuleReplicatorP2P";
|
||||
import { initialiseServiceModulesCLI } from "./serviceModules/CLIServiceModules";
|
||||
@@ -43,6 +28,7 @@ import { getPathFromUXFileInfo } from "@lib/common/typeUtils";
|
||||
import { stripAllPrefixes } from "@lib/string_and_binary/path";
|
||||
|
||||
const SETTINGS_FILE = ".livesync/settings.json";
|
||||
ensureGlobalNodeLocalStorage();
|
||||
defaultLoggerEnv.minLogLevel = LOG_LEVEL_DEBUG;
|
||||
|
||||
function printHelp(): void {
|
||||
@@ -252,6 +238,7 @@ export async function main() {
|
||||
const settingsPath = options.settingsPath
|
||||
? path.resolve(options.settingsPath)
|
||||
: path.join(vaultPath, SETTINGS_FILE);
|
||||
configureNodeLocalStorage(path.join(vaultPath, ".livesync", "runtime", "local-storage.json"));
|
||||
|
||||
infoLog(`Self-hosted LiveSync CLI`);
|
||||
infoLog(`Vault: ${vaultPath}`);
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
"preview": "vite preview",
|
||||
"cli": "node dist/index.cjs",
|
||||
"buildRun": "npm run build && npm run cli --",
|
||||
"build:docker": "docker build -f Dockerfile -t livesync-cli ../../..",
|
||||
"check": "svelte-check --tsconfig ./tsconfig.app.json && tsc -p tsconfig.node.json",
|
||||
"test:unit": "cd ../../.. && npx vitest run --config vitest.config.unit.ts src/apps/cli/main.unit.spec.ts src/apps/cli/commands/utils.unit.spec.ts src/apps/cli/commands/runCommand.unit.spec.ts src/apps/cli/commands/p2p.unit.spec.ts",
|
||||
"test:e2e:two-vaults": "bash test/test-e2e-two-vaults-with-docker-linux.sh",
|
||||
@@ -24,7 +25,15 @@
|
||||
"test:e2e:p2p-sync": "bash test/test-p2p-sync-linux.sh",
|
||||
"test:e2e:mirror": "bash test/test-mirror-linux.sh",
|
||||
"pretest:e2e:all": "npm run build",
|
||||
"test:e2e:all": " export RUN_BUILD=0 && npm run test:e2e:setup-put-cat && npm run test:e2e:push-pull && npm run test:e2e:sync-two-local && npm run test:e2e:p2p && npm run test:e2e:mirror && npm run test:e2e:two-vaults && npm run test:e2e:p2p"
|
||||
"test:e2e:all": " export RUN_BUILD=0 && npm run test:e2e:setup-put-cat && npm run test:e2e:push-pull && npm run test:e2e:sync-two-local && npm run test:e2e:p2p && npm run test:e2e:mirror && npm run test:e2e:two-vaults && npm run test:e2e:p2p",
|
||||
"pretest:e2e:docker:all": "npm run build:docker",
|
||||
"test:e2e:docker:push-pull": "RUN_BUILD=0 LIVESYNC_TEST_DOCKER=1 bash test/test-push-pull-linux.sh",
|
||||
"test:e2e:docker:setup-put-cat": "RUN_BUILD=0 LIVESYNC_TEST_DOCKER=1 bash test/test-setup-put-cat-linux.sh",
|
||||
"test:e2e:docker:mirror": "RUN_BUILD=0 LIVESYNC_TEST_DOCKER=1 bash test/test-mirror-linux.sh",
|
||||
"test:e2e:docker:sync-two-local": "RUN_BUILD=0 LIVESYNC_TEST_DOCKER=1 bash test/test-sync-two-local-databases-linux.sh",
|
||||
"test:e2e:docker:p2p": "RUN_BUILD=0 LIVESYNC_TEST_DOCKER=1 bash test/test-p2p-three-nodes-conflict-linux.sh",
|
||||
"test:e2e:docker:p2p-sync": "RUN_BUILD=0 LIVESYNC_TEST_DOCKER=1 bash test/test-p2p-sync-linux.sh",
|
||||
"test:e2e:docker:all": "export RUN_BUILD=0 && npm run test:e2e:docker:setup-put-cat && npm run test:e2e:docker:push-pull && npm run test:e2e:docker:sync-two-local && npm run test:e2e:docker:mirror"
|
||||
},
|
||||
"dependencies": {},
|
||||
"devDependencies": {}
|
||||
|
||||
24
src/apps/cli/runtime-package.json
Normal file
24
src/apps/cli/runtime-package.json
Normal file
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"name": "livesync-cli-runtime",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"description": "Runtime dependencies for Self-hosted LiveSync CLI Docker image",
|
||||
"dependencies": {
|
||||
"commander": "^14.0.3",
|
||||
"werift": "^0.22.9",
|
||||
"pouchdb-adapter-http": "^9.0.0",
|
||||
"pouchdb-adapter-idb": "^9.0.0",
|
||||
"pouchdb-adapter-indexeddb": "^9.0.0",
|
||||
"pouchdb-adapter-leveldb": "^9.0.0",
|
||||
"pouchdb-adapter-memory": "^9.0.0",
|
||||
"pouchdb-core": "^9.0.0",
|
||||
"pouchdb-errors": "^9.0.0",
|
||||
"pouchdb-find": "^9.0.0",
|
||||
"pouchdb-mapreduce": "^9.0.0",
|
||||
"pouchdb-merge": "^9.0.0",
|
||||
"pouchdb-replication": "^9.0.0",
|
||||
"pouchdb-utils": "^9.0.0",
|
||||
"pouchdb-wrappers": "*",
|
||||
"transform-pouch": "^2.0.0"
|
||||
}
|
||||
}
|
||||
111
src/apps/cli/services/NodeLocalStorage.ts
Normal file
111
src/apps/cli/services/NodeLocalStorage.ts
Normal file
@@ -0,0 +1,111 @@
|
||||
import * as nodeFs from "node:fs";
|
||||
import * as nodePath from "node:path";
|
||||
|
||||
type LocalStorageShape = {
|
||||
getItem(key: string): string | null;
|
||||
setItem(key: string, value: string): void;
|
||||
removeItem(key: string): void;
|
||||
clear(): void;
|
||||
};
|
||||
|
||||
class PersistentNodeLocalStorage {
|
||||
private storagePath: string | undefined;
|
||||
private localStore: Record<string, string> = {};
|
||||
|
||||
configure(storagePath: string) {
|
||||
if (this.storagePath === storagePath) {
|
||||
return;
|
||||
}
|
||||
this.storagePath = storagePath;
|
||||
this.loadFromFile();
|
||||
}
|
||||
|
||||
private loadFromFile() {
|
||||
if (!this.storagePath) {
|
||||
this.localStore = {};
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const loaded = JSON.parse(nodeFs.readFileSync(this.storagePath, "utf-8")) as Record<string, string>;
|
||||
this.localStore = { ...loaded };
|
||||
} catch {
|
||||
this.localStore = {};
|
||||
}
|
||||
}
|
||||
|
||||
private flushToFile() {
|
||||
if (!this.storagePath) {
|
||||
return;
|
||||
}
|
||||
nodeFs.mkdirSync(nodePath.dirname(this.storagePath), { recursive: true });
|
||||
nodeFs.writeFileSync(this.storagePath, JSON.stringify(this.localStore, null, 2), "utf-8");
|
||||
}
|
||||
|
||||
getItem(key: string): string | null {
|
||||
return this.localStore[key] ?? null;
|
||||
}
|
||||
|
||||
setItem(key: string, value: string) {
|
||||
this.localStore[key] = value;
|
||||
this.flushToFile();
|
||||
}
|
||||
|
||||
removeItem(key: string) {
|
||||
if (!(key in this.localStore)) {
|
||||
return;
|
||||
}
|
||||
delete this.localStore[key];
|
||||
this.flushToFile();
|
||||
}
|
||||
|
||||
clear() {
|
||||
this.localStore = {};
|
||||
this.flushToFile();
|
||||
}
|
||||
}
|
||||
|
||||
const persistentNodeLocalStorage = new PersistentNodeLocalStorage();
|
||||
|
||||
function createNodeLocalStorageShim(): LocalStorageShape {
|
||||
return {
|
||||
getItem(key: string) {
|
||||
return persistentNodeLocalStorage.getItem(key);
|
||||
},
|
||||
setItem(key: string, value: string) {
|
||||
persistentNodeLocalStorage.setItem(key, value);
|
||||
},
|
||||
removeItem(key: string) {
|
||||
persistentNodeLocalStorage.removeItem(key);
|
||||
},
|
||||
clear() {
|
||||
persistentNodeLocalStorage.clear();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function ensureGlobalNodeLocalStorage() {
|
||||
if (!("localStorage" in globalThis) || typeof (globalThis as any).localStorage?.getItem !== "function") {
|
||||
(globalThis as any).localStorage = createNodeLocalStorageShim();
|
||||
}
|
||||
}
|
||||
|
||||
export function configureNodeLocalStorage(storagePath: string) {
|
||||
persistentNodeLocalStorage.configure(storagePath);
|
||||
ensureGlobalNodeLocalStorage();
|
||||
}
|
||||
|
||||
export function getNodeLocalStorageItem(key: string): string {
|
||||
return persistentNodeLocalStorage.getItem(key) ?? "";
|
||||
}
|
||||
|
||||
export function setNodeLocalStorageItem(key: string, value: string) {
|
||||
persistentNodeLocalStorage.setItem(key, value);
|
||||
}
|
||||
|
||||
export function deleteNodeLocalStorageItem(key: string) {
|
||||
persistentNodeLocalStorage.removeItem(key);
|
||||
}
|
||||
|
||||
export function clearNodeLocalStorage() {
|
||||
persistentNodeLocalStorage.clear();
|
||||
}
|
||||
60
src/apps/cli/services/NodeLocalStorage.unit.spec.ts
Normal file
60
src/apps/cli/services/NodeLocalStorage.unit.spec.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
import * as fs from "node:fs";
|
||||
import * as os from "node:os";
|
||||
import * as path from "node:path";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import {
|
||||
clearNodeLocalStorage,
|
||||
configureNodeLocalStorage,
|
||||
ensureGlobalNodeLocalStorage,
|
||||
getNodeLocalStorageItem,
|
||||
setNodeLocalStorageItem,
|
||||
} from "./NodeLocalStorage";
|
||||
|
||||
describe("NodeLocalStorage", () => {
|
||||
const tempDirs: string[] = [];
|
||||
|
||||
afterEach(() => {
|
||||
clearNodeLocalStorage();
|
||||
for (const tempDir of tempDirs.splice(0)) {
|
||||
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("persists values to the configured file", () => {
|
||||
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "livesync-node-local-storage-"));
|
||||
tempDirs.push(tempDir);
|
||||
const storagePath = path.join(tempDir, "runtime", "local-storage.json");
|
||||
|
||||
configureNodeLocalStorage(storagePath);
|
||||
setNodeLocalStorageItem("checkpoint", "42");
|
||||
|
||||
const saved = JSON.parse(fs.readFileSync(storagePath, "utf-8")) as Record<string, string>;
|
||||
expect(saved.checkpoint).toBe("42");
|
||||
});
|
||||
|
||||
it("reloads persisted values when configured again", () => {
|
||||
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "livesync-node-local-storage-"));
|
||||
tempDirs.push(tempDir);
|
||||
const storagePath = path.join(tempDir, "runtime", "local-storage.json");
|
||||
|
||||
fs.mkdirSync(path.dirname(storagePath), { recursive: true });
|
||||
fs.writeFileSync(storagePath, JSON.stringify({ persisted: "value" }, null, 2), "utf-8");
|
||||
|
||||
configureNodeLocalStorage(storagePath);
|
||||
|
||||
expect(getNodeLocalStorageItem("persisted")).toBe("value");
|
||||
});
|
||||
|
||||
it("installs a global localStorage shim backed by the same store", () => {
|
||||
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "livesync-node-local-storage-"));
|
||||
tempDirs.push(tempDir);
|
||||
const storagePath = path.join(tempDir, "runtime", "local-storage.json");
|
||||
|
||||
configureNodeLocalStorage(storagePath);
|
||||
ensureGlobalNodeLocalStorage();
|
||||
|
||||
globalThis.localStorage.setItem("shared", "state");
|
||||
|
||||
expect(getNodeLocalStorageItem("shared")).toBe("state");
|
||||
});
|
||||
});
|
||||
@@ -5,17 +5,17 @@ import { handlers } from "@lib/services/lib/HandlerUtils";
|
||||
import type { ObsidianLiveSyncSettings } from "@lib/common/types";
|
||||
import type { ServiceContext } from "@lib/services/base/ServiceBase";
|
||||
import { SettingService, type SettingServiceDependencies } from "@lib/services/base/SettingService";
|
||||
import * as nodeFs from "node:fs";
|
||||
import * as nodePath from "node:path";
|
||||
import {
|
||||
configureNodeLocalStorage,
|
||||
deleteNodeLocalStorageItem,
|
||||
getNodeLocalStorageItem,
|
||||
setNodeLocalStorageItem,
|
||||
} from "./NodeLocalStorage";
|
||||
|
||||
export class NodeSettingService<T extends ServiceContext> extends SettingService<T> {
|
||||
private storagePath: string;
|
||||
private localStore: Record<string, string> = {};
|
||||
|
||||
constructor(context: T, dependencies: SettingServiceDependencies, storagePath: string) {
|
||||
super(context, dependencies);
|
||||
this.storagePath = storagePath;
|
||||
this.loadLocalStoreFromFile();
|
||||
configureNodeLocalStorage(storagePath);
|
||||
this.onSettingSaved.addHandler((settings) => {
|
||||
eventHub.emitEvent(EVENT_SETTING_SAVED, settings);
|
||||
return Promise.resolve(true);
|
||||
@@ -26,34 +26,16 @@ export class NodeSettingService<T extends ServiceContext> extends SettingService
|
||||
});
|
||||
}
|
||||
|
||||
private loadLocalStoreFromFile() {
|
||||
try {
|
||||
const loaded = JSON.parse(nodeFs.readFileSync(this.storagePath, "utf-8")) as Record<string, string>;
|
||||
this.localStore = { ...loaded };
|
||||
} catch {
|
||||
this.localStore = {};
|
||||
}
|
||||
}
|
||||
|
||||
private flushLocalStoreToFile() {
|
||||
nodeFs.mkdirSync(nodePath.dirname(this.storagePath), { recursive: true });
|
||||
nodeFs.writeFileSync(this.storagePath, JSON.stringify(this.localStore, null, 2), "utf-8");
|
||||
}
|
||||
|
||||
protected setItem(key: string, value: string) {
|
||||
this.localStore[key] = value;
|
||||
this.flushLocalStoreToFile();
|
||||
setNodeLocalStorageItem(key, value);
|
||||
}
|
||||
|
||||
protected getItem(key: string): string {
|
||||
return this.localStore[key] ?? "";
|
||||
return getNodeLocalStorageItem(key);
|
||||
}
|
||||
|
||||
protected deleteItem(key: string): void {
|
||||
if (key in this.localStore) {
|
||||
delete this.localStore[key];
|
||||
this.flushLocalStoreToFile();
|
||||
}
|
||||
deleteNodeLocalStorageItem(key);
|
||||
}
|
||||
|
||||
public saveData = handlers<{ saveData: (data: ObsidianLiveSyncSettings) => Promise<void> }>().binder("saveData");
|
||||
|
||||
16
src/apps/cli/test/test-e2e-two-vaults-common.sh
Executable file → Normal file
16
src/apps/cli/test/test-e2e-two-vaults-common.sh
Executable file → Normal file
@@ -136,6 +136,8 @@ fi
|
||||
|
||||
TARGET_A_ONLY="e2e/a-only-info.md"
|
||||
TARGET_SYNC="e2e/sync-info.md"
|
||||
TARGET_SYNC_TWICE_FIRST="e2e/sync-twice-first.md"
|
||||
TARGET_SYNC_TWICE_SECOND="e2e/sync-twice-second.md"
|
||||
TARGET_PUSH="e2e/pushed-from-a.md"
|
||||
TARGET_PUT="e2e/put-from-a.md"
|
||||
TARGET_PUSH_BINARY="e2e/pushed-from-a.bin"
|
||||
@@ -154,6 +156,20 @@ INFO_B_SYNC="$(run_cli_b info "$TARGET_SYNC")"
|
||||
cli_test_assert_contains "$INFO_B_SYNC" "\"path\": \"$TARGET_SYNC\"" "B info should include path after sync"
|
||||
echo "[PASS] sync A->B and B info"
|
||||
|
||||
echo "[CASE] B can sync again after first replication has completed"
|
||||
printf 'first-sync-round-%s\n' "$DB_SUFFIX" | run_cli_a put "$TARGET_SYNC_TWICE_FIRST" >/dev/null
|
||||
run_cli_a sync >/dev/null
|
||||
run_cli_b sync >/dev/null
|
||||
CAT_B_SYNC_TWICE_FIRST="$(run_cli_b cat "$TARGET_SYNC_TWICE_FIRST" | cli_test_sanitise_cat_stdout)"
|
||||
cli_test_assert_equal "first-sync-round-$DB_SUFFIX" "$CAT_B_SYNC_TWICE_FIRST" "B should receive first update after first sync"
|
||||
|
||||
printf 'second-sync-round-%s\n' "$DB_SUFFIX" | run_cli_a put "$TARGET_SYNC_TWICE_SECOND" >/dev/null
|
||||
run_cli_a sync >/dev/null
|
||||
run_cli_b sync >/dev/null
|
||||
CAT_B_SYNC_TWICE_SECOND="$(run_cli_b cat "$TARGET_SYNC_TWICE_SECOND" | cli_test_sanitise_cat_stdout)"
|
||||
cli_test_assert_equal "second-sync-round-$DB_SUFFIX" "$CAT_B_SYNC_TWICE_SECOND" "B should receive second update after re-running sync"
|
||||
echo "[PASS] second sync after completion works"
|
||||
|
||||
echo "[CASE] A pushes and puts, both sync, and B can pull and cat"
|
||||
PUSH_SRC="$WORK_DIR/push-source.txt"
|
||||
PULL_DST="$WORK_DIR/pull-destination.txt"
|
||||
|
||||
0
src/apps/cli/test/test-e2e-two-vaults-matrix.sh
Executable file → Normal file
0
src/apps/cli/test/test-e2e-two-vaults-matrix.sh
Executable file → Normal file
0
src/apps/cli/test/test-e2e-two-vaults-with-docker-linux.sh
Executable file → Normal file
0
src/apps/cli/test/test-e2e-two-vaults-with-docker-linux.sh
Executable file → Normal file
150
src/apps/cli/test/test-helpers-docker.sh
Normal file
150
src/apps/cli/test/test-helpers-docker.sh
Normal file
@@ -0,0 +1,150 @@
|
||||
#!/usr/bin/env bash
|
||||
# test-helpers-docker.sh
|
||||
#
|
||||
# Docker-mode overrides for test-helpers.sh.
|
||||
# Sourced automatically at the end of test-helpers.sh when
|
||||
# LIVESYNC_TEST_DOCKER=1 is set, replacing run_cli (and related helpers)
|
||||
# with a Docker-based implementation.
|
||||
#
|
||||
# The Docker container and the host share a common directory layout:
|
||||
# $WORK_DIR (host) <-> /workdir (container)
|
||||
# $CLI_DIR (host) <-> /clidir (container)
|
||||
#
|
||||
# Usage (run an existing test against the Docker image):
|
||||
# LIVESYNC_TEST_DOCKER=1 bash test/test-push-pull-linux.sh
|
||||
# LIVESYNC_TEST_DOCKER=1 bash test/test-mirror-linux.sh
|
||||
# LIVESYNC_TEST_DOCKER=1 bash test/test-sync-two-local-databases-linux.sh
|
||||
# LIVESYNC_TEST_DOCKER=1 bash test/test-setup-put-cat-linux.sh
|
||||
#
|
||||
# Optional environment variables:
|
||||
# DOCKER_IMAGE Image name/tag to use (default: livesync-cli)
|
||||
# RUN_BUILD Set to 1 to rebuild the Docker image before the test
|
||||
# (default: 0 — assumes the image is already built)
|
||||
# Build command: npm run build:docker (from src/apps/cli/)
|
||||
#
|
||||
# Notes:
|
||||
# - The container is started with --network host so that it can reach
|
||||
# CouchDB / P2P relay containers that are also using the host network.
|
||||
# - On macOS / Windows Docker Desktop --network host behaves differently
|
||||
# (it is not a true host-network bridge); tests that rely on localhost
|
||||
# connectivity to other containers may fail on those platforms.
|
||||
|
||||
# Ensure Docker-mode tests do not trigger host-side `npm run build` unless
|
||||
# explicitly requested by the caller.
|
||||
RUN_BUILD="${RUN_BUILD:-0}"
|
||||
|
||||
# Override the standard implementation.
|
||||
# In Docker mode the CLI_CMD array is a no-op sentinel; run_cli is overridden
|
||||
# directly.
|
||||
cli_test_init_cli_cmd() {
|
||||
DOCKER_IMAGE="${DOCKER_IMAGE:-livesync-cli}"
|
||||
# CLI_CMD is unused in Docker mode; set a sentinel so existing code
|
||||
# that references it will not error.
|
||||
CLI_CMD=(__docker__)
|
||||
}
|
||||
|
||||
# ─── display_test_info ────────────────────────────────────────────────────────
|
||||
display_test_info() {
|
||||
local image="${DOCKER_IMAGE:-livesync-cli}"
|
||||
local image_id
|
||||
image_id="$(docker inspect --format='{{slice .Id 7 19}}' "$image" 2>/dev/null || echo "N/A")"
|
||||
echo "======================"
|
||||
echo "Script: ${BASH_SOURCE[1]:-$0}"
|
||||
echo "Date: $(date -u +%Y-%m-%dT%H:%M:%SZ)"
|
||||
echo "Commit: $(git -C "${SCRIPT_DIR:-.}" rev-parse --short HEAD 2>/dev/null || echo "N/A")"
|
||||
echo "Mode: Docker image=${image} id=${image_id}"
|
||||
echo "======================"
|
||||
}
|
||||
|
||||
# ─── _docker_translate_arg ───────────────────────────────────────────────────
|
||||
# Translate a single host filesystem path to its in-container equivalent.
|
||||
# Paths under WORK_DIR → /workdir/...
|
||||
# Paths under CLI_DIR → /clidir/...
|
||||
# Everything else is returned unchanged (relative paths, URIs, plain names).
|
||||
_docker_translate_arg() {
|
||||
local arg="$1"
|
||||
if [[ -n "${WORK_DIR:-}" && "$arg" == "$WORK_DIR"* ]]; then
|
||||
printf '%s' "/workdir${arg#$WORK_DIR}"
|
||||
return
|
||||
fi
|
||||
if [[ -n "${CLI_DIR:-}" && "$arg" == "$CLI_DIR"* ]]; then
|
||||
printf '%s' "/clidir${arg#$CLI_DIR}"
|
||||
return
|
||||
fi
|
||||
printf '%s' "$arg"
|
||||
}
|
||||
|
||||
# ─── run_cli ─────────────────────────────────────────────────────────────────
|
||||
# Drop-in replacement for run_cli that executes the CLI inside a Docker
|
||||
# container, translating host paths to container paths automatically.
|
||||
#
|
||||
# Calling convention is identical to the native run_cli:
|
||||
# run_cli <vault-path> [options] <command> [command-args]
|
||||
# run_cli init-settings [options] <settings-file>
|
||||
#
|
||||
# The vault path (first positional argument for regular commands) is forwarded
|
||||
# via the LIVESYNC_DB_PATH environment variable so that docker-entrypoint.sh
|
||||
# can inject it before the remaining CLI arguments.
|
||||
run_cli() {
|
||||
local args=("$@")
|
||||
|
||||
# ── 1. Translate all host paths to container paths ────────────────────
|
||||
local translated=()
|
||||
for arg in "${args[@]}"; do
|
||||
translated+=("$(_docker_translate_arg "$arg")")
|
||||
done
|
||||
|
||||
# ── 2. Split vault path from the rest of the arguments ───────────────
|
||||
local first="${translated[0]:-}"
|
||||
local env_args=()
|
||||
local cli_args=()
|
||||
|
||||
# These tokens are commands or flags that appear before any vault path.
|
||||
case "$first" in
|
||||
"" | --help | -h \
|
||||
| init-settings \
|
||||
| -v | --verbose | -d | --debug | -f | --force | -s | --settings)
|
||||
# No leading vault path — pass all translated args as-is.
|
||||
cli_args=("${translated[@]}")
|
||||
;;
|
||||
*)
|
||||
# First arg is the vault path; hand it to docker-entrypoint.sh
|
||||
# via LIVESYNC_DB_PATH so the entrypoint prepends it correctly.
|
||||
env_args+=(-e "LIVESYNC_DB_PATH=$first")
|
||||
cli_args=("${translated[@]:1}")
|
||||
;;
|
||||
esac
|
||||
|
||||
# ── 3. Inject verbose / debug flags ──────────────────────────────────
|
||||
if [[ "${VERBOSE_TEST_LOGGING:-0}" == "1" ]]; then
|
||||
cli_args=(-v "${cli_args[@]}")
|
||||
fi
|
||||
|
||||
# ── 4. Volume mounts ──────────────────────────────────────────────────
|
||||
local vol_args=()
|
||||
if [[ -n "${WORK_DIR:-}" ]]; then
|
||||
vol_args+=(-v "${WORK_DIR}:/workdir")
|
||||
fi
|
||||
# Mount CLI_DIR (src/apps/cli) for two-vault tests that store vault data
|
||||
# under $CLI_DIR/.livesync/.
|
||||
if [[ -n "${CLI_DIR:-}" ]]; then
|
||||
vol_args+=(-v "${CLI_DIR}:/clidir")
|
||||
fi
|
||||
|
||||
# ── 5. stdin forwarding ───────────────────────────────────────────────
|
||||
# Attach stdin only when it is a pipe (the 'put' command reads from stdin).
|
||||
# Without -i the pipe data would never reach the container process.
|
||||
local stdin_flags=()
|
||||
if [[ ! -t 0 ]]; then
|
||||
stdin_flags=(-i)
|
||||
fi
|
||||
|
||||
docker run --rm \
|
||||
"${stdin_flags[@]}" \
|
||||
--network host \
|
||||
--user "$(id -u):$(id -g)" \
|
||||
"${vol_args[@]}" \
|
||||
"${env_args[@]}" \
|
||||
"${DOCKER_IMAGE:-livesync-cli}" \
|
||||
"${cli_args[@]}"
|
||||
}
|
||||
@@ -1,5 +1,15 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
# ─── local init hook ────────────────────────────────────────────────────────
|
||||
# If test-init.local.sh exists alongside this file, source it before anything
|
||||
# else. Use it to set up your local environment (e.g. activate nvm, set
|
||||
# DOCKER_IMAGE, ...). The file is git-ignored so it is safe to put personal
|
||||
# or machine-specific configuration there.
|
||||
_TEST_HELPERS_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
# shellcheck source=/dev/null
|
||||
[[ -f "$_TEST_HELPERS_DIR/test-init.local.sh" ]] && source "$_TEST_HELPERS_DIR/test-init.local.sh"
|
||||
unset _TEST_HELPERS_DIR
|
||||
|
||||
cli_test_init_cli_cmd() {
|
||||
if [[ "${VERBOSE_TEST_LOGGING:-0}" == "1" ]]; then
|
||||
CLI_CMD=(npm --silent run cli -- -v)
|
||||
@@ -343,4 +353,10 @@ display_test_info(){
|
||||
echo "Date: $(date -u +%Y-%m-%dT%H:%M:%SZ)"
|
||||
echo "Git commit: $(git -C "$SCRIPT_DIR/.." rev-parse --short HEAD 2>/dev/null || echo "N/A")"
|
||||
echo "======================"
|
||||
}
|
||||
}
|
||||
|
||||
# Docker-mode hook — source overrides when LIVESYNC_TEST_DOCKER=1.
|
||||
if [[ "${LIVESYNC_TEST_DOCKER:-0}" == "1" ]]; then
|
||||
# shellcheck source=/dev/null
|
||||
source "$(dirname "${BASH_SOURCE[0]}")/test-helpers-docker.sh"
|
||||
fi
|
||||
0
src/apps/cli/test/test-mirror-linux.sh
Executable file → Normal file
0
src/apps/cli/test/test-mirror-linux.sh
Executable file → Normal file
0
src/apps/cli/test/test-p2p-three-nodes-conflict-linux.sh
Executable file → Normal file
0
src/apps/cli/test/test-p2p-three-nodes-conflict-linux.sh
Executable file → Normal file
0
src/apps/cli/test/test-setup-put-cat-linux.sh
Executable file → Normal file
0
src/apps/cli/test/test-setup-put-cat-linux.sh
Executable file → Normal file
0
src/apps/cli/test/test-sync-locked-remote-linux.sh
Executable file → Normal file
0
src/apps/cli/test/test-sync-locked-remote-linux.sh
Executable file → Normal file
0
src/apps/cli/test/test-sync-two-local-databases-linux.sh
Executable file → Normal file
0
src/apps/cli/test/test-sync-two-local-databases-linux.sh
Executable file → Normal file
@@ -1,2 +1,30 @@
|
||||
#!/bin/bash
|
||||
docker run -d --name relay-test -p 4000:8080 scsibug/nostr-rs-relay:latest
|
||||
set -e
|
||||
|
||||
docker run -d --name relay-test -p 4000:7777 \
|
||||
--tmpfs /app/strfry-db:rw,size=256m \
|
||||
--entrypoint sh \
|
||||
ghcr.io/hoytech/strfry:latest \
|
||||
-lc 'cat > /tmp/strfry.conf <<"EOF"
|
||||
db = "./strfry-db/"
|
||||
|
||||
relay {
|
||||
bind = "0.0.0.0"
|
||||
port = 7777
|
||||
nofiles = 100000
|
||||
|
||||
info {
|
||||
name = "livesync test relay"
|
||||
description = "local relay for livesync p2p tests"
|
||||
}
|
||||
|
||||
maxWebsocketPayloadSize = 131072
|
||||
autoPingSeconds = 55
|
||||
|
||||
writePolicy {
|
||||
plugin = ""
|
||||
}
|
||||
}
|
||||
EOF
|
||||
exec /app/strfry --config /tmp/strfry.conf relay'
|
||||
|
||||
|
||||
@@ -12,8 +12,7 @@ const defaultExternal = [
|
||||
"pouchdb-adapter-leveldb",
|
||||
"commander",
|
||||
"punycode",
|
||||
"node-datachannel",
|
||||
"node-datachannel/polyfill",
|
||||
"werift",
|
||||
];
|
||||
export default defineConfig({
|
||||
plugins: [svelte()],
|
||||
@@ -52,7 +51,7 @@ export default defineConfig({
|
||||
if (id === "fs" || id === "fs/promises" || id === "path" || id === "crypto" || id === "worker_threads")
|
||||
return true;
|
||||
if (id.startsWith("pouchdb-")) return true;
|
||||
if (id.startsWith("node-datachannel")) return true;
|
||||
if (id.startsWith("werift")) return true;
|
||||
if (id.startsWith("node:")) return true;
|
||||
return false;
|
||||
},
|
||||
|
||||
58
src/apps/webapp/Dockerfile
Normal file
58
src/apps/webapp/Dockerfile
Normal file
@@ -0,0 +1,58 @@
|
||||
# syntax=docker/dockerfile:1
|
||||
#
|
||||
# Self-hosted LiveSync WebApp — Docker image
|
||||
# Browser-based vault sync using the FileSystem API, served by nginx.
|
||||
#
|
||||
# Build (from the repository root):
|
||||
# docker build -f src/apps/webapp/Dockerfile -t livesync-webapp .
|
||||
#
|
||||
# Run:
|
||||
# docker run --rm -p 8080:80 livesync-webapp
|
||||
# Then open http://localhost:8080/webapp.html in Chrome/Edge 86+.
|
||||
#
|
||||
# Notes:
|
||||
# - This image serves purely static files; no server-side code is involved.
|
||||
# - The FileSystem API is a browser feature and requires Chrome/Edge 86+ or
|
||||
# Safari 15.2+ (limited). Firefox is not supported.
|
||||
# - CouchDB / S3 connections are made directly from the browser; the container
|
||||
# only serves HTML/JS/CSS assets.
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# Stage 1 — builder
|
||||
# Full Node.js environment to install dependencies and build the Vite bundle.
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
FROM node:22-slim AS builder
|
||||
|
||||
WORKDIR /build
|
||||
|
||||
# Install workspace dependencies (all apps share the root package.json)
|
||||
COPY package.json ./
|
||||
RUN npm install
|
||||
|
||||
# Copy the full source tree and build the WebApp bundle
|
||||
COPY . .
|
||||
RUN cd src/apps/webapp && npm run build
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# Stage 2 — runtime
|
||||
# Minimal nginx image that serves the static build output.
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
FROM nginx:stable-alpine
|
||||
|
||||
# Remove the default nginx welcome page
|
||||
RUN rm -rf /usr/share/nginx/html/*
|
||||
|
||||
# Copy the built static assets
|
||||
COPY --from=builder /build/src/apps/webapp/dist /usr/share/nginx/html
|
||||
|
||||
# Redirect the root to webapp.html so the app loads on first visit
|
||||
RUN printf 'server {\n\
|
||||
listen 80;\n\
|
||||
root /usr/share/nginx/html;\n\
|
||||
index webapp.html;\n\
|
||||
location / {\n\
|
||||
try_files $uri $uri/ =404;\n\
|
||||
}\n\
|
||||
}\n' > /etc/nginx/conf.d/default.conf
|
||||
|
||||
EXPOSE 80
|
||||
@@ -14,6 +14,7 @@ import { useOfflineScanner } from "@lib/serviceFeatures/offlineScanner";
|
||||
import { useRedFlagFeatures } from "@/serviceFeatures/redFlag";
|
||||
import { useCheckRemoteSize } from "@lib/serviceFeatures/checkRemoteSize";
|
||||
import { useSetupURIFeature } from "@lib/serviceFeatures/setupObsidian/setupUri";
|
||||
import { useRemoteConfiguration } from "@lib/serviceFeatures/remoteConfig";
|
||||
import { SetupManager } from "@/modules/features/SetupManager";
|
||||
import { useSetupManagerHandlersFeature } from "@/serviceFeatures/setupObsidian/setupManagerHandlers";
|
||||
import { useP2PReplicatorCommands } from "@/lib/src/replication/trystero/useP2PReplicatorCommands";
|
||||
@@ -132,6 +133,7 @@ class LiveSyncWebApp {
|
||||
useOfflineScanner(core);
|
||||
useRedFlagFeatures(core);
|
||||
useCheckRemoteSize(core);
|
||||
useRemoteConfiguration(core);
|
||||
const replicator = useP2PReplicatorFeature(core);
|
||||
useP2PReplicatorCommands(core, replicator);
|
||||
const setupManager = core.getModule(SetupManager);
|
||||
|
||||
@@ -7,6 +7,8 @@
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"build:docker": "docker build -f Dockerfile -t livesync-webapp ../../..",
|
||||
"run:docker": "docker run -p 8002:80 livesync-webapp",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {},
|
||||
|
||||
57
src/apps/webpeer/Dockerfile
Normal file
57
src/apps/webpeer/Dockerfile
Normal file
@@ -0,0 +1,57 @@
|
||||
# syntax=docker/dockerfile:1
|
||||
#
|
||||
# Self-hosted LiveSync WebPeer — Docker image
|
||||
# Browser-based P2P peer daemon served by nginx.
|
||||
#
|
||||
# Build (from the repository root):
|
||||
# docker build -f src/apps/webpeer/Dockerfile -t livesync-webpeer .
|
||||
#
|
||||
# Run:
|
||||
# docker run --rm -p 8081:80 livesync-webpeer
|
||||
# Then open http://localhost:8081/ in any modern browser.
|
||||
#
|
||||
# What is WebPeer?
|
||||
# WebPeer acts as a pseudo P2P peer that runs entirely in the browser.
|
||||
# It can replace a CouchDB remote server by replying to sync requests from
|
||||
# other Self-hosted LiveSync instances over the WebRTC P2P channel.
|
||||
#
|
||||
# P2P (WebRTC) networking notes
|
||||
# ─────────────────────────────
|
||||
# WebRTC connections are initiated by the *browser* visiting this page, not by
|
||||
# the nginx container itself. Therefore the Docker network mode of this
|
||||
# container has NO effect on WebRTC connectivity.
|
||||
# Simply publish port 80 (as above) and the browser handles all ICE/STUN/TURN
|
||||
# negotiation on its own.
|
||||
#
|
||||
# If the browser is running inside another container or a restricted network,
|
||||
# configuring a TURN server in the WebPeer settings is recommended.
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# Stage 1 — builder
|
||||
# Full Node.js environment to install dependencies and build the Vite bundle.
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
FROM node:22-slim AS builder
|
||||
|
||||
WORKDIR /build
|
||||
|
||||
# Install workspace dependencies (all apps share the root package.json)
|
||||
COPY package.json ./
|
||||
RUN npm install
|
||||
|
||||
# Copy the full source tree and build the WebPeer bundle
|
||||
COPY . .
|
||||
RUN cd src/apps/webpeer && npm run build
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# Stage 2 — runtime
|
||||
# Minimal nginx image that serves the static build output.
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
FROM nginx:stable-alpine
|
||||
|
||||
# Remove the default nginx welcome page
|
||||
RUN rm -rf /usr/share/nginx/html/*
|
||||
|
||||
# Copy the built static assets
|
||||
COPY --from=builder /build/src/apps/webpeer/dist /usr/share/nginx/html
|
||||
|
||||
EXPOSE 80
|
||||
@@ -6,6 +6,8 @@
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"build:docker": "docker build -f Dockerfile -t livesync-webpeer ../../..",
|
||||
"run:docker": "docker run -p 8001:80 livesync-webpeer",
|
||||
"preview": "vite preview",
|
||||
"check": "svelte-check --tsconfig ./tsconfig.app.json && tsc -p tsconfig.node.json"
|
||||
},
|
||||
|
||||
@@ -276,7 +276,7 @@ export class P2PReplicatorShim implements P2PReplicatorBase {
|
||||
}
|
||||
}
|
||||
}
|
||||
await this.services.setting.applyPartial(remoteConfig, true);
|
||||
await this.services.setting.applyExternalSettings(remoteConfig, true);
|
||||
if (yn !== DROP) {
|
||||
await this.plugin.core.services.appLifecycle.scheduleRestart();
|
||||
}
|
||||
|
||||
@@ -87,7 +87,7 @@ And you can also drop the local database to rebuild from the remote device.`,
|
||||
|
||||
// this.plugin.settings = remoteConfig;
|
||||
// await this.plugin.saveSettings();
|
||||
await this.core.services.setting.applyPartial(remoteConfig);
|
||||
await this.core.services.setting.applyExternalSettings(remoteConfig);
|
||||
if (yn === DROP) {
|
||||
await this.core.rebuilder.scheduleFetch();
|
||||
} else {
|
||||
|
||||
2
src/lib
2
src/lib
Submodule src/lib updated: 3d6d9603bf...37b8e2813e
@@ -33,6 +33,7 @@ import { SetupManager } from "./modules/features/SetupManager.ts";
|
||||
import { ModuleMigration } from "./modules/essential/ModuleMigration.ts";
|
||||
import { enableI18nFeature } from "./serviceFeatures/onLayoutReady/enablei18n.ts";
|
||||
import { useOfflineScanner } from "@lib/serviceFeatures/offlineScanner.ts";
|
||||
import { useRemoteConfiguration } from "@lib/serviceFeatures/remoteConfig.ts";
|
||||
import { useCheckRemoteSize } from "@lib/serviceFeatures/checkRemoteSize.ts";
|
||||
import { useRedFlagFeatures } from "./serviceFeatures/redFlag.ts";
|
||||
import { useSetupProtocolFeature } from "./serviceFeatures/setupObsidian/setupProtocol.ts";
|
||||
@@ -174,6 +175,9 @@ export default class ObsidianLiveSyncPlugin extends Plugin {
|
||||
const curriedFeature = () => featuresInitialiser(core);
|
||||
core.services.appLifecycle.onLayoutReady.addHandler(curriedFeature);
|
||||
const setupManager = core.getModule(SetupManager);
|
||||
|
||||
useRemoteConfiguration(core);
|
||||
|
||||
useSetupProtocolFeature(core, setupManager);
|
||||
useSetupQRCodeFeature(core);
|
||||
useSetupURIFeature(core);
|
||||
|
||||
@@ -277,27 +277,36 @@ export class ModuleLog extends AbstractObsidianModule {
|
||||
}
|
||||
|
||||
async updateMessageArea() {
|
||||
if (this.messageArea) {
|
||||
const messageLines = [];
|
||||
const fileStatus = this.activeFileStatus.value;
|
||||
if (fileStatus && !this.settings.hideFileWarningNotice) messageLines.push(fileStatus);
|
||||
const messages = (await this.services.appLifecycle.getUnresolvedMessages()).flat().filter((e) => e);
|
||||
const stringMessages = messages.filter((m): m is string => typeof m === "string"); // for 'startsWith'
|
||||
const networkMessages = stringMessages.filter((m) => m.startsWith(MARK_LOG_NETWORK_ERROR));
|
||||
const otherMessages = stringMessages.filter((m) => !m.startsWith(MARK_LOG_NETWORK_ERROR));
|
||||
if (!this.messageArea) return;
|
||||
|
||||
messageLines.push(...otherMessages);
|
||||
|
||||
if (
|
||||
this.settings.networkWarningStyle !== NetworkWarningStyles.ICON &&
|
||||
this.settings.networkWarningStyle !== NetworkWarningStyles.HIDDEN
|
||||
) {
|
||||
messageLines.push(...networkMessages);
|
||||
} else if (this.settings.networkWarningStyle === NetworkWarningStyles.ICON) {
|
||||
if (networkMessages.length > 0) messageLines.push("🔗❌");
|
||||
}
|
||||
this.messageArea.innerText = messageLines.map((e) => `⚠️ ${e}`).join("\n");
|
||||
const showStatusOnEditor = this.settings?.showStatusOnEditor ?? false;
|
||||
if (this.statusDiv) {
|
||||
this.statusDiv.style.display = showStatusOnEditor ? "" : "none";
|
||||
}
|
||||
if (!showStatusOnEditor) {
|
||||
this.messageArea.innerText = "";
|
||||
return;
|
||||
}
|
||||
|
||||
const messageLines = [];
|
||||
const fileStatus = this.activeFileStatus.value;
|
||||
if (fileStatus && !this.settings.hideFileWarningNotice) messageLines.push(fileStatus);
|
||||
const messages = (await this.services.appLifecycle.getUnresolvedMessages()).flat().filter((e) => e);
|
||||
const stringMessages = messages.filter((m): m is string => typeof m === "string"); // for 'startsWith'
|
||||
const networkMessages = stringMessages.filter((m) => m.startsWith(MARK_LOG_NETWORK_ERROR));
|
||||
const otherMessages = stringMessages.filter((m) => !m.startsWith(MARK_LOG_NETWORK_ERROR));
|
||||
|
||||
messageLines.push(...otherMessages);
|
||||
|
||||
if (
|
||||
this.settings.networkWarningStyle !== NetworkWarningStyles.ICON &&
|
||||
this.settings.networkWarningStyle !== NetworkWarningStyles.HIDDEN
|
||||
) {
|
||||
messageLines.push(...networkMessages);
|
||||
} else if (this.settings.networkWarningStyle === NetworkWarningStyles.ICON) {
|
||||
if (networkMessages.length > 0) messageLines.push("🔗❌");
|
||||
}
|
||||
this.messageArea.innerText = messageLines.map((e) => `⚠️ ${e}`).join("\n");
|
||||
}
|
||||
|
||||
onActiveLeafChange() {
|
||||
@@ -326,6 +335,9 @@ export class ModuleLog extends AbstractObsidianModule {
|
||||
}
|
||||
|
||||
this.statusBar?.setText(newMsg.split("\n")[0]);
|
||||
if (this.statusDiv) {
|
||||
this.statusDiv.style.display = this.settings?.showStatusOnEditor ? "" : "none";
|
||||
}
|
||||
if (this.settings?.showStatusOnEditor && this.statusDiv) {
|
||||
if (this.settings.showLongerLogInsideEditor) {
|
||||
const now = new Date().getTime();
|
||||
@@ -402,6 +414,7 @@ export class ModuleLog extends AbstractObsidianModule {
|
||||
this.messageArea = this.statusDiv.createDiv({ cls: "livesync-status-messagearea" });
|
||||
this.logMessage = this.statusDiv.createDiv({ cls: "livesync-status-logmessage" });
|
||||
this.logHistory = this.statusDiv.createDiv({ cls: "livesync-status-loghistory" });
|
||||
this.statusDiv.style.display = this.settings?.showStatusOnEditor ? "" : "none";
|
||||
eventHub.onEvent(EVENT_LAYOUT_READY, () => this.adjustStatusDivPosition());
|
||||
if (this.settings?.showStatusOnStatusbar) {
|
||||
this.statusBar = this.services.API.addStatusBarItem();
|
||||
|
||||
@@ -162,8 +162,8 @@ export class ModuleObsidianSettingsAsMarkdown extends AbstractModule {
|
||||
result == APPLY_AND_REBUILD ||
|
||||
result == APPLY_AND_FETCH
|
||||
) {
|
||||
this.core.settings = settingToApply;
|
||||
await this.services.setting.saveSettingData();
|
||||
await this.services.setting.applyExternalSettings(settingToApply, true);
|
||||
this.services.setting.clearUsedPassphrase();
|
||||
if (result == APPLY_ONLY) {
|
||||
this._log("Loaded settings have been applied!", LOG_LEVEL_NOTICE);
|
||||
return;
|
||||
|
||||
@@ -21,6 +21,9 @@ export function paneGeneral(
|
||||
});
|
||||
this.addOnSaved("displayLanguage", () => this.display());
|
||||
new Setting(paneEl).autoWireToggle("showStatusOnEditor");
|
||||
this.addOnSaved("showStatusOnEditor", () => {
|
||||
eventHub.emitEvent(EVENT_ON_UNRESOLVED_ERROR);
|
||||
});
|
||||
new Setting(paneEl).autoWireToggle("showOnlyIconsOnEditor", {
|
||||
onUpdate: visibleOnly(() => this.isConfiguredAs("showStatusOnEditor", true)),
|
||||
});
|
||||
|
||||
@@ -2,8 +2,11 @@ import {
|
||||
REMOTE_COUCHDB,
|
||||
REMOTE_MINIO,
|
||||
REMOTE_P2P,
|
||||
DEFAULT_SETTINGS,
|
||||
LOG_LEVEL_NOTICE,
|
||||
type ObsidianLiveSyncSettings,
|
||||
} from "../../../lib/src/common/types.ts";
|
||||
import { Menu } from "@/deps.ts";
|
||||
import { $msg } from "../../../lib/src/common/i18n.ts";
|
||||
import { LiveSyncSetting as Setting } from "./LiveSyncSetting.ts";
|
||||
import type { ObsidianLiveSyncSettingTab } from "./ObsidianLiveSyncSettingTab.ts";
|
||||
@@ -21,6 +24,14 @@ import {
|
||||
import { SETTING_KEY_P2P_DEVICE_NAME } from "../../../lib/src/common/types.ts";
|
||||
import { SetupManager, UserMode } from "../SetupManager.ts";
|
||||
import { OnDialogSettingsDefault, type AllSettings } from "./settingConstants.ts";
|
||||
import { activateRemoteConfiguration } from "../../../lib/src/serviceFeatures/remoteConfig.ts";
|
||||
import { ConnectionStringParser } from "../../../lib/src/common/ConnectionString.ts";
|
||||
import type { RemoteConfigurationResult } from "../../../lib/src/common/ConnectionString.ts";
|
||||
import type { RemoteConfiguration } from "../../../lib/src/common/models/setting.type.ts";
|
||||
import SetupRemote from "../SetupWizard/dialogs/SetupRemote.svelte";
|
||||
import SetupRemoteCouchDB from "../SetupWizard/dialogs/SetupRemoteCouchDB.svelte";
|
||||
import SetupRemoteBucket from "../SetupWizard/dialogs/SetupRemoteBucket.svelte";
|
||||
import SetupRemoteP2P from "../SetupWizard/dialogs/SetupRemoteP2P.svelte";
|
||||
|
||||
function getSettingsFromEditingSettings(editingSettings: AllSettings): ObsidianLiveSyncSettings {
|
||||
const workObj = { ...editingSettings } as ObsidianLiveSyncSettings;
|
||||
@@ -39,17 +50,54 @@ const toggleActiveSyncClass = (el: HTMLElement, isActive: () => boolean) => {
|
||||
return {};
|
||||
};
|
||||
|
||||
function createRemoteConfigurationId(): string {
|
||||
return `remote-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
|
||||
}
|
||||
|
||||
function cloneRemoteConfigurations(
|
||||
configs: Record<string, RemoteConfiguration> | undefined
|
||||
): Record<string, RemoteConfiguration> {
|
||||
return Object.fromEntries(Object.entries(configs || {}).map(([id, config]) => [id, { ...config }]));
|
||||
}
|
||||
|
||||
function serializeRemoteConfiguration(settings: ObsidianLiveSyncSettings): string {
|
||||
if (settings.remoteType === REMOTE_MINIO) {
|
||||
return ConnectionStringParser.serialize({ type: "s3", settings });
|
||||
}
|
||||
if (settings.remoteType === REMOTE_P2P) {
|
||||
return ConnectionStringParser.serialize({ type: "p2p", settings });
|
||||
}
|
||||
return ConnectionStringParser.serialize({ type: "couchdb", settings });
|
||||
}
|
||||
|
||||
function setEmojiButton(button: any, emoji: string, tooltip: string) {
|
||||
button.setButtonText(emoji);
|
||||
button.setTooltip(tooltip, { delay: 10, placement: "top" });
|
||||
// button.buttonEl.addClass("clickable-icon");
|
||||
button.buttonEl.addClass("mod-muted");
|
||||
return button;
|
||||
}
|
||||
|
||||
function suggestRemoteConfigurationName(parsed: RemoteConfigurationResult): string {
|
||||
if (parsed.type === "couchdb") {
|
||||
try {
|
||||
const url = new URL(parsed.settings.couchDB_URI);
|
||||
return `CouchDB ${url.host}`;
|
||||
} catch {
|
||||
return "Imported CouchDB";
|
||||
}
|
||||
}
|
||||
if (parsed.type === "s3") {
|
||||
return `S3 ${parsed.settings.bucket || parsed.settings.endpoint}`;
|
||||
}
|
||||
return `P2P ${parsed.settings.P2P_roomID || "Remote"}`;
|
||||
}
|
||||
|
||||
export function paneRemoteConfig(
|
||||
this: ObsidianLiveSyncSettingTab,
|
||||
paneEl: HTMLElement,
|
||||
{ addPanel, addPane }: PageFunctions
|
||||
): void {
|
||||
const remoteNameMap = {
|
||||
[REMOTE_COUCHDB]: $msg("obsidianLiveSyncSettingTab.optionCouchDB"),
|
||||
[REMOTE_MINIO]: $msg("obsidianLiveSyncSettingTab.optionMinioS3R2"),
|
||||
[REMOTE_P2P]: "Only Peer-to-Peer",
|
||||
} as const;
|
||||
|
||||
{
|
||||
/* E2EE */
|
||||
const E2EEInitialProps = {
|
||||
@@ -91,24 +139,335 @@ export function paneRemoteConfig(
|
||||
});
|
||||
}
|
||||
{
|
||||
// TODO: very WIP. need to refactor the UI.
|
||||
void addPanel(paneEl, $msg("obsidianLiveSyncSettingTab.titleRemoteServer"), () => {}).then((paneEl) => {
|
||||
const setting = new Setting(paneEl).setName($msg("Active Remote Configuration"));
|
||||
const actions = new Setting(paneEl).setName("Remote Databases");
|
||||
// actions.addButton((button) =>
|
||||
// button
|
||||
// .setButtonText("Change Remote and Setup")
|
||||
// .setCta()
|
||||
// .onClick(async () => {
|
||||
// const setupManager = this.core.getModule(SetupManager);
|
||||
// const originalSettings = getSettingsFromEditingSettings(this.editingSettings);
|
||||
// await setupManager.onSelectServer(originalSettings, UserMode.Update);
|
||||
// })
|
||||
// );
|
||||
|
||||
const el = setting.controlEl.createDiv({});
|
||||
el.setText(`${remoteNameMap[this.editingSettings.remoteType] || " - "}`);
|
||||
setting.addButton((button) =>
|
||||
button
|
||||
.setButtonText("Change Remote and Setup")
|
||||
.setCta()
|
||||
.onClick(async () => {
|
||||
const setupManager = this.core.getModule(SetupManager);
|
||||
const originalSettings = getSettingsFromEditingSettings(this.editingSettings);
|
||||
await setupManager.onSelectServer(originalSettings, UserMode.Update);
|
||||
})
|
||||
// Connection List
|
||||
const listContainer = paneEl.createDiv({ cls: "sls-remote-list" });
|
||||
const syncRemoteConfigurationBuffers = () => {
|
||||
const currentConfigs = cloneRemoteConfigurations(this.core.settings.remoteConfigurations);
|
||||
this.editingSettings.remoteConfigurations = currentConfigs;
|
||||
this.editingSettings.activeConfigurationId = this.core.settings.activeConfigurationId;
|
||||
if (this.initialSettings) {
|
||||
this.initialSettings.remoteConfigurations = cloneRemoteConfigurations(currentConfigs);
|
||||
this.initialSettings.activeConfigurationId = this.core.settings.activeConfigurationId;
|
||||
}
|
||||
};
|
||||
const persistRemoteConfigurations = async (synchroniseActiveRemote: boolean = false) => {
|
||||
await this.services.setting.updateSettings((currentSettings) => {
|
||||
currentSettings.remoteConfigurations = cloneRemoteConfigurations(
|
||||
this.editingSettings.remoteConfigurations
|
||||
);
|
||||
currentSettings.activeConfigurationId = this.editingSettings.activeConfigurationId;
|
||||
if (synchroniseActiveRemote && currentSettings.activeConfigurationId) {
|
||||
const activated = activateRemoteConfiguration(
|
||||
currentSettings,
|
||||
currentSettings.activeConfigurationId
|
||||
);
|
||||
if (activated) {
|
||||
return activated;
|
||||
}
|
||||
}
|
||||
return currentSettings;
|
||||
}, true);
|
||||
|
||||
if (synchroniseActiveRemote) {
|
||||
await this.saveAllDirtySettings();
|
||||
}
|
||||
|
||||
syncRemoteConfigurationBuffers();
|
||||
this.requestUpdate();
|
||||
};
|
||||
const runRemoteSetup = async (
|
||||
baseSettings: ObsidianLiveSyncSettings,
|
||||
remoteType?: typeof REMOTE_COUCHDB | typeof REMOTE_MINIO | typeof REMOTE_P2P
|
||||
): Promise<ObsidianLiveSyncSettings | false> => {
|
||||
const setupManager = this.core.getModule(SetupManager);
|
||||
const dialogManager = setupManager.dialogManager;
|
||||
let targetRemoteType = remoteType;
|
||||
|
||||
if (targetRemoteType === undefined) {
|
||||
const method = await dialogManager.openWithExplicitCancel(SetupRemote);
|
||||
if (method === "cancelled") {
|
||||
return false;
|
||||
}
|
||||
targetRemoteType =
|
||||
method === "bucket" ? REMOTE_MINIO : method === "p2p" ? REMOTE_P2P : REMOTE_COUCHDB;
|
||||
}
|
||||
|
||||
if (targetRemoteType === REMOTE_MINIO) {
|
||||
const bucketConf = await dialogManager.openWithExplicitCancel(SetupRemoteBucket, baseSettings);
|
||||
if (bucketConf === "cancelled" || typeof bucketConf !== "object") {
|
||||
return false;
|
||||
}
|
||||
return { ...baseSettings, ...bucketConf, remoteType: REMOTE_MINIO };
|
||||
}
|
||||
|
||||
if (targetRemoteType === REMOTE_P2P) {
|
||||
const p2pConf = await dialogManager.openWithExplicitCancel(SetupRemoteP2P, baseSettings);
|
||||
if (p2pConf === "cancelled" || typeof p2pConf !== "object") {
|
||||
return false;
|
||||
}
|
||||
return { ...baseSettings, ...p2pConf, remoteType: REMOTE_P2P };
|
||||
}
|
||||
|
||||
const couchConf = await dialogManager.openWithExplicitCancel(SetupRemoteCouchDB, baseSettings);
|
||||
if (couchConf === "cancelled" || typeof couchConf !== "object") {
|
||||
return false;
|
||||
}
|
||||
return { ...baseSettings, ...couchConf, remoteType: REMOTE_COUCHDB };
|
||||
};
|
||||
const createBaseRemoteSettings = (): ObsidianLiveSyncSettings => ({
|
||||
...DEFAULT_SETTINGS,
|
||||
...getSettingsFromEditingSettings(this.editingSettings),
|
||||
});
|
||||
const createNewRemoteSettings = (): ObsidianLiveSyncSettings => ({
|
||||
...DEFAULT_SETTINGS,
|
||||
encrypt: this.editingSettings.encrypt,
|
||||
usePathObfuscation: this.editingSettings.usePathObfuscation,
|
||||
passphrase: this.editingSettings.passphrase,
|
||||
configPassphraseStore: this.editingSettings.configPassphraseStore,
|
||||
});
|
||||
const addRemoteConfiguration = async () => {
|
||||
const name = await this.services.UI.confirm.askString("Remote name", "Display name", "New Remote");
|
||||
if (name === false) {
|
||||
return;
|
||||
}
|
||||
const nextSettings = await runRemoteSetup(createNewRemoteSettings());
|
||||
if (!nextSettings) {
|
||||
return;
|
||||
}
|
||||
const id = createRemoteConfigurationId();
|
||||
const configs = cloneRemoteConfigurations(this.editingSettings.remoteConfigurations);
|
||||
configs[id] = {
|
||||
id,
|
||||
name: name.trim() || "New Remote",
|
||||
uri: serializeRemoteConfiguration(nextSettings),
|
||||
isEncrypted: nextSettings.encrypt,
|
||||
};
|
||||
this.editingSettings.remoteConfigurations = configs;
|
||||
if (!this.editingSettings.activeConfigurationId) {
|
||||
this.editingSettings.activeConfigurationId = id;
|
||||
}
|
||||
await persistRemoteConfigurations(this.editingSettings.activeConfigurationId === id);
|
||||
refreshList();
|
||||
};
|
||||
const importRemoteConfiguration = async () => {
|
||||
const importedURI = await this.services.UI.confirm.askString(
|
||||
"Import connection",
|
||||
"Paste a connection string",
|
||||
""
|
||||
);
|
||||
if (importedURI === false) {
|
||||
return;
|
||||
}
|
||||
|
||||
const trimmedURI = importedURI.trim();
|
||||
if (trimmedURI === "") {
|
||||
return;
|
||||
}
|
||||
|
||||
let parsed: RemoteConfigurationResult;
|
||||
try {
|
||||
parsed = ConnectionStringParser.parse(trimmedURI);
|
||||
} catch (ex) {
|
||||
this.services.API.addLog(`Failed to import remote configuration: ${ex}`, LOG_LEVEL_NOTICE);
|
||||
return;
|
||||
}
|
||||
|
||||
const defaultName = suggestRemoteConfigurationName(parsed);
|
||||
const name = await this.services.UI.confirm.askString("Remote name", "Display name", defaultName);
|
||||
if (name === false) {
|
||||
return;
|
||||
}
|
||||
|
||||
const id = createRemoteConfigurationId();
|
||||
const configs = cloneRemoteConfigurations(this.editingSettings.remoteConfigurations);
|
||||
configs[id] = {
|
||||
id,
|
||||
name: name.trim() || defaultName,
|
||||
uri: ConnectionStringParser.serialize(parsed),
|
||||
isEncrypted: false,
|
||||
};
|
||||
this.editingSettings.remoteConfigurations = configs;
|
||||
if (!this.editingSettings.activeConfigurationId) {
|
||||
this.editingSettings.activeConfigurationId = id;
|
||||
}
|
||||
await persistRemoteConfigurations(this.editingSettings.activeConfigurationId === id);
|
||||
refreshList();
|
||||
};
|
||||
actions.addButton((button) =>
|
||||
setEmojiButton(button, "➕", "Add new connection").onClick(async () => {
|
||||
await addRemoteConfiguration();
|
||||
})
|
||||
);
|
||||
actions.addButton((button) =>
|
||||
setEmojiButton(button, "📥", "Import connection").onClick(async () => {
|
||||
await importRemoteConfiguration();
|
||||
})
|
||||
);
|
||||
const refreshList = () => {
|
||||
listContainer.empty();
|
||||
const configs = this.editingSettings.remoteConfigurations || {};
|
||||
for (const config of Object.values(configs)) {
|
||||
const row = new Setting(listContainer)
|
||||
.setName(config.name)
|
||||
.setDesc(config.uri.split("@").pop() || ""); // Show host part for privacy
|
||||
|
||||
if (config.id === this.editingSettings.activeConfigurationId) {
|
||||
row.nameEl.addClass("sls-active-remote-name");
|
||||
row.nameEl.appendText(" (Active)");
|
||||
}
|
||||
|
||||
row.addButton((btn) =>
|
||||
setEmojiButton(btn, "🔧", "Configure").onClick(async () => {
|
||||
const parsed = ConnectionStringParser.parse(config.uri);
|
||||
const workSettings = createBaseRemoteSettings();
|
||||
if (parsed.type === "couchdb") {
|
||||
workSettings.remoteType = REMOTE_COUCHDB;
|
||||
} else if (parsed.type === "s3") {
|
||||
workSettings.remoteType = REMOTE_MINIO;
|
||||
} else {
|
||||
workSettings.remoteType = REMOTE_P2P;
|
||||
}
|
||||
Object.assign(workSettings, parsed.settings);
|
||||
|
||||
const nextSettings = await runRemoteSetup(workSettings, workSettings.remoteType);
|
||||
if (!nextSettings) {
|
||||
return;
|
||||
}
|
||||
|
||||
const nextConfigs = cloneRemoteConfigurations(this.editingSettings.remoteConfigurations);
|
||||
nextConfigs[config.id] = {
|
||||
...config,
|
||||
uri: serializeRemoteConfiguration(nextSettings),
|
||||
isEncrypted: nextSettings.encrypt,
|
||||
};
|
||||
this.editingSettings.remoteConfigurations = nextConfigs;
|
||||
await persistRemoteConfigurations(config.id === this.editingSettings.activeConfigurationId);
|
||||
refreshList();
|
||||
})
|
||||
);
|
||||
row.addButton((btn) =>
|
||||
btn
|
||||
.setButtonText("✅")
|
||||
.setTooltip("Activate", { delay: 10, placement: "top" })
|
||||
.setDisabled(config.id === this.editingSettings.activeConfigurationId)
|
||||
.onClick(async () => {
|
||||
this.editingSettings.activeConfigurationId = config.id;
|
||||
await persistRemoteConfigurations(true);
|
||||
refreshList();
|
||||
})
|
||||
);
|
||||
|
||||
row.addButton((btn) =>
|
||||
setEmojiButton(btn, "…", "More actions").onClick(() => {
|
||||
const menu = new Menu()
|
||||
.addItem((item) => {
|
||||
item.setTitle("🪪 Rename").onClick(async () => {
|
||||
const nextName = await this.services.UI.confirm.askString(
|
||||
"Remote name",
|
||||
"Display name",
|
||||
config.name
|
||||
);
|
||||
if (nextName === false) {
|
||||
return;
|
||||
}
|
||||
const nextConfigs = cloneRemoteConfigurations(
|
||||
this.editingSettings.remoteConfigurations
|
||||
);
|
||||
nextConfigs[config.id] = {
|
||||
...config,
|
||||
name: nextName.trim() || config.name,
|
||||
};
|
||||
this.editingSettings.remoteConfigurations = nextConfigs;
|
||||
await persistRemoteConfigurations();
|
||||
refreshList();
|
||||
});
|
||||
})
|
||||
.addItem((item) => {
|
||||
item.setTitle("📤 Export").onClick(async () => {
|
||||
await this.services.UI.promptCopyToClipboard(
|
||||
`Remote configuration: ${config.name}`,
|
||||
config.uri
|
||||
);
|
||||
});
|
||||
})
|
||||
.addItem((item) => {
|
||||
item.setTitle("🧬 Duplicate").onClick(async () => {
|
||||
const nextName = await this.services.UI.confirm.askString(
|
||||
"Duplicate remote",
|
||||
"Display name",
|
||||
`${config.name} (Copy)`
|
||||
);
|
||||
if (nextName === false) {
|
||||
return;
|
||||
}
|
||||
|
||||
const nextId = createRemoteConfigurationId();
|
||||
const nextConfigs = cloneRemoteConfigurations(
|
||||
this.editingSettings.remoteConfigurations
|
||||
);
|
||||
nextConfigs[nextId] = {
|
||||
...config,
|
||||
id: nextId,
|
||||
name: nextName.trim() || `${config.name} (Copy)`,
|
||||
};
|
||||
this.editingSettings.remoteConfigurations = nextConfigs;
|
||||
await persistRemoteConfigurations();
|
||||
refreshList();
|
||||
});
|
||||
})
|
||||
.addSeparator()
|
||||
.addItem((item) => {
|
||||
item.setTitle("🗑 Delete").onClick(async () => {
|
||||
const confirmed = await this.services.UI.confirm.askYesNoDialog(
|
||||
`Delete remote configuration '${config.name}'?`,
|
||||
{ title: "Delete Remote Configuration", defaultOption: "No" }
|
||||
);
|
||||
if (confirmed !== "yes") {
|
||||
return;
|
||||
}
|
||||
|
||||
const nextConfigs = cloneRemoteConfigurations(
|
||||
this.editingSettings.remoteConfigurations
|
||||
);
|
||||
delete nextConfigs[config.id];
|
||||
this.editingSettings.remoteConfigurations = nextConfigs;
|
||||
|
||||
let syncActiveRemote = false;
|
||||
if (this.editingSettings.activeConfigurationId === config.id) {
|
||||
const nextActiveId = Object.keys(nextConfigs)[0] || "";
|
||||
this.editingSettings.activeConfigurationId = nextActiveId;
|
||||
syncActiveRemote = nextActiveId !== "";
|
||||
}
|
||||
|
||||
await persistRemoteConfigurations(syncActiveRemote);
|
||||
refreshList();
|
||||
});
|
||||
});
|
||||
const rect = btn.buttonEl.getBoundingClientRect();
|
||||
menu.showAtPosition({ x: rect.left, y: rect.bottom });
|
||||
})
|
||||
);
|
||||
}
|
||||
};
|
||||
refreshList();
|
||||
});
|
||||
}
|
||||
{
|
||||
// eslint-disable-next-line no-constant-condition
|
||||
if (false) {
|
||||
const initialProps = {
|
||||
info: getCouchDBConfigSummary(this.editingSettings),
|
||||
};
|
||||
@@ -143,7 +502,8 @@ export function paneRemoteConfig(
|
||||
);
|
||||
});
|
||||
}
|
||||
{
|
||||
// eslint-disable-next-line no-constant-condition
|
||||
if (false) {
|
||||
const initialProps = {
|
||||
info: getBucketConfigSummary(this.editingSettings),
|
||||
};
|
||||
@@ -178,7 +538,8 @@ export function paneRemoteConfig(
|
||||
);
|
||||
});
|
||||
}
|
||||
{
|
||||
// eslint-disable-next-line no-constant-condition
|
||||
if (false) {
|
||||
const getDevicePeerId = () => this.services.config.getSmallConfig(SETTING_KEY_P2P_DEVICE_NAME) || "";
|
||||
const initialProps = {
|
||||
info: getP2PConfigSummary(this.editingSettings, {
|
||||
|
||||
@@ -275,6 +275,10 @@ export class SetupManager extends AbstractModule {
|
||||
activate: boolean = true,
|
||||
extra: () => void = () => {}
|
||||
): Promise<boolean> {
|
||||
newConf = await this.services.setting.adjustSettings({
|
||||
...this.settings,
|
||||
...newConf,
|
||||
});
|
||||
let userMode = _userMode;
|
||||
if (userMode === UserMode.Unknown) {
|
||||
if (isObjectDifferent(this.settings, newConf, true) === false) {
|
||||
@@ -368,13 +372,8 @@ export class SetupManager extends AbstractModule {
|
||||
* @returns Promise that resolves to true if settings applied successfully, false otherwise
|
||||
*/
|
||||
async applySetting(newConf: ObsidianLiveSyncSettings, userMode: UserMode) {
|
||||
const newSetting = {
|
||||
...this.core.settings,
|
||||
...newConf,
|
||||
};
|
||||
this.core.settings = newSetting;
|
||||
this.services.setting.clearUsedPassphrase();
|
||||
await this.services.setting.saveSettingData();
|
||||
await this.services.setting.applyExternalSettings(newConf, true);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
157
src/modules/features/SetupManager.unit.spec.ts
Normal file
157
src/modules/features/SetupManager.unit.spec.ts
Normal file
@@ -0,0 +1,157 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { DEFAULT_SETTINGS, REMOTE_COUCHDB, type ObsidianLiveSyncSettings } from "../../lib/src/common/types";
|
||||
import { SettingService } from "../../lib/src/services/base/SettingService";
|
||||
import { ServiceContext } from "../../lib/src/services/base/ServiceBase";
|
||||
|
||||
vi.mock("./SetupWizard/dialogs/Intro.svelte", () => ({ default: {} }));
|
||||
vi.mock("./SetupWizard/dialogs/SelectMethodNewUser.svelte", () => ({ default: {} }));
|
||||
vi.mock("./SetupWizard/dialogs/SelectMethodExisting.svelte", () => ({ default: {} }));
|
||||
vi.mock("./SetupWizard/dialogs/ScanQRCode.svelte", () => ({ default: {} }));
|
||||
vi.mock("./SetupWizard/dialogs/UseSetupURI.svelte", () => ({ default: {} }));
|
||||
vi.mock("./SetupWizard/dialogs/OutroNewUser.svelte", () => ({ default: {} }));
|
||||
vi.mock("./SetupWizard/dialogs/OutroExistingUser.svelte", () => ({ default: {} }));
|
||||
vi.mock("./SetupWizard/dialogs/OutroAskUserMode.svelte", () => ({ default: {} }));
|
||||
vi.mock("./SetupWizard/dialogs/SetupRemote.svelte", () => ({ default: {} }));
|
||||
vi.mock("./SetupWizard/dialogs/SetupRemoteCouchDB.svelte", () => ({ default: {} }));
|
||||
vi.mock("./SetupWizard/dialogs/SetupRemoteBucket.svelte", () => ({ default: {} }));
|
||||
vi.mock("./SetupWizard/dialogs/SetupRemoteP2P.svelte", () => ({ default: {} }));
|
||||
vi.mock("./SetupWizard/dialogs/SetupRemoteE2EE.svelte", () => ({ default: {} }));
|
||||
|
||||
vi.mock("../../lib/src/API/processSetting.ts", () => ({
|
||||
decodeSettingsFromQRCodeData: vi.fn(),
|
||||
}));
|
||||
|
||||
import { decodeSettingsFromQRCodeData } from "../../lib/src/API/processSetting.ts";
|
||||
import { SetupManager, UserMode } from "./SetupManager";
|
||||
|
||||
class TestSettingService extends SettingService<ServiceContext> {
|
||||
protected setItem(_key: string, _value: string): void {}
|
||||
protected getItem(_key: string): string {
|
||||
return "";
|
||||
}
|
||||
protected deleteItem(_key: string): void {}
|
||||
protected saveData(_setting: ObsidianLiveSyncSettings): Promise<void> {
|
||||
return Promise.resolve();
|
||||
}
|
||||
protected loadData(): Promise<ObsidianLiveSyncSettings | undefined> {
|
||||
return Promise.resolve(undefined);
|
||||
}
|
||||
}
|
||||
|
||||
function createLegacyRemoteSetting(): ObsidianLiveSyncSettings {
|
||||
return {
|
||||
...DEFAULT_SETTINGS,
|
||||
remoteConfigurations: {},
|
||||
activeConfigurationId: "",
|
||||
remoteType: REMOTE_COUCHDB,
|
||||
couchDB_URI: "http://localhost:5984",
|
||||
couchDB_USER: "user",
|
||||
couchDB_PASSWORD: "password",
|
||||
couchDB_DBNAME: "vault",
|
||||
};
|
||||
}
|
||||
|
||||
function createSetupManager() {
|
||||
const setting = new TestSettingService(new ServiceContext(), {
|
||||
APIService: {
|
||||
getSystemVaultName: vi.fn(() => "vault"),
|
||||
getAppID: vi.fn(() => "app"),
|
||||
confirm: {
|
||||
askString: vi.fn(() => Promise.resolve("")),
|
||||
},
|
||||
addLog: vi.fn(),
|
||||
addCommand: vi.fn(),
|
||||
registerWindow: vi.fn(),
|
||||
addRibbonIcon: vi.fn(),
|
||||
registerProtocolHandler: vi.fn(),
|
||||
} as any,
|
||||
});
|
||||
setting.settings = {
|
||||
...DEFAULT_SETTINGS,
|
||||
remoteConfigurations: {},
|
||||
activeConfigurationId: "",
|
||||
};
|
||||
vi.spyOn(setting, "saveSettingData").mockResolvedValue();
|
||||
|
||||
const dialogManager = {
|
||||
openWithExplicitCancel: vi.fn(),
|
||||
open: vi.fn(),
|
||||
};
|
||||
const services = {
|
||||
API: {
|
||||
addLog: vi.fn(),
|
||||
addCommand: vi.fn(),
|
||||
registerWindow: vi.fn(),
|
||||
addRibbonIcon: vi.fn(),
|
||||
registerProtocolHandler: vi.fn(),
|
||||
},
|
||||
UI: {
|
||||
dialogManager,
|
||||
},
|
||||
setting,
|
||||
} as any;
|
||||
const core: any = {
|
||||
_services: services,
|
||||
rebuilder: {
|
||||
scheduleRebuild: vi.fn(() => Promise.resolve()),
|
||||
scheduleFetch: vi.fn(() => Promise.resolve()),
|
||||
},
|
||||
};
|
||||
Object.defineProperty(core, "services", {
|
||||
get() {
|
||||
return services;
|
||||
},
|
||||
});
|
||||
Object.defineProperty(core, "settings", {
|
||||
get() {
|
||||
return setting.settings;
|
||||
},
|
||||
set(value: ObsidianLiveSyncSettings) {
|
||||
setting.settings = value;
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
manager: new SetupManager(core),
|
||||
setting,
|
||||
dialogManager,
|
||||
core,
|
||||
};
|
||||
}
|
||||
|
||||
describe("SetupManager", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("onUseSetupURI should normalise imported legacy remote settings before applying", async () => {
|
||||
const { manager, setting, dialogManager } = createSetupManager();
|
||||
dialogManager.openWithExplicitCancel
|
||||
.mockResolvedValueOnce(createLegacyRemoteSetting())
|
||||
.mockResolvedValueOnce("compatible-existing-user");
|
||||
|
||||
const result = await manager.onUseSetupURI(UserMode.Unknown, "mock-config://settings");
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(setting.currentSettings().remoteConfigurations["legacy-couchdb"]?.uri).toContain(
|
||||
"sls+http://user:password@localhost:5984"
|
||||
);
|
||||
expect(setting.currentSettings().activeConfigurationId).toBe("legacy-couchdb");
|
||||
});
|
||||
|
||||
it("decodeQR should normalise imported legacy remote settings before applying", async () => {
|
||||
const { manager, setting, dialogManager } = createSetupManager();
|
||||
vi.mocked(decodeSettingsFromQRCodeData).mockReturnValue(createLegacyRemoteSetting());
|
||||
dialogManager.openWithExplicitCancel.mockResolvedValueOnce("compatible-existing-user");
|
||||
|
||||
const result = await manager.decodeQR("qr-data");
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(decodeSettingsFromQRCodeData).toHaveBeenCalledWith("qr-data");
|
||||
expect(setting.currentSettings().remoteConfigurations["legacy-couchdb"]?.uri).toContain(
|
||||
"sls+http://user:password@localhost:5984"
|
||||
);
|
||||
expect(setting.currentSettings().activeConfigurationId).toBe("legacy-couchdb");
|
||||
});
|
||||
});
|
||||
@@ -176,7 +176,7 @@ export async function adjustSettingToRemote(
|
||||
...config,
|
||||
...Object.fromEntries(differentItems),
|
||||
} satisfies ObsidianLiveSyncSettings;
|
||||
await host.services.setting.applyPartial(config, true);
|
||||
await host.services.setting.applyExternalSettings(config, true);
|
||||
log("Remote configuration applied.", LOG_LEVEL_NOTICE);
|
||||
canProceed = true;
|
||||
const updatedConfig = host.services.setting.currentSettings();
|
||||
|
||||
@@ -49,6 +49,10 @@ const createSettingServiceMock = () => {
|
||||
return {
|
||||
settings,
|
||||
currentSettings: vi.fn(() => settings),
|
||||
applyExternalSettings: vi.fn((partial: any, _feedback?: boolean) => {
|
||||
Object.assign(settings, partial);
|
||||
return Promise.resolve();
|
||||
}),
|
||||
applyPartial: vi.fn((partial: any, _feedback?: boolean) => {
|
||||
Object.assign(settings, partial);
|
||||
return Promise.resolve();
|
||||
@@ -552,7 +556,7 @@ describe("Red Flag Feature", () => {
|
||||
|
||||
await adjustSettingToRemote(host as any, createLoggerMock(), config);
|
||||
expect(host.mocks.ui.confirm.askSelectStringDialogue).toHaveBeenCalled();
|
||||
expect(host.mocks.setting.applyPartial).toHaveBeenCalled();
|
||||
expect(host.mocks.setting.applyExternalSettings).toHaveBeenCalled();
|
||||
}
|
||||
);
|
||||
const mismatchAcceptedKeys = Object.keys(TweakValuesRecommendedTemplate).filter(
|
||||
@@ -579,7 +583,7 @@ describe("Red Flag Feature", () => {
|
||||
|
||||
await adjustSettingToRemote(host as any, createLoggerMock(), config);
|
||||
|
||||
expect(host.mocks.setting.applyPartial).toHaveBeenCalled();
|
||||
expect(host.mocks.setting.applyExternalSettings).toHaveBeenCalled();
|
||||
expect(host.mocks.ui.confirm.askSelectStringDialogue).not.toHaveBeenCalled();
|
||||
}
|
||||
);
|
||||
|
||||
@@ -3,6 +3,31 @@ set -e
|
||||
script_dir=$(dirname "$0")
|
||||
webpeer_dir=$script_dir/../../src/apps/webpeer
|
||||
|
||||
docker run -d --name relay-test -p 4000:8080 scsibug/nostr-rs-relay:latest
|
||||
docker run -d --name relay-test -p 4000:7777 \
|
||||
--tmpfs /app/strfry-db:rw,size=256m \
|
||||
--entrypoint sh \
|
||||
ghcr.io/hoytech/strfry:latest \
|
||||
-lc 'cat > /tmp/strfry.conf <<"EOF"
|
||||
db = "./strfry-db/"
|
||||
|
||||
relay {
|
||||
bind = "0.0.0.0"
|
||||
port = 7777
|
||||
nofiles = 100000
|
||||
|
||||
info {
|
||||
name = "livesync test relay"
|
||||
description = "local relay for livesync p2p tests"
|
||||
}
|
||||
|
||||
maxWebsocketPayloadSize = 131072
|
||||
autoPingSeconds = 55
|
||||
|
||||
writePolicy {
|
||||
plugin = ""
|
||||
}
|
||||
}
|
||||
EOF
|
||||
exec /app/strfry --config /tmp/strfry.conf relay'
|
||||
npm run --prefix $webpeer_dir build
|
||||
docker run -d --name webpeer-test -p 8081:8043 -v $webpeer_dir/dist:/srv/http pierrezemb/gostatic
|
||||
111
updates.md
111
updates.md
@@ -3,6 +3,32 @@ 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.
|
||||
|
||||
## 0.25.56+patched2
|
||||
|
||||
5th April, 2026
|
||||
|
||||
Beta release tagging is now changed to +patched1, +patched2, and so on.
|
||||
|
||||
### Translations
|
||||
|
||||
- Russian translation has been added! Thank you so much for the contribution!
|
||||
|
||||
### Fixed
|
||||
|
||||
- No unexpected error (about a replicator) during the early stage of initialisation.
|
||||
- Now error messages are kept hidden if the show status inside the editor is disabled.
|
||||
|
||||
### New features
|
||||
|
||||
- Now we can configure multiple Remote Databases of the same type, e.g, multiple CouchDBs or S3 remotes.
|
||||
- We can switch between multiple Remote Databases in the settings dialogue.
|
||||
|
||||
### CLI
|
||||
|
||||
#### Fixed
|
||||
|
||||
- Replication progress is now correctly saved and restored in the CLI.
|
||||
|
||||
## ~~0.25.55~~ 0.25.56
|
||||
|
||||
30th March, 2026
|
||||
@@ -206,91 +232,6 @@ As a result of recent refactoring, we are able to write tests more easily now!
|
||||
- `ModuleObsidianAPI` has been removed and implemented in `APIService` and `RemoteService`.
|
||||
- Now `APIService` is responsible for the network-online-status, not `databaseService.managers.networkManager`.
|
||||
|
||||
## 0.25.44
|
||||
|
||||
24th February, 2026
|
||||
|
||||
This release represents a significant architectural overhaul of the plug-in, focusing on modularity, testability, and stability. While many changes are internal, they pave the way for more robust features and easier maintenance.
|
||||
However, as this update is very substantial, please do feel free to let me know if you encounter any issues.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Ignore files (e.g., `.ignore`) are now handled efficiently.
|
||||
- Replication & Database:
|
||||
- Replication statistics are now correctly reset after switching replicators.
|
||||
- Fixed `File already exists` for .md files has been merged (PR #802) So thanks @waspeer for the contribution!
|
||||
|
||||
### Improved
|
||||
|
||||
- Now we can configure network-error banners as icons, or hide them completely with the new `Network Warning Style` setting in the `General` pane of the settings dialogue. (#770, PR #804)
|
||||
- Thanks so much to @A-wry!
|
||||
|
||||
### Refactored
|
||||
|
||||
#### Architectural Overhaul:
|
||||
|
||||
- A major transition from Class-based Modules to a Service/Middleware architecture has begun.
|
||||
- Many modules (for example, `ModulePouchDB`, `ModuleLocalDatabaseObsidian`, `ModuleKeyValueDB`) have been removed or integrated into specific Services (`database`, `keyValueDB`, etc.).
|
||||
- Reduced reliance on dynamic binding and inverted dependencies; dependencies are now explicit.
|
||||
- `ObsidianLiveSyncPlugin` properties (`replicator`, `localDatabase`, `storageAccess`, etc.) have been moved to their respective services for better separation of concerns.
|
||||
- In this refactoring, the Service will henceforth, as a rule, cease to use setHandler, that is to say, simple lazy binding.
|
||||
- They will be implemented directly in the service.
|
||||
- However, not everything will be middlewarised. Modules that maintain state or make decisions based on the results of multiple handlers are permitted.
|
||||
- Lifecycle:
|
||||
- Application LifeCycle now starts in `Main` rather than `ServiceHub` or `ObsidianMenuModule`, ensuring smoother startup coordination.
|
||||
|
||||
#### New Services & Utilities:
|
||||
|
||||
- Added a `control` service to orchestrate other services (for example, handling stop/start logic during settings realisation).
|
||||
- Added `UnresolvedErrorManager` to handle and display unresolved errors in a unified way.
|
||||
- Added `logUtils` to unify logging injection and formatting.
|
||||
- `VaultService.isTargetFile` now uses multiple, distinct checkers for better extensibility.
|
||||
|
||||
#### Code Separation:
|
||||
|
||||
- Separated Obsidian-specific logic from base logic for `StorageEventManager` and `FileAccess` modules.
|
||||
- Moved reactive state values and statistics from the main plug-in instance to the services responsible for them.
|
||||
|
||||
#### Internal Cleanups:
|
||||
|
||||
- Many functions have been renamed for clarity (for example, `_isTargetFileByLocalDB` is now `_isTargetAcceptedByLocalDB`).
|
||||
- Added `override` keywords to overridden items and removed dynamic binding for clearer code inheritance.
|
||||
- Moved common functions to the common library.
|
||||
|
||||
#### Dependencies:
|
||||
|
||||
- Bumped dependencies simply to a point where they can be considered problem-free (by human-powered-artefacts-diff).
|
||||
- Svelte, terser, and more something will be bumped later. They have a significant impact on the diff and paint it totally.
|
||||
- You may be surprised, but when I bump the library, I am actually checking for any unintended code.
|
||||
|
||||
## 0.25.43
|
||||
|
||||
5th, February, 2026
|
||||
|
||||
### Fixed
|
||||
|
||||
- Encryption/decryption issues when using Object Storage as remote have been fixed.
|
||||
- Now the plug-in falls back to V1 encryption/decryption when V2 fails (if not configured as ForceV1).
|
||||
- This may fix the issue reported in #772.
|
||||
|
||||
### Notice
|
||||
|
||||
Quite a few packages have been updated in this release. Please report if you find any unexpected behaviour after this update.
|
||||
|
||||
## 0.25.42
|
||||
|
||||
2nd, February, 2026
|
||||
|
||||
This release is identical to 0.25.41-patched-3, except for the version number.
|
||||
|
||||
### Refactored
|
||||
|
||||
- Now the service context is `protected` instead of `private` in `ServiceBase`.
|
||||
- This change allows derived classes to access the context directly.
|
||||
- Some dynamically bound services have been moved to services for better dependency management.
|
||||
- `WebPeer` has been moved to the main repository from the sub repository `livesync-commonlib` for correct dependency management.
|
||||
- Migrated from the outdated, unstable platform abstraction layer to services.
|
||||
- A bit more services will be added in the future for better maintainability.
|
||||
|
||||
Full notes are in
|
||||
[updates_old.md](https://github.com/vrtmrz/obsidian-livesync/blob/main/updates_old.md).
|
||||
|
||||
@@ -3,6 +3,47 @@ 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.
|
||||
|
||||
|
||||
|
||||
## ~~0.25.55~~ 0.25.56
|
||||
|
||||
30th March, 2026
|
||||
|
||||
### Fixed
|
||||
|
||||
- No longer `Peer-to-Peer Sync is not enabled. We cannot open a new connection.` error occurs when we have not enabled P2P sync and are not expected to use it (#830).
|
||||
|
||||
### CLI
|
||||
|
||||
- Fixed incomplete localStorage support in the CLI (#831). Thank you so much @rewse !
|
||||
- Fixed the issue where the CLI could not be connected to the remote which had been locked once (#833), also thanks to @rewse !
|
||||
|
||||
## 0.25.54
|
||||
|
||||
18th March, 2026
|
||||
|
||||
### Fixed
|
||||
|
||||
- Remote storage size check now works correctly again (#818).
|
||||
- Some buttons on the settings dialogue now respond correctly again (#827).
|
||||
|
||||
### Refactored
|
||||
|
||||
- P2P replicator has been refactored to be a little more robust and easier to understand.
|
||||
- Delete items which are no longer used that might cause potential problems
|
||||
|
||||
### CLI
|
||||
|
||||
- Fixed the corrupted display of the help message.
|
||||
- Remove some unnecessary code.
|
||||
|
||||
### WebApp
|
||||
|
||||
- Fixed the issue where the detail level was not being applied in the log pane.
|
||||
- Pop-ups are now shown.
|
||||
- Add coverage for the test.
|
||||
- Pop-ups are now shown in the web app as well.
|
||||
|
||||
## 0.25.53
|
||||
|
||||
17th March, 2026
|
||||
|
||||
@@ -5,6 +5,23 @@ import path from "path";
|
||||
import dotenv from "dotenv";
|
||||
import { grantClipboardPermissions, writeHandoffFile, readHandoffFile } from "./test/lib/commands";
|
||||
|
||||
// P2P test environment variables
|
||||
// Configure these in .env or .test.env, or inject via shell before running tests.
|
||||
// Shell-injected values take precedence over dotenv files.
|
||||
//
|
||||
// Required:
|
||||
// P2P_TEST_ROOM_ID - Shared room identifier for peers to discover each other
|
||||
// P2P_TEST_PASSPHRASE - Encryption passphrase shared between test peers
|
||||
//
|
||||
// Optional:
|
||||
// P2P_TEST_HOST_PEER_NAME - Name used to identify the host peer (default varies)
|
||||
// P2P_TEST_RELAY - Nostr relay server URL used for peer signalling/discovery
|
||||
// P2P_TEST_APP_ID - Application ID scoping the P2P session
|
||||
// P2P_TEST_HANDOFF_FILE - File path used to pass state between up/down test phases
|
||||
//
|
||||
// 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 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)
|
||||
|
||||
Reference in New Issue
Block a user