geneseed 3.1.1 → 3.1.3

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 (106) hide show
  1. package/CHANGELOG.md +123 -1
  2. package/DESIGN.md +15 -15
  3. package/README.md +1 -1
  4. package/SETUP.md +1 -1
  5. package/SHIPPED.md +1 -1
  6. package/bin/{geneseed.mjs → build-driver.mjs} +39 -42
  7. package/bin/geneseed-cli.mjs +33 -32
  8. package/bin/geneseed-hook.mjs +7 -7
  9. package/docs/design-history.md +40 -40
  10. package/docs/extending.md +111 -83
  11. package/docs/web/exclusions.md +1 -1
  12. package/docs/web/footprint.md +1 -1
  13. package/docs/web/foreman-mode.md +2 -2
  14. package/docs/web/themes-author.md +1 -1
  15. package/docs/web-ui.md +41 -29
  16. package/harness.config.json +1 -1
  17. package/js/README.md +203 -0
  18. package/js/build/bundle.mjs +231 -0
  19. package/js/{catalog.mjs → build/catalog.mjs} +7 -7
  20. package/js/build/emit-claude.mjs +287 -0
  21. package/js/build/emit-common.mjs +255 -0
  22. package/js/build/emit-opencode.mjs +214 -0
  23. package/js/{generate.mjs → build/generate.mjs} +27 -28
  24. package/js/{render.mjs → build/render.mjs} +13 -9
  25. package/js/{checkout.mjs → build/source.mjs} +9 -9
  26. package/js/build/stubs.mjs +256 -0
  27. package/js/{themes.mjs → build/themes.mjs} +24 -24
  28. package/js/build/version.mjs +155 -0
  29. package/js/{hooks.mjs → hosts/hooks.mjs} +47 -44
  30. package/js/{hosts.mjs → hosts/hosts.mjs} +30 -28
  31. package/js/{installs.mjs → hosts/installs.mjs} +18 -16
  32. package/js/{link.mjs → hosts/link.mjs} +29 -28
  33. package/js/{mcp.mjs → hosts/mcp.mjs} +10 -9
  34. package/js/{native.mjs → hosts/native.mjs} +24 -22
  35. package/js/{opencode.mjs → hosts/opencode.mjs} +12 -7
  36. package/js/{settings.mjs → hosts/settings.mjs} +63 -55
  37. package/js/inspect/checks-authoring.mjs +761 -0
  38. package/js/inspect/checks-build.mjs +231 -0
  39. package/js/inspect/checks-repo.mjs +308 -0
  40. package/js/{diff.mjs → inspect/diff.mjs} +34 -31
  41. package/js/inspect/doctor.mjs +286 -0
  42. package/js/{excludes.mjs → inspect/excludes.mjs} +30 -33
  43. package/js/{inventory.mjs → inspect/inventory.mjs} +20 -17
  44. package/js/{registry.mjs → inspect/registry.mjs} +12 -10
  45. package/js/inspect/scan.mjs +125 -0
  46. package/js/{status.mjs → inspect/status.mjs} +27 -25
  47. package/js/inspect/validate.mjs +199 -0
  48. package/js/lib/fs.mjs +37 -759
  49. package/js/lib/json.mjs +344 -0
  50. package/js/lib/paths.mjs +242 -0
  51. package/js/lib/proc.mjs +3 -3
  52. package/js/lib/text.mjs +181 -0
  53. package/js/lib/udiff.mjs +1 -1
  54. package/js/{memory.mjs → maintain/memory.mjs} +10 -10
  55. package/js/{migrate.mjs → maintain/migrate.mjs} +28 -26
  56. package/js/{setup.mjs → maintain/setup.mjs} +34 -32
  57. package/js/{uninstall.mjs → maintain/uninstall.mjs} +55 -56
  58. package/js/{update.mjs → maintain/update.mjs} +51 -51
  59. package/js/{anim.mjs → ui/anim.mjs} +30 -28
  60. package/js/{cli.mjs → ui/cli.mjs} +10 -10
  61. package/js/{menu.mjs → ui/menu.mjs} +10 -9
  62. package/js/{tui.mjs → ui/tui.mjs} +36 -34
  63. package/js/web/actions.mjs +84 -85
  64. package/js/web/activity.mjs +22 -21
  65. package/js/web/api.mjs +154 -51
  66. package/js/web/daemon.mjs +319 -0
  67. package/js/web/docs.mjs +24 -21
  68. package/js/web/graph.mjs +0 -0
  69. package/js/web/handler.mjs +442 -0
  70. package/js/web/jobs.mjs +21 -19
  71. package/js/web/routes.mjs +245 -0
  72. package/js/web/server.mjs +56 -987
  73. package/package.json +11 -3
  74. package/src/doctrines/README.md +1 -1
  75. package/themes/opencode/README.md +1 -1
  76. package/web/dist/assets/{Activity-D_sYBlW-.js → Activity-B49fE_ug.js} +1 -1
  77. package/web/dist/assets/{ActivityDetail-BomKcMhy.js → ActivityDetail-abt7hzD2.js} +1 -1
  78. package/web/dist/assets/{Diff-BSvi1WUg.js → Diff-D3_bVkih.js} +1 -1
  79. package/web/dist/assets/{Doctor-Cz1s-y1n.js → Doctor-hXp7srsx.js} +1 -1
  80. package/web/dist/assets/{Graph-CuntzFJ0.js → Graph-DvYMtlvl.js} +1 -1
  81. package/web/dist/assets/Harness-BIWei7N5.js +1 -0
  82. package/web/dist/assets/Laws-CxmnRdwt.js +3 -0
  83. package/web/dist/assets/{Library-DXhTN9ju.js → Library-MjbOkHIW.js} +1 -1
  84. package/web/dist/assets/{Markdown-BoiZcdUl.js → Markdown-Dx_w0faR.js} +1 -1
  85. package/web/dist/assets/{Profile-DfCHlZ03.js → Profile-BRFvv2gr.js} +1 -1
  86. package/web/dist/assets/{Rules-eND7hJv9.js → Rules-CfsV1EVy.js} +1 -1
  87. package/web/dist/assets/{Skills-CDl9opZO.js → Skills-DArA6IHS.js} +1 -1
  88. package/web/dist/assets/{StatusBadge-DSDtcArX.js → StatusBadge-C9bZOEZk.js} +1 -1
  89. package/web/dist/assets/{activity-Dmb0FTAx.js → activity-Cc0n2jwx.js} +1 -1
  90. package/web/dist/assets/index-Bx3oxZ1o.css +1 -0
  91. package/web/dist/assets/index-DC3-GlCY.js +1 -0
  92. package/web/dist/assets/index-DP035wPa.js +12 -0
  93. package/web/dist/assets/{index-Dg58ScBe.js → index-dHi-7M-F.js} +1 -1
  94. package/web/dist/index.html +2 -2
  95. package/web/src/pages/Laws.jsx +5 -19
  96. package/docs/declined.md +0 -200
  97. package/docs/limits.md +0 -329
  98. package/js/doctor.mjs +0 -1750
  99. package/js/emit.mjs +0 -1411
  100. package/web/dist/assets/About-DyK7yRy7.js +0 -1
  101. package/web/dist/assets/Harnesses-DgRyOviS.js +0 -1
  102. package/web/dist/assets/Laws-CWk3Mpri.js +0 -3
  103. package/web/dist/assets/Themes-CoAWuIeM.js +0 -2
  104. package/web/dist/assets/index-B-mXquBZ.css +0 -1
  105. package/web/dist/assets/index-CH_Z725C.js +0 -12
  106. package/web/dist/assets/index-jrY4m9jP.js +0 -1
