sillyspec 3.26.10 → 3.26.13
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/.claude/skills/sillyspec-archive/SKILL.md +85 -85
- package/.claude/skills/sillyspec-brainstorm/SKILL.md +114 -114
- package/.claude/skills/sillyspec-commit/SKILL.md +109 -109
- package/.claude/skills/sillyspec-continue/SKILL.md +45 -45
- package/.claude/skills/sillyspec-doctor/SKILL.md +76 -76
- package/.claude/skills/sillyspec-execute/SKILL.md +200 -200
- package/.claude/skills/sillyspec-explore/SKILL.md +95 -95
- package/.claude/skills/sillyspec-knowledge/SKILL.md +269 -269
- package/.claude/skills/sillyspec-plan/SKILL.md +148 -148
- package/.claude/skills/sillyspec-quick/SKILL.md +115 -111
- package/.claude/skills/sillyspec-resume/SKILL.md +68 -68
- package/.claude/skills/sillyspec-scan/SKILL.md +79 -79
- package/.claude/skills/sillyspec-state/SKILL.md +64 -64
- package/.claude/skills/sillyspec-status/SKILL.md +54 -54
- package/.claude/skills/sillyspec-verify/SKILL.md +83 -83
- package/.claude/skills/sillyspec-workspace/SKILL.md +169 -169
- package/.gitattributes +2 -0
- package/.husky/pre-push +20 -20
- package/README.md +203 -201
- package/SKILL.md +91 -90
- package/package.json +39 -39
- package/packages/dashboard/dist/assets/index-Bq_Z2hne.js +7446 -7446
- package/packages/dashboard/dist/assets/index-O2W5RV4z.css +1 -1
- package/packages/dashboard/dist/index.html +16 -16
- package/packages/dashboard/package.json +25 -25
- package/packages/dashboard/server/executor.js +95 -86
- package/packages/dashboard/server/index.js +663 -601
- package/packages/dashboard/server/parser.js +526 -526
- package/src/change-risk-profile.js +254 -254
- package/src/classify-change.js +109 -109
- package/src/config-schema.js +313 -310
- package/src/contract-matrix.js +388 -379
- package/src/db.js +370 -350
- package/src/dispatch/backends/local-agent.js +155 -155
- package/src/dispatch/backends/sillyhub-mcp.js +245 -245
- package/src/dispatch/probe.js +246 -210
- package/src/dispatch/strategy.js +121 -121
- package/src/docs-check.js +188 -16
- package/src/docs-debt.js +6 -2
- package/src/docs-gate.js +1 -0
- package/src/doctor-diagnostics.js +898 -711
- package/src/endpoint-extractor.js +401 -401
- package/src/fs-atomic.js +71 -69
- package/src/git-helper.js +82 -79
- package/src/hooks/claude-pre-tool-use.cjs +125 -125
- package/src/hooks/worktree-guard.js +854 -848
- package/src/index.js +251 -20
- package/src/init.js +624 -623
- package/src/knowledge-match.js +130 -130
- package/src/local-detect.js +128 -120
- package/src/machine-interface.js +543 -543
- package/src/migrate.js +127 -127
- package/src/modules.js +7 -5
- package/src/progress/change-registry.js +297 -260
- package/src/progress/consistency-doctor.js +355 -355
- package/src/progress/shared.js +36 -36
- package/src/progress/stage-machine.js +589 -550
- package/src/progress/step-store.js +261 -261
- package/src/progress.js +1285 -1127
- package/src/quicklog.js +724 -693
- package/src/review-tier.js +77 -77
- package/src/run/command.js +1408 -1364
- package/src/run/complete-handlers.js +1450 -1399
- package/src/run/complete.js +1181 -920
- package/src/run/gates.js +878 -755
- package/src/run/prompt.js +765 -761
- package/src/run/quick-audit.js +155 -146
- package/src/run/shared.js +1348 -1215
- package/src/run/stage.js +527 -479
- package/src/scan-postcheck.js +371 -371
- package/src/setup.js +414 -416
- package/src/sillyhub-mcp/client.js +597 -529
- package/src/spec-dir-typo.js +3 -24
- package/src/spec-sync.js +358 -319
- package/src/stage-contract.js +871 -897
- package/src/stage-review.js +549 -549
- package/src/stages/archive.js +156 -155
- package/src/stages/brainstorm-auto.js +236 -236
- package/src/stages/brainstorm.js +597 -597
- package/src/stages/cmd-existence.js +145 -145
- package/src/stages/doctor.js +386 -371
- package/src/stages/execute.js +1014 -1003
- package/src/stages/explore.js +34 -34
- package/src/stages/index.js +29 -29
- package/src/stages/knowledge.js +498 -498
- package/src/stages/plan-postcheck.js +1323 -1302
- package/src/stages/plan.js +642 -623
- package/src/stages/quick.js +153 -151
- package/src/stages/scan.js +607 -607
- package/src/stages/status.js +64 -64
- package/src/stages/verify.js +264 -264
- package/src/sync.js +1325 -1135
- package/src/task-review.js +1204 -1019
- package/src/taskcard.js +178 -0
- package/src/verify-postcheck.js +1199 -1186
- package/src/workflow.js +600 -600
- package/src/worktree-apply.js +1237 -1136
- package/src/worktree-deps.js +453 -353
- package/src/worktree.js +1527 -1481
- package/templates/workflows/archive-impact.yaml +79 -79
- package/templates/workflows/scan-docs.yaml +132 -132
|
@@ -1,601 +1,663 @@
|
|
|
1
|
-
import { createServer } from 'http'
|
|
2
|
-
import { WebSocketServer } from 'ws'
|
|
3
|
-
import { existsSync, readFileSync, readdirSync, realpathSync, watch } from 'fs'
|
|
4
|
-
import { join, dirname, basename, sep, resolve, relative } from 'path'
|
|
5
|
-
import { fileURLToPath } from 'url'
|
|
6
|
-
import { homedir } from 'os'
|
|
7
|
-
import open from 'open'
|
|
8
|
-
import { parseProjectState, parseSillyspecDocsTree, parseProjectOverview, parseGitDetail, parseTechStackDetail } from './parser.js'
|
|
9
|
-
import { startWatcher, stopWatcher, addCustomScanPath, removeCustomScanPath, getCustomScanPaths, customScanPaths } from './watcher.js'
|
|
10
|
-
import { executeCommand } from './executor.js'
|
|
11
|
-
|
|
12
|
-
const __dirname = dirname(fileURLToPath(import.meta.url))
|
|
13
|
-
|
|
14
|
-
// WebSocket clients and active processes
|
|
15
|
-
let wss = null
|
|
16
|
-
const activeProcesses = new Map()
|
|
17
|
-
const allowedStages = new Set(['brainstorm', 'plan', 'execute', 'verify', 'scan', 'quick', 'explore', 'archive', 'status', 'doctor', 'auto'])
|
|
18
|
-
|
|
19
|
-
function isAllowedCliCommand(command) {
|
|
20
|
-
const args = String(command || '').trim().split(/\s+/).filter(Boolean)
|
|
21
|
-
if (args.length === 0) return false
|
|
22
|
-
if (args[0] === 'run') {
|
|
23
|
-
if (!allowedStages.has(args[1])) return false
|
|
24
|
-
const allowedFlags = new Set(['--status', '--reset', '--done', '--skip', '--output', '--input', '--change'])
|
|
25
|
-
for (let i = 2; i < args.length; i++) {
|
|
26
|
-
if (args[i].startsWith('-') && !allowedFlags.has(args[i])) return false
|
|
27
|
-
if (['--output', '--input', '--change'].includes(args[i])) i++
|
|
28
|
-
}
|
|
29
|
-
return true
|
|
30
|
-
}
|
|
31
|
-
if (args[0] === 'progress') {
|
|
32
|
-
const sub = args[1]
|
|
33
|
-
if (!['show', 'status', 'validate', 'reset'].includes(sub)) return false
|
|
34
|
-
for (let i = 2; i < args.length; i++) {
|
|
35
|
-
if (!['--stage', '--deep'].includes(args[i])) return false
|
|
36
|
-
if (args[i] === '--stage') i++
|
|
37
|
-
}
|
|
38
|
-
return true
|
|
39
|
-
}
|
|
40
|
-
return false
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
// Progress file watchers: projectPath -> { watcher, timer, refCount }
|
|
44
|
-
const progressWatchers = new Map()
|
|
45
|
-
|
|
46
|
-
function startProgressWatch(projectPath) {
|
|
47
|
-
if (progressWatchers.has(projectPath)) {
|
|
48
|
-
progressWatchers.get(projectPath).refCount++
|
|
49
|
-
return
|
|
50
|
-
}
|
|
51
|
-
// Watch the SQLite database file for changes (replaces old progress.json watch)
|
|
52
|
-
const dbFile = join(projectPath, '.sillyspec', '.runtime', 'sillyspec.db')
|
|
53
|
-
if (!existsSync(dbFile)) return
|
|
54
|
-
|
|
55
|
-
let timer = null
|
|
56
|
-
try {
|
|
57
|
-
const watcher = watch(dbFile, (eventType) => {
|
|
58
|
-
if (timer) clearTimeout(timer)
|
|
59
|
-
timer = setTimeout(() => {
|
|
60
|
-
timer = null
|
|
61
|
-
try {
|
|
62
|
-
const state = parseProjectState(projectPath)
|
|
63
|
-
broadcast({ type: 'project:update', data: { path: projectPath, state } })
|
|
64
|
-
} catch {}
|
|
65
|
-
}, 200)
|
|
66
|
-
})
|
|
67
|
-
progressWatchers.set(projectPath, { watcher, timer, refCount: 1 })
|
|
68
|
-
} catch {}
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
function stopProgressWatch(projectPath) {
|
|
72
|
-
const entry = progressWatchers.get(projectPath)
|
|
73
|
-
if (!entry) return
|
|
74
|
-
entry.refCount--
|
|
75
|
-
if (entry.refCount <= 0) {
|
|
76
|
-
entry.watcher.close()
|
|
77
|
-
if (entry.timer) clearTimeout(entry.timer)
|
|
78
|
-
progressWatchers.delete(projectPath)
|
|
79
|
-
}
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
/**
|
|
83
|
-
* Broadcast message to all connected WebSocket clients
|
|
84
|
-
* @param {object} data - Data to broadcast
|
|
85
|
-
*/
|
|
86
|
-
function broadcast(data) {
|
|
87
|
-
if (!wss) return
|
|
88
|
-
const message = JSON.stringify(data)
|
|
89
|
-
wss.clients.forEach(client => {
|
|
90
|
-
if (client.readyState === 1) { // OPEN
|
|
91
|
-
client.send(message)
|
|
92
|
-
}
|
|
93
|
-
})
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
function isInside(parent, child) {
|
|
97
|
-
const rel = relative(resolve(parent), resolve(child))
|
|
98
|
-
return rel === '' || (!!rel && !rel.startsWith('..') && !rel.includes(`..${sep}`))
|
|
99
|
-
}
|
|
100
|
-
|
|
101
|
-
function isSillyspecPath(filePath) {
|
|
102
|
-
const parts = resolve(filePath).split(/[\\/]+/)
|
|
103
|
-
return parts.includes('.sillyspec')
|
|
104
|
-
}
|
|
105
|
-
|
|
106
|
-
// 凭据/敏感运行时文件:local.yaml 含 SillyHub token(connect 明文写入),.plan.md.lock 等锁文件
|
|
107
|
-
// 是并发原语非文档。/api/docs/content 无鉴权(仅 Origin 检查),这些文件绝不经 HTTP 暴露。
|
|
108
|
-
function isCredentialPath(filePath) {
|
|
109
|
-
const base = basename(resolve(filePath)).toLowerCase()
|
|
110
|
-
return base === 'local.yaml' || base === 'local.yml' || base.endsWith('.lock') || base === 'sillyspec-platform.json'
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
function isViewableDocPath(filePath) {
|
|
114
|
-
return /\.(md|markdown|mdx|html?|txt|log|json|ya?ml|toml|xml|csv)$/i.test(filePath)
|
|
115
|
-
}
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
}
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
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
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
const
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
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
|
-
const
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
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
|
-
command
|
|
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
|
-
|
|
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
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
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
|
-
res.writeHead(
|
|
364
|
-
res.
|
|
365
|
-
|
|
366
|
-
res.
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
res.
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
return
|
|
422
|
-
}
|
|
423
|
-
|
|
424
|
-
//
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
if (
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
const
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
1
|
+
import { createServer } from 'http'
|
|
2
|
+
import { WebSocketServer } from 'ws'
|
|
3
|
+
import { existsSync, readFileSync, readdirSync, realpathSync, watch } from 'fs'
|
|
4
|
+
import { join, dirname, basename, sep, resolve, relative } from 'path'
|
|
5
|
+
import { fileURLToPath } from 'url'
|
|
6
|
+
import { homedir } from 'os'
|
|
7
|
+
import open from 'open'
|
|
8
|
+
import { parseProjectState, parseSillyspecDocsTree, parseProjectOverview, parseGitDetail, parseTechStackDetail } from './parser.js'
|
|
9
|
+
import { startWatcher, stopWatcher, addCustomScanPath, removeCustomScanPath, getCustomScanPaths, customScanPaths } from './watcher.js'
|
|
10
|
+
import { executeCommand } from './executor.js'
|
|
11
|
+
|
|
12
|
+
const __dirname = dirname(fileURLToPath(import.meta.url))
|
|
13
|
+
|
|
14
|
+
// WebSocket clients and active processes
|
|
15
|
+
let wss = null
|
|
16
|
+
const activeProcesses = new Map()
|
|
17
|
+
const allowedStages = new Set(['brainstorm', 'plan', 'execute', 'verify', 'scan', 'quick', 'explore', 'archive', 'status', 'doctor', 'auto'])
|
|
18
|
+
|
|
19
|
+
function isAllowedCliCommand(command) {
|
|
20
|
+
const args = String(command || '').trim().split(/\s+/).filter(Boolean)
|
|
21
|
+
if (args.length === 0) return false
|
|
22
|
+
if (args[0] === 'run') {
|
|
23
|
+
if (!allowedStages.has(args[1])) return false
|
|
24
|
+
const allowedFlags = new Set(['--status', '--reset', '--done', '--skip', '--output', '--input', '--change'])
|
|
25
|
+
for (let i = 2; i < args.length; i++) {
|
|
26
|
+
if (args[i].startsWith('-') && !allowedFlags.has(args[i])) return false
|
|
27
|
+
if (['--output', '--input', '--change'].includes(args[i])) i++
|
|
28
|
+
}
|
|
29
|
+
return true
|
|
30
|
+
}
|
|
31
|
+
if (args[0] === 'progress') {
|
|
32
|
+
const sub = args[1]
|
|
33
|
+
if (!['show', 'status', 'validate', 'reset'].includes(sub)) return false
|
|
34
|
+
for (let i = 2; i < args.length; i++) {
|
|
35
|
+
if (!['--stage', '--deep'].includes(args[i])) return false
|
|
36
|
+
if (args[i] === '--stage') i++
|
|
37
|
+
}
|
|
38
|
+
return true
|
|
39
|
+
}
|
|
40
|
+
return false
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// Progress file watchers: projectPath -> { watcher, timer, refCount }
|
|
44
|
+
const progressWatchers = new Map()
|
|
45
|
+
|
|
46
|
+
function startProgressWatch(projectPath) {
|
|
47
|
+
if (progressWatchers.has(projectPath)) {
|
|
48
|
+
progressWatchers.get(projectPath).refCount++
|
|
49
|
+
return
|
|
50
|
+
}
|
|
51
|
+
// Watch the SQLite database file for changes (replaces old progress.json watch)
|
|
52
|
+
const dbFile = join(projectPath, '.sillyspec', '.runtime', 'sillyspec.db')
|
|
53
|
+
if (!existsSync(dbFile)) return
|
|
54
|
+
|
|
55
|
+
let timer = null
|
|
56
|
+
try {
|
|
57
|
+
const watcher = watch(dbFile, (eventType) => {
|
|
58
|
+
if (timer) clearTimeout(timer)
|
|
59
|
+
timer = setTimeout(() => {
|
|
60
|
+
timer = null
|
|
61
|
+
try {
|
|
62
|
+
const state = parseProjectState(projectPath)
|
|
63
|
+
broadcast({ type: 'project:update', data: { path: projectPath, state } })
|
|
64
|
+
} catch {}
|
|
65
|
+
}, 200)
|
|
66
|
+
})
|
|
67
|
+
progressWatchers.set(projectPath, { watcher, timer, refCount: 1 })
|
|
68
|
+
} catch {}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function stopProgressWatch(projectPath) {
|
|
72
|
+
const entry = progressWatchers.get(projectPath)
|
|
73
|
+
if (!entry) return
|
|
74
|
+
entry.refCount--
|
|
75
|
+
if (entry.refCount <= 0) {
|
|
76
|
+
entry.watcher.close()
|
|
77
|
+
if (entry.timer) clearTimeout(entry.timer)
|
|
78
|
+
progressWatchers.delete(projectPath)
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Broadcast message to all connected WebSocket clients
|
|
84
|
+
* @param {object} data - Data to broadcast
|
|
85
|
+
*/
|
|
86
|
+
function broadcast(data) {
|
|
87
|
+
if (!wss) return
|
|
88
|
+
const message = JSON.stringify(data)
|
|
89
|
+
wss.clients.forEach(client => {
|
|
90
|
+
if (client.readyState === 1) { // OPEN
|
|
91
|
+
client.send(message)
|
|
92
|
+
}
|
|
93
|
+
})
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function isInside(parent, child) {
|
|
97
|
+
const rel = relative(resolve(parent), resolve(child))
|
|
98
|
+
return rel === '' || (!!rel && !rel.startsWith('..') && !rel.includes(`..${sep}`))
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function isSillyspecPath(filePath) {
|
|
102
|
+
const parts = resolve(filePath).split(/[\\/]+/)
|
|
103
|
+
return parts.includes('.sillyspec')
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// 凭据/敏感运行时文件:local.yaml 含 SillyHub token(connect 明文写入),.plan.md.lock 等锁文件
|
|
107
|
+
// 是并发原语非文档。/api/docs/content 无鉴权(仅 Origin 检查),这些文件绝不经 HTTP 暴露。
|
|
108
|
+
function isCredentialPath(filePath) {
|
|
109
|
+
const base = basename(resolve(filePath)).toLowerCase()
|
|
110
|
+
return base === 'local.yaml' || base === 'local.yml' || base.endsWith('.lock') || base === 'sillyspec-platform.json'
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function isViewableDocPath(filePath) {
|
|
114
|
+
return /\.(md|markdown|mdx|html?|txt|log|json|ya?ml|toml|xml|csv)$/i.test(filePath)
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// ── 项目发现缓存 + 路径校验(体检 SEC-06;TTL 缓存兼避免每请求全量扫 HOME 级目录)──
|
|
118
|
+
let _projectsCache = { at: 0, data: null }
|
|
119
|
+
async function getDiscoveredProjects() {
|
|
120
|
+
const TTL_MS = 10_000
|
|
121
|
+
if (_projectsCache.data && Date.now() - _projectsCache.at < TTL_MS) return _projectsCache.data
|
|
122
|
+
const data = await discoverProjects()
|
|
123
|
+
_projectsCache = { at: Date.now(), data }
|
|
124
|
+
return data
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* 目标路径是否位于某个已发现项目内。此前 /api/docs/content 只查"路径含 .sillyspec 组件",
|
|
129
|
+
* 磁盘上任意 .sillyspec 树(含 .runtime 运行时文件)都可读;detail API 还以任意路径为
|
|
130
|
+
* cwd 跑 git。收紧为"必须位于已发现项目的目录内"(与 WS cli:execute 的项目校验同范式)。
|
|
131
|
+
*/
|
|
132
|
+
async function isKnownProjectPath(targetPath) {
|
|
133
|
+
const projects = await getDiscoveredProjects()
|
|
134
|
+
return projects.some(p => isInside(p.path, targetPath))
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* 跨站防护(体检 SEC-03 收紧):dashboard 前端由本服务自身托管,合法浏览器请求的
|
|
139
|
+
* Origin 端口必然等于服务端口。此前只校验 hostname,本机任意其他端口的 web 页面
|
|
140
|
+
* (被攻陷的 dev server / 恶意 npm 包起的页面)也能通过检查,驱动 WS cli:execute 等
|
|
141
|
+
* 完整 API。无 Origin 头(curl / 本地非浏览器进程)保持放行——与 localhost 服务
|
|
142
|
+
* "本地进程可信"的固有级别一致,浏览器攻击面才是本检查能挡的部分。
|
|
143
|
+
*/
|
|
144
|
+
function isSameDashboardOrigin(origin, ownPort) {
|
|
145
|
+
if (!origin) return true
|
|
146
|
+
try {
|
|
147
|
+
const { hostname, port: originPort } = new URL(origin)
|
|
148
|
+
if (hostname !== 'localhost' && hostname !== '127.0.0.1' && hostname !== '::1') return false
|
|
149
|
+
return String(originPort) === String(ownPort)
|
|
150
|
+
} catch {
|
|
151
|
+
return false
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// --- Shared scan logic (aligned with watcher.js) ---
|
|
156
|
+
|
|
157
|
+
const excludeDirs = new Set([
|
|
158
|
+
'.Trash', '.cache', '.npm', '.local', '.vscode', 'Library',
|
|
159
|
+
'.git', 'node_modules', '.Trash-*', '.DS_Store', '.config',
|
|
160
|
+
'.cocoapods', '.gem', '.rvm', '.nvm', '.asdf', '.brew',
|
|
161
|
+
'AppData', 'Application Data', '.cargo', '.rustup',
|
|
162
|
+
'.nuget', '.android', '.gradle', '.m2', '.vscode-server'
|
|
163
|
+
])
|
|
164
|
+
|
|
165
|
+
function shouldExclude(name, cwd) {
|
|
166
|
+
if (excludeDirs.has(name)) return true
|
|
167
|
+
for (const pattern of excludeDirs) {
|
|
168
|
+
if (pattern.includes('*')) {
|
|
169
|
+
const regex = new RegExp('^' + pattern.replace(/\*/g, '.*') + '$')
|
|
170
|
+
if (regex.test(name)) return true
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
const cwdName = cwd.split(sep).pop() || cwd.split('/').pop() || ''
|
|
174
|
+
if (name.startsWith('.') && name !== cwdName) return true
|
|
175
|
+
return false
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function scanDirectory(baseDir, seen, maxDepth = 2, currentDepth = 0) {
|
|
179
|
+
const cwd = process.cwd()
|
|
180
|
+
const projects = []
|
|
181
|
+
try {
|
|
182
|
+
const entries = readdirSync(baseDir, { withFileTypes: true })
|
|
183
|
+
for (const entry of entries) {
|
|
184
|
+
if (!entry.isDirectory()) continue
|
|
185
|
+
if (shouldExclude(entry.name, cwd)) continue
|
|
186
|
+
const dirPath = join(baseDir, entry.name)
|
|
187
|
+
let realPath
|
|
188
|
+
try { realPath = realpathSync(dirPath) } catch { realPath = dirPath }
|
|
189
|
+
const normalizedPath = realPath.toLowerCase()
|
|
190
|
+
if (seen.has(normalizedPath)) continue
|
|
191
|
+
seen.add(normalizedPath)
|
|
192
|
+
if (existsSync(join(dirPath, '.sillyspec'))) {
|
|
193
|
+
projects.push({ name: entry.name, path: dirPath })
|
|
194
|
+
}
|
|
195
|
+
if (currentDepth < maxDepth) {
|
|
196
|
+
projects.push(...scanDirectory(dirPath, seen, maxDepth, currentDepth + 1))
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
} catch (err) {}
|
|
200
|
+
return projects
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* Discover all SillySpec projects
|
|
205
|
+
* @returns {Promise<Array>} Array of project objects
|
|
206
|
+
*/
|
|
207
|
+
async function discoverProjects() {
|
|
208
|
+
const home = homedir()
|
|
209
|
+
const cwd = process.cwd()
|
|
210
|
+
|
|
211
|
+
const scanDirs = new Set()
|
|
212
|
+
scanDirs.add(cwd)
|
|
213
|
+
scanDirs.add(dirname(cwd))
|
|
214
|
+
scanDirs.add(dirname(dirname(cwd)))
|
|
215
|
+
scanDirs.add(home)
|
|
216
|
+
|
|
217
|
+
const extraDirs = [
|
|
218
|
+
'Desktop', '桌面', 'Documents', '文档', 'Downloads', '下载',
|
|
219
|
+
'Projects', '项目', 'Work', '工作', 'Repos', 'Code', 'src', 'dev',
|
|
220
|
+
'workspace', '工作区'
|
|
221
|
+
]
|
|
222
|
+
|
|
223
|
+
for (const extra of extraDirs) {
|
|
224
|
+
const extraPath = join(home, extra)
|
|
225
|
+
if (existsSync(extraPath)) scanDirs.add(extraPath)
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
for (const customPath of customScanPaths) {
|
|
229
|
+
if (existsSync(customPath)) scanDirs.add(customPath)
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
const seen = new Set()
|
|
233
|
+
const projects = []
|
|
234
|
+
|
|
235
|
+
// Normalize cwd for dedup
|
|
236
|
+
let cwdReal
|
|
237
|
+
try { cwdReal = realpathSync(cwd) } catch { cwdReal = cwd }
|
|
238
|
+
seen.add(cwdReal.toLowerCase())
|
|
239
|
+
if (existsSync(join(cwd, '.sillyspec'))) {
|
|
240
|
+
projects.push({ name: basename(cwd), path: cwd })
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
for (const baseDir of scanDirs) {
|
|
244
|
+
projects.push(...scanDirectory(baseDir, seen, 2, 0))
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
return projects
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* Handle WebSocket message for CLI execution
|
|
252
|
+
* @param {object} ws - WebSocket client
|
|
253
|
+
* @param {object} data - Message data
|
|
254
|
+
*/
|
|
255
|
+
function handleCliExecute(ws, data) {
|
|
256
|
+
const { projectName, command } = data
|
|
257
|
+
|
|
258
|
+
if (!projectName || !command) {
|
|
259
|
+
ws.send(JSON.stringify({
|
|
260
|
+
type: 'cli:error',
|
|
261
|
+
data: { message: 'Missing projectName or command' }
|
|
262
|
+
}))
|
|
263
|
+
return
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
if (!isAllowedCliCommand(command)) {
|
|
267
|
+
ws.send(JSON.stringify({
|
|
268
|
+
type: 'cli:error',
|
|
269
|
+
data: { message: `Command not allowed: ${command}` }
|
|
270
|
+
}))
|
|
271
|
+
return
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
discoverProjects().then(projects => {
|
|
275
|
+
const project = projects.find(p => p.name === projectName)
|
|
276
|
+
if (!project) {
|
|
277
|
+
ws.send(JSON.stringify({
|
|
278
|
+
type: 'cli:error',
|
|
279
|
+
data: { message: `Project not found: ${projectName}` }
|
|
280
|
+
}))
|
|
281
|
+
return
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
const existingKill = activeProcesses.get(projectName)
|
|
285
|
+
if (existingKill) existingKill()
|
|
286
|
+
|
|
287
|
+
const kill = executeCommand(
|
|
288
|
+
project.path,
|
|
289
|
+
command,
|
|
290
|
+
(output) => {
|
|
291
|
+
broadcast({
|
|
292
|
+
type: 'cli:output',
|
|
293
|
+
data: { projectName, output: output.data, outputType: output.type }
|
|
294
|
+
})
|
|
295
|
+
},
|
|
296
|
+
(result) => {
|
|
297
|
+
activeProcesses.delete(projectName)
|
|
298
|
+
broadcast({
|
|
299
|
+
type: 'cli:complete',
|
|
300
|
+
data: { projectName, exitCode: result.code, signal: result.signal }
|
|
301
|
+
})
|
|
302
|
+
}
|
|
303
|
+
)
|
|
304
|
+
|
|
305
|
+
activeProcesses.set(projectName, kill)
|
|
306
|
+
broadcast({ type: 'cli:started', data: { projectName, command } })
|
|
307
|
+
})
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
/**
|
|
311
|
+
* Start the dashboard server
|
|
312
|
+
* @param {object} options - Server options
|
|
313
|
+
* @returns {object} HTTP server instance
|
|
314
|
+
*/
|
|
315
|
+
function startServer({ port = 3456, open: openBrowser = true } = {}) {
|
|
316
|
+
const server = createServer((req, res) => {
|
|
317
|
+
const origin = req.headers.origin
|
|
318
|
+
if (!isSameDashboardOrigin(origin, port)) {
|
|
319
|
+
res.writeHead(403)
|
|
320
|
+
res.end('Forbidden')
|
|
321
|
+
return
|
|
322
|
+
}
|
|
323
|
+
res.setHeader('Access-Control-Allow-Origin', origin || `http://127.0.0.1:${port}`)
|
|
324
|
+
res.setHeader('Vary', 'Origin')
|
|
325
|
+
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS')
|
|
326
|
+
res.setHeader('Access-Control-Allow-Headers', 'Content-Type')
|
|
327
|
+
|
|
328
|
+
if (req.method === 'OPTIONS') {
|
|
329
|
+
res.writeHead(200)
|
|
330
|
+
res.end()
|
|
331
|
+
return
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
if (req.url === '/api/projects') {
|
|
335
|
+
discoverProjects().then(projects => {
|
|
336
|
+
res.setHeader('Content-Type', 'application/json')
|
|
337
|
+
res.writeHead(200)
|
|
338
|
+
res.end(JSON.stringify(projects))
|
|
339
|
+
}).catch(err => {
|
|
340
|
+
res.writeHead(500)
|
|
341
|
+
res.end(JSON.stringify({ error: err.message }))
|
|
342
|
+
})
|
|
343
|
+
return
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
// Detail API(SEC-06:projectPath 须位于已发现项目内——parseGitDetail 以其为 cwd 跑 git)
|
|
347
|
+
const detailMatch = req.url?.match(/^\/api\/projects\/(.+)\/detail(\?|$)/)
|
|
348
|
+
if (detailMatch) {
|
|
349
|
+
const projectPath = decodeURIComponent(detailMatch[1])
|
|
350
|
+
const url = new URL(req.url, `http://${req.headers.host}`)
|
|
351
|
+
const type = url.searchParams.get('type')
|
|
352
|
+
isKnownProjectPath(projectPath).then(known => {
|
|
353
|
+
if (!known) {
|
|
354
|
+
res.writeHead(403)
|
|
355
|
+
res.end(JSON.stringify({ error: 'Unknown project path' }))
|
|
356
|
+
return
|
|
357
|
+
}
|
|
358
|
+
let data
|
|
359
|
+
try {
|
|
360
|
+
if (type === 'git') data = parseGitDetail(projectPath)
|
|
361
|
+
else if (type === 'tech') data = parseTechStackDetail(projectPath)
|
|
362
|
+
else if (type === 'docs') data = parseSillyspecDocsTree(projectPath).groups
|
|
363
|
+
else { res.writeHead(400); res.end(JSON.stringify({ error: 'Invalid type' })); return }
|
|
364
|
+
res.setHeader('Content-Type', 'application/json')
|
|
365
|
+
res.writeHead(200)
|
|
366
|
+
res.end(JSON.stringify(data))
|
|
367
|
+
} catch (err) {
|
|
368
|
+
res.writeHead(500)
|
|
369
|
+
res.end(JSON.stringify({ error: err.message }))
|
|
370
|
+
}
|
|
371
|
+
}).catch(() => {
|
|
372
|
+
res.writeHead(500)
|
|
373
|
+
res.end(JSON.stringify({ error: 'Project discovery failed' }))
|
|
374
|
+
})
|
|
375
|
+
return
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
// Overview API(SEC-06:同 detail,projectPath 须位于已发现项目内)
|
|
379
|
+
if (req.url?.startsWith('/api/projects/') && req.url.endsWith('/overview')) {
|
|
380
|
+
const parts = req.url.replace('/api/projects/', '').replace('/overview', '').split('/')
|
|
381
|
+
const projectPath = decodeURIComponent(parts.join('/'))
|
|
382
|
+
isKnownProjectPath(projectPath).then(known => {
|
|
383
|
+
if (!known) {
|
|
384
|
+
res.writeHead(403)
|
|
385
|
+
res.end(JSON.stringify({ error: 'Unknown project path' }))
|
|
386
|
+
return
|
|
387
|
+
}
|
|
388
|
+
try {
|
|
389
|
+
const overview = parseProjectOverview(projectPath)
|
|
390
|
+
res.setHeader('Content-Type', 'application/json')
|
|
391
|
+
res.writeHead(200)
|
|
392
|
+
res.end(JSON.stringify(overview))
|
|
393
|
+
} catch (err) {
|
|
394
|
+
res.writeHead(500)
|
|
395
|
+
res.end(JSON.stringify({ error: err.message }))
|
|
396
|
+
}
|
|
397
|
+
}).catch(() => {
|
|
398
|
+
res.writeHead(500)
|
|
399
|
+
res.end(JSON.stringify({ error: 'Project discovery failed' }))
|
|
400
|
+
})
|
|
401
|
+
return
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
if (req.url?.startsWith('/api/project/')) {
|
|
405
|
+
const projectName = decodeURIComponent(req.url.split('/').pop())
|
|
406
|
+
discoverProjects().then(projects => {
|
|
407
|
+
const project = projects.find(p => p.name === projectName)
|
|
408
|
+
if (project) {
|
|
409
|
+
const state = parseProjectState(project.path)
|
|
410
|
+
res.setHeader('Content-Type', 'application/json')
|
|
411
|
+
res.writeHead(200)
|
|
412
|
+
res.end(JSON.stringify({ ...project, state }))
|
|
413
|
+
} else {
|
|
414
|
+
res.writeHead(404)
|
|
415
|
+
res.end(JSON.stringify({ error: 'Project not found' }))
|
|
416
|
+
}
|
|
417
|
+
}).catch(err => {
|
|
418
|
+
res.writeHead(500)
|
|
419
|
+
res.end(JSON.stringify({ error: err.message }))
|
|
420
|
+
})
|
|
421
|
+
return
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
// Docs API
|
|
425
|
+
if (req.url?.startsWith('/api/docs/content')) {
|
|
426
|
+
const url = new URL(req.url, `http://${req.headers.host}`)
|
|
427
|
+
const filePath = url.searchParams.get('path')
|
|
428
|
+
if (!filePath) {
|
|
429
|
+
res.writeHead(400)
|
|
430
|
+
res.end(JSON.stringify({ error: 'Missing path parameter' }))
|
|
431
|
+
return
|
|
432
|
+
}
|
|
433
|
+
// Security: only allow reading viewable text documents under a discovered
|
|
434
|
+
// project's .sillyspec tree(SEC-06:不限在已发现项目内则磁盘上任意 .sillyspec 树可读).
|
|
435
|
+
const normalizedPath = resolve(filePath)
|
|
436
|
+
isKnownProjectPath(normalizedPath).then(known => {
|
|
437
|
+
if (!known || !isSillyspecPath(normalizedPath) || !isViewableDocPath(normalizedPath) || isCredentialPath(normalizedPath)) {
|
|
438
|
+
res.writeHead(403)
|
|
439
|
+
res.end(JSON.stringify({ error: 'Access denied' }))
|
|
440
|
+
return
|
|
441
|
+
}
|
|
442
|
+
if (existsSync(normalizedPath)) {
|
|
443
|
+
try {
|
|
444
|
+
const content = readFileSync(normalizedPath, 'utf-8')
|
|
445
|
+
res.setHeader('Content-Type', 'text/plain; charset=utf-8')
|
|
446
|
+
res.writeHead(200)
|
|
447
|
+
res.end(content)
|
|
448
|
+
} catch (err) {
|
|
449
|
+
res.writeHead(500)
|
|
450
|
+
res.end(JSON.stringify({ error: err.message }))
|
|
451
|
+
}
|
|
452
|
+
} else {
|
|
453
|
+
res.writeHead(404)
|
|
454
|
+
res.end(JSON.stringify({ error: 'File not found' }))
|
|
455
|
+
}
|
|
456
|
+
}).catch(() => {
|
|
457
|
+
res.writeHead(500)
|
|
458
|
+
res.end(JSON.stringify({ error: 'Project discovery failed' }))
|
|
459
|
+
})
|
|
460
|
+
return
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
if (req.url?.startsWith('/api/docs')) {
|
|
464
|
+
const url = new URL(req.url, `http://${req.headers.host}`)
|
|
465
|
+
const projectPath = url.searchParams.get('project')
|
|
466
|
+
if (!projectPath) {
|
|
467
|
+
res.writeHead(400)
|
|
468
|
+
res.end(JSON.stringify({ error: 'Missing project parameter' }))
|
|
469
|
+
return
|
|
470
|
+
}
|
|
471
|
+
const docs = parseSillyspecDocsTree(projectPath)
|
|
472
|
+
res.setHeader('Content-Type', 'application/json')
|
|
473
|
+
res.writeHead(200)
|
|
474
|
+
res.end(JSON.stringify(docs))
|
|
475
|
+
return
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
// Serve static files (dist/)
|
|
479
|
+
const distDir = join(__dirname, '../dist')
|
|
480
|
+
const indexPath = join(distDir, 'index.html')
|
|
481
|
+
if (existsSync(distDir)) {
|
|
482
|
+
const url = new URL(req.url || '/', `http://${req.headers.host}`)
|
|
483
|
+
const requestPath = decodeURIComponent(url.pathname)
|
|
484
|
+
const filePath = resolve(distDir, requestPath === '/' ? 'index.html' : `.${requestPath}`)
|
|
485
|
+
if (!isInside(distDir, filePath)) {
|
|
486
|
+
res.writeHead(403)
|
|
487
|
+
res.end('Access denied')
|
|
488
|
+
return
|
|
489
|
+
}
|
|
490
|
+
if (existsSync(filePath)) {
|
|
491
|
+
const ext = filePath.split('.').pop()
|
|
492
|
+
const mimeTypes = { html: 'text/html', js: 'application/javascript', css: 'text/css', svg: 'image/svg+xml', png: 'image/png', jpg: 'image/jpeg' }
|
|
493
|
+
res.setHeader('Content-Type', mimeTypes[ext] || 'application/octet-stream')
|
|
494
|
+
res.writeHead(200)
|
|
495
|
+
res.end(readFileSync(filePath))
|
|
496
|
+
return
|
|
497
|
+
}
|
|
498
|
+
if (existsSync(indexPath)) {
|
|
499
|
+
res.setHeader('Content-Type', 'text/html')
|
|
500
|
+
res.writeHead(200)
|
|
501
|
+
res.end(readFileSync(indexPath, 'utf8'))
|
|
502
|
+
return
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
res.writeHead(404)
|
|
507
|
+
res.end('Not found')
|
|
508
|
+
})
|
|
509
|
+
|
|
510
|
+
wss = new WebSocketServer({ server })
|
|
511
|
+
|
|
512
|
+
wss.on('error', (err) => {
|
|
513
|
+
console.error('WebSocket server error:', err)
|
|
514
|
+
})
|
|
515
|
+
|
|
516
|
+
wss.on('connection', (ws, req) => {
|
|
517
|
+
if (!isSameDashboardOrigin(req.headers.origin, port)) {
|
|
518
|
+
ws.close(1008, 'Forbidden origin')
|
|
519
|
+
return
|
|
520
|
+
}
|
|
521
|
+
console.log('WebSocket client connected')
|
|
522
|
+
|
|
523
|
+
// Send initial projects list
|
|
524
|
+
discoverProjects().then(projects => {
|
|
525
|
+
const projectsWithState = projects.map(p => ({
|
|
526
|
+
...p,
|
|
527
|
+
state: parseProjectState(p.path),
|
|
528
|
+
overview: parseProjectOverview(p.path)
|
|
529
|
+
}))
|
|
530
|
+
|
|
531
|
+
ws.send(JSON.stringify({
|
|
532
|
+
type: 'projects:init',
|
|
533
|
+
data: projectsWithState
|
|
534
|
+
}))
|
|
535
|
+
|
|
536
|
+
// Start watching progress files for all discovered projects
|
|
537
|
+
for (const p of projectsWithState) {
|
|
538
|
+
startProgressWatch(p.path)
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
// Send current scan paths
|
|
542
|
+
ws.send(JSON.stringify({
|
|
543
|
+
type: 'scan:paths',
|
|
544
|
+
data: getCustomScanPaths()
|
|
545
|
+
}))
|
|
546
|
+
}).catch(err => {
|
|
547
|
+
console.error('Error sending initial projects:', err)
|
|
548
|
+
})
|
|
549
|
+
|
|
550
|
+
ws.on('message', (message) => {
|
|
551
|
+
try {
|
|
552
|
+
const data = JSON.parse(message)
|
|
553
|
+
|
|
554
|
+
switch (data.type) {
|
|
555
|
+
case 'cli:execute':
|
|
556
|
+
handleCliExecute(ws, data.data)
|
|
557
|
+
break
|
|
558
|
+
case 'cli:kill':
|
|
559
|
+
const kill = activeProcesses.get(data.data.projectName)
|
|
560
|
+
if (kill) {
|
|
561
|
+
kill()
|
|
562
|
+
activeProcesses.delete(data.data.projectName)
|
|
563
|
+
broadcast({ type: 'cli:killed', data: { projectName: data.data.projectName } })
|
|
564
|
+
}
|
|
565
|
+
break
|
|
566
|
+
case 'scan:add-path':
|
|
567
|
+
if (data.data?.path) {
|
|
568
|
+
addCustomScanPath(data.data.path)
|
|
569
|
+
broadcast({ type: 'scan:paths', data: getCustomScanPaths() })
|
|
570
|
+
// Resend projects after scan
|
|
571
|
+
discoverProjects().then(projects => {
|
|
572
|
+
broadcast({
|
|
573
|
+
type: 'projects:updated',
|
|
574
|
+
data: projects.map(p => ({
|
|
575
|
+
...p,
|
|
576
|
+
state: parseProjectState(p.path),
|
|
577
|
+
overview: parseProjectOverview(p.path)
|
|
578
|
+
}))
|
|
579
|
+
})
|
|
580
|
+
})
|
|
581
|
+
}
|
|
582
|
+
break
|
|
583
|
+
case 'scan:remove-path':
|
|
584
|
+
if (data.data?.path) {
|
|
585
|
+
removeCustomScanPath(data.data.path)
|
|
586
|
+
ws.send(JSON.stringify({ type: 'scan:paths', data: getCustomScanPaths() }))
|
|
587
|
+
}
|
|
588
|
+
break
|
|
589
|
+
case 'scan:get-paths':
|
|
590
|
+
ws.send(JSON.stringify({ type: 'scan:paths', data: getCustomScanPaths() }))
|
|
591
|
+
break
|
|
592
|
+
case 'docs:get':
|
|
593
|
+
if (data.data?.projectPath) {
|
|
594
|
+
const docs = parseSillyspecDocsTree(data.data.projectPath)
|
|
595
|
+
ws.send(JSON.stringify({ type: 'docs:tree', data: docs }))
|
|
596
|
+
}
|
|
597
|
+
break
|
|
598
|
+
default:
|
|
599
|
+
console.log('Unknown message type:', data.type)
|
|
600
|
+
}
|
|
601
|
+
} catch (err) {
|
|
602
|
+
console.error('Error handling message:', err)
|
|
603
|
+
}
|
|
604
|
+
})
|
|
605
|
+
|
|
606
|
+
ws.on('close', () => {
|
|
607
|
+
console.log('WebSocket client disconnected')
|
|
608
|
+
// Decrement progress watchers — we track which projects this client was watching
|
|
609
|
+
// via the initial projects:init. For simplicity, stop all that we started.
|
|
610
|
+
// (Other clients will re-start via their connection if still active)
|
|
611
|
+
for (const [path] of progressWatchers) {
|
|
612
|
+
stopProgressWatch(path)
|
|
613
|
+
}
|
|
614
|
+
})
|
|
615
|
+
|
|
616
|
+
ws.on('error', (err) => {
|
|
617
|
+
console.error('WebSocket error:', err)
|
|
618
|
+
})
|
|
619
|
+
})
|
|
620
|
+
|
|
621
|
+
// listen 前置:Windows 下 startWatcher 的初始全盘同步扫描(homedir/Temp/桌面 depth 2 +
|
|
622
|
+
// 每项目多次 execSync('git'))实测 150s+,若排在其后则服务永不响应(假死)。
|
|
623
|
+
// 先绑定端口(listen 回调立即可达),扫描用 setImmediate 延后到事件循环——端口立即响应,
|
|
624
|
+
// 初始 projects 数据由扫描完成后 broadcast 补发。
|
|
625
|
+
server.listen(port, '127.0.0.1', () => {
|
|
626
|
+
console.log(`Dashboard server running on http://127.0.0.1:${port}`)
|
|
627
|
+
if (openBrowser) {
|
|
628
|
+
open(`http://127.0.0.1:${port}`)
|
|
629
|
+
}
|
|
630
|
+
})
|
|
631
|
+
|
|
632
|
+
setImmediate(() => {
|
|
633
|
+
try {
|
|
634
|
+
startWatcher((projects) => {
|
|
635
|
+
broadcast({ type: 'projects:updated', data: projects })
|
|
636
|
+
})
|
|
637
|
+
} catch (err) {
|
|
638
|
+
console.error('Failed to start file watcher:', err)
|
|
639
|
+
}
|
|
640
|
+
})
|
|
641
|
+
|
|
642
|
+
const shutdown = () => {
|
|
643
|
+
stopWatcher()
|
|
644
|
+
activeProcesses.forEach(kill => kill())
|
|
645
|
+
activeProcesses.clear()
|
|
646
|
+
// server.close() 只停止接受新连接,不终止已升级的 WS 连接——事件循环被客户端
|
|
647
|
+
// 持有导致 Ctrl+C 无法退出(体检 BUG-10)。先 terminate 客户端再关 wss。
|
|
648
|
+
if (wss) {
|
|
649
|
+
for (const client of wss.clients) {
|
|
650
|
+
try { client.terminate() } catch { /* 已断开的客户端忽略 */ }
|
|
651
|
+
}
|
|
652
|
+
try { wss.close() } catch { /* 关闭失败不阻断退出 */ }
|
|
653
|
+
}
|
|
654
|
+
server.close()
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
process.on('SIGTERM', shutdown)
|
|
658
|
+
process.on('SIGINT', shutdown)
|
|
659
|
+
|
|
660
|
+
return server
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
export { startServer, broadcast, discoverProjects }
|