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.
Files changed (101) hide show
  1. package/.claude/skills/sillyspec-archive/SKILL.md +85 -85
  2. package/.claude/skills/sillyspec-brainstorm/SKILL.md +114 -114
  3. package/.claude/skills/sillyspec-commit/SKILL.md +109 -109
  4. package/.claude/skills/sillyspec-continue/SKILL.md +45 -45
  5. package/.claude/skills/sillyspec-doctor/SKILL.md +76 -76
  6. package/.claude/skills/sillyspec-execute/SKILL.md +200 -200
  7. package/.claude/skills/sillyspec-explore/SKILL.md +95 -95
  8. package/.claude/skills/sillyspec-knowledge/SKILL.md +269 -269
  9. package/.claude/skills/sillyspec-plan/SKILL.md +148 -148
  10. package/.claude/skills/sillyspec-quick/SKILL.md +115 -111
  11. package/.claude/skills/sillyspec-resume/SKILL.md +68 -68
  12. package/.claude/skills/sillyspec-scan/SKILL.md +79 -79
  13. package/.claude/skills/sillyspec-state/SKILL.md +64 -64
  14. package/.claude/skills/sillyspec-status/SKILL.md +54 -54
  15. package/.claude/skills/sillyspec-verify/SKILL.md +83 -83
  16. package/.claude/skills/sillyspec-workspace/SKILL.md +169 -169
  17. package/.gitattributes +2 -0
  18. package/.husky/pre-push +20 -20
  19. package/README.md +203 -201
  20. package/SKILL.md +91 -90
  21. package/package.json +39 -39
  22. package/packages/dashboard/dist/assets/index-Bq_Z2hne.js +7446 -7446
  23. package/packages/dashboard/dist/assets/index-O2W5RV4z.css +1 -1
  24. package/packages/dashboard/dist/index.html +16 -16
  25. package/packages/dashboard/package.json +25 -25
  26. package/packages/dashboard/server/executor.js +95 -86
  27. package/packages/dashboard/server/index.js +663 -601
  28. package/packages/dashboard/server/parser.js +526 -526
  29. package/src/change-risk-profile.js +254 -254
  30. package/src/classify-change.js +109 -109
  31. package/src/config-schema.js +313 -310
  32. package/src/contract-matrix.js +388 -379
  33. package/src/db.js +370 -350
  34. package/src/dispatch/backends/local-agent.js +155 -155
  35. package/src/dispatch/backends/sillyhub-mcp.js +245 -245
  36. package/src/dispatch/probe.js +246 -210
  37. package/src/dispatch/strategy.js +121 -121
  38. package/src/docs-check.js +188 -16
  39. package/src/docs-debt.js +6 -2
  40. package/src/docs-gate.js +1 -0
  41. package/src/doctor-diagnostics.js +898 -711
  42. package/src/endpoint-extractor.js +401 -401
  43. package/src/fs-atomic.js +71 -69
  44. package/src/git-helper.js +82 -79
  45. package/src/hooks/claude-pre-tool-use.cjs +125 -125
  46. package/src/hooks/worktree-guard.js +854 -848
  47. package/src/index.js +251 -20
  48. package/src/init.js +624 -623
  49. package/src/knowledge-match.js +130 -130
  50. package/src/local-detect.js +128 -120
  51. package/src/machine-interface.js +543 -543
  52. package/src/migrate.js +127 -127
  53. package/src/modules.js +7 -5
  54. package/src/progress/change-registry.js +297 -260
  55. package/src/progress/consistency-doctor.js +355 -355
  56. package/src/progress/shared.js +36 -36
  57. package/src/progress/stage-machine.js +589 -550
  58. package/src/progress/step-store.js +261 -261
  59. package/src/progress.js +1285 -1127
  60. package/src/quicklog.js +724 -693
  61. package/src/review-tier.js +77 -77
  62. package/src/run/command.js +1408 -1364
  63. package/src/run/complete-handlers.js +1450 -1399
  64. package/src/run/complete.js +1181 -920
  65. package/src/run/gates.js +878 -755
  66. package/src/run/prompt.js +765 -761
  67. package/src/run/quick-audit.js +155 -146
  68. package/src/run/shared.js +1348 -1215
  69. package/src/run/stage.js +527 -479
  70. package/src/scan-postcheck.js +371 -371
  71. package/src/setup.js +414 -416
  72. package/src/sillyhub-mcp/client.js +597 -529
  73. package/src/spec-dir-typo.js +3 -24
  74. package/src/spec-sync.js +358 -319
  75. package/src/stage-contract.js +871 -897
  76. package/src/stage-review.js +549 -549
  77. package/src/stages/archive.js +156 -155
  78. package/src/stages/brainstorm-auto.js +236 -236
  79. package/src/stages/brainstorm.js +597 -597
  80. package/src/stages/cmd-existence.js +145 -145
  81. package/src/stages/doctor.js +386 -371
  82. package/src/stages/execute.js +1014 -1003
  83. package/src/stages/explore.js +34 -34
  84. package/src/stages/index.js +29 -29
  85. package/src/stages/knowledge.js +498 -498
  86. package/src/stages/plan-postcheck.js +1323 -1302
  87. package/src/stages/plan.js +642 -623
  88. package/src/stages/quick.js +153 -151
  89. package/src/stages/scan.js +607 -607
  90. package/src/stages/status.js +64 -64
  91. package/src/stages/verify.js +264 -264
  92. package/src/sync.js +1325 -1135
  93. package/src/task-review.js +1204 -1019
  94. package/src/taskcard.js +178 -0
  95. package/src/verify-postcheck.js +1199 -1186
  96. package/src/workflow.js +600 -600
  97. package/src/worktree-apply.js +1237 -1136
  98. package/src/worktree-deps.js +453 -353
  99. package/src/worktree.js +1527 -1481
  100. package/templates/workflows/archive-impact.yaml +79 -79
  101. 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
