geneseed 3.1.0 → 3.1.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/CHANGELOG.md +110 -0
- package/DESIGN.md +31 -20
- package/README.md +2 -2
- package/SETUP.md +30 -3
- package/SHIPPED.md +1 -1
- package/bin/{geneseed.mjs → build-driver.mjs} +88 -42
- package/bin/geneseed-cli.mjs +33 -32
- package/bin/geneseed-hook.mjs +7 -7
- package/docs/design-history.md +40 -40
- package/docs/extending.md +113 -85
- package/docs/web/rules.md +2 -0
- package/docs/web/setup-choices.md +13 -1
- package/harness.config.json +1 -1
- package/js/README.md +203 -0
- package/js/build/bundle.mjs +231 -0
- package/js/{catalog.mjs → build/catalog.mjs} +7 -7
- package/js/build/emit-claude.mjs +287 -0
- package/js/build/emit-common.mjs +255 -0
- package/js/build/emit-opencode.mjs +214 -0
- package/js/{generate.mjs → build/generate.mjs} +45 -31
- package/js/{render.mjs → build/render.mjs} +72 -11
- package/js/{checkout.mjs → build/source.mjs} +52 -11
- package/js/build/stubs.mjs +256 -0
- package/js/{themes.mjs → build/themes.mjs} +24 -24
- package/js/build/version.mjs +155 -0
- package/js/{hooks.mjs → hosts/hooks.mjs} +47 -44
- package/js/{hosts.mjs → hosts/hosts.mjs} +30 -28
- package/js/{installs.mjs → hosts/installs.mjs} +68 -16
- package/js/{link.mjs → hosts/link.mjs} +29 -28
- package/js/{mcp.mjs → hosts/mcp.mjs} +10 -9
- package/js/{native.mjs → hosts/native.mjs} +24 -22
- package/js/{opencode.mjs → hosts/opencode.mjs} +12 -7
- package/js/{settings.mjs → hosts/settings.mjs} +94 -69
- package/js/inspect/checks-authoring.mjs +761 -0
- package/js/inspect/checks-build.mjs +231 -0
- package/js/inspect/checks-repo.mjs +308 -0
- package/js/{diff.mjs → inspect/diff.mjs} +34 -31
- package/js/inspect/doctor.mjs +286 -0
- package/js/{excludes.mjs → inspect/excludes.mjs} +30 -33
- package/js/{inventory.mjs → inspect/inventory.mjs} +34 -20
- package/js/{registry.mjs → inspect/registry.mjs} +12 -10
- package/js/inspect/scan.mjs +125 -0
- package/js/{status.mjs → inspect/status.mjs} +27 -25
- package/js/inspect/validate.mjs +199 -0
- package/js/lib/fs.mjs +37 -759
- package/js/lib/json.mjs +344 -0
- package/js/lib/paths.mjs +242 -0
- package/js/lib/proc.mjs +3 -3
- package/js/lib/text.mjs +181 -0
- package/js/lib/udiff.mjs +1 -1
- package/js/{memory.mjs → maintain/memory.mjs} +10 -10
- package/js/{migrate.mjs → maintain/migrate.mjs} +28 -26
- package/js/{setup.mjs → maintain/setup.mjs} +49 -37
- package/js/{uninstall.mjs → maintain/uninstall.mjs} +62 -58
- package/js/{update.mjs → maintain/update.mjs} +51 -51
- package/js/{anim.mjs → ui/anim.mjs} +30 -28
- package/js/{cli.mjs → ui/cli.mjs} +10 -10
- package/js/{menu.mjs → ui/menu.mjs} +10 -9
- package/js/{tui.mjs → ui/tui.mjs} +36 -34
- package/js/web/actions.mjs +124 -89
- package/js/web/activity.mjs +22 -21
- package/js/web/api.mjs +88 -58
- package/js/web/daemon.mjs +319 -0
- package/js/web/docs.mjs +24 -21
- package/js/web/graph.mjs +0 -0
- package/js/web/handler.mjs +442 -0
- package/js/web/jobs.mjs +29 -21
- package/js/web/routes.mjs +223 -0
- package/js/web/server.mjs +56 -987
- package/package.json +11 -3
- package/src/AGENT.md.tmpl +1 -1
- package/src/doctrines/README.md +1 -1
- package/themes/opencode/README.md +1 -1
- package/web/dist/assets/{About-_MnJcwb_.js → About-CPvIFZGh.js} +1 -1
- package/web/dist/assets/{Activity-DpjqO-FR.js → Activity-z0uXdS7J.js} +1 -1
- package/web/dist/assets/{ActivityDetail-Bvrjv8oW.js → ActivityDetail-CEj5IJpF.js} +1 -1
- package/web/dist/assets/{Diff-DZ0IehBV.js → Diff-BMR8fPuG.js} +1 -1
- package/web/dist/assets/{Doctor-cu4krooO.js → Doctor-Bz5qE4r9.js} +1 -1
- package/web/dist/assets/{Graph-DUOPY65j.js → Graph-BV6a6t2l.js} +1 -1
- package/web/dist/assets/{Harnesses-B5Osg-BF.js → Harnesses-C5Ag5PaQ.js} +1 -1
- package/web/dist/assets/Laws-BK07NKw9.js +3 -0
- package/web/dist/assets/{Library-8y69I3_z.js → Library-CiXN9sC3.js} +1 -1
- package/web/dist/assets/{Markdown-Ds5GS1OD.js → Markdown-DG6FjUz-.js} +1 -1
- package/web/dist/assets/{Profile-7Jxa7ZUX.js → Profile-B2oSvdxH.js} +1 -1
- package/web/dist/assets/{Rules-UZ0jWkjg.js → Rules-DobVPd80.js} +1 -1
- package/web/dist/assets/{Skills-Bgkl0xLd.js → Skills-DQPsvsn9.js} +1 -1
- package/web/dist/assets/{StatusBadge-Cx4caF_2.js → StatusBadge-BhJ3HhdI.js} +1 -1
- package/web/dist/assets/{Themes-De8az7Sv.js → Themes-BP38FBU9.js} +1 -1
- package/web/dist/assets/{activity-VDAdVQ3q.js → activity-D9mfv7pY.js} +1 -1
- package/web/dist/assets/index-BE226Gv6.css +1 -0
- package/web/dist/assets/{index-BKrjBRV4.js → index-BZ7cPLMI.js} +1 -1
- package/web/dist/assets/index-BgIs5eIa.js +12 -0
- package/web/dist/assets/{index-wNiNzFFQ.js → index-DGM5j2hB.js} +1 -1
- package/web/dist/index.html +2 -2
- package/web/src/pages/Laws.jsx +135 -69
- package/docs/declined.md +0 -200
- package/docs/limits.md +0 -329
- package/js/doctor.mjs +0 -1712
- package/js/emit.mjs +0 -1410
- package/web/dist/assets/Laws-D8DJgTBp.js +0 -3
- package/web/dist/assets/index-1pPSz7Kl.css +0 -1
- package/web/dist/assets/index-C-gb-q-r.js +0 -12
package/js/web/docs.mjs
CHANGED
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
* cannot even run. This port's standing rule is that a copy of a value under test stops
|
|
17
17
|
* being the value under test, and this would be the largest copy in it. **PAID IN P10c**,
|
|
18
18
|
* by the fix the deferral named: the parser's metadata became a FILE both implementations
|
|
19
|
-
* read — `js/cli-table.json` since P2. `js/cli.mjs` is the reader,
|
|
19
|
+
* read — `js/cli-table.json` since P2. `js/ui/cli.mjs` is the reader,
|
|
20
20
|
* `tests/test_cli_reference.py` is what fails when the table and the parser part company
|
|
21
21
|
* for as long as the parser exists, and `bin/geneseed-cli.mjs`'s `VERBS` table
|
|
22
22
|
* — which was a SECOND transcription of the same 43 calls — reads it too, so the port
|
|
@@ -28,9 +28,10 @@
|
|
|
28
28
|
* removal from `NOT_PORTED_KINDS` and a row in `KIND_ROUTES`.
|
|
29
29
|
*
|
|
30
30
|
* So `NOT_PORTED_KINDS` declares what is left, `api_docs_page` answers 501 for it, and
|
|
31
|
-
* `tests/
|
|
32
|
-
*
|
|
33
|
-
*
|
|
31
|
+
* `tests/unit/web_server.test.mjs` requires `PORTED_KINDS` ∪ `NOT_PORTED_KINDS` to equal
|
|
32
|
+
* `REF_KINDS` — a FROZEN LITERAL of the five kinds the reference dispatched on, since the
|
|
33
|
+
* reference itself is deleted. Same partition shape as the route table, which is why it is a
|
|
34
|
+
* table here too and not two `if`s.
|
|
34
35
|
*
|
|
35
36
|
* THE `?harness=` QUERY PARAM LANDS HERE. P6a skipped it deliberately (no caller); it is
|
|
36
37
|
* the Docs selector, and it is the ONLY input to these endpoints that is not the checkout,
|
|
@@ -40,13 +41,15 @@
|
|
|
40
41
|
import { readdirSync, statSync } from 'node:fs';
|
|
41
42
|
import path from 'node:path';
|
|
42
43
|
|
|
43
|
-
import { ROOT, THEMES } from '../
|
|
44
|
-
import { cliReference } from '../cli.mjs';
|
|
45
|
-
import { readJsonMaybe, readMaybe } from '../installs.mjs';
|
|
46
|
-
import {
|
|
47
|
-
import {
|
|
48
|
-
import {
|
|
49
|
-
import {
|
|
44
|
+
import { ROOT, THEMES } from '../build/source.mjs';
|
|
45
|
+
import { cliReference } from '../ui/cli.mjs';
|
|
46
|
+
import { readJsonMaybe, readMaybe } from '../hosts/installs.mjs';
|
|
47
|
+
import { resolvePath } from '../hosts/hosts.mjs';
|
|
48
|
+
import { parseJson } from '../lib/json.mjs';
|
|
49
|
+
import { normcase } from '../lib/paths.mjs';
|
|
50
|
+
import { WHITESPACE, stripWhitespace } from '../lib/text.mjs';
|
|
51
|
+
import { statusData } from '../inspect/status.mjs';
|
|
52
|
+
import { originDisplay } from '../maintain/update.mjs';
|
|
50
53
|
import { NotFound, deployed, resolveLinks } from './api.mjs';
|
|
51
54
|
|
|
52
55
|
const isFile = (p) => { try { return statSync(p).isFile(); } catch { return false; } };
|
|
@@ -262,7 +265,7 @@ export function harnessBlocksBalanced(lines) {
|
|
|
262
265
|
/** `_web_docs._strip_harness_blocks`. */
|
|
263
266
|
export function stripHarnessBlocks(body, harnessName) {
|
|
264
267
|
if (!HARNESS_HINT_RE.test(body)) return body;
|
|
265
|
-
const lines =
|
|
268
|
+
const lines = splitLines(body);
|
|
266
269
|
if (!harnessBlocksBalanced(lines)) return body;
|
|
267
270
|
const out = [];
|
|
268
271
|
let keep = true;
|
|
@@ -288,7 +291,7 @@ export function stripHarnessBlocks(body, harnessName) {
|
|
|
288
291
|
* instead of yielding a final empty string, and that difference reaches the output here
|
|
289
292
|
* because the result is re-joined with `\n`.
|
|
290
293
|
*/
|
|
291
|
-
function
|
|
294
|
+
function splitLines(s) {
|
|
292
295
|
const parts = s.split('\n');
|
|
293
296
|
if (parts.length && parts[parts.length - 1] === '') parts.pop();
|
|
294
297
|
return parts;
|
|
@@ -307,8 +310,8 @@ function visibleGroups(harnessName) {
|
|
|
307
310
|
|
|
308
311
|
// ---- slugs and section slicing ----------------------------------------------------------
|
|
309
312
|
|
|
310
|
-
const SLUG_STRIP_RE = new RegExp(`[^a-z0-9${
|
|
311
|
-
const SLUG_WS_RE = new RegExp(`[${
|
|
313
|
+
const SLUG_STRIP_RE = new RegExp(`[^a-z0-9${WHITESPACE}-]`, 'g');
|
|
314
|
+
const SLUG_WS_RE = new RegExp(`[${WHITESPACE}]+`, 'g');
|
|
312
315
|
const SLUG_DASH_RE = /-+/g;
|
|
313
316
|
const HEADING_RE = /^(#{1,6})\s+(.+?)\s*$/;
|
|
314
317
|
|
|
@@ -316,14 +319,14 @@ const HEADING_RE = /^(#{1,6})\s+(.+?)\s*$/;
|
|
|
316
319
|
* `_web_docs._slugify_heading` — the same rules the frontend's `slug()` uses, so a
|
|
317
320
|
* registry `anchor` written against a heading matches the id the renderer assigns.
|
|
318
321
|
*
|
|
319
|
-
* `
|
|
322
|
+
* `WHITESPACE` and `stripWhitespace` rather than `\s` and `trim()`, and the corpus in
|
|
320
323
|
* `tests/test_pure_function_parity.py` is what made that necessary rather than tidy: the
|
|
321
324
|
* two languages' whitespace classes differ at U+FEFF and at U+001C-U+001F, and a heading
|
|
322
|
-
* carrying one slugs differently on each side. See `
|
|
325
|
+
* carrying one slugs differently on each side. See `WHITESPACE`'s docblock for the measured
|
|
323
326
|
* set.
|
|
324
327
|
*/
|
|
325
328
|
export function slugifyHeading(text) {
|
|
326
|
-
let s =
|
|
329
|
+
let s = stripWhitespace(text.toLowerCase()).replace(SLUG_STRIP_RE, '');
|
|
327
330
|
s = s.replace(SLUG_WS_RE, '-').replace(SLUG_DASH_RE, '-');
|
|
328
331
|
return s.replace(/^-+|-+$/g, '');
|
|
329
332
|
}
|
|
@@ -336,7 +339,7 @@ export function slugifyHeading(text) {
|
|
|
336
339
|
* H2 — `max(level, 2)` — so it captures an intro paragraph rather than the whole file.
|
|
337
340
|
*/
|
|
338
341
|
export function sliceSection(body, anchor) {
|
|
339
|
-
const lines =
|
|
342
|
+
const lines = splitLines(body);
|
|
340
343
|
let start = -1;
|
|
341
344
|
let startLevel = 0;
|
|
342
345
|
let inFence = false;
|
|
@@ -383,7 +386,7 @@ function findDocPage(pageId) {
|
|
|
383
386
|
* but the guard is what makes that safe to say.
|
|
384
387
|
*/
|
|
385
388
|
function readDocSource(rel) {
|
|
386
|
-
const target =
|
|
389
|
+
const target = resolvePath(path.join(ROOT, rel));
|
|
387
390
|
if (!within(target, ROOT) || !isFile(target)) throw new NotFound(rel);
|
|
388
391
|
return readMaybe(target) ?? '';
|
|
389
392
|
}
|
|
@@ -480,7 +483,7 @@ function about(state) {
|
|
|
480
483
|
*
|
|
481
484
|
* `about` left in P8c (`_origin_display` and the `git` spawn behind it, not the page).
|
|
482
485
|
* `cli` left in P10c, and its blocker was neither: it was that `harness.build_argparser()`
|
|
483
|
-
* cannot be introspected from Node at all. See `js/cli.mjs`.
|
|
486
|
+
* cannot be introspected from Node at all. See `js/ui/cli.mjs`.
|
|
484
487
|
*/
|
|
485
488
|
export const NOT_PORTED_KINDS = new Set();
|
|
486
489
|
|
package/js/web/graph.mjs
CHANGED
|
Binary file
|
|
@@ -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
|
+
|