@lifestreamdynamics/vault-cli 1.4.1 → 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 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
@@ -3,6 +3,21 @@ import { getClientAsync } from '../client.js';
3
3
  import { addGlobalFlags, resolveFlags } from '../utils/flags.js';
4
4
  import { createOutput, handleError } from '../utils/output.js';
5
5
  import { resolveVaultId } from '../utils/resolve-vault.js';
6
+ /**
7
+ * Valid hook trigger events. Unlike webhooks, hooks do NOT accept the `*`
8
+ * wildcard. Inlined from the internal vault-shared package (not published to
9
+ * npm) so the standalone CLI build has no unpublishable workspace dependency.
10
+ * Keep in sync with VAULT_EVENT_TYPES in packages/shared/src/constants.ts.
11
+ */
12
+ const VAULT_EVENT_TYPES = [
13
+ 'document.created', 'document.updated', 'document.deleted', 'document.moved', 'document.copied',
14
+ 'directory.created', 'document.overdue', 'document.due-soon',
15
+ 'calendar.event.created', 'calendar.event.updated', 'calendar.event.deleted', 'calendar.event.due',
16
+ 'calendar.event.overdue', 'calendar.event.status_changed',
17
+ 'booking.created', 'booking.confirmed', 'booking.cancelled', 'booking.no_show', 'booking.completed',
18
+ 'booking.reminder', 'booking.rescheduled',
19
+ 'calendar.event.participant.added', 'calendar.event.participant.responded',
20
+ ];
6
21
  export function registerHookCommands(program) {
7
22
  const hooks = program.command('hooks').description('Manage vault event hooks');
8
23
  addGlobalFlags(hooks.command('list')
@@ -49,17 +64,35 @@ export function registerHookCommands(program) {
49
64
  .description('Create a new hook')
50
65
  .argument('<vaultId>', 'Vault ID or slug')
51
66
  .argument('<name>', 'Hook name')
52
- .requiredOption('--trigger <event>', 'Trigger event (document.created, document.updated, document.deleted, document.moved, document.copied)')
67
+ .requiredOption('--trigger <event>', 'Trigger event (e.g. document.created, calendar.event.created, booking.confirmed)')
53
68
  .requiredOption('--action <type>', 'Action type (webhook, ai_prompt, document_operation, auto_calendar_event, auto_booking_process)')
54
69
  .requiredOption('--config <json>', 'Action configuration as JSON')
55
70
  .option('--filter <json>', 'Trigger filter as JSON')
56
71
  .addHelpText('after', `
57
- VALID TRIGGER EVENTS
58
- document.created Document was created
59
- document.updated Document content was updated
60
- document.deleted Document was deleted
61
- document.moved Document was moved or renamed
62
- document.copied Document was copied
72
+ VALID TRIGGER EVENTS (no wildcard — hooks fire on a single event type)
73
+ document.created Document was created
74
+ document.updated Document content was updated
75
+ document.deleted Document was deleted
76
+ document.moved Document was moved or renamed
77
+ document.copied Document was copied
78
+ directory.created Directory was created
79
+ document.overdue Document is past its due date
80
+ document.due-soon Document is due soon
81
+ calendar.event.created Calendar event was created
82
+ calendar.event.updated Calendar event was updated
83
+ calendar.event.deleted Calendar event was deleted
84
+ calendar.event.due Calendar event is due
85
+ calendar.event.overdue Calendar event is overdue
86
+ calendar.event.status_changed Calendar event status changed
87
+ booking.created Booking was created
88
+ booking.confirmed Booking was confirmed
89
+ booking.cancelled Booking was cancelled
90
+ booking.no_show Booking was marked no-show
91
+ booking.completed Booking was completed
92
+ booking.reminder Booking reminder sent
93
+ booking.rescheduled Booking was rescheduled
94
+ calendar.event.participant.added Participant added to event
95
+ calendar.event.participant.responded Participant responded to event
63
96
 
64
97
  VALID ACTION TYPES
65
98
  webhook Send an HTTP notification to a URL
@@ -71,6 +104,7 @@ VALID ACTION TYPES
71
104
  EXAMPLES
72
105
  lsvault hooks create <vaultId> my-hook --trigger document.created --action webhook --config '{"url":"https://example.com/hook"}'
73
106
  lsvault hooks create <vaultId> ai-tag --trigger document.created --action ai_prompt --config '{"prompt":"Suggest tags"}'
107
+ lsvault hooks create <vaultId> booking-hook --trigger booking.confirmed --action webhook --config '{"url":"https://example.com/hook"}'
74
108
  lsvault hooks create <vaultId> move-docs --trigger document.created --action document_operation --config '{"operation":"move","targetPath":"inbox/"}'`))
75
109
  .action(async (vaultId, name, _opts) => {
76
110
  const flags = resolveFlags(_opts);
@@ -95,12 +129,12 @@ EXAMPLES
95
129
  return;
96
130
  }
97
131
  }
98
- const VALID_TRIGGERS = ['document.created', 'document.updated', 'document.deleted', 'document.moved', 'document.copied'];
132
+ const VALID_TRIGGERS = VAULT_EVENT_TYPES;
99
133
  const VALID_ACTIONS = ['webhook', 'ai_prompt', 'document_operation', 'auto_calendar_event', 'auto_booking_process'];
100
134
  const trigger = String(_opts.trigger);
101
135
  const action = String(_opts.action);
102
136
  if (!VALID_TRIGGERS.includes(trigger)) {
103
- out.error(`Invalid trigger "${trigger}". Valid values: document.created, document.updated, document.deleted, document.moved, document.copied`);
137
+ out.error(`Invalid trigger "${trigger}". Valid values: ${VALID_TRIGGERS.join(', ')}`);
104
138
  process.exitCode = 1;
105
139
  return;
106
140
  }
@@ -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,17 +168,30 @@ 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
- const localFiles = scanLocalFiles(config.localPath, ignorePatterns);
177
+ const localFiles = scanLocalFiles(config.localPath, ignorePatterns, lastState);
173
178
  out.debug(`Found ${Object.keys(localFiles).length} local files`);
174
179
  out.startSpinner('Scanning remote files...');
175
- const remoteFiles = await scanRemoteFiles(client, config.vaultId, ignorePatterns);
180
+ const remoteResult = await scanRemoteFiles(client, config.vaultId, ignorePatterns, { remote: lastState.remote ?? {}, remoteListEtag: lastState.remoteListEtag });
181
+ const remoteFiles = remoteResult.files;
176
182
  out.debug(`Found ${Object.keys(remoteFiles).length} remote files`);
177
183
  out.startSpinner('Computing diff...');
178
184
  const diff = computePullDiff(localFiles, remoteFiles, lastState);
179
185
  const unchanged = Object.keys(remoteFiles).length - diff.downloads.length;
180
186
  const totalOps = diff.downloads.length + diff.deletes.length;
187
+ // Persist the new list ETag regardless of whether there are changes
188
+ if (remoteResult.listEtag) {
189
+ lastState.remoteListEtag = remoteResult.listEtag;
190
+ }
181
191
  if (totalOps === 0) {
192
+ if (remoteResult.listEtag) {
193
+ saveSyncState(lastState);
194
+ }
182
195
  out.succeedSpinner('Everything is up to date');
183
196
  if (flags.output === 'json') {
184
197
  out.record({
@@ -218,7 +231,9 @@ Sync modes:
218
231
  if (progress.phase === 'transferring' && progress.currentFile) {
219
232
  out.startSpinner(`[${progress.current}/${progress.total}] ${progress.currentFile}`);
220
233
  }
221
- }, concurrency);
234
+ }, concurrency, (file) => {
235
+ out.startSpinner(`Rate limited — waiting and retrying… (${file})`);
236
+ });
222
237
  if (result.errors.length > 0) {
223
238
  out.failSpinner(`Pull completed with ${result.errors.length} error(s)`);
224
239
  for (const err of result.errors) {
@@ -259,16 +274,24 @@ Sync modes:
259
274
  const ignorePatterns = resolveIgnorePatterns(config.ignore, config.localPath);
260
275
  const lastState = loadSyncState(config.id);
261
276
  out.startSpinner('Scanning local files...');
262
- const localFiles = scanLocalFiles(config.localPath, ignorePatterns);
277
+ const localFiles = scanLocalFiles(config.localPath, ignorePatterns, lastState);
263
278
  out.debug(`Found ${Object.keys(localFiles).length} local files`);
264
279
  out.startSpinner('Scanning remote files...');
265
- const remoteFiles = await scanRemoteFiles(client, config.vaultId, ignorePatterns);
280
+ const remoteResult = await scanRemoteFiles(client, config.vaultId, ignorePatterns, { remote: lastState.remote ?? {}, remoteListEtag: lastState.remoteListEtag });
281
+ const remoteFiles = remoteResult.files;
266
282
  out.debug(`Found ${Object.keys(remoteFiles).length} remote files`);
267
283
  out.startSpinner('Computing diff...');
268
284
  const diff = computePushDiff(localFiles, remoteFiles, lastState);
269
285
  const unchanged = Object.keys(localFiles).length - diff.uploads.length;
270
286
  const totalOps = diff.uploads.length + diff.deletes.length;
287
+ // Persist the new list ETag regardless of whether there are changes
288
+ if (remoteResult.listEtag) {
289
+ lastState.remoteListEtag = remoteResult.listEtag;
290
+ }
271
291
  if (totalOps === 0) {
292
+ if (remoteResult.listEtag) {
293
+ saveSyncState(lastState);
294
+ }
272
295
  out.succeedSpinner('Everything is up to date');
273
296
  if (flags.output === 'json') {
274
297
  out.record({
@@ -308,7 +331,9 @@ Sync modes:
308
331
  if (progress.phase === 'transferring' && progress.currentFile) {
309
332
  out.startSpinner(`[${progress.current}/${progress.total}] ${progress.currentFile}`);
310
333
  }
311
- }, concurrency);
334
+ }, concurrency, (file) => {
335
+ out.startSpinner(`Rate limited — waiting and retrying… (${file})`);
336
+ });
312
337
  if (result.errors.length > 0) {
313
338
  out.failSpinner(`Push completed with ${result.errors.length} error(s)`);
314
339
  for (const err of result.errors) {
@@ -348,8 +373,9 @@ Sync modes:
348
373
  const ignorePatterns = resolveIgnorePatterns(config.ignore, config.localPath);
349
374
  const lastState = loadSyncState(config.id);
350
375
  out.startSpinner('Scanning...');
351
- const localFiles = scanLocalFiles(config.localPath, ignorePatterns);
352
- const remoteFiles = await scanRemoteFiles(client, config.vaultId, ignorePatterns);
376
+ const localFiles = scanLocalFiles(config.localPath, ignorePatterns, lastState);
377
+ const remoteResult = await scanRemoteFiles(client, config.vaultId, ignorePatterns, { remote: lastState.remote ?? {}, remoteListEtag: lastState.remoteListEtag });
378
+ const remoteFiles = remoteResult.files;
353
379
  const pullDiff = computePullDiff(localFiles, remoteFiles, lastState);
354
380
  const pushDiff = computePushDiff(localFiles, remoteFiles, lastState);
355
381
  out.stopSpinner();
@@ -3,6 +3,22 @@ import { getClientAsync } from '../client.js';
3
3
  import { addGlobalFlags, resolveFlags } from '../utils/flags.js';
4
4
  import { createOutput, handleError } from '../utils/output.js';
5
5
  import { resolveVaultId } from '../utils/resolve-vault.js';
6
+ /**
7
+ * Valid webhook event names, including the `*` wildcard. Inlined from the
8
+ * internal vault-shared package (not published to npm) so the standalone CLI
9
+ * build has no unpublishable workspace dependency. Keep in sync with
10
+ * WEBHOOK_EVENT_TYPES in packages/shared/src/constants.ts.
11
+ */
12
+ const WEBHOOK_EVENT_TYPES = [
13
+ '*',
14
+ 'document.created', 'document.updated', 'document.deleted', 'document.moved', 'document.copied',
15
+ 'directory.created', 'document.overdue', 'document.due-soon',
16
+ 'calendar.event.created', 'calendar.event.updated', 'calendar.event.deleted', 'calendar.event.due',
17
+ 'calendar.event.overdue', 'calendar.event.status_changed',
18
+ 'booking.created', 'booking.confirmed', 'booking.cancelled', 'booking.no_show', 'booking.completed',
19
+ 'booking.reminder', 'booking.rescheduled',
20
+ 'calendar.event.participant.added', 'calendar.event.participant.responded',
21
+ ];
6
22
  export function registerWebhookCommands(program) {
7
23
  const webhooks = program.command('webhooks').description('Manage vault webhooks');
8
24
  addGlobalFlags(webhooks.command('list')
@@ -46,19 +62,38 @@ export function registerWebhookCommands(program) {
46
62
  .description('Create a new webhook')
47
63
  .argument('<vaultId>', 'Vault ID or slug')
48
64
  .argument('<url>', 'Webhook endpoint URL')
49
- .option('--events <events>', 'Comma-separated events (document.created, document.updated, document.deleted, document.moved, document.copied, or * for all)', 'document.created,document.updated,document.deleted')
65
+ .option('--events <events>', 'Comma-separated events (document.created, calendar.event.created, booking.created, *, etc.)', 'document.created,document.updated,document.deleted')
50
66
  .addHelpText('after', `
51
67
  VALID EVENT NAMES
52
- document.created Document was created
53
- document.updated Document content was updated
54
- document.deleted Document was deleted
55
- document.moved Document was moved or renamed
56
- document.copied Document was copied
57
- * All events
68
+ document.created Document was created
69
+ document.updated Document content was updated
70
+ document.deleted Document was deleted
71
+ document.moved Document was moved or renamed
72
+ document.copied Document was copied
73
+ directory.created Directory was created
74
+ document.overdue Document is past its due date
75
+ document.due-soon Document is due soon
76
+ calendar.event.created Calendar event was created
77
+ calendar.event.updated Calendar event was updated
78
+ calendar.event.deleted Calendar event was deleted
79
+ calendar.event.due Calendar event is due
80
+ calendar.event.overdue Calendar event is overdue
81
+ calendar.event.status_changed Calendar event status changed
82
+ booking.created Booking was created
83
+ booking.confirmed Booking was confirmed
84
+ booking.cancelled Booking was cancelled
85
+ booking.no_show Booking was marked no-show
86
+ booking.completed Booking was completed
87
+ booking.reminder Booking reminder sent
88
+ booking.rescheduled Booking was rescheduled
89
+ calendar.event.participant.added Participant added to event
90
+ calendar.event.participant.responded Participant responded to event
91
+ * All events
58
92
 
59
93
  EXAMPLES
60
94
  lsvault webhooks create <vaultId> https://example.com/hook
61
95
  lsvault webhooks create <vaultId> https://example.com/hook --events "document.created,document.deleted"
96
+ lsvault webhooks create <vaultId> https://example.com/hook --events "calendar.event.created,booking.created"
62
97
  lsvault webhooks create <vaultId> https://example.com/hook --events "*"`))
63
98
  .action(async (vaultId, url, _opts) => {
64
99
  const flags = resolveFlags(_opts);
@@ -68,7 +103,7 @@ EXAMPLES
68
103
  process.exitCode = 1;
69
104
  return;
70
105
  }
71
- const VALID_EVENTS = ['document.created', 'document.updated', 'document.deleted', 'document.moved', 'document.copied', '*'];
106
+ const VALID_EVENTS = WEBHOOK_EVENT_TYPES;
72
107
  const events = String(_opts.events || 'document.created,document.updated,document.deleted').split(',').map((e) => e.trim());
73
108
  const invalid = events.filter(e => !VALID_EVENTS.includes(e));
74
109
  if (invalid.length > 0) {
@@ -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,8 +10,8 @@ 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';
14
- import { loadSyncState } from './state.js';
13
+ import { scanLocalFiles, scanRemoteFiles, computePushDiff, computePullDiff, executePush, executePull, sweepOrphanedTempFiles } from './engine.js';
14
+ import { loadSyncState, saveSyncState } from './state.js';
15
15
  const managed = [];
16
16
  function log(msg) {
17
17
  const ts = new Date().toISOString();
@@ -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) {
@@ -50,8 +62,13 @@ async function start() {
50
62
  log(`Reconciling ${config.id.slice(0, 8)} (${config.mode} mode)...`);
51
63
  const ignorePatterns = resolveIgnorePatterns(config.ignore, config.localPath);
52
64
  const lastState = loadSyncState(config.id);
53
- const localFiles = scanLocalFiles(config.localPath, ignorePatterns);
54
- const remoteFiles = await scanRemoteFiles(client, config.vaultId, ignorePatterns);
65
+ const localFiles = scanLocalFiles(config.localPath, ignorePatterns, lastState);
66
+ const remoteResult = await scanRemoteFiles(client, config.vaultId, ignorePatterns, { remote: lastState.remote ?? {}, remoteListEtag: lastState.remoteListEtag });
67
+ const remoteFiles = remoteResult.files;
68
+ if (remoteResult.listEtag) {
69
+ lastState.remoteListEtag = remoteResult.listEtag;
70
+ saveSyncState(lastState);
71
+ }
55
72
  let pushed = 0;
56
73
  let pulled = 0;
57
74
  let deleted = 0;
@@ -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
- import type { SyncConfig, FileState } from './types.js';
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;
@@ -23,13 +25,40 @@ export interface SyncResult {
23
25
  /**
24
26
  * Scan local directory recursively for .md files.
25
27
  * Returns a map of relative doc paths -> FileState.
28
+ *
29
+ * When `lastState` is provided, files whose stat mtime and size match the
30
+ * persisted entry skip the readFileSync + hash step entirely (D-2 fast-path).
26
31
  */
27
- export declare function scanLocalFiles(localPath: string, ignorePatterns: string[]): Record<string, FileState>;
32
+ export declare function scanLocalFiles(localPath: string, ignorePatterns: string[], lastState?: SyncState): Record<string, FileState>;
33
+ /** Result type for {@link scanRemoteFiles}. */
34
+ export interface ScanRemoteResult {
35
+ /** Map of doc paths -> FileState for all non-ignored remote files. */
36
+ files: Record<string, FileState>;
37
+ /** List ETag from this response, for use in the next call. */
38
+ listEtag: string;
39
+ /** True when the server confirmed the vault is unchanged (304 fast-path). */
40
+ vaultUnchanged: boolean;
41
+ }
42
+ /**
43
+ * Scan remote vault for document list using the syncList fast-path.
44
+ *
45
+ * When `knownState` is provided (with hashes and optionally a prior listEtag),
46
+ * the server may respond 304 and `vaultUnchanged` will be true — in that case
47
+ * `files` is rebuilt from `knownState.remote` without any per-doc network call.
48
+ *
49
+ * When `knownState` is omitted a full list is always fetched (backward-compat).
50
+ */
51
+ export declare function scanRemoteFiles(client: LifestreamVaultClient, vaultId: string, ignorePatterns: string[], knownState?: {
52
+ remote: Record<string, FileState>;
53
+ remoteListEtag?: string;
54
+ }): Promise<ScanRemoteResult>;
28
55
  /**
29
- * Scan remote vault for document list.
30
- * Returns a map of doc paths -> FileState.
56
+ * Called when the SDK signals a 429 response while transferring a file.
57
+ * The SDK will back off and retry automatically; the CLI uses this to
58
+ * update the spinner text so the user sees "Rate limited — waiting and retrying…"
59
+ * rather than an apparently frozen transfer.
31
60
  */
32
- export declare function scanRemoteFiles(client: LifestreamVaultClient, vaultId: string, ignorePatterns: string[]): Promise<Record<string, FileState>>;
61
+ export type ThrottleCallback = (file: string) => void;
33
62
  /**
34
63
  * Validates and clamps a user-supplied concurrency value. Throws on invalid
35
64
  * values so the CLI can surface a clear error before kicking off any I/O.
@@ -38,9 +67,22 @@ export declare function resolveConcurrency(value: number | undefined): number;
38
67
  /**
39
68
  * Execute a pull operation: download remote changes to local.
40
69
  */
41
- export declare function executePull(client: LifestreamVaultClient, config: SyncConfig, diff: SyncDiff, onProgress?: ProgressCallback, concurrency?: number): Promise<SyncResult>;
70
+ export declare function executePull(client: LifestreamVaultClient, config: SyncConfig, diff: SyncDiff, onProgress?: ProgressCallback, concurrency?: number, onThrottle?: ThrottleCallback): Promise<SyncResult>;
42
71
  /**
43
72
  * Execute a push operation: upload local changes to remote.
44
73
  */
45
- export declare function executePush(client: LifestreamVaultClient, config: SyncConfig, diff: SyncDiff, onProgress?: ProgressCallback, concurrency?: number): Promise<SyncResult>;
74
+ export declare function executePush(client: LifestreamVaultClient, config: SyncConfig, diff: SyncDiff, onProgress?: ProgressCallback, concurrency?: number, onThrottle?: ThrottleCallback): Promise<SyncResult>;
75
+ /**
76
+ * Returns true when an error represents an HTTP 429 (Too Many Requests / rate
77
+ * limited) response. The SDK retries 429s transparently; a 429 reaching here
78
+ * means all retry attempts were exhausted.
79
+ *
80
+ * Prefers the structured status code the SDK attaches (RateLimitError sets
81
+ * `statusCode = 429`) and only falls back to a narrow message match. The
82
+ * message fallback is intentionally strict — it does NOT match "rate limit" or
83
+ * "throttle" loosely, since those words appear in unrelated errors (e.g. an
84
+ * authorization message mentioning a rate-limited account) and a false positive
85
+ * would suppress a real error.
86
+ */
87
+ export declare function isThrottleError(err: unknown): boolean;
46
88
  export { computePullDiff, computePushDiff, type SyncDiff, type SyncDiffEntry };
@@ -3,16 +3,20 @@
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.
15
+ *
16
+ * When `lastState` is provided, files whose stat mtime and size match the
17
+ * persisted entry skip the readFileSync + hash step entirely (D-2 fast-path).
14
18
  */
15
- export function scanLocalFiles(localPath, ignorePatterns) {
19
+ export function scanLocalFiles(localPath, ignorePatterns, lastState) {
16
20
  const files = {};
17
21
  function walk(dir, prefix) {
18
22
  if (!fs.existsSync(dir))
@@ -28,14 +32,21 @@ export function scanLocalFiles(localPath, ignorePatterns) {
28
32
  else if (entry.isFile() && entry.name.endsWith('.md')) {
29
33
  if (!shouldIgnore(relPath, ignorePatterns)) {
30
34
  const absPath = path.join(dir, entry.name);
31
- const content = fs.readFileSync(absPath);
32
35
  const stat = fs.statSync(absPath);
33
- files[relPath] = {
34
- path: relPath,
35
- hash: hashFileContent(content),
36
- mtime: stat.mtime.toISOString(),
37
- size: stat.size,
38
- };
36
+ const stored = lastState?.local?.[relPath];
37
+ if (stored && stat.size === stored.size && stat.mtime.toISOString() === stored.mtime) {
38
+ // mtime+size unchanged — reuse persisted hash, skip file read
39
+ files[relPath] = stored;
40
+ }
41
+ else {
42
+ const content = fs.readFileSync(absPath);
43
+ files[relPath] = {
44
+ path: relPath,
45
+ hash: hashFileContent(content),
46
+ mtime: stat.mtime.toISOString(),
47
+ size: stat.size,
48
+ };
49
+ }
39
50
  }
40
51
  }
41
52
  }
@@ -44,32 +55,61 @@ export function scanLocalFiles(localPath, ignorePatterns) {
44
55
  return files;
45
56
  }
46
57
  /**
47
- * Scan remote vault for document list.
48
- * Returns a map of doc paths -> FileState.
58
+ * Scan remote vault for document list using the syncList fast-path.
59
+ *
60
+ * When `knownState` is provided (with hashes and optionally a prior listEtag),
61
+ * the server may respond 304 and `vaultUnchanged` will be true — in that case
62
+ * `files` is rebuilt from `knownState.remote` without any per-doc network call.
63
+ *
64
+ * When `knownState` is omitted a full list is always fetched (backward-compat).
49
65
  */
50
- export async function scanRemoteFiles(client, vaultId, ignorePatterns) {
51
- const docs = await client.documents.list(vaultId);
66
+ export async function scanRemoteFiles(client, vaultId, ignorePatterns, knownState) {
67
+ const sdkKnownState = {
68
+ hashes: knownState
69
+ ? Object.fromEntries(Object.entries(knownState.remote).map(([k, v]) => [k, v.hash]))
70
+ : {},
71
+ listEtag: knownState?.remoteListEtag,
72
+ };
73
+ const sync = await client.documents.syncList(vaultId, sdkKnownState);
74
+ if (sync.vaultUnchanged) {
75
+ // Server confirmed nothing changed — rebuild files from persisted state.
76
+ const files = {};
77
+ if (knownState) {
78
+ for (const [docPath, fs_] of Object.entries(knownState.remote)) {
79
+ if (!shouldIgnore(docPath, ignorePatterns)) {
80
+ files[docPath] = fs_;
81
+ }
82
+ }
83
+ }
84
+ return { files, listEtag: sync.listEtag, vaultUnchanged: true };
85
+ }
86
+ // Build files from changes + unchanged paths.
52
87
  const files = {};
53
- for (const doc of docs) {
54
- if (!shouldIgnore(doc.path, ignorePatterns)) {
55
- files[doc.path] = {
56
- path: doc.path,
57
- hash: doc.contentHash,
58
- mtime: doc.fileModifiedAt,
59
- size: doc.sizeBytes,
88
+ for (const change of sync.changes) {
89
+ if (!shouldIgnore(change.path, ignorePatterns)) {
90
+ files[change.path] = {
91
+ path: change.path,
92
+ hash: change.contentHash,
93
+ mtime: change.fileModifiedAt,
94
+ // sizeBytes is not provided by syncList change objects; use 0 as a
95
+ // fallback — size is only used for progress-bar estimation in the diff.
96
+ size: 0,
60
97
  };
61
98
  }
62
99
  }
63
- return files;
64
- }
65
- /**
66
- * Write a file atomically using a temp file + rename.
67
- * Prevents partial reads if the process is interrupted mid-write.
68
- */
69
- function atomicWriteFileSync(targetPath, content, encoding = 'utf-8') {
70
- const tmpFile = targetPath + '.tmp.' + randomBytes(4).toString('hex');
71
- fs.writeFileSync(tmpFile, content, encoding);
72
- fs.renameSync(tmpFile, targetPath);
100
+ for (const unchangedPath of sync.unchanged) {
101
+ if (!shouldIgnore(unchangedPath, ignorePatterns)) {
102
+ // Reuse the persisted FileState so size is preserved for progress reporting.
103
+ const stored = knownState?.remote?.[unchangedPath];
104
+ files[unchangedPath] = stored ?? {
105
+ path: unchangedPath,
106
+ hash: sdkKnownState.hashes[unchangedPath] ?? '',
107
+ mtime: '',
108
+ size: 0,
109
+ };
110
+ }
111
+ }
112
+ return { files, listEtag: sync.listEtag, vaultUnchanged: false };
73
113
  }
74
114
  /**
75
115
  * Default in-flight transfer count. A small number flattens the load1 spike
@@ -95,7 +135,7 @@ export function resolveConcurrency(value) {
95
135
  * Handles result initialization, state loading, progress callbacks,
96
136
  * quota error handling, state saving, and lastSync update.
97
137
  */
98
- async function executeSyncOperation(config, diff, handlers, onProgress, concurrency = DEFAULT_TRANSFER_CONCURRENCY) {
138
+ async function executeSyncOperation(config, diff, handlers, onProgress, concurrency = DEFAULT_TRANSFER_CONCURRENCY, onThrottle) {
99
139
  const result = {
100
140
  filesUploaded: 0,
101
141
  filesDownloaded: 0,
@@ -122,7 +162,7 @@ async function executeSyncOperation(config, diff, handlers, onProgress, concurre
122
162
  totalBytes: diff.totalBytes,
123
163
  });
124
164
  try {
125
- const content = await handlers.transferFile(entry, config);
165
+ const content = await handlers.transferFile(entry, config, onThrottle);
126
166
  result[handlers.transferCounterKey]++;
127
167
  result.bytesTransferred += entry.sizeBytes;
128
168
  // Update state
@@ -140,6 +180,11 @@ async function executeSyncOperation(config, diff, handlers, onProgress, concurre
140
180
  if (isQuotaError(message)) {
141
181
  stopSubmitting = true;
142
182
  }
183
+ // 429 errors that reach here have already exhausted SDK-level retries.
184
+ // Stop submitting new work to avoid hammering a still-throttled API.
185
+ if (isThrottleError(err)) {
186
+ stopSubmitting = true;
187
+ }
143
188
  }
144
189
  }
145
190
  // Bounded async pool. Workers race for entries off the queue tail; once
@@ -190,15 +235,33 @@ async function executeSyncOperation(config, diff, handlers, onProgress, concurre
190
235
  /**
191
236
  * Execute a pull operation: download remote changes to local.
192
237
  */
193
- export async function executePull(client, config, diff, onProgress, concurrency) {
238
+ export async function executePull(client, config, diff, onProgress, concurrency, onThrottle) {
194
239
  return executeSyncOperation(config, diff, {
195
240
  transfers: diff.downloads,
196
241
  deletes: diff.deletes,
197
242
  transferCounterKey: 'filesDownloaded',
198
- async transferFile(entry, cfg) {
199
- const { content } = await retryWithBackoff(() => client.documents.get(cfg.vaultId, entry.path));
243
+ async transferFile(entry, cfg, throttleCallback) {
200
244
  const localFile = path.join(cfg.localPath, entry.path);
201
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;
202
265
  if (!fs.existsSync(localDir)) {
203
266
  fs.mkdirSync(localDir, { recursive: true });
204
267
  }
@@ -211,31 +274,40 @@ export async function executePull(client, config, diff, onProgress, concurrency)
211
274
  fs.unlinkSync(localFile);
212
275
  }
213
276
  },
214
- }, onProgress, concurrency);
277
+ }, onProgress, concurrency, onThrottle);
215
278
  }
216
279
  /**
217
280
  * Execute a push operation: upload local changes to remote.
218
281
  */
219
- export async function executePush(client, config, diff, onProgress, concurrency) {
282
+ export async function executePush(client, config, diff, onProgress, concurrency, onThrottle) {
220
283
  return executeSyncOperation(config, diff, {
221
284
  transfers: diff.uploads,
222
285
  deletes: diff.deletes,
223
286
  transferCounterKey: 'filesUploaded',
224
- async transferFile(entry, cfg) {
287
+ async transferFile(entry, cfg, throttleCallback) {
225
288
  const localFile = path.join(cfg.localPath, entry.path);
226
289
  const content = fs.readFileSync(localFile, 'utf-8');
227
- await retryWithBackoff(() => client.documents.put(cfg.vaultId, entry.path, content));
290
+ await retryWithBackoff(() => client.documents.put(cfg.vaultId, entry.path, content), throttleCallback ? () => throttleCallback(entry.path) : undefined);
228
291
  return content;
229
292
  },
230
293
  async deleteFile(entry, cfg) {
231
294
  await retryWithBackoff(() => client.documents.delete(cfg.vaultId, entry.path));
232
295
  },
233
- }, onProgress, concurrency);
296
+ }, onProgress, concurrency, onThrottle);
234
297
  }
235
298
  /**
236
- * Retry a function with exponential backoff (max 3 retries).
299
+ * Retry a function with exponential backoff (max 3 retries) for transient
300
+ * network errors. Throttle (429) and quota/permission errors are NOT retried
301
+ * here — the SDK already retries 429s transparently (ky retry config), and
302
+ * quota/permission errors are not recoverable by retrying.
303
+ *
304
+ * @param onThrottle - Optional callback to invoke when a 429 is observed.
305
+ * The SDK will retry automatically; this is called so the CLI can update
306
+ * its spinner text to "Rate limited — waiting and retrying…".
307
+ * The parameter value is the file path being transferred.
237
308
  */
238
- async function retryWithBackoff(fn, maxRetries = 3) {
309
+ async function retryWithBackoff(fn, onThrottle) {
310
+ const maxRetries = 3;
239
311
  let lastError;
240
312
  for (let attempt = 0; attempt <= maxRetries; attempt++) {
241
313
  try {
@@ -244,7 +316,14 @@ async function retryWithBackoff(fn, maxRetries = 3) {
244
316
  catch (err) {
245
317
  lastError = err;
246
318
  const message = err instanceof Error ? err.message : String(err);
247
- // Don't retry on non-transient errors
319
+ // Throttle errors: the SDK already exhausted its own retry budget with
320
+ // proper Retry-After backoff. Don't layer another retry loop on top —
321
+ // that would ignore the server's backoff signal and hammer the API.
322
+ if (isThrottleError(err)) {
323
+ onThrottle?.('');
324
+ throw err;
325
+ }
326
+ // Don't retry on other non-transient errors
248
327
  if (isQuotaError(message) || isPermissionError(message)) {
249
328
  throw err;
250
329
  }
@@ -256,6 +335,28 @@ async function retryWithBackoff(fn, maxRetries = 3) {
256
335
  }
257
336
  throw lastError;
258
337
  }
338
+ /**
339
+ * Returns true when an error represents an HTTP 429 (Too Many Requests / rate
340
+ * limited) response. The SDK retries 429s transparently; a 429 reaching here
341
+ * means all retry attempts were exhausted.
342
+ *
343
+ * Prefers the structured status code the SDK attaches (RateLimitError sets
344
+ * `statusCode = 429`) and only falls back to a narrow message match. The
345
+ * message fallback is intentionally strict — it does NOT match "rate limit" or
346
+ * "throttle" loosely, since those words appear in unrelated errors (e.g. an
347
+ * authorization message mentioning a rate-limited account) and a false positive
348
+ * would suppress a real error.
349
+ */
350
+ export function isThrottleError(err) {
351
+ if (err && typeof err === 'object') {
352
+ const code = err.statusCode
353
+ ?? err.status;
354
+ if (code === 429)
355
+ return true;
356
+ }
357
+ const message = err instanceof Error ? err.message : String(err);
358
+ return /\b429\b|too many requests/i.test(message);
359
+ }
259
360
  function isQuotaError(message) {
260
361
  return /quota|storage limit|limit exceeded/i.test(message);
261
362
  }
@@ -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/',
@@ -50,6 +51,7 @@ export function resolveIgnorePatterns(configIgnore, localPath) {
50
51
  * The docPath should be a relative path using forward slashes.
51
52
  */
52
53
  export function shouldIgnore(docPath, patterns) {
54
+ const basename = path.posix.basename(docPath);
53
55
  for (const pattern of patterns) {
54
56
  // Directory patterns (ending with /)
55
57
  if (pattern.endsWith('/')) {
@@ -63,7 +65,6 @@ export function shouldIgnore(docPath, patterns) {
63
65
  return true;
64
66
  }
65
67
  // Also check basename for file-level patterns (e.g., ".DS_Store" matches "sub/.DS_Store")
66
- const basename = path.posix.basename(docPath);
67
68
  if (minimatch(basename, pattern, { dot: true })) {
68
69
  return true;
69
70
  }
@@ -8,6 +8,8 @@ import { shouldIgnore } from './ignore.js';
8
8
  import { loadSyncState, saveSyncState, hashFileContent, buildRemoteFileState } from './state.js';
9
9
  import { updateLastSync } from './config.js';
10
10
  import { resolveConflict, detectConflict, createConflictFile, formatConflictLog } from './conflict.js';
11
+ import { isThrottleError } from './engine.js';
12
+ import { atomicWriteFileSync } from './atomic-write.js';
11
13
  /**
12
14
  * Creates and starts a remote poller for a sync configuration.
13
15
  * Returns a stop function.
@@ -91,9 +93,7 @@ export function createRemotePoller(client, config, options) {
91
93
  if (resolution === 'remote') {
92
94
  conflictFile = createConflictFile(config.localPath, change.path, localContent, 'local');
93
95
  onLocalWrite?.(change.path);
94
- const tmpConflict = localFile + '.tmp';
95
- fs.writeFileSync(tmpConflict, content, 'utf-8');
96
- fs.renameSync(tmpConflict, localFile);
96
+ atomicWriteFileSync(localFile, content, 'utf-8');
97
97
  log(`Conflict: ${change.path} — used remote, saved local as ${conflictFile}`);
98
98
  }
99
99
  else {
@@ -117,9 +117,7 @@ export function createRemotePoller(client, config, options) {
117
117
  fs.mkdirSync(dir, { recursive: true });
118
118
  }
119
119
  onLocalWrite?.(change.path);
120
- const tmpFile = localFile + '.tmp';
121
- fs.writeFileSync(tmpFile, content, 'utf-8');
122
- fs.renameSync(tmpFile, localFile);
120
+ atomicWriteFileSync(localFile, content, 'utf-8');
123
121
  log(`Pulled: ${change.path}`);
124
122
  changes++;
125
123
  state.local[change.path] = {
@@ -154,7 +152,17 @@ export function createRemotePoller(client, config, options) {
154
152
  }
155
153
  }
156
154
  catch (err) {
157
- onError?.(err instanceof Error ? err : new Error(String(err)));
155
+ if (isThrottleError(err)) {
156
+ // The SDK already retried the request with Retry-After backoff and
157
+ // exhausted its retry budget. Log a warning rather than invoking
158
+ // onError so the daemon loop does NOT immediately re-poll on top of
159
+ // the backoff that the SDK already applied. The next scheduled poll
160
+ // (after intervalMs) will pick up the changes.
161
+ log('Rate limited by server — will retry on next scheduled poll');
162
+ }
163
+ else {
164
+ onError?.(err instanceof Error ? err : new Error(String(err)));
165
+ }
158
166
  }
159
167
  finally {
160
168
  polling = false;
@@ -46,7 +46,7 @@ export function saveSyncState(state) {
46
46
  fs.mkdirSync(STATE_DIR, { recursive: true, mode: 0o700 });
47
47
  }
48
48
  state.updatedAt = new Date().toISOString();
49
- fs.writeFileSync(stateFilePath(state.syncId), JSON.stringify(state, null, 2) + '\n', { mode: 0o600 });
49
+ fs.writeFileSync(stateFilePath(state.syncId), JSON.stringify(state) + '\n', { mode: 0o600 });
50
50
  }
51
51
  /**
52
52
  * Delete sync state for a given sync configuration.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lifestreamdynamics/vault-cli",
3
- "version": "1.4.1",
3
+ "version": "1.4.6",
4
4
  "description": "Command-line interface for Lifestream Vault",
5
5
  "engines": {
6
6
  "node": ">=22"