sillyspec 3.20.3 → 3.20.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (224) hide show
  1. package/.claude/skills/sillyspec-archive/SKILL.md +21 -17
  2. package/.claude/skills/sillyspec-auto/SKILL.md +83 -77
  3. package/.claude/skills/sillyspec-brainstorm/SKILL.md +44 -17
  4. package/.claude/skills/sillyspec-commit/SKILL.md +106 -105
  5. package/.claude/skills/sillyspec-continue/SKILL.md +45 -44
  6. package/.claude/skills/sillyspec-doctor/SKILL.md +31 -22
  7. package/.claude/skills/sillyspec-execute/SKILL.md +30 -17
  8. package/.claude/skills/sillyspec-explore/SKILL.md +109 -96
  9. package/.claude/skills/sillyspec-knowledge/SKILL.md +270 -0
  10. package/.claude/skills/sillyspec-plan/SKILL.md +21 -52
  11. package/.claude/skills/sillyspec-propose/SKILL.md +21 -17
  12. package/.claude/skills/sillyspec-quick/SKILL.md +21 -17
  13. package/.claude/skills/sillyspec-resume/SKILL.md +68 -111
  14. package/.claude/skills/sillyspec-scan/SKILL.md +21 -17
  15. package/.claude/skills/sillyspec-state/SKILL.md +54 -54
  16. package/.claude/skills/sillyspec-status/SKILL.md +21 -17
  17. package/.claude/skills/sillyspec-verify/SKILL.md +21 -17
  18. package/.claude/skills/sillyspec-workspace/SKILL.md +157 -149
  19. package/.husky/pre-push +13 -0
  20. package/CLAUDE.md +18 -0
  21. package/README.md +198 -186
  22. package/SKILL.md +90 -87
  23. package/bin/sillyspec.js +2 -2
  24. package/docs/brainstorm-plan-contract.md +64 -0
  25. package/docs/plan-execute-contract.md +123 -0
  26. package/docs/platform-scan-protocol.md +298 -0
  27. package/docs/revision-mode.md +115 -0
  28. package/docs/sillyspec/file-lifecycle/known-implementation-gaps.md +99 -0
  29. package/docs/sillyspec/file-lifecycle/platform-workflows-sync.md +218 -0
  30. package/docs/sillyspec/file-lifecycle/stage-artifacts.md +167 -0
  31. package/docs/sillyspec/file-lifecycle/storage-and-state.md +148 -0
  32. package/docs/sillyspec/file-lifecycle/worktree-and-guard.md +211 -0
  33. package/docs/sillyspec/file-lifecycle.md +125 -0
  34. package/docs/workflow-contract-regression.md +106 -0
  35. package/docs/worktree-isolation.md +252 -0
  36. package/package.json +40 -34
  37. package/packages/dashboard/dist/assets/{index-D1EVTLmc.js → index-Bq_Z2hne.js} +7446 -7446
  38. package/packages/dashboard/dist/assets/index-O2W5RV4z.css +1 -0
  39. package/packages/dashboard/dist/index.html +16 -16
  40. package/packages/dashboard/dist/prototype-dashboard.html +836 -0
  41. package/packages/dashboard/dist/prototype-overview.html +256 -0
  42. package/packages/dashboard/index.html +15 -15
  43. package/packages/dashboard/package-lock.json +2384 -2384
  44. package/packages/dashboard/package.json +25 -25
  45. package/packages/dashboard/public/prototype-dashboard.html +836 -0
  46. package/packages/dashboard/public/prototype-overview.html +256 -0
  47. package/packages/dashboard/server/executor.js +86 -86
  48. package/packages/dashboard/server/index.js +588 -508
  49. package/packages/dashboard/server/parser.js +526 -458
  50. package/packages/dashboard/server/watcher.js +350 -342
  51. package/packages/dashboard/src/App.vue +558 -325
  52. package/packages/dashboard/src/components/ActionBar.vue +93 -84
  53. package/packages/dashboard/src/components/CommandPalette.vue +96 -92
  54. package/packages/dashboard/src/components/DetailPanel.vue +137 -137
  55. package/packages/dashboard/src/components/DocPreview.vue +105 -8
  56. package/packages/dashboard/src/components/DocTree.vue +75 -19
  57. package/packages/dashboard/src/components/HResizeHandle.vue +48 -0
  58. package/packages/dashboard/src/components/LogStream.vue +65 -65
  59. package/packages/dashboard/src/components/PipelineStage.vue +95 -75
  60. package/packages/dashboard/src/components/PipelineView.vue +156 -129
  61. package/packages/dashboard/src/components/ProjectCard.vue +187 -0
  62. package/packages/dashboard/src/components/ProjectList.vue +210 -210
  63. package/packages/dashboard/src/components/ProjectOverview.vue +113 -139
  64. package/packages/dashboard/src/components/StageBadge.vue +67 -53
  65. package/packages/dashboard/src/components/StepCard.vue +94 -89
  66. package/packages/dashboard/src/components/VResizeHandle.vue +61 -0
  67. package/packages/dashboard/src/components/detail/DocsDetail.vue +48 -48
  68. package/packages/dashboard/src/components/detail/GitDetail.vue +61 -61
  69. package/packages/dashboard/src/components/detail/TechDetail.vue +43 -43
  70. package/packages/dashboard/src/composables/useDashboard.js +192 -164
  71. package/packages/dashboard/src/composables/useKeyboard.js +119 -119
  72. package/packages/dashboard/src/composables/useLayout.js +131 -0
  73. package/packages/dashboard/src/composables/useWebSocket.js +129 -129
  74. package/packages/dashboard/src/main.js +8 -8
  75. package/packages/dashboard/src/style.css +132 -132
  76. package/packages/dashboard/vite.config.js +18 -18
  77. package/src/brainstorm-postcheck.js +158 -0
  78. package/src/change-list.js +52 -0
  79. package/src/change-risk-profile.js +352 -0
  80. package/src/classify-change.js +73 -0
  81. package/src/constants.js +70 -0
  82. package/src/contract-matrix.js +278 -0
  83. package/src/db.js +201 -0
  84. package/src/endpoint-extractor.js +315 -0
  85. package/src/hooks/claude-pre-tool-use.cjs +125 -0
  86. package/src/hooks/worktree-guard.js +761 -0
  87. package/src/index.js +922 -252
  88. package/src/init.js +431 -375
  89. package/src/knowledge-match.js +130 -0
  90. package/src/migrate.js +117 -117
  91. package/src/modules.js +482 -0
  92. package/src/progress.js +1734 -508
  93. package/src/run.js +3465 -652
  94. package/src/scan-postcheck.js +387 -0
  95. package/src/setup.js +398 -460
  96. package/src/stage-contract.js +700 -0
  97. package/src/stages/archive.js +160 -54
  98. package/src/stages/brainstorm-auto.js +229 -0
  99. package/src/stages/brainstorm.js +645 -239
  100. package/src/stages/doctor.js +365 -312
  101. package/src/stages/execute.js +625 -264
  102. package/src/stages/explore.js +34 -0
  103. package/src/stages/index.js +29 -35
  104. package/src/stages/knowledge.js +498 -0
  105. package/src/stages/plan-postcheck.js +511 -0
  106. package/src/stages/plan.js +582 -279
  107. package/src/stages/propose.js +174 -115
  108. package/src/stages/quick.js +102 -63
  109. package/src/stages/scan.js +558 -141
  110. package/src/stages/status.js +65 -65
  111. package/src/stages/verify.js +322 -135
  112. package/src/sync.js +497 -0
  113. package/src/task-review.js +346 -0
  114. package/src/workflow.js +785 -0
  115. package/src/worktree-apply.js +549 -0
  116. package/src/worktree-deps.js +185 -0
  117. package/src/worktree.js +982 -0
  118. package/templates/workflows/archive-impact.yaml +79 -0
  119. package/templates/workflows/scan-docs.yaml +132 -0
  120. package/test/brainstorm-plan-contract.test.mjs +273 -0
  121. package/test/check-syntax.mjs +26 -0
  122. package/test/cli-top-level-aliases.test.mjs +174 -0
  123. package/test/contract-artifacts.test.mjs +323 -0
  124. package/test/decision-supersede.test.mjs +277 -0
  125. package/test/knowledge-match.test.mjs +231 -0
  126. package/test/plan-execute-contract.test.mjs +330 -0
  127. package/test/plan-optimization.test.mjs +572 -0
  128. package/test/platform-artifacts.test.mjs +190 -0
  129. package/test/platform-failure-samples.test.mjs +199 -0
  130. package/test/platform-recovery-chain.test.mjs +179 -0
  131. package/test/platform-recovery.test.mjs +167 -0
  132. package/test/platform-scan-p0.test.mjs +175 -0
  133. package/test/revision-v1.test.mjs +1145 -0
  134. package/test/run-sanitize-project-name.test.mjs +51 -0
  135. package/test/run-scan-postcheck-fail.test.mjs +64 -0
  136. package/test/run-scan-project-parse.test.mjs +200 -0
  137. package/test/run-tests.mjs +48 -0
  138. package/test/scan-docs-yaml-placeholders.test.mjs +84 -0
  139. package/test/scan-knowledge.test.mjs +175 -0
  140. package/test/scan-paths.test.mjs +68 -0
  141. package/test/scan-postcheck-project-priority.test.mjs +85 -0
  142. package/test/scan-postcheck.test.mjs +197 -0
  143. package/test/scan-workflow-anyfailed-block.test.mjs +52 -0
  144. package/test/spec-dir.test.mjs +206 -0
  145. package/test/stage-contract-failed-post-check.test.mjs +102 -0
  146. package/test/stage-contract.test.mjs +299 -0
  147. package/test/stage-definitions.test.mjs +39 -0
  148. package/test/wait-gates.test.mjs +501 -0
  149. package/test/workflow-spec-base.test.mjs +142 -0
  150. package/test/worktree-deps-provision.test.mjs +148 -0
  151. package/test/worktree-guard.test.mjs +136 -0
  152. package/test/worktree-native-overlay.test.mjs +188 -0
  153. package/.sillyspec/changes/archive/2026-04-08-derive-state/design.md +0 -97
  154. package/.sillyspec/changes/archive/2026-04-08-derive-state/plan.md +0 -51
  155. package/.sillyspec/changes/archive/2026-04-08-derive-state/proposal.md +0 -29
  156. package/.sillyspec/changes/archive/2026-04-08-derive-state/requirements.md +0 -34
  157. package/.sillyspec/changes/archive/2026-04-08-derive-state/tasks.md +0 -13
  158. package/.sillyspec/changes/archive/2026-04-08-derive-state/verify-result.md +0 -43
  159. package/.sillyspec/changes/auto-mode/design.md +0 -50
  160. package/.sillyspec/changes/auto-mode/proposal.md +0 -19
  161. package/.sillyspec/changes/auto-mode/requirements.md +0 -21
  162. package/.sillyspec/changes/auto-mode/tasks.md +0 -7
  163. package/.sillyspec/changes/brainstorm-archive/2026-04-05-dashboard-design.md +0 -206
  164. package/.sillyspec/changes/brainstorm-archive/2026-04-05-unified-docs-design.md +0 -199
  165. package/.sillyspec/changes/dashboard/design.md +0 -219
  166. package/.sillyspec/changes/dashboard/design.md.braindraft +0 -206
  167. package/.sillyspec/changes/run-command-design/design.md +0 -1230
  168. package/.sillyspec/changes/unified-docs-design/design.md +0 -199
  169. package/.sillyspec/docs/sillyspec/scan/.gitkeep +0 -0
  170. package/.sillyspec/knowledge/INDEX.md +0 -8
  171. package/.sillyspec/knowledge/uncategorized.md +0 -3
  172. package/.sillyspec/plans/2026-04-05-dashboard.md +0 -737
  173. package/.sillyspec/projects/sillyspec.yaml +0 -3
  174. package/dist/steps/brainstorm/01-load-context.md +0 -30
  175. package/dist/steps/brainstorm/02-reuse-check.md +0 -6
  176. package/dist/steps/brainstorm/03-prototype-analysis.md +0 -11
  177. package/dist/steps/brainstorm/04-module-split.md +0 -23
  178. package/dist/steps/brainstorm/05-dialog-explore.md +0 -8
  179. package/dist/steps/brainstorm/06-propose-approaches.md +0 -3
  180. package/dist/steps/brainstorm/07-present-design.md +0 -3
  181. package/dist/steps/brainstorm/08-write-design.md +0 -21
  182. package/dist/steps/brainstorm/09-self-review.md +0 -15
  183. package/dist/steps/brainstorm/10-user-confirm.md +0 -3
  184. package/dist/steps/brainstorm/11-output-spec.md +0 -7
  185. package/dist/steps/brainstorm/manifest.yaml +0 -26
  186. package/dist/steps/execute/01-load-context.md +0 -41
  187. package/dist/steps/execute/02-scan-conventions.md +0 -47
  188. package/dist/steps/execute/03-skill-mcp.md +0 -19
  189. package/dist/steps/execute/04-assign-task.md +0 -22
  190. package/dist/steps/execute/04b-prompt-template.md +0 -54
  191. package/dist/steps/execute/05-write-test.md +0 -7
  192. package/dist/steps/execute/06-write-code.md +0 -8
  193. package/dist/steps/execute/07-run-test.md +0 -26
  194. package/dist/steps/execute/08-fix-issues.md +0 -28
  195. package/dist/steps/execute/09-next-task.md +0 -33
  196. package/dist/steps/execute/manifest.yaml +0 -28
  197. package/dist/steps/plan/01-load-context.md +0 -22
  198. package/dist/steps/plan/02-anchor-confirm.md +0 -1
  199. package/dist/steps/plan/03-expand-tasks.md +0 -33
  200. package/dist/steps/plan/04-mark-order.md +0 -15
  201. package/dist/steps/plan/05-e2e-planning.md +0 -17
  202. package/dist/steps/plan/06-self-check.md +0 -16
  203. package/dist/steps/plan/07-save.md +0 -1
  204. package/dist/steps/plan/manifest.yaml +0 -18
  205. package/dist/steps/scan/01-env-detect.md +0 -51
  206. package/dist/steps/scan/02-tech-stack.md +0 -16
  207. package/dist/steps/scan/03-conventions.md +0 -16
  208. package/dist/steps/scan/04-structure.md +0 -19
  209. package/dist/steps/scan/05-quality.md +0 -18
  210. package/dist/steps/scan/06-complete.md +0 -49
  211. package/dist/steps/scan/manifest.yaml +0 -16
  212. package/dist/steps/verify/01-load-specs.md +0 -28
  213. package/dist/steps/verify/02-check-tasks.md +0 -1
  214. package/dist/steps/verify/03-check-design.md +0 -6
  215. package/dist/steps/verify/04-run-tests.md +0 -7
  216. package/dist/steps/verify/05-e2e-tests.md +0 -27
  217. package/dist/steps/verify/05b-e2e-fix.md +0 -33
  218. package/dist/steps/verify/06-code-quality.md +0 -25
  219. package/dist/steps/verify/07-lint-check.md +0 -27
  220. package/dist/steps/verify/08-output-report.md +0 -14
  221. package/dist/steps/verify/manifest.yaml +0 -22
  222. package/packages/dashboard/dist/assets/index-DGe8CqeP.css +0 -1
  223. package/src/derive.js +0 -147
  224. package/src/step.js +0 -543
