@skitterbyte/skitterspec 17.0.0 → 19.0.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/MIGRATION.md +260 -10
- package/README.md +53 -4
- package/assets/claude-md-section.md +48 -2
- package/assets/commands/spec-connect.md +2 -2
- package/assets/commands/spec-live.md +2 -2
- package/assets/core/env.config.json.example +9 -3
- package/assets/core/env.config.md +102 -30
- package/assets/core/gating.config.json.example +4 -0
- package/assets/core/gating.config.md +81 -0
- package/assets/review/page.html +1501 -0
- package/assets/rules/spec-planning.md +224 -15
- package/assets/rules/spec-reports.md +269 -0
- package/assets/skills/spec/SKILL.md +63 -12
- package/assets/skills/spec-bug/SKILL.md +134 -26
- package/assets/skills/spec-cancel/SKILL.md +85 -6
- package/assets/skills/spec-complete/SKILL.md +109 -20
- package/assets/skills/spec-diff/SKILL.md +564 -0
- package/assets/skills/spec-hotfix/SKILL.md +143 -21
- package/assets/skills/spec-init/SKILL.md +49 -9
- package/assets/skills/spec-next/SKILL.md +289 -7
- package/assets/skills/spec-review/SKILL.md +45 -9
- package/assets/skills/spec-reviewed/SKILL.md +241 -0
- package/assets/skills/spec-start/SKILL.md +323 -66
- package/assets/skills/spec-to-main/SKILL.md +42 -20
- package/package.json +11 -7
- package/src/cli.js +1710 -80
- package/src/env/building.js +143 -0
- package/src/env/classify.js +91 -0
- package/src/env/config.js +57 -9
- package/src/env/provision.js +192 -19
- package/src/env/proxy.js +34 -1
- package/src/env/render.js +3 -12
- package/src/env/resolve.js +296 -9
- package/src/env/review.js +1329 -0
- package/src/env/serve.js +549 -0
- package/src/env/teardown.js +13 -6
- package/src/gating.js +155 -0
- package/src/init.js +124 -2
- package/src/prompts.js +10 -1
- package/LICENSE +0 -21
package/src/env/serve.js
ADDED
|
@@ -0,0 +1,549 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* `spec-env review serve` — every spec's diff on one local HTTP server,
|
|
5
|
+
* **rendered per request**.
|
|
6
|
+
*
|
|
7
|
+
* The page-on-disk path (`spec-env review <spec>`) writes a photograph of one
|
|
8
|
+
* moment: true when it was taken, and overwritten by the next render. A served
|
|
9
|
+
* page cannot be out of date, because there is no artefact between the git
|
|
10
|
+
* objects and the response. That is the whole reason this exists, and it is why
|
|
11
|
+
* nothing here reads or writes `.spec-env/reviews/` — the file path and the
|
|
12
|
+
* served path are two answers to the same question, and keeping them
|
|
13
|
+
* independent is what stops one quietly becoming the other's cache.
|
|
14
|
+
*
|
|
15
|
+
* Dependency-free, in the shape of `proxy.js`: pure functions for routing and
|
|
16
|
+
* the index, an injectable render callback, and a `require.main` entry point so
|
|
17
|
+
* the CLI can supervise it as a detached process exactly as it supervises the
|
|
18
|
+
* proxy.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
const http = require('node:http')
|
|
22
|
+
const fs = require('node:fs')
|
|
23
|
+
const path = require('node:path')
|
|
24
|
+
const crypto = require('node:crypto')
|
|
25
|
+
|
|
26
|
+
const {
|
|
27
|
+
liveWorktreePaths,
|
|
28
|
+
allSpecs,
|
|
29
|
+
resolveSpec,
|
|
30
|
+
resolveBaseBranch,
|
|
31
|
+
} = require('./resolve.js')
|
|
32
|
+
const { loadEnvConfig } = require('./config.js')
|
|
33
|
+
const {
|
|
34
|
+
rawGitReader,
|
|
35
|
+
collectReview,
|
|
36
|
+
renderReviewPage,
|
|
37
|
+
renderReviewBlock,
|
|
38
|
+
reviewOutPath,
|
|
39
|
+
validateNotesBlob,
|
|
40
|
+
readPending,
|
|
41
|
+
writePending,
|
|
42
|
+
addPending,
|
|
43
|
+
} = require('./review.js')
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* A random path prefix, minted only when binding beyond loopback.
|
|
47
|
+
*
|
|
48
|
+
* An unguessable path is the whole guard: bound to 0.0.0.0 the server is
|
|
49
|
+
* reachable by anything on the network, and a diff of unreleased work is not
|
|
50
|
+
* something to hand to whoever else is on the coffee-shop wifi. 48 bits from
|
|
51
|
+
* `crypto` — not `Math.random`, which is seeded predictably enough to enumerate.
|
|
52
|
+
*/
|
|
53
|
+
function mintToken() {
|
|
54
|
+
return crypto.randomBytes(6).toString('hex')
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Parse a request URL into what to serve. PURE — no git, no fs, no server.
|
|
59
|
+
*
|
|
60
|
+
* With a token, every path must carry it as the first segment. A wrong or
|
|
61
|
+
* missing token is `notfound`, never a redirect: a redirect would confirm the
|
|
62
|
+
* server is here to anybody probing ports, which is the one thing the token is
|
|
63
|
+
* bought to prevent.
|
|
64
|
+
*/
|
|
65
|
+
function routeFor(url, { token = null } = {}) {
|
|
66
|
+
const [rawPath, rawQuery] = String(url || '/').split('?')
|
|
67
|
+
const query = new URLSearchParams(rawQuery || '')
|
|
68
|
+
let segments = rawPath.split('/').filter(Boolean).map(decodeURIComponent)
|
|
69
|
+
|
|
70
|
+
if (token) {
|
|
71
|
+
if (segments[0] !== token) return { kind: 'notfound' }
|
|
72
|
+
segments = segments.slice(1)
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
if (segments.length === 0) return { kind: 'index' }
|
|
76
|
+
if (segments.length > 1) return { kind: 'notfound' }
|
|
77
|
+
|
|
78
|
+
// `?branch` and `?branch=1` both mean the whole-spec view; `?branch=0` does
|
|
79
|
+
// not, so a link can turn it off as well as on.
|
|
80
|
+
const raw = query.get('branch')
|
|
81
|
+
const branch = raw !== null && raw !== '0' && raw !== 'false'
|
|
82
|
+
return { kind: 'spec', spec: segments[0], branch }
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function escapeHtml(s) {
|
|
86
|
+
return String(s).replace(
|
|
87
|
+
/[&<>"]/g,
|
|
88
|
+
(c) => ({ '&': '&', '<': '<', '>': '>', '"': '"' })[c],
|
|
89
|
+
)
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* The index page. PURE — takes already-resolved entries and returns HTML.
|
|
94
|
+
*
|
|
95
|
+
* Deliberately plain: the diff page is the designed surface, and this is a
|
|
96
|
+
* doorway. It carries the same theme handling as the page template (a light
|
|
97
|
+
* palette on bare `:root`, redefined under `prefers-color-scheme`) so the two
|
|
98
|
+
* do not disagree when opened side by side.
|
|
99
|
+
*/
|
|
100
|
+
function renderIndex(entries, { token = null } = {}) {
|
|
101
|
+
const prefix = token ? `/${token}` : ''
|
|
102
|
+
const rows = entries
|
|
103
|
+
.map((e) => {
|
|
104
|
+
const t = e.totals || { files: 0, additions: 0, deletions: 0 }
|
|
105
|
+
return (
|
|
106
|
+
`<li><a href="${prefix}/${encodeURIComponent(e.folder)}">${escapeHtml(e.folder)}</a>` +
|
|
107
|
+
`<span class="b">${escapeHtml(e.branch || '')}</span>` +
|
|
108
|
+
`<span class="n">${t.files} file${t.files === 1 ? '' : 's'}</span>` +
|
|
109
|
+
`<span class="a">+${t.additions}</span><span class="d">-${t.deletions}</span>` +
|
|
110
|
+
`<a class="w" href="${prefix}/${encodeURIComponent(e.folder)}?branch=1">whole spec</a></li>`
|
|
111
|
+
)
|
|
112
|
+
})
|
|
113
|
+
.join('\n')
|
|
114
|
+
|
|
115
|
+
// An empty list is an ordinary state — no spec has a worktree yet — and says
|
|
116
|
+
// so rather than rendering a bare page that reads as a failure.
|
|
117
|
+
const body = entries.length
|
|
118
|
+
? `<ul>\n${rows}\n</ul>`
|
|
119
|
+
: '<p class="empty">No spec has a worktree. Run <code>/spec-start <name></code> to provision one.</p>'
|
|
120
|
+
|
|
121
|
+
return `<!doctype html>
|
|
122
|
+
<html lang="en">
|
|
123
|
+
<head>
|
|
124
|
+
<meta charset="utf-8">
|
|
125
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
126
|
+
<title>spec diffs</title>
|
|
127
|
+
<style>
|
|
128
|
+
:root { --bg:#fff; --fg:#1a1a1a; --dim:#6b6b6b; --line:#e3e3e3; --add:#0a7b34; --del:#b3261e; --link:#0b5fbd }
|
|
129
|
+
@media (prefers-color-scheme: dark) {
|
|
130
|
+
:root:not([data-theme="light"]) { --bg:#16181c; --fg:#e6e6e6; --dim:#9a9a9a; --line:#2c2f36; --add:#4ec97a; --del:#ff7b72; --link:#7cb7ff }
|
|
131
|
+
}
|
|
132
|
+
* { box-sizing:border-box }
|
|
133
|
+
body { margin:0; padding:2rem 1.25rem; background:var(--bg); color:var(--fg);
|
|
134
|
+
font:15px/1.5 ui-sans-serif,system-ui,-apple-system,Segoe UI,Roboto,sans-serif }
|
|
135
|
+
main { max-width:52rem; margin:0 auto }
|
|
136
|
+
h1 { font-size:1.1rem; margin:0 0 1.25rem; font-weight:600 }
|
|
137
|
+
ul { list-style:none; margin:0; padding:0 }
|
|
138
|
+
li { display:flex; flex-wrap:wrap; gap:.75rem; align-items:baseline;
|
|
139
|
+
padding:.7rem 0; border-top:1px solid var(--line) }
|
|
140
|
+
a { color:var(--link); text-decoration:none; font-weight:600 }
|
|
141
|
+
a:hover { text-decoration:underline }
|
|
142
|
+
.b { color:var(--dim); font-family:ui-monospace,SFMono-Regular,Menlo,monospace; font-size:.85em }
|
|
143
|
+
.n { color:var(--dim); margin-left:auto }
|
|
144
|
+
.a { color:var(--add) } .d { color:var(--del) }
|
|
145
|
+
.w { font-weight:400; font-size:.85em }
|
|
146
|
+
.empty { color:var(--dim) }
|
|
147
|
+
code { font-family:ui-monospace,SFMono-Regular,Menlo,monospace }
|
|
148
|
+
</style>
|
|
149
|
+
</head>
|
|
150
|
+
<body>
|
|
151
|
+
<main>
|
|
152
|
+
<h1>spec diffs</h1>
|
|
153
|
+
${body}
|
|
154
|
+
</main>
|
|
155
|
+
</body>
|
|
156
|
+
</html>
|
|
157
|
+
`
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Resolve the specs worth listing: every spec with a worktree of its own.
|
|
162
|
+
*
|
|
163
|
+
* A spec with no worktree is OMITTED, not listed as an error. An unstarted spec
|
|
164
|
+
* has nothing to diff, which is the ordinary state of most of `specs/` — and a
|
|
165
|
+
* doorway that lists them as failures would be wrong about a healthy repo.
|
|
166
|
+
*/
|
|
167
|
+
function servableSpecs(dir, config, git) {
|
|
168
|
+
const worktreePaths = liveWorktreePaths(git)
|
|
169
|
+
return allSpecs(dir, config, worktreePaths)
|
|
170
|
+
.filter((s) => {
|
|
171
|
+
const wt = s.worktreePath
|
|
172
|
+
return wt && wt !== dir && worktreePaths.has(path.resolve(wt))
|
|
173
|
+
})
|
|
174
|
+
.sort((a, b) => a.folder.localeCompare(b.folder))
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Render one spec's page, now. Returns null when the spec has no worktree —
|
|
179
|
+
* there is nothing to diff, and that is a 404 rather than an error page.
|
|
180
|
+
*
|
|
181
|
+
* `mode`/`ref` follow the same rules the CLI applies, including the clean-tree
|
|
182
|
+
* fallback: a committed phase shows the branch range rather than an empty page.
|
|
183
|
+
*/
|
|
184
|
+
/**
|
|
185
|
+
* Take a review pass for one spec and put it in the holding area. Returns the
|
|
186
|
+
* code that claims it, or an error to relay verbatim.
|
|
187
|
+
*
|
|
188
|
+
* IT WRITES ONE FILE, and it is not the review. Everything a POST can reach is
|
|
189
|
+
* the pending store; the sidecar the review actually reads is only ever written
|
|
190
|
+
* by a claim, which needs a person to read six digits off the screen. That is
|
|
191
|
+
* the whole containment, and it is why this endpoint can be open to the network
|
|
192
|
+
* at all.
|
|
193
|
+
*
|
|
194
|
+
* The `render` field keys superseding: a second pass from the same page render
|
|
195
|
+
* replaces the first unclaimed one, so the code on the screen is always the
|
|
196
|
+
* pass on the screen. It comes from the blob's own `generatedAt`, which the
|
|
197
|
+
* page mints per render — absent, the pass simply never supersedes anything,
|
|
198
|
+
* which is the harmless direction.
|
|
199
|
+
*/
|
|
200
|
+
function receivePass(dir, config, spec, blob) {
|
|
201
|
+
if (!spec || !fs.existsSync(spec.worktreePath)) return null
|
|
202
|
+
let parsed
|
|
203
|
+
try {
|
|
204
|
+
parsed = validateNotesBlob(blob, spec.folder)
|
|
205
|
+
} catch (err) {
|
|
206
|
+
// The engine's own message, relayed rather than paraphrased — it names the
|
|
207
|
+
// entry that was wrong, so there is nothing for the reader to guess.
|
|
208
|
+
return { error: err.message }
|
|
209
|
+
}
|
|
210
|
+
const out = reviewOutPath(dir, spec.folder, null)
|
|
211
|
+
const read = readPending(out, spec.folder)
|
|
212
|
+
if (read.corrupt) {
|
|
213
|
+
// Refuse rather than write over passes we could not read. Same rule the
|
|
214
|
+
// notes sidecar follows, for the same reason: what is in there is someone's
|
|
215
|
+
// work and overwriting it is unrecoverable.
|
|
216
|
+
return { error: 'the pending store is not readable JSON — move it aside' }
|
|
217
|
+
}
|
|
218
|
+
const added = addPending(read.pending, {
|
|
219
|
+
blob,
|
|
220
|
+
at: new Date().toISOString(),
|
|
221
|
+
render: blob && blob.generatedAt ? String(blob.generatedAt) : null,
|
|
222
|
+
})
|
|
223
|
+
writePending(out, added.pending)
|
|
224
|
+
return { code: added.code, accepted: parsed.accepted.length, comments: parsed.comments.length }
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
function renderSpecPage(dir, config, spec, { branch = false } = {}) {
|
|
228
|
+
if (!spec || !fs.existsSync(spec.worktreePath)) return null
|
|
229
|
+
|
|
230
|
+
const git = rawGitReader(spec.worktreePath)
|
|
231
|
+
const trimmed = (argv) => {
|
|
232
|
+
const out = git(argv)
|
|
233
|
+
return out == null ? null : String(out).trim() || null
|
|
234
|
+
}
|
|
235
|
+
const base = () => spec.baseRef || resolveBaseBranch(config, trimmed)
|
|
236
|
+
|
|
237
|
+
const now = new Date().toISOString()
|
|
238
|
+
let mode = 'working'
|
|
239
|
+
let ref = 'HEAD'
|
|
240
|
+
let baseName = null
|
|
241
|
+
let fellBack = false
|
|
242
|
+
|
|
243
|
+
if (branch) {
|
|
244
|
+
baseName = base()
|
|
245
|
+
const mergeBase = trimmed(['merge-base', baseName, 'HEAD'])
|
|
246
|
+
if (!mergeBase) return { html: null, error: `no merge-base between ${baseName} and HEAD` }
|
|
247
|
+
ref = mergeBase
|
|
248
|
+
mode = 'branch'
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
let data = collectReview({ spec, git, mode, ref, base: baseName, now })
|
|
252
|
+
|
|
253
|
+
if (!branch && data.totals.files === 0) {
|
|
254
|
+
const fallbackBase = base()
|
|
255
|
+
const mergeBase = trimmed(['merge-base', fallbackBase, 'HEAD'])
|
|
256
|
+
if (mergeBase) {
|
|
257
|
+
const wider = collectReview({
|
|
258
|
+
spec,
|
|
259
|
+
git,
|
|
260
|
+
mode: 'branch',
|
|
261
|
+
ref: mergeBase,
|
|
262
|
+
base: fallbackBase,
|
|
263
|
+
now,
|
|
264
|
+
fellBack: true,
|
|
265
|
+
})
|
|
266
|
+
if (wider.totals.files > 0) {
|
|
267
|
+
data = wider
|
|
268
|
+
fellBack = true
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
return {
|
|
274
|
+
html: renderReviewPage(data, { reviewHtml: renderReviewBlock(data.review) }),
|
|
275
|
+
totals: data.totals,
|
|
276
|
+
mode: data.mode,
|
|
277
|
+
fellBack,
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/**
|
|
282
|
+
* Counts for the index, without building a single patch.
|
|
283
|
+
*
|
|
284
|
+
* The index shows three numbers per spec, and `collectReview` would produce
|
|
285
|
+
* them as a by-product of splicing every patch of every spec — making the
|
|
286
|
+
* doorway far and away the most expensive page here. `--numstat` answers the
|
|
287
|
+
* same question in one call per spec; untracked files are counted separately
|
|
288
|
+
* because `git diff` cannot see them.
|
|
289
|
+
*/
|
|
290
|
+
function specSummary(spec) {
|
|
291
|
+
if (!spec || !fs.existsSync(spec.worktreePath)) return null
|
|
292
|
+
const git = rawGitReader(spec.worktreePath)
|
|
293
|
+
|
|
294
|
+
const totals = { files: 0, additions: 0, deletions: 0 }
|
|
295
|
+
const add = (row) => {
|
|
296
|
+
const [a, d] = row.split('\t')
|
|
297
|
+
totals.files += 1
|
|
298
|
+
// A binary file reports `-` for both; count the file, never guess its lines.
|
|
299
|
+
if (a === '-' || d === '-') return
|
|
300
|
+
totals.additions += Number(a) || 0
|
|
301
|
+
totals.deletions += Number(d) || 0
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
for (const row of String(git(['diff', '--numstat', 'HEAD']) || '')
|
|
305
|
+
.split('\n')
|
|
306
|
+
.filter(Boolean)) {
|
|
307
|
+
add(row)
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
// `git diff` cannot see untracked files, so each is measured the same way the
|
|
311
|
+
// page measures it — against /dev/null. One process per untracked file is what
|
|
312
|
+
// the page already pays; the index matching it exactly is worth more than the
|
|
313
|
+
// processes saved, because two different numbers for the same spec is a bug
|
|
314
|
+
// report waiting to happen.
|
|
315
|
+
for (const f of String(git(['ls-files', '--others', '--exclude-standard']) || '')
|
|
316
|
+
.split('\n')
|
|
317
|
+
.filter(Boolean)) {
|
|
318
|
+
const row = String(git(['diff', '--numstat', '--no-index', '/dev/null', f]) || '')
|
|
319
|
+
.split('\n')
|
|
320
|
+
.filter(Boolean)[0]
|
|
321
|
+
if (row) add(row)
|
|
322
|
+
else totals.files += 1
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
return totals
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
/**
|
|
329
|
+
* Build (but do not listen on) the server. `resolveEntries` and `render` are
|
|
330
|
+
* injected so this can be driven in a test without a git fixture, and so the
|
|
331
|
+
* module never has to reach back into the CLI.
|
|
332
|
+
*/
|
|
333
|
+
// A review pass is JSON written by a person, not a payload. Ten megabytes is
|
|
334
|
+
// far past any real review and far short of anything that could hurt — the cap
|
|
335
|
+
// exists so a body is REFUSED BEFORE IT IS PARSED, not so a number is tuned.
|
|
336
|
+
const MAX_PASS_BYTES = 1_000_000
|
|
337
|
+
|
|
338
|
+
/**
|
|
339
|
+
* Read a request body, refusing anything over the cap without buffering it all.
|
|
340
|
+
*
|
|
341
|
+
* The check is per-chunk rather than on the finished body: a cap applied after
|
|
342
|
+
* the fact has already done the thing it was meant to prevent.
|
|
343
|
+
*/
|
|
344
|
+
function readBody(req, limit = MAX_PASS_BYTES) {
|
|
345
|
+
return new Promise((resolve) => {
|
|
346
|
+
let size = 0
|
|
347
|
+
const chunks = []
|
|
348
|
+
let done = false
|
|
349
|
+
const finish = (value) => {
|
|
350
|
+
if (done) return
|
|
351
|
+
done = true
|
|
352
|
+
resolve(value)
|
|
353
|
+
}
|
|
354
|
+
req.on('data', (c) => {
|
|
355
|
+
size += c.length
|
|
356
|
+
if (size > limit) {
|
|
357
|
+
req.destroy()
|
|
358
|
+
return finish({ error: 'too large' })
|
|
359
|
+
}
|
|
360
|
+
chunks.push(c)
|
|
361
|
+
})
|
|
362
|
+
req.on('end', () => finish({ body: Buffer.concat(chunks).toString('utf8') }))
|
|
363
|
+
req.on('error', () => finish({ error: 'read failed' }))
|
|
364
|
+
})
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
function createReviewServer({ resolveEntries, render, receive = null, token = null }) {
|
|
368
|
+
return http.createServer((req, res) => {
|
|
369
|
+
const send = (code, body, type = 'text/html; charset=utf-8') => {
|
|
370
|
+
res.writeHead(code, { 'content-type': type, 'cache-control': 'no-store' })
|
|
371
|
+
res.end(body)
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
const route = routeFor(req.url, { token })
|
|
375
|
+
if (route.kind === 'notfound') return send(404, 'not found', 'text/plain; charset=utf-8')
|
|
376
|
+
|
|
377
|
+
// THE ONE WRITE PATH, and it writes to a holding area rather than to the
|
|
378
|
+
// review. What a stranger on the network can do with it is queue a pass
|
|
379
|
+
// that nobody will claim; the code is what decides whether it ever counts.
|
|
380
|
+
if (req.method === 'POST') {
|
|
381
|
+
// A write to the index is not a write to a spec, and answering it any
|
|
382
|
+
// differently from a GET would make this route an enumeration oracle.
|
|
383
|
+
if (route.kind !== 'spec' || !receive) {
|
|
384
|
+
return send(404, 'not found', 'text/plain; charset=utf-8')
|
|
385
|
+
}
|
|
386
|
+
readBody(req).then((read) => {
|
|
387
|
+
if (read.error) return send(413, read.error, 'text/plain; charset=utf-8')
|
|
388
|
+
let parsed
|
|
389
|
+
try {
|
|
390
|
+
parsed = JSON.parse(read.body)
|
|
391
|
+
} catch {
|
|
392
|
+
return send(400, 'not JSON', 'text/plain; charset=utf-8')
|
|
393
|
+
}
|
|
394
|
+
// The engine's own validator, and its own message. A pass arriving here
|
|
395
|
+
// is exactly as untrusted as one arriving through a clipboard.
|
|
396
|
+
const out = receive(route.spec, parsed)
|
|
397
|
+
if (!out) return send(404, 'not found', 'text/plain; charset=utf-8')
|
|
398
|
+
if (out.error) return send(422, out.error, 'text/plain; charset=utf-8')
|
|
399
|
+
return send(200, JSON.stringify({ code: out.code }), 'application/json; charset=utf-8')
|
|
400
|
+
})
|
|
401
|
+
return
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
try {
|
|
405
|
+
if (route.kind === 'index') return send(200, renderIndex(resolveEntries(), { token }))
|
|
406
|
+
const out = render(route.spec, { branch: route.branch })
|
|
407
|
+
if (!out) return send(404, 'not found', 'text/plain; charset=utf-8')
|
|
408
|
+
if (out.error) return send(409, out.error, 'text/plain; charset=utf-8')
|
|
409
|
+
return send(200, out.html)
|
|
410
|
+
} catch (err) {
|
|
411
|
+
// A render that throws is one spec's problem, not the server's — say so
|
|
412
|
+
// and stay up, so the other specs remain readable.
|
|
413
|
+
return send(500, `render failed: ${err.message}`, 'text/plain; charset=utf-8')
|
|
414
|
+
}
|
|
415
|
+
})
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
function startReviewServer(server, { port, host = '127.0.0.1' }) {
|
|
419
|
+
return new Promise((resolve, reject) => {
|
|
420
|
+
server.once('error', reject)
|
|
421
|
+
server.listen(port, host, () => resolve(server.address()))
|
|
422
|
+
})
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
/**
|
|
426
|
+
* The version of the package that OWNS a script — walk up to the nearest
|
|
427
|
+
* `package.json` from the script's own directory.
|
|
428
|
+
*
|
|
429
|
+
* WHY NOT just report the running CLI's version: the CLI and the daemon are
|
|
430
|
+
* routinely DIFFERENT PACKAGES. A superset distribution exposes the
|
|
431
|
+
* `skitterspec` binary from its own package while `daemonScript` resolves the
|
|
432
|
+
* daemon out of `node_modules/@skitterbyte/skitterspec`, so comparing one
|
|
433
|
+
* against the other reports a mismatch that is never true and never goes away —
|
|
434
|
+
* which, wired to a restart, is a server replaced on every single render.
|
|
435
|
+
* Resolving from the script means both halves of the comparison are the same
|
|
436
|
+
* question asked at two different times.
|
|
437
|
+
*
|
|
438
|
+
* Returns `null` rather than throwing or guessing. An unreadable package, an
|
|
439
|
+
* absent one, a `version` that is not a string: each is a state where the
|
|
440
|
+
* lookup could not see, and `staleServer` routes all of them to `unknown`.
|
|
441
|
+
*/
|
|
442
|
+
function engineVersionFor(scriptPath) {
|
|
443
|
+
if (!scriptPath) return null
|
|
444
|
+
let dir = path.dirname(path.resolve(scriptPath))
|
|
445
|
+
// Bounded: stop at the filesystem root rather than trusting a break.
|
|
446
|
+
for (let i = 0; i < 40; i++) {
|
|
447
|
+
const candidate = path.join(dir, 'package.json')
|
|
448
|
+
try {
|
|
449
|
+
const parsed = JSON.parse(fs.readFileSync(candidate, 'utf-8'))
|
|
450
|
+
if (typeof parsed.version === 'string' && parsed.version) return parsed.version
|
|
451
|
+
return null
|
|
452
|
+
} catch {}
|
|
453
|
+
const up = path.dirname(dir)
|
|
454
|
+
if (up === dir) return null
|
|
455
|
+
dir = up
|
|
456
|
+
}
|
|
457
|
+
return null
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
/**
|
|
461
|
+
* Is the running server executing the engine this one would start? Pure.
|
|
462
|
+
*
|
|
463
|
+
* THREE STATES, NOT TWO (`.claude/rules/negative-checks.md` rule 4). The third
|
|
464
|
+
* is what this whole feature turns on: a server that recorded no version — every
|
|
465
|
+
* server started before this shipped — is not stale, it is UNANSWERABLE, and the
|
|
466
|
+
* caller must route it to inaction. Reading a missing record as "different, so
|
|
467
|
+
* stale" would restart every healthy server on the first render after upgrading,
|
|
468
|
+
* which is the accusation this check exists to avoid making.
|
|
469
|
+
*
|
|
470
|
+
* WHAT WOULD BLIND THIS: `recorded` is absent on a pre-feature server, and
|
|
471
|
+
* `running` is null whenever `engineVersionFor` could not read a package at all
|
|
472
|
+
* (a bundled build, an odd install layout). Both are answered `unknown`, and
|
|
473
|
+
* neither is evidence of anything.
|
|
474
|
+
*/
|
|
475
|
+
function staleServer(recorded, running) {
|
|
476
|
+
if (typeof recorded !== 'string' || !recorded) return 'unknown'
|
|
477
|
+
if (typeof running !== 'string' || !running) return 'unknown'
|
|
478
|
+
return recorded === running ? 'current' : 'stale'
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
module.exports = {
|
|
482
|
+
mintToken,
|
|
483
|
+
readBody,
|
|
484
|
+
MAX_PASS_BYTES,
|
|
485
|
+
engineVersionFor,
|
|
486
|
+
staleServer,
|
|
487
|
+
specSummary,
|
|
488
|
+
routeFor,
|
|
489
|
+
renderIndex,
|
|
490
|
+
servableSpecs,
|
|
491
|
+
renderSpecPage,
|
|
492
|
+
receivePass,
|
|
493
|
+
createReviewServer,
|
|
494
|
+
startReviewServer,
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
// Entry point: run detached by the CLI, reading its settings from a file so a
|
|
498
|
+
// restart is a rewrite of that file — the same contract `proxy.js` uses. Only
|
|
499
|
+
// `dir` is stored, never a snapshot of the specs: resolving per request is what
|
|
500
|
+
// keeps a spec provisioned after the server started from being invisible to it.
|
|
501
|
+
if (require.main === module) {
|
|
502
|
+
const settingsFile = process.argv[2]
|
|
503
|
+
if (!settingsFile) {
|
|
504
|
+
process.stderr.write('serve: usage: node serve.js <settingsFile>\n')
|
|
505
|
+
process.exit(1)
|
|
506
|
+
}
|
|
507
|
+
const { dir, port, host, token } = JSON.parse(fs.readFileSync(settingsFile, 'utf-8'))
|
|
508
|
+
const { config } = loadEnvConfig(dir)
|
|
509
|
+
const git = rawGitReader(dir)
|
|
510
|
+
const trimmedGit = (argv) => {
|
|
511
|
+
const out = git(argv)
|
|
512
|
+
return out == null ? null : String(out).trim() || null
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
const resolveOne = (folder) => {
|
|
516
|
+
try {
|
|
517
|
+
return resolveSpec(folder, dir, config, { searchDirs: [...liveWorktreePaths(trimmedGit)] })
|
|
518
|
+
} catch {
|
|
519
|
+
return null
|
|
520
|
+
}
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
const resolveEntries = () =>
|
|
524
|
+
servableSpecs(dir, config, trimmedGit).map((s) => {
|
|
525
|
+
const one = resolveOne(s.folder)
|
|
526
|
+
return {
|
|
527
|
+
folder: s.folder,
|
|
528
|
+
branch: one ? one.branch : '',
|
|
529
|
+
totals: specSummary(one),
|
|
530
|
+
}
|
|
531
|
+
})
|
|
532
|
+
|
|
533
|
+
const server = createReviewServer({
|
|
534
|
+
resolveEntries,
|
|
535
|
+
render: (folder, opts) => renderSpecPage(dir, config, resolveOne(folder), opts),
|
|
536
|
+
receive: (folder, blob) => receivePass(dir, config, resolveOne(folder), blob),
|
|
537
|
+
token,
|
|
538
|
+
})
|
|
539
|
+
startReviewServer(server, { port, host }).then(
|
|
540
|
+
() => {},
|
|
541
|
+
(err) => {
|
|
542
|
+
process.stderr.write(`serve: ${err.message}\n`)
|
|
543
|
+
process.exit(1)
|
|
544
|
+
},
|
|
545
|
+
)
|
|
546
|
+
const shutdown = () => server.close(() => process.exit(0))
|
|
547
|
+
process.on('SIGTERM', shutdown)
|
|
548
|
+
process.on('SIGINT', shutdown)
|
|
549
|
+
}
|
package/src/env/teardown.js
CHANGED
|
@@ -105,9 +105,15 @@ function planDown(spec, config, flags, ctx) {
|
|
|
105
105
|
// different question from ours: it also declines a branch that is ahead of its
|
|
106
106
|
// upstream ref, reporting `not yet merged to refs/remotes/origin/<branch>,
|
|
107
107
|
// even though it is merged to HEAD`. That fires on the ordinary spec flow —
|
|
108
|
-
//
|
|
109
|
-
//
|
|
110
|
-
//
|
|
108
|
+
// it fires on any branch with an upstream ref whose later commits were landed
|
|
109
|
+
// locally rather than pushed — the shape you get the moment someone publishes a
|
|
110
|
+
// spec branch by hand, which is the only way one reaches a remote now. Teardown
|
|
111
|
+
// then meets a branch whose every commit is on `main` and `-d` refuses it.
|
|
112
|
+
//
|
|
113
|
+
// DO NOT "simplify" this back to `-d` on the grounds that nothing pushes at
|
|
114
|
+
// provisioning any more. That removed the COMMON case, not the case: one
|
|
115
|
+
// hand-published branch is enough, and `merged` already establishes what we
|
|
116
|
+
// care about more strongly than `-d` checks. `merged` (HEAD is an ancestor
|
|
111
117
|
// of base) already establishes what we actually care about, and establishes it
|
|
112
118
|
// more strongly than `-d` checks.
|
|
113
119
|
//
|
|
@@ -122,9 +128,10 @@ function planDown(spec, config, flags, ctx) {
|
|
|
122
128
|
|
|
123
129
|
// --- delete the branch on the remote (planned, never run here) ---
|
|
124
130
|
//
|
|
125
|
-
//
|
|
126
|
-
//
|
|
127
|
-
//
|
|
131
|
+
// A hand-published spec branch otherwise leaves a merged branch on the remote
|
|
132
|
+
// forever. Cleaning that up is the goal; doing it safely is the constraint.
|
|
133
|
+
// Nothing publishes at provision time any more, so this plans a delete only for
|
|
134
|
+
// a branch the user pushed themselves — which is exactly when they want it.
|
|
128
135
|
//
|
|
129
136
|
// Gated on `landed` because until the branch is merged (or captured by a tag)
|
|
130
137
|
// the remote copy is the ONLY backup of the work — that is the whole reason
|