@@ -0,0 +1,442 @@
1
+ /**
2
+ * The request handler — one function per request, from method dispatch to bytes on the wire.
3
+ *
4
+ * `makeHandler(state, jm, token, dist, holder)` is a FACTORY and the shape is load-bearing: three
5
+ * test call sites build one directly and drive it without a socket, which is what makes the web
6
+ * surface testable at all. Keep the arity.
7
+ *
8
+ * What it actually uses of HTTP is small and deliberate — a whole-buffer `gzipSync` over a few
9
+ * content types, two Cache-Control policies, a CSRF token on POST only, and a Host guard against
10
+ * DNS rebinding. No streaming, no SSE, no ranges, no cookies, no CORS. The routes it honours are
11
+ * declared next door in `routes.mjs`; the daemon that starts it is in `daemon.mjs`.
12
+ */
13
+ import { resolvePath } from '../hosts/hosts.mjs';
14
+ import { doctrinesForBuild } from '../hosts/installs.mjs';
15
+ import { isTruthy, jsonDumpsCompact } from '../lib/json.mjs';
16
+ import { parseIntStrict, percentDecode } from '../lib/text.mjs';
17
+ import { preflight } from '../maintain/update.mjs';
18
+ import { apiDeployCmd, apiInstallCmd, apiRestore, buildOverride, mcpTargetPaths } from './actions.mjs';
19
+ import { NotFound, PREFIX_ROUTES, STATE_ROUTES } from './api.mjs';
20
+ import { openUrl, requestRestart } from './daemon.mjs';
21
+ import { apiDocs, apiDocsPage } from './docs.mjs';
22
+ import { NOT_PORTED_ACTIONS, actionCommands } from './jobs.mjs';
23
+ import { POST_ROUTES, notPorted, notPortedPost, readJsonBody } from './routes.mjs';
24
+ import { readFileSync, statSync } from 'node:fs';
25
+ import { basename, dirname, extname, join, sep } from 'node:path';
26
+ import { gzipSync } from 'node:zlib';
27
+
28
+ /**
29
+ * DNS-rebinding guard. The reference's own comment carries the why; the shape is the
30
+ * part a port gets wrong. `h.split(":", 1)[0]` keeps everything before the FIRST colon,
31
+ * so `localhost:4747` passes and a bare IPv6 literal would not — which is what the
32
+ * `[::1]` prefix test above it is for.
33
+ */
34
+ export function localHost(host) {
35
+ const h = (host || '').trim().toLowerCase();
36
+ if (h.startsWith('[::1]')) return true;
37
+ return ['127.0.0.1', 'localhost'].includes(h.split(':', 1)[0]);
38
+ }
39
+
40
+ const GZIP_TYPES = ['application/json', 'text/', 'image/svg+xml',
41
+ 'application/manifest+json', 'application/javascript'];
42
+ const GZIP_MIN = 1024;
43
+
44
+ const CTYPES = {
45
+ '.html': 'text/html', '.js': 'text/javascript',
46
+ '.css': 'text/css', '.json': 'application/json',
47
+ '.svg': 'image/svg+xml', '.ico': 'image/x-icon',
48
+ '.woff2': 'font/woff2', '.woff': 'font/woff',
49
+ '.webmanifest': 'application/manifest+json',
50
+ '.png': 'image/png',
51
+ };
52
+
53
+ // ---- the request handler ---------------------------------------------------
54
+
55
+ /**
56
+ * `make_handler(state, jm, token, dist, holder)` — `jm` is the SECOND argument here as it is
57
+ * there. P6a shipped this without it because there was no `JobManager` to pass; every caller
58
+ * moved in the same commit that added one.
59
+ */
60
+ export function makeHandler(state, jm, token, dist, holder = null) {
61
+ const staticCache = new Map();
62
+
63
+ function sendBytes(res, body, ctype, code = 200, extra = null, acceptEncoding = '') {
64
+ const headers = { ...(extra || {}) };
65
+ if (body.length >= GZIP_MIN && GZIP_TYPES.some((t) => ctype.startsWith(t))
66
+ && (acceptEncoding || '').includes('gzip')) {
67
+ body = gzipSync(body, { level: 6 });
68
+ headers['Content-Encoding'] = 'gzip';
69
+ headers.Vary = 'Accept-Encoding';
70
+ }
71
+ res.writeHead(code, {
72
+ 'Content-Type': ctype,
73
+ 'Content-Length': String(body.length),
74
+ ...headers,
75
+ });
76
+ res.end(body);
77
+ }
78
+
79
+ function sendJson(res, obj, code = 200, acceptEncoding = '') {
80
+ sendBytes(res, Buffer.from(jsonDumpsCompact(obj, { bareInts: true }), 'utf-8'),
81
+ 'application/json', code, null, acceptEncoding);
82
+ }
83
+
84
+ function serveStatic(res, path, acceptEncoding) {
85
+ const rel = (path === '/' || path === '') ? 'index.html' : path.replace(/^\/+/, '');
86
+ const index = resolvePath(join(dist, 'index.html'));
87
+ let fp = resolvePath(join(dist, rel));
88
+ // `dist not in fp.parents`: STRICTLY under dist. `Path.parents` never contains the
89
+ // path itself, so `fp == dist` takes the fallback too.
90
+ if (!(fp.startsWith(dist + sep) && fp !== dist) && fp !== index) fp = join(dist, 'index.html');
91
+ if (!isFile(fp)) fp = join(dist, 'index.html');
92
+ if (!isFile(fp)) {
93
+ return sendJson(res, { error: 'web/dist missing — run the UI build' }, 500, acceptEncoding);
94
+ }
95
+ let data = staticCache.get(fp);
96
+ if (data === undefined) {
97
+ data = readFileSync(fp);
98
+ staticCache.set(fp, data);
99
+ }
100
+ let extra = null;
101
+ if (basename(fp) === 'index.html') {
102
+ const inject = Buffer.from(`<script>window.__GENESEED_TOKEN__="${token}";</script>`, 'utf-8');
103
+ data = replaceOnce(data, Buffer.from('</head>'), Buffer.concat([inject, Buffer.from('</head>')]));
104
+ extra = { 'Cache-Control': 'no-store' };
105
+ } else if (path.includes('/assets/')) {
106
+ extra = { 'Cache-Control': 'public, max-age=31536000, immutable' };
107
+ }
108
+ return sendBytes(res, data, CTYPES[extname(fp)] || 'application/octet-stream',
109
+ 200, extra, acceptEncoding);
110
+ }
111
+
112
+ function doGet(req, res, path, ae) {
113
+ // The reference's own table first, then the routes that parse the path — and `ping`
114
+ // sits between them there too, so the order here is its order.
115
+ const route = STATE_ROUTES[path];
116
+ if (route !== undefined) return sendJson(res, route(state), 200, ae);
117
+ if (path === '/api/ping') return sendJson(res, { ok: true, theme: state.theme }, 200, ae);
118
+ for (const [prefix, handler] of PREFIX_ROUTES) {
119
+ if (path.startsWith(prefix)) return sendJson(res, handler(state, path), 200, ae);
120
+ }
121
+ if (path === '/api/docs') {
122
+ return sendJson(res, apiDocs(state, harnessParam(req.url)), 200, ae);
123
+ }
124
+ if (path.startsWith('/api/docs/page/')) {
125
+ const pid = percentDecode(path.slice('/api/docs/page/'.length));
126
+ try {
127
+ return sendJson(res, apiDocsPage(state, pid, harnessParam(req.url)), 200, ae);
128
+ } catch (e) {
129
+ // A kind P6d does not answer says so, rather than falling through to the
130
+ // `NotFound` at the bottom of `apiDocsPage` and claiming the page is missing.
131
+ if (e && e.notPorted) return sendJson(res, { error: e.message }, 501, ae);
132
+ throw e;
133
+ }
134
+ }
135
+ if (path === '/api/jobs') return sendJson(res, { jobs: jm.recent() }, 200, ae);
136
+ if (path.startsWith('/api/jobs/')) {
137
+ // `path.rsplit("/", 1)[1]` — the LAST segment, so `/api/jobs/a/b` looks up `b` and
138
+ // misses, rather than 404ing on the shape.
139
+ const j = jm.get(path.slice(path.lastIndexOf('/') + 1));
140
+ return j ? sendJson(res, j, 200, ae)
141
+ : sendJson(res, { error: 'no such job' }, 404, ae);
142
+ }
143
+ if (notPorted(path)) {
144
+ return sendJson(res, { error: `not ported yet: ${path}` }, 501, ae);
145
+ }
146
+ return serveStatic(res, path, ae);
147
+ }
148
+
149
+ function doPost(req, res, path, ae, body) {
150
+ if (!localHost(req.headers.host)) {
151
+ return sendJson(res, { error: 'forbidden host' }, 403, ae);
152
+ }
153
+ if (req.headers['x-geneseed-token'] !== token) {
154
+ return sendJson(res, { error: 'forbidden' }, 403, ae);
155
+ }
156
+ if (path === '/api/shutdown') {
157
+ // The one POST route the SHELL owns. It must answer BEFORE it stops, which is why
158
+ // the reference runs `srv.shutdown()` on its own thread — called inline it would
159
+ // deadlock against `serve_forever`. Here the equivalent is closing after the
160
+ // response has flushed. `closeAllConnections` is the other half: `close()` alone
161
+ // waits for idle keep-alive sockets, and the client that just sent this request is
162
+ // holding one open. The reference drops them by exiting.
163
+ res.on('finish', () => {
164
+ holder.srv.close();
165
+ holder.srv.closeAllConnections();
166
+ });
167
+ return sendJson(res, { stopping: true }, 200, ae);
168
+ }
169
+ if (path === '/api/restart') {
170
+ // THE ONE ROUTE IN THIS FILE NO TEST MAY REACH, and it is declared rather than probed.
171
+ // `requestRestart` spawns a DETACHED `web restart`, which stops whatever daemon the
172
+ // record names and starts a fresh one that outlives the caller. Every way of asking it
173
+ // a question runs it: `tests/web_golden.py` would have its own server stopped and a
174
+ // replacement orphaned into a sandbox about to be deleted, and
175
+ // `tests/test_web_server.py`'s dispatcher probe runs in the DEVELOPER'S environment,
176
+ // where the replacement would bind 4747 and serve the checkout forever. That is the
177
+ // same reasoning P6g used to ban a job cell that starts real work, one step further:
178
+ // there the child was merely orphaned, here it is a second daemon.
179
+ //
180
+ // So the gate is `tests/test_web_daemon.py`, which reads both implementations and
181
+ // asserts they dispatch this path to `request_restart(state.theme)` and answer the
182
+ // same body. Rule 7 says a declaration is not a dispatcher — this is the one route
183
+ // where probing the dispatcher costs more than the assurance is worth, and saying so
184
+ // beats a silently missing probe.
185
+ requestRestart(state.theme);
186
+ return sendJson(res, { restarting: true }, 200, ae);
187
+ }
188
+ if (path === '/api/reveal') {
189
+ // "It asks for a token and a URL and I don't know where to put them." The screen already
190
+ // PRINTS the config path above each target's rows; what it could not do is take you there.
191
+ //
192
+ // THE ALLOWLIST IS THE WHOLE SECURITY OF THIS ENDPOINT, same as the toggle beside it, and
193
+ // it is the same allowlist — `mcpTargetPaths()`, one function with two callers rather than
194
+ // two copies that drift. An unlisted path is a 404 before anything is opened. Without it
195
+ // this is "ask the desktop to open any path on the machine", behind a CSRF token and
196
+ // nothing else, and on Windows `openUrl` interpolates into a `cmd` command line with
197
+ // `windowsVerbatimArguments` — so the exact-match lookup is also what bounds the strings
198
+ // that can ever reach a shell to the ones the machine's own install registry produced.
199
+ //
200
+ // THE FOLDER, NOT THE FILE. `open`/`start`/`xdg-open` on a `.json` hands it to whatever
201
+ // claims that extension — which on a stock Windows box is a browser, i.e. a read-only view
202
+ // of the exact file the user is trying to EDIT. The containing folder lands in Explorer /
203
+ // Finder / the file manager every time, and the filename is already on screen beside the
204
+ // button.
205
+ const want = String(readJsonBody(body).path ?? '');
206
+ if (!mcpTargetPaths().has(want)) {
207
+ return sendJson(res, { error: `not found: ${want || '(none)'}` }, 404, ae);
208
+ }
209
+ const dir = dirname(want);
210
+ // `openUrl` SWALLOWS EVERY FAILURE BY DESIGN (a headless box has no opener), so `ok` here
211
+ // means "the path was allowed and the request was made", never "a window appeared". The
212
+ // response says which directory, so the UI can print it as the fallback instruction.
213
+ openUrl(dir);
214
+ return sendJson(res, { ok: true, dir }, 200, ae);
215
+ }
216
+ const route = POST_ROUTES.get(path);
217
+ if (route !== undefined) {
218
+ const [fn, okIs409] = route;
219
+ const obj = fn(state, readJsonBody(body));
220
+ return sendJson(res, obj, (okIs409 && !isTruthy(obj.ok)) ? 409 : 200, ae);
221
+ }
222
+ if (path.startsWith('/api/jobs/') && path.endsWith('/cancel')) {
223
+ // `path.split("/")[3]`, positional — so `/api/jobs/<id>/cancel` takes `<id>` and a
224
+ // deeper path takes its third segment, which is what the reference looks up.
225
+ const jid = path.split('/')[3];
226
+ return jm.cancel(jid) ? sendJson(res, { cancelled: jid }, 200, ae)
227
+ : sendJson(res, { error: 'no running job by that id' }, 404, ae);
228
+ }
229
+ if (path.startsWith('/api/actions/')) {
230
+ return doAction(res, path.slice(path.lastIndexOf('/') + 1), readJsonBody(body), ae);
231
+ }
232
+ if (notPortedPost(path)) {
233
+ return sendJson(res, { error: `not ported yet: ${path}` }, 501, ae);
234
+ }
235
+ return sendJson(res, { error: 'not found' }, 404, ae);
236
+ }
237
+
238
+ /**
239
+ * `/api/actions/<x>` — the reference's own order, which is the part a port gets wrong.
240
+ *
241
+ * `restore` is answered BEFORE anything else because it is synchronous and returns a result
242
+ * rather than a job id. `install` and `deploy` resolve their argv from the body, and their
243
+ * refusal statuses DIFFER — 409 for install (its target came from an allowlist, so a refusal
244
+ * means the world changed under the client) and 400 for deploy (its path came from the body,
245
+ * so a refusal means the body was wrong). That asymmetry is the reference's; a port that
246
+ * unified them would be wrong on one of the two and no cell would say which.
247
+ *
248
+ * `update` COMES NEXT AND IS INLINE, since P8c: it is gated on a LOCAL-ONLY preflight before
249
+ * a job is ever started, so a dirty tree or a zip-download install gets a friendly 422 the UI
250
+ * renders as an info popup instead of a job that spawns and then fails. Its finish handler is
251
+ * its own too — the child skips its own daemon bounce (`GENESEED_WEB_JOB`, set by `_run`), so
252
+ * the restart happens HERE, after the job is saved as finished, and only when it succeeded: a
253
+ * failed update changed nothing worth reloading and bouncing would disconnect the PWA for no
254
+ * reason.
255
+ *
256
+ * THEN the unported rows, and only then the table. Order matters: `link`/`unlink` are REAL
257
+ * actions whose verb has not crossed, and letting them fall through to `actionCommands` would
258
+ * answer `{"error": "unknown action link"}` — the same thing a typo gets.
259
+ */
260
+ function doAction(res, action, body, ae) {
261
+ if (action === 'restore') {
262
+ return sendJson(res, apiRestore(state, isTruthy(body.files) ? body.files : []), 200, ae);
263
+ }
264
+ if (action === 'install') {
265
+ const plan = apiInstallCmd(state, body);
266
+ if (Object.hasOwn(plan, 'error')) return sendJson(res, plan, 409, ae);
267
+ return startJob(res, 'install', [plan.cmd], ae);
268
+ }
269
+ if (action === 'deploy') {
270
+ const plan = apiDeployCmd(state, body);
271
+ if (Object.hasOwn(plan, 'error')) return sendJson(res, plan, 400, ae);
272
+ return startJob(res, 'deploy', [plan.cmd], ae);
273
+ }
274
+ if (action === 'update') {
275
+ const pre = preflight();
276
+ if (!pre.ok) {
277
+ return sendJson(res,
278
+ { precondition: pre.code, kind: pre.kind, message: pre.message }, 422, ae);
279
+ }
280
+ const jid = jm.start('update', actionCommands('update'), (rc) => {
281
+ state.refresh();
282
+ if (rc === 0) requestRestart(state.theme);
283
+ });
284
+ if (jid === null) return sendJson(res, { error: 'busy' }, 409, ae);
285
+ return sendJson(res, { job_id: jid }, 202, ae);
286
+ }
287
+ if (NOT_PORTED_ACTIONS.has(action)) {
288
+ return sendJson(res, { error: `not ported yet: /api/actions/${action}` }, 501, ae);
289
+ }
290
+ // Build can be re-themed/re-targeted from the UI picker; the other actions self-resolve
291
+ // the deployed theme downstream. Footprint, posture and mode always follow the current
292
+ // install, so a re-theme preserves lean/full, the register and the operating mode.
293
+ const [theme, emit] = action === 'build'
294
+ ? buildOverride(state, body) : [state.theme, state.emit];
295
+ // The pack selection follows the install like footprint/posture/mode, and is read here
296
+ // rather than left to the table's default: a console Build must re-emit the constitution
297
+ // the deployment already carries. Unknown (no `Active packs:` marker) resolves to ALL
298
+ // packs — never to `harness.config.json`, which would drop the consent gate.
299
+ const cmds = actionCommands(action, {
300
+ theme, emit, footprint: state.footprint, posture: state.posture, mode: state.mode,
301
+ doctrines: doctrinesForBuild(state.target),
302
+ });
303
+ if (!isTruthy(cmds)) {
304
+ return sendJson(res, { error: `unknown action ${action}` }, 404, ae);
305
+ }
306
+ return startJob(res, action, cmds, ae);
307
+ }
308
+
309
+ /**
310
+ * The 202/409 pair every job route ends in. `state.refresh()` on finish is not optional: a
311
+ * Build may re-theme the install, and the re-detect must read the new `.geneseed-emit` or
312
+ * the console keeps serving the old mode (the line P6b dropped and P6f restored).
313
+ */
314
+ function startJob(res, action, cmds, ae) {
315
+ const jid = jm.start(action, cmds, () => state.refresh());
316
+ if (jid === null) return sendJson(res, { error: 'busy' }, 409, ae);
317
+ return sendJson(res, { job_id: jid }, 202, ae);
318
+ }
319
+
320
+ return function handler(req, res) {
321
+ const ae = req.headers['accept-encoding'] || '';
322
+ const path = (req.url || '').split('?', 1)[0];
323
+ try {
324
+ if (req.method === 'GET') {
325
+ if (!localHost(req.headers.host)) {
326
+ return sendJson(res, { error: 'forbidden host' }, 403, ae);
327
+ }
328
+ try {
329
+ return doGet(req, res, path, ae);
330
+ } catch (e) {
331
+ // The `NotFound` → 404 convention, which P6c is the first phase to raise. It
332
+ // wraps the ROUTE DISPATCH and not each handler, exactly as the reference's
333
+ // `try` around `do_GET`'s body does — an endpoint several frames down raises and
334
+ // the shell decides the status. Anything else is still the outer 500.
335
+ if (e instanceof NotFound) {
336
+ return sendJson(res, { error: `not found: ${e.message}` }, 404, ae);
337
+ }
338
+ throw e;
339
+ }
340
+ }
341
+ if (req.method === 'POST') {
342
+ // Drain the body BEFORE routing, as the reference does and for its stated
343
+ // reason — under keep-alive an unread body is parsed as the next request line,
344
+ // and both guards answer without ever reaching a route that would read it.
345
+ //
346
+ // THIS HALF OF IT IS INDISTINGUISHABLE HERE, and measured rather than assumed:
347
+ // two mutations — reading zero bytes, then neither reading nor resuming the
348
+ // stream — both survived the keep-alive cell, which the reference needs the
349
+ // drain to pass. Node's HTTP parser owns the message boundary, so unread body
350
+ // bytes are discarded when the response ends instead of being re-parsed. It
351
+ // stays because `_read_json_body` needs the bytes from P6f on, and because a
352
+ // shell whose two implementations diverged only under load would be the worst
353
+ // kind of divergence to carry. UNREACHABLE is not the word for it: the branch
354
+ // runs on every POST, it simply cannot be observed to matter on this runtime.
355
+ const length = parseIntStrict(String(req.headers['content-length'] ?? '')) ?? 0;
356
+ return readBody(req, length, (buf) => {
357
+ req._body = buf;
358
+ try {
359
+ return doPost(req, res, path, ae, buf);
360
+ } catch (e) {
361
+ // `do_POST`'s own two-armed catch, and P6f is the phase that needs the first
362
+ // arm: `api_mcp_toggle`, `api_select_view`, `api_memory_delete`,
363
+ // `api_rules_mutate` and `api_rules_promote` all raise `NotFound` for an
364
+ // unknown target, and the reference answers 404. The 500 arm below it is what
365
+ // carries `_rule_fields`' ValueError message, which is a 500 there too.
366
+ if (e instanceof NotFound) {
367
+ return sendJson(res, { error: `not found: ${e.message}` }, 404, ae);
368
+ }
369
+ return sendJson(res, { error: String(e && e.message ? e.message : e) }, 500, ae);
370
+ }
371
+ });
372
+ }
373
+ return sendJson(res, { error: 'not found' }, 404, ae);
374
+ } catch (e) {
375
+ return sendJson(res, { error: String(e && e.message ? e.message : e) }, 500, ae);
376
+ }
377
+ };
378
+ }
379
+
380
+ /**
381
+ * `Handler._harness` — the `?harness=` query param, `null` when absent.
382
+ *
383
+ * `urllib.parse.parse_qs` with its defaults, reproduced only as far as this one caller
384
+ * needs and no further: pairs split on `&` then `=`, values unquoted with `+` meaning a
385
+ * space, and a BLANK value dropped (`keep_blank_values=False`), so `?harness=` resolves to
386
+ * the installed default rather than to the empty string. The first occurrence wins,
387
+ * because `parse_qs` returns a list and the reference takes `[0]`.
388
+ *
389
+ * THE BLANK-VALUE DROP IS INDISTINGUISHABLE HERE, measured rather than assumed: a
390
+ * mutation removing it survived, because `normHarness('')` and `normHarness(null)` both
391
+ * fall through to the installed default. It stays for the reason `parse_qs`' own default
392
+ * exists — the next parameter read through this function may be one whose empty string
393
+ * means something — and because a shell whose two implementations agreed only by accident
394
+ * is the kind of agreement that stops holding when a caller is added. UNREACHABLE is not
395
+ * the word: the branch runs on every `?harness=`, it simply cannot be observed to matter
396
+ * through the one consumer it has.
397
+ */
398
+ function harnessParam(url) {
399
+ const at = (url || '').indexOf('?');
400
+ if (at < 0) return null;
401
+ for (const pair of url.slice(at + 1).split('&')) {
402
+ if (!pair) continue;
403
+ const eq = pair.indexOf('=');
404
+ const k = percentDecode((eq < 0 ? pair : pair.slice(0, eq)).replace(/\+/g, ' '));
405
+ const v = eq < 0 ? '' : percentDecode(pair.slice(eq + 1).replace(/\+/g, ' '));
406
+ if (k === 'harness' && v !== '') return v;
407
+ }
408
+ return null;
409
+ }
410
+
411
+ function readBody(req, length, done) {
412
+ if (!(length > 0)) {
413
+ req.resume();
414
+ return done(Buffer.alloc(0));
415
+ }
416
+ const chunks = [];
417
+ let seen = 0;
418
+ req.on('data', (c) => {
419
+ // Exactly `Content-Length` bytes, as `rfile.read(length)` takes: a longer body is
420
+ // the next request's bytes and belongs to the parser, not to this handler.
421
+ if (seen >= length) return;
422
+ chunks.push(c.subarray(0, length - seen));
423
+ seen += c.length;
424
+ });
425
+ req.on('end', () => done(Buffer.concat(chunks)));
426
+ }
427
+
428
+ export function isFile(p) {
429
+ try {
430
+ return statSync(p).isFile();
431
+ } catch {
432
+ return false;
433
+ }
434
+ }
435
+
436
+ /** `bytes.replace(old, new, 1)` — the first occurrence only. */
437
+ function replaceOnce(buf, find, repl) {
438
+ const at = buf.indexOf(find);
439
+ if (at < 0) return buf;
440
+ return Buffer.concat([buf.subarray(0, at), repl, buf.subarray(at + find.length)]);
441
+ }
442
+
package/js/web/jobs.mjs CHANGED
@@ -33,7 +33,7 @@
33
33
  * the chain on the first non-zero. In-process that would be a return value threaded through
