@lifestreamdynamics/vault-cli 1.0.1 → 1.1.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.
package/README.md CHANGED
@@ -20,6 +20,7 @@ A powerful command-line interface for Lifestream Vault - the multi-user Markdown
20
20
  - [Team Commands](#team-commands)
21
21
  - [Sharing & Publishing](#sharing--publishing)
22
22
  - [Hooks & Webhooks](#hooks--webhooks)
23
+ - [Links & Backlinks](#links--backlinks)
23
24
  - [Admin Commands](#admin-commands)
24
25
  - [Sync & Watch Mode](#-sync--watch-mode)
25
26
  - [Configuration](#️-configuration)
@@ -372,6 +373,41 @@ lsvault webhooks create \
372
373
  --secret webhook_secret_key
373
374
  ```
374
375
 
376
+ ### Calendar
377
+
378
+ | Command | Description |
379
+ |---------|-------------|
380
+ | `lsvault calendar view <vaultId>` | Browse calendar views and activity heatmap |
381
+ | `lsvault calendar due <vaultId>` | List documents by due date |
382
+ | `lsvault calendar events <vaultId>` | List calendar events |
383
+ | `lsvault calendar create-event <vaultId>` | Create a calendar event |
384
+ | `lsvault calendar update-event <vaultId> <eventId>` | Update a calendar event |
385
+ | `lsvault calendar delete-event <vaultId> <eventId>` | Delete a calendar event |
386
+
387
+ ### Links & Backlinks
388
+
389
+ | Command | Description |
390
+ |---------|-------------|
391
+ | `lsvault links list <vaultId> <path>` | List forward links from a document |
392
+ | `lsvault links backlinks <vaultId> <path>` | List backlinks pointing to a document |
393
+ | `lsvault links graph <vaultId>` | Get the link graph for a vault |
394
+ | `lsvault links broken <vaultId>` | List unresolved (broken) links in a vault |
395
+
396
+ **Example:**
397
+ ```bash
398
+ # List forward links from a document
399
+ lsvault links list vault_abc123 notes/index.md
400
+
401
+ # Find all documents linking to a specific document
402
+ lsvault links backlinks vault_abc123 notes/important.md
403
+
404
+ # Get the full link graph for visualization
405
+ lsvault links graph vault_abc123 --output json > graph.json
406
+
407
+ # Find broken links
408
+ lsvault links broken vault_abc123
409
+ ```
410
+
375
411
  ### Admin Commands
376
412
 
377
413
  **Note:** Admin commands require admin role.
@@ -11,11 +11,13 @@ export function registerAuthCommands(program) {
11
11
  .option('--api-key <key>', 'API key (lsv_k_... prefix)')
12
12
  .option('--email <email>', 'Email address for password login')
13
13
  .option('--password <password>', 'Password (prompts interactively if omitted)')
14
+ .option('--mfa-code <code>', 'MFA code (TOTP or backup code) if account has MFA enabled')
14
15
  .option('--api-url <url>', 'API server URL (default: https://vault.lifestreamdynamics.com)')
15
16
  .addHelpText('after', `
16
17
  EXAMPLES
17
18
  lsvault auth login --api-key lsv_k_abc123
18
19
  lsvault auth login --email user@example.com
20
+ lsvault auth login --email user@example.com --mfa-code 123456
19
21
  lsvault auth login --email user@example.com --api-url https://api.example.com`)
20
22
  .action(async (opts) => {
21
23
  const cm = getCredentialManager();
@@ -42,7 +44,20 @@ EXAMPLES
42
44
  const apiUrl = opts.apiUrl ?? config.apiUrl;
43
45
  const spinner = ora('Authenticating...').start();
44
46
  try {
45
- const { tokens, refreshToken } = await LifestreamVaultClient.login(apiUrl, opts.email, password);
47
+ const { tokens, refreshToken } = await LifestreamVaultClient.login(apiUrl, opts.email, password, {}, {
48
+ mfaCode: opts.mfaCode,
49
+ onMfaRequired: async (challenge) => {
50
+ spinner.stop();
51
+ console.log(chalk.yellow('MFA required for this account.'));
52
+ console.log(`Available methods: ${challenge.methods.join(', ')}`);
53
+ const code = await promptMfaCode();
54
+ if (!code) {
55
+ throw new Error('MFA code is required');
56
+ }
57
+ spinner.start('Verifying MFA code...');
58
+ return { method: 'totp', code };
59
+ },
60
+ });
46
61
  // Save tokens to secure storage
47
62
  await cm.saveCredentials({
48
63
  accessToken: tokens.accessToken,
@@ -245,6 +260,57 @@ async function promptPassword() {
245
260
  process.stdin.resume();
246
261
  });
247
262
  }
263
+ /**
264
+ * Prompt for an MFA code from stdin (6 digits, non-echoing).
265
+ * Returns the code or null if stdin is not a TTY.
266
+ */
267
+ async function promptMfaCode() {
268
+ // In non-interactive mode, cannot prompt
269
+ if (!process.stdin.isTTY) {
270
+ return null;
271
+ }
272
+ const readline = await import('node:readline');
273
+ return new Promise((resolve) => {
274
+ const rl = readline.createInterface({
275
+ input: process.stdin,
276
+ output: process.stderr,
277
+ terminal: true,
278
+ });
279
+ // Disable echoing
280
+ process.stderr.write('MFA code: ');
281
+ process.stdin.setRawMode?.(true);
282
+ let code = '';
283
+ const onData = (chunk) => {
284
+ const char = chunk.toString('utf-8');
285
+ if (char === '\n' || char === '\r' || char === '\u0004') {
286
+ process.stderr.write('\n');
287
+ process.stdin.setRawMode?.(false);
288
+ process.stdin.removeListener('data', onData);
289
+ rl.close();
290
+ resolve(code);
291
+ }
292
+ else if (char === '\u0003') {
293
+ // Ctrl+C
294
+ process.stderr.write('\n');
295
+ process.stdin.setRawMode?.(false);
296
+ process.stdin.removeListener('data', onData);
297
+ rl.close();
298
+ resolve(null);
299
+ }
300
+ else if (char === '\u007F' || char === '\b') {
301
+ // Backspace
302
+ if (code.length > 0) {
303
+ code = code.slice(0, -1);
304
+ }
305
+ }
306
+ else {
307
+ code += char;
308
+ }
309
+ };
310
+ process.stdin.on('data', onData);
311
+ process.stdin.resume();
312
+ });
313
+ }
248
314
  function formatMethod(method) {
249
315
  switch (method) {
250
316
  case 'keychain': return chalk.green('OS Keychain');
@@ -0,0 +1,2 @@
1
+ import type { Command } from 'commander';
2
+ export declare function registerCalendarCommands(program: Command): void;
@@ -0,0 +1,167 @@
1
+ import chalk from 'chalk';
2
+ import { getClientAsync } from '../client.js';
3
+ import { addGlobalFlags, resolveFlags } from '../utils/flags.js';
4
+ import { createOutput, handleError } from '../utils/output.js';
5
+ export function registerCalendarCommands(program) {
6
+ const calendar = program.command('calendar').description('Document calendar and due date management');
7
+ // calendar view
8
+ addGlobalFlags(calendar.command('view')
9
+ .description('View calendar activity for a vault')
10
+ .argument('<vaultId>', 'Vault ID')
11
+ .option('--start <date>', 'Start date (YYYY-MM-DD)', getDefaultStart())
12
+ .option('--end <date>', 'End date (YYYY-MM-DD)', getDefaultEnd()))
13
+ .action(async (vaultId, _opts) => {
14
+ const flags = resolveFlags(_opts);
15
+ const out = createOutput(flags);
16
+ out.startSpinner('Loading calendar...');
17
+ try {
18
+ const client = await getClientAsync();
19
+ const response = await client.calendar.getActivity(vaultId, {
20
+ start: _opts.start,
21
+ end: _opts.end,
22
+ });
23
+ out.stopSpinner();
24
+ if (flags.output === 'text') {
25
+ out.status(chalk.dim(`Activity from ${response.start} to ${response.end}:\n`));
26
+ }
27
+ out.list(response.days.map(d => ({
28
+ date: d.date,
29
+ created: String(d.created),
30
+ updated: String(d.updated),
31
+ deleted: String(d.deleted),
32
+ total: String(d.total),
33
+ })), {
34
+ emptyMessage: 'No activity in this period.',
35
+ columns: [
36
+ { key: 'date', header: 'Date' },
37
+ { key: 'created', header: 'Created' },
38
+ { key: 'updated', header: 'Updated' },
39
+ { key: 'deleted', header: 'Deleted' },
40
+ { key: 'total', header: 'Total' },
41
+ ],
42
+ textFn: (d) => {
43
+ const bar = '█'.repeat(Math.min(Number(d.total), 20));
44
+ return `${chalk.dim(String(d.date))} ${chalk.green(bar)} ${chalk.bold(String(d.total))}`;
45
+ },
46
+ });
47
+ }
48
+ catch (err) {
49
+ handleError(out, err, 'Calendar view failed');
50
+ }
51
+ });
52
+ // calendar due
53
+ addGlobalFlags(calendar.command('due')
54
+ .description('List documents with due dates')
55
+ .argument('<vaultId>', 'Vault ID')
56
+ .option('--status <status>', 'Filter: overdue, upcoming, all', 'all'))
57
+ .action(async (vaultId, _opts) => {
58
+ const flags = resolveFlags(_opts);
59
+ const out = createOutput(flags);
60
+ out.startSpinner('Loading due dates...');
61
+ try {
62
+ const client = await getClientAsync();
63
+ const docs = await client.calendar.getDueDates(vaultId, {
64
+ status: _opts.status,
65
+ });
66
+ out.stopSpinner();
67
+ out.list(docs.map(d => ({
68
+ title: d.title || d.path,
69
+ path: d.path,
70
+ dueAt: d.dueAt,
71
+ priority: d.priority || '-',
72
+ status: d.overdue ? 'OVERDUE' : d.completed ? 'Done' : 'Pending',
73
+ })), {
74
+ emptyMessage: 'No documents with due dates.',
75
+ columns: [
76
+ { key: 'title', header: 'Title' },
77
+ { key: 'dueAt', header: 'Due' },
78
+ { key: 'priority', header: 'Priority' },
79
+ { key: 'status', header: 'Status' },
80
+ ],
81
+ textFn: (d) => {
82
+ const statusColor = d.status === 'OVERDUE' ? chalk.red : d.status === 'Done' ? chalk.green : chalk.yellow;
83
+ return `${chalk.cyan(String(d.title))} — due ${chalk.dim(String(d.dueAt))} ${statusColor(String(d.status))}`;
84
+ },
85
+ });
86
+ }
87
+ catch (err) {
88
+ handleError(out, err, 'Due dates failed');
89
+ }
90
+ });
91
+ // calendar set-due
92
+ addGlobalFlags(calendar.command('set-due')
93
+ .description('Set due date on a document')
94
+ .argument('<vaultId>', 'Vault ID')
95
+ .argument('<path>', 'Document path')
96
+ .requiredOption('--date <date>', 'Due date (YYYY-MM-DD or "clear")')
97
+ .option('--priority <priority>', 'Priority (low/medium/high)')
98
+ .option('--recurrence <recurrence>', 'Recurrence (daily/weekly/monthly/yearly)'))
99
+ .action(async (vaultId, path, _opts) => {
100
+ const flags = resolveFlags(_opts);
101
+ const out = createOutput(flags);
102
+ out.startSpinner('Setting due date...');
103
+ try {
104
+ const client = await getClientAsync();
105
+ const dateStr = _opts.date;
106
+ await client.calendar.setDocumentDue(vaultId, path, {
107
+ dueAt: dateStr === 'clear' ? null : new Date(dateStr).toISOString(),
108
+ priority: _opts.priority || null,
109
+ recurrence: _opts.recurrence || null,
110
+ });
111
+ out.stopSpinner();
112
+ out.status(dateStr === 'clear'
113
+ ? chalk.green(`Due date cleared for ${path}`)
114
+ : chalk.green(`Due date set to ${dateStr} for ${path}`));
115
+ }
116
+ catch (err) {
117
+ handleError(out, err, 'Set due date failed');
118
+ }
119
+ });
120
+ // calendar events
121
+ addGlobalFlags(calendar.command('events')
122
+ .description('List calendar events')
123
+ .argument('<vaultId>', 'Vault ID')
124
+ .option('--start <date>', 'Start date')
125
+ .option('--end <date>', 'End date'))
126
+ .action(async (vaultId, _opts) => {
127
+ const flags = resolveFlags(_opts);
128
+ const out = createOutput(flags);
129
+ out.startSpinner('Loading events...');
130
+ try {
131
+ const client = await getClientAsync();
132
+ const events = await client.calendar.listEvents(vaultId, {
133
+ start: _opts.start,
134
+ end: _opts.end,
135
+ });
136
+ out.stopSpinner();
137
+ out.list(events.map(e => ({
138
+ title: e.title,
139
+ startDate: e.startDate,
140
+ priority: e.priority || '-',
141
+ completed: e.completed ? '✓' : '-',
142
+ })), {
143
+ emptyMessage: 'No calendar events.',
144
+ columns: [
145
+ { key: 'title', header: 'Title' },
146
+ { key: 'startDate', header: 'Date' },
147
+ { key: 'priority', header: 'Priority' },
148
+ { key: 'completed', header: 'Done' },
149
+ ],
150
+ textFn: (e) => `${chalk.cyan(String(e.title))} — ${chalk.dim(String(e.startDate))}`,
151
+ });
152
+ }
153
+ catch (err) {
154
+ handleError(out, err, 'Calendar events failed');
155
+ }
156
+ });
157
+ }
158
+ function getDefaultStart() {
159
+ const d = new Date();
160
+ d.setDate(1);
161
+ return d.toISOString().split('T')[0];
162
+ }
163
+ function getDefaultEnd() {
164
+ const d = new Date();
165
+ d.setMonth(d.getMonth() + 1, 0);
166
+ return d.toISOString().split('T')[0];
167
+ }
@@ -0,0 +1,2 @@
1
+ import type { Command } from 'commander';
2
+ export declare function registerLinkCommands(program: Command): void;
@@ -0,0 +1,126 @@
1
+ import chalk from 'chalk';
2
+ import { getClientAsync } from '../client.js';
3
+ import { addGlobalFlags, resolveFlags } from '../utils/flags.js';
4
+ import { createOutput, handleError } from '../utils/output.js';
5
+ export function registerLinkCommands(program) {
6
+ const links = program.command('links').description('Manage document links and backlinks');
7
+ // lsvault links list <vaultId> <path> — forward links
8
+ addGlobalFlags(links.command('list')
9
+ .description('List forward links from a document')
10
+ .argument('<vaultId>', 'Vault ID')
11
+ .argument('<path>', 'Document path'))
12
+ .action(async (vaultId, docPath, _opts) => {
13
+ const flags = resolveFlags(_opts);
14
+ const out = createOutput(flags);
15
+ out.startSpinner('Fetching links...');
16
+ try {
17
+ const client = await getClientAsync();
18
+ const linkList = await client.documents.getLinks(vaultId, docPath);
19
+ out.stopSpinner();
20
+ out.list(linkList.map(link => ({
21
+ targetPath: link.targetPath,
22
+ linkText: link.linkText,
23
+ resolved: link.isResolved ? 'Yes' : 'No',
24
+ })), {
25
+ emptyMessage: 'No forward links found.',
26
+ columns: [
27
+ { key: 'targetPath', header: 'Target' },
28
+ { key: 'linkText', header: 'Link Text' },
29
+ { key: 'resolved', header: 'Resolved' },
30
+ ],
31
+ textFn: (link) => {
32
+ const resolved = link.resolved === 'Yes' ? chalk.green('✓') : chalk.red('✗');
33
+ return ` ${resolved} [[${String(link.linkText)}]] → ${String(link.targetPath)}`;
34
+ },
35
+ });
36
+ }
37
+ catch (err) {
38
+ handleError(out, err, 'Failed to fetch links');
39
+ }
40
+ });
41
+ // lsvault links backlinks <vaultId> <path>
42
+ addGlobalFlags(links.command('backlinks')
43
+ .description('List backlinks pointing to a document')
44
+ .argument('<vaultId>', 'Vault ID')
45
+ .argument('<path>', 'Document path'))
46
+ .action(async (vaultId, docPath, _opts) => {
47
+ const flags = resolveFlags(_opts);
48
+ const out = createOutput(flags);
49
+ out.startSpinner('Fetching backlinks...');
50
+ try {
51
+ const client = await getClientAsync();
52
+ const backlinks = await client.documents.getBacklinks(vaultId, docPath);
53
+ out.stopSpinner();
54
+ out.list(backlinks.map(bl => ({
55
+ source: bl.sourceDocument.title || bl.sourceDocument.path,
56
+ linkText: bl.linkText,
57
+ context: bl.contextSnippet || '',
58
+ })), {
59
+ emptyMessage: 'No backlinks found.',
60
+ columns: [
61
+ { key: 'source', header: 'Source' },
62
+ { key: 'linkText', header: 'Link Text' },
63
+ { key: 'context', header: 'Context' },
64
+ ],
65
+ textFn: (bl) => {
66
+ const lines = [chalk.cyan(` ${String(bl.source)}`)];
67
+ lines.push(` Link: [[${String(bl.linkText)}]]`);
68
+ if (bl.context)
69
+ lines.push(` Context: ...${String(bl.context)}...`);
70
+ return lines.join('\n');
71
+ },
72
+ });
73
+ }
74
+ catch (err) {
75
+ handleError(out, err, 'Failed to fetch backlinks');
76
+ }
77
+ });
78
+ // lsvault links graph <vaultId>
79
+ addGlobalFlags(links.command('graph')
80
+ .description('Get the link graph for a vault')
81
+ .argument('<vaultId>', 'Vault ID'))
82
+ .action(async (vaultId, _opts) => {
83
+ const flags = resolveFlags(_opts);
84
+ const out = createOutput(flags);
85
+ out.startSpinner('Fetching link graph...');
86
+ try {
87
+ const client = await getClientAsync();
88
+ const graph = await client.vaults.getGraph(vaultId);
89
+ out.stopSpinner();
90
+ // For graph, output as JSON structure
91
+ process.stdout.write(JSON.stringify({ nodes: graph.nodes, edges: graph.edges }) + '\n');
92
+ }
93
+ catch (err) {
94
+ handleError(out, err, 'Failed to fetch link graph');
95
+ }
96
+ });
97
+ // lsvault links broken <vaultId>
98
+ addGlobalFlags(links.command('broken')
99
+ .description('List unresolved (broken) links in a vault')
100
+ .argument('<vaultId>', 'Vault ID'))
101
+ .action(async (vaultId, _opts) => {
102
+ const flags = resolveFlags(_opts);
103
+ const out = createOutput(flags);
104
+ out.startSpinner('Fetching unresolved links...');
105
+ try {
106
+ const client = await getClientAsync();
107
+ const unresolved = await client.vaults.getUnresolvedLinks(vaultId);
108
+ out.stopSpinner();
109
+ if (unresolved.length === 0) {
110
+ out.success('No broken links found!');
111
+ return;
112
+ }
113
+ // Format as grouped output
114
+ for (const group of unresolved) {
115
+ process.stdout.write(chalk.red(` ✗ ${group.targetPath}`) + '\n');
116
+ for (const ref of group.references) {
117
+ process.stdout.write(` ← ${ref.sourcePath} (${chalk.dim(ref.linkText)})` + '\n');
118
+ }
119
+ }
120
+ process.stdout.write(`\n ${chalk.yellow(`${unresolved.length} broken link target(s) found`)}` + '\n');
121
+ }
122
+ catch (err) {
123
+ handleError(out, err, 'Failed to fetch unresolved links');
124
+ }
125
+ });
126
+ }
@@ -0,0 +1,2 @@
1
+ import type { Command } from 'commander';
2
+ export declare function registerMfaCommands(program: Command): void;
@@ -0,0 +1,224 @@
1
+ import chalk from 'chalk';
2
+ import ora from 'ora';
3
+ import { getClientAsync } from '../client.js';
4
+ export function registerMfaCommands(program) {
5
+ const mfa = program.command('mfa').description('Multi-factor authentication management');
6
+ mfa.command('status')
7
+ .description('Show MFA status and configured methods')
8
+ .action(async () => {
9
+ const spinner = ora('Fetching MFA status...').start();
10
+ try {
11
+ const client = await getClientAsync();
12
+ const status = await client.mfa.getStatus();
13
+ spinner.stop();
14
+ console.log(chalk.bold('MFA Status'));
15
+ console.log(` Enabled: ${status.mfaEnabled ? chalk.green('Yes') : chalk.dim('No')}`);
16
+ console.log(` TOTP Configured: ${status.totpConfigured ? chalk.green('Yes') : chalk.dim('No')}`);
17
+ console.log(` Passkeys Registered: ${status.passkeyCount > 0 ? chalk.cyan(status.passkeyCount) : chalk.dim('0')}`);
18
+ console.log(` Backup Codes Left: ${status.backupCodesRemaining > 0 ? chalk.cyan(status.backupCodesRemaining) : chalk.yellow('0')}`);
19
+ if (status.passkeys.length > 0) {
20
+ console.log('');
21
+ console.log(chalk.bold('Registered Passkeys:'));
22
+ for (const passkey of status.passkeys) {
23
+ const lastUsed = passkey.lastUsedAt
24
+ ? new Date(passkey.lastUsedAt).toLocaleDateString()
25
+ : chalk.dim('never');
26
+ console.log(` - ${chalk.cyan(passkey.name)} (last used: ${lastUsed})`);
27
+ }
28
+ }
29
+ }
30
+ catch (err) {
31
+ spinner.fail('Failed to fetch MFA status');
32
+ console.error(err instanceof Error ? err.message : String(err));
33
+ }
34
+ });
35
+ mfa.command('setup-totp')
36
+ .description('Set up TOTP authenticator app (Google Authenticator, Authy, etc.)')
37
+ .action(async () => {
38
+ const spinner = ora('Generating TOTP secret...').start();
39
+ try {
40
+ const client = await getClientAsync();
41
+ const setup = await client.mfa.setupTotp();
42
+ spinner.stop();
43
+ console.log(chalk.bold('TOTP Setup'));
44
+ console.log('');
45
+ console.log(`Secret: ${chalk.cyan(setup.secret)}`);
46
+ console.log('');
47
+ console.log('Add this URI to your authenticator app:');
48
+ console.log(chalk.dim(setup.otpauthUri));
49
+ console.log('');
50
+ console.log(chalk.yellow('Note: QR codes cannot be displayed in the terminal.'));
51
+ console.log(chalk.yellow(' Copy the URI above to any authenticator app that supports otpauth:// URIs.'));
52
+ console.log('');
53
+ // Prompt for verification code
54
+ const code = await promptMfaCode();
55
+ if (!code) {
56
+ console.log(chalk.yellow('Setup cancelled.'));
57
+ return;
58
+ }
59
+ const verifySpinner = ora('Verifying code and enabling TOTP...').start();
60
+ const result = await client.mfa.verifyTotp(code);
61
+ verifySpinner.succeed('TOTP enabled successfully!');
62
+ console.log('');
63
+ console.log(chalk.bold.yellow('IMPORTANT: Save these backup codes securely!'));
64
+ console.log(chalk.dim('You can use them to access your account if you lose your authenticator device.'));
65
+ console.log('');
66
+ // Display backup codes in a grid (2 columns)
67
+ const codes = result.backupCodes;
68
+ for (let i = 0; i < codes.length; i += 2) {
69
+ const left = codes[i] || '';
70
+ const right = codes[i + 1] || '';
71
+ console.log(` ${chalk.cyan(left.padEnd(20))} ${chalk.cyan(right)}`);
72
+ }
73
+ console.log('');
74
+ }
75
+ catch (err) {
76
+ spinner.fail('TOTP setup failed');
77
+ console.error(err instanceof Error ? err.message : String(err));
78
+ }
79
+ });
80
+ mfa.command('disable-totp')
81
+ .description('Disable TOTP authentication (requires password)')
82
+ .action(async () => {
83
+ const password = await promptPassword();
84
+ if (!password) {
85
+ console.log(chalk.yellow('Operation cancelled.'));
86
+ return;
87
+ }
88
+ const spinner = ora('Disabling TOTP...').start();
89
+ try {
90
+ const client = await getClientAsync();
91
+ const result = await client.mfa.disableTotp(password);
92
+ spinner.succeed(result.message);
93
+ }
94
+ catch (err) {
95
+ spinner.fail('Failed to disable TOTP');
96
+ console.error(err instanceof Error ? err.message : String(err));
97
+ }
98
+ });
99
+ mfa.command('backup-codes')
100
+ .description('Show remaining backup code count or regenerate codes')
101
+ .option('--regenerate', 'Generate new backup codes (requires password, invalidates old codes)')
102
+ .action(async (opts) => {
103
+ if (opts.regenerate) {
104
+ // Regenerate backup codes
105
+ const password = await promptPassword();
106
+ if (!password) {
107
+ console.log(chalk.yellow('Operation cancelled.'));
108
+ return;
109
+ }
110
+ const spinner = ora('Regenerating backup codes...').start();
111
+ try {
112
+ const client = await getClientAsync();
113
+ const result = await client.mfa.regenerateBackupCodes(password);
114
+ spinner.succeed('Backup codes regenerated!');
115
+ console.log('');
116
+ console.log(chalk.bold.yellow('IMPORTANT: Save these new backup codes securely!'));
117
+ console.log(chalk.dim('All previous backup codes have been invalidated.'));
118
+ console.log('');
119
+ // Display backup codes in a grid (2 columns)
120
+ const codes = result.backupCodes;
121
+ for (let i = 0; i < codes.length; i += 2) {
122
+ const left = codes[i] || '';
123
+ const right = codes[i + 1] || '';
124
+ console.log(` ${chalk.cyan(left.padEnd(20))} ${chalk.cyan(right)}`);
125
+ }
126
+ console.log('');
127
+ }
128
+ catch (err) {
129
+ spinner.fail('Failed to regenerate backup codes');
130
+ console.error(err instanceof Error ? err.message : String(err));
131
+ }
132
+ }
133
+ else {
134
+ // Show backup code count
135
+ const spinner = ora('Fetching backup code count...').start();
136
+ try {
137
+ const client = await getClientAsync();
138
+ const status = await client.mfa.getStatus();
139
+ spinner.stop();
140
+ console.log(chalk.bold('Backup Codes'));
141
+ console.log(` Remaining: ${status.backupCodesRemaining > 0 ? chalk.cyan(status.backupCodesRemaining) : chalk.yellow('0')}`);
142
+ if (status.backupCodesRemaining === 0) {
143
+ console.log('');
144
+ console.log(chalk.yellow('You have no backup codes remaining.'));
145
+ console.log(chalk.yellow('Run `lsvault mfa backup-codes --regenerate` to generate new codes.'));
146
+ }
147
+ }
148
+ catch (err) {
149
+ spinner.fail('Failed to fetch backup code count');
150
+ console.error(err instanceof Error ? err.message : String(err));
151
+ }
152
+ }
153
+ });
154
+ }
155
+ /**
156
+ * Prompt for a password from stdin (non-echoing).
157
+ * Returns the password or null if stdin is not a TTY.
158
+ */
159
+ async function promptPassword() {
160
+ if (!process.stdin.isTTY) {
161
+ return null;
162
+ }
163
+ const readline = await import('node:readline');
164
+ return new Promise((resolve) => {
165
+ const rl = readline.createInterface({
166
+ input: process.stdin,
167
+ output: process.stderr,
168
+ terminal: true,
169
+ });
170
+ process.stderr.write('Password: ');
171
+ process.stdin.setRawMode?.(true);
172
+ let password = '';
173
+ const onData = (chunk) => {
174
+ const char = chunk.toString('utf-8');
175
+ if (char === '\n' || char === '\r' || char === '\u0004') {
176
+ process.stderr.write('\n');
177
+ process.stdin.setRawMode?.(false);
178
+ process.stdin.removeListener('data', onData);
179
+ rl.close();
180
+ resolve(password);
181
+ }
182
+ else if (char === '\u0003') {
183
+ // Ctrl+C
184
+ process.stderr.write('\n');
185
+ process.stdin.setRawMode?.(false);
186
+ process.stdin.removeListener('data', onData);
187
+ rl.close();
188
+ resolve(null);
189
+ }
190
+ else if (char === '\u007F' || char === '\b') {
191
+ // Backspace
192
+ if (password.length > 0) {
193
+ password = password.slice(0, -1);
194
+ }
195
+ }
196
+ else {
197
+ password += char;
198
+ }
199
+ };
200
+ process.stdin.on('data', onData);
201
+ process.stdin.resume();
202
+ });
203
+ }
204
+ /**
205
+ * Prompt for an MFA code from stdin (6 digits, echoed for visibility).
206
+ * Returns the code or null if stdin is not a TTY.
207
+ */
208
+ async function promptMfaCode() {
209
+ if (!process.stdin.isTTY) {
210
+ return null;
211
+ }
212
+ const readline = await import('node:readline');
213
+ return new Promise((resolve) => {
214
+ const rl = readline.createInterface({
215
+ input: process.stdin,
216
+ output: process.stderr,
217
+ terminal: true,
218
+ });
219
+ rl.question('Enter 6-digit code from authenticator app: ', (answer) => {
220
+ rl.close();
221
+ resolve(answer.trim() || null);
222
+ });
223
+ });
224
+ }
@@ -9,26 +9,31 @@ export function registerSearchCommands(program) {
9
9
  .option('--vault <vaultId>', 'Limit search to a specific vault')
10
10
  .option('--tags <tags>', 'Filter by tags (comma-separated)')
11
11
  .option('--limit <n>', 'Maximum number of results', '20')
12
+ .option('--mode <mode>', 'Search mode: text, semantic, hybrid', 'text')
12
13
  .addHelpText('after', `
13
14
  EXAMPLES
14
15
  lsvault search "meeting notes"
15
16
  lsvault search "project plan" --vault abc123
16
- lsvault search "typescript" --tags dev,code --limit 5`))
17
+ lsvault search "typescript" --tags dev,code --limit 5
18
+ lsvault search "machine learning" --mode semantic`))
17
19
  .action(async (query, _opts) => {
18
20
  const flags = resolveFlags(_opts);
19
21
  const out = createOutput(flags);
20
22
  out.startSpinner('Searching...');
21
23
  try {
22
24
  const client = await getClientAsync();
25
+ const mode = _opts.mode;
23
26
  const response = await client.search.search({
24
27
  q: query,
25
28
  vault: _opts.vault,
26
29
  tags: _opts.tags,
27
30
  limit: parseInt(String(_opts.limit || '20'), 10),
31
+ mode: mode,
28
32
  });
29
33
  out.stopSpinner();
30
34
  if (flags.output === 'text') {
31
- out.status(chalk.dim(`${response.total} result(s) for "${response.query}":\n`));
35
+ const modeInfo = mode && mode !== 'text' ? `[${mode}] ` : '';
36
+ out.status(chalk.dim(`${modeInfo}${response.total} result(s) for "${response.query}":\n`));
32
37
  }
33
38
  out.list(response.results.map(r => ({
34
39
  title: r.title || r.path,
@@ -32,7 +32,7 @@ export function registerSubscriptionCommands(program) {
32
32
  process.stdout.write(chalk.dim('Usage:') + '\n');
33
33
  process.stdout.write(` Vaults: ${data.usage.vaultCount}\n`);
34
34
  process.stdout.write(` Storage: ${formatBytes(data.usage.totalStorageBytes)}\n`);
35
- process.stdout.write(` API calls today: ${data.usage.apiCallsToday}\n`);
35
+ process.stdout.write(` API calls this month: ${data.usage.apiCallsThisMonth}\n`);
36
36
  process.stdout.write(` AI tokens: ${data.usage.aiTokens}\n`);
37
37
  process.stdout.write(` Hook executions: ${data.usage.hookExecutions}\n`);
38
38
  process.stdout.write(` Webhook deliveries: ${data.usage.webhookDeliveries}\n`);
@@ -501,8 +501,11 @@ export function registerSyncCommands(program) {
501
501
  const out = createOutput(flags);
502
502
  try {
503
503
  const logFile = _opts.logFile;
504
- const pid = startDaemon(logFile);
504
+ const { pid, lingerWarning } = startDaemon(logFile);
505
505
  out.success('Daemon started', { pid, status: 'running' });
506
+ if (lingerWarning) {
507
+ out.warn(`Warning: ${lingerWarning}`);
508
+ }
506
509
  }
507
510
  catch (err) {
508
511
  handleError(out, err, 'Failed to start daemon');
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,8 @@ 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';
21
24
  const program = new Command();
22
25
  program
23
26
  .name('lsvault')
@@ -43,6 +46,7 @@ LEARN MORE
43
46
  lsvault <command> --help Show help for a command
44
47
  lsvault <command> <subcommand> --help Show help for a subcommand`);
45
48
  registerAuthCommands(program);
49
+ registerMfaCommands(program);
46
50
  registerVaultCommands(program);
47
51
  registerDocCommands(program);
48
52
  registerSearchCommands(program);
@@ -60,4 +64,6 @@ registerWebhookCommands(program);
60
64
  registerConfigCommands(program);
61
65
  registerSyncCommands(program);
62
66
  registerVersionCommands(program);
67
+ registerLinkCommands(program);
68
+ registerCalendarCommands(program);
63
69
  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.1.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.1.0",
48
48
  "chalk": "^5.4.0",
49
49
  "chokidar": "^4.0.3",
50
50
  "commander": "^13.0.0",