@lifestreamdynamics/vault-cli 1.1.0 → 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (56) hide show
  1. package/README.md +140 -30
  2. package/dist/client.d.ts +4 -0
  3. package/dist/client.js +12 -11
  4. package/dist/commands/admin.js +5 -5
  5. package/dist/commands/ai.d.ts +2 -0
  6. package/dist/commands/ai.js +124 -0
  7. package/dist/commands/analytics.d.ts +2 -0
  8. package/dist/commands/analytics.js +84 -0
  9. package/dist/commands/auth.js +10 -105
  10. package/dist/commands/booking.d.ts +2 -0
  11. package/dist/commands/booking.js +739 -0
  12. package/dist/commands/calendar.js +778 -6
  13. package/dist/commands/completion.d.ts +5 -0
  14. package/dist/commands/completion.js +60 -0
  15. package/dist/commands/config.js +17 -16
  16. package/dist/commands/connectors.js +12 -1
  17. package/dist/commands/custom-domains.d.ts +2 -0
  18. package/dist/commands/custom-domains.js +154 -0
  19. package/dist/commands/docs.js +152 -5
  20. package/dist/commands/hooks.js +6 -1
  21. package/dist/commands/links.js +9 -2
  22. package/dist/commands/mfa.js +1 -70
  23. package/dist/commands/plugins.d.ts +2 -0
  24. package/dist/commands/plugins.js +172 -0
  25. package/dist/commands/publish-vault.d.ts +2 -0
  26. package/dist/commands/publish-vault.js +117 -0
  27. package/dist/commands/publish.js +63 -2
  28. package/dist/commands/saml.d.ts +2 -0
  29. package/dist/commands/saml.js +220 -0
  30. package/dist/commands/scim.d.ts +2 -0
  31. package/dist/commands/scim.js +238 -0
  32. package/dist/commands/shares.js +25 -3
  33. package/dist/commands/subscription.js +9 -2
  34. package/dist/commands/sync.js +3 -0
  35. package/dist/commands/teams.js +233 -4
  36. package/dist/commands/user.js +444 -0
  37. package/dist/commands/vaults.js +240 -8
  38. package/dist/commands/webhooks.js +6 -1
  39. package/dist/config.d.ts +2 -0
  40. package/dist/config.js +7 -3
  41. package/dist/index.js +28 -1
  42. package/dist/lib/credential-manager.js +32 -7
  43. package/dist/lib/migration.js +2 -2
  44. package/dist/lib/profiles.js +4 -4
  45. package/dist/sync/config.js +2 -2
  46. package/dist/sync/daemon-worker.js +13 -6
  47. package/dist/sync/daemon.js +2 -1
  48. package/dist/sync/remote-poller.js +7 -3
  49. package/dist/sync/state.js +2 -2
  50. package/dist/utils/confirm.d.ts +11 -0
  51. package/dist/utils/confirm.js +23 -0
  52. package/dist/utils/format.js +1 -1
  53. package/dist/utils/output.js +4 -1
  54. package/dist/utils/prompt.d.ts +29 -0
  55. package/dist/utils/prompt.js +146 -0
  56. package/package.json +2 -2
@@ -3,7 +3,7 @@ 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 { generateVaultKey } from '@lifestreamdynamics/vault-sdk';
6
- import { createCredentialManager } from '../lib/credential-manager.js';
6
+ import { getCredentialManager } from '../config.js';
7
7
  export function registerVaultCommands(program) {
8
8
  const vaults = program.command('vaults').description('Create, list, and inspect document vaults');
9
9
  addGlobalFlags(vaults.command('list')
@@ -84,7 +84,7 @@ EXAMPLES
84
84
  });
85
85
  if (isEncrypted) {
86
86
  const key = generateVaultKey();
87
- const credManager = createCredentialManager();
87
+ const credManager = getCredentialManager();
88
88
  await credManager.saveVaultKey(vault.id, key);
89
89
  out.success(`Encrypted vault created: ${chalk.cyan(vault.name)} (${vault.slug})`, {
90
90
  id: vault.id,
@@ -93,10 +93,13 @@ EXAMPLES
93
93
  encrypted: true,
94
94
  vaultKey: key,
95
95
  });
96
- out.warn('IMPORTANT: Save this encryption key securely. If lost, your data cannot be recovered.');
97
- out.status(`Vault Key: ${chalk.green(key)}`);
98
- out.status(chalk.dim('The key has been saved to your credential store.'));
99
- out.status(chalk.dim('You can export it later with: lsvault vaults export-key ' + vault.id));
96
+ // Always write key warning to stderr even in JSON mode the caller must not miss this.
97
+ process.stderr.write(chalk.yellow('WARNING: Save this encryption key securely. If lost, your data cannot be recovered.\n'));
98
+ if (flags.output !== 'json') {
99
+ out.status(`Vault Key: ${chalk.green(key)}`);
100
+ out.status(chalk.dim('The key has been saved to your credential store.'));
101
+ out.status(chalk.dim('You can export it later with: lsvault vaults export-key ' + vault.id));
102
+ }
100
103
  out.warn('Encrypted vaults disable: full-text search, AI features, hooks, and webhooks.');
101
104
  }
