@lifestreamdynamics/vault-cli 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (83) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +759 -0
  3. package/dist/client.d.ts +12 -0
  4. package/dist/client.js +79 -0
  5. package/dist/commands/admin.d.ts +2 -0
  6. package/dist/commands/admin.js +263 -0
  7. package/dist/commands/audit.d.ts +2 -0
  8. package/dist/commands/audit.js +119 -0
  9. package/dist/commands/auth.d.ts +2 -0
  10. package/dist/commands/auth.js +256 -0
  11. package/dist/commands/config.d.ts +2 -0
  12. package/dist/commands/config.js +130 -0
  13. package/dist/commands/connectors.d.ts +2 -0
  14. package/dist/commands/connectors.js +224 -0
  15. package/dist/commands/docs.d.ts +2 -0
  16. package/dist/commands/docs.js +194 -0
  17. package/dist/commands/hooks.d.ts +2 -0
  18. package/dist/commands/hooks.js +159 -0
  19. package/dist/commands/keys.d.ts +2 -0
  20. package/dist/commands/keys.js +165 -0
  21. package/dist/commands/publish.d.ts +2 -0
  22. package/dist/commands/publish.js +138 -0
  23. package/dist/commands/search.d.ts +2 -0
  24. package/dist/commands/search.js +61 -0
  25. package/dist/commands/shares.d.ts +2 -0
  26. package/dist/commands/shares.js +121 -0
  27. package/dist/commands/subscription.d.ts +2 -0
  28. package/dist/commands/subscription.js +166 -0
  29. package/dist/commands/sync.d.ts +2 -0
  30. package/dist/commands/sync.js +565 -0
  31. package/dist/commands/teams.d.ts +2 -0
  32. package/dist/commands/teams.js +322 -0
  33. package/dist/commands/user.d.ts +2 -0
  34. package/dist/commands/user.js +48 -0
  35. package/dist/commands/vaults.d.ts +2 -0
  36. package/dist/commands/vaults.js +157 -0
  37. package/dist/commands/versions.d.ts +2 -0
  38. package/dist/commands/versions.js +219 -0
  39. package/dist/commands/webhooks.d.ts +2 -0
  40. package/dist/commands/webhooks.js +181 -0
  41. package/dist/config.d.ts +24 -0
  42. package/dist/config.js +88 -0
  43. package/dist/index.d.ts +2 -0
  44. package/dist/index.js +63 -0
  45. package/dist/lib/credential-manager.d.ts +48 -0
  46. package/dist/lib/credential-manager.js +101 -0
  47. package/dist/lib/encrypted-config.d.ts +20 -0
  48. package/dist/lib/encrypted-config.js +102 -0
  49. package/dist/lib/keychain.d.ts +8 -0
  50. package/dist/lib/keychain.js +82 -0
  51. package/dist/lib/migration.d.ts +31 -0
  52. package/dist/lib/migration.js +92 -0
  53. package/dist/lib/profiles.d.ts +43 -0
  54. package/dist/lib/profiles.js +104 -0
  55. package/dist/sync/config.d.ts +32 -0
  56. package/dist/sync/config.js +100 -0
  57. package/dist/sync/conflict.d.ts +30 -0
  58. package/dist/sync/conflict.js +60 -0
  59. package/dist/sync/daemon-worker.d.ts +1 -0
  60. package/dist/sync/daemon-worker.js +128 -0
  61. package/dist/sync/daemon.d.ts +44 -0
  62. package/dist/sync/daemon.js +174 -0
  63. package/dist/sync/diff.d.ts +43 -0
  64. package/dist/sync/diff.js +166 -0
  65. package/dist/sync/engine.d.ts +41 -0
  66. package/dist/sync/engine.js +233 -0
  67. package/dist/sync/ignore.d.ts +16 -0
  68. package/dist/sync/ignore.js +72 -0
  69. package/dist/sync/remote-poller.d.ts +23 -0
  70. package/dist/sync/remote-poller.js +145 -0
  71. package/dist/sync/state.d.ts +32 -0
  72. package/dist/sync/state.js +98 -0
  73. package/dist/sync/types.d.ts +68 -0
  74. package/dist/sync/types.js +4 -0
  75. package/dist/sync/watcher.d.ts +23 -0
  76. package/dist/sync/watcher.js +207 -0
  77. package/dist/utils/flags.d.ts +18 -0
  78. package/dist/utils/flags.js +31 -0
  79. package/dist/utils/format.d.ts +2 -0
  80. package/dist/utils/format.js +22 -0
  81. package/dist/utils/output.d.ts +87 -0
  82. package/dist/utils/output.js +229 -0
  83. package/package.json +62 -0
