@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 +36 -0
- package/dist/commands/auth.js +67 -1
- package/dist/commands/calendar.d.ts +2 -0
- package/dist/commands/calendar.js +167 -0
- package/dist/commands/links.d.ts +2 -0
- package/dist/commands/links.js +126 -0
- package/dist/commands/mfa.d.ts +2 -0
- package/dist/commands/mfa.js +224 -0
- package/dist/commands/search.js +7 -2
- package/dist/commands/subscription.js +1 -1
- package/dist/commands/sync.js +4 -1
- package/dist/index.js +6 -0
- package/dist/sync/daemon-worker.js +62 -0
- package/dist/sync/daemon.d.ts +9 -1
- package/dist/sync/daemon.js +22 -1
- package/package.json +2 -2
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.
|
package/dist/commands/auth.js
CHANGED
|
@@ -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,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,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,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
|
+
}
|
package/dist/commands/search.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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`);
|
package/dist/commands/sync.js
CHANGED
|
@@ -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);
|
package/dist/sync/daemon.d.ts
CHANGED
|
@@ -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):
|
|
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 };
|
package/dist/sync/daemon.js
CHANGED
|
@@ -150,7 +150,12 @@ export function startDaemon(logFile) {
|
|
|
150
150
|
writePid(child.pid);
|
|
151
151
|
child.unref();
|
|
152
152
|
fs.closeSync(logFd);
|
|
153
|
-
|
|
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
|
|
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.
|
|
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",
|