102
105
  else {
@@ -118,7 +121,7 @@ EXAMPLES
118
121
  const flags = resolveFlags(_opts);
119
122
  const out = createOutput(flags);
120
123
  try {
121
- const credManager = createCredentialManager();
124
+ const credManager = getCredentialManager();
122
125
  const key = await credManager.getVaultKey(vaultId);
123
126
  if (!key) {
124
127
  out.error('No encryption key found for vault ' + vaultId);
@@ -146,7 +149,7 @@ EXAMPLES
146
149
  process.exitCode = 1;
147
150
  return;
148
151
  }
149
- const credManager = createCredentialManager();
152
+ const credManager = getCredentialManager();
150
153
  await credManager.saveVaultKey(vaultId, keyValue);
151
154
  out.success('Vault encryption key saved successfully.', { vaultId });
152
155
  }
@@ -154,4 +157,233 @@ EXAMPLES
154
157
  handleError(out, err, 'Failed to import vault key');
155
158
  }
156
159
  });
160
+ // vault tree
161
+ addGlobalFlags(vaults.command('tree')
162
+ .description('Show vault file tree')
163
+ .argument('<vaultId>', 'Vault ID'))
164
+ .action(async (vaultId, _opts) => {
165
+ const flags = resolveFlags(_opts);
166
+ const out = createOutput(flags);
167
+ out.startSpinner('Fetching vault tree...');
168
+ try {
169
+ const client = await getClientAsync();
170
+ const tree = await client.vaults.getTree(vaultId);
171
+ out.stopSpinner();
172
+ if (flags.output === 'json') {
173
+ out.raw(JSON.stringify(tree, null, 2) + '\n');
174
+ }
175
+ else {
176
+ function printNode(node, depth) {
177
+ const indent = ' '.repeat(depth);
178
+ const icon = node.type === 'directory' ? chalk.yellow('📁') : chalk.cyan('📄');
179
+ process.stdout.write(`${indent}${icon} ${node.name}\n`);
180
+ if (node.children) {
181
+ for (const child of node.children)
182
+ printNode(child, depth + 1);
183
+ }
184
+ }
185
+ for (const node of tree)
186
+ printNode(node, 0);
187
+ }
188
+ }
189
+ catch (err) {
190
+ handleError(out, err, 'Failed to fetch vault tree');
191
+ }
192
+ });
193
+ // vault archive
194
+ addGlobalFlags(vaults.command('archive')
195
+ .description('Archive a vault')
196
+ .argument('<vaultId>', 'Vault ID'))
197
+ .action(async (vaultId, _opts) => {
198
+ const flags = resolveFlags(_opts);
199
+ const out = createOutput(flags);
200
+ out.startSpinner('Archiving vault...');
201
+ try {
202
+ const client = await getClientAsync();
203
+ const vault = await client.vaults.archive(vaultId);
204
+ out.success(`Vault archived: ${vault.name}`, { id: vault.id, name: vault.name, isArchived: vault.isArchived });
205
+ }
206
+ catch (err) {
207
+ handleError(out, err, 'Failed to archive vault');
208
+ }
209
+ });
210
+ // vault unarchive
211
+ addGlobalFlags(vaults.command('unarchive')
212
+ .description('Unarchive a vault')
213
+ .argument('<vaultId>', 'Vault ID'))
214
+ .action(async (vaultId, _opts) => {
215
+ const flags = resolveFlags(_opts);
216
+ const out = createOutput(flags);
217
+ out.startSpinner('Unarchiving vault...');
218
+ try {
219
+ const client = await getClientAsync();
220
+ const vault = await client.vaults.unarchive(vaultId);
221
+ out.success(`Vault unarchived: ${vault.name}`, { id: vault.id, name: vault.name, isArchived: vault.isArchived });
222
+ }
223
+ catch (err) {
224
+ handleError(out, err, 'Failed to unarchive vault');
225
+ }
226
+ });
227
+ // vault transfer
228
+ addGlobalFlags(vaults.command('transfer')
229
+ .description('Transfer vault ownership to another user')
230
+ .argument('<vaultId>', 'Vault ID')
231
+ .argument('<targetEmail>', 'Email of the user to transfer to'))
232
+ .action(async (vaultId, targetEmail, _opts) => {
233
+ const flags = resolveFlags(_opts);
234
+ const out = createOutput(flags);
235
+ out.startSpinner('Transferring vault...');
236
+ try {
237
+ const client = await getClientAsync();
238
+ const vault = await client.vaults.transfer(vaultId, targetEmail);
239
+ out.success(`Vault transferred to ${targetEmail}`, { id: vault.id, name: vault.name });
240
+ }
241
+ catch (err) {
242
+ handleError(out, err, 'Failed to transfer vault');
243
+ }
244
+ });
245
+ // vault export-vault subgroup
246
+ const exportVault = vaults.command('export-vault').description('Vault export operations');
247
+ addGlobalFlags(exportVault.command('create')
248
+ .description('Create a vault export')
249
+ .argument('<vaultId>', 'Vault ID')
250
+ .option('--metadata', 'Include metadata in export')
251
+ .option('--format <fmt>', 'Export format', 'zip'))
252
+ .action(async (vaultId, _opts) => {
253
+ const flags = resolveFlags(_opts);
254
+ const out = createOutput(flags);
255
+ out.startSpinner('Creating export...');
256
+ try {
257
+ const client = await getClientAsync();
258
+ const exp = await client.vaults.createExport(vaultId, {
259
+ includeMetadata: _opts.metadata === true,
260
+ format: _opts.format || 'zip',
261
+ });
262
+ out.success('Export created', { id: exp.id, status: exp.status, format: exp.format });
263
+ }
264
+ catch (err) {
265
+ handleError(out, err, 'Failed to create export');
266
+ }
267
+ });
268
+ addGlobalFlags(exportVault.command('list')
269
+ .description('List vault exports')
270
+ .argument('<vaultId>', 'Vault ID'))
271
+ .action(async (vaultId, _opts) => {
272
+ const flags = resolveFlags(_opts);
273
+ const out = createOutput(flags);
274
+ out.startSpinner('Fetching exports...');
275
+ try {
276
+ const client = await getClientAsync();
277
+ const exports = await client.vaults.listExports(vaultId);
278
+ out.stopSpinner();
279
+ out.list(exports.map(e => ({ id: e.id, status: e.status, format: e.format, createdAt: e.createdAt, completedAt: e.completedAt || '' })), {
280
+ emptyMessage: 'No exports found.',
281
+ columns: [
282
+ { key: 'id', header: 'ID' },
283
+ { key: 'status', header: 'Status' },
284
+ { key: 'format', header: 'Format' },
285
+ { key: 'createdAt', header: 'Created' },
286
+ { key: 'completedAt', header: 'Completed' },
287
+ ],
288
+ textFn: (e) => `${chalk.cyan(String(e.id))} [${String(e.status)}] ${String(e.format)} created: ${String(e.createdAt)}`,
289
+ });
290
+ }
291
+ catch (err) {
292
+ handleError(out, err, 'Failed to list exports');
293
+ }
294
+ });
295
+ addGlobalFlags(exportVault.command('download')
296
+ .description('Download a vault export')
297
+ .argument('<vaultId>', 'Vault ID')
298
+ .argument('<exportId>', 'Export ID')
299
+ .requiredOption('--file <path>', 'Output file path'))
300
+ .action(async (vaultId, exportId, _opts) => {
301
+ const flags = resolveFlags(_opts);
302
+ const out = createOutput(flags);
303
+ out.startSpinner('Downloading export...');
304
+ try {
305
+ const { writeFile } = await import('node:fs/promises');
306
+ const client = await getClientAsync();
307
+ const blob = await client.vaults.downloadExport(vaultId, exportId);
308
+ const buffer = Buffer.from(await blob.arrayBuffer());
309
+ await writeFile(_opts.file, buffer);
310
+ out.success(`Export downloaded to ${String(_opts.file)}`, { path: _opts.file, size: buffer.length });
311
+ }
312
+ catch (err) {
313
+ handleError(out, err, 'Failed to download export');
314
+ }
315
+ });
316
+ // vault mfa subgroup
317
+ const mfa = vaults.command('mfa').description('Vault MFA configuration');
318
+ addGlobalFlags(mfa.command('get')
319
+ .description('Get vault MFA configuration')
320
+ .argument('<vaultId>', 'Vault ID'))
321
+ .action(async (vaultId, _opts) => {
322
+ const flags = resolveFlags(_opts);
323
+ const out = createOutput(flags);
324
+ out.startSpinner('Fetching MFA config...');
325
+ try {
326
+ const client = await getClientAsync();
327
+ const config = await client.vaults.getMfaConfig(vaultId);
328
+ out.stopSpinner();
329
+ out.record({
330
+ mfaRequired: config.mfaRequired,
331
+ sessionWindowMinutes: config.sessionWindowMinutes,
332
+ userVerified: config.userVerified,
333
+ verificationExpiresAt: config.verificationExpiresAt,
334
+ });
335
+ }
336
+ catch (err) {
337
+ handleError(out, err, 'Failed to fetch MFA config');
338
+ }
339
+ });
340
+ addGlobalFlags(mfa.command('set')
341
+ .description('Set vault MFA configuration')
342
+ .argument('<vaultId>', 'Vault ID')
343
+ .option('--require', 'Require MFA for vault access')
344
+ .option('--no-require', 'Disable MFA requirement')
345
+ .option('--window <minutes>', 'Session window in minutes', '60'))
346
+ .action(async (vaultId, _opts) => {
347
+ const flags = resolveFlags(_opts);
348
+ const out = createOutput(flags);
349
+ // Require an explicit --require or --no-require flag to avoid silent side-effects.
350
+ if (_opts.require === undefined) {
351
+ out.error('You must pass --require or --no-require to set the MFA policy.');
352
+ process.exitCode = 1;
353
+ return;
354
+ }
355
+ out.startSpinner('Updating MFA config...');
356
+ try {
357
+ const client = await getClientAsync();
358
+ const config = await client.vaults.setMfaConfig(vaultId, {
359
+ mfaRequired: _opts.require !== false,
360
+ sessionWindowMinutes: parseInt(String(_opts.window || '60'), 10),
361
+ });
362
+ out.success('MFA config updated', { mfaRequired: config.mfaRequired, sessionWindowMinutes: config.sessionWindowMinutes });
363
+ }
364
+ catch (err) {
365
+ handleError(out, err, 'Failed to update MFA config');
366
+ }
367
+ });
368
+ addGlobalFlags(mfa.command('verify')
369
+ .description('Verify MFA for vault access')
370
+ .argument('<vaultId>', 'Vault ID')
371
+ .requiredOption('--method <totp|backup_code>', 'MFA method')
372
+ .requiredOption('--code <code>', 'MFA code'))
373
+ .action(async (vaultId, _opts) => {
374
+ const flags = resolveFlags(_opts);
375
+ const out = createOutput(flags);
376
+ out.startSpinner('Verifying MFA...');
377
+ try {
378
+ const client = await getClientAsync();
379
+ const result = await client.vaults.verifyMfa(vaultId, {
380
+ method: _opts.method,
381
+ code: _opts.code,
382
+ });
383
+ out.success('MFA verified', { verified: result.verified, expiresAt: result.expiresAt });
384
+ }
385
+ catch (err) {
386
+ handleError(out, err, 'Failed to verify MFA');
387
+ }
388
+ });
157
389
  }