34
34
  * every verb's error paths, several of which currently end at `process.exitCode`.
35
35
  *
36
- * SO THE ARGV IS `node bin/geneseed-cli.mjs <verb>` — or `node bin/geneseed.mjs <build args>`
36
+ * SO THE ARGV IS `node bin/geneseed-cli.mjs <verb>` — or `node bin/build-driver.mjs <build args>`
37
37
  * for the three rows that run the GENERATOR, which is `build.py`'s twin and the same program
38
38
  * by a different entry. `process.execPath` is the running interpreter by absolute path, so
39
39
  * stripping `node` off PATH cannot change what starts. Nothing here can name `python`.
@@ -47,15 +47,16 @@
47
47
  * primitive, not this program.
48
48
  *
49
49
  * ---------------------------------------------------------------------------------------
50
- * THE PARTITION — six of the eight action rows cross and two do not.
50
+ * THE PARTITION — all eight action rows cross, and the declaration outlives them.
51
51
  *
52
- * `doctor`, `build`, `build-all`, `export`, `uninstall` and — since P8c — `update` all name a
53
- * verb that has crossed. `link`/`unlink` run `harness.py link|unlink` (P10b). A Node runner
54
- * that spawned the Python harness for those would put Python back into a "no Python
55
- * needed" install, so they are DECLARED in `NOT_PORTED_ACTIONS` and answered 501 by the
56
- * dispatcher, and P10b ends by deleting both rows. `tests/test_web_jobs.py` cross-checks the
57
- * two sets against `action_commands`' own keys with `ast`, so a NINTH row added to the
58
- * reference cannot quietly answer "unknown action".
52
+ * `doctor`, `build`, `build-all`, `export`, `uninstall`, `update`, `link` and `unlink` each
53
+ * name a verb `bin/geneseed-cli.mjs` runs. `link`/`unlink` were the last two: they ran
54
+ * `harness.py link|unlink` until P10b, and a runner that spawned the Python harness would
55
+ * have put Python back into a "no Python needed" install.
56
+ *
57
+ * `NOT_PORTED_ACTIONS` IS EMPTY AND STILL DECLARED, which is the point of it: a ninth action
58
+ * added later cannot quietly answer "unknown action", because `tests/unit/web_jobs.test.mjs`
59
+ * holds the two sets against `actionCommands`' own keys.
59
60
  *
