spexcode 0.1.0

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 (55) hide show
  1. package/bin/spex.mjs +15 -0
  2. package/dashboard-dist/assets/index-B60MILFg.js +139 -0
  3. package/dashboard-dist/assets/index-Cq7hwngj.css +32 -0
  4. package/dashboard-dist/index.html +16 -0
  5. package/package.json +35 -0
  6. package/src/board.ts +119 -0
  7. package/src/cli.ts +487 -0
  8. package/src/client.ts +102 -0
  9. package/src/gateway.ts +241 -0
  10. package/src/git.ts +492 -0
  11. package/src/guide.ts +134 -0
  12. package/src/harness.ts +674 -0
  13. package/src/hooks.ts +41 -0
  14. package/src/index.ts +233 -0
  15. package/src/init.ts +120 -0
  16. package/src/layout.ts +246 -0
  17. package/src/lint.ts +206 -0
  18. package/src/login-page.ts +79 -0
  19. package/src/materialize.ts +85 -0
  20. package/src/pty-bridge.ts +235 -0
  21. package/src/ranker.ts +129 -0
  22. package/src/resilience.ts +41 -0
  23. package/src/search.bench.mjs +47 -0
  24. package/src/search.ts +24 -0
  25. package/src/self.ts +256 -0
  26. package/src/sessions.ts +1469 -0
  27. package/src/slash-commands.ts +242 -0
  28. package/src/specs.ts +331 -0
  29. package/src/supervise.ts +158 -0
  30. package/src/uploads.ts +31 -0
  31. package/templates/hooks/pre-commit +57 -0
  32. package/templates/hooks/prepare-commit-msg +14 -0
  33. package/templates/spec/project/.config/core/idle/idle.sh +15 -0
  34. package/templates/spec/project/.config/core/idle/spec.md +13 -0
  35. package/templates/spec/project/.config/core/mark-active/mark-active.sh +46 -0
  36. package/templates/spec/project/.config/core/mark-active/spec.md +16 -0
  37. package/templates/spec/project/.config/core/session-fail/fail.sh +12 -0
  38. package/templates/spec/project/.config/core/session-fail/spec.md +13 -0
  39. package/templates/spec/project/.config/core/spec-first/spec-first.sh +54 -0
  40. package/templates/spec/project/.config/core/spec-first/spec.md +15 -0
  41. package/templates/spec/project/.config/core/spec-of-file/spec-of-file.sh +42 -0
  42. package/templates/spec/project/.config/core/spec-of-file/spec.md +15 -0
  43. package/templates/spec/project/.config/core/spec.md +13 -0
  44. package/templates/spec/project/.config/core/stop-gate/spec.md +17 -0
  45. package/templates/spec/project/.config/core/stop-gate/stop-gate.sh +108 -0
  46. package/templates/spec/project/.config/extract/spec.md +60 -0
  47. package/templates/spec/project/.config/forge-link/spec.md +9 -0
  48. package/templates/spec/project/.config/memory-hygiene/spec.md +15 -0
  49. package/templates/spec/project/.config/regroup/spec.md +25 -0
  50. package/templates/spec/project/.config/scenario/spec.md +32 -0
  51. package/templates/spec/project/.config/spec.md +15 -0
  52. package/templates/spec/project/.config/supervisor/spec.md +8 -0
  53. package/templates/spec/project/.config/tidy/spec.md +25 -0
  54. package/templates/spec/project/spec.md +19 -0
  55. package/templates/spexcode.json +5 -0