@@ -1,508 +1,588 @@
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 } from 'path'
5
- import { fileURLToPath } from 'url'
6
- import { homedir } from 'os'
7
- import open from 'open'
8
- import { parseProjectState, parseDocsTree, parseProjectOverview, parseGitDetail, parseTechStackDetail, parseDocsList } 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
-
18
- // Progress file watchers: projectPath -> { watcher, timer, refCount }
19
- const progressWatchers = new Map()
20
-
21
- function startProgressWatch(projectPath) {
22
- if (progressWatchers.has(projectPath)) {
23
- progressWatchers.get(projectPath).refCount++
24
- return
25
- }
26
- const progressFile = join(projectPath, '.sillyspec', '.runtime', 'progress.json')
27
- if (!existsSync(progressFile)) return
28
-
29
- let timer = null
30
- try {
31
- const watcher = watch(progressFile, (eventType) => {
32
- if (timer) clearTimeout(timer)
33
- timer = setTimeout(() => {
34
- timer = null
35
- try {
36
- const state = parseProjectState(projectPath)
37
- broadcast({ type: 'project:update', data: { path: projectPath, state } })
38
- } catch {}
39
- }, 200)
40
- })
41
- progressWatchers.set(projectPath, { watcher, timer, refCount: 1 })
42
- } catch {}
43
- }
44
-
45
- function stopProgressWatch(projectPath) {
46
- const entry = progressWatchers.get(projectPath)
47
- if (!entry) return
48
- entry.refCount--
49
- if (entry.refCount <= 0) {
50
- entry.watcher.close()
51
- if (entry.timer) clearTimeout(entry.timer)
52
- progressWatchers.delete(projectPath)
53
- }
54
- }
55
-
56
- /**
57
- * Broadcast message to all connected WebSocket clients
58
- * @param {object} data - Data to broadcast
59
- */
60
- function broadcast(data) {
61
- if (!wss) return
62
- const message = JSON.stringify(data)
63
- wss.clients.forEach(client => {
64
- if (client.readyState === 1) { // OPEN
65
- client.send(message)
66
- }
67
- })
68
- }
69
-
70
- // --- Shared scan logic (aligned with watcher.js) ---
71
-
72
- const excludeDirs = new Set([
73
- '.Trash', '.cache', '.npm', '.local', '.vscode', 'Library',
74
- '.git', 'node_modules', '.Trash-*', '.DS_Store', '.config',
75
- '.cocoapods', '.gem', '.rvm', '.nvm', '.asdf', '.brew',
76
- 'AppData', 'Application Data', '.cargo', '.rustup',
77
- '.nuget', '.android', '.gradle', '.m2', '.vscode-server'
78
- ])
79
-
80
- function shouldExclude(name, cwd) {
81
- if (excludeDirs.has(name)) return true
82
- for (const pattern of excludeDirs) {
83
- if (pattern.includes('*')) {
84
- const regex = new RegExp('^' + pattern.replace(/\*/g, '.*') + '$')
85
- if (regex.test(name)) return true
86
- }
87
- }
88
- const cwdName = cwd.split(sep).pop() || cwd.split('/').pop() || ''
89
- if (name.startsWith('.') && name !== cwdName) return true
90
- return false
91
- }
92
-
93
- function scanDirectory(baseDir, seen, maxDepth = 2, currentDepth = 0) {
94
- const cwd = process.cwd()
95
- const projects = []
96
- try {
97
- const entries = readdirSync(baseDir, { withFileTypes: true })
98
- for (const entry of entries) {
99
- if (!entry.isDirectory()) continue
100
- if (shouldExclude(entry.name, cwd)) continue
101
- const dirPath = join(baseDir, entry.name)
102
- let realPath
103
- try { realPath = realpathSync(dirPath) } catch { realPath = dirPath }
104
- const normalizedPath = realPath.toLowerCase()
105
- if (seen.has(normalizedPath)) continue
106
- seen.add(normalizedPath)
107
- if (existsSync(join(dirPath, '.sillyspec'))) {
108
- projects.push({ name: entry.name, path: dirPath })
109
- }
110
- if (currentDepth < maxDepth) {
111
- projects.push(...scanDirectory(dirPath, seen, maxDepth, currentDepth + 1))
112
- }
113
- }
114
- } catch (err) {}
115
- return projects
116
- }
117
-
118
- /**
119
- * Discover all SillySpec projects
120
- * @returns {Promise<Array>} Array of project objects
121
- */
122
- async function discoverProjects() {
123
- const home = homedir()
124
- const cwd = process.cwd()
125
-
126
- const scanDirs = new Set()
127
- scanDirs.add(cwd)
128
- scanDirs.add(dirname(cwd))
129
- scanDirs.add(dirname(dirname(cwd)))
130
- scanDirs.add(home)
131
-
132
- const extraDirs = [
133
- 'Desktop', '桌面', 'Documents', '文档', 'Downloads', '下载',
134
- 'Projects', '项目', 'Work', '工作', 'Repos', 'Code', 'src', 'dev',
135
- 'workspace', '工作区'
136
- ]
137
-
138
- for (const extra of extraDirs) {
139
- const extraPath = join(home, extra)
140
- if (existsSync(extraPath)) scanDirs.add(extraPath)
141
- }
142
-
143
- for (const customPath of customScanPaths) {
144
- if (existsSync(customPath)) scanDirs.add(customPath)
145
- }
146
-
147
- const seen = new Set()
148
- const projects = []
149
-
150
- // Normalize cwd for dedup
151
- let cwdReal
152
- try { cwdReal = realpathSync(cwd) } catch { cwdReal = cwd }
153
- seen.add(cwdReal.toLowerCase())
154
- if (existsSync(join(cwd, '.sillyspec'))) {
155
- projects.push({ name: basename(cwd), path: cwd })
156
- }
157
-
158
- for (const baseDir of scanDirs) {
159
- projects.push(...scanDirectory(baseDir, seen, 2, 0))
160
- }
161
-
162
- return projects
163
- }
164
-
165
- /**
166
- * Handle WebSocket message for CLI execution
167
- * @param {object} ws - WebSocket client
168
- * @param {object} data - Message data
169
- */
170
- function handleCliExecute(ws, data) {
171
- const { projectName, command } = data
172
-
173
- if (!projectName || !command) {
174
- ws.send(JSON.stringify({
175
- type: 'cli:error',
176
- data: { message: 'Missing projectName or command' }
177
- }))
178
- return
179
- }
180
-
181
- discoverProjects().then(projects => {
182
- const project = projects.find(p => p.name === projectName)
183
- if (!project) {
184
- ws.send(JSON.stringify({
185
- type: 'cli:error',
186
- data: { message: `Project not found: ${projectName}` }
187
- }))
188
- return
189
- }
190
-
191
- const existingKill = activeProcesses.get(projectName)
192
- if (existingKill) existingKill()
193
-
194
- const kill = executeCommand(
195
- project.path,
196
- command,
197
- (output) => {
198
- broadcast({
199
- type: 'cli:output',
200
- data: { projectName, output: output.data, outputType: output.type }
201
- })
202
- },
203
- (result) => {
204
- activeProcesses.delete(projectName)
205
- broadcast({
206
- type: 'cli:complete',
207
- data: { projectName, exitCode: result.code, signal: result.signal }
208
- })
209
- }
210
- )
211
-
212
- activeProcesses.set(projectName, kill)
213
- broadcast({ type: 'cli:started', data: { projectName, command } })
214
- })
215
- }
216
-
217
- /**
218
- * Start the dashboard server
219
- * @param {object} options - Server options
220
- * @returns {object} HTTP server instance
221
- */
222
- function startServer({ port = 3456, open: openBrowser = true } = {}) {
223
- const server = createServer((req, res) => {
224
- res.setHeader('Access-Control-Allow-Origin', '*')
225
- res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS')
226
- res.setHeader('Access-Control-Allow-Headers', 'Content-Type')
227
-
228
- if (req.method === 'OPTIONS') {
229
- res.writeHead(200)
230
- res.end()
231
- return
232
- }
233
-
234
- if (req.url === '/api/projects') {
235
- discoverProjects().then(projects => {
236
- res.setHeader('Content-Type', 'application/json')
237
- res.writeHead(200)
238
- res.end(JSON.stringify(projects))
239
- }).catch(err => {
240
- res.writeHead(500)
241
- res.end(JSON.stringify({ error: err.message }))
242
- })
243
- return
244
- }
245
-
246
- // Detail API
247
- const detailMatch = req.url?.match(/^\/api\/projects\/(.+)\/detail(\?|$)/)
248
- if (detailMatch) {
249
- const projectPath = decodeURIComponent(detailMatch[1])
250
- const url = new URL(req.url, `http://${req.headers.host}`)
251
- const type = url.searchParams.get('type')
252
- let data
253
- try {
254
- if (type === 'git') data = parseGitDetail(projectPath)
255
- else if (type === 'tech') data = parseTechStackDetail(projectPath)
256
- else if (type === 'docs') data = parseDocsList(projectPath)
257
- else { res.writeHead(400); res.end(JSON.stringify({ error: 'Invalid type' })); return }
258
- res.setHeader('Content-Type', 'application/json')
259
- res.writeHead(200)
260
- res.end(JSON.stringify(data))
261
- } catch (err) {
262
- res.writeHead(500)
263
- res.end(JSON.stringify({ error: err.message }))
264
- }
265
- return
266
- }
267
-
268
- // Overview API
269
- if (req.url?.startsWith('/api/projects/') && req.url.endsWith('/overview')) {
270
- const parts = req.url.replace('/api/projects/', '').replace('/overview', '').split('/')
271
- const projectPath = decodeURIComponent(parts.join('/'))
272
- try {
273
- const overview = parseProjectOverview(projectPath)
274
- res.setHeader('Content-Type', 'application/json')
275
- res.writeHead(200)
276
- res.end(JSON.stringify(overview))
277
- } catch (err) {
278
- res.writeHead(500)
279
- res.end(JSON.stringify({ error: err.message }))
280
- }
281
- return
282
- }
283
-
284
- if (req.url?.startsWith('/api/project/')) {
285
- const projectName = decodeURIComponent(req.url.split('/').pop())
286
- discoverProjects().then(projects => {
287
- const project = projects.find(p => p.name === projectName)
288
- if (project) {
289
- const state = parseProjectState(project.path)
290
- res.setHeader('Content-Type', 'application/json')
291
- res.writeHead(200)
292
- res.end(JSON.stringify({ ...project, state }))
293
- } else {
294
- res.writeHead(404)
295
- res.end(JSON.stringify({ error: 'Project not found' }))
296
- }
297
- }).catch(err => {
298
- res.writeHead(500)
299
- res.end(JSON.stringify({ error: err.message }))
300
- })
301
- return
302
- }
303
-
304
- // Docs API
305
- if (req.url?.startsWith('/api/docs/content')) {
306
- const url = new URL(req.url, `http://${req.headers.host}`)
307
- const filePath = url.searchParams.get('path')
308
- if (!filePath) {
309
- res.writeHead(400)
310
- res.end(JSON.stringify({ error: 'Missing path parameter' }))
311
- return
312
- }
313
- // Security: only allow reading files under .sillyspec/docs/
314
- const normalizedPath = resolve(filePath)
315
- if (!normalizedPath.includes('.sillyspec' + sep + 'docs')) {
316
- res.writeHead(403)
317
- res.end(JSON.stringify({ error: 'Access denied' }))
318
- return
319
- }
320
- if (existsSync(normalizedPath)) {
321
- try {
322
- const content = readFileSync(normalizedPath, 'utf-8')
323
- res.setHeader('Content-Type', 'text/plain; charset=utf-8')
324
- res.writeHead(200)
325
- res.end(content)
326
- } catch (err) {
327
- res.writeHead(500)
328
- res.end(JSON.stringify({ error: err.message }))
329
- }
330
- } else {
331
- res.writeHead(404)
332
- res.end(JSON.stringify({ error: 'File not found' }))
333
- }
334
- return
335
- }
336
-
337
- if (req.url?.startsWith('/api/docs')) {
338
- const url = new URL(req.url, `http://${req.headers.host}`)
339
- const projectPath = url.searchParams.get('project')
340
- if (!projectPath) {
341
- res.writeHead(400)
342
- res.end(JSON.stringify({ error: 'Missing project parameter' }))
343
- return
344
- }
345
- const docs = parseDocsTree(projectPath)
346
- res.setHeader('Content-Type', 'application/json')
347
- res.writeHead(200)
348
- res.end(JSON.stringify(docs))
349
- return
350
- }
351
-
352
- // Serve static files (dist/)
353
- const distDir = join(__dirname, '../dist')
354
- const indexPath = join(distDir, 'index.html')
355
- if (existsSync(distDir)) {
356
- const filePath = join(distDir, req.url === '/' ? 'index.html' : req.url.replace(/^\//, ''))
357
- if (existsSync(filePath) && !filePath.includes('..')) {
358
- const ext = filePath.split('.').pop()
359
- const mimeTypes = { html: 'text/html', js: 'application/javascript', css: 'text/css', svg: 'image/svg+xml', png: 'image/png', jpg: 'image/jpeg' }
360
- res.setHeader('Content-Type', mimeTypes[ext] || 'application/octet-stream')
361
- res.writeHead(200)
362
- res.end(readFileSync(filePath))
363
- return
364
- }
365
- if (existsSync(indexPath)) {
366
- res.setHeader('Content-Type', 'text/html')
367
- res.writeHead(200)
368
- res.end(readFileSync(indexPath, 'utf8'))
369
- return
370
- }
371
- }
372
-
373
- res.writeHead(404)
374
- res.end('Not found')
375
- })
376
-
377
- wss = new WebSocketServer({ server })
378
-
379
- wss.on('error', (err) => {
380
- console.error('WebSocket server error:', err)
381
- })
382
-
383
- wss.on('connection', (ws) => {
384
- console.log('WebSocket client connected')
385
-
386
- // Send initial projects list
387
- discoverProjects().then(projects => {
388
- const projectsWithState = projects.map(p => ({
389
- ...p,
390
- state: parseProjectState(p.path),
391
- overview: parseProjectOverview(p.path)
392
- }))
393
-
394
- ws.send(JSON.stringify({
395
- type: 'projects:init',
396
- data: projectsWithState
397
- }))
398
-
399
- // Start watching progress files for all discovered projects
400
- for (const p of projectsWithState) {
401
- startProgressWatch(p.path)
402
- }
403
-
404
- // Send current scan paths
405
- ws.send(JSON.stringify({
406
- type: 'scan:paths',
407
- data: getCustomScanPaths()
408
- }))
409
- }).catch(err => {
410
- console.error('Error sending initial projects:', err)
411
- })
412
-
413
- ws.on('message', (message) => {
414
- try {
415
- const data = JSON.parse(message)
416
-
417
- switch (data.type) {
418
- case 'cli:execute':
419
- handleCliExecute(ws, data.data)
420
- break
421
- case 'cli:kill':
422
- const kill = activeProcesses.get(data.data.projectName)
423
- if (kill) {
424
- kill()
425
- activeProcesses.delete(data.data.projectName)
426
- broadcast({ type: 'cli:killed', data: { projectName: data.data.projectName } })
427
- }
428
- break
429
- case 'scan:add-path':
430
- if (data.data?.path) {
431
- addCustomScanPath(data.data.path)
432
- broadcast({ type: 'scan:paths', data: getCustomScanPaths() })
433
- // Resend projects after scan
434
- discoverProjects().then(projects => {
435
- broadcast({
436
- type: 'projects:updated',
437
- data: projects.map(p => ({ ...p, state: parseProjectState(p.path) }))
438
- })
439
- })
440
- }
441
- break
442
- case 'scan:remove-path':
443
- if (data.data?.path) {
444
- removeCustomScanPath(data.data.path)
445
- ws.send(JSON.stringify({ type: 'scan:paths', data: getCustomScanPaths() }))
446
- }
447
- break
448
- case 'scan:get-paths':
449
- ws.send(JSON.stringify({ type: 'scan:paths', data: getCustomScanPaths() }))
450
- break
451
- case 'docs:get':
452
- if (data.data?.projectPath) {
453
- const docs = parseDocsTree(data.data.projectPath)
454
- ws.send(JSON.stringify({ type: 'docs:tree', data: docs }))
455
- }
456
- break
457
- default:
458
- console.log('Unknown message type:', data.type)
459
- }
460
- } catch (err) {
461
- console.error('Error handling message:', err)
462
- }
463
- })
464
-
465
- ws.on('close', () => {
466
- console.log('WebSocket client disconnected')
467
- // Decrement progress watchers — we track which projects this client was watching
468
- // via the initial projects:init. For simplicity, stop all that we started.
469
- // (Other clients will re-start via their connection if still active)
470
- for (const [path] of progressWatchers) {
471
- stopProgressWatch(path)
472
- }
473
- })
474
-
475
- ws.on('error', (err) => {
476
- console.error('WebSocket error:', err)
477
- })
478
- })
479
-
480
- try {
481
- startWatcher((projects) => {
482
- broadcast({ type: 'projects:updated', data: projects })
483
- })
484
- } catch (err) {
485
- console.error('Failed to start file watcher:', err)
486
- }
487
-
488
- server.listen(port, () => {
489
- console.log(`Dashboard server running on http://localhost:${port}`)
490
- if (openBrowser) {
491
- open(`http://localhost:${port}`)
492
- }
493
- })
494
-
495
- const shutdown = () => {
496
- stopWatcher()
497
- activeProcesses.forEach(kill => kill())
498
- activeProcesses.clear()
499
- server.close()
500
- }
501
-
502
- process.on('SIGTERM', shutdown)
503
- process.on('SIGINT', shutdown)
504
-
505
- return server
506
- }
507
-
508
- 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
+ function isViewableDocPath(filePath) {
107
+ return /\.(md|markdown|mdx|html?|txt|log|json|ya?ml|toml|xml|csv)$/i.test(filePath)
108
+ }
109
+
110
+ function isLocalOrigin(origin) {
111
+ if (!origin) return true
112
+ try {
113
+ const { hostname } = new URL(origin)
114
+ return hostname === 'localhost' || hostname === '127.0.0.1' || hostname === '::1'
115
+ } catch {
116
+ return false
117
+ }
118
+ }
119
+
120
+ // --- Shared scan logic (aligned with watcher.js) ---
121
+
122
+ const excludeDirs = new Set([
123
+ '.Trash', '.cache', '.npm', '.local', '.vscode', 'Library',
124
+ '.git', 'node_modules', '.Trash-*', '.DS_Store', '.config',
125
+ '.cocoapods', '.gem', '.rvm', '.nvm', '.asdf', '.brew',
126
+ 'AppData', 'Application Data', '.cargo', '.rustup',
127
+ '.nuget', '.android', '.gradle', '.m2', '.vscode-server'
128
+ ])
129
+
130
+ function shouldExclude(name, cwd) {
131
+ if (excludeDirs.has(name)) return true
132
+ for (const pattern of excludeDirs) {
133
+ if (pattern.includes('*')) {
134
+ const regex = new RegExp('^' + pattern.replace(/\*/g, '.*') + '$')
135
+ if (regex.test(name)) return true
136
+ }
137
+ }
138
+ const cwdName = cwd.split(sep).pop() || cwd.split('/').pop() || ''
139
+ if (name.startsWith('.') && name !== cwdName) return true
140
+ return false
141
+ }
142
+
143
+ function scanDirectory(baseDir, seen, maxDepth = 2, currentDepth = 0) {
144
+ const cwd = process.cwd()
145
+ const projects = []
146
+ try {
147
+ const entries = readdirSync(baseDir, { withFileTypes: true })
148
+ for (const entry of entries) {
149
+ if (!entry.isDirectory()) continue
150
+ if (shouldExclude(entry.name, cwd)) continue
151
+ const dirPath = join(baseDir, entry.name)
152
+ let realPath
153
+ try { realPath = realpathSync(dirPath) } catch { realPath = dirPath }
154
+ const normalizedPath = realPath.toLowerCase()
155
+ if (seen.has(normalizedPath)) continue
156
+ seen.add(normalizedPath)
157
+ if (existsSync(join(dirPath, '.sillyspec'))) {
158
+ projects.push({ name: entry.name, path: dirPath })
159
+ }
160
+ if (currentDepth < maxDepth) {
161
+ projects.push(...scanDirectory(dirPath, seen, maxDepth, currentDepth + 1))
162
+ }
163
+ }
164
+ } catch (err) {}
165
+ return projects
166
+ }
167
+
168
+ /**
169
+ * Discover all SillySpec projects
170
+ * @returns {Promise<Array>} Array of project objects
171
+ */
172
+ async function discoverProjects() {
173
+ const home = homedir()
174
+ const cwd = process.cwd()
175
+
176
+ const scanDirs = new Set()
177
+ scanDirs.add(cwd)
178
+ scanDirs.add(dirname(cwd))
179
+ scanDirs.add(dirname(dirname(cwd)))
180
+ scanDirs.add(home)
181
+
182
+ const extraDirs = [
183
+ 'Desktop', '桌面', 'Documents', '文档', 'Downloads', '下载',
184
+ 'Projects', '项目', 'Work', '工作', 'Repos', 'Code', 'src', 'dev',
185
+ 'workspace', '工作区'
186
+ ]
187
+
188
+ for (const extra of extraDirs) {
189
+ const extraPath = join(home, extra)
190
+ if (existsSync(extraPath)) scanDirs.add(extraPath)
191
+ }
192
+
193
+ for (const customPath of customScanPaths) {
194
+ if (existsSync(customPath)) scanDirs.add(customPath)
195
+ }
196
+
197
+ const seen = new Set()
198
+ const projects = []
199
+
200
+ // Normalize cwd for dedup
201
+ let cwdReal
202
+ try { cwdReal = realpathSync(cwd) } catch { cwdReal = cwd }
203
+ seen.add(cwdReal.toLowerCase())
204
+ if (existsSync(join(cwd, '.sillyspec'))) {
205
+ projects.push({ name: basename(cwd), path: cwd })
206
+ }
207
+
208
+ for (const baseDir of scanDirs) {
209
+ projects.push(...scanDirectory(baseDir, seen, 2, 0))
210
+ }
211
+
212
+ return projects
213
+ }
214
+
215
+ /**
216
+ * Handle WebSocket message for CLI execution
217
+ * @param {object} ws - WebSocket client
218
+ * @param {object} data - Message data
219
+ */
220
+ function handleCliExecute(ws, data) {
221
+ const { projectName, command } = data
222
+
223
+ if (!projectName || !command) {
224
+ ws.send(JSON.stringify({
225
+ type: 'cli:error',
226
+ data: { message: 'Missing projectName or command' }
227
+ }))
228
+ return
229
+ }
230
+
231
+ if (!isAllowedCliCommand(command)) {
232
+ ws.send(JSON.stringify({
233
+ type: 'cli:error',
234
+ data: { message: `Command not allowed: ${command}` }
235
+ }))
236
+ return
237
+ }
238
+
239
+ discoverProjects().then(projects => {
240
+ const project = projects.find(p => p.name === projectName)
241
+ if (!project) {
242
+ ws.send(JSON.stringify({
243
+ type: 'cli:error',
244
+ data: { message: `Project not found: ${projectName}` }
245
+ }))
246
+ return
247
+ }
248
+
249
+ const existingKill = activeProcesses.get(projectName)
250
+ if (existingKill) existingKill()
251
+
252
+ const kill = executeCommand(
253
+ project.path,
254
+ command,
255
+ (output) => {
256
+ broadcast({
257
+ type: 'cli:output',
258
+ data: { projectName, output: output.data, outputType: output.type }
259
+ })
260
+ },
261
+ (result) => {
262
+ activeProcesses.delete(projectName)
263
+ broadcast({
264
+ type: 'cli:complete',
265
+ data: { projectName, exitCode: result.code, signal: result.signal }
266
+ })
267
+ }
268
+ )
269
+
270
+ activeProcesses.set(projectName, kill)
271
+ broadcast({ type: 'cli:started', data: { projectName, command } })
272
+ })
273
+ }
274
+
275
+ /**
276
+ * Start the dashboard server
277
+ * @param {object} options - Server options
278
+ * @returns {object} HTTP server instance
279
+ */
280
+ function startServer({ port = 3456, open: openBrowser = true } = {}) {
281
+ const server = createServer((req, res) => {
282
+ const origin = req.headers.origin
283
+ if (!isLocalOrigin(origin)) {
284
+ res.writeHead(403)
285
+ res.end('Forbidden')
286
+ return
287
+ }
288
+ res.setHeader('Access-Control-Allow-Origin', origin || `http://127.0.0.1:${port}`)
289
+ res.setHeader('Vary', 'Origin')
290
+ res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS')
291
+ res.setHeader('Access-Control-Allow-Headers', 'Content-Type')
292
+
293
+ if (req.method === 'OPTIONS') {
294
+ res.writeHead(200)
295
+ res.end()
296
+ return
297
+ }
298
+
299
+ if (req.url === '/api/projects') {
300
+ discoverProjects().then(projects => {
301
+ res.setHeader('Content-Type', 'application/json')
302
+ res.writeHead(200)
303
+ res.end(JSON.stringify(projects))
304
+ }).catch(err => {
305
+ res.writeHead(500)
306
+ res.end(JSON.stringify({ error: err.message }))
307
+ })
308
+ return
309
+ }
310
+
311
+ // Detail API
312
+ const detailMatch = req.url?.match(/^\/api\/projects\/(.+)\/detail(\?|$)/)
313
+ if (detailMatch) {
314
+ const projectPath = decodeURIComponent(detailMatch[1])
315
+ const url = new URL(req.url, `http://${req.headers.host}`)
316
+ const type = url.searchParams.get('type')
317
+ let data
318
+ try {
319
+ if (type === 'git') data = parseGitDetail(projectPath)
320
+ else if (type === 'tech') data = parseTechStackDetail(projectPath)
321
+ else if (type === 'docs') data = parseSillyspecDocsTree(projectPath).groups
322
+ else { res.writeHead(400); res.end(JSON.stringify({ error: 'Invalid type' })); return }
323
+ res.setHeader('Content-Type', 'application/json')
324
+ res.writeHead(200)
325
+ res.end(JSON.stringify(data))
326
+ } catch (err) {
327
+ res.writeHead(500)
328
+ res.end(JSON.stringify({ error: err.message }))
329
+ }
330
+ return
331
+ }
332
+
333
+ // Overview API
334
+ if (req.url?.startsWith('/api/projects/') && req.url.endsWith('/overview')) {
335
+ const parts = req.url.replace('/api/projects/', '').replace('/overview', '').split('/')
336
+ const projectPath = decodeURIComponent(parts.join('/'))
337
+ try {
338
+ const overview = parseProjectOverview(projectPath)
339
+ res.setHeader('Content-Type', 'application/json')
340
+ res.writeHead(200)
341
+ res.end(JSON.stringify(overview))
342
+ } catch (err) {
343
+ res.writeHead(500)
344
+ res.end(JSON.stringify({ error: err.message }))
345
+ }
346
+ return
347
+ }
348
+
349
+ if (req.url?.startsWith('/api/project/')) {
350
+ const projectName = decodeURIComponent(req.url.split('/').pop())
351
+ discoverProjects().then(projects => {
352
+ const project = projects.find(p => p.name === projectName)
353
+ if (project) {
354
+ const state = parseProjectState(project.path)
355
+ res.setHeader('Content-Type', 'application/json')
356
+ res.writeHead(200)
357
+ res.end(JSON.stringify({ ...project, state }))
358
+ } else {
359
+ res.writeHead(404)
360
+ res.end(JSON.stringify({ error: 'Project not found' }))
361
+ }
362
+ }).catch(err => {
363
+ res.writeHead(500)
364
+ res.end(JSON.stringify({ error: err.message }))
365
+ })
366
+ return
367
+ }
368
+
369
+ // Docs API
370
+ if (req.url?.startsWith('/api/docs/content')) {
371
+ const url = new URL(req.url, `http://${req.headers.host}`)
372
+ const filePath = url.searchParams.get('path')
373
+ if (!filePath) {
374
+ res.writeHead(400)
375
+ res.end(JSON.stringify({ error: 'Missing path parameter' }))
376
+ return
377
+ }
378
+ // Security: only allow reading viewable text documents under a .sillyspec tree.
379
+ const normalizedPath = resolve(filePath)
380
+ if (!isSillyspecPath(normalizedPath) || !isViewableDocPath(normalizedPath)) {
381
+ res.writeHead(403)
382
+ res.end(JSON.stringify({ error: 'Access denied' }))
383
+ return
384
+ }
385
+ if (existsSync(normalizedPath)) {
386
+ try {
387
+ const content = readFileSync(normalizedPath, 'utf-8')
388
+ res.setHeader('Content-Type', 'text/plain; charset=utf-8')
389
+ res.writeHead(200)
390
+ res.end(content)
391
+ } catch (err) {
392
+ res.writeHead(500)
393
+ res.end(JSON.stringify({ error: err.message }))
394
+ }
395
+ } else {
396
+ res.writeHead(404)
397
+ res.end(JSON.stringify({ error: 'File not found' }))
398
+ }
399
+ return
400
+ }
401
+
402
+ if (req.url?.startsWith('/api/docs')) {
403
+ const url = new URL(req.url, `http://${req.headers.host}`)
404
+ const projectPath = url.searchParams.get('project')
405
+ if (!projectPath) {
406
+ res.writeHead(400)
407
+ res.end(JSON.stringify({ error: 'Missing project parameter' }))
408
+ return
409
+ }
410
+ const docs = parseSillyspecDocsTree(projectPath)
411
+ res.setHeader('Content-Type', 'application/json')
412
+ res.writeHead(200)
413
+ res.end(JSON.stringify(docs))
414
+ return
415
+ }
416
+
417
+ // Serve static files (dist/)
418
+ const distDir = join(__dirname, '../dist')
419
+ const indexPath = join(distDir, 'index.html')
420
+ if (existsSync(distDir)) {
421
+ const url = new URL(req.url || '/', `http://${req.headers.host}`)
422
+ const requestPath = decodeURIComponent(url.pathname)
423
+ const filePath = resolve(distDir, requestPath === '/' ? 'index.html' : `.${requestPath}`)
424
+ if (!isInside(distDir, filePath)) {
425
+ res.writeHead(403)
426
+ res.end('Access denied')
427
+ return
428
+ }
429
+ if (existsSync(filePath)) {
430
+ const ext = filePath.split('.').pop()
431
+ const mimeTypes = { html: 'text/html', js: 'application/javascript', css: 'text/css', svg: 'image/svg+xml', png: 'image/png', jpg: 'image/jpeg' }
432
+ res.setHeader('Content-Type', mimeTypes[ext] || 'application/octet-stream')
433
+ res.writeHead(200)
434
+ res.end(readFileSync(filePath))
435
+ return
436
+ }
437
+ if (existsSync(indexPath)) {
438
+ res.setHeader('Content-Type', 'text/html')
439
+ res.writeHead(200)
440
+ res.end(readFileSync(indexPath, 'utf8'))
441
+ return
442
+ }
443
+ }
444
+
445
+ res.writeHead(404)
446
+ res.end('Not found')
447
+ })
448
+
449
+ wss = new WebSocketServer({ server })
450
+
451
+ wss.on('error', (err) => {
452
+ console.error('WebSocket server error:', err)
453
+ })
454
+
455
+ wss.on('connection', (ws, req) => {
456
+ if (!isLocalOrigin(req.headers.origin)) {
457
+ ws.close(1008, 'Forbidden origin')
458
+ return
459
+ }
460
+ console.log('WebSocket client connected')
461
+
462
+ // Send initial projects list
463
+ discoverProjects().then(projects => {
464
+ const projectsWithState = projects.map(p => ({
465
+ ...p,
466
+ state: parseProjectState(p.path),
467
+ overview: parseProjectOverview(p.path)
468
+ }))
469
+
470
+ ws.send(JSON.stringify({
471
+ type: 'projects:init',
472
+ data: projectsWithState
473
+ }))
474
+
475
+ // Start watching progress files for all discovered projects
476
+ for (const p of projectsWithState) {
477
+ startProgressWatch(p.path)
478
+ }
479
+
480
+ // Send current scan paths
481
+ ws.send(JSON.stringify({
482
+ type: 'scan:paths',
483
+ data: getCustomScanPaths()
484
+ }))
485
+ }).catch(err => {
486
+ console.error('Error sending initial projects:', err)
487
+ })
488
+
489
+ ws.on('message', (message) => {
490
+ try {
491
+ const data = JSON.parse(message)
492
+
493
+ switch (data.type) {
494
+ case 'cli:execute':
495
+ handleCliExecute(ws, data.data)
496
+ break
497
+ case 'cli:kill':
498
+ const kill = activeProcesses.get(data.data.projectName)
499
+ if (kill) {
500
+ kill()
501
+ activeProcesses.delete(data.data.projectName)
502
+ broadcast({ type: 'cli:killed', data: { projectName: data.data.projectName } })
503
+ }
504
+ break
505
+ case 'scan:add-path':
506
+ if (data.data?.path) {
507
+ addCustomScanPath(data.data.path)
508
+ broadcast({ type: 'scan:paths', data: getCustomScanPaths() })
509
+ // Resend projects after scan
510
+ discoverProjects().then(projects => {
511
+ broadcast({
512
+ type: 'projects:updated',
513
+ data: projects.map(p => ({
514
+ ...p,
515
+ state: parseProjectState(p.path),
516
+ overview: parseProjectOverview(p.path)
517
+ }))
518
+ })
519
+ })
520
+ }
521
+ break
522
+ case 'scan:remove-path':
523
+ if (data.data?.path) {
524
+ removeCustomScanPath(data.data.path)
525
+ ws.send(JSON.stringify({ type: 'scan:paths', data: getCustomScanPaths() }))
526
+ }
527
+ break
528
+ case 'scan:get-paths':
529
+ ws.send(JSON.stringify({ type: 'scan:paths', data: getCustomScanPaths() }))
530
+ break
531
+ case 'docs:get':
532
+ if (data.data?.projectPath) {
533
+ const docs = parseSillyspecDocsTree(data.data.projectPath)
534
+ ws.send(JSON.stringify({ type: 'docs:tree', data: docs }))
535
+ }
536
+ break
537
+ default:
538
+ console.log('Unknown message type:', data.type)
539
+ }
540
+ } catch (err) {
541
+ console.error('Error handling message:', err)
542
+ }
543
+ })
544
+
545
+ ws.on('close', () => {
546
+ console.log('WebSocket client disconnected')
547
+ // Decrement progress watchers — we track which projects this client was watching
548
+ // via the initial projects:init. For simplicity, stop all that we started.
549
+ // (Other clients will re-start via their connection if still active)
550
+ for (const [path] of progressWatchers) {
551
+ stopProgressWatch(path)
552
+ }
553
+ })
554
+
555
+ ws.on('error', (err) => {
556
+ console.error('WebSocket error:', err)
557
+ })
558
+ })
559
+
560
+ try {
561
+ startWatcher((projects) => {
562
+ broadcast({ type: 'projects:updated', data: projects })
563
+ })
564
+ } catch (err) {
565
+ console.error('Failed to start file watcher:', err)
566
+ }
567
+
568
+ server.listen(port, '127.0.0.1', () => {
569
+ console.log(`Dashboard server running on http://127.0.0.1:${port}`)
570
+ if (openBrowser) {
571
+ open(`http://127.0.0.1:${port}`)
572
+ }
573
+ })
574
+
575
+ const shutdown = () => {
576
+ stopWatcher()
577
+ activeProcesses.forEach(kill => kill())
578
+ activeProcesses.clear()
579
+ server.close()
580
+ }
581
+
582
+ process.on('SIGTERM', shutdown)
583
+ process.on('SIGINT', shutdown)
584
+
585
+ return server
586
+ }
587
+
588
+ export { startServer, broadcast, discoverProjects }