feat(obsidian): headless CLI vault ingestion (hindsight-obsidian-sync) - #3179
Merged
Conversation
…sport seam Introduce a Transport abstraction so the HTTP client no longer imports `obsidian` directly. The plugin injects an obsidian-transport (requestUrl, to escape the renderer CORS sandbox); a headless CLI will inject a fetch-based transport. This lets both frontends share one client and one set of request semantics instead of maintaining divergent copies. No behavior change: existing client tests pass unchanged after switching from mocking requestUrl to injecting a fake transport (same request shape).
Add a headless second frontend over the shared SyncEngine so a vault can be ingested into Hindsight from an always-on server with no Obsidian desktop app (issue #3128). Because it drives the same engine as the plugin, it produces identical document ids, scope tags, and prune-ownership — the two ingesters never fight or duplicate. New Node modules (src/node/): - fs-vault.ts — filesystem SyncVault (recursive *.md walk, POSIX-relative paths, ms mtime/ctime, skips dotfolders) - fetch-transport.ts — fetch-based Transport for the client (no renderer CORS) - json-index.ts — sync index persisted to JSON, atomic write; defaults to ~/.hindsight/obsidian/<vault>.json (outside the vault so Obsidian Sync never propagates it) - cli.ts / cli-bin.ts — `hindsight-obsidian-sync reconcile --vault <p> --bank <id>` with env fallbacks, --include/--exclude/--prefix-doc-id, and a chokidar --watch mode Packaging: second esbuild target builds dist/cli.js (node, shebang); package.json gains the bin, a files allowlist, and chokidar. README documents the CLI, the out-of-vault index, and the shared-scope constraint when running both frontends against one bank. Tests (33 new, 79 total): FsVault, json-index, fetch transport, CLI arg parsing + watch handlers + a full runCli path (fetch mocked), and a full-stack reconcile suite over a real temp vault (create/update/skip/delete/rename/exclude/prefix/ prune-ownership) plus a parity check that the filesystem and in-memory (plugin) vaults emit byte-identical retain requests.
Add higher-fidelity tests beyond the mocked units, and refactor watch mode to be testable: - Extract watchVault() from startWatch() so a test can drive a REAL chokidar watcher over a temp vault and then close it. New watch.spec.ts asserts create/modify/delete on disk flow through to the engine and non-markdown is ignored (polling + tight awaitWriteFinish for CI determinism). - e2e-http.spec.ts runs runCli against a real node:http server — the full FsVault → SyncEngine → HindsightClient → fetch → sockets path with nothing mocked: asserts the retain POST (bearer token, document id, scope tags) and a real DELETE on prune, plus exit-code 1 when the server is unreachable. - fetch-transport: full-stack HindsightClient error propagation + health() true/false, a fetch-rejection case, and a cross-transport parity test proving the same call yields an identical request under two transports. - reconcile: frontmatter tags + created-date timestamp + vault metadata, empty-body skip, and includeFolders-only scoping. - cli: --exclude threaded end-to-end through runCli. Pin chokidar to ^4.0.3 (bundles its own TS types). 90 tests pass (+11).
… add CLI to docs-site page - Parity test pinned mtime via utimes but ctime/birthtime can't be set and differs across OSes (macOS clamps birthtime to a past mtime, Linux doesn't), so the FS vault's created-date tags diverged from the memory vault's on Linux CI. Give both notes a frontmatter created: date so the tags come from the note. - Add a 'Headless / CLI ingestion' section to the public docs-site page hindsight-docs/docs-integrations/obsidian.md (mirrors the package README).
benfrank241
force-pushed
the
feat/obsidian-headless-cli
branch
from
August 4, 2026 19:49
843255d to
3afa5d7
Compare
6 tasks
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #3128.
What & why
The Obsidian integration only runs inside the desktop app (
isDesktopOnly: true), so a vault hosted on a headless server (kept current on disk by Obsidian Sync) can't be ingested without either running the full Electron app hidden or hand-rolling ingestion against the HTTP API and losing the plugin's carefully-designed behavior. @0xble asked for a supported headless path.The codebase was already set up for this:
SyncEngineis written against the minimalSyncVault/SyncFileinterfaces and dependency-injects the vault, sync index, andpersistwriter — the only Obsidian coupling wasrequestUrlin the client. So this is a thin second frontend over the same engine, which means a server-synced vault produces identical document ids, scope tags, and prune-ownership as the plugin — the two ingesters never fight or duplicate.How it's built (3 commits)
refactor: transport seam. Introduce aTransportabstraction soHindsightClientno longer importsobsidian. The plugin injects anobsidianTransport(requestUrl, to escape the renderer CORS sandbox); the CLI injects afetchtransport. One client, one set of request semantics — no divergent copies. Existing client tests pass unchanged (they switch from mockingrequestUrlto injecting a fake transport).feat: the Node frontend (src/node/):fs-vault.ts— filesystemSyncVault(recursive*.md, POSIX-relative paths, msmtime/ctime, skips dotfolders)fetch-transport.ts—fetch-basedTransportjson-index.ts— sync index persisted to JSON, atomic write; defaults to~/.hindsight/obsidian/<vault>.json(outside the vault, so Obsidian Sync never propagates it)cli.ts/cli-bin.ts—hindsight-obsidian-sync reconcile --vault <p> --bank <id>with env fallbacks,--include/--exclude/--prefix-doc-id, and a chokidar--watchmodedist/cli.js(node, shebang);package.jsongains thebin, afilesallowlist, andchokidar. The plugin build (main.js) and the release mirror are untouched.test: real-framework + e2e coverage (see below).Usage
Testing — 90 tests (was 46)
Deterministic units plus real-framework and end-to-end suites:
watch.spec.ts) — drives an actual watcher over a temp vault: create → ingest, modify → re-ingest, delete → prune, non-markdown ignored.e2e-http.spec.ts) —runCliagainst a livenode:httpserver, nothing mocked: asserts the retainPOST(bearer token, document id, scope tags), a realDELETEon prune, and exit-1 when unreachable.prefixDocId, prune-ownership, frontmatter tags + created-date timestamp, empty-body skip.health(), fetch rejection, cross-transport request parity.tsc --noEmitclean; both bundles build. The existingtest-obsidian-integrationCI job (install → tsc → build → test) covers all of it.Notes
~/.hindsight/obsidian/<vault>.jsonindex location is deliberately out-of-vault; the README documents the shared-scope constraint when running the CLI and plugin against the same bank.@vectorize-io/hindsight-obsidianis already npm-published on release (the release workflow runsnpm run build— which now also emitsdist/cli.js— andnpm cibefore publish), sonpm install -ggets a workinghindsight-obsidian-syncbin with chokidar.Thanks to @0xble for the well-scoped request and the code reading that made the direction obvious.