spexcode 0.6.1 → 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.
Files changed (72) hide show
  1. package/README.md +97 -149
  2. package/package.json +1 -1
  3. package/spec-cli/hooks/harness.sh +26 -6
  4. package/spec-cli/src/anchors.ts +27 -5
  5. package/spec-cli/src/cli.ts +171 -28
  6. package/spec-cli/src/client.ts +112 -13
  7. package/spec-cli/src/delivery-queue.ts +62 -4
  8. package/spec-cli/src/execution-trace.ts +444 -0
  9. package/spec-cli/src/gateway-hub.ts +10 -8
  10. package/spec-cli/src/gateway.ts +99 -6
  11. package/spec-cli/src/git.ts +477 -101
  12. package/spec-cli/src/graph.ts +56 -6
  13. package/spec-cli/src/graphCache.ts +86 -17
  14. package/spec-cli/src/graphStream.ts +1 -1
  15. package/spec-cli/src/guide.ts +93 -4
  16. package/spec-cli/src/harness-select.ts +2 -2
  17. package/spec-cli/src/harness.ts +344 -66
  18. package/spec-cli/src/help.ts +22 -10
  19. package/spec-cli/src/host-resources.ts +62 -7
  20. package/spec-cli/src/identity-presets.js +16 -6
  21. package/spec-cli/src/index.ts +101 -13
  22. package/spec-cli/src/layout.ts +116 -33
  23. package/spec-cli/src/lint.ts +37 -7
  24. package/spec-cli/src/materialize.ts +10 -5
  25. package/spec-cli/src/plugin-harness.ts +10 -10
  26. package/spec-cli/src/reviews.ts +57 -28
  27. package/spec-cli/src/session-execution.ts +68 -0
  28. package/spec-cli/src/session-files.ts +121 -0
  29. package/spec-cli/src/session-follow.ts +4 -4
  30. package/spec-cli/src/session-reparent.ts +33 -0
  31. package/spec-cli/src/session-timeline.ts +163 -14
  32. package/spec-cli/src/session-web.ts +135 -0
  33. package/spec-cli/src/sessions.ts +584 -104
  34. package/spec-cli/src/uninstall.ts +1 -1
  35. package/spec-cli/templates/spec/project/.plugins/core/comment-altitude/spec.md +2 -21
  36. package/spec-cli/templates/spec/project/.plugins/core/spec.md +12 -13
  37. package/spec-cli/templates/spec/project/.plugins/prompts/atomic-landing/spec.md +5 -22
  38. package/spec-cli/templates/spec/project/.plugins/prompts/forge-link/spec.md +2 -1
  39. package/spec-cli/templates/spec/project/.plugins/prompts/memory-hygiene/spec.md +4 -6
  40. package/spec-cli/templates/spec/project/.plugins/prompts/reproduce-before-fix/spec.md +3 -9
  41. package/spec-dashboard/dist/assets/{App-F9uaAVcH.js → App-B6IbFbGu.js} +2 -2
  42. package/spec-dashboard/dist/assets/Dashboard-_sDk7Rcg.js +27 -0
  43. package/spec-dashboard/dist/assets/EvalsPage-BT357Lyt.js +2 -0
  44. package/spec-dashboard/dist/assets/{IssuesPage-CuKLFhH3.js → IssuesPage-BG9rNOgV.js} +1 -1
  45. package/spec-dashboard/dist/assets/MobileApp-D9yPMR3A.js +2 -0
  46. package/spec-dashboard/dist/assets/{Modal-CQgYymmr.js → Modal-B3EcgeA3.js} +1 -1
  47. package/spec-dashboard/dist/assets/{PageScroll-hT7UTLvD.js → PageScroll-CNZOugWV.js} +1 -1
  48. package/spec-dashboard/dist/assets/{ProjectsPage-CtXxakF9.js → ProjectsPage-C2CRHmvs.js} +1 -1
  49. package/spec-dashboard/dist/assets/SessionInterface-DOEjuT3l.js +40 -0
  50. package/spec-dashboard/dist/assets/{SessionWindow-CixDi4PI.js → SessionWindow-1nYgZ4CL.js} +1 -1
  51. package/spec-dashboard/dist/assets/Settings-7WeTJn0V.js +1 -0
  52. package/spec-dashboard/dist/assets/Thread-CEbzAFQn.js +13 -0
  53. package/spec-dashboard/dist/assets/data-SlvxNA2r.js +1 -0
  54. package/spec-dashboard/dist/assets/{index-DrVao0Ep.js → index-DLVeOHL-.js} +10 -10
  55. package/spec-dashboard/dist/assets/index-DYLGfPjx.css +1 -0
  56. package/spec-dashboard/dist/assets/{launch-BBH02b1v.js → launch-yxCYr64x.js} +29 -29
  57. package/spec-dashboard/dist/assets/sessionSurface--7l69h4F.js +1 -0
  58. package/spec-dashboard/dist/index.html +2 -2
  59. package/spec-eval/src/cli.ts +6 -1
  60. package/spec-eval/src/evaltab.ts +7 -5
  61. package/spec-eval/src/freshness.ts +383 -104
  62. package/spec-eval/src/scenariofresh.ts +2 -2
  63. package/spec-eval/src/sessioneval.ts +126 -132
  64. package/spec-dashboard/dist/assets/Dashboard-Ba_jhxp1.js +0 -27
  65. package/spec-dashboard/dist/assets/EvalsPage-FixoOg_n.js +0 -2
  66. package/spec-dashboard/dist/assets/MobileApp-CHgEHORJ.js +0 -2
  67. package/spec-dashboard/dist/assets/SessionInterface-Bpie-9fs.js +0 -39
  68. package/spec-dashboard/dist/assets/Settings-C2MsucfE.js +0 -1
  69. package/spec-dashboard/dist/assets/Thread-C6Go8HRh.js +0 -13
  70. package/spec-dashboard/dist/assets/data-B-RQmit6.js +0 -1
  71. package/spec-dashboard/dist/assets/index-CsI8DElI.css +0 -1
  72. package/spec-dashboard/dist/assets/terminalFont-C6oGNKrf.js +0 -1
@@ -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('upstream unreachable') }
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: '127.0.0.1', port: upstreamPort, path: path ?? req.url, method: req.method, headers }, (received) => {
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
  }