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/api.mjs
CHANGED
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
* `apiOverview` compute anything of their own, and most of what `apiOverview` computes is
|
|
15
15
|
* counting.
|
|
16
16
|
*
|
|
17
|
-
* THE ONE PARAMETER: `doctorCollect({groups})`. `js/doctor.mjs` carried a `ran(check,
|
|
17
|
+
* THE ONE PARAMETER: `doctorCollect({groups})`. `js/inspect/doctor.mjs` carried a `ran(check,
|
|
18
18
|
* label, probs)` that returned its third argument and threw the first two away, with a
|
|
19
19
|
* docblock saying the label stays "because it is the one place each check is NAMED, and
|
|
20
20
|
* what P6's accumulator will key on". This is that phase. `on_progress=` — the TUI's — is
|
|
@@ -26,15 +26,15 @@
|
|
|
26
26
|
* `state.inventory` and the badges and taxonomy classes belonged to a phase that had not
|
|
27
27
|
* arrived. `api_catalog` consumes exactly those fields, so this is the phase that would
|
|
28
28
|
* have made the counting half and the reading half two classifiers. It did not:
|
|
29
|
-
* `tuiInventory` in `js/inventory.mjs` is the one walk, `js/status.mjs`'s `inventoryCounts`
|
|
29
|
+
* `tuiInventory` in `js/inspect/inventory.mjs` is the one walk, `js/inspect/status.mjs`'s `inventoryCounts`
|
|
30
30
|
* is three `length`s off it, and `specEntries` grew out of `specNames` by adding the READ
|
|
31
31
|
* rather than by being written beside it.
|
|
32
32
|
*
|
|
33
33
|
* THE TAXONOMY WAS ALREADY PORTED, which the measurement found and the handoff did not.
|
|
34
|
-
* `js/status.mjs` said flatly that "the ~111 LOC of TUI taxonomy" was P7's; `LAW_CLASS`,
|
|
34
|
+
* `js/inspect/status.mjs` said flatly that "the ~111 LOC of TUI taxonomy" was P7's; `LAW_CLASS`,
|
|
35
35
|
* `SKILL_CLASS`, `LAW_CLASSES` and `ENTITY_STATUSES` had in fact crossed in P5g inside
|
|
36
|
-
* `js/
|
|
37
|
-
* four to `js/inventory.mjs` — where Python keeps them — rather than copying them, and
|
|
36
|
+
* `js/inspect/checks-authoring.mjs`, because doctor's authoring gates are what validate them. P6c moved the
|
|
37
|
+
* four to `js/inspect/inventory.mjs` — where Python keeps them — rather than copying them, and
|
|
38
38
|
* doctor imports them back. Only `parse_laws`, `load_registry` and `entity_status` were
|
|
39
39
|
* genuinely new.
|
|
40
40
|
*
|
|
@@ -46,7 +46,7 @@
|
|
|
46
46
|
* carries the key, which is why its replay harness erases it before comparing.
|
|
47
47
|
*
|
|
48
48
|
* P6c's ROUTES ARE THE FIRST THAT PARSE A PATH, which brings three things no earlier
|
|
49
|
-
* endpoint could reach: `
|
|
49
|
+
* endpoint could reach: `percentDecode` (NOT `decodeURIComponent`, which throws where the
|
|
50
50
|
* reference leaves a stray `%` alone), the `NotFound` → 404 convention, and `flatName`'s
|
|
51
51
|
* traversal refusal — a GET carries no token, so before that check the item route was an
|
|
52
52
|
* arbitrary-file read.
|
|
@@ -55,26 +55,28 @@ import { createHash } from 'node:crypto';
|
|
|
55
55
|
import { existsSync, readdirSync, statSync } from 'node:fs';
|
|
56
56
|
import path from 'node:path';
|
|
57
57
|
|
|
58
|
-
import { ROOT, THEMES, discoverNames } from '../
|
|
59
|
-
import {
|
|
60
|
-
import {
|
|
61
|
-
import {
|
|
62
|
-
import {
|
|
58
|
+
import { CONFIG, ROOT, THEMES, discoverNames } from '../build/source.mjs';
|
|
59
|
+
import { sourceReleaseVersion } from '../hosts/opencode.mjs';
|
|
60
|
+
import { diffCollect } from '../inspect/diff.mjs';
|
|
61
|
+
import { doctorCollect } from '../inspect/doctor.mjs';
|
|
62
|
+
import { excludesSnapshot } from '../inspect/excludes.mjs';
|
|
63
|
+
import { GLOBAL_MANIFEST, HOSTS, opencodeConfigDir, resolvePath } from '../hosts/hosts.mjs';
|
|
63
64
|
import {
|
|
64
|
-
doctrinesForBuild, footprintOfDir, installState, installTargets,
|
|
65
|
+
doctrinesForBuild, excludedRulesOfDir, footprintOfDir, installState, installTargets,
|
|
66
|
+
installedDefaults, modeOfDir,
|
|
65
67
|
postureOfDir, readJsonMaybe, readMaybe, themeOfDir,
|
|
66
|
-
} from '../installs.mjs';
|
|
67
|
-
import { frontmatter } from '../hooks.mjs';
|
|
68
|
+
} from '../hosts/installs.mjs';
|
|
69
|
+
import { frontmatter } from '../hosts/hooks.mjs';
|
|
68
70
|
import {
|
|
69
71
|
SKILL_CLASS, entityStatus, loadRegistry, tuiInventory,
|
|
70
|
-
} from '../inventory.mjs';
|
|
71
|
-
import { firstBlockquote } from '../native.mjs';
|
|
72
|
-
import { EMIT_OPTIONS, themeOptions } from '../setup.mjs';
|
|
73
|
-
import { accentFor, statusData } from '../status.mjs';
|
|
74
|
-
import {
|
|
75
|
-
|
|
76
|
-
} from '../lib/
|
|
77
|
-
import { readJsonc } from '../settings.mjs';
|
|
72
|
+
} from '../inspect/inventory.mjs';
|
|
73
|
+
import { firstBlockquote } from '../hosts/native.mjs';
|
|
74
|
+
import { EMIT_OPTIONS, themeOptions } from '../maintain/setup.mjs';
|
|
75
|
+
import { accentFor, statusData } from '../inspect/status.mjs';
|
|
76
|
+
import { readText, withDiscardableStderr } from '../lib/fs.mjs';
|
|
77
|
+
import { comparePaths, normcase, within } from '../lib/paths.mjs';
|
|
78
|
+
import { stripWhitespace, percentDecode } from '../lib/text.mjs';
|
|
79
|
+
import { readJsonc } from '../hosts/settings.mjs';
|
|
78
80
|
import { apiActivity, apiActivityDetail } from './activity.mjs';
|
|
79
81
|
import { apiMcp, apiRules } from './actions.mjs';
|
|
80
82
|
import { apiGraph } from './graph.mjs';
|
|
@@ -86,7 +88,7 @@ const isDir = (p) => { try { return statSync(p).isDirectory(); } catch { return
|
|
|
86
88
|
const isFile = (p) => { try { return statSync(p).isFile(); } catch { return false; } };
|
|
87
89
|
|
|
88
90
|
/** Two resolved paths, compared as `PurePath.__eq__` does — case-folded on Windows. */
|
|
89
|
-
const samePath = (a, b) => normcase(
|
|
91
|
+
const samePath = (a, b) => normcase(resolvePath(a)) === normcase(resolvePath(b));
|
|
90
92
|
|
|
91
93
|
/** `dict.get(key, default)` — `??` would also swallow an explicit null the file declared. */
|
|
92
94
|
const dget = (obj, key, dflt) => (Object.hasOwn(obj, key) ? obj[key] : dflt);
|
|
@@ -176,7 +178,7 @@ export function webState(theme = null, target = null) {
|
|
|
176
178
|
try {
|
|
177
179
|
const em = path.join(d, '.geneseed-emit');
|
|
178
180
|
if (isFile(em)) {
|
|
179
|
-
const v =
|
|
181
|
+
const v = stripWhitespace(readText(em));
|
|
180
182
|
if (v) return v;
|
|
181
183
|
}
|
|
182
184
|
} catch { /* as the Python's `except OSError: pass` */ }
|
|
@@ -263,7 +265,7 @@ export function specEntries(root, nested) {
|
|
|
263
265
|
if (!isFile(p)) continue;
|
|
264
266
|
const [fm, body] = frontmatter(readMaybe(p) ?? '');
|
|
265
267
|
const name = nested ? path.basename(path.dirname(p)) : path.basename(p, '.md');
|
|
266
|
-
out.push({ name, desc: specDesc(fm, body), body, source:
|
|
268
|
+
out.push({ name, desc: specDesc(fm, body), body, source: resolvePath(p) });
|
|
267
269
|
}
|
|
268
270
|
out.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
|
|
269
271
|
return out;
|
|
@@ -288,7 +290,8 @@ export function specEntries(root, nested) {
|
|
|
288
290
|
* its own carrier via `doctrinesForBuild`, which resolves "no marker" to every pack.
|
|
289
291
|
*/
|
|
290
292
|
export function deployedInventory(state) {
|
|
291
|
-
const render = tuiInventory(state.theme, doctrinesForBuild(state.target)
|
|
293
|
+
const render = tuiInventory(state.theme, doctrinesForBuild(state.target),
|
|
294
|
+
excludedRulesOfDir(state.target));
|
|
292
295
|
const registry = loadRegistry();
|
|
293
296
|
const skills = specEntries(path.join(state.target, 'skills'), true);
|
|
294
297
|
for (const e of skills) {
|
|
@@ -351,7 +354,7 @@ export function memoryItems(state) {
|
|
|
351
354
|
name: stem,
|
|
352
355
|
title: fm.has('name') ? fm.get('name') : stem,
|
|
353
356
|
desc: fm.has('description') ? fm.get('description') : '',
|
|
354
|
-
source:
|
|
357
|
+
source: resolvePath(p),
|
|
355
358
|
};
|
|
356
359
|
});
|
|
357
360
|
}
|
|
@@ -361,7 +364,7 @@ export function notebookItems(state) {
|
|
|
361
364
|
const d = notebookDir(state);
|
|
362
365
|
if (!isDir(d)) return [];
|
|
363
366
|
return globMd(d).map((n) => ({
|
|
364
|
-
name: stemOf(n), title: stemOf(n), desc: '', source:
|
|
367
|
+
name: stemOf(n), title: stemOf(n), desc: '', source: resolvePath(path.join(d, n)),
|
|
365
368
|
}));
|
|
366
369
|
}
|
|
367
370
|
|
|
@@ -379,7 +382,7 @@ export function configItems(state) {
|
|
|
379
382
|
if (!isFile(p)) continue;
|
|
380
383
|
const [title, desc] = CONFIG_META[fname] ?? [fname, ''];
|
|
381
384
|
out.push({ name: fname, title, desc, type: 'config', kind: 'manifest',
|
|
382
|
-
source:
|
|
385
|
+
source: resolvePath(p) });
|
|
383
386
|
}
|
|
384
387
|
return out;
|
|
385
388
|
}
|
|
@@ -387,13 +390,14 @@ export function configItems(state) {
|
|
|
387
390
|
export const WIKI_FILE_CAP = 5000;
|
|
388
391
|
|
|
389
392
|
/**
|
|
390
|
-
* `
|
|
393
|
+
* `resolvePath` for a path that came out of a HAND-MAINTAINED manifest — `null`, never a throw.
|
|
391
394
|
*
|
|
392
|
-
* The three wiki sites below
|
|
393
|
-
* REFUSES a `~user` form by printing and throwing
|
|
394
|
-
* may let that escape: `js/web/
|
|
395
|
+
* The three wiki sites below, and `apiDeployCmd` in `js/web/actions.mjs`, are the
|
|
396
|
+
* request-path callers of `expanduser`, and it REFUSES a `~user` form by printing and throwing
|
|
397
|
+
* (`js/hosts/hosts.mjs`'s docblock). None of them may let that escape: `js/web/handler.mjs`
|
|
398
|
+
* wraps the whole GET in a blanket `catch` → a JSON
|
|
395
399
|
* 500, so an unguarded refusal would turn ONE bad line in a file the user hand-edits into a
|
|
396
|
-
* dead Knowledge section. That is the same argument `sovereignBypass` makes in `js/hooks.mjs`
|
|
400
|
+
* dead Knowledge section. That is the same argument `sovereignBypass` makes in `js/hosts/hooks.mjs`
|
|
397
401
|
* — contain the refusal per entry and take the site's OWN degrade — and each of the three
|
|
398
402
|
* already has one: `[]`, `continue`, `continue`-into-404. The reference reaches those same
|
|
399
403
|
* three degrades for a `~user` path, from the other direction: `ntpath.expanduser` expands
|
|
@@ -411,7 +415,7 @@ export const WIKI_FILE_CAP = 5000;
|
|
|
411
415
|
*/
|
|
412
416
|
function wikiPath(p) {
|
|
413
417
|
try {
|
|
414
|
-
return withDiscardableStderr(() =>
|
|
418
|
+
return withDiscardableStderr(() => resolvePath(p));
|
|
415
419
|
} catch {
|
|
416
420
|
return null;
|
|
417
421
|
}
|
|
@@ -421,7 +425,7 @@ function wikiPath(p) {
|
|
|
421
425
|
* `_web_catalog._wiki_manifest` — `$GENESEED_WIKI` first, else `wiki.jsonc` beside the
|
|
422
426
|
* deployed bundle, read with the harness's generic JSONC loader.
|
|
423
427
|
*
|
|
424
|
-
* `
|
|
428
|
+
* `resolvePath` where the reference only `expanduser()`s. NOT OBSERVABLE, so it stays: `p` is
|
|
425
429
|
* consumed by `isFile` and `mcpLoad` and never reaches a response body, and both of those
|
|
426
430
|
* follow symlinks and resolve a relative path against the same cwd anyway.
|
|
427
431
|
*/
|
|
@@ -474,7 +478,7 @@ export function wikiItems(state) {
|
|
|
474
478
|
if (!w || typeof w !== 'object' || Array.isArray(w)) continue;
|
|
475
479
|
const wname = String(dget(w, 'name', null) || 'wiki');
|
|
476
480
|
// PER ENTRY, and the loop continues — one unusable `path` in the manifest must not blank
|
|
477
|
-
// every OTHER vault in it. `
|
|
481
|
+
// every OTHER vault in it. `resolvePath` where the reference only `expanduser()`s is again
|
|
478
482
|
// unobservable: `root` is only ever joined against or used as the base of a `relative()`
|
|
479
483
|
// whose other operand was built FROM it, and `source` is `.resolve()`d on both sides.
|
|
480
484
|
const root = wikiPath(String(dget(w, 'path', null) || ''));
|
|
@@ -502,7 +506,7 @@ export function wikiItems(state) {
|
|
|
502
506
|
seen.add(key);
|
|
503
507
|
items.push({ name: key, title: path.basename(md, '.md'),
|
|
504
508
|
desc: mds.length === 1 ? desc : r, type: 'wiki', kind: 'page', group: wname,
|
|
505
|
-
source:
|
|
509
|
+
source: resolvePath(md) });
|
|
506
510
|
}
|
|
507
511
|
}
|
|
508
512
|
}
|
|
@@ -523,7 +527,7 @@ export function apiWikiItem(state, name) {
|
|
|
523
527
|
// where the reference lands when the expanded root turns out not to exist.
|
|
524
528
|
const root = wikiPath(String(dget(w, 'path', null) || ''));
|
|
525
529
|
if (!root) continue;
|
|
526
|
-
const p =
|
|
530
|
+
const p = resolvePath(path.join(root, rel));
|
|
527
531
|
if (rel && path.extname(p) === '.md' && within(p, root) && isFile(p)) {
|
|
528
532
|
const body = readMaybe(p) ?? '';
|
|
529
533
|
return { type: 'wiki', name, title: path.basename(p, '.md'), desc: '',
|
|
@@ -534,10 +538,13 @@ export function apiWikiItem(state, name) {
|
|
|
534
538
|
}
|
|
535
539
|
|
|
536
540
|
/**
|
|
537
|
-
* `harness._within` — re-exported from `js/lib/
|
|
541
|
+
* `harness._within` — re-exported from `js/lib/paths.mjs`, where the lib split put it.
|
|
542
|
+
*
|
|
543
|
+
* ⚠ A SECOND, INDEPENDENT COPY lives in `js/inspect/scan.mjs`, written for the doctor's tree
|
|
544
|
+
* walks. A change to the containment rule has to land on both, and nothing compares them.
|
|
538
545
|
*
|
|
539
546
|
* It lived here from P6b (the restore's containment check) until `_install_move_list` needed
|
|
540
|
-
* the same predicate from `js/uninstall.mjs`, which must not import the web tree. The
|
|
547
|
+
* the same predicate from `js/maintain/uninstall.mjs`, which must not import the web tree. The
|
|
541
548
|
* re-export keeps `js/web/actions.mjs`'s import path unchanged.
|
|
542
549
|
*/
|
|
543
550
|
export { within };
|
|
@@ -575,9 +582,9 @@ export function resolveLinks(state, body) {
|
|
|
575
582
|
const links = [];
|
|
576
583
|
const seen = new Set();
|
|
577
584
|
for (const m of body.matchAll(WIKILINK_RE)) {
|
|
578
|
-
// `
|
|
585
|
+
// `stripWhitespace`, not `trim()` — P6d measured the two whitespace classes apart and P6e
|
|
579
586
|
// gave this regex a second reader, so the two call sites answer with one rule.
|
|
580
|
-
const label =
|
|
587
|
+
const label = stripWhitespace(m[1]);
|
|
581
588
|
if (known.has(label) && !seen.has(label)) {
|
|
582
589
|
seen.add(label);
|
|
583
590
|
links.push({ label, type: known.get(label), name: label });
|
|
@@ -642,10 +649,12 @@ function constitutionItems(inv) {
|
|
|
642
649
|
pack: p.pack,
|
|
643
650
|
packTitle: p.title,
|
|
644
651
|
packDesc: p.desc,
|
|
645
|
-
// ⚠ EVERY RULE IS LISTED WHETHER OR NOT
|
|
646
|
-
//
|
|
647
|
-
//
|
|
648
|
-
|
|
652
|
+
// ⚠ EVERY RULE IS LISTED WHETHER OR NOT IT IS BUILT IN, and `active` is the RULE's own
|
|
653
|
+
// — a pack can be on with one rule excluded. A row that vanished from the payload would
|
|
654
|
+
// be indistinguishable from one that never shipped, and the console could not then
|
|
655
|
+
// offer the switch that turns it back on.
|
|
656
|
+
active: r.active !== false,
|
|
657
|
+
packActive: p.active,
|
|
649
658
|
}))),
|
|
650
659
|
];
|
|
651
660
|
}
|
|
@@ -711,7 +720,7 @@ export function apiItem(state, type, name) {
|
|
|
711
720
|
if (!isFile(p)) throw new NotFound(name);
|
|
712
721
|
const body = readMaybe(p) ?? '';
|
|
713
722
|
return { type, name, title: name, desc: '', body,
|
|
714
|
-
links: resolveLinks(state, body), source:
|
|
723
|
+
links: resolveLinks(state, body), source: resolvePath(p) };
|
|
715
724
|
}
|
|
716
725
|
if (type === 'wiki') return apiWikiItem(state, name);
|
|
717
726
|
if (type === 'config') {
|
|
@@ -721,7 +730,7 @@ export function apiItem(state, type, name) {
|
|
|
721
730
|
const raw = readMaybe(p) ?? '';
|
|
722
731
|
const [title, desc] = CONFIG_META[name] ?? [name, ''];
|
|
723
732
|
return { type, name, title, desc, manifest: configManifest(name, p),
|
|
724
|
-
body: `\`\`\`json\n${raw}\n\`\`\``, links: [], source:
|
|
733
|
+
body: `\`\`\`json\n${raw}\n\`\`\``, links: [], source: resolvePath(p) };
|
|
725
734
|
}
|
|
726
735
|
throw new NotFound(type);
|
|
727
736
|
}
|
|
@@ -879,8 +888,12 @@ export function apiOverview(state) {
|
|
|
879
888
|
doctrines: {
|
|
880
889
|
active: (inv.doctrines ?? []).filter((p) => p.active).length,
|
|
881
890
|
total: (inv.doctrines ?? []).length,
|
|
882
|
-
|
|
883
|
-
|
|
891
|
+
// Counted RULE BY RULE, not pack by pack: with the per-rule axis an active pack can
|
|
892
|
+
// carry an inactive rule, so `p.rules.length` over active packs over-counts by
|
|
893
|
+
// exactly what the user switched off — the one number on the dashboard that claims
|
|
894
|
+
// to say how many rules bind this install.
|
|
895
|
+
rules: (inv.doctrines ?? [])
|
|
896
|
+
.reduce((n, p) => n + p.rules.filter((r) => r.active !== false).length, 0),
|
|
884
897
|
},
|
|
885
898
|
memory: memoryItems(state).length,
|
|
886
899
|
notebook: notebookItems(state).length,
|
|
@@ -890,22 +903,39 @@ export function apiOverview(state) {
|
|
|
890
903
|
doctor: state.doctor,
|
|
891
904
|
diff,
|
|
892
905
|
build_time: buildTime,
|
|
906
|
+
// The RELEASE LABEL OF THE SOURCE THIS CONSOLE IS SERVED FROM, not of the deployed install —
|
|
907
|
+
// the two differ whenever something has changed since the last emit, which is exactly when
|
|
908
|
+
// knowing which one you are looking at matters. `.geneseed-version` carries the install's own
|
|
909
|
+
// label and the fingerprint comparison already reports the drift; this answers the plainer
|
|
910
|
+
// question the topbar asks, "what is running".
|
|
911
|
+
//
|
|
912
|
+
// Degrades to null rather than to a wrong number: `sourceReleaseVersion` answers '0.0.0' for
|
|
913
|
+
// an unreadable config, and a console confidently displaying 0.0.0 is worse than one that
|
|
914
|
+
// shows nothing.
|
|
915
|
+
version: releaseLabel(),
|
|
893
916
|
};
|
|
894
917
|
}
|
|
895
918
|
|
|
919
|
+
/** The source's release label, or null when it cannot be read. */
|
|
920
|
+
function releaseLabel() {
|
|
921
|
+
const v = sourceReleaseVersion({ config: CONFIG });
|
|
922
|
+
return v && v !== '0.0.0' ? v : null;
|
|
923
|
+
}
|
|
924
|
+
|
|
896
925
|
/**
|
|
897
926
|
* `_web_server.Handler.STATE_ROUTES`, the ported half.
|
|
898
927
|
*
|
|
899
928
|
* A literal path to `api_X(state)`, exactly as the reference's table is — and the reason
|
|
900
|
-
* it is a table on this side too is `tests/
|
|
901
|
-
*
|
|
902
|
-
*
|
|
903
|
-
*
|
|
929
|
+
* it is a table on this side too is `tests/unit/web_server.test.mjs`'s cross-check, which
|
|
930
|
+
* requires this table plus `PREFIX_ROUTES`, `PORTED_INLINE` and `NOT_PORTED` to equal
|
|
931
|
+
* `REF_GET` — a FROZEN LITERAL of what the reference daemon answered, since the source it
|
|
932
|
+
* was once read from with `ast` is deleted. On the second instance of anything, the gate
|
|
933
|
+
* becomes a table cross-checked against a declaration.
|
|
904
934
|
*/
|
|
905
935
|
/**
|
|
906
936
|
* `_web_server.do_GET`'s prefix routes, the ported ones — path in, response out.
|
|
907
937
|
*
|
|
908
|
-
* `
|
|
938
|
+
* `percentDecode` and not `decodeURIComponent`: the JS builtin throws a `URIError` on a `%`
|
|
909
939
|
* that is not an escape, which the shell would answer as a 500 where the reference answers
|
|
910
940
|
* a 404 naming the literal text.
|
|
911
941
|
*/
|
|
@@ -917,13 +947,13 @@ export const PREFIX_ROUTES = [
|
|
|
917
947
|
['/api/item/', (state, p) => {
|
|
918
948
|
const parts = splitN(p, '/', 4);
|
|
919
949
|
if (parts.length < 5 || !parts[4]) throw new NotFound(p);
|
|
920
|
-
return apiItem(state, parts[3],
|
|
950
|
+
return apiItem(state, parts[3], percentDecode(parts[4]));
|
|
921
951
|
}],
|
|
922
952
|
// P6e. The reference's own `path[len("/api/activity/"):]`, unquoted — the sid keeps
|
|
923
953
|
// whatever it carries and `api_activity_detail`'s safe-name scheme is what makes the
|
|
924
954
|
// lookup safe, rather than a check out here.
|
|
925
955
|
['/api/activity/', (state, p) => apiActivityDetail(
|
|
926
|
-
state,
|
|
956
|
+
state, percentDecode(p.slice('/api/activity/'.length)))],
|
|
927
957
|
];
|
|
928
958
|
|
|
929
959
|
/** `str.split(sep, maxsplit)` — at most `n` splits, the remainder kept whole. */
|
|
@@ -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
|
+
|