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/server.mjs
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* The web
|
|
3
|
-
*
|
|
4
|
-
*
|
|
2
|
+
* The `geneseed web` VERB — the UI-bundle decision, the npm build, `serve()` and the CLI
|
|
3
|
+
* entry. The request handler and the static route are in `handler.mjs`, the route
|
|
4
|
+
* declarations in `routes.mjs`, the daemon record and lifecycle in `daemon.mjs`; this file
|
|
5
|
+
* binds a socket and wires the three together.
|
|
5
6
|
*
|
|
6
7
|
* WHY THE SHELL FIRST, AND ALONE. `_web_server.py` is 654 lines and none of it is an API
|
|
7
8
|
* function: it is routing, two security guards, gzip negotiation, the static route with
|
|
@@ -17,7 +18,7 @@
|
|
|
17
18
|
* read its body, it joins on `', '`. So the twin needs no new helper and the response
|
|
18
19
|
* bodies stay byte-comparable.
|
|
19
20
|
*
|
|
20
|
-
* Its one condition is the int/float distinction. `
|
|
21
|
+
* Its one condition is the int/float distinction. `formatValue` refuses a BARE JS number
|
|
21
22
|
* because `json.loads` tells `20` from `1.0` and `JSON.parse` does not — but the numbers
|
|
22
23
|
* in a response body are not parsed from JSON, they are computed here (counts, a port, a
|
|
23
24
|
* pid, a unix second), and every one of them is a Python `int`, which renders identically
|
|
@@ -25,975 +26,43 @@
|
|
|
25
26
|
* call sites in a factory. A Python float would be the counter-example — `1.0` against
|
|
26
27
|
* JS's `1` — and the byte gate over every endpoint body is what would catch one.
|
|
27
28
|
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
*
|
|
32
|
-
*
|
|
33
|
-
* `
|
|
34
|
-
*
|
|
35
|
-
*
|
|
29
|
+
* THE DAEMON LIFECYCLE MOVED OUT, AND THE PREDICTED CYCLE DID NOT MATERIALISE. This
|
|
30
|
+
* docblock used to argue that splitting the handler from the daemon would make `serve()`
|
|
31
|
+
* (which needs `npmBuild`) and the launcher (which needs `readDaemon`) import each other.
|
|
32
|
+
* They do not: the dependency runs ONE WAY, `server → daemon`, because `npmBuild` stayed
|
|
33
|
+
* here with `serve` and only the record readers went. `probe`, `liveDaemon`,
|
|
34
|
+
* `spawnDetached`, `start|stop|status|restartDaemon`, `openUrl` and `requestRestart` are in
|
|
35
|
+
* `daemon.mjs`; `npmBuild`, `buildPlan`, `serve` and `cmdWeb` are here.
|
|
36
|
+
*
|
|
37
|
+
* `bin/geneseed-cli.mjs` imports `cmdWeb` statically, which is what put this tree on the
|
|
38
|
+
* CLI's import graph and moved `js/web/jobs.mjs`'s spawn row to `entry: "cli"` — and it is
|
|
39
|
+
* why a runtime dependency reached from here would load on EVERY `geneseed` invocation.
|
|
36
40
|
*
|
|
37
41
|
* WHAT IS STILL NOT HERE, SINCE P6i: `/api/pick-folder` alone, and it is DECLINED rather than
|
|
38
42
|
* deferred — an OS-native folder chooser has no Node twin that is not a new GUI dependency.
|
|
39
43
|
* `NOT_PORTED_POST` is empty and stays declared, because an empty half of a partition is the
|
|
40
44
|
* partition asserting there is nothing left; `DECLINED_POST` is what still has a member.
|
|
41
45
|
*/
|
|
42
|
-
import {
|
|
46
|
+
import { GLOBAL_MANIFEST, resolvePath } from '../hosts/hosts.mjs';
|
|
47
|
+
import { printOut } from '../lib/fs.mjs';
|
|
48
|
+
import { which } from '../lib/paths.mjs';
|
|
49
|
+
import { parseIntStrict } from '../lib/text.mjs';
|
|
50
|
+
import { promptLine } from '../maintain/setup.mjs';
|
|
51
|
+
import { webState } from './api.mjs';
|
|
52
|
+
import { clearDaemon, openUrl, readDaemon, restartDaemon, startDaemon, statusDaemon, stopDaemon, writeDaemon } from './daemon.mjs';
|
|
53
|
+
import { isFile, makeHandler } from './handler.mjs';
|
|
54
|
+
import { JobManager } from './jobs.mjs';
|
|
55
|
+
import { spawnSync } from 'node:child_process';
|
|
43
56
|
import { randomBytes } from 'node:crypto';
|
|
44
|
-
import {
|
|
45
|
-
|
|
46
|
-
} from 'node:
|
|
47
|
-
import { createServer, request as httpRequest } from 'node:http';
|
|
48
|
-
import { join, resolve as pathResolve, dirname, extname, basename, sep } from 'node:path';
|
|
57
|
+
import { existsSync } from 'node:fs';
|
|
58
|
+
import { createServer } from 'node:http';
|
|
59
|
+
import { join, resolve as pathResolve } from 'node:path';
|
|
49
60
|
import { fileURLToPath } from 'node:url';
|
|
50
|
-
import { gzipSync } from 'node:zlib';
|
|
51
|
-
|
|
52
|
-
import { GLOBAL_MANIFEST, pyResolve } from '../hosts.mjs';
|
|
53
|
-
import {
|
|
54
|
-
jsonDumpsCompact, parseJson, pyInt, pyPrint, pyStr, pyTruthy, pyUnquote, pyWhich, writeText,
|
|
55
|
-
} from '../lib/fs.mjs';
|
|
56
|
-
import { promptLine } from '../setup.mjs';
|
|
57
|
-
// P8c. The reference imports `_update` LAZILY inside the handler, to keep `build` and its
|
|
58
|
-
// `sys.path` side effect out of web-import time; ESM has no such side effect and the import is
|
|
59
|
-
// static. `js/update.mjs` imports `restartDaemon` from THIS module, so the two form a cycle —
|
|
60
|
-
// which resolves because everything either side needs of the other is a hoisted function
|
|
61
|
-
// declaration, not a value read at module-evaluation time.
|
|
62
|
-
import { preflight } from '../update.mjs';
|
|
63
|
-
import { doctrinesForBuild } from '../installs.mjs';
|
|
64
|
-
import { NotFound, PREFIX_ROUTES, STATE_ROUTES, webState } from './api.mjs';
|
|
65
|
-
import {
|
|
66
|
-
apiDeployCmd, apiExcludesMutate, apiInstallCmd, apiInstallToggle, apiMcpToggle,
|
|
67
|
-
apiMemoryDelete, apiProfileSave,
|
|
68
|
-
apiRestore, apiRulesMutate, apiRulesPromote, apiSelectView, buildOverride, mcpTargetPaths,
|
|
69
|
-
} from './actions.mjs';
|
|
70
|
-
import { apiActivityToggle } from './activity.mjs';
|
|
71
|
-
import { apiDocs, apiDocsPage } from './docs.mjs';
|
|
72
|
-
import { JobManager, NOT_PORTED_ACTIONS, actionCommands } from './jobs.mjs';
|
|
73
|
-
|
|
74
|
-
export { webState };
|
|
75
61
|
|
|
76
62
|
const ROOT = pathResolve(fileURLToPath(import.meta.url), '..', '..', '..');
|
|
77
63
|
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
* part a port gets wrong. `h.split(":", 1)[0]` keeps everything before the FIRST colon,
|
|
81
|
-
* so `localhost:4747` passes and a bare IPv6 literal would not — which is what the
|
|
82
|
-
* `[::1]` prefix test above it is for.
|
|
83
|
-
*/
|
|
84
|
-
export function localHost(host) {
|
|
85
|
-
const h = (host || '').trim().toLowerCase();
|
|
86
|
-
if (h.startsWith('[::1]')) return true;
|
|
87
|
-
return ['127.0.0.1', 'localhost'].includes(h.split(':', 1)[0]);
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
const GZIP_TYPES = ['application/json', 'text/', 'image/svg+xml',
|
|
91
|
-
'application/manifest+json', 'application/javascript'];
|
|
92
|
-
const GZIP_MIN = 1024;
|
|
93
|
-
|
|
94
|
-
const CTYPES = {
|
|
95
|
-
'.html': 'text/html', '.js': 'text/javascript',
|
|
96
|
-
'.css': 'text/css', '.json': 'application/json',
|
|
97
|
-
'.svg': 'image/svg+xml', '.ico': 'image/x-icon',
|
|
98
|
-
'.woff2': 'font/woff2', '.woff': 'font/woff',
|
|
99
|
-
'.webmanifest': 'application/manifest+json',
|
|
100
|
-
'.png': 'image/png',
|
|
101
|
-
};
|
|
102
|
-
|
|
103
|
-
/**
|
|
104
|
-
* The API paths this phase does not answer yet.
|
|
105
|
-
*
|
|
106
|
-
* Without it every one of them would fall through to `serveStatic`, which answers ANY
|
|
107
|
-
* unknown path with index.html and a 200 — the SPA fallback. An unported endpoint would
|
|
108
|
-
* therefore return a plausible HTML page instead of failing, and the first cell written
|
|
109
|
-
* against it in P6b would be the thing that discovered the route was missing. A 501
|
|
110
|
-
* naming the phase costs one lookup and cannot be mistaken for a working endpoint.
|
|
111
|
-
*
|
|
112
|
-
* It shrank to empty in P6g and the DECLARATION STAYS. Every GET the reference answers is now
|
|
113
|
-
* answered here, so the two sets hold nothing — but they are half of a partition
|
|
114
|
-
* (`test_every_get_route_is_either_ported_or_declared_unported`), and deleting them would let
|
|
115
|
-
* the next GET route added to the reference fall through to the SPA with nothing to say so.
|
|
116
|
-
* An empty declaration is the partition asserting that there is nothing left to declare.
|
|
117
|
-
*/
|
|
118
|
-
/**
|
|
119
|
-
* SPLIT BY VERB SINCE P6b, and the split is the point rather than a tidy-up. Five paths
|
|
120
|
-
* answer BOTH verbs with different bodies — `/api/mcp`, `/api/excludes`, `/api/rules`,
|
|
121
|
-
* `/api/profile`, `/api/activity` — and P6b ports the GET half of two of them. One set
|
|
122
|
-
* keyed on path alone would have taken `/api/excludes` out of the unported list the
|
|
123
|
-
* moment its GET landed, and its POST would then have answered `{"error": "not found"}`
|
|
124
|
-
* with a 404: a plausible-looking response where the reference returns 200 or 409, which
|
|
125
|
-
* is exactly the failure the 501 exists to prevent.
|
|
126
|
-
*/
|
|
127
|
-
/** EMPTY SINCE P6g — every GET the reference answers is answered here. */
|
|
128
|
-
export const NOT_PORTED = new Set([]);
|
|
129
|
-
export const NOT_PORTED_PREFIXES = [];
|
|
130
|
-
|
|
131
|
-
/**
|
|
132
|
-
* The POSTs that have not crossed YET — and EMPTY SINCE P6i, which took `/api/install`.
|
|
133
|
-
*
|
|
134
|
-
* IT STAYS DECLARED, for the same reason the GET pair above stays and the prefix lists have
|
|
135
|
-
* stayed since P6g: this set is half of a partition
|
|
136
|
-
* (`test_every_post_route_is_either_ported_or_declared_unported`), so an empty set is the
|
|
137
|
-
* partition ASSERTING that there is nothing left to declare. Delete it and the next POST route
|
|
138
|
-
* added to the reference falls through to `{"error": "not found"}` at 404 — a plausible-looking
|
|
139
|
-
* answer where the reference returns 200 or 409 — with nothing to say so.
|
|
140
|
-
*
|
|
141
|
-
* THE DISPATCHER PROBE NEEDS A TARGET, which an empty set no longer supplies.
|
|
142
|
-
* `tests/test_web_server.py` retired its `unportedGet` probe when `NOT_PORTED` emptied in P6g;
|
|
143
|
-
* `unportedPost` is retired here for the same reason, and `declinedPost` (`/api/pick-folder`,
|
|
144
|
-
* which never crosses) is what keeps the 501 arm of the dispatcher under a live probe.
|
|
145
|
-
*/
|
|
146
|
-
export const NOT_PORTED_POST = new Set([]);
|
|
147
|
-
export const NOT_PORTED_POST_PREFIXES = [];
|
|
148
|
-
|
|
149
|
-
/**
|
|
150
|
-
* The POSTs that will NEVER cross, which is a different claim from the set above.
|
|
151
|
-
*
|
|
152
|
-
* `/api/pick-folder` opens an OS-NATIVE folder chooser on the daemon host — `osascript` on
|
|
153
|
-
* macOS, a one-shot `tkinter` subprocess elsewhere. There is no Node twin that is not a new
|
|
154
|
-
* GUI dependency, and reaching for one would also be a sixth `_ALLOWED_SPAWNS` row for a
|
|
155
|
-
* modal dialog. The UI already falls back to its editable path field when this endpoint
|
|
156
|
-
* fails, which is what a 501 gives it.
|
|
157
|
-
*
|
|
158
|
-
* SEPARATE FROM `NOT_PORTED_POST` ON PURPOSE. That set is a to-do list every later phase
|
|
159
|
-
* shrinks, and folding a permanent decline into it would make the list wrong in the one
|
|
160
|
-
* direction nobody checks: a phase that emptied it would have to either port a GUI dialog
|
|
161
|
-
* or quietly delete a declaration. Both sets are unioned into the partition the reference's
|
|
162
|
-
* routes are cross-checked against, so neither can drift.
|
|
163
|
-
*/
|
|
164
|
-
export const DECLINED_POST = new Set([
|
|
165
|
-
'/api/pick-folder',
|
|
166
|
-
]);
|
|
167
|
-
|
|
168
|
-
/**
|
|
169
|
-
* The POSTs P6f answers — and this table IS the dispatch, not a declaration beside it.
|
|
170
|
-
*
|
|
171
|
-
* M23's lesson, applied one verb over: `test_the_declared_partition_is_the_one_the_dispatcher_uses`
|
|
172
|
-
* exists because a hand-kept list can agree with the reference while the running handler
|
|
173
|
-
* does something else. `doPost` looks routes up HERE, and `PORTED_POST` is this table's own
|
|
174
|
-
* keys, so the two cannot drift — the same reason `STATE_ROUTES` is a table on the GET side.
|
|
175
|
-
*
|
|
176
|
-
* THE SECOND COLUMN IS THE 409 CONVENTION, and it is a column because it is not uniform.
|
|
177
|
-
* `ok: false` means the write did not happen — a stale fingerprint, a body that never
|
|
178
|
-
* parsed, nothing to remove — and the client must re-fetch rather than retry, which is what
|
|
179
|
-
* 409 says and 200 does not. Five routes map it. `/api/activity` does NOT: its flag write
|
|
180
|
-
* answers 200 whatever `ok` says, and a port that unified the rule would be wrong exactly
|
|
181
|
-
* there. `/api/memory/delete` carries no `ok` at all — it deletes, or it raises `NotFound`
|
|
182
|
-
* and the outer catch answers 404 — so it is `false` here for a third reason again.
|
|
183
|
-
*
|
|
184
|
-
* `NotFound` IS NOT CAUGHT PER ROUTE. The reference wraps six of these calls in their own
|
|
185
|
-
* `except NotFound` AND wraps the whole of `_post_routes` in another inside `do_POST`. The
|
|
186
|
-
* inner six are redundant — every one produces the same `{"error": "not found: …"}` at 404
|
|
187
|
-
* — so the twin keeps the outer one only, in `handler`, where `do_POST`'s is.
|
|
188
|
-
*/
|
|
189
|
-
const POST_ROUTES = new Map([
|
|
190
|
-
['/api/mcp', [apiMcpToggle, true]],
|
|
191
|
-
// P6i. `_post_routes` wraps this one in its own `except NotFound` and then maps
|
|
192
|
-
// `ok: false` to 409 — the same shape as `/api/mcp` beside it, so it fits the table's
|
|
193
|
-
// second column exactly and needs no third inline dispatch. The inner catch is redundant
|
|
194
|
-
// (see this table's docblock); `handler`'s outer one answers identically.
|
|
195
|
-
['/api/install', [apiInstallToggle, true]],
|
|
196
|
-
['/api/excludes', [apiExcludesMutate, true]],
|
|
197
|
-
['/api/view', [apiSelectView, true]],
|
|
198
|
-
['/api/activity', [apiActivityToggle, false]],
|
|
199
|
-
// `(self._read_json_body().get("name") or "")` — the SHELL resolves the name, and the
|
|
200
|
-
// endpoint takes a bare slug rather than a body.
|
|
201
|
-
['/api/memory/delete',
|
|
202
|
-
[(state, b) => apiMemoryDelete(state, pyTruthy(b.name) ? pyStr(b.name) : ''), false]],
|
|
203
|
-
['/api/rules', [apiRulesMutate, true]],
|
|
204
|
-
['/api/rules/promote', [apiRulesPromote, true]],
|
|
205
|
-
['/api/profile', [apiProfileSave, true]],
|
|
206
|
-
]);
|
|
207
|
-
|
|
208
|
-
export const PORTED_POST = [...POST_ROUTES.keys()];
|
|
209
|
-
|
|
210
|
-
/**
|
|
211
|
-
* The 409 column, exported so it can be cross-checked against the reference's own `200 if
|
|
212
|
-
* res.get("ok") else 409` conditionals by `ast`.
|
|
213
|
-
*
|
|
214
|
-
* A MUTATION IS WHY THIS EXISTS. Giving `/api/activity` the 409 treatment survived the whole
|
|
215
|
-
* cell matrix: every toggle a cell can perform succeeds, and reaching the `ok: false` arm
|
|
216
|
-
* needs the flag write to raise — which the two runtimes word differently, so no byte
|
|
217
|
-
* comparison could hold it. The column is the thing under test and no cell can see it, so
|
|
218
|
-
* the gate reads it out of both implementations instead.
|
|
219
|
-
*/
|
|
220
|
-
export const POST_ROUTES_CONVENTION = Object.fromEntries(
|
|
221
|
-
[...POST_ROUTES].map(([p, [, okIs409]]) => [p, okIs409]),
|
|
222
|
-
);
|
|
223
|
-
|
|
224
|
-
/**
|
|
225
|
-
* The GET paths the dispatcher answers OUTSIDE the two tables, declared so the partition
|
|
226
|
-
* cross-check can see them: `/api/ping` is the shell's own, and the two Docs routes take
|
|
227
|
-
* the `?harness=` query param, which a `(state)` table entry has no way to receive.
|
|
228
|
-
*
|
|
229
|
-
* A declaration is not a dispatcher — M23 — so
|
|
230
|
-
* `test_the_declared_partition_is_the_one_the_dispatcher_uses` probes all three against
|
|
231
|
-
* the running handler as well.
|
|
232
|
-
*/
|
|
233
|
-
export const PORTED_INLINE = ['/api/ping', '/api/docs', '/api/docs/page/',
|
|
234
|
-
'/api/jobs', '/api/jobs/'];
|
|
235
|
-
|
|
236
|
-
/**
|
|
237
|
-
* The POSTs P6g answers outside `POST_ROUTES`, and they are outside it for a reason the
|
|
238
|
-
* table's SECOND COLUMN makes plain.
|
|
239
|
-
*
|
|
240
|
-
* `POST_ROUTES` maps a route to `(fn, okIs409)` — one handler taking `(state, body)`, one
|
|
241
|
-
* boolean deciding 200 against 409. The jobs routes fit neither half. `/api/jobs/<id>/cancel`
|
|
242
|
-
* answers 200 or 404 on a lookup, not on an `ok` field; `/api/actions/<x>` answers 202 with a
|
|
243
|
-
* job id, 409 when the runner is busy, 404 for an action the table does not name, 501 for one
|
|
244
|
-
* that has not crossed, 400 or 409 for a resolver's refusal, and 200 for `restore` — six
|
|
245
|
-
* statuses over one prefix, none of them read off `ok`. Bending the column to fit would make
|
|
246
|
-
* it lie about the five routes it currently describes exactly, so these dispatch beside the
|
|
247
|
-
* table and `tests/test_web_jobs.py` reads them out of `PORTED_POST_INLINE` instead.
|
|
248
|
-
*/
|
|
249
|
-
export const PORTED_POST_INLINE = ['/api/shutdown', '/api/restart', '/api/jobs/',
|
|
250
|
-
'/api/actions/'];
|
|
251
|
-
|
|
252
|
-
/**
|
|
253
|
-
* POST routes THAT NEVER EXISTED ON THE REFERENCE — the first of them, and the reason this set
|
|
254
|
-
* has to exist at all.
|
|
255
|
-
*
|
|
256
|
-
* `test_every_post_route_is_either_ported_or_declared_unported` asserts SET EQUALITY against
|
|
257
|
-
* `REF_POST`, a frozen literal of the paths the Python daemon answered. That was exactly right
|
|
258
|
-
* while the port was in flight: a route on one side and not the other was a defect either way.
|
|
259
|
-
* The port is finished and the reference is deleted, so the assertion now says something else —
|
|
260
|
-
* "this daemon may never grow a route" — which is a rule nobody chose.
|
|
261
|
-
*
|
|
262
|
-
* SO THE PARTITION GAINS A FOURTH PART RATHER THAN LOSING ITS EQUALITY. Widening `REF_POST` would
|
|
263
|
-
* have been the cheap edit and it would have been a lie: that list is a RECORD of what the
|
|
264
|
-
* reference answered, and a route added in 2026 was never in it. Keeping the record honest and
|
|
265
|
-
* declaring the additions beside it keeps both claims checkable — the reference's surface is
|
|
266
|
-
* still pinned, and everything past it is enumerated here instead of merely appearing.
|
|
267
|
-
*
|
|
268
|
-
* A route belongs here only once it is dispatched below; the dispatcher probe is what keeps this
|
|
269
|
-
* from becoming a declaration nobody honours.
|
|
270
|
-
*/
|
|
271
|
-
export const POST_BEYOND_REF = new Set([
|
|
272
|
-
'/api/reveal',
|
|
273
|
-
]);
|
|
274
|
-
|
|
275
|
-
function notPorted(path) {
|
|
276
|
-
return NOT_PORTED.has(path) || NOT_PORTED_PREFIXES.some((p) => path.startsWith(p));
|
|
277
|
-
}
|
|
278
|
-
|
|
279
|
-
function notPortedPost(path) {
|
|
280
|
-
return NOT_PORTED_POST.has(path) || DECLINED_POST.has(path)
|
|
281
|
-
|| NOT_PORTED_POST_PREFIXES.some((p) => path.startsWith(p));
|
|
282
|
-
}
|
|
283
|
-
|
|
284
|
-
/**
|
|
285
|
-
* `Handler._read_json_body` — the drained body as a dict, `{}` for ANYTHING else.
|
|
286
|
-
*
|
|
287
|
-
* P6a retained the drain with a note that it was measured indistinguishable and stayed
|
|
288
|
-
* because "`_read_json_body` needs the bytes from P6f on". This is that caller.
|
|
289
|
-
*
|
|
290
|
-
* `{}` AND NEVER A RAISE is the contract, and it is what makes a malformed body a 409 or a
|
|
291
|
-
* 404 from the endpoint's own validation rather than a 500 from a parser. That matters
|
|
292
|
-
* beyond tidiness: P5a's one un-portable line is a `json` error message, because CPython
|
|
293
|
-
* and V8 word them differently AND report different offsets — so a 500 carrying a parse
|
|
294
|
-
* error is a response the two implementations can never agree on.
|
|
295
|
-
*
|
|
296
|
-
* `parseJson`, not `JSON.parse`: a body value can be written straight into a user's file
|
|
297
|
-
* (`api_profile_save`'s `text`, `api_rules_mutate`'s `title`) and `str(1.0)` is `"1.0"`
|
|
298
|
-
* where `String(1)` is `"1"`. A non-object body — a bare list, a number, `null` — takes the
|
|
299
|
-
* same `{}` arm as garbage, because `isinstance(obj, dict)` is what the reference asks.
|
|
300
|
-
*/
|
|
301
|
-
function readJsonBody(buf) {
|
|
302
|
-
let obj;
|
|
303
|
-
try {
|
|
304
|
-
obj = parseJson((buf && buf.length ? buf : Buffer.from('{}')).toString('utf-8'));
|
|
305
|
-
} catch {
|
|
306
|
-
return {};
|
|
307
|
-
}
|
|
308
|
-
return (obj !== null && typeof obj === 'object' && obj.constructor === Object) ? obj : {};
|
|
309
|
-
}
|
|
310
|
-
|
|
311
|
-
// ---- the request handler ---------------------------------------------------
|
|
312
|
-
|
|
313
|
-
/**
|
|
314
|
-
* `make_handler(state, jm, token, dist, holder)` — `jm` is the SECOND argument here as it is
|
|
315
|
-
* there. P6a shipped this without it because there was no `JobManager` to pass; every caller
|
|
316
|
-
* moved in the same commit that added one.
|
|
317
|
-
*/
|
|
318
|
-
export function makeHandler(state, jm, token, dist, holder = null) {
|
|
319
|
-
const staticCache = new Map();
|
|
320
|
-
|
|
321
|
-
function sendBytes(res, body, ctype, code = 200, extra = null, acceptEncoding = '') {
|
|
322
|
-
const headers = { ...(extra || {}) };
|
|
323
|
-
if (body.length >= GZIP_MIN && GZIP_TYPES.some((t) => ctype.startsWith(t))
|
|
324
|
-
&& (acceptEncoding || '').includes('gzip')) {
|
|
325
|
-
body = gzipSync(body, { level: 6 });
|
|
326
|
-
headers['Content-Encoding'] = 'gzip';
|
|
327
|
-
headers.Vary = 'Accept-Encoding';
|
|
328
|
-
}
|
|
329
|
-
res.writeHead(code, {
|
|
330
|
-
'Content-Type': ctype,
|
|
331
|
-
'Content-Length': String(body.length),
|
|
332
|
-
...headers,
|
|
333
|
-
});
|
|
334
|
-
res.end(body);
|
|
335
|
-
}
|
|
336
|
-
|
|
337
|
-
function sendJson(res, obj, code = 200, acceptEncoding = '') {
|
|
338
|
-
sendBytes(res, Buffer.from(jsonDumpsCompact(obj, { bareInts: true }), 'utf-8'),
|
|
339
|
-
'application/json', code, null, acceptEncoding);
|
|
340
|
-
}
|
|
341
|
-
|
|
342
|
-
function serveStatic(res, path, acceptEncoding) {
|
|
343
|
-
const rel = (path === '/' || path === '') ? 'index.html' : path.replace(/^\/+/, '');
|
|
344
|
-
const index = pyResolve(join(dist, 'index.html'));
|
|
345
|
-
let fp = pyResolve(join(dist, rel));
|
|
346
|
-
// `dist not in fp.parents`: STRICTLY under dist. `Path.parents` never contains the
|
|
347
|
-
// path itself, so `fp == dist` takes the fallback too.
|
|
348
|
-
if (!(fp.startsWith(dist + sep) && fp !== dist) && fp !== index) fp = join(dist, 'index.html');
|
|
349
|
-
if (!isFile(fp)) fp = join(dist, 'index.html');
|
|
350
|
-
if (!isFile(fp)) {
|
|
351
|
-
return sendJson(res, { error: 'web/dist missing — run the UI build' }, 500, acceptEncoding);
|
|
352
|
-
}
|
|
353
|
-
let data = staticCache.get(fp);
|
|
354
|
-
if (data === undefined) {
|
|
355
|
-
data = readFileSync(fp);
|
|
356
|
-
staticCache.set(fp, data);
|
|
357
|
-
}
|
|
358
|
-
let extra = null;
|
|
359
|
-
if (basename(fp) === 'index.html') {
|
|
360
|
-
const inject = Buffer.from(`<script>window.__GENESEED_TOKEN__="${token}";</script>`, 'utf-8');
|
|
361
|
-
data = replaceOnce(data, Buffer.from('</head>'), Buffer.concat([inject, Buffer.from('</head>')]));
|
|
362
|
-
extra = { 'Cache-Control': 'no-store' };
|
|
363
|
-
} else if (path.includes('/assets/')) {
|
|
364
|
-
extra = { 'Cache-Control': 'public, max-age=31536000, immutable' };
|
|
365
|
-
}
|
|
366
|
-
return sendBytes(res, data, CTYPES[extname(fp)] || 'application/octet-stream',
|
|
367
|
-
200, extra, acceptEncoding);
|
|
368
|
-
}
|
|
369
|
-
|
|
370
|
-
function doGet(req, res, path, ae) {
|
|
371
|
-
// The reference's own table first, then the routes that parse the path — and `ping`
|
|
372
|
-
// sits between them there too, so the order here is its order.
|
|
373
|
-
const route = STATE_ROUTES[path];
|
|
374
|
-
if (route !== undefined) return sendJson(res, route(state), 200, ae);
|
|
375
|
-
if (path === '/api/ping') return sendJson(res, { ok: true, theme: state.theme }, 200, ae);
|
|
376
|
-
for (const [prefix, handler] of PREFIX_ROUTES) {
|
|
377
|
-
if (path.startsWith(prefix)) return sendJson(res, handler(state, path), 200, ae);
|
|
378
|
-
}
|
|
379
|
-
if (path === '/api/docs') {
|
|
380
|
-
return sendJson(res, apiDocs(state, harnessParam(req.url)), 200, ae);
|
|
381
|
-
}
|
|
382
|
-
if (path.startsWith('/api/docs/page/')) {
|
|
383
|
-
const pid = pyUnquote(path.slice('/api/docs/page/'.length));
|
|
384
|
-
try {
|
|
385
|
-
return sendJson(res, apiDocsPage(state, pid, harnessParam(req.url)), 200, ae);
|
|
386
|
-
} catch (e) {
|
|
387
|
-
// A kind P6d does not answer says so, rather than falling through to the
|
|
388
|
-
// `NotFound` at the bottom of `apiDocsPage` and claiming the page is missing.
|
|
389
|
-
if (e && e.notPorted) return sendJson(res, { error: e.message }, 501, ae);
|
|
390
|
-
throw e;
|
|
391
|
-
}
|
|
392
|
-
}
|
|
393
|
-
if (path === '/api/jobs') return sendJson(res, { jobs: jm.recent() }, 200, ae);
|
|
394
|
-
if (path.startsWith('/api/jobs/')) {
|
|
395
|
-
// `path.rsplit("/", 1)[1]` — the LAST segment, so `/api/jobs/a/b` looks up `b` and
|
|
396
|
-
// misses, rather than 404ing on the shape.
|
|
397
|
-
const j = jm.get(path.slice(path.lastIndexOf('/') + 1));
|
|
398
|
-
return j ? sendJson(res, j, 200, ae)
|
|
399
|
-
: sendJson(res, { error: 'no such job' }, 404, ae);
|
|
400
|
-
}
|
|
401
|
-
if (notPorted(path)) {
|
|
402
|
-
return sendJson(res, { error: `not ported yet: ${path}` }, 501, ae);
|
|
403
|
-
}
|
|
404
|
-
return serveStatic(res, path, ae);
|
|
405
|
-
}
|
|
406
|
-
|
|
407
|
-
function doPost(req, res, path, ae, body) {
|
|
408
|
-
if (!localHost(req.headers.host)) {
|
|
409
|
-
return sendJson(res, { error: 'forbidden host' }, 403, ae);
|
|
410
|
-
}
|
|
411
|
-
if (req.headers['x-geneseed-token'] !== token) {
|
|
412
|
-
return sendJson(res, { error: 'forbidden' }, 403, ae);
|
|
413
|
-
}
|
|
414
|
-
if (path === '/api/shutdown') {
|
|
415
|
-
// The one POST route the SHELL owns. It must answer BEFORE it stops, which is why
|
|
416
|
-
// the reference runs `srv.shutdown()` on its own thread — called inline it would
|
|
417
|
-
// deadlock against `serve_forever`. Here the equivalent is closing after the
|
|
418
|
-
// response has flushed. `closeAllConnections` is the other half: `close()` alone
|
|
419
|
-
// waits for idle keep-alive sockets, and the client that just sent this request is
|
|
420
|
-
// holding one open. The reference drops them by exiting.
|
|
421
|
-
res.on('finish', () => {
|
|
422
|
-
holder.srv.close();
|
|
423
|
-
holder.srv.closeAllConnections();
|
|
424
|
-
});
|
|
425
|
-
return sendJson(res, { stopping: true }, 200, ae);
|
|
426
|
-
}
|
|
427
|
-
if (path === '/api/restart') {
|
|
428
|
-
// THE ONE ROUTE IN THIS FILE NO TEST MAY REACH, and it is declared rather than probed.
|
|
429
|
-
// `requestRestart` spawns a DETACHED `web restart`, which stops whatever daemon the
|
|
430
|
-
// record names and starts a fresh one that outlives the caller. Every way of asking it
|
|
431
|
-
// a question runs it: `tests/web_golden.py` would have its own server stopped and a
|
|
432
|
-
// replacement orphaned into a sandbox about to be deleted, and
|
|
433
|
-
// `tests/test_web_server.py`'s dispatcher probe runs in the DEVELOPER'S environment,
|
|
434
|
-
// where the replacement would bind 4747 and serve the checkout forever. That is the
|
|
435
|
-
// same reasoning P6g used to ban a job cell that starts real work, one step further:
|
|
436
|
-
// there the child was merely orphaned, here it is a second daemon.
|
|
437
|
-
//
|
|
438
|
-
// So the gate is `tests/test_web_daemon.py`, which reads both implementations and
|
|
439
|
-
// asserts they dispatch this path to `request_restart(state.theme)` and answer the
|
|
440
|
-
// same body. Rule 7 says a declaration is not a dispatcher — this is the one route
|
|
441
|
-
// where probing the dispatcher costs more than the assurance is worth, and saying so
|
|
442
|
-
// beats a silently missing probe.
|
|
443
|
-
requestRestart(state.theme);
|
|
444
|
-
return sendJson(res, { restarting: true }, 200, ae);
|
|
445
|
-
}
|
|
446
|
-
if (path === '/api/reveal') {
|
|
447
|
-
// "It asks for a token and a URL and I don't know where to put them." The screen already
|
|
448
|
-
// PRINTS the config path above each target's rows; what it could not do is take you there.
|
|
449
|
-
//
|
|
450
|
-
// THE ALLOWLIST IS THE WHOLE SECURITY OF THIS ENDPOINT, same as the toggle beside it, and
|
|
451
|
-
// it is the same allowlist — `mcpTargetPaths()`, one function with two callers rather than
|
|
452
|
-
// two copies that drift. An unlisted path is a 404 before anything is opened. Without it
|
|
453
|
-
// this is "ask the desktop to open any path on the machine", behind a CSRF token and
|
|
454
|
-
// nothing else, and on Windows `openUrl` interpolates into a `cmd` command line with
|
|
455
|
-
// `windowsVerbatimArguments` — so the exact-match lookup is also what bounds the strings
|
|
456
|
-
// that can ever reach a shell to the ones the machine's own install registry produced.
|
|
457
|
-
//
|
|
458
|
-
// THE FOLDER, NOT THE FILE. `open`/`start`/`xdg-open` on a `.json` hands it to whatever
|
|
459
|
-
// claims that extension — which on a stock Windows box is a browser, i.e. a read-only view
|
|
460
|
-
// of the exact file the user is trying to EDIT. The containing folder lands in Explorer /
|
|
461
|
-
// Finder / the file manager every time, and the filename is already on screen beside the
|
|
462
|
-
// button.
|
|
463
|
-
const want = String(readJsonBody(body).path ?? '');
|
|
464
|
-
if (!mcpTargetPaths().has(want)) {
|
|
465
|
-
return sendJson(res, { error: `not found: ${want || '(none)'}` }, 404, ae);
|
|
466
|
-
}
|
|
467
|
-
const dir = dirname(want);
|
|
468
|
-
// `openUrl` SWALLOWS EVERY FAILURE BY DESIGN (a headless box has no opener), so `ok` here
|
|
469
|
-
// means "the path was allowed and the request was made", never "a window appeared". The
|
|
470
|
-
// response says which directory, so the UI can print it as the fallback instruction.
|
|
471
|
-
openUrl(dir);
|
|
472
|
-
return sendJson(res, { ok: true, dir }, 200, ae);
|
|
473
|
-
}
|
|
474
|
-
const route = POST_ROUTES.get(path);
|
|
475
|
-
if (route !== undefined) {
|
|
476
|
-
const [fn, okIs409] = route;
|
|
477
|
-
const obj = fn(state, readJsonBody(body));
|
|
478
|
-
return sendJson(res, obj, (okIs409 && !pyTruthy(obj.ok)) ? 409 : 200, ae);
|
|
479
|
-
}
|
|
480
|
-
if (path.startsWith('/api/jobs/') && path.endsWith('/cancel')) {
|
|
481
|
-
// `path.split("/")[3]`, positional — so `/api/jobs/<id>/cancel` takes `<id>` and a
|
|
482
|
-
// deeper path takes its third segment, which is what the reference looks up.
|
|
483
|
-
const jid = path.split('/')[3];
|
|
484
|
-
return jm.cancel(jid) ? sendJson(res, { cancelled: jid }, 200, ae)
|
|
485
|
-
: sendJson(res, { error: 'no running job by that id' }, 404, ae);
|
|
486
|
-
}
|
|
487
|
-
if (path.startsWith('/api/actions/')) {
|
|
488
|
-
return doAction(res, path.slice(path.lastIndexOf('/') + 1), readJsonBody(body), ae);
|
|
489
|
-
}
|
|
490
|
-
if (notPortedPost(path)) {
|
|
491
|
-
return sendJson(res, { error: `not ported yet: ${path}` }, 501, ae);
|
|
492
|
-
}
|
|
493
|
-
return sendJson(res, { error: 'not found' }, 404, ae);
|
|
494
|
-
}
|
|
495
|
-
|
|
496
|
-
/**
|
|
497
|
-
* `/api/actions/<x>` — the reference's own order, which is the part a port gets wrong.
|
|
498
|
-
*
|
|
499
|
-
* `restore` is answered BEFORE anything else because it is synchronous and returns a result
|
|
500
|
-
* rather than a job id. `install` and `deploy` resolve their argv from the body, and their
|
|
501
|
-
* refusal statuses DIFFER — 409 for install (its target came from an allowlist, so a refusal
|
|
502
|
-
* means the world changed under the client) and 400 for deploy (its path came from the body,
|
|
503
|
-
* so a refusal means the body was wrong). That asymmetry is the reference's; a port that
|
|
504
|
-
* unified them would be wrong on one of the two and no cell would say which.
|
|
505
|
-
*
|
|
506
|
-
* `update` COMES NEXT AND IS INLINE, since P8c: it is gated on a LOCAL-ONLY preflight before
|
|
507
|
-
* a job is ever started, so a dirty tree or a zip-download install gets a friendly 422 the UI
|
|
508
|
-
* renders as an info popup instead of a job that spawns and then fails. Its finish handler is
|
|
509
|
-
* its own too — the child skips its own daemon bounce (`GENESEED_WEB_JOB`, set by `_run`), so
|
|
510
|
-
* the restart happens HERE, after the job is saved as finished, and only when it succeeded: a
|
|
511
|
-
* failed update changed nothing worth reloading and bouncing would disconnect the PWA for no
|
|
512
|
-
* reason.
|
|
513
|
-
*
|
|
514
|
-
* THEN the unported rows, and only then the table. Order matters: `link`/`unlink` are REAL
|
|
515
|
-
* actions whose verb has not crossed, and letting them fall through to `actionCommands` would
|
|
516
|
-
* answer `{"error": "unknown action link"}` — the same thing a typo gets.
|
|
517
|
-
*/
|
|
518
|
-
function doAction(res, action, body, ae) {
|
|
519
|
-
if (action === 'restore') {
|
|
520
|
-
return sendJson(res, apiRestore(state, pyTruthy(body.files) ? body.files : []), 200, ae);
|
|
521
|
-
}
|
|
522
|
-
if (action === 'install') {
|
|
523
|
-
const plan = apiInstallCmd(state, body);
|
|
524
|
-
if (Object.hasOwn(plan, 'error')) return sendJson(res, plan, 409, ae);
|
|
525
|
-
return startJob(res, 'install', [plan.cmd], ae);
|
|
526
|
-
}
|
|
527
|
-
if (action === 'deploy') {
|
|
528
|
-
const plan = apiDeployCmd(state, body);
|
|
529
|
-
if (Object.hasOwn(plan, 'error')) return sendJson(res, plan, 400, ae);
|
|
530
|
-
return startJob(res, 'deploy', [plan.cmd], ae);
|
|
531
|
-
}
|
|
532
|
-
if (action === 'update') {
|
|
533
|
-
const pre = preflight();
|
|
534
|
-
if (!pre.ok) {
|
|
535
|
-
return sendJson(res,
|
|
536
|
-
{ precondition: pre.code, kind: pre.kind, message: pre.message }, 422, ae);
|
|
537
|
-
}
|
|
538
|
-
const jid = jm.start('update', actionCommands('update'), (rc) => {
|
|
539
|
-
state.refresh();
|
|
540
|
-
if (rc === 0) requestRestart(state.theme);
|
|
541
|
-
});
|
|
542
|
-
if (jid === null) return sendJson(res, { error: 'busy' }, 409, ae);
|
|
543
|
-
return sendJson(res, { job_id: jid }, 202, ae);
|
|
544
|
-
}
|
|
545
|
-
if (NOT_PORTED_ACTIONS.has(action)) {
|
|
546
|
-
return sendJson(res, { error: `not ported yet: /api/actions/${action}` }, 501, ae);
|
|
547
|
-
}
|
|
548
|
-
// Build can be re-themed/re-targeted from the UI picker; the other actions self-resolve
|
|
549
|
-
// the deployed theme downstream. Footprint, posture and mode always follow the current
|
|
550
|
-
// install, so a re-theme preserves lean/full, the register and the operating mode.
|
|
551
|
-
const [theme, emit] = action === 'build'
|
|
552
|
-
? buildOverride(state, body) : [state.theme, state.emit];
|
|
553
|
-
// The pack selection follows the install like footprint/posture/mode, and is read here
|
|
554
|
-
// rather than left to the table's default: a console Build must re-emit the constitution
|
|
555
|
-
// the deployment already carries. Unknown (no `Active packs:` marker) resolves to ALL
|
|
556
|
-
// packs — never to `harness.config.json`, which would drop the consent gate.
|
|
557
|
-
const cmds = actionCommands(action, {
|
|
558
|
-
theme, emit, footprint: state.footprint, posture: state.posture, mode: state.mode,
|
|
559
|
-
doctrines: doctrinesForBuild(state.target),
|
|
560
|
-
});
|
|
561
|
-
if (!pyTruthy(cmds)) {
|
|
562
|
-
return sendJson(res, { error: `unknown action ${action}` }, 404, ae);
|
|
563
|
-
}
|
|
564
|
-
return startJob(res, action, cmds, ae);
|
|
565
|
-
}
|
|
566
|
-
|
|
567
|
-
/**
|
|
568
|
-
* The 202/409 pair every job route ends in. `state.refresh()` on finish is not optional: a
|
|
569
|
-
* Build may re-theme the install, and the re-detect must read the new `.geneseed-emit` or
|
|
570
|
-
* the console keeps serving the old mode (the line P6b dropped and P6f restored).
|
|
571
|
-
*/
|
|
572
|
-
function startJob(res, action, cmds, ae) {
|
|
573
|
-
const jid = jm.start(action, cmds, () => state.refresh());
|
|
574
|
-
if (jid === null) return sendJson(res, { error: 'busy' }, 409, ae);
|
|
575
|
-
return sendJson(res, { job_id: jid }, 202, ae);
|
|
576
|
-
}
|
|
577
|
-
|
|
578
|
-
return function handler(req, res) {
|
|
579
|
-
const ae = req.headers['accept-encoding'] || '';
|
|
580
|
-
const path = (req.url || '').split('?', 1)[0];
|
|
581
|
-
try {
|
|
582
|
-
if (req.method === 'GET') {
|
|
583
|
-
if (!localHost(req.headers.host)) {
|
|
584
|
-
return sendJson(res, { error: 'forbidden host' }, 403, ae);
|
|
585
|
-
}
|
|
586
|
-
try {
|
|
587
|
-
return doGet(req, res, path, ae);
|
|
588
|
-
} catch (e) {
|
|
589
|
-
// The `NotFound` → 404 convention, which P6c is the first phase to raise. It
|
|
590
|
-
// wraps the ROUTE DISPATCH and not each handler, exactly as the reference's
|
|
591
|
-
// `try` around `do_GET`'s body does — an endpoint several frames down raises and
|
|
592
|
-
// the shell decides the status. Anything else is still the outer 500.
|
|
593
|
-
if (e instanceof NotFound) {
|
|
594
|
-
return sendJson(res, { error: `not found: ${e.message}` }, 404, ae);
|
|
595
|
-
}
|
|
596
|
-
throw e;
|
|
597
|
-
}
|
|
598
|
-
}
|
|
599
|
-
if (req.method === 'POST') {
|
|
600
|
-
// Drain the body BEFORE routing, as the reference does and for its stated
|
|
601
|
-
// reason — under keep-alive an unread body is parsed as the next request line,
|
|
602
|
-
// and both guards answer without ever reaching a route that would read it.
|
|
603
|
-
//
|
|
604
|
-
// THIS HALF OF IT IS INDISTINGUISHABLE HERE, and measured rather than assumed:
|
|
605
|
-
// two mutations — reading zero bytes, then neither reading nor resuming the
|
|
606
|
-
// stream — both survived the keep-alive cell, which the reference needs the
|
|
607
|
-
// drain to pass. Node's HTTP parser owns the message boundary, so unread body
|
|
608
|
-
// bytes are discarded when the response ends instead of being re-parsed. It
|
|
609
|
-
// stays because `_read_json_body` needs the bytes from P6f on, and because a
|
|
610
|
-
// shell whose two implementations diverged only under load would be the worst
|
|
611
|
-
// kind of divergence to carry. UNREACHABLE is not the word for it: the branch
|
|
612
|
-
// runs on every POST, it simply cannot be observed to matter on this runtime.
|
|
613
|
-
const length = pyInt(String(req.headers['content-length'] ?? '')) ?? 0;
|
|
614
|
-
return readBody(req, length, (buf) => {
|
|
615
|
-
req._body = buf;
|
|
616
|
-
try {
|
|
617
|
-
return doPost(req, res, path, ae, buf);
|
|
618
|
-
} catch (e) {
|
|
619
|
-
// `do_POST`'s own two-armed catch, and P6f is the phase that needs the first
|
|
620
|
-
// arm: `api_mcp_toggle`, `api_select_view`, `api_memory_delete`,
|
|
621
|
-
// `api_rules_mutate` and `api_rules_promote` all raise `NotFound` for an
|
|
622
|
-
// unknown target, and the reference answers 404. The 500 arm below it is what
|
|
623
|
-
// carries `_rule_fields`' ValueError message, which is a 500 there too.
|
|
624
|
-
if (e instanceof NotFound) {
|
|
625
|
-
return sendJson(res, { error: `not found: ${e.message}` }, 404, ae);
|
|
626
|
-
}
|
|
627
|
-
return sendJson(res, { error: String(e && e.message ? e.message : e) }, 500, ae);
|
|
628
|
-
}
|
|
629
|
-
});
|
|
630
|
-
}
|
|
631
|
-
return sendJson(res, { error: 'not found' }, 404, ae);
|
|
632
|
-
} catch (e) {
|
|
633
|
-
return sendJson(res, { error: String(e && e.message ? e.message : e) }, 500, ae);
|
|
634
|
-
}
|
|
635
|
-
};
|
|
636
|
-
}
|
|
637
|
-
|
|
638
|
-
/**
|
|
639
|
-
* `Handler._harness` — the `?harness=` query param, `null` when absent.
|
|
640
|
-
*
|
|
641
|
-
* `urllib.parse.parse_qs` with its defaults, reproduced only as far as this one caller
|
|
642
|
-
* needs and no further: pairs split on `&` then `=`, values unquoted with `+` meaning a
|
|
643
|
-
* space, and a BLANK value dropped (`keep_blank_values=False`), so `?harness=` resolves to
|
|
644
|
-
* the installed default rather than to the empty string. The first occurrence wins,
|
|
645
|
-
* because `parse_qs` returns a list and the reference takes `[0]`.
|
|
646
|
-
*
|
|
647
|
-
* THE BLANK-VALUE DROP IS INDISTINGUISHABLE HERE, measured rather than assumed: a
|
|
648
|
-
* mutation removing it survived, because `normHarness('')` and `normHarness(null)` both
|
|
649
|
-
* fall through to the installed default. It stays for the reason `parse_qs`' own default
|
|
650
|
-
* exists — the next parameter read through this function may be one whose empty string
|
|
651
|
-
* means something — and because a shell whose two implementations agreed only by accident
|
|
652
|
-
* is the kind of agreement that stops holding when a caller is added. UNREACHABLE is not
|
|
653
|
-
* the word: the branch runs on every `?harness=`, it simply cannot be observed to matter
|
|
654
|
-
* through the one consumer it has.
|
|
655
|
-
*/
|
|
656
|
-
function harnessParam(url) {
|
|
657
|
-
const at = (url || '').indexOf('?');
|
|
658
|
-
if (at < 0) return null;
|
|
659
|
-
for (const pair of url.slice(at + 1).split('&')) {
|
|
660
|
-
if (!pair) continue;
|
|
661
|
-
const eq = pair.indexOf('=');
|
|
662
|
-
const k = pyUnquote((eq < 0 ? pair : pair.slice(0, eq)).replace(/\+/g, ' '));
|
|
663
|
-
const v = eq < 0 ? '' : pyUnquote(pair.slice(eq + 1).replace(/\+/g, ' '));
|
|
664
|
-
if (k === 'harness' && v !== '') return v;
|
|
665
|
-
}
|
|
666
|
-
return null;
|
|
667
|
-
}
|
|
668
|
-
|
|
669
|
-
function readBody(req, length, done) {
|
|
670
|
-
if (!(length > 0)) {
|
|
671
|
-
req.resume();
|
|
672
|
-
return done(Buffer.alloc(0));
|
|
673
|
-
}
|
|
674
|
-
const chunks = [];
|
|
675
|
-
let seen = 0;
|
|
676
|
-
req.on('data', (c) => {
|
|
677
|
-
// Exactly `Content-Length` bytes, as `rfile.read(length)` takes: a longer body is
|
|
678
|
-
// the next request's bytes and belongs to the parser, not to this handler.
|
|
679
|
-
if (seen >= length) return;
|
|
680
|
-
chunks.push(c.subarray(0, length - seen));
|
|
681
|
-
seen += c.length;
|
|
682
|
-
});
|
|
683
|
-
req.on('end', () => done(Buffer.concat(chunks)));
|
|
684
|
-
}
|
|
685
|
-
|
|
686
|
-
function isFile(p) {
|
|
687
|
-
try {
|
|
688
|
-
return statSync(p).isFile();
|
|
689
|
-
} catch {
|
|
690
|
-
return false;
|
|
691
|
-
}
|
|
692
|
-
}
|
|
693
|
-
|
|
694
|
-
/** `bytes.replace(old, new, 1)` — the first occurrence only. */
|
|
695
|
-
function replaceOnce(buf, find, repl) {
|
|
696
|
-
const at = buf.indexOf(find);
|
|
697
|
-
if (at < 0) return buf;
|
|
698
|
-
return Buffer.concat([buf.subarray(0, at), repl, buf.subarray(at + find.length)]);
|
|
699
|
-
}
|
|
700
|
-
|
|
701
|
-
// ---- the daemon record -----------------------------------------------------
|
|
702
|
-
|
|
703
|
-
const statePath = (target) => join(target, '.geneseed-web.json');
|
|
704
|
-
|
|
705
|
-
export function readDaemon(target) {
|
|
706
|
-
try {
|
|
707
|
-
return JSON.parse(readFileSync(statePath(target), 'utf-8'));
|
|
708
|
-
} catch {
|
|
709
|
-
return null;
|
|
710
|
-
}
|
|
711
|
-
}
|
|
712
|
-
|
|
713
|
-
export function writeDaemon(target, data) {
|
|
714
|
-
try {
|
|
715
|
-
mkdirSync(target, { recursive: true });
|
|
716
|
-
const p = statePath(target);
|
|
717
|
-
writeText(p, jsonDumpsCompact(data, { bareInts: true }));
|
|
718
|
-
// Owner-only: the record carries the API token. A no-op on Windows, where the
|
|
719
|
-
// mode only maps to the read-only attribute — same as the reference's os.chmod.
|
|
720
|
-
chmodSync(p, 0o600);
|
|
721
|
-
} catch { /* the reference swallows OSError here too */ }
|
|
722
|
-
}
|
|
723
|
-
|
|
724
|
-
export function clearDaemon(target) {
|
|
725
|
-
try {
|
|
726
|
-
unlinkSync(statePath(target));
|
|
727
|
-
} catch { /* already gone */ }
|
|
728
|
-
}
|
|
729
|
-
|
|
730
|
-
// ---- daemon mode -----------------------------------------------------------
|
|
731
|
-
//
|
|
732
|
-
// `geneseed web start|stop|status|restart`. The reference's own banner explains the design
|
|
733
|
-
// — the running server records pid/port/token/url in a small JSON file and control is over
|
|
734
|
-
// HTTP, so nothing here needs OS process-kill semantics, only a localhost request with the
|
|
735
|
-
// token. Two things about the TWIN, neither of which is in that banner:
|
|
736
|
-
//
|
|
737
|
-
// * `_probe` and `_post_shutdown` are `urllib.request.urlopen` with a timeout, which is
|
|
738
|
-
// synchronous. Node has no synchronous HTTP client, so every function below that
|
|
739
|
-
// reaches one is `async` and `cmdWeb` awaits it — which is why `bin/geneseed-cli.mjs`
|
|
740
|
-
// awaits its dispatch. Nothing about the ORDER of the reference's steps changes.
|
|
741
|
-
// * `agent: false` on both requests. Node's global agent has kept sockets alive by
|
|
742
|
-
// default since v19, and a pooled idle socket keeps the event loop referenced — a
|
|
743
|
-
// `web status` that answered correctly would then hang for the keep-alive timeout
|
|
744
|
-
// instead of exiting. Python's `urlopen` opens and closes a connection per call.
|
|
745
|
-
|
|
746
|
-
/** `f"{url}/api/ping"` answered 200 — the cheap liveness probe both `status` and the
|
|
747
|
-
* launcher's wait loop are built on. Every failure mode is False, as the reference's
|
|
748
|
-
* `(URLError, OSError, ValueError)` catch is. */
|
|
749
|
-
export function probe(url, timeout = 1.5) {
|
|
750
|
-
return new Promise((done) => {
|
|
751
|
-
let settled = false;
|
|
752
|
-
const finish = (v) => { if (!settled) { settled = true; done(v); } };
|
|
753
|
-
let req;
|
|
754
|
-
try {
|
|
755
|
-
req = httpRequest(`${url}/api/ping`, { method: 'GET', agent: false }, (res) => {
|
|
756
|
-
res.resume(); // drain, or the socket never closes
|
|
757
|
-
finish(res.statusCode === 200);
|
|
758
|
-
});
|
|
759
|
-
} catch {
|
|
760
|
-
return finish(false); // a malformed url is `ValueError` there and a throw here
|
|
761
|
-
}
|
|
762
|
-
req.setTimeout(Math.round(timeout * 1000), () => { req.destroy(); finish(false); });
|
|
763
|
-
req.on('error', () => finish(false));
|
|
764
|
-
req.end();
|
|
765
|
-
return undefined;
|
|
766
|
-
});
|
|
767
|
-
}
|
|
768
|
-
|
|
769
|
-
/** The token-carrying POST `geneseed web stop` and the in-page Stop button both send. */
|
|
770
|
-
export function postShutdown(url, token, timeout = 3.0) {
|
|
771
|
-
return new Promise((done) => {
|
|
772
|
-
let settled = false;
|
|
773
|
-
const finish = (v) => { if (!settled) { settled = true; done(v); } };
|
|
774
|
-
let req;
|
|
775
|
-
try {
|
|
776
|
-
req = httpRequest(`${url}/api/shutdown`, {
|
|
777
|
-
method: 'POST',
|
|
778
|
-
agent: false,
|
|
779
|
-
headers: { 'X-Geneseed-Token': token, 'Content-Type': 'application/json' },
|
|
780
|
-
}, (res) => {
|
|
781
|
-
res.resume();
|
|
782
|
-
finish(res.statusCode === 200);
|
|
783
|
-
});
|
|
784
|
-
} catch {
|
|
785
|
-
return finish(false);
|
|
786
|
-
}
|
|
787
|
-
req.setTimeout(Math.round(timeout * 1000), () => { req.destroy(); finish(false); });
|
|
788
|
-
req.on('error', () => finish(false));
|
|
789
|
-
req.end('{}');
|
|
790
|
-
return undefined;
|
|
791
|
-
});
|
|
792
|
-
}
|
|
793
|
-
|
|
794
|
-
/**
|
|
795
|
-
* The record ONLY if a server is answering; otherwise the stale file is deleted.
|
|
796
|
-
*
|
|
797
|
-
* The deletion is the part a port drops, and it is what stops a machine that crashed with
|
|
798
|
-
* a daemon running from re-probing a dead URL on every `status` forever. Both of its arms
|
|
799
|
-
* are gated by a `harness_golden` cell — a record whose url nothing answers, and a record
|
|
800
|
-
* carrying no url at all, which skips the probe and still clears.
|
|
801
|
-
*/
|
|
802
|
-
export async function liveDaemon(target) {
|
|
803
|
-
const st = readDaemon(target);
|
|
804
|
-
if (st && pyTruthy(st.url) && await probe(st.url)) return st;
|
|
805
|
-
if (pyTruthy(st)) clearDaemon(target);
|
|
806
|
-
return null;
|
|
807
|
-
}
|
|
808
|
-
|
|
809
|
-
const sleep = (ms) => new Promise((r) => { setTimeout(r, ms); });
|
|
810
|
-
|
|
811
|
-
/** `st.get(k)` rendered as Python renders it — a missing key prints `None`, not
|
|
812
|
-
* `undefined`. Only reachable through a hand-edited record, and one character of
|
|
813
|
-
* divergence in a message is still a divergence. */
|
|
814
|
-
const none = (v) => (v === undefined || v === null ? 'None' : String(v));
|
|
815
|
-
|
|
816
|
-
/**
|
|
817
|
-
* `_spawn_detached` — the daemon launcher, and the second `_ALLOWED_SPAWNS` entry this
|
|
818
|
-
* module owns.
|
|
819
|
-
*
|
|
820
|
-
* WHY THERE IS NO IN-PROCESS EQUIVALENT: the whole contract is that the server OUTLIVES
|
|
821
|
-
* the process that started it. `geneseed web start` prints a URL and returns to the shell;
|
|
822
|
-
* the server it started must still be there. A thread, a `worker_thread` or an in-process
|
|
823
|
-
* `serve()` all die with the launcher.
|
|
824
|
-
*
|
|
825
|
-
* WHAT IT STARTS IS THIS PROGRAM, named as `process.execPath` + `bin/geneseed-cli.mjs` —
|
|
826
|
-
* the running interpreter by absolute path and this repo's own CLI. The reference names
|
|
827
|
-
* `sys.executable` + `rituals/harness.py`; each side re-executes ITSELF, which is what
|
|
828
|
-
* makes this not the passthrough the port exists to remove, and
|
|
829
|
-
* `tests/test_web_daemon.py` asserts the twin's argv contains no `python`, no `harness.py`
|
|
830
|
-
* and no `build.py` at all.
|
|
831
|
-
*/
|
|
832
|
-
function spawnDetached(webArgs, log) {
|
|
833
|
-
const cmd = [process.execPath, join(ROOT, 'bin', 'geneseed-cli.mjs'), 'web', ...webArgs];
|
|
834
|
-
let out = 'ignore';
|
|
835
|
-
try {
|
|
836
|
-
out = openSync(log, 'a'); // `open(log, "ab")`; DEVNULL on OSError, as there
|
|
837
|
-
} catch { /* the reference falls back to DEVNULL on exactly this failure */ }
|
|
838
|
-
const child = spawn(cmd[0], cmd.slice(1), {
|
|
839
|
-
// `DETACHED_PROCESS | NEW_PROCESS_GROUP` on Windows and `start_new_session` on POSIX
|
|
840
|
-
// are both `detached` here; `unref()` is what lets the launcher's event loop end while
|
|
841
|
-
// the child runs on, which is `Popen`'s default and Node's is not.
|
|
842
|
-
detached: true, windowsHide: true, stdio: ['ignore', out, out],
|
|
843
|
-
});
|
|
844
|
-
child.unref();
|
|
845
|
-
}
|
|
846
|
-
|
|
847
|
-
/** The `web` argv the launcher passes. Pure, and compared against `_daemon_args` by the
|
|
848
|
-
* corpus: the binary differs per side by design, the FLAGS may not. */
|
|
849
|
-
export function daemonArgs(port, theme) {
|
|
850
|
-
const args = ['--daemon-internal', '--port', String(port), '--no-browser'];
|
|
851
|
-
if (pyTruthy(theme)) args.push('--theme', theme);
|
|
852
|
-
return args;
|
|
853
|
-
}
|
|
854
|
-
|
|
855
|
-
/** The same for the out-of-band restart — `_restart_args`. */
|
|
856
|
-
export function restartArgs(theme) {
|
|
857
|
-
const args = ['restart', '--no-browser'];
|
|
858
|
-
if (pyTruthy(theme)) args.push('--theme', theme);
|
|
859
|
-
return args;
|
|
860
|
-
}
|
|
861
|
-
|
|
862
|
-
/** `request_restart` — a detached `web restart`, so the new server survives the exit of
|
|
863
|
-
* the very process asking for it. Called by POST `/api/restart`. */
|
|
864
|
-
export function requestRestart(theme) {
|
|
865
|
-
const target = webState(theme).target;
|
|
866
|
-
spawnDetached(restartArgs(theme), join(target, '.geneseed-web.log'));
|
|
867
|
-
}
|
|
868
|
-
|
|
869
|
-
/**
|
|
870
|
-
* `webbrowser.open(url)`, which has no Node equivalent at all — there is no stdlib module
|
|
871
|
-
* that knows what a desktop's default browser is.
|
|
872
|
-
*
|
|
873
|
-
* IT IS A MACHINE PRIMITIVE, the same class as `taskkill` and `java -version` and the
|
|
874
|
-
* opposite of a passthrough: it asks the OS to open a URL and hands it nothing of this
|
|
875
|
-
* program's work. Declining it instead would have been a silent regression in the one verb
|
|
876
|
-
* whose job is to put a UI on screen — `geneseed web` would print an address and do
|
|
877
|
-
* nothing, and no cell would say so because every cell passes `--no-browser`.
|
|
878
|
-
*
|
|
879
|
-
* The reference swallows every failure here (`contextlib.suppress(Exception)` in two call
|
|
880
|
-
* sites and a bare `except` in the third), and so does this.
|
|
881
|
-
*/
|
|
882
|
-
function openUrl(url) {
|
|
883
|
-
const [file, args, extra] = process.platform === 'win32'
|
|
884
|
-
// `start` is a cmd BUILTIN, not a program. The empty `""` is its title argument —
|
|
885
|
-
// without it `start "http://…"` treats the quoted URL as the window title and opens
|
|
886
|
-
// nothing. Verbatim args because Node would otherwise re-quote the whole string.
|
|
887
|
-
? [process.env.COMSPEC || 'cmd.exe', ['/d', '/s', '/c', `start "" "${url}"`],
|
|
888
|
-
{ windowsVerbatimArguments: true }]
|
|
889
|
-
: (process.platform === 'darwin' ? ['open', [url], {}] : ['xdg-open', [url], {}]);
|
|
890
|
-
try {
|
|
891
|
-
const child = spawn(file, args, {
|
|
892
|
-
detached: true, stdio: 'ignore', windowsHide: true, ...extra,
|
|
893
|
-
});
|
|
894
|
-
// ⚠ ENOENT ARRIVES AS AN EVENT, NOT AS A THROW, AND THE `try` ABOVE CANNOT SEE IT.
|
|
895
|
-
// This is where the reference's suppression failed to survive translation: Python's
|
|
896
|
-
// `subprocess` raises `FileNotFoundError` synchronously, so `contextlib.suppress(Exception)`
|
|
897
|
-
// really did swallow a missing opener. Node's `spawn` reports the same condition on the
|
|
898
|
-
// child's `error` event, and an 'error' with no listener is re-thrown as an uncaught
|
|
899
|
-
// exception. So the port's `try/catch` read as equivalent and suppressed nothing.
|
|
900
|
-
//
|
|
901
|
-
// MEASURED, in the packaged no-python container: `geneseed web` on a headless Linux box
|
|
902
|
-
// printed `Error: spawn xdg-open ENOENT`. Found by the acceptance gate rather than by the
|
|
903
|
-
// 690-cell corpus, and the docblock above says why the corpus never could — every cell
|
|
904
|
-
// passes `--no-browser`, so no recorded cell has ever executed this line.
|
|
905
|
-
child.on('error', () => { /* no opener on this machine; the daemon is still serving */ });
|
|
906
|
-
child.unref();
|
|
907
|
-
} catch { /* the reference suppresses every exception from this call too */ }
|
|
908
|
-
}
|
|
909
|
-
|
|
910
|
-
export async function startDaemon(theme, port, openBrowser = true) {
|
|
911
|
-
const { target } = webState(theme);
|
|
912
|
-
const st = await liveDaemon(target);
|
|
913
|
-
if (st) {
|
|
914
|
-
pyPrint(`[web] already running on ${st.url} (pid ${none(st.pid)})\n`);
|
|
915
|
-
if (openBrowser) openUrl(st.url);
|
|
916
|
-
return 0;
|
|
917
|
-
}
|
|
918
|
-
clearDaemon(target);
|
|
919
|
-
const log = join(target, '.geneseed-web.log');
|
|
920
|
-
spawnDetached(daemonArgs(port, theme), log);
|
|
921
|
-
// `for _ in range(60): ... time.sleep(0.2)` — twelve seconds for the child to bind and
|
|
922
|
-
// write its record. The probe timeout is the reference's shorter 0.5 here, not 1.5.
|
|
923
|
-
for (let i = 0; i < 60; i += 1) {
|
|
924
|
-
const rec = readDaemon(target);
|
|
925
|
-
// eslint-disable-next-line no-await-in-loop
|
|
926
|
-
if (rec && pyTruthy(rec.url) && await probe(rec.url, 0.5)) {
|
|
927
|
-
pyPrint(`[web] Geneseed UI on ${rec.url} (theme: ${none(rec.theme)}, `
|
|
928
|
-
+ `pid ${none(rec.pid)})\n`);
|
|
929
|
-
pyPrint('[web] running in the background — `geneseed web stop` to stop it.\n');
|
|
930
|
-
if (openBrowser) openUrl(rec.url);
|
|
931
|
-
return 0;
|
|
932
|
-
}
|
|
933
|
-
// eslint-disable-next-line no-await-in-loop
|
|
934
|
-
await sleep(200);
|
|
935
|
-
}
|
|
936
|
-
pyPrint('[web] daemon did not come up in time — check the log:\n');
|
|
937
|
-
pyPrint(` ${log}\n`);
|
|
938
|
-
return 1;
|
|
939
|
-
}
|
|
940
|
-
|
|
941
|
-
export async function stopDaemon(theme = null) {
|
|
942
|
-
const { target } = webState(theme);
|
|
943
|
-
const st = readDaemon(target);
|
|
944
|
-
if (!pyTruthy(st) || !pyTruthy(st.url)) {
|
|
945
|
-
// NO `clearDaemon` HERE, and the asymmetry with `liveDaemon` is the reference's. A
|
|
946
|
-
// record that names no url is left exactly where it is; `web status` on the same file
|
|
947
|
-
// deletes it. Both directions are cells, because a port that unified them would delete
|
|
948
|
-
// a file the reference keeps and stdout would look identical.
|
|
949
|
-
pyPrint('[web] no running server recorded.\n');
|
|
950
|
-
return 0;
|
|
951
|
-
}
|
|
952
|
-
if (await postShutdown(st.url, pyTruthy(st.token) ? st.token : '')) {
|
|
953
|
-
clearDaemon(target);
|
|
954
|
-
pyPrint(`[web] stopped (pid ${none(st.pid)}).\n`);
|
|
955
|
-
return 0;
|
|
956
|
-
}
|
|
957
|
-
clearDaemon(target);
|
|
958
|
-
pyPrint('[web] no live server (cleared a stale record).\n');
|
|
959
|
-
return 0;
|
|
960
|
-
}
|
|
961
|
-
|
|
962
|
-
export async function statusDaemon(theme = null) {
|
|
963
|
-
const { target } = webState(theme);
|
|
964
|
-
const st = await liveDaemon(target);
|
|
965
|
-
if (st) {
|
|
966
|
-
pyPrint(`[web] running on ${st.url} (theme: ${none(st.theme)}, pid ${none(st.pid)})\n`);
|
|
967
|
-
return 0;
|
|
968
|
-
}
|
|
969
|
-
// EXIT 1, which is the verb's whole contract for a script. The snapshot's `<exit>`
|
|
970
|
-
// column is what gates it.
|
|
971
|
-
pyPrint('[web] not running.\n');
|
|
972
|
-
return 1;
|
|
973
|
-
}
|
|
974
|
-
|
|
975
|
-
export async function restartDaemon(theme = null, port = 4747, openBrowser = true,
|
|
976
|
-
onlyIfRunning = false) {
|
|
977
|
-
const { target } = webState(theme);
|
|
978
|
-
const st = readDaemon(target);
|
|
979
|
-
const live = (await liveDaemon(target)) !== null;
|
|
980
|
-
if (onlyIfRunning && !live) return 0;
|
|
981
|
-
const usePort = (st && pyTruthy(st.port) ? st.port : null) || port;
|
|
982
|
-
let open = openBrowser;
|
|
983
|
-
if (live) {
|
|
984
|
-
// A live daemon means a tab is already open on this (preserved) port and will
|
|
985
|
-
// reconnect on its own — the reference's `ponytail:` note, kept.
|
|
986
|
-
open = false;
|
|
987
|
-
await stopDaemon(theme);
|
|
988
|
-
for (let i = 0; i < 50; i += 1) {
|
|
989
|
-
// eslint-disable-next-line no-await-in-loop
|
|
990
|
-
if (!await probe(`http://127.0.0.1:${usePort}`, 0.2)) break;
|
|
991
|
-
// eslint-disable-next-line no-await-in-loop
|
|
992
|
-
await sleep(100);
|
|
993
|
-
}
|
|
994
|
-
}
|
|
995
|
-
return startDaemon(theme, usePort, open);
|
|
996
|
-
}
|
|
64
|
+
// Re-exported so the console's state lives in one place and its importers are unchanged.
|
|
65
|
+
export { webState };
|
|
997
66
|
|
|
998
67
|
// ---- serve -----------------------------------------------------------------
|
|
999
68
|
|
|
@@ -1028,7 +97,7 @@ export function buildPlan(dist, webDir, npm, interactive) {
|
|
|
1028
97
|
* library to call instead; a Node twin that reimplemented `npm install` is not a thing that
|
|
1029
98
|
* exists.
|
|
1030
99
|
*
|
|
1031
|
-
* `shell` ON WINDOWS, and it is required rather than stylistic: `
|
|
100
|
+
* `shell` ON WINDOWS, and it is required rather than stylistic: `which('npm')` resolves
|
|
1032
101
|
* to `npm.CMD` through PATHEXT, and Node refuses to `spawn` a `.cmd` directly. The path is
|
|
1033
102
|
* quoted because Node wraps the whole command in one more pair and `cmd /s` strips only
|
|
1034
103
|
* the outermost — `C:\Program Files\…` would otherwise split at the space.
|
|
@@ -1036,13 +105,13 @@ export function buildPlan(dist, webDir, npm, interactive) {
|
|
|
1036
105
|
export function npmBuild(npm, webDir) {
|
|
1037
106
|
const win = process.platform === 'win32';
|
|
1038
107
|
for (const step of [['install'], ['run', 'build']]) {
|
|
1039
|
-
|
|
108
|
+
printOut(`[web] npm ${step.join(' ')} ...\n`);
|
|
1040
109
|
const r = spawnSync(win ? `"${npm}"` : npm, step, {
|
|
1041
110
|
cwd: webDir, stdio: 'inherit', shell: win,
|
|
1042
111
|
});
|
|
1043
112
|
const code = r.status === null || r.status === undefined ? 1 : r.status;
|
|
1044
113
|
if (code) {
|
|
1045
|
-
|
|
114
|
+
printOut(`[web] npm ${step.join(' ')} failed (exit ${code}).\n`);
|
|
1046
115
|
return code;
|
|
1047
116
|
}
|
|
1048
117
|
}
|
|
@@ -1056,22 +125,22 @@ export async function serve({ theme = null, port = 4747, openBrowser = true,
|
|
|
1056
125
|
const manual = ' cd web && npm install && npm run build';
|
|
1057
126
|
// `sys.stdin.isatty()`. `process.stdin.isTTY` is `undefined` rather than false on a pipe,
|
|
1058
127
|
// which is the same trap `cmdSetup` documents.
|
|
1059
|
-
const plan = buildPlan(dist, webDir,
|
|
128
|
+
const plan = buildPlan(dist, webDir, which('npm'), Boolean(process.stdin.isTTY));
|
|
1060
129
|
if (plan === 'no-source') {
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
130
|
+
printOut(`[web] web/ sources are missing from ${ROOT}.\n`);
|
|
131
|
+
printOut(' Run `geneseed upgrade` to fetch them (twice on installs whose\n');
|
|
132
|
+
printOut(' updater predates web/ in the sync list).\n');
|
|
1064
133
|
return 1;
|
|
1065
134
|
}
|
|
1066
135
|
if (plan === 'no-npm') {
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
136
|
+
printOut('[web] web/dist is missing and npm was not found. Install Node.js,\n');
|
|
137
|
+
printOut(' then build the UI:\n');
|
|
138
|
+
printOut(`${manual}\n`);
|
|
1070
139
|
return 1;
|
|
1071
140
|
}
|
|
1072
141
|
if (plan === 'no-tty') {
|
|
1073
|
-
|
|
1074
|
-
|
|
142
|
+
printOut('[web] web/dist is missing. Build the UI first:\n');
|
|
143
|
+
printOut(`${manual}\n`);
|
|
1075
144
|
return 1;
|
|
1076
145
|
}
|
|
1077
146
|
if (plan === 'ask') {
|
|
@@ -1079,22 +148,22 @@ export async function serve({ theme = null, port = 4747, openBrowser = true,
|
|
|
1079
148
|
// it is the one a naive port gets backwards: `input()` RAISES at EOF, so the reference
|
|
1080
149
|
// reads it as "no", while a read that returned `''` would fall into the empty-answer
|
|
1081
150
|
// arm and start an npm install nobody asked for. `promptLine` returns null for exactly
|
|
1082
|
-
// that case — see its docblock in js/setup.mjs.
|
|
151
|
+
// that case — see its docblock in js/maintain/setup.mjs.
|
|
1083
152
|
const line = promptLine('[web] UI not built — run npm install && npm run build now? [Y/n] ');
|
|
1084
153
|
const answer = line === null ? 'n' : line;
|
|
1085
154
|
if (['', 'y', 'yes'].includes(answer.trim().toLowerCase())) {
|
|
1086
|
-
const code = npmBuild(
|
|
155
|
+
const code = npmBuild(which('npm'), webDir);
|
|
1087
156
|
if (code) return code;
|
|
1088
157
|
} else {
|
|
1089
|
-
|
|
1090
|
-
|
|
158
|
+
printOut('[web] skipped. Build the UI manually:\n');
|
|
159
|
+
printOut(`${manual}\n`);
|
|
1091
160
|
return 0;
|
|
1092
161
|
}
|
|
1093
162
|
}
|
|
1094
163
|
const state = webState(theme);
|
|
1095
164
|
if (!existsSync(join(state.target, GLOBAL_MANIFEST))) {
|
|
1096
|
-
|
|
1097
|
-
|
|
165
|
+
printOut(`[web] no deployed harness at ${state.target}.\n`);
|
|
166
|
+
printOut(' Run `geneseed setup` first — serving anyway (read-only UI).\n');
|
|
1098
167
|
}
|
|
1099
168
|
const token = randomBytes(24).toString('base64url');
|
|
1100
169
|
const holder = {};
|
|
@@ -1119,15 +188,15 @@ export async function serve({ theme = null, port = 4747, openBrowser = true,
|
|
|
1119
188
|
token, theme: state.theme, started: Math.floor(Date.now() / 1000),
|
|
1120
189
|
});
|
|
1121
190
|
}
|
|
1122
|
-
|
|
1123
|
-
|
|
191
|
+
printOut(`[web] Geneseed UI on ${url} (theme: ${state.theme})\n`);
|
|
192
|
+
printOut(daemon ? '[web] daemon ready.\n' : '[web] Ctrl-C to stop.\n');
|
|
1124
193
|
if (openBrowser) openUrl(url);
|
|
1125
194
|
// `except KeyboardInterrupt: print("\n[web] stopped.")` around `serve_forever`. Node
|
|
1126
195
|
// kills on SIGINT by default, which would skip the message AND the record cleanup in
|
|
1127
196
|
// the `finally` below — a foreground `--daemon-internal` server stopped with Ctrl-C
|
|
1128
197
|
// would leave a record pointing at a dead port.
|
|
1129
198
|
process.on('SIGINT', () => {
|
|
1130
|
-
|
|
199
|
+
printOut('\n[web] stopped.\n');
|
|
1131
200
|
srv.close();
|
|
1132
201
|
srv.closeAllConnections();
|
|
1133
202
|
});
|
|
@@ -1156,8 +225,8 @@ export async function serve({ theme = null, port = 4747, openBrowser = true,
|
|
|
1156
225
|
*/
|
|
1157
226
|
export async function cmdWeb(args) {
|
|
1158
227
|
// `--port` is `type=int` on the reference, and `bin/geneseed-cli.mjs`'s `ints` column
|
|
1159
|
-
// refuses a non-integer before this runs — so `
|
|
1160
|
-
const port = args.port === null ? 4747 :
|
|
228
|
+
// refuses a non-integer before this runs — so `parseIntStrict` cannot be null here.
|
|
229
|
+
const port = args.port === null ? 4747 : parseIntStrict(args.port);
|
|
1161
230
|
const openBrowser = !args.noBrowser;
|
|
1162
231
|
if (args.action === 'start') return startDaemon(args.theme, port, openBrowser);
|
|
1163
232
|
if (args.action === 'stop') return stopDaemon(args.theme);
|
|
@@ -1181,12 +250,12 @@ async function main(argv) {
|
|
|
1181
250
|
let daemon = false;
|
|
1182
251
|
let openBrowser = true;
|
|
1183
252
|
for (let i = 0; i < argv.length; i += 1) {
|
|
1184
|
-
if (argv[i] === '--theme') { theme = argv[i + 1]; i += 1; } else if (argv[i] === '--port') { port =
|
|
253
|
+
if (argv[i] === '--theme') { theme = argv[i + 1]; i += 1; } else if (argv[i] === '--port') { port = parseIntStrict(String(argv[i + 1])) ?? 4747; i += 1; } else if (argv[i] === '--daemon-internal') daemon = true;
|
|
1185
254
|
else if (argv[i] === '--no-browser') openBrowser = false;
|
|
1186
255
|
}
|
|
1187
256
|
return serve({ theme, port, openBrowser, daemon });
|
|
1188
257
|
}
|
|
1189
258
|
|
|
1190
|
-
if (process.argv[1] &&
|
|
259
|
+
if (process.argv[1] && resolvePath(process.argv[1]) === resolvePath(fileURLToPath(import.meta.url))) {
|
|
1191
260
|
process.exitCode = await main(process.argv.slice(2));
|
|
1192
261
|
}
|