60
61
  * ---------------------------------------------------------------------------------------
61
62
  * THREE DETAILS OF `_run`, EACH A COMMENT EXPLAINING A BUG IT ALREADY FIXED.
@@ -64,7 +65,7 @@
64
65
  * the daemon mid-job — which killed the job's own tracking and left the console on `running`
65
66
  * forever. It was set here before any ported row read it, because it was P6g's CONTRACT WITH
66
67
  * P8, and a contract written after the fact is a contract that was broken once first. P8a is
67
- * where `js/update.mjs` began honouring it, and P8c is where the `update` row that triggers
68
+ * where `js/maintain/update.mjs` began honouring it, and P8c is where the `update` row that triggers
68
69
  * it crossed — so it is a live pair now rather than a promise.
69
70
  *
70
71
  * `PYTHONUNBUFFERED=1` reaches the Python child AND its own Python children, or their stdout
@@ -107,10 +108,11 @@ import { statSync } from 'node:fs';
107
108
  import { StringDecoder } from 'node:string_decoder';
108
109
  import path from 'node:path';
109
110
 
110
- import { ROOT, PACK_ORDER } from '../checkout.mjs';
111
- import { setupBuildArgs } from '../generate.mjs';
112
- import { isDict } from '../mcp.mjs';
113
- import { jsonDumpsCompact, parseJson, pyTruthy, readText, writeText } from '../lib/fs.mjs';
111
+ import { ROOT, PACK_ORDER } from '../build/source.mjs';
112
+ import { setupBuildArgs } from '../build/generate.mjs';
113
+ import { isDict } from '../hosts/mcp.mjs';
114
+ import { readText, writeText } from '../lib/fs.mjs';
115
+ import { jsonDumpsCompact, parseJson, isTruthy } from '../lib/json.mjs';
114
116
 
