sillyspec 3.20.2 → 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.
- package/.claude/skills/sillyspec-archive/SKILL.md +21 -21
- package/.claude/skills/sillyspec-auto/SKILL.md +83 -83
- package/.claude/skills/sillyspec-brainstorm/SKILL.md +44 -44
- package/.claude/skills/sillyspec-commit/SKILL.md +106 -106
- package/.claude/skills/sillyspec-continue/SKILL.md +45 -45
- package/.claude/skills/sillyspec-doctor/SKILL.md +31 -31
- package/.claude/skills/sillyspec-execute/SKILL.md +30 -30
- package/.claude/skills/sillyspec-explore/SKILL.md +109 -109
- package/.claude/skills/sillyspec-knowledge/SKILL.md +269 -269
- package/.claude/skills/sillyspec-plan/SKILL.md +21 -21
- package/.claude/skills/sillyspec-propose/SKILL.md +21 -21
- package/.claude/skills/sillyspec-quick/SKILL.md +21 -21
- package/.claude/skills/sillyspec-resume/SKILL.md +68 -68
- package/.claude/skills/sillyspec-scan/SKILL.md +21 -21
- package/.claude/skills/sillyspec-state/SKILL.md +54 -54
- package/.claude/skills/sillyspec-status/SKILL.md +21 -21
- package/.claude/skills/sillyspec-verify/SKILL.md +21 -21
- package/.claude/skills/sillyspec-workspace/SKILL.md +157 -157
- package/.husky/pre-push +13 -13
- package/CLAUDE.md +18 -18
- package/README.md +198 -188
- package/SKILL.md +90 -91
- package/bin/sillyspec.js +2 -2
- package/docs/brainstorm-plan-contract.md +64 -64
- package/docs/plan-execute-contract.md +123 -123
- package/docs/platform-scan-protocol.md +298 -298
- package/docs/revision-mode.md +115 -115
- package/docs/sillyspec/file-lifecycle/known-implementation-gaps.md +99 -99
- package/docs/sillyspec/file-lifecycle/platform-workflows-sync.md +218 -218
- package/docs/sillyspec/file-lifecycle/stage-artifacts.md +167 -167
- package/docs/sillyspec/file-lifecycle/storage-and-state.md +148 -148
- package/docs/sillyspec/file-lifecycle/worktree-and-guard.md +211 -193
- package/docs/sillyspec/file-lifecycle.md +125 -125
- package/docs/workflow-contract-regression.md +106 -106
- package/docs/worktree-isolation.md +252 -252
- package/package.json +40 -40
- package/packages/dashboard/dist/assets/index-Bq_Z2hne.js +7446 -7446
- package/packages/dashboard/dist/assets/index-O2W5RV4z.css +1 -1
- package/packages/dashboard/dist/index.html +16 -16
- package/packages/dashboard/index.html +15 -15
- package/packages/dashboard/package-lock.json +2384 -2384
- package/packages/dashboard/package.json +25 -25
- package/packages/dashboard/server/executor.js +86 -86
- package/packages/dashboard/server/index.js +588 -588
- package/packages/dashboard/server/parser.js +526 -526
- package/packages/dashboard/server/watcher.js +344 -344
- package/packages/dashboard/src/App.vue +558 -558
- package/packages/dashboard/src/components/ActionBar.vue +93 -93
- package/packages/dashboard/src/components/CommandPalette.vue +96 -96
- package/packages/dashboard/src/components/DetailPanel.vue +137 -137
- package/packages/dashboard/src/components/LogStream.vue +65 -65
- package/packages/dashboard/src/components/PipelineStage.vue +95 -95
- package/packages/dashboard/src/components/PipelineView.vue +156 -156
- package/packages/dashboard/src/components/ProjectList.vue +210 -210
- package/packages/dashboard/src/components/StageBadge.vue +67 -67
- package/packages/dashboard/src/components/StepCard.vue +94 -94
- package/packages/dashboard/src/components/detail/DocsDetail.vue +48 -48
- package/packages/dashboard/src/components/detail/GitDetail.vue +61 -61
- package/packages/dashboard/src/components/detail/TechDetail.vue +43 -43
- package/packages/dashboard/src/composables/useDashboard.js +170 -170
- package/packages/dashboard/src/composables/useKeyboard.js +119 -119
- package/packages/dashboard/src/composables/useWebSocket.js +129 -129
- package/packages/dashboard/src/main.js +8 -8
- package/packages/dashboard/src/style.css +132 -132
- package/packages/dashboard/vite.config.js +18 -18
- package/src/brainstorm-postcheck.js +158 -158
- package/src/change-list.js +52 -52
- package/src/change-risk-profile.js +352 -352
- package/src/classify-change.js +73 -73
- package/src/constants.js +70 -70
- package/src/contract-matrix.js +278 -278
- package/src/db.js +201 -201
- package/src/endpoint-extractor.js +315 -315
- package/src/hooks/claude-pre-tool-use.cjs +125 -125
- package/src/hooks/worktree-guard.js +653 -653
- package/src/index.js +922 -900
- package/src/init.js +431 -431
- package/src/knowledge-match.js +130 -130
- package/src/migrate.js +117 -117
- package/src/modules.js +482 -482
- package/src/progress.js +1734 -1734
- package/src/run.js +3465 -3358
- package/src/scan-postcheck.js +387 -383
- package/src/setup.js +398 -398
- package/src/stage-contract.js +700 -700
- package/src/stages/archive.js +160 -160
- package/src/stages/brainstorm-auto.js +229 -229
- package/src/stages/brainstorm.js +645 -645
- package/src/stages/doctor.js +365 -365
- package/src/stages/execute.js +625 -625
- package/src/stages/explore.js +34 -34
- package/src/stages/index.js +29 -29
- package/src/stages/knowledge.js +498 -498
- package/src/stages/plan-postcheck.js +511 -513
- package/src/stages/plan.js +582 -582
- package/src/stages/propose.js +174 -174
- package/src/stages/quick.js +82 -82
- package/src/stages/scan.js +558 -558
- package/src/stages/status.js +65 -65
- package/src/stages/verify.js +322 -322
- package/src/sync.js +497 -497
- package/src/task-review.js +346 -346
- package/src/workflow.js +785 -785
- package/src/worktree-apply.js +549 -549
- package/src/worktree-deps.js +185 -0
- package/src/worktree.js +982 -932
- package/templates/workflows/archive-impact.yaml +79 -79
- package/templates/workflows/scan-docs.yaml +132 -132
- package/test/brainstorm-plan-contract.test.mjs +273 -273
- package/test/check-syntax.mjs +26 -26
- package/test/contract-artifacts.test.mjs +323 -323
- package/test/decision-supersede.test.mjs +277 -277
- package/test/knowledge-match.test.mjs +231 -231
- package/test/plan-execute-contract.test.mjs +330 -330
- package/test/plan-optimization.test.mjs +572 -572
- package/test/platform-artifacts.test.mjs +166 -166
- package/test/platform-failure-samples.test.mjs +199 -199
- package/test/platform-recovery-chain.test.mjs +167 -167
- package/test/platform-recovery.test.mjs +136 -136
- package/test/platform-scan-p0.test.mjs +168 -168
- package/test/revision-v1.test.mjs +1145 -1145
- package/test/run-scan-project-parse.test.mjs +200 -200
- package/test/run-tests.mjs +48 -48
- package/test/scan-knowledge.test.mjs +175 -175
- package/test/scan-paths.test.mjs +68 -68
- package/test/scan-postcheck.test.mjs +197 -197
- package/test/spec-dir.test.mjs +206 -206
- package/test/stage-contract.test.mjs +299 -299
- package/test/stage-definitions.test.mjs +39 -39
- package/test/wait-gates.test.mjs +496 -496
- package/test/worktree-deps-provision.test.mjs +148 -0
- package/test/worktree-guard.test.mjs +71 -71
- package/test/worktree-native-overlay.test.mjs +188 -188
|
@@ -1,588 +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, 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 }
|
|
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 }
|