sillyspec 3.8.7 → 3.9.1
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 +17 -0
- package/.claude/skills/sillyspec-auto/SKILL.md +78 -0
- package/.claude/skills/sillyspec-brainstorm/SKILL.md +17 -0
- package/{templates/commit.md → .claude/skills/sillyspec-commit/SKILL.md} +32 -47
- package/.claude/skills/sillyspec-continue/SKILL.md +45 -0
- package/.claude/skills/sillyspec-doctor/SKILL.md +27 -0
- package/.claude/skills/sillyspec-execute/SKILL.md +17 -0
- package/.claude/skills/sillyspec-explore/SKILL.md +96 -0
- package/.claude/skills/sillyspec-export/SKILL.md +53 -0
- package/.claude/skills/sillyspec-init/SKILL.md +170 -0
- package/.claude/skills/sillyspec-plan/SKILL.md +52 -0
- package/.claude/skills/sillyspec-propose/SKILL.md +17 -0
- package/.claude/skills/sillyspec-quick/SKILL.md +17 -0
- package/.claude/skills/sillyspec-resume/SKILL.md +111 -0
- package/.claude/skills/sillyspec-scan/SKILL.md +17 -0
- package/.claude/skills/sillyspec-state/SKILL.md +54 -0
- package/.claude/skills/sillyspec-status/SKILL.md +17 -0
- package/.claude/skills/sillyspec-verify/SKILL.md +17 -0
- package/.claude/skills/sillyspec-workspace/SKILL.md +149 -0
- package/.sillyspec/changes/archive/2026-04-08-derive-state/design.md +97 -0
- package/.sillyspec/changes/archive/2026-04-08-derive-state/plan.md +51 -0
- package/.sillyspec/changes/archive/2026-04-08-derive-state/proposal.md +29 -0
- package/.sillyspec/changes/archive/2026-04-08-derive-state/requirements.md +34 -0
- package/.sillyspec/changes/archive/2026-04-08-derive-state/tasks.md +13 -0
- package/.sillyspec/changes/archive/2026-04-08-derive-state/verify-result.md +43 -0
- package/.sillyspec/changes/auto-mode/design.md +50 -0
- package/.sillyspec/changes/auto-mode/proposal.md +19 -0
- package/.sillyspec/changes/auto-mode/requirements.md +21 -0
- package/.sillyspec/changes/auto-mode/tasks.md +7 -0
- package/.sillyspec/changes/brainstorm-archive/2026-04-05-unified-docs-design.md +199 -0
- package/.sillyspec/changes/dashboard/design.md.braindraft +206 -0
- package/.sillyspec/changes/run-command-design/design.md +1230 -0
- package/.sillyspec/changes/unified-docs-design/design.md +199 -0
- package/.sillyspec/docs/sillyspec/scan/.gitkeep +0 -0
- package/.sillyspec/knowledge/INDEX.md +8 -0
- package/.sillyspec/knowledge/uncategorized.md +3 -0
- package/.sillyspec/projects/sillyspec.yaml +3 -0
- package/README.md +12 -5
- package/package.json +9 -11
- package/packages/dashboard/dist/assets/index-CntACGUN.css +1 -0
- package/packages/dashboard/dist/assets/index-RsLVPAy7.js +7446 -0
- package/packages/dashboard/dist/index.html +3 -2
- package/packages/dashboard/package-lock.json +226 -6
- package/packages/dashboard/package.json +8 -5
- package/packages/dashboard/public/logo.jpg +0 -0
- package/packages/dashboard/server/executor.js +1 -1
- package/packages/dashboard/server/index.js +336 -113
- package/packages/dashboard/server/parser.js +333 -29
- package/packages/dashboard/server/watcher.js +203 -131
- package/packages/dashboard/src/App.vue +187 -11
- package/packages/dashboard/src/components/ActionBar.vue +26 -42
- package/packages/dashboard/src/components/CommandPalette.vue +40 -65
- package/packages/dashboard/src/components/DetailPanel.vue +68 -53
- package/packages/dashboard/src/components/DocPreview.vue +160 -0
- package/packages/dashboard/src/components/DocTree.vue +58 -0
- package/packages/dashboard/src/components/LogStream.vue +13 -33
- package/packages/dashboard/src/components/PipelineStage.vue +8 -8
- package/packages/dashboard/src/components/PipelineView.vue +80 -45
- package/packages/dashboard/src/components/ProjectList.vue +103 -45
- package/packages/dashboard/src/components/ProjectOverview.vue +178 -0
- package/packages/dashboard/src/components/StageBadge.vue +13 -13
- package/packages/dashboard/src/components/StepCard.vue +15 -15
- package/packages/dashboard/src/components/detail/DocsDetail.vue +48 -0
- package/packages/dashboard/src/components/detail/GitDetail.vue +61 -0
- package/packages/dashboard/src/components/detail/TechDetail.vue +43 -0
- package/packages/dashboard/src/composables/useDashboard.js +20 -6
- package/packages/dashboard/src/composables/useKeyboard.js +6 -4
- package/packages/dashboard/src/main.js +4 -1
- package/packages/dashboard/src/style.css +17 -17
- package/src/index.js +136 -14
- package/src/init.js +83 -228
- package/src/migrate.js +117 -0
- package/src/progress.js +459 -0
- package/src/run.js +624 -0
- package/src/setup.js +2 -72
- package/src/stages/archive.js +54 -0
- package/src/stages/brainstorm.js +239 -0
- package/src/stages/doctor.js +303 -0
- package/src/stages/execute.js +262 -0
- package/src/stages/index.js +26 -0
- package/src/stages/plan.js +282 -0
- package/src/stages/propose.js +115 -0
- package/src/stages/quick.js +64 -0
- package/src/stages/scan.js +141 -0
- package/src/stages/status.js +65 -0
- package/src/stages/verify.js +135 -0
- package/docs/.vitepress/config.mts +0 -45
- package/docs/.vitepress/dist/404.html +0 -25
- package/docs/.vitepress/dist/assets/app.YytxICdd.js +0 -1
- package/docs/.vitepress/dist/assets/chunks/framework.Czhw_PXq.js +0 -19
- package/docs/.vitepress/dist/assets/chunks/theme.DusTRZQk.js +0 -1
- package/docs/.vitepress/dist/assets/index.md.C3VCvtQA.js +0 -1
- package/docs/.vitepress/dist/assets/index.md.C3VCvtQA.lean.js +0 -1
- package/docs/.vitepress/dist/assets/inter-italic-cyrillic-ext.r48I6akx.woff2 +0 -0
- package/docs/.vitepress/dist/assets/inter-italic-cyrillic.By2_1cv3.woff2 +0 -0
- package/docs/.vitepress/dist/assets/inter-italic-greek-ext.1u6EdAuj.woff2 +0 -0
- package/docs/.vitepress/dist/assets/inter-italic-greek.DJ8dCoTZ.woff2 +0 -0
- package/docs/.vitepress/dist/assets/inter-italic-latin-ext.CN1xVJS-.woff2 +0 -0
- package/docs/.vitepress/dist/assets/inter-italic-latin.C2AdPX0b.woff2 +0 -0
- package/docs/.vitepress/dist/assets/inter-italic-vietnamese.BSbpV94h.woff2 +0 -0
- package/docs/.vitepress/dist/assets/inter-roman-cyrillic-ext.BBPuwvHQ.woff2 +0 -0
- package/docs/.vitepress/dist/assets/inter-roman-cyrillic.C5lxZ8CY.woff2 +0 -0
- package/docs/.vitepress/dist/assets/inter-roman-greek-ext.CqjqNYQ-.woff2 +0 -0
- package/docs/.vitepress/dist/assets/inter-roman-greek.BBVDIX6e.woff2 +0 -0
- package/docs/.vitepress/dist/assets/inter-roman-latin-ext.4ZJIpNVo.woff2 +0 -0
- package/docs/.vitepress/dist/assets/inter-roman-latin.Di8DUHzh.woff2 +0 -0
- package/docs/.vitepress/dist/assets/inter-roman-vietnamese.BjW4sHH5.woff2 +0 -0
- package/docs/.vitepress/dist/assets/sillyspec_commands.md.CXFFsj08.js +0 -15
- package/docs/.vitepress/dist/assets/sillyspec_commands.md.CXFFsj08.lean.js +0 -1
- package/docs/.vitepress/dist/assets/sillyspec_dashboard.md.BuPXHqjX.js +0 -4
- package/docs/.vitepress/dist/assets/sillyspec_dashboard.md.BuPXHqjX.lean.js +0 -1
- package/docs/.vitepress/dist/assets/sillyspec_file-io.md.Cz3x7llx.js +0 -1
- package/docs/.vitepress/dist/assets/sillyspec_file-io.md.Cz3x7llx.lean.js +0 -1
- package/docs/.vitepress/dist/assets/sillyspec_getting-started.md.ClcvV8k3.js +0 -4
- package/docs/.vitepress/dist/assets/sillyspec_getting-started.md.ClcvV8k3.lean.js +0 -1
- package/docs/.vitepress/dist/assets/sillyspec_install.md.CKuR2tiT.js +0 -5
- package/docs/.vitepress/dist/assets/sillyspec_install.md.CKuR2tiT.lean.js +0 -1
- package/docs/.vitepress/dist/assets/sillyspec_lifecycle.md.DY293cR1.js +0 -28
- package/docs/.vitepress/dist/assets/sillyspec_lifecycle.md.DY293cR1.lean.js +0 -1
- package/docs/.vitepress/dist/assets/sillyspec_structure.md.sVYS4zPs.js +0 -30
- package/docs/.vitepress/dist/assets/sillyspec_structure.md.sVYS4zPs.lean.js +0 -1
- package/docs/.vitepress/dist/assets/style.DFTx90Kk.css +0 -1
- package/docs/.vitepress/dist/hashmap.json +0 -1
- package/docs/.vitepress/dist/index.html +0 -28
- package/docs/.vitepress/dist/sillyspec/commands.html +0 -42
- package/docs/.vitepress/dist/sillyspec/dashboard.html +0 -31
- package/docs/.vitepress/dist/sillyspec/file-io.html +0 -28
- package/docs/.vitepress/dist/sillyspec/getting-started.html +0 -31
- package/docs/.vitepress/dist/sillyspec/install.html +0 -32
- package/docs/.vitepress/dist/sillyspec/lifecycle.html +0 -55
- package/docs/.vitepress/dist/sillyspec/structure.html +0 -57
- package/docs/.vitepress/dist/vp-icons.css +0 -1
- package/docs/index.md +0 -34
- package/docs/sillyspec/commands.md +0 -218
- package/docs/sillyspec/dashboard.md +0 -51
- package/docs/sillyspec/file-io.md +0 -34
- package/docs/sillyspec/getting-started.md +0 -61
- package/docs/sillyspec/install.md +0 -51
- package/docs/sillyspec/lifecycle.md +0 -146
- package/docs/sillyspec/structure.md +0 -62
- package/packages/dashboard/dist/assets/index-Bh-GPjKY.css +0 -1
- package/packages/dashboard/dist/assets/index-CrCn5Gg6.js +0 -17
- package/templates/archive.md +0 -120
- package/templates/brainstorm.md +0 -170
- package/templates/continue.md +0 -32
- package/templates/execute.md +0 -304
- package/templates/explore.md +0 -59
- package/templates/export.md +0 -21
- package/templates/init.md +0 -61
- package/templates/plan.md +0 -146
- package/templates/quick.md +0 -135
- package/templates/scan-quick.md +0 -49
- package/templates/scan.md +0 -156
- package/templates/skills/playwright-e2e/SKILL.md +0 -340
- package/templates/status.md +0 -75
- package/templates/verify.md +0 -236
- package/templates/workspace-sync.md +0 -99
- package/templates/workspace.md +0 -70
- /package/.sillyspec/{specs → changes/brainstorm-archive}/2026-04-05-dashboard-design.md +0 -0
- /package/{docs/.vitepress/dist/logo.jpg → logo.jpg} +0 -0
- /package/{docs/.vitepress → packages/dashboard}/dist/favicon.jpg +0 -0
- /package/{docs/public → packages/dashboard/dist}/logo.jpg +0 -0
- /package/{docs → packages/dashboard}/public/favicon.jpg +0 -0
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
import { createServer } from 'http'
|
|
2
2
|
import { WebSocketServer } from 'ws'
|
|
3
|
-
import { existsSync, readFileSync } from 'fs'
|
|
4
|
-
import { join, dirname } from 'path'
|
|
3
|
+
import { existsSync, readFileSync, readdirSync, realpathSync, watch } from 'fs'
|
|
4
|
+
import { join, dirname, basename, sep, resolve, relative } from 'path'
|
|
5
5
|
import { fileURLToPath } from 'url'
|
|
6
|
+
import { homedir } from 'os'
|
|
6
7
|
import open from 'open'
|
|
7
|
-
import { parseProjectState } from './parser.js'
|
|
8
|
-
import { startWatcher, stopWatcher } from './watcher.js'
|
|
8
|
+
import { parseProjectState, parseDocsTree, parseProjectOverview, parseGitDetail, parseTechStackDetail, parseDocsList } from './parser.js'
|
|
9
|
+
import { startWatcher, stopWatcher, addCustomScanPath, removeCustomScanPath, getCustomScanPaths, customScanPaths } from './watcher.js'
|
|
9
10
|
import { executeCommand } from './executor.js'
|
|
10
11
|
|
|
11
12
|
const __dirname = dirname(fileURLToPath(import.meta.url))
|
|
@@ -13,6 +14,69 @@ const __dirname = dirname(fileURLToPath(import.meta.url))
|
|
|
13
14
|
// WebSocket clients and active processes
|
|
14
15
|
let wss = null
|
|
15
16
|
const activeProcesses = new Map()
|
|
17
|
+
const allowedStages = new Set(['brainstorm', 'plan', 'execute', 'verify', 'scan', 'quick', '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
|
+
const progressFile = join(projectPath, '.sillyspec', '.runtime', 'progress.json')
|
|
52
|
+
if (!existsSync(progressFile)) return
|
|
53
|
+
|
|
54
|
+
let timer = null
|
|
55
|
+
try {
|
|
56
|
+
const watcher = watch(progressFile, (eventType) => {
|
|
57
|
+
if (timer) clearTimeout(timer)
|
|
58
|
+
timer = setTimeout(() => {
|
|
59
|
+
timer = null
|
|
60
|
+
try {
|
|
61
|
+
const state = parseProjectState(projectPath)
|
|
62
|
+
broadcast({ type: 'project:update', data: { path: projectPath, state } })
|
|
63
|
+
} catch {}
|
|
64
|
+
}, 200)
|
|
65
|
+
})
|
|
66
|
+
progressWatchers.set(projectPath, { watcher, timer, refCount: 1 })
|
|
67
|
+
} catch {}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function stopProgressWatch(projectPath) {
|
|
71
|
+
const entry = progressWatchers.get(projectPath)
|
|
72
|
+
if (!entry) return
|
|
73
|
+
entry.refCount--
|
|
74
|
+
if (entry.refCount <= 0) {
|
|
75
|
+
entry.watcher.close()
|
|
76
|
+
if (entry.timer) clearTimeout(entry.timer)
|
|
77
|
+
progressWatchers.delete(projectPath)
|
|
78
|
+
}
|
|
79
|
+
}
|
|
16
80
|
|
|
17
81
|
/**
|
|
18
82
|
* Broadcast message to all connected WebSocket clients
|
|
@@ -28,83 +92,119 @@ function broadcast(data) {
|
|
|
28
92
|
})
|
|
29
93
|
}
|
|
30
94
|
|
|
95
|
+
function isInside(parent, child) {
|
|
96
|
+
const rel = relative(resolve(parent), resolve(child))
|
|
97
|
+
return rel === '' || (!!rel && !rel.startsWith('..') && !rel.includes(`..${sep}`))
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function isSillyspecDocsPath(filePath) {
|
|
101
|
+
const parts = resolve(filePath).split(/[\\/]+/)
|
|
102
|
+
for (let i = 0; i < parts.length - 1; i++) {
|
|
103
|
+
if (parts[i] === '.sillyspec' && parts[i + 1] === 'docs') return true
|
|
104
|
+
}
|
|
105
|
+
return false
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function isLocalOrigin(origin) {
|
|
109
|
+
if (!origin) return true
|
|
110
|
+
try {
|
|
111
|
+
const { hostname } = new URL(origin)
|
|
112
|
+
return hostname === 'localhost' || hostname === '127.0.0.1' || hostname === '::1'
|
|
113
|
+
} catch {
|
|
114
|
+
return false
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// --- Shared scan logic (aligned with watcher.js) ---
|
|
119
|
+
|
|
120
|
+
const excludeDirs = new Set([
|
|
121
|
+
'.Trash', '.cache', '.npm', '.local', '.vscode', 'Library',
|
|
122
|
+
'.git', 'node_modules', '.Trash-*', '.DS_Store', '.config',
|
|
123
|
+
'.cocoapods', '.gem', '.rvm', '.nvm', '.asdf', '.brew',
|
|
124
|
+
'AppData', 'Application Data', '.cargo', '.rustup',
|
|
125
|
+
'.nuget', '.android', '.gradle', '.m2', '.vscode-server'
|
|
126
|
+
])
|
|
127
|
+
|
|
128
|
+
function shouldExclude(name, cwd) {
|
|
129
|
+
if (excludeDirs.has(name)) return true
|
|
130
|
+
for (const pattern of excludeDirs) {
|
|
131
|
+
if (pattern.includes('*')) {
|
|
132
|
+
const regex = new RegExp('^' + pattern.replace(/\*/g, '.*') + '$')
|
|
133
|
+
if (regex.test(name)) return true
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
const cwdName = cwd.split(sep).pop() || cwd.split('/').pop() || ''
|
|
137
|
+
if (name.startsWith('.') && name !== cwdName) return true
|
|
138
|
+
return false
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function scanDirectory(baseDir, seen, maxDepth = 2, currentDepth = 0) {
|
|
142
|
+
const cwd = process.cwd()
|
|
143
|
+
const projects = []
|
|
144
|
+
try {
|
|
145
|
+
const entries = readdirSync(baseDir, { withFileTypes: true })
|
|
146
|
+
for (const entry of entries) {
|
|
147
|
+
if (!entry.isDirectory()) continue
|
|
148
|
+
if (shouldExclude(entry.name, cwd)) continue
|
|
149
|
+
const dirPath = join(baseDir, entry.name)
|
|
150
|
+
let realPath
|
|
151
|
+
try { realPath = realpathSync(dirPath) } catch { realPath = dirPath }
|
|
152
|
+
const normalizedPath = realPath.toLowerCase()
|
|
153
|
+
if (seen.has(normalizedPath)) continue
|
|
154
|
+
seen.add(normalizedPath)
|
|
155
|
+
if (existsSync(join(dirPath, '.sillyspec'))) {
|
|
156
|
+
projects.push({ name: entry.name, path: dirPath })
|
|
157
|
+
}
|
|
158
|
+
if (currentDepth < maxDepth) {
|
|
159
|
+
projects.push(...scanDirectory(dirPath, seen, maxDepth, currentDepth + 1))
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
} catch (err) {}
|
|
163
|
+
return projects
|
|
164
|
+
}
|
|
165
|
+
|
|
31
166
|
/**
|
|
32
167
|
* Discover all SillySpec projects
|
|
33
168
|
* @returns {Promise<Array>} Array of project objects
|
|
34
169
|
*/
|
|
35
170
|
async function discoverProjects() {
|
|
36
|
-
const { homedir } = await import('os')
|
|
37
171
|
const home = homedir()
|
|
38
172
|
const cwd = process.cwd()
|
|
39
173
|
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
])
|
|
46
|
-
|
|
47
|
-
// Helper to check if directory should be excluded
|
|
48
|
-
const shouldExclude = (name) => {
|
|
49
|
-
// Check exact matches
|
|
50
|
-
if (excludeDirs.has(name)) return true
|
|
51
|
-
// Check wildcard patterns (like .Trash-*)
|
|
52
|
-
for (const pattern of excludeDirs) {
|
|
53
|
-
if (pattern.includes('*')) {
|
|
54
|
-
const regex = new RegExp('^' + pattern.replace(/\*/g, '.*') + '$')
|
|
55
|
-
if (regex.test(name)) return true
|
|
56
|
-
}
|
|
57
|
-
}
|
|
58
|
-
// Exclude hidden directories (starting with .) unless it's the cwd basename
|
|
59
|
-
if (name.startsWith('.') && name !== cwd.split('/').pop()) {
|
|
60
|
-
return true
|
|
61
|
-
}
|
|
62
|
-
return false
|
|
63
|
-
}
|
|
174
|
+
const scanDirs = new Set()
|
|
175
|
+
scanDirs.add(cwd)
|
|
176
|
+
scanDirs.add(dirname(cwd))
|
|
177
|
+
scanDirs.add(dirname(dirname(cwd)))
|
|
178
|
+
scanDirs.add(home)
|
|
64
179
|
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
180
|
+
const extraDirs = [
|
|
181
|
+
'Desktop', '桌面', 'Documents', '文档', 'Downloads', '下载',
|
|
182
|
+
'Projects', '项目', 'Work', '工作', 'Repos', 'Code', 'src', 'dev',
|
|
183
|
+
'workspace', '工作区'
|
|
184
|
+
]
|
|
68
185
|
|
|
69
186
|
for (const extra of extraDirs) {
|
|
70
187
|
const extraPath = join(home, extra)
|
|
71
|
-
if (existsSync(extraPath))
|
|
72
|
-
scanDirs.push(extraPath)
|
|
73
|
-
}
|
|
188
|
+
if (existsSync(extraPath)) scanDirs.add(extraPath)
|
|
74
189
|
}
|
|
75
190
|
|
|
76
|
-
const
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
for (const baseDir of scanDirs) {
|
|
80
|
-
try {
|
|
81
|
-
const { readdirSync } = await import('fs')
|
|
82
|
-
const entries = readdirSync(baseDir, { withFileTypes: true })
|
|
83
|
-
|
|
84
|
-
for (const entry of entries) {
|
|
85
|
-
if (!entry.isDirectory()) continue
|
|
86
|
-
if (shouldExclude(entry.name)) continue
|
|
87
|
-
|
|
88
|
-
const dirPath = join(baseDir, entry.name)
|
|
191
|
+
for (const customPath of customScanPaths) {
|
|
192
|
+
if (existsSync(customPath)) scanDirs.add(customPath)
|
|
193
|
+
}
|
|
89
194
|
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
if (seen.has(realPath)) continue
|
|
93
|
-
seen.add(realPath)
|
|
195
|
+
const seen = new Set()
|
|
196
|
+
const projects = []
|
|
94
197
|
|
|
95
|
-
|
|
198
|
+
// Normalize cwd for dedup
|
|
199
|
+
let cwdReal
|
|
200
|
+
try { cwdReal = realpathSync(cwd) } catch { cwdReal = cwd }
|
|
201
|
+
seen.add(cwdReal.toLowerCase())
|
|
202
|
+
if (existsSync(join(cwd, '.sillyspec'))) {
|
|
203
|
+
projects.push({ name: basename(cwd), path: cwd })
|
|
204
|
+
}
|
|
96
205
|
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
name: entry.name,
|
|
100
|
-
path: dirPath
|
|
101
|
-
})
|
|
102
|
-
}
|
|
103
|
-
}
|
|
104
|
-
} catch (err) {
|
|
105
|
-
// Skip directories we can't read
|
|
106
|
-
continue
|
|
107
|
-
}
|
|
206
|
+
for (const baseDir of scanDirs) {
|
|
207
|
+
projects.push(...scanDirectory(baseDir, seen, 2, 0))
|
|
108
208
|
}
|
|
109
209
|
|
|
110
210
|
return projects
|
|
@@ -126,7 +226,14 @@ function handleCliExecute(ws, data) {
|
|
|
126
226
|
return
|
|
127
227
|
}
|
|
128
228
|
|
|
129
|
-
|
|
229
|
+
if (!isAllowedCliCommand(command)) {
|
|
230
|
+
ws.send(JSON.stringify({
|
|
231
|
+
type: 'cli:error',
|
|
232
|
+
data: { message: `Command not allowed: ${command}` }
|
|
233
|
+
}))
|
|
234
|
+
return
|
|
235
|
+
}
|
|
236
|
+
|
|
130
237
|
discoverProjects().then(projects => {
|
|
131
238
|
const project = projects.find(p => p.name === projectName)
|
|
132
239
|
if (!project) {
|
|
@@ -137,45 +244,29 @@ function handleCliExecute(ws, data) {
|
|
|
137
244
|
return
|
|
138
245
|
}
|
|
139
246
|
|
|
140
|
-
// Kill existing process for this project if any
|
|
141
247
|
const existingKill = activeProcesses.get(projectName)
|
|
142
|
-
if (existingKill)
|
|
143
|
-
existingKill()
|
|
144
|
-
}
|
|
248
|
+
if (existingKill) existingKill()
|
|
145
249
|
|
|
146
|
-
// Execute command
|
|
147
250
|
const kill = executeCommand(
|
|
148
251
|
project.path,
|
|
149
252
|
command,
|
|
150
253
|
(output) => {
|
|
151
254
|
broadcast({
|
|
152
255
|
type: 'cli:output',
|
|
153
|
-
data: {
|
|
154
|
-
projectName,
|
|
155
|
-
output: output.data,
|
|
156
|
-
outputType: output.type
|
|
157
|
-
}
|
|
256
|
+
data: { projectName, output: output.data, outputType: output.type }
|
|
158
257
|
})
|
|
159
258
|
},
|
|
160
259
|
(result) => {
|
|
161
260
|
activeProcesses.delete(projectName)
|
|
162
261
|
broadcast({
|
|
163
262
|
type: 'cli:complete',
|
|
164
|
-
data: {
|
|
165
|
-
projectName,
|
|
166
|
-
exitCode: result.code,
|
|
167
|
-
signal: result.signal
|
|
168
|
-
}
|
|
263
|
+
data: { projectName, exitCode: result.code, signal: result.signal }
|
|
169
264
|
})
|
|
170
265
|
}
|
|
171
266
|
)
|
|
172
267
|
|
|
173
268
|
activeProcesses.set(projectName, kill)
|
|
174
|
-
|
|
175
|
-
broadcast({
|
|
176
|
-
type: 'cli:started',
|
|
177
|
-
data: { projectName, command }
|
|
178
|
-
})
|
|
269
|
+
broadcast({ type: 'cli:started', data: { projectName, command } })
|
|
179
270
|
})
|
|
180
271
|
}
|
|
181
272
|
|
|
@@ -186,8 +277,14 @@ function handleCliExecute(ws, data) {
|
|
|
186
277
|
*/
|
|
187
278
|
function startServer({ port = 3456, open: openBrowser = true } = {}) {
|
|
188
279
|
const server = createServer((req, res) => {
|
|
189
|
-
|
|
190
|
-
|
|
280
|
+
const origin = req.headers.origin
|
|
281
|
+
if (!isLocalOrigin(origin)) {
|
|
282
|
+
res.writeHead(403)
|
|
283
|
+
res.end('Forbidden')
|
|
284
|
+
return
|
|
285
|
+
}
|
|
286
|
+
res.setHeader('Access-Control-Allow-Origin', origin || `http://127.0.0.1:${port}`)
|
|
287
|
+
res.setHeader('Vary', 'Origin')
|
|
191
288
|
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS')
|
|
192
289
|
res.setHeader('Access-Control-Allow-Headers', 'Content-Type')
|
|
193
290
|
|
|
@@ -197,7 +294,6 @@ function startServer({ port = 3456, open: openBrowser = true } = {}) {
|
|
|
197
294
|
return
|
|
198
295
|
}
|
|
199
296
|
|
|
200
|
-
// API: List all projects
|
|
201
297
|
if (req.url === '/api/projects') {
|
|
202
298
|
discoverProjects().then(projects => {
|
|
203
299
|
res.setHeader('Content-Type', 'application/json')
|
|
@@ -210,7 +306,44 @@ function startServer({ port = 3456, open: openBrowser = true } = {}) {
|
|
|
210
306
|
return
|
|
211
307
|
}
|
|
212
308
|
|
|
213
|
-
// API
|
|
309
|
+
// Detail API
|
|
310
|
+
const detailMatch = req.url?.match(/^\/api\/projects\/(.+)\/detail(\?|$)/)
|
|
311
|
+
if (detailMatch) {
|
|
312
|
+
const projectPath = decodeURIComponent(detailMatch[1])
|
|
313
|
+
const url = new URL(req.url, `http://${req.headers.host}`)
|
|
314
|
+
const type = url.searchParams.get('type')
|
|
315
|
+
let data
|
|
316
|
+
try {
|
|
317
|
+
if (type === 'git') data = parseGitDetail(projectPath)
|
|
318
|
+
else if (type === 'tech') data = parseTechStackDetail(projectPath)
|
|
319
|
+
else if (type === 'docs') data = parseDocsList(projectPath)
|
|
320
|
+
else { res.writeHead(400); res.end(JSON.stringify({ error: 'Invalid type' })); return }
|
|
321
|
+
res.setHeader('Content-Type', 'application/json')
|
|
322
|
+
res.writeHead(200)
|
|
323
|
+
res.end(JSON.stringify(data))
|
|
324
|
+
} catch (err) {
|
|
325
|
+
res.writeHead(500)
|
|
326
|
+
res.end(JSON.stringify({ error: err.message }))
|
|
327
|
+
}
|
|
328
|
+
return
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
// Overview API
|
|
332
|
+
if (req.url?.startsWith('/api/projects/') && req.url.endsWith('/overview')) {
|
|
333
|
+
const parts = req.url.replace('/api/projects/', '').replace('/overview', '').split('/')
|
|
334
|
+
const projectPath = decodeURIComponent(parts.join('/'))
|
|
335
|
+
try {
|
|
336
|
+
const overview = parseProjectOverview(projectPath)
|
|
337
|
+
res.setHeader('Content-Type', 'application/json')
|
|
338
|
+
res.writeHead(200)
|
|
339
|
+
res.end(JSON.stringify(overview))
|
|
340
|
+
} catch (err) {
|
|
341
|
+
res.writeHead(500)
|
|
342
|
+
res.end(JSON.stringify({ error: err.message }))
|
|
343
|
+
}
|
|
344
|
+
return
|
|
345
|
+
}
|
|
346
|
+
|
|
214
347
|
if (req.url?.startsWith('/api/project/')) {
|
|
215
348
|
const projectName = decodeURIComponent(req.url.split('/').pop())
|
|
216
349
|
discoverProjects().then(projects => {
|
|
@@ -219,10 +352,7 @@ function startServer({ port = 3456, open: openBrowser = true } = {}) {
|
|
|
219
352
|
const state = parseProjectState(project.path)
|
|
220
353
|
res.setHeader('Content-Type', 'application/json')
|
|
221
354
|
res.writeHead(200)
|
|
222
|
-
res.end(JSON.stringify({
|
|
223
|
-
...project,
|
|
224
|
-
state
|
|
225
|
-
}))
|
|
355
|
+
res.end(JSON.stringify({ ...project, state }))
|
|
226
356
|
} else {
|
|
227
357
|
res.writeHead(404)
|
|
228
358
|
res.end(JSON.stringify({ error: 'Project not found' }))
|
|
@@ -234,12 +364,67 @@ function startServer({ port = 3456, open: openBrowser = true } = {}) {
|
|
|
234
364
|
return
|
|
235
365
|
}
|
|
236
366
|
|
|
367
|
+
// Docs API
|
|
368
|
+
if (req.url?.startsWith('/api/docs/content')) {
|
|
369
|
+
const url = new URL(req.url, `http://${req.headers.host}`)
|
|
370
|
+
const filePath = url.searchParams.get('path')
|
|
371
|
+
if (!filePath) {
|
|
372
|
+
res.writeHead(400)
|
|
373
|
+
res.end(JSON.stringify({ error: 'Missing path parameter' }))
|
|
374
|
+
return
|
|
375
|
+
}
|
|
376
|
+
// Security: only allow reading files under a .sillyspec/docs/ tree.
|
|
377
|
+
const normalizedPath = resolve(filePath)
|
|
378
|
+
if (!isSillyspecDocsPath(normalizedPath)) {
|
|
379
|
+
res.writeHead(403)
|
|
380
|
+
res.end(JSON.stringify({ error: 'Access denied' }))
|
|
381
|
+
return
|
|
382
|
+
}
|
|
383
|
+
if (existsSync(normalizedPath)) {
|
|
384
|
+
try {
|
|
385
|
+
const content = readFileSync(normalizedPath, 'utf-8')
|
|
386
|
+
res.setHeader('Content-Type', 'text/plain; charset=utf-8')
|
|
387
|
+
res.writeHead(200)
|
|
388
|
+
res.end(content)
|
|
389
|
+
} catch (err) {
|
|
390
|
+
res.writeHead(500)
|
|
391
|
+
res.end(JSON.stringify({ error: err.message }))
|
|
392
|
+
}
|
|
393
|
+
} else {
|
|
394
|
+
res.writeHead(404)
|
|
395
|
+
res.end(JSON.stringify({ error: 'File not found' }))
|
|
396
|
+
}
|
|
397
|
+
return
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
if (req.url?.startsWith('/api/docs')) {
|
|
401
|
+
const url = new URL(req.url, `http://${req.headers.host}`)
|
|
402
|
+
const projectPath = url.searchParams.get('project')
|
|
403
|
+
if (!projectPath) {
|
|
404
|
+
res.writeHead(400)
|
|
405
|
+
res.end(JSON.stringify({ error: 'Missing project parameter' }))
|
|
406
|
+
return
|
|
407
|
+
}
|
|
408
|
+
const docs = parseDocsTree(projectPath)
|
|
409
|
+
res.setHeader('Content-Type', 'application/json')
|
|
410
|
+
res.writeHead(200)
|
|
411
|
+
res.end(JSON.stringify(docs))
|
|
412
|
+
return
|
|
413
|
+
}
|
|
414
|
+
|
|
237
415
|
// Serve static files (dist/)
|
|
238
416
|
const distDir = join(__dirname, '../dist')
|
|
239
417
|
const indexPath = join(distDir, 'index.html')
|
|
240
418
|
if (existsSync(distDir)) {
|
|
241
|
-
const
|
|
242
|
-
|
|
419
|
+
const url = new URL(req.url || '/', `http://${req.headers.host}`)
|
|
420
|
+
const requestPath = decodeURIComponent(url.pathname)
|
|
421
|
+
const filePath = resolve(distDir, requestPath === '/' ? 'index.html' : `.${requestPath}`)
|
|
422
|
+
if (!isInside(distDir, filePath)) {
|
|
423
|
+
res.writeHead(403)
|
|
424
|
+
res.end('Access denied')
|
|
425
|
+
return
|
|
426
|
+
}
|
|
427
|
+
if (existsSync(filePath)) {
|
|
243
428
|
const ext = filePath.split('.').pop()
|
|
244
429
|
const mimeTypes = { html: 'text/html', js: 'application/javascript', css: 'text/css', svg: 'image/svg+xml', png: 'image/png', jpg: 'image/jpeg' }
|
|
245
430
|
res.setHeader('Content-Type', mimeTypes[ext] || 'application/octet-stream')
|
|
@@ -247,7 +432,6 @@ function startServer({ port = 3456, open: openBrowser = true } = {}) {
|
|
|
247
432
|
res.end(readFileSync(filePath))
|
|
248
433
|
return
|
|
249
434
|
}
|
|
250
|
-
// SPA fallback: serve index.html for unknown routes
|
|
251
435
|
if (existsSync(indexPath)) {
|
|
252
436
|
res.setHeader('Content-Type', 'text/html')
|
|
253
437
|
res.writeHead(200)
|
|
@@ -256,32 +440,46 @@ function startServer({ port = 3456, open: openBrowser = true } = {}) {
|
|
|
256
440
|
}
|
|
257
441
|
}
|
|
258
442
|
|
|
259
|
-
// 404
|
|
260
443
|
res.writeHead(404)
|
|
261
444
|
res.end('Not found')
|
|
262
445
|
})
|
|
263
446
|
|
|
264
|
-
// WebSocket Server
|
|
265
447
|
wss = new WebSocketServer({ server })
|
|
266
448
|
|
|
267
449
|
wss.on('error', (err) => {
|
|
268
450
|
console.error('WebSocket server error:', err)
|
|
269
451
|
})
|
|
270
452
|
|
|
271
|
-
wss.on('connection', (ws) => {
|
|
453
|
+
wss.on('connection', (ws, req) => {
|
|
454
|
+
if (!isLocalOrigin(req.headers.origin)) {
|
|
455
|
+
ws.close(1008, 'Forbidden origin')
|
|
456
|
+
return
|
|
457
|
+
}
|
|
272
458
|
console.log('WebSocket client connected')
|
|
273
459
|
|
|
274
460
|
// Send initial projects list
|
|
275
461
|
discoverProjects().then(projects => {
|
|
276
462
|
const projectsWithState = projects.map(p => ({
|
|
277
463
|
...p,
|
|
278
|
-
state: parseProjectState(p.path)
|
|
464
|
+
state: parseProjectState(p.path),
|
|
465
|
+
overview: parseProjectOverview(p.path)
|
|
279
466
|
}))
|
|
280
467
|
|
|
281
468
|
ws.send(JSON.stringify({
|
|
282
469
|
type: 'projects:init',
|
|
283
470
|
data: projectsWithState
|
|
284
471
|
}))
|
|
472
|
+
|
|
473
|
+
// Start watching progress files for all discovered projects
|
|
474
|
+
for (const p of projectsWithState) {
|
|
475
|
+
startProgressWatch(p.path)
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
// Send current scan paths
|
|
479
|
+
ws.send(JSON.stringify({
|
|
480
|
+
type: 'scan:paths',
|
|
481
|
+
data: getCustomScanPaths()
|
|
482
|
+
}))
|
|
285
483
|
}).catch(err => {
|
|
286
484
|
console.error('Error sending initial projects:', err)
|
|
287
485
|
})
|
|
@@ -299,12 +497,37 @@ function startServer({ port = 3456, open: openBrowser = true } = {}) {
|
|
|
299
497
|
if (kill) {
|
|
300
498
|
kill()
|
|
301
499
|
activeProcesses.delete(data.data.projectName)
|
|
302
|
-
broadcast({
|
|
303
|
-
|
|
304
|
-
|
|
500
|
+
broadcast({ type: 'cli:killed', data: { projectName: data.data.projectName } })
|
|
501
|
+
}
|
|
502
|
+
break
|
|
503
|
+
case 'scan:add-path':
|
|
504
|
+
if (data.data?.path) {
|
|
505
|
+
addCustomScanPath(data.data.path)
|
|
506
|
+
broadcast({ type: 'scan:paths', data: getCustomScanPaths() })
|
|
507
|
+
// Resend projects after scan
|
|
508
|
+
discoverProjects().then(projects => {
|
|
509
|
+
broadcast({
|
|
510
|
+
type: 'projects:updated',
|
|
511
|
+
data: projects.map(p => ({ ...p, state: parseProjectState(p.path) }))
|
|
512
|
+
})
|
|
305
513
|
})
|
|
306
514
|
}
|
|
307
515
|
break
|
|
516
|
+
case 'scan:remove-path':
|
|
517
|
+
if (data.data?.path) {
|
|
518
|
+
removeCustomScanPath(data.data.path)
|
|
519
|
+
ws.send(JSON.stringify({ type: 'scan:paths', data: getCustomScanPaths() }))
|
|
520
|
+
}
|
|
521
|
+
break
|
|
522
|
+
case 'scan:get-paths':
|
|
523
|
+
ws.send(JSON.stringify({ type: 'scan:paths', data: getCustomScanPaths() }))
|
|
524
|
+
break
|
|
525
|
+
case 'docs:get':
|
|
526
|
+
if (data.data?.projectPath) {
|
|
527
|
+
const docs = parseDocsTree(data.data.projectPath)
|
|
528
|
+
ws.send(JSON.stringify({ type: 'docs:tree', data: docs }))
|
|
529
|
+
}
|
|
530
|
+
break
|
|
308
531
|
default:
|
|
309
532
|
console.log('Unknown message type:', data.type)
|
|
310
533
|
}
|
|
@@ -315,6 +538,12 @@ function startServer({ port = 3456, open: openBrowser = true } = {}) {
|
|
|
315
538
|
|
|
316
539
|
ws.on('close', () => {
|
|
317
540
|
console.log('WebSocket client disconnected')
|
|
541
|
+
// Decrement progress watchers — we track which projects this client was watching
|
|
542
|
+
// via the initial projects:init. For simplicity, stop all that we started.
|
|
543
|
+
// (Other clients will re-start via their connection if still active)
|
|
544
|
+
for (const [path] of progressWatchers) {
|
|
545
|
+
stopProgressWatch(path)
|
|
546
|
+
}
|
|
318
547
|
})
|
|
319
548
|
|
|
320
549
|
ws.on('error', (err) => {
|
|
@@ -322,27 +551,21 @@ function startServer({ port = 3456, open: openBrowser = true } = {}) {
|
|
|
322
551
|
})
|
|
323
552
|
})
|
|
324
553
|
|
|
325
|
-
// Start file watcher (wrapped to avoid crashing server)
|
|
326
554
|
try {
|
|
327
555
|
startWatcher((projects) => {
|
|
328
|
-
broadcast({
|
|
329
|
-
type: 'projects:updated',
|
|
330
|
-
data: projects
|
|
331
|
-
})
|
|
556
|
+
broadcast({ type: 'projects:updated', data: projects })
|
|
332
557
|
})
|
|
333
558
|
} catch (err) {
|
|
334
559
|
console.error('Failed to start file watcher:', err)
|
|
335
560
|
}
|
|
336
561
|
|
|
337
|
-
server.listen(port, () => {
|
|
338
|
-
console.log(`Dashboard server running on http://
|
|
339
|
-
|
|
562
|
+
server.listen(port, '127.0.0.1', () => {
|
|
563
|
+
console.log(`Dashboard server running on http://127.0.0.1:${port}`)
|
|
340
564
|
if (openBrowser) {
|
|
341
|
-
open(`http://
|
|
565
|
+
open(`http://127.0.0.1:${port}`)
|
|
342
566
|
}
|
|
343
567
|
})
|
|
344
568
|
|
|
345
|
-
// Handle shutdown
|
|
346
569
|
const shutdown = () => {
|
|
347
570
|
stopWatcher()
|
|
348
571
|
activeProcesses.forEach(kill => kill())
|