Commit graph

13 commits

Author SHA1 Message Date
Souta
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>
2026-05-17 23:50:36 +09:00
Souta
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>
2026-05-17 23:50:36 +09:00
Souta
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>
2026-05-17 23:35:48 +09:00
Souta
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>
2026-05-17 15:00:28 +09:00
Souta
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>
2026-05-17 14:48:26 +09:00
sotashimozono
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:`.)
2026-05-09 23:26:01 +09:00
sotashimozono
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>
2026-05-09 22:16:35 +09:00
Souta
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>
2026-04-28 10:24:21 +09:00
Souta
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>
2026-04-28 09:52:03 +09:00
Souta
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>
2026-04-28 09:17:17 +09:00
Souta
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>
2026-04-28 08:06:36 +09:00
Souta
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>
2026-04-28 07:40:43 +09:00
Souta
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>
2026-04-28 00:57:14 +09:00