@lifestreamdynamics/vault-cli 1.0.1 → 1.2.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.
@@ -154,4 +154,227 @@ EXAMPLES
154
154
  handleError(out, err, 'Failed to import vault key');
155
155
  }
156
156
  });
157
+ // vault tree
158
+ addGlobalFlags(vaults.command('tree')
159
+ .description('Show vault file tree')
160
+ .argument('<vaultId>', 'Vault ID'))
161
+ .action(async (vaultId, _opts) => {
162
+ const flags = resolveFlags(_opts);
163
+ const out = createOutput(flags);
164
+ out.startSpinner('Fetching vault tree...');
165
+ try {
166
+ const client = await getClientAsync();
167
+ const tree = await client.vaults.getTree(vaultId);
168
+ out.stopSpinner();
169
+ if (flags.output === 'json') {
170
+ out.raw(JSON.stringify(tree, null, 2) + '\n');
171
+ }
172
+ else {
173
+ function printNode(node, depth) {
174
+ const indent = ' '.repeat(depth);
175
+ const icon = node.type === 'directory' ? chalk.yellow('📁') : chalk.cyan('📄');
176
+ process.stdout.write(`${indent}${icon} ${node.name}\n`);
177
+ if (node.children) {
178
+ for (const child of node.children)
179
+ printNode(child, depth + 1);
180
+ }
181
+ }
182
+ for (const node of tree)
183
+ printNode(node, 0);
184
+ }
185
+ }
186
+ catch (err) {
187
+ handleError(out, err, 'Failed to fetch vault tree');
188
+ }
189
+ });
190
+ // vault archive
191
+ addGlobalFlags(vaults.command('archive')
192
+ .description('Archive a vault')
193
+ .argument('<vaultId>', 'Vault ID'))
194
+ .action(async (vaultId, _opts) => {
195
+ const flags = resolveFlags(_opts);
196
+ const out = createOutput(flags);
197
+ out.startSpinner('Archiving vault...');
198
+ try {
199
+ const client = await getClientAsync();
200
+ const vault = await client.vaults.archive(vaultId);
201
+ out.success(`Vault archived: ${vault.name}`, { id: vault.id, name: vault.name, isArchived: vault.isArchived });
202
+ }
203
+ catch (err) {
204
+ handleError(out, err, 'Failed to archive vault');
205
+ }
206
+ });
207
+ // vault unarchive
208
+ addGlobalFlags(vaults.command('unarchive')
209
+ .description('Unarchive a vault')
210
+ .argument('<vaultId>', 'Vault ID'))
211
+ .action(async (vaultId, _opts) => {
212
+ const flags = resolveFlags(_opts);
213
+ const out = createOutput(flags);
214
+ out.startSpinner('Unarchiving vault...');
215
+ try {
216
+ const client = await getClientAsync();
217
+ const vault = await client.vaults.unarchive(vaultId);
218
+ out.success(`Vault unarchived: ${vault.name}`, { id: vault.id, name: vault.name, isArchived: vault.isArchived });
219
+ }
220
+ catch (err) {
221
+ handleError(out, err, 'Failed to unarchive vault');
222
+ }
223
+ });
224
+ // vault transfer
225
+ addGlobalFlags(vaults.command('transfer')
226
+ .description('Transfer vault ownership to another user')
227
+ .argument('<vaultId>', 'Vault ID')
228
+ .argument('<targetEmail>', 'Email of the user to transfer to'))
229
+ .action(async (vaultId, targetEmail, _opts) => {
230
+ const flags = resolveFlags(_opts);
231
+ const out = createOutput(flags);
232
+ out.startSpinner('Transferring vault...');
233
+ try {
234
+ const client = await getClientAsync();
235
+ const vault = await client.vaults.transfer(vaultId, targetEmail);
236
+ out.success(`Vault transferred to ${targetEmail}`, { id: vault.id, name: vault.name });
237
+ }
238
+ catch (err) {
239
+ handleError(out, err, 'Failed to transfer vault');
240
+ }
241
+ });
242
+ // vault export-vault subgroup
243
+ const exportVault = vaults.command('export-vault').description('Vault export operations');
244
+ addGlobalFlags(exportVault.command('create')
245
+ .description('Create a vault export')
246
+ .argument('<vaultId>', 'Vault ID')
247
+ .option('--metadata', 'Include metadata in export')
248
+ .option('--format <fmt>', 'Export format', 'zip'))
249
+ .action(async (vaultId, _opts) => {
250
+ const flags = resolveFlags(_opts);
251
+ const out = createOutput(flags);
252
+ out.startSpinner('Creating export...');
253
+ try {
254
+ const client = await getClientAsync();
255
+ const exp = await client.vaults.createExport(vaultId, {
256
+ includeMetadata: _opts.metadata === true,
257
+ format: _opts.format || 'zip',
258
+ });
259
+ out.success('Export created', { id: exp.id, status: exp.status, format: exp.format });
260
+ }
261
+ catch (err) {
262
+ handleError(out, err, 'Failed to create export');
263
+ }
264
+ });
265
+ addGlobalFlags(exportVault.command('list')
266
+ .description('List vault exports')
267
+ .argument('<vaultId>', 'Vault ID'))
268
+ .action(async (vaultId, _opts) => {
269
+ const flags = resolveFlags(_opts);
270
+ const out = createOutput(flags);
271
+ out.startSpinner('Fetching exports...');
272
+ try {
273
+ const client = await getClientAsync();
274
+ const exports = await client.vaults.listExports(vaultId);
275
+ out.stopSpinner();
276
+ out.list(exports.map(e => ({ id: e.id, status: e.status, format: e.format, createdAt: e.createdAt, completedAt: e.completedAt || '' })), {
277
+ emptyMessage: 'No exports found.',
278
+ columns: [
279
+ { key: 'id', header: 'ID' },
280
+ { key: 'status', header: 'Status' },
281
+ { key: 'format', header: 'Format' },
282
+ { key: 'createdAt', header: 'Created' },
283
+ { key: 'completedAt', header: 'Completed' },
284
+ ],
285
+ textFn: (e) => `${chalk.cyan(String(e.id))} [${String(e.status)}] ${String(e.format)} created: ${String(e.createdAt)}`,
286
+ });
287
+ }
288
+ catch (err) {
289
+ handleError(out, err, 'Failed to list exports');
290
+ }
291
+ });
292
+ addGlobalFlags(exportVault.command('download')
293
+ .description('Download a vault export')
294
+ .argument('<vaultId>', 'Vault ID')
295
+ .argument('<exportId>', 'Export ID')
296
+ .requiredOption('--file <path>', 'Output file path'))
297
+ .action(async (vaultId, exportId, _opts) => {
298
+ const flags = resolveFlags(_opts);
299
+ const out = createOutput(flags);
300
+ out.startSpinner('Downloading export...');
301
+ try {
302
+ const { writeFile } = await import('node:fs/promises');
303
+ const client = await getClientAsync();
304
+ const blob = await client.vaults.downloadExport(vaultId, exportId);
305
+ const buffer = Buffer.from(await blob.arrayBuffer());
306
+ await writeFile(_opts.file, buffer);
307
+ out.success(`Export downloaded to ${String(_opts.file)}`, { path: _opts.file, size: buffer.length });
308
+ }
309
+ catch (err) {
310
+ handleError(out, err, 'Failed to download export');
311
+ }
312
+ });
313
+ // vault mfa subgroup
314
+ const mfa = vaults.command('mfa').description('Vault MFA configuration');
315
+ addGlobalFlags(mfa.command('get')
316
+ .description('Get vault MFA configuration')
317
+ .argument('<vaultId>', 'Vault ID'))
318
+ .action(async (vaultId, _opts) => {
319
+ const flags = resolveFlags(_opts);
320
+ const out = createOutput(flags);
321
+ out.startSpinner('Fetching MFA config...');
322
+ try {
323
+ const client = await getClientAsync();
324
+ const config = await client.vaults.getMfaConfig(vaultId);
325
+ out.stopSpinner();
326
+ out.record({
327
+ mfaRequired: config.mfaRequired,
328
+ sessionWindowMinutes: config.sessionWindowMinutes,
329
+ userVerified: config.userVerified,
330
+ verificationExpiresAt: config.verificationExpiresAt,
331
+ });
332
+ }
333
+ catch (err) {
334
+ handleError(out, err, 'Failed to fetch MFA config');
335
+ }
336
+ });
337
+ addGlobalFlags(mfa.command('set')
338
+ .description('Set vault MFA configuration')
339
+ .argument('<vaultId>', 'Vault ID')
340
+ .option('--require', 'Require MFA for vault access')
341
+ .option('--no-require', 'Disable MFA requirement')
342
+ .option('--window <minutes>', 'Session window in minutes', '60'))
343
+ .action(async (vaultId, _opts) => {
344
+ const flags = resolveFlags(_opts);
345
+ const out = createOutput(flags);
346
+ out.startSpinner('Updating MFA config...');
347
+ try {
348
+ const client = await getClientAsync();
349
+ const config = await client.vaults.setMfaConfig(vaultId, {
350
+ mfaRequired: _opts.require !== false,
351
+ sessionWindowMinutes: parseInt(String(_opts.window || '60'), 10),
352
+ });
353
+ out.success('MFA config updated', { mfaRequired: config.mfaRequired, sessionWindowMinutes: config.sessionWindowMinutes });
354
+ }
355
+ catch (err) {
356
+ handleError(out, err, 'Failed to update MFA config');
357
+ }
358
+ });
359
+ addGlobalFlags(mfa.command('verify')
360
+ .description('Verify MFA for vault access')
361
+ .argument('<vaultId>', 'Vault ID')
362
+ .requiredOption('--method <totp|backup_code>', 'MFA method')
363
+ .requiredOption('--code <code>', 'MFA code'))
364
+ .action(async (vaultId, _opts) => {
365
+ const flags = resolveFlags(_opts);
366
+ const out = createOutput(flags);
367
+ out.startSpinner('Verifying MFA...');
368
+ try {
369
+ const client = await getClientAsync();
370
+ const result = await client.vaults.verifyMfa(vaultId, {
371
+ method: _opts.method,
372
+ code: _opts.code,
373
+ });
374
+ out.success('MFA verified', { verified: result.verified, expiresAt: result.expiresAt });
375
+ }
376
+ catch (err) {
377
+ handleError(out, err, 'Failed to verify MFA');
378
+ }
379
+ });
157
380
  }