@@ -0,0 +1,145 @@
1
+ /**
2
+ * Remote change poller for continuous sync.
3
+ * Periodically checks the remote vault for changes and pulls them down.
4
+ */
5
+ import fs from 'node:fs';
6
+ import path from 'node:path';
7
+ import { shouldIgnore } from './ignore.js';
8
+ import { loadSyncState, saveSyncState, hashFileContent, buildRemoteFileState } from './state.js';
9
+ import { updateLastSync } from './config.js';
10
+ import { resolveConflict, detectConflict, createConflictFile, formatConflictLog } from './conflict.js';
11
+ /**
12
+ * Creates and starts a remote poller for a sync configuration.
13
+ * Returns a stop function.
14
+ */
15
+ export function createRemotePoller(client, config, options) {
16
+ const { ignorePatterns, intervalMs = 30000, onLog, onConflictLog, onError, onLocalWrite, } = options;
17
+ const log = (msg) => onLog?.(`[poll:${config.id.slice(0, 8)}] ${msg}`);
18
+ let timer = null;
19
+ let polling = false;
20
+ async function poll() {
21
+ if (polling)
22
+ return; // Skip if previous poll still in progress
23
+ polling = true;
24
+ try {
25
+ const remoteDocs = await client.documents.list(config.vaultId);
26
+ const state = loadSyncState(config.id);
27
+ let changes = 0;
28
+ for (const doc of remoteDocs) {
29
+ if (shouldIgnore(doc.path, ignorePatterns))
30
+ continue;
31
+ const lastRemote = state.remote[doc.path];
32
+ // Detect remote changes by comparing mtime
33
+ const remoteChanged = !lastRemote || doc.fileModifiedAt !== lastRemote.mtime;
34
+ if (!remoteChanged)
35
+ continue;
36
+ // Fetch the full content
37
+ const { content } = await client.documents.get(config.vaultId, doc.path);
38
+ const remoteHash = hashFileContent(content);
39
+ // Skip if hash hasn't actually changed
40
+ if (lastRemote && remoteHash === lastRemote.hash) {
41
+ // Update mtime in state but skip file operations
42
+ state.remote[doc.path] = buildRemoteFileState(doc.path, content, doc.fileModifiedAt);
43
+ continue;
44
+ }
45
+ const localFile = path.join(config.localPath, doc.path);
46
+ const localExists = fs.existsSync(localFile);
47
+ if (localExists) {
48
+ const localContent = fs.readFileSync(localFile, 'utf-8');
49
+ const localHash = hashFileContent(localContent);
50
+ if (localHash === remoteHash) {
51
+ // Content is already the same — just update state
52
+ state.local[doc.path] = { path: doc.path, hash: localHash, mtime: new Date().toISOString(), size: Buffer.byteLength(localContent) };
53
+ state.remote[doc.path] = buildRemoteFileState(doc.path, content, doc.fileModifiedAt);
54
+ continue;
55
+ }
56
+ // Check for conflict
57
+ const lastLocal = state.local[doc.path];
58
+ const localState = { path: doc.path, hash: localHash, mtime: fs.statSync(localFile).mtime.toISOString(), size: Buffer.byteLength(localContent) };
59
+ const remoteState = { path: doc.path, hash: remoteHash, mtime: doc.fileModifiedAt, size: Buffer.byteLength(content) };
60
+ if (detectConflict(localState, remoteState, lastLocal, lastRemote)) {
61
+ const resolution = resolveConflict(config.onConflict, localState, remoteState);
62
+ let conflictFile = null;
63
+ if (resolution === 'remote') {
64
+ conflictFile = createConflictFile(config.localPath, doc.path, localContent, 'local');
65
+ onLocalWrite?.(doc.path);
66
+ fs.writeFileSync(localFile, content, 'utf-8');
67
+ log(`Conflict: ${doc.path} — used remote, saved local as ${conflictFile}`);
68
+ }
69
+ else {
70
+ conflictFile = createConflictFile(config.localPath, doc.path, content, 'remote');
71
+ await client.documents.put(config.vaultId, doc.path, localContent);
72
+ log(`Conflict: ${doc.path} — used local, saved remote as ${conflictFile}`);
73
+ }
74
+ onConflictLog?.(formatConflictLog(doc.path, resolution, conflictFile));
75
+ state.local[doc.path] = resolution === 'remote' ? remoteState : localState;
76
+ state.remote[doc.path] = resolution === 'remote'
77
+ ? buildRemoteFileState(doc.path, content, doc.fileModifiedAt)
78
+ : buildRemoteFileState(doc.path, localContent, new Date().toISOString());
79
+ changes++;
80
+ continue;
81
+ }
82
+ }
83
+ // No conflict — download the file
84
+ const dir = path.dirname(localFile);
85
+ if (!fs.existsSync(dir)) {
86
+ fs.mkdirSync(dir, { recursive: true });
87
+ }
88
+ onLocalWrite?.(doc.path);
89
+ fs.writeFileSync(localFile, content, 'utf-8');
90
+ log(`Pulled: ${doc.path}`);
91
+ changes++;
92
+ state.local[doc.path] = {
93
+ path: doc.path,
94
+ hash: remoteHash,
95
+ mtime: new Date().toISOString(),
96
+ size: Buffer.byteLength(content),
97
+ };
98
+ state.remote[doc.path] = buildRemoteFileState(doc.path, content, doc.fileModifiedAt);
99
+ }
100
+ // Check for remote deletions
101
+ for (const docPath of Object.keys(state.remote)) {
102
+ if (shouldIgnore(docPath, ignorePatterns))
103
+ continue;
104
+ const stillExists = remoteDocs.some(d => d.path === docPath);
105
+ if (!stillExists) {
106
+ const localFile = path.join(config.localPath, docPath);
107
+ if (fs.existsSync(localFile)) {
108
+ fs.unlinkSync(localFile);
109
+ log(`Deleted local: ${docPath} (removed from remote)`);
110
+ changes++;
111
+ }
112
+ delete state.local[docPath];
113
+ delete state.remote[docPath];
114
+ }
115
+ }
116
+ if (changes > 0) {
117
+ saveSyncState(state);
118
+ updateLastSync(config.id);
119
+ log(`Poll complete: ${changes} change(s)`);
120
+ }
121
+ }
122
+ catch (err) {
123
+ onError?.(err instanceof Error ? err : new Error(String(err)));
124
+ }
125
+ finally {
126
+ polling = false;
127
+ }
128
+ }
129
+ // Initial poll
130
+ poll().catch(err => onError?.(err instanceof Error ? err : new Error(String(err))));
131
+ // Start interval
132
+ timer = setInterval(() => {
133
+ poll().catch(err => onError?.(err instanceof Error ? err : new Error(String(err))));
134
+ }, intervalMs);
135
+ log(`Polling every ${intervalMs / 1000}s`);
136
+ return {
137
+ stop: () => {
138
+ if (timer) {
139
+ clearInterval(timer);
140
+ timer = null;
141
+ }
142
+ log('Stopped polling');
143
+ },
144
+ };
145
+ }
@@ -0,0 +1,32 @@
1
+ import type { SyncState, FileState } from './types.js';
2
+ /**
3
+ * Load sync state for a given sync configuration.
4
+ * Returns a fresh empty state if no state file exists.
5
+ */
6
+ export declare function loadSyncState(syncId: string): SyncState;
7
+ /**
8
+ * Save sync state to disk.
9
+ */
10
+ export declare function saveSyncState(state: SyncState): void;
11
+ /**
12
+ * Delete sync state for a given sync configuration.
13
+ * Returns true if the state file was found and deleted.
14
+ */
15
+ export declare function deleteSyncState(syncId: string): boolean;
16
+ /**
17
+ * Compute SHA-256 hash of a file's content.
18
+ */
19
+ export declare function hashFileContent(content: string | Buffer): string;
20
+ /**
21
+ * Build a FileState entry from a file path on the local filesystem.
22
+ * The docPath should be the relative document path (forward slashes).
23
+ */
24
+ export declare function buildFileState(absolutePath: string, docPath: string): FileState;
25
+ /**
26
+ * Build a FileState entry from remote content (e.g., from the API).
27
+ */
28
+ export declare function buildRemoteFileState(docPath: string, content: string, updatedAt: string): FileState;
29
+ /**
30
+ * Check if a file has changed compared to a known state.
31
+ */
32
+ export declare function hasFileChanged(current: FileState, known: FileState): boolean;
@@ -0,0 +1,98 @@
1
+ /**
2
+ * Sync state tracking.
3
+ * Manages per-sync state files at ~/.lsvault/sync-state/<syncId>.json.
4
+ * Tracks file hashes and modification times for change detection.
5
+ */
6
+ import fs from 'node:fs';
7
+ import path from 'node:path';
8
+ import os from 'node:os';
9
+ import crypto from 'node:crypto';
10
+ const STATE_DIR = path.join(os.homedir(), '.lsvault', 'sync-state');
11
+ function stateFilePath(syncId) {
12
+ return path.join(STATE_DIR, `${syncId}.json`);
13
+ }
14
+ /**
15
+ * Load sync state for a given sync configuration.
16
+ * Returns a fresh empty state if no state file exists.
17
+ */
18
+ export function loadSyncState(syncId) {
19
+ const filePath = stateFilePath(syncId);
20
+ if (!fs.existsSync(filePath)) {
21
+ return {
22
+ syncId,
23
+ local: {},
24
+ remote: {},
25
+ updatedAt: new Date(0).toISOString(),
26
+ };
27
+ }
28
+ try {
29
+ const raw = fs.readFileSync(filePath, 'utf-8');
30
+ return JSON.parse(raw);
31
+ }
32
+ catch {
33
+ return {
34
+ syncId,
35
+ local: {},
36
+ remote: {},
37
+ updatedAt: new Date(0).toISOString(),
38
+ };
39
+ }
40
+ }
41
+ /**
42
+ * Save sync state to disk.
43
+ */
44
+ export function saveSyncState(state) {
45
+ if (!fs.existsSync(STATE_DIR)) {
46
+ fs.mkdirSync(STATE_DIR, { recursive: true });
47
+ }
48
+ state.updatedAt = new Date().toISOString();
49
+ fs.writeFileSync(stateFilePath(state.syncId), JSON.stringify(state, null, 2) + '\n');
50
+ }
51
+ /**
52
+ * Delete sync state for a given sync configuration.
53
+ * Returns true if the state file was found and deleted.
54
+ */
55
+ export function deleteSyncState(syncId) {
56
+ const filePath = stateFilePath(syncId);
57
+ if (!fs.existsSync(filePath))
58
+ return false;
59
+ fs.unlinkSync(filePath);
60
+ return true;
61
+ }
62
+ /**
63
+ * Compute SHA-256 hash of a file's content.
64
+ */
65
+ export function hashFileContent(content) {
66
+ return crypto.createHash('sha256').update(content).digest('hex');
67
+ }
68
+ /**
69
+ * Build a FileState entry from a file path on the local filesystem.
70
+ * The docPath should be the relative document path (forward slashes).
71
+ */
72
+ export function buildFileState(absolutePath, docPath) {
73
+ const content = fs.readFileSync(absolutePath);
74
+ const stat = fs.statSync(absolutePath);
75
+ return {
76
+ path: docPath,
77
+ hash: hashFileContent(content),
78
+ mtime: stat.mtime.toISOString(),
79
+ size: stat.size,
80
+ };
81
+ }
82
+ /**
83
+ * Build a FileState entry from remote content (e.g., from the API).
84
+ */
85
+ export function buildRemoteFileState(docPath, content, updatedAt) {
86
+ return {
87
+ path: docPath,
88
+ hash: hashFileContent(content),
89
+ mtime: updatedAt,
90
+ size: Buffer.byteLength(content, 'utf-8'),
91
+ };
92
+ }
93
+ /**
94
+ * Check if a file has changed compared to a known state.
95
+ */
96
+ export function hasFileChanged(current, known) {
97
+ return current.hash !== known.hash;
98
+ }
@@ -0,0 +1,68 @@
1
+ /**
2
+ * Type definitions for the sync engine.
3
+ */
4
+ export type SyncMode = 'pull' | 'push' | 'sync';
5
+ export type ConflictStrategy = 'newer' | 'local' | 'remote' | 'ask';
6
+ /**
7
+ * Persisted configuration for a single vault sync.
8
+ * Stored in ~/.lsvault/syncs.json.
9
+ */
10
+ export interface SyncConfig {
11
+ /** Unique identifier for this sync configuration */
12
+ id: string;
13
+ /** Remote vault ID */
14
+ vaultId: string;
15
+ /** Absolute local filesystem path */
16
+ localPath: string;
17
+ /** Sync direction: pull (remote->local), push (local->remote), sync (bidirectional) */
18
+ mode: SyncMode;
19
+ /** How to resolve conflicts */
20
+ onConflict: ConflictStrategy;
21
+ /** Glob patterns to ignore (relative to localPath) */
22
+ ignore: string[];
23
+ /** ISO 8601 timestamp of last successful sync */
24
+ lastSyncAt: string;
25
+ /** Sync interval for auto-sync (e.g., '5m', '1h') */
26
+ syncInterval?: string;
27
+ /** Whether auto-sync is enabled */
28
+ autoSync: boolean;
29
+ }
30
+ /**
31
+ * Per-file tracking entry in sync state.
32
+ */
33
+ export interface FileState {
34
+ /** Document path (relative, using forward slashes) */
35
+ path: string;
36
+ /** SHA-256 hash of the file content */
37
+ hash: string;
38
+ /** Last modified time as ISO 8601 timestamp */
39
+ mtime: string;
40
+ /** File size in bytes */
41
+ size: number;
42
+ }
43
+ /**
44
+ * Persisted state for a single sync configuration.
45
+ * Stored in ~/.lsvault/sync-state/<syncId>.json.
46
+ */
47
+ export interface SyncState {
48
+ /** Corresponding sync config ID */
49
+ syncId: string;
50
+ /** Map of document path -> file state for local files */
51
+ local: Record<string, FileState>;
52
+ /** Map of document path -> file state for remote files */
53
+ remote: Record<string, FileState>;
54
+ /** ISO 8601 timestamp when state was last updated */
55
+ updatedAt: string;
56
+ }
57
+ /**
58
+ * Options for creating a new sync configuration.
59
+ */
60
+ export interface CreateSyncOptions {
61
+ vaultId: string;
62
+ localPath: string;
63
+ mode?: SyncMode;
64
+ onConflict?: ConflictStrategy;
65
+ ignore?: string[];
66
+ syncInterval?: string;
67
+ autoSync?: boolean;
68
+ }
@@ -0,0 +1,4 @@
1
+ /**
2
+ * Type definitions for the sync engine.
3
+ */
4
+ export {};
@@ -0,0 +1,23 @@
1
+ import { type FSWatcher } from 'chokidar';
2
+ import type { LifestreamVaultClient } from '@lifestreamdynamics/vault-sdk';
3
+ import type { SyncConfig } from './types.js';
4
+ export interface WatcherOptions {
5
+ /** Patterns to ignore */
6
+ ignorePatterns: string[];
7
+ /** Callback for log messages */
8
+ onLog?: (message: string) => void;
9
+ /** Callback for conflict log messages */
10
+ onConflictLog?: (message: string) => void;
11
+ /** Callback for errors */
12
+ onError?: (error: Error) => void;
13
+ /** Debounce delay in ms (default: 500) */
14
+ debounceMs?: number;
15
+ }
16
+ /**
17
+ * Creates and starts a file watcher for a sync configuration.
18
+ * Returns a cleanup function to stop watching.
19
+ */
20
+ export declare function createWatcher(client: LifestreamVaultClient, config: SyncConfig, options: WatcherOptions): {
21
+ watcher: FSWatcher;
22
+ stop: () => Promise<void>;
23
+ };
@@ -0,0 +1,207 @@
1
+ /**
2
+ * Local file watcher for continuous sync.
3
+ * Uses chokidar to detect file changes and triggers sync operations.
4
+ */
5
+ import path from 'node:path';
6
+ import { randomBytes } from 'node:crypto';
7
+ import { watch } from 'chokidar';
8
+ import { shouldIgnore } from './ignore.js';
9
+ import { hashFileContent, loadSyncState, saveSyncState, buildRemoteFileState } from './state.js';
10
+ import { updateLastSync } from './config.js';
11
+ import { resolveConflict, detectConflict, createConflictFile, formatConflictLog } from './conflict.js';
12
+ import fs from 'node:fs';
13
+ /** TTL set to prevent sync loops — files written by sync are ignored for 5s */
14
+ class RecentlyWrittenSet {
15
+ map = new Map();
16
+ ttlMs;
17
+ constructor(ttlMs = 5000) {
18
+ this.ttlMs = ttlMs;
19
+ }
20
+ add(filePath) {
21
+ this.map.set(filePath, Date.now());
22
+ }
23
+ has(filePath) {
24
+ const ts = this.map.get(filePath);
25
+ if (!ts)
26
+ return false;
27
+ if (Date.now() - ts > this.ttlMs) {
28
+ this.map.delete(filePath);
29
+ return false;
30
+ }
31
+ return true;
32
+ }
33
+ clear() {
34
+ this.map.clear();
35
+ }
36
+ }
37
+ /**
38
+ * Creates and starts a file watcher for a sync configuration.
39
+ * Returns a cleanup function to stop watching.
40
+ */
41
+ export function createWatcher(client, config, options) {
42
+ const { ignorePatterns, onLog, onConflictLog, onError, debounceMs = 500 } = options;
43
+ const recentlyWritten = new RecentlyWrittenSet();
44
+ const pendingChanges = new Map();
45
+ const log = (msg) => onLog?.(`[sync:${config.id.slice(0, 8)}] ${msg}`);
46
+ function toDocPath(absPath) {
47
+ const rel = path.relative(config.localPath, absPath);
48
+ return rel.split(path.sep).join('/');
49
+ }
50
+ /**
51
+ * Handles a detected conflict between local and remote versions of a file.
52
+ * Creates a backup of the losing side and applies the winning resolution.
53
+ * Returns the resolution chosen, or 'skip' if no actual conflict was detected.
54
+ */
55
+ async function handleConflict(params) {
56
+ const { absPath, docPath, localContent, localHash, lastLocal, lastRemote, remoteContent, remoteHash, remoteUpdatedAt, state } = params;
57
+ const localState = { path: docPath, hash: localHash, mtime: new Date().toISOString(), size: Buffer.byteLength(localContent) };
58
+ const remoteState = { path: docPath, hash: remoteHash, mtime: remoteUpdatedAt, size: Buffer.byteLength(remoteContent) };
59
+ if (!detectConflict(localState, remoteState, lastLocal, lastRemote)) {
60
+ return 'skip';
61
+ }
62
+ const resolution = resolveConflict(config.onConflict, localState, remoteState);
63
+ let conflictFile = null;
64
+ if (resolution === 'local') {
65
+ conflictFile = createConflictFile(config.localPath, docPath, remoteContent, 'remote');
66
+ await client.documents.put(config.vaultId, docPath, localContent);
67
+ log(`Conflict: ${docPath} — used local, saved remote as ${conflictFile}`);
68
+ }
69
+ else {
70
+ conflictFile = createConflictFile(config.localPath, docPath, localContent, 'local');
71
+ recentlyWritten.add(docPath);
72
+ const tmpFile = absPath + '.tmp.' + randomBytes(4).toString('hex');
73
+ fs.writeFileSync(tmpFile, remoteContent, 'utf-8');
74
+ fs.renameSync(tmpFile, absPath);
75
+ log(`Conflict: ${docPath} — used remote, saved local as ${conflictFile}`);
76
+ }
77
+ onConflictLog?.(formatConflictLog(docPath, resolution, conflictFile));
78
+ state.local[docPath] = resolution === 'local' ? localState : remoteState;
79
+ state.remote[docPath] = resolution === 'local'
80
+ ? buildRemoteFileState(docPath, localContent, new Date().toISOString())
81
+ : buildRemoteFileState(docPath, remoteContent, remoteUpdatedAt);
82
+ saveSyncState(state);
83
+ return resolution;
84
+ }
85
+ async function handleFileChange(absPath) {
86
+ const docPath = toDocPath(absPath);
87
+ if (shouldIgnore(docPath, ignorePatterns))
88
+ return;
89
+ if (!docPath.endsWith('.md'))
90
+ return;
91
+ if (recentlyWritten.has(docPath)) {
92
+ log(`Skipping ${docPath} (recently written by sync)`);
93
+ return;
94
+ }
95
+ try {
96
+ const content = fs.readFileSync(absPath, 'utf-8');
97
+ const localHash = hashFileContent(content);
98
+ const state = loadSyncState(config.id);
99
+ const lastLocal = state.local[docPath];
100
+ const lastRemote = state.remote[docPath];
101
+ // Check remote for conflicts in bidirectional mode
102
+ if (config.mode === 'sync' && lastRemote) {
103
+ try {
104
+ const remote = await client.documents.get(config.vaultId, docPath);
105
+ const remoteHash = hashFileContent(remote.content);
106
+ if (remoteHash !== lastRemote.hash) {
107
+ const result = await handleConflict({
108
+ absPath, docPath, localContent: content, localHash,
109
+ lastLocal, lastRemote,
110
+ remoteContent: remote.content, remoteHash,
111
+ remoteUpdatedAt: remote.document.updatedAt, state,
112
+ });
113
+ if (result !== 'skip')
114
+ return;
115
+ }
116
+ }
117
+ catch {
118
+ // Remote check failed — proceed with push
119
+ }
120
+ }
121
+ // No conflict — push the change
122
+ if (config.mode === 'push' || config.mode === 'sync') {
123
+ await client.documents.put(config.vaultId, docPath, content);
124
+ log(`Pushed: ${docPath}`);
125
+ state.local[docPath] = { path: docPath, hash: localHash, mtime: new Date().toISOString(), size: Buffer.byteLength(content) };
126
+ state.remote[docPath] = buildRemoteFileState(docPath, content, new Date().toISOString());
127
+ saveSyncState(state);
128
+ updateLastSync(config.id);
129
+ }
130
+ }
131
+ catch (err) {
132
+ onError?.(err instanceof Error ? err : new Error(String(err)));
133
+ }
134
+ }
135
+ async function handleFileDelete(absPath) {
136
+ const docPath = toDocPath(absPath);
137
+ if (shouldIgnore(docPath, ignorePatterns))
138
+ return;
139
+ if (!docPath.endsWith('.md'))
140
+ return;
141
+ if (recentlyWritten.has(docPath))
142
+ return;
143
+ try {
144
+ if (config.mode === 'push' || config.mode === 'sync') {
145
+ await client.documents.delete(config.vaultId, docPath);
146
+ log(`Deleted remote: ${docPath}`);
147
+ const state = loadSyncState(config.id);
148
+ delete state.local[docPath];
149
+ delete state.remote[docPath];
150
+ saveSyncState(state);
151
+ updateLastSync(config.id);
152
+ }
153
+ }
154
+ catch (err) {
155
+ onError?.(err instanceof Error ? err : new Error(String(err)));
156
+ }
157
+ }
158
+ const watcher = watch(config.localPath, {
159
+ ignoreInitial: true,
160
+ persistent: true,
161
+ awaitWriteFinish: { stabilityThreshold: debounceMs },
162
+ ignored: (filePath) => {
163
+ const rel = path.relative(config.localPath, filePath);
164
+ if (!rel || rel === '.')
165
+ return false;
166
+ const docPath = rel.split(path.sep).join('/');
167
+ return shouldIgnore(docPath, ignorePatterns);
168
+ },
169
+ });
170
+ watcher.on('add', (absPath) => {
171
+ clearTimeout(pendingChanges.get(absPath));
172
+ pendingChanges.set(absPath, setTimeout(() => {
173
+ pendingChanges.delete(absPath);
174
+ handleFileChange(absPath).catch(err => onError?.(err instanceof Error ? err : new Error(String(err))));
175
+ }, debounceMs));
176
+ });
177
+ watcher.on('change', (absPath) => {
178
+ clearTimeout(pendingChanges.get(absPath));
179
+ pendingChanges.set(absPath, setTimeout(() => {
180
+ pendingChanges.delete(absPath);
181
+ handleFileChange(absPath).catch(err => onError?.(err instanceof Error ? err : new Error(String(err))));
182
+ }, debounceMs));
183
+ });
184
+ watcher.on('unlink', (absPath) => {
185
+ clearTimeout(pendingChanges.get(absPath));
186
+ pendingChanges.set(absPath, setTimeout(() => {
187
+ pendingChanges.delete(absPath);
188
+ handleFileDelete(absPath).catch(err => onError?.(err instanceof Error ? err : new Error(String(err))));
189
+ }, debounceMs));
190
+ });
191
+ watcher.on('error', (err) => {
192
+ onError?.(err instanceof Error ? err : new Error(String(err)));
193
+ });
194
+ log('Watching for changes...');
195
+ return {
196
+ watcher,
197
+ stop: async () => {
198
+ for (const timeout of pendingChanges.values()) {
199
+ clearTimeout(timeout);
200
+ }
201
+ pendingChanges.clear();
202
+ recentlyWritten.clear();
203
+ await watcher.close();
204
+ log('Stopped watching');
205
+ },
206
+ };
207
+ }
@@ -0,0 +1,18 @@
1
+ import type { Command } from 'commander';
2
+ export type OutputFormat = 'text' | 'json' | 'table';
3
+ export interface GlobalFlags {
4
+ output: OutputFormat;
5
+ verbose: boolean;
6
+ quiet: boolean;
7
+ noColor: boolean;
8
+ dryRun: boolean;
9
+ }
10
+ /**
11
+ * Add universal flags to a command.
12
+ * Call this on each leaf command (action command) to register the flags.
13
+ */
14
+ export declare function addGlobalFlags(cmd: Command): Command;
15
+ /**
16
+ * Resolve global flags from parsed options, applying TTY detection defaults.
17
+ */
18
+ export declare function resolveFlags(opts: Record<string, unknown>): GlobalFlags;
@@ -0,0 +1,31 @@
1
+ import chalk from 'chalk';
2
+ /**
3
+ * Add universal flags to a command.
4
+ * Call this on each leaf command (action command) to register the flags.
5
+ */
6
+ export function addGlobalFlags(cmd) {
7
+ return cmd
8
+ .option('-o, --output <format>', 'Output format: text, json, table (default: auto)')
9
+ .option('-v, --verbose', 'Verbose output (debug info)')
10
+ .option('-q, --quiet', 'Minimal output (errors only)')
11
+ .option('--no-color', 'Disable colored output')
12
+ .option('--dry-run', 'Preview changes (where applicable)');
13
+ }
14
+ /**
15
+ * Resolve global flags from parsed options, applying TTY detection defaults.
16
+ */
17
+ export function resolveFlags(opts) {
18
+ const isTTY = process.stdout.isTTY ?? false;
19
+ const noColor = opts.noColor === true || opts.color === false;
20
+ const format = opts.output ?? (isTTY ? 'text' : 'json');
21
+ if (noColor) {
22
+ chalk.level = 0;
23
+ }
24
+ return {
25
+ output: format,
26
+ verbose: opts.verbose === true,
27
+ quiet: opts.quiet === true,
28
+ noColor,
29
+ dryRun: opts.dryRun === true,
30
+ };
31
+ }
@@ -0,0 +1,2 @@
1
+ export declare function formatBytes(bytes: number): string;
2
+ export declare function formatUptime(seconds: number): string;
@@ -0,0 +1,22 @@
1
+ export function formatBytes(bytes) {
2
+ if (bytes === 0)
3
+ return '0 B';
4
+ const units = ['B', 'KB', 'MB', 'GB', 'TB'];
5
+ const i = Math.floor(Math.log(bytes) / Math.log(1024));
6
+ return `${(bytes / Math.pow(1024, i)).toFixed(1)} ${units[i]}`;
7
+ }
8
+ export function formatUptime(seconds) {
9
+ const days = Math.floor(seconds / 86400);
10
+ const hours = Math.floor((seconds % 86400) / 3600);
11
+ const mins = Math.floor((seconds % 3600) / 60);
12
+ const secs = seconds % 60;
13
+ const parts = [];
14
+ if (days > 0)
15
+ parts.push(`${days}d`);
16
+ if (hours > 0)
17
+ parts.push(`${hours}h`);
18
+ parts.push(`${mins}m`);
19
+ if (days === 0)
20
+ parts.push(`${secs}s`);
21
+ return parts.join(' ');
22
+ }