@lifestreamdynamics/vault-cli 1.3.11 → 1.4.1
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/dist/commands/audit.js +1 -1
- package/dist/sync/remote-poller.js +84 -54
- package/dist/sync/types.d.ts +2 -0
- package/dist/sync/watcher.js +18 -11
- package/package.json +2 -2
package/dist/commands/audit.js
CHANGED
|
@@ -2,7 +2,7 @@ import chalk from 'chalk';
|
|
|
2
2
|
import fs from 'node:fs';
|
|
3
3
|
import path from 'node:path';
|
|
4
4
|
import os from 'node:os';
|
|
5
|
-
import { AuditLogger } from '@lifestreamdynamics/vault-sdk';
|
|
5
|
+
import { AuditLogger } from '@lifestreamdynamics/vault-sdk/audit';
|
|
6
6
|
import { addGlobalFlags, resolveFlags } from '../utils/flags.js';
|
|
7
7
|
import { createOutput, handleError } from '../utils/output.js';
|
|
8
8
|
const DEFAULT_LOG_PATH = path.join(os.homedir(), '.lsvault', 'audit.log');
|
|
@@ -22,62 +22,91 @@ export function createRemotePoller(client, config, options) {
|
|
|
22
22
|
return; // Skip if previous poll still in progress
|
|
23
23
|
polling = true;
|
|
24
24
|
try {
|
|
25
|
-
const remoteDocs = await client.documents.list(config.vaultId);
|
|
26
25
|
const state = loadSyncState(config.id);
|
|
27
26
|
let changes = 0;
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
27
|
+
let stateMutated = false;
|
|
28
|
+
// Build the known state from persisted remote hashes + stored list ETag.
|
|
29
|
+
const known = {
|
|
30
|
+
hashes: Object.fromEntries(Object.entries(state.remote).map(([k, v]) => [k, v.hash])),
|
|
31
|
+
listEtag: state.remoteListEtag,
|
|
32
|
+
};
|
|
33
|
+
const sync = await client.documents.syncList(config.vaultId, known);
|
|
34
|
+
// Steady-state: server confirmed nothing changed — skip all per-doc work.
|
|
35
|
+
if (sync.vaultUnchanged)
|
|
36
|
+
return;
|
|
37
|
+
// Persist the new list ETag so the next poll can 304.
|
|
38
|
+
state.remoteListEtag = sync.listEtag;
|
|
39
|
+
stateMutated = true;
|
|
40
|
+
// Process added/changed documents.
|
|
41
|
+
for (const change of sync.changes) {
|
|
42
|
+
if (shouldIgnore(change.path, ignorePatterns))
|
|
43
43
|
continue;
|
|
44
|
+
const lastRemote = state.remote[change.path];
|
|
45
|
+
let content;
|
|
46
|
+
let remoteHash;
|
|
47
|
+
if (lastRemote) {
|
|
48
|
+
// Conditional GET — server can still 304 us if our hash is current
|
|
49
|
+
// (rare: syncList classified this as 'changed' but the GET hash matches
|
|
50
|
+
// our last-known hash — possible race between list and get).
|
|
51
|
+
const result = await client.documents.get(config.vaultId, change.path, {
|
|
52
|
+
ifNoneMatch: `"${lastRemote.hash}"`,
|
|
53
|
+
});
|
|
54
|
+
if (result.notModified) {
|
|
55
|
+
// Server confirmed our copy is current despite differing list hash.
|
|
56
|
+
// Update mtime only; no file write needed.
|
|
57
|
+
if (lastRemote.mtime !== change.fileModifiedAt) {
|
|
58
|
+
state.remote[change.path] = { ...lastRemote, mtime: change.fileModifiedAt };
|
|
59
|
+
stateMutated = true;
|
|
60
|
+
}
|
|
61
|
+
continue;
|
|
62
|
+
}
|
|
63
|
+
content = result.content;
|
|
64
|
+
remoteHash = hashFileContent(content);
|
|
44
65
|
}
|
|
45
|
-
|
|
66
|
+
else {
|
|
67
|
+
// First-time entry: unconditional GET.
|
|
68
|
+
const fetched = await client.documents.get(config.vaultId, change.path);
|
|
69
|
+
content = fetched.content;
|
|
70
|
+
remoteHash = hashFileContent(content);
|
|
71
|
+
}
|
|
72
|
+
const localFile = path.join(config.localPath, change.path);
|
|
46
73
|
const localExists = fs.existsSync(localFile);
|
|
47
74
|
if (localExists) {
|
|
48
75
|
const localContent = fs.readFileSync(localFile, 'utf-8');
|
|
49
76
|
const localHash = hashFileContent(localContent);
|
|
50
77
|
if (localHash === remoteHash) {
|
|
51
78
|
// Content is already the same — just update state
|
|
52
|
-
state.local[
|
|
53
|
-
state.remote[
|
|
79
|
+
state.local[change.path] = { path: change.path, hash: localHash, mtime: new Date().toISOString(), size: Buffer.byteLength(localContent) };
|
|
80
|
+
state.remote[change.path] = buildRemoteFileState(change.path, content, change.fileModifiedAt);
|
|
81
|
+
stateMutated = true;
|
|
54
82
|
continue;
|
|
55
83
|
}
|
|
56
84
|
// Check for conflict
|
|
57
|
-
const lastLocal = state.local[
|
|
58
|
-
const localState = { path:
|
|
59
|
-
const remoteState = { path:
|
|
85
|
+
const lastLocal = state.local[change.path];
|
|
86
|
+
const localState = { path: change.path, hash: localHash, mtime: fs.statSync(localFile).mtime.toISOString(), size: Buffer.byteLength(localContent) };
|
|
87
|
+
const remoteState = { path: change.path, hash: remoteHash, mtime: change.fileModifiedAt, size: Buffer.byteLength(content) };
|
|
60
88
|
if (detectConflict(localState, remoteState, lastLocal, lastRemote)) {
|
|
61
89
|
const resolution = resolveConflict(config.onConflict, localState, remoteState);
|
|
62
90
|
let conflictFile = null;
|
|
63
91
|
if (resolution === 'remote') {
|
|
64
|
-
conflictFile = createConflictFile(config.localPath,
|
|
65
|
-
onLocalWrite?.(
|
|
92
|
+
conflictFile = createConflictFile(config.localPath, change.path, localContent, 'local');
|
|
93
|
+
onLocalWrite?.(change.path);
|
|
66
94
|
const tmpConflict = localFile + '.tmp';
|
|
67
95
|
fs.writeFileSync(tmpConflict, content, 'utf-8');
|
|
68
96
|
fs.renameSync(tmpConflict, localFile);
|
|
69
|
-
log(`Conflict: ${
|
|
97
|
+
log(`Conflict: ${change.path} — used remote, saved local as ${conflictFile}`);
|
|
70
98
|
}
|
|
71
99
|
else {
|
|
72
|
-
conflictFile = createConflictFile(config.localPath,
|
|
73
|
-
await client.documents.put(config.vaultId,
|
|
74
|
-
log(`Conflict: ${
|
|
100
|
+
conflictFile = createConflictFile(config.localPath, change.path, content, 'remote');
|
|
101
|
+
await client.documents.put(config.vaultId, change.path, localContent);
|
|
102
|
+
log(`Conflict: ${change.path} — used local, saved remote as ${conflictFile}`);
|
|
75
103
|
}
|
|
76
|
-
onConflictLog?.(formatConflictLog(
|
|
77
|
-
state.local[
|
|
78
|
-
state.remote[
|
|
79
|
-
? buildRemoteFileState(
|
|
80
|
-
: buildRemoteFileState(
|
|
104
|
+
onConflictLog?.(formatConflictLog(change.path, resolution, conflictFile));
|
|
105
|
+
state.local[change.path] = resolution === 'remote' ? remoteState : localState;
|
|
106
|
+
state.remote[change.path] = resolution === 'remote'
|
|
107
|
+
? buildRemoteFileState(change.path, content, change.fileModifiedAt)
|
|
108
|
+
: buildRemoteFileState(change.path, localContent, new Date().toISOString());
|
|
109
|
+
stateMutated = true;
|
|
81
110
|
changes++;
|
|
82
111
|
continue;
|
|
83
112
|
}
|
|
@@ -87,40 +116,41 @@ export function createRemotePoller(client, config, options) {
|
|
|
87
116
|
if (!fs.existsSync(dir)) {
|
|
88
117
|
fs.mkdirSync(dir, { recursive: true });
|
|
89
118
|
}
|
|
90
|
-
onLocalWrite?.(
|
|
119
|
+
onLocalWrite?.(change.path);
|
|
91
120
|
const tmpFile = localFile + '.tmp';
|
|
92
121
|
fs.writeFileSync(tmpFile, content, 'utf-8');
|
|
93
122
|
fs.renameSync(tmpFile, localFile);
|
|
94
|
-
log(`Pulled: ${
|
|
123
|
+
log(`Pulled: ${change.path}`);
|
|
95
124
|
changes++;
|
|
96
|
-
state.local[
|
|
97
|
-
path:
|
|
125
|
+
state.local[change.path] = {
|
|
126
|
+
path: change.path,
|
|
98
127
|
hash: remoteHash,
|
|
99
128
|
mtime: new Date().toISOString(),
|
|
100
129
|
size: Buffer.byteLength(content),
|
|
101
130
|
};
|
|
102
|
-
state.remote[
|
|
131
|
+
state.remote[change.path] = buildRemoteFileState(change.path, content, change.fileModifiedAt);
|
|
132
|
+
stateMutated = true;
|
|
103
133
|
}
|
|
104
|
-
//
|
|
105
|
-
for (const
|
|
106
|
-
if (shouldIgnore(
|
|
134
|
+
// Process remote deletions.
|
|
135
|
+
for (const removedPath of sync.removed) {
|
|
136
|
+
if (shouldIgnore(removedPath, ignorePatterns))
|
|
107
137
|
continue;
|
|
108
|
-
const
|
|
109
|
-
if (
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
log(`Deleted local: ${docPath} (removed from remote)`);
|
|
114
|
-
changes++;
|
|
115
|
-
}
|
|
116
|
-
delete state.local[docPath];
|
|
117
|
-
delete state.remote[docPath];
|
|
138
|
+
const localFile = path.join(config.localPath, removedPath);
|
|
139
|
+
if (fs.existsSync(localFile)) {
|
|
140
|
+
fs.unlinkSync(localFile);
|
|
141
|
+
log(`Deleted local: ${removedPath} (removed from remote)`);
|
|
142
|
+
changes++;
|
|
118
143
|
}
|
|
144
|
+
delete state.local[removedPath];
|
|
145
|
+
delete state.remote[removedPath];
|
|
146
|
+
stateMutated = true;
|
|
119
147
|
}
|
|
120
|
-
if (changes > 0) {
|
|
148
|
+
if (changes > 0 || stateMutated) {
|
|
121
149
|
saveSyncState(state);
|
|
122
|
-
|
|
123
|
-
|
|
150
|
+
if (changes > 0) {
|
|
151
|
+
updateLastSync(config.id);
|
|
152
|
+
log(`Poll complete: ${changes} change(s)`);
|
|
153
|
+
}
|
|
124
154
|
}
|
|
125
155
|
}
|
|
126
156
|
catch (err) {
|
package/dist/sync/types.d.ts
CHANGED
|
@@ -51,6 +51,8 @@ export interface SyncState {
|
|
|
51
51
|
local: Record<string, FileState>;
|
|
52
52
|
/** Map of document path -> file state for remote files */
|
|
53
53
|
remote: Record<string, FileState>;
|
|
54
|
+
/** ETag from the most recent successful list response (for conditional polling). */
|
|
55
|
+
remoteListEtag?: string;
|
|
54
56
|
/** ISO 8601 timestamp when state was last updated */
|
|
55
57
|
updatedAt: string;
|
|
56
58
|
}
|
package/dist/sync/watcher.js
CHANGED
|
@@ -101,18 +101,25 @@ export function createWatcher(client, config, options) {
|
|
|
101
101
|
// Check remote for conflicts in bidirectional mode
|
|
102
102
|
if (config.mode === 'sync' && lastRemote) {
|
|
103
103
|
try {
|
|
104
|
-
const
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
104
|
+
const result = await client.documents.get(config.vaultId, docPath, {
|
|
105
|
+
ifNoneMatch: `"${lastRemote.hash}"`,
|
|
106
|
+
});
|
|
107
|
+
if (!result.notModified) {
|
|
108
|
+
// Server returned the body — check if hash differs from our last-known state.
|
|
109
|
+
const remoteHash = hashFileContent(result.content);
|
|
110
|
+
if (remoteHash !== lastRemote.hash) {
|
|
111
|
+
const conflictResult = await handleConflict({
|
|
112
|
+
absPath, docPath, localContent: content, localHash,
|
|
113
|
+
lastLocal, lastRemote,
|
|
114
|
+
remoteContent: result.content, remoteHash,
|
|
115
|
+
remoteUpdatedAt: result.document.updatedAt, state,
|
|
116
|
+
});
|
|
117
|
+
if (conflictResult !== 'skip')
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
// 200 + matching hash: rare list/cache mismatch — fall through to push.
|
|
115
121
|
}
|
|
122
|
+
// 304: remote unchanged since last sync — no conflict possible. Fall through to push.
|
|
116
123
|
}
|
|
117
124
|
catch {
|
|
118
125
|
// Remote check failed — proceed with push
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@lifestreamdynamics/vault-cli",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.4.1",
|
|
4
4
|
"description": "Command-line interface for Lifestream Vault",
|
|
5
5
|
"engines": {
|
|
6
6
|
"node": ">=22"
|
|
@@ -44,7 +44,7 @@
|
|
|
44
44
|
"prepublishOnly": "npm run build && npm test"
|
|
45
45
|
},
|
|
46
46
|
"dependencies": {
|
|
47
|
-
"@lifestreamdynamics/vault-sdk": "^2.
|
|
47
|
+
"@lifestreamdynamics/vault-sdk": "^2.2.0",
|
|
48
48
|
"chalk": "^5.4.0",
|
|
49
49
|
"chokidar": "^4.0.3",
|
|
50
50
|
"commander": "^13.0.0",
|