package/dist/index.js CHANGED
@@ -1,6 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
  import { Command } from 'commander';
3
3
  import { registerAuthCommands } from './commands/auth.js';
4
+ import { registerMfaCommands } from './commands/mfa.js';
4
5
  import { registerVaultCommands } from './commands/vaults.js';
5
6
  import { registerDocCommands } from './commands/docs.js';
6
7
  import { registerSearchCommands } from './commands/search.js';
@@ -18,6 +19,12 @@ import { registerWebhookCommands } from './commands/webhooks.js';
18
19
  import { registerConfigCommands } from './commands/config.js';
19
20
  import { registerSyncCommands } from './commands/sync.js';
20
21
  import { registerVersionCommands } from './commands/versions.js';
22
+ import { registerLinkCommands } from './commands/links.js';
23
+ import { registerCalendarCommands } from './commands/calendar.js';
24
+ import { registerAiCommands } from './commands/ai.js';
25
+ import { registerAnalyticsCommands } from './commands/analytics.js';
26
+ import { registerCustomDomainCommands } from './commands/custom-domains.js';
27
+ import { registerPublishVaultCommands } from './commands/publish-vault.js';
21
28
  const program = new Command();
22
29
  program
23
30
  .name('lsvault')
@@ -43,6 +50,7 @@ LEARN MORE
43
50
  lsvault <command> --help Show help for a command
