@lifestreamdynamics/vault-cli 1.4.4 → 1.4.7

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 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 --name "CI/CD Pipeline" --scopes vaults:read,documents:read
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` | Create a new team |
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 invite <teamId>` | Invite user to team |
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 --name "Engineering" --description "Dev team workspace"
332
+ lsvault teams create "Engineering" --description "Dev team workspace"
328
333
 
329
334
  # Invite a member
330
- lsvault teams invite team_abc123 --email engineer@example.com --role member
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` | List all share links |
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` | List published documents |
344
- | `lsvault publish create <vaultId> <path>` | Publish a document publicly |
345
- | `lsvault publish unpublish <publishId>` | Unpublish a document |
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 /reports/Q1.md \
351
- --password secret123 \
352
- --expires-in 7d
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 /blog/post.md --slug my-first-post
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 update <hookId>` | Update hook configuration |
391
- | `lsvault hooks delete <hookId>` | Delete a hook |
392
- | `lsvault webhooks list` | List all webhooks |
393
- | `lsvault webhooks create` | Create a new webhook |
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 auto-tag hook
400
- lsvault hooks create vault_abc123 \
401
- --type auto-tag \
402
- --config '{"patterns":{"meeting":"#meeting"}}'
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
- --url https://api.example.com/webhook \
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 create-event <vaultId>` | Create a calendar event |
419
- | `lsvault calendar update-event <vaultId> <eventId>` | Update a calendar event |
420
- | `lsvault calendar delete-event <vaultId> <eventId>` | Delete a calendar event |
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 create-profile production --api-url https://vault.lifestreamdynamics.com
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 /reports/Q4-2025.md \
763
- --password secure123 \
764
- --expires-in 30d
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 /blog/announcement.md \
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 --name "Product Team" --description "Product docs"
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 invite team_abc123 --email pm@example.com --role admin
782
- lsvault teams invite team_abc123 --email dev@example.com --role member
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 team updates
785
- lsvault webhooks create \
786
- --url https://slack.example.com/webhook \
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
- --name "Monitoring Script" \
797
- --scopes vaults:read,documents:read \
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
@@ -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) {
@@ -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
  }
@@ -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;
@@ -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
  }
@@ -12,6 +12,7 @@ export const DEFAULT_IGNORE_PATTERNS = [
12
12
  '.hg/',
13
13
  'node_modules/',
14
14
  '*.tmp',
15
+ '*.tmp.*',
15
16
  '.DS_Store',
16
17
  'Thumbs.db',
17
18
  '.lsvault/',
@@ -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
- const tmpConflict = localFile + '.tmp';
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
- const tmpFile = localFile + '.tmp';
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] = {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lifestreamdynamics/vault-cli",
3
- "version": "1.4.4",
3
+ "version": "1.4.7",
4
4
  "description": "Command-line interface for Lifestream Vault",
5
5
  "engines": {
6
6
  "node": ">=22"
@@ -58,7 +58,7 @@
58
58
  "@types/node": "^22.0.0",
59
59
  "@vitest/coverage-v8": "^4.0.18",
60
60
  "@vitest/ui": "^4.0.18",
61
- "tsx": "^4.19.0",
61
+ "tsx": "^4.22.4",
62
62
  "typescript": "^5.7.0",
63
63
  "vitest": "^4.0.18"
64
64
  }