Skip to content

feat: headless CLI for vault-to-CRDT sync#16

Open
enieuwy wants to merge 5 commits intokavinsood:mainfrom
enieuwy:feat/headless-cli-fresh
Open

feat: headless CLI for vault-to-CRDT sync#16
enieuwy wants to merge 5 commits intokavinsood:mainfrom
enieuwy:feat/headless-cli-fresh

Conversation

@enieuwy
Copy link
Copy Markdown

@enieuwy enieuwy commented Apr 13, 2026

Summary

Adds a headless Node.js CLI that mirrors a Markdown vault directory to a YAOS CRDT room without Obsidian. Enables server-side vault sync, automation, and headless daemon deployments.

New: packages/cli/

A complete CLI package under npm workspaces:

  • Config resolution (config.ts) — layered precedence: CLI flags > env vars > config file (~/.config/yaos/cli.json). Validates required fields, rejects invalid externalEditPolicy values, expands ~ in vault paths.
  • Daemon mode (index.ts) — commander-based CLI with sync, daemon, and status commands. daemon runs a long-lived watcher process with graceful shutdown.
  • Disk mirror (nodeDiskMirror.ts) — chokidar-based file watcher that syncs disk to CRDT bidirectionally. Handles renames, batch writes (Promise.allSettled), ENOENT races, write suppression for remote-originated changes.
  • Vault sync client (nodeVaultSync.ts) — WebSocket-based CRDT sync using ws polyfill. noop persistence (headless v1 skips IndexedDB). Reconnection handling with generation tracking to trigger reconciliation on reconnect.
  • Tests (tests/config.test.ts) — config precedence and validation tests.

Changed: Core plugin (minimal impact)

  • normalizeVaultPath (new) — extracted Obsidian's normalizePath() into a shared utility usable outside the plugin. Identical behavior: backslash to forward slash, collapse double slashes, strip leading ./ and /, strip trailing /.
  • vaultSync.ts — replaced normalizePath (Obsidian API) with normalizeVaultPath (shared). No behavioral change.
  • exclude.ts — uses normalizeVaultPath instead of inline normalizePrefix. Preserves trailing slash in user exclude patterns (e.g. templates/ will not match templates-old.md).
  • snapshotClient.ts — import path change only.
  • package.json — added "workspaces": ["packages/*"].

Security and robustness (code review fixes)

  • Path traversal rejection in toAbsolutePath().. segments in CRDT paths are caught before filesystem operations
  • Dot-directory watching fix — directories like notes.v2 are no longer incorrectly pruned by chokidar
  • Strict integer parsing for CLI options — rejects 1e3, 2.5, etc.
  • Empty string filtering from CLI overrides — prevents blanking config values
  • Reconnect callback gated until startup state is initialized
  • Rename fallback writes new file before deleting old

Test plan

  • tsc --noEmit passes (root + packages/cli)
  • esbuild bundles (740kb)
  • Config tests pass (2/2)
  • Deployed as Docker container, daemon running with connected: true, watcherReady: true, mode: authoritative
  • Verify bidirectional sync: create/rename/delete files on disk, confirm CRDT propagation
  • Verify Obsidian plugin still works: edit on Mac/iOS, confirm sync to headless vault
  • Verify exclude patterns work correctly (trailing slash preserved)

enieuwy added 5 commits April 12, 2026 11:03
Two bugs prevented the filesystem watcher from working:

1. shouldIgnoreNormalizedPath treated null-stats paths as files. When
   chokidar calls _isIgnored for the root directory without stats, the
   path was checked against isMarkdownSyncable (returns false for
   directory names not ending in .md), causing the entire tree to be
   pruned. Fix: when stats are null, only ignore paths that are
   definitively non-markdown files (have an extension but not .md).

2. Chokidar's internal _isIgnored calls .map() on the ignored option.
   When ignored is a bare function, .map() throws TypeError (functions
   don't have .map), which is silently caught — the watcher starts but
   watches nothing. Wrapping in an array preserves the function through
   normalizeIgnored's type check.

Also upgraded chokidar from 4.0.3 to 5.0.0.
P1: Add trailing slash removal to normalizeVaultPath to match Obsidian
normalizePath() semantics, preventing CRDT key mismatches between plugin
and headless clients.

P1: Add requireRuntimeConfig() to status command so host/token/vaultId
are validated before creating a sync client.

P1: Move startMapObservers() before reconcileFromDisk() so remote edits
arriving during startup disk scan are immediately mirrored. Safe because
observers filter local origins (ORIGIN_SEED).

P2: Resolve relative vault dir to absolute path in resolveCliConfig so
YAOS_DIR/config values don't depend on process working directory.

P2: Reject invalid externalEditPolicy values instead of silently
defaulting to the most permissive policy (always).

P2: Change createNodeVaultSync to accept RuntimeCliConfig instead of
ResolvedCliConfig, making the type system enforce that connection
settings are validated before sync client construction.

Also includes prior uncommitted fixes: Promise.allSettled for batch
writes, fs.rm cleanup on rename fallback, ENOENT handling during vault
walk, early reconnection handler installation, reconcileInFlight flag,
commander exitOverride, and prepare/dev scripts.
P1 fixes:
- nodeDiskMirror: reject path traversal via .. segments in toAbsolutePath()
- nodeDiskMirror: don't prune directories when chokidar has no stats
- exclude: preserve trailing slash in user exclude patterns

P2 fixes:
- config: expand ~ in vault directory before resolve
- cli: use strict regex for positive integer parsing
- config: filter empty strings from CLI overrides in pickDefined
- nodeVaultSync: gate reconnect callback until startup state initialized
- nodeDiskMirror: write new file before deleting old in rename fallback
@enieuwy enieuwy marked this pull request as draft April 13, 2026 17:27
@enieuwy enieuwy marked this pull request as ready for review April 13, 2026 17:27
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant