geneseed 3.1.1 → 3.1.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +123 -1
- package/DESIGN.md +15 -15
- package/README.md +1 -1
- package/SETUP.md +1 -1
- package/SHIPPED.md +1 -1
- package/bin/{geneseed.mjs → build-driver.mjs} +39 -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 +111 -83
- package/docs/web/exclusions.md +1 -1
- package/docs/web/footprint.md +1 -1
- package/docs/web/foreman-mode.md +2 -2
- package/docs/web/themes-author.md +1 -1
- package/docs/web-ui.md +41 -29
- 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} +27 -28
- package/js/{render.mjs → build/render.mjs} +13 -9
- package/js/{checkout.mjs → build/source.mjs} +9 -9
- 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} +18 -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} +63 -55
- 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} +20 -17
- 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} +34 -32
- package/js/{uninstall.mjs → maintain/uninstall.mjs} +55 -56
- 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 +84 -85
- package/js/web/activity.mjs +22 -21
- package/js/web/api.mjs +154 -51
- 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 +21 -19
- package/js/web/routes.mjs +245 -0
- package/js/web/server.mjs +56 -987
- package/package.json +11 -3
- package/src/doctrines/README.md +1 -1
- package/themes/opencode/README.md +1 -1
- package/web/dist/assets/{Activity-D_sYBlW-.js → Activity-B49fE_ug.js} +1 -1
- package/web/dist/assets/{ActivityDetail-BomKcMhy.js → ActivityDetail-abt7hzD2.js} +1 -1
- package/web/dist/assets/{Diff-BSvi1WUg.js → Diff-D3_bVkih.js} +1 -1
- package/web/dist/assets/{Doctor-Cz1s-y1n.js → Doctor-hXp7srsx.js} +1 -1
- package/web/dist/assets/{Graph-CuntzFJ0.js → Graph-DvYMtlvl.js} +1 -1
- package/web/dist/assets/Harness-BIWei7N5.js +1 -0
- package/web/dist/assets/Laws-CxmnRdwt.js +3 -0
- package/web/dist/assets/{Library-DXhTN9ju.js → Library-MjbOkHIW.js} +1 -1
- package/web/dist/assets/{Markdown-BoiZcdUl.js → Markdown-Dx_w0faR.js} +1 -1
- package/web/dist/assets/{Profile-DfCHlZ03.js → Profile-BRFvv2gr.js} +1 -1
- package/web/dist/assets/{Rules-eND7hJv9.js → Rules-CfsV1EVy.js} +1 -1
- package/web/dist/assets/{Skills-CDl9opZO.js → Skills-DArA6IHS.js} +1 -1
- package/web/dist/assets/{StatusBadge-DSDtcArX.js → StatusBadge-C9bZOEZk.js} +1 -1
- package/web/dist/assets/{activity-Dmb0FTAx.js → activity-Cc0n2jwx.js} +1 -1
- package/web/dist/assets/index-Bx3oxZ1o.css +1 -0
- package/web/dist/assets/index-DC3-GlCY.js +1 -0
- package/web/dist/assets/index-DP035wPa.js +12 -0
- package/web/dist/assets/{index-Dg58ScBe.js → index-dHi-7M-F.js} +1 -1
- package/web/dist/index.html +2 -2
- package/web/src/pages/Laws.jsx +5 -19
- package/docs/declined.md +0 -200
- package/docs/limits.md +0 -329
- package/js/doctor.mjs +0 -1750
- package/js/emit.mjs +0 -1411
- package/web/dist/assets/About-DyK7yRy7.js +0 -1
- package/web/dist/assets/Harnesses-DgRyOviS.js +0 -1
- package/web/dist/assets/Laws-CWk3Mpri.js +0 -3
- package/web/dist/assets/Themes-CoAWuIeM.js +0 -2
- package/web/dist/assets/index-B-mXquBZ.css +0 -1
- package/web/dist/assets/index-CH_Z725C.js +0 -12
- package/web/dist/assets/index-jrY4m9jP.js +0 -1
|
@@ -0,0 +1,319 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The daemon — its on-disk record, and the lifecycle that starts, probes, stops and restarts it.
|
|
3
|
+
*
|
|
4
|
+
* `geneseed web` can run in the foreground or detached. Detached is the interesting half: the
|
|
5
|
+
* record at `.geneseed-web.json` is how a later `web stop` or `web status` finds a server nobody
|
|
6
|
+
* kept a handle to, and `probe` is the liveness question every one of those verbs asks first.
|
|
7
|
+
*
|
|
8
|
+
* TWO OF THIS PACKAGE'S THREE SPAWNS ARE HERE — the detached daemon and the desktop URL opener —
|
|
9
|
+
* and both are declared by name in `tests/unit/hook_cli.test.mjs`'s spawn allow-list. A new spawn
|
|
10
|
+
* in this file is a deliberate act that reddens that gate until it is declared.
|
|
11
|
+
*/
|
|
12
|
+
import { printOut, writeText } from '../lib/fs.mjs';
|
|
13
|
+
import { isTruthy, jsonDumpsCompact } from '../lib/json.mjs';
|
|
14
|
+
import { webState } from './api.mjs';
|
|
15
|
+
import { spawn } from 'node:child_process';
|
|
16
|
+
import { chmodSync, mkdirSync, openSync, readFileSync, unlinkSync } from 'node:fs';
|
|
17
|
+
import { request as httpRequest } from 'node:http';
|
|
18
|
+
import { join, resolve as pathResolve } from 'node:path';
|
|
19
|
+
import { fileURLToPath } from 'node:url';
|
|
20
|
+
|
|
21
|
+
const ROOT = pathResolve(fileURLToPath(import.meta.url), '..', '..', '..');
|
|
22
|
+
|
|
23
|
+
// ---- the daemon record -----------------------------------------------------
|
|
24
|
+
|
|
25
|
+
const statePath = (target) => join(target, '.geneseed-web.json');
|
|
26
|
+
|
|
27
|
+
export function readDaemon(target) {
|
|
28
|
+
try {
|
|
29
|
+
return JSON.parse(readFileSync(statePath(target), 'utf-8'));
|
|
30
|
+
} catch {
|
|
31
|
+
return null;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function writeDaemon(target, data) {
|
|
36
|
+
try {
|
|
37
|
+
mkdirSync(target, { recursive: true });
|
|
38
|
+
const p = statePath(target);
|
|
39
|
+
writeText(p, jsonDumpsCompact(data, { bareInts: true }));
|
|
40
|
+
// Owner-only: the record carries the API token. A no-op on Windows, where the
|
|
41
|
+
// mode only maps to the read-only attribute — same as the reference's os.chmod.
|
|
42
|
+
chmodSync(p, 0o600);
|
|
43
|
+
} catch { /* the reference swallows OSError here too */ }
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function clearDaemon(target) {
|
|
47
|
+
try {
|
|
48
|
+
unlinkSync(statePath(target));
|
|
49
|
+
} catch { /* already gone */ }
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// ---- daemon mode -----------------------------------------------------------
|
|
53
|
+
//
|
|
54
|
+
// `geneseed web start|stop|status|restart`. The reference's own banner explains the design
|
|
55
|
+
// — the running server records pid/port/token/url in a small JSON file and control is over
|
|
56
|
+
// HTTP, so nothing here needs OS process-kill semantics, only a localhost request with the
|
|
57
|
+
// token. Two things about the TWIN, neither of which is in that banner:
|
|
58
|
+
//
|
|
59
|
+
// * `_probe` and `_post_shutdown` are `urllib.request.urlopen` with a timeout, which is
|
|
60
|
+
// synchronous. Node has no synchronous HTTP client, so every function below that
|
|
61
|
+
// reaches one is `async` and `cmdWeb` awaits it — which is why `bin/geneseed-cli.mjs`
|
|
62
|
+
// awaits its dispatch. Nothing about the ORDER of the reference's steps changes.
|
|
63
|
+
// * `agent: false` on both requests. Node's global agent has kept sockets alive by
|
|
64
|
+
// default since v19, and a pooled idle socket keeps the event loop referenced — a
|
|
65
|
+
// `web status` that answered correctly would then hang for the keep-alive timeout
|
|
66
|
+
// instead of exiting. Python's `urlopen` opens and closes a connection per call.
|
|
67
|
+
|
|
68
|
+
/** `f"{url}/api/ping"` answered 200 — the cheap liveness probe both `status` and the
|
|
69
|
+
* launcher's wait loop are built on. Every failure mode is False, as the reference's
|
|
70
|
+
* `(URLError, OSError, ValueError)` catch is. */
|
|
71
|
+
export function probe(url, timeout = 1.5) {
|
|
72
|
+
return new Promise((done) => {
|
|
73
|
+
let settled = false;
|
|
74
|
+
const finish = (v) => { if (!settled) { settled = true; done(v); } };
|
|
75
|
+
let req;
|
|
76
|
+
try {
|
|
77
|
+
req = httpRequest(`${url}/api/ping`, { method: 'GET', agent: false }, (res) => {
|
|
78
|
+
res.resume(); // drain, or the socket never closes
|
|
79
|
+
finish(res.statusCode === 200);
|
|
80
|
+
});
|
|
81
|
+
} catch {
|
|
82
|
+
return finish(false); // a malformed url is `ValueError` there and a throw here
|
|
83
|
+
}
|
|
84
|
+
req.setTimeout(Math.round(timeout * 1000), () => { req.destroy(); finish(false); });
|
|
85
|
+
req.on('error', () => finish(false));
|
|
86
|
+
req.end();
|
|
87
|
+
return undefined;
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** The token-carrying POST `geneseed web stop` and the in-page Stop button both send. */
|
|
92
|
+
export function postShutdown(url, token, timeout = 3.0) {
|
|
93
|
+
return new Promise((done) => {
|
|
94
|
+
let settled = false;
|
|
95
|
+
const finish = (v) => { if (!settled) { settled = true; done(v); } };
|
|
96
|
+
let req;
|
|
97
|
+
try {
|
|
98
|
+
req = httpRequest(`${url}/api/shutdown`, {
|
|
99
|
+
method: 'POST',
|
|
100
|
+
agent: false,
|
|
101
|
+
headers: { 'X-Geneseed-Token': token, 'Content-Type': 'application/json' },
|
|
102
|
+
}, (res) => {
|
|
103
|
+
res.resume();
|
|
104
|
+
finish(res.statusCode === 200);
|
|
105
|
+
});
|
|
106
|
+
} catch {
|
|
107
|
+
return finish(false);
|
|
108
|
+
}
|
|
109
|
+
req.setTimeout(Math.round(timeout * 1000), () => { req.destroy(); finish(false); });
|
|
110
|
+
req.on('error', () => finish(false));
|
|
111
|
+
req.end('{}');
|
|
112
|
+
return undefined;
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* The record ONLY if a server is answering; otherwise the stale file is deleted.
|
|
118
|
+
*
|
|
119
|
+
* The deletion is the part a port drops, and it is what stops a machine that crashed with
|
|
120
|
+
* a daemon running from re-probing a dead URL on every `status` forever. Both of its arms
|
|
121
|
+
* are gated by a `harness_golden` cell — a record whose url nothing answers, and a record
|
|
122
|
+
* carrying no url at all, which skips the probe and still clears.
|
|
123
|
+
*/
|
|
124
|
+
export async function liveDaemon(target) {
|
|
125
|
+
const st = readDaemon(target);
|
|
126
|
+
if (st && isTruthy(st.url) && await probe(st.url)) return st;
|
|
127
|
+
if (isTruthy(st)) clearDaemon(target);
|
|
128
|
+
return null;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const sleep = (ms) => new Promise((r) => { setTimeout(r, ms); });
|
|
132
|
+
|
|
133
|
+
/** `st.get(k)` rendered as Python renders it — a missing key prints `None`, not
|
|
134
|
+
* `undefined`. Only reachable through a hand-edited record, and one character of
|
|
135
|
+
* divergence in a message is still a divergence. */
|
|
136
|
+
export const none = (v) => (v === undefined || v === null ? 'None' : String(v));
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* `_spawn_detached` — the daemon launcher, and the second `_ALLOWED_SPAWNS` entry this
|
|
140
|
+
* module owns.
|
|
141
|
+
*
|
|
142
|
+
* WHY THERE IS NO IN-PROCESS EQUIVALENT: the whole contract is that the server OUTLIVES
|
|
143
|
+
* the process that started it. `geneseed web start` prints a URL and returns to the shell;
|
|
144
|
+
* the server it started must still be there. A thread, a `worker_thread` or an in-process
|
|
145
|
+
* `serve()` all die with the launcher.
|
|
146
|
+
*
|
|
147
|
+
* WHAT IT STARTS IS THIS PROGRAM, named as `process.execPath` + `bin/geneseed-cli.mjs` —
|
|
148
|
+
* the running interpreter by absolute path and this repo's own CLI. The reference names
|
|
149
|
+
* `sys.executable` + `rituals/harness.py`; each side re-executes ITSELF, which is what
|
|
150
|
+
* makes this not the passthrough the port exists to remove, and
|
|
151
|
+
* `tests/test_web_daemon.py` asserts the twin's argv contains no `python`, no `harness.py`
|
|
152
|
+
* and no `build.py` at all.
|
|
153
|
+
*/
|
|
154
|
+
function spawnDetached(webArgs, log) {
|
|
155
|
+
const cmd = [process.execPath, join(ROOT, 'bin', 'geneseed-cli.mjs'), 'web', ...webArgs];
|
|
156
|
+
let out = 'ignore';
|
|
157
|
+
try {
|
|
158
|
+
out = openSync(log, 'a'); // `open(log, "ab")`; DEVNULL on OSError, as there
|
|
159
|
+
} catch { /* the reference falls back to DEVNULL on exactly this failure */ }
|
|
160
|
+
const child = spawn(cmd[0], cmd.slice(1), {
|
|
161
|
+
// `DETACHED_PROCESS | NEW_PROCESS_GROUP` on Windows and `start_new_session` on POSIX
|
|
162
|
+
// are both `detached` here; `unref()` is what lets the launcher's event loop end while
|
|
163
|
+
// the child runs on, which is `Popen`'s default and Node's is not.
|
|
164
|
+
detached: true, windowsHide: true, stdio: ['ignore', out, out],
|
|
165
|
+
});
|
|
166
|
+
child.unref();
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/** The `web` argv the launcher passes. Pure, and compared against `_daemon_args` by the
|
|
170
|
+
* corpus: the binary differs per side by design, the FLAGS may not. */
|
|
171
|
+
export function daemonArgs(port, theme) {
|
|
172
|
+
const args = ['--daemon-internal', '--port', String(port), '--no-browser'];
|
|
173
|
+
if (isTruthy(theme)) args.push('--theme', theme);
|
|
174
|
+
return args;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/** The same for the out-of-band restart — `_restart_args`. */
|
|
178
|
+
export function restartArgs(theme) {
|
|
179
|
+
const args = ['restart', '--no-browser'];
|
|
180
|
+
if (isTruthy(theme)) args.push('--theme', theme);
|
|
181
|
+
return args;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/** `request_restart` — a detached `web restart`, so the new server survives the exit of
|
|
185
|
+
* the very process asking for it. Called by POST `/api/restart`. */
|
|
186
|
+
export function requestRestart(theme) {
|
|
187
|
+
const target = webState(theme).target;
|
|
188
|
+
spawnDetached(restartArgs(theme), join(target, '.geneseed-web.log'));
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* `webbrowser.open(url)`, which has no Node equivalent at all — there is no stdlib module
|
|
193
|
+
* that knows what a desktop's default browser is.
|
|
194
|
+
*
|
|
195
|
+
* IT IS A MACHINE PRIMITIVE, the same class as `taskkill` and `java -version` and the
|
|
196
|
+
* opposite of a passthrough: it asks the OS to open a URL and hands it nothing of this
|
|
197
|
+
* program's work. Declining it instead would have been a silent regression in the one verb
|
|
198
|
+
* whose job is to put a UI on screen — `geneseed web` would print an address and do
|
|
199
|
+
* nothing, and no cell would say so because every cell passes `--no-browser`.
|
|
200
|
+
*
|
|
201
|
+
* The reference swallows every failure here (`contextlib.suppress(Exception)` in two call
|
|
202
|
+
* sites and a bare `except` in the third), and so does this.
|
|
203
|
+
*/
|
|
204
|
+
export function openUrl(url) {
|
|
205
|
+
const [file, args, extra] = process.platform === 'win32'
|
|
206
|
+
// `start` is a cmd BUILTIN, not a program. The empty `""` is its title argument —
|
|
207
|
+
// without it `start "http://…"` treats the quoted URL as the window title and opens
|
|
208
|
+
// nothing. Verbatim args because Node would otherwise re-quote the whole string.
|
|
209
|
+
? [process.env.COMSPEC || 'cmd.exe', ['/d', '/s', '/c', `start "" "${url}"`],
|
|
210
|
+
{ windowsVerbatimArguments: true }]
|
|
211
|
+
: (process.platform === 'darwin' ? ['open', [url], {}] : ['xdg-open', [url], {}]);
|
|
212
|
+
try {
|
|
213
|
+
const child = spawn(file, args, {
|
|
214
|
+
detached: true, stdio: 'ignore', windowsHide: true, ...extra,
|
|
215
|
+
});
|
|
216
|
+
// ⚠ ENOENT ARRIVES AS AN EVENT, NOT AS A THROW, AND THE `try` ABOVE CANNOT SEE IT.
|
|
217
|
+
// This is where the reference's suppression failed to survive translation: Python's
|
|
218
|
+
// `subprocess` raises `FileNotFoundError` synchronously, so `contextlib.suppress(Exception)`
|
|
219
|
+
// really did swallow a missing opener. Node's `spawn` reports the same condition on the
|
|
220
|
+
// child's `error` event, and an 'error' with no listener is re-thrown as an uncaught
|
|
221
|
+
// exception. So the port's `try/catch` read as equivalent and suppressed nothing.
|
|
222
|
+
//
|
|
223
|
+
// MEASURED, in the packaged no-python container: `geneseed web` on a headless Linux box
|
|
224
|
+
// printed `Error: spawn xdg-open ENOENT`. Found by the acceptance gate rather than by the
|
|
225
|
+
// 690-cell corpus, and the docblock above says why the corpus never could — every cell
|
|
226
|
+
// passes `--no-browser`, so no recorded cell has ever executed this line.
|
|
227
|
+
child.on('error', () => { /* no opener on this machine; the daemon is still serving */ });
|
|
228
|
+
child.unref();
|
|
229
|
+
} catch { /* the reference suppresses every exception from this call too */ }
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
export async function startDaemon(theme, port, openBrowser = true) {
|
|
233
|
+
const { target } = webState(theme);
|
|
234
|
+
const st = await liveDaemon(target);
|
|
235
|
+
if (st) {
|
|
236
|
+
printOut(`[web] already running on ${st.url} (pid ${none(st.pid)})\n`);
|
|
237
|
+
if (openBrowser) openUrl(st.url);
|
|
238
|
+
return 0;
|
|
239
|
+
}
|
|
240
|
+
clearDaemon(target);
|
|
241
|
+
const log = join(target, '.geneseed-web.log');
|
|
242
|
+
spawnDetached(daemonArgs(port, theme), log);
|
|
243
|
+
// `for _ in range(60): ... time.sleep(0.2)` — twelve seconds for the child to bind and
|
|
244
|
+
// write its record. The probe timeout is the reference's shorter 0.5 here, not 1.5.
|
|
245
|
+
for (let i = 0; i < 60; i += 1) {
|
|
246
|
+
const rec = readDaemon(target);
|
|
247
|
+
|
|
248
|
+
if (rec && isTruthy(rec.url) && await probe(rec.url, 0.5)) {
|
|
249
|
+
printOut(`[web] Geneseed UI on ${rec.url} (theme: ${none(rec.theme)}, `
|
|
250
|
+
+ `pid ${none(rec.pid)})\n`);
|
|
251
|
+
printOut('[web] running in the background — `geneseed web stop` to stop it.\n');
|
|
252
|
+
if (openBrowser) openUrl(rec.url);
|
|
253
|
+
return 0;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
await sleep(200);
|
|
257
|
+
}
|
|
258
|
+
printOut('[web] daemon did not come up in time — check the log:\n');
|
|
259
|
+
printOut(` ${log}\n`);
|
|
260
|
+
return 1;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
export async function stopDaemon(theme = null) {
|
|
264
|
+
const { target } = webState(theme);
|
|
265
|
+
const st = readDaemon(target);
|
|
266
|
+
if (!isTruthy(st) || !isTruthy(st.url)) {
|
|
267
|
+
// NO `clearDaemon` HERE, and the asymmetry with `liveDaemon` is the reference's. A
|
|
268
|
+
// record that names no url is left exactly where it is; `web status` on the same file
|
|
269
|
+
// deletes it. Both directions are cells, because a port that unified them would delete
|
|
270
|
+
// a file the reference keeps and stdout would look identical.
|
|
271
|
+
printOut('[web] no running server recorded.\n');
|
|
272
|
+
return 0;
|
|
273
|
+
}
|
|
274
|
+
if (await postShutdown(st.url, isTruthy(st.token) ? st.token : '')) {
|
|
275
|
+
clearDaemon(target);
|
|
276
|
+
printOut(`[web] stopped (pid ${none(st.pid)}).\n`);
|
|
277
|
+
return 0;
|
|
278
|
+
}
|
|
279
|
+
clearDaemon(target);
|
|
280
|
+
printOut('[web] no live server (cleared a stale record).\n');
|
|
281
|
+
return 0;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
export async function statusDaemon(theme = null) {
|
|
285
|
+
const { target } = webState(theme);
|
|
286
|
+
const st = await liveDaemon(target);
|
|
287
|
+
if (st) {
|
|
288
|
+
printOut(`[web] running on ${st.url} (theme: ${none(st.theme)}, pid ${none(st.pid)})\n`);
|
|
289
|
+
return 0;
|
|
290
|
+
}
|
|
291
|
+
// EXIT 1, which is the verb's whole contract for a script. The snapshot's `<exit>`
|
|
292
|
+
// column is what gates it.
|
|
293
|
+
printOut('[web] not running.\n');
|
|
294
|
+
return 1;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
export async function restartDaemon(theme = null, port = 4747, openBrowser = true,
|
|
298
|
+
onlyIfRunning = false) {
|
|
299
|
+
const { target } = webState(theme);
|
|
300
|
+
const st = readDaemon(target);
|
|
301
|
+
const live = (await liveDaemon(target)) !== null;
|
|
302
|
+
if (onlyIfRunning && !live) return 0;
|
|
303
|
+
const usePort = (st && isTruthy(st.port) ? st.port : null) || port;
|
|
304
|
+
let open = openBrowser;
|
|
305
|
+
if (live) {
|
|
306
|
+
// A live daemon means a tab is already open on this (preserved) port and will
|
|
307
|
+
// reconnect on its own — the reference's `ponytail:` note, kept.
|
|
308
|
+
open = false;
|
|
309
|
+
await stopDaemon(theme);
|
|
310
|
+
for (let i = 0; i < 50; i += 1) {
|
|
311
|
+
|
|
312
|
+
if (!await probe(`http://127.0.0.1:${usePort}`, 0.2)) break;
|
|
313
|
+
|
|
314
|
+
await sleep(100);
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
return startDaemon(theme, usePort, open);
|
|
318
|
+
}
|
|
319
|
+
|
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
|