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.
- package/bin/spex.mjs +15 -0
- package/dashboard-dist/assets/index-B60MILFg.js +139 -0
- package/dashboard-dist/assets/index-Cq7hwngj.css +32 -0
- package/dashboard-dist/index.html +16 -0
- package/package.json +35 -0
- package/src/board.ts +119 -0
- package/src/cli.ts +487 -0
- package/src/client.ts +102 -0
- package/src/gateway.ts +241 -0
- package/src/git.ts +492 -0
- package/src/guide.ts +134 -0
- package/src/harness.ts +674 -0
- package/src/hooks.ts +41 -0
- package/src/index.ts +233 -0
- package/src/init.ts +120 -0
- package/src/layout.ts +246 -0
- package/src/lint.ts +206 -0
- package/src/login-page.ts +79 -0
- package/src/materialize.ts +85 -0
- package/src/pty-bridge.ts +235 -0
- package/src/ranker.ts +129 -0
- package/src/resilience.ts +41 -0
- package/src/search.bench.mjs +47 -0
- package/src/search.ts +24 -0
- package/src/self.ts +256 -0
- package/src/sessions.ts +1469 -0
- package/src/slash-commands.ts +242 -0
- package/src/specs.ts +331 -0
- package/src/supervise.ts +158 -0
- package/src/uploads.ts +31 -0
- package/templates/hooks/pre-commit +57 -0
- package/templates/hooks/prepare-commit-msg +14 -0
- package/templates/spec/project/.config/core/idle/idle.sh +15 -0
- package/templates/spec/project/.config/core/idle/spec.md +13 -0
- package/templates/spec/project/.config/core/mark-active/mark-active.sh +46 -0
- package/templates/spec/project/.config/core/mark-active/spec.md +16 -0
- package/templates/spec/project/.config/core/session-fail/fail.sh +12 -0
- package/templates/spec/project/.config/core/session-fail/spec.md +13 -0
- package/templates/spec/project/.config/core/spec-first/spec-first.sh +54 -0
- package/templates/spec/project/.config/core/spec-first/spec.md +15 -0
- package/templates/spec/project/.config/core/spec-of-file/spec-of-file.sh +42 -0
- package/templates/spec/project/.config/core/spec-of-file/spec.md +15 -0
- package/templates/spec/project/.config/core/spec.md +13 -0
- package/templates/spec/project/.config/core/stop-gate/spec.md +17 -0
- package/templates/spec/project/.config/core/stop-gate/stop-gate.sh +108 -0
- package/templates/spec/project/.config/extract/spec.md +60 -0
- package/templates/spec/project/.config/forge-link/spec.md +9 -0
- package/templates/spec/project/.config/memory-hygiene/spec.md +15 -0
- package/templates/spec/project/.config/regroup/spec.md +25 -0
- package/templates/spec/project/.config/scenario/spec.md +32 -0
- package/templates/spec/project/.config/spec.md +15 -0
- package/templates/spec/project/.config/supervisor/spec.md +8 -0
- package/templates/spec/project/.config/tidy/spec.md +25 -0
- package/templates/spec/project/spec.md +19 -0
- 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
|
+
}
|