- function isLocalOrigin(origin) {
118
- if (!origin) return true
119
- try {
120
- const { hostname } = new URL(origin)
121
- return hostname === 'localhost' || hostname === '127.0.0.1' || hostname === '::1'
122
- } catch {
123
- return false
124
- }
125
- }
126
-
127
- // --- Shared scan logic (aligned with watcher.js) ---
128
-
129
- const excludeDirs = new Set([
130
- '.Trash', '.cache', '.npm', '.local', '.vscode', 'Library',
131
- '.git', 'node_modules', '.Trash-*', '.DS_Store', '.config',
132
- '.cocoapods', '.gem', '.rvm', '.nvm', '.asdf', '.brew',
133
- 'AppData', 'Application Data', '.cargo', '.rustup',
134
- '.nuget', '.android', '.gradle', '.m2', '.vscode-server'
135
- ])
136
-
137
- function shouldExclude(name, cwd) {
138
- if (excludeDirs.has(name)) return true
139
- for (const pattern of excludeDirs) {
140
- if (pattern.includes('*')) {
141
- const regex = new RegExp('^' + pattern.replace(/\*/g, '.*') + '$')
142
- if (regex.test(name)) return true
143
- }
144
- }
145
- const cwdName = cwd.split(sep).pop() || cwd.split('/').pop() || ''
146
- if (name.startsWith('.') && name !== cwdName) return true
147
- return false
148
- }
149
-
150
- function scanDirectory(baseDir, seen, maxDepth = 2, currentDepth = 0) {
151
- const cwd = process.cwd()
152
- const projects = []
153
- try {
154
- const entries = readdirSync(baseDir, { withFileTypes: true })
155
- for (const entry of entries) {
156
- if (!entry.isDirectory()) continue
157
- if (shouldExclude(entry.name, cwd)) continue
158
- const dirPath = join(baseDir, entry.name)
159
- let realPath
160
- try { realPath = realpathSync(dirPath) } catch { realPath = dirPath }
161
- const normalizedPath = realPath.toLowerCase()
162
- if (seen.has(normalizedPath)) continue
163
- seen.add(normalizedPath)
164
- if (existsSync(join(dirPath, '.sillyspec'))) {
165
- projects.push({ name: entry.name, path: dirPath })
166
- }
167
- if (currentDepth < maxDepth) {
168
- projects.push(...scanDirectory(dirPath, seen, maxDepth, currentDepth + 1))
169
- }
170
- }
171
- } catch (err) {}
172
- return projects
173
- }
174
-
175
- /**
176
- * Discover all SillySpec projects
177
- * @returns {Promise<Array>} Array of project objects
178
- */
179
- async function discoverProjects() {
180
- const home = homedir()
181
- const cwd = process.cwd()
182
-
183
- const scanDirs = new Set()
184
- scanDirs.add(cwd)
185
- scanDirs.add(dirname(cwd))
186
- scanDirs.add(dirname(dirname(cwd)))
187
- scanDirs.add(home)
188
-
189
- const extraDirs = [
190
- 'Desktop', '桌面', 'Documents', '文档', 'Downloads', '下载',
191
- 'Projects', '项目', 'Work', '工作', 'Repos', 'Code', 'src', 'dev',
192
- 'workspace', '工作区'
193
- ]
194
-
195
- for (const extra of extraDirs) {
196
- const extraPath = join(home, extra)
197
- if (existsSync(extraPath)) scanDirs.add(extraPath)
198
- }
199
-
200
- for (const customPath of customScanPaths) {
201
- if (existsSync(customPath)) scanDirs.add(customPath)
202
- }
203
-
204
- const seen = new Set()
205
- const projects = []
206
-
207
- // Normalize cwd for dedup
208
- let cwdReal
209
- try { cwdReal = realpathSync(cwd) } catch { cwdReal = cwd }
210
- seen.add(cwdReal.toLowerCase())
211
- if (existsSync(join(cwd, '.sillyspec'))) {
212
- projects.push({ name: basename(cwd), path: cwd })
213
- }
214
-
215
- for (const baseDir of scanDirs) {
216
- projects.push(...scanDirectory(baseDir, seen, 2, 0))
217
- }
218
-
219
- return projects
220
- }
221
-
222
- /**
223
- * Handle WebSocket message for CLI execution
224
- * @param {object} ws - WebSocket client
225
- * @param {object} data - Message data
226
- */
227
- function handleCliExecute(ws, data) {
228
- const { projectName, command } = data
229
-
230
- if (!projectName || !command) {
231
- ws.send(JSON.stringify({
232
- type: 'cli:error',
233
- data: { message: 'Missing projectName or command' }
234
- }))
235
- return
236
- }
237
-
238
- if (!isAllowedCliCommand(command)) {
239
- ws.send(JSON.stringify({
240
- type: 'cli:error',
241
- data: { message: `Command not allowed: ${command}` }
242
- }))
243
- return
244
- }
245
-
246
- discoverProjects().then(projects => {
247
- const project = projects.find(p => p.name === projectName)
248
- if (!project) {
249
- ws.send(JSON.stringify({
250
- type: 'cli:error',
251
- data: { message: `Project not found: ${projectName}` }
252
- }))
253
- return
254
- }
255
-
256
- const existingKill = activeProcesses.get(projectName)
257
- if (existingKill) existingKill()
258
-
259
- const kill = executeCommand(
260
- project.path,
261
- command,
262
- (output) => {
263
- broadcast({
264
- type: 'cli:output',
265
- data: { projectName, output: output.data, outputType: output.type }
266
- })
267
- },
268
- (result) => {
269
- activeProcesses.delete(projectName)
270
- broadcast({
271
- type: 'cli:complete',
272
- data: { projectName, exitCode: result.code, signal: result.signal }
273
- })
274
- }
275
- )
276
-
277
- activeProcesses.set(projectName, kill)
278
- broadcast({ type: 'cli:started', data: { projectName, command } })
279
- })
280
- }
281
-
282
- /**
283
- * Start the dashboard server
284
- * @param {object} options - Server options
285
- * @returns {object} HTTP server instance
286
- */
287
- function startServer({ port = 3456, open: openBrowser = true } = {}) {
288
- const server = createServer((req, res) => {
289
- const origin = req.headers.origin
290
- if (!isLocalOrigin(origin)) {
291
- res.writeHead(403)
292
- res.end('Forbidden')
293
- return
294
- }
295
- res.setHeader('Access-Control-Allow-Origin', origin || `http://127.0.0.1:${port}`)
296
- res.setHeader('Vary', 'Origin')
297
- res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS')
298
- res.setHeader('Access-Control-Allow-Headers', 'Content-Type')
299
-
300
- if (req.method === 'OPTIONS') {
301
- res.writeHead(200)
302
- res.end()
303
- return
304
- }
305
-
306
- if (req.url === '/api/projects') {
307
- discoverProjects().then(projects => {
308
- res.setHeader('Content-Type', 'application/json')
309
- res.writeHead(200)
310
- res.end(JSON.stringify(projects))
311
- }).catch(err => {
312
- res.writeHead(500)
313
- res.end(JSON.stringify({ error: err.message }))
314
- })
315
- return
316
- }
317
-
318
- // Detail API
319
- const detailMatch = req.url?.match(/^\/api\/projects\/(.+)\/detail(\?|$)/)
320
- if (detailMatch) {
321
- const projectPath = decodeURIComponent(detailMatch[1])
322
- const url = new URL(req.url, `http://${req.headers.host}`)
323
- const type = url.searchParams.get('type')
324
- let data
325
- try {
326
- if (type === 'git') data = parseGitDetail(projectPath)
327
- else if (type === 'tech') data = parseTechStackDetail(projectPath)
328
- else if (type === 'docs') data = parseSillyspecDocsTree(projectPath).groups
329
- else { res.writeHead(400); res.end(JSON.stringify({ error: 'Invalid type' })); return }
330
- res.setHeader('Content-Type', 'application/json')
331
- res.writeHead(200)
332
- res.end(JSON.stringify(data))
333
- } catch (err) {
334
- res.writeHead(500)
335
- res.end(JSON.stringify({ error: err.message }))
336
- }
337
- return
338
- }
339
-
340
- // Overview API
341
- if (req.url?.startsWith('/api/projects/') && req.url.endsWith('/overview')) {
342
- const parts = req.url.replace('/api/projects/', '').replace('/overview', '').split('/')
343
- const projectPath = decodeURIComponent(parts.join('/'))
344
- try {
345
- const overview = parseProjectOverview(projectPath)
346
- res.setHeader('Content-Type', 'application/json')
347
- res.writeHead(200)
348
- res.end(JSON.stringify(overview))
349
- } catch (err) {
350
- res.writeHead(500)
351
- res.end(JSON.stringify({ error: err.message }))
352
- }
353
- return
354
- }
355
-
356
- if (req.url?.startsWith('/api/project/')) {
357
- const projectName = decodeURIComponent(req.url.split('/').pop())
358
- discoverProjects().then(projects => {
359
- const project = projects.find(p => p.name === projectName)
360
- if (project) {
361
- const state = parseProjectState(project.path)
362
- res.setHeader('Content-Type', 'application/json')
363
- res.writeHead(200)
364
- res.end(JSON.stringify({ ...project, state }))
365
- } else {
366
- res.writeHead(404)
367
- res.end(JSON.stringify({ error: 'Project not found' }))
368
- }
369
- }).catch(err => {
370
- res.writeHead(500)
371
- res.end(JSON.stringify({ error: err.message }))
372
- })
373
- return
374
- }
375
-
376
- // Docs API
377
- if (req.url?.startsWith('/api/docs/content')) {
378
- const url = new URL(req.url, `http://${req.headers.host}`)
379
- const filePath = url.searchParams.get('path')
380
- if (!filePath) {
381
- res.writeHead(400)
382
- res.end(JSON.stringify({ error: 'Missing path parameter' }))
383
- return
384
- }
385
- // Security: only allow reading viewable text documents under a .sillyspec tree.
386
- const normalizedPath = resolve(filePath)
387
- if (!isSillyspecPath(normalizedPath) || !isViewableDocPath(normalizedPath) || isCredentialPath(normalizedPath)) {
388
- res.writeHead(403)
389
- res.end(JSON.stringify({ error: 'Access denied' }))
390
- return
391
- }
392
- if (existsSync(normalizedPath)) {
393
- try {
394
- const content = readFileSync(normalizedPath, 'utf-8')
395
- res.setHeader('Content-Type', 'text/plain; charset=utf-8')
396
- res.writeHead(200)
397
- res.end(content)
398
- } catch (err) {
399
- res.writeHead(500)
400
- res.end(JSON.stringify({ error: err.message }))
401
- }
402
- } else {
403
- res.writeHead(404)
404
- res.end(JSON.stringify({ error: 'File not found' }))
405
- }
406
- return
407
- }
408
-
409
- if (req.url?.startsWith('/api/docs')) {
410
- const url = new URL(req.url, `http://${req.headers.host}`)
411
- const projectPath = url.searchParams.get('project')
412
- if (!projectPath) {
413
- res.writeHead(400)
414
- res.end(JSON.stringify({ error: 'Missing project parameter' }))
415
- return
416
- }
417
- const docs = parseSillyspecDocsTree(projectPath)
418
- res.setHeader('Content-Type', 'application/json')
419
- res.writeHead(200)
420
- res.end(JSON.stringify(docs))
421
- return
422
- }
423
-
424
- // Serve static files (dist/)
425
- const distDir = join(__dirname, '../dist')
426
- const indexPath = join(distDir, 'index.html')
427
- if (existsSync(distDir)) {
428
- const url = new URL(req.url || '/', `http://${req.headers.host}`)
429
- const requestPath = decodeURIComponent(url.pathname)
430
- const filePath = resolve(distDir, requestPath === '/' ? 'index.html' : `.${requestPath}`)
431
- if (!isInside(distDir, filePath)) {
432
- res.writeHead(403)
433
- res.end('Access denied')
434
- return
435
- }
436
- if (existsSync(filePath)) {
437
- const ext = filePath.split('.').pop()
438
- const mimeTypes = { html: 'text/html', js: 'application/javascript', css: 'text/css', svg: 'image/svg+xml', png: 'image/png', jpg: 'image/jpeg' }
439
- res.setHeader('Content-Type', mimeTypes[ext] || 'application/octet-stream')
440
- res.writeHead(200)
441
- res.end(readFileSync(filePath))
442
- return
443
- }
444
- if (existsSync(indexPath)) {
445
- res.setHeader('Content-Type', 'text/html')
446
- res.writeHead(200)
447
- res.end(readFileSync(indexPath, 'utf8'))
448
- return
449
- }
450
- }
451
-
452
- res.writeHead(404)
453
- res.end('Not found')
454
- })
455
-
456
- wss = new WebSocketServer({ server })
457
-
458
- wss.on('error', (err) => {
459
- console.error('WebSocket server error:', err)
460
- })
461
-
462
- wss.on('connection', (ws, req) => {
463
- if (!isLocalOrigin(req.headers.origin)) {
464
- ws.close(1008, 'Forbidden origin')
465
- return
466
- }
467
- console.log('WebSocket client connected')
468
-
469
- // Send initial projects list
470
- discoverProjects().then(projects => {
471
- const projectsWithState = projects.map(p => ({
472
- ...p,
473
- state: parseProjectState(p.path),
474
- overview: parseProjectOverview(p.path)
475
- }))
476
-
477
- ws.send(JSON.stringify({
478
- type: 'projects:init',
479
- data: projectsWithState
480
- }))
481
-
482
- // Start watching progress files for all discovered projects
483
- for (const p of projectsWithState) {
484
- startProgressWatch(p.path)
485
- }
486
-
487
- // Send current scan paths
488
- ws.send(JSON.stringify({
489
- type: 'scan:paths',
490
- data: getCustomScanPaths()
491
- }))
492
- }).catch(err => {
493
- console.error('Error sending initial projects:', err)
494
- })
495
-
496
- ws.on('message', (message) => {
497
- try {
498
- const data = JSON.parse(message)
499
-
500
- switch (data.type) {
501
- case 'cli:execute':
502
- handleCliExecute(ws, data.data)
503
- break
504
- case 'cli:kill':
505
- const kill = activeProcesses.get(data.data.projectName)
506
- if (kill) {
507
- kill()
508
- activeProcesses.delete(data.data.projectName)
509
- broadcast({ type: 'cli:killed', data: { projectName: data.data.projectName } })
510
- }
511
- break
512
- case 'scan:add-path':
513
- if (data.data?.path) {
514
- addCustomScanPath(data.data.path)
515
- broadcast({ type: 'scan:paths', data: getCustomScanPaths() })
516
- // Resend projects after scan
517
- discoverProjects().then(projects => {
518
- broadcast({
519
- type: 'projects:updated',
520
- data: projects.map(p => ({
521
- ...p,
522
- state: parseProjectState(p.path),
523
- overview: parseProjectOverview(p.path)
524
- }))
525
- })
526
- })
527
- }
528
- break
529
- case 'scan:remove-path':
530
- if (data.data?.path) {
531
- removeCustomScanPath(data.data.path)
532
- ws.send(JSON.stringify({ type: 'scan:paths', data: getCustomScanPaths() }))
533
- }
534
- break
535
- case 'scan:get-paths':
536
- ws.send(JSON.stringify({ type: 'scan:paths', data: getCustomScanPaths() }))
537
- break
538
- case 'docs:get':
539
- if (data.data?.projectPath) {
540
- const docs = parseSillyspecDocsTree(data.data.projectPath)
541
- ws.send(JSON.stringify({ type: 'docs:tree', data: docs }))
542
- }
543
- break
544
- default:
545
- console.log('Unknown message type:', data.type)
546
- }
547
- } catch (err) {
548
- console.error('Error handling message:', err)
549
- }
550
- })
551
-
552
- ws.on('close', () => {
553
- console.log('WebSocket client disconnected')
554
- // Decrement progress watchers — we track which projects this client was watching
555
- // via the initial projects:init. For simplicity, stop all that we started.
556
- // (Other clients will re-start via their connection if still active)
557
- for (const [path] of progressWatchers) {
558
- stopProgressWatch(path)
559
- }
560
- })
561
-
562
- ws.on('error', (err) => {
563
- console.error('WebSocket error:', err)
564
- })
565
- })
566
-
567
- // listen 前置:Windows 下 startWatcher 的初始全盘同步扫描(homedir/Temp/桌面 depth 2 +
568
- // 每项目多次 execSync('git'))实测 150s+,若排在其后则服务永不响应(假死)。
569
- // 先绑定端口(listen 回调立即可达),扫描用 setImmediate 延后到事件循环——端口立即响应,
570
- // 初始 projects 数据由扫描完成后 broadcast 补发。
571
- server.listen(port, '127.0.0.1', () => {
572
- console.log(`Dashboard server running on http://127.0.0.1:${port}`)
573
- if (openBrowser) {
574
- open(`http://127.0.0.1:${port}`)
575
- }
576
- })
577
-
578
- setImmediate(() => {
579
- try {
580
- startWatcher((projects) => {
581
- broadcast({ type: 'projects:updated', data: projects })
582
- })
583
- } catch (err) {
584
- console.error('Failed to start file watcher:', err)
585
- }
586
- })
587
-
588
- const shutdown = () => {
589
- stopWatcher()
590
- activeProcesses.forEach(kill => kill())
591
- activeProcesses.clear()
592
- server.close()
593
- }
594
-
595
- process.on('SIGTERM', shutdown)
596
- process.on('SIGINT', shutdown)
597
-
598
- return server
599
- }
600
-
601
- export { startServer, broadcast, discoverProjects }
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 }