docutrack 0.1.0 → 0.1.6
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/LICENSE +21 -0
- package/README.md +114 -65
- package/bin/docutrack.js +73 -67
- package/package.json +40 -38
- package/src/commands/init.js +245 -80
- package/src/commands/install-global.js +93 -0
- package/src/commands/scan.js +3 -15
- package/src/commands/setup.js +126 -0
- package/src/hooks/global-on-stop.js +18 -0
- package/src/hooks/global-post-tool-use.js +25 -0
- package/src/utils/daemon.js +48 -0
- package/src/utils/queue.js +11 -10
- package/src/utils/queue.test.js +54 -0
- package/src/viewer/index.html +1545 -1411
- package/src/viewer/server.js +383 -652
- package/src/viewer/server.test.js +50 -0
- package/templates/agents/documentalista.md +47 -28
- package/templates/claude-snippet.md +53 -39
- package/templates/hooks/on-stop.js +59 -11
- package/templates/hooks/post-tool-use.js +12 -8
package/src/viewer/server.js
CHANGED
|
@@ -1,652 +1,383 @@
|
|
|
1
|
-
'use strict'
|
|
2
|
-
|
|
3
|
-
const http = require('http')
|
|
4
|
-
const
|
|
5
|
-
const
|
|
6
|
-
const
|
|
7
|
-
const {
|
|
8
|
-
const {
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
this.
|
|
16
|
-
this.
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
if (p === '/
|
|
38
|
-
if (p === '/api/
|
|
39
|
-
if (p === '/api/
|
|
40
|
-
if (p === '/api/
|
|
41
|
-
if (p === '/api/
|
|
42
|
-
if (p === '/api/
|
|
43
|
-
if (p === '/api/
|
|
44
|
-
if (p === '/api/
|
|
45
|
-
if (p === '/
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
const
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
}
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
const
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
const
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
})
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
const
|
|
171
|
-
const
|
|
172
|
-
|
|
173
|
-
const
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
if (e.
|
|
182
|
-
if (!
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
const
|
|
202
|
-
const newFiles
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
res.
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
//
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
if (
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
this.
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
}
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
const
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
const response = await this.callClaude(apiKey, systemPrompt, userPrompt)
|
|
386
|
-
return response
|
|
387
|
-
}
|
|
388
|
-
|
|
389
|
-
callClaude(apiKey, system, user) {
|
|
390
|
-
return new Promise((resolve, reject) => {
|
|
391
|
-
const body = JSON.stringify({
|
|
392
|
-
model: 'claude-haiku-4-5-20251001',
|
|
393
|
-
max_tokens: 1024,
|
|
394
|
-
system,
|
|
395
|
-
messages: [{ role: 'user', content: user }],
|
|
396
|
-
})
|
|
397
|
-
|
|
398
|
-
const req = https.request({
|
|
399
|
-
hostname: 'api.anthropic.com',
|
|
400
|
-
path: '/v1/messages',
|
|
401
|
-
method: 'POST',
|
|
402
|
-
headers: {
|
|
403
|
-
'Content-Type': 'application/json',
|
|
404
|
-
'x-api-key': apiKey,
|
|
405
|
-
'anthropic-version': '2023-06-01',
|
|
406
|
-
'Content-Length': Buffer.byteLength(body),
|
|
407
|
-
},
|
|
408
|
-
}, (res) => {
|
|
409
|
-
let data = ''
|
|
410
|
-
res.on('data', chunk => { data += chunk })
|
|
411
|
-
res.on('end', () => {
|
|
412
|
-
try {
|
|
413
|
-
const r = JSON.parse(data)
|
|
414
|
-
if (r.content?.[0]?.text) resolve(r.content[0].text)
|
|
415
|
-
else reject(new Error(r.error?.message || `API error ${res.statusCode}`))
|
|
416
|
-
} catch (e) { reject(e) }
|
|
417
|
-
})
|
|
418
|
-
})
|
|
419
|
-
req.on('error', reject)
|
|
420
|
-
req.write(body)
|
|
421
|
-
req.end()
|
|
422
|
-
})
|
|
423
|
-
}
|
|
424
|
-
|
|
425
|
-
readApiKey() {
|
|
426
|
-
if (process.env.ANTHROPIC_API_KEY) return process.env.ANTHROPIC_API_KEY
|
|
427
|
-
for (const envFile of ['.env.local', '.env']) {
|
|
428
|
-
const p = path.join(this.root, envFile)
|
|
429
|
-
if (!fs.existsSync(p)) continue
|
|
430
|
-
for (const line of fs.readFileSync(p, 'utf8').split('\n')) {
|
|
431
|
-
const m = line.match(/^ANTHROPIC_API_KEY\s*=\s*(.+)/)
|
|
432
|
-
if (m) return m[1].trim().replace(/^["']|["']$/g, '')
|
|
433
|
-
}
|
|
434
|
-
}
|
|
435
|
-
return null
|
|
436
|
-
}
|
|
437
|
-
|
|
438
|
-
serveGenerateArch(res, req) {
|
|
439
|
-
const apiKey = this.readApiKey()
|
|
440
|
-
if (!apiKey) {
|
|
441
|
-
res.writeHead(400, { 'Content-Type': 'application/json' })
|
|
442
|
-
return res.end(JSON.stringify({ error: 'no_api_key' }))
|
|
443
|
-
}
|
|
444
|
-
|
|
445
|
-
let body = ''
|
|
446
|
-
req.on('data', chunk => { body += chunk })
|
|
447
|
-
req.on('end', async () => {
|
|
448
|
-
let opts = {}
|
|
449
|
-
try { opts = JSON.parse(body) } catch { /* ok */ }
|
|
450
|
-
const lang = opts.lang || 'es'
|
|
451
|
-
|
|
452
|
-
// Gather project context
|
|
453
|
-
let pkg = {}
|
|
454
|
-
try { pkg = JSON.parse(fs.readFileSync(path.join(this.root, 'package.json'), 'utf8')) } catch { /* ok */ }
|
|
455
|
-
|
|
456
|
-
const files = this.scanSourceFiles().slice(0, 80)
|
|
457
|
-
const fileTree = files.join('\n')
|
|
458
|
-
|
|
459
|
-
// Read a few key files for context
|
|
460
|
-
const contextFiles = []
|
|
461
|
-
for (const f of files.slice(0, 6)) {
|
|
462
|
-
try {
|
|
463
|
-
const content = fs.readFileSync(path.join(this.root, f), 'utf8').slice(0, 1500)
|
|
464
|
-
contextFiles.push(`### ${f}\n\`\`\`\n${content}\n\`\`\``)
|
|
465
|
-
} catch { /* ok */ }
|
|
466
|
-
}
|
|
467
|
-
|
|
468
|
-
// Read existing ARCHITECTURE.md (the template)
|
|
469
|
-
let existingArch = ''
|
|
470
|
-
try { existingArch = fs.readFileSync(path.join(this.root, 'ARCHITECTURE.md'), 'utf8') } catch { /* ok */ }
|
|
471
|
-
|
|
472
|
-
const langInstruction = lang === 'es'
|
|
473
|
-
? 'Escribe toda la documentación en español. Los títulos de sección también en español.'
|
|
474
|
-
: 'Write all documentation in English.'
|
|
475
|
-
|
|
476
|
-
const system = `You are a senior software architect writing project documentation.
|
|
477
|
-
Output ONLY the markdown document. No preamble, no explanation.
|
|
478
|
-
${langInstruction}`
|
|
479
|
-
|
|
480
|
-
const user = `Fill in this ARCHITECTURE.md for a real project. Replace ALL placeholder content with real information derived from the project files below.
|
|
481
|
-
|
|
482
|
-
Package.json:
|
|
483
|
-
\`\`\`json
|
|
484
|
-
${JSON.stringify({ name: pkg.name, description: pkg.description, dependencies: pkg.dependencies, devDependencies: pkg.devDependencies }, null, 2).slice(0, 2000)}
|
|
485
|
-
\`\`\`
|
|
486
|
-
|
|
487
|
-
Source file list (${files.length} files):
|
|
488
|
-
\`\`\`
|
|
489
|
-
${fileTree}
|
|
490
|
-
\`\`\`
|
|
491
|
-
|
|
492
|
-
Sample source files:
|
|
493
|
-
${contextFiles.join('\n\n')}
|
|
494
|
-
|
|
495
|
-
Current ARCHITECTURE.md template to fill in:
|
|
496
|
-
${existingArch}
|
|
497
|
-
|
|
498
|
-
Instructions:
|
|
499
|
-
- Fill every empty table cell and placeholder comment with real content derived from the project
|
|
500
|
-
- For the Tech Stack table: detect framework, styling, auth, database, ORM from package.json dependencies
|
|
501
|
-
- For Module Map: list the most important modules from the file list with their actual responsibilities
|
|
502
|
-
- For App Structure: show the real directory tree
|
|
503
|
-
- For Data Flow: describe the actual flow based on the code
|
|
504
|
-
- Keep the same markdown structure and headers
|
|
505
|
-
- Remove placeholder comments like <!-- Describe... -->
|
|
506
|
-
- If a section truly doesn't apply, write "N/A" rather than leaving it blank`
|
|
507
|
-
|
|
508
|
-
try {
|
|
509
|
-
const arch = await this.callClaude(apiKey, system, user)
|
|
510
|
-
fs.writeFileSync(path.join(this.root, 'ARCHITECTURE.md'), arch)
|
|
511
|
-
this.broadcast('reload')
|
|
512
|
-
res.writeHead(200, { 'Content-Type': 'application/json' })
|
|
513
|
-
res.end(JSON.stringify({ ok: true }))
|
|
514
|
-
} catch (err) {
|
|
515
|
-
res.writeHead(500, { 'Content-Type': 'application/json' })
|
|
516
|
-
res.end(JSON.stringify({ error: err.message }))
|
|
517
|
-
}
|
|
518
|
-
})
|
|
519
|
-
}
|
|
520
|
-
|
|
521
|
-
scanSourceFiles() {
|
|
522
|
-
const SOURCE_DIRS = ['src', 'lib', 'app', 'pkg', 'internal', 'api', 'routes', 'controllers', 'handlers']
|
|
523
|
-
const SOURCE_EXTS = new Set(['.js', '.ts', '.mjs', '.jsx', '.tsx', '.py', '.go'])
|
|
524
|
-
const IGNORE_DIRS = new Set(['node_modules', '.next', '.git', 'dist', 'build', '__pycache__', '.docutrack', 'docs', '.worktrees', 'coverage', '.turbo'])
|
|
525
|
-
const IGNORE_RE = [/\.test\.[jt]sx?$/, /\.spec\.[jt]sx?$/, /\.d\.ts$/, /\.min\.js$/]
|
|
526
|
-
const found = []
|
|
527
|
-
const walk = (dir, depth = 0) => {
|
|
528
|
-
if (depth > 6) return
|
|
529
|
-
let entries
|
|
530
|
-
try { entries = fs.readdirSync(dir, { withFileTypes: true }) } catch { return }
|
|
531
|
-
for (const e of entries) {
|
|
532
|
-
if (e.isDirectory()) {
|
|
533
|
-
if (!IGNORE_DIRS.has(e.name) && !e.name.startsWith('.')) walk(path.join(dir, e.name), depth + 1)
|
|
534
|
-
} else if (e.isFile() && SOURCE_EXTS.has(path.extname(e.name))) {
|
|
535
|
-
if (!IGNORE_RE.some(re => re.test(e.name))) {
|
|
536
|
-
found.push(path.relative(this.root, path.join(dir, e.name)).replace(/\\/g, '/'))
|
|
537
|
-
}
|
|
538
|
-
}
|
|
539
|
-
}
|
|
540
|
-
}
|
|
541
|
-
for (const dir of SOURCE_DIRS) {
|
|
542
|
-
const full = path.join(this.root, dir)
|
|
543
|
-
if (fs.existsSync(full)) walk(full)
|
|
544
|
-
}
|
|
545
|
-
return found
|
|
546
|
-
}
|
|
547
|
-
|
|
548
|
-
moduleDocName(file) {
|
|
549
|
-
// app/dashboard/SearchBar.tsx → dashboard-SearchBar
|
|
550
|
-
// lib/rules-engine.ts → rules-engine
|
|
551
|
-
// src/utils/queue.js → utils-queue
|
|
552
|
-
const noExt = file.replace(/\.[^.]+$/, '')
|
|
553
|
-
const parts = noExt.replace(/\\/g, '/').split('/')
|
|
554
|
-
// Drop leading src/lib/app if only one level below
|
|
555
|
-
if (['src', 'lib', 'app'].includes(parts[0]) && parts.length === 2) return parts[1]
|
|
556
|
-
// For deeper paths, join last 2 segments with dash
|
|
557
|
-
return parts.slice(-2).join('-')
|
|
558
|
-
}
|
|
559
|
-
|
|
560
|
-
routeDocName(file) {
|
|
561
|
-
return file
|
|
562
|
-
.replace(/^app\/api\//, '')
|
|
563
|
-
.replace(/\/route\.[jt]s$/, '')
|
|
564
|
-
.replace(/\[([^\]]+)\]/g, '$1')
|
|
565
|
-
.replace(/\//g, '-') || 'api'
|
|
566
|
-
}
|
|
567
|
-
|
|
568
|
-
fileToApiPath(file) {
|
|
569
|
-
return '/' + file
|
|
570
|
-
.replace(/^app\//, '')
|
|
571
|
-
.replace(/\/route\.[jt]s$/, '')
|
|
572
|
-
.replace(/\[([^\]]+)\]/g, '{$1}')
|
|
573
|
-
}
|
|
574
|
-
|
|
575
|
-
serveSSE(req, res) {
|
|
576
|
-
res.writeHead(200, {
|
|
577
|
-
'Content-Type': 'text/event-stream',
|
|
578
|
-
'Cache-Control': 'no-cache',
|
|
579
|
-
'Connection': 'keep-alive',
|
|
580
|
-
})
|
|
581
|
-
res.write('data: connected\n\n')
|
|
582
|
-
|
|
583
|
-
this.sseClients.push(res)
|
|
584
|
-
req.on('close', () => {
|
|
585
|
-
this.sseClients = this.sseClients.filter(c => c !== res)
|
|
586
|
-
})
|
|
587
|
-
}
|
|
588
|
-
|
|
589
|
-
broadcast(event) {
|
|
590
|
-
for (const client of this.sseClients) {
|
|
591
|
-
client.write(`data: ${event}\n\n`)
|
|
592
|
-
}
|
|
593
|
-
}
|
|
594
|
-
|
|
595
|
-
buildTree() {
|
|
596
|
-
const tree = { architecture: null, modules: [], decisions: [], api: [] }
|
|
597
|
-
|
|
598
|
-
const arch = path.join(this.root, 'ARCHITECTURE.md')
|
|
599
|
-
if (fs.existsSync(arch)) tree.architecture = 'ARCHITECTURE.md'
|
|
600
|
-
|
|
601
|
-
const readDir = (rel, key) => {
|
|
602
|
-
const full = path.join(this.root, rel)
|
|
603
|
-
if (!fs.existsSync(full)) return
|
|
604
|
-
for (const e of fs.readdirSync(full, { withFileTypes: true })) {
|
|
605
|
-
if (e.isFile() && e.name.endsWith('.md') && e.name !== '.gitkeep') {
|
|
606
|
-
tree[key].push({ path: `${rel}/${e.name}`, name: e.name.replace('.md', '') })
|
|
607
|
-
}
|
|
608
|
-
}
|
|
609
|
-
tree[key].sort((a, b) => a.name.localeCompare(b.name))
|
|
610
|
-
}
|
|
611
|
-
|
|
612
|
-
readDir('docs/modules', 'modules')
|
|
613
|
-
readDir('docs/decisions', 'decisions')
|
|
614
|
-
readDir('docs/api', 'api')
|
|
615
|
-
|
|
616
|
-
return tree
|
|
617
|
-
}
|
|
618
|
-
|
|
619
|
-
countDocs() {
|
|
620
|
-
let n = 0
|
|
621
|
-
const walk = (dir) => {
|
|
622
|
-
if (!fs.existsSync(dir)) return
|
|
623
|
-
for (const e of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
624
|
-
if (e.isDirectory()) walk(path.join(dir, e.name))
|
|
625
|
-
else if (e.name.endsWith('.md') && e.name !== '.gitkeep') n++
|
|
626
|
-
}
|
|
627
|
-
}
|
|
628
|
-
walk(path.join(this.root, 'docs'))
|
|
629
|
-
return n
|
|
630
|
-
}
|
|
631
|
-
|
|
632
|
-
watchDocs() {
|
|
633
|
-
const debounce = (fn, ms) => {
|
|
634
|
-
let t
|
|
635
|
-
return () => { clearTimeout(t); t = setTimeout(fn, ms) }
|
|
636
|
-
}
|
|
637
|
-
const reload = debounce(() => this.broadcast('reload'), 300)
|
|
638
|
-
|
|
639
|
-
const targets = [
|
|
640
|
-
path.join(this.root, 'docs'),
|
|
641
|
-
path.join(this.root, 'ARCHITECTURE.md'),
|
|
642
|
-
path.join(this.root, '.docutrack', 'queue.json'),
|
|
643
|
-
]
|
|
644
|
-
for (const t of targets) {
|
|
645
|
-
if (fs.existsSync(t)) {
|
|
646
|
-
try { fs.watch(t, { recursive: true }, reload) } catch { /* ignore */ }
|
|
647
|
-
}
|
|
648
|
-
}
|
|
649
|
-
}
|
|
650
|
-
}
|
|
651
|
-
|
|
652
|
-
module.exports = DocuTrackServer
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
const http = require('http')
|
|
4
|
+
const fs = require('fs')
|
|
5
|
+
const path = require('path')
|
|
6
|
+
const { findStale } = require('../utils/stale')
|
|
7
|
+
const { analyzeComplexity } = require('../analyzer/complexity')
|
|
8
|
+
const { analyzeDrift } = require('../utils/drift')
|
|
9
|
+
|
|
10
|
+
const HTML_PATH = path.join(__dirname, 'index.html')
|
|
11
|
+
|
|
12
|
+
class DocuTrackServer {
|
|
13
|
+
constructor(projectRoot, port = 4242) {
|
|
14
|
+
this.root = projectRoot
|
|
15
|
+
this.port = port
|
|
16
|
+
this.sseClients = []
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
start() {
|
|
20
|
+
this.server = http.createServer((req, res) => this.route(req, res))
|
|
21
|
+
this.server.listen(this.port, '127.0.0.1', () => {
|
|
22
|
+
console.log(`\n DocuTrack docs → http://localhost:${this.port}\n`)
|
|
23
|
+
console.log(' Press Ctrl+C to stop.\n')
|
|
24
|
+
})
|
|
25
|
+
this.watchDocs()
|
|
26
|
+
return this
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
route(req, res) {
|
|
30
|
+
const reqUrl = new URL(req.url, `http://127.0.0.1:${this.port}`)
|
|
31
|
+
const p = reqUrl.pathname
|
|
32
|
+
|
|
33
|
+
res.setHeader('Access-Control-Allow-Origin', '*')
|
|
34
|
+
|
|
35
|
+
if (p === '/' || p === '/index.html') return this.serveShell(res)
|
|
36
|
+
if (p === '/api/tree') return this.serveTree(res)
|
|
37
|
+
if (p === '/api/content') return this.serveContent(res, reqUrl.searchParams.get('path'))
|
|
38
|
+
if (p === '/api/status') return this.serveStatus(res)
|
|
39
|
+
if (p === '/api/openapi') return this.serveOpenAPI(res)
|
|
40
|
+
if (p === '/api/check') return this.serveCheck(res)
|
|
41
|
+
if (p === '/api/complexity') return this.serveComplexity(res)
|
|
42
|
+
if (p === '/api/scan' && req.method === 'POST') return this.serveScan(res)
|
|
43
|
+
if (p === '/api/generate' && req.method === 'POST') return this.serveGenerate(res, req)
|
|
44
|
+
if (p === '/api/search') return this.serveSearch(res, reqUrl.searchParams.get('q'))
|
|
45
|
+
if (p === '/events') return this.serveSSE(req, res)
|
|
46
|
+
|
|
47
|
+
res.writeHead(404, { 'Content-Type': 'text/plain' })
|
|
48
|
+
res.end('Not found')
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
serveShell(res) {
|
|
52
|
+
const html = fs.readFileSync(HTML_PATH, 'utf8')
|
|
53
|
+
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' })
|
|
54
|
+
res.end(html)
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
serveTree(res) {
|
|
58
|
+
res.writeHead(200, { 'Content-Type': 'application/json' })
|
|
59
|
+
res.end(JSON.stringify(this.buildTree()))
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
serveContent(res, filePath) {
|
|
63
|
+
if (!filePath) {
|
|
64
|
+
res.writeHead(400, { 'Content-Type': 'text/plain' })
|
|
65
|
+
return res.end('Missing path parameter')
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// Allow only docs/, ARCHITECTURE.md, and a few safe root files
|
|
69
|
+
const normalized = filePath.replace(/\\/g, '/').replace(/^\//, '')
|
|
70
|
+
const SAFE_ROOT = new Set(['ARCHITECTURE.md', 'package.json', 'README.md'])
|
|
71
|
+
const allowed = SAFE_ROOT.has(normalized) || normalized.startsWith('docs/')
|
|
72
|
+
if (!allowed) {
|
|
73
|
+
res.writeHead(403, { 'Content-Type': 'text/plain' })
|
|
74
|
+
return res.end('Forbidden')
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const fullPath = path.join(this.root, normalized)
|
|
78
|
+
if (!fs.existsSync(fullPath)) {
|
|
79
|
+
res.writeHead(404, { 'Content-Type': 'text/plain' })
|
|
80
|
+
return res.end('File not found')
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const content = fs.readFileSync(fullPath, 'utf8')
|
|
84
|
+
res.writeHead(200, { 'Content-Type': 'text/plain; charset=utf-8' })
|
|
85
|
+
res.end(content)
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
serveOpenAPI(res) {
|
|
89
|
+
const specPath = path.join(this.root, 'docs', 'api', 'openapi.json')
|
|
90
|
+
if (!fs.existsSync(specPath)) {
|
|
91
|
+
res.writeHead(404, { 'Content-Type': 'application/json' })
|
|
92
|
+
return res.end(JSON.stringify({ error: 'No spec found. Run: npx docutrack analyze' }))
|
|
93
|
+
}
|
|
94
|
+
const spec = fs.readFileSync(specPath, 'utf8')
|
|
95
|
+
res.writeHead(200, { 'Content-Type': 'application/json' })
|
|
96
|
+
res.end(spec)
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
serveStatus(res) {
|
|
100
|
+
const queuePath = path.join(this.root, '.docutrack', 'queue.json')
|
|
101
|
+
let queue = { pending: [], lastClear: null }
|
|
102
|
+
try {
|
|
103
|
+
if (fs.existsSync(queuePath)) queue = JSON.parse(fs.readFileSync(queuePath, 'utf8'))
|
|
104
|
+
} catch { /* ignore */ }
|
|
105
|
+
|
|
106
|
+
const docCount = this.countDocs()
|
|
107
|
+
const stale = findStale(this.root)
|
|
108
|
+
const coverage = docCount + queue.pending.length > 0
|
|
109
|
+
? Math.round((docCount / (docCount + queue.pending.length)) * 100)
|
|
110
|
+
: 100
|
|
111
|
+
|
|
112
|
+
res.writeHead(200, { 'Content-Type': 'application/json' })
|
|
113
|
+
res.end(JSON.stringify({
|
|
114
|
+
pending: queue.pending.length,
|
|
115
|
+
pendingFiles: queue.pending,
|
|
116
|
+
docCount,
|
|
117
|
+
coverage,
|
|
118
|
+
stale: stale.map(s => ({ doc: s.doc, source: s.source, staleSinceMs: s.staleSinceMs })),
|
|
119
|
+
}))
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
serveCheck(res) {
|
|
123
|
+
const queuePath = path.join(this.root, '.docutrack', 'queue.json')
|
|
124
|
+
let queue = { pending: [] }
|
|
125
|
+
try { queue = JSON.parse(fs.readFileSync(queuePath, 'utf8')) } catch { /* ok */ }
|
|
126
|
+
|
|
127
|
+
let drift = []
|
|
128
|
+
try { drift = analyzeDrift(this.root) } catch { /* ok */ }
|
|
129
|
+
|
|
130
|
+
let complexity = { summary: { total: 0, critical: 0, warnings: 0, healthy: 0 }, files: [] }
|
|
131
|
+
try { complexity = analyzeComplexity(this.root) } catch { /* ok */ }
|
|
132
|
+
|
|
133
|
+
const stale = findStale(this.root)
|
|
134
|
+
|
|
135
|
+
const critical = complexity.files.filter(f => f.warnings.some(w => w.level === 'critical'))
|
|
136
|
+
.map(f => ({ file: path.relative(this.root, f.file), score: f.score, warnings: f.warnings }))
|
|
137
|
+
.slice(0, 10)
|
|
138
|
+
|
|
139
|
+
res.writeHead(200, { 'Content-Type': 'application/json' })
|
|
140
|
+
res.end(JSON.stringify({
|
|
141
|
+
pending: queue.pending.length,
|
|
142
|
+
stale: stale.length,
|
|
143
|
+
drift: drift.map(d => ({ module: d.module, severity: d.severity, undocumented: d.undocumented.slice(0, 5), orphaned: d.orphaned.slice(0, 5) })),
|
|
144
|
+
complexity: { summary: complexity.summary, critical },
|
|
145
|
+
ok: queue.pending.length === 0 && stale.length === 0 && drift.filter(d => d.severity === 'high').length === 0 && critical.length === 0,
|
|
146
|
+
}))
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
serveComplexity(res) {
|
|
150
|
+
let report = { files: [], summary: { total: 0, critical: 0, warnings: 0, healthy: 0 } }
|
|
151
|
+
try { report = analyzeComplexity(this.root) } catch { /* ok */ }
|
|
152
|
+
|
|
153
|
+
const top = report.files.filter(f => f.warnings.length > 0).slice(0, 20).map(f => ({
|
|
154
|
+
file: path.relative(this.root, f.file),
|
|
155
|
+
score: f.score,
|
|
156
|
+
lines: f.lines,
|
|
157
|
+
exports: f.exports,
|
|
158
|
+
complexity: f.complexity,
|
|
159
|
+
maxNesting: f.maxNesting,
|
|
160
|
+
warnings: f.warnings,
|
|
161
|
+
}))
|
|
162
|
+
|
|
163
|
+
res.writeHead(200, { 'Content-Type': 'application/json' })
|
|
164
|
+
res.end(JSON.stringify({ summary: report.summary, files: top }))
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
serveScan(res) {
|
|
168
|
+
const SOURCE_DIRS = ['src', 'lib', 'app', 'pkg', 'internal', 'api', 'routes', 'controllers', 'handlers', 'packages']
|
|
169
|
+
const SOURCE_EXTS = new Set(['.js', '.ts', '.mjs', '.jsx', '.tsx', '.py', '.go'])
|
|
170
|
+
const IGNORE_DIRS = new Set(['node_modules', '.next', '.git', 'dist', 'build', '__pycache__', '.docutrack', 'docs', '.worktrees', 'coverage', '.turbo'])
|
|
171
|
+
const IGNORE_RE = [/\.test\.[jt]sx?$/, /\.spec\.[jt]sx?$/, /\.d\.ts$/, /\.min\.js$/]
|
|
172
|
+
|
|
173
|
+
const found = []
|
|
174
|
+
const walk = (dir, depth = 0) => {
|
|
175
|
+
if (depth > 6) return
|
|
176
|
+
let entries
|
|
177
|
+
try { entries = fs.readdirSync(dir, { withFileTypes: true }) } catch { return }
|
|
178
|
+
for (const e of entries) {
|
|
179
|
+
if (e.isDirectory()) {
|
|
180
|
+
if (!IGNORE_DIRS.has(e.name) && !e.name.startsWith('.')) walk(path.join(dir, e.name), depth + 1)
|
|
181
|
+
} else if (e.isFile() && SOURCE_EXTS.has(path.extname(e.name))) {
|
|
182
|
+
if (!IGNORE_RE.some(re => re.test(e.name))) {
|
|
183
|
+
found.push(path.relative(this.root, path.join(dir, e.name)).replace(/\\/g, '/'))
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
for (const dir of SOURCE_DIRS) {
|
|
190
|
+
const full = path.join(this.root, dir)
|
|
191
|
+
if (fs.existsSync(full)) walk(full)
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// Load existing queue and add new files
|
|
195
|
+
const queuePath = path.join(this.root, '.docutrack', 'queue.json')
|
|
196
|
+
let queue = { pending: [], lastClear: null }
|
|
197
|
+
try { queue = JSON.parse(fs.readFileSync(queuePath, 'utf8')) } catch { /* ok */ }
|
|
198
|
+
|
|
199
|
+
const alreadyQueued = new Set(queue.pending.map(e => e.file))
|
|
200
|
+
const newFiles = found.filter(f => !alreadyQueued.has(f))
|
|
201
|
+
const now = new Date().toISOString()
|
|
202
|
+
for (const f of newFiles) queue.pending.push({ file: f, addedAt: now })
|
|
203
|
+
|
|
204
|
+
try {
|
|
205
|
+
fs.writeFileSync(queuePath, JSON.stringify(queue, null, 2))
|
|
206
|
+
} catch (err) {
|
|
207
|
+
res.writeHead(500, { 'Content-Type': 'application/json' })
|
|
208
|
+
return res.end(JSON.stringify({ error: err.message }))
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// Notify SSE clients so sidebar updates immediately
|
|
212
|
+
this.broadcast('reload')
|
|
213
|
+
|
|
214
|
+
res.writeHead(200, { 'Content-Type': 'application/json' })
|
|
215
|
+
res.end(JSON.stringify({
|
|
216
|
+
queued: newFiles.length,
|
|
217
|
+
skipped: found.length - newFiles.length,
|
|
218
|
+
total: found.length,
|
|
219
|
+
files: newFiles.slice(0, 5),
|
|
220
|
+
hasMore: newFiles.length > 5,
|
|
221
|
+
}))
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
serveGenerate(res, req) {
|
|
225
|
+
let body = ''
|
|
226
|
+
req.on('data', c => { body += c })
|
|
227
|
+
req.on('end', () => {
|
|
228
|
+
try {
|
|
229
|
+
const { lang = 'es', force = false } = JSON.parse(body || '{}')
|
|
230
|
+
const triggerPath = path.join(this.root, '.docutrack', 'generate.trigger')
|
|
231
|
+
fs.writeFileSync(triggerPath, JSON.stringify({ lang, force, requestedAt: new Date().toISOString() }))
|
|
232
|
+
} catch { /* ok */ }
|
|
233
|
+
res.writeHead(200, { 'Content-Type': 'application/json' })
|
|
234
|
+
res.end(JSON.stringify({ triggered: true }))
|
|
235
|
+
})
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
moduleDocName(file) {
|
|
239
|
+
// app/dashboard/SearchBar.tsx → dashboard-SearchBar
|
|
240
|
+
// lib/rules-engine.ts → rules-engine
|
|
241
|
+
// src/utils/queue.js → utils-queue
|
|
242
|
+
const noExt = file.replace(/\.[^.]+$/, '')
|
|
243
|
+
const parts = noExt.replace(/\\/g, '/').split('/')
|
|
244
|
+
// Drop leading src/lib/app if only one level below
|
|
245
|
+
if (['src', 'lib', 'app'].includes(parts[0]) && parts.length === 2) return parts[1]
|
|
246
|
+
// For deeper paths, join last 2 segments with dash
|
|
247
|
+
return parts.slice(-2).join('-')
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
routeDocName(file) {
|
|
251
|
+
return file
|
|
252
|
+
.replace(/^app\/api\//, '')
|
|
253
|
+
.replace(/\/route\.[jt]s$/, '')
|
|
254
|
+
.replace(/\[([^\]]+)\]/g, '$1')
|
|
255
|
+
.replace(/\//g, '-') || 'api'
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
fileToApiPath(file) {
|
|
259
|
+
return '/' + file
|
|
260
|
+
.replace(/^app\//, '')
|
|
261
|
+
.replace(/\/route\.[jt]s$/, '')
|
|
262
|
+
.replace(/\[([^\]]+)\]/g, '{$1}')
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
serveSearch(res, q) {
|
|
266
|
+
res.writeHead(200, { 'Content-Type': 'application/json' })
|
|
267
|
+
if (!q || q.length < 2) return res.end(JSON.stringify([]))
|
|
268
|
+
|
|
269
|
+
const lq = q.toLowerCase()
|
|
270
|
+
const results = []
|
|
271
|
+
|
|
272
|
+
const searchFile = (fullPath, relPath) => {
|
|
273
|
+
if (results.length >= 8) return
|
|
274
|
+
try {
|
|
275
|
+
const content = fs.readFileSync(fullPath, 'utf8')
|
|
276
|
+
const lc = content.toLowerCase()
|
|
277
|
+
const idx = lc.indexOf(lq)
|
|
278
|
+
if (idx === -1) return
|
|
279
|
+
const start = Math.max(0, idx - 50)
|
|
280
|
+
const end = Math.min(content.length, idx + lq.length + 100)
|
|
281
|
+
const snippet = (start > 0 ? '…' : '') + content.slice(start, end).replace(/[#*`\n]/g, ' ').replace(/\s+/g, ' ').trim() + (end < content.length ? '…' : '')
|
|
282
|
+
const titleMatch = content.match(/^#\s+(.+)/m)
|
|
283
|
+
const title = titleMatch ? titleMatch[1].trim() : path.basename(relPath, '.md')
|
|
284
|
+
results.push({ path: relPath.replace(/\\/g, '/'), title, snippet })
|
|
285
|
+
} catch { /* skip */ }
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
const archPath = path.join(this.root, 'ARCHITECTURE.md')
|
|
289
|
+
if (fs.existsSync(archPath)) searchFile(archPath, 'ARCHITECTURE.md')
|
|
290
|
+
|
|
291
|
+
const walk = (dir) => {
|
|
292
|
+
if (results.length >= 8 || !fs.existsSync(dir)) return
|
|
293
|
+
for (const e of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
294
|
+
if (e.isDirectory()) walk(path.join(dir, e.name))
|
|
295
|
+
else if (e.name.endsWith('.md') && e.name !== '.gitkeep') {
|
|
296
|
+
const full = path.join(dir, e.name)
|
|
297
|
+
searchFile(full, path.relative(this.root, full))
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
walk(path.join(this.root, 'docs'))
|
|
302
|
+
|
|
303
|
+
res.end(JSON.stringify(results))
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
serveSSE(req, res) {
|
|
307
|
+
res.writeHead(200, {
|
|
308
|
+
'Content-Type': 'text/event-stream',
|
|
309
|
+
'Cache-Control': 'no-cache',
|
|
310
|
+
'Connection': 'keep-alive',
|
|
311
|
+
})
|
|
312
|
+
res.write('data: connected\n\n')
|
|
313
|
+
|
|
314
|
+
this.sseClients.push(res)
|
|
315
|
+
req.on('close', () => {
|
|
316
|
+
this.sseClients = this.sseClients.filter(c => c !== res)
|
|
317
|
+
})
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
broadcast(event) {
|
|
321
|
+
for (const client of this.sseClients) {
|
|
322
|
+
client.write(`data: ${event}\n\n`)
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
buildTree() {
|
|
327
|
+
const tree = { architecture: null, modules: [], decisions: [], api: [] }
|
|
328
|
+
|
|
329
|
+
const arch = path.join(this.root, 'ARCHITECTURE.md')
|
|
330
|
+
if (fs.existsSync(arch)) tree.architecture = 'ARCHITECTURE.md'
|
|
331
|
+
|
|
332
|
+
const readDir = (rel, key) => {
|
|
333
|
+
const full = path.join(this.root, rel)
|
|
334
|
+
if (!fs.existsSync(full)) return
|
|
335
|
+
for (const e of fs.readdirSync(full, { withFileTypes: true })) {
|
|
336
|
+
if (e.isFile() && e.name.endsWith('.md') && e.name !== '.gitkeep') {
|
|
337
|
+
tree[key].push({ path: `${rel}/${e.name}`, name: e.name.replace('.md', '') })
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
tree[key].sort((a, b) => a.name.localeCompare(b.name))
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
readDir('docs/modules', 'modules')
|
|
344
|
+
readDir('docs/decisions', 'decisions')
|
|
345
|
+
readDir('docs/api', 'api')
|
|
346
|
+
|
|
347
|
+
return tree
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
countDocs() {
|
|
351
|
+
let n = 0
|
|
352
|
+
const walk = (dir) => {
|
|
353
|
+
if (!fs.existsSync(dir)) return
|
|
354
|
+
for (const e of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
355
|
+
if (e.isDirectory()) walk(path.join(dir, e.name))
|
|
356
|
+
else if (e.name.endsWith('.md') && e.name !== '.gitkeep') n++
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
walk(path.join(this.root, 'docs'))
|
|
360
|
+
return n
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
watchDocs() {
|
|
364
|
+
const debounce = (fn, ms) => {
|
|
365
|
+
let t
|
|
366
|
+
return () => { clearTimeout(t); t = setTimeout(fn, ms) }
|
|
367
|
+
}
|
|
368
|
+
const reload = debounce(() => this.broadcast('reload'), 300)
|
|
369
|
+
|
|
370
|
+
const targets = [
|
|
371
|
+
path.join(this.root, 'docs'),
|
|
372
|
+
path.join(this.root, 'ARCHITECTURE.md'),
|
|
373
|
+
path.join(this.root, '.docutrack', 'queue.json'),
|
|
374
|
+
]
|
|
375
|
+
for (const t of targets) {
|
|
376
|
+
if (fs.existsSync(t)) {
|
|
377
|
+
try { fs.watch(t, { recursive: true }, reload) } catch { /* ignore */ }
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
module.exports = DocuTrackServer
|