@@ -119,10 +119,15 @@ export function registerWebhookCommands(program) {
119
119
  addGlobalFlags(webhooks.command('delete')
120
120
  .description('Delete a webhook')
121
121
  .argument('<vaultId>', 'Vault ID')
122
- .argument('<webhookId>', 'Webhook ID'))
122
+ .argument('<webhookId>', 'Webhook ID')
123
+ .option('-y, --yes', 'Skip confirmation prompt'))
123
124
  .action(async (vaultId, webhookId, _opts) => {
124
125
  const flags = resolveFlags(_opts);
125
126
  const out = createOutput(flags);
127
+ if (!_opts.yes) {
128
+ out.status(chalk.yellow(`Pass --yes to delete webhook ${webhookId}.`));
129
+ return;
130
+ }
126
131
  out.startSpinner('Deleting webhook...');
127
132
  try {
128
133
  const client = await getClientAsync();
package/dist/config.d.ts CHANGED
@@ -5,6 +5,8 @@ export interface CliConfig {
5
5
  apiKey?: string;
6
6
  accessToken?: string;
7
7
  refreshToken?: string;
8
+ /** Per-vault client-side encryption keys, keyed by vaultId */
9
+ vaultKeys?: Record<string, string>;
8
10
  }
9
11
  export declare function getCredentialManager(): CredentialManager;
10
12
  /**
package/dist/config.js CHANGED
@@ -71,19 +71,23 @@ export async function loadConfigAsync() {
71
71
  const fileConfig = JSON.parse(fs.readFileSync(CONFIG_FILE, 'utf-8'));
72
72
  if (fileConfig.apiUrl && !config.apiUrl)
73
73
  config.apiUrl = fileConfig.apiUrl;
74
- if (fileConfig.apiKey && !config.apiKey)
74
+ if (fileConfig.apiKey && !config.apiKey) {
75
75
  config.apiKey = fileConfig.apiKey;
76
+ process.stderr.write('\x1b[33mWarning: API key loaded from plaintext config (~/.lsvault/config.json).\n' +
77
+ 'Run `lsvault auth migrate` to migrate to secure storage.\n' +
78
+ 'See: https://docs.lifestreamdynamics.com/migration-to-secure-storage\x1b[0m\n');
79
+ }
76
80
  }
77
81
  return config;
78
82
  }
79
83
  export function saveConfig(config) {
80
84
  if (!fs.existsSync(CONFIG_DIR)) {
81
- fs.mkdirSync(CONFIG_DIR, { recursive: true });
85
+ fs.mkdirSync(CONFIG_DIR, { recursive: true, mode: 0o700 });
82
86
  }
83
87
  let existing = {};
84
88
  if (fs.existsSync(CONFIG_FILE)) {
85
89
  existing = JSON.parse(fs.readFileSync(CONFIG_FILE, 'utf-8'));
86
90
  }
87
91
  const merged = { ...existing, ...config };
88
- fs.writeFileSync(CONFIG_FILE, JSON.stringify(merged, null, 2) + '\n');
92
+ fs.writeFileSync(CONFIG_FILE, JSON.stringify(merged, null, 2) + '\n', { mode: 0o600 });
89
93
  }
package/dist/index.js CHANGED
@@ -1,5 +1,14 @@
1
1
  #!/usr/bin/env node
2
+ import { createRequire } from 'node:module';
2
3
  import { Command } from 'commander';
4
+ import chalk from 'chalk';
5
+ const require = createRequire(import.meta.url);
6
+ const pkg = require('../package.json');
7
+ // Apply --no-color / NO_COLOR at startup so it takes effect for all output,
8
+ // including messages printed before any action handler runs.
9
+ if (process.argv.includes('--no-color') || process.env.NO_COLOR !== undefined) {
10
+ chalk.level = 0;
11
+ }
3
12
  import { registerAuthCommands } from './commands/auth.js';
4
13
  import { registerMfaCommands } from './commands/mfa.js';
5
14
  import { registerVaultCommands } from './commands/vaults.js';
@@ -21,11 +30,20 @@ import { registerSyncCommands } from './commands/sync.js';
21
30
  import { registerVersionCommands } from './commands/versions.js';
22
31
  import { registerLinkCommands } from './commands/links.js';
23
32
  import { registerCalendarCommands } from './commands/calendar.js';
33
+ import { registerBookingCommands } from './commands/booking.js';
34
+ import { registerAiCommands } from './commands/ai.js';
35
+ import { registerAnalyticsCommands } from './commands/analytics.js';
36
+ import { registerCustomDomainCommands } from './commands/custom-domains.js';
37
+ import { registerPublishVaultCommands } from './commands/publish-vault.js';
38
+ import { registerSamlCommands } from './commands/saml.js';
39
+ import { registerScimCommands } from './commands/scim.js';
40
+ import { registerPluginCommands } from './commands/plugins.js';
41
+ import { registerCompletionCommands } from './commands/completion.js';
24
42
  const program = new Command();
25
43
  program
26
44
  .name('lsvault')
27
45
  .description('Lifestream Vault CLI - manage vaults, documents, and settings')
28
- .version('0.1.0')
46
+ .version(pkg.version)
29
47
  .addHelpText('after', `
30
48
  GETTING STARTED
31
49
  lsvault auth login --email <email> Log in with email/password
@@ -66,4 +84,13 @@ registerSyncCommands(program);
66
84
  registerVersionCommands(program);
67
85
  registerLinkCommands(program);
68
86
  registerCalendarCommands(program);
87
+ registerBookingCommands(program);
88
+ registerAiCommands(program);
89
+ registerAnalyticsCommands(program);
90
+ registerCustomDomainCommands(program);
91
+ registerPublishVaultCommands(program);
92
+ registerSamlCommands(program);
93
+ registerScimCommands(program);
94
+ registerPluginCommands(program);
95
+ registerCompletionCommands(program);
69
96
  program.parse();
@@ -1,13 +1,38 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import crypto from 'node:crypto';
4
+ import os from 'node:os';
1
5
  import { createKeychainBackend } from './keychain.js';
2
6
  import { createEncryptedConfigBackend } from './encrypted-config.js';
3
- // Default passphrase for encrypted config when no interactive prompt is available.
4
- // This provides basic obfuscation the real security benefit is file permissions (0600)
5
- // and the fact that credentials aren't in a JSON file that's easy to grep for API keys.
6
- const DEFAULT_PASSPHRASE = 'lsvault-cli-local-encryption-key';
7
+ /**
8
+ * Returns the machine-specific passphrase used to encrypt the local credential
9
+ * store. On first call it generates a cryptographically random 32-byte hex
10
+ * string, writes it to `~/.lsvault/.passphrase` (mode 0o600, directory 0o700),
11
+ * and returns it. Subsequent calls read the stored value.
12
+ *
13
+ * This replaces the old hardcoded `DEFAULT_PASSPHRASE` constant so that the
14
+ * passphrase is never present in source code or version control.
15
+ */
16
+ function getOrCreatePassphrase() {
17
+ const configDir = path.join(os.homedir(), '.lsvault');
18
+ const passphrasePath = path.join(configDir, '.passphrase');
19
+ try {
20
+ return fs.readFileSync(passphrasePath, 'utf-8').trim();
21
+ }
22
+ catch {
23
+ // File does not exist yet — generate a new one.
24
+ const passphrase = crypto.randomBytes(32).toString('hex');
25
+ fs.mkdirSync(configDir, { recursive: true, mode: 0o700 });
26
+ fs.writeFileSync(passphrasePath, passphrase, { mode: 0o600 });
27
+ return passphrase;
28
+ }
29
+ }
7
30
  export function createCredentialManager(options = {}) {
8
31
  const keychain = options.keychain ?? createKeychainBackend();
9
32
  const encryptedConfig = options.encryptedConfig ?? createEncryptedConfigBackend();
10
- const passphrase = options.passphrase ?? DEFAULT_PASSPHRASE;
33
+ // Use the caller-supplied passphrase (tests pass one explicitly) or lazily
34
+ // generate / load the per-machine passphrase from ~/.lsvault/.passphrase.
35
+ const passphrase = options.passphrase ?? getOrCreatePassphrase();
11
36
  return {
12
37
  async getCredentials() {
13
38
  const result = {};
@@ -87,13 +112,13 @@ export function createCredentialManager(options = {}) {
87
112
  async saveVaultKey(vaultId, keyHex) {
88
113
  // Read existing config, merge vault key, save
89
114
  const existing = encryptedConfig.getCredentials(passphrase) ?? {};
90
- const vaultKeys = existing.vaultKeys ?? {};
115
+ const vaultKeys = { ...(existing.vaultKeys ?? {}) };
91
116
  vaultKeys[vaultId] = keyHex;
92
117
  encryptedConfig.saveCredentials({ ...existing, vaultKeys }, passphrase);
93
118
  },
94
119
  async deleteVaultKey(vaultId) {
95
120
  const existing = encryptedConfig.getCredentials(passphrase) ?? {};
96
- const vaultKeys = existing.vaultKeys ?? {};
121
+ const vaultKeys = { ...(existing.vaultKeys ?? {}) };
97
122
  delete vaultKeys[vaultId];
98
123
  encryptedConfig.saveCredentials({ ...existing, vaultKeys }, passphrase);
99
124
  },
@@ -85,8 +85,8 @@ export async function migrateCredentials(credentialManager, promptFn) {
85
85
  export async function checkAndPromptMigration(credentialManager) {
86
86
  if (!hasPlaintextCredentials())
87
87
  return false;
88
- console.log(chalk.yellow('\nWarning: API key found in plaintext config (~/.lsvault/config.json)'));
89
- console.log(chalk.yellow('Run `lsvault auth migrate` to migrate to secure storage.\n'));
88
+ process.stderr.write(chalk.yellow('\nWarning: API key found in plaintext config (~/.lsvault/config.json)') + '\n');
89
+ process.stderr.write(chalk.yellow('Run `lsvault auth migrate` to migrate to secure storage.\n') + '\n');
90
90
  return false;
91
91
  }
92
92
  export { CONFIG_FILE, CONFIG_DIR };
@@ -29,9 +29,9 @@ export function getActiveProfile() {
29
29
  */
30
30
  export function setActiveProfile(name) {
31
31
  if (!fs.existsSync(CONFIG_DIR)) {
32
- fs.mkdirSync(CONFIG_DIR, { recursive: true });
32
+ fs.mkdirSync(CONFIG_DIR, { recursive: true, mode: 0o700 });
33
33
  }
34
- fs.writeFileSync(ACTIVE_PROFILE_FILE, name + '\n');
34
+ fs.writeFileSync(ACTIVE_PROFILE_FILE, name + '\n', { mode: 0o600 });
35
35
  }
36
36
  /**
37
37
  * Loads a profile's configuration. Returns an empty object if the profile
@@ -54,11 +54,11 @@ export function loadProfile(name) {
54
54
  */
55
55
  export function setProfileValue(name, key, value) {
56
56
  if (!fs.existsSync(PROFILES_DIR)) {
57
- fs.mkdirSync(PROFILES_DIR, { recursive: true });
57
+ fs.mkdirSync(PROFILES_DIR, { recursive: true, mode: 0o700 });
58
58
  }
59
59
  const config = loadProfile(name);
60
60
  config[key] = value;
61
- fs.writeFileSync(getProfilePath(name), JSON.stringify(config, null, 2) + '\n');
61
+ fs.writeFileSync(getProfilePath(name), JSON.stringify(config, null, 2) + '\n', { mode: 0o600 });
62
62
  }
63
63
  /**
64
64
  * Gets a single value from a profile.
@@ -31,9 +31,9 @@ export function loadSyncConfigs() {
31
31
  */
32
32
  export function saveSyncConfigs(configs) {
33
33
  if (!fs.existsSync(CONFIG_DIR)) {
34
- fs.mkdirSync(CONFIG_DIR, { recursive: true });
34
+ fs.mkdirSync(CONFIG_DIR, { recursive: true, mode: 0o700 });
35
35
  }
36
- fs.writeFileSync(SYNCS_FILE, JSON.stringify(configs, null, 2) + '\n');
36
+ fs.writeFileSync(SYNCS_FILE, JSON.stringify(configs, null, 2) + '\n', { mode: 0o600 });
37
37
  }
38
38
  /**
39
39
  * Find a sync config by its ID.
@@ -8,7 +8,7 @@ import { resolveIgnorePatterns } from './ignore.js';
8
8
  import { createWatcher } from './watcher.js';
9
9
  import { createRemotePoller } from './remote-poller.js';
10
10
  import { removePid } from './daemon.js';
11
- import { loadConfig } from '../config.js';
11
+ import { loadConfigAsync } from '../config.js';
12
12
  import { LifestreamVaultClient } from '@lifestreamdynamics/vault-sdk';
13
13
  import { scanLocalFiles, scanRemoteFiles, computePushDiff, computePullDiff, executePush, executePull } from './engine.js';
14
14
  import { loadSyncState } from './state.js';
@@ -17,10 +17,17 @@ function log(msg) {
17
17
  const ts = new Date().toISOString();
18
18
  process.stdout.write(`[${ts}] ${msg}\n`);
19
19
  }
20
- function createClient() {
21
- const config = loadConfig();
22
- if (!config.apiKey) {
23
- throw new Error('No API key configured. Run `lsvault auth login` first.');
20
+ async function createClient() {
21
+ const config = await loadConfigAsync();
22
+ if (!config.apiKey && !config.accessToken) {
23
+ throw new Error('No credentials configured. Run `lsvault auth login` first.');
24
+ }
25
+ if (config.accessToken) {
26
+ return new LifestreamVaultClient({
27
+ baseUrl: config.apiUrl,
28
+ accessToken: config.accessToken,
29
+ refreshToken: config.refreshToken,
30
+ });
24
31
  }
25
32
  return new LifestreamVaultClient({
26
33
  baseUrl: config.apiUrl,
@@ -36,7 +43,7 @@ async function start() {
36
43
  process.exit(0);
37
44
  }
38
45
  log(`Found ${configs.length} auto-sync configuration(s)`);
39
- const client = createClient();
46
+ const client = await createClient();
40
47
  // Startup reconciliation: catch changes made while daemon was stopped
41
48
  for (const config of configs) {
42
49
  try {
@@ -137,7 +137,8 @@ export function startDaemon(logFile) {
137
137
  rotateLogIfNeeded(targetLog);
138
138
  const logFd = fs.openSync(targetLog, 'a');
139
139
  // Spawn the daemon worker as a detached process
140
- const workerPath = path.join(import.meta.dirname, 'daemon-worker.js');
140
+ // Use URL constructor for Node 20.0-20.10 compatibility (import.meta.dirname was added in 20.11).
141
+ const workerPath = path.join(path.dirname(new URL(import.meta.url).pathname), 'daemon-worker.js');
141
142
  const child = spawn(process.execPath, [workerPath], {
142
143
  detached: true,
143
144
  stdio: ['ignore', logFd, logFd],
@@ -63,7 +63,9 @@ export function createRemotePoller(client, config, options) {
63
63
  if (resolution === 'remote') {
64
64
  conflictFile = createConflictFile(config.localPath, doc.path, localContent, 'local');
65
65
  onLocalWrite?.(doc.path);
66
- fs.writeFileSync(localFile, content, 'utf-8');
66
+ const tmpConflict = localFile + '.tmp';
67
+ fs.writeFileSync(tmpConflict, content, 'utf-8');
68
+ fs.renameSync(tmpConflict, localFile);
67
69
  log(`Conflict: ${doc.path} — used remote, saved local as ${conflictFile}`);
68
70
  }
69
71
  else {
@@ -80,13 +82,15 @@ export function createRemotePoller(client, config, options) {
80
82
  continue;
81
83
  }
82
84
  }
83
- // No conflict — download the file
85
+ // No conflict — download the file atomically
84
86
  const dir = path.dirname(localFile);
85
87
  if (!fs.existsSync(dir)) {
86
88
  fs.mkdirSync(dir, { recursive: true });
87
89
  }
88
90
  onLocalWrite?.(doc.path);
89
- fs.writeFileSync(localFile, content, 'utf-8');
91
+ const tmpFile = localFile + '.tmp';
92
+ fs.writeFileSync(tmpFile, content, 'utf-8');
93
+ fs.renameSync(tmpFile, localFile);
90
94
  log(`Pulled: ${doc.path}`);
91
95
  changes++;
92
96
  state.local[doc.path] = {
@@ -43,10 +43,10 @@ export function loadSyncState(syncId) {
43
43
  */
44
44
  export function saveSyncState(state) {
45
45
  if (!fs.existsSync(STATE_DIR)) {
46
- fs.mkdirSync(STATE_DIR, { recursive: true });
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');
49
+ fs.writeFileSync(stateFilePath(state.syncId), JSON.stringify(state, null, 2) + '\n', { mode: 0o600 });
50
50
  }
51
51
  /**
52
52
  * Delete sync state for a given sync configuration.
@@ -0,0 +1,11 @@
1
+ /**
2
+ * Prompt the user to confirm a destructive action.
3
+ *
4
+ * @param message - The confirmation message to display (without " [y/N] " suffix)
5
+ * @param options.yes - If true, skip the prompt and return true immediately (e.g. --yes flag)
6
+ * @returns Promise resolving to true if confirmed, false if declined
7
+ * @throws Error if stdin is not a TTY and --yes was not provided
8
+ */
9
+ export declare function confirmAction(message: string, options?: {
10
+ yes?: boolean;
11
+ }): Promise<boolean>;
@@ -0,0 +1,23 @@
1
+ import readline from 'node:readline';
2
+ /**
3
+ * Prompt the user to confirm a destructive action.
4
+ *
5
+ * @param message - The confirmation message to display (without " [y/N] " suffix)
6
+ * @param options.yes - If true, skip the prompt and return true immediately (e.g. --yes flag)
7
+ * @returns Promise resolving to true if confirmed, false if declined
8
+ * @throws Error if stdin is not a TTY and --yes was not provided
9
+ */
10
+ export async function confirmAction(message, options) {
11
+ if (options?.yes)
12
+ return true;
13
+ if (!process.stdin.isTTY) {
14
+ throw new Error('Cannot prompt for confirmation in non-interactive mode. Use --yes to skip confirmation.');
15
+ }
16
+ const rl = readline.createInterface({ input: process.stdin, output: process.stderr });
17
+ return new Promise((resolve) => {
18
+ rl.question(`${message} [y/N] `, (answer) => {
19
+ rl.close();
20
+ resolve(answer.toLowerCase() === 'y' || answer.toLowerCase() === 'yes');
21
+ });
22
+ });
23
+ }
@@ -1,5 +1,5 @@
1
1
  export function formatBytes(bytes) {
2
- if (bytes === 0)
2
+ if (bytes <= 0)
3
3
  return '0 B';
4
4
  const units = ['B', 'KB', 'MB', 'GB', 'TB'];
5
5
  const i = Math.floor(Math.log(bytes) / Math.log(1024));