44
51
  lsvault <command> <subcommand> --help Show help for a subcommand`);
45
52
  registerAuthCommands(program);
53
+ registerMfaCommands(program);
46
54
  registerVaultCommands(program);
47
55
  registerDocCommands(program);
48
56
  registerSearchCommands(program);
@@ -60,4 +68,10 @@ registerWebhookCommands(program);
60
68
  registerConfigCommands(program);
61
69
  registerSyncCommands(program);
62
70
  registerVersionCommands(program);
71
+ registerLinkCommands(program);
72
+ registerCalendarCommands(program);
73
+ registerAiCommands(program);
74
+ registerAnalyticsCommands(program);
75
+ registerCustomDomainCommands(program);
76
+ registerPublishVaultCommands(program);
63
77
  program.parse();
@@ -10,6 +10,8 @@ import { createRemotePoller } from './remote-poller.js';
10
10
  import { removePid } from './daemon.js';
11
11
  import { loadConfig } 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
15
  const managed = [];
14
16
  function log(msg) {
15
17
  const ts = new Date().toISOString();
@@ -35,6 +37,66 @@ async function start() {
35
37
  }
36
38
  log(`Found ${configs.length} auto-sync configuration(s)`);
37
39
  const client = createClient();
40
+ // Startup reconciliation: catch changes made while daemon was stopped
41
+ for (const config of configs) {
42
+ try {
43
+ log(`Reconciling ${config.id.slice(0, 8)} (${config.mode} mode)...`);
44
+ const ignorePatterns = resolveIgnorePatterns(config.ignore, config.localPath);
45
+ const lastState = loadSyncState(config.id);
46
+ const localFiles = scanLocalFiles(config.localPath, ignorePatterns);
47
+ const remoteFiles = await scanRemoteFiles(client, config.vaultId, ignorePatterns);
48
+ let pushed = 0;
49
+ let pulled = 0;
50
+ let deleted = 0;
51
+ if (config.mode === 'push' || config.mode === 'sync') {
52
+ const pushDiff = computePushDiff(localFiles, remoteFiles, lastState);
53
+ const pushOps = pushDiff.uploads.length + pushDiff.deletes.length;
54
+ if (pushOps > 0) {
55
+ const result = await executePush(client, config, pushDiff);
56
+ pushed = result.filesUploaded;
57
+ deleted += result.filesDeleted;
58
+ if (result.errors.length > 0) {
59
+ for (const err of result.errors) {
60
+ log(` Push error: ${err.path}: ${err.error}`);
61
+ }
62
+ }
63
+ }
64
+ }
65
+ if (config.mode === 'pull' || config.mode === 'sync') {
66
+ const pullDiff = computePullDiff(localFiles, remoteFiles, lastState);
67
+ const pullOps = pullDiff.downloads.length + pullDiff.deletes.length;
68
+ if (pullOps > 0) {
69
+ const result = await executePull(client, config, pullDiff);
70
+ pulled = result.filesDownloaded;
71
+ deleted += result.filesDeleted;
72
+ if (result.errors.length > 0) {
73
+ for (const err of result.errors) {
74
+ log(` Pull error: ${err.path}: ${err.error}`);
75
+ }
76
+ }
77
+ }
78
+ }
79
+ const total = pushed + pulled + deleted;
80
+ if (total > 0) {
81
+ const parts = [];
82
+ if (pushed > 0)
83
+ parts.push(`${pushed} uploaded`);
84
+ if (pulled > 0)
85
+ parts.push(`${pulled} downloaded`);
86
+ if (deleted > 0)
87
+ parts.push(`${deleted} deleted`);
88
+ log(`Reconciled ${config.id.slice(0, 8)}: ${parts.join(', ')}`);
89
+ }
90
+ else {
91
+ log(`Reconciled ${config.id.slice(0, 8)}: up to date`);
92
+ }
93
+ }
94
+ catch (err) {
95
+ const msg = err instanceof Error ? err.message : String(err);
96
+ log(`Reconciliation failed for ${config.id.slice(0, 8)}: ${msg}`);
97
+ // Continue — still start the watcher even if reconciliation fails
98
+ }
99
+ }
38
100
  for (const config of configs) {
39
101
  try {
40
102
  const ignorePatterns = resolveIgnorePatterns(config.ignore, config.localPath);
@@ -36,9 +36,17 @@ export declare function rotateLogIfNeeded(logFile?: string): void;
36
36
  * Start the daemon as a detached child process.
37
37
  * Returns the PID of the spawned process.
38
38
  */
39
- export declare function startDaemon(logFile?: string): number;
39
+ export declare function startDaemon(logFile?: string): {
40
+ pid: number;
41
+ lingerWarning?: string;
42
+ };
40
43
  /**
41
44
  * Stop the running daemon.
42
45
  */
43
46
  export declare function stopDaemon(): boolean;
47
+ /**
48
+ * Check if systemd linger is enabled for the current user (Linux only).
49
+ * When linger is disabled, user services stop when the SSH session ends.
50
+ */
51
+ export declare function checkLingerStatus(): 'enabled' | 'disabled' | 'unknown';
44
52
  export { DAEMON_DIR, PID_FILE, LOG_FILE };
@@ -150,7 +150,12 @@ export function startDaemon(logFile) {
150
150
  writePid(child.pid);
151
151
  child.unref();
152
152
  fs.closeSync(logFd);
153
- return child.pid;
153
+ const result = { pid: child.pid };
154
+ const lingerStatus = checkLingerStatus();
155
+ if (lingerStatus === 'disabled') {
156
+ result.lingerWarning = 'systemd lingering is not enabled for your user. The daemon will stop when you log out. To fix:\n sudo loginctl enable-linger $(whoami)';
157
+ }
158
+ return result;
154
159
  }
155
160
  /**
156
161
  * Stop the running daemon.
@@ -171,4 +176,20 @@ export function stopDaemon() {
171
176
  return false;
172
177
  }
173
178
  }
179
+ /**
180
+ * Check if systemd linger is enabled for the current user (Linux only).
181
+ * When linger is disabled, user services stop when the SSH session ends.
182
+ */
183
+ export function checkLingerStatus() {
184
+ if (process.platform !== 'linux')
185
+ return 'unknown';
186
+ try {
187
+ const username = os.userInfo().username;
188
+ const lingerFile = `/var/lib/systemd/linger/${username}`;
189
+ return fs.existsSync(lingerFile) ? 'enabled' : 'disabled';
190
+ }
191
+ catch {
192
+ return 'unknown';
193
+ }
194
+ }
174
195
  export { DAEMON_DIR, PID_FILE, LOG_FILE };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lifestreamdynamics/vault-cli",
3
- "version": "1.0.1",
3
+ "version": "1.2.0",
4
4
  "description": "Command-line interface for Lifestream Vault",
5
5
  "engines": {
6
6
  "node": ">=20"
@@ -44,7 +44,7 @@
44
44
  "prepublishOnly": "npm run build && npm test"
45
45
  },
46
46
  "dependencies": {
47
- "@lifestreamdynamics/vault-sdk": "^1.0.0",
47
+ "@lifestreamdynamics/vault-sdk": "^1.2.0",
48
48
  "chalk": "^5.4.0",
49
49
  "chokidar": "^4.0.3",
50
50
  "commander": "^13.0.0",