obsyncd
v1.0.0
Published
A tool for synchronizing Obsidian vaults across devices via Dropbox, Google Drive, or any shared folder
Maintainers
Readme
obsyncd
A bidirectional synchronization tool for Obsidian vaults with conflict detection and resolution. Sync your vaults between computers via Dropbox, Google Drive, iCloud, or any shared folder.
Quick Start
# Install globally
npm install -g obsyncd
# Initialize your vault
cd ~/Obsidian/MyVault
obsyncd init
# Sync to Dropbox/Google Drive folder
obsyncd sync -s ~/Obsidian/MyVault -d ~/Dropbox/ObsidianSync --remote
# Continuous sync (watch mode)
obsyncd sync -s ~/Obsidian/MyVault -d ~/Dropbox/ObsidianSync --remote --watchTable of Contents
User Documentation
Installation
# Install globally via npm
npm install -g obsyncd
# Or run directly with npx
npx obsyncd --helpSyncing via Dropbox/Google Drive
obsyncd makes it easy to sync your Obsidian vaults between computers using any cloud storage service (Dropbox, Google Drive, iCloud, OneDrive, etc.).
Why not just put the vault in Dropbox directly?
Syncing an Obsidian vault directly with cloud storage causes problems:
- Constant conflicts - Obsidian writes to workspace files constantly
- Corrupted vaults - Simultaneous edits on different devices cause issues
- "Conflicted copy" files - You end up with duplicates everywhere
obsyncd solves this by:
- Syncing only your notes (not Obsidian's internal files)
- Letting you control when sync happens
- Smart conflict resolution when the same file is edited on both sides
Architecture
Work Computer Cloud Personal Computer
+------------------+ +-------------+ +------------------+
| ~/Obsidian/ | | Dropbox | | ~/Obsidian/ |
| MyVault/ |<------->| ObsidianSync|<------->| MyVault/ |
| (Obsidian vault) | obsyncd| (sync folder| Dropbox | (Obsidian vault) |
+------------------+ +-------------+ +------------------+Setup (One-time per computer)
On your first computer (e.g., work):
# 1. Install obsyncd
npm install -g obsyncd
# 2. Initialize your vault
cd ~/Obsidian/MyVault
obsyncd init
# 3. Create a sync folder in Dropbox/Google Drive
mkdir -p ~/Dropbox/ObsidianSync
# 4. Push your vault to the sync folder
obsyncd sync -s ~/Obsidian/MyVault -d ~/Dropbox/ObsidianSync --remoteOn your second computer (e.g., personal):
# 1. Install obsyncd
npm install -g obsyncd
# 2. Create your local vault folder
mkdir -p ~/Obsidian/MyVault
# 3. Wait for Dropbox to sync, then pull
obsyncd sync -s ~/Dropbox/ObsidianSync -d ~/Obsidian/MyVault --remote
# 4. Initialize the vault
cd ~/Obsidian/MyVault
obsyncd initDaily Workflow
Option A: Manual sync
# Before starting work - pull latest changes
obsyncd sync -s ~/Dropbox/ObsidianSync -d ~/Obsidian/MyVault --remote
# After finishing work - push your changes
obsyncd sync -s ~/Obsidian/MyVault -d ~/Dropbox/ObsidianSync --remoteOption B: Continuous sync (watch mode)
# Start watch mode - syncs automatically on file changes
obsyncd sync -s ~/Obsidian/MyVault -d ~/Dropbox/ObsidianSync --remote --watch
# Press Ctrl+C to stopCommands
obsyncd init
Initialize a vault for syncing. Creates .obsync.json config and .obsync/sync-manifest.json.
obsyncd init # Current directory
obsyncd init --path ~/MyVault # Specific pathobsyncd sync
Synchronize files between source and destination.
obsyncd sync -s <source> -d <destination> [options]
Options:
-s, --source <path> Source vault path
-d, --destination <path> Destination path
-r, --remote Allow non-vault destination (for Dropbox folders)
-c, --conflict <strategy> Conflict resolution: newest|source|destination|skip
-w, --watch Continuous sync mode
--dry-run Show what would sync without making changesobsyncd status
Show sync status of a vault.
obsyncd status # Current directory
obsyncd status --path ~/MyVault # Specific pathConflict Resolution
When the same file is modified on both sides, obsyncd can resolve conflicts automatically:
| Strategy | Description |
|----------|-------------|
| newest (default) | Use the version with the most recent modification time |
| source | Always use the source version |
| destination | Always use the destination version |
| skip | Skip conflicting files for manual review |
obsyncd sync -s ~/vault -d ~/sync --remote --conflict newestDeveloper Documentation
What is obsync?
obsync is a TypeScript/Node.js CLI tool designed to synchronize Obsidian vaults bidirectionally across devices with intelligent conflict detection. Unlike traditional one-way sync tools, obsync implements a three-way merge algorithm to detect changes on both source and destination, ensuring no data loss during synchronization.
Key Design Goals
- Cloud-Agnostic: Works with local filesystems initially, with planned support for Google Drive, UploadThing, S3, and other cloud providers
- Bidirectional Sync: Changes on either side are detected and propagated
- Conflict-Aware: Detects when the same file is modified on both sides
- Obsidian-Specific: Understands Obsidian vault structure and excludes system files (
.obsidian/workspace*,.trash/) - Extensible Architecture: Storage adapter pattern allows easy addition of new backends
Planned Features
Phase 1: Local Sync ✅ COMPLETE
- ✅ Local filesystem storage adapter
- ✅ Vault detection and file listing
- ✅ Sync manifest management (state tracking)
- ✅ Three-way merge change detection
- ✅ Conflict resolution strategies (newest, source, destination, skip)
- ✅ Core sync engine
- ✅ CLI commands (
init,sync,status)
Phase 2: Watch Mode ✅ COMPLETE
- ✅ Continuous file monitoring with chokidar
- ✅ Automatic sync on file changes
- ✅ Debounced change detection
- ✅ Batched file change processing
- ✅ Graceful start/stop/pause/resume
- ✅ Real-time bidirectional sync
- ✅ Session tracking and statistics
Phase 3: Cloud Storage (Future)
- ⏳ Google Drive adapter
- ⏳ UploadThing adapter
- ⏳ AWS S3 adapter
- ⏳ WebDAV support
Installation
Phases 1 and 2 are complete and ready for local testing:
# Clone and build
git clone <repo-url>
cd obsync
bun install
bun run build
# Link for global use (optional)
bun linkUsage
# Initialize a vault for syncing
obsync init --path ./my-vault
# Sync between two local vaults
obsync sync --source ~/work-vault --destination ~/personal-vault
# Sync with conflict resolution strategy
obsync sync --source ./vault-a --destination ./vault-b --conflict newest
# Continuous sync with watch mode
obsync sync --source ./vault --destination ./backup --watch
# Check sync status
obsync status --path ./my-vaultDeveloper Documentation
Architecture Overview
obsync uses a modular, layered architecture with clear separation of concerns:
┌─────────────────────────────────────────────────────────────┐
│ CLI Layer (src/cli.ts) │
│ Commander.js interface, command parsing │
└────────────────────────┬────────────────────────────────────┘
│
┌────────────────────────┴────────────────────────────────────┐
│ Orchestration Layer (src/sync/) │
│ SyncEngine, ChangeDetector, ConflictResolver, Manifest │
└────────────────────────┬────────────────────────────────────┘
│
┌────────────────┼────────────────┐
│ │ │
┌───────▼──────┐ ┌──────▼──────┐ ┌─────▼──────┐
│ Storage │ │ Vault │ │ Config │
│ (src/storage)│ │ (src/vault) │ │(src/config)│
│ Adapters │ │ Operations │ │ Management │
└──────────────┘ └─────────────┘ └────────────┘
│
┌───────▼──────────────────────────────────────┐
│ Utilities (src/utils/) │
│ Hash computation, path normalization, etc. │
└──────────────────────────────────────────────┘Core Design Patterns
Storage Adapter Pattern: All storage backends implement
StorageAdapterinterface- Enables plug-and-play cloud storage support
- No changes to sync logic when adding new backends
Three-Way Merge Algorithm: Tracks three states (source, destination, base)
- Base state stored in sync manifest (
.obsync/sync-manifest.json) - Enables conflict detection without false positives
- Base state stored in sync manifest (
Strategy Pattern: Pluggable conflict resolution strategies
newest: Use most recent modification timesource: Always prefer source versiondestination: Always prefer destination versionskip: Skip conflicts, log for manual review
Implementation Status
✓ Phase 1: Local Storage Adapter (COMPLETE)
File: src/storage/index.ts
Status: 100% implemented, 22 unit tests passing
Implementation Details:
- Uses Node.js
fs/promisesfor async filesystem operations - All paths normalized via
sanitizePath()utility (backslash → forward slash) - Parent directories created automatically on write
- Graceful error handling (e.g., delete succeeds even if file doesn't exist)
- Recursive directory traversal for
list()operation - Returns sorted file lists for consistency
Methods:
read(path: string): Promise<Buffer>- Read file contentswrite(path: string, content: Buffer): Promise<void>- Write with dir creationdelete(path: string): Promise<void>- Delete file (idempotent)list(prefix: string): Promise<string[]>- Recursive listing, relative pathsexists(path: string): Promise<boolean>- Check file/directory existence
Error Handling:
ENOENT(file not found) → Clear error message on read, silent success on delete- Permission errors → Propagated to caller
- Non-existent directories in
list()→ Returns empty array
Performance Characteristics:
list(): O(n) where n = total files in directory treeread(): O(1) filesystem operationwrite(): O(d) where d = directory depth (mkdir operations)
✓ Phase 2: Vault Operations (COMPLETE)
File: src/vault/index.ts
Status: 100% implemented, 22 unit tests passing
Implementation Details:
- Uses
picomatchlibrary for glob pattern matching (fast, widely-used) - Default exclusions:
.obsidian/**,.trash/**,.git/**,.obsync/** - Supports both include and exclude patterns
- Computes metadata by aggregating stats from all files
Methods:
isObsidianVault(path?: string): Promise<boolean>- Check for.obsidiandirectoryvalidateStructure(): Promise<boolean>- Verify vault has config fileslistFiles(): Promise<string[]>- List with pattern filteringgetMetadata(): Promise<VaultMetadata>- Extract file count, size, last modified
Pattern Matching Algorithm:
- Get all files via
LocalStorageAdapter.list() - Apply include patterns (if specified) - file must match at least one
- Apply exclude patterns (defaults + custom) - file must not match any
- Return filtered, sorted list
Metadata Extraction:
- File Count: Count of files after pattern filtering
- Total Size: Sum of all file sizes (via
fs.stat()) - Last Modified: Most recent
mtimeacross all files - Gracefully skips files that can't be read (permissions, etc.)
Vault Detection Logic:
- isObsidianVault: Checks for
.obsidian/directory - validateStructure: Checks for config files (
app.json,appearance.json,config) - New vaults (empty
.obsidian/) are considered valid
⏳ Phase 3: Sync Manifest Manager (NOT YET IMPLEMENTED)
Planned File: src/sync/manifest.ts
Purpose: Track sync state for three-way merge algorithm
Manifest Schema:
{
"version": "1.0",
"lastSync": "2026-01-09T10:30:00Z",
"syncId": "uuid-v4", // Identifies sync pair
"files": {
"relative/path.md": {
"hash": "sha256-hash",
"size": 1234,
"mtime": "2026-01-09T10:29:00Z"
}
}
}Storage Location: {vault}/.obsync/sync-manifest.json
Planned Methods:
load(): Promise<SyncManifest | null>- Read from vaultsave(manifest: SyncManifest): Promise<void>- Atomic write (temp + rename)getFileState(path: string): Promise<FileState | null>- Lookup single fileupdateFileState(path: string, state: FileState): Promise<void>- Update single entryremoveFileState(path: string): Promise<void>- Remove deleted filecreateEmptyManifest(): SyncManifest- Generate new manifest with UUID
⏳ Phases 4-9 (NOT YET IMPLEMENTED)
See CLAUDE.md for detailed implementation plan.
Implemented Components
1. LocalStorageAdapter
Type: Class implementing StorageAdapter interface
Location: src/storage/index.ts
Dependencies:
- Node.js
fs/promises - Node.js
path sanitizePath()from utils
Usage Example:
import { LocalStorageAdapter } from './storage/index.js';
const storage = new LocalStorageAdapter();
// Read file
const content = await storage.read('/path/to/file.txt');
console.log(content.toString());
// Write file (creates parent dirs)
await storage.write('/path/to/new/file.txt', Buffer.from('content'));
// List all files recursively
const files = await storage.list('/vault/path');
// Returns: ['note1.md', 'folder/note2.md', ...]
// Check existence
const exists = await storage.exists('/path/to/file.txt');
// Delete file
await storage.delete('/path/to/file.txt');Implementation Notes:
- All methods are async (return Promises)
- Paths normalized automatically via
sanitizePath() list()returns relative paths sorted alphabeticallywrite()creates intermediate directories recursivelydelete()never throws on ENOENT (already deleted = success)
Testing: 22 unit tests in tests/unit/storage.test.ts
- Covers all methods with edge cases
- Tests binary file handling
- Tests deep nesting, special characters
- Tests error conditions (permissions, missing files)
2. ObsidianVault
Type: Class for Obsidian-specific operations
Location: src/vault/index.ts
Dependencies:
LocalStorageAdapterpicomatch(glob matching)- Node.js
fs/promises,path
Constructor:
interface VaultConfig {
vaultPath: string;
excludePatterns?: string[]; // Additional patterns beyond defaults
includePatterns?: string[]; // If set, only include matching files
}
const vault = new ObsidianVault({
vaultPath: '/path/to/vault',
excludePatterns: ['private/**'] // Optional
});Usage Example:
import { ObsidianVault } from './vault/index.js';
const vault = new ObsidianVault({ vaultPath: '/my/vault' });
// Check if path is Obsidian vault
if (await vault.isObsidianVault()) {
console.log('Valid vault!');
}
// Validate structure
const isValid = await vault.validateStructure();
// List all files (excluding .obsidian, .trash, etc.)
const files = await vault.listFiles();
// Returns: ['note1.md', 'folder/note2.md', ...]
// Get metadata
const metadata = await vault.getMetadata();
console.log(`Files: ${metadata.fileCount}`);
console.log(`Size: ${metadata.totalSize} bytes`);
console.log(`Last modified: ${metadata.lastModified}`);Default Exclusions:
.obsidian/**- All Obsidian config/cache files.trash/**- Obsidian trash folder.git/**- Git repository files.obsync/**- obsync metadata (sync manifests)
Custom Pattern Example:
const vault = new ObsidianVault({
vaultPath: '/vault',
excludePatterns: ['archive/**', 'templates/**'],
includePatterns: ['*.md'] // Only markdown files
});Pattern Matching Logic:
- If
includePatternsspecified, file must match at least one - File must NOT match any pattern in
excludePatterns+ defaults - Uses
picomatchfor fast, reliable glob matching
Testing: 22 unit tests in tests/unit/vault.test.ts
- Tests vault detection and validation
- Tests file listing with various pattern combinations
- Tests metadata extraction accuracy
- Tests with real fixture vault (
tests/fixtures/sample-vault/)
3. Utility Functions
Location: src/utils/index.ts
Functions:
computeFileHash(content: Buffer): string
- Computes SHA-256 hash of file content
- Returns hex-encoded hash string
- Used for change detection in sync algorithm
- Usage:
const hash = computeFileHash(Buffer.from('content'));
sanitizePath(path: string): string
- Normalizes path separators (backslash → forward slash)
- Ensures consistent paths across Windows/Unix
- Usage:
const normalized = sanitizePath('path\\to\\file');
isMarkdownFile(filename: string): boolean
- Checks if filename ends with
.md - Simple extension check
- Usage:
if (isMarkdownFile('note.md')) { ... }
formatBytes(bytes: number): string
- Formats byte count to human-readable string (KB, MB, GB)
- Usage:
formatBytes(1048576) // "1.00 MB"
Testing: 4 unit tests in tests/unit/utils.test.ts
API Reference
StorageAdapter Interface
interface StorageAdapter {
/**
* Read file contents as Buffer
* @throws Error if file doesn't exist
*/
read(path: string): Promise<Buffer>;
/**
* Write content to file, creating parent directories
* @param path - Absolute or relative file path
* @param content - File content as Buffer
*/
write(path: string, content: Buffer): Promise<void>;
/**
* Delete file (idempotent - succeeds even if file doesn't exist)
* @param path - File path to delete
*/
delete(path: string): Promise<void>;
/**
* List all files recursively under prefix
* @param prefix - Directory path to list
* @returns Array of relative file paths, sorted
*/
list(prefix: string): Promise<string[]>;
/**
* Check if file or directory exists
* @param path - Path to check
* @returns true if exists, false otherwise
*/
exists(path: string): Promise<boolean>;
}VaultMetadata Type
interface VaultMetadata {
name: string; // Vault directory name
path: string; // Absolute path to vault
fileCount: number; // Number of files (after exclusions)
totalSize: number; // Total size in bytes
lastModified: Date; // Most recent file modification
}VaultConfig Type
interface VaultConfig {
vaultPath: string; // Path to Obsidian vault
excludePatterns?: string[]; // Additional exclude globs
includePatterns?: string[]; // If set, only include matching
}WatchModeOptions Type
interface WatchModeOptions {
source: string; // Source vault path
destination: string; // Destination vault path
conflictResolution?: ConflictStrategy; // Conflict resolution strategy
debounceMs?: number; // Debounce delay (default: 300)
batchDelayMs?: number; // Batch delay (default: 500)
maxWaitMs?: number; // Max wait before sync (default: 5000)
initialSync?: boolean; // Run initial sync (default: true)
onSync?: (result: SyncResult) => void; // Sync callback
onError?: (error: Error) => void; // Error callback
onStatusChange?: (status: WatchModeStatus) => void; // Status callback
}WatchModeStatus Type
interface WatchModeStatus {
state: 'idle' | 'watching' | 'syncing' | 'error' | 'stopped';
isActive: boolean; // Whether watch mode is active
pendingChanges: number; // Number of pending changes
lastSyncTime: Date | null; // Last sync timestamp
lastSyncResult: SyncResult | null; // Last sync result
syncCount: number; // Total syncs in session
totalFilesSynced: number; // Total files synced
currentError: Error | null; // Current error if any
watchedPaths: string[]; // Paths being watched
}WatchModeSync Usage Example
import { WatchModeSync } from 'obsync';
const watchMode = new WatchModeSync({
source: '/path/to/source-vault',
destination: '/path/to/dest-vault',
conflictResolution: 'newest',
onSync: (result) => {
console.log(`Synced: ${result.filesAdded} added, ${result.filesUpdated} updated`);
},
onStatusChange: (status) => {
console.log(`State: ${status.state}, Pending: ${status.pendingChanges}`);
},
});
// Start watching
await watchMode.start();
// Get status
console.log(watchMode.getStatus());
// Force immediate sync
await watchMode.forceSync();
// Pause/resume
await watchMode.pause();
await watchMode.resume();
// Stop watching
await watchMode.stop();
// Get session info
console.log(watchMode.getSession());Testing
Test Structure
tests/
├── unit/ # Unit tests for individual modules
│ ├── storage.test.ts # LocalStorageAdapter tests (22 tests)
│ ├── vault.test.ts # ObsidianVault tests (22 tests)
│ ├── config.test.ts # ConfigManager tests
│ ├── manifest.test.ts # ManifestManager tests
│ ├── watcher.test.ts # FileWatcher tests (29 tests)
│ ├── watchMode.test.ts # WatchModeSync tests
│ └── utils.test.ts # Utility function tests (4 tests)
├── integration/ # Integration tests
│ ├── sync.test.ts # Full sync workflow tests
│ └── watchMode.test.ts # Watch mode integration tests
└── fixtures/ # Test data
└── sample-vault/ # Example Obsidian vault
├── .obsidian/
│ └── app.json
├── note1.md
├── note2.md
└── folder/
└── note3.mdRunning Tests
# Run all tests
bun test
# Run specific test file
bun test storage.test.ts
# Run tests in watch mode
bun test --watch
# Run tests with UI
bun run test:uiTest Coverage
Current: 165 passing tests, comprehensive coverage for all Phase 1 and 2 modules
Coverage Breakdown:
LocalStorageAdapter: 100% (all methods, all branches)ObsidianVault: 100% (all methods, all branches)ManifestManager: 100% (all methods, all branches)ConfigManager: 100% (all methods, all branches)SyncEngine: Integration tests covering all sync scenariosFileWatcher: 100% (all methods, debouncing, ignore patterns)BatchFileWatcher: 100% (batch collection, flushing)WatchModeSync: 100% (start/stop/pause/resume, auto-sync, session tracking)Utils: 100%
Testing Philosophy
- Unit Tests: Test individual methods in isolation
- Edge Cases: Test error conditions, empty inputs, boundary cases
- Integration: Tests with real filesystem operations (temp directories)
- Fixtures: Reusable test data in
tests/fixtures/
Development Setup
Prerequisites
- Bun >= 1.0.0 (runtime and package manager)
- Node.js is not required - Bun is a complete replacement
Setup
This project uses Bun as the primary runtime and package manager:
# Install Bun (if not already installed)
curl -fsSL https://bun.sh/install | bash
# Clone repository
git clone <repo-url>
cd obsync
# Install dependencies
bun install
# Build TypeScript
bun run build
# Run tests
bun test
# Lint code
bun run lint
# Format code
bun run formatSee BUN_SETUP.md for detailed Bun usage, performance comparisons, and troubleshooting.
Project Structure
obsync/
├── src/
│ ├── cli.ts # CLI interface (placeholder)
│ ├── index.ts # Main exports
│ ├── storage/
│ │ └── index.ts # ✓ StorageAdapter + LocalStorageAdapter
│ ├── sync/
│ │ └── index.ts # ⏳ SyncEngine (placeholder)
│ ├── vault/
│ │ └── index.ts # ✓ ObsidianVault
│ ├── config/
│ │ └── index.ts # ⏳ ConfigManager (placeholder)
│ └── utils/
│ └── index.ts # ✓ Utility functions
├── tests/
│ ├── unit/ # ✓ Unit tests
│ ├── integration/ # ⏳ Integration tests (future)
│ └── fixtures/ # Test data
├── dist/ # Build output
├── package.json
├── tsconfig.json # TypeScript config
├── vitest.config.ts # Test config
├── eslint.config.js # Linting rules
├── .prettierrc # Code formatting
├── README.md # This file
└── CLAUDE.md # Development guideBuild System
- TypeScript: Compiles to ES modules
- Target: ES2022
- Module Resolution: Bundler (ESM with .js extensions)
- Source Maps: Enabled
- Declarations: Generated (.d.ts files)
Code Quality Tools
- TypeScript: Strict mode enabled, no unused parameters/locals
- ESLint: TypeScript-specific rules, flat config format
- Prettier: Consistent code formatting (2-space indent, single quotes)
- Vitest: Fast unit test runner with V8 coverage
Contributing
Adding a New Storage Adapter
To add support for a new storage backend (e.g., Google Drive, S3):
Create adapter file:
src/storage/<backend>.tsImplement interface:
import { StorageAdapter } from './index.js';
export class MyStorageAdapter implements StorageAdapter {
async read(path: string): Promise<Buffer> {
// Implement backend-specific read
}
async write(path: string, content: Buffer): Promise<void> {
// Implement backend-specific write
}
// ... implement other methods
}Add tests:
tests/unit/<backend>.test.tsExport: Add to
src/storage/index.ts:
export * from './<backend>.js';Sync Algorithm Implementation
When implementing Phase 3+ (sync engine), follow this sequence:
- Manifest Manager (
src/sync/manifest.ts) - State tracking - Change Detector (
src/sync/changeDetector.ts) - Three-way merge logic - Conflict Resolver (
src/sync/conflictResolver.ts) - Strategy pattern - Sync Engine (
src/sync/index.ts) - Orchestration
See CLAUDE.md for detailed implementation plan.
Code Style
- Use async/await (not callbacks or raw Promises)
- Prefer
constoverlet, avoidvar - Use TypeScript strict types, avoid
any - ESM imports with
.jsextension (TypeScript requirement) - Document complex algorithms with comments
- Keep functions focused (single responsibility)
License
MIT
Project Roadmap
Phase 1 (✅ COMPLETE): Local-to-local sync with bidirectional support
- Storage layer, vault operations, sync engine, conflict resolution, CLI
Phase 2 (✅ COMPLETE): Watch mode for continuous sync
- File monitoring with chokidar, automatic sync on changes, debouncing
- Batch processing, session tracking, pause/resume support
Phase 3+ (Future): Cloud storage adapters
- Google Drive, AWS S3, UploadThing, WebDAV support
For detailed technical planning, see CLAUDE.md.
