@lifestreamdynamics/vault-cli 1.4.4 → 1.4.6
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/README.md +59 -55
- package/dist/commands/sync.js +6 -1
- package/dist/sync/atomic-write.d.ts +25 -0
- package/dist/sync/atomic-write.js +98 -0
- package/dist/sync/daemon-worker.js +13 -1
- package/dist/sync/diff.d.ts +6 -0
- package/dist/sync/diff.js +4 -0
- package/dist/sync/engine.d.ts +2 -0
- package/dist/sync/engine.js +21 -11
- package/dist/sync/ignore.js +1 -0
- package/dist/sync/remote-poller.js +3 -6
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -158,7 +158,7 @@ lsvault auth login --api-key lsv_k_your_api_key_here
|
|
|
158
158
|
|
|
159
159
|
**Create an API Key:**
|
|
160
160
|
```bash
|
|
161
|
-
lsvault keys create
|
|
161
|
+
lsvault keys create "CI/CD Pipeline" --scopes read,write
|
|
162
162
|
```
|
|
163
163
|
|
|
164
164
|
### Email/Password Authentication
|
|
@@ -313,46 +313,52 @@ lsvault search "meeting" --tags work,urgent --limit 10
|
|
|
313
313
|
| Command | Description |
|
|
314
314
|
|---------|-------------|
|
|
315
315
|
| `lsvault teams list` | List all teams |
|
|
316
|
-
| `lsvault teams create
|
|
316
|
+
| `lsvault teams create <name>` | Create a new team |
|
|
317
317
|
| `lsvault teams get <teamId>` | Get team details |
|
|
318
318
|
| `lsvault teams update <teamId>` | Update team settings |
|
|
319
319
|
| `lsvault teams delete <teamId>` | Delete a team |
|
|
320
|
-
| `lsvault teams members <teamId>` | List team members |
|
|
321
|
-
| `lsvault teams
|
|
322
|
-
| `lsvault teams remove <teamId> <userId>` | Remove member from team |
|
|
320
|
+
| `lsvault teams members list <teamId>` | List team members |
|
|
321
|
+
| `lsvault teams members update <teamId> <userId> --role <role>` | Update a member's role (admin/editor/viewer) |
|
|
322
|
+
| `lsvault teams members remove <teamId> <userId>` | Remove member from team |
|
|
323
|
+
| `lsvault teams invitations create <teamId> <email> --role <role>` | Invite a user to the team |
|
|
324
|
+
| `lsvault teams invitations list <teamId>` | List pending invitations |
|
|
325
|
+
| `lsvault teams invitations revoke <teamId> <invitationId>` | Revoke an invitation |
|
|
326
|
+
| `lsvault teams vaults list <teamId>` | List shared team vaults |
|
|
327
|
+
| `lsvault teams vaults create <teamId> <name>` | Create a shared team vault |
|
|
323
328
|
|
|
324
329
|
**Example:**
|
|
325
330
|
```bash
|
|
326
331
|
# Create a team
|
|
327
|
-
lsvault teams create
|
|
332
|
+
lsvault teams create "Engineering" --description "Dev team workspace"
|
|
328
333
|
|
|
329
334
|
# Invite a member
|
|
330
|
-
lsvault teams
|
|
335
|
+
lsvault teams invitations create team_abc123 engineer@example.com --role editor
|
|
331
336
|
|
|
332
337
|
# List members
|
|
333
|
-
lsvault teams members team_abc123
|
|
338
|
+
lsvault teams members list team_abc123
|
|
334
339
|
```
|
|
335
340
|
|
|
336
341
|
### Sharing & Publishing
|
|
337
342
|
|
|
338
343
|
| Command | Description |
|
|
339
344
|
|---------|-------------|
|
|
340
|
-
| `lsvault shares list
|
|
345
|
+
| `lsvault shares list <vaultId> <path>` | List share links for a document |
|
|
341
346
|
| `lsvault shares create <vaultId> <path>` | Create a share link for a document |
|
|
342
|
-
| `lsvault shares revoke <shareId>` | Revoke a share link |
|
|
343
|
-
| `lsvault publish list
|
|
344
|
-
| `lsvault publish create <vaultId> <path>` | Publish a document publicly |
|
|
345
|
-
| `lsvault publish
|
|
347
|
+
| `lsvault shares revoke <vaultId> <shareId>` | Revoke a share link |
|
|
348
|
+
| `lsvault publish list <vaultId>` | List published documents in a vault |
|
|
349
|
+
| `lsvault publish create <vaultId> <path> --slug <slug>` | Publish a document publicly |
|
|
350
|
+
| `lsvault publish delete <vaultId> <path>` | Unpublish a document |
|
|
346
351
|
|
|
347
352
|
**Example:**
|
|
348
353
|
```bash
|
|
349
|
-
# Create a password-protected share link
|
|
350
|
-
lsvault shares create vault_abc123
|
|
351
|
-
--
|
|
352
|
-
--
|
|
354
|
+
# Create a password-protected share link (prompts for the password)
|
|
355
|
+
lsvault shares create vault_abc123 reports/Q1.md \
|
|
356
|
+
--permission view \
|
|
357
|
+
--protect-with-password \
|
|
358
|
+
--expires 2026-12-31
|
|
353
359
|
|
|
354
360
|
# Publish a document
|
|
355
|
-
lsvault publish create vault_abc123
|
|
361
|
+
lsvault publish create vault_abc123 blog/post.md --slug my-first-post
|
|
356
362
|
```
|
|
357
363
|
|
|
358
364
|
### Publish Vault Commands
|
|
@@ -386,26 +392,25 @@ lsvault publish-vault unpublish vault_abc123
|
|
|
386
392
|
| Command | Description |
|
|
387
393
|
|---------|-------------|
|
|
388
394
|
| `lsvault hooks list <vaultId>` | List vault hooks |
|
|
389
|
-
| `lsvault hooks create <vaultId>` | Create a new hook |
|
|
390
|
-
| `lsvault hooks
|
|
391
|
-
| `lsvault hooks
|
|
392
|
-
| `lsvault webhooks list
|
|
393
|
-
| `lsvault webhooks create
|
|
394
|
-
| `lsvault webhooks update <webhookId>` | Update webhook configuration |
|
|
395
|
-
| `lsvault webhooks delete <webhookId>` | Delete a webhook |
|
|
395
|
+
| `lsvault hooks create <vaultId> <name>` | Create a new hook |
|
|
396
|
+
| `lsvault hooks delete <vaultId> <hookId>` | Delete a hook |
|
|
397
|
+
| `lsvault hooks executions <vaultId> <hookId>` | View hook execution history |
|
|
398
|
+
| `lsvault webhooks list <vaultId>` | List vault webhooks |
|
|
399
|
+
| `lsvault webhooks create <vaultId> <url>` | Create a new webhook |
|
|
400
|
+
| `lsvault webhooks update <vaultId> <webhookId>` | Update webhook configuration |
|
|
401
|
+
| `lsvault webhooks delete <vaultId> <webhookId>` | Delete a webhook |
|
|
396
402
|
|
|
397
403
|
**Example:**
|
|
398
404
|
```bash
|
|
399
|
-
# Create an
|
|
400
|
-
lsvault hooks create vault_abc123 \
|
|
401
|
-
--
|
|
402
|
-
--
|
|
405
|
+
# Create a hook that runs an AI prompt on document creation
|
|
406
|
+
lsvault hooks create vault_abc123 "Auto-tag" \
|
|
407
|
+
--trigger document.created \
|
|
408
|
+
--action ai_prompt \
|
|
409
|
+
--config '{"prompt":"Suggest tags"}'
|
|
403
410
|
|
|
404
411
|
# Create a webhook for document updates
|
|
405
|
-
lsvault webhooks create \
|
|
406
|
-
--
|
|
407
|
-
--events document.created,document.updated \
|
|
408
|
-
--secret webhook_secret_key
|
|
412
|
+
lsvault webhooks create vault_abc123 https://api.example.com/webhook \
|
|
413
|
+
--events document.created,document.updated
|
|
409
414
|
```
|
|
410
415
|
|
|
411
416
|
### Calendar
|
|
@@ -414,10 +419,11 @@ lsvault webhooks create \
|
|
|
414
419
|
|---------|-------------|
|
|
415
420
|
| `lsvault calendar view <vaultId>` | Browse calendar views and activity heatmap |
|
|
416
421
|
| `lsvault calendar due <vaultId>` | List documents by due date |
|
|
422
|
+
| `lsvault calendar set-due <vaultId> <path> --date <date>` | Set or clear a document due date (pass `--date clear` to clear) |
|
|
417
423
|
| `lsvault calendar events <vaultId>` | List calendar events |
|
|
418
|
-
| `lsvault calendar
|
|
419
|
-
| `lsvault calendar
|
|
420
|
-
| `lsvault calendar
|
|
424
|
+
| `lsvault calendar event create <vaultId> <title>` | Create a calendar event |
|
|
425
|
+
| `lsvault calendar event update <vaultId> <eventId>` | Update a calendar event |
|
|
426
|
+
| `lsvault calendar event delete <vaultId> <eventId>` | Delete a calendar event |
|
|
421
427
|
|
|
422
428
|
### Booking Commands
|
|
423
429
|
|
|
@@ -638,13 +644,13 @@ Manage multiple configurations with profiles:
|
|
|
638
644
|
# List profiles
|
|
639
645
|
lsvault config profiles
|
|
640
646
|
|
|
641
|
-
# Create a profile
|
|
642
|
-
lsvault config
|
|
647
|
+
# Create a profile by setting a value in it (profiles are created on first use)
|
|
648
|
+
lsvault config set apiUrl https://vault.lifestreamdynamics.com --profile production
|
|
643
649
|
|
|
644
650
|
# Switch profiles
|
|
645
651
|
lsvault config use production
|
|
646
652
|
|
|
647
|
-
# Set config values
|
|
653
|
+
# Set config values (in the active profile)
|
|
648
654
|
lsvault config set apiUrl https://vault.lifestreamdynamics.com
|
|
649
655
|
|
|
650
656
|
# Get config values
|
|
@@ -759,12 +765,13 @@ lsvault sync watch sync_xyz789
|
|
|
759
765
|
lsvault search "quarterly report" --vault vault_abc123 -o json
|
|
760
766
|
|
|
761
767
|
# Create a share link for the found document
|
|
762
|
-
lsvault shares create vault_abc123
|
|
763
|
-
--
|
|
764
|
-
--
|
|
768
|
+
lsvault shares create vault_abc123 reports/Q4-2025.md \
|
|
769
|
+
--permission view \
|
|
770
|
+
--protect-with-password \
|
|
771
|
+
--expires 2026-01-31
|
|
765
772
|
|
|
766
773
|
# Publish a document publicly
|
|
767
|
-
lsvault publish create vault_abc123
|
|
774
|
+
lsvault publish create vault_abc123 blog/announcement.md \
|
|
768
775
|
--slug new-features-2026
|
|
769
776
|
```
|
|
770
777
|
|
|
@@ -772,30 +779,27 @@ lsvault publish create vault_abc123 /blog/announcement.md \
|
|
|
772
779
|
|
|
773
780
|
```bash
|
|
774
781
|
# Create a team
|
|
775
|
-
lsvault teams create
|
|
782
|
+
lsvault teams create "Product Team" --description "Product docs"
|
|
776
783
|
|
|
777
784
|
# Create a vault
|
|
778
785
|
lsvault vaults create "Product Docs" --description "Product documentation"
|
|
779
786
|
|
|
780
787
|
# Invite team members
|
|
781
|
-
lsvault teams
|
|
782
|
-
lsvault teams
|
|
788
|
+
lsvault teams invitations create team_abc123 pm@example.com --role admin
|
|
789
|
+
lsvault teams invitations create team_abc123 dev@example.com --role editor
|
|
783
790
|
|
|
784
|
-
# Configure webhook for
|
|
785
|
-
lsvault webhooks create \
|
|
786
|
-
--
|
|
787
|
-
--events document.created,document.updated \
|
|
788
|
-
--filter '{"vaultId":"vault_xyz789"}'
|
|
791
|
+
# Configure a webhook for vault updates
|
|
792
|
+
lsvault webhooks create vault_xyz789 https://slack.example.com/webhook \
|
|
793
|
+
--events document.created,document.updated
|
|
789
794
|
```
|
|
790
795
|
|
|
791
796
|
### Automation with API Keys
|
|
792
797
|
|
|
793
798
|
```bash
|
|
794
799
|
# Create a read-only API key for monitoring
|
|
795
|
-
lsvault keys create \
|
|
796
|
-
--
|
|
797
|
-
--
|
|
798
|
-
--expires-in 90d
|
|
800
|
+
lsvault keys create "Monitoring Script" \
|
|
801
|
+
--scopes read \
|
|
802
|
+
--expires 2026-12-31
|
|
799
803
|
|
|
800
804
|
# Use API key in scripts
|
|
801
805
|
export LSVAULT_API_KEY=lsv_k_generated_key
|
package/dist/commands/sync.js
CHANGED
|
@@ -9,7 +9,7 @@ import { formatUptime } from '../utils/format.js';
|
|
|
9
9
|
import { loadSyncConfigs, createSyncConfig, deleteSyncConfig, getSyncConfig, } from '../sync/config.js';
|
|
10
10
|
import { deleteSyncState, loadSyncState, saveSyncState, hashFileContent, buildRemoteFileState } from '../sync/state.js';
|
|
11
11
|
import { resolveIgnorePatterns } from '../sync/ignore.js';
|
|
12
|
-
import { scanLocalFiles, scanRemoteFiles, executePull, executePush, computePullDiff, computePushDiff, resolveConcurrency, } from '../sync/engine.js';
|
|
12
|
+
import { scanLocalFiles, scanRemoteFiles, executePull, executePush, computePullDiff, computePushDiff, resolveConcurrency, sweepOrphanedTempFiles, } from '../sync/engine.js';
|
|
13
13
|
import { formatDiff } from '../sync/diff.js';
|
|
14
14
|
import { createWatcher } from '../sync/watcher.js';
|
|
15
15
|
import { createRemotePoller } from '../sync/remote-poller.js';
|
|
@@ -168,6 +168,11 @@ Sync modes:
|
|
|
168
168
|
const client = await getClientAsync();
|
|
169
169
|
const ignorePatterns = resolveIgnorePatterns(config.ignore, config.localPath);
|
|
170
170
|
const lastState = loadSyncState(config.id);
|
|
171
|
+
// Clean up any orphaned temp files left by a prior interrupted pull.
|
|
172
|
+
const swept = sweepOrphanedTempFiles(config.localPath);
|
|
173
|
+
if (swept > 0) {
|
|
174
|
+
out.debug(`Removed ${swept} orphaned temp file(s) from ${config.localPath}`);
|
|
175
|
+
}
|
|
171
176
|
out.startSpinner('Scanning local files...');
|
|
172
177
|
const localFiles = scanLocalFiles(config.localPath, ignorePatterns, lastState);
|
|
173
178
|
out.debug(`Found ${Object.keys(localFiles).length} local files`);
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Write `content` to `targetPath` atomically.
|
|
3
|
+
*
|
|
4
|
+
* A uniquely named temp file is created alongside the target. On success it
|
|
5
|
+
* is renamed to the target (atomic on POSIX). If the write or rename throws,
|
|
6
|
+
* the temp file is best-effort deleted before the original error is re-thrown,
|
|
7
|
+
* guaranteeing no orphaned `.tmp.<hash>` files are left behind.
|
|
8
|
+
*/
|
|
9
|
+
export declare function atomicWriteFileSync(targetPath: string, content: string, encoding?: BufferEncoding): void;
|
|
10
|
+
/**
|
|
11
|
+
* Recursively walk `rootDir` and delete orphaned temp files.
|
|
12
|
+
*
|
|
13
|
+
* A temp file is considered orphaned when its canonical counterpart — the
|
|
14
|
+
* path with the `.tmp[.<hash>]` suffix stripped — already exists on disk.
|
|
15
|
+
* This guard ensures only leftover cruft is removed, never unique content.
|
|
16
|
+
*
|
|
17
|
+
* Dirs named `.git`, `node_modules`, or starting with `.` (hidden dirs) and
|
|
18
|
+
* the `.lsvault` dir are skipped, matching the sync engine's own exclusions.
|
|
19
|
+
*
|
|
20
|
+
* Any individual fs error (unreadable dir, permission denied, etc.) is caught
|
|
21
|
+
* and skipped so a single bad entry cannot abort the sweep.
|
|
22
|
+
*
|
|
23
|
+
* @returns the number of orphaned temp files deleted.
|
|
24
|
+
*/
|
|
25
|
+
export declare function sweepOrphanedTempFiles(rootDir: string): number;
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Atomic file-write helpers shared by the sync engine and remote poller.
|
|
3
|
+
*
|
|
4
|
+
* All writes use a temp-file + rename strategy so interrupted writes never
|
|
5
|
+
* leave a partial file at the target path. On any error the temp file is
|
|
6
|
+
* cleaned up before the error is re-thrown.
|
|
7
|
+
*/
|
|
8
|
+
import fs from 'node:fs';
|
|
9
|
+
import path from 'node:path';
|
|
10
|
+
import { randomBytes } from 'node:crypto';
|
|
11
|
+
/**
|
|
12
|
+
* Write `content` to `targetPath` atomically.
|
|
13
|
+
*
|
|
14
|
+
* A uniquely named temp file is created alongside the target. On success it
|
|
15
|
+
* is renamed to the target (atomic on POSIX). If the write or rename throws,
|
|
16
|
+
* the temp file is best-effort deleted before the original error is re-thrown,
|
|
17
|
+
* guaranteeing no orphaned `.tmp.<hash>` files are left behind.
|
|
18
|
+
*/
|
|
19
|
+
export function atomicWriteFileSync(targetPath, content, encoding = 'utf-8') {
|
|
20
|
+
const tmpFile = targetPath + '.tmp.' + randomBytes(4).toString('hex');
|
|
21
|
+
try {
|
|
22
|
+
fs.writeFileSync(tmpFile, content, encoding);
|
|
23
|
+
fs.renameSync(tmpFile, targetPath);
|
|
24
|
+
}
|
|
25
|
+
catch (err) {
|
|
26
|
+
try {
|
|
27
|
+
fs.unlinkSync(tmpFile);
|
|
28
|
+
}
|
|
29
|
+
catch {
|
|
30
|
+
// Ignore — the file may not exist if writeFileSync was what threw.
|
|
31
|
+
}
|
|
32
|
+
throw err;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* Regex that matches both forms of orphaned temp-file suffix:
|
|
37
|
+
* - `.tmp` (static suffix used by the legacy inline write in remote-poller)
|
|
38
|
+
* - `.tmp.<8hex>` (randomised suffix used by atomicWriteFileSync)
|
|
39
|
+
*/
|
|
40
|
+
const ORPHAN_TEMP_RE = /\.tmp(\.[0-9a-f]{8})?$/;
|
|
41
|
+
/**
|
|
42
|
+
* Recursively walk `rootDir` and delete orphaned temp files.
|
|
43
|
+
*
|
|
44
|
+
* A temp file is considered orphaned when its canonical counterpart — the
|
|
45
|
+
* path with the `.tmp[.<hash>]` suffix stripped — already exists on disk.
|
|
46
|
+
* This guard ensures only leftover cruft is removed, never unique content.
|
|
47
|
+
*
|
|
48
|
+
* Dirs named `.git`, `node_modules`, or starting with `.` (hidden dirs) and
|
|
49
|
+
* the `.lsvault` dir are skipped, matching the sync engine's own exclusions.
|
|
50
|
+
*
|
|
51
|
+
* Any individual fs error (unreadable dir, permission denied, etc.) is caught
|
|
52
|
+
* and skipped so a single bad entry cannot abort the sweep.
|
|
53
|
+
*
|
|
54
|
+
* @returns the number of orphaned temp files deleted.
|
|
55
|
+
*/
|
|
56
|
+
export function sweepOrphanedTempFiles(rootDir) {
|
|
57
|
+
let removed = 0;
|
|
58
|
+
function walk(dir) {
|
|
59
|
+
let entries;
|
|
60
|
+
try {
|
|
61
|
+
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
62
|
+
}
|
|
63
|
+
catch {
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
for (const entry of entries) {
|
|
67
|
+
const fullPath = path.join(dir, entry.name);
|
|
68
|
+
try {
|
|
69
|
+
if (entry.isDirectory()) {
|
|
70
|
+
// Skip hidden dirs, node_modules, .lsvault
|
|
71
|
+
if (entry.name.startsWith('.') ||
|
|
72
|
+
entry.name === 'node_modules') {
|
|
73
|
+
continue;
|
|
74
|
+
}
|
|
75
|
+
walk(fullPath);
|
|
76
|
+
}
|
|
77
|
+
else if (entry.isFile() && ORPHAN_TEMP_RE.test(entry.name)) {
|
|
78
|
+
// Derive the canonical sibling path by stripping the temp suffix.
|
|
79
|
+
const canonical = fullPath.replace(ORPHAN_TEMP_RE, '');
|
|
80
|
+
if (fs.existsSync(canonical)) {
|
|
81
|
+
try {
|
|
82
|
+
fs.unlinkSync(fullPath);
|
|
83
|
+
removed++;
|
|
84
|
+
}
|
|
85
|
+
catch {
|
|
86
|
+
// Ignore individual unlink failures.
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
catch {
|
|
92
|
+
// Ignore stat/access errors for individual entries.
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
walk(rootDir);
|
|
97
|
+
return removed;
|
|
98
|
+
}
|
|
@@ -10,7 +10,7 @@ import { createRemotePoller } from './remote-poller.js';
|
|
|
10
10
|
import { removePid } from './daemon.js';
|
|
11
11
|
import { loadConfigAsync } from '../config.js';
|
|
12
12
|
import { LifestreamVaultClient } from '@lifestreamdynamics/vault-sdk';
|
|
13
|
-
import { scanLocalFiles, scanRemoteFiles, computePushDiff, computePullDiff, executePush, executePull } from './engine.js';
|
|
13
|
+
import { scanLocalFiles, scanRemoteFiles, computePushDiff, computePullDiff, executePush, executePull, sweepOrphanedTempFiles } from './engine.js';
|
|
14
14
|
import { loadSyncState, saveSyncState } from './state.js';
|
|
15
15
|
const managed = [];
|
|
16
16
|
function log(msg) {
|
|
@@ -43,6 +43,18 @@ async function start() {
|
|
|
43
43
|
process.exit(0);
|
|
44
44
|
}
|
|
45
45
|
log(`Found ${configs.length} auto-sync configuration(s)`);
|
|
46
|
+
// One-time orphan sweep: remove any temp files left by prior interrupted pulls.
|
|
47
|
+
for (const config of configs) {
|
|
48
|
+
try {
|
|
49
|
+
const swept = sweepOrphanedTempFiles(config.localPath);
|
|
50
|
+
if (swept > 0) {
|
|
51
|
+
log(`Swept ${swept} orphaned temp file(s) from ${config.localPath}`);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
catch {
|
|
55
|
+
// Non-fatal — continue startup.
|
|
56
|
+
}
|
|
57
|
+
}
|
|
46
58
|
const client = await createClient();
|
|
47
59
|
// Startup reconciliation: catch changes made while daemon was stopped
|
|
48
60
|
for (const config of configs) {
|
package/dist/sync/diff.d.ts
CHANGED
|
@@ -16,6 +16,12 @@ export interface SyncDiffEntry {
|
|
|
16
16
|
sizeBytes: number;
|
|
17
17
|
/** Human-readable reason for this change */
|
|
18
18
|
reason: string;
|
|
19
|
+
/**
|
|
20
|
+
* SHA-256 hash of the remote file at diff-computation time.
|
|
21
|
+
* Present on download entries so executePull can issue a conditional GET
|
|
22
|
+
* (If-None-Match) and skip the write when the local file is already current.
|
|
23
|
+
*/
|
|
24
|
+
remoteHash?: string;
|
|
19
25
|
}
|
|
20
26
|
export interface SyncDiff {
|
|
21
27
|
/** Files to upload (local -> remote) */
|
package/dist/sync/diff.js
CHANGED
|
@@ -19,6 +19,7 @@ export function computePullDiff(localFiles, remoteFiles, lastState) {
|
|
|
19
19
|
direction: 'download',
|
|
20
20
|
sizeBytes: remote.size,
|
|
21
21
|
reason: 'Deleted locally, exists remotely (pull restores)',
|
|
22
|
+
remoteHash: remote.hash,
|
|
22
23
|
});
|
|
23
24
|
}
|
|
24
25
|
else {
|
|
@@ -29,6 +30,7 @@ export function computePullDiff(localFiles, remoteFiles, lastState) {
|
|
|
29
30
|
direction: 'download',
|
|
30
31
|
sizeBytes: remote.size,
|
|
31
32
|
reason: 'New remote file',
|
|
33
|
+
remoteHash: remote.hash,
|
|
32
34
|
});
|
|
33
35
|
}
|
|
34
36
|
}
|
|
@@ -40,6 +42,7 @@ export function computePullDiff(localFiles, remoteFiles, lastState) {
|
|
|
40
42
|
direction: 'download',
|
|
41
43
|
sizeBytes: remote.size,
|
|
42
44
|
reason: 'Remote file updated',
|
|
45
|
+
remoteHash: remote.hash,
|
|
43
46
|
});
|
|
44
47
|
}
|
|
45
48
|
else if (!lastRemote && remote.hash !== local.hash) {
|
|
@@ -50,6 +53,7 @@ export function computePullDiff(localFiles, remoteFiles, lastState) {
|
|
|
50
53
|
direction: 'download',
|
|
51
54
|
sizeBytes: remote.size,
|
|
52
55
|
reason: 'Content differs (first sync, pull prefers remote)',
|
|
56
|
+
remoteHash: remote.hash,
|
|
53
57
|
});
|
|
54
58
|
}
|
|
55
59
|
}
|
package/dist/sync/engine.d.ts
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import type { LifestreamVaultClient } from '@lifestreamdynamics/vault-sdk';
|
|
2
2
|
import type { SyncConfig, SyncState, FileState } from './types.js';
|
|
3
3
|
import { computePullDiff, computePushDiff, type SyncDiff, type SyncDiffEntry } from './diff.js';
|
|
4
|
+
import { sweepOrphanedTempFiles } from './atomic-write.js';
|
|
5
|
+
export { sweepOrphanedTempFiles };
|
|
4
6
|
export interface SyncProgress {
|
|
5
7
|
phase: 'scanning' | 'computing' | 'transferring' | 'complete';
|
|
6
8
|
current: number;
|
package/dist/sync/engine.js
CHANGED
|
@@ -3,11 +3,12 @@
|
|
|
3
3
|
*/
|
|
4
4
|
import fs from 'node:fs';
|
|
5
5
|
import path from 'node:path';
|
|
6
|
-
import { randomBytes } from 'node:crypto';
|
|
7
6
|
import { loadSyncState, saveSyncState, hashFileContent, buildRemoteFileState } from './state.js';
|
|
8
7
|
import { updateLastSync } from './config.js';
|
|
9
8
|
import { shouldIgnore } from './ignore.js';
|
|
10
9
|
import { computePullDiff, computePushDiff } from './diff.js';
|
|
10
|
+
import { atomicWriteFileSync, sweepOrphanedTempFiles } from './atomic-write.js';
|
|
11
|
+
export { sweepOrphanedTempFiles };
|
|
11
12
|
/**
|
|
12
13
|
* Scan local directory recursively for .md files.
|
|
13
14
|
* Returns a map of relative doc paths -> FileState.
|
|
@@ -110,15 +111,6 @@ export async function scanRemoteFiles(client, vaultId, ignorePatterns, knownStat
|
|
|
110
111
|
}
|
|
111
112
|
return { files, listEtag: sync.listEtag, vaultUnchanged: false };
|
|
112
113
|
}
|
|
113
|
-
/**
|
|
114
|
-
* Write a file atomically using a temp file + rename.
|
|
115
|
-
* Prevents partial reads if the process is interrupted mid-write.
|
|
116
|
-
*/
|
|
117
|
-
function atomicWriteFileSync(targetPath, content, encoding = 'utf-8') {
|
|
118
|
-
const tmpFile = targetPath + '.tmp.' + randomBytes(4).toString('hex');
|
|
119
|
-
fs.writeFileSync(tmpFile, content, encoding);
|
|
120
|
-
fs.renameSync(tmpFile, targetPath);
|
|
121
|
-
}
|
|
122
114
|
/**
|
|
123
115
|
* Default in-flight transfer count. A small number flattens the load1 spike
|
|
124
116
|
* a full-vault `sync pull` causes on the API host without making single
|
|
@@ -249,9 +241,27 @@ export async function executePull(client, config, diff, onProgress, concurrency,
|
|
|
249
241
|
deletes: diff.deletes,
|
|
250
242
|
transferCounterKey: 'filesDownloaded',
|
|
251
243
|
async transferFile(entry, cfg, throttleCallback) {
|
|
252
|
-
const { content } = await retryWithBackoff(() => client.documents.get(cfg.vaultId, entry.path), throttleCallback ? () => throttleCallback(entry.path) : undefined);
|
|
253
244
|
const localFile = path.join(cfg.localPath, entry.path);
|
|
254
245
|
const localDir = path.dirname(localFile);
|
|
246
|
+
// Only use a conditional GET when (a) we have the remote hash AND (b)
|
|
247
|
+
// the local file already exists. For 'create' entries the local file
|
|
248
|
+
// does not exist yet — the server will always 304 (our remoteHash IS the
|
|
249
|
+
// server's current hash), which would send the 304 branch into a
|
|
250
|
+
// readFileSync on a non-existent path (ENOENT). Guarding on existsSync
|
|
251
|
+
// also makes the readFileSync in the 304 branch provably safe.
|
|
252
|
+
const useConditional = entry.remoteHash !== undefined && fs.existsSync(localFile);
|
|
253
|
+
const result = await retryWithBackoff(() => useConditional
|
|
254
|
+
? client.documents.get(cfg.vaultId, entry.path, { ifNoneMatch: `"${entry.remoteHash}"` })
|
|
255
|
+
: client.documents.get(cfg.vaultId, entry.path), throttleCallback ? () => throttleCallback(entry.path) : undefined);
|
|
256
|
+
// 304 Not Modified — local already has the right content; skip the write.
|
|
257
|
+
// Safe: only reached when useConditional === true, which requires the
|
|
258
|
+
// local file to exist.
|
|
259
|
+
if ('notModified' in result && result.notModified) {
|
|
260
|
+
const localContent = fs.readFileSync(localFile, 'utf-8');
|
|
261
|
+
return localContent;
|
|
262
|
+
}
|
|
263
|
+
// 200 response — result has `content` (handle both shapes of the union).
|
|
264
|
+
const content = result.content;
|
|
255
265
|
if (!fs.existsSync(localDir)) {
|
|
256
266
|
fs.mkdirSync(localDir, { recursive: true });
|
|
257
267
|
}
|
package/dist/sync/ignore.js
CHANGED
|
@@ -9,6 +9,7 @@ import { loadSyncState, saveSyncState, hashFileContent, buildRemoteFileState } f
|
|
|
9
9
|
import { updateLastSync } from './config.js';
|
|
10
10
|
import { resolveConflict, detectConflict, createConflictFile, formatConflictLog } from './conflict.js';
|
|
11
11
|
import { isThrottleError } from './engine.js';
|
|
12
|
+
import { atomicWriteFileSync } from './atomic-write.js';
|
|
12
13
|
/**
|
|
13
14
|
* Creates and starts a remote poller for a sync configuration.
|
|
14
15
|
* Returns a stop function.
|
|
@@ -92,9 +93,7 @@ export function createRemotePoller(client, config, options) {
|
|
|
92
93
|
if (resolution === 'remote') {
|
|
93
94
|
conflictFile = createConflictFile(config.localPath, change.path, localContent, 'local');
|
|
94
95
|
onLocalWrite?.(change.path);
|
|
95
|
-
|
|
96
|
-
fs.writeFileSync(tmpConflict, content, 'utf-8');
|
|
97
|
-
fs.renameSync(tmpConflict, localFile);
|
|
96
|
+
atomicWriteFileSync(localFile, content, 'utf-8');
|
|
98
97
|
log(`Conflict: ${change.path} — used remote, saved local as ${conflictFile}`);
|
|
99
98
|
}
|
|
100
99
|
else {
|
|
@@ -118,9 +117,7 @@ export function createRemotePoller(client, config, options) {
|
|
|
118
117
|
fs.mkdirSync(dir, { recursive: true });
|
|
119
118
|
}
|
|
120
119
|
onLocalWrite?.(change.path);
|
|
121
|
-
|
|
122
|
-
fs.writeFileSync(tmpFile, content, 'utf-8');
|
|
123
|
-
fs.renameSync(tmpFile, localFile);
|
|
120
|
+
atomicWriteFileSync(localFile, content, 'utf-8');
|
|
124
121
|
log(`Pulled: ${change.path}`);
|
|
125
122
|
changes++;
|
|
126
123
|
state.local[change.path] = {
|