115
117
  const isFile = (p) => { try { return statSync(p).isFile(); } catch { return false; } };
116
118
 
@@ -232,7 +234,7 @@ export class JobManager {
232
234
  return;
233
235
  }
234
236
  for (const j of Array.isArray(jobs) ? jobs : []) {
235
- if (isDict(j) && pyTruthy(j.id) && j.status !== 'running') this._jobs.set(String(j.id), j);
237
+ if (isDict(j) && isTruthy(j.id) && j.status !== 'running') this._jobs.set(String(j.id), j);
236
238
  }
237
239
  }
238
240
 
@@ -401,7 +403,7 @@ export class JobManager {
401
403
  * five. `update` and `export` self-resolve the deployed theme downstream and take no args.
402
404
  *
403
405
  * THE ARGV HEAD IS THE PORT. The reference names `sys.executable` and `rituals/harness.py` (or
404
- * `build.py`); this names `process.execPath` and `bin/geneseed-cli.mjs` (or `bin/geneseed.mjs`,
406
+ * `build.py`); this names `process.execPath` and `bin/geneseed-cli.mjs` (or `bin/build-driver.mjs`,
405
407
  * which is `build.py`'s twin). The two can never be byte-equal, `tests/web_golden.py`
406
408
  * normalises the `$ <argv>` echo line for that reason, and `tests/test_web_jobs.py` asserts
407
409
  * each side's head ABSOLUTELY while comparing the tails literally — which is where a real port
@@ -409,7 +411,7 @@ export class JobManager {
409
411
  */
410
412
  const NODE = () => process.execPath;
411
413
  const CLI = () => path.join(ROOT, 'bin', 'geneseed-cli.mjs');
412
- const GEN = () => path.join(ROOT, 'bin', 'geneseed.mjs');
414
+ const GEN = () => path.join(ROOT, 'bin', 'build-driver.mjs');
413
415
 
414
416
  /**
415
417
  * The rows whose VERB has not crossed. **EMPTY SINCE P10b, AND STILL DECLARED.**
@@ -417,7 +419,7 @@ const GEN = () => path.join(ROOT, 'bin', 'geneseed.mjs');
417
419
  * P6g opened it with three rows and wrote the rule that closes it: each removed by the phase
418
420
  * that ports its verb. `update` left in P8c; `link` and `unlink` left in P10b, which is the
419
421
  * phase that also had to decide whether those two verbs should exist under npm at all
420
- * (`js/link.mjs`'s docblock carries the argument, and the deciding vote was these two rows —
422
+ * (`js/hosts/link.mjs`'s docblock carries the argument, and the deciding vote was these two rows —
421
423
  * the console's Settings buttons POST here, and only a ported verb can answer them).
422
424
  *
423
425
  * WHY AN EMPTY SET RATHER THAN A DELETED ONE. The `ast` cross-check in
@@ -473,7 +475,7 @@ function actionTable({
473
475
  // P8c. The row names `upgrade`, which is the VERB; `update` is the action name here and
474
476
  // argparse's alias there, and the reference's own row is `[py, harness.py, "upgrade"]` for
475
477
  // the same reason. The daemon bounce this job must not do itself is `GENESEED_WEB_JOB`'s
476
- // job, set by `_run` above and read by `js/update.mjs` — P6g's contract with P8, honoured.
478
+ // job, set by `_run` above and read by `js/maintain/update.mjs` — P6g's contract with P8, honoured.
477
479
  update: [[NODE(), CLI(), 'upgrade']],
478
480
  export: [[NODE(), CLI(), 'diff', '--out']],
479
481
  // Local-machine maintenance, surfaced in the web Settings. uninstall keeps memory (never