mirror of
https://github.com/sotashimozono/obsidian-remote-ssh.git
synced 2026-07-22 06:52:07 +00:00
Merge pull request #436 from sotashimozono/next
release: next → main — stage 1.1.6 (do not merge yet)
This commit is contained in:
commit
9ebc520464
63 changed files with 3646 additions and 534 deletions
2
.github/workflows/bench.yml
vendored
2
.github/workflows/bench.yml
vendored
|
|
@ -63,7 +63,7 @@ jobs:
|
|||
REMOTE_SSH_PERF: '1'
|
||||
REMOTE_SSH_PERF_BRANCH: ${{ github.head_ref || github.ref_name }}
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v7
|
||||
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
|
|
|
|||
14
.github/workflows/ci.yml
vendored
14
.github/workflows/ci.yml
vendored
|
|
@ -19,7 +19,7 @@ jobs:
|
|||
run:
|
||||
working-directory: plugin
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: '20'
|
||||
|
|
@ -51,7 +51,7 @@ jobs:
|
|||
run:
|
||||
working-directory: plugin
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: '20'
|
||||
|
|
@ -79,7 +79,7 @@ jobs:
|
|||
NODE_OPTIONS: --max-old-space-size=6144
|
||||
- name: Upload coverage to Codecov
|
||||
if: matrix.os == 'ubuntu-latest'
|
||||
uses: codecov/codecov-action@v6
|
||||
uses: codecov/codecov-action@v7
|
||||
with:
|
||||
# Explicit slug avoids fork/PR ambiguity — Codecov's auto-
|
||||
# detection can mis-route uploads from PRs opened from forks.
|
||||
|
|
@ -118,7 +118,7 @@ jobs:
|
|||
run:
|
||||
working-directory: plugin
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: ${{ matrix.node-version }}
|
||||
|
|
@ -144,7 +144,7 @@ jobs:
|
|||
run:
|
||||
working-directory: plugin
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: '20'
|
||||
|
|
@ -215,7 +215,7 @@ jobs:
|
|||
run:
|
||||
working-directory: server
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/setup-go@v6
|
||||
with:
|
||||
# Bumped 1.22 → 1.25 to follow `server/go.mod`'s `go 1.25.0`
|
||||
|
|
@ -239,7 +239,7 @@ jobs:
|
|||
name: Deploy docker image build
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v7
|
||||
- uses: docker/setup-buildx-action@v4
|
||||
- name: Build deploy/docker image
|
||||
uses: docker/build-push-action@v7
|
||||
|
|
|
|||
2
.github/workflows/commitlint.yml
vendored
2
.github/workflows/commitlint.yml
vendored
|
|
@ -42,7 +42,7 @@ jobs:
|
|||
run: echo "Sync PR (main → next) — commitlint skipped (commit range includes autogenerated merge titles + already-linted promotion content)."
|
||||
|
||||
- if: github.head_ref != 'main'
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
# Need full history so commitlint can walk the PR's
|
||||
# commit range (base..head). Fetch-depth: 0 is overkill
|
||||
|
|
|
|||
4
.github/workflows/demo-capture.yml
vendored
4
.github/workflows/demo-capture.yml
vendored
|
|
@ -29,7 +29,7 @@ jobs:
|
|||
run:
|
||||
working-directory: plugin
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v7
|
||||
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
|
|
@ -66,7 +66,7 @@ jobs:
|
|||
# which then survived in the cache and broke every later run.
|
||||
- name: Cache Obsidian AppImage
|
||||
id: obsidian-cache
|
||||
uses: actions/cache@v5
|
||||
uses: actions/cache@v6
|
||||
with:
|
||||
path: ~/obsidian/Obsidian.AppImage
|
||||
key: obsidian-appimage-1.8.9-v2
|
||||
|
|
|
|||
2
.github/workflows/docs.yml
vendored
2
.github/workflows/docs.yml
vendored
|
|
@ -23,7 +23,7 @@ jobs:
|
|||
build-deploy:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
# Full history so Quartz CreatedModifiedDate plugin can read git log
|
||||
fetch-depth: 0
|
||||
|
|
|
|||
4
.github/workflows/e2e.yml
vendored
4
.github/workflows/e2e.yml
vendored
|
|
@ -68,7 +68,7 @@ jobs:
|
|||
run:
|
||||
working-directory: plugin
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v7
|
||||
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
|
|
@ -101,7 +101,7 @@ jobs:
|
|||
# run that hit the cache.
|
||||
- name: Cache Obsidian AppImage
|
||||
id: obsidian-cache
|
||||
uses: actions/cache@v5
|
||||
uses: actions/cache@v6
|
||||
with:
|
||||
path: ~/obsidian/Obsidian.AppImage
|
||||
key: obsidian-appimage-1.8.9-v2
|
||||
|
|
|
|||
2
.github/workflows/integration.yml
vendored
2
.github/workflows/integration.yml
vendored
|
|
@ -23,7 +23,7 @@ jobs:
|
|||
run:
|
||||
working-directory: plugin
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v7
|
||||
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
|
|
|
|||
25
.github/workflows/release.yml
vendored
25
.github/workflows/release.yml
vendored
|
|
@ -53,7 +53,7 @@ jobs:
|
|||
is_beta: ${{ steps.ver.outputs.is_beta }}
|
||||
should_release: ${{ steps.gate.outputs.should_release }}
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
|
|
@ -120,7 +120,7 @@ jobs:
|
|||
run:
|
||||
working-directory: plugin
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: '20'
|
||||
|
|
@ -153,7 +153,7 @@ jobs:
|
|||
outputs:
|
||||
manifest_sha256: ${{ steps.manifest.outputs.sha256 }}
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/setup-go@v6
|
||||
with:
|
||||
go-version: '1.25'
|
||||
|
|
@ -165,6 +165,23 @@ jobs:
|
|||
- name: Build cross-platform daemon binaries
|
||||
run: make -C server cross
|
||||
|
||||
- name: Assert the linux/amd64 daemon is statically linked (glibc-independent)
|
||||
# The host-native linux/amd64 target is the only one Go builds with CGO
|
||||
# auto-ENABLED; a dynamically-linked build dies on older hosts with
|
||||
# "GLIBC_x.y not found" — the daemon then never writes its token, RPC
|
||||
# times out, and the client silently drops to SFTP. Fail the release
|
||||
# loudly if a CGO regression sneaks back in (server/Makefile sets
|
||||
# CGO_ENABLED=0 to keep it static).
|
||||
run: |
|
||||
set -euo pipefail
|
||||
f=server/dist/obsidian-remote-server-linux-amd64
|
||||
info=$(file "$f")
|
||||
echo "$info"
|
||||
if echo "$info" | grep -q "dynamically linked"; then
|
||||
echo "::error::$f is dynamically linked — build with CGO_ENABLED=0 (server/Makefile)"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Build daemon-manifest.json (sha256 set)
|
||||
id: manifest
|
||||
working-directory: server/dist
|
||||
|
|
@ -229,7 +246,7 @@ jobs:
|
|||
if: needs.precheck.outputs.should_release == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
fetch-depth: 0 # needed for changelog generation
|
||||
|
||||
|
|
|
|||
2
.github/workflows/replay.yml
vendored
2
.github/workflows/replay.yml
vendored
|
|
@ -31,7 +31,7 @@ jobs:
|
|||
run:
|
||||
working-directory: plugin
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
# Need full history + tags so `git describe --tags
|
||||
# --abbrev=0 origin/main` resolves the latest release.
|
||||
|
|
|
|||
10
.github/workflows/security.yml
vendored
10
.github/workflows/security.yml
vendored
|
|
@ -52,7 +52,7 @@ jobs:
|
|||
run:
|
||||
working-directory: plugin
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: '20'
|
||||
|
|
@ -75,7 +75,7 @@ jobs:
|
|||
run:
|
||||
working-directory: server
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/setup-go@v6
|
||||
with:
|
||||
go-version: '1.25'
|
||||
|
|
@ -100,7 +100,7 @@ jobs:
|
|||
name: trivy fs scan
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v7
|
||||
- name: Trivy filesystem scan → SARIF
|
||||
# `@master` is what aquasecurity/trivy-action's own README
|
||||
# recommends for "always-latest stable"; their tag namespace
|
||||
|
|
@ -132,7 +132,7 @@ jobs:
|
|||
run:
|
||||
working-directory: plugin
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: '20'
|
||||
|
|
@ -165,7 +165,7 @@ jobs:
|
|||
run:
|
||||
working-directory: server
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/setup-go@v6
|
||||
with:
|
||||
go-version: '1.25'
|
||||
|
|
|
|||
2
.github/workflows/sync-main-to-next.yml
vendored
2
.github/workflows/sync-main-to-next.yml
vendored
|
|
@ -37,7 +37,7 @@ jobs:
|
|||
name: Open sync PR (main → next)
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
|
|
|
|||
2
.github/workflows/version-check.yml
vendored
2
.github/workflows/version-check.yml
vendored
|
|
@ -69,7 +69,7 @@ jobs:
|
|||
run: echo "Auto-generated PR (actor=${{ github.actor }}, head=${{ github.head_ref }}) — version-check is a no-op."
|
||||
|
||||
- if: steps.sync_check.outputs.is_sync != 'true'
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
fetch-depth: 0 # need both branches for `git show`
|
||||
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ Design specs for the major subsystems — the **why** behind decisions, not just
|
|||
|
||||
| Doc | What it covers |
|
||||
|---|---|
|
||||
| [[shadow-vault\|Shadow vault]] | The "local Obsidian vault that mirrors remote" model — shadow lifecycle, file routing, sync events |
|
||||
| [[shadow-vault\|Shadow vault]] | The shadow-vault model (a local vault whose patched adapter serves the remote virtually) — lifecycle, file routing, change events |
|
||||
| [[perf\|Performance]] | Sync latency budget, perf bench, the per-merge baseline tracking on `perf-baseline` branch |
|
||||
| [[collab\|Collaboration]] | Multi-client editing, conflict handling, the per-client `.obsidian/user/<id>/` workspace partition |
|
||||
| [[release-pipeline\|Release & deploy pipeline]] | Two-channel release model, `release.yml` signing flow, sync workflow, branch-aware lint/version-check, plugin-side deploy lifecycle |
|
||||
|
|
@ -28,9 +28,9 @@ The daemon is the only thing that touches the actual vault files. The plugin nev
|
|||
|
||||
### "Shadow vault is a real local vault"
|
||||
|
||||
Obsidian's plugin API is opinionated about vault paths. Rather than forking Obsidian's vault layer to be remote-aware (a multi-month investment), the shadow vault model just creates a real vault on your local disk and syncs files. Every Obsidian feature works without modification.
|
||||
Obsidian's plugin API is opinionated about vault paths. Rather than forking Obsidian's vault layer to be remote-aware (a multi-month investment), the shadow vault model creates a real local vault but patches its adapter so every file op goes to the remote — the notes are never copied to local disk (only `.obsidian/` lives there). Every Obsidian feature works without modification.
|
||||
|
||||
The cost: storage on your local machine grows with what you have opened. The plugin keeps a hot cache of recently-touched files; LRU eviction frees space for files not opened in N days.
|
||||
The cost: file ops need the remote reachable and pay per-op SSH/RPC latency. An in-memory cache (capped) speeds re-reads of recently-touched files.
|
||||
|
||||
### v1.x stability promises
|
||||
|
||||
|
|
|
|||
|
|
@ -8,13 +8,13 @@ description: "What happens when you click Connect: SSH handshake, daemon auto-de
|
|||
|
||||
## The shadow vault model
|
||||
|
||||
obsidian-remote-ssh does not edit your remote files directly through Obsidian's vault layer. Instead it:
|
||||
obsidian-remote-ssh doesn't keep a synced copy of your vault — the remote is the single source of truth. Instead it:
|
||||
|
||||
1. Creates a **local shadow vault** under `~/.obsidian-remote/vaults/<profile-id>/`. This is a real Obsidian vault on your local disk.
|
||||
2. Mirrors the remote vault's files into that shadow vault (lazily — large files are fetched on first open).
|
||||
3. Watches both sides for changes and syncs them through the SSH-tunneled daemon RPC.
|
||||
1. Creates a **local shadow vault** under `~/.obsidian-remote/vaults/<profile-id>/` — a real Obsidian vault dir, but only `.obsidian/` config lives on disk.
|
||||
2. Patches that vault's file adapter so every read/write goes **directly to the remote** over the SSH-tunneled daemon (or SFTP). Your notes are served virtually — never copied to local disk.
|
||||
3. The daemon's `fs.watch` pushes remote changes back, so the file explorer and open editors update live (~1 s).
|
||||
|
||||
Result: Obsidian thinks it's editing a local vault. All Obsidian features (Dataview, Templater, Excalidraw, …) work because they ARE working against a local vault. The "remote-ness" lives in the sync layer, invisible to most plugins.
|
||||
Result: Obsidian thinks it's editing a local vault, so all features (Dataview, Templater, Excalidraw, …) work — but every file op actually hits the remote. (Plugins that bypass the vault API and use Node `fs` directly are the exception — see #429.)
|
||||
|
||||
See [[shadow-vault|Shadow vault architecture]] for the full design.
|
||||
|
||||
|
|
|
|||
|
|
@ -95,7 +95,7 @@ The default-recommended transport mode — plugin auto-deploys the daemon and sp
|
|||
The current factory default transport — uses SSH's SFTP subsystem directly (no daemon). Higher per-op latency, no `fs.watch`, but works on hosts where you cannot deploy a binary.
|
||||
|
||||
**Shadow vault**
|
||||
A local-disk Obsidian vault under `~/.obsidian-remote/vaults/<profile-id>/` that mirrors the remote vault. Obsidian thinks it's editing local files; the plugin syncs them to the remote via the daemon. See [[en/architecture/shadow-vault|Shadow vault architecture]].
|
||||
A local Obsidian vault under `~/.obsidian-remote/vaults/<profile-id>/` whose adapter sends every read/write straight to the remote — the single source of truth, **not** a synced copy. Only `.obsidian/` is on disk; notes are virtual. Exception: plugins writing via raw Node `fs` instead of the vault API stay local (#429). See [[en/architecture/shadow-vault|Shadow vault architecture]].
|
||||
|
||||
**Sync workflow** — *`.github/workflows/sync-main-to-next.yml`*
|
||||
After every push to `main`, opens a PR `main → next` and enables auto-merge so the histories rejoin. Prevents drift between channels.
|
||||
|
|
|
|||
|
|
@ -38,8 +38,8 @@ Numbers in the table reflect a `~/work/VaultDev`-class remote
|
|||
| Plugin | Access pattern | Status | Notes |
|
||||
|---|---|---|---|
|
||||
| Dataview | reads every MD file via `app.vault.cachedRead` for the index, then queries against `app.metadataCache` | ✅ verified-by-harness (#124 F11) | Initial index build does N reads on connect. ReadCache absorbs subsequent queries. Watch for noticeable startup delay on bigger vaults. F11 harness scenario (`plugin/tests/compat/dataview.test.ts`) drives `dv.pages()`-shape queries against a 5-page fixture and asserts frontmatter scalars + aggregations round-trip through `metadataCache.getFileCache()`. |
|
||||
| Templater | reads templates from a configured folder, evaluates JS, writes through `app.vault.modify` / `create`. `tp.file.path(false)` and the `child_process.exec` cwd path read `adapter.basePath`. | ✅ verified-by-harness (#124 F12) | `basePath` now resolves to the shadow-vault local root via #170, so `tp.file.path(false)` returns a path whose `fs.readFileSync` finds mirrored content. JS user functions that import Node `fs` and write under `basePath` land in the shadow dir, which propagates to the remote. F12 harness scenario (`plugin/tests/compat/templater.test.ts`) drives `tp.file.create_new` against 3 fixture templates (date placeholder, frontmatter + title, no-date) and asserts `vault.create` + `vault.modify` round-trip with `metadataCache` reflecting the new frontmatter. |
|
||||
| Kanban | stores boards as MD files with YAML frontmatter; standard vault read/write on every drag. Clipboard image paste joins `(adapter as any).basePath` with the attachment path and calls `fs.copyFile`. | 🟡 expected (smoke pending after #170) | Each card move = 1 write. Network latency may show as a noticeable lag on big boards; otherwise fine. Clipboard paste is fixed by #170: `basePath` now resolves to the shadow-vault local root, so `fs.copyFile` lands in the shadow dir. |
|
||||
| Templater | reads templates from a configured folder, evaluates JS, writes through `app.vault.modify` / `create`. `tp.file.path(false)` and the `child_process.exec` cwd path read `adapter.basePath`. | ✅ verified-by-harness (#124 F12) | `vault.create` / `vault.modify` round-trips — F12 harness (`plugin/tests/compat/templater.test.ts`) drives `tp.file.create_new` against 3 fixtures and asserts frontmatter round-trips via `metadataCache`. #170 makes `tp.file.path(false)` / `exec` cwd non-`undefined`, but a JS user script using Node `fs` only sees the local shadow copy — those writes don't reach the remote (#429). |
|
||||
| Kanban | stores boards as MD files with YAML frontmatter; standard vault read/write on every drag. Clipboard image paste joins `(adapter as any).basePath` with the attachment path and calls `fs.copyFile`. | 🟡 expected (smoke pending after #170) | Each card move = 1 write (vault API → round-trips; latency may lag big boards). Clipboard image paste uses `fs.copyFile` → local shadow copy only, doesn't reach the remote (#429). |
|
||||
| Thino | reads/writes daily-note-style files; standard vault API | 🟡 expected | Pure vault API user. No special concerns. |
|
||||
| Commander | UI plugin: ribbon icons, hotkeys, command macros. Doesn't touch vault files for its own state (uses plugin data via `loadData`/`saveData`, which goes through the patched adapter) | 🟡 expected | If a custom command invokes a different plugin, the wrapped plugin's compatibility applies. |
|
||||
| Emoji Shortcodes | pure UI typing helper. No filesystem access | ✅ verified-by-architecture | Not affected by adapter patching at all. |
|
||||
|
|
@ -47,7 +47,7 @@ Numbers in the table reflect a `~/work/VaultDev`-class remote
|
|||
| Meta Bind | input bindings on YAML / inline frontmatter; reads / writes via vault API | 🟡 expected | Each input change writes the host note. Latency is noticeable but functional. |
|
||||
| Omnisearch | full-text indexer: reads every file in the vault on init + on changes | ⚠️ degraded (expected) | This is the most network-bound plugin in the list. Initial index build on a remote vault can take seconds-to-minutes depending on size. After the warm cache, queries are local. Recommendation: only enable Omnisearch when the connection is stable. |
|
||||
| QuickLatex | renders LaTeX inline. Pure UI, no FS access | ✅ verified-by-architecture | Not affected. |
|
||||
| Importer | converts external formats (Evernote `.enex`, etc.) to MD using `path.join(getBasePath(), folder.path)` as `outputDir` for the Yarle Evernote converter (Node `fs.writeFile`) | 🟡 expected (smoke pending after #170) | `getBasePath()` now returns the shadow-vault local root via #170, so the converter writes files into the shadow dir; the file-watcher propagates them to the remote. Initial conversion of a large `.enex` may produce many writes; watch for queue lag. |
|
||||
| Importer | converts external formats (Evernote `.enex`, etc.) to MD using `path.join(getBasePath(), folder.path)` as `outputDir` for the Yarle Evernote converter (Node `fs.writeFile`) | 🟡 expected (smoke pending after #170) | Yarle converter writes via Node `fs` → local shadow copy only, doesn't reach the remote (#429). To import to a remote vault, convert in a local vault and move the result over. |
|
||||
| Copilot | reads `getBasePath?.()` then falls back to `basePath` for local-context AI indexing (`src/miyo/miyoUtils.ts`) | 🟡 expected (smoke pending after #170) | Either form now resolves to the shadow-vault local root via #170, so Copilot's local-context indexing operates against the synced copy. The remote is the source of truth; the index reflects whatever has been mirrored to the shadow dir. |
|
||||
| Git (Vinzent03) | desktop hardcodes `SimpleGit` (spawns local `git` against `getBasePath()`); mobile uses `IsomorphicGit` via `MyAdapter(vault.adapter)` (pure JS, no shell-out) | ❌ broken on desktop / fixable upstream | The desktop path silently mis-routes commits to the shadow git repo, not the remote. The isomorphic-git path *would* work transparently against our remote adapter but is gated behind `Platform.isDesktopApp` with no toggle. **We won't ship a workaround** — see [#150](https://github.com/sotashimozono/obsidian-remote-ssh/issues/150) for the rationale. Users wanting git on a remote vault should use the integrated terminal pane ([#149](https://github.com/sotashimozono/obsidian-remote-ssh/issues/149)) or file a feature request at Vinzent03/obsidian-git for a `forceIsomorphicGit` toggle. |
|
||||
| Excalidraw | drawings stored as `.excalidraw.md` (JSON) or embedded markdown; embedded images go through `getResourcePath`. `pathToFileURL(adapter.basePath)` is used as a vault-membership prefix check (`src/utils/fileUtils.ts:343`). | ✅ verified-by-harness (#124 F13) | The `RPC` transport is required for the ResourceBridge to serve images. On `SFTP` transport, embedded images fall back to a broken `data:` URL. First read of a large `.excalidraw.md` pulls the whole JSON; subsequent edits stream cleanly. After #170 the prefix check stays internally consistent (both sides see the shadow path). F13 harness scenario (`plugin/tests/compat/excalidraw.test.ts`) covers `.excalidraw.md` text round-trip + binary attachment CRUD with byte-exact equality on a 1 KB cyclic blob and a 4 KB PNG-magic + xorshift32 payload. |
|
||||
|
|
@ -130,15 +130,15 @@ Things that aren't a specific plugin but trip plugins in general:
|
|||
|
||||
- **`app.vault.adapter.basePath` and `getBasePath()`** resolve to the
|
||||
**shadow vault's** local root (e.g. `~/.obsidian-remote/vaults/<P-id>/`),
|
||||
not the remote SSH path. Plugins that join paths against `basePath`
|
||||
and feed them to Node `fs` directly read mirrored content and write
|
||||
into the shadow dir; the file-watcher then propagates writes back to
|
||||
the remote. This is the natural value of `FileSystemAdapter.basePath`
|
||||
in the shadow window, and #170 patches both forms onto the
|
||||
replacement adapter explicitly so the contract is stable across
|
||||
Obsidian version upgrades. See the **basePath compat survey** section
|
||||
below (#133, 2026-04-29) for the top-20 plugin survey, and #170 for
|
||||
the implementation. **Exception:** plugins that shell out to a local
|
||||
not the remote SSH path; #170 patches both so readers don't crash on
|
||||
`undefined`. But the vault tree is **virtual** — served from the
|
||||
remote, not mirrored to disk (only `.obsidian/` lives there). So
|
||||
`basePath` + Node `fs` only touches the local shadow copy: `fs` reads
|
||||
miss remote notes, `fs` writes don't reach the remote. Only the vault
|
||||
API round-trips (`.obsidian/` config via a dedicated watcher,
|
||||
#342/#434). See *Direct-disk / agentic plugins* (#429) and the
|
||||
**basePath survey** (#133) — its "syncs up to the remote" mitigations
|
||||
assumed a vault-tree watcher that was never built. **Exception:** plugins that shell out to a local
|
||||
binary (notably obsidian-Git's `SimpleGit` desktop path) operate on
|
||||
the shadow git repo rather than the remote one — patching can't fix
|
||||
this from our side. obsidian-Git's bundled `IsomorphicGit` mode
|
||||
|
|
@ -163,6 +163,13 @@ Things that aren't a specific plugin but trip plugins in general:
|
|||
image-processing plugins outside the top-20 use this pattern
|
||||
(see survey section below). No webview-side URL rewriting is
|
||||
needed at this time.
|
||||
- **Direct-disk / agentic plugins** (e.g. Claude Code wrappers like
|
||||
"Claudian") write via Node `fs` / child processes, bypassing the
|
||||
adapter — so their files stay in the local shadow copy and never
|
||||
reach the remote ([#429](https://github.com/sotashimozono/obsidian-remote-ssh/issues/429)).
|
||||
No local→remote reconciler exists for the vault tree (adding one =
|
||||
the sync model this single-source-of-truth design avoids). Use
|
||||
plugins that go through the vault API.
|
||||
|
||||
## Why we can't auto-test all of this
|
||||
|
||||
|
|
@ -274,6 +281,14 @@ compare, child-process cwd, etc.).
|
|||
in a real dev vault remains a manual step; the matrix above keeps
|
||||
these at `🟡 expected (smoke pending after #170)` until run.
|
||||
|
||||
> **⚠️ Correction (2026-06-30, #429).** The "syncs up to the remote"
|
||||
> mitigations below assumed a shadow→remote file-watcher that was
|
||||
> **never built** for the vault tree (the only local→remote watcher is
|
||||
> `SharedConfigWatcher`, `.obsidian/`-only, #342/#434). #170 made
|
||||
> `basePath` *return* the local root (stops `undefined` crashes) but
|
||||
> created no mirror or sync path — `fs` writes stay local (#429). Read
|
||||
> "fixed by #170" as "no longer crashes," not "round-trips."
|
||||
|
||||
### Method
|
||||
|
||||
- Registry source: `community-plugins.json` +
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
{
|
||||
"id": "remote-ssh",
|
||||
"name": "Remote SSH",
|
||||
"version": "1.1.5",
|
||||
"version": "1.1.6",
|
||||
"minAppVersion": "1.5.0",
|
||||
"description": "Edit remote vaults over SSH/SFTP — VS Code Remote-SSH style.",
|
||||
"author": "souta shimozono",
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
{
|
||||
"id": "remote-ssh",
|
||||
"name": "Remote SSH",
|
||||
"version": "1.1.5",
|
||||
"version": "1.1.6",
|
||||
"minAppVersion": "1.5.0",
|
||||
"description": "Edit remote vaults over SSH/SFTP — VS Code Remote-SSH style.",
|
||||
"author": "souta shimozono",
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
{
|
||||
"id": "remote-ssh",
|
||||
"name": "Remote SSH",
|
||||
"version": "1.1.5",
|
||||
"version": "1.1.6",
|
||||
"minAppVersion": "1.5.0",
|
||||
"description": "Edit remote vaults over SSH/SFTP — VS Code Remote-SSH style.",
|
||||
"author": "souta shimozono",
|
||||
|
|
|
|||
616
plugin/package-lock.json
generated
616
plugin/package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "obsidian-remote-ssh",
|
||||
"version": "1.1.5",
|
||||
"version": "1.1.6",
|
||||
"description": "VS Code Remote SSH-like experience for Obsidian",
|
||||
"main": "main.js",
|
||||
"scripts": {
|
||||
|
|
@ -44,14 +44,14 @@
|
|||
},
|
||||
"devDependencies": {
|
||||
"@eslint-community/eslint-plugin-eslint-comments": "^4.7.2",
|
||||
"@playwright/test": "^1.60.0",
|
||||
"@types/node": "^25.9.3",
|
||||
"@playwright/test": "^1.61.1",
|
||||
"@types/node": "^26.0.1",
|
||||
"@types/ssh2": "^1.11.19",
|
||||
"@typescript-eslint/eslint-plugin": "^8.61.0",
|
||||
"@typescript-eslint/eslint-plugin": "^8.62.1",
|
||||
"@typescript-eslint/parser": "^8.59.2",
|
||||
"@vitest/coverage-v8": "^4.1.8",
|
||||
"@vitest/coverage-v8": "^4.1.9",
|
||||
"esbuild": "^0.28.1",
|
||||
"eslint": "^10.5.0",
|
||||
"eslint": "^10.6.0",
|
||||
"eslint-plugin-obsidianmd": "^0.3.0",
|
||||
"fast-check": "^4.8.0",
|
||||
"jsdom": "^29.1.1",
|
||||
|
|
|
|||
|
|
@ -82,6 +82,13 @@ if (result.status !== 0) {
|
|||
|
||||
console.log(`build-server: staged ${path.relative(repoRoot, outPath)} (${prettySize(fs.statSync(outPath).size)})`);
|
||||
|
||||
// Mark the staged binary as a DEV build so the plugin's locateDaemonBinary
|
||||
// trusts it over a download (it now requires this marker). dev-install.mjs and
|
||||
// the E2E vault-scaffold copy server-bin/ verbatim, so the marker rides along;
|
||||
// build:server on its own (the E2E path, no dev-install) would otherwise leave
|
||||
// the daemon unmarked → gated out → RPC unavailable in the Obsidian smoke run.
|
||||
fs.writeFileSync(path.join(stageDir, '.dev-daemon'), '');
|
||||
|
||||
function locateGo() {
|
||||
const fromPath = which('go') ?? which('go.exe');
|
||||
if (fromPath) return fromPath;
|
||||
|
|
|
|||
|
|
@ -69,6 +69,13 @@ if (fs.existsSync(serverBinDir)) {
|
|||
fs.copyFileSync(src, dst);
|
||||
console.log(`copied server-bin/${f} -> ${dst}`);
|
||||
}
|
||||
// Mark this as a DEV-staged daemon so the plugin trusts it over a
|
||||
// downloaded binary: locateDaemonBinary returns a staged binary only when
|
||||
// this marker is present. Without it, a *downloaded* binary at the same
|
||||
// filename would masquerade as a dev build and skip the version/sha
|
||||
// re-check (so a changed daemon would never get refreshed).
|
||||
fs.writeFileSync(path.join(binTarget, '.dev-daemon'), '');
|
||||
console.log('wrote server-bin/.dev-daemon marker');
|
||||
} else {
|
||||
console.log('server-bin/ not present — skipping daemon staging (run `npm run build:server` to build it)');
|
||||
}
|
||||
|
|
|
|||
|
|
@ -119,9 +119,11 @@ export class SftpDataAdapter {
|
|||
* `getBasePath()` method return this value, so plugins that read
|
||||
* `adapter.basePath` (Templater's `tp.file.path`, Kanban's clipboard
|
||||
* paste, Importer, Copilot — see `docs/en/user-guide/plugin-compatibility.md`)
|
||||
* receive a path on the shadow vault. Reads against that path
|
||||
* succeed against files mirrored locally by the file watcher;
|
||||
* writes land in the shadow dir and propagate to the remote.
|
||||
* receive the shadow-vault root (so they don't crash on
|
||||
* `undefined`). But the vault tree is virtual — served from the
|
||||
* remote, not mirrored to disk — so raw Node `fs` here only touches
|
||||
* the local shadow copy: reads miss remote notes, writes don't
|
||||
* reach the remote. Only the vault API round-trips (#429).
|
||||
*
|
||||
* Defaults to `''` for tests that never exercise these members. The
|
||||
* production wiring in `main.ts` always passes the real shadow
|
||||
|
|
@ -142,11 +144,11 @@ export class SftpDataAdapter {
|
|||
) {}
|
||||
|
||||
/**
|
||||
* Mirror of `FileSystemAdapter.basePath`. Returns the absolute path
|
||||
* of the shadow vault's local root. Plugins that join paths against
|
||||
* this value and call Node `fs` directly read mirrored content and
|
||||
* write into the shadow dir, where the file watcher propagates to
|
||||
* the remote.
|
||||
* Mirror of `FileSystemAdapter.basePath`: the shadow-vault local
|
||||
* root. The vault tree is virtual (not mirrored to disk), so raw
|
||||
* `fs` against this path only touches the local shadow copy — writes
|
||||
* don't reach the remote (#429); only the vault API round-trips.
|
||||
* #170 returns a defined path so plugins don't crash.
|
||||
*/
|
||||
get basePath(): string {
|
||||
return this.shadowBasePath;
|
||||
|
|
|
|||
|
|
@ -16,6 +16,12 @@ export const DEFAULT_WALK_IGNORE_DIRS: readonly string[] = [
|
|||
'__pycache__', '.venv', 'venv', '.tox', '.mypy_cache', '.pytest_cache',
|
||||
'target', 'dist', 'build', '.next', '.nuxt', '.cache', '.gradle',
|
||||
'.idea', 'vendor', '.terraform',
|
||||
// Language / toolchain caches — often enormous (a populated `.julia`
|
||||
// or `.cargo` is 100k+ files of pure noise). Dot-dirs are hidden from
|
||||
// the File Explorer anyway (BulkWalker filters them); listing them
|
||||
// here ALSO prunes them server-side so the daemon never descends into
|
||||
// or transfers them — the perf half of "hide `.julia` by default".
|
||||
'.julia', '.cargo', '.rustup', '.npm', '.conda', '.gem',
|
||||
];
|
||||
|
||||
export const DEFAULT_PROFILE: Omit<SshProfile, 'id' | 'name'> = {
|
||||
|
|
@ -39,6 +45,10 @@ export const DEFAULT_SETTINGS: PluginSettings = {
|
|||
reconnectMaxRetries: 5,
|
||||
clientId: '',
|
||||
userName: '',
|
||||
// Deepen the remote tree one folder level at a time on File-Explorer expand,
|
||||
// rather than eager-walking the whole (potentially huge, deep) tree at
|
||||
// connect. Default on; safe to turn off for small vaults.
|
||||
lazyFolderLoad: true,
|
||||
// Phase 4 marker for shadow vaults; null on a normal vault. Only
|
||||
// `ShadowVaultBootstrap` writes a non-null value.
|
||||
autoConnectProfileId: null,
|
||||
|
|
|
|||
|
|
@ -24,9 +24,11 @@ import { classifyToNotice } from './transport/errorTaxonomy';
|
|||
import { VaultModelBuilder } from './vault/VaultModelBuilder';
|
||||
import { FsChangeListener } from './vault/FsChangeListener';
|
||||
import { BulkWalker } from './vault/BulkWalker';
|
||||
import { LazyFolderLoader } from './vault/LazyFolderLoader';
|
||||
import { RenameLeafFollower } from './vault/RenameLeafFollower';
|
||||
import { ObsidianRegistry } from './shadow/ObsidianRegistry';
|
||||
import { ShadowVaultBootstrap } from './shadow/ShadowVaultBootstrap';
|
||||
import type { SharedConfigReader, BootstrapResult } from './shadow/ShadowVaultBootstrap';
|
||||
import { SharedConfigWatcher } from './shadow/SharedConfigWatcher';
|
||||
import { ShadowVaultManager } from './shadow/ShadowVaultManager';
|
||||
import { WindowSpawner } from './shadow/WindowSpawner';
|
||||
|
|
@ -34,6 +36,9 @@ import { ShadowStartupCoordinator } from './shadow/ShadowStartupCoordinator';
|
|||
import * as os from 'os';
|
||||
import { ObservabilityInstaller } from './util/ObservabilityInstaller';
|
||||
import { normalizeRemotePath } from './util/pathUtils';
|
||||
import { PathMapper } from './path/PathMapper';
|
||||
import { preSpawnRemotePath } from './shadow/preSpawnPaths';
|
||||
import { withTimeout } from './util/withTimeout';
|
||||
import * as path from 'path';
|
||||
import { errorMessage } from "./util/errorMessage";
|
||||
import { ConnectionManager, DaemonUnavailableError } from "./ConnectionManager";
|
||||
|
|
@ -42,7 +47,9 @@ import {
|
|||
ensureDaemonBinary as downloadDaemonBinary,
|
||||
resolveDaemonConsent,
|
||||
DaemonVerificationError,
|
||||
binaryFilename,
|
||||
} from './transport/DaemonDownloader';
|
||||
import { createHash } from 'crypto';
|
||||
import { TransferTracker } from "./util/TransferTracker";
|
||||
import { LargeTransferBar } from "./ui/LargeTransferBar";
|
||||
import { OnboardingModal } from "./ui/OnboardingModal";
|
||||
|
|
@ -470,18 +477,21 @@ export default class RemoteSshPlugin extends Plugin {
|
|||
const ver = this.conn.rpcConnection?.info.version ?? '?';
|
||||
rpcSummary = ` — daemon ${ver}, ${caps} capabilities`;
|
||||
} catch (e) {
|
||||
if (e instanceof DaemonUnavailableError) {
|
||||
// Unsupported remote arch, or the user declined the daemon
|
||||
// download → keep the session on SFTP. Vault read/write/sync
|
||||
// still work; daemon-only features (fast walk, thumbnails, live
|
||||
// watch, image/PDF rendering) are unavailable.
|
||||
logger.warn(`RPC unavailable, falling back to SFTP: ${e.message}`);
|
||||
new Notice('Remote SSH: daemon unavailable — connected via SFTP (reduced features).');
|
||||
transport = 'sftp';
|
||||
} else {
|
||||
// The daemon is an optimization layer, not a requirement. Most
|
||||
// failures to bring it up degrade to SFTP rather than failing the
|
||||
// whole connect — a hard failure skips populate and strands the user
|
||||
// on an EMPTY vault. SFTP shares the same live SSH channel, so
|
||||
// read/write/sync still work (minus daemon-only features: fast walk,
|
||||
// thumbnails, live watch, image/PDF rendering).
|
||||
//
|
||||
// EXCEPTION: a daemon binary that fails integrity verification
|
||||
// (checksum mismatch / bad manifest) fails LOUD and does NOT
|
||||
// downgrade — silently falling back could mask a tampered or corrupt
|
||||
// binary (supply-chain safety, #406).
|
||||
if (e instanceof DaemonVerificationError) {
|
||||
this.setState(SyncState.ERROR);
|
||||
const { notice, classified } = classifyToNotice(e);
|
||||
logger.error(`RPC startup failed: ${classified.title}`, {
|
||||
logger.error(`RPC startup failed (verification): ${classified.title}`, {
|
||||
category: classified.category, code: classified.code,
|
||||
original: classified.original.message, profileId: profile.id,
|
||||
});
|
||||
|
|
@ -489,6 +499,22 @@ export default class RemoteSshPlugin extends Plugin {
|
|||
try { await this.conn.client.disconnect(); } catch { /* ignore */ }
|
||||
return;
|
||||
}
|
||||
if (e instanceof DaemonUnavailableError) {
|
||||
// Expected: unsupported remote arch, or the user declined download.
|
||||
logger.warn(`RPC unavailable, falling back to SFTP: ${e.message}`);
|
||||
new Notice('Remote SSH: daemon unavailable — connected via SFTP (reduced features).');
|
||||
} else {
|
||||
// Unexpected daemon-start failure (deploy / token-write timeout /
|
||||
// handshake mismatch). Log the classified cause for diagnosis, but
|
||||
// still degrade to SFTP instead of leaving the vault empty.
|
||||
const { classified } = classifyToNotice(e);
|
||||
logger.warn(`RPC startup failed, falling back to SFTP: ${classified.title}`, {
|
||||
category: classified.category, code: classified.code,
|
||||
original: classified.original.message, profileId: profile.id,
|
||||
});
|
||||
new Notice('Remote SSH: daemon failed to start — connected via SFTP (reduced features). See console.log.');
|
||||
}
|
||||
transport = 'sftp';
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -558,12 +584,12 @@ export default class RemoteSshPlugin extends Plugin {
|
|||
return;
|
||||
}
|
||||
|
||||
// Pull the shared Obsidian config (app.json / appearance.json /
|
||||
// core-plugins.json / hotkeys.json) from the remote onto the
|
||||
// local shadow disk *before* the populate, so the next time this
|
||||
// window restarts Obsidian reads fresh settings instead of the
|
||||
// stale local copy (#342). Best-effort: a failure here must not
|
||||
// block rendering the vault.
|
||||
// Pull this device's Obsidian config (app.json / appearance.json /
|
||||
// core-plugins.json / hotkeys.json — now per-client via PathMapper)
|
||||
// from the remote onto the local shadow disk *before* the populate, so
|
||||
// the next time this window restarts Obsidian reads fresh settings
|
||||
// instead of the stale local copy (#342). Best-effort: a failure here
|
||||
// must not block rendering the vault.
|
||||
const da = this.adapterMgr.dataAdapter;
|
||||
const hostAdapter = this.app.vault.adapter;
|
||||
if (da && hostAdapter instanceof FileSystemAdapter) {
|
||||
|
|
@ -582,7 +608,7 @@ export default class RemoteSshPlugin extends Plugin {
|
|||
// settings silently not update — the #342 symptom. Absent
|
||||
// files are not errored, so a fresh vault stays quiet.
|
||||
new Notice(
|
||||
`Remote SSH: ${cfg.errored.length} shared-config file` +
|
||||
`Remote SSH: ${cfg.errored.length} config file` +
|
||||
`${cfg.errored.length === 1 ? '' : 's'} (${cfg.errored.join(', ')}) ` +
|
||||
'could not be synced — settings may be stale until the next connect',
|
||||
);
|
||||
|
|
@ -593,6 +619,56 @@ export default class RemoteSshPlugin extends Plugin {
|
|||
);
|
||||
}
|
||||
|
||||
// #429 / #342 residual: round-trip the enabled community-plugins
|
||||
// list. Pull first so plugins set up on the remote load in the
|
||||
// shadow vault (the marketplace installer then fetches any missing
|
||||
// binaries); then push the merged result so a plugin present only
|
||||
// locally reaches the remote for other machines. Pushing *after*
|
||||
// the pull is safe — the local list is now the union, so it can
|
||||
// never drop a plugin the remote already had. Kept out of the
|
||||
// verbatim shared-config set because `remote-ssh` must be
|
||||
// force-preserved through the merge (a verbatim copy of a remote
|
||||
// list omitting it would disable this very plugin).
|
||||
try {
|
||||
await ShadowVaultBootstrap.pullCommunityPlugins(da, remoteConfigDir, localConfigDir);
|
||||
await ShadowVaultBootstrap.pushCommunityPlugins(da, remoteConfigDir, localConfigDir);
|
||||
} catch (e) {
|
||||
logger.warn(
|
||||
`runAutoConnect(${tag}): community-plugins round-trip failed: ${errorMessage(e)}`,
|
||||
);
|
||||
}
|
||||
|
||||
// #429b: the startup installer (prepareForAutoConnect) ran BEFORE
|
||||
// the pull above — and not at all on a reconnect — so a plugin the
|
||||
// pull just added to community-plugins.json has no binary staged yet
|
||||
// and won't load. Re-run the marketplace installer now the list is
|
||||
// current; `enablePluginAndSave` loads a marketplace plugin live, no
|
||||
// restart. Idempotent (already-installed ids are skipped). BRAT /
|
||||
// non-marketplace plugins still need their binary on the remote.
|
||||
try {
|
||||
await new ShadowStartupCoordinator(this.app, this.settings, () => this.saveSettings())
|
||||
.installMissingShadowPlugins();
|
||||
} catch (e) {
|
||||
logger.warn(`runAutoConnect(${tag}): post-pull plugin install failed: ${errorMessage(e)}`);
|
||||
}
|
||||
|
||||
// #429b binary round-trip: the marketplace installer above can't
|
||||
// fetch a BRAT / sideloaded plugin (it isn't on the registry). Run
|
||||
// AFTER the installer so the plugins it just fetched are on disk —
|
||||
// the pull then only stages what's STILL missing (the non-market
|
||||
// ones), which keeps the installer's live load intact and also
|
||||
// acts as a fallback if a marketplace fetch failed. The push makes
|
||||
// the remote `.obsidian/plugins/` a complete vault so every machine
|
||||
// can pull. A pulled binary loads on the next vault open (Obsidian
|
||||
// scans the plugins dir at startup).
|
||||
try {
|
||||
const enabledIds = ShadowVaultBootstrap.readEnabledPluginIds(localConfigDir);
|
||||
await ShadowVaultBootstrap.pullPluginBinaries(da, remoteConfigDir, localConfigDir, enabledIds);
|
||||
await ShadowVaultBootstrap.pushPluginBinaries(da, remoteConfigDir, localConfigDir, enabledIds);
|
||||
} catch (e) {
|
||||
logger.warn(`runAutoConnect(${tag}): plugin-binary round-trip failed: ${errorMessage(e)}`);
|
||||
}
|
||||
|
||||
// #342 push half: pull only brought remote→local. Without this,
|
||||
// a settings change made HERE never reaches the remote, so the
|
||||
// next session's pull finds nothing and the change evaporates.
|
||||
|
|
@ -616,7 +692,7 @@ export default class RemoteSshPlugin extends Plugin {
|
|||
);
|
||||
if (r.errored.length > 0) {
|
||||
new Notice(
|
||||
`Remote SSH: ${r.errored.length} shared-config file` +
|
||||
`Remote SSH: ${r.errored.length} config file` +
|
||||
`${r.errored.length === 1 ? '' : 's'} (${r.errored.join(', ')}) ` +
|
||||
'could not be pushed — settings change not yet saved remotely',
|
||||
);
|
||||
|
|
@ -770,6 +846,9 @@ export default class RemoteSshPlugin extends Plugin {
|
|||
this.sharedConfigWatcher?.stop();
|
||||
this.sharedConfigWatcher = null;
|
||||
this.adapterMgr.restore();
|
||||
// Drop lazy-load state — the walker it captured is now disconnected. A
|
||||
// stray File-Explorer click after this finds a null loader and no-ops.
|
||||
this.lazyLoader = null;
|
||||
await this.conn.disconnectTransport();
|
||||
this.setState(SyncState.IDLE);
|
||||
if (this.settings.activeProfileId !== null) {
|
||||
|
|
@ -811,6 +890,43 @@ export default class RemoteSshPlugin extends Plugin {
|
|||
}
|
||||
}
|
||||
|
||||
/** Lazy per-folder loader (deepen-on-expand); null until a lazy connect. */
|
||||
private lazyLoader: LazyFolderLoader | null = null;
|
||||
private lazyExpandHookInstalled = false;
|
||||
|
||||
/** A fresh BulkWalker bound to the current session's transport + ignore list. */
|
||||
private makeWalker(): BulkWalker {
|
||||
return new BulkWalker({
|
||||
adapter: this.app.vault.adapter,
|
||||
rpcConnection: this.conn.rpcConnection ?? undefined,
|
||||
// Older profiles have no walkIgnoreDirs → fall back to the sensible
|
||||
// defaults so existing users immediately benefit. An explicit empty
|
||||
// array (user cleared it) means "ignore nothing" and is respected.
|
||||
ignoreDirs: this.conn.activeProfile?.walkIgnoreDirs ?? [...DEFAULT_WALK_IGNORE_DIRS],
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* One delegated, capture-phase click listener that deepens a folder the
|
||||
* first time it's expanded in File Explorer. Obsidian renders folder
|
||||
* children from the in-memory model, does NOT re-list on expand, and exposes
|
||||
* no public folder-expand event — hence the DOM hook on `.nav-folder-title`
|
||||
* (which carries the folder's `data-path`; verified in a real-Obsidian
|
||||
* spike). Idempotent per folder via LazyFolderLoader, so a click on an
|
||||
* already-loaded folder (or a collapse) is a cheap no-op. Installed once;
|
||||
* `registerDomEvent` tears it down on unload.
|
||||
*/
|
||||
private installLazyExpandHook(): void {
|
||||
if (this.lazyExpandHookInstalled) return;
|
||||
this.lazyExpandHookInstalled = true;
|
||||
this.registerDomEvent(activeDocument, 'click', (evt) => {
|
||||
const title = (evt.target as HTMLElement | null)?.closest?.('.nav-folder-title');
|
||||
const path = title?.getAttribute('data-path');
|
||||
if (path == null) return;
|
||||
void this.lazyLoader?.loadFolder(path);
|
||||
}, { capture: true });
|
||||
}
|
||||
|
||||
/**
|
||||
* POC for the shadow-vault architecture (see
|
||||
* docs/en/architecture/shadow-vault.md, Phase 1): walk the patched
|
||||
|
|
@ -848,27 +964,37 @@ export default class RemoteSshPlugin extends Plugin {
|
|||
// mtime+size per entry) when the active session is RPC AND the
|
||||
// daemon advertises the capability. Otherwise BulkWalker
|
||||
// transparently runs the legacy BFS via the patched adapter.
|
||||
const walker = new BulkWalker({
|
||||
adapter: this.app.vault.adapter,
|
||||
rpcConnection: this.conn.rpcConnection ?? undefined,
|
||||
// Older profiles have no walkIgnoreDirs → fall back to the
|
||||
// sensible defaults so existing users immediately benefit. An
|
||||
// explicit empty array (user cleared it) means "ignore nothing"
|
||||
// and is respected (?? only fills null/undefined).
|
||||
ignoreDirs: this.conn.activeProfile?.walkIgnoreDirs
|
||||
?? [...DEFAULT_WALK_IGNORE_DIRS],
|
||||
});
|
||||
const walk = await walker.walk('');
|
||||
const walker = this.makeWalker();
|
||||
// Lazy mode (default): walk only the ROOT level and deepen each folder on
|
||||
// first expand, so a deep, dir-heavy vault doesn't pull + materialise tens
|
||||
// of thousands of entries at connect. `walkIgnoreDirs` is applied per level
|
||||
// either way. Set `lazyFolderLoad: false` to restore the full eager walk.
|
||||
const lazy = this.settings.lazyFolderLoad !== false;
|
||||
const walk = await walker.walk('', !lazy);
|
||||
logger.info(
|
||||
`populateVaultFromRemote(${label}): ${walk.source}, ${walk.entries.length} entries ` +
|
||||
`in ${walk.walkMs}ms (pages=${walk.pages})` +
|
||||
`(${walk.hiddenCount} hidden) in ${walk.walkMs}ms (pages=${walk.pages})${lazy ? ' [lazy: root level]' : ''}` +
|
||||
(walk.fastPathError ? ` (fast-path fallback: ${walk.fastPathError})` : ''),
|
||||
);
|
||||
|
||||
const builder = new VaultModelBuilder(this.app.vault, { TFile, TFolder });
|
||||
const result = await builder.build(walk.entries);
|
||||
// Chunked so even one level (or a full eager walk) fills the File Explorer
|
||||
// progressively instead of freezing the window while every entry is
|
||||
// materialised + `create`-triggered in a single JS tick.
|
||||
const result = await builder.buildChunked(walk.entries);
|
||||
const totalMs = Date.now() - start;
|
||||
|
||||
if (lazy) {
|
||||
// Obsidian renders folders from the in-memory model and does NOT re-list
|
||||
// on expand, so deepen-on-expand is driven by a File-Explorer click hook.
|
||||
this.lazyLoader = new LazyFolderLoader(
|
||||
() => this.makeWalker(),
|
||||
() => new VaultModelBuilder(this.app.vault, { TFile, TFolder }),
|
||||
);
|
||||
this.lazyLoader.markLoaded('');
|
||||
this.installLazyExpandHook();
|
||||
}
|
||||
|
||||
const summary =
|
||||
`${result.filesAdded}f + ${result.foldersAdded}d built, ` +
|
||||
`${result.skipped} skipped, ${result.errors.length} errors (${totalMs}ms)`;
|
||||
|
|
@ -883,8 +1009,12 @@ export default class RemoteSshPlugin extends Plugin {
|
|||
// vault (the silent "remote files won't open" symptom). Surface it.
|
||||
if (walk.entries.length === 0) {
|
||||
new Notice(
|
||||
'Remote SSH: 0 files found on the remote. Check the profile’s ' +
|
||||
'remotePath actually points at the vault (see console.log).',
|
||||
walk.hiddenCount > 0
|
||||
? `Remote SSH: 0 visible files — all ${walk.hiddenCount} walked ` +
|
||||
'entries are hidden dot-files (e.g. content under a “.”-prefixed ' +
|
||||
'folder). Rename them if they should appear in the vault.'
|
||||
: 'Remote SSH: 0 files found on the remote. Check the profile’s ' +
|
||||
'remotePath actually points at the vault (see console.log).',
|
||||
10_000,
|
||||
);
|
||||
} else if (walk.truncated) {
|
||||
|
|
@ -958,26 +1088,34 @@ export default class RemoteSshPlugin extends Plugin {
|
|||
logger.warn(`openShadowVaultFor: pre-spawn settings flush failed (${errorMessage(e)}); continuing to spawn`);
|
||||
}
|
||||
|
||||
const result = await manager.openShadowFor(profile, this.settings.profiles);
|
||||
const result = await manager.openShadowFor(
|
||||
profile, this.settings.profiles,
|
||||
// #429b / Phase B-3: pull canonical .obsidian/ before the window
|
||||
// boots. Best-effort + time-boxed inside preSpawnPull; the manager
|
||||
// swallows any throw so a slow/failed pull never blocks the spawn.
|
||||
(r) => this.preSpawnPull(profile, r),
|
||||
);
|
||||
const how = result.pluginInstallMethod;
|
||||
const reg = result.registryCreated ? 'newly registered' : 'reused';
|
||||
const reg = result.registryCreated ? 'newly registered' : result.migrated ? 'migrated' : 'reused';
|
||||
logger.info(
|
||||
`openShadowVaultFor: profile=${profile.name}, vault=${result.layout.vaultDir}, ` +
|
||||
`registry id=${result.registryId} (${reg}), plugin=${how}`,
|
||||
);
|
||||
if (result.registryCreated) {
|
||||
// First-ever open of this profile: the vault was just added to
|
||||
// obsidian.json, but the already-running Obsidian only reads that
|
||||
// file at startup — so the obsidian://open we just fired is a no-op
|
||||
// and no window appears. Surface a PERSISTENT notice (duration 0)
|
||||
// telling the user to restart, rather than the misleading "opened in
|
||||
// new window" that never actually opened. Only happens once per
|
||||
// profile (subsequent connects hit the `reused` branch).
|
||||
if (result.registryCreated || result.migrated) {
|
||||
// The vault's path in obsidian.json is new to the already-running
|
||||
// Obsidian (it only reads that file at startup), so the
|
||||
// obsidian://open we just fired is a no-op and no window appears.
|
||||
// This happens on a profile's first-ever open (registryCreated) and
|
||||
// the one-time legacy→friendly dir rename (migrated). Surface a
|
||||
// PERSISTENT notice (duration 0) telling the user to restart, rather
|
||||
// than the misleading "opened in new window" that never opened.
|
||||
const why = result.registryCreated
|
||||
? `"${profile.name}" is a newly registered vault.`
|
||||
: `"${profile.name}" was renamed to a friendlier vault name.`;
|
||||
new Notice(
|
||||
`Remote SSH: "${profile.name}" is a newly registered vault. Obsidian only ` +
|
||||
'loads new vaults at startup, so it cannot open it this session — fully ' +
|
||||
'quit and reopen Obsidian, then click Connect again. (Only needed the ' +
|
||||
'first time you open a profile.)',
|
||||
`Remote SSH: ${why} Obsidian only loads vaults at startup, so it cannot ` +
|
||||
'open it this session — fully quit and reopen Obsidian, then click Connect ' +
|
||||
'again. (One-time.)',
|
||||
0,
|
||||
);
|
||||
} else {
|
||||
|
|
@ -1001,6 +1139,76 @@ export default class RemoteSshPlugin extends Plugin {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Pre-spawn pull (#429b / Phase B-3). Before the shadow window opens,
|
||||
* pull the remote `.obsidian/` — shared config (#342), the enabled-
|
||||
* plugins list, and plugin binaries (#429b) — into the freshly
|
||||
* bootstrapped shadow dir, so the window boots on the CANONICAL remote
|
||||
* config instead of a stale local copy (no mid-session settings reload).
|
||||
*
|
||||
* Strictly best-effort and time-boxed. It builds a STANDALONE
|
||||
* `SftpClient` that does NOT patch this (source) window's vault adapter,
|
||||
* so the user's real vault is never hijacked. The kbd-interactive and
|
||||
* host-key callbacks REJECT rather than prompt: pre-spawn must be
|
||||
* non-interactive, so a 2FA / unknown-host connect falls through to the
|
||||
* shadow window (which prompts exactly once) — no double prompt, no
|
||||
* surprise modal in the source window. On ANY error or timeout it logs
|
||||
* and returns; `ShadowVaultManager` then spawns and the shadow window's
|
||||
* own connect catches up. Never throws.
|
||||
*/
|
||||
private async preSpawnPull(profile: SshProfile, result: BootstrapResult): Promise<void> {
|
||||
const localConfigDir = result.layout.configDir;
|
||||
const remoteConfigDir = this.app.vault.configDir; // ".obsidian"
|
||||
const remoteBase = normalizeRemotePath(profile.remotePath);
|
||||
// Apply the SAME per-client redirect the shadow window's adapter will use
|
||||
// (AdapterManager builds `new PathMapper(resolveClientId(settings), configDir)`),
|
||||
// so the four now-per-device config files (app/appearance/core-plugins/hotkeys)
|
||||
// are pulled from THIS client's `<configDir>/user/<id>/` subtree — NOT the dead
|
||||
// shared identity path. Without this, pre-spawn read the shared path and
|
||||
// clobbered the per-device config on every spawn, undoing the redirect.
|
||||
// `PathMapper.toRemote` is identity for non-private paths (community-plugins.json,
|
||||
// plugins/…), so those still round-trip shared, unchanged.
|
||||
const mapper = new PathMapper(ConnectionManager.resolveClientId(this.settings), remoteConfigDir);
|
||||
const toRemote = (p: string): string => preSpawnRemotePath(mapper, remoteBase, p);
|
||||
|
||||
const client = new SftpClient(
|
||||
this.authResolver,
|
||||
this.hostKeyStore,
|
||||
() => Promise.reject(new Error('pre-spawn: keyboard-interactive deferred to shadow window')),
|
||||
() => Promise.reject(new Error('pre-spawn: host-key prompt deferred to shadow window')),
|
||||
);
|
||||
const reader: SharedConfigReader = {
|
||||
exists: (p) => client.exists(toRemote(p)),
|
||||
read: (p) => client.readText(toRemote(p)),
|
||||
};
|
||||
// Bound the whole connect+pull so a slow link can't stall the window
|
||||
// open — beyond the budget we fall back to spawn-and-catch-up.
|
||||
const budgetMs = (profile.connectTimeoutMs || 15_000) + 8_000;
|
||||
// Run as a standalone promise: if the budget fires first, `finally`
|
||||
// disconnects the client and any read still in flight then throws
|
||||
// "not connected". That settles `pull` a SECOND time, after the race
|
||||
// already lost — an unhandledrejection in the renderer unless we keep
|
||||
// a handler on it. The real-error diagnostics still flow through the
|
||||
// `withTimeout` race into the catch below; this handler only mops up
|
||||
// the expected post-disconnect noise.
|
||||
const pull = (async () => {
|
||||
await client.connect(profile);
|
||||
await ShadowVaultBootstrap.pullSharedObsidianConfig(reader, remoteConfigDir, localConfigDir);
|
||||
await ShadowVaultBootstrap.pullCommunityPlugins(reader, remoteConfigDir, localConfigDir);
|
||||
const enabledIds = ShadowVaultBootstrap.readEnabledPluginIds(localConfigDir);
|
||||
await ShadowVaultBootstrap.pullPluginBinaries(reader, remoteConfigDir, localConfigDir, enabledIds);
|
||||
})();
|
||||
pull.catch(() => { /* post-timeout teardown error — handled via the race */ });
|
||||
try {
|
||||
await withTimeout(pull, budgetMs, 'pre-spawn pull');
|
||||
logger.info(`preSpawnPull: staged canonical .obsidian/ before spawn (${profile.name})`);
|
||||
} catch (e) {
|
||||
logger.warn(`preSpawnPull: skipped (${errorMessage(e)}); shadow window will sync after open`);
|
||||
} finally {
|
||||
try { await client.disconnect(); } catch { /* best effort */ }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Manual command-palette entry point for adapter patching. Used
|
||||
* during development to inspect pre-patch behaviour or to re-patch
|
||||
|
|
@ -1148,10 +1356,32 @@ export default class RemoteSshPlugin extends Plugin {
|
|||
private locateDaemonBinary(): string | null {
|
||||
const pluginDir = this.pluginDir();
|
||||
if (!pluginDir) return null;
|
||||
const candidate = path.join(pluginDir, 'server-bin', 'obsidian-remote-server-linux-amd64');
|
||||
const serverBin = path.join(pluginDir, 'server-bin');
|
||||
// Only treat a staged binary as a genuine DEV build when dev-install
|
||||
// marked it (`.dev-daemon`). Without this, a *downloaded* binary at the
|
||||
// same filename would masquerade as a dev build and bypass the version /
|
||||
// sha refresh in ensureDaemonBinary — the exact reason a plugin upgrade
|
||||
// kept re-deploying a stale (dynamically-linked) daemon.
|
||||
if (!fs.existsSync(path.join(serverBin, '.dev-daemon'))) return null;
|
||||
const candidate = path.join(serverBin, 'obsidian-remote-server-linux-amd64');
|
||||
return fs.existsSync(candidate) ? candidate : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* sha256 (hex) of a cached daemon binary, or null if it can't be read.
|
||||
* Used by the connect fast-path to re-validate the cached bytes without a
|
||||
* network round-trip. Reads the whole file (a daemon binary is a few MB) —
|
||||
* cheap next to the SSH connect it gates.
|
||||
*/
|
||||
private async sha256File(abs: string): Promise<string | null> {
|
||||
try {
|
||||
const buf = await fs.promises.readFile(abs);
|
||||
return createHash('sha256').update(buf).digest('hex');
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Acquire a daemon binary for the REMOTE's os/arch when one isn't staged
|
||||
* locally. Community-store installs don't ship `server-bin/`, so we probe
|
||||
|
|
@ -1189,6 +1419,57 @@ export default class RemoteSshPlugin extends Plugin {
|
|||
if (!pluginDir) return null;
|
||||
const cacheDir = path.join(pluginDir, 'server-bin');
|
||||
|
||||
// R3: `server-bin` must be a REAL per-shadow dir. Older installs (and dev
|
||||
// symlink installs) could leave it as a junction to ANOTHER vault's
|
||||
// server-bin (installPlugin used to propagate it); if that target vault was
|
||||
// later deleted, the junction dangles and mkdir throws a raw ENOENT — the
|
||||
// connect then silently degrades to SFTP. Try a plain mkdir first (a valid
|
||||
// dir/junction is fine); on failure, unlink a stale reparse point (never
|
||||
// following into / deleting its target) and recreate a real dir. Only give
|
||||
// up to SFTP if even that fails.
|
||||
try {
|
||||
fs.mkdirSync(cacheDir, { recursive: true });
|
||||
} catch (firstErr) {
|
||||
let repaired = false;
|
||||
try {
|
||||
// lstat succeeds for a dangling junction; unlink drops the link only.
|
||||
if (fs.lstatSync(cacheDir).isSymbolicLink()) {
|
||||
fs.unlinkSync(cacheDir);
|
||||
fs.mkdirSync(cacheDir, { recursive: true });
|
||||
repaired = true;
|
||||
}
|
||||
} catch { /* fall through to the SFTP path below */ }
|
||||
if (!repaired) {
|
||||
logger.error(
|
||||
`ensureDaemonBinary: server-bin unusable (${errorMessage(firstErr)}); staying on SFTP. ` +
|
||||
'If this persists, delete the shadow vault dir under ~/.obsidian-remote/vaults and reconnect.',
|
||||
);
|
||||
new Notice(
|
||||
'Remote SSH: the daemon cache dir is broken — staying on SFTP. If this persists, ' +
|
||||
'delete the vault dir under ~/.obsidian-remote/vaults and reconnect.',
|
||||
);
|
||||
return null;
|
||||
}
|
||||
logger.warn(`ensureDaemonBinary: repaired a stale server-bin link at ${cacheDir}`);
|
||||
}
|
||||
|
||||
// Fast path: reuse the cached binary without a GitHub round-trip when it
|
||||
// was provisioned for THIS plugin version AND its bytes still hash to the
|
||||
// recorded sha. The sha re-check upholds the "never deploy an unverified
|
||||
// binary" invariant even here — a file corrupted/truncated after its
|
||||
// verified download is caught (network-free) and re-fetched below. A
|
||||
// version mismatch, missing marker, or sha mismatch falls through to the
|
||||
// manifest sha re-check / download.
|
||||
const dest = path.join(cacheDir, binaryFilename(target));
|
||||
if (
|
||||
this.settings.daemonBinaryVersion === this.manifest.version &&
|
||||
this.settings.daemonBinarySha &&
|
||||
(await this.sha256File(dest)) === this.settings.daemonBinarySha
|
||||
) {
|
||||
logger.info(`ensureDaemonBinary: cached daemon validated for ${this.manifest.version}; reusing`);
|
||||
return dest;
|
||||
}
|
||||
|
||||
// Consent gate (asked once; the decision — accept OR decline — is
|
||||
// persisted so a decline doesn't re-prompt on every connect / restart).
|
||||
const consented = await resolveDaemonConsent(
|
||||
|
|
@ -1207,11 +1488,14 @@ export default class RemoteSshPlugin extends Plugin {
|
|||
fetchBinary: async (url) => new Uint8Array((await requestUrl({ url })).arrayBuffer),
|
||||
fetchText: async (url) => (await requestUrl({ url })).text,
|
||||
cacheDir,
|
||||
cacheHit: (abs) => fs.existsSync(abs),
|
||||
readCached: async (abs) => {
|
||||
try { return new Uint8Array(await fs.promises.readFile(abs)); }
|
||||
catch { return null; }
|
||||
},
|
||||
writeExecutable: async (abs, bytes) => {
|
||||
// Atomic: write a temp sibling, chmod, then rename. A crash
|
||||
// mid-write can't then leave a torn binary that a later cacheHit
|
||||
// would hand back unverified (#406 review).
|
||||
// mid-write can't then leave a torn binary that a later sha
|
||||
// re-check would hand back unverified (#406 review).
|
||||
await fs.promises.mkdir(path.dirname(abs), { recursive: true });
|
||||
const tmp = `${abs}.${process.pid}.tmp`;
|
||||
await fs.promises.writeFile(tmp, bytes);
|
||||
|
|
@ -1223,7 +1507,22 @@ export default class RemoteSshPlugin extends Plugin {
|
|||
},
|
||||
target,
|
||||
);
|
||||
new Notice(`Remote SSH: downloaded daemon for ${target.os}/${target.arch}.`);
|
||||
// Record the version + sha this cached binary is validated for, so the
|
||||
// next same-version connect takes the network-free fast path above.
|
||||
// Non-fatal: the binary is verified on disk, so a marker-persist failure
|
||||
// must NOT be reported as a download failure — we just re-verify on the
|
||||
// next connect.
|
||||
try {
|
||||
this.settings.daemonBinaryVersion = this.manifest.version;
|
||||
this.settings.daemonBinarySha = (await this.sha256File(local)) ?? undefined;
|
||||
await this.saveSettings();
|
||||
} catch (e) {
|
||||
logger.warn(
|
||||
`ensureDaemonBinary: daemon ready but failed to persist cache marker ` +
|
||||
`(${errorMessage(e)}); will re-verify on the next connect`,
|
||||
);
|
||||
}
|
||||
new Notice(`Remote SSH: daemon ready for ${target.os}/${target.arch}.`);
|
||||
return local;
|
||||
} catch (e) {
|
||||
// A sha256 mismatch / malformed manifest (DaemonVerificationError) is a
|
||||
|
|
|
|||
|
|
@ -29,6 +29,18 @@ function defaultObsidianConfigDir(): string {
|
|||
export const DEFAULT_PRIVATE_PATTERN_BASENAMES: readonly string[] = [
|
||||
'workspace.json',
|
||||
'workspace-mobile.json',
|
||||
// Per-device Obsidian settings. Each machine keeps its OWN
|
||||
// app/appearance/hotkeys/enabled-core-plugins under its per-client
|
||||
// subtree instead of fighting over one shared copy on the remote.
|
||||
// The shared copy was a perpetual write-conflict source: two sessions
|
||||
// (or the same device across reconnects) both wrote `<configDir>/app.json`
|
||||
// at the identity path, so every settings save tripped PreconditionFailed
|
||||
// against the other's mtime. Redirecting them makes a shared path — and
|
||||
// thus the conflict — impossible by construction.
|
||||
'app.json',
|
||||
'appearance.json',
|
||||
'core-plugins.json',
|
||||
'hotkeys.json',
|
||||
'cache',
|
||||
'cache.zlib',
|
||||
'types.json',
|
||||
|
|
|
|||
|
|
@ -103,6 +103,18 @@ export class ProfileForm extends Modal {
|
|||
.addText(t => t.setValue(this.profile.privateKeyPath ?? '')
|
||||
.onChange(v => { this.profile.privateKeyPath = v || undefined; }));
|
||||
|
||||
new Setting(contentEl)
|
||||
.setName('Proxy command')
|
||||
.setDesc(
|
||||
'Optional. OpenSSH ProxyCommand to reach the host through a ' +
|
||||
'subprocess, e.g. `cloudflared access ssh --hostname %h`. ' +
|
||||
'`%h`/`%p`/`%r` expand at connect time. Leave blank for a direct ' +
|
||||
'connection; ignored when a jump host is set. (Desktop only.)',
|
||||
)
|
||||
.addText(t => t
|
||||
.setValue(this.profile.proxyCommand ?? '')
|
||||
.onChange(v => { this.profile.proxyCommand = v.trim() || undefined; }));
|
||||
|
||||
contentEl.createEl('h3', { text: 'Remote vault' });
|
||||
|
||||
const pathSetting = new Setting(contentEl)
|
||||
|
|
@ -216,6 +228,17 @@ export class ProfileForm extends Modal {
|
|||
} else {
|
||||
this.profile.jumpHost = undefined;
|
||||
}
|
||||
// ProxyCommand → subprocess transport (#430). ProxyJump and
|
||||
// ProxyCommand are mutually exclusive in OpenSSH; mirror the
|
||||
// SftpClient precedence (jumpHost wins) by only adopting the
|
||||
// ProxyCommand when no ProxyJump is present.
|
||||
this.profile.proxyCommand = (e.proxyCommand && !e.proxyJump) ? e.proxyCommand : undefined;
|
||||
// IdentityAgent → agent socket (#430), e.g. 1Password's agent.sock.
|
||||
// Don't override an explicit IdentityFile (key) auth choice.
|
||||
if (e.identityAgent) {
|
||||
this.profile.agentSocket = e.identityAgent;
|
||||
if (!e.identityFile) this.profile.authMethod = 'agent';
|
||||
}
|
||||
}
|
||||
|
||||
private validate(): boolean {
|
||||
|
|
|
|||
|
|
@ -2,6 +2,8 @@ import * as fs from 'fs';
|
|||
import * as path from 'path';
|
||||
import * as os from 'os';
|
||||
import * as crypto from 'crypto';
|
||||
import { logger } from '../util/logger';
|
||||
import { errorMessage } from '../util/errorMessage';
|
||||
|
||||
/**
|
||||
* One entry in `obsidian.json`'s `vaults` map.
|
||||
|
|
@ -104,6 +106,57 @@ export class ObsidianRegistry {
|
|||
return { id, created: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* Repoint an existing vault entry from `oldPath` to `newPath`,
|
||||
* keeping the same Obsidian vault id. Used by the transitional
|
||||
* shadow-dir rename (legacy UUID name → friendly name) so the
|
||||
* registration follows the moved directory instead of leaving a
|
||||
* dead entry pointing at the old path. No-op (returns false) when no
|
||||
* entry matches `oldPath`.
|
||||
*/
|
||||
updatePath(oldPath: string, newPath: string): boolean {
|
||||
const cfg = this.read();
|
||||
const target = canonicalisePath(oldPath);
|
||||
for (const [id, entry] of Object.entries(cfg.vaults)) {
|
||||
if (canonicalisePath(entry.path) === target) {
|
||||
cfg.vaults[id] = { ...entry, path: newPath };
|
||||
this.writeAtomic(cfg);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether Obsidian currently has this vault open, per the
|
||||
* `obsidian.json` `open` flag. Used by the shadow-dir migration to
|
||||
* avoid renaming a live vault's directory — on Windows that corrupts
|
||||
* the open junction/handles (dangling plugin dir → daemon ENOENT).
|
||||
*
|
||||
* A registered path with `open !== true`, or a path not in the vault
|
||||
* list at all, is "not open" (safe to migrate). But an UNREADABLE
|
||||
* `obsidian.json` is treated as OPEN: we can't tell, and a false
|
||||
* "closed" here would let the caller rename a live vault and cause the
|
||||
* very corruption this guards against. Deferring a migration by one
|
||||
* bootstrap is the trivial cost of failing safe.
|
||||
*/
|
||||
isOpen(vaultPath: string): boolean {
|
||||
try {
|
||||
const cfg = this.read();
|
||||
const target = canonicalisePath(vaultPath);
|
||||
for (const entry of Object.values(cfg.vaults)) {
|
||||
if (canonicalisePath(entry.path) === target) return entry.open === true;
|
||||
}
|
||||
return false; // registered nowhere → Obsidian doesn't have it open
|
||||
} catch (e) {
|
||||
logger.warn(
|
||||
`ObsidianRegistry.isOpen: unreadable obsidian.json (${errorMessage(e)}); ` +
|
||||
'assuming open, deferring rename',
|
||||
);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Atomic write: serialise to a sibling temp file, then rename.
|
||||
* Limits the window during which Obsidian could read a half-written
|
||||
|
|
|
|||
|
|
@ -126,12 +126,18 @@ export class ShadowStartupCoordinator {
|
|||
/**
|
||||
* Read this shadow vault's community-plugins.json, find ids whose
|
||||
* binaries aren't yet installed, and download them from Obsidian's
|
||||
* community marketplace. On first bootstrap this is a no-op (the
|
||||
* list is just `["remote-ssh"]`); the path matters on re-bootstrap
|
||||
* where the user has accumulated a real list and a binary went
|
||||
* missing (vault moved disks, plugin dir purged, …).
|
||||
* community marketplace.
|
||||
*
|
||||
* Called twice: once in `prepareForAutoConnect` (before connect, from
|
||||
* the local list — re-installs a binary that went missing) and again
|
||||
* by the connect flow right after the remote community-plugins pull,
|
||||
* so a plugin enabled on another machine gets its binary and loads on
|
||||
* the first reconnect rather than only after a restart (#429b).
|
||||
* Idempotent — `installMissing` skips ids already on disk.
|
||||
* (Non-marketplace / BRAT plugins still need their binary on the
|
||||
* remote — out of scope here.)
|
||||
*/
|
||||
private async installMissingShadowPlugins(): Promise<void> {
|
||||
async installMissingShadowPlugins(): Promise<void> {
|
||||
const adapter = this.app.vault.adapter;
|
||||
if (!(adapter instanceof FileSystemAdapter)) {
|
||||
logger.warn('installMissingShadowPlugins: vault is not FileSystemAdapter-backed; skipping');
|
||||
|
|
|
|||
|
|
@ -52,6 +52,14 @@ export interface BootstrapResult {
|
|||
registryId: string;
|
||||
/** True if the vault entry was newly added (false = was already registered). */
|
||||
registryCreated: boolean;
|
||||
/**
|
||||
* True if an existing shadow dir (located by profile id, under any
|
||||
* prior naming scheme) was renamed to the current `<name>--<tail>` on
|
||||
* this bootstrap. Like a newly-registered vault, the renamed path isn't
|
||||
* in the running Obsidian's cached vault list, so the connect flow
|
||||
* surfaces the one-time "restart Obsidian" notice.
|
||||
*/
|
||||
migrated: boolean;
|
||||
/** How the plugin source landed in the shadow vault. */
|
||||
pluginInstallMethod: 'symlink' | 'copy';
|
||||
}
|
||||
|
|
@ -84,8 +92,8 @@ export interface SharedConfigWriter {
|
|||
*
|
||||
* Layout:
|
||||
*
|
||||
* <baseDir>/<sanitised-profile-id>/
|
||||
* ├── .obsidian/
|
||||
* <baseDir>/<name>--<remotePath-tail>/ (identity is the profile id
|
||||
* ├── .obsidian/ in data.json, not the dir name)
|
||||
* │ ├── community-plugins.json ← ["remote-ssh"]
|
||||
* │ └── plugins/
|
||||
* │ └── remote-ssh/ ← symlink (or copy on Windows
|
||||
|
|
@ -121,7 +129,10 @@ export class ShadowVaultBootstrap {
|
|||
* is `fs.*Sync` and JSON arithmetic, no I/O actually awaits.
|
||||
*/
|
||||
private bootstrapSync(profile: SshProfile, allProfiles: ReadonlyArray<SshProfile>): BootstrapResult {
|
||||
const layout = this.layoutFor(profile.id);
|
||||
// Resolve (and, when safe, migrate to) the `<name>--<tail>` shadow
|
||||
// dir for this profile. Identity is the profile id, not the dir
|
||||
// name, so a rename/collision never strands config (see resolveLayout).
|
||||
const { layout, migrated } = this.resolveLayout(profile);
|
||||
|
||||
fs.mkdirSync(layout.vaultDir, { recursive: true });
|
||||
fs.mkdirSync(layout.configDir, { recursive: true });
|
||||
|
|
@ -209,16 +220,21 @@ export class ShadowVaultBootstrap {
|
|||
`at ${layout.vaultDir} (registry id=${registryId}, plugin=${pluginInstallMethod})`,
|
||||
);
|
||||
|
||||
return { layout, registryId, registryCreated: created, pluginInstallMethod };
|
||||
return { layout, registryId, registryCreated: created, migrated, pluginInstallMethod };
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute paths for a given profile id without doing any I/O.
|
||||
* Useful for callers that need the layout up-front (e.g. the
|
||||
* spawner needs `vaultDir` for the open URL).
|
||||
* Compute the shadow paths for a profile without doing any I/O: the
|
||||
* pure `<name>--<tail>` layout, before any collision ` (n)` suffix
|
||||
* (which only resolveLayout applies). A thin, side-effect-free path
|
||||
* builder — resolveLayout is the sole caller.
|
||||
*/
|
||||
layoutFor(profileId: string): ShadowVaultLayout {
|
||||
const vaultDir = path.join(this.baseDir, sanitiseProfileId(profileId));
|
||||
layoutFor(profile: Pick<SshProfile, 'name' | 'remotePath'>): ShadowVaultLayout {
|
||||
return this.layoutForDir(path.join(this.baseDir, friendlyVaultDirName(profile)));
|
||||
}
|
||||
|
||||
/** Derive the `.obsidian/...` sub-paths for a concrete vault dir. */
|
||||
private layoutForDir(vaultDir: string): ShadowVaultLayout {
|
||||
// The shadow vault is freshly created on disk by us before Obsidian
|
||||
// ever opens it; there's no live `App` instance whose
|
||||
// `vault.configDir` we could query, so we build the directory name
|
||||
|
|
@ -233,19 +249,179 @@ export class ShadowVaultBootstrap {
|
|||
return { vaultDir, configDir, pluginDir, pluginDataFile };
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the shadow layout to use for this profile.
|
||||
*
|
||||
* Identity is the profile *id* (persisted as `autoConnectProfileId`
|
||||
* in the shadow's data.json), NOT the directory name — so a profile
|
||||
* rename, a display-name collision, or an older `<uuid>` / `--<id8>`
|
||||
* naming scheme all still resolve to the same shadow via
|
||||
* `findShadowByProfileId`. When the existing shadow's dir name no
|
||||
* longer matches the desired `<name>--<tail>`, we migrate it once by
|
||||
* renaming, EXCEPT:
|
||||
*
|
||||
* - the vault is currently open (obsidian.json `open` flag): renaming
|
||||
* an open vault's dir on Windows corrupts the live junction/handles
|
||||
* (the plugin dir goes dangling → daemon download ENOENTs → SFTP
|
||||
* fallback). Use it as-is; migrate on the next closed bootstrap.
|
||||
* - the desired name is already taken by a *different* profile:
|
||||
* `uniqueVaultDir` appends ` (2)` so two vaults never share one dir
|
||||
* (which would merge their data.json / secrets / host keys).
|
||||
*
|
||||
* The rename and the obsidian.json path update are separate `try`s:
|
||||
* once the dir physically moves we commit to the new path even if
|
||||
* updating the registry throws, because falling back would recreate
|
||||
* the old dir empty and orphan the real config (#438 review).
|
||||
*/
|
||||
private resolveLayout(
|
||||
profile: Pick<SshProfile, 'id' | 'name' | 'remotePath'>,
|
||||
): { layout: ShadowVaultLayout; migrated: boolean } {
|
||||
const desired = this.layoutFor(profile);
|
||||
const found = this.findShadowByProfileId(profile.id);
|
||||
|
||||
if (found) {
|
||||
// Where this profile's shadow SHOULD live (collision-safe): its own
|
||||
// dir if it already owns one, else the free `<name>--<tail>` or a
|
||||
// ` (n)` variant. Compare `found` to this resolved target — NOT to
|
||||
// the bare desired name — otherwise a profile permanently parked in a
|
||||
// ` (2)` dir (a display-name collision) would "migrate" to itself on
|
||||
// every reconnect: a no-op same-path rename that falsely reports
|
||||
// migrated=true and re-shows the one-time "restart Obsidian" notice.
|
||||
const target = this.uniqueVaultDir(desired.vaultDir, profile.id);
|
||||
if (path.basename(found) === path.basename(target)) {
|
||||
return { layout: this.layoutForDir(found), migrated: false };
|
||||
}
|
||||
// R2: never rename a dir whose vault is open — Windows corrupts the
|
||||
// live junction. Use it as-is; migrate on the next closed run.
|
||||
if (this.registry.isOpen(found)) {
|
||||
logger.info(`ShadowVaultBootstrap: ${found} is open; deferring rename`);
|
||||
return { layout: this.layoutForDir(found), migrated: false };
|
||||
}
|
||||
// Migrate. A successful rename commits the move; a failing updatePath
|
||||
// is logged but not fatal (self-heals on register()). A failing
|
||||
// rename falls back to the found dir.
|
||||
try {
|
||||
fs.renameSync(found, target);
|
||||
} catch (e) {
|
||||
logger.warn(
|
||||
`ShadowVaultBootstrap: rename ${found} → ${target} failed (${errorMessage(e)}); using ${found} this session`,
|
||||
);
|
||||
return { layout: this.layoutForDir(found), migrated: false };
|
||||
}
|
||||
try {
|
||||
this.registry.updatePath(found, target);
|
||||
} catch (e) {
|
||||
logger.warn(
|
||||
`ShadowVaultBootstrap: registry path update failed post-migration (${errorMessage(e)}); ` +
|
||||
`config is at ${target}, registry self-heals on register()`,
|
||||
);
|
||||
}
|
||||
logger.info(`ShadowVaultBootstrap: migrated shadow ${found} → ${target}`);
|
||||
return { layout: this.layoutForDir(target), migrated: true };
|
||||
}
|
||||
|
||||
// Brand-new profile — pick a collision-free dir (a different profile
|
||||
// may already own the desired `<name>--<tail>`).
|
||||
const target = this.uniqueVaultDir(desired.vaultDir, profile.id);
|
||||
return { layout: this.layoutForDir(target), migrated: false };
|
||||
}
|
||||
|
||||
/**
|
||||
* Scan `baseDir` for the shadow whose data.json `autoConnectProfileId`
|
||||
* matches. Identity is the id, not the dir name, so this is naming-scheme
|
||||
* agnostic — it reuses the existing config whether the dir is a legacy
|
||||
* `<uuid>`, an older `<name>--<id8>`, or the current `<name>--<tail>`,
|
||||
* instead of stranding it. Returns the first match in sorted order
|
||||
* (deterministic) or null.
|
||||
*/
|
||||
private findShadowByProfileId(profileId: string): string | null {
|
||||
let entries: string[];
|
||||
try {
|
||||
entries = fs.readdirSync(this.baseDir).sort();
|
||||
} catch {
|
||||
return null; // baseDir not created yet
|
||||
}
|
||||
for (const entry of entries) {
|
||||
const dir = path.join(this.baseDir, entry);
|
||||
let isDir: boolean;
|
||||
try { isDir = fs.statSync(dir).isDirectory(); } catch { continue; }
|
||||
if (isDir && this.readShadowProfileId(dir) === profileId) return dir;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The `autoConnectProfileId` recorded in `dir`'s shadow data.json, or
|
||||
* null if `dir` isn't a bootstrapped shadow. A genuinely absent data.json
|
||||
* (ENOENT) is the normal "not a shadow dir" case and stays silent; an
|
||||
* EXISTING-but-unreadable data.json — a transient lock (AV / Dropbox sync
|
||||
* / a mid-write) or malformed JSON — is logged, because it can briefly
|
||||
* hide this profile's OWN shadow and make resolveLayout fork a duplicate
|
||||
* ` (2)` dir instead of reusing it. A non-string id is treated as no
|
||||
* match (only a false negative is possible, never a false reuse).
|
||||
*/
|
||||
private readShadowProfileId(dir: string): string | null {
|
||||
const dataFile = this.layoutForDir(dir).pluginDataFile;
|
||||
let raw: string;
|
||||
try {
|
||||
raw = fs.readFileSync(dataFile, 'utf-8');
|
||||
} catch (e) {
|
||||
if ((e as NodeJS.ErrnoException).code !== 'ENOENT') {
|
||||
logger.warn(`ShadowVaultBootstrap: unreadable ${dataFile} (${errorMessage(e)}); treating as non-matching`);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
const parsed = JSON.parse(raw) as { autoConnectProfileId?: unknown };
|
||||
return typeof parsed.autoConnectProfileId === 'string' ? parsed.autoConnectProfileId : null;
|
||||
} catch (e) {
|
||||
logger.warn(`ShadowVaultBootstrap: malformed ${dataFile} (${errorMessage(e)}); treating as non-matching`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A dir the given profile may safely occupy: the desired name if it's
|
||||
* free or already this profile's, else `desired (2)`, `desired (3)`, …
|
||||
* Two profiles must never share a dir — that would merge their
|
||||
* data.json (secrets, host keys, active profile). Display-name
|
||||
* collisions are allowed; only the on-disk dir is disambiguated.
|
||||
*/
|
||||
private uniqueVaultDir(desiredVaultDir: string, profileId: string): string {
|
||||
// Free (absent) or already ours → safe to take. An existing dir whose
|
||||
// data.json belongs to a DIFFERENT profile (or is unreadable, logged by
|
||||
// readShadowProfileId) is left alone so configs never merge.
|
||||
const ownsOrFree = (dir: string): boolean =>
|
||||
!fs.existsSync(dir) || this.readShadowProfileId(dir) === profileId;
|
||||
if (ownsOrFree(desiredVaultDir)) return desiredVaultDir;
|
||||
for (let n = 2; n < 100; n++) {
|
||||
const candidate = `${desiredVaultDir} (${n})`;
|
||||
if (ownsOrFree(candidate)) return candidate;
|
||||
}
|
||||
// 98 collisions on one name is absurd; fall back to an id-suffixed
|
||||
// dir so we still return something unique rather than loop forever.
|
||||
return `${desiredVaultDir} (${profileId.slice(0, 8)})`;
|
||||
}
|
||||
|
||||
// ─── shared-config round-trip (#342) ────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Shared (non per-client) Obsidian config files. `PathMapper`
|
||||
* leaves these unmapped on purpose so every machine on the vault
|
||||
* sees the same `app.json` / theme / enabled-core-plugins /
|
||||
* hotkeys. The sharing was one-way though: edits in one session
|
||||
* push to the remote, but the local shadow disk never pulled them
|
||||
* back, so the *next* Obsidian startup read a stale local copy and
|
||||
* the settings appeared to evaporate (#342).
|
||||
* Obsidian config files this vault round-trips to the remote so a
|
||||
* settings change survives a shadow-window restart (#342: without the
|
||||
* pull half, the next startup read a stale local copy and settings
|
||||
* appeared to evaporate).
|
||||
*
|
||||
* `workspace.json` is deliberately NOT here — it's per-client UI
|
||||
* state that `PathMapper` already redirects into a private subtree.
|
||||
* These are now **per-device**, not shared: `PathMapper` redirects each
|
||||
* basename into this client's `<configDir>/user/<client-id>/` subtree
|
||||
* (they were added to `DEFAULT_PRIVATE_PATTERN_BASENAMES`). So the
|
||||
* round-trip below reads/writes THIS device's own copy — giving each
|
||||
* machine a remote backup + cross-session persistence without two
|
||||
* devices ever colliding on one shared `<configDir>/app.json` (the
|
||||
* perpetual write-conflict this round-trip used to cause). The name is
|
||||
* kept for back-compat; "shared" is historical.
|
||||
*
|
||||
* `workspace.json` is deliberately NOT here — it's per-client UI state
|
||||
* `PathMapper` already redirects AND that Obsidian rewrites constantly.
|
||||
*/
|
||||
static readonly SHARED_OBSIDIAN_CONFIG_FILES = [
|
||||
'app.json',
|
||||
|
|
@ -422,6 +598,313 @@ export class ShadowVaultBootstrap {
|
|||
return { pushed, skipped, errored };
|
||||
}
|
||||
|
||||
// ─── community-plugins list round-trip (#429 / #342) ─────────────────────
|
||||
//
|
||||
// `community-plugins.json` is the *enabled community plugins* list.
|
||||
// It deliberately does NOT join SHARED_OBSIDIAN_CONFIG_FILES (whose
|
||||
// pull/push copy bytes verbatim): a remote list that omitted
|
||||
// `remote-ssh` would, written verbatim, disable the very plugin doing
|
||||
// the sync. Instead these two methods round-trip the list with a
|
||||
// forced `remote-ssh` union, so a plugin enabled on one machine
|
||||
// reaches another machine's shadow vault and reopening no longer
|
||||
// "loses" installed plugins. The marketplace installer re-fetches any
|
||||
// binaries the list names but that aren't staged locally yet.
|
||||
|
||||
/** The plugin's own id — always kept enabled across a round-trip. */
|
||||
static readonly SELF_PLUGIN_ID = 'remote-ssh';
|
||||
|
||||
/**
|
||||
* Pull the remote enabled-plugin list into the shadow vault, merged
|
||||
* with the local list and with `remote-ssh` forced on. A remote list
|
||||
* that's absent or not a valid id array leaves the local list
|
||||
* untouched (never clobbered).
|
||||
*/
|
||||
static async pullCommunityPlugins(
|
||||
reader: SharedConfigReader,
|
||||
remoteConfigDir: string,
|
||||
localConfigDir: string,
|
||||
): Promise<{ pulled: boolean; merged: string[] }> {
|
||||
const basename = 'community-plugins.json';
|
||||
const remoteRel = `${remoteConfigDir}/${basename}`;
|
||||
const localPath = path.join(localConfigDir, basename);
|
||||
|
||||
fs.mkdirSync(localConfigDir, { recursive: true });
|
||||
const local = ShadowVaultBootstrap.readPluginIdList(localPath);
|
||||
|
||||
let remote: string[] | null = null;
|
||||
try {
|
||||
if (await reader.exists(remoteRel)) {
|
||||
remote = ShadowVaultBootstrap.parsePluginIdList(await reader.read(remoteRel));
|
||||
if (remote === null) {
|
||||
logger.warn(
|
||||
`pullCommunityPlugins: remote ${basename} is not a valid id array; keeping local list`,
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
logger.warn(`pullCommunityPlugins: ${basename} skipped (${errorMessage(e)})`);
|
||||
}
|
||||
|
||||
const merged = ShadowVaultBootstrap.mergePluginIds(
|
||||
local, remote ?? [], ShadowVaultBootstrap.SELF_PLUGIN_ID,
|
||||
);
|
||||
const changed =
|
||||
merged.length !== local.length || merged.some((id, i) => id !== local[i]);
|
||||
if (changed) ShadowVaultBootstrap.writePluginIdListAtomic(localPath, merged);
|
||||
|
||||
logger.info(`pullCommunityPlugins: merged [${merged.join(', ')}] (changed=${changed})`);
|
||||
return { pulled: remote !== null, merged };
|
||||
}
|
||||
|
||||
/**
|
||||
* Push the local enabled-plugin list to the remote, unioned with the
|
||||
* remote's CURRENT list and with `remote-ssh` forced on.
|
||||
*
|
||||
* Self-protecting against clobber: it re-reads the remote first and
|
||||
* unions, so a stale/minimal local list (e.g. after a transient pull
|
||||
* read-failure earlier in the same connect) can never drop a plugin
|
||||
* another machine enabled. If the remote HAS the file but it can't be
|
||||
* read or parsed, it aborts rather than overwrite what it couldn't
|
||||
* see. A genuinely-absent remote file is seeded from local.
|
||||
*/
|
||||
static async pushCommunityPlugins(
|
||||
rw: SharedConfigReader & SharedConfigWriter,
|
||||
remoteConfigDir: string,
|
||||
localConfigDir: string,
|
||||
): Promise<{ pushed: boolean }> {
|
||||
const basename = 'community-plugins.json';
|
||||
const remoteRel = `${remoteConfigDir}/${basename}`;
|
||||
const local = ShadowVaultBootstrap.readPluginIdList(path.join(localConfigDir, basename));
|
||||
|
||||
let remote: string[] = [];
|
||||
try {
|
||||
if (await rw.exists(remoteRel)) {
|
||||
const parsed = ShadowVaultBootstrap.parsePluginIdList(await rw.read(remoteRel));
|
||||
if (parsed === null) {
|
||||
logger.warn('pushCommunityPlugins: remote list is not a valid id array; not pushing (avoid clobber)');
|
||||
return { pushed: false };
|
||||
}
|
||||
remote = parsed;
|
||||
}
|
||||
} catch (e) {
|
||||
logger.warn(`pushCommunityPlugins: cannot read remote (${errorMessage(e)}); not pushing (avoid clobber)`);
|
||||
return { pushed: false };
|
||||
}
|
||||
|
||||
const ids = ShadowVaultBootstrap.mergePluginIds(remote, local, ShadowVaultBootstrap.SELF_PLUGIN_ID);
|
||||
// No-op when the remote already equals the union — avoid churn.
|
||||
if (remote.length === ids.length && remote.every((id, i) => id === ids[i])) {
|
||||
return { pushed: false };
|
||||
}
|
||||
try {
|
||||
await rw.write(remoteRel, JSON.stringify(ids) + '\n');
|
||||
logger.info(`pushCommunityPlugins: pushed [${ids.join(', ')}]`);
|
||||
return { pushed: true };
|
||||
} catch (e) {
|
||||
logger.warn(`pushCommunityPlugins: push failed (${errorMessage(e)})`);
|
||||
return { pushed: false };
|
||||
}
|
||||
}
|
||||
|
||||
// ─── plugin code round-trip (#429b — BRAT / non-marketplace) ────────────
|
||||
//
|
||||
// The enabled-plugins LIST round-trips (above) and the marketplace
|
||||
// installer fetches binaries for plugins on Obsidian's registry. But a
|
||||
// BRAT / sideloaded plugin isn't on the marketplace, so its code would
|
||||
// never reach another machine. These methods round-trip the plugin
|
||||
// *code* through the remote vault's `.obsidian/plugins/<id>/` (the
|
||||
// canonical store) so such plugins load everywhere. Code only — the
|
||||
// plugin's own `data.json` (settings, sometimes secrets) is left alone.
|
||||
//
|
||||
// Convergence is VERSION-ORDERED, not last-writer-wins: pull only when
|
||||
// the remote is strictly newer (or the plugin is absent locally), push
|
||||
// only when the local copy is strictly newer (or the remote lacks it).
|
||||
// A plain "content differs" gate would let a machine still on an old
|
||||
// version downgrade a plugin the rest of the fleet already upgraded,
|
||||
// and the two sides would ping-pong forever (review: #429b).
|
||||
|
||||
// NOTE: synced over a UTF-8 TEXT channel (readText/writeText). Every
|
||||
// entry MUST be text. Do NOT add binary assets (.png/.woff/…) here —
|
||||
// the UTF-8 round-trip would corrupt them.
|
||||
static readonly PLUGIN_BINARY_FILES = ['manifest.json', 'main.js', 'styles.css'] as const;
|
||||
|
||||
/**
|
||||
* Pull a plugin's code from the remote into the local shadow when the
|
||||
* plugin is ABSENT locally or the remote is a STRICTLY NEWER version
|
||||
* (by manifest `version`) — so a plugin enabled/updated on another
|
||||
* machine (incl. BRAT / non-marketplace) reaches here and loads on the
|
||||
* next vault open. Never downgrades a local copy, never touches
|
||||
* `data.json`. `remote-ssh` is skipped (self-managed).
|
||||
*/
|
||||
static async pullPluginBinaries(
|
||||
reader: SharedConfigReader,
|
||||
remoteConfigDir: string,
|
||||
localConfigDir: string,
|
||||
pluginIds: readonly string[],
|
||||
): Promise<{ pulled: string[] }> {
|
||||
const pulled: string[] = [];
|
||||
for (const id of pluginIds) {
|
||||
if (id === ShadowVaultBootstrap.SELF_PLUGIN_ID) continue;
|
||||
const localPluginDir = path.join(localConfigDir, 'plugins', id);
|
||||
const remoteManifest = `${remoteConfigDir}/plugins/${id}/manifest.json`;
|
||||
try {
|
||||
if (!(await reader.exists(remoteManifest))) continue; // no code on the remote
|
||||
const remoteVer = ShadowVaultBootstrap.parseManifestVersion(await reader.read(remoteManifest));
|
||||
const localManifest = path.join(localPluginDir, 'manifest.json');
|
||||
const localVer = fs.existsSync(localManifest)
|
||||
? ShadowVaultBootstrap.parseManifestVersion(fs.readFileSync(localManifest, 'utf-8'))
|
||||
: null;
|
||||
// Skip unless absent locally, or the remote is strictly newer.
|
||||
if (localVer !== null && !ShadowVaultBootstrap.versionGt(remoteVer, localVer)) continue;
|
||||
let staged = false;
|
||||
for (const file of ShadowVaultBootstrap.PLUGIN_BINARY_FILES) {
|
||||
const remoteRel = `${remoteConfigDir}/plugins/${id}/${file}`;
|
||||
if (!(await reader.exists(remoteRel))) continue;
|
||||
const content = await reader.read(remoteRel);
|
||||
fs.mkdirSync(localPluginDir, { recursive: true });
|
||||
ShadowVaultBootstrap.writeFileAtomic(path.join(localPluginDir, file), content);
|
||||
staged = true;
|
||||
}
|
||||
if (staged) pulled.push(id);
|
||||
} catch (e) {
|
||||
logger.warn(`pullPluginBinaries: ${id} skipped (${errorMessage(e)})`);
|
||||
}
|
||||
}
|
||||
if (pulled.length) logger.info(`pullPluginBinaries: staged [${pulled.join(', ')}]`);
|
||||
return { pulled };
|
||||
}
|
||||
|
||||
/**
|
||||
* Push a plugin's local code to the remote when the remote LACKS it or
|
||||
* the local copy is a STRICTLY NEWER version — so other machines can
|
||||
* pull it. Never downgrades the remote, never pushes `data.json`;
|
||||
* byte-identical files are skipped to avoid churn. `remote-ssh` skipped.
|
||||
*/
|
||||
static async pushPluginBinaries(
|
||||
rw: SharedConfigReader & SharedConfigWriter,
|
||||
remoteConfigDir: string,
|
||||
localConfigDir: string,
|
||||
pluginIds: readonly string[],
|
||||
): Promise<{ pushed: string[] }> {
|
||||
const pushed: string[] = [];
|
||||
for (const id of pluginIds) {
|
||||
if (id === ShadowVaultBootstrap.SELF_PLUGIN_ID) continue;
|
||||
const localPluginDir = path.join(localConfigDir, 'plugins', id);
|
||||
const localManifest = path.join(localPluginDir, 'manifest.json');
|
||||
if (!fs.existsSync(localManifest)) continue; // nothing to push
|
||||
const remoteManifest = `${remoteConfigDir}/plugins/${id}/manifest.json`;
|
||||
try {
|
||||
const localVer = ShadowVaultBootstrap.parseManifestVersion(fs.readFileSync(localManifest, 'utf-8'));
|
||||
const remoteVer = (await rw.exists(remoteManifest))
|
||||
? ShadowVaultBootstrap.parseManifestVersion(await rw.read(remoteManifest))
|
||||
: null;
|
||||
// Skip unless the remote lacks it, or the local copy is newer.
|
||||
if (remoteVer !== null && !ShadowVaultBootstrap.versionGt(localVer, remoteVer)) continue;
|
||||
let sent = false;
|
||||
for (const file of ShadowVaultBootstrap.PLUGIN_BINARY_FILES) {
|
||||
const localFile = path.join(localPluginDir, file);
|
||||
if (!fs.existsSync(localFile)) continue;
|
||||
const content = fs.readFileSync(localFile, 'utf-8');
|
||||
const remoteRel = `${remoteConfigDir}/plugins/${id}/${file}`;
|
||||
if ((await rw.exists(remoteRel)) && (await rw.read(remoteRel)) === content) continue; // identical
|
||||
await rw.write(remoteRel, content);
|
||||
sent = true;
|
||||
}
|
||||
if (sent) pushed.push(id);
|
||||
} catch (e) {
|
||||
logger.warn(`pushPluginBinaries: ${id} skipped (${errorMessage(e)})`);
|
||||
}
|
||||
}
|
||||
if (pushed.length) logger.info(`pushPluginBinaries: pushed [${pushed.join(', ')}]`);
|
||||
return { pushed };
|
||||
}
|
||||
|
||||
/** Parse a manifest.json body's dotted-numeric `version` ([0] when absent/unparseable = oldest). */
|
||||
private static parseManifestVersion(body: string): number[] {
|
||||
try {
|
||||
const v = (JSON.parse(body) as { version?: unknown }).version;
|
||||
if (typeof v !== 'string') return [0];
|
||||
const parts = v.split('.').map((s) => parseInt(s, 10)).map((n) => (Number.isFinite(n) ? n : 0));
|
||||
return parts.length ? parts : [0];
|
||||
} catch {
|
||||
return [0];
|
||||
}
|
||||
}
|
||||
|
||||
/** True when dotted-numeric version `a` is strictly greater than `b` (missing segments = 0). */
|
||||
private static versionGt(a: number[], b: number[]): boolean {
|
||||
const len = Math.max(a.length, b.length);
|
||||
for (let i = 0; i < len; i++) {
|
||||
const x = a[i] ?? 0;
|
||||
const y = b[i] ?? 0;
|
||||
if (x !== y) return x > y;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/** Atomic (tmp + rename) write of arbitrary file content. */
|
||||
private static writeFileAtomic(localFile: string, content: string): void {
|
||||
const tmp = `${localFile}.${process.pid}.tmp`;
|
||||
fs.writeFileSync(tmp, content, 'utf-8');
|
||||
try {
|
||||
fs.renameSync(tmp, localFile);
|
||||
} catch (e) {
|
||||
try { fs.unlinkSync(tmp); } catch { /* best effort */ }
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
/** Parse a community-plugins.json body into a string-id array, or null if malformed. */
|
||||
private static parsePluginIdList(content: string): string[] | null {
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(content);
|
||||
if (!Array.isArray(parsed)) return null;
|
||||
return (parsed as unknown[]).filter((s): s is string => typeof s === 'string');
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Enabled-plugin ids from a local `.obsidian/community-plugins.json`
|
||||
* ([] when absent/malformed) — the set whose binaries the connect flow
|
||||
* round-trips via {@link pullPluginBinaries}/{@link pushPluginBinaries}.
|
||||
*/
|
||||
static readEnabledPluginIds(localConfigDir: string): string[] {
|
||||
return ShadowVaultBootstrap.readPluginIdList(path.join(localConfigDir, 'community-plugins.json'));
|
||||
}
|
||||
|
||||
/** Read a local community-plugins.json into an id array ([] when absent/malformed). */
|
||||
private static readPluginIdList(localPath: string): string[] {
|
||||
try {
|
||||
return ShadowVaultBootstrap.parsePluginIdList(fs.readFileSync(localPath, 'utf-8')) ?? [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/** Order-preserving union of `local` + `remote`, with `required` guaranteed present. */
|
||||
private static mergePluginIds(local: string[], remote: string[], required: string): string[] {
|
||||
const out: string[] = [];
|
||||
const seen = new Set<string>();
|
||||
for (const id of [...local, ...remote, required]) {
|
||||
if (!seen.has(id)) { seen.add(id); out.push(id); }
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Atomic (tmp + rename) write of an id array as JSON. */
|
||||
private static writePluginIdListAtomic(localPath: string, ids: string[]): void {
|
||||
const tmp = `${localPath}.${process.pid}.tmp`;
|
||||
fs.writeFileSync(tmp, JSON.stringify(ids) + '\n', 'utf-8');
|
||||
try {
|
||||
fs.renameSync(tmp, localPath);
|
||||
} catch (e) {
|
||||
try { fs.unlinkSync(tmp); } catch { /* best effort */ }
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
// ─── internals ──────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
|
|
@ -722,7 +1205,14 @@ export class ShadowVaultBootstrap {
|
|||
for (const d of sharedDirs) {
|
||||
const src = path.join(this.sourcePluginDir, d);
|
||||
const dst = path.join(pluginDir, d);
|
||||
if (!fs.existsSync(src)) continue;
|
||||
// Only mirror a REAL source dir (a local dev build of the daemon). A
|
||||
// junction/symlink source is NOT propagated: it would chain the shadow
|
||||
// to another vault's server-bin and dangle if that vault is deleted,
|
||||
// breaking the per-shadow daemon download (the binary is per-arch,
|
||||
// fetched at connect time — not a shared build artifact like main.js).
|
||||
let srcStat: fs.Stats;
|
||||
try { srcStat = fs.lstatSync(src); } catch { continue; } // absent → skip
|
||||
if (srcStat.isSymbolicLink() || !srcStat.isDirectory()) continue;
|
||||
// Use lstat + unlink for symlinks vs rmSync recursive for real
|
||||
// dirs so we never accidentally recurse through a link.
|
||||
try {
|
||||
|
|
@ -750,14 +1240,56 @@ export class ShadowVaultBootstrap {
|
|||
}
|
||||
|
||||
/**
|
||||
* Profile ids should already be uuids, but we sanitise defensively:
|
||||
* a malicious or unusual id should never escape `baseDir` via `..`
|
||||
* or surprise the filesystem with separators.
|
||||
* Filesystem-safe form of a profile *name* for the friendly vault-dir
|
||||
* name. Obsidian shows a vault by its directory basename, so this is
|
||||
* what the user sees instead of a raw UUID. Spaces are kept (valid in
|
||||
* dir names on every OS); anything path-dangerous is collapsed to `_`;
|
||||
* length-capped; never `.`/`..`/empty.
|
||||
*/
|
||||
function sanitiseProfileId(id: string): string {
|
||||
const cleaned = id.replace(/[^a-zA-Z0-9._-]/g, '_');
|
||||
// Empty / dot-only ids would resolve to baseDir itself or its
|
||||
// parent; force them into something benign.
|
||||
if (!cleaned || cleaned === '.' || cleaned === '..') return '_invalid';
|
||||
function sanitiseVaultName(name: string): string {
|
||||
const cleaned = (name ?? '')
|
||||
.replace(/[^a-zA-Z0-9._ -]/g, '_')
|
||||
.replace(/_{2,}/g, '_')
|
||||
.trim()
|
||||
.slice(0, 40)
|
||||
.trim();
|
||||
if (!cleaned || cleaned === '.' || cleaned === '..') return 'vault';
|
||||
return cleaned;
|
||||
}
|
||||
|
||||
/**
|
||||
* Filesystem-safe form of the remotePath's LAST segment. One host
|
||||
* (profile name) often holds several independent vaults under
|
||||
* different folders, so the folder tail is what disambiguates them —
|
||||
* `/home/souta/work` → `work`, `/home/souta/work/dev` → `dev`. Same
|
||||
* sanitising rules as the name; a bare/degenerate path falls back to
|
||||
* `vault`.
|
||||
*/
|
||||
function sanitisePathTail(remotePath: string): string {
|
||||
const trimmed = (remotePath ?? '').replace(/[/\\]+$/, '');
|
||||
const tail = trimmed.split(/[/\\]/).pop() ?? '';
|
||||
// A bare `~` (vault rooted at the remote home dir) has no meaningful
|
||||
// folder tail — catch it before the charset strip below turns it into `_`.
|
||||
if (tail === '~') return 'vault';
|
||||
const cleaned = tail
|
||||
.replace(/[^a-zA-Z0-9._ -]/g, '_')
|
||||
.replace(/_{2,}/g, '_')
|
||||
.trim()
|
||||
.slice(0, 40)
|
||||
.trim();
|
||||
if (!cleaned || cleaned === '.' || cleaned === '..') return 'vault';
|
||||
return cleaned;
|
||||
}
|
||||
|
||||
/**
|
||||
* The shadow vault's directory name: `<friendly-name>--<path-tail>`.
|
||||
* The name identifies the host, the tail the folder — together they
|
||||
* let Obsidian show a recognisable, folder-distinct name (e.g.
|
||||
* `Panza--work` vs `Panza--dev`) instead of a bare UUID. The id is NOT
|
||||
* in the name: identity is resolved from `data.json`'s
|
||||
* `autoConnectProfileId` (see `findShadowByProfileId`), so a profile
|
||||
* rename or a display-name collision never strands its config.
|
||||
*/
|
||||
function friendlyVaultDirName(profile: Pick<SshProfile, 'name' | 'remotePath'>): string {
|
||||
return `${sanitiseVaultName(profile.name)}--${sanitisePathTail(profile.remotePath)}`;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -24,8 +24,23 @@ export class ShadowVaultManager {
|
|||
async openShadowFor(
|
||||
profile: SshProfile,
|
||||
allProfiles: ReadonlyArray<SshProfile>,
|
||||
onBootstrapped?: (result: BootstrapResult) => Promise<void>,
|
||||
): Promise<BootstrapResult> {
|
||||
const result = await this.bootstrap.bootstrap(profile, allProfiles);
|
||||
// #429b / Phase B-3: optional pre-spawn step — e.g. pull the remote
|
||||
// `.obsidian/` into the freshly-created shadow dir so the window
|
||||
// boots on canonical config + plugins instead of the stale local
|
||||
// copy. Best-effort BY CONTRACT: a throw here must never block the
|
||||
// spawn, so the shadow window's own connect still catches up (the
|
||||
// pre-B3 behaviour). The hook owns its own timeout.
|
||||
if (onBootstrapped) {
|
||||
try {
|
||||
await onBootstrapped(result);
|
||||
} catch {
|
||||
// swallowed — spawn proceeds regardless (fallback to open-now,
|
||||
// sync-inside-the-window).
|
||||
}
|
||||
}
|
||||
this.spawner.spawn(result.layout.vaultDir);
|
||||
return result;
|
||||
}
|
||||
|
|
|
|||
26
plugin/src/shadow/preSpawnPaths.ts
Normal file
26
plugin/src/shadow/preSpawnPaths.ts
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
import type { PathMapper } from '../path/PathMapper';
|
||||
|
||||
/**
|
||||
* Resolve a vault-relative `.obsidian/…` path to the absolute remote path
|
||||
* the pre-spawn config pull must read, applying the SAME per-client
|
||||
* redirect the shadow window's adapter uses.
|
||||
*
|
||||
* Load-bearing: the four per-device config files (`app.json`,
|
||||
* `appearance.json`, `core-plugins.json`, `hotkeys.json`) must resolve to
|
||||
* `<configDir>/user/<clientId>/…`, NOT the dead shared identity path — the
|
||||
* pre-spawn pull previously joined the bare remote base and read the shared
|
||||
* path, clobbering the redirected per-device config on every spawn (#450
|
||||
* CRITICAL). `PathMapper.toRemote` is identity for non-private paths, so
|
||||
* `community-plugins.json` / `plugins/*` stay shared, unchanged.
|
||||
*
|
||||
* Extracted as a pure function so this redirect is unit-testable without
|
||||
* standing up `preSpawnPull`'s standalone `SftpClient`.
|
||||
*/
|
||||
export function preSpawnRemotePath(
|
||||
mapper: PathMapper,
|
||||
remoteBase: string,
|
||||
vaultRelPath: string,
|
||||
): string {
|
||||
const mapped = mapper.toRemote(vaultRelPath);
|
||||
return remoteBase === '.' ? mapped : `${remoteBase}/${mapped}`;
|
||||
}
|
||||
106
plugin/src/ssh/ProxyCommandTunnel.ts
Normal file
106
plugin/src/ssh/ProxyCommandTunnel.ts
Normal file
|
|
@ -0,0 +1,106 @@
|
|||
import { spawn } from 'child_process';
|
||||
import { Duplex } from 'stream';
|
||||
import { logger } from '../util/logger';
|
||||
|
||||
/**
|
||||
* The concrete connection target a `ProxyCommand`'s %-tokens expand
|
||||
* against. `%h` → host, `%p` → port, `%r` → remote user.
|
||||
*/
|
||||
export interface ProxyCommandTarget {
|
||||
host: string;
|
||||
port: number;
|
||||
user?: string;
|
||||
}
|
||||
|
||||
export interface ProxyCommandTunnelOptions {
|
||||
/** Injection seam for tests; defaults to `child_process.spawn`. */
|
||||
spawnFn?: typeof spawn;
|
||||
}
|
||||
|
||||
/**
|
||||
* Expand the OpenSSH `ProxyCommand` %-tokens against a concrete target.
|
||||
* Supports `%h` (host), `%p` (port), `%r` (remote user) and `%%`
|
||||
* (a literal `%`). Unknown `%x` sequences are left untouched.
|
||||
*/
|
||||
export function expandProxyCommandTokens(template: string, target: ProxyCommandTarget): string {
|
||||
return template.replace(/%[hpr%]/g, (tok) => {
|
||||
switch (tok) {
|
||||
case '%h': return target.host;
|
||||
case '%p': return String(target.port);
|
||||
case '%r': return target.user ?? '';
|
||||
case '%%': return '%';
|
||||
default: return tok;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Open a `ProxyCommand` transport: spawn the (token-expanded) command
|
||||
* through the platform shell — exactly as OpenSSH runs
|
||||
* `/bin/sh -c <ProxyCommand>` — and bridge its stdio as a single
|
||||
* `Duplex`. The returned stream is what ssh2 takes as its `sock`
|
||||
* option: ssh2 writes the SSH protocol into the child's stdin and
|
||||
* reads the peer's bytes from its stdout.
|
||||
*
|
||||
* Desktop-only: `child_process` is unavailable on mobile. Callers must
|
||||
* gate on `Platform.isDesktop` before reaching this path.
|
||||
*/
|
||||
export function createProxyCommandTunnel(
|
||||
proxyCommand: string,
|
||||
target: ProxyCommandTarget,
|
||||
opts: ProxyCommandTunnelOptions = {},
|
||||
): Duplex {
|
||||
const spawnFn = opts.spawnFn ?? spawn;
|
||||
const command = expandProxyCommandTokens(proxyCommand, target);
|
||||
logger.info(`ProxyCommandTunnel: spawning proxy for ${target.host}:${target.port}`);
|
||||
|
||||
// `shell: true` + a full command line mirrors OpenSSH's behaviour
|
||||
// (the directive is a command line, not an argv vector). With no
|
||||
// `stdio` option this overload returns a ChildProcessWithoutNullStreams
|
||||
// so stdin/stdout/stderr are guaranteed non-null.
|
||||
const child = spawnFn(command, { shell: true });
|
||||
|
||||
const duplex = new Duplex({
|
||||
read() {
|
||||
// Resume the child's stdout if backpressure had paused it.
|
||||
child.stdout.resume();
|
||||
},
|
||||
write(chunk: Buffer, _enc, cb) {
|
||||
child.stdin.write(chunk, (err) => cb(err ?? undefined));
|
||||
},
|
||||
final(cb) {
|
||||
child.stdin.end();
|
||||
cb();
|
||||
},
|
||||
});
|
||||
|
||||
// proxy → client: push child stdout onto the readable side, honouring
|
||||
// backpressure so a slow consumer can't make us buffer without bound.
|
||||
child.stdout.on('data', (chunk: Buffer) => {
|
||||
if (!duplex.push(chunk)) child.stdout.pause();
|
||||
});
|
||||
child.stdout.on('end', () => duplex.push(null));
|
||||
child.stderr.on('data', (chunk: Buffer) => {
|
||||
logger.warn(`ProxyCommandTunnel[${target.host}] stderr: ${chunk.toString().trim()}`);
|
||||
});
|
||||
|
||||
// Spawn failure (ENOENT for a missing proxy binary, EACCES, …) surfaces
|
||||
// on the stream so ssh2's connect rejects with a clear cause.
|
||||
child.on('error', (err: Error) => {
|
||||
duplex.destroy(err);
|
||||
});
|
||||
child.on('close', (code: number | null) => {
|
||||
duplex.push(null);
|
||||
if (code && code !== 0) {
|
||||
logger.warn(`ProxyCommandTunnel[${target.host}] proxy exited with code ${code}`);
|
||||
}
|
||||
});
|
||||
|
||||
// When ssh2 (or the caller) closes the tunnel, reap the proxy process
|
||||
// so it can't linger.
|
||||
duplex.on('close', () => {
|
||||
if (!child.killed) child.kill();
|
||||
});
|
||||
|
||||
return duplex;
|
||||
}
|
||||
|
|
@ -6,6 +6,7 @@ import { TMP_SUFFIX } from '../constants';
|
|||
import { AuthResolver } from './AuthResolver';
|
||||
import { HostKeyStore, type HostKeyMismatchHandler } from './HostKeyStore';
|
||||
import { createJumpTunnel } from './JumpHostTunnel';
|
||||
import { createProxyCommandTunnel } from './ProxyCommandTunnel';
|
||||
import { logger } from '../util/logger';
|
||||
import { asError, errorMessage } from '../util/errorMessage';
|
||||
|
||||
|
|
@ -179,6 +180,17 @@ export class SftpClient {
|
|||
keepaliveIntervalMs: profile.keepaliveIntervalMs,
|
||||
},
|
||||
);
|
||||
} else if (profile.proxyCommand) {
|
||||
// ProxyCommand transport (#430): run the command (e.g.
|
||||
// `cloudflared access ssh --hostname %h`) and hand ssh2 its
|
||||
// stdio as the `sock`. Mutually exclusive with jumpHost; if both
|
||||
// are somehow set, the bastion jump above wins (it already ran).
|
||||
logger.info(`SftpClient: opening ProxyCommand transport for ${profile.host}`);
|
||||
sock = createProxyCommandTunnel(profile.proxyCommand, {
|
||||
host: profile.host,
|
||||
port: profile.port,
|
||||
user: profile.username,
|
||||
});
|
||||
}
|
||||
|
||||
const client = new Client();
|
||||
|
|
|
|||
|
|
@ -14,6 +14,20 @@ export interface SshConfigEntry {
|
|||
port: number;
|
||||
identityFile?: string;
|
||||
proxyJump?: ProxyJumpEntry;
|
||||
/**
|
||||
* Raw `ProxyCommand` directive value, with OpenSSH %-tokens (`%h`,
|
||||
* `%p`, `%r`) left intact — they are expanded at connect time once
|
||||
* the concrete host/port/user are known (see `ProxyCommandTunnel`).
|
||||
* A command-based proxy (e.g. `cloudflared access ssh --hostname %h`)
|
||||
* is distinct from `ProxyJump`, which opens an SSH bastion hop.
|
||||
*/
|
||||
proxyCommand?: string;
|
||||
/**
|
||||
* `IdentityAgent` socket path (tilde-expanded), e.g. 1Password's
|
||||
* `~/.1password/agent.sock`. Lets auth go through a specific agent
|
||||
* rather than `$SSH_AUTH_SOCK`.
|
||||
*/
|
||||
identityAgent?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -44,6 +58,8 @@ interface RawFields {
|
|||
port?: number;
|
||||
identityFile?: string;
|
||||
proxyJump?: string;
|
||||
proxyCommand?: string;
|
||||
identityAgent?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -112,11 +128,16 @@ function parseBlocks(content: string): RawHostBlock[] {
|
|||
if (!current) continue;
|
||||
|
||||
switch (key) {
|
||||
case 'hostname': current.fields.hostname = value; break;
|
||||
case 'user': current.fields.user = value; break;
|
||||
case 'port': current.fields.port = parseInt(value, 10) || 22; break;
|
||||
case 'identityfile': current.fields.identityFile = expandTilde(value); break;
|
||||
case 'proxyjump': current.fields.proxyJump = value; break;
|
||||
case 'hostname': current.fields.hostname = value; break;
|
||||
case 'user': current.fields.user = value; break;
|
||||
case 'port': current.fields.port = parseInt(value, 10) || 22; break;
|
||||
case 'identityfile': current.fields.identityFile = expandTilde(value); break;
|
||||
case 'proxyjump': current.fields.proxyJump = value; break;
|
||||
// ProxyCommand is kept raw: the %-tokens (%h/%p/%r) are expanded
|
||||
// at connect time, not here. IdentityAgent is a socket path, so
|
||||
// it tilde-expands like IdentityFile.
|
||||
case 'proxycommand': current.fields.proxyCommand = value; break;
|
||||
case 'identityagent': current.fields.identityAgent = expandTilde(value); break;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -133,11 +154,13 @@ function mergeDefaults(blocks: RawHostBlock[]): RawFields {
|
|||
const defaults: RawFields = {};
|
||||
for (const b of blocks) {
|
||||
if (!b.isDefaults) continue;
|
||||
if (b.fields.hostname !== undefined) defaults.hostname = b.fields.hostname;
|
||||
if (b.fields.user !== undefined) defaults.user = b.fields.user;
|
||||
if (b.fields.port !== undefined) defaults.port = b.fields.port;
|
||||
if (b.fields.identityFile !== undefined) defaults.identityFile = b.fields.identityFile;
|
||||
if (b.fields.proxyJump !== undefined) defaults.proxyJump = b.fields.proxyJump;
|
||||
if (b.fields.hostname !== undefined) defaults.hostname = b.fields.hostname;
|
||||
if (b.fields.user !== undefined) defaults.user = b.fields.user;
|
||||
if (b.fields.port !== undefined) defaults.port = b.fields.port;
|
||||
if (b.fields.identityFile !== undefined) defaults.identityFile = b.fields.identityFile;
|
||||
if (b.fields.proxyJump !== undefined) defaults.proxyJump = b.fields.proxyJump;
|
||||
if (b.fields.proxyCommand !== undefined) defaults.proxyCommand = b.fields.proxyCommand;
|
||||
if (b.fields.identityAgent !== undefined) defaults.identityAgent = b.fields.identityAgent;
|
||||
}
|
||||
return defaults;
|
||||
}
|
||||
|
|
@ -150,8 +173,10 @@ function buildEntry(
|
|||
const hostname = fields.hostname ?? defaults.hostname ?? alias;
|
||||
const user = fields.user ?? defaults.user ?? defaultUserName();
|
||||
const port = fields.port ?? defaults.port ?? 22;
|
||||
const identityFile = fields.identityFile ?? defaults.identityFile;
|
||||
const rawJump = fields.proxyJump ?? defaults.proxyJump;
|
||||
const identityFile = fields.identityFile ?? defaults.identityFile;
|
||||
const rawJump = fields.proxyJump ?? defaults.proxyJump;
|
||||
const proxyCommand = fields.proxyCommand ?? defaults.proxyCommand;
|
||||
const identityAgent = fields.identityAgent ?? defaults.identityAgent;
|
||||
|
||||
return {
|
||||
alias,
|
||||
|
|
@ -164,6 +189,8 @@ function buildEntry(
|
|||
// from the referenced alias when available, falling back to the
|
||||
// parent host's user when no alias matched.
|
||||
proxyJump: rawJump !== undefined ? parseProxyJumpRaw(rawJump) : undefined,
|
||||
proxyCommand,
|
||||
identityAgent,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -32,8 +32,12 @@ export interface DaemonDownloaderDeps {
|
|||
cacheDir: string;
|
||||
/** Persist downloaded bytes to disk and mark executable (chmod +x). */
|
||||
writeExecutable: (absPath: string, bytes: Uint8Array) => Promise<void>;
|
||||
/** True if a cached file already exists. */
|
||||
cacheHit: (absPath: string) => boolean;
|
||||
/**
|
||||
* Read a cached binary's bytes, or null if absent / unreadable. Used for
|
||||
* sha-based cache validation: a cached file whose bytes still match the
|
||||
* release manifest's expected sha is reused; anything else is re-downloaded.
|
||||
*/
|
||||
readCached: (absPath: string) => Promise<Uint8Array | null>;
|
||||
/** `owner/repo` for the release URL. */
|
||||
repo: string;
|
||||
/**
|
||||
|
|
@ -113,8 +117,6 @@ export async function ensureDaemonBinary(
|
|||
): Promise<string> {
|
||||
const filename = binaryFilename(target);
|
||||
const dest = path.join(deps.cacheDir, filename);
|
||||
if (deps.cacheHit(dest)) return dest;
|
||||
|
||||
const base = `https://github.com/${deps.repo}/releases/download/${deps.version}`;
|
||||
|
||||
const manifestRaw = await deps.fetchText(`${base}/${MANIFEST_NAME}`);
|
||||
|
|
@ -142,6 +144,19 @@ export async function ensureDaemonBinary(
|
|||
);
|
||||
}
|
||||
|
||||
// Reuse the cached binary IFF its bytes already match the expected sha —
|
||||
// i.e. the daemon didn't change across this bump. A cached binary from a
|
||||
// prior plugin version (or the pre-static-build era) has the same filename
|
||||
// but different bytes, so it fails this check and is re-downloaded. This is
|
||||
// what makes a plugin upgrade refresh a *changed* daemon instead of
|
||||
// re-deploying the stale one, while skipping the download for an unchanged
|
||||
// one. (`writeExecutable` overwrites atomically, so the stale file is
|
||||
// replaced in place — no separate delete.)
|
||||
const cached = await deps.readCached(dest);
|
||||
if (cached && sha256Hex(cached).toLowerCase() === expected.toLowerCase()) {
|
||||
return dest;
|
||||
}
|
||||
|
||||
const bytes = await deps.fetchBinary(`${base}/${filename}`);
|
||||
const got = sha256Hex(bytes);
|
||||
if (got.toLowerCase() !== expected.toLowerCase()) {
|
||||
|
|
|
|||
|
|
@ -43,6 +43,15 @@ export interface SshProfile {
|
|||
keepaliveCountMax: number;
|
||||
hostKeyFingerprint?: string;
|
||||
jumpHost?: JumpHostConfig;
|
||||
/**
|
||||
* OpenSSH `ProxyCommand` line, e.g. `cloudflared access ssh
|
||||
* --hostname %h`. When set (and no `jumpHost`), the connection is
|
||||
* tunnelled through this subprocess's stdio instead of a direct TCP
|
||||
* socket — covers Cloudflare-tunnel / `cloudflared` / `nc`-style
|
||||
* reachability. `%h`/`%p`/`%r` are expanded at connect time. Desktop
|
||||
* only (`child_process`). See `ProxyCommandTunnel`.
|
||||
*/
|
||||
proxyCommand?: string;
|
||||
|
||||
/**
|
||||
* Picks between the legacy direct-SFTP transport and the α path
|
||||
|
|
@ -125,6 +134,31 @@ export interface PluginSettings {
|
|||
* is staged locally (community-store installs); remembered thereafter.
|
||||
*/
|
||||
daemonDownloadConsented?: boolean;
|
||||
/**
|
||||
* Plugin version the currently-cached daemon binary was validated for.
|
||||
* The connect fast-path reuses the cached binary without hitting GitHub
|
||||
* when this equals the running `manifest.version`; a mismatch (or absence)
|
||||
* forces a sha re-check against the release manifest, so a plugin upgrade
|
||||
* always refreshes a *changed* daemon and never re-downloads an unchanged
|
||||
* one. Written after each successful (re)provision.
|
||||
*/
|
||||
daemonBinaryVersion?: string;
|
||||
/**
|
||||
* sha256 (hex) of the currently-cached daemon binary. The connect fast-path
|
||||
* re-hashes the cached file and reuses it only when this still matches — so
|
||||
* a binary corrupted/truncated after its verified download is detected
|
||||
* (network-free) and re-fetched instead of deployed. Paired with
|
||||
* `daemonBinaryVersion`; both are written together after a provision.
|
||||
*/
|
||||
daemonBinarySha?: string;
|
||||
/**
|
||||
* Load the remote tree ONE folder level at a time (deepen on File-Explorer
|
||||
* expand) instead of walking + materialising the whole tree at connect.
|
||||
* Default true — essential for large, deep, dir-heavy vaults where the eager
|
||||
* build freezes the window. Set false to restore the full eager walk (fine
|
||||
* for small vaults; makes search/graph see the whole tree immediately).
|
||||
*/
|
||||
lazyFolderLoad?: boolean;
|
||||
/**
|
||||
* #149 — terminal pane preferences. All optional; the View applies
|
||||
* sensible defaults when missing. `terminalShell` overrides the
|
||||
|
|
|
|||
23
plugin/src/util/withTimeout.ts
Normal file
23
plugin/src/util/withTimeout.ts
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
/**
|
||||
* Race a promise against a timeout. Rejects with a labelled error if
|
||||
* `ms` elapses before `promise` settles; the timer is always cleared so
|
||||
* a fast-settling promise leaves no dangling handle.
|
||||
*
|
||||
* The underlying promise is NOT cancelled on timeout — callers that need
|
||||
* teardown (e.g. closing a socket) must do it themselves, typically in a
|
||||
* `finally`. Used by the pre-spawn config pull (#429b / Phase B-3) to
|
||||
* bound how long a Connect blocks before falling back to spawning the
|
||||
* shadow window and letting it catch up.
|
||||
*/
|
||||
export function withTimeout<T>(promise: Promise<T>, ms: number, label: string): Promise<T> {
|
||||
// `window.setTimeout` returns a number in the Obsidian renderer (DOM),
|
||||
// not a NodeJS.Timeout — type it as such so @types/node's global
|
||||
// overload doesn't leak in.
|
||||
let timer: number | undefined;
|
||||
const timeout = new Promise<never>((_resolve, reject) => {
|
||||
timer = window.setTimeout(() => reject(new Error(`${label} timed out after ${ms}ms`)), ms);
|
||||
});
|
||||
return Promise.race([promise, timeout]).finally(() => {
|
||||
if (timer !== undefined) window.clearTimeout(timer);
|
||||
});
|
||||
}
|
||||
|
|
@ -69,6 +69,13 @@ export interface BulkWalkResult {
|
|||
pages: number;
|
||||
/** When `fallback-list` because of a fast-path error, the error message; else null. */
|
||||
fastPathError: string | null;
|
||||
/**
|
||||
* Count of entries dropped by the hidden-file (dot-prefix) filter before
|
||||
* `entries` was returned. Lets the caller tell a genuinely empty remote
|
||||
* apart from "everything walked was hidden" (e.g. all content nested under
|
||||
* a dot-dir) instead of firing a misleading "0 files — check remotePath".
|
||||
*/
|
||||
hiddenCount: number;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -111,13 +118,22 @@ export class BulkWalker {
|
|||
*/
|
||||
private static readonly MAX_PAGES = 1_000;
|
||||
|
||||
async walk(rootPath: string = ''): Promise<BulkWalkResult> {
|
||||
/**
|
||||
* Walk `rootPath`. With `recursive` true (default) the whole subtree is
|
||||
* returned; with `recursive` false only `rootPath`'s IMMEDIATE children are
|
||||
* returned — the primitive the lazy per-folder loader uses to deepen one
|
||||
* level on demand instead of pulling a huge deep tree at connect.
|
||||
*/
|
||||
async walk(rootPath: string = '', recursive: boolean = true): Promise<BulkWalkResult> {
|
||||
const start = Date.now();
|
||||
if (this.canUseFastPath()) {
|
||||
try {
|
||||
const result = await this.fastPath(rootPath);
|
||||
const result = await this.fastPath(rootPath, recursive);
|
||||
const visible = this.visibleEntries(result.entries);
|
||||
return {
|
||||
...result,
|
||||
entries: visible,
|
||||
hiddenCount: result.entries.length - visible.length,
|
||||
walkMs: Date.now() - start,
|
||||
// `truncated` here means we stopped at the page guard on a
|
||||
// pathological tree — surface it so populate can Notice the
|
||||
|
|
@ -127,23 +143,49 @@ export class BulkWalker {
|
|||
} catch (e) {
|
||||
const message = errorMessage(e);
|
||||
logger.warn(`BulkWalker: fs.walk failed (${message}); falling back to per-folder list`);
|
||||
const fallback = await this.fallbackPath(rootPath);
|
||||
const fallback = await this.fallbackPath(rootPath, recursive);
|
||||
const visible = this.visibleEntries(fallback.entries);
|
||||
return {
|
||||
...fallback,
|
||||
entries: visible,
|
||||
hiddenCount: fallback.entries.length - visible.length,
|
||||
walkMs: Date.now() - start,
|
||||
fastPathError: message,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const fallback = await this.fallbackPath(rootPath);
|
||||
const fallback = await this.fallbackPath(rootPath, recursive);
|
||||
const visible = this.visibleEntries(fallback.entries);
|
||||
return {
|
||||
...fallback,
|
||||
entries: visible,
|
||||
hiddenCount: fallback.entries.length - visible.length,
|
||||
walkMs: Date.now() - start,
|
||||
fastPathError: null,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Drop dot-prefixed entries so hidden files/dirs never reach the File
|
||||
* Explorer — matching Obsidian's own default of hiding dot-names. An entry
|
||||
* is hidden when ANY of its path segments starts with `.`, so a dot-DIR
|
||||
* (`.julia`, `.git`, …) hides its whole subtree. The vault config dir
|
||||
* (`.obsidian`) is deliberately included: Obsidian loads config directly
|
||||
* off the local shadow disk, NOT from this walked model, so dropping it
|
||||
* costs nothing AND keeps every client's per-device `<configDir>/user/<id>/`
|
||||
* subtree out of the tree (a foreign client's state must never surface as
|
||||
* editable files). Applies to the full walk AND every lazy per-folder
|
||||
* deepen (both route through here).
|
||||
*/
|
||||
private visibleEntries(entries: RemoteEntry[]): RemoteEntry[] {
|
||||
return entries.filter((e) => !BulkWalker.isHiddenPath(e.path));
|
||||
}
|
||||
|
||||
private static isHiddenPath(vaultPath: string): boolean {
|
||||
return vaultPath.split('/').some((seg) => seg.startsWith('.'));
|
||||
}
|
||||
|
||||
// ─── internals ──────────────────────────────────────────────────────────
|
||||
|
||||
private canUseFastPath(): boolean {
|
||||
|
|
@ -152,7 +194,7 @@ export class BulkWalker {
|
|||
return conn.info.capabilities.includes(BulkWalker.FAST_PATH_CAPABILITY);
|
||||
}
|
||||
|
||||
private async fastPath(rootPath: string): Promise<{
|
||||
private async fastPath(rootPath: string, recursive: boolean): Promise<{
|
||||
entries: RemoteEntry[];
|
||||
source: 'rpc-walk';
|
||||
truncated: boolean;
|
||||
|
|
@ -167,7 +209,7 @@ export class BulkWalker {
|
|||
let offset = 0;
|
||||
let pages = 0;
|
||||
for (;;) {
|
||||
const params: WalkParams = { path: rootPath, recursive: true };
|
||||
const params: WalkParams = { path: rootPath, recursive };
|
||||
if (this.deps.maxEntries != null) params.maxEntries = this.deps.maxEntries;
|
||||
if (offset > 0) params.offset = offset;
|
||||
if (this.deps.ignoreDirs?.length) params.ignore = this.deps.ignoreDirs;
|
||||
|
|
@ -206,7 +248,7 @@ export class BulkWalker {
|
|||
}
|
||||
}
|
||||
|
||||
private async fallbackPath(rootPath: string): Promise<{
|
||||
private async fallbackPath(rootPath: string, recursive: boolean): Promise<{
|
||||
entries: RemoteEntry[];
|
||||
source: 'fallback-list';
|
||||
truncated: false;
|
||||
|
|
@ -226,7 +268,7 @@ export class BulkWalker {
|
|||
for (const sub of listing.folders) {
|
||||
if (!sub) continue;
|
||||
entries.push({ path: sub, isDirectory: true, ctime: 0, mtime: 0, size: 0 });
|
||||
queue.push(sub);
|
||||
if (recursive) queue.push(sub); // one level only when non-recursive
|
||||
}
|
||||
for (const file of listing.files) {
|
||||
if (!file) continue;
|
||||
|
|
|
|||
81
plugin/src/vault/LazyFolderLoader.ts
Normal file
81
plugin/src/vault/LazyFolderLoader.ts
Normal file
|
|
@ -0,0 +1,81 @@
|
|||
import type { BulkWalker } from './BulkWalker';
|
||||
import type { VaultModelBuilder } from './VaultModelBuilder';
|
||||
import { logger } from '../util/logger';
|
||||
import { errorMessage } from '../util/errorMessage';
|
||||
|
||||
/**
|
||||
* Loads the remote vault tree ONE folder level at a time, on demand, instead
|
||||
* of walking + materialising the whole (deep, dir-heavy) tree at connect.
|
||||
*
|
||||
* Connect does a shallow populate (root's immediate children). Every folder is
|
||||
* materialised as an initially childless `TFolder` — Obsidian still renders an
|
||||
* expand arrow for a childless folder (verified in a real-Obsidian spike:
|
||||
* `nav-folder-title` gets `mod-collapsible` + a collapse-icon regardless of
|
||||
* child count), so the user can open it. A File-Explorer folder-expand click
|
||||
* (wired in `main.ts`, since Obsidian renders from the in-memory model and
|
||||
* does NOT call `adapter.list` on expand) calls {@link loadFolder}, which
|
||||
* walks just that folder and materialises its children; their own subfolders
|
||||
* stay unloaded until expanded in turn.
|
||||
*
|
||||
* `walkIgnoreDirs` is honoured per level (the walker carries it), so ignore
|
||||
* pruning and lazy depth-limiting compose. The live `fs.changed` watch keeps
|
||||
* working against a partially-loaded tree because inserts `ensureParents`.
|
||||
*
|
||||
* Idempotent + deduped: re-expanding a loaded folder is a no-op, and two
|
||||
* near-simultaneous expands of the same folder share one walk.
|
||||
*/
|
||||
export class LazyFolderLoader {
|
||||
private readonly loaded = new Set<string>();
|
||||
private readonly inFlight = new Map<string, Promise<void>>();
|
||||
|
||||
constructor(
|
||||
/** Fresh walker per load — mirrors how the connect populate builds one. */
|
||||
private readonly makeWalker: () => BulkWalker,
|
||||
/** Fresh (stateless) builder per load. */
|
||||
private readonly makeBuilder: () => VaultModelBuilder,
|
||||
) {}
|
||||
|
||||
/** Mark a path as already loaded (the root, after the connect shallow populate). */
|
||||
markLoaded(path: string): void {
|
||||
this.loaded.add(path);
|
||||
}
|
||||
|
||||
/** Drop all lazy state (on disconnect / re-patch). */
|
||||
reset(): void {
|
||||
this.loaded.clear();
|
||||
this.inFlight.clear();
|
||||
}
|
||||
|
||||
isLoaded(path: string): boolean {
|
||||
return this.loaded.has(path);
|
||||
}
|
||||
|
||||
/**
|
||||
* Materialise `folderPath`'s immediate children (one level). Resolves once
|
||||
* they're in the vault model. No-op if the folder was already loaded; a
|
||||
* concurrent load of the same folder shares one walk. A failed load leaves
|
||||
* the folder unloaded so a later expand retries.
|
||||
*/
|
||||
loadFolder(folderPath: string): Promise<void> {
|
||||
if (this.loaded.has(folderPath)) return Promise.resolve();
|
||||
const existing = this.inFlight.get(folderPath);
|
||||
if (existing) return existing;
|
||||
const p = this.doLoad(folderPath);
|
||||
this.inFlight.set(folderPath, p);
|
||||
return p.finally(() => this.inFlight.delete(folderPath));
|
||||
}
|
||||
|
||||
private async doLoad(folderPath: string): Promise<void> {
|
||||
try {
|
||||
const walk = await this.makeWalker().walk(folderPath, false); // one level only
|
||||
const result = await this.makeBuilder().buildChunked(walk.entries);
|
||||
this.loaded.add(folderPath);
|
||||
logger.info(
|
||||
`LazyFolderLoader: loaded "${folderPath}" — ${result.filesAdded}f + ${result.foldersAdded}d ` +
|
||||
`(${result.skipped} already present)`,
|
||||
);
|
||||
} catch (e) {
|
||||
logger.warn(`LazyFolderLoader: load "${folderPath}" failed (${errorMessage(e)}); will retry on next expand`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -103,36 +103,7 @@ export class VaultModelBuilder {
|
|||
// Folders before files at the same depth, then by depth ascending,
|
||||
// so parents always exist by the time their children are processed.
|
||||
const ordered = [...entries].sort(byFoldersFirstThenDepth);
|
||||
|
||||
for (const entry of ordered) {
|
||||
if (!entry.path) {
|
||||
result.errors.push({ path: entry.path, message: 'empty path is not allowed' });
|
||||
continue;
|
||||
}
|
||||
if (this.vault.getAbstractFileByPath(entry.path)) {
|
||||
result.skipped++;
|
||||
continue;
|
||||
}
|
||||
const parent = this.resolveParent(entry.path);
|
||||
if (!parent) {
|
||||
result.errors.push({
|
||||
path: entry.path,
|
||||
message: `parent folder for "${entry.path}" not found in vault`,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
if (entry.isDirectory) {
|
||||
this.insertFolder(entry.path, parent);
|
||||
result.foldersAdded++;
|
||||
} else {
|
||||
this.insertFile(entry, parent);
|
||||
result.filesAdded++;
|
||||
}
|
||||
} catch (e) {
|
||||
result.errors.push({ path: entry.path, message: errorMessage(e) });
|
||||
}
|
||||
}
|
||||
for (const entry of ordered) this.insertBuildEntry(entry, result);
|
||||
|
||||
logger.info(
|
||||
`VaultModelBuilder: built ${result.filesAdded}f + ${result.foldersAdded}d, ` +
|
||||
|
|
@ -141,6 +112,77 @@ export class VaultModelBuilder {
|
|||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Chunked, non-blocking variant of `build` for the bulk connect-time
|
||||
* populate. Inserts the (folders-first) entry list in fixed-size chunks,
|
||||
* yielding to the event loop between them so Obsidian can paint the File
|
||||
* Explorer increments and stay responsive.
|
||||
*
|
||||
* A synchronous one-tick build of a large vault (tens of thousands of
|
||||
* entries, each firing a `vault.trigger('create')`) blocks the main thread
|
||||
* for tens of seconds — the tree looks empty and the window freezes.
|
||||
* Chunking trades that for the tree filling in progressively.
|
||||
*
|
||||
* Ordering is preserved (the sorted list is walked start-to-end), so a
|
||||
* file's parent folder is always inserted in the same or an earlier chunk.
|
||||
* Inserts are idempotent (an already-present path is skipped), so a live
|
||||
* `fs.changed` insert landing between chunks can't double-add.
|
||||
*/
|
||||
async buildChunked(
|
||||
entries: ReadonlyArray<RemoteEntry>,
|
||||
chunkSize = 500,
|
||||
): Promise<BuildResult> {
|
||||
const result: BuildResult = {
|
||||
filesAdded: 0, foldersAdded: 0, skipped: 0, errors: [],
|
||||
};
|
||||
const ordered = [...entries].sort(byFoldersFirstThenDepth);
|
||||
for (let i = 0; i < ordered.length; i += chunkSize) {
|
||||
const end = Math.min(i + chunkSize, ordered.length);
|
||||
for (let j = i; j < end; j++) this.insertBuildEntry(ordered[j], result);
|
||||
if (end < ordered.length) await yieldToEventLoop();
|
||||
}
|
||||
logger.info(
|
||||
`VaultModelBuilder: built ${result.filesAdded}f + ${result.foldersAdded}d (chunked), ` +
|
||||
`${result.skipped} skipped, ${result.errors.length} errors`,
|
||||
);
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Insert one walked entry into the vault model, accumulating counters and
|
||||
* errors. Shared by `buildSync` and `buildChunked` so the two stay in
|
||||
* lock-step. Never throws — a bad entry is recorded in `result.errors`.
|
||||
*/
|
||||
private insertBuildEntry(entry: RemoteEntry, result: BuildResult): void {
|
||||
if (!entry.path) {
|
||||
result.errors.push({ path: entry.path, message: 'empty path is not allowed' });
|
||||
return;
|
||||
}
|
||||
if (this.vault.getAbstractFileByPath(entry.path)) {
|
||||
result.skipped++;
|
||||
return;
|
||||
}
|
||||
const parent = this.resolveParent(entry.path);
|
||||
if (!parent) {
|
||||
result.errors.push({
|
||||
path: entry.path,
|
||||
message: `parent folder for "${entry.path}" not found in vault`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
try {
|
||||
if (entry.isDirectory) {
|
||||
this.insertFolder(entry.path, parent);
|
||||
result.foldersAdded++;
|
||||
} else {
|
||||
this.insertFile(entry, parent);
|
||||
result.filesAdded++;
|
||||
}
|
||||
} catch (e) {
|
||||
result.errors.push({ path: entry.path, message: errorMessage(e) });
|
||||
}
|
||||
}
|
||||
|
||||
// ─── internals ───────────────────────────────────────────────────────────
|
||||
|
||||
private resolveParent(path: string): TFolder | null {
|
||||
|
|
@ -305,11 +347,20 @@ export class VaultModelBuilder {
|
|||
* Returns `true` if the rename happened, `false` if the source
|
||||
* wasn't in the model or the destination's parent is missing.
|
||||
*/
|
||||
renameOne(oldPath: string, newPath: string): boolean {
|
||||
renameOne(oldPath: string, newPath: string, opts: { ensureParents?: boolean } = {}): boolean {
|
||||
if (!oldPath || !newPath || oldPath === newPath) return false;
|
||||
const target = this.vault.getAbstractFileByPath(oldPath);
|
||||
if (!target) return false;
|
||||
const newParent = this.resolveParent(newPath);
|
||||
// The destination subtree may not be modelled yet — e.g. a note
|
||||
// dragged into a freshly-created folder (#423). On the writer-reflect
|
||||
// path we synthesise the missing parent chain instead of bailing,
|
||||
// symmetric with `insertOne({ ensureParents: true })`. The bulk
|
||||
// `build()` path and remote-echo `FsChangeListener` leave
|
||||
// `ensureParents` off so a missing parent stays a sanity-check error.
|
||||
let newParent = this.resolveParent(newPath);
|
||||
if (!newParent && opts.ensureParents) {
|
||||
newParent = this.ensureParentFolder(newPath);
|
||||
}
|
||||
if (!newParent) {
|
||||
logger.warn(`VaultModelBuilder.renameOne: parent missing for "${newPath}"`);
|
||||
return false;
|
||||
|
|
@ -436,7 +487,17 @@ export class VaultModelBuilder {
|
|||
|
||||
/** Reflect a writer-side `adapter.rename`. */
|
||||
reflectRename(oldPath: string, newPath: string): void {
|
||||
if (!this.renameOne(oldPath, newPath)) {
|
||||
// Obsidian's own `Vault.rename` may have already relocated the entry
|
||||
// to the new path before this writer-reflect runs. The desired end
|
||||
// state already holds, so it's a benign no-op — not the #341/#423
|
||||
// divergence — and must not log a misleading "stale" warning.
|
||||
if (!this.vault.getAbstractFileByPath(oldPath) && this.vault.getAbstractFileByPath(newPath)) {
|
||||
return;
|
||||
}
|
||||
// `ensureParents`: a move into a not-yet-modelled folder (#423) must
|
||||
// synthesise the destination subtree rather than drop the file from
|
||||
// the model (which makes it vanish from File Explorer until reopen).
|
||||
if (!this.renameOne(oldPath, newPath, { ensureParents: true })) {
|
||||
// The open editor tab stays bound to the stale TFile — this is
|
||||
// the core #341 failure. renameOne already warns with the
|
||||
// specific cause; add the reflect context.
|
||||
|
|
@ -526,6 +587,17 @@ export class VaultModelBuilder {
|
|||
|
||||
// ─── helpers ──────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Yield to the macrotask queue so the renderer can paint pending File
|
||||
* Explorer updates between build chunks. A microtask (`Promise.resolve()`)
|
||||
* would run before the browser paints; a 0 ms timer lets it paint first.
|
||||
* Uses `window.setTimeout` per the repo's timer convention (the vitest setup
|
||||
* polyfills `window`).
|
||||
*/
|
||||
function yieldToEventLoop(): Promise<void> {
|
||||
return new Promise(resolve => { window.setTimeout(resolve, 0); });
|
||||
}
|
||||
|
||||
/**
|
||||
* Sort key: folders first at any given depth, then by depth ascending.
|
||||
* Two folders at the same depth, or two files at the same depth, are
|
||||
|
|
|
|||
|
|
@ -69,6 +69,48 @@ describe('BulkWalker', () => {
|
|||
expect(result.entries[2]).toMatchObject({ path: 'README.md', isDirectory: false, mtime: 3000, size: 42 });
|
||||
});
|
||||
|
||||
it('hides ALL dot-prefixed entries (incl the config dir) and reports hiddenCount', async () => {
|
||||
const adapter = makeAdapter({});
|
||||
const { rpc } = makeRpc(['fs.walk'], {
|
||||
entries: [
|
||||
{ path: 'Notes', type: 'folder', mtime: 1, size: 0 },
|
||||
{ path: 'Notes/keep.md', type: 'file', mtime: 2, size: 1 },
|
||||
{ path: '.julia', type: 'folder', mtime: 3, size: 0 },
|
||||
{ path: '.julia/registry.md', type: 'file', mtime: 4, size: 1 },
|
||||
{ path: 'Notes/.secret.md', type: 'file', mtime: 5, size: 1 },
|
||||
{ path: '.obsidian/app.json', type: 'file', mtime: 6, size: 1 },
|
||||
{ path: '.obsidian/user/box/app.json', type: 'file', mtime: 7, size: 1 },
|
||||
],
|
||||
truncated: false,
|
||||
});
|
||||
const walker = new BulkWalker({ adapter, rpcConnection: rpc });
|
||||
|
||||
const result = await walker.walk('');
|
||||
|
||||
// `.julia` + its subtree, a nested dotfile, AND the whole `.obsidian`
|
||||
// config dir (incl any client's per-device `user/<id>/` subtree) are
|
||||
// dropped — config is loaded off the local disk, never from this model.
|
||||
expect(result.entries.map(e => e.path)).toEqual(['Notes', 'Notes/keep.md']);
|
||||
expect(result.hiddenCount).toBe(5);
|
||||
});
|
||||
|
||||
it('applies the same dotfile filter on the fallback (adapter.list) path', async () => {
|
||||
const adapter = makeAdapter({
|
||||
'': { folders: ['Notes', '.git'], files: ['README.md'] },
|
||||
'Notes': { folders: [], files: ['Notes/keep.md', 'Notes/.secret.md'] },
|
||||
'.git': { folders: [], files: ['.git/config'] },
|
||||
});
|
||||
const walker = new BulkWalker({ adapter /* no rpcConnection → fallback */ });
|
||||
|
||||
const result = await walker.walk('');
|
||||
|
||||
expect(result.source).toBe('fallback-list');
|
||||
// `.git` (+ its file) and the nested `.secret.md` are dropped, exactly
|
||||
// as on the fast path — the filter lives in walk(), shared by both.
|
||||
expect(result.entries.map(e => e.path).sort()).toEqual(['Notes', 'Notes/keep.md', 'README.md']);
|
||||
expect(result.hiddenCount).toBe(3);
|
||||
});
|
||||
|
||||
it('passes through the maxEntries override when set', async () => {
|
||||
const adapter = makeAdapter({});
|
||||
const callSpy = vi.fn(async () => ({ entries: [], truncated: false } as WalkResult));
|
||||
|
|
|
|||
|
|
@ -84,7 +84,7 @@ describe('ensureDaemonBinary', () => {
|
|||
return bytes;
|
||||
},
|
||||
cacheDir: '/vault/.obsidian/plugins/remote-ssh/server-bin',
|
||||
cacheHit: () => false,
|
||||
readCached: async () => null,
|
||||
writeExecutable: async (p, b) => {
|
||||
written.set(p, b);
|
||||
},
|
||||
|
|
@ -101,18 +101,28 @@ describe('ensureDaemonBinary', () => {
|
|||
expect(deps.written.get(p)).toEqual(bytes);
|
||||
});
|
||||
|
||||
it('uses the cache and does NOT download on a cache hit', async () => {
|
||||
it('reuses the cached binary WITHOUT downloading when its bytes match the manifest sha', async () => {
|
||||
let fetched = false;
|
||||
const deps = makeDeps({
|
||||
cacheHit: () => true,
|
||||
fetchBinary: async () => {
|
||||
fetched = true;
|
||||
return bytes;
|
||||
},
|
||||
readCached: async () => bytes, // cached bytes already hash to `sha`
|
||||
fetchBinary: async () => { fetched = true; return bytes; },
|
||||
});
|
||||
const p = await ensureDaemonBinary(deps, target);
|
||||
expect(p).toContain(filename);
|
||||
expect(fetched).toBe(false);
|
||||
expect(fetched).toBe(false); // no binary download
|
||||
expect(deps.written.size).toBe(0); // nothing re-written
|
||||
});
|
||||
|
||||
it('RE-DOWNLOADS when the cached binary sha does NOT match (stale from a prior plugin version)', async () => {
|
||||
let fetched = false;
|
||||
const stale = new Uint8Array([0x00, 0x11, 0x22]); // different bytes → different sha
|
||||
const deps = makeDeps({
|
||||
readCached: async () => stale,
|
||||
fetchBinary: async () => { fetched = true; return bytes; },
|
||||
});
|
||||
const p = await ensureDaemonBinary(deps, target);
|
||||
expect(fetched).toBe(true); // stale cache → fetched fresh
|
||||
expect(deps.written.get(p)).toEqual(bytes); // overwritten with the correct binary
|
||||
});
|
||||
|
||||
it('throws DaemonVerificationError on sha256 mismatch (never caches)', async () => {
|
||||
|
|
@ -183,5 +193,7 @@ describe('resolveDaemonConsent', () => {
|
|||
describe('deferred hardening (#406 review)', () => {
|
||||
it.todo('verifies the cosign .bundle (sigstore) against the GitHub OIDC identity before deploy — closes the MITM gap that sha256-from-the-same-channel leaves open');
|
||||
it.todo('shares the daemon binary cache across vaults/profiles (e.g. ~/.obsidian-remote/server-bin) so a second profile reuses the first download + single consent');
|
||||
it.todo('re-verifies a cache-hit binary against its sha256 before reuse — defends post-write on-disk corruption (the atomic tmp+rename write already closes the mid-download crash window)');
|
||||
// Implemented: ensureDaemonBinary now re-checks a cached binary's sha256
|
||||
// against the release manifest before reuse (see the reuse / re-download
|
||||
// tests above), which also refreshes a stale binary after a plugin upgrade.
|
||||
});
|
||||
|
|
|
|||
83
plugin/tests/LazyFolderLoader.test.ts
Normal file
83
plugin/tests/LazyFolderLoader.test.ts
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { LazyFolderLoader } from '../src/vault/LazyFolderLoader';
|
||||
import type { BulkWalker } from '../src/vault/BulkWalker';
|
||||
import type { VaultModelBuilder } from '../src/vault/VaultModelBuilder';
|
||||
|
||||
function fakeWalk(path: string) {
|
||||
return {
|
||||
entries: [{ path: `${path}/child.md`, isDirectory: false, ctime: 0, mtime: 0, size: 0 }],
|
||||
source: 'rpc-walk' as const,
|
||||
truncated: false,
|
||||
walkMs: 1,
|
||||
pages: 1,
|
||||
fastPathError: null,
|
||||
};
|
||||
}
|
||||
|
||||
function makeBuilder(): VaultModelBuilder & { buildChunked: ReturnType<typeof vi.fn> } {
|
||||
return {
|
||||
buildChunked: vi.fn(async (entries: readonly unknown[]) => ({
|
||||
filesAdded: entries.length, foldersAdded: 0, skipped: 0, errors: [],
|
||||
})),
|
||||
} as unknown as VaultModelBuilder & { buildChunked: ReturnType<typeof vi.fn> };
|
||||
}
|
||||
|
||||
describe('LazyFolderLoader', () => {
|
||||
it('loadFolder walks ONE level (recursive=false) and builds the children', async () => {
|
||||
const walk = vi.fn(async (path: string, _recursive: boolean) => fakeWalk(path));
|
||||
const builder = makeBuilder();
|
||||
const loader = new LazyFolderLoader(() => ({ walk } as unknown as BulkWalker), () => builder);
|
||||
|
||||
await loader.loadFolder('work/sub');
|
||||
|
||||
expect(walk).toHaveBeenCalledExactlyOnceWith('work/sub', false);
|
||||
expect(builder.buildChunked).toHaveBeenCalledOnce();
|
||||
expect(loader.isLoaded('work/sub')).toBe(true);
|
||||
});
|
||||
|
||||
it('is idempotent — a second load of the same folder does not re-walk', async () => {
|
||||
const walk = vi.fn(async (path: string) => fakeWalk(path));
|
||||
const loader = new LazyFolderLoader(() => ({ walk } as unknown as BulkWalker), () => makeBuilder());
|
||||
await loader.loadFolder('a');
|
||||
await loader.loadFolder('a');
|
||||
expect(walk).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('dedupes concurrent loads of the same folder into one walk', async () => {
|
||||
const walk = vi.fn(async (path: string) => fakeWalk(path));
|
||||
const loader = new LazyFolderLoader(() => ({ walk } as unknown as BulkWalker), () => makeBuilder());
|
||||
await Promise.all([loader.loadFolder('b'), loader.loadFolder('b'), loader.loadFolder('b')]);
|
||||
expect(walk).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('markLoaded skips the walk entirely', async () => {
|
||||
const walk = vi.fn(async (path: string) => fakeWalk(path));
|
||||
const loader = new LazyFolderLoader(() => ({ walk } as unknown as BulkWalker), () => makeBuilder());
|
||||
loader.markLoaded('c');
|
||||
await loader.loadFolder('c');
|
||||
expect(walk).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('a failed walk leaves the folder unloaded so a later expand retries', async () => {
|
||||
const walk = vi.fn()
|
||||
.mockRejectedValueOnce(new Error('boom'))
|
||||
.mockResolvedValueOnce(fakeWalk('x'));
|
||||
const loader = new LazyFolderLoader(() => ({ walk } as unknown as BulkWalker), () => makeBuilder());
|
||||
|
||||
await loader.loadFolder('x');
|
||||
expect(loader.isLoaded('x')).toBe(false); // failed → not marked loaded
|
||||
|
||||
await loader.loadFolder('x'); // retry succeeds
|
||||
expect(walk).toHaveBeenCalledTimes(2);
|
||||
expect(loader.isLoaded('x')).toBe(true);
|
||||
});
|
||||
|
||||
it('reset clears loaded state', async () => {
|
||||
const walk = vi.fn(async (path: string) => fakeWalk(path));
|
||||
const loader = new LazyFolderLoader(() => ({ walk } as unknown as BulkWalker), () => makeBuilder());
|
||||
await loader.loadFolder('a');
|
||||
expect(loader.isLoaded('a')).toBe(true);
|
||||
loader.reset();
|
||||
expect(loader.isLoaded('a')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
|
@ -189,3 +189,50 @@ describe('ObsidianRegistry writeAtomic error propagation', () => {
|
|||
.toThrow('EXDEV: cross-device link not permitted');
|
||||
});
|
||||
});
|
||||
|
||||
// ── isOpen (shadow-migration open-safety gate) ───────────────────────────
|
||||
|
||||
describe('ObsidianRegistry.isOpen', () => {
|
||||
let configPath: string;
|
||||
|
||||
beforeEach(() => { configPath = makeTmpConfigPath(); });
|
||||
afterEach(() => { try { fs.unlinkSync(configPath); } catch { /* may not exist */ } });
|
||||
|
||||
function writeVaults(vaults: Record<string, { path: string; ts: number; open?: boolean }>): void {
|
||||
fs.writeFileSync(configPath, JSON.stringify({ vaults }));
|
||||
}
|
||||
|
||||
it('returns true when the matching vault entry has open === true', () => {
|
||||
writeVaults({ v: { path: '/vault/one', ts: 1, open: true } });
|
||||
expect(new ObsidianRegistry(configPath).isOpen('/vault/one')).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false when the matching entry has open false or absent', () => {
|
||||
writeVaults({
|
||||
closed: { path: '/vault/closed', ts: 1, open: false },
|
||||
noflag: { path: '/vault/noflag', ts: 2 },
|
||||
});
|
||||
const r = new ObsidianRegistry(configPath);
|
||||
expect(r.isOpen('/vault/closed')).toBe(false);
|
||||
expect(r.isOpen('/vault/noflag')).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false for a path not registered in the vault list', () => {
|
||||
writeVaults({ v: { path: '/vault/one', ts: 1, open: true } });
|
||||
expect(new ObsidianRegistry(configPath).isOpen('/vault/other')).toBe(false);
|
||||
});
|
||||
|
||||
it('canonicalises a trailing separator so the open flag still matches', () => {
|
||||
const base = path.resolve(os.tmpdir(), 'fake-vault-isopen');
|
||||
writeVaults({ v: { path: base + path.sep, ts: 1, open: true } });
|
||||
expect(new ObsidianRegistry(configPath).isOpen(base)).toBe(true);
|
||||
});
|
||||
|
||||
it('assumes OPEN (defers migration) when obsidian.json is missing or corrupt', () => {
|
||||
// Missing file — never written by this test — must fail safe, not throw.
|
||||
expect(new ObsidianRegistry(configPath).isOpen('/vault/one')).toBe(true);
|
||||
// Corrupt JSON: same fail-safe (renaming a possibly-open vault corrupts it).
|
||||
fs.writeFileSync(configPath, '{ not valid json');
|
||||
expect(new ObsidianRegistry(configPath).isOpen('/vault/one')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -49,6 +49,16 @@ describe('PathMapper.isPrivate', () => {
|
|||
expect(m.isPrivate('.obsidian/types.json')).toBe(true);
|
||||
});
|
||||
|
||||
it('treats per-device settings (app/appearance/core-plugins/hotkeys) as private', () => {
|
||||
// Moved from shared → per-client so two devices (or one device
|
||||
// across reconnects) never collide on a single shared copy — the
|
||||
// perpetual write-conflict this fixes.
|
||||
expect(m.isPrivate('.obsidian/app.json')).toBe(true);
|
||||
expect(m.isPrivate('.obsidian/appearance.json')).toBe(true);
|
||||
expect(m.isPrivate('.obsidian/core-plugins.json')).toBe(true);
|
||||
expect(m.isPrivate('.obsidian/hotkeys.json')).toBe(true);
|
||||
});
|
||||
|
||||
it('matches inside a private directory pattern', () => {
|
||||
expect(m.isPrivate('.obsidian/cache/index')).toBe(true);
|
||||
expect(m.isPrivate('.obsidian/cache/sub/x.bin')).toBe(true);
|
||||
|
|
@ -61,7 +71,9 @@ describe('PathMapper.isPrivate', () => {
|
|||
|
||||
it('rejects regular vault content', () => {
|
||||
expect(m.isPrivate('Notes/foo.md')).toBe(false);
|
||||
expect(m.isPrivate('.obsidian/hotkeys.json')).toBe(false);
|
||||
// community-plugins.json stays shared (round-tripped with a forced
|
||||
// remote-ssh union), so it is NOT redirected per-client.
|
||||
expect(m.isPrivate('.obsidian/community-plugins.json')).toBe(false);
|
||||
expect(m.isPrivate('.obsidian/plugins/myplugin/data.json')).toBe(false);
|
||||
});
|
||||
|
||||
|
|
@ -97,11 +109,13 @@ describe('PathMapper.toRemote / toVault', () => {
|
|||
.toBe('.obsidian/user/host-a/workspace.json');
|
||||
expect(m.toRemote('.obsidian/cache/foo.bin'))
|
||||
.toBe('.obsidian/user/host-a/cache/foo.bin');
|
||||
expect(m.toRemote('.obsidian/app.json'))
|
||||
.toBe('.obsidian/user/host-a/app.json');
|
||||
});
|
||||
|
||||
it('passes non-private paths through unchanged', () => {
|
||||
expect(m.toRemote('Notes/foo.md')).toBe('Notes/foo.md');
|
||||
expect(m.toRemote('.obsidian/hotkeys.json')).toBe('.obsidian/hotkeys.json');
|
||||
expect(m.toRemote('.obsidian/community-plugins.json')).toBe('.obsidian/community-plugins.json');
|
||||
expect(m.toRemote('.obsidian')).toBe('.obsidian');
|
||||
});
|
||||
|
||||
|
|
@ -122,6 +136,17 @@ describe('PathMapper.toRemote / toVault', () => {
|
|||
expect(other.toRemote('.obsidian/workspace.json'))
|
||||
.toBe('.obsidian/user/SomeBox/workspace.json');
|
||||
});
|
||||
|
||||
it('isolates per-device settings by client id (no cross-machine clobber)', () => {
|
||||
// The whole point of making app.json per-device: two machines writing
|
||||
// "the same" config file land on DIFFERENT remote paths, so neither can
|
||||
// trip the other's write-precondition (the perpetual conflict fixed here).
|
||||
const a = new PathMapper('host-a');
|
||||
const b = new PathMapper('host-b');
|
||||
expect(a.toRemote('.obsidian/app.json')).toBe('.obsidian/user/host-a/app.json');
|
||||
expect(b.toRemote('.obsidian/app.json')).toBe('.obsidian/user/host-b/app.json');
|
||||
expect(a.toRemote('.obsidian/app.json')).not.toBe(b.toRemote('.obsidian/app.json'));
|
||||
});
|
||||
});
|
||||
|
||||
describe('PathMapper.resolveListing', () => {
|
||||
|
|
|
|||
124
plugin/tests/ProxyCommandTunnel.test.ts
Normal file
124
plugin/tests/ProxyCommandTunnel.test.ts
Normal file
|
|
@ -0,0 +1,124 @@
|
|||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { EventEmitter } from 'events';
|
||||
import { PassThrough } from 'stream';
|
||||
// #430: a ProxyCommand subprocess transport. None of this exists yet —
|
||||
// the module import fails today, which is the point: these cases lock
|
||||
// in the contract the fix must satisfy.
|
||||
// ProxyCommand cloudflared access ssh --hostname %h
|
||||
// OpenSSH spawns that command and uses its stdio as the connection
|
||||
// stream; ssh2 accepts the same via its `sock` option. The tunnel must
|
||||
// (a) expand the %-tokens, (b) spawn the command, and (c) bridge the
|
||||
// child's stdio as a single Duplex stream.
|
||||
import {
|
||||
createProxyCommandTunnel,
|
||||
expandProxyCommandTokens,
|
||||
} from '../src/ssh/ProxyCommandTunnel';
|
||||
|
||||
/**
|
||||
* Minimal fake of a Node ChildProcess. `stdin`/`stdout` are real
|
||||
* PassThrough streams so the tunnel's piping is exercised for real;
|
||||
* `kill` records teardown.
|
||||
*/
|
||||
class FakeChild extends EventEmitter {
|
||||
stdin = new PassThrough();
|
||||
stdout = new PassThrough();
|
||||
stderr = new PassThrough();
|
||||
killed = 0;
|
||||
kill(): boolean { this.killed++; return true; }
|
||||
}
|
||||
|
||||
function fakeSpawn() {
|
||||
const calls: Array<{ command: string; options: Record<string, unknown> }> = [];
|
||||
const child = new FakeChild();
|
||||
const spawnFn = ((command: string, options: Record<string, unknown>) => {
|
||||
calls.push({ command, options });
|
||||
return child;
|
||||
}) as unknown as typeof import('child_process').spawn;
|
||||
return { spawnFn, calls, child };
|
||||
}
|
||||
|
||||
describe('expandProxyCommandTokens (#430)', () => {
|
||||
it('substitutes %h (host) and %p (port)', () => {
|
||||
expect(
|
||||
expandProxyCommandTokens('ssh -W %h:%p jump.example.com', { host: 'target.example.com', port: 2222 }),
|
||||
).toBe('ssh -W target.example.com:2222 jump.example.com');
|
||||
});
|
||||
|
||||
it('substitutes %r (remote user) when provided', () => {
|
||||
expect(
|
||||
expandProxyCommandTokens('connect --user %r %h', { host: 'h', port: 22, user: 'alice' }),
|
||||
).toBe('connect --user alice h');
|
||||
});
|
||||
|
||||
it('expands the real-world cloudflared template', () => {
|
||||
expect(
|
||||
expandProxyCommandTokens('cloudflared access ssh --hostname %h', { host: 'lab.example.com', port: 22 }),
|
||||
).toBe('cloudflared access ssh --hostname lab.example.com');
|
||||
});
|
||||
|
||||
it('unescapes %% to a literal % and does not treat it as a token', () => {
|
||||
expect(
|
||||
expandProxyCommandTokens('echo 100%% done %h', { host: 'h', port: 22 }),
|
||||
).toBe('echo 100% done h');
|
||||
});
|
||||
});
|
||||
|
||||
describe('createProxyCommandTunnel (#430)', () => {
|
||||
it('spawns the token-expanded command and returns a stream', () => {
|
||||
const { spawnFn, calls } = fakeSpawn();
|
||||
const stream = createProxyCommandTunnel(
|
||||
'cloudflared access ssh --hostname %h',
|
||||
{ host: 'lab.example.com', port: 22 },
|
||||
{ spawnFn },
|
||||
);
|
||||
expect(calls).toHaveLength(1);
|
||||
expect(calls[0].command).toContain('cloudflared access ssh --hostname lab.example.com');
|
||||
expect(typeof (stream as { pipe?: unknown }).pipe).toBe('function'); // a stream
|
||||
});
|
||||
|
||||
it('forwards bytes written to the tunnel into the child stdin (client → proxy)', async () => {
|
||||
const { spawnFn, child } = fakeSpawn();
|
||||
const stream = createProxyCommandTunnel('proxy %h %p', { host: 'h', port: 22 }, { spawnFn });
|
||||
|
||||
const seen: Buffer[] = [];
|
||||
child.stdin.on('data', (c: Buffer) => seen.push(Buffer.from(c)));
|
||||
(stream as NodeJS.WritableStream).write(Buffer.from('SSH-2.0-hello'));
|
||||
await new Promise((r) => setImmediate(r));
|
||||
|
||||
expect(Buffer.concat(seen).toString()).toBe('SSH-2.0-hello');
|
||||
});
|
||||
|
||||
it('surfaces child stdout as readable data on the tunnel (proxy → client)', async () => {
|
||||
const { spawnFn, child } = fakeSpawn();
|
||||
const stream = createProxyCommandTunnel('proxy %h %p', { host: 'h', port: 22 }, { spawnFn });
|
||||
|
||||
const seen: Buffer[] = [];
|
||||
(stream as NodeJS.ReadableStream).on('data', (c: Buffer) => seen.push(Buffer.from(c)));
|
||||
child.stdout.write(Buffer.from('SSH-2.0-server'));
|
||||
await new Promise((r) => setImmediate(r));
|
||||
|
||||
expect(Buffer.concat(seen).toString()).toBe('SSH-2.0-server');
|
||||
});
|
||||
|
||||
it('tears the child process down when the tunnel stream closes', async () => {
|
||||
const { spawnFn, child } = fakeSpawn();
|
||||
const stream = createProxyCommandTunnel('proxy %h %p', { host: 'h', port: 22 }, { spawnFn });
|
||||
expect(child.killed).toBe(0);
|
||||
(stream as EventEmitter).emit('close');
|
||||
await new Promise((r) => setImmediate(r));
|
||||
expect(child.killed).toBe(1);
|
||||
});
|
||||
|
||||
it('emits an error on the tunnel when the proxy command cannot be spawned', async () => {
|
||||
const child = new FakeChild();
|
||||
const spawnFn = (() => child) as unknown as typeof import('child_process').spawn;
|
||||
const stream = createProxyCommandTunnel('does-not-exist %h', { host: 'h', port: 22 }, { spawnFn });
|
||||
|
||||
const onError = vi.fn();
|
||||
(stream as EventEmitter).on('error', onError);
|
||||
child.emit('error', new Error('spawn ENOENT'));
|
||||
await new Promise((r) => setImmediate(r));
|
||||
|
||||
expect(onError).toHaveBeenCalledWith(expect.objectContaining({ message: expect.stringMatching(/ENOENT/) }));
|
||||
});
|
||||
});
|
||||
|
|
@ -15,6 +15,7 @@ import * as fs from 'fs';
|
|||
import * as path from 'path';
|
||||
import * as os from 'os';
|
||||
import { ShadowVaultBootstrap } from '../src/shadow/ShadowVaultBootstrap';
|
||||
import type { SharedConfigReader, SharedConfigWriter } from '../src/shadow/ShadowVaultBootstrap';
|
||||
import { ObsidianRegistry } from '../src/shadow/ObsidianRegistry';
|
||||
import type { SshProfile, PendingPluginSuggestion } from '../src/types';
|
||||
|
||||
|
|
@ -82,29 +83,113 @@ function makeScratch(): {
|
|||
}
|
||||
|
||||
describe('ShadowVaultBootstrap.layoutFor', () => {
|
||||
it('returns a layout under baseDir/profileId for a clean id', () => {
|
||||
it('names the vault dir <friendly-name>--<remotePath-tail> so Obsidian shows a recognisable, folder-distinct name', () => {
|
||||
const scratch = makeScratch();
|
||||
try {
|
||||
const r = new ShadowVaultBootstrap(scratch.baseDir, scratch.sourceDir, new ObsidianRegistry(scratch.configPath));
|
||||
const layout = r.layoutFor('abc-123');
|
||||
expect(layout.vaultDir).toBe(path.join(scratch.baseDir, 'abc-123'));
|
||||
expect(layout.configDir).toBe(path.join(scratch.baseDir, 'abc-123', '.obsidian'));
|
||||
expect(layout.pluginDir).toBe(path.join(scratch.baseDir, 'abc-123', '.obsidian', 'plugins', 'remote-ssh'));
|
||||
const layout = r.layoutFor({ name: 'My Homelab', remotePath: '/home/souta/work' });
|
||||
expect(path.basename(layout.vaultDir)).toBe('My Homelab--work');
|
||||
expect(layout.configDir).toBe(path.join(layout.vaultDir, '.obsidian'));
|
||||
expect(layout.pluginDir).toBe(path.join(layout.vaultDir, '.obsidian', 'plugins', 'remote-ssh'));
|
||||
expect(layout.pluginDataFile).toBe(path.join(layout.pluginDir, 'data.json'));
|
||||
} finally { scratch.cleanup(); }
|
||||
});
|
||||
|
||||
it('sanitises ids that contain path separators or traversal so vaultDir stays inside baseDir', () => {
|
||||
it('uses the last path segment as the tail, ignoring a trailing slash', () => {
|
||||
const scratch = makeScratch();
|
||||
try {
|
||||
const r = new ShadowVaultBootstrap(scratch.baseDir, scratch.sourceDir, new ObsidianRegistry(scratch.configPath));
|
||||
// `../etc` would escape baseDir if not sanitised.
|
||||
const lo = r.layoutFor('../etc');
|
||||
const layout = r.layoutFor({ name: 'Panza', remotePath: '/home/souta/work/dev/' });
|
||||
expect(path.basename(layout.vaultDir)).toBe('Panza--dev');
|
||||
} finally { scratch.cleanup(); }
|
||||
});
|
||||
|
||||
it('falls back to a generic name and tail when both are empty', () => {
|
||||
const scratch = makeScratch();
|
||||
try {
|
||||
const r = new ShadowVaultBootstrap(scratch.baseDir, scratch.sourceDir, new ObsidianRegistry(scratch.configPath));
|
||||
const layout = r.layoutFor({ name: '', remotePath: '' });
|
||||
expect(path.basename(layout.vaultDir)).toBe('vault--vault');
|
||||
} finally { scratch.cleanup(); }
|
||||
});
|
||||
|
||||
it('same host name + different remotePath → different dirs (multiple vaults on one host)', () => {
|
||||
const scratch = makeScratch();
|
||||
try {
|
||||
const r = new ShadowVaultBootstrap(scratch.baseDir, scratch.sourceDir, new ObsidianRegistry(scratch.configPath));
|
||||
const a = r.layoutFor({ name: 'Panza', remotePath: '/home/souta/work' });
|
||||
const b = r.layoutFor({ name: 'Panza', remotePath: '/home/souta/work/dev' });
|
||||
expect(a.vaultDir).not.toBe(b.vaultDir);
|
||||
expect(path.basename(a.vaultDir)).toBe('Panza--work');
|
||||
expect(path.basename(b.vaultDir)).toBe('Panza--dev');
|
||||
} finally { scratch.cleanup(); }
|
||||
});
|
||||
|
||||
it('sanitises name + tail so the dir never escapes baseDir', () => {
|
||||
const scratch = makeScratch();
|
||||
try {
|
||||
const r = new ShadowVaultBootstrap(scratch.baseDir, scratch.sourceDir, new ObsidianRegistry(scratch.configPath));
|
||||
const lo = r.layoutFor({ name: '../../evil/name', remotePath: '/x/../../etc/' });
|
||||
expect(path.dirname(lo.vaultDir)).toBe(scratch.baseDir);
|
||||
expect(path.resolve(lo.vaultDir).startsWith(path.resolve(scratch.baseDir) + path.sep)).toBe(true);
|
||||
// Slashes inside an id should not introduce nested directories.
|
||||
const slash = r.layoutFor('a/b/c');
|
||||
expect(path.dirname(slash.vaultDir)).toBe(scratch.baseDir);
|
||||
} finally { scratch.cleanup(); }
|
||||
});
|
||||
|
||||
it('falls back to a generic tail for a bare `~` home-dir remotePath', () => {
|
||||
const scratch = makeScratch();
|
||||
try {
|
||||
const r = new ShadowVaultBootstrap(scratch.baseDir, scratch.sourceDir, new ObsidianRegistry(scratch.configPath));
|
||||
expect(path.basename(r.layoutFor({ name: 'Home', remotePath: '~' }).vaultDir)).toBe('Home--vault');
|
||||
expect(path.basename(r.layoutFor({ name: 'Home', remotePath: '~/' }).vaultDir)).toBe('Home--vault');
|
||||
} finally { scratch.cleanup(); }
|
||||
});
|
||||
|
||||
it('splits on backslash separators too (Windows-style remotePath)', () => {
|
||||
const scratch = makeScratch();
|
||||
try {
|
||||
const r = new ShadowVaultBootstrap(scratch.baseDir, scratch.sourceDir, new ObsidianRegistry(scratch.configPath));
|
||||
expect(path.basename(r.layoutFor({ name: 'Win', remotePath: 'C:\\Users\\a\\notes' }).vaultDir)).toBe('Win--notes');
|
||||
} finally { scratch.cleanup(); }
|
||||
});
|
||||
});
|
||||
|
||||
describe('ShadowVaultBootstrap: server-bin (daemon dir) install', () => {
|
||||
it('mirrors a REAL source server-bin dir into the shadow (local dev build)', async () => {
|
||||
const scratch = makeScratch();
|
||||
try {
|
||||
const srcBin = path.join(scratch.sourceDir, 'server-bin');
|
||||
fs.mkdirSync(srcBin, { recursive: true });
|
||||
fs.writeFileSync(path.join(srcBin, 'obsidian-remote-server'), 'ELF\n');
|
||||
const r = new ShadowVaultBootstrap(scratch.baseDir, scratch.sourceDir, new ObsidianRegistry(scratch.configPath));
|
||||
const profile = makeProfile({ id: 's1', name: 'S', remotePath: '/s1' });
|
||||
const result = await r.bootstrap(profile, [profile]);
|
||||
// Real dir → mirrored (symlink or copy); the binary is reachable.
|
||||
expect(fs.existsSync(path.join(result.layout.pluginDir, 'server-bin', 'obsidian-remote-server'))).toBe(true);
|
||||
} finally { scratch.cleanup(); }
|
||||
});
|
||||
|
||||
it('does NOT propagate a junction/symlink source server-bin (avoids the dangling-junction daemon breakage)', async () => {
|
||||
const scratch = makeScratch();
|
||||
try {
|
||||
// The corrupted shape: source/server-bin is a LINK to a dir elsewhere.
|
||||
// Propagating it would chain the shadow to another vault and dangle if
|
||||
// that vault is deleted (the field bug: mkdir server-bin → ENOENT).
|
||||
const realElsewhere = path.join(scratch.sourceVaultDir, 'real-bin');
|
||||
fs.mkdirSync(realElsewhere, { recursive: true });
|
||||
fs.writeFileSync(path.join(realElsewhere, 'obsidian-remote-server'), 'ELF\n');
|
||||
const srcBinLink = path.join(scratch.sourceDir, 'server-bin');
|
||||
try {
|
||||
fs.symlinkSync(realElsewhere, srcBinLink, process.platform === 'win32' ? 'junction' : 'dir');
|
||||
} catch (e) {
|
||||
console.warn(`skipping: cannot create symlink in test env: ${(e as Error).message}`);
|
||||
return;
|
||||
}
|
||||
const r = new ShadowVaultBootstrap(scratch.baseDir, scratch.sourceDir, new ObsidianRegistry(scratch.configPath));
|
||||
const profile = makeProfile({ id: 's2', name: 'S', remotePath: '/s2' });
|
||||
const result = await r.bootstrap(profile, [profile]);
|
||||
// Shadow gets NO server-bin — ensureDaemonBinary creates a real per-shadow
|
||||
// dir at connect time instead of inheriting a link.
|
||||
expect(fs.existsSync(path.join(result.layout.pluginDir, 'server-bin'))).toBe(false);
|
||||
} finally { scratch.cleanup(); }
|
||||
});
|
||||
});
|
||||
|
|
@ -315,12 +400,17 @@ describe('ShadowVaultBootstrap.bootstrap', () => {
|
|||
});
|
||||
|
||||
it('regression: a stale whole-dir symlink at pluginDir is unlinked, not followed (does not delete source)', async () => {
|
||||
// Simulate the pre-fix on-disk state: pluginDir is a symlink to
|
||||
// sourceDir. installPlugin must replace this with a real dir
|
||||
// without deleting any of the source files.
|
||||
// Simulate the pre-fix on-disk state: pluginDir is a whole-dir
|
||||
// symlink to the source plugin dir. installPlugin must replace it
|
||||
// with a real dir without deleting any of the source files.
|
||||
const r = new ShadowVaultBootstrap(scratch.baseDir, scratch.sourceDir, new ObsidianRegistry(scratch.configPath));
|
||||
const layout = r.layoutFor('p1');
|
||||
const profile = makeProfile({ id: 'p1', name: 'P', remotePath: '/p1' });
|
||||
const layout = r.layoutFor(profile);
|
||||
fs.mkdirSync(path.dirname(layout.pluginDir), { recursive: true });
|
||||
// The source plugin dir carries a data.json in the field; give it this
|
||||
// profile's id so find-by-id resolves the shadow THROUGH the link
|
||||
// (instead of forking a " (2)" dir) and installPlugin runs on the junction.
|
||||
fs.writeFileSync(path.join(scratch.sourceDir, 'data.json'), JSON.stringify({ autoConnectProfileId: 'p1' }));
|
||||
try {
|
||||
const linkType = process.platform === 'win32' ? 'junction' : 'dir';
|
||||
fs.symlinkSync(scratch.sourceDir, layout.pluginDir, linkType);
|
||||
|
|
@ -337,7 +427,6 @@ describe('ShadowVaultBootstrap.bootstrap', () => {
|
|||
const sourceCanary = path.join(scratch.sourceDir, 'CANARY.txt');
|
||||
fs.writeFileSync(sourceCanary, 'do-not-delete', 'utf-8');
|
||||
|
||||
const profile = makeProfile({ id: 'p1' });
|
||||
await r.bootstrap(profile, [profile]);
|
||||
|
||||
// Source file still there.
|
||||
|
|
@ -956,3 +1045,505 @@ describe('ShadowVaultBootstrap — seedObsidianFirstRunState', () => {
|
|||
expect(fs.readFileSync(corePath, 'utf-8')).toBe(custom);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── community-plugins round-trip (#429 / #342 residual) ────────────────
|
||||
//
|
||||
// app/appearance/core-plugins/hotkeys already round-trip via
|
||||
// pull/pushSharedObsidianConfig, but the *enabled community-plugins*
|
||||
// list does NOT: it is only ever seeded locally as ["remote-ssh"]
|
||||
// (see the bootstrap suite above). So a plugin enabled on machine A
|
||||
// never reaches machine B's shadow vault, and reopening the vault
|
||||
// "loses" installed plugins — exactly kazink's #429 follow-up and the
|
||||
// #342 residual.
|
||||
//
|
||||
// community-plugins.json must NOT join the verbatim
|
||||
// SHARED_OBSIDIAN_CONFIG_FILES set: a remote list that happens to omit
|
||||
// `remote-ssh` would, written verbatim, disable the very plugin doing
|
||||
// the sync. The fix is a dedicated pull/push pair that round-trips the
|
||||
// list while always keeping `remote-ssh` enabled. These fail today —
|
||||
// the methods do not exist yet.
|
||||
describe('ShadowVaultBootstrap community-plugins round-trip (#429 / #342)', () => {
|
||||
function makeLocalConfigDir(seed: string[] = ['remote-ssh']): string {
|
||||
const dir = path.join(
|
||||
os.tmpdir(),
|
||||
`cp-roundtrip-${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2)}`,
|
||||
'.obsidian',
|
||||
);
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
fs.writeFileSync(path.join(dir, 'community-plugins.json'), JSON.stringify(seed), 'utf-8');
|
||||
return dir;
|
||||
}
|
||||
function readLocal(dir: string): string[] {
|
||||
return JSON.parse(fs.readFileSync(path.join(dir, 'community-plugins.json'), 'utf-8'));
|
||||
}
|
||||
|
||||
it('pulls the remote enabled-plugin list into the shadow vault, keeping remote-ssh enabled', async () => {
|
||||
const localConfigDir = makeLocalConfigDir(['remote-ssh']);
|
||||
const remote: Record<string, string> = {
|
||||
'.obsidian/community-plugins.json': JSON.stringify(['dataview', 'templater']),
|
||||
};
|
||||
const reader: SharedConfigReader = {
|
||||
exists: (p) => Promise.resolve(p in remote),
|
||||
read: (p) => Promise.resolve(remote[p]),
|
||||
};
|
||||
|
||||
await ShadowVaultBootstrap.pullCommunityPlugins(reader, '.obsidian', localConfigDir);
|
||||
|
||||
expect(readLocal(localConfigDir)).toEqual(
|
||||
expect.arrayContaining(['dataview', 'templater', 'remote-ssh']),
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps remote-ssh enabled even when the remote list omits it', async () => {
|
||||
const localConfigDir = makeLocalConfigDir(['remote-ssh']);
|
||||
const remote: Record<string, string> = {
|
||||
'.obsidian/community-plugins.json': JSON.stringify(['dataview']),
|
||||
};
|
||||
const reader: SharedConfigReader = {
|
||||
exists: (p) => Promise.resolve(p in remote),
|
||||
read: (p) => Promise.resolve(remote[p]),
|
||||
};
|
||||
|
||||
await ShadowVaultBootstrap.pullCommunityPlugins(reader, '.obsidian', localConfigDir);
|
||||
|
||||
const local = readLocal(localConfigDir);
|
||||
expect(local, 'remote-ssh must survive a pull that omits it').toContain('remote-ssh');
|
||||
expect(local).toContain('dataview');
|
||||
});
|
||||
|
||||
it('is a benign no-op when the remote has no community-plugins.json yet', async () => {
|
||||
const localConfigDir = makeLocalConfigDir(['remote-ssh']);
|
||||
const reader: SharedConfigReader = {
|
||||
exists: () => Promise.resolve(false),
|
||||
read: () => Promise.reject(new Error('must not read an absent file')),
|
||||
};
|
||||
|
||||
await expect(
|
||||
ShadowVaultBootstrap.pullCommunityPlugins(reader, '.obsidian', localConfigDir),
|
||||
).resolves.toBeDefined();
|
||||
expect(readLocal(localConfigDir)).toEqual(['remote-ssh']);
|
||||
});
|
||||
|
||||
it('does not clobber a healthy local list when the remote list is corrupt JSON', async () => {
|
||||
const localConfigDir = makeLocalConfigDir(['remote-ssh', 'dataview']);
|
||||
const remote: Record<string, string> = {
|
||||
'.obsidian/community-plugins.json': '{ this is : not json',
|
||||
};
|
||||
const reader: SharedConfigReader = {
|
||||
exists: (p) => Promise.resolve(p in remote),
|
||||
read: (p) => Promise.resolve(remote[p]),
|
||||
};
|
||||
|
||||
await ShadowVaultBootstrap.pullCommunityPlugins(reader, '.obsidian', localConfigDir);
|
||||
|
||||
expect(readLocal(localConfigDir)).toEqual(
|
||||
expect.arrayContaining(['remote-ssh', 'dataview']),
|
||||
);
|
||||
});
|
||||
|
||||
// Reader+writer fake backed by an in-memory store. `read` can be made
|
||||
// to throw to simulate a transient SSH error mid-read.
|
||||
function makeRemoteRW(initial?: string[], readThrows = false): {
|
||||
rw: SharedConfigReader & SharedConfigWriter;
|
||||
store: Record<string, string>;
|
||||
} {
|
||||
const store: Record<string, string> = {};
|
||||
if (initial) store['.obsidian/community-plugins.json'] = JSON.stringify(initial);
|
||||
const rw: SharedConfigReader & SharedConfigWriter = {
|
||||
exists: (p) => Promise.resolve(p in store),
|
||||
read: (p) => (readThrows ? Promise.reject(new Error('SSH hiccup')) : Promise.resolve(store[p])),
|
||||
write: (p, c) => { store[p] = c; return Promise.resolve(); },
|
||||
};
|
||||
return { rw, store };
|
||||
}
|
||||
|
||||
it('seeds the remote (union with local) when the remote has none yet', async () => {
|
||||
const localConfigDir = makeLocalConfigDir(['remote-ssh', 'dataview']);
|
||||
const { rw, store } = makeRemoteRW();
|
||||
await ShadowVaultBootstrap.pushCommunityPlugins(rw, '.obsidian', localConfigDir);
|
||||
expect(store['.obsidian/community-plugins.json'], 'a push must seed the remote').toBeDefined();
|
||||
expect(JSON.parse(store['.obsidian/community-plugins.json'])).toEqual(
|
||||
expect.arrayContaining(['dataview', 'remote-ssh']),
|
||||
);
|
||||
});
|
||||
|
||||
it('unions with the remote list and never drops a plugin enabled elsewhere', async () => {
|
||||
const localConfigDir = makeLocalConfigDir(['remote-ssh', 'templater']);
|
||||
const { rw, store } = makeRemoteRW(['remote-ssh', 'dataview']);
|
||||
await ShadowVaultBootstrap.pushCommunityPlugins(rw, '.obsidian', localConfigDir);
|
||||
const pushed = JSON.parse(store['.obsidian/community-plugins.json']);
|
||||
expect(pushed, 'remote dataview must survive + local templater added')
|
||||
.toEqual(expect.arrayContaining(['dataview', 'templater', 'remote-ssh']));
|
||||
});
|
||||
|
||||
it('does NOT clobber the remote when the remote read fails (transient SSH error)', async () => {
|
||||
const localConfigDir = makeLocalConfigDir(['remote-ssh']); // minimal local
|
||||
const { rw, store } = makeRemoteRW(['remote-ssh', 'dataview', 'obsidian-git'], /*readThrows*/ true);
|
||||
const before = store['.obsidian/community-plugins.json'];
|
||||
const r = await ShadowVaultBootstrap.pushCommunityPlugins(rw, '.obsidian', localConfigDir);
|
||||
expect(r.pushed).toBe(false);
|
||||
expect(store['.obsidian/community-plugins.json'], 'remote list must be untouched (no data loss)').toBe(before);
|
||||
});
|
||||
|
||||
it('does NOT clobber the remote when the remote list is corrupt JSON', async () => {
|
||||
const localConfigDir = makeLocalConfigDir(['remote-ssh']);
|
||||
const { rw, store } = makeRemoteRW();
|
||||
store['.obsidian/community-plugins.json'] = '{ not : json';
|
||||
const r = await ShadowVaultBootstrap.pushCommunityPlugins(rw, '.obsidian', localConfigDir);
|
||||
expect(r.pushed).toBe(false);
|
||||
expect(store['.obsidian/community-plugins.json']).toBe('{ not : json');
|
||||
});
|
||||
});
|
||||
|
||||
describe('ShadowVaultBootstrap plugin-binary round-trip (#429b — BRAT / non-marketplace)', () => {
|
||||
function makeLocalConfigDir(): string {
|
||||
const dir = path.join(
|
||||
os.tmpdir(),
|
||||
`bin-roundtrip-${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2)}`,
|
||||
'.obsidian',
|
||||
);
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
return dir;
|
||||
}
|
||||
const pluginFile = (cfg: string, id: string, file: string) => path.join(cfg, 'plugins', id, file);
|
||||
function seedLocal(cfg: string, id: string, file: string, content: string): void {
|
||||
fs.mkdirSync(path.dirname(pluginFile(cfg, id, file)), { recursive: true });
|
||||
fs.writeFileSync(pluginFile(cfg, id, file), content, 'utf-8');
|
||||
}
|
||||
|
||||
// Reader+writer fake over an in-memory store keyed by remote rel path.
|
||||
function makeRW(seed: Record<string, string> = {}): {
|
||||
rw: SharedConfigReader & SharedConfigWriter;
|
||||
store: Record<string, string>;
|
||||
} {
|
||||
const store: Record<string, string> = { ...seed };
|
||||
const rw: SharedConfigReader & SharedConfigWriter = {
|
||||
exists: (p) => Promise.resolve(p in store),
|
||||
read: (p) => Promise.resolve(store[p]),
|
||||
write: (p, c) => { store[p] = c; return Promise.resolve(); },
|
||||
};
|
||||
return { rw, store };
|
||||
}
|
||||
|
||||
// A manifest.json body carrying the version the round-trip orders on.
|
||||
const manifest = (id: string, version: string) => JSON.stringify({ id, version });
|
||||
|
||||
it('pulls a plugin that is ABSENT locally (fresh install from the remote)', async () => {
|
||||
const cfg = makeLocalConfigDir();
|
||||
const { rw } = makeRW({
|
||||
'.obsidian/plugins/brat-x/manifest.json': manifest('brat-x', '1.0.0'),
|
||||
'.obsidian/plugins/brat-x/main.js': '/* brat code */\n',
|
||||
});
|
||||
|
||||
const { pulled } = await ShadowVaultBootstrap.pullPluginBinaries(rw, '.obsidian', cfg, ['brat-x']);
|
||||
|
||||
expect(pulled).toContain('brat-x');
|
||||
expect(fs.readFileSync(pluginFile(cfg, 'brat-x', 'main.js'), 'utf-8')).toBe('/* brat code */\n');
|
||||
expect(fs.existsSync(pluginFile(cfg, 'brat-x', 'manifest.json'))).toBe(true);
|
||||
});
|
||||
|
||||
it('UPGRADES a local plugin when the remote is a strictly newer version', async () => {
|
||||
const cfg = makeLocalConfigDir();
|
||||
seedLocal(cfg, 'p', 'manifest.json', manifest('p', '1.0.0'));
|
||||
seedLocal(cfg, 'p', 'main.js', '/* OLD v1 */\n');
|
||||
const { rw } = makeRW({
|
||||
'.obsidian/plugins/p/manifest.json': manifest('p', '2.0.0'),
|
||||
'.obsidian/plugins/p/main.js': '/* NEW v2 */\n',
|
||||
});
|
||||
|
||||
await ShadowVaultBootstrap.pullPluginBinaries(rw, '.obsidian', cfg, ['p']);
|
||||
|
||||
expect(fs.readFileSync(pluginFile(cfg, 'p', 'main.js'), 'utf-8'), 'newer remote must upgrade local')
|
||||
.toBe('/* NEW v2 */\n');
|
||||
});
|
||||
|
||||
it('does NOT downgrade a local plugin when the remote is OLDER (#429b clobber guard)', async () => {
|
||||
const cfg = makeLocalConfigDir();
|
||||
seedLocal(cfg, 'p', 'manifest.json', manifest('p', '2.0.0'));
|
||||
seedLocal(cfg, 'p', 'main.js', '/* NEW v2 */\n');
|
||||
const { rw } = makeRW({
|
||||
'.obsidian/plugins/p/manifest.json': manifest('p', '1.0.0'),
|
||||
'.obsidian/plugins/p/main.js': '/* OLD v1 */\n',
|
||||
});
|
||||
|
||||
await ShadowVaultBootstrap.pullPluginBinaries(rw, '.obsidian', cfg, ['p']);
|
||||
|
||||
expect(fs.readFileSync(pluginFile(cfg, 'p', 'main.js'), 'utf-8'), 'older remote must NOT clobber newer local')
|
||||
.toBe('/* NEW v2 */\n');
|
||||
});
|
||||
|
||||
it('is a no-op when the remote has no manifest (nothing to pull)', async () => {
|
||||
const cfg = makeLocalConfigDir();
|
||||
const { rw } = makeRW({ '.obsidian/plugins/p/main.js': '/* orphan main, no manifest */\n' });
|
||||
|
||||
const { pulled } = await ShadowVaultBootstrap.pullPluginBinaries(rw, '.obsidian', cfg, ['p']);
|
||||
|
||||
expect(pulled).not.toContain('p');
|
||||
expect(fs.existsSync(pluginFile(cfg, 'p', 'main.js'))).toBe(false);
|
||||
});
|
||||
|
||||
it('skips remote-ssh on pull — the plugin manages its own install', async () => {
|
||||
const cfg = makeLocalConfigDir();
|
||||
const { rw } = makeRW({ '.obsidian/plugins/remote-ssh/manifest.json': manifest('remote-ssh', '9.9.9') });
|
||||
|
||||
const { pulled } = await ShadowVaultBootstrap.pullPluginBinaries(rw, '.obsidian', cfg, ['remote-ssh']);
|
||||
|
||||
expect(pulled).not.toContain('remote-ssh');
|
||||
expect(fs.existsSync(pluginFile(cfg, 'remote-ssh', 'manifest.json'))).toBe(false);
|
||||
});
|
||||
|
||||
it('seeds the remote when it LACKS the plugin (push)', async () => {
|
||||
const cfg = makeLocalConfigDir();
|
||||
seedLocal(cfg, 'q', 'manifest.json', manifest('q', '1.0.0'));
|
||||
seedLocal(cfg, 'q', 'main.js', '/* q code */\n');
|
||||
const { rw, store } = makeRW();
|
||||
|
||||
const { pushed } = await ShadowVaultBootstrap.pushPluginBinaries(rw, '.obsidian', cfg, ['q']);
|
||||
|
||||
expect(pushed).toContain('q');
|
||||
expect(store['.obsidian/plugins/q/main.js']).toBe('/* q code */\n');
|
||||
expect(store['.obsidian/plugins/q/manifest.json']).toBe(manifest('q', '1.0.0'));
|
||||
});
|
||||
|
||||
it('pushes an UPGRADE when the local copy is a strictly newer version', async () => {
|
||||
const cfg = makeLocalConfigDir();
|
||||
seedLocal(cfg, 'q', 'manifest.json', manifest('q', '2.0.0'));
|
||||
seedLocal(cfg, 'q', 'main.js', '/* NEW v2 */\n');
|
||||
const { rw, store } = makeRW({
|
||||
'.obsidian/plugins/q/manifest.json': manifest('q', '1.0.0'),
|
||||
'.obsidian/plugins/q/main.js': '/* OLD v1 */\n',
|
||||
});
|
||||
|
||||
const { pushed } = await ShadowVaultBootstrap.pushPluginBinaries(rw, '.obsidian', cfg, ['q']);
|
||||
|
||||
expect(pushed).toContain('q');
|
||||
expect(store['.obsidian/plugins/q/main.js'], 'newer local must upgrade the remote').toBe('/* NEW v2 */\n');
|
||||
});
|
||||
|
||||
it('does NOT downgrade the remote when the local copy is OLDER (#429b clobber guard)', async () => {
|
||||
const cfg = makeLocalConfigDir();
|
||||
seedLocal(cfg, 'q', 'manifest.json', manifest('q', '1.0.0'));
|
||||
seedLocal(cfg, 'q', 'main.js', '/* OLD v1 */\n');
|
||||
const { rw, store } = makeRW({
|
||||
'.obsidian/plugins/q/manifest.json': manifest('q', '2.0.0'),
|
||||
'.obsidian/plugins/q/main.js': '/* NEW v2 */\n',
|
||||
});
|
||||
|
||||
const { pushed } = await ShadowVaultBootstrap.pushPluginBinaries(rw, '.obsidian', cfg, ['q']);
|
||||
|
||||
expect(pushed).not.toContain('q');
|
||||
expect(store['.obsidian/plugins/q/main.js'], 'older local must NOT clobber newer remote').toBe('/* NEW v2 */\n');
|
||||
});
|
||||
|
||||
it('push is skipped when the local plugin has no manifest', async () => {
|
||||
const cfg = makeLocalConfigDir();
|
||||
seedLocal(cfg, 'r', 'main.js', '/* code, no manifest */\n');
|
||||
const { rw, store } = makeRW();
|
||||
|
||||
const { pushed } = await ShadowVaultBootstrap.pushPluginBinaries(rw, '.obsidian', cfg, ['r']);
|
||||
|
||||
expect(pushed).not.toContain('r');
|
||||
expect(store['.obsidian/plugins/r/main.js']).toBeUndefined();
|
||||
});
|
||||
|
||||
it('CONVERGES without ping-pong: pull an upgrade, then push is a no-op', async () => {
|
||||
const cfg = makeLocalConfigDir();
|
||||
seedLocal(cfg, 'c', 'manifest.json', manifest('c', '1.0.0'));
|
||||
seedLocal(cfg, 'c', 'main.js', '/* v1 */\n');
|
||||
const { rw, store } = makeRW({
|
||||
'.obsidian/plugins/c/manifest.json': manifest('c', '2.0.0'),
|
||||
'.obsidian/plugins/c/main.js': '/* v2 */\n',
|
||||
});
|
||||
|
||||
// Round 1 pull: the newer remote upgrades local to v2.
|
||||
await ShadowVaultBootstrap.pullPluginBinaries(rw, '.obsidian', cfg, ['c']);
|
||||
expect(fs.readFileSync(pluginFile(cfg, 'c', 'main.js'), 'utf-8')).toBe('/* v2 */\n');
|
||||
|
||||
// Then push: local == remote (both v2) → nothing pushed, no oscillation.
|
||||
const { pushed } = await ShadowVaultBootstrap.pushPluginBinaries(rw, '.obsidian', cfg, ['c']);
|
||||
expect(pushed, 'after converging on v2 the push must be a no-op').not.toContain('c');
|
||||
expect(store['.obsidian/plugins/c/main.js']).toBe('/* v2 */\n');
|
||||
});
|
||||
|
||||
it('a per-plugin SSH error is swallowed and does not abort the batch', async () => {
|
||||
const cfg = makeLocalConfigDir();
|
||||
const { rw } = makeRW({
|
||||
'.obsidian/plugins/bad/manifest.json': manifest('bad', '1.0.0'),
|
||||
'.obsidian/plugins/good/manifest.json': manifest('good', '1.0.0'),
|
||||
'.obsidian/plugins/good/main.js': '/* good */\n',
|
||||
});
|
||||
const origRead = rw.read;
|
||||
rw.read = (p) => (p.includes('/bad/') ? Promise.reject(new Error('SSH hiccup')) : origRead(p));
|
||||
|
||||
const { pulled } = await ShadowVaultBootstrap.pullPluginBinaries(rw, '.obsidian', cfg, ['bad', 'good']);
|
||||
|
||||
expect(pulled, 'good pulls even though bad errored').toContain('good');
|
||||
expect(pulled).not.toContain('bad');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── shadow-dir naming + migration (identity = profile id) ──────────────
|
||||
//
|
||||
// The shadow dir is `<friendly-name>--<remotePath-tail>` so Obsidian
|
||||
// shows a recognisable, folder-distinct name. Identity is the profile
|
||||
// *id* (persisted as data.json `autoConnectProfileId`), NOT the dir
|
||||
// name — so a profile rename, an older `<uuid>` / `--<id8>` name, or a
|
||||
// display-name collision all still resolve to the same shadow via
|
||||
// find-by-id. A stale dir name is renamed once, unless the vault is
|
||||
// open (Windows corruption guard) or the name collides with another
|
||||
// profile's.
|
||||
describe('ShadowVaultBootstrap shadow-dir migration (find-by-id)', () => {
|
||||
let scratch: ReturnType<typeof makeScratch>;
|
||||
beforeEach(() => { scratch = makeScratch(); });
|
||||
afterEach(() => { scratch.cleanup(); });
|
||||
|
||||
/**
|
||||
* Seed an existing shadow dir with a data.json carrying the profile
|
||||
* id (the stable identity find-by-id keys on) plus a
|
||||
* community-plugins.json so config carry-over is observable.
|
||||
*/
|
||||
function seedShadow(dirName: string, profileId: string, cp: string[] = ['remote-ssh']): string {
|
||||
const dir = path.join(scratch.baseDir, dirName);
|
||||
const pluginDir = path.join(dir, '.obsidian', 'plugins', 'remote-ssh');
|
||||
fs.mkdirSync(pluginDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(pluginDir, 'data.json'), JSON.stringify({ autoConnectProfileId: profileId }));
|
||||
fs.writeFileSync(path.join(dir, '.obsidian', 'community-plugins.json'), JSON.stringify(cp));
|
||||
return dir;
|
||||
}
|
||||
|
||||
it('reuses the existing shadow by id and migrates a renamed profile to <name>--<tail>', async () => {
|
||||
const id = 'dddddddd-1111-2222-3333-444444444444';
|
||||
const oldDir = seedShadow('Old Name--work', id, ['remote-ssh', 'dataview']);
|
||||
const registry = new ObsidianRegistry(scratch.configPath);
|
||||
const { id: regId } = registry.register(oldDir);
|
||||
|
||||
const profile = makeProfile({ id, name: 'New Name', remotePath: '/home/souta/work' });
|
||||
const result = await new ShadowVaultBootstrap(scratch.baseDir, scratch.sourceDir, registry)
|
||||
.bootstrap(profile, [profile]);
|
||||
|
||||
expect(result.migrated).toBe(true);
|
||||
expect(path.basename(result.layout.vaultDir)).toBe('New Name--work');
|
||||
expect(fs.existsSync(oldDir)).toBe(false); // old dir moved, not left behind
|
||||
expect(JSON.parse(fs.readFileSync(path.join(result.layout.configDir, 'community-plugins.json'), 'utf-8')))
|
||||
.toEqual(expect.arrayContaining(['remote-ssh', 'dataview'])); // config carried over
|
||||
const cfg = JSON.parse(fs.readFileSync(scratch.configPath, 'utf-8'));
|
||||
expect(cfg.vaults[regId].path).toBe(result.layout.vaultDir); // same registry id, repointed
|
||||
});
|
||||
|
||||
it('migrates a legacy <uuid>-named dir (data.json carries the id) to <name>--<tail>', async () => {
|
||||
const id = 'eeeeeeee-1111-2222-3333-444444444444';
|
||||
const legacyDir = seedShadow(id, id, ['remote-ssh', 'keepme']); // dir named by the raw uuid
|
||||
const profile = makeProfile({ id, name: 'My Homelab', remotePath: '/home/souta/work' });
|
||||
const result = await new ShadowVaultBootstrap(scratch.baseDir, scratch.sourceDir, new ObsidianRegistry(scratch.configPath))
|
||||
.bootstrap(profile, [profile]);
|
||||
expect(result.migrated).toBe(true);
|
||||
expect(path.basename(result.layout.vaultDir)).toBe('My Homelab--work');
|
||||
expect(fs.existsSync(legacyDir)).toBe(false);
|
||||
expect(JSON.parse(fs.readFileSync(path.join(result.layout.configDir, 'community-plugins.json'), 'utf-8')))
|
||||
.toContain('keepme');
|
||||
});
|
||||
|
||||
it('is idempotent: a brand-new bootstrap then a second one does not migrate', async () => {
|
||||
const id = 'ffffffff-1111-2222-3333-444444444444';
|
||||
const profile = makeProfile({ id, name: 'Vault', remotePath: '/home/souta/work' });
|
||||
const r = new ShadowVaultBootstrap(scratch.baseDir, scratch.sourceDir, new ObsidianRegistry(scratch.configPath));
|
||||
const first = await r.bootstrap(profile, [profile]);
|
||||
expect(first.migrated).toBe(false); // brand-new, not a migration
|
||||
expect(path.basename(first.layout.vaultDir)).toBe('Vault--work');
|
||||
const second = await r.bootstrap(profile, [profile]);
|
||||
expect(second.migrated).toBe(false);
|
||||
expect(second.layout.vaultDir).toBe(first.layout.vaultDir);
|
||||
});
|
||||
|
||||
it('does not migrate a brand-new profile (no existing shadow)', async () => {
|
||||
const profile = makeProfile({ id: 'aaaa0000-1111', name: 'Fresh', remotePath: '/srv/notes' });
|
||||
const result = await new ShadowVaultBootstrap(scratch.baseDir, scratch.sourceDir, new ObsidianRegistry(scratch.configPath))
|
||||
.bootstrap(profile, [profile]);
|
||||
expect(result.migrated).toBe(false);
|
||||
expect(path.basename(result.layout.vaultDir)).toBe('Fresh--notes');
|
||||
});
|
||||
|
||||
it('open-safety: does NOT rename when the found vault is currently open', async () => {
|
||||
const id = 'bbbb0000-1111-2222-3333-444444444444';
|
||||
const oldDir = seedShadow('Old Name--work', id, ['remote-ssh', 'keepme']);
|
||||
const registry = new ObsidianRegistry(scratch.configPath);
|
||||
registry.register(oldDir);
|
||||
const isOpenSpy = vi.spyOn(registry, 'isOpen').mockReturnValue(true);
|
||||
try {
|
||||
const profile = makeProfile({ id, name: 'New Name', remotePath: '/home/souta/work' });
|
||||
const result = await new ShadowVaultBootstrap(scratch.baseDir, scratch.sourceDir, registry)
|
||||
.bootstrap(profile, [profile]);
|
||||
expect(result.migrated).toBe(false);
|
||||
expect(result.layout.vaultDir).toBe(oldDir); // used as-is, rename deferred
|
||||
expect(fs.existsSync(oldDir)).toBe(true);
|
||||
expect(fs.existsSync(path.join(scratch.baseDir, 'New Name--work'))).toBe(false);
|
||||
} finally { isOpenSpy.mockRestore(); }
|
||||
});
|
||||
|
||||
it('collision: a second profile with the same <name>--<tail> gets a " (2)" dir, configs stay separate', async () => {
|
||||
const a = makeProfile({ id: 'aaaa1111-2222-3333-4444-555555555555', name: 'Panza', remotePath: '/home/a/work' });
|
||||
const b = makeProfile({ id: 'bbbb2222-3333-4444-5555-666666666666', name: 'Panza', remotePath: '/home/b/work' });
|
||||
const boot = new ShadowVaultBootstrap(scratch.baseDir, scratch.sourceDir, new ObsidianRegistry(scratch.configPath));
|
||||
const ra = await boot.bootstrap(a, [a, b]);
|
||||
const rb = await boot.bootstrap(b, [a, b]);
|
||||
expect(path.basename(ra.layout.vaultDir)).toBe('Panza--work');
|
||||
expect(path.basename(rb.layout.vaultDir)).toBe('Panza--work (2)');
|
||||
expect(ra.layout.vaultDir).not.toBe(rb.layout.vaultDir);
|
||||
// Distinct data.json → the two vaults never merge into one dir.
|
||||
expect(JSON.parse(fs.readFileSync(ra.layout.pluginDataFile, 'utf-8')).autoConnectProfileId).toBe(a.id);
|
||||
expect(JSON.parse(fs.readFileSync(rb.layout.pluginDataFile, 'utf-8')).autoConnectProfileId).toBe(b.id);
|
||||
|
||||
// Reconnect: a profile parked in a ` (2)` dir must resolve back to its
|
||||
// OWN dir with migrated=false — not report a spurious migration (a no-op
|
||||
// same-path rename) and re-show the one-time "restart Obsidian" notice
|
||||
// on every connect.
|
||||
const rb2 = await boot.bootstrap(b, [a, b]);
|
||||
expect(rb2.migrated).toBe(false);
|
||||
expect(rb2.layout.vaultDir).toBe(rb.layout.vaultDir);
|
||||
const ra2 = await boot.bootstrap(a, [a, b]);
|
||||
expect(ra2.migrated).toBe(false);
|
||||
expect(ra2.layout.vaultDir).toBe(ra.layout.vaultDir);
|
||||
});
|
||||
|
||||
it('commits the migration even if the registry update throws AFTER a successful rename', async () => {
|
||||
// #438 regression: rename succeeds (config physically moves) but
|
||||
// updatePath throws (obsidian.json briefly locked). The session must
|
||||
// use the migrated dir — NOT fall back to the old dir, which bootstrap
|
||||
// would recreate empty, opening a blank vault while config sits orphaned.
|
||||
const id = 'cccc3333-4444-5555-6666-777777777777';
|
||||
const oldDir = seedShadow('Old Name--work', id, ['remote-ssh', 'keepme']);
|
||||
const registry = new ObsidianRegistry(scratch.configPath);
|
||||
registry.register(oldDir);
|
||||
const updateSpy = vi.spyOn(registry, 'updatePath').mockImplementation(() => { throw new Error('EBUSY'); });
|
||||
try {
|
||||
const profile = makeProfile({ id, name: 'New Name', remotePath: '/home/souta/work' });
|
||||
const result = await new ShadowVaultBootstrap(scratch.baseDir, scratch.sourceDir, registry)
|
||||
.bootstrap(profile, [profile]);
|
||||
|
||||
expect(result.migrated, 'rename succeeded → migration committed').toBe(true);
|
||||
expect(path.basename(result.layout.vaultDir)).toBe('New Name--work');
|
||||
expect(fs.existsSync(oldDir), 'old dir was moved, not recreated empty').toBe(false);
|
||||
expect(
|
||||
JSON.parse(fs.readFileSync(path.join(result.layout.configDir, 'community-plugins.json'), 'utf-8')),
|
||||
'config must live at the migrated dir, never orphaned',
|
||||
).toContain('keepme');
|
||||
} finally { updateSpy.mockRestore(); }
|
||||
});
|
||||
|
||||
it('falls back to the found dir (no data loss) when the rename fails', async () => {
|
||||
const id = 'dddd4444-5555-6666-7777-888888888888';
|
||||
const oldDir = seedShadow('Old Name--work', id, ['remote-ssh', 'keepme']);
|
||||
// Only the migration rename (the first renameSync) throws; bootstrap's
|
||||
// own later atomic writes use the real implementation.
|
||||
const renameSpy = vi.spyOn(fs, 'renameSync').mockImplementationOnce(() => { throw new Error('EBUSY'); });
|
||||
try {
|
||||
const profile = makeProfile({ id, name: 'New Name', remotePath: '/home/souta/work' });
|
||||
const result = await new ShadowVaultBootstrap(scratch.baseDir, scratch.sourceDir, new ObsidianRegistry(scratch.configPath))
|
||||
.bootstrap(profile, [profile]);
|
||||
expect(result.migrated).toBe(false);
|
||||
expect(result.layout.vaultDir).toBe(oldDir); // fell back to found this session
|
||||
expect(JSON.parse(fs.readFileSync(path.join(oldDir, '.obsidian', 'community-plugins.json'), 'utf-8')))
|
||||
.toContain('keepme'); // config NOT lost
|
||||
} finally { renameSpy.mockRestore(); }
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -13,6 +13,17 @@ function makeProfile(id: string, name = id): SshProfile {
|
|||
} as SshProfile;
|
||||
}
|
||||
|
||||
function makeResult(): BootstrapResult {
|
||||
return {
|
||||
layout: {
|
||||
vaultDir: '/tmp/v', configDir: '/tmp/v/.obsidian',
|
||||
pluginDir: '/tmp/v/.obsidian/plugins/remote-ssh',
|
||||
pluginDataFile: '/tmp/v/.obsidian/plugins/remote-ssh/data.json',
|
||||
},
|
||||
registryId: 'abc', registryCreated: false, pluginInstallMethod: 'symlink',
|
||||
};
|
||||
}
|
||||
|
||||
describe('ShadowVaultManager.openShadowFor', () => {
|
||||
it('runs bootstrap then spawn, in that order, with the right args', async () => {
|
||||
const order: string[] = [];
|
||||
|
|
@ -55,4 +66,43 @@ describe('ShadowVaultManager.openShadowFor', () => {
|
|||
).rejects.toThrow(/disk full/);
|
||||
expect(spawner.spawn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('runs onBootstrapped AFTER bootstrap and BEFORE spawn, with the bootstrap result', async () => {
|
||||
const order: string[] = [];
|
||||
const fakeResult = makeResult();
|
||||
const bootstrap = {
|
||||
bootstrap: vi.fn(async () => { order.push('bootstrap'); return fakeResult; }),
|
||||
} as unknown as ShadowVaultBootstrap;
|
||||
const spawner = {
|
||||
spawn: vi.fn(() => { order.push('spawn'); return ''; }),
|
||||
} as unknown as WindowSpawner;
|
||||
let seen: BootstrapResult | null = null;
|
||||
const hook = vi.fn(async (r: BootstrapResult) => { order.push('hook'); seen = r; });
|
||||
|
||||
const profile = makeProfile('p1');
|
||||
await new ShadowVaultManager(bootstrap, spawner).openShadowFor(profile, [profile], hook);
|
||||
|
||||
expect(order, 'hook runs between bootstrap and spawn').toEqual(['bootstrap', 'hook', 'spawn']);
|
||||
expect(seen).toBe(fakeResult);
|
||||
});
|
||||
|
||||
it('still spawns when onBootstrapped throws (graceful fallback — never blocks the window)', async () => {
|
||||
const fakeResult = makeResult();
|
||||
const bootstrap = {
|
||||
bootstrap: vi.fn(async () => fakeResult),
|
||||
} as unknown as ShadowVaultBootstrap;
|
||||
const spawner = {
|
||||
spawn: vi.fn(() => ''),
|
||||
} as unknown as WindowSpawner;
|
||||
const hook = vi.fn(async () => { throw new Error('pre-spawn pull failed'); });
|
||||
|
||||
const profile = makeProfile('p1');
|
||||
const result = await new ShadowVaultManager(bootstrap, spawner)
|
||||
.openShadowFor(profile, [profile], hook);
|
||||
|
||||
expect(hook).toHaveBeenCalledOnce();
|
||||
expect(spawner.spawn, 'a pre-spawn hook failure must NOT block the spawn')
|
||||
.toHaveBeenCalledWith('/tmp/v');
|
||||
expect(result).toBe(fakeResult);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -272,3 +272,85 @@ describe('readSshConfig: ProxyJump', () => {
|
|||
expect(srv.proxyJump?.host).toBe('10.0.0.99');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── ProxyCommand (#430) ────────────────────────────────────────────────
|
||||
//
|
||||
// A user behind a Cloudflare tunnel connects via
|
||||
// ProxyCommand cloudflared access ssh --hostname %h
|
||||
// The reader must surface the raw command (OpenSSH %-tokens left
|
||||
// intact for the transport layer to expand at connect time). None of
|
||||
// these pass today — the parser has no `proxycommand` case and
|
||||
// SshConfigEntry has no `proxyCommand` field.
|
||||
describe('readSshConfig: ProxyCommand (#430)', () => {
|
||||
it('parses a ProxyCommand and preserves the raw command incl. %-tokens', () => {
|
||||
const p = write([
|
||||
'Host homelab',
|
||||
' HostName lab.example.com',
|
||||
' User alice',
|
||||
' ProxyCommand cloudflared access ssh --hostname %h',
|
||||
].join('\n'));
|
||||
const entry = readSshConfig(p)[0];
|
||||
expect(entry.alias).toBe('homelab');
|
||||
expect(entry.hostname).toBe('lab.example.com');
|
||||
expect(entry.proxyCommand).toBe('cloudflared access ssh --hostname %h');
|
||||
});
|
||||
|
||||
it('leaves proxyCommand undefined when the host declares none', () => {
|
||||
const p = write(['Host plain', ' HostName plain.example.com'].join('\n'));
|
||||
expect(readSshConfig(p)[0].proxyCommand).toBeUndefined();
|
||||
});
|
||||
|
||||
it('applies a ProxyCommand from the Host * defaults block', () => {
|
||||
const p = write([
|
||||
'Host *',
|
||||
' ProxyCommand nc -X connect -x proxy:1080 %h %p',
|
||||
'Host srv',
|
||||
' HostName srv.example.com',
|
||||
].join('\n'));
|
||||
expect(readSshConfig(p).find(e => e.alias === 'srv')?.proxyCommand)
|
||||
.toBe('nc -X connect -x proxy:1080 %h %p');
|
||||
});
|
||||
|
||||
it('a host-specific ProxyCommand overrides the Host * default', () => {
|
||||
const p = write([
|
||||
'Host *',
|
||||
' ProxyCommand default-proxy %h',
|
||||
'Host srv',
|
||||
' HostName srv.example.com',
|
||||
' ProxyCommand specific-proxy %h %p',
|
||||
].join('\n'));
|
||||
expect(readSshConfig(p).find(e => e.alias === 'srv')?.proxyCommand)
|
||||
.toBe('specific-proxy %h %p');
|
||||
});
|
||||
|
||||
it('preserves spaces in the ProxyCommand value verbatim', () => {
|
||||
const p = write([
|
||||
'Host q',
|
||||
' HostName q.example.com',
|
||||
' ProxyCommand ssh -W %h:%p jump.example.com',
|
||||
].join('\n'));
|
||||
expect(readSshConfig(p)[0].proxyCommand).toBe('ssh -W %h:%p jump.example.com');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── IdentityAgent (#430) ───────────────────────────────────────────────
|
||||
//
|
||||
// The same user authenticates through 1Password's SSH agent socket
|
||||
// (`IdentityAgent ~/.1password/agent.sock`). The reader must surface
|
||||
// the tilde-expanded socket path so AuthResolver can use it. Fails
|
||||
// today — no `identityagent` case, no `identityAgent` field.
|
||||
describe('readSshConfig: IdentityAgent (#430)', () => {
|
||||
it('parses IdentityAgent and tilde-expands the socket path', () => {
|
||||
const p = write([
|
||||
'Host onepw',
|
||||
' HostName host.example.com',
|
||||
' IdentityAgent ~/.1password/agent.sock',
|
||||
].join('\n'));
|
||||
const entry = readSshConfig(p)[0];
|
||||
expect(entry.identityAgent).toBeDefined();
|
||||
expect(entry.identityAgent?.startsWith('~')).toBe(false); // tilde expanded
|
||||
// Compare via path.join so the separator is OS-agnostic (Windows
|
||||
// expands to backslashes).
|
||||
expect(entry.identityAgent).toBe(path.join(os.homedir(), '.1password', 'agent.sock'));
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { VaultModelBuilder, type RemoteEntry, type ObsidianClassDeps } from '../src/vault/VaultModelBuilder';
|
||||
import { logger } from '../src/util/logger';
|
||||
|
||||
/**
|
||||
* Minimal stubs that mirror the structural shape of TFile / TFolder
|
||||
|
|
@ -72,6 +73,55 @@ function makeFakeVault() {
|
|||
return { vault, root, fileMap, triggers };
|
||||
}
|
||||
|
||||
describe('VaultModelBuilder.buildChunked', () => {
|
||||
it('builds every entry across multiple chunks (folders-before-files), no errors', async () => {
|
||||
const { vault, fileMap } = makeFakeVault();
|
||||
// One folder + 1200 files under it → spans several chunks at chunkSize 100.
|
||||
const entries: RemoteEntry[] = [{ path: 'work', isDirectory: true, ctime: 0, mtime: 0, size: 0 }];
|
||||
for (let i = 0; i < 1200; i++) {
|
||||
entries.push({ path: `work/n${i}.md`, isDirectory: false, ctime: 0, mtime: 0, size: 0 });
|
||||
}
|
||||
// Reverse the input to prove ordering is fixed internally, not by the caller.
|
||||
const shuffled = [...entries].reverse();
|
||||
|
||||
const result = await new VaultModelBuilder(vault as never, deps).buildChunked(shuffled, 100);
|
||||
|
||||
expect(result.foldersAdded).toBe(1);
|
||||
expect(result.filesAdded).toBe(1200);
|
||||
expect(result.errors).toEqual([]);
|
||||
expect(Object.keys(fileMap)).toHaveLength(1201);
|
||||
expect(fileMap['work']).toBeInstanceOf(FakeTFolder);
|
||||
expect(fileMap['work/n0.md']).toBeInstanceOf(FakeTFile);
|
||||
expect(fileMap['work/n1199.md']).toBeInstanceOf(FakeTFile);
|
||||
});
|
||||
|
||||
it('is idempotent: entries already in the vault are skipped, not re-added', async () => {
|
||||
const { vault } = makeFakeVault();
|
||||
const builder = new VaultModelBuilder(vault as never, deps);
|
||||
const entries: RemoteEntry[] = [
|
||||
{ path: 'a', isDirectory: true, ctime: 0, mtime: 0, size: 0 },
|
||||
{ path: 'a/x.md', isDirectory: false, ctime: 0, mtime: 0, size: 0 },
|
||||
];
|
||||
await builder.buildChunked(entries, 1);
|
||||
const second = await builder.buildChunked(entries, 1);
|
||||
expect(second).toEqual({ filesAdded: 0, foldersAdded: 0, skipped: 2, errors: [] });
|
||||
});
|
||||
|
||||
it('produces the same model + result as the synchronous build()', async () => {
|
||||
const entries: RemoteEntry[] = [
|
||||
{ path: 'd', isDirectory: true, ctime: 0, mtime: 0, size: 0 },
|
||||
{ path: 'd/a.md', isDirectory: false, ctime: 1, mtime: 2, size: 3 },
|
||||
{ path: 'top.md', isDirectory: false, ctime: 0, mtime: 0, size: 0 },
|
||||
];
|
||||
const a = makeFakeVault();
|
||||
const sync = await new VaultModelBuilder(a.vault as never, deps).build(entries);
|
||||
const b = makeFakeVault();
|
||||
const chunked = await new VaultModelBuilder(b.vault as never, deps).buildChunked(entries, 1);
|
||||
expect(chunked).toEqual(sync);
|
||||
expect(Object.keys(b.fileMap).sort()).toEqual(Object.keys(a.fileMap).sort());
|
||||
});
|
||||
});
|
||||
|
||||
describe('VaultModelBuilder.build', () => {
|
||||
it('returns zero counts for empty entry list', async () => {
|
||||
const { vault } = makeFakeVault();
|
||||
|
|
@ -615,3 +665,91 @@ describe('VaultModelBuilder.reflect* (#341 writer self-reflect)', () => {
|
|||
expect(triggers).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── #423: move into a folder the writer model hasn't caught up to ──────────
|
||||
//
|
||||
// Field repro (issue #423): a user creates a folder and drags several
|
||||
// top-level notes into it. The destination folder exists on the remote
|
||||
// but the writer's vault model is momentarily stale, so `renameOne`'s
|
||||
// strict `resolveParent` returns null and the reflect bails with
|
||||
// "writer vault model is stale". The notes then vanish from File
|
||||
// Explorer (detached from the old path, never attached to the new one)
|
||||
// until a full reopen re-populates the model.
|
||||
//
|
||||
// `reflectRename` is the writer-reflect entry point and MUST tolerate a
|
||||
// stale destination subtree — exactly as `reflectWrite` already does via
|
||||
// `insertOne({ ensureParents: true })`. The low-level `renameOne` keeps
|
||||
// its strict contract for the bulk-build sanity check; only the
|
||||
// writer-reflect path synthesises. These cases fail today.
|
||||
describe('VaultModelBuilder.reflectRename — stale destination subtree (#423)', () => {
|
||||
it('synthesises the destination folder and moves the file when the target folder is not modelled', () => {
|
||||
const { vault, fileMap, triggers } = makeFakeVault();
|
||||
const builder = new VaultModelBuilder(vault as never, deps);
|
||||
|
||||
builder.reflectWrite('note.md'); // source exists at top level
|
||||
triggers.length = 0;
|
||||
|
||||
// "Folder" exists on the remote but the writer model hasn't caught
|
||||
// up yet — the #423 drag-into-a-fresh-folder race.
|
||||
builder.reflectRename('note.md', 'Folder/note.md');
|
||||
|
||||
expect(fileMap['note.md']).toBeUndefined();
|
||||
expect(fileMap['Folder']).toBeInstanceOf(FakeTFolder);
|
||||
expect(fileMap['Folder/note.md']).toBeDefined();
|
||||
expect((fileMap['Folder/note.md'] as FakeTFile).path).toBe('Folder/note.md');
|
||||
const renameEvt = triggers.find((t) => t.event === 'rename');
|
||||
expect(renameEvt, 'a rename trigger must fire so File Explorer relocates the item').toBeDefined();
|
||||
expect(renameEvt?.args[1]).toBe('note.md');
|
||||
});
|
||||
|
||||
it('moves several top-level notes into the same fresh folder (exact #423 field-log shape)', () => {
|
||||
const { vault, fileMap } = makeFakeVault();
|
||||
const builder = new VaultModelBuilder(vault as never, deps);
|
||||
builder.reflectMkdir('Zettelkasten');
|
||||
builder.reflectWrite('Zettelkasten/a.md');
|
||||
builder.reflectWrite('Zettelkasten/b.md');
|
||||
builder.reflectWrite('Zettelkasten/c.md');
|
||||
|
||||
// "Baza" is created, then the three notes are dragged into it.
|
||||
builder.reflectRename('Zettelkasten/a.md', 'Zettelkasten/Baza/a.md');
|
||||
builder.reflectRename('Zettelkasten/b.md', 'Zettelkasten/Baza/b.md');
|
||||
builder.reflectRename('Zettelkasten/c.md', 'Zettelkasten/Baza/c.md');
|
||||
|
||||
expect(fileMap['Zettelkasten/Baza']).toBeInstanceOf(FakeTFolder);
|
||||
for (const f of ['a', 'b', 'c']) {
|
||||
expect(fileMap[`Zettelkasten/Baza/${f}.md`], `${f}.md should land in Baza`).toBeDefined();
|
||||
expect(fileMap[`Zettelkasten/${f}.md`], `${f}.md should leave its old location`).toBeUndefined();
|
||||
}
|
||||
});
|
||||
|
||||
it('renames into a deeply nested fresh subtree by synthesising the whole chain', () => {
|
||||
const { vault, fileMap } = makeFakeVault();
|
||||
const builder = new VaultModelBuilder(vault as never, deps);
|
||||
builder.reflectWrite('note.md');
|
||||
|
||||
builder.reflectRename('note.md', 'a/b/c/note.md');
|
||||
|
||||
expect(fileMap['a']).toBeInstanceOf(FakeTFolder);
|
||||
expect(fileMap['a/b']).toBeInstanceOf(FakeTFolder);
|
||||
expect(fileMap['a/b/c']).toBeInstanceOf(FakeTFolder);
|
||||
expect(fileMap['a/b/c/note.md']).toBeDefined();
|
||||
expect(fileMap['note.md']).toBeUndefined();
|
||||
});
|
||||
|
||||
it('does not log "writer vault model is stale" when Obsidian already moved the file (benign echo)', () => {
|
||||
const { vault, fileMap } = makeFakeVault();
|
||||
const builder = new VaultModelBuilder(vault as never, deps);
|
||||
builder.reflectMkdir('Folder');
|
||||
builder.reflectWrite('Folder/note.md'); // file already at the destination
|
||||
|
||||
const warn = vi.spyOn(logger, 'warn').mockImplementation(() => {});
|
||||
// Source key already gone — Obsidian's own rename ran first.
|
||||
builder.reflectRename('note.md', 'Folder/note.md');
|
||||
// Capture before restoring: mockRestore() also clears mock.calls.
|
||||
const staleWarnings = warn.mock.calls.filter((c) => String(c[0]).includes('writer vault model is stale'));
|
||||
warn.mockRestore();
|
||||
|
||||
expect(fileMap['Folder/note.md']).toBeDefined();
|
||||
expect(staleWarnings, 'a no-op move must not be reported as a stale-model divergence').toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -60,6 +60,22 @@ describe('connectProfile — RPC daemon failure handling (#399 / #406)', () => {
|
|||
expect(recordedNotices().some((n) => /SFTP/i.test(n))).toBe(true);
|
||||
});
|
||||
|
||||
it('downgrades to SFTP (not a hard fail) when the daemon deploy/startup fails, e.g. token timeout', async () => {
|
||||
// Field regression: a daemon that deploys but never writes its token
|
||||
// used to hard-fail the connect → populate skipped → EMPTY vault. A
|
||||
// generic (non-verification) daemon-start failure must degrade to SFTP.
|
||||
const { plugin, conn, disconnect } = makePlugin(
|
||||
new Error('ServerDeployer: daemon did not write /home/u/.obsidian-remote/token within 5000ms'),
|
||||
);
|
||||
|
||||
await plugin.connectProfile(rpcProfile);
|
||||
|
||||
expect(conn.connectSsh).toHaveBeenCalledWith(rpcProfile);
|
||||
expect(plugin.isConnected()).toBe(true); // reached CONNECTED → populate runs
|
||||
expect(disconnect).not.toHaveBeenCalled(); // SSH kept for SFTP
|
||||
expect(recordedNotices().some((n) => /SFTP/i.test(n))).toBe(true);
|
||||
});
|
||||
|
||||
it('fails LOUD (no silent SFTP downgrade) when daemon verification fails', async () => {
|
||||
const { plugin, conn, disconnect } = makePlugin(new DaemonVerificationError('sha256 mismatch'));
|
||||
|
||||
|
|
|
|||
134
plugin/tests/ensureDaemonBinary.test.ts
Normal file
134
plugin/tests/ensureDaemonBinary.test.ts
Normal file
|
|
@ -0,0 +1,134 @@
|
|||
import { describe, it, expect, afterEach, vi } from 'vitest';
|
||||
import * as fs from 'fs';
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
import { createHash } from 'crypto';
|
||||
|
||||
// main.ts pulls in RemoteTerminalView (extends the obsidian ItemView the unit
|
||||
// mock doesn't model) — stub it so the import graph resolves. Same shim the
|
||||
// sibling connectProfile.daemon-downgrade.test.ts uses.
|
||||
vi.mock('../src/ui/RemoteTerminalView', () => ({
|
||||
RemoteTerminalView: class {},
|
||||
VIEW_TYPE_REMOTE_TERMINAL: 'remote-terminal',
|
||||
}));
|
||||
|
||||
import { App } from 'obsidian';
|
||||
import RemoteSshPlugin from '../src/main';
|
||||
|
||||
/** A fake SftpClient whose `exec` answers the daemon's `uname` probe. */
|
||||
const linuxAmd64Client = {
|
||||
exec: async (cmd: string) => ({
|
||||
stdout: cmd === 'uname -s' ? 'Linux' : 'x86_64',
|
||||
stderr: '',
|
||||
exitCode: 0,
|
||||
}),
|
||||
};
|
||||
|
||||
const tmpDirs: string[] = [];
|
||||
function scratchPluginDir(): string {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), `rs-daemon-${process.pid}-`));
|
||||
tmpDirs.push(dir);
|
||||
fs.mkdirSync(path.join(dir, 'server-bin'), { recursive: true });
|
||||
return dir;
|
||||
}
|
||||
afterEach(() => {
|
||||
while (tmpDirs.length) {
|
||||
try { fs.rmSync(tmpDirs.pop()!, { recursive: true, force: true }); } catch { /* ignore */ }
|
||||
}
|
||||
});
|
||||
|
||||
const BIN_NAME = 'obsidian-remote-server-linux-amd64';
|
||||
|
||||
describe('RemoteSshPlugin.locateDaemonBinary — .dev-daemon marker gate', () => {
|
||||
it('returns null for an UNMARKED staged binary (a download must not masquerade as a dev build)', () => {
|
||||
const plugin = new RemoteSshPlugin(new App() as never);
|
||||
const dir = scratchPluginDir();
|
||||
fs.writeFileSync(path.join(dir, 'server-bin', BIN_NAME), 'ELF');
|
||||
const p = plugin as unknown as { pluginDir: () => string; locateDaemonBinary: () => string | null };
|
||||
p.pluginDir = () => dir;
|
||||
|
||||
expect(p.locateDaemonBinary()).toBeNull();
|
||||
});
|
||||
|
||||
it('returns the binary path once dev-install has written the .dev-daemon marker', () => {
|
||||
const plugin = new RemoteSshPlugin(new App() as never);
|
||||
const dir = scratchPluginDir();
|
||||
const bin = path.join(dir, 'server-bin', BIN_NAME);
|
||||
fs.writeFileSync(bin, 'ELF');
|
||||
fs.writeFileSync(path.join(dir, 'server-bin', '.dev-daemon'), '');
|
||||
const p = plugin as unknown as { pluginDir: () => string; locateDaemonBinary: () => string | null };
|
||||
p.pluginDir = () => dir;
|
||||
|
||||
expect(p.locateDaemonBinary()).toBe(bin);
|
||||
});
|
||||
});
|
||||
|
||||
describe('RemoteSshPlugin.ensureDaemonBinary — version + sha fast path', () => {
|
||||
interface Internals {
|
||||
pluginDir: () => string;
|
||||
manifest: { version: string };
|
||||
settings: Record<string, unknown>;
|
||||
saveData: (d: unknown) => Promise<void>;
|
||||
confirmDaemonDownload: (v: string) => Promise<boolean>;
|
||||
ensureDaemonBinary: (client: unknown) => Promise<string | null>;
|
||||
}
|
||||
|
||||
function makePlugin(dir: string, settings: Record<string, unknown>): Internals {
|
||||
const plugin = new RemoteSshPlugin(new App() as never);
|
||||
const p = plugin as unknown as Internals;
|
||||
p.pluginDir = () => dir;
|
||||
p.manifest = { version: '1.2.0' };
|
||||
p.settings = settings;
|
||||
p.saveData = async () => { /* no-op persist */ };
|
||||
return p;
|
||||
}
|
||||
|
||||
it('reuses the cached binary WITHOUT any download when version + sha both match', async () => {
|
||||
const dir = scratchPluginDir();
|
||||
const bin = path.join(dir, 'server-bin', BIN_NAME);
|
||||
const bytes = Buffer.from([0x7f, 0x45, 0x4c, 0x46, 0x02, 0x01]);
|
||||
fs.writeFileSync(bin, bytes);
|
||||
const sha = createHash('sha256').update(bytes).digest('hex');
|
||||
const p = makePlugin(dir, { daemonBinaryVersion: '1.2.0', daemonBinarySha: sha });
|
||||
|
||||
// No requestUrl is mocked here: a fall-through to the download path would
|
||||
// throw, so returning the cached path proves the fast path fired.
|
||||
const result = await p.ensureDaemonBinary(linuxAmd64Client);
|
||||
expect(result).toBe(bin);
|
||||
});
|
||||
|
||||
it('does NOT take the fast path when the cached bytes no longer match the sha (post-write corruption)', async () => {
|
||||
const dir = scratchPluginDir();
|
||||
// File whose real sha does NOT match the recorded marker → integrity fail.
|
||||
fs.writeFileSync(path.join(dir, 'server-bin', BIN_NAME), Buffer.from([0x00, 0x11, 0x22]));
|
||||
const p = makePlugin(dir, {
|
||||
daemonBinaryVersion: '1.2.0',
|
||||
daemonBinarySha: 'f'.repeat(64), // deliberately wrong
|
||||
daemonDownloadConsented: false,
|
||||
});
|
||||
// Fall-through reaches the consent gate; decline it to end deterministically
|
||||
// on the "staying on SFTP" path (no network) rather than the fast path.
|
||||
p.confirmDaemonDownload = async () => false;
|
||||
|
||||
const result = await p.ensureDaemonBinary(linuxAmd64Client);
|
||||
expect(result).toBeNull(); // fell through (not reused) → declined → SFTP
|
||||
});
|
||||
|
||||
it('does NOT take the fast path when the recorded version differs from the plugin version', async () => {
|
||||
const dir = scratchPluginDir();
|
||||
const bin = path.join(dir, 'server-bin', BIN_NAME);
|
||||
const bytes = Buffer.from([0x7f, 0x45, 0x4c, 0x46]);
|
||||
fs.writeFileSync(bin, bytes);
|
||||
const sha = createHash('sha256').update(bytes).digest('hex');
|
||||
// sha matches the file, but the marker is for an OLDER plugin version.
|
||||
const p = makePlugin(dir, {
|
||||
daemonBinaryVersion: '1.1.0',
|
||||
daemonBinarySha: sha,
|
||||
daemonDownloadConsented: false,
|
||||
});
|
||||
p.confirmDaemonDownload = async () => false;
|
||||
|
||||
const result = await p.ensureDaemonBinary(linuxAmd64Client);
|
||||
expect(result).toBeNull(); // version mismatch → re-check path → declined → SFTP
|
||||
});
|
||||
});
|
||||
214
plugin/tests/integration/config-consistency.e2e.test.ts
Normal file
214
plugin/tests/integration/config-consistency.e2e.test.ts
Normal file
|
|
@ -0,0 +1,214 @@
|
|||
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
||||
import * as fs from 'node:fs';
|
||||
import * as os from 'node:os';
|
||||
import * as path from 'node:path';
|
||||
import { ShadowVaultBootstrap } from '../../src/shadow/ShadowVaultBootstrap';
|
||||
import { ObsidianRegistry } from '../../src/shadow/ObsidianRegistry';
|
||||
import { setupClientPair, TEST_PRIVATE_KEY, type TestClient } from './helpers/makeAdapter';
|
||||
import type { SshProfile } from '../../src/types';
|
||||
|
||||
/**
|
||||
* Config consistency across connect cycles (#429 / #342).
|
||||
*
|
||||
* Layer 1 (integration, vitest + docker sshd, no Obsidian UI). Drives
|
||||
* the real bootstrap + pull/push round-trip against a real SSH server,
|
||||
* so the #429/#342 promise — "config and the enabled-plugins list stay
|
||||
* consistent across reconnects, and a push never clobbers a list
|
||||
* enabled on another machine" — is verified end-to-end, not just over
|
||||
* in-memory fakes. The remote vault's `.obsidian/` is the canonical
|
||||
* store; a fresh shadow vault is a new "session" / "machine".
|
||||
*
|
||||
* Runs only when the test keypair is staged (`npm run sshd:start`).
|
||||
*/
|
||||
|
||||
if (!fs.existsSync(TEST_PRIVATE_KEY)) {
|
||||
throw new Error(
|
||||
`Integration test keypair missing at ${TEST_PRIVATE_KEY}. ` +
|
||||
'Run `npm run sshd:start` from the repo root before `npm run test:integration`.',
|
||||
);
|
||||
}
|
||||
|
||||
const REMOTE_CFG = '.obsidian';
|
||||
const CP = `${REMOTE_CFG}/community-plugins.json`;
|
||||
const APP = `${REMOTE_CFG}/app.json`;
|
||||
|
||||
describe('Config consistency across connect cycles (#429 / #342)', () => {
|
||||
let pair: Awaited<ReturnType<typeof setupClientPair>>;
|
||||
let remoteClient: TestClient;
|
||||
let baseDir: string;
|
||||
let sourcePluginDir: string;
|
||||
let registryConfigPath: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
pair = await setupClientPair({ testLabel: 'config-consistency' });
|
||||
remoteClient = pair.a;
|
||||
|
||||
baseDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cfg-consistency-base-'));
|
||||
const tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), 'cfg-consistency-home-'));
|
||||
registryConfigPath = path.join(tmpHome, 'obsidian.json');
|
||||
fs.writeFileSync(registryConfigPath, JSON.stringify({ vaults: {} }) + '\n', 'utf-8');
|
||||
|
||||
sourcePluginDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cfg-consistency-source-'));
|
||||
fs.writeFileSync(path.join(sourcePluginDir, 'main.js'), '/* test stub */\n', 'utf-8');
|
||||
fs.writeFileSync(path.join(sourcePluginDir, 'manifest.json'),
|
||||
JSON.stringify({ id: 'remote-ssh', version: '0.0.0-test', name: 'Test', minAppVersion: '1.5.0' }) + '\n', 'utf-8');
|
||||
fs.writeFileSync(path.join(sourcePluginDir, 'styles.css'), '/* test stub */\n', 'utf-8');
|
||||
|
||||
await remoteClient.adapter.mkdir(REMOTE_CFG);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
if (pair) await pair.cleanup();
|
||||
try { fs.rmSync(baseDir, { recursive: true, force: true }); } catch { /* best effort */ }
|
||||
try { fs.rmSync(sourcePluginDir, { recursive: true, force: true }); } catch { /* best effort */ }
|
||||
});
|
||||
|
||||
/** A fresh shadow-vault session against the shared docker remote. */
|
||||
function freshSession(): ShadowVaultBootstrap {
|
||||
return new ShadowVaultBootstrap(baseDir, sourcePluginDir, new ObsidianRegistry(registryConfigPath));
|
||||
}
|
||||
function profileFor(caseId: string): SshProfile {
|
||||
return {
|
||||
id: `cfg-${caseId}-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`,
|
||||
name: `Config consistency (${caseId})`,
|
||||
host: '127.0.0.1', port: 2222, username: 'tester',
|
||||
authMethod: 'privateKey', privateKeyPath: TEST_PRIVATE_KEY,
|
||||
remotePath: remoteClient.vaultRoot,
|
||||
connectTimeoutMs: 10_000, keepaliveIntervalMs: 0, keepaliveCountMax: 0,
|
||||
};
|
||||
}
|
||||
const readLocalCp = (dir: string): string[] =>
|
||||
JSON.parse(fs.readFileSync(path.join(dir, 'community-plugins.json'), 'utf-8'));
|
||||
|
||||
it('community plugins enabled on the remote round-trip into a fresh shadow, keeping remote-ssh (#429/#342)', async () => {
|
||||
// Remote set up "on another machine": dataview enabled.
|
||||
await remoteClient.adapter.write(CP, JSON.stringify(['dataview']));
|
||||
|
||||
const profile = profileFor('cp-pull');
|
||||
const { layout } = await freshSession().bootstrap(profile, [profile]);
|
||||
await ShadowVaultBootstrap.pullCommunityPlugins(remoteClient.adapter, REMOTE_CFG, layout.configDir);
|
||||
|
||||
expect(readLocalCp(layout.configDir)).toEqual(expect.arrayContaining(['dataview', 'remote-ssh']));
|
||||
});
|
||||
|
||||
it('a plugin enabled in one session is still enabled in the next (persists via the remote)', async () => {
|
||||
// Known base so the assertion doesn't depend on case order.
|
||||
await remoteClient.adapter.write(CP, JSON.stringify(['remote-ssh']));
|
||||
|
||||
// Session 1: enable templater locally, push to remote.
|
||||
const p1 = profileFor('cycle-1');
|
||||
const s1 = await freshSession().bootstrap(p1, [p1]);
|
||||
fs.writeFileSync(path.join(s1.layout.configDir, 'community-plugins.json'),
|
||||
JSON.stringify(['remote-ssh', 'templater']));
|
||||
await ShadowVaultBootstrap.pushCommunityPlugins(remoteClient.adapter, REMOTE_CFG, s1.layout.configDir);
|
||||
|
||||
// Session 2: a brand-new shadow (different id → different local dir) pulls.
|
||||
const p2 = profileFor('cycle-2');
|
||||
const s2 = await freshSession().bootstrap(p2, [p2]);
|
||||
await ShadowVaultBootstrap.pullCommunityPlugins(remoteClient.adapter, REMOTE_CFG, s2.layout.configDir);
|
||||
|
||||
expect(readLocalCp(s2.layout.configDir), 'templater enabled in session 1 must survive into session 2')
|
||||
.toContain('templater');
|
||||
});
|
||||
|
||||
it('push unions with the remote and never drops a plugin enabled elsewhere (#437, real SSH)', async () => {
|
||||
// Remote (machine B) carries a rich list.
|
||||
await remoteClient.adapter.write(CP, JSON.stringify(['remote-ssh', 'dataview', 'obsidian-git']));
|
||||
|
||||
// Machine A has only a minimal local list, then pushes.
|
||||
const pA = profileFor('union');
|
||||
const sA = await freshSession().bootstrap(pA, [pA]);
|
||||
fs.writeFileSync(path.join(sA.layout.configDir, 'community-plugins.json'),
|
||||
JSON.stringify(['remote-ssh', 'templater']));
|
||||
await ShadowVaultBootstrap.pushCommunityPlugins(remoteClient.adapter, REMOTE_CFG, sA.layout.configDir);
|
||||
|
||||
const remoteAfter = JSON.parse(await remoteClient.adapter.read(CP));
|
||||
expect(remoteAfter, 'remote dataview + obsidian-git must survive a push from a minimal local')
|
||||
.toEqual(expect.arrayContaining(['dataview', 'obsidian-git', 'templater', 'remote-ssh']));
|
||||
});
|
||||
|
||||
it('a settings change stays consistent across a write → push → fresh-pull cycle (#342 reproducer)', async () => {
|
||||
// Session 1: seed remote app.json, pull into the shadow.
|
||||
await remoteClient.adapter.write(APP, JSON.stringify({ useMarkdownLinks: false, theme: 'obsidian' }));
|
||||
const p1 = profileFor('app-1');
|
||||
const s1 = await freshSession().bootstrap(p1, [p1]);
|
||||
await ShadowVaultBootstrap.pullSharedObsidianConfig(remoteClient.adapter, REMOTE_CFG, s1.layout.configDir);
|
||||
|
||||
// User changes a setting in the shadow, then it's pushed back.
|
||||
const changed = { useMarkdownLinks: true, theme: 'things' };
|
||||
fs.writeFileSync(path.join(s1.layout.configDir, 'app.json'), JSON.stringify(changed));
|
||||
await ShadowVaultBootstrap.pushSharedObsidianConfig(remoteClient.adapter, REMOTE_CFG, s1.layout.configDir);
|
||||
|
||||
// The test holds a snapshot of the intended state.
|
||||
const snapshot = JSON.parse(fs.readFileSync(path.join(s1.layout.configDir, 'app.json'), 'utf-8'));
|
||||
|
||||
// Session 2: a fresh shadow pulls — must equal the snapshot.
|
||||
const p2 = profileFor('app-2');
|
||||
const s2 = await freshSession().bootstrap(p2, [p2]);
|
||||
await ShadowVaultBootstrap.pullSharedObsidianConfig(remoteClient.adapter, REMOTE_CFG, s2.layout.configDir);
|
||||
|
||||
const local2 = JSON.parse(fs.readFileSync(path.join(s2.layout.configDir, 'app.json'), 'utf-8'));
|
||||
expect(local2, 'the setting changed in session 1 must be consistent in session 2').toEqual(snapshot);
|
||||
});
|
||||
|
||||
it('a sideloaded plugin binary enabled on another machine is staged into a fresh shadow (#429b)', async () => {
|
||||
const id = 'sideloaded-plugin';
|
||||
await remoteClient.adapter.write(`${REMOTE_CFG}/plugins/${id}/manifest.json`,
|
||||
JSON.stringify({ id, version: '1.0.0', name: 'Sideloaded', minAppVersion: '1.5.0' }));
|
||||
await remoteClient.adapter.write(`${REMOTE_CFG}/plugins/${id}/main.js`, '/* sideloaded code */\n');
|
||||
|
||||
const profile = profileFor('bin-pull');
|
||||
const { layout } = await freshSession().bootstrap(profile, [profile]);
|
||||
const { pulled } = await ShadowVaultBootstrap.pullPluginBinaries(
|
||||
remoteClient.adapter, REMOTE_CFG, layout.configDir, [id]);
|
||||
|
||||
expect(pulled).toContain(id);
|
||||
expect(fs.readFileSync(path.join(layout.configDir, 'plugins', id, 'main.js'), 'utf-8'))
|
||||
.toBe('/* sideloaded code */\n');
|
||||
expect(fs.existsSync(path.join(layout.configDir, 'plugins', id, 'manifest.json'))).toBe(true);
|
||||
});
|
||||
|
||||
it('a sideloaded plugin installed in one session round-trips into the next via the remote', async () => {
|
||||
const id = 'local-only-plugin';
|
||||
// Session 1 (machine A): code present locally, pushed to the remote.
|
||||
const pA = profileFor('bin-push');
|
||||
const sA = await freshSession().bootstrap(pA, [pA]);
|
||||
const dirA = path.join(sA.layout.configDir, 'plugins', id);
|
||||
fs.mkdirSync(dirA, { recursive: true });
|
||||
fs.writeFileSync(path.join(dirA, 'manifest.json'),
|
||||
JSON.stringify({ id, version: '2.0.0', name: 'LocalOnly', minAppVersion: '1.5.0' }));
|
||||
fs.writeFileSync(path.join(dirA, 'main.js'), '/* local-only code v2 */\n');
|
||||
await ShadowVaultBootstrap.pushPluginBinaries(remoteClient.adapter, REMOTE_CFG, sA.layout.configDir, [id]);
|
||||
|
||||
// Session 2 (machine B): a fresh shadow pulls the pushed binary.
|
||||
const pB = profileFor('bin-push-2');
|
||||
const sB = await freshSession().bootstrap(pB, [pB]);
|
||||
await ShadowVaultBootstrap.pullPluginBinaries(remoteClient.adapter, REMOTE_CFG, sB.layout.configDir, [id]);
|
||||
|
||||
expect(fs.readFileSync(path.join(sB.layout.configDir, 'plugins', id, 'main.js'), 'utf-8'),
|
||||
'a sideloaded plugin installed in session 1 must reach session 2')
|
||||
.toBe('/* local-only code v2 */\n');
|
||||
});
|
||||
|
||||
it('pullPluginBinaries never DOWNGRADES a newer local plugin (older remote, real SSH)', async () => {
|
||||
const id = 'conflict-plugin';
|
||||
// Remote carries an OLDER version.
|
||||
await remoteClient.adapter.write(`${REMOTE_CFG}/plugins/${id}/manifest.json`,
|
||||
JSON.stringify({ id, version: '1.0.0', name: 'Conflict', minAppVersion: '1.5.0' }));
|
||||
await remoteClient.adapter.write(`${REMOTE_CFG}/plugins/${id}/main.js`, '/* REMOTE v1 */\n');
|
||||
|
||||
const profile = profileFor('bin-conflict');
|
||||
const { layout } = await freshSession().bootstrap(profile, [profile]);
|
||||
const dir = path.join(layout.configDir, 'plugins', id);
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
// Local has a strictly NEWER version — the pull must leave it alone.
|
||||
fs.writeFileSync(path.join(dir, 'manifest.json'),
|
||||
JSON.stringify({ id, version: '2.0.0', name: 'Conflict', minAppVersion: '1.5.0' }));
|
||||
fs.writeFileSync(path.join(dir, 'main.js'), '/* LOCAL v2 */\n');
|
||||
|
||||
await ShadowVaultBootstrap.pullPluginBinaries(remoteClient.adapter, REMOTE_CFG, layout.configDir, [id]);
|
||||
|
||||
expect(fs.readFileSync(path.join(dir, 'main.js'), 'utf-8'), 'older remote must not downgrade newer local')
|
||||
.toBe('/* LOCAL v2 */\n');
|
||||
});
|
||||
});
|
||||
|
|
@ -38,9 +38,11 @@ import type { SshProfile } from '../../src/types';
|
|||
* - `core-plugins.json` — which built-in plugins are enabled
|
||||
* - `hotkeys.json` — keybinding overrides
|
||||
*
|
||||
* Each file is `PathMapper.isPrivate(...)` = **false** today, i.e.
|
||||
* it's intentionally shared across machines; the gap is that the
|
||||
* sharing is only one-way (push, no pull).
|
||||
* Each file is now `PathMapper.isPrivate(...)` = **true** (per-device):
|
||||
* the round-trip persists THIS device's OWN copy across a restart. The
|
||||
* seed-write and pull-read go through the same PathMapper, so this still
|
||||
* exercises the push/pull persistence the #342 fix added — it just lands
|
||||
* on the per-client subtree instead of the old shared identity path.
|
||||
*
|
||||
* Runs only when the test keypair is staged (`npm run sshd:start`).
|
||||
*/
|
||||
|
|
|
|||
42
plugin/tests/preSpawnRemotePath.test.ts
Normal file
42
plugin/tests/preSpawnRemotePath.test.ts
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
import { describe, it, expect } from 'vitest';
|
||||
import { PathMapper } from '../src/path/PathMapper';
|
||||
import { preSpawnRemotePath } from '../src/shadow/preSpawnPaths';
|
||||
|
||||
/**
|
||||
* Pins the #450 CRITICAL fix: the pre-spawn config pull must resolve the
|
||||
* four per-device config files to THIS client's `user/<id>/` subtree, not
|
||||
* the dead shared identity path (reading which clobbered per-device config
|
||||
* on every shadow-window spawn). A regression that dropped the
|
||||
* `PathMapper.toRemote` redirect (bare base-join) would flip the config
|
||||
* assertions below to the shared path and fail here.
|
||||
*/
|
||||
describe('preSpawnRemotePath', () => {
|
||||
const mapper = new PathMapper('host-a', '.obsidian');
|
||||
const base = '/home/u/vault';
|
||||
|
||||
it('redirects the four per-device config files to the per-client subtree', () => {
|
||||
for (const f of ['app.json', 'appearance.json', 'core-plugins.json', 'hotkeys.json']) {
|
||||
expect(preSpawnRemotePath(mapper, base, `.obsidian/${f}`))
|
||||
.toBe(`${base}/.obsidian/user/host-a/${f}`);
|
||||
}
|
||||
});
|
||||
|
||||
it('leaves shared files (community-plugins.json, plugin binaries) at the identity path', () => {
|
||||
expect(preSpawnRemotePath(mapper, base, '.obsidian/community-plugins.json'))
|
||||
.toBe(`${base}/.obsidian/community-plugins.json`);
|
||||
expect(preSpawnRemotePath(mapper, base, '.obsidian/plugins/dataview/main.js'))
|
||||
.toBe(`${base}/.obsidian/plugins/dataview/main.js`);
|
||||
});
|
||||
|
||||
it('joins onto a "." remote base (vault root) without a leading slash', () => {
|
||||
expect(preSpawnRemotePath(mapper, '.', '.obsidian/app.json'))
|
||||
.toBe('.obsidian/user/host-a/app.json');
|
||||
});
|
||||
|
||||
it('isolates clients — two client ids never resolve to the same config path', () => {
|
||||
const a = new PathMapper('host-a', '.obsidian');
|
||||
const b = new PathMapper('host-b', '.obsidian');
|
||||
expect(preSpawnRemotePath(a, base, '.obsidian/app.json'))
|
||||
.not.toBe(preSpawnRemotePath(b, base, '.obsidian/app.json'));
|
||||
});
|
||||
});
|
||||
31
plugin/tests/withTimeout.test.ts
Normal file
31
plugin/tests/withTimeout.test.ts
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
import { describe, it, expect, vi, afterEach } from 'vitest';
|
||||
import { withTimeout } from '../src/util/withTimeout';
|
||||
|
||||
describe('withTimeout', () => {
|
||||
afterEach(() => { vi.useRealTimers(); });
|
||||
|
||||
it('resolves with the value when the promise settles before the timeout', async () => {
|
||||
await expect(withTimeout(Promise.resolve('ok'), 1000, 'x')).resolves.toBe('ok');
|
||||
});
|
||||
|
||||
it('propagates the promise rejection when it rejects before the timeout', async () => {
|
||||
await expect(withTimeout(Promise.reject(new Error('boom')), 1000, 'x')).rejects.toThrow('boom');
|
||||
});
|
||||
|
||||
it('rejects with a labelled timeout error when the promise is too slow', async () => {
|
||||
vi.useFakeTimers();
|
||||
const slow = new Promise<string>(() => { /* never settles */ });
|
||||
const p = withTimeout(slow, 5000, 'pre-spawn pull');
|
||||
const assertion = expect(p).rejects.toThrow('pre-spawn pull timed out after 5000ms');
|
||||
await vi.advanceTimersByTimeAsync(5000);
|
||||
await assertion;
|
||||
});
|
||||
|
||||
it('clears the timer on a fast resolve so a later tick raises no timeout', async () => {
|
||||
vi.useFakeTimers();
|
||||
const p = withTimeout(Promise.resolve('fast'), 5000, 'x');
|
||||
await expect(p).resolves.toBe('fast');
|
||||
// Past the budget the cleared timer must not fire a stray rejection.
|
||||
await vi.advanceTimersByTimeAsync(10_000);
|
||||
});
|
||||
});
|
||||
|
|
@ -190,5 +190,6 @@
|
|||
"1.1.2": "1.5.0",
|
||||
"1.1.3": "1.5.0",
|
||||
"1.1.4": "1.5.0",
|
||||
"1.1.5": "1.5.0"
|
||||
"1.1.5": "1.5.0",
|
||||
"1.1.6": "1.5.0"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,6 +5,17 @@ CMD := ./cmd/obsidian-remote-server
|
|||
BIN_DIR := bin
|
||||
DIST_DIR := dist
|
||||
|
||||
# Fully static binaries: CGO off so the daemon links no libc and runs on
|
||||
# ANY Linux glibc version. Without this, the host-native build in `cross`
|
||||
# (linux/amd64 on a modern ubuntu-latest runner, glibc 2.35+) is the only
|
||||
# target Go builds with CGO auto-ENABLED — it links dynamically against the
|
||||
# runner's glibc and then dies on older hosts with
|
||||
# `libc.so.6: version GLIBC_2.34 not found`. The daemon never writes its
|
||||
# token, RPC times out, and the client falls back to SFTP. The cross
|
||||
# (non-host) targets already default to CGO off; exporting it here makes all
|
||||
# targets consistent and static. Mirrors plugin/scripts/build-server.mjs.
|
||||
export CGO_ENABLED := 0
|
||||
|
||||
.PHONY: build clean cross test fmt
|
||||
|
||||
build:
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ go 1.25.0
|
|||
|
||||
require (
|
||||
github.com/fsnotify/fsnotify v1.10.1
|
||||
golang.org/x/image v0.42.0
|
||||
golang.org/x/image v0.43.0
|
||||
)
|
||||
|
||||
require golang.org/x/sys v0.13.0 // indirect
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
github.com/fsnotify/fsnotify v1.10.1 h1:b0/UzAf9yR5rhf3RPm9gf3ehBPpf0oZKIjtpKrx59Ho=
|
||||
github.com/fsnotify/fsnotify v1.10.1/go.mod h1:TLheqan6HD6GBK6PrDWyDPBaEV8LspOxvPSjC+bVfgo=
|
||||
golang.org/x/image v0.42.0 h1:1gSs6ehNWXLbkHBIPcWztk3D/6aIA/8hauiAYtlodVY=
|
||||
golang.org/x/image v0.42.0/go.mod h1:rrpelvGFt+kLPAjPM4HeWPgrl0FtafueU//e5N0qk/Q=
|
||||
golang.org/x/image v0.43.0 h1:FLxcP4ec2350nTfOC8ysKtqYSIFbk/QGjw1ZHNP4tsY=
|
||||
golang.org/x/image v0.43.0/go.mod h1:rrpelvGFt+kLPAjPM4HeWPgrl0FtafueU//e5N0qk/Q=
|
||||
golang.org/x/sys v0.13.0 h1:Af8nKPmuFypiUBjVoU9V20FiaFXOcuZI21p0ycVYYGE=
|
||||
golang.org/x/sys v0.13.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
|
|
|
|||
|
|
@ -190,5 +190,6 @@
|
|||
"1.1.2": "1.5.0",
|
||||
"1.1.3": "1.5.0",
|
||||
"1.1.4": "1.5.0",
|
||||
"1.1.5": "1.5.0"
|
||||
"1.1.5": "1.5.0",
|
||||
"1.1.6": "1.5.0"
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue