clawvault 2.5.2 → 2.5.4
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 +159 -200
- package/bin/clawvault.js +111 -111
- package/bin/command-registration.test.js +166 -166
- package/bin/command-runtime.js +93 -93
- package/bin/command-runtime.test.js +154 -154
- package/bin/help-contract.test.js +39 -39
- package/bin/register-config-commands.js +153 -153
- package/bin/register-config-route-commands.test.js +121 -121
- package/bin/register-core-commands.js +237 -237
- package/bin/register-kanban-commands.js +56 -56
- package/bin/register-kanban-commands.test.js +83 -83
- package/bin/register-maintenance-commands.js +282 -282
- package/bin/register-project-commands.js +209 -209
- package/bin/register-project-commands.test.js +206 -206
- package/bin/register-query-commands.js +317 -317
- package/bin/register-query-commands.test.js +65 -65
- package/bin/register-resilience-commands.js +182 -182
- package/bin/register-resilience-commands.test.js +81 -81
- package/bin/register-route-commands.js +114 -114
- package/bin/register-session-lifecycle-commands.js +206 -206
- package/bin/register-tailscale-commands.js +106 -106
- package/bin/register-task-commands.js +348 -348
- package/bin/register-task-commands.test.js +69 -69
- package/bin/register-template-commands.js +72 -72
- package/bin/register-vault-operations-commands.js +300 -300
- package/bin/test-helpers/cli-command-fixtures.js +119 -119
- package/dashboard/lib/graph-diff.js +104 -104
- package/dashboard/lib/graph-diff.test.js +75 -75
- package/dashboard/lib/vault-parser.js +556 -556
- package/dashboard/lib/vault-parser.test.js +254 -254
- package/dashboard/public/app.js +796 -796
- package/dashboard/public/index.html +52 -52
- package/dashboard/public/styles.css +221 -221
- package/dashboard/server.js +374 -374
- package/dist/{chunk-3FP5BJ42.js → chunk-4QYGFWRM.js} +1 -1
- package/dist/{chunk-M25QVSJM.js → chunk-AXKYDCNN.js} +1 -1
- package/dist/{chunk-CLE2HHNT.js → chunk-IVRIKYFE.js} +18 -11
- package/dist/{chunk-HRTPQQF2.js → chunk-IZEY5S74.js} +1 -1
- package/dist/{chunk-HWUNREDJ.js → chunk-JDLOL2PL.js} +4 -4
- package/dist/{chunk-AY4PGUVL.js → chunk-KL4NAOMO.js} +1 -1
- package/dist/{chunk-O7XHXF7F.js → chunk-MAKNAHAW.js} +4 -4
- package/dist/{chunk-PLZKZW4I.js → chunk-OSMS7QIG.js} +1 -1
- package/dist/{chunk-NZ4ZZNSR.js → chunk-THRJVD4L.js} +1 -1
- package/dist/{chunk-4GBPTBFJ.js → chunk-TIGW564L.js} +1 -1
- package/dist/{chunk-BHO7WSAY.js → chunk-W2HNZC22.js} +3 -3
- package/dist/{chunk-GFJ3LIIB.js → chunk-XAVB4GB4.js} +1 -1
- package/dist/cli/index.js +10 -10
- package/dist/commands/context.js +3 -3
- package/dist/commands/doctor.js +4 -4
- package/dist/commands/embed.js +2 -2
- package/dist/commands/observe.js +2 -2
- package/dist/commands/setup.js +2 -2
- package/dist/commands/sleep.js +2 -2
- package/dist/commands/status.js +3 -3
- package/dist/commands/tailscale.js +3 -3
- package/dist/commands/wake.js +2 -2
- package/dist/index.js +12 -12
- package/dist/lib/tailscale.js +2 -2
- package/dist/lib/webdav.js +1 -1
- package/hooks/clawvault/HOOK.md +83 -74
- package/hooks/clawvault/handler.js +816 -816
- package/hooks/clawvault/handler.test.js +263 -263
- package/package.json +94 -125
- package/templates/checkpoint.md +19 -19
- package/templates/daily-note.md +19 -19
- package/templates/daily.md +19 -19
- package/templates/decision.md +17 -17
- package/templates/handoff.md +19 -19
- package/templates/lesson.md +16 -16
- package/templates/person.md +19 -19
- package/templates/project.md +23 -23
package/dashboard/server.js
CHANGED
|
@@ -1,374 +1,374 @@
|
|
|
1
|
-
import express from 'express';
|
|
2
|
-
import * as fs from 'node:fs/promises';
|
|
3
|
-
import * as os from 'node:os';
|
|
4
|
-
import * as path from 'node:path';
|
|
5
|
-
import { fileURLToPath } from 'node:url';
|
|
6
|
-
import chokidar from 'chokidar';
|
|
7
|
-
import { WebSocketServer } from 'ws';
|
|
8
|
-
import { buildVaultGraph } from './lib/vault-parser.js';
|
|
9
|
-
import { diffGraphs } from './lib/graph-diff.js';
|
|
10
|
-
|
|
11
|
-
const DEFAULT_PORT = 3377;
|
|
12
|
-
const HOST = '0.0.0.0';
|
|
13
|
-
|
|
14
|
-
export async function startDashboard(options = {}) {
|
|
15
|
-
const port = normalizePort(options.port ?? DEFAULT_PORT);
|
|
16
|
-
const vaultPath = resolveVaultPath(options.vaultPath);
|
|
17
|
-
await assertVaultPath(vaultPath);
|
|
18
|
-
|
|
19
|
-
const app = express();
|
|
20
|
-
const serverDir = path.dirname(fileURLToPath(import.meta.url));
|
|
21
|
-
const projectDir = path.resolve(serverDir, '..');
|
|
22
|
-
const publicDir = path.join(serverDir, 'public');
|
|
23
|
-
const forceGraphDistDir = path.join(projectDir, 'node_modules', 'force-graph', 'dist');
|
|
24
|
-
|
|
25
|
-
const graphStore = createLiveGraphStore(vaultPath);
|
|
26
|
-
await graphStore.init();
|
|
27
|
-
|
|
28
|
-
app.get('/api/graph', async (req, res) => {
|
|
29
|
-
try {
|
|
30
|
-
const shouldRefresh = req.query.refresh === '1';
|
|
31
|
-
if (shouldRefresh) {
|
|
32
|
-
await graphStore.refresh({ reason: 'api:refresh' });
|
|
33
|
-
}
|
|
34
|
-
res.json(graphStore.getGraph());
|
|
35
|
-
} catch (error) {
|
|
36
|
-
res.status(500).json({
|
|
37
|
-
error: 'Failed to build graph',
|
|
38
|
-
detail: error instanceof Error ? error.message : String(error)
|
|
39
|
-
});
|
|
40
|
-
}
|
|
41
|
-
});
|
|
42
|
-
|
|
43
|
-
app.get('/api/health', (_req, res) => {
|
|
44
|
-
res.json({
|
|
45
|
-
ok: true,
|
|
46
|
-
vaultPath
|
|
47
|
-
});
|
|
48
|
-
});
|
|
49
|
-
|
|
50
|
-
app.use('/vendor', express.static(forceGraphDistDir));
|
|
51
|
-
app.use(express.static(publicDir, { extensions: ['html'] }));
|
|
52
|
-
|
|
53
|
-
const server = await new Promise((resolve, reject) => {
|
|
54
|
-
const runningServer = app
|
|
55
|
-
.listen(port, HOST, () => resolve(runningServer))
|
|
56
|
-
.on('error', reject);
|
|
57
|
-
});
|
|
58
|
-
|
|
59
|
-
const wsServer = new WebSocketServer({
|
|
60
|
-
server,
|
|
61
|
-
path: '/ws'
|
|
62
|
-
});
|
|
63
|
-
|
|
64
|
-
const unsubscribeGraphUpdates = graphStore.subscribe((update) => {
|
|
65
|
-
broadcast(wsServer, {
|
|
66
|
-
type: 'graph:patch',
|
|
67
|
-
payload: {
|
|
68
|
-
version: update.version,
|
|
69
|
-
reason: update.reason,
|
|
70
|
-
changedPaths: update.changedPaths,
|
|
71
|
-
...update.patch
|
|
72
|
-
}
|
|
73
|
-
});
|
|
74
|
-
});
|
|
75
|
-
|
|
76
|
-
wsServer.on('connection', (socket) => {
|
|
77
|
-
socket.send(
|
|
78
|
-
JSON.stringify({
|
|
79
|
-
type: 'graph:init',
|
|
80
|
-
payload: {
|
|
81
|
-
version: graphStore.getVersion(),
|
|
82
|
-
graph: graphStore.getGraph()
|
|
83
|
-
}
|
|
84
|
-
})
|
|
85
|
-
);
|
|
86
|
-
});
|
|
87
|
-
|
|
88
|
-
const heartbeatInterval = setInterval(() => {
|
|
89
|
-
for (const client of wsServer.clients) {
|
|
90
|
-
if (client.readyState === 1) {
|
|
91
|
-
client.ping();
|
|
92
|
-
}
|
|
93
|
-
}
|
|
94
|
-
}, 20_000);
|
|
95
|
-
|
|
96
|
-
await graphStore.startWatching();
|
|
97
|
-
|
|
98
|
-
logStartup({
|
|
99
|
-
port,
|
|
100
|
-
vaultPath
|
|
101
|
-
});
|
|
102
|
-
|
|
103
|
-
let isShuttingDown = false;
|
|
104
|
-
const shutdown = async () => {
|
|
105
|
-
if (isShuttingDown) {
|
|
106
|
-
return;
|
|
107
|
-
}
|
|
108
|
-
isShuttingDown = true;
|
|
109
|
-
clearInterval(heartbeatInterval);
|
|
110
|
-
unsubscribeGraphUpdates();
|
|
111
|
-
await graphStore.close();
|
|
112
|
-
await new Promise((resolve) => wsServer.close(() => resolve()));
|
|
113
|
-
server.close(() => {
|
|
114
|
-
process.exit(0);
|
|
115
|
-
});
|
|
116
|
-
};
|
|
117
|
-
|
|
118
|
-
process.on('SIGINT', () => {
|
|
119
|
-
void shutdown();
|
|
120
|
-
});
|
|
121
|
-
process.on('SIGTERM', () => {
|
|
122
|
-
void shutdown();
|
|
123
|
-
});
|
|
124
|
-
|
|
125
|
-
return server;
|
|
126
|
-
}
|
|
127
|
-
|
|
128
|
-
function createLiveGraphStore(vaultPath) {
|
|
129
|
-
const subscribers = new Set();
|
|
130
|
-
const changedPathBuffer = new Set();
|
|
131
|
-
const refreshDebounceMs = 240;
|
|
132
|
-
let graph = null;
|
|
133
|
-
let version = 0;
|
|
134
|
-
let refreshTimer = null;
|
|
135
|
-
let watcher = null;
|
|
136
|
-
let inFlightRefresh = null;
|
|
137
|
-
let refreshQueued = false;
|
|
138
|
-
|
|
139
|
-
async function init() {
|
|
140
|
-
graph = await buildVaultGraph(vaultPath);
|
|
141
|
-
version = 1;
|
|
142
|
-
}
|
|
143
|
-
|
|
144
|
-
function getGraph() {
|
|
145
|
-
return graph;
|
|
146
|
-
}
|
|
147
|
-
|
|
148
|
-
function getVersion() {
|
|
149
|
-
return version;
|
|
150
|
-
}
|
|
151
|
-
|
|
152
|
-
function subscribe(listener) {
|
|
153
|
-
subscribers.add(listener);
|
|
154
|
-
return () => {
|
|
155
|
-
subscribers.delete(listener);
|
|
156
|
-
};
|
|
157
|
-
}
|
|
158
|
-
|
|
159
|
-
function emit(update) {
|
|
160
|
-
for (const listener of subscribers) {
|
|
161
|
-
listener(update);
|
|
162
|
-
}
|
|
163
|
-
}
|
|
164
|
-
|
|
165
|
-
function queueRefresh({ reason, changedPath }) {
|
|
166
|
-
if (changedPath) {
|
|
167
|
-
changedPathBuffer.add(changedPath);
|
|
168
|
-
}
|
|
169
|
-
if (refreshTimer) {
|
|
170
|
-
clearTimeout(refreshTimer);
|
|
171
|
-
}
|
|
172
|
-
refreshTimer = setTimeout(() => {
|
|
173
|
-
refreshTimer = null;
|
|
174
|
-
void refresh({ reason });
|
|
175
|
-
}, refreshDebounceMs);
|
|
176
|
-
}
|
|
177
|
-
|
|
178
|
-
async function refresh({ reason = 'manual' } = {}) {
|
|
179
|
-
if (inFlightRefresh) {
|
|
180
|
-
refreshQueued = true;
|
|
181
|
-
return inFlightRefresh;
|
|
182
|
-
}
|
|
183
|
-
|
|
184
|
-
const changedPaths = Array.from(changedPathBuffer).sort((a, b) => a.localeCompare(b));
|
|
185
|
-
changedPathBuffer.clear();
|
|
186
|
-
|
|
187
|
-
inFlightRefresh = buildVaultGraph(vaultPath)
|
|
188
|
-
.then((nextGraph) => {
|
|
189
|
-
const patch = diffGraphs(graph, nextGraph);
|
|
190
|
-
graph = nextGraph;
|
|
191
|
-
if (!patch.hasChanges) {
|
|
192
|
-
return;
|
|
193
|
-
}
|
|
194
|
-
version += 1;
|
|
195
|
-
emit({
|
|
196
|
-
version,
|
|
197
|
-
reason,
|
|
198
|
-
changedPaths,
|
|
199
|
-
patch
|
|
200
|
-
});
|
|
201
|
-
})
|
|
202
|
-
.finally(async () => {
|
|
203
|
-
inFlightRefresh = null;
|
|
204
|
-
if (refreshQueued) {
|
|
205
|
-
refreshQueued = false;
|
|
206
|
-
await refresh({ reason: 'coalesced' });
|
|
207
|
-
}
|
|
208
|
-
});
|
|
209
|
-
|
|
210
|
-
return inFlightRefresh;
|
|
211
|
-
}
|
|
212
|
-
|
|
213
|
-
async function startWatching() {
|
|
214
|
-
watcher = chokidar.watch(path.join(vaultPath, '**', '*.md'), {
|
|
215
|
-
persistent: true,
|
|
216
|
-
ignoreInitial: true,
|
|
217
|
-
awaitWriteFinish: {
|
|
218
|
-
stabilityThreshold: 180,
|
|
219
|
-
pollInterval: 50
|
|
220
|
-
},
|
|
221
|
-
ignored: (watchedPath) => isIgnoredPath(vaultPath, watchedPath)
|
|
222
|
-
});
|
|
223
|
-
|
|
224
|
-
watcher
|
|
225
|
-
.on('add', (filePath) => {
|
|
226
|
-
queueRefresh({
|
|
227
|
-
reason: 'fs:add',
|
|
228
|
-
changedPath: toRelativeVaultPath(vaultPath, filePath)
|
|
229
|
-
});
|
|
230
|
-
})
|
|
231
|
-
.on('change', (filePath) => {
|
|
232
|
-
queueRefresh({
|
|
233
|
-
reason: 'fs:change',
|
|
234
|
-
changedPath: toRelativeVaultPath(vaultPath, filePath)
|
|
235
|
-
});
|
|
236
|
-
})
|
|
237
|
-
.on('unlink', (filePath) => {
|
|
238
|
-
queueRefresh({
|
|
239
|
-
reason: 'fs:unlink',
|
|
240
|
-
changedPath: toRelativeVaultPath(vaultPath, filePath)
|
|
241
|
-
});
|
|
242
|
-
})
|
|
243
|
-
.on('error', (error) => {
|
|
244
|
-
console.error(`Dashboard file watcher error: ${error instanceof Error ? error.message : String(error)}`);
|
|
245
|
-
});
|
|
246
|
-
}
|
|
247
|
-
|
|
248
|
-
async function close() {
|
|
249
|
-
if (refreshTimer) {
|
|
250
|
-
clearTimeout(refreshTimer);
|
|
251
|
-
refreshTimer = null;
|
|
252
|
-
}
|
|
253
|
-
if (watcher) {
|
|
254
|
-
await watcher.close();
|
|
255
|
-
watcher = null;
|
|
256
|
-
}
|
|
257
|
-
}
|
|
258
|
-
|
|
259
|
-
return {
|
|
260
|
-
init,
|
|
261
|
-
getGraph,
|
|
262
|
-
getVersion,
|
|
263
|
-
subscribe,
|
|
264
|
-
refresh,
|
|
265
|
-
startWatching,
|
|
266
|
-
close
|
|
267
|
-
};
|
|
268
|
-
}
|
|
269
|
-
|
|
270
|
-
function broadcast(wsServer, data) {
|
|
271
|
-
const payload = JSON.stringify(data);
|
|
272
|
-
for (const client of wsServer.clients) {
|
|
273
|
-
if (client.readyState === 1) {
|
|
274
|
-
client.send(payload);
|
|
275
|
-
}
|
|
276
|
-
}
|
|
277
|
-
}
|
|
278
|
-
|
|
279
|
-
function isIgnoredPath(vaultPath, watchedPath) {
|
|
280
|
-
const relativePath = toRelativeVaultPath(vaultPath, watchedPath);
|
|
281
|
-
const segments = relativePath.split('/').filter(Boolean);
|
|
282
|
-
|
|
283
|
-
return segments.some((segment) =>
|
|
284
|
-
segment === '.git' || segment === '.obsidian' || segment === '.trash' || segment === 'node_modules'
|
|
285
|
-
);
|
|
286
|
-
}
|
|
287
|
-
|
|
288
|
-
function toRelativeVaultPath(vaultPath, absolutePath) {
|
|
289
|
-
return path.relative(vaultPath, absolutePath).split(path.sep).join('/');
|
|
290
|
-
}
|
|
291
|
-
|
|
292
|
-
function parseArgs(argv) {
|
|
293
|
-
const options = {
|
|
294
|
-
port: DEFAULT_PORT,
|
|
295
|
-
vaultPath: undefined
|
|
296
|
-
};
|
|
297
|
-
|
|
298
|
-
for (let i = 0; i < argv.length; i += 1) {
|
|
299
|
-
const arg = argv[i];
|
|
300
|
-
if (arg === '--port' || arg === '-p') {
|
|
301
|
-
options.port = argv[i + 1];
|
|
302
|
-
i += 1;
|
|
303
|
-
continue;
|
|
304
|
-
}
|
|
305
|
-
if (arg === '--vault' || arg === '-v') {
|
|
306
|
-
options.vaultPath = argv[i + 1];
|
|
307
|
-
i += 1;
|
|
308
|
-
}
|
|
309
|
-
}
|
|
310
|
-
|
|
311
|
-
return options;
|
|
312
|
-
}
|
|
313
|
-
|
|
314
|
-
function normalizePort(value) {
|
|
315
|
-
const parsed = Number.parseInt(String(value), 10);
|
|
316
|
-
if (!Number.isInteger(parsed) || parsed < 1 || parsed > 65535) {
|
|
317
|
-
throw new Error(`Invalid port: ${value}`);
|
|
318
|
-
}
|
|
319
|
-
return parsed;
|
|
320
|
-
}
|
|
321
|
-
|
|
322
|
-
function resolveVaultPath(input) {
|
|
323
|
-
const candidate = input || process.env.CLAWVAULT_PATH || process.cwd();
|
|
324
|
-
return path.resolve(candidate);
|
|
325
|
-
}
|
|
326
|
-
|
|
327
|
-
async function assertVaultPath(vaultPath) {
|
|
328
|
-
let stat;
|
|
329
|
-
try {
|
|
330
|
-
stat = await fs.stat(vaultPath);
|
|
331
|
-
} catch (error) {
|
|
332
|
-
throw new Error(`Vault path not found: ${vaultPath}`);
|
|
333
|
-
}
|
|
334
|
-
|
|
335
|
-
if (!stat.isDirectory()) {
|
|
336
|
-
throw new Error(`Vault path is not a directory: ${vaultPath}`);
|
|
337
|
-
}
|
|
338
|
-
}
|
|
339
|
-
|
|
340
|
-
function logStartup({ port, vaultPath }) {
|
|
341
|
-
const interfaces = os.networkInterfaces();
|
|
342
|
-
const networkUrls = [];
|
|
343
|
-
|
|
344
|
-
for (const addresses of Object.values(interfaces)) {
|
|
345
|
-
for (const address of addresses ?? []) {
|
|
346
|
-
if (address.family !== 'IPv4' || address.internal) {
|
|
347
|
-
continue;
|
|
348
|
-
}
|
|
349
|
-
networkUrls.push(`http://${address.address}:${port}`);
|
|
350
|
-
}
|
|
351
|
-
}
|
|
352
|
-
|
|
353
|
-
console.log('\nClawVault Dashboard');
|
|
354
|
-
console.log(`Vault: ${vaultPath}`);
|
|
355
|
-
console.log(`Local: http://localhost:${port}`);
|
|
356
|
-
for (const url of networkUrls) {
|
|
357
|
-
console.log(`Network: ${url}`);
|
|
358
|
-
}
|
|
359
|
-
console.log('\nPress Ctrl+C to stop.\n');
|
|
360
|
-
}
|
|
361
|
-
|
|
362
|
-
const currentFile = fileURLToPath(import.meta.url);
|
|
363
|
-
const executedFile = process.argv[1] ? path.resolve(process.argv[1]) : '';
|
|
364
|
-
|
|
365
|
-
if (currentFile === executedFile) {
|
|
366
|
-
startDashboard(parseArgs(process.argv.slice(2))).catch((error) => {
|
|
367
|
-
if (error && typeof error === 'object' && 'code' in error && error.code === 'EADDRINUSE') {
|
|
368
|
-
console.error('Port already in use.');
|
|
369
|
-
} else {
|
|
370
|
-
console.error(error instanceof Error ? error.message : String(error));
|
|
371
|
-
}
|
|
372
|
-
process.exit(1);
|
|
373
|
-
});
|
|
374
|
-
}
|
|
1
|
+
import express from 'express';
|
|
2
|
+
import * as fs from 'node:fs/promises';
|
|
3
|
+
import * as os from 'node:os';
|
|
4
|
+
import * as path from 'node:path';
|
|
5
|
+
import { fileURLToPath } from 'node:url';
|
|
6
|
+
import chokidar from 'chokidar';
|
|
7
|
+
import { WebSocketServer } from 'ws';
|
|
8
|
+
import { buildVaultGraph } from './lib/vault-parser.js';
|
|
9
|
+
import { diffGraphs } from './lib/graph-diff.js';
|
|
10
|
+
|
|
11
|
+
const DEFAULT_PORT = 3377;
|
|
12
|
+
const HOST = '0.0.0.0';
|
|
13
|
+
|
|
14
|
+
export async function startDashboard(options = {}) {
|
|
15
|
+
const port = normalizePort(options.port ?? DEFAULT_PORT);
|
|
16
|
+
const vaultPath = resolveVaultPath(options.vaultPath);
|
|
17
|
+
await assertVaultPath(vaultPath);
|
|
18
|
+
|
|
19
|
+
const app = express();
|
|
20
|
+
const serverDir = path.dirname(fileURLToPath(import.meta.url));
|
|
21
|
+
const projectDir = path.resolve(serverDir, '..');
|
|
22
|
+
const publicDir = path.join(serverDir, 'public');
|
|
23
|
+
const forceGraphDistDir = path.join(projectDir, 'node_modules', 'force-graph', 'dist');
|
|
24
|
+
|
|
25
|
+
const graphStore = createLiveGraphStore(vaultPath);
|
|
26
|
+
await graphStore.init();
|
|
27
|
+
|
|
28
|
+
app.get('/api/graph', async (req, res) => {
|
|
29
|
+
try {
|
|
30
|
+
const shouldRefresh = req.query.refresh === '1';
|
|
31
|
+
if (shouldRefresh) {
|
|
32
|
+
await graphStore.refresh({ reason: 'api:refresh' });
|
|
33
|
+
}
|
|
34
|
+
res.json(graphStore.getGraph());
|
|
35
|
+
} catch (error) {
|
|
36
|
+
res.status(500).json({
|
|
37
|
+
error: 'Failed to build graph',
|
|
38
|
+
detail: error instanceof Error ? error.message : String(error)
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
app.get('/api/health', (_req, res) => {
|
|
44
|
+
res.json({
|
|
45
|
+
ok: true,
|
|
46
|
+
vaultPath
|
|
47
|
+
});
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
app.use('/vendor', express.static(forceGraphDistDir));
|
|
51
|
+
app.use(express.static(publicDir, { extensions: ['html'] }));
|
|
52
|
+
|
|
53
|
+
const server = await new Promise((resolve, reject) => {
|
|
54
|
+
const runningServer = app
|
|
55
|
+
.listen(port, HOST, () => resolve(runningServer))
|
|
56
|
+
.on('error', reject);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
const wsServer = new WebSocketServer({
|
|
60
|
+
server,
|
|
61
|
+
path: '/ws'
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
const unsubscribeGraphUpdates = graphStore.subscribe((update) => {
|
|
65
|
+
broadcast(wsServer, {
|
|
66
|
+
type: 'graph:patch',
|
|
67
|
+
payload: {
|
|
68
|
+
version: update.version,
|
|
69
|
+
reason: update.reason,
|
|
70
|
+
changedPaths: update.changedPaths,
|
|
71
|
+
...update.patch
|
|
72
|
+
}
|
|
73
|
+
});
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
wsServer.on('connection', (socket) => {
|
|
77
|
+
socket.send(
|
|
78
|
+
JSON.stringify({
|
|
79
|
+
type: 'graph:init',
|
|
80
|
+
payload: {
|
|
81
|
+
version: graphStore.getVersion(),
|
|
82
|
+
graph: graphStore.getGraph()
|
|
83
|
+
}
|
|
84
|
+
})
|
|
85
|
+
);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
const heartbeatInterval = setInterval(() => {
|
|
89
|
+
for (const client of wsServer.clients) {
|
|
90
|
+
if (client.readyState === 1) {
|
|
91
|
+
client.ping();
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
}, 20_000);
|
|
95
|
+
|
|
96
|
+
await graphStore.startWatching();
|
|
97
|
+
|
|
98
|
+
logStartup({
|
|
99
|
+
port,
|
|
100
|
+
vaultPath
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
let isShuttingDown = false;
|
|
104
|
+
const shutdown = async () => {
|
|
105
|
+
if (isShuttingDown) {
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
isShuttingDown = true;
|
|
109
|
+
clearInterval(heartbeatInterval);
|
|
110
|
+
unsubscribeGraphUpdates();
|
|
111
|
+
await graphStore.close();
|
|
112
|
+
await new Promise((resolve) => wsServer.close(() => resolve()));
|
|
113
|
+
server.close(() => {
|
|
114
|
+
process.exit(0);
|
|
115
|
+
});
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
process.on('SIGINT', () => {
|
|
119
|
+
void shutdown();
|
|
120
|
+
});
|
|
121
|
+
process.on('SIGTERM', () => {
|
|
122
|
+
void shutdown();
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
return server;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function createLiveGraphStore(vaultPath) {
|
|
129
|
+
const subscribers = new Set();
|
|
130
|
+
const changedPathBuffer = new Set();
|
|
131
|
+
const refreshDebounceMs = 240;
|
|
132
|
+
let graph = null;
|
|
133
|
+
let version = 0;
|
|
134
|
+
let refreshTimer = null;
|
|
135
|
+
let watcher = null;
|
|
136
|
+
let inFlightRefresh = null;
|
|
137
|
+
let refreshQueued = false;
|
|
138
|
+
|
|
139
|
+
async function init() {
|
|
140
|
+
graph = await buildVaultGraph(vaultPath);
|
|
141
|
+
version = 1;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function getGraph() {
|
|
145
|
+
return graph;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function getVersion() {
|
|
149
|
+
return version;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function subscribe(listener) {
|
|
153
|
+
subscribers.add(listener);
|
|
154
|
+
return () => {
|
|
155
|
+
subscribers.delete(listener);
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function emit(update) {
|
|
160
|
+
for (const listener of subscribers) {
|
|
161
|
+
listener(update);
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function queueRefresh({ reason, changedPath }) {
|
|
166
|
+
if (changedPath) {
|
|
167
|
+
changedPathBuffer.add(changedPath);
|
|
168
|
+
}
|
|
169
|
+
if (refreshTimer) {
|
|
170
|
+
clearTimeout(refreshTimer);
|
|
171
|
+
}
|
|
172
|
+
refreshTimer = setTimeout(() => {
|
|
173
|
+
refreshTimer = null;
|
|
174
|
+
void refresh({ reason });
|
|
175
|
+
}, refreshDebounceMs);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
async function refresh({ reason = 'manual' } = {}) {
|
|
179
|
+
if (inFlightRefresh) {
|
|
180
|
+
refreshQueued = true;
|
|
181
|
+
return inFlightRefresh;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
const changedPaths = Array.from(changedPathBuffer).sort((a, b) => a.localeCompare(b));
|
|
185
|
+
changedPathBuffer.clear();
|
|
186
|
+
|
|
187
|
+
inFlightRefresh = buildVaultGraph(vaultPath)
|
|
188
|
+
.then((nextGraph) => {
|
|
189
|
+
const patch = diffGraphs(graph, nextGraph);
|
|
190
|
+
graph = nextGraph;
|
|
191
|
+
if (!patch.hasChanges) {
|
|
192
|
+
return;
|
|
193
|
+
}
|
|
194
|
+
version += 1;
|
|
195
|
+
emit({
|
|
196
|
+
version,
|
|
197
|
+
reason,
|
|
198
|
+
changedPaths,
|
|
199
|
+
patch
|
|
200
|
+
});
|
|
201
|
+
})
|
|
202
|
+
.finally(async () => {
|
|
203
|
+
inFlightRefresh = null;
|
|
204
|
+
if (refreshQueued) {
|
|
205
|
+
refreshQueued = false;
|
|
206
|
+
await refresh({ reason: 'coalesced' });
|
|
207
|
+
}
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
return inFlightRefresh;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
async function startWatching() {
|
|
214
|
+
watcher = chokidar.watch(path.join(vaultPath, '**', '*.md'), {
|
|
215
|
+
persistent: true,
|
|
216
|
+
ignoreInitial: true,
|
|
217
|
+
awaitWriteFinish: {
|
|
218
|
+
stabilityThreshold: 180,
|
|
219
|
+
pollInterval: 50
|
|
220
|
+
},
|
|
221
|
+
ignored: (watchedPath) => isIgnoredPath(vaultPath, watchedPath)
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
watcher
|
|
225
|
+
.on('add', (filePath) => {
|
|
226
|
+
queueRefresh({
|
|
227
|
+
reason: 'fs:add',
|
|
228
|
+
changedPath: toRelativeVaultPath(vaultPath, filePath)
|
|
229
|
+
});
|
|
230
|
+
})
|
|
231
|
+
.on('change', (filePath) => {
|
|
232
|
+
queueRefresh({
|
|
233
|
+
reason: 'fs:change',
|
|
234
|
+
changedPath: toRelativeVaultPath(vaultPath, filePath)
|
|
235
|
+
});
|
|
236
|
+
})
|
|
237
|
+
.on('unlink', (filePath) => {
|
|
238
|
+
queueRefresh({
|
|
239
|
+
reason: 'fs:unlink',
|
|
240
|
+
changedPath: toRelativeVaultPath(vaultPath, filePath)
|
|
241
|
+
});
|
|
242
|
+
})
|
|
243
|
+
.on('error', (error) => {
|
|
244
|
+
console.error(`Dashboard file watcher error: ${error instanceof Error ? error.message : String(error)}`);
|
|
245
|
+
});
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
async function close() {
|
|
249
|
+
if (refreshTimer) {
|
|
250
|
+
clearTimeout(refreshTimer);
|
|
251
|
+
refreshTimer = null;
|
|
252
|
+
}
|
|
253
|
+
if (watcher) {
|
|
254
|
+
await watcher.close();
|
|
255
|
+
watcher = null;
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
return {
|
|
260
|
+
init,
|
|
261
|
+
getGraph,
|
|
262
|
+
getVersion,
|
|
263
|
+
subscribe,
|
|
264
|
+
refresh,
|
|
265
|
+
startWatching,
|
|
266
|
+
close
|
|
267
|
+
};
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
function broadcast(wsServer, data) {
|
|
271
|
+
const payload = JSON.stringify(data);
|
|
272
|
+
for (const client of wsServer.clients) {
|
|
273
|
+
if (client.readyState === 1) {
|
|
274
|
+
client.send(payload);
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
function isIgnoredPath(vaultPath, watchedPath) {
|
|
280
|
+
const relativePath = toRelativeVaultPath(vaultPath, watchedPath);
|
|
281
|
+
const segments = relativePath.split('/').filter(Boolean);
|
|
282
|
+
|
|
283
|
+
return segments.some((segment) =>
|
|
284
|
+
segment === '.git' || segment === '.obsidian' || segment === '.trash' || segment === 'node_modules'
|
|
285
|
+
);
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
function toRelativeVaultPath(vaultPath, absolutePath) {
|
|
289
|
+
return path.relative(vaultPath, absolutePath).split(path.sep).join('/');
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
function parseArgs(argv) {
|
|
293
|
+
const options = {
|
|
294
|
+
port: DEFAULT_PORT,
|
|
295
|
+
vaultPath: undefined
|
|
296
|
+
};
|
|
297
|
+
|
|
298
|
+
for (let i = 0; i < argv.length; i += 1) {
|
|
299
|
+
const arg = argv[i];
|
|
300
|
+
if (arg === '--port' || arg === '-p') {
|
|
301
|
+
options.port = argv[i + 1];
|
|
302
|
+
i += 1;
|
|
303
|
+
continue;
|
|
304
|
+
}
|
|
305
|
+
if (arg === '--vault' || arg === '-v') {
|
|
306
|
+
options.vaultPath = argv[i + 1];
|
|
307
|
+
i += 1;
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
return options;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
function normalizePort(value) {
|
|
315
|
+
const parsed = Number.parseInt(String(value), 10);
|
|
316
|
+
if (!Number.isInteger(parsed) || parsed < 1 || parsed > 65535) {
|
|
317
|
+
throw new Error(`Invalid port: ${value}`);
|
|
318
|
+
}
|
|
319
|
+
return parsed;
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
function resolveVaultPath(input) {
|
|
323
|
+
const candidate = input || process.env.CLAWVAULT_PATH || process.cwd();
|
|
324
|
+
return path.resolve(candidate);
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
async function assertVaultPath(vaultPath) {
|
|
328
|
+
let stat;
|
|
329
|
+
try {
|
|
330
|
+
stat = await fs.stat(vaultPath);
|
|
331
|
+
} catch (error) {
|
|
332
|
+
throw new Error(`Vault path not found: ${vaultPath}`);
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
if (!stat.isDirectory()) {
|
|
336
|
+
throw new Error(`Vault path is not a directory: ${vaultPath}`);
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
function logStartup({ port, vaultPath }) {
|
|
341
|
+
const interfaces = os.networkInterfaces();
|
|
342
|
+
const networkUrls = [];
|
|
343
|
+
|
|
344
|
+
for (const addresses of Object.values(interfaces)) {
|
|
345
|
+
for (const address of addresses ?? []) {
|
|
346
|
+
if (address.family !== 'IPv4' || address.internal) {
|
|
347
|
+
continue;
|
|
348
|
+
}
|
|
349
|
+
networkUrls.push(`http://${address.address}:${port}`);
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
console.log('\nClawVault Dashboard');
|
|
354
|
+
console.log(`Vault: ${vaultPath}`);
|
|
355
|
+
console.log(`Local: http://localhost:${port}`);
|
|
356
|
+
for (const url of networkUrls) {
|
|
357
|
+
console.log(`Network: ${url}`);
|
|
358
|
+
}
|
|
359
|
+
console.log('\nPress Ctrl+C to stop.\n');
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
const currentFile = fileURLToPath(import.meta.url);
|
|
363
|
+
const executedFile = process.argv[1] ? path.resolve(process.argv[1]) : '';
|
|
364
|
+
|
|
365
|
+
if (currentFile === executedFile) {
|
|
366
|
+
startDashboard(parseArgs(process.argv.slice(2))).catch((error) => {
|
|
367
|
+
if (error && typeof error === 'object' && 'code' in error && error.code === 'EADDRINUSE') {
|
|
368
|
+
console.error('Port already in use.');
|
|
369
|
+
} else {
|
|
370
|
+
console.error(error instanceof Error ? error.message : String(error));
|
|
371
|
+
}
|
|
372
|
+
process.exit(1);
|
|
373
|
+
});
|
|
374
|
+
}
|