@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.
- package/LICENSE +21 -0
- package/README.md +759 -0
- package/dist/client.d.ts +12 -0
- package/dist/client.js +79 -0
- package/dist/commands/admin.d.ts +2 -0
- package/dist/commands/admin.js +263 -0
- package/dist/commands/audit.d.ts +2 -0
- package/dist/commands/audit.js +119 -0
- package/dist/commands/auth.d.ts +2 -0
- package/dist/commands/auth.js +256 -0
- package/dist/commands/config.d.ts +2 -0
- package/dist/commands/config.js +130 -0
- package/dist/commands/connectors.d.ts +2 -0
- package/dist/commands/connectors.js +224 -0
- package/dist/commands/docs.d.ts +2 -0
- package/dist/commands/docs.js +194 -0
- package/dist/commands/hooks.d.ts +2 -0
- package/dist/commands/hooks.js +159 -0
- package/dist/commands/keys.d.ts +2 -0
- package/dist/commands/keys.js +165 -0
- package/dist/commands/publish.d.ts +2 -0
- package/dist/commands/publish.js +138 -0
- package/dist/commands/search.d.ts +2 -0
- package/dist/commands/search.js +61 -0
- package/dist/commands/shares.d.ts +2 -0
- package/dist/commands/shares.js +121 -0
- package/dist/commands/subscription.d.ts +2 -0
- package/dist/commands/subscription.js +166 -0
- package/dist/commands/sync.d.ts +2 -0
- package/dist/commands/sync.js +565 -0
- package/dist/commands/teams.d.ts +2 -0
- package/dist/commands/teams.js +322 -0
- package/dist/commands/user.d.ts +2 -0
- package/dist/commands/user.js +48 -0
- package/dist/commands/vaults.d.ts +2 -0
- package/dist/commands/vaults.js +157 -0
- package/dist/commands/versions.d.ts +2 -0
- package/dist/commands/versions.js +219 -0
- package/dist/commands/webhooks.d.ts +2 -0
- package/dist/commands/webhooks.js +181 -0
- package/dist/config.d.ts +24 -0
- package/dist/config.js +88 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +63 -0
- package/dist/lib/credential-manager.d.ts +48 -0
- package/dist/lib/credential-manager.js +101 -0
- package/dist/lib/encrypted-config.d.ts +20 -0
- package/dist/lib/encrypted-config.js +102 -0
- package/dist/lib/keychain.d.ts +8 -0
- package/dist/lib/keychain.js +82 -0
- package/dist/lib/migration.d.ts +31 -0
- package/dist/lib/migration.js +92 -0
- package/dist/lib/profiles.d.ts +43 -0
- package/dist/lib/profiles.js +104 -0
- package/dist/sync/config.d.ts +32 -0
- package/dist/sync/config.js +100 -0
- package/dist/sync/conflict.d.ts +30 -0
- package/dist/sync/conflict.js +60 -0
- package/dist/sync/daemon-worker.d.ts +1 -0
- package/dist/sync/daemon-worker.js +128 -0
- package/dist/sync/daemon.d.ts +44 -0
- package/dist/sync/daemon.js +174 -0
- package/dist/sync/diff.d.ts +43 -0
- package/dist/sync/diff.js +166 -0
- package/dist/sync/engine.d.ts +41 -0
- package/dist/sync/engine.js +233 -0
- package/dist/sync/ignore.d.ts +16 -0
- package/dist/sync/ignore.js +72 -0
- package/dist/sync/remote-poller.d.ts +23 -0
- package/dist/sync/remote-poller.js +145 -0
- package/dist/sync/state.d.ts +32 -0
- package/dist/sync/state.js +98 -0
- package/dist/sync/types.d.ts +68 -0
- package/dist/sync/types.js +4 -0
- package/dist/sync/watcher.d.ts +23 -0
- package/dist/sync/watcher.js +207 -0
- package/dist/utils/flags.d.ts +18 -0
- package/dist/utils/flags.js +31 -0
- package/dist/utils/format.d.ts +2 -0
- package/dist/utils/format.js +22 -0
- package/dist/utils/output.d.ts +87 -0
- package/dist/utils/output.js +229 -0
- package/package.json +62 -0
|
@@ -0,0 +1,565 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import chalk from 'chalk';
|
|
4
|
+
import { getClient } from '../client.js';
|
|
5
|
+
import { addGlobalFlags, resolveFlags } from '../utils/flags.js';
|
|
6
|
+
import { createOutput, handleError } from '../utils/output.js';
|
|
7
|
+
import { formatUptime } from '../utils/format.js';
|
|
8
|
+
import { loadSyncConfigs, createSyncConfig, deleteSyncConfig, getSyncConfig, } from '../sync/config.js';
|
|
9
|
+
import { deleteSyncState, loadSyncState, saveSyncState, hashFileContent, buildRemoteFileState } from '../sync/state.js';
|
|
10
|
+
import { resolveIgnorePatterns } from '../sync/ignore.js';
|
|
11
|
+
import { scanLocalFiles, scanRemoteFiles, executePull, executePush, computePullDiff, computePushDiff, } from '../sync/engine.js';
|
|
12
|
+
import { formatDiff } from '../sync/diff.js';
|
|
13
|
+
import { createWatcher } from '../sync/watcher.js';
|
|
14
|
+
import { createRemotePoller } from '../sync/remote-poller.js';
|
|
15
|
+
import { startDaemon, stopDaemon, getDaemonStatus } from '../sync/daemon.js';
|
|
16
|
+
export function registerSyncCommands(program) {
|
|
17
|
+
const sync = program.command('sync').description('Configure and manage vault sync');
|
|
18
|
+
// sync init <vaultId> <localPath>
|
|
19
|
+
addGlobalFlags(sync.command('init')
|
|
20
|
+
.description('Initialize sync for a vault to a local directory')
|
|
21
|
+
.argument('<vaultId>', 'Vault ID to sync')
|
|
22
|
+
.argument('<localPath>', 'Local directory path')
|
|
23
|
+
.option('--mode <mode>', 'Sync mode: pull, push, sync (default: sync)')
|
|
24
|
+
.option('--on-conflict <strategy>', 'Conflict strategy: newer, local, remote, ask (default: newer)')
|
|
25
|
+
.option('--ignore <patterns...>', 'Glob patterns to ignore')
|
|
26
|
+
.option('--interval <interval>', 'Auto-sync interval (e.g., 5m, 1h)')
|
|
27
|
+
.option('--auto-sync', 'Enable auto-sync'))
|
|
28
|
+
.action(async (vaultId, localPath, _opts) => {
|
|
29
|
+
const flags = resolveFlags(_opts);
|
|
30
|
+
const out = createOutput(flags);
|
|
31
|
+
out.startSpinner('Initializing sync...');
|
|
32
|
+
try {
|
|
33
|
+
const client = getClient();
|
|
34
|
+
const vault = await client.vaults.get(vaultId);
|
|
35
|
+
const absPath = path.resolve(localPath);
|
|
36
|
+
const mode = _opts.mode ?? 'sync';
|
|
37
|
+
const onConflict = _opts.onConflict ?? 'newer';
|
|
38
|
+
const ignore = _opts.ignore;
|
|
39
|
+
const syncInterval = _opts.interval;
|
|
40
|
+
const autoSync = _opts.autoSync === true;
|
|
41
|
+
const config = createSyncConfig({
|
|
42
|
+
vaultId,
|
|
43
|
+
localPath: absPath,
|
|
44
|
+
mode,
|
|
45
|
+
onConflict,
|
|
46
|
+
ignore,
|
|
47
|
+
syncInterval,
|
|
48
|
+
autoSync,
|
|
49
|
+
});
|
|
50
|
+
out.success(`Sync initialized for vault "${vault.name}"`, {
|
|
51
|
+
id: config.id,
|
|
52
|
+
vaultId: config.vaultId,
|
|
53
|
+
localPath: config.localPath,
|
|
54
|
+
mode: config.mode,
|
|
55
|
+
onConflict: config.onConflict,
|
|
56
|
+
autoSync: config.autoSync,
|
|
57
|
+
});
|
|
58
|
+
if (flags.output === 'text' && !flags.quiet) {
|
|
59
|
+
out.status('');
|
|
60
|
+
out.status(`Run ${chalk.cyan(`lsvault sync pull ${config.id}`)} or ${chalk.cyan(`lsvault sync push ${config.id}`)} to perform the first sync.`);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
catch (err) {
|
|
64
|
+
handleError(out, err, 'Failed to initialize sync');
|
|
65
|
+
}
|
|
66
|
+
});
|
|
67
|
+
// sync list
|
|
68
|
+
addGlobalFlags(sync.command('list')
|
|
69
|
+
.description('List all sync configurations'))
|
|
70
|
+
.action(async (_opts) => {
|
|
71
|
+
const flags = resolveFlags(_opts);
|
|
72
|
+
const out = createOutput(flags);
|
|
73
|
+
try {
|
|
74
|
+
const configs = loadSyncConfigs();
|
|
75
|
+
out.list(configs.map(c => ({
|
|
76
|
+
id: c.id,
|
|
77
|
+
vaultId: c.vaultId,
|
|
78
|
+
localPath: c.localPath,
|
|
79
|
+
mode: c.mode,
|
|
80
|
+
autoSync: c.autoSync,
|
|
81
|
+
lastSyncAt: c.lastSyncAt,
|
|
82
|
+
})), {
|
|
83
|
+
emptyMessage: 'No sync configurations found. Run `lsvault sync init` to create one.',
|
|
84
|
+
columns: [
|
|
85
|
+
{ key: 'id', header: 'ID', width: 36 },
|
|
86
|
+
{ key: 'vaultId', header: 'Vault' },
|
|
87
|
+
{ key: 'localPath', header: 'Local Path' },
|
|
88
|
+
{ key: 'mode', header: 'Mode' },
|
|
89
|
+
{ key: 'autoSync', header: 'Auto' },
|
|
90
|
+
],
|
|
91
|
+
textFn: (c) => {
|
|
92
|
+
const lines = [chalk.cyan(` ${String(c.id)}`)];
|
|
93
|
+
lines.push(` Vault: ${String(c.vaultId)}`);
|
|
94
|
+
lines.push(` Path: ${String(c.localPath)}`);
|
|
95
|
+
lines.push(` Mode: ${String(c.mode)}`);
|
|
96
|
+
lines.push(` Auto-sync: ${c.autoSync ? chalk.green('enabled') : chalk.dim('disabled')}`);
|
|
97
|
+
if (c.lastSyncAt && c.lastSyncAt !== '1970-01-01T00:00:00.000Z') {
|
|
98
|
+
lines.push(` Last sync: ${new Date(String(c.lastSyncAt)).toLocaleString()}`);
|
|
99
|
+
}
|
|
100
|
+
else {
|
|
101
|
+
lines.push(` Last sync: ${chalk.dim('never')}`);
|
|
102
|
+
}
|
|
103
|
+
return lines.join('\n');
|
|
104
|
+
},
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
catch (err) {
|
|
108
|
+
handleError(out, err, 'Failed to list sync configs');
|
|
109
|
+
}
|
|
110
|
+
});
|
|
111
|
+
// sync delete <syncId>
|
|
112
|
+
addGlobalFlags(sync.command('delete')
|
|
113
|
+
.description('Delete a sync configuration')
|
|
114
|
+
.argument('<syncId>', 'Sync configuration ID'))
|
|
115
|
+
.action(async (syncId, _opts) => {
|
|
116
|
+
const flags = resolveFlags(_opts);
|
|
117
|
+
const out = createOutput(flags);
|
|
118
|
+
out.startSpinner('Deleting sync configuration...');
|
|
119
|
+
try {
|
|
120
|
+
const deleted = deleteSyncConfig(syncId);
|
|
121
|
+
if (!deleted) {
|
|
122
|
+
out.failSpinner('Sync configuration not found');
|
|
123
|
+
process.exitCode = 1;
|
|
124
|
+
return;
|
|
125
|
+
}
|
|
126
|
+
deleteSyncState(syncId);
|
|
127
|
+
out.success('Sync configuration deleted', { id: syncId, deleted: true });
|
|
128
|
+
}
|
|
129
|
+
catch (err) {
|
|
130
|
+
handleError(out, err, 'Failed to delete sync configuration');
|
|
131
|
+
}
|
|
132
|
+
});
|
|
133
|
+
// sync pull <syncId>
|
|
134
|
+
addGlobalFlags(sync.command('pull')
|
|
135
|
+
.description('Pull remote changes to local directory')
|
|
136
|
+
.argument('<syncId>', 'Sync configuration ID'))
|
|
137
|
+
.action(async (syncId, _opts) => {
|
|
138
|
+
const flags = resolveFlags(_opts);
|
|
139
|
+
const out = createOutput(flags);
|
|
140
|
+
try {
|
|
141
|
+
const config = getSyncConfig(syncId);
|
|
142
|
+
if (!config) {
|
|
143
|
+
out.error(`Sync configuration not found: ${syncId}`);
|
|
144
|
+
process.exitCode = 1;
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
147
|
+
const client = getClient();
|
|
148
|
+
const ignorePatterns = resolveIgnorePatterns(config.ignore, config.localPath);
|
|
149
|
+
const lastState = loadSyncState(config.id);
|
|
150
|
+
out.startSpinner('Scanning local files...');
|
|
151
|
+
const localFiles = scanLocalFiles(config.localPath, ignorePatterns);
|
|
152
|
+
out.debug(`Found ${Object.keys(localFiles).length} local files`);
|
|
153
|
+
out.startSpinner('Scanning remote files...');
|
|
154
|
+
const remoteFiles = await scanRemoteFiles(client, config.vaultId, ignorePatterns);
|
|
155
|
+
out.debug(`Found ${Object.keys(remoteFiles).length} remote files`);
|
|
156
|
+
out.startSpinner('Computing diff...');
|
|
157
|
+
const diff = computePullDiff(localFiles, remoteFiles, lastState);
|
|
158
|
+
const totalOps = diff.downloads.length + diff.deletes.length;
|
|
159
|
+
if (totalOps === 0) {
|
|
160
|
+
out.succeedSpinner('Everything is up to date');
|
|
161
|
+
if (flags.output === 'json') {
|
|
162
|
+
out.record({ status: 'up-to-date', changes: 0 });
|
|
163
|
+
}
|
|
164
|
+
return;
|
|
165
|
+
}
|
|
166
|
+
out.stopSpinner();
|
|
167
|
+
if (flags.dryRun) {
|
|
168
|
+
out.status(chalk.yellow('Dry run — no changes will be made:'));
|
|
169
|
+
out.status(formatDiff(diff));
|
|
170
|
+
if (flags.output === 'json') {
|
|
171
|
+
out.record({
|
|
172
|
+
dryRun: true,
|
|
173
|
+
downloads: diff.downloads.length,
|
|
174
|
+
deletes: diff.deletes.length,
|
|
175
|
+
totalBytes: diff.totalBytes,
|
|
176
|
+
});
|
|
177
|
+
}
|
|
178
|
+
return;
|
|
179
|
+
}
|
|
180
|
+
if (flags.verbose) {
|
|
181
|
+
out.status(formatDiff(diff));
|
|
182
|
+
}
|
|
183
|
+
out.startSpinner(`Pulling ${totalOps} file(s)...`);
|
|
184
|
+
const result = await executePull(client, config, diff, (progress) => {
|
|
185
|
+
if (progress.phase === 'transferring' && progress.currentFile) {
|
|
186
|
+
out.startSpinner(`[${progress.current}/${progress.total}] ${progress.currentFile}`);
|
|
187
|
+
}
|
|
188
|
+
});
|
|
189
|
+
if (result.errors.length > 0) {
|
|
190
|
+
out.failSpinner(`Pull completed with ${result.errors.length} error(s)`);
|
|
191
|
+
for (const err of result.errors) {
|
|
192
|
+
out.error(` ${err.path}: ${err.error}`);
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
else {
|
|
196
|
+
out.succeedSpinner('Pull complete');
|
|
197
|
+
}
|
|
198
|
+
out.success('', {
|
|
199
|
+
downloaded: result.filesDownloaded,
|
|
200
|
+
deleted: result.filesDeleted,
|
|
201
|
+
bytesTransferred: result.bytesTransferred,
|
|
202
|
+
errors: result.errors.length,
|
|
203
|
+
});
|
|
204
|
+
}
|
|
205
|
+
catch (err) {
|
|
206
|
+
handleError(out, err, 'Pull failed');
|
|
207
|
+
}
|
|
208
|
+
});
|
|
209
|
+
// sync push <syncId>
|
|
210
|
+
addGlobalFlags(sync.command('push')
|
|
211
|
+
.description('Push local changes to remote vault')
|
|
212
|
+
.argument('<syncId>', 'Sync configuration ID'))
|
|
213
|
+
.action(async (syncId, _opts) => {
|
|
214
|
+
const flags = resolveFlags(_opts);
|
|
215
|
+
const out = createOutput(flags);
|
|
216
|
+
try {
|
|
217
|
+
const config = getSyncConfig(syncId);
|
|
218
|
+
if (!config) {
|
|
219
|
+
out.error(`Sync configuration not found: ${syncId}`);
|
|
220
|
+
process.exitCode = 1;
|
|
221
|
+
return;
|
|
222
|
+
}
|
|
223
|
+
const client = getClient();
|
|
224
|
+
const ignorePatterns = resolveIgnorePatterns(config.ignore, config.localPath);
|
|
225
|
+
const lastState = loadSyncState(config.id);
|
|
226
|
+
out.startSpinner('Scanning local files...');
|
|
227
|
+
const localFiles = scanLocalFiles(config.localPath, ignorePatterns);
|
|
228
|
+
out.debug(`Found ${Object.keys(localFiles).length} local files`);
|
|
229
|
+
out.startSpinner('Scanning remote files...');
|
|
230
|
+
const remoteFiles = await scanRemoteFiles(client, config.vaultId, ignorePatterns);
|
|
231
|
+
out.debug(`Found ${Object.keys(remoteFiles).length} remote files`);
|
|
232
|
+
out.startSpinner('Computing diff...');
|
|
233
|
+
const diff = computePushDiff(localFiles, remoteFiles, lastState);
|
|
234
|
+
const totalOps = diff.uploads.length + diff.deletes.length;
|
|
235
|
+
if (totalOps === 0) {
|
|
236
|
+
out.succeedSpinner('Everything is up to date');
|
|
237
|
+
if (flags.output === 'json') {
|
|
238
|
+
out.record({ status: 'up-to-date', changes: 0 });
|
|
239
|
+
}
|
|
240
|
+
return;
|
|
241
|
+
}
|
|
242
|
+
out.stopSpinner();
|
|
243
|
+
if (flags.dryRun) {
|
|
244
|
+
out.status(chalk.yellow('Dry run — no changes will be made:'));
|
|
245
|
+
out.status(formatDiff(diff));
|
|
246
|
+
if (flags.output === 'json') {
|
|
247
|
+
out.record({
|
|
248
|
+
dryRun: true,
|
|
249
|
+
uploads: diff.uploads.length,
|
|
250
|
+
deletes: diff.deletes.length,
|
|
251
|
+
totalBytes: diff.totalBytes,
|
|
252
|
+
});
|
|
253
|
+
}
|
|
254
|
+
return;
|
|
255
|
+
}
|
|
256
|
+
if (flags.verbose) {
|
|
257
|
+
out.status(formatDiff(diff));
|
|
258
|
+
}
|
|
259
|
+
out.startSpinner(`Pushing ${totalOps} file(s)...`);
|
|
260
|
+
const result = await executePush(client, config, diff, (progress) => {
|
|
261
|
+
if (progress.phase === 'transferring' && progress.currentFile) {
|
|
262
|
+
out.startSpinner(`[${progress.current}/${progress.total}] ${progress.currentFile}`);
|
|
263
|
+
}
|
|
264
|
+
});
|
|
265
|
+
if (result.errors.length > 0) {
|
|
266
|
+
out.failSpinner(`Push completed with ${result.errors.length} error(s)`);
|
|
267
|
+
for (const err of result.errors) {
|
|
268
|
+
out.error(` ${err.path}: ${err.error}`);
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
else {
|
|
272
|
+
out.succeedSpinner('Push complete');
|
|
273
|
+
}
|
|
274
|
+
out.success('', {
|
|
275
|
+
uploaded: result.filesUploaded,
|
|
276
|
+
deleted: result.filesDeleted,
|
|
277
|
+
bytesTransferred: result.bytesTransferred,
|
|
278
|
+
errors: result.errors.length,
|
|
279
|
+
});
|
|
280
|
+
}
|
|
281
|
+
catch (err) {
|
|
282
|
+
handleError(out, err, 'Push failed');
|
|
283
|
+
}
|
|
284
|
+
});
|
|
285
|
+
// sync status <syncId>
|
|
286
|
+
addGlobalFlags(sync.command('status')
|
|
287
|
+
.description('Show sync status and pending changes')
|
|
288
|
+
.argument('<syncId>', 'Sync configuration ID'))
|
|
289
|
+
.action(async (syncId, _opts) => {
|
|
290
|
+
const flags = resolveFlags(_opts);
|
|
291
|
+
const out = createOutput(flags);
|
|
292
|
+
try {
|
|
293
|
+
const config = getSyncConfig(syncId);
|
|
294
|
+
if (!config) {
|
|
295
|
+
out.error(`Sync configuration not found: ${syncId}`);
|
|
296
|
+
process.exitCode = 1;
|
|
297
|
+
return;
|
|
298
|
+
}
|
|
299
|
+
const client = getClient();
|
|
300
|
+
const ignorePatterns = resolveIgnorePatterns(config.ignore, config.localPath);
|
|
301
|
+
const lastState = loadSyncState(config.id);
|
|
302
|
+
out.startSpinner('Scanning...');
|
|
303
|
+
const localFiles = scanLocalFiles(config.localPath, ignorePatterns);
|
|
304
|
+
const remoteFiles = await scanRemoteFiles(client, config.vaultId, ignorePatterns);
|
|
305
|
+
const pullDiff = computePullDiff(localFiles, remoteFiles, lastState);
|
|
306
|
+
const pushDiff = computePushDiff(localFiles, remoteFiles, lastState);
|
|
307
|
+
out.stopSpinner();
|
|
308
|
+
const pullOps = pullDiff.downloads.length + pullDiff.deletes.length;
|
|
309
|
+
const pushOps = pushDiff.uploads.length + pushDiff.deletes.length;
|
|
310
|
+
if (flags.output === 'json') {
|
|
311
|
+
out.record({
|
|
312
|
+
syncId: config.id,
|
|
313
|
+
vaultId: config.vaultId,
|
|
314
|
+
localPath: config.localPath,
|
|
315
|
+
mode: config.mode,
|
|
316
|
+
localFiles: Object.keys(localFiles).length,
|
|
317
|
+
remoteFiles: Object.keys(remoteFiles).length,
|
|
318
|
+
pendingPull: pullOps,
|
|
319
|
+
pendingPush: pushOps,
|
|
320
|
+
lastSyncAt: config.lastSyncAt,
|
|
321
|
+
});
|
|
322
|
+
return;
|
|
323
|
+
}
|
|
324
|
+
out.status(`Sync: ${chalk.cyan(config.id)}`);
|
|
325
|
+
out.status(`Vault: ${config.vaultId}`);
|
|
326
|
+
out.status(`Path: ${config.localPath}`);
|
|
327
|
+
out.status(`Mode: ${config.mode}`);
|
|
328
|
+
out.status('');
|
|
329
|
+
out.status(`Local files: ${Object.keys(localFiles).length}`);
|
|
330
|
+
out.status(`Remote files: ${Object.keys(remoteFiles).length}`);
|
|
331
|
+
out.status('');
|
|
332
|
+
if (pullOps > 0) {
|
|
333
|
+
out.status(chalk.yellow(`${pullOps} pending pull operation(s):`));
|
|
334
|
+
out.status(formatDiff(pullDiff));
|
|
335
|
+
}
|
|
336
|
+
else {
|
|
337
|
+
out.status(chalk.green('Pull: up to date'));
|
|
338
|
+
}
|
|
339
|
+
out.status('');
|
|
340
|
+
if (pushOps > 0) {
|
|
341
|
+
out.status(chalk.yellow(`${pushOps} pending push operation(s):`));
|
|
342
|
+
out.status(formatDiff(pushDiff));
|
|
343
|
+
}
|
|
344
|
+
else {
|
|
345
|
+
out.status(chalk.green('Push: up to date'));
|
|
346
|
+
}
|
|
347
|
+
if (config.lastSyncAt !== '1970-01-01T00:00:00.000Z') {
|
|
348
|
+
out.status('');
|
|
349
|
+
out.status(`Last sync: ${new Date(config.lastSyncAt).toLocaleString()}`);
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
catch (err) {
|
|
353
|
+
handleError(out, err, 'Failed to get sync status');
|
|
354
|
+
}
|
|
355
|
+
});
|
|
356
|
+
// sync watch <syncId>
|
|
357
|
+
addGlobalFlags(sync.command('watch')
|
|
358
|
+
.description('Watch for changes and sync continuously')
|
|
359
|
+
.argument('<syncId>', 'Sync configuration ID')
|
|
360
|
+
.option('--poll-interval <ms>', 'Remote poll interval in milliseconds', '30000'))
|
|
361
|
+
.action(async (syncId, _opts) => {
|
|
362
|
+
const flags = resolveFlags(_opts);
|
|
363
|
+
const out = createOutput(flags);
|
|
364
|
+
try {
|
|
365
|
+
const config = getSyncConfig(syncId);
|
|
366
|
+
if (!config) {
|
|
367
|
+
out.error(`Sync configuration not found: ${syncId}`);
|
|
368
|
+
process.exitCode = 1;
|
|
369
|
+
return;
|
|
370
|
+
}
|
|
371
|
+
if (config.mode === 'pull') {
|
|
372
|
+
out.error('Watch mode is not supported for pull-only configurations. Use "sync" or "push" mode.');
|
|
373
|
+
process.exitCode = 1;
|
|
374
|
+
return;
|
|
375
|
+
}
|
|
376
|
+
const client = getClient();
|
|
377
|
+
const ignorePatterns = resolveIgnorePatterns(config.ignore, config.localPath);
|
|
378
|
+
const pollInterval = parseInt(String(_opts.pollInterval ?? '30000'), 10);
|
|
379
|
+
out.status(`Watching sync ${chalk.cyan(syncId.slice(0, 8))}...`);
|
|
380
|
+
out.status(` Vault: ${config.vaultId}`);
|
|
381
|
+
out.status(` Path: ${config.localPath}`);
|
|
382
|
+
out.status(` Mode: ${config.mode}`);
|
|
383
|
+
out.status(` Conflict: ${config.onConflict}`);
|
|
384
|
+
out.status(` Poll: ${pollInterval / 1000}s`);
|
|
385
|
+
out.status('');
|
|
386
|
+
out.status('Press Ctrl+C to stop.');
|
|
387
|
+
out.status('');
|
|
388
|
+
const logHandler = (msg) => out.debug(msg);
|
|
389
|
+
const conflictHandler = (msg) => out.warn(msg);
|
|
390
|
+
const errorHandler = (err) => out.error(err.message);
|
|
391
|
+
// Start local watcher
|
|
392
|
+
const { stop: stopWatcher } = createWatcher(client, config, {
|
|
393
|
+
ignorePatterns,
|
|
394
|
+
onLog: logHandler,
|
|
395
|
+
onConflictLog: conflictHandler,
|
|
396
|
+
onError: errorHandler,
|
|
397
|
+
});
|
|
398
|
+
// Start remote poller (only for sync and pull modes)
|
|
399
|
+
let stopPoller;
|
|
400
|
+
if (config.mode === 'sync') {
|
|
401
|
+
const poller = createRemotePoller(client, config, {
|
|
402
|
+
ignorePatterns,
|
|
403
|
+
intervalMs: pollInterval,
|
|
404
|
+
onLog: logHandler,
|
|
405
|
+
onConflictLog: conflictHandler,
|
|
406
|
+
onError: errorHandler,
|
|
407
|
+
});
|
|
408
|
+
stopPoller = poller.stop;
|
|
409
|
+
}
|
|
410
|
+
// Handle graceful shutdown
|
|
411
|
+
const shutdown = async () => {
|
|
412
|
+
out.status('\nStopping...');
|
|
413
|
+
stopPoller?.();
|
|
414
|
+
await stopWatcher();
|
|
415
|
+
out.status('Sync watch stopped.');
|
|
416
|
+
process.exit(0);
|
|
417
|
+
};
|
|
418
|
+
process.on('SIGINT', shutdown);
|
|
419
|
+
process.on('SIGTERM', shutdown);
|
|
420
|
+
// Keep process alive
|
|
421
|
+
await new Promise(() => { }); // Never resolves — relies on signal handlers
|
|
422
|
+
}
|
|
423
|
+
catch (err) {
|
|
424
|
+
handleError(out, err, 'Watch failed');
|
|
425
|
+
}
|
|
426
|
+
});
|
|
427
|
+
// sync resolve <syncId> <path> --use <local|remote>
|
|
428
|
+
addGlobalFlags(sync.command('resolve')
|
|
429
|
+
.description('Manually resolve a sync conflict')
|
|
430
|
+
.argument('<syncId>', 'Sync configuration ID')
|
|
431
|
+
.argument('<docPath>', 'Document path to resolve')
|
|
432
|
+
.requiredOption('--use <version>', 'Which version to keep: local or remote'))
|
|
433
|
+
.action(async (syncId, docPath, _opts) => {
|
|
434
|
+
const flags = resolveFlags(_opts);
|
|
435
|
+
const out = createOutput(flags);
|
|
436
|
+
out.startSpinner('Resolving conflict...');
|
|
437
|
+
try {
|
|
438
|
+
const config = getSyncConfig(syncId);
|
|
439
|
+
if (!config) {
|
|
440
|
+
out.failSpinner('Sync configuration not found');
|
|
441
|
+
process.exitCode = 1;
|
|
442
|
+
return;
|
|
443
|
+
}
|
|
444
|
+
const useVersion = String(_opts.use);
|
|
445
|
+
if (useVersion !== 'local' && useVersion !== 'remote') {
|
|
446
|
+
out.failSpinner('--use must be "local" or "remote"');
|
|
447
|
+
process.exitCode = 1;
|
|
448
|
+
return;
|
|
449
|
+
}
|
|
450
|
+
const client = getClient();
|
|
451
|
+
const localFile = path.join(config.localPath, docPath);
|
|
452
|
+
const state = loadSyncState(config.id);
|
|
453
|
+
if (useVersion === 'local') {
|
|
454
|
+
if (!fs.existsSync(localFile)) {
|
|
455
|
+
out.failSpinner(`Local file not found: ${localFile}`);
|
|
456
|
+
process.exitCode = 1;
|
|
457
|
+
return;
|
|
458
|
+
}
|
|
459
|
+
const content = fs.readFileSync(localFile, 'utf-8');
|
|
460
|
+
await client.documents.put(config.vaultId, docPath, content);
|
|
461
|
+
state.local[docPath] = {
|
|
462
|
+
path: docPath,
|
|
463
|
+
hash: hashFileContent(content),
|
|
464
|
+
mtime: new Date().toISOString(),
|
|
465
|
+
size: Buffer.byteLength(content),
|
|
466
|
+
};
|
|
467
|
+
state.remote[docPath] = buildRemoteFileState(docPath, content, new Date().toISOString());
|
|
468
|
+
}
|
|
469
|
+
else {
|
|
470
|
+
const { content } = await client.documents.get(config.vaultId, docPath);
|
|
471
|
+
const dir = path.dirname(localFile);
|
|
472
|
+
if (!fs.existsSync(dir)) {
|
|
473
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
474
|
+
}
|
|
475
|
+
fs.writeFileSync(localFile, content, 'utf-8');
|
|
476
|
+
state.local[docPath] = {
|
|
477
|
+
path: docPath,
|
|
478
|
+
hash: hashFileContent(content),
|
|
479
|
+
mtime: new Date().toISOString(),
|
|
480
|
+
size: Buffer.byteLength(content),
|
|
481
|
+
};
|
|
482
|
+
state.remote[docPath] = buildRemoteFileState(docPath, content, new Date().toISOString());
|
|
483
|
+
}
|
|
484
|
+
saveSyncState(state);
|
|
485
|
+
out.success(`Conflict resolved: ${docPath} — using ${useVersion}`, {
|
|
486
|
+
docPath,
|
|
487
|
+
resolved: useVersion,
|
|
488
|
+
});
|
|
489
|
+
}
|
|
490
|
+
catch (err) {
|
|
491
|
+
handleError(out, err, 'Failed to resolve conflict');
|
|
492
|
+
}
|
|
493
|
+
});
|
|
494
|
+
// sync daemon <start|stop|status>
|
|
495
|
+
const daemon = sync.command('daemon').description('Manage the background sync daemon');
|
|
496
|
+
addGlobalFlags(daemon.command('start')
|
|
497
|
+
.description('Start the background sync daemon')
|
|
498
|
+
.option('--log-file <path>', 'Custom log file path'))
|
|
499
|
+
.action(async (_opts) => {
|
|
500
|
+
const flags = resolveFlags(_opts);
|
|
501
|
+
const out = createOutput(flags);
|
|
502
|
+
try {
|
|
503
|
+
const logFile = _opts.logFile;
|
|
504
|
+
const pid = startDaemon(logFile);
|
|
505
|
+
out.success('Daemon started', { pid, status: 'running' });
|
|
506
|
+
}
|
|
507
|
+
catch (err) {
|
|
508
|
+
handleError(out, err, 'Failed to start daemon');
|
|
509
|
+
}
|
|
510
|
+
});
|
|
511
|
+
addGlobalFlags(daemon.command('stop')
|
|
512
|
+
.description('Stop the background sync daemon'))
|
|
513
|
+
.action(async (_opts) => {
|
|
514
|
+
const flags = resolveFlags(_opts);
|
|
515
|
+
const out = createOutput(flags);
|
|
516
|
+
try {
|
|
517
|
+
const stopped = stopDaemon();
|
|
518
|
+
if (stopped) {
|
|
519
|
+
out.success('Daemon stopped', { status: 'stopped' });
|
|
520
|
+
}
|
|
521
|
+
else {
|
|
522
|
+
out.status('Daemon is not running.');
|
|
523
|
+
}
|
|
524
|
+
}
|
|
525
|
+
catch (err) {
|
|
526
|
+
handleError(out, err, 'Failed to stop daemon');
|
|
527
|
+
}
|
|
528
|
+
});
|
|
529
|
+
addGlobalFlags(daemon.command('status')
|
|
530
|
+
.description('Show daemon status'))
|
|
531
|
+
.action(async (_opts) => {
|
|
532
|
+
const flags = resolveFlags(_opts);
|
|
533
|
+
const out = createOutput(flags);
|
|
534
|
+
try {
|
|
535
|
+
const status = getDaemonStatus();
|
|
536
|
+
if (flags.output === 'json') {
|
|
537
|
+
out.record({
|
|
538
|
+
running: status.running,
|
|
539
|
+
pid: status.pid,
|
|
540
|
+
logFile: status.logFile,
|
|
541
|
+
uptime: status.uptime,
|
|
542
|
+
startedAt: status.startedAt,
|
|
543
|
+
});
|
|
544
|
+
return;
|
|
545
|
+
}
|
|
546
|
+
if (status.running) {
|
|
547
|
+
out.status(chalk.green('Daemon is running'));
|
|
548
|
+
out.status(` PID: ${status.pid}`);
|
|
549
|
+
out.status(` Log file: ${status.logFile}`);
|
|
550
|
+
if (status.uptime !== null) {
|
|
551
|
+
out.status(` Uptime: ${formatUptime(status.uptime)}`);
|
|
552
|
+
}
|
|
553
|
+
if (status.startedAt) {
|
|
554
|
+
out.status(` Started at: ${new Date(status.startedAt).toLocaleString()}`);
|
|
555
|
+
}
|
|
556
|
+
}
|
|
557
|
+
else {
|
|
558
|
+
out.status(chalk.dim('Daemon is not running'));
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
catch (err) {
|
|
562
|
+
handleError(out, err, 'Failed to get daemon status');
|
|
563
|
+
}
|
|
564
|
+
});
|
|
565
|
+
}
|