spexcode 0.6.0 → 0.6.2
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/README.md +97 -149
- package/package.json +1 -1
- package/spec-cli/hooks/harness.sh +26 -6
- package/spec-cli/src/anchors.ts +27 -5
- package/spec-cli/src/cli.ts +239 -75
- package/spec-cli/src/client.ts +113 -14
- package/spec-cli/src/codex-runtime-generations.ts +97 -7
- package/spec-cli/src/commit-surgery.ts +2 -1
- package/spec-cli/src/contract-filter.ts +58 -42
- package/spec-cli/src/delivery-queue.ts +62 -4
- package/spec-cli/src/doctor.ts +2 -1
- package/spec-cli/src/execution-trace.ts +444 -0
- package/spec-cli/src/file-write.ts +22 -0
- package/spec-cli/src/gateway-hub.ts +10 -8
- package/spec-cli/src/gateway.ts +99 -6
- package/spec-cli/src/git.ts +503 -105
- package/spec-cli/src/graph.ts +56 -6
- package/spec-cli/src/graphCache.ts +86 -17
- package/spec-cli/src/graphStream.ts +79 -11
- package/spec-cli/src/guide.ts +93 -4
- package/spec-cli/src/harness-select.ts +2 -2
- package/spec-cli/src/harness.ts +355 -75
- package/spec-cli/src/help.ts +30 -23
- package/spec-cli/src/host-resources.ts +62 -7
- package/spec-cli/src/identity-presets.js +16 -6
- package/spec-cli/src/index.ts +109 -14
- package/spec-cli/src/init.ts +8 -17
- package/spec-cli/src/layout.ts +151 -40
- package/spec-cli/src/lint.ts +37 -7
- package/spec-cli/src/materialize.ts +160 -112
- package/spec-cli/src/plugin-harness.ts +29 -18
- package/spec-cli/src/process-identity.ts +13 -0
- package/spec-cli/src/reviews.ts +103 -30
- package/spec-cli/src/session-execution.ts +68 -0
- package/spec-cli/src/session-files.ts +121 -0
- package/spec-cli/src/session-follow.ts +4 -4
- package/spec-cli/src/session-reparent.ts +33 -0
- package/spec-cli/src/session-timeline.ts +163 -14
- package/spec-cli/src/session-web.ts +135 -0
- package/spec-cli/src/sessions.ts +748 -111
- package/spec-cli/src/uninstall.ts +3 -2
- package/spec-cli/templates/hooks/post-checkout +3 -0
- package/spec-cli/templates/hooks/post-merge +1 -0
- package/spec-cli/templates/spec/project/.plugins/commands/supervisor/spec.md +1 -1
- package/spec-cli/templates/spec/project/.plugins/core/comment-altitude/spec.md +2 -21
- package/spec-cli/templates/spec/project/.plugins/core/spec.md +12 -13
- package/spec-cli/templates/spec/project/.plugins/core/stop-gate/spec.md +1 -1
- package/spec-cli/templates/spec/project/.plugins/core/stop-gate/stop-gate.sh +2 -2
- package/spec-cli/templates/spec/project/.plugins/prompts/atomic-landing/spec.md +5 -22
- package/spec-cli/templates/spec/project/.plugins/prompts/forge-link/spec.md +2 -1
- package/spec-cli/templates/spec/project/.plugins/prompts/memory-hygiene/spec.md +4 -6
- package/spec-cli/templates/spec/project/.plugins/prompts/reproduce-before-fix/spec.md +3 -9
- package/spec-dashboard/dist/assets/{App-b8Nh0sgk.js → App-B6IbFbGu.js} +2 -2
- package/spec-dashboard/dist/assets/Dashboard-_sDk7Rcg.js +27 -0
- package/spec-dashboard/dist/assets/EvalsPage-BT357Lyt.js +2 -0
- package/spec-dashboard/dist/assets/{IssuesPage-CAP64YWE.js → IssuesPage-BG9rNOgV.js} +1 -1
- package/spec-dashboard/dist/assets/MobileApp-D9yPMR3A.js +2 -0
- package/spec-dashboard/dist/assets/{Modal-Drscez-d.js → Modal-B3EcgeA3.js} +1 -1
- package/spec-dashboard/dist/assets/{PageScroll-qW6uOJL8.js → PageScroll-CNZOugWV.js} +1 -1
- package/spec-dashboard/dist/assets/{ProjectsPage-CjybFBmR.js → ProjectsPage-C2CRHmvs.js} +1 -1
- package/spec-dashboard/dist/assets/SessionInterface-DOEjuT3l.js +40 -0
- package/spec-dashboard/dist/assets/{SessionWindow-iOk0yHoU.js → SessionWindow-1nYgZ4CL.js} +1 -1
- package/spec-dashboard/dist/assets/Settings-7WeTJn0V.js +1 -0
- package/spec-dashboard/dist/assets/Thread-CEbzAFQn.js +13 -0
- package/spec-dashboard/dist/assets/data-SlvxNA2r.js +1 -0
- package/spec-dashboard/dist/assets/{index-paP-z_Vd.js → index-DLVeOHL-.js} +10 -10
- package/spec-dashboard/dist/assets/index-DYLGfPjx.css +1 -0
- package/spec-dashboard/dist/assets/{launch-B-bYdWmh.js → launch-yxCYr64x.js} +29 -29
- package/spec-dashboard/dist/assets/sessionSurface--7l69h4F.js +1 -0
- package/spec-dashboard/dist/index.html +2 -2
- package/spec-dashboard/src/reviewFilters.js +5 -0
- package/spec-dashboard/src/session.js +5 -1
- package/spec-eval/src/cli.ts +13 -9
- package/spec-eval/src/evaltab.ts +32 -10
- package/spec-eval/src/freshness.ts +404 -81
- package/spec-eval/src/scenariofresh.ts +58 -8
- package/spec-eval/src/scenarios.ts +60 -12
- package/spec-eval/src/sessioneval.ts +234 -148
- package/spec-dashboard/dist/assets/Dashboard-CvAjfRC2.js +0 -27
- package/spec-dashboard/dist/assets/EvalsPage-Bz-nMKoS.js +0 -2
- package/spec-dashboard/dist/assets/MobileApp-D9L1Va8Z.js +0 -2
- package/spec-dashboard/dist/assets/SessionInterface-Dl9v0JFM.js +0 -39
- package/spec-dashboard/dist/assets/Settings-BZ1lGRJs.js +0 -1
- package/spec-dashboard/dist/assets/Thread-D_kcDnfd.js +0 -13
- package/spec-dashboard/dist/assets/data-Bwd3kAVL.js +0 -1
- package/spec-dashboard/dist/assets/index-DAbQBBK_.css +0 -1
- package/spec-dashboard/dist/assets/terminalFont-C6oGNKrf.js +0 -1
- package/spec-eval/src/matrix.ts +0 -693
package/spec-cli/src/gateway.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import http from 'node:http'
|
|
2
2
|
import https from 'node:https'
|
|
3
3
|
import net from 'node:net'
|
|
4
|
+
import type { Duplex } from 'node:stream'
|
|
4
5
|
import { createHmac, timingSafeEqual } from 'node:crypto'
|
|
5
6
|
import { execFileSync, spawnSync } from 'node:child_process'
|
|
6
7
|
import { readFileSync, existsSync, mkdirSync, writeFileSync, statSync } from 'node:fs'
|
|
@@ -11,6 +12,7 @@ import { homedir } from 'node:os'
|
|
|
11
12
|
import { loginPage } from './login-page.js'
|
|
12
13
|
import { listenOrExit } from './listen.js'
|
|
13
14
|
import { installConnectionReaper } from './reaper.js'
|
|
15
|
+
import { postedSessionWeb, SessionWebError } from './session-web.js'
|
|
14
16
|
|
|
15
17
|
export type PublicConfig = { password: string; tls: { cert: string; key: string } | null }
|
|
16
18
|
function argFlag(name: string): string | undefined {
|
|
@@ -107,7 +109,7 @@ export function resolveDistDir(): string {
|
|
|
107
109
|
return join(pkgRoot, '..', 'spec-dashboard', 'dist')
|
|
108
110
|
}
|
|
109
111
|
|
|
110
|
-
export type GatewayOpts = { publicPort: number; upstreamPort: number; password: string; tls: { cert: string; key: string } | null; distDir: string; host?: string; label?: string; onBindFail?: () => void }
|
|
112
|
+
export type GatewayOpts = { publicPort: number; upstreamPort: number; password: string; tls: { cert: string; key: string } | null; distDir: string; host?: string; label?: string; onBindFail?: () => void; projectRoot?: string }
|
|
111
113
|
|
|
112
114
|
export function startGateway(opts: GatewayOpts): void {
|
|
113
115
|
// gated ONLY when a password is set; otherwise the login layer doesn't exist and the dashboard is served open.
|
|
@@ -133,6 +135,7 @@ export function startGateway(opts: GatewayOpts): void {
|
|
|
133
135
|
res.writeHead(302, { Location: '/login' }); return res.end()
|
|
134
136
|
}
|
|
135
137
|
}
|
|
138
|
+
if (proxySessionWeb(req, res, req.url || '/', opts.projectRoot)) return
|
|
136
139
|
if (url.startsWith('/api')) return proxyHttp(req, res, opts.upstreamPort)
|
|
137
140
|
return serveStatic(req, res, opts.distDir, url)
|
|
138
141
|
}
|
|
@@ -144,6 +147,7 @@ export function startGateway(opts: GatewayOpts): void {
|
|
|
144
147
|
|
|
145
148
|
server.on('upgrade', (req, socket, head) => {
|
|
146
149
|
if (gated && !isAuthed(req, token, cookieName)) { socket.destroy(); return }
|
|
150
|
+
if (proxySessionWebUpgrade(req, socket, head, req.url || '/', opts.projectRoot)) return
|
|
147
151
|
const up = net.connect(opts.upstreamPort, '127.0.0.1', () => {
|
|
148
152
|
up.write(`${req.method} ${req.url} HTTP/1.1\r\n` + rawHeaders(req))
|
|
149
153
|
if (head && head.length) up.write(head)
|
|
@@ -207,7 +211,7 @@ const wantsGzip = (req: http.IncomingMessage) => /\bgzip\b/.test(String(req.head
|
|
|
207
211
|
// stream-gzipping compressible bodies (measured: the board JSON rides down at under a third).
|
|
208
212
|
// `path` and `headers` optionally override routing inputs (the host gateway strips its /p/:projectId
|
|
209
213
|
// prefix and gateway cookies); transport ownership stays here once. Defaults pass the request through.
|
|
210
|
-
export function proxyHttp(req: http.IncomingMessage, res: http.ServerResponse, upstreamPort: number, path?: string, headers: http.OutgoingHttpHeaders = req.headers) {
|
|
214
|
+
export function proxyHttp(req: http.IncomingMessage, res: http.ServerResponse, upstreamPort: number, path?: string, headers: http.OutgoingHttpHeaders = req.headers, unavailableMessage = 'upstream unreachable', upstreamHost = '127.0.0.1') {
|
|
211
215
|
let upstreamResponse: http.IncomingMessage | null = null
|
|
212
216
|
let transform: ReturnType<typeof createGzip> | null = null
|
|
213
217
|
let settled = false
|
|
@@ -270,11 +274,11 @@ export function proxyHttp(req: http.IncomingMessage, res: http.ServerResponse, u
|
|
|
270
274
|
removeDownstreamListeners()
|
|
271
275
|
destroyUpstream()
|
|
272
276
|
if (res.destroyed) return
|
|
273
|
-
if (!res.headersSent) { res.writeHead(502); res.end(
|
|
277
|
+
if (!res.headersSent) { res.writeHead(502); res.end(unavailableMessage) }
|
|
274
278
|
else res.destroy()
|
|
275
279
|
}
|
|
276
280
|
|
|
277
|
-
const up = http.request({ host:
|
|
281
|
+
const up = http.request({ host: upstreamHost, port: upstreamPort, path: path ?? req.url, method: req.method, headers }, (received) => {
|
|
278
282
|
if (settled || res.destroyed) { received.destroy(); up.destroy(); return }
|
|
279
283
|
upstreamResponse = received
|
|
280
284
|
received.once('aborted', failFromUpstream)
|
|
@@ -306,6 +310,95 @@ export function proxyHttp(req: http.IncomingMessage, res: http.ServerResponse, u
|
|
|
306
310
|
req.pipe(up)
|
|
307
311
|
}
|
|
308
312
|
|
|
313
|
+
type WebRoute = { sessionId: string; key: string; tail: string; query: string }
|
|
314
|
+
|
|
315
|
+
function webRoute(raw: string): WebRoute | null {
|
|
316
|
+
const q = raw.indexOf('?')
|
|
317
|
+
const path = q >= 0 ? raw.slice(0, q) : raw
|
|
318
|
+
const match = path.match(/^\/web\/([^/]+)\/([A-Za-z0-9_-]+)(\/.*)?$/)
|
|
319
|
+
if (!match) return null
|
|
320
|
+
return { sessionId: decodeURIComponent(match[1]), key: match[2], tail: match[3] || '/', query: q >= 0 ? raw.slice(q) : '' }
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
function sessionWebTarget(route: WebRoute, projectRoot?: string): { port: number; host: string; connectHost: string; path: string } {
|
|
324
|
+
const endpoint = postedSessionWeb(route.sessionId, route.key, projectRoot)
|
|
325
|
+
const base = endpoint.pathname.endsWith('/') ? endpoint.pathname : `${endpoint.pathname}/`
|
|
326
|
+
const path = route.tail === '/' ? endpoint.pathname : `${base}${route.tail.slice(1)}`
|
|
327
|
+
return { port: Number(endpoint.port), host: endpoint.host, connectHost: endpoint.hostname.replace(/^\[|\]$/g, ''), path: `${path}${route.query}` }
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
function withoutGatewayCookies(cookie: string | undefined): string {
|
|
331
|
+
return (cookie ?? '').split(';').map((part) => part.trim())
|
|
332
|
+
.filter((part) => part && !/^spex_(admin|proj|auth)_/i.test(part.slice(0, Math.max(part.indexOf('='), 0))))
|
|
333
|
+
.join('; ')
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
function webHeaders(req: http.IncomingMessage, host: string): http.OutgoingHttpHeaders {
|
|
337
|
+
const headers: http.OutgoingHttpHeaders = { ...req.headers, host }
|
|
338
|
+
const kept = withoutGatewayCookies(req.headers.cookie)
|
|
339
|
+
if (kept) headers.cookie = kept; else delete headers.cookie
|
|
340
|
+
return headers
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
function webRawHeaders(req: http.IncomingMessage, host: string): string {
|
|
344
|
+
let out = ''
|
|
345
|
+
let wroteHost = false
|
|
346
|
+
for (let i = 0; i < req.rawHeaders.length; i += 2) {
|
|
347
|
+
const name = req.rawHeaders[i]
|
|
348
|
+
const value = req.rawHeaders[i + 1]
|
|
349
|
+
if (name.toLowerCase() === 'host') { out += `Host: ${host}\r\n`; wroteHost = true; continue }
|
|
350
|
+
if (name.toLowerCase() === 'cookie') {
|
|
351
|
+
const kept = withoutGatewayCookies(value)
|
|
352
|
+
if (kept) out += `Cookie: ${kept}\r\n`
|
|
353
|
+
continue
|
|
354
|
+
}
|
|
355
|
+
out += `${name}: ${value}\r\n`
|
|
356
|
+
}
|
|
357
|
+
return `${out}${wroteHost ? '' : `Host: ${host}\r\n`}\r\n`
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
function sessionWebFailure(res: http.ServerResponse, error: unknown): void {
|
|
361
|
+
if (error instanceof SessionWebError) {
|
|
362
|
+
res.writeHead(error.status, { 'Content-Type': 'text/plain; charset=utf-8', 'Cache-Control': 'no-store' })
|
|
363
|
+
res.end(error.message)
|
|
364
|
+
return
|
|
365
|
+
}
|
|
366
|
+
console.error(`[web] route failed: ${error instanceof Error ? error.message : String(error)}`)
|
|
367
|
+
res.writeHead(500, { 'Content-Type': 'text/plain; charset=utf-8', 'Cache-Control': 'no-store' })
|
|
368
|
+
res.end('web preview failed')
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
// A posted URL selects a loopback service; the gateway owns only the prefix and transport. It never opens
|
|
372
|
+
// an endpoint until a browser asks for this route, so each request observes the service's current bytes.
|
|
373
|
+
export function proxySessionWeb(req: http.IncomingMessage, res: http.ServerResponse, raw = req.url || '/', projectRoot?: string): boolean {
|
|
374
|
+
const route = webRoute(raw)
|
|
375
|
+
if (!route) return false
|
|
376
|
+
try {
|
|
377
|
+
const target = sessionWebTarget(route, projectRoot)
|
|
378
|
+
proxyHttp(req, res, target.port, target.path, webHeaders(req, target.host), 'posted web service is unavailable', target.connectHost)
|
|
379
|
+
} catch (error) { sessionWebFailure(res, error) }
|
|
380
|
+
return true
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
export function proxySessionWebUpgrade(req: http.IncomingMessage, socket: Duplex, head: Buffer, raw = req.url || '/', projectRoot?: string): boolean {
|
|
384
|
+
const route = webRoute(raw)
|
|
385
|
+
if (!route) return false
|
|
386
|
+
let target: { port: number; host: string; connectHost: string; path: string }
|
|
387
|
+
try { target = sessionWebTarget(route, projectRoot) }
|
|
388
|
+
catch { socket.destroy(); return true }
|
|
389
|
+
const upstream = net.connect(target.port, target.connectHost, () => {
|
|
390
|
+
upstream.write(`${req.method} ${target.path} HTTP/1.1\r\n` + webRawHeaders(req, target.host))
|
|
391
|
+
if (head.length) upstream.write(head)
|
|
392
|
+
socket.pipe(upstream); upstream.pipe(socket)
|
|
393
|
+
})
|
|
394
|
+
const bail = () => { socket.destroy(); upstream.destroy() }
|
|
395
|
+
socket.on('error', bail); upstream.on('error', bail)
|
|
396
|
+
socket.on('end', bail); upstream.on('end', bail)
|
|
397
|
+
socket.once('close', () => upstream.destroy())
|
|
398
|
+
upstream.once('close', () => socket.destroy())
|
|
399
|
+
return true
|
|
400
|
+
}
|
|
401
|
+
|
|
309
402
|
// serve the built dashboard (vite dist). Unknown non-file paths fall back to index.html (SPA). Path
|
|
310
403
|
// traversal is blocked by normalising and confining to distDir. Compressible files ship gzipped, memoized
|
|
311
404
|
// per (path, mtime) — a dist file is immutable per build, so each is compressed once, not per request.
|
|
@@ -359,10 +452,10 @@ export function ensureDashboardBuilt(repoRoot: string, distDir: string): void {
|
|
|
359
452
|
// It serves the bundled dist and proxies /api + the terminal socket to a separately-run `spex serve`.
|
|
360
453
|
// This is the post-install replacement for the dogfood-only `npm run web` (a vite dev server an
|
|
361
454
|
// installed user has no source tree for). See [[packaging]].
|
|
362
|
-
export function serveDashboardLocal(opts: { port: number; apiPort: number; host?: string }): void {
|
|
455
|
+
export function serveDashboardLocal(opts: { port: number; apiPort: number; host?: string; projectRoot?: string }): void {
|
|
363
456
|
const pkgRoot = fileURLToPath(new URL('..', import.meta.url))
|
|
364
457
|
const distDir = resolveDistDir()
|
|
365
458
|
ensureDashboardBuilt(join(pkgRoot, '..'), distDir) // bundled dist already has index.html → returns at once
|
|
366
459
|
console.log(`[dashboard] serving ${distDir.endsWith('dashboard-dist') ? 'bundled' : 'monorepo'} build, /api → backend :${opts.apiPort}`)
|
|
367
|
-
startGateway({ host: opts.host ?? '127.0.0.1', publicPort: opts.port, upstreamPort: opts.apiPort, password: '', tls: null, distDir, label: 'dashboard' })
|
|
460
|
+
startGateway({ host: opts.host ?? '127.0.0.1', publicPort: opts.port, upstreamPort: opts.apiPort, password: '', tls: null, distDir, label: 'dashboard', projectRoot: opts.projectRoot })
|
|
368
461
|
}
|