package/src/gateway.ts ADDED
@@ -0,0 +1,241 @@
1
+ // @@@ public gateway - the internet face of `spex serve --public`. The supervisor (supervise.ts) and its
2
+ // Hono child stay bound to 127.0.0.1; THIS is the only listener on 0.0.0.0. It terminates TLS, gates every
3
+ // request behind one password (a designed login → signed cookie), serves the built dashboard, and reverse-
4
+ // proxies /api + the terminal WebSocket to the loopback supervisor. Loopback is the trust boundary (local
5
+ // agents hit the supervisor directly, no password); the gateway is the boundary crossed from outside.
6
+ import http from 'node:http'
7
+ import https from 'node:https'
8
+ import net from 'node:net'
9
+ import { createHmac, timingSafeEqual } from 'node:crypto'
10
+ import { execFileSync, spawnSync } from 'node:child_process'
11
+ import { readFileSync, existsSync, mkdirSync, writeFileSync } from 'node:fs'
12
+ import { join, normalize, extname } from 'node:path'
13
+ import { fileURLToPath } from 'node:url'
14
+ import { homedir } from 'node:os'
15
+ import { loginPage } from './login-page.js'
16
+
17
+ // @@@ resolvePublicConfig - the cert/gate is a RESOLVED value, never hardcoded. Reads the same precedence
18
+ // chain the spec promises: flag > env > spexcode.json > self-signed default. Returns null when public mode
19
+ // is off (the supervisor then serves plain loopback, unchanged). process.argv carries the `spex serve …`
20
+ // flags since the supervisor runs in the same process as the CLI command.
21
+ export type PublicConfig = { password: string; tls: { cert: string; key: string } | null }
22
+ function argFlag(name: string): string | undefined {
23
+ const i = process.argv.indexOf(`--${name}`)
24
+ return i >= 0 ? process.argv[i + 1] : undefined
25
+ }
26
+ const argHas = (name: string) => process.argv.includes(`--${name}`)
27
+
28
+ export function resolvePublicConfig(repoRoot: string): PublicConfig | null {
29
+ let fileCfg: any = {}
30
+ // a MISSING spexcode.json is fine (defaults); a MALFORMED one fails LOUD — silently swallowing it would
31
+ // serve the dashboard with the wrong public/TLS posture, the opposite of what the file says.
32
+ try { fileCfg = JSON.parse(readFileSync(join(repoRoot, 'spexcode.json'), 'utf8'))?.serve?.public ?? {} }
33
+ catch (e) { if ((e as NodeJS.ErrnoException).code !== 'ENOENT') throw new Error(`spexcode.json is malformed (cannot resolve public-mode config): ${(e as Error).message}`) }
34
+ const enabled = argHas('public') || process.env.SPEXCODE_PUBLIC === '1' || fileCfg?.enabled === true
35
+ if (!enabled) return null
36
+
37
+ // the gate is OPT-IN: a password (flag/env only — never spexcode.json) makes the login appear; WITHOUT one
38
+ // the dashboard is served OPEN. That is loud-warned, not refused — open public access drives the agents, so
39
+ // anyone who reaches the URL has them. The caller (you) chooses; we never silently gate or silently expose.
40
+ const password = argFlag('password') ?? process.env.SPEXCODE_PASSWORD ?? ''
41
+ if (!password) console.error('⚠ spex serve --public with NO password: the dashboard is OPEN — anyone who reaches it controls the agents. Add --password <pw> / SPEXCODE_PASSWORD to require a login.')
42
+
43
+ // --http: knowingly drop TLS. Loud, because the password then crosses the wire in clear and secure-context
44
+ // browser features (clipboard) break. Anything else resolves a cert; absent any source → self-signed.
45
+ if (argHas('http') || fileCfg?.http === true) {
46
+ console.error('⚠ spex serve --public --http: TLS is OFF. The password travels in CLEARTEXT and clipboard/secure-context features will not work. Use this only on a trusted path.')
47
+ return { password, tls: null }
48
+ }
49
+ const certPath = argFlag('tls-cert') ?? process.env.SPEXCODE_TLS_CERT ?? fileCfg?.tls?.cert
50
+ const keyPath = argFlag('tls-key') ?? process.env.SPEXCODE_TLS_KEY ?? fileCfg?.tls?.key
51
+ if (certPath || keyPath) {
52
+ if (!certPath || !keyPath) { console.error('spex serve --public: --tls-cert and --tls-key must be given together.'); process.exit(1) }
53
+ for (const [label, p] of [['cert', certPath], ['key', keyPath]] as const) {
54
+ if (!existsSync(p)) { console.error(`spex serve --public: TLS ${label} file not found: ${p} — fix the path, or omit both for a self-signed cert, or use --http.`); process.exit(1) }
55
+ }
56
+ return { password, tls: { cert: readFileSync(certPath, 'utf8'), key: readFileSync(keyPath, 'utf8') } }
57
+ }
58
+ return { password, tls: selfSignedCert() }
59
+ }
60
+
61
+ // @@@ self-signed default - generated ONCE via openssl into ~/.spexcode/tls and reused, so a visitor accepts
62
+ // the cert only once (not on every restart). openssl is near-universal on Linux/macOS; if it is genuinely
63
+ // absent we FAIL LOUD with the three repair paths rather than silently dropping to plaintext. Web PKI will
64
+ // not issue a browser-trusted cert for a bare IP, so this cert is untrusted by construction — the visitor's
65
+ // one-time "proceed" is the price of needing no domain, not a bug.
66
+ function selfSignedCert(): { cert: string; key: string } {
67
+ const dir = join(homedir(), '.spexcode', 'tls')
68
+ const certFile = join(dir, 'self-signed.cert.pem'), keyFile = join(dir, 'self-signed.key.pem')
69
+ if (!existsSync(certFile) || !existsSync(keyFile)) {
70
+ if (spawnSync('openssl', ['version']).status !== 0) {
71
+ console.error('spex serve --public: openssl not found, so a self-signed cert cannot be generated. Install openssl, OR pass --tls-cert/--tls-key with your own cert, OR use --http (no TLS).')
72
+ process.exit(1)
73
+ }
74
+ mkdirSync(dir, { recursive: true })
75
+ console.log('[gateway] generating a self-signed TLS cert (one-time) → ' + dir)
76
+ execFileSync('openssl', ['req', '-x509', '-newkey', 'rsa:2048', '-nodes', '-keyout', keyFile, '-out', certFile,
77
+ '-days', '3650', '-subj', '/CN=spexcode', '-addext', 'subjectAltName=DNS:localhost,IP:127.0.0.1'], { stdio: 'ignore' })
78
+ }
79
+ return { cert: readFileSync(certFile, 'utf8'), key: readFileSync(keyFile, 'utf8') }
80
+ }
81
+
82
+ // @@@ cookie auth - the gate is a designed login, NOT the browser's Basic dialog. The auth cookie is a
83
+ // keyed HMAC of a constant under a secret DERIVED from the password, so it (a) survives a restart with no
84
+ // server-side session store and (b) reveals nothing about the password. Verified in constant time. The same
85
+ // cookie authorises /api and the WebSocket upgrade — the browser sends it on the same-origin handshake.
86
+ const COOKIE = 'spex_auth'
87
+ function authToken(password: string): string {
88
+ const secret = createHmac('sha256', password).update('spexcode-public-gateway-v1').digest()
89
+ return createHmac('sha256', secret).update('authed').digest('base64url')
90
+ }
91
+ function constEq(a: string, b: string): boolean {
92
+ const ab = Buffer.from(a), bb = Buffer.from(b)
93
+ return ab.length === bb.length && timingSafeEqual(ab, bb)
94
+ }
95
+ function cookieOf(header: string | undefined, name: string): string | null {
96
+ for (const part of (header ?? '').split(';')) {
97
+ const eq = part.indexOf('=')
98
+ if (eq > 0 && part.slice(0, eq).trim() === name) return decodeURIComponent(part.slice(eq + 1).trim())
99
+ }
100
+ return null
101
+ }
102
+ function isAuthed(req: http.IncomingMessage, token: string): boolean {
103
+ const c = cookieOf(req.headers.cookie, COOKIE)
104
+ return c != null && constEq(c, token)
105
+ }
106
+
107
+ const MIME: Record<string, string> = { '.html': 'text/html; charset=utf-8', '.js': 'text/javascript', '.css': 'text/css', '.json': 'application/json', '.svg': 'image/svg+xml', '.png': 'image/png', '.ico': 'image/x-icon', '.woff2': 'font/woff2', '.map': 'application/json' }
108
+
109
+ // @@@ resolveDistDir - where the built dashboard lives, bundled-or-monorepo. In an INSTALLED `spexcode`
110
+ // package the dist rides inside it (prepublish copies spec-dashboard's build to <pkg>/dashboard-dist);
111
+ // in the dogfood monorepo there is no bundled copy, so it falls back to the sibling spec-dashboard/dist.
112
+ // This is the one seam that lets the same gateway code serve from either layout (see [[packaging]]).
113
+ export function resolveDistDir(): string {
114
+ const pkgRoot = fileURLToPath(new URL('..', import.meta.url)) // gateway.ts is in src/ → .. = package root
115
+ const bundled = join(pkgRoot, 'dashboard-dist')
116
+ if (existsSync(join(bundled, 'index.html'))) return bundled
117
+ return join(pkgRoot, '..', 'spec-dashboard', 'dist')
118
+ }
119
+
120
+ export type GatewayOpts = { publicPort: number; upstreamPort: number; password: string; tls: { cert: string; key: string } | null; distDir: string; host?: string; label?: string }
121
+
122
+ export function startGateway(opts: GatewayOpts): void {
123
+ // gated ONLY when a password is set; otherwise the login layer doesn't exist and the dashboard is served open.
124
+ const gated = !!opts.password
125
+ const token = gated ? authToken(opts.password) : ''
126
+ const secure = !!opts.tls
127
+ const setCookie = `${COOKIE}=${token}; HttpOnly; Path=/; SameSite=Lax; Max-Age=2592000${secure ? '; Secure' : ''}`
128
+
129
+ const handler = (req: http.IncomingMessage, res: http.ServerResponse) => {
130
+ const url = (req.url || '/').split('?')[0]
131
+ if (gated) {
132
+ // login surface — the only routes reachable without a cookie. Absent entirely when ungated.
133
+ if (url === '/login' && req.method === 'POST') return doLogin(req, res, opts.password, setCookie)
134
+ if (url === '/login') return sendHtml(res, 200, loginPage())
135
+ if (url === '/logout') { res.writeHead(302, { 'Set-Cookie': `${COOKIE}=; Path=/; Max-Age=0`, Location: '/login' }); return res.end() }
136
+ if (!isAuthed(req, token)) {
137
+ if (url.startsWith('/api')) { res.writeHead(401, { 'Content-Type': 'application/json' }); return res.end('{"error":"authentication required"}') }
138
+ res.writeHead(302, { Location: '/login' }); return res.end()
139
+ }
140
+ }
141
+ if (url.startsWith('/api')) return proxyHttp(req, res, opts.upstreamPort)
142
+ return serveStatic(res, opts.distDir, url)
143
+ }
144
+
145
+ const server = secure ? https.createServer({ cert: opts.tls!.cert, key: opts.tls!.key }, handler) : http.createServer(handler)
146
+
147
+ // @@@ WS gate - the terminal socket rides an HTTP upgrade. Gate it by the SAME cookie (the browser sends
148
+ // it on the same-origin handshake), then raw-pipe to the loopback supervisor, replaying the buffered
149
+ // upgrade request so the child completes the WebSocket handshake. Mirrors supervise.ts's byte pipe.
150
+ server.on('upgrade', (req, socket, head) => {
151
+ if (gated && !isAuthed(req, token)) { socket.destroy(); return }
152
+ const up = net.connect(opts.upstreamPort, '127.0.0.1', () => {
153
+ up.write(`${req.method} ${req.url} HTTP/1.1\r\n` + rawHeaders(req))
154
+ if (head && head.length) up.write(head)
155
+ socket.pipe(up); up.pipe(socket)
156
+ })
157
+ const bail = () => { socket.destroy(); up.destroy() }
158
+ socket.on('error', bail); up.on('error', bail)
159
+ })
160
+
161
+ // `spex dashboard` passes an explicit loopback host; `--public` passes none → bind ALL interfaces (the
162
+ // original behaviour, IPv4+IPv6), so adding the local path never narrows the public gateway's reach.
163
+ const isLocal = !!opts.host
164
+ const onListen = () => {
165
+ const scheme = secure ? 'https' : 'http'
166
+ const label = opts.label ?? 'public mode'
167
+ const gate = isLocal ? '' : ` — ${gated ? 'password-gated' : 'OPEN (no password)'}` // ungated loopback is normal, not a warning
168
+ console.log(`[gateway] ${label} on ${scheme}://${isLocal ? 'localhost' : '0.0.0.0'}:${opts.publicPort}${gate}, proxying /api to :${opts.upstreamPort}`)
169
+ if (!secure && !isLocal) console.log('[gateway] (TLS off — --http)')
170
+ }
171
+ if (opts.host) server.listen(opts.publicPort, opts.host, onListen)
172
+ else server.listen(opts.publicPort, onListen)
173
+ }
174
+
175
+ function rawHeaders(req: http.IncomingMessage): string {
176
+ let s = ''
177
+ for (let i = 0; i < req.rawHeaders.length; i += 2) s += `${req.rawHeaders[i]}: ${req.rawHeaders[i + 1]}\r\n`
178
+ return s + '\r\n'
179
+ }
180
+
181
+ function doLogin(req: http.IncomingMessage, res: http.ServerResponse, password: string, setCookie: string) {
182
+ let body = ''
183
+ req.on('data', (d) => { body += d; if (body.length > 4096) req.destroy() })
184
+ req.on('end', () => {
185
+ let pw = ''
186
+ try { pw = req.headers['content-type']?.includes('application/json') ? JSON.parse(body).password ?? '' : new URLSearchParams(body).get('password') ?? '' } catch { /* malformed */ }
187
+ if (constEq(pw, password)) { res.writeHead(302, { 'Set-Cookie': setCookie, Location: '/' }); res.end() }
188
+ else sendHtml(res, 401, loginPage(true))
189
+ })
190
+ }
191
+
192
+ // reverse-proxy an /api request to the loopback supervisor (which forwards to the live child).
193
+ function proxyHttp(req: http.IncomingMessage, res: http.ServerResponse, upstreamPort: number) {
194
+ const up = http.request({ host: '127.0.0.1', port: upstreamPort, path: req.url, method: req.method, headers: req.headers }, (upRes) => {
195
+ res.writeHead(upRes.statusCode || 502, upRes.headers)
196
+ upRes.pipe(res)
197
+ })
198
+ up.on('error', () => { if (!res.headersSent) res.writeHead(502); res.end('upstream unreachable') })
199
+ req.pipe(up)
200
+ }
201
+
202
+ // serve the built dashboard (vite dist). Unknown non-file paths fall back to index.html (SPA). Path
203
+ // traversal is blocked by normalising and confining to distDir.
204
+ function serveStatic(res: http.ServerResponse, distDir: string, urlPath: string) {
205
+ const rel = normalize(decodeURIComponent(urlPath)).replace(/^(\.\.[/\\])+/, '')
206
+ let file = join(distDir, rel)
207
+ if (!file.startsWith(distDir)) file = join(distDir, 'index.html')
208
+ if (urlPath === '/' || !existsSync(file)) file = join(distDir, 'index.html')
209
+ if (!existsSync(file)) { res.writeHead(503); return res.end('dashboard build missing') }
210
+ res.writeHead(200, { 'Content-Type': MIME[extname(file)] || 'application/octet-stream' })
211
+ res.end(readFileSync(file))
212
+ }
213
+
214
+ function sendHtml(res: http.ServerResponse, status: number, html: string) {
215
+ res.writeHead(status, { 'Content-Type': 'text/html; charset=utf-8' })
216
+ res.end(html)
217
+ }
218
+
219
+ // @@@ ensureDashboardBuilt - public mode serves a STATIC build, so the dist must exist. If it's missing we
220
+ // build it once (vite build) so "one command" holds; a build failure is loud, not a blank serve.
221
+ export function ensureDashboardBuilt(repoRoot: string, distDir: string): void {
222
+ if (existsSync(join(distDir, 'index.html'))) return
223
+ console.log('[gateway] dashboard build not found — building it once (vite build)…')
224
+ const r = spawnSync('npm', ['run', 'build'], { cwd: join(repoRoot, 'spec-dashboard'), stdio: 'inherit' })
225
+ if (r.status !== 0 || !existsSync(join(distDir, 'index.html'))) {
226
+ console.error('[gateway] dashboard build failed. Build it manually: (cd spec-dashboard && npm run build), then retry.')
227
+ process.exit(1)
228
+ }
229
+ }
230
+
231
+ // @@@ serveDashboardLocal - the engine behind `spex dashboard`: the SAME gateway as public mode, bound to
232
+ // loopback with no TLS and no password. It serves the bundled dist and proxies /api + the terminal socket
233
+ // to a separately-run `spex serve`. This is the post-install replacement for the dogfood-only `npm run web`
234
+ // (a vite dev server an installed user has no source tree for). See [[packaging]].
235
+ export function serveDashboardLocal(opts: { port: number; apiPort: number }): void {
236
+ const pkgRoot = fileURLToPath(new URL('..', import.meta.url))
237
+ const distDir = resolveDistDir()
238
+ ensureDashboardBuilt(join(pkgRoot, '..'), distDir) // bundled dist already has index.html → returns at once
239
+ console.log(`[dashboard] serving ${distDir.endsWith('dashboard-dist') ? 'bundled' : 'monorepo'} build, /api → backend :${opts.apiPort}`)
240
+ startGateway({ host: '127.0.0.1', publicPort: opts.port, upstreamPort: opts.apiPort, password: '', tls: null, distDir, label: 'dashboard' })
241
+ }