mirror of
https://github.com/sotashimozono/obsidian-remote-ssh.git
synced 2026-07-22 06:52:07 +00:00
13 commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
46430471d8 |
fix(shadow): address #354 review — detect "{}" app.json first-run state
- CRITICAL: seedObsidianFirstRunState used `.trim() === ''`, which
passes Obsidian's ACTUAL first-run content (the literal `{}`) through
as "configured" — the deadlock the fix was supposed to close stayed
open. Replace with a parse-based predicate: seed when absent, blank,
unparseable, or an empty object/array; a real config (>=1 key/elem)
is still never clobbered.
- IMPORTANT: the bare `catch { appNeedsSeed = true }` swallowed EACCES
/ EISDIR as "absent" and would overwrite a file it merely failed to
read. Now only ENOENT means "absent"; other errors rethrow.
- IMPORTANT: core-plugins.json used `!existsSync` only — a zero-byte
or `[]` file still deadlocked (asymmetric with app.json). Both files
now go through the same needsFirstRunSeed predicate.
- Tests: add the `{}` app.json case (fails on the old code — the real
regression guard the zero-byte-only test never caught), the empty
`[]` core-plugins.json case, and a non-ENOENT (EISDIR) no-clobber
case. The reviewed "RED now GREEN" stale comment does not exist in
the file (nothing to remove).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
|
||
|
|
376705613f |
fix(shadow): seed non-empty app.json so first-time connect works
THE actual "can't connect to a new vault" bug, root-caused from a
live dev-vault capture:
- SSH + remotePath are correct (`SFTP channel open` in the source
log), but the freshly-bootstrapped shadow vault had an EMPTY
app.json and ZERO plugin console.log — i.e. remote-ssh never even
loaded in the shadow window.
- Obsidian treats an empty/absent app.json as "never configured" and
opens the vault in first-run / Restricted mode, so community
plugins (incl. remote-ssh) don't load → onLayoutReady →
runShadowStartup → runAutoConnect never run → and the
pullSharedObsidianConfig that *would* populate the real app.json
also never runs. Deadlock: the very first connect to any new
profile silently does nothing; only a manually-trusted (reused)
shadow vault works.
Fix: ShadowVaultBootstrap.seedObsidianFirstRunState — on bootstrap,
write a minimal non-empty app.json (`{promptDelete:false}`) and a
default core-plugins.json when absent/empty. Idempotent and
non-destructive (same contract as seedCommunityPlugins): a real
app.json later written by pullSharedObsidianConfig or by Obsidian is
never clobbered. This is exactly what e2e/helpers/vault-scaffold.ts
has always pre-written — the production bootstrap was the only path
missing it (which is also why the connect e2e never reproduced this;
tracked as a follow-up to bootstrap-via-the-real-path in the e2e).
Tests: 4 cases in ShadowVaultBootstrap.test.ts — first bootstrap
writes non-empty app.json + core-plugins.json; an empty app.json
(the field state) is re-seeded; a real app.json / custom
core-plugins.json is preserved across re-bootstrap.
Validation: tsc clean, eslint clean, ShadowVaultBootstrap.test.ts
34 passed. Beta bump 1.0.49 → 1.0.50-beta.0 (version-check; root
manifest.json + versions.json stay pinned per the beta rule).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
|
||
|
|
9458507c8f |
fix(shadow): push local shared-config changes to remote — close #342
#342: a settings change in the shadow vault was written to the local `.obsidian/app.json` but never reached the remote, so the next session's pull found nothing and the change "evaporated". Only the remote→local half (pullSharedObsidianConfig) existed. - ShadowVaultBootstrap.pushSharedObsidianConfig: symmetric to pull — reads each local shared file, JSON-validates (a half-written file must not clobber a healthy remote copy), writes verbatim to remote; {pushed,skipped,errored} so a failed push surfaces instead of silently losing settings. - SharedConfigWatcher: watches the shadow vault's local config dir and debounce-pushes divergent shared files. Catches BOTH adapter and direct-fs writes (the shadow `.obsidian/` is local disk). Echo- suppressed via markSynced so the pull's own writes / identical re-saves don't bounce back; a failed push is retried (not masked as synced). - main.ts: after the connect pull, seed markSynced with the pulled bytes, start the watcher (flush = pushSharedObsidianConfig via the patched adapter, Notice on errored); stop it on disconnect and on reconnect-failed. Tests: pushSharedObsidianConfig (verbatim / absent-skip / invalid- JSON-not-pushed / write-fail-errored) + SharedConfigWatcher (debounce, echo-suppress, diverge-pushes, burst-coalesce, non-shared-ignored, null-filename, stop-cancels, failed-push-retried). Full suite 1059 passed/1 skipped; typecheck + lint clean. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> |
||
|
|
4bfb6ea51f |
fix(sync): address PR #346 second-round review (N1-N6)
Second review pass confirmed the prior C1/I1-I5 fixes are sound (transport ordering verified against ConnectionManager, build anchor proven to hard-error on drift, C1 test confirmed not a false guard). This resolves the residual confidence>=80 findings. - N1: pullSharedObsidianConfig unlinks the tmp file when renameSync fails (cross-device / perms) instead of leaking an orphan .tmp, then rethrows into the skip path. - N5: the result now reports `errored` (remote HAD the file but it couldn't be pulled) distinctly from `skipped` (superset incl. benign absence). runAutoConnect surfaces a Notice when errored is non-empty so a transient SSH hiccup no longer silently leaves settings stale — a fresh vault (all-absent) stays quiet. - N2: reflectWrite (new-path insert) and reflectMkdir now log when insertOne returns null, so the documented "not silently dropped" contract holds for the insert path too, not just modify/rename. - N6: the reflect() swallow log includes the error class name so a systematic VaultModelBuilder bug (TypeError) is triageable apart from a transient listener fault. - N3: AdapterManager SFTP-policy test now asserts the wired reflector is an actual VaultModelBuilder instance (a no-op stub would have passed the old not-toBeNull check). - N4: added reconnecting-no-reflect coverage for rmdir + rename (C1 was only covered for remove) and a throwing-reflector test for rename (the #341-core op; previously only write was covered). ShadowVaultBootstrap tests assert the new errored vs skipped split. Out of scope (tracked as follow-up): copy() has no writer reflect — a pre-existing gap deliberately deferred, documented by its test. Validation: tsc --noEmit clean, eslint clean, vitest 1039 passed / 1 skipped. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> |
||
|
|
4e46a634ea |
fix(sync): address PR #346 multi-agent review (C1 + I1-I5 + advisories)
Follow-up to the review on PR #346. Resolves every confidence>=80 finding from the 6-agent review pass. Critical - C1: remove/rmdir/rename now reflect ONLY when the op actually hit the remote (guarded by !reconnecting), not when merely queued during a reconnect. A failed QueueReplayer drain no longer leaves the writer's vault model permanently diverged from remote. Important - I1: appendBinary now reflects (it writes via writeBuffer directly, so it was the one mutating op missing self-reflect); all reflect calls go through a central try/catch helper so a throwing reflector / vault listener can't surface as a spurious write failure. - I2: pullSharedObsidianConfig validates JSON before writing and writes atomically (tmp + rename) so a truncated remote file can't clobber a healthy local copy or tear on crash. - I3: build-time `satisfies`-style anchor pins VaultModelBuilder to WriterReflector (tests aren't typechecked; this is the only compile-time guard against reflect* signature drift). - I4: unit tests added — VaultModelBuilder.reflect* (incl. folder guard + idempotency), pullSharedObsidianConfig (missing / throw / corrupt / mkdir / atomic), SftpDataAdapter reflect wiring (incl. the C1 reconnecting-no-reflect guard), AdapterManager SFTP-vs-RPC policy (double-fire regression guard). - I5: AdapterManager.afterSwapClient re-applies the reflect policy after a reconnect transport swap (RPC->null, SFTP->builder); wired into the main.ts swapClient hook. Advisory - A1/A2: reflect* logs when the underlying model mutation no-ops (folder-at-path collision, renameOne/modifyOne returning false) so the #341-class divergence is diagnosable instead of silent. - A3: corrected stale/misleading comments (assertConfigRoundTrip file JSDoc, SftpDataAdapter reflector-field rationale, the rot-prone AdapterManager "still red by design" note, VaultModel- Builder dependency-edge phrasing). - A4: extracted makeWriterReflector() into harnessVault.ts; the 3 e2e suites no longer duplicate the builder-construction cast boilerplate. - A5: SHARED_OBSIDIAN_CONFIG_FILES is now an `as const` literal tuple. Validation: tsc --noEmit clean, eslint clean, vitest 1037 passed / 1 skipped (+22 new unit tests). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> |
||
|
|
34ea26255b
|
fix(tests): repair 6 CI failures + bump 1.0.36 (#278)
* fix(tests): repair 6 CI failures introduced by recent PR merges
ObsidianRegistry.test.ts + ShadowVaultBootstrap.test.ts:
Wrap `import * as fs from "fs"` in `vi.mock("fs", () => ({ ...actual }))`
so vitest creates a configurable namespace. The raw ESM `fs` namespace
is non-configurable in Node + Vitest 4, which made the existing
`vi.spyOn(fs, "writeFileSync"|"renameSync"|"symlinkSync")` calls throw
`Cannot redefine property` at test setup time. The mock re-exports
`actual` so all non-spied fs calls keep their real behaviour.
ShadowVaultBootstrap.test.ts (collectPendingPluginSuggestions):
Two tests asserted `data.pendingPluginSuggestions` equal `[]` when
source `community-plugins.json` is invalid/non-array. The implementation
(ShadowVaultBootstrap.ts:121-126) deliberately omits the key entirely
when `pending.length === 0` -- the absent key is the "no suggestions"
signal that ShadowStartupCoordinator relies on for its no-op fast path.
Aligned the test with the implementation contract.
ConflictResolver.test.ts:58:
`swapClient` test asserted `writeBinary` was called with a third
`undefined` arg. Other tests in the file (e.g. line 91) and the
source (ConflictResolver.ts:88,92,129) confirm the call passes only
two args. Removed the spurious trailing `undefined`.
* chore: bump version to 1.0.36
* ci: bump Node heap to 6 GiB to stop intermittent test-job OOMs
Vitest 4's default `forks` pool plus jsdom on every test file pushed
peak RSS just past Node's default ~4 GiB old-space ceiling once the
suite grew to ~70 files / ~1000 tests. The CI logs showed `FATAL ERROR:
Reached heap limit Allocation failed - JavaScript heap out of memory`
during ubuntu-latest, ubuntu-node22, macos-latest, and windows-latest
test jobs on the last several main-branch runs (it predates this PR;
the earlier 6 unit-test failures were just the first symptom).
Apply the same env to release.yml's test gate so a release tag push
does not flake on the same wall.
GitHub-hosted runners advertise 7+ GiB, so 6 GiB is safe headroom
without crowding the kernel/page-cache budget.
* fix(tests): SftpClient unit tests caused worker OOM via infinite BFS
Root cause of the CI test-job crashes:
tests/SftpClient.unit.test.ts > rmdir() > recursive: removes all
files and subdirs before root
mocked sftp.readdir with a path-agnostic `mockImplementation` that
returned `[file.md, sub (dir)]` for every call. SftpClient.rmdir's
recursive branch calls listRecursive, whose BFS then re-discovered
`sub` inside `/vault/sub`, then `/vault/sub/sub`, then
`/vault/sub/sub/sub`, ... -- growing the visited Set, the queue, and
the result array without bound until the worker hit
`FATAL ERROR: Reached heap limit`. Replaced with a path-aware mock so
only `/vault` has children; the BFS terminates after one level.
Also fixed `listRecursive() returns files from nested subdirectories
via BFS`: the assertion expected `['deep.md', ...]` but
`SftpClient.listRecursive` deliberately returns the full path under
root as relativePath (`sub/deep.md`), not the basename -- every
downstream consumer needs the unambiguous form.
Plus pass `--max-old-space-size=6144` to vitest workers via
`test.execArgv` so future legitimately-large suites have headroom
without needing per-job NODE_OPTIONS overrides. (In Vitest 4 the
top-level `poolOptions` was removed; both `pool` and `execArgv` are
now top-level keys inside `test:`.)
|
||
|
|
a0f1061def
|
test(ShadowVaultBootstrap): cover parse-failure and symlink-fallback branches (#270)
* test(RpcConnection): cover auth ok=false and server.info error branches
Adds two tests that exercise previously uncovered branches in
establishRpcConnection(): one where auth succeeds but the daemon
returns result.ok=false (→ AuthInvalid), and one where server.info
replies with an error envelope (→ connection cleaned up before rethrow).
Also extends ServerScript/startFakeDaemon to support both scenarios.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* test(ShadowVaultBootstrap): cover parse-failure and symlink-fallback branches
Adds 7 new tests for previously uncovered code paths:
seedCommunityPlugins():
- Rewrites shadow community-plugins.json when existing file is invalid JSON
- Rewrites when existing file is valid JSON but not an array
collectPendingPluginSuggestions():
- Returns [] when source list is not an array (valid JSON object)
- Returns [] when source list is invalid JSON
- Returns suggestion with sourceData=null when plugin data.json is invalid
readBaseDataJson():
- Starts from {} when shadow data.json is corrupt (no crash)
installPlugin():
- Falls back to copyFileSync when symlinkSync throws EPERM
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
---------
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
|
||
|
|
c71a01ad03 |
feat(plugin): selection modal for source-vault plugin install instead of auto-install (0.4.18)
Bootstrap no longer auto-seeds the shadow vault's `community-plugins.json`
with the source's enabled list — that behaviour caused every source plugin
to start downloading from the marketplace right after Obsidian's "trust
this vault" prompt, which felt like the plugin acting on its own.
New flow:
1. On first bootstrap, ShadowVaultBootstrap captures source's enabled
community plugins (excluding `remote-ssh`) plus each plugin's
source-side `data.json` snapshot, into a new
`pendingPluginSuggestions` field on the shadow's data.json.
2. The shadow window's `runShadowStartup` checks that field and, if
non-empty, surfaces `PendingPluginsModal`:
- checkbox list of suggested plugins (default all checked),
- "copy each plugin's data.json from source" toggle (default off),
- [Ask later] [Skip — install nothing] [Install selected] buttons.
3. On "install": only the user-ticked subset goes through
`PluginMarketplaceInstaller.installMissing`, then if the toggle is
on, each successfully-installed plugin's `data.json` is seeded
from the captured source snapshot.
4. "Skip" / "install" clear `pendingPluginSuggestions` so the modal
doesn't return on next reload; "Ask later" leaves it in place.
The pre-existing `installMissingShadowPlugins` path stays as a safety net
for re-bootstraps where the user has accumulated a real
`community-plugins.json` and a binary went missing.
5 new ShadowVaultBootstrap tests cover the snapshot collection + the
"first bootstrap only" gating; all 332 tests pass.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||
|
|
0629db2ebb |
feat(plugin): shadow vault installs missing plugins from Obsidian marketplace instead of copying from source (0.4.17)
Replaces the source-binary-copy approach (PR #72) with a marketplace-download approach. The shadow vault no longer needs the source vault to have plugin binaries on disk — it just needs the source's community-plugins.json (id list) and downloads each missing binary from Obsidian's community plugin registry on shadow window startup. Why marketplace download: - source-independence: works whether or not source vault has each plugin's files (PR #72 had to skip-with-warning when a listed plugin was uninstalled at source — `obsidian-icon-folder` and `obsidian-minimal-settings` were the visible cases on the dev vault). - latest version: shadow always gets the marketplace-latest release; PR #72 froze at whatever stale version source had. - smaller bootstrap: no recursive copy of N plugin dirs at Connect time. - matches Obsidian's own community plugin browser flow: the UI button calls the same `app.plugins.installPlugin` we now do. New module: src/shadow/PluginMarketplaceInstaller.ts Pluggable URL fetcher + Obsidian PluginsApi shim. installMissing(ids): 1. computes missing = wanted - already-installed (from app.plugins.manifests). 2. fetches Obsidian's master community-plugins.json (id -> owner/repo mapping). 3. for each missing id: fetches <repo>/HEAD/manifest.json (gives version + manifest object), then calls app.plugins.installPlugin(repo, version, manifest), then app.plugins.enablePluginAndSave(id). 4. returns a per-id report (installed / skipped / failed) so the caller can Notice + log the outcome. Failures are per-id and tolerant: a missing master entry, a manifest 404, an id mismatch (defensive against repo hijack), all surface as one failed entry without aborting the rest of the batch. Master-list fetch failure (offline, GitHub down) marks every missing id as failed but the auto-connect step still runs. ShadowVaultBootstrap changes: - Deletes seedThirdPartyPluginDirs (PR #72's recursive copy). sourcePluginDir is now used only for our own remote-ssh per-file install — third-party plugin BINARIES are no longer source-derived. seedCommunityPlugins (the LIST seeding) stays: shadow still inherits source's enabled plugin set. - Test reframed: "does NOT copy source plugin binaries" — asserts shadow's plugins/dataview/ does NOT exist post- bootstrap (the marketplace installer is the only path that materialises it now). main.ts changes: - onLayoutReady now dispatches to runShadowStartup, which runs installMissingShadowPlugins first and then runAutoConnect. The Reconnect command still calls runAutoConnect directly — no need to re-fetch the master plugin list every reconnect. - installMissingShadowPlugins reads community-plugins.json, builds the installer with Obsidian's `requestUrl` as the fetcher (cross-origin friendly), and surfaces a Notice summarising what landed. Tests: 322 -> 330, all green; tsc clean. - 10 new PluginMarketplaceInstaller tests cover: skip when everything present, install missing batch (master + per- plugin manifests fetched, installPlugin + enablePluginAndSave called in the right order with the right args), mixed skipped/installed, missing-from-master = per-id failure, manifest fetch error tolerance, manifest id mismatch rejection (defensive against hijacked repos), master fetch failure marks all missing as failed (no installs attempted), malformed master JSON rejected, manifest missing required fields rejected, custom URL overrides for fixture wiring. - ShadowVaultBootstrap test "seeds third-party plugin dirs" replaced with negation: "does NOT copy source plugin binaries". Other 10 tests unchanged. Manual smoke (after merge): 1. Delete shadow vault dir to force fresh bootstrap: rm -rf ~/.obsidian-remote/vaults/<profile-id>/ 2. Source vault: Settings -> Connect on the active profile. 3. Shadow window opens. Watch Notices: first an "installed N plugins from marketplace" Notice (~30s for ~15 plugins on a typical SSH RTT), then the auto-connect summary. 4. Shadow's .obsidian/plugins/ should now contain each id from community-plugins.json, with main.js / manifest.json downloaded fresh from each repo's release. 5. Plugins should activate without source needing to have them installed. Bump 0.4.16 -> 0.4.17. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|
|
3af27579b1 |
feat(plugin): shadow vault inherits source vault's enabled community plugins on first bootstrap (0.4.16)
Phase 6C prep: today the shadow vault opens with only `remote-ssh`
enabled, so smoke-testing other plugins (Dataview, Templater,
Excalidraw, ...) requires installing each one manually inside the
shadow window. With 11 plugins in the matrix that's a lot of
clicks per session. This PR closes the gap by treating the source
vault's enabled plugin set as the shadow's starting state.
Bootstrap behaviour adds two pieces:
ShadowVaultBootstrap.seedCommunityPlugins(configDir)
First bootstrap (no shadow community-plugins.json yet): copy
the source vault's community-plugins.json verbatim, then
ensure remote-ssh is in the list. Re-bootstrap: leave the
shadow's existing list alone — the user can enable/disable
plugins inside the shadow window without us reverting them.
ShadowVaultBootstrap.seedThirdPartyPluginDirs(layout, ids)
For each plugin id in the seeded list other than remote-ssh,
recursively copy the source plugin's directory into the
shadow vault. Each copy is independent of source (no
symlinks for these — per-plugin data.json must stay
separate, same lesson PR #65 learned for our own plugin).
Idempotent: skips dirs that already exist in shadow so
re-bootstrap doesn't blow away the shadow's accumulated
per-plugin settings. Skips ids whose source dir is missing
(logs a warning; Obsidian skips the missing plugin
gracefully on its side).
The two new helpers live alongside the existing per-file install
of remote-ssh and the data.json merge logic, so the bootstrap
flow now produces a shadow vault that opens with the user's
familiar plugin set already enabled — exactly what Phase 6C
needs to smoke each plugin against the remote.
Tests: 315 -> 322, all green.
- Seeds community-plugins.json from source on first bootstrap.
- Always includes remote-ssh in shadow list even when source
list omits it.
- Falls back to [remote-ssh] only when source has no
community-plugins.json.
- Preserves shadow community-plugins.json on re-bootstrap (does
not reset to source's).
- Seeds third-party plugin dirs from source on first bootstrap
(per-plugin data.json copied so shadow starts with user's
preferences but writes don't bleed back).
- Does not overwrite existing shadow plugin dirs on
re-bootstrap.
- Skips plugins listed in community-plugins.json whose source
dir is missing.
Test fixture refactor: makeScratch() now produces a vault-shaped
source (root/source-vault/.obsidian/plugins/remote-ssh) instead
of a flat plugin dir, so tests can stage source's
community-plugins.json + third-party plugin dirs the same way
production sees them.
Manual smoke (after merge):
1. Delete the existing shadow vault dir for the test profile so
bootstrap runs fresh:
rm -rf ~/.obsidian-remote/vaults/<profile-id>/
2. Reload the source vault's plugin (Settings -> Community
plugins -> toggle).
3. Settings -> Connect on the active profile.
4. New shadow window opens. Settings -> Community plugins inside
it should now show the source vault's plugin set already
listed (Dataview, Templater, Excalidraw, ...). Each plugin's
data.json reflects the source's settings as a starting point
but lives in the shadow vault from now on.
5. Phase 6C smoke from this point is just "open the matrix and
try each plugin" — no per-plugin install detour.
Bump 0.4.15 -> 0.4.16.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||
|
|
cdc1fdf019 |
feat(plugin): Phase 4 — shadow window auto-connects on layout-ready and runs VaultModelBuilder (0.4.11)
The headline UX moment: opening a shadow vault from
Settings → Connect now Just Works end-to-end. The new window
loads, the plugin sees the autoConnectProfileId marker its
bootstrap wrote, connects to the remote, patches the adapter,
walks the remote tree, and populates File Explorer — no further
user input.
Mechanics:
src/types.ts + src/constants.ts
PluginSettings.autoConnectProfileId is optional; default null.
Persistent across reloads so closing/reopening the shadow
window picks up the connection again.
src/main.ts
onLayoutReady hook: if autoConnectProfileId is set, run the
auto-connect flow. Done as a workspace event rather than at
onload to avoid racing with the in-progress vault model build.
runAutoConnect(tag): find profile, defensively disconnect any
in-flight session, connectProfile(profile, skipReconcile: true)
— shadow vault is empty so legacy reconcileVaultRoot would
just generate noise — then populateVaultFromRemote so File
Explorer renders the remote tree, then a final summary Notice.
populateVaultFromRemote(label): public method extracted from
the Phase 1 debug command so both that command and the new
auto-connect path share one walking + building implementation.
Reconnect command: gated on the shadow marker, lets the user
manually retry after a transient connect failure without
closing+reopening the window.
patchAdapter / connectProfile gain { skipReconcile?: boolean }
purely to drive the legacy reconcileVaultRoot bypass; Phase 5
deletes both the flag and the legacy path.
src/shadow/ShadowVaultBootstrap.ts
data.json strategy switches from blanket overwrite to MERGE
via readBaseDataJson:
- if shadow data.json exists, parse + use as base
(preserves accumulated state — host keys collected via
TOFU on prior shadow connects, secrets, etc.)
- else, seed from source vault data.json so first-ever
shadow connect can re-use already-trusted host keys
instead of TOFU-prompting
- bootstrap-managed fields always overwrite the merge base
- parse failures fall back to start-fresh so a malformed
file cannot brick bootstrap
Without this, every Settings → Connect click on the same
profile would wipe the shadow hostKeyStore and force a fresh
TOFU prompt on the next auto-connect.
Tests: 298 → 300, all green; tsc clean.
- Shadow inherits source data.json on first bootstrap.
- Shadow-side accumulated state preserved on re-bootstrap.
- Earlier source-untouched regression updated for the merge
behaviour.
Out of scope (still deferred):
Phase 5: delete reconcileVaultRoot, autoPatchAdapter,
debugDumpVaultAdapterAPI, the Disconnect legacy branch, and
the skipReconcile flag once the legacy path is gone.
Phase 6: full smoke + plugin-compatibility re-verified + plan
file rewrite.
Manual smoke (after merge):
1. Reload plugin in dev vault.
2. Settings → Remote SSH → click Connect on the active profile.
3. New Obsidian window opens at the shadow vault path.
4. Inside that window, no user input: connect notice, then a
few seconds later "Remote SSH: <profile> ready — Nf + Md
built, ...".
5. File Explorer in the shadow window shows the remote tree.
6. Click a remote file -> opens through the patched adapter.
7. Close + reopen shadow window via Obsidian vault picker ->
auto-connect runs again on its own.
8. Source vault data.json untouched throughout.
Bump 0.4.10 -> 0.4.11.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||
|
|
ef313ec5ed |
fix(plugin): shadow vault installs plugin per-file so source vault's data.json is never clobbered (0.4.9)
Phase 2 (PR #64) shipped with installPlugin symlinking the WHOLE remote-ssh plugin directory from the source vault into the shadow vault. That worked for code reuse, but the very first run revealed the catastrophic side-effect: when the shadow vault's plugin writes its own per-vault data.json, the write follows the symlink and overwrites the source vault's data.json — taking hostKeyStore, secrets, enableDebugLog, and every other setting with it. (Manually verified on the dev vault during smoke; data was restorable thanks to the in-session log of the original contents.) Fix: install per-file, not whole-dir. pluginDir is now a real directory. main.js / manifest.json / styles.css are symlinked individually (or copied on platforms where symlink fails — Windows 'junction' works without admin for the dir-typed entries). server-bin/ is symlinked as a directory. data.json is NEVER touched by installPlugin — the caller writes the per-vault data.json into pluginDir as a real file after install completes. Stale state cleanup: if pluginDir is already a whole-dir symlink from a previous (buggy) install, install unlinks it (DOES NOT rmSync — that would follow the link and recursively delete the source). On real-dir state, individual stale entries are removed before re-install so a plugin update lands cleanly. Tests: 296 → 298, all green. Two new regressions: - source plugin's data.json is never touched by install, even on re-bootstrap (writes a sentinel data.json into the source dir, runs bootstrap twice, asserts the sentinel survives both passes AND the shadow vault has its own bootstrap-written data.json without the source's hostKeyStore-shaped key) - a stale whole-dir symlink at pluginDir is unlinked, not followed (creates a symlink-to-source plugin dir, places a canary file in source, runs bootstrap, asserts the canary survives and pluginDir is now a real directory) Bump 0.4.8 → 0.4.9. Out of band: the previously-bootstrapped shadow vault on the dev machine had the buggy whole-dir symlink. Unlinked it manually before this PR so further opens of that shadow vault can't hit the bug — the next debug-open-shadow-vault run will rebuild it correctly under this PR's per-file install. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|
|
1cea91270f |
feat(plugin): Phase 2 — ShadowVaultBootstrap + Manager + WindowSpawner + ObsidianRegistry (0.4.8)
Lands the Phase 2 surface from
docs/architecture-shadow-vault.md §3: the local-disk + URL-handler
plumbing that lets us open a shadow vault for a profile in a new
Obsidian window. No connect happens here; the auto-connect inside
the new window is Phase 4.
What's in:
src/shadow/ObsidianRegistry.ts
Reads / atomically rewrites obsidian.json (Obsidian's per-user
list of known vaults) so Obsidian's `obsidian://open?path=…`
URL handler accepts the shadow vault path directly instead of
falling back to its picker UI. Per-OS path resolution
(Windows %APPDATA%, macOS ~/Library/Application Support, Linux
$XDG_CONFIG_HOME) — never hardcodes a specific user. register()
is idempotent and case-insensitive on Windows.
src/shadow/ShadowVaultBootstrap.ts
Materialises ~/.obsidian-remote/vaults/<profile-id>/ with
.obsidian/community-plugins.json (["remote-ssh"]),
.obsidian/plugins/remote-ssh/ as a symlink (junction on
Windows) or recursive copy fallback, and data.json containing
the profile config + an `autoConnectProfileId` marker the
shadow window will read in Phase 4. Re-running for the same
profile refreshes the plugin install (so dev iterations land
immediately) and rewrites data.json but never touches files
Obsidian itself created.
src/shadow/WindowSpawner.ts
Builds the `obsidian://open?path=…` URL and fires it via
window.open(). Pluggable opener so tests can record without
actually launching browsers.
src/shadow/ShadowVaultManager.ts
Top-level orchestrator: bootstrap → spawn.
src/main.ts
Hidden command "Remote SSH: Debug: open shadow vault for
active profile (Phase 2 POC)". Visible only when an active
profile is selected (no need to be connected; this is a
local-disk operation).
What's NOT in (deferred):
- Settings UI rewires of the Connect button — Phase 3.
- Auto-connect-on-startup inside the shadow window — Phase 4.
(data.json is written with the marker; nothing reads it yet.)
- Removing the legacy reconcileVaultRoot / autoPatchAdapter
paths — Phase 5.
- The shadow data.json is intentionally minimal for Phase 2:
profiles + activeProfileId + autoConnectProfileId. Phase 4
will add hostKeyStore / secrets / clientId / ... so the
auto-connect actually has everything it needs.
Tests: 274 → 296, all green. New unit tests cover:
- ObsidianRegistry: per-OS default path; reads preserve unknown
top-level keys (adblock, cli, …); register adds a hex16 id
with current ts; idempotent on the same path; preserves
untouched vault entries; canonicalises trailing slashes;
case-insensitive on Windows; atomic write leaves no .tmp file.
- ShadowVaultBootstrap: layoutFor returns the expected paths;
sanitises ids that contain `..` / `/` so the vault dir stays
inside baseDir; bootstrap creates dir + plugin install +
community-plugins.json + data.json + registry entry; idempotent;
writes ALL profiles into data.json; refreshes plugin install
on re-bootstrap.
- WindowSpawner: builds `obsidian://open?path=…` with proper
URL encoding; returns the URL it fired.
- ShadowVaultManager: bootstrap before spawn; does not spawn
when bootstrap throws.
Manual smoke (after merge):
1. Reload plugin in dev vault.
2. Make sure a profile is active (Settings → Remote SSH).
3. Command palette → "Remote SSH: Debug: open shadow vault…".
4. Notice reports "opened shadow vault for <name>
(symlink|copy, newly registered)".
5. New Obsidian window opens at
~/.obsidian-remote/vaults/<profile-id>/, empty, with
Remote SSH plugin pre-installed (still disabled on first
load — Obsidian's default for community plugins; user
enables once).
6. ~/.obsidian-remote/vaults/<profile-id>/ on disk has the
expected layout; obsidian.json has a new vault entry with
a fresh hex16 id.
Bump 0.4.7 → 0.4.8.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|