geneseed 2.0.0
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 +938 -0
- package/DESIGN.md +1423 -0
- package/LICENSE +21 -0
- package/QUICKSTART.md +48 -0
- package/README.md +336 -0
- package/SETUP.md +1027 -0
- package/SHIPPED.md +70 -0
- package/adapters/bob/README.md +100 -0
- package/adapters/claude-code/README.md +101 -0
- package/adapters/claude-code/settings.json +47 -0
- package/adapters/claude-code/skills/geneseed/SKILL.md +72 -0
- package/adapters/copilot/README.md +76 -0
- package/adapters/opencode/GLOBAL-HARNESS-SPEC.md +434 -0
- package/adapters/opencode/HOW-OPENCODE-LOADS.md +158 -0
- package/adapters/opencode/README.md +582 -0
- package/adapters/opencode/agents/orchestrator.md +25 -0
- package/adapters/opencode/opencode.json +44 -0
- package/adapters/opencode/plugins/geneseed-activity.js +430 -0
- package/adapters/opencode/plugins/geneseed-context.js +876 -0
- package/adapters/opencode/plugins/geneseed-guard.js +290 -0
- package/adapters/opencode/plugins/geneseed-learn.js +396 -0
- package/adapters/opencode/plugins/geneseed-notify.js +142 -0
- package/adapters/opencode/plugins/geneseed-ponytail.js +148 -0
- package/adapters/opencode/plugins/geneseed-workflow.js +183 -0
- package/adapters/opencode/workflows/_runtime.js +277 -0
- package/adapters/opencode/workflows/council.js +64 -0
- package/adapters/opencode/workflows/dispatch.js +89 -0
- package/adapters/opencode/workflows/research-plan-implement.js +43 -0
- package/adapters/opencode/workflows/review.js +57 -0
- package/bin/geneseed-cli.mjs +412 -0
- package/bin/geneseed-hook.mjs +129 -0
- package/bin/geneseed.mjs +1086 -0
- package/docs/declined.md +149 -0
- package/docs/limits.md +270 -0
- package/docs/opencode-plugin-setup.md +64 -0
- package/docs/token-footprint.md +68 -0
- package/docs/web/_groups.json +45 -0
- package/docs/web/about.md +6 -0
- package/docs/web/adapters-claude-code.md +8 -0
- package/docs/web/adapters-opencode-loads.md +8 -0
- package/docs/web/adapters-opencode-spec.md +8 -0
- package/docs/web/adapters-opencode.md +8 -0
- package/docs/web/agents.md +8 -0
- package/docs/web/authoring.md +11 -0
- package/docs/web/autostart.md +9 -0
- package/docs/web/claude-hooks.md +36 -0
- package/docs/web/cli.md +6 -0
- package/docs/web/collaboration.md +27 -0
- package/docs/web/configure-wiki.md +9 -0
- package/docs/web/design.md +7 -0
- package/docs/web/env-knobs.md +9 -0
- package/docs/web/exclusions.md +26 -0
- package/docs/web/first-session.md +28 -0
- package/docs/web/footprint.md +31 -0
- package/docs/web/foreman-mode.md +33 -0
- package/docs/web/glossary.md +6 -0
- package/docs/web/harnesses.md +28 -0
- package/docs/web/headless.md +9 -0
- package/docs/web/ingest-docs.md +9 -0
- package/docs/web/install-paths.md +9 -0
- package/docs/web/install-quick.md +35 -0
- package/docs/web/lsp-overview.md +37 -0
- package/docs/web/mcp-claude-code.md +10 -0
- package/docs/web/mcp-filesystem.md +9 -0
- package/docs/web/mcp-gitlab.md +9 -0
- package/docs/web/mcp-markitdown.md +9 -0
- package/docs/web/mcp-overview.md +34 -0
- package/docs/web/mcp-trouble.md +9 -0
- package/docs/web/mcp-verify.md +9 -0
- package/docs/web/memory.md +7 -0
- package/docs/web/migrate.md +39 -0
- package/docs/web/model.md +20 -0
- package/docs/web/notebook.md +7 -0
- package/docs/web/pipelines.md +24 -0
- package/docs/web/plugin-activity.md +23 -0
- package/docs/web/plugin-context.md +26 -0
- package/docs/web/plugin-guard.md +24 -0
- package/docs/web/plugin-learn.md +27 -0
- package/docs/web/plugin-notify.md +24 -0
- package/docs/web/plugin-ponytail.md +23 -0
- package/docs/web/plugin-setup.md +7 -0
- package/docs/web/plugin-workflow.md +24 -0
- package/docs/web/plugins.md +17 -0
- package/docs/web/project-context.md +9 -0
- package/docs/web/rules.md +8 -0
- package/docs/web/run-anywhere.md +9 -0
- package/docs/web/self-improve.md +8 -0
- package/docs/web/setup-choices.md +39 -0
- package/docs/web/skills.md +8 -0
- package/docs/web/themes-author.md +8 -0
- package/docs/web/themes.md +9 -0
- package/docs/web/token-footprint.md +7 -0
- package/docs/web/trouble.md +38 -0
- package/docs/web/upgrade.md +9 -0
- package/docs/web/verify.md +30 -0
- package/docs/web/wiki.md +7 -0
- package/docs/web/worktree.md +10 -0
- package/docs/web-ui.md +144 -0
- package/docs/wiki.md +43 -0
- package/geneseed +39 -0
- package/geneseed.cmd +34 -0
- package/harness.config.json +6 -0
- package/js/anim.mjs +326 -0
- package/js/catalog.mjs +112 -0
- package/js/checkout.mjs +88 -0
- package/js/cli-table.json +1034 -0
- package/js/cli.mjs +498 -0
- package/js/diff.mjs +384 -0
- package/js/doctor.mjs +1364 -0
- package/js/emit.mjs +1373 -0
- package/js/excludes.mjs +271 -0
- package/js/generate.mjs +379 -0
- package/js/hooks.mjs +1026 -0
- package/js/hosts.mjs +253 -0
- package/js/installs.mjs +378 -0
- package/js/inventory.mjs +175 -0
- package/js/lib/fs.mjs +913 -0
- package/js/lib/proc.mjs +28 -0
- package/js/lib/udiff.mjs +275 -0
- package/js/link.mjs +286 -0
- package/js/mcp.mjs +335 -0
- package/js/memory.mjs +108 -0
- package/js/menu.mjs +118 -0
- package/js/migrate.mjs +251 -0
- package/js/native.mjs +426 -0
- package/js/opencode.mjs +325 -0
- package/js/registry.mjs +110 -0
- package/js/render.mjs +282 -0
- package/js/settings.mjs +924 -0
- package/js/setup.mjs +492 -0
- package/js/status.mjs +281 -0
- package/js/themes.mjs +195 -0
- package/js/tui.mjs +444 -0
- package/js/uninstall.mjs +1137 -0
- package/js/update.mjs +910 -0
- package/js/web/actions.mjs +868 -0
- package/js/web/activity.mjs +284 -0
- package/js/web/api.mjs +873 -0
- package/js/web/docs.mjs +531 -0
- package/js/web/graph.mjs +0 -0
- package/js/web/jobs.mjs +480 -0
- package/js/web/server.mjs +1135 -0
- package/package.json +54 -0
- package/registry.json +474 -0
- package/src/AGENT.md.tmpl +390 -0
- package/src/agents/_template.md +76 -0
- package/src/agents/advocate.md +36 -0
- package/src/agents/architect.md +55 -0
- package/src/agents/developer.md +66 -0
- package/src/agents/docs.md +54 -0
- package/src/agents/empiricist.md +39 -0
- package/src/agents/explorer.md +54 -0
- package/src/agents/framer.md +36 -0
- package/src/agents/historian.md +51 -0
- package/src/agents/operator.md +37 -0
- package/src/agents/pragmatist.md +37 -0
- package/src/agents/reviewer.md +52 -0
- package/src/agents/security.md +57 -0
- package/src/agents/skeptic.md +48 -0
- package/src/agents/steward.md +36 -0
- package/src/agents/tester.md +54 -0
- package/src/agents/user-advocate.md +36 -0
- package/src/agents/visionary.md +36 -0
- package/src/laws/universal.md +387 -0
- package/src/memory/README.md +109 -0
- package/src/memory/gitignore +6 -0
- package/src/modes/README.md +26 -0
- package/src/modes/direct.md +2 -0
- package/src/modes/foreman.md +19 -0
- package/src/notebook/README.md +39 -0
- package/src/notebook/gitignore +7 -0
- package/src/postures/README.md +31 -0
- package/src/postures/artisan.md +14 -0
- package/src/postures/assistant.md +12 -0
- package/src/postures/expert.md +11 -0
- package/src/postures/mentor.md +11 -0
- package/src/postures/peer.md +12 -0
- package/src/skills/_template.md +47 -0
- package/src/skills/brainstorm.md +28 -0
- package/src/skills/clarify.md +28 -0
- package/src/skills/codebase-design.md +44 -0
- package/src/skills/commit.md +48 -0
- package/src/skills/council.md +34 -0
- package/src/skills/crash-course.md +28 -0
- package/src/skills/daydream/.claude-plugin/plugin.json +9 -0
- package/src/skills/daydream/SKILL.md +74 -0
- package/src/skills/daydream/VENDOR.md +25 -0
- package/src/skills/daydream/critic-prompt.md +38 -0
- package/src/skills/daydream/instructions.md +422 -0
- package/src/skills/daydream/synthesizer-prompt.md +41 -0
- package/src/skills/debug.md +37 -0
- package/src/skills/decode.md +28 -0
- package/src/skills/develop.md +29 -0
- package/src/skills/document-project.md +79 -0
- package/src/skills/domain-modeling.md +38 -0
- package/src/skills/drill.md +28 -0
- package/src/skills/feynman.md +26 -0
- package/src/skills/forge-mcp.md +48 -0
- package/src/skills/fresh-eyes.md +29 -0
- package/src/skills/frontend-design.md +58 -0
- package/src/skills/gap-detector.md +27 -0
- package/src/skills/geneseed-code-review.md +33 -0
- package/src/skills/geneseed.md +29 -0
- package/src/skills/git-archaeology.md +47 -0
- package/src/skills/git-rescue.md +68 -0
- package/src/skills/handoff.md +29 -0
- package/src/skills/herdr.md +164 -0
- package/src/skills/ingest.md +49 -0
- package/src/skills/learning-path.md +26 -0
- package/src/skills/migrate.md +45 -0
- package/src/skills/opencode-theme.md +52 -0
- package/src/skills/parallel-agents.md +28 -0
- package/src/skills/pipeline.md +52 -0
- package/src/skills/plan.md +45 -0
- package/src/skills/ponytail.md +29 -0
- package/src/skills/profile.md +58 -0
- package/src/skills/prose.md +42 -0
- package/src/skills/prototype.md +44 -0
- package/src/skills/react-view-transitions/AGENTS.md +955 -0
- package/src/skills/react-view-transitions/README.md +42 -0
- package/src/skills/react-view-transitions/SKILL.md +320 -0
- package/src/skills/react-view-transitions/VENDOR.md +16 -0
- package/src/skills/react-view-transitions/metadata.json +12 -0
- package/src/skills/react-view-transitions/references/css-recipes.md +242 -0
- package/src/skills/react-view-transitions/references/implementation.md +182 -0
- package/src/skills/react-view-transitions/references/nextjs.md +176 -0
- package/src/skills/react-view-transitions/references/patterns.md +262 -0
- package/src/skills/refactor.md +32 -0
- package/src/skills/release.md +43 -0
- package/src/skills/repo-map.md +36 -0
- package/src/skills/research.md +42 -0
- package/src/skills/review-response.md +36 -0
- package/src/skills/roast-me.md +28 -0
- package/src/skills/rule.md +100 -0
- package/src/skills/ship.md +49 -0
- package/src/skills/tdd.md +31 -0
- package/src/skills/tickets.md +44 -0
- package/src/skills/token-report/SKILL.md +76 -0
- package/src/skills/token-report/VENDOR.md +23 -0
- package/src/skills/token-report/scripts/token_report.mjs +1301 -0
- package/src/skills/wayfinder.md +49 -0
- package/src/skills/wiki.md +68 -0
- package/src/skills/workflow.md +38 -0
- package/themes/README.md +69 -0
- package/themes/_TEMPLATE.json +147 -0
- package/themes/biker.json +147 -0
- package/themes/commentator.json +147 -0
- package/themes/cyberpunk.json +147 -0
- package/themes/gamer.json +147 -0
- package/themes/imperial.json +147 -0
- package/themes/joker.json +147 -0
- package/themes/marvin.json +147 -0
- package/themes/mean.json +147 -0
- package/themes/military.json +147 -0
- package/themes/neutral.json +147 -0
- package/themes/opencode/README.md +58 -0
- package/themes/opencode/catppuccin.json +27 -0
- package/themes/opencode/dracula.json +27 -0
- package/themes/opencode/everforest.json +27 -0
- package/themes/opencode/gruvbox.json +27 -0
- package/themes/opencode/kanagawa.json +27 -0
- package/themes/opencode/neon.json +27 -0
- package/themes/opencode/nord.json +27 -0
- package/themes/opencode/rosepine.json +27 -0
- package/themes/opencode/tokyonight.json +27 -0
- package/themes/pirate.json +147 -0
- package/themes/sports.json +147 -0
- package/themes/verstappen.json +147 -0
- package/themes/wizard.json +147 -0
- package/web/dist/assets/About-DX1-VTBP.js +1 -0
- package/web/dist/assets/Activity-Dp9NaXOn.js +1 -0
- package/web/dist/assets/ActivityDetail-HpOBtrXS.js +1 -0
- package/web/dist/assets/Diff-Ct4G3Se8.js +5 -0
- package/web/dist/assets/Doctor-DGjkZFuA.js +1 -0
- package/web/dist/assets/Graph-b8o-S6kv.js +1 -0
- package/web/dist/assets/Harnesses-gr3-aHBF.js +1 -0
- package/web/dist/assets/Laws-DTwF5tTC.js +1 -0
- package/web/dist/assets/Library-BEZscyaQ.js +1 -0
- package/web/dist/assets/Markdown-k_LSLBGq.js +1 -0
- package/web/dist/assets/Profile-DydukTxP.js +1 -0
- package/web/dist/assets/Rules-CIRG4Ldt.js +1 -0
- package/web/dist/assets/Skills-Buoi1IBr.js +1 -0
- package/web/dist/assets/StatusBadge-CE_AJttl.js +1 -0
- package/web/dist/assets/Themes-CI4o3gJC.js +2 -0
- package/web/dist/assets/activity-BCW7e6Cy.js +1 -0
- package/web/dist/assets/audiowide-latin-400-normal-6GFCX7ni.woff2 +0 -0
- package/web/dist/assets/geist-mono-latin-400-normal-DKaoCDn5.woff2 +0 -0
- package/web/dist/assets/geist-mono-latin-500-normal-C3sF8Y1B.woff2 +0 -0
- package/web/dist/assets/geist-mono-latin-600-normal-BHzjB6_C.woff2 +0 -0
- package/web/dist/assets/hanken-grotesk-latin-400-italic-Bserb8X5.woff2 +0 -0
- package/web/dist/assets/hanken-grotesk-latin-400-normal-BG6hkEXj.woff2 +0 -0
- package/web/dist/assets/hanken-grotesk-latin-500-italic-BGfwwhek.woff2 +0 -0
- package/web/dist/assets/hanken-grotesk-latin-500-normal-DrDcrrxK.woff2 +0 -0
- package/web/dist/assets/hanken-grotesk-latin-600-normal-CIXX6EOa.woff2 +0 -0
- package/web/dist/assets/hanken-grotesk-latin-700-normal-CeQ8H3UY.woff2 +0 -0
- package/web/dist/assets/ibm-plex-mono-latin-400-normal-DMJ8VG8y.woff2 +0 -0
- package/web/dist/assets/ibm-plex-mono-latin-500-normal-DSY6xOcd.woff2 +0 -0
- package/web/dist/assets/ibm-plex-mono-latin-600-normal-BgSNZQsw.woff2 +0 -0
- package/web/dist/assets/ibm-plex-serif-latin-400-normal-BIGslYFI.woff2 +0 -0
- package/web/dist/assets/ibm-plex-serif-latin-500-normal-REKAfBVG.woff2 +0 -0
- package/web/dist/assets/ibm-plex-serif-latin-600-normal-DAxitmSu.woff2 +0 -0
- package/web/dist/assets/index-B1PIx7RM.js +1 -0
- package/web/dist/assets/index-BnTQUSUz.js +12 -0
- package/web/dist/assets/index-Ck2iCdaC.js +2 -0
- package/web/dist/assets/index-DUCsDv7z.css +1 -0
- package/web/dist/assets/inter-latin-400-normal-C38fXH4l.woff2 +0 -0
- package/web/dist/assets/inter-latin-500-normal-Cerq10X2.woff2 +0 -0
- package/web/dist/assets/inter-latin-600-normal-LgqL8muc.woff2 +0 -0
- package/web/dist/assets/inter-latin-700-normal-Yt3aPRUw.woff2 +0 -0
- package/web/dist/assets/jetbrains-mono-latin-400-normal-V6pRDFza.woff2 +0 -0
- package/web/dist/assets/jetbrains-mono-latin-500-normal-BWZEU5yA.woff2 +0 -0
- package/web/dist/assets/jetbrains-mono-latin-600-normal-C8RAYTDA.woff2 +0 -0
- package/web/dist/assets/marked.esm-JKvyS0Pp.js +46 -0
- package/web/dist/assets/oswald-latin-400-normal-BAFo8sP8.woff2 +0 -0
- package/web/dist/assets/oswald-latin-500-normal-B-eyT0Dw.woff2 +0 -0
- package/web/dist/assets/oswald-latin-600-normal-plFmSr5g.woff2 +0 -0
- package/web/dist/assets/oswald-latin-700-normal-vy6hmKSr.woff2 +0 -0
- package/web/dist/assets/poppins-latin-400-normal-cpxAROuN.woff2 +0 -0
- package/web/dist/assets/poppins-latin-500-normal-C8OXljZJ.woff2 +0 -0
- package/web/dist/assets/poppins-latin-600-normal-zEkxB9Mr.woff2 +0 -0
- package/web/dist/assets/poppins-latin-700-normal-Qrb0O0WB.woff2 +0 -0
- package/web/dist/assets/react-DOpiTn5v.js +32 -0
- package/web/dist/assets/roman-DkYN0LX9.js +1 -0
- package/web/dist/assets/space-grotesk-latin-400-normal-CJ-V5oYT.woff2 +0 -0
- package/web/dist/assets/space-grotesk-latin-500-normal-lFbtlQH6.woff2 +0 -0
- package/web/dist/assets/space-grotesk-latin-600-normal-DjKNqYRj.woff2 +0 -0
- package/web/dist/assets/space-grotesk-latin-700-normal-RjhwGPKo.woff2 +0 -0
- package/web/dist/assets/space-mono-latin-400-italic-YylcN9Ay.woff2 +0 -0
- package/web/dist/assets/space-mono-latin-400-normal-Rg4St2Dn.woff2 +0 -0
- package/web/dist/assets/space-mono-latin-700-italic-vNvENeTh.woff2 +0 -0
- package/web/dist/assets/space-mono-latin-700-normal-mWgeinG7.woff2 +0 -0
- package/web/dist/icon-maskable.svg +10 -0
- package/web/dist/icon.svg +8 -0
- package/web/dist/index.html +21 -0
- package/web/dist/manifest.webmanifest +14 -0
- package/web/dist/sw.js +40 -0
- package/web/src/pages/Laws.jsx +230 -0
|
@@ -0,0 +1,1135 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The web console's HTTP shell — `rituals/_web_server.py`'s handler, static route and
|
|
3
|
+
* `serve()` entry. P6a: the shell and `/api/ping`, and nothing that answers a real
|
|
4
|
+
* endpoint.
|
|
5
|
+
*
|
|
6
|
+
* WHY THE SHELL FIRST, AND ALONE. `_web_server.py` is 654 lines and none of it is an API
|
|
7
|
+
* function: it is routing, two security guards, gzip negotiation, the static route with
|
|
8
|
+
* its per-request token injection, and the daemon record. Every one of those is invisible
|
|
9
|
+
* to a test that calls `api_X(state)` in process — which is how `tests/test_web.py`
|
|
10
|
+
* reaches all 136 of its assertions — so the shell is both the part with no existing
|
|
11
|
+
* coverage and the part every later sub-phase builds on. `tests/web_golden.py` is its
|
|
12
|
+
* gate and was written before this file.
|
|
13
|
+
*
|
|
14
|
+
* THE SERIALISER, WHICH TOUCHES ALL 29 PATHS. `_send_json` is a bare `json.dumps(obj)`:
|
|
15
|
+
* Python's DEFAULT separators, `(', ', ': ')`, and `ensure_ascii=True`. That is exactly
|
|
16
|
+
* `jsonDumpsCompact` — whose name says "compact" meaning "no indent", not "no spaces";
|
|
17
|
+
* read its body, it joins on `', '`. So the twin needs no new helper and the response
|
|
18
|
+
* bodies stay byte-comparable.
|
|
19
|
+
*
|
|
20
|
+
* Its one condition is the int/float distinction. `pyStr` refuses a BARE JS number
|
|
21
|
+
* because `json.loads` tells `20` from `1.0` and `JSON.parse` does not — but the numbers
|
|
22
|
+
* in a response body are not parsed from JSON, they are computed here (counts, a port, a
|
|
23
|
+
* pid, a unix second), and every one of them is a Python `int`, which renders identically
|
|
24
|
+
* in both languages. `bareInts` says that out loud rather than wrapping several dozen
|
|
25
|
+
* call sites in a factory. A Python float would be the counter-example — `1.0` against
|
|
26
|
+
* JS's `1` — and the byte gate over every endpoint body is what would catch one.
|
|
27
|
+
*
|
|
28
|
+
* P6h FINISHED IT, and the file grew a second half rather than a second module. The
|
|
29
|
+
* reference keeps the handler and the daemon lifecycle in ONE file under a
|
|
30
|
+
* `# ---- daemon mode` banner, and splitting them here would have made `serve()` (which
|
|
31
|
+
* needs `npmBuild`) and the launcher (which needs `readDaemon`) import each other. So
|
|
32
|
+
* `_probe`, `_live_daemon`, `_spawn_detached`, `start|stop|status|restart_daemon`,
|
|
33
|
+
* `_npm_build` and `cmd_web` all live below, and `bin/geneseed-cli.mjs` is finally a
|
|
34
|
+
* caller — which is what put this tree on the CLI's import graph and moved
|
|
35
|
+
* `js/web/jobs.mjs`'s spawn row to `entry: "cli"`.
|
|
36
|
+
*
|
|
37
|
+
* WHAT IS STILL NOT HERE, SINCE P6i: `/api/pick-folder` alone, and it is DECLINED rather than
|
|
38
|
+
* deferred — an OS-native folder chooser has no Node twin that is not a new GUI dependency.
|
|
39
|
+
* `NOT_PORTED_POST` is empty and stays declared, because an empty half of a partition is the
|
|
40
|
+
* partition asserting there is nothing left; `DECLINED_POST` is what still has a member.
|
|
41
|
+
*/
|
|
42
|
+
import { spawn, spawnSync } from 'node:child_process';
|
|
43
|
+
import { randomBytes } from 'node:crypto';
|
|
44
|
+
import {
|
|
45
|
+
existsSync, readFileSync, statSync, unlinkSync, chmodSync, mkdirSync, openSync,
|
|
46
|
+
} from 'node:fs';
|
|
47
|
+
import { createServer, request as httpRequest } from 'node:http';
|
|
48
|
+
import { join, resolve as pathResolve, extname, basename, sep } from 'node:path';
|
|
49
|
+
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 { NotFound, PREFIX_ROUTES, STATE_ROUTES, webState } from './api.mjs';
|
|
64
|
+
import {
|
|
65
|
+
apiDeployCmd, apiExcludesMutate, apiInstallCmd, apiInstallToggle, apiMcpToggle,
|
|
66
|
+
apiMemoryDelete, apiProfileSave,
|
|
67
|
+
apiRestore, apiRulesMutate, apiRulesPromote, apiSelectView, buildOverride,
|
|
68
|
+
} from './actions.mjs';
|
|
69
|
+
import { apiActivityToggle } from './activity.mjs';
|
|
70
|
+
import { apiDocs, apiDocsPage } from './docs.mjs';
|
|
71
|
+
import { JobManager, NOT_PORTED_ACTIONS, actionCommands } from './jobs.mjs';
|
|
72
|
+
|
|
73
|
+
export { webState };
|
|
74
|
+
|
|
75
|
+
const ROOT = pathResolve(fileURLToPath(import.meta.url), '..', '..', '..');
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* DNS-rebinding guard. The reference's own comment carries the why; the shape is the
|
|
79
|
+
* part a port gets wrong. `h.split(":", 1)[0]` keeps everything before the FIRST colon,
|
|
80
|
+
* so `localhost:4747` passes and a bare IPv6 literal would not — which is what the
|
|
81
|
+
* `[::1]` prefix test above it is for.
|
|
82
|
+
*/
|
|
83
|
+
export function localHost(host) {
|
|
84
|
+
const h = (host || '').trim().toLowerCase();
|
|
85
|
+
if (h.startsWith('[::1]')) return true;
|
|
86
|
+
return ['127.0.0.1', 'localhost'].includes(h.split(':', 1)[0]);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const GZIP_TYPES = ['application/json', 'text/', 'image/svg+xml',
|
|
90
|
+
'application/manifest+json', 'application/javascript'];
|
|
91
|
+
const GZIP_MIN = 1024;
|
|
92
|
+
|
|
93
|
+
const CTYPES = {
|
|
94
|
+
'.html': 'text/html', '.js': 'text/javascript',
|
|
95
|
+
'.css': 'text/css', '.json': 'application/json',
|
|
96
|
+
'.svg': 'image/svg+xml', '.ico': 'image/x-icon',
|
|
97
|
+
'.woff2': 'font/woff2', '.woff': 'font/woff',
|
|
98
|
+
'.webmanifest': 'application/manifest+json',
|
|
99
|
+
'.png': 'image/png',
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* The API paths this phase does not answer yet.
|
|
104
|
+
*
|
|
105
|
+
* Without it every one of them would fall through to `serveStatic`, which answers ANY
|
|
106
|
+
* unknown path with index.html and a 200 — the SPA fallback. An unported endpoint would
|
|
107
|
+
* therefore return a plausible HTML page instead of failing, and the first cell written
|
|
108
|
+
* against it in P6b would be the thing that discovered the route was missing. A 501
|
|
109
|
+
* naming the phase costs one lookup and cannot be mistaken for a working endpoint.
|
|
110
|
+
*
|
|
111
|
+
* It shrank to empty in P6g and the DECLARATION STAYS. Every GET the reference answers is now
|
|
112
|
+
* answered here, so the two sets hold nothing — but they are half of a partition
|
|
113
|
+
* (`test_every_get_route_is_either_ported_or_declared_unported`), and deleting them would let
|
|
114
|
+
* the next GET route added to the reference fall through to the SPA with nothing to say so.
|
|
115
|
+
* An empty declaration is the partition asserting that there is nothing left to declare.
|
|
116
|
+
*/
|
|
117
|
+
/**
|
|
118
|
+
* SPLIT BY VERB SINCE P6b, and the split is the point rather than a tidy-up. Five paths
|
|
119
|
+
* answer BOTH verbs with different bodies — `/api/mcp`, `/api/excludes`, `/api/rules`,
|
|
120
|
+
* `/api/profile`, `/api/activity` — and P6b ports the GET half of two of them. One set
|
|
121
|
+
* keyed on path alone would have taken `/api/excludes` out of the unported list the
|
|
122
|
+
* moment its GET landed, and its POST would then have answered `{"error": "not found"}`
|
|
123
|
+
* with a 404: a plausible-looking response where the reference returns 200 or 409, which
|
|
124
|
+
* is exactly the failure the 501 exists to prevent.
|
|
125
|
+
*/
|
|
126
|
+
/** EMPTY SINCE P6g — every GET the reference answers is answered here. */
|
|
127
|
+
export const NOT_PORTED = new Set([]);
|
|
128
|
+
export const NOT_PORTED_PREFIXES = [];
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* The POSTs that have not crossed YET — and EMPTY SINCE P6i, which took `/api/install`.
|
|
132
|
+
*
|
|
133
|
+
* IT STAYS DECLARED, for the same reason the GET pair above stays and the prefix lists have
|
|
134
|
+
* stayed since P6g: this set is half of a partition
|
|
135
|
+
* (`test_every_post_route_is_either_ported_or_declared_unported`), so an empty set is the
|
|
136
|
+
* partition ASSERTING that there is nothing left to declare. Delete it and the next POST route
|
|
137
|
+
* added to the reference falls through to `{"error": "not found"}` at 404 — a plausible-looking
|
|
138
|
+
* answer where the reference returns 200 or 409 — with nothing to say so.
|
|
139
|
+
*
|
|
140
|
+
* THE DISPATCHER PROBE NEEDS A TARGET, which an empty set no longer supplies.
|
|
141
|
+
* `tests/test_web_server.py` retired its `unportedGet` probe when `NOT_PORTED` emptied in P6g;
|
|
142
|
+
* `unportedPost` is retired here for the same reason, and `declinedPost` (`/api/pick-folder`,
|
|
143
|
+
* which never crosses) is what keeps the 501 arm of the dispatcher under a live probe.
|
|
144
|
+
*/
|
|
145
|
+
export const NOT_PORTED_POST = new Set([]);
|
|
146
|
+
export const NOT_PORTED_POST_PREFIXES = [];
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* The POSTs that will NEVER cross, which is a different claim from the set above.
|
|
150
|
+
*
|
|
151
|
+
* `/api/pick-folder` opens an OS-NATIVE folder chooser on the daemon host — `osascript` on
|
|
152
|
+
* macOS, a one-shot `tkinter` subprocess elsewhere. There is no Node twin that is not a new
|
|
153
|
+
* GUI dependency, and reaching for one would also be a sixth `_ALLOWED_SPAWNS` row for a
|
|
154
|
+
* modal dialog. The UI already falls back to its editable path field when this endpoint
|
|
155
|
+
* fails, which is what a 501 gives it.
|
|
156
|
+
*
|
|
157
|
+
* SEPARATE FROM `NOT_PORTED_POST` ON PURPOSE. That set is a to-do list every later phase
|
|
158
|
+
* shrinks, and folding a permanent decline into it would make the list wrong in the one
|
|
159
|
+
* direction nobody checks: a phase that emptied it would have to either port a GUI dialog
|
|
160
|
+
* or quietly delete a declaration. Both sets are unioned into the partition the reference's
|
|
161
|
+
* routes are cross-checked against, so neither can drift.
|
|
162
|
+
*/
|
|
163
|
+
export const DECLINED_POST = new Set([
|
|
164
|
+
'/api/pick-folder',
|
|
165
|
+
]);
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* The POSTs P6f answers — and this table IS the dispatch, not a declaration beside it.
|
|
169
|
+
*
|
|
170
|
+
* M23's lesson, applied one verb over: `test_the_declared_partition_is_the_one_the_dispatcher_uses`
|
|
171
|
+
* exists because a hand-kept list can agree with the reference while the running handler
|
|
172
|
+
* does something else. `doPost` looks routes up HERE, and `PORTED_POST` is this table's own
|
|
173
|
+
* keys, so the two cannot drift — the same reason `STATE_ROUTES` is a table on the GET side.
|
|
174
|
+
*
|
|
175
|
+
* THE SECOND COLUMN IS THE 409 CONVENTION, and it is a column because it is not uniform.
|
|
176
|
+
* `ok: false` means the write did not happen — a stale fingerprint, a body that never
|
|
177
|
+
* parsed, nothing to remove — and the client must re-fetch rather than retry, which is what
|
|
178
|
+
* 409 says and 200 does not. Five routes map it. `/api/activity` does NOT: its flag write
|
|
179
|
+
* answers 200 whatever `ok` says, and a port that unified the rule would be wrong exactly
|
|
180
|
+
* there. `/api/memory/delete` carries no `ok` at all — it deletes, or it raises `NotFound`
|
|
181
|
+
* and the outer catch answers 404 — so it is `false` here for a third reason again.
|
|
182
|
+
*
|
|
183
|
+
* `NotFound` IS NOT CAUGHT PER ROUTE. The reference wraps six of these calls in their own
|
|
184
|
+
* `except NotFound` AND wraps the whole of `_post_routes` in another inside `do_POST`. The
|
|
185
|
+
* inner six are redundant — every one produces the same `{"error": "not found: …"}` at 404
|
|
186
|
+
* — so the twin keeps the outer one only, in `handler`, where `do_POST`'s is.
|
|
187
|
+
*/
|
|
188
|
+
const POST_ROUTES = new Map([
|
|
189
|
+
['/api/mcp', [apiMcpToggle, true]],
|
|
190
|
+
// P6i. `_post_routes` wraps this one in its own `except NotFound` and then maps
|
|
191
|
+
// `ok: false` to 409 — the same shape as `/api/mcp` beside it, so it fits the table's
|
|
192
|
+
// second column exactly and needs no third inline dispatch. The inner catch is redundant
|
|
193
|
+
// (see this table's docblock); `handler`'s outer one answers identically.
|
|
194
|
+
['/api/install', [apiInstallToggle, true]],
|
|
195
|
+
['/api/excludes', [apiExcludesMutate, true]],
|
|
196
|
+
['/api/view', [apiSelectView, true]],
|
|
197
|
+
['/api/activity', [apiActivityToggle, false]],
|
|
198
|
+
// `(self._read_json_body().get("name") or "")` — the SHELL resolves the name, and the
|
|
199
|
+
// endpoint takes a bare slug rather than a body.
|
|
200
|
+
['/api/memory/delete',
|
|
201
|
+
[(state, b) => apiMemoryDelete(state, pyTruthy(b.name) ? pyStr(b.name) : ''), false]],
|
|
202
|
+
['/api/rules', [apiRulesMutate, true]],
|
|
203
|
+
['/api/rules/promote', [apiRulesPromote, true]],
|
|
204
|
+
['/api/profile', [apiProfileSave, true]],
|
|
205
|
+
]);
|
|
206
|
+
|
|
207
|
+
export const PORTED_POST = [...POST_ROUTES.keys()];
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* The 409 column, exported so it can be cross-checked against the reference's own `200 if
|
|
211
|
+
* res.get("ok") else 409` conditionals by `ast`.
|
|
212
|
+
*
|
|
213
|
+
* A MUTATION IS WHY THIS EXISTS. Giving `/api/activity` the 409 treatment survived the whole
|
|
214
|
+
* cell matrix: every toggle a cell can perform succeeds, and reaching the `ok: false` arm
|
|
215
|
+
* needs the flag write to raise — which the two runtimes word differently, so no byte
|
|
216
|
+
* comparison could hold it. The column is the thing under test and no cell can see it, so
|
|
217
|
+
* the gate reads it out of both implementations instead.
|
|
218
|
+
*/
|
|
219
|
+
export const POST_ROUTES_CONVENTION = Object.fromEntries(
|
|
220
|
+
[...POST_ROUTES].map(([p, [, okIs409]]) => [p, okIs409]),
|
|
221
|
+
);
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* The GET paths the dispatcher answers OUTSIDE the two tables, declared so the partition
|
|
225
|
+
* cross-check can see them: `/api/ping` is the shell's own, and the two Docs routes take
|
|
226
|
+
* the `?harness=` query param, which a `(state)` table entry has no way to receive.
|
|
227
|
+
*
|
|
228
|
+
* A declaration is not a dispatcher — M23 — so
|
|
229
|
+
* `test_the_declared_partition_is_the_one_the_dispatcher_uses` probes all three against
|
|
230
|
+
* the running handler as well.
|
|
231
|
+
*/
|
|
232
|
+
export const PORTED_INLINE = ['/api/ping', '/api/docs', '/api/docs/page/',
|
|
233
|
+
'/api/jobs', '/api/jobs/'];
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* The POSTs P6g answers outside `POST_ROUTES`, and they are outside it for a reason the
|
|
237
|
+
* table's SECOND COLUMN makes plain.
|
|
238
|
+
*
|
|
239
|
+
* `POST_ROUTES` maps a route to `(fn, okIs409)` — one handler taking `(state, body)`, one
|
|
240
|
+
* boolean deciding 200 against 409. The jobs routes fit neither half. `/api/jobs/<id>/cancel`
|
|
241
|
+
* answers 200 or 404 on a lookup, not on an `ok` field; `/api/actions/<x>` answers 202 with a
|
|
242
|
+
* job id, 409 when the runner is busy, 404 for an action the table does not name, 501 for one
|
|
243
|
+
* that has not crossed, 400 or 409 for a resolver's refusal, and 200 for `restore` — six
|
|
244
|
+
* statuses over one prefix, none of them read off `ok`. Bending the column to fit would make
|
|
245
|
+
* it lie about the five routes it currently describes exactly, so these dispatch beside the
|
|
246
|
+
* table and `tests/test_web_jobs.py` reads them out of `PORTED_POST_INLINE` instead.
|
|
247
|
+
*/
|
|
248
|
+
export const PORTED_POST_INLINE = ['/api/shutdown', '/api/restart', '/api/jobs/',
|
|
249
|
+
'/api/actions/'];
|
|
250
|
+
|
|
251
|
+
function notPorted(path) {
|
|
252
|
+
return NOT_PORTED.has(path) || NOT_PORTED_PREFIXES.some((p) => path.startsWith(p));
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
function notPortedPost(path) {
|
|
256
|
+
return NOT_PORTED_POST.has(path) || DECLINED_POST.has(path)
|
|
257
|
+
|| NOT_PORTED_POST_PREFIXES.some((p) => path.startsWith(p));
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/**
|
|
261
|
+
* `Handler._read_json_body` — the drained body as a dict, `{}` for ANYTHING else.
|
|
262
|
+
*
|
|
263
|
+
* P6a retained the drain with a note that it was measured indistinguishable and stayed
|
|
264
|
+
* because "`_read_json_body` needs the bytes from P6f on". This is that caller.
|
|
265
|
+
*
|
|
266
|
+
* `{}` AND NEVER A RAISE is the contract, and it is what makes a malformed body a 409 or a
|
|
267
|
+
* 404 from the endpoint's own validation rather than a 500 from a parser. That matters
|
|
268
|
+
* beyond tidiness: P5a's one un-portable line is a `json` error message, because CPython
|
|
269
|
+
* and V8 word them differently AND report different offsets — so a 500 carrying a parse
|
|
270
|
+
* error is a response the two implementations can never agree on.
|
|
271
|
+
*
|
|
272
|
+
* `parseJson`, not `JSON.parse`: a body value can be written straight into a user's file
|
|
273
|
+
* (`api_profile_save`'s `text`, `api_rules_mutate`'s `title`) and `str(1.0)` is `"1.0"`
|
|
274
|
+
* where `String(1)` is `"1"`. A non-object body — a bare list, a number, `null` — takes the
|
|
275
|
+
* same `{}` arm as garbage, because `isinstance(obj, dict)` is what the reference asks.
|
|
276
|
+
*/
|
|
277
|
+
function readJsonBody(buf) {
|
|
278
|
+
let obj;
|
|
279
|
+
try {
|
|
280
|
+
obj = parseJson((buf && buf.length ? buf : Buffer.from('{}')).toString('utf-8'));
|
|
281
|
+
} catch {
|
|
282
|
+
return {};
|
|
283
|
+
}
|
|
284
|
+
return (obj !== null && typeof obj === 'object' && obj.constructor === Object) ? obj : {};
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
// ---- the request handler ---------------------------------------------------
|
|
288
|
+
|
|
289
|
+
/**
|
|
290
|
+
* `make_handler(state, jm, token, dist, holder)` — `jm` is the SECOND argument here as it is
|
|
291
|
+
* there. P6a shipped this without it because there was no `JobManager` to pass; every caller
|
|
292
|
+
* moved in the same commit that added one.
|
|
293
|
+
*/
|
|
294
|
+
export function makeHandler(state, jm, token, dist, holder = null) {
|
|
295
|
+
const staticCache = new Map();
|
|
296
|
+
|
|
297
|
+
function sendBytes(res, body, ctype, code = 200, extra = null, acceptEncoding = '') {
|
|
298
|
+
const headers = { ...(extra || {}) };
|
|
299
|
+
if (body.length >= GZIP_MIN && GZIP_TYPES.some((t) => ctype.startsWith(t))
|
|
300
|
+
&& (acceptEncoding || '').includes('gzip')) {
|
|
301
|
+
body = gzipSync(body, { level: 6 });
|
|
302
|
+
headers['Content-Encoding'] = 'gzip';
|
|
303
|
+
headers.Vary = 'Accept-Encoding';
|
|
304
|
+
}
|
|
305
|
+
res.writeHead(code, {
|
|
306
|
+
'Content-Type': ctype,
|
|
307
|
+
'Content-Length': String(body.length),
|
|
308
|
+
...headers,
|
|
309
|
+
});
|
|
310
|
+
res.end(body);
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
function sendJson(res, obj, code = 200, acceptEncoding = '') {
|
|
314
|
+
sendBytes(res, Buffer.from(jsonDumpsCompact(obj, { bareInts: true }), 'utf-8'),
|
|
315
|
+
'application/json', code, null, acceptEncoding);
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
function serveStatic(res, path, acceptEncoding) {
|
|
319
|
+
const rel = (path === '/' || path === '') ? 'index.html' : path.replace(/^\/+/, '');
|
|
320
|
+
const index = pyResolve(join(dist, 'index.html'));
|
|
321
|
+
let fp = pyResolve(join(dist, rel));
|
|
322
|
+
// `dist not in fp.parents`: STRICTLY under dist. `Path.parents` never contains the
|
|
323
|
+
// path itself, so `fp == dist` takes the fallback too.
|
|
324
|
+
if (!(fp.startsWith(dist + sep) && fp !== dist) && fp !== index) fp = join(dist, 'index.html');
|
|
325
|
+
if (!isFile(fp)) fp = join(dist, 'index.html');
|
|
326
|
+
if (!isFile(fp)) {
|
|
327
|
+
return sendJson(res, { error: 'web/dist missing — run the UI build' }, 500, acceptEncoding);
|
|
328
|
+
}
|
|
329
|
+
let data = staticCache.get(fp);
|
|
330
|
+
if (data === undefined) {
|
|
331
|
+
data = readFileSync(fp);
|
|
332
|
+
staticCache.set(fp, data);
|
|
333
|
+
}
|
|
334
|
+
let extra = null;
|
|
335
|
+
if (basename(fp) === 'index.html') {
|
|
336
|
+
const inject = Buffer.from(`<script>window.__GENESEED_TOKEN__="${token}";</script>`, 'utf-8');
|
|
337
|
+
data = replaceOnce(data, Buffer.from('</head>'), Buffer.concat([inject, Buffer.from('</head>')]));
|
|
338
|
+
extra = { 'Cache-Control': 'no-store' };
|
|
339
|
+
} else if (path.includes('/assets/')) {
|
|
340
|
+
extra = { 'Cache-Control': 'public, max-age=31536000, immutable' };
|
|
341
|
+
}
|
|
342
|
+
return sendBytes(res, data, CTYPES[extname(fp)] || 'application/octet-stream',
|
|
343
|
+
200, extra, acceptEncoding);
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
function doGet(req, res, path, ae) {
|
|
347
|
+
// The reference's own table first, then the routes that parse the path — and `ping`
|
|
348
|
+
// sits between them there too, so the order here is its order.
|
|
349
|
+
const route = STATE_ROUTES[path];
|
|
350
|
+
if (route !== undefined) return sendJson(res, route(state), 200, ae);
|
|
351
|
+
if (path === '/api/ping') return sendJson(res, { ok: true, theme: state.theme }, 200, ae);
|
|
352
|
+
for (const [prefix, handler] of PREFIX_ROUTES) {
|
|
353
|
+
if (path.startsWith(prefix)) return sendJson(res, handler(state, path), 200, ae);
|
|
354
|
+
}
|
|
355
|
+
if (path === '/api/docs') {
|
|
356
|
+
return sendJson(res, apiDocs(state, harnessParam(req.url)), 200, ae);
|
|
357
|
+
}
|
|
358
|
+
if (path.startsWith('/api/docs/page/')) {
|
|
359
|
+
const pid = pyUnquote(path.slice('/api/docs/page/'.length));
|
|
360
|
+
try {
|
|
361
|
+
return sendJson(res, apiDocsPage(state, pid, harnessParam(req.url)), 200, ae);
|
|
362
|
+
} catch (e) {
|
|
363
|
+
// A kind P6d does not answer says so, rather than falling through to the
|
|
364
|
+
// `NotFound` at the bottom of `apiDocsPage` and claiming the page is missing.
|
|
365
|
+
if (e && e.notPorted) return sendJson(res, { error: e.message }, 501, ae);
|
|
366
|
+
throw e;
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
if (path === '/api/jobs') return sendJson(res, { jobs: jm.recent() }, 200, ae);
|
|
370
|
+
if (path.startsWith('/api/jobs/')) {
|
|
371
|
+
// `path.rsplit("/", 1)[1]` — the LAST segment, so `/api/jobs/a/b` looks up `b` and
|
|
372
|
+
// misses, rather than 404ing on the shape.
|
|
373
|
+
const j = jm.get(path.slice(path.lastIndexOf('/') + 1));
|
|
374
|
+
return j ? sendJson(res, j, 200, ae)
|
|
375
|
+
: sendJson(res, { error: 'no such job' }, 404, ae);
|
|
376
|
+
}
|
|
377
|
+
if (notPorted(path)) {
|
|
378
|
+
return sendJson(res, { error: `not ported yet: ${path}` }, 501, ae);
|
|
379
|
+
}
|
|
380
|
+
return serveStatic(res, path, ae);
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
function doPost(req, res, path, ae, body) {
|
|
384
|
+
if (!localHost(req.headers.host)) {
|
|
385
|
+
return sendJson(res, { error: 'forbidden host' }, 403, ae);
|
|
386
|
+
}
|
|
387
|
+
if (req.headers['x-geneseed-token'] !== token) {
|
|
388
|
+
return sendJson(res, { error: 'forbidden' }, 403, ae);
|
|
389
|
+
}
|
|
390
|
+
if (path === '/api/shutdown') {
|
|
391
|
+
// The one POST route the SHELL owns. It must answer BEFORE it stops, which is why
|
|
392
|
+
// the reference runs `srv.shutdown()` on its own thread — called inline it would
|
|
393
|
+
// deadlock against `serve_forever`. Here the equivalent is closing after the
|
|
394
|
+
// response has flushed. `closeAllConnections` is the other half: `close()` alone
|
|
395
|
+
// waits for idle keep-alive sockets, and the client that just sent this request is
|
|
396
|
+
// holding one open. The reference drops them by exiting.
|
|
397
|
+
res.on('finish', () => {
|
|
398
|
+
holder.srv.close();
|
|
399
|
+
holder.srv.closeAllConnections();
|
|
400
|
+
});
|
|
401
|
+
return sendJson(res, { stopping: true }, 200, ae);
|
|
402
|
+
}
|
|
403
|
+
if (path === '/api/restart') {
|
|
404
|
+
// THE ONE ROUTE IN THIS FILE NO TEST MAY REACH, and it is declared rather than probed.
|
|
405
|
+
// `requestRestart` spawns a DETACHED `web restart`, which stops whatever daemon the
|
|
406
|
+
// record names and starts a fresh one that outlives the caller. Every way of asking it
|
|
407
|
+
// a question runs it: `tests/web_golden.py` would have its own server stopped and a
|
|
408
|
+
// replacement orphaned into a sandbox about to be deleted, and
|
|
409
|
+
// `tests/test_web_server.py`'s dispatcher probe runs in the DEVELOPER'S environment,
|
|
410
|
+
// where the replacement would bind 4747 and serve the checkout forever. That is the
|
|
411
|
+
// same reasoning P6g used to ban a job cell that starts real work, one step further:
|
|
412
|
+
// there the child was merely orphaned, here it is a second daemon.
|
|
413
|
+
//
|
|
414
|
+
// So the gate is `tests/test_web_daemon.py`, which reads both implementations and
|
|
415
|
+
// asserts they dispatch this path to `request_restart(state.theme)` and answer the
|
|
416
|
+
// same body. Rule 7 says a declaration is not a dispatcher — this is the one route
|
|
417
|
+
// where probing the dispatcher costs more than the assurance is worth, and saying so
|
|
418
|
+
// beats a silently missing probe.
|
|
419
|
+
requestRestart(state.theme);
|
|
420
|
+
return sendJson(res, { restarting: true }, 200, ae);
|
|
421
|
+
}
|
|
422
|
+
const route = POST_ROUTES.get(path);
|
|
423
|
+
if (route !== undefined) {
|
|
424
|
+
const [fn, okIs409] = route;
|
|
425
|
+
const obj = fn(state, readJsonBody(body));
|
|
426
|
+
return sendJson(res, obj, (okIs409 && !pyTruthy(obj.ok)) ? 409 : 200, ae);
|
|
427
|
+
}
|
|
428
|
+
if (path.startsWith('/api/jobs/') && path.endsWith('/cancel')) {
|
|
429
|
+
// `path.split("/")[3]`, positional — so `/api/jobs/<id>/cancel` takes `<id>` and a
|
|
430
|
+
// deeper path takes its third segment, which is what the reference looks up.
|
|
431
|
+
const jid = path.split('/')[3];
|
|
432
|
+
return jm.cancel(jid) ? sendJson(res, { cancelled: jid }, 200, ae)
|
|
433
|
+
: sendJson(res, { error: 'no running job by that id' }, 404, ae);
|
|
434
|
+
}
|
|
435
|
+
if (path.startsWith('/api/actions/')) {
|
|
436
|
+
return doAction(res, path.slice(path.lastIndexOf('/') + 1), readJsonBody(body), ae);
|
|
437
|
+
}
|
|
438
|
+
if (notPortedPost(path)) {
|
|
439
|
+
return sendJson(res, { error: `not ported yet: ${path}` }, 501, ae);
|
|
440
|
+
}
|
|
441
|
+
return sendJson(res, { error: 'not found' }, 404, ae);
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
/**
|
|
445
|
+
* `/api/actions/<x>` — the reference's own order, which is the part a port gets wrong.
|
|
446
|
+
*
|
|
447
|
+
* `restore` is answered BEFORE anything else because it is synchronous and returns a result
|
|
448
|
+
* rather than a job id. `install` and `deploy` resolve their argv from the body, and their
|
|
449
|
+
* refusal statuses DIFFER — 409 for install (its target came from an allowlist, so a refusal
|
|
450
|
+
* means the world changed under the client) and 400 for deploy (its path came from the body,
|
|
451
|
+
* so a refusal means the body was wrong). That asymmetry is the reference's; a port that
|
|
452
|
+
* unified them would be wrong on one of the two and no cell would say which.
|
|
453
|
+
*
|
|
454
|
+
* `update` COMES NEXT AND IS INLINE, since P8c: it is gated on a LOCAL-ONLY preflight before
|
|
455
|
+
* a job is ever started, so a dirty tree or a zip-download install gets a friendly 422 the UI
|
|
456
|
+
* renders as an info popup instead of a job that spawns and then fails. Its finish handler is
|
|
457
|
+
* its own too — the child skips its own daemon bounce (`GENESEED_WEB_JOB`, set by `_run`), so
|
|
458
|
+
* the restart happens HERE, after the job is saved as finished, and only when it succeeded: a
|
|
459
|
+
* failed update changed nothing worth reloading and bouncing would disconnect the PWA for no
|
|
460
|
+
* reason.
|
|
461
|
+
*
|
|
462
|
+
* THEN the unported rows, and only then the table. Order matters: `link`/`unlink` are REAL
|
|
463
|
+
* actions whose verb has not crossed, and letting them fall through to `actionCommands` would
|
|
464
|
+
* answer `{"error": "unknown action link"}` — the same thing a typo gets.
|
|
465
|
+
*/
|
|
466
|
+
function doAction(res, action, body, ae) {
|
|
467
|
+
if (action === 'restore') {
|
|
468
|
+
return sendJson(res, apiRestore(state, pyTruthy(body.files) ? body.files : []), 200, ae);
|
|
469
|
+
}
|
|
470
|
+
if (action === 'install') {
|
|
471
|
+
const plan = apiInstallCmd(state, body);
|
|
472
|
+
if (Object.hasOwn(plan, 'error')) return sendJson(res, plan, 409, ae);
|
|
473
|
+
return startJob(res, 'install', [plan.cmd], ae);
|
|
474
|
+
}
|
|
475
|
+
if (action === 'deploy') {
|
|
476
|
+
const plan = apiDeployCmd(state, body);
|
|
477
|
+
if (Object.hasOwn(plan, 'error')) return sendJson(res, plan, 400, ae);
|
|
478
|
+
return startJob(res, 'deploy', [plan.cmd], ae);
|
|
479
|
+
}
|
|
480
|
+
if (action === 'update') {
|
|
481
|
+
const pre = preflight();
|
|
482
|
+
if (!pre.ok) {
|
|
483
|
+
return sendJson(res,
|
|
484
|
+
{ precondition: pre.code, kind: pre.kind, message: pre.message }, 422, ae);
|
|
485
|
+
}
|
|
486
|
+
const jid = jm.start('update', actionCommands('update'), (rc) => {
|
|
487
|
+
state.refresh();
|
|
488
|
+
if (rc === 0) requestRestart(state.theme);
|
|
489
|
+
});
|
|
490
|
+
if (jid === null) return sendJson(res, { error: 'busy' }, 409, ae);
|
|
491
|
+
return sendJson(res, { job_id: jid }, 202, ae);
|
|
492
|
+
}
|
|
493
|
+
if (NOT_PORTED_ACTIONS.has(action)) {
|
|
494
|
+
return sendJson(res, { error: `not ported yet: /api/actions/${action}` }, 501, ae);
|
|
495
|
+
}
|
|
496
|
+
// Build can be re-themed/re-targeted from the UI picker; the other actions self-resolve
|
|
497
|
+
// the deployed theme downstream. Footprint, posture and mode always follow the current
|
|
498
|
+
// install, so a re-theme preserves lean/full, the register and the operating mode.
|
|
499
|
+
const [theme, emit] = action === 'build'
|
|
500
|
+
? buildOverride(state, body) : [state.theme, state.emit];
|
|
501
|
+
const cmds = actionCommands(action, {
|
|
502
|
+
theme, emit, footprint: state.footprint, posture: state.posture, mode: state.mode,
|
|
503
|
+
});
|
|
504
|
+
if (!pyTruthy(cmds)) {
|
|
505
|
+
return sendJson(res, { error: `unknown action ${action}` }, 404, ae);
|
|
506
|
+
}
|
|
507
|
+
return startJob(res, action, cmds, ae);
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
/**
|
|
511
|
+
* The 202/409 pair every job route ends in. `state.refresh()` on finish is not optional: a
|
|
512
|
+
* Build may re-theme the install, and the re-detect must read the new `.geneseed-emit` or
|
|
513
|
+
* the console keeps serving the old mode (the line P6b dropped and P6f restored).
|
|
514
|
+
*/
|
|
515
|
+
function startJob(res, action, cmds, ae) {
|
|
516
|
+
const jid = jm.start(action, cmds, () => state.refresh());
|
|
517
|
+
if (jid === null) return sendJson(res, { error: 'busy' }, 409, ae);
|
|
518
|
+
return sendJson(res, { job_id: jid }, 202, ae);
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
return function handler(req, res) {
|
|
522
|
+
const ae = req.headers['accept-encoding'] || '';
|
|
523
|
+
const path = (req.url || '').split('?', 1)[0];
|
|
524
|
+
try {
|
|
525
|
+
if (req.method === 'GET') {
|
|
526
|
+
if (!localHost(req.headers.host)) {
|
|
527
|
+
return sendJson(res, { error: 'forbidden host' }, 403, ae);
|
|
528
|
+
}
|
|
529
|
+
try {
|
|
530
|
+
return doGet(req, res, path, ae);
|
|
531
|
+
} catch (e) {
|
|
532
|
+
// The `NotFound` → 404 convention, which P6c is the first phase to raise. It
|
|
533
|
+
// wraps the ROUTE DISPATCH and not each handler, exactly as the reference's
|
|
534
|
+
// `try` around `do_GET`'s body does — an endpoint several frames down raises and
|
|
535
|
+
// the shell decides the status. Anything else is still the outer 500.
|
|
536
|
+
if (e instanceof NotFound) {
|
|
537
|
+
return sendJson(res, { error: `not found: ${e.message}` }, 404, ae);
|
|
538
|
+
}
|
|
539
|
+
throw e;
|
|
540
|
+
}
|
|
541
|
+
}
|
|
542
|
+
if (req.method === 'POST') {
|
|
543
|
+
// Drain the body BEFORE routing, as the reference does and for its stated
|
|
544
|
+
// reason — under keep-alive an unread body is parsed as the next request line,
|
|
545
|
+
// and both guards answer without ever reaching a route that would read it.
|
|
546
|
+
//
|
|
547
|
+
// THIS HALF OF IT IS INDISTINGUISHABLE HERE, and measured rather than assumed:
|
|
548
|
+
// two mutations — reading zero bytes, then neither reading nor resuming the
|
|
549
|
+
// stream — both survived the keep-alive cell, which the reference needs the
|
|
550
|
+
// drain to pass. Node's HTTP parser owns the message boundary, so unread body
|
|
551
|
+
// bytes are discarded when the response ends instead of being re-parsed. It
|
|
552
|
+
// stays because `_read_json_body` needs the bytes from P6f on, and because a
|
|
553
|
+
// shell whose two implementations diverged only under load would be the worst
|
|
554
|
+
// kind of divergence to carry. UNREACHABLE is not the word for it: the branch
|
|
555
|
+
// runs on every POST, it simply cannot be observed to matter on this runtime.
|
|
556
|
+
const length = pyInt(String(req.headers['content-length'] ?? '')) ?? 0;
|
|
557
|
+
return readBody(req, length, (buf) => {
|
|
558
|
+
req._body = buf;
|
|
559
|
+
try {
|
|
560
|
+
return doPost(req, res, path, ae, buf);
|
|
561
|
+
} catch (e) {
|
|
562
|
+
// `do_POST`'s own two-armed catch, and P6f is the phase that needs the first
|
|
563
|
+
// arm: `api_mcp_toggle`, `api_select_view`, `api_memory_delete`,
|
|
564
|
+
// `api_rules_mutate` and `api_rules_promote` all raise `NotFound` for an
|
|
565
|
+
// unknown target, and the reference answers 404. The 500 arm below it is what
|
|
566
|
+
// carries `_rule_fields`' ValueError message, which is a 500 there too.
|
|
567
|
+
if (e instanceof NotFound) {
|
|
568
|
+
return sendJson(res, { error: `not found: ${e.message}` }, 404, ae);
|
|
569
|
+
}
|
|
570
|
+
return sendJson(res, { error: String(e && e.message ? e.message : e) }, 500, ae);
|
|
571
|
+
}
|
|
572
|
+
});
|
|
573
|
+
}
|
|
574
|
+
return sendJson(res, { error: 'not found' }, 404, ae);
|
|
575
|
+
} catch (e) {
|
|
576
|
+
return sendJson(res, { error: String(e && e.message ? e.message : e) }, 500, ae);
|
|
577
|
+
}
|
|
578
|
+
};
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
/**
|
|
582
|
+
* `Handler._harness` — the `?harness=` query param, `null` when absent.
|
|
583
|
+
*
|
|
584
|
+
* `urllib.parse.parse_qs` with its defaults, reproduced only as far as this one caller
|
|
585
|
+
* needs and no further: pairs split on `&` then `=`, values unquoted with `+` meaning a
|
|
586
|
+
* space, and a BLANK value dropped (`keep_blank_values=False`), so `?harness=` resolves to
|
|
587
|
+
* the installed default rather than to the empty string. The first occurrence wins,
|
|
588
|
+
* because `parse_qs` returns a list and the reference takes `[0]`.
|
|
589
|
+
*
|
|
590
|
+
* THE BLANK-VALUE DROP IS INDISTINGUISHABLE HERE, measured rather than assumed: a
|
|
591
|
+
* mutation removing it survived, because `normHarness('')` and `normHarness(null)` both
|
|
592
|
+
* fall through to the installed default. It stays for the reason `parse_qs`' own default
|
|
593
|
+
* exists — the next parameter read through this function may be one whose empty string
|
|
594
|
+
* means something — and because a shell whose two implementations agreed only by accident
|
|
595
|
+
* is the kind of agreement that stops holding when a caller is added. UNREACHABLE is not
|
|
596
|
+
* the word: the branch runs on every `?harness=`, it simply cannot be observed to matter
|
|
597
|
+
* through the one consumer it has.
|
|
598
|
+
*/
|
|
599
|
+
function harnessParam(url) {
|
|
600
|
+
const at = (url || '').indexOf('?');
|
|
601
|
+
if (at < 0) return null;
|
|
602
|
+
for (const pair of url.slice(at + 1).split('&')) {
|
|
603
|
+
if (!pair) continue;
|
|
604
|
+
const eq = pair.indexOf('=');
|
|
605
|
+
const k = pyUnquote((eq < 0 ? pair : pair.slice(0, eq)).replace(/\+/g, ' '));
|
|
606
|
+
const v = eq < 0 ? '' : pyUnquote(pair.slice(eq + 1).replace(/\+/g, ' '));
|
|
607
|
+
if (k === 'harness' && v !== '') return v;
|
|
608
|
+
}
|
|
609
|
+
return null;
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
function readBody(req, length, done) {
|
|
613
|
+
if (!(length > 0)) {
|
|
614
|
+
req.resume();
|
|
615
|
+
return done(Buffer.alloc(0));
|
|
616
|
+
}
|
|
617
|
+
const chunks = [];
|
|
618
|
+
let seen = 0;
|
|
619
|
+
req.on('data', (c) => {
|
|
620
|
+
// Exactly `Content-Length` bytes, as `rfile.read(length)` takes: a longer body is
|
|
621
|
+
// the next request's bytes and belongs to the parser, not to this handler.
|
|
622
|
+
if (seen >= length) return;
|
|
623
|
+
chunks.push(c.subarray(0, length - seen));
|
|
624
|
+
seen += c.length;
|
|
625
|
+
});
|
|
626
|
+
req.on('end', () => done(Buffer.concat(chunks)));
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
function isFile(p) {
|
|
630
|
+
try {
|
|
631
|
+
return statSync(p).isFile();
|
|
632
|
+
} catch {
|
|
633
|
+
return false;
|
|
634
|
+
}
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
/** `bytes.replace(old, new, 1)` — the first occurrence only. */
|
|
638
|
+
function replaceOnce(buf, find, repl) {
|
|
639
|
+
const at = buf.indexOf(find);
|
|
640
|
+
if (at < 0) return buf;
|
|
641
|
+
return Buffer.concat([buf.subarray(0, at), repl, buf.subarray(at + find.length)]);
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
// ---- the daemon record -----------------------------------------------------
|
|
645
|
+
|
|
646
|
+
const statePath = (target) => join(target, '.geneseed-web.json');
|
|
647
|
+
|
|
648
|
+
export function readDaemon(target) {
|
|
649
|
+
try {
|
|
650
|
+
return JSON.parse(readFileSync(statePath(target), 'utf-8'));
|
|
651
|
+
} catch {
|
|
652
|
+
return null;
|
|
653
|
+
}
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
export function writeDaemon(target, data) {
|
|
657
|
+
try {
|
|
658
|
+
mkdirSync(target, { recursive: true });
|
|
659
|
+
const p = statePath(target);
|
|
660
|
+
writeText(p, jsonDumpsCompact(data, { bareInts: true }));
|
|
661
|
+
// Owner-only: the record carries the API token. A no-op on Windows, where the
|
|
662
|
+
// mode only maps to the read-only attribute — same as the reference's os.chmod.
|
|
663
|
+
chmodSync(p, 0o600);
|
|
664
|
+
} catch { /* the reference swallows OSError here too */ }
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
export function clearDaemon(target) {
|
|
668
|
+
try {
|
|
669
|
+
unlinkSync(statePath(target));
|
|
670
|
+
} catch { /* already gone */ }
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
// ---- daemon mode -----------------------------------------------------------
|
|
674
|
+
//
|
|
675
|
+
// `geneseed web start|stop|status|restart`. The reference's own banner explains the design
|
|
676
|
+
// — the running server records pid/port/token/url in a small JSON file and control is over
|
|
677
|
+
// HTTP, so nothing here needs OS process-kill semantics, only a localhost request with the
|
|
678
|
+
// token. Two things about the TWIN, neither of which is in that banner:
|
|
679
|
+
//
|
|
680
|
+
// * `_probe` and `_post_shutdown` are `urllib.request.urlopen` with a timeout, which is
|
|
681
|
+
// synchronous. Node has no synchronous HTTP client, so every function below that
|
|
682
|
+
// reaches one is `async` and `cmdWeb` awaits it — which is why `bin/geneseed-cli.mjs`
|
|
683
|
+
// awaits its dispatch. Nothing about the ORDER of the reference's steps changes.
|
|
684
|
+
// * `agent: false` on both requests. Node's global agent has kept sockets alive by
|
|
685
|
+
// default since v19, and a pooled idle socket keeps the event loop referenced — a
|
|
686
|
+
// `web status` that answered correctly would then hang for the keep-alive timeout
|
|
687
|
+
// instead of exiting. Python's `urlopen` opens and closes a connection per call.
|
|
688
|
+
|
|
689
|
+
/** `f"{url}/api/ping"` answered 200 — the cheap liveness probe both `status` and the
|
|
690
|
+
* launcher's wait loop are built on. Every failure mode is False, as the reference's
|
|
691
|
+
* `(URLError, OSError, ValueError)` catch is. */
|
|
692
|
+
export function probe(url, timeout = 1.5) {
|
|
693
|
+
return new Promise((done) => {
|
|
694
|
+
let settled = false;
|
|
695
|
+
const finish = (v) => { if (!settled) { settled = true; done(v); } };
|
|
696
|
+
let req;
|
|
697
|
+
try {
|
|
698
|
+
req = httpRequest(`${url}/api/ping`, { method: 'GET', agent: false }, (res) => {
|
|
699
|
+
res.resume(); // drain, or the socket never closes
|
|
700
|
+
finish(res.statusCode === 200);
|
|
701
|
+
});
|
|
702
|
+
} catch {
|
|
703
|
+
return finish(false); // a malformed url is `ValueError` there and a throw here
|
|
704
|
+
}
|
|
705
|
+
req.setTimeout(Math.round(timeout * 1000), () => { req.destroy(); finish(false); });
|
|
706
|
+
req.on('error', () => finish(false));
|
|
707
|
+
req.end();
|
|
708
|
+
return undefined;
|
|
709
|
+
});
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
/** The token-carrying POST `geneseed web stop` and the in-page Stop button both send. */
|
|
713
|
+
export function postShutdown(url, token, timeout = 3.0) {
|
|
714
|
+
return new Promise((done) => {
|
|
715
|
+
let settled = false;
|
|
716
|
+
const finish = (v) => { if (!settled) { settled = true; done(v); } };
|
|
717
|
+
let req;
|
|
718
|
+
try {
|
|
719
|
+
req = httpRequest(`${url}/api/shutdown`, {
|
|
720
|
+
method: 'POST',
|
|
721
|
+
agent: false,
|
|
722
|
+
headers: { 'X-Geneseed-Token': token, 'Content-Type': 'application/json' },
|
|
723
|
+
}, (res) => {
|
|
724
|
+
res.resume();
|
|
725
|
+
finish(res.statusCode === 200);
|
|
726
|
+
});
|
|
727
|
+
} catch {
|
|
728
|
+
return finish(false);
|
|
729
|
+
}
|
|
730
|
+
req.setTimeout(Math.round(timeout * 1000), () => { req.destroy(); finish(false); });
|
|
731
|
+
req.on('error', () => finish(false));
|
|
732
|
+
req.end('{}');
|
|
733
|
+
return undefined;
|
|
734
|
+
});
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
/**
|
|
738
|
+
* The record ONLY if a server is answering; otherwise the stale file is deleted.
|
|
739
|
+
*
|
|
740
|
+
* The deletion is the part a port drops, and it is what stops a machine that crashed with
|
|
741
|
+
* a daemon running from re-probing a dead URL on every `status` forever. Both of its arms
|
|
742
|
+
* are gated by a `harness_golden` cell — a record whose url nothing answers, and a record
|
|
743
|
+
* carrying no url at all, which skips the probe and still clears.
|
|
744
|
+
*/
|
|
745
|
+
export async function liveDaemon(target) {
|
|
746
|
+
const st = readDaemon(target);
|
|
747
|
+
if (st && pyTruthy(st.url) && await probe(st.url)) return st;
|
|
748
|
+
if (pyTruthy(st)) clearDaemon(target);
|
|
749
|
+
return null;
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
const sleep = (ms) => new Promise((r) => { setTimeout(r, ms); });
|
|
753
|
+
|
|
754
|
+
/** `st.get(k)` rendered as Python renders it — a missing key prints `None`, not
|
|
755
|
+
* `undefined`. Only reachable through a hand-edited record, and one character of
|
|
756
|
+
* divergence in a message is still a divergence. */
|
|
757
|
+
const none = (v) => (v === undefined || v === null ? 'None' : String(v));
|
|
758
|
+
|
|
759
|
+
/**
|
|
760
|
+
* `_spawn_detached` — the daemon launcher, and the second `_ALLOWED_SPAWNS` entry this
|
|
761
|
+
* module owns.
|
|
762
|
+
*
|
|
763
|
+
* WHY THERE IS NO IN-PROCESS EQUIVALENT: the whole contract is that the server OUTLIVES
|
|
764
|
+
* the process that started it. `geneseed web start` prints a URL and returns to the shell;
|
|
765
|
+
* the server it started must still be there. A thread, a `worker_thread` or an in-process
|
|
766
|
+
* `serve()` all die with the launcher.
|
|
767
|
+
*
|
|
768
|
+
* WHAT IT STARTS IS THIS PROGRAM, named as `process.execPath` + `bin/geneseed-cli.mjs` —
|
|
769
|
+
* the running interpreter by absolute path and this repo's own CLI. The reference names
|
|
770
|
+
* `sys.executable` + `rituals/harness.py`; each side re-executes ITSELF, which is what
|
|
771
|
+
* makes this not the passthrough the port exists to remove, and
|
|
772
|
+
* `tests/test_web_daemon.py` asserts the twin's argv contains no `python`, no `harness.py`
|
|
773
|
+
* and no `build.py` at all.
|
|
774
|
+
*/
|
|
775
|
+
function spawnDetached(webArgs, log) {
|
|
776
|
+
const cmd = [process.execPath, join(ROOT, 'bin', 'geneseed-cli.mjs'), 'web', ...webArgs];
|
|
777
|
+
let out = 'ignore';
|
|
778
|
+
try {
|
|
779
|
+
out = openSync(log, 'a'); // `open(log, "ab")`; DEVNULL on OSError, as there
|
|
780
|
+
} catch { /* the reference falls back to DEVNULL on exactly this failure */ }
|
|
781
|
+
const child = spawn(cmd[0], cmd.slice(1), {
|
|
782
|
+
// `DETACHED_PROCESS | NEW_PROCESS_GROUP` on Windows and `start_new_session` on POSIX
|
|
783
|
+
// are both `detached` here; `unref()` is what lets the launcher's event loop end while
|
|
784
|
+
// the child runs on, which is `Popen`'s default and Node's is not.
|
|
785
|
+
detached: true, windowsHide: true, stdio: ['ignore', out, out],
|
|
786
|
+
});
|
|
787
|
+
child.unref();
|
|
788
|
+
}
|
|
789
|
+
|
|
790
|
+
/** The `web` argv the launcher passes. Pure, and compared against `_daemon_args` by the
|
|
791
|
+
* corpus: the binary differs per side by design, the FLAGS may not. */
|
|
792
|
+
export function daemonArgs(port, theme) {
|
|
793
|
+
const args = ['--daemon-internal', '--port', String(port), '--no-browser'];
|
|
794
|
+
if (pyTruthy(theme)) args.push('--theme', theme);
|
|
795
|
+
return args;
|
|
796
|
+
}
|
|
797
|
+
|
|
798
|
+
/** The same for the out-of-band restart — `_restart_args`. */
|
|
799
|
+
export function restartArgs(theme) {
|
|
800
|
+
const args = ['restart', '--no-browser'];
|
|
801
|
+
if (pyTruthy(theme)) args.push('--theme', theme);
|
|
802
|
+
return args;
|
|
803
|
+
}
|
|
804
|
+
|
|
805
|
+
/** `request_restart` — a detached `web restart`, so the new server survives the exit of
|
|
806
|
+
* the very process asking for it. Called by POST `/api/restart`. */
|
|
807
|
+
export function requestRestart(theme) {
|
|
808
|
+
const target = webState(theme).target;
|
|
809
|
+
spawnDetached(restartArgs(theme), join(target, '.geneseed-web.log'));
|
|
810
|
+
}
|
|
811
|
+
|
|
812
|
+
/**
|
|
813
|
+
* `webbrowser.open(url)`, which has no Node equivalent at all — there is no stdlib module
|
|
814
|
+
* that knows what a desktop's default browser is.
|
|
815
|
+
*
|
|
816
|
+
* IT IS A MACHINE PRIMITIVE, the same class as `taskkill` and `java -version` and the
|
|
817
|
+
* opposite of a passthrough: it asks the OS to open a URL and hands it nothing of this
|
|
818
|
+
* program's work. Declining it instead would have been a silent regression in the one verb
|
|
819
|
+
* whose job is to put a UI on screen — `geneseed web` would print an address and do
|
|
820
|
+
* nothing, and no cell would say so because every cell passes `--no-browser`.
|
|
821
|
+
*
|
|
822
|
+
* The reference swallows every failure here (`contextlib.suppress(Exception)` in two call
|
|
823
|
+
* sites and a bare `except` in the third), and so does this.
|
|
824
|
+
*/
|
|
825
|
+
function openUrl(url) {
|
|
826
|
+
const [file, args, extra] = process.platform === 'win32'
|
|
827
|
+
// `start` is a cmd BUILTIN, not a program. The empty `""` is its title argument —
|
|
828
|
+
// without it `start "http://…"` treats the quoted URL as the window title and opens
|
|
829
|
+
// nothing. Verbatim args because Node would otherwise re-quote the whole string.
|
|
830
|
+
? [process.env.COMSPEC || 'cmd.exe', ['/d', '/s', '/c', `start "" "${url}"`],
|
|
831
|
+
{ windowsVerbatimArguments: true }]
|
|
832
|
+
: (process.platform === 'darwin' ? ['open', [url], {}] : ['xdg-open', [url], {}]);
|
|
833
|
+
try {
|
|
834
|
+
const child = spawn(file, args, {
|
|
835
|
+
detached: true, stdio: 'ignore', windowsHide: true, ...extra,
|
|
836
|
+
});
|
|
837
|
+
// ⚠ ENOENT ARRIVES AS AN EVENT, NOT AS A THROW, AND THE `try` ABOVE CANNOT SEE IT.
|
|
838
|
+
// This is where the reference's suppression failed to survive translation: Python's
|
|
839
|
+
// `subprocess` raises `FileNotFoundError` synchronously, so `contextlib.suppress(Exception)`
|
|
840
|
+
// really did swallow a missing opener. Node's `spawn` reports the same condition on the
|
|
841
|
+
// child's `error` event, and an 'error' with no listener is re-thrown as an uncaught
|
|
842
|
+
// exception. So the port's `try/catch` read as equivalent and suppressed nothing.
|
|
843
|
+
//
|
|
844
|
+
// MEASURED, in the packaged no-python container: `geneseed web` on a headless Linux box
|
|
845
|
+
// printed `Error: spawn xdg-open ENOENT`. Found by the acceptance gate rather than by the
|
|
846
|
+
// 690-cell corpus, and the docblock above says why the corpus never could — every cell
|
|
847
|
+
// passes `--no-browser`, so no recorded cell has ever executed this line.
|
|
848
|
+
child.on('error', () => { /* no opener on this machine; the daemon is still serving */ });
|
|
849
|
+
child.unref();
|
|
850
|
+
} catch { /* the reference suppresses every exception from this call too */ }
|
|
851
|
+
}
|
|
852
|
+
|
|
853
|
+
export async function startDaemon(theme, port, openBrowser = true) {
|
|
854
|
+
const { target } = webState(theme);
|
|
855
|
+
const st = await liveDaemon(target);
|
|
856
|
+
if (st) {
|
|
857
|
+
pyPrint(`[web] already running on ${st.url} (pid ${none(st.pid)})\n`);
|
|
858
|
+
if (openBrowser) openUrl(st.url);
|
|
859
|
+
return 0;
|
|
860
|
+
}
|
|
861
|
+
clearDaemon(target);
|
|
862
|
+
const log = join(target, '.geneseed-web.log');
|
|
863
|
+
spawnDetached(daemonArgs(port, theme), log);
|
|
864
|
+
// `for _ in range(60): ... time.sleep(0.2)` — twelve seconds for the child to bind and
|
|
865
|
+
// write its record. The probe timeout is the reference's shorter 0.5 here, not 1.5.
|
|
866
|
+
for (let i = 0; i < 60; i += 1) {
|
|
867
|
+
const rec = readDaemon(target);
|
|
868
|
+
// eslint-disable-next-line no-await-in-loop
|
|
869
|
+
if (rec && pyTruthy(rec.url) && await probe(rec.url, 0.5)) {
|
|
870
|
+
pyPrint(`[web] Geneseed UI on ${rec.url} (theme: ${none(rec.theme)}, `
|
|
871
|
+
+ `pid ${none(rec.pid)})\n`);
|
|
872
|
+
pyPrint('[web] running in the background — `geneseed web stop` to stop it.\n');
|
|
873
|
+
if (openBrowser) openUrl(rec.url);
|
|
874
|
+
return 0;
|
|
875
|
+
}
|
|
876
|
+
// eslint-disable-next-line no-await-in-loop
|
|
877
|
+
await sleep(200);
|
|
878
|
+
}
|
|
879
|
+
pyPrint('[web] daemon did not come up in time — check the log:\n');
|
|
880
|
+
pyPrint(` ${log}\n`);
|
|
881
|
+
return 1;
|
|
882
|
+
}
|
|
883
|
+
|
|
884
|
+
export async function stopDaemon(theme = null) {
|
|
885
|
+
const { target } = webState(theme);
|
|
886
|
+
const st = readDaemon(target);
|
|
887
|
+
if (!pyTruthy(st) || !pyTruthy(st.url)) {
|
|
888
|
+
// NO `clearDaemon` HERE, and the asymmetry with `liveDaemon` is the reference's. A
|
|
889
|
+
// record that names no url is left exactly where it is; `web status` on the same file
|
|
890
|
+
// deletes it. Both directions are cells, because a port that unified them would delete
|
|
891
|
+
// a file the reference keeps and stdout would look identical.
|
|
892
|
+
pyPrint('[web] no running server recorded.\n');
|
|
893
|
+
return 0;
|
|
894
|
+
}
|
|
895
|
+
if (await postShutdown(st.url, pyTruthy(st.token) ? st.token : '')) {
|
|
896
|
+
clearDaemon(target);
|
|
897
|
+
pyPrint(`[web] stopped (pid ${none(st.pid)}).\n`);
|
|
898
|
+
return 0;
|
|
899
|
+
}
|
|
900
|
+
clearDaemon(target);
|
|
901
|
+
pyPrint('[web] no live server (cleared a stale record).\n');
|
|
902
|
+
return 0;
|
|
903
|
+
}
|
|
904
|
+
|
|
905
|
+
export async function statusDaemon(theme = null) {
|
|
906
|
+
const { target } = webState(theme);
|
|
907
|
+
const st = await liveDaemon(target);
|
|
908
|
+
if (st) {
|
|
909
|
+
pyPrint(`[web] running on ${st.url} (theme: ${none(st.theme)}, pid ${none(st.pid)})\n`);
|
|
910
|
+
return 0;
|
|
911
|
+
}
|
|
912
|
+
// EXIT 1, which is the verb's whole contract for a script. The snapshot's `<exit>`
|
|
913
|
+
// column is what gates it.
|
|
914
|
+
pyPrint('[web] not running.\n');
|
|
915
|
+
return 1;
|
|
916
|
+
}
|
|
917
|
+
|
|
918
|
+
export async function restartDaemon(theme = null, port = 4747, openBrowser = true,
|
|
919
|
+
onlyIfRunning = false) {
|
|
920
|
+
const { target } = webState(theme);
|
|
921
|
+
const st = readDaemon(target);
|
|
922
|
+
const live = (await liveDaemon(target)) !== null;
|
|
923
|
+
if (onlyIfRunning && !live) return 0;
|
|
924
|
+
const usePort = (st && pyTruthy(st.port) ? st.port : null) || port;
|
|
925
|
+
let open = openBrowser;
|
|
926
|
+
if (live) {
|
|
927
|
+
// A live daemon means a tab is already open on this (preserved) port and will
|
|
928
|
+
// reconnect on its own — the reference's `ponytail:` note, kept.
|
|
929
|
+
open = false;
|
|
930
|
+
await stopDaemon(theme);
|
|
931
|
+
for (let i = 0; i < 50; i += 1) {
|
|
932
|
+
// eslint-disable-next-line no-await-in-loop
|
|
933
|
+
if (!await probe(`http://127.0.0.1:${usePort}`, 0.2)) break;
|
|
934
|
+
// eslint-disable-next-line no-await-in-loop
|
|
935
|
+
await sleep(100);
|
|
936
|
+
}
|
|
937
|
+
}
|
|
938
|
+
return startDaemon(theme, usePort, open);
|
|
939
|
+
}
|
|
940
|
+
|
|
941
|
+
// ---- serve -----------------------------------------------------------------
|
|
942
|
+
|
|
943
|
+
/**
|
|
944
|
+
* Pure: what `serve()` should do about the UI bundle. Ported ahead of the rest of the
|
|
945
|
+
* verb because it decides whether a server starts at all, and because it is the second
|
|
946
|
+
* interactive prompt in this port — `interactive` is an ARGUMENT, which is what makes it
|
|
947
|
+
* gateable by a corpus rather than only by a cell.
|
|
948
|
+
*
|
|
949
|
+
* P6h GAVE IT BOTH KINDS OF GATE AND FOUND THE CLAIM ABOVE HAD NEVER BEEN PAID. It had a
|
|
950
|
+
* Python unit test (`tests/test_web.py`) and nothing cross-implementation until now: the
|
|
951
|
+
* corpus in `tests/test_pure_function_parity.py` covers all five answers, and two
|
|
952
|
+
* `harness_golden` cells cover the two arms that TERMINATE — which are also the only two
|
|
953
|
+
* that let a `web` cell exist at all, since every other arm of `serve` binds a socket.
|
|
954
|
+
*/
|
|
955
|
+
export function buildPlan(dist, webDir, npm, interactive) {
|
|
956
|
+
if (isFile(join(dist, 'index.html'))) return 'serve';
|
|
957
|
+
if (!isFile(join(webDir, 'package.json'))) return 'no-source';
|
|
958
|
+
if (!npm) return 'no-npm';
|
|
959
|
+
if (!interactive) return 'no-tty';
|
|
960
|
+
return 'ask';
|
|
961
|
+
}
|
|
962
|
+
|
|
963
|
+
/**
|
|
964
|
+
* `npm install` then `npm run build` in `web/`, streams inherited — the third spawn this
|
|
965
|
+
* module declares, and the one NO test reaches.
|
|
966
|
+
*
|
|
967
|
+
* THAT IS A DECLARED PARTITION, not an omission. `web/dist/index.html` is TRACKED (asserted
|
|
968
|
+
* by `tests/test_web_daemon.py` against `git ls-files`, so the claim cannot rot), which
|
|
969
|
+
* means `buildPlan` answers `serve` in every checkout a cell can build and the `ask` arm is
|
|
970
|
+
* reachable only from a partial checkout on an interactive terminal. There is no npm
|
|
971
|
+
* library to call instead; a Node twin that reimplemented `npm install` is not a thing that
|
|
972
|
+
* exists.
|
|
973
|
+
*
|
|
974
|
+
* `shell` ON WINDOWS, and it is required rather than stylistic: `pyWhich('npm')` resolves
|
|
975
|
+
* to `npm.CMD` through PATHEXT, and Node refuses to `spawn` a `.cmd` directly. The path is
|
|
976
|
+
* quoted because Node wraps the whole command in one more pair and `cmd /s` strips only
|
|
977
|
+
* the outermost — `C:\Program Files\…` would otherwise split at the space.
|
|
978
|
+
*/
|
|
979
|
+
export function npmBuild(npm, webDir) {
|
|
980
|
+
const win = process.platform === 'win32';
|
|
981
|
+
for (const step of [['install'], ['run', 'build']]) {
|
|
982
|
+
pyPrint(`[web] npm ${step.join(' ')} ...\n`);
|
|
983
|
+
const r = spawnSync(win ? `"${npm}"` : npm, step, {
|
|
984
|
+
cwd: webDir, stdio: 'inherit', shell: win,
|
|
985
|
+
});
|
|
986
|
+
const code = r.status === null || r.status === undefined ? 1 : r.status;
|
|
987
|
+
if (code) {
|
|
988
|
+
pyPrint(`[web] npm ${step.join(' ')} failed (exit ${code}).\n`);
|
|
989
|
+
return code;
|
|
990
|
+
}
|
|
991
|
+
}
|
|
992
|
+
return 0;
|
|
993
|
+
}
|
|
994
|
+
|
|
995
|
+
export async function serve({ theme = null, port = 4747, openBrowser = true,
|
|
996
|
+
daemon = false } = {}) {
|
|
997
|
+
const dist = join(ROOT, 'web', 'dist');
|
|
998
|
+
const webDir = join(ROOT, 'web');
|
|
999
|
+
const manual = ' cd web && npm install && npm run build';
|
|
1000
|
+
// `sys.stdin.isatty()`. `process.stdin.isTTY` is `undefined` rather than false on a pipe,
|
|
1001
|
+
// which is the same trap `cmdSetup` documents.
|
|
1002
|
+
const plan = buildPlan(dist, webDir, pyWhich('npm'), Boolean(process.stdin.isTTY));
|
|
1003
|
+
if (plan === 'no-source') {
|
|
1004
|
+
pyPrint(`[web] web/ sources are missing from ${ROOT}.\n`);
|
|
1005
|
+
pyPrint(' Run `geneseed upgrade` to fetch them (twice on installs whose\n');
|
|
1006
|
+
pyPrint(' updater predates web/ in the sync list).\n');
|
|
1007
|
+
return 1;
|
|
1008
|
+
}
|
|
1009
|
+
if (plan === 'no-npm') {
|
|
1010
|
+
pyPrint('[web] web/dist is missing and npm was not found. Install Node.js,\n');
|
|
1011
|
+
pyPrint(' then build the UI:\n');
|
|
1012
|
+
pyPrint(`${manual}\n`);
|
|
1013
|
+
return 1;
|
|
1014
|
+
}
|
|
1015
|
+
if (plan === 'no-tty') {
|
|
1016
|
+
pyPrint('[web] web/dist is missing. Build the UI first:\n');
|
|
1017
|
+
pyPrint(`${manual}\n`);
|
|
1018
|
+
return 1;
|
|
1019
|
+
}
|
|
1020
|
+
if (plan === 'ask') {
|
|
1021
|
+
// `except (EOFError, KeyboardInterrupt): answer = "n"`. EOF is the arm that matters and
|
|
1022
|
+
// it is the one a naive port gets backwards: `input()` RAISES at EOF, so the reference
|
|
1023
|
+
// reads it as "no", while a read that returned `''` would fall into the empty-answer
|
|
1024
|
+
// arm and start an npm install nobody asked for. `promptLine` returns null for exactly
|
|
1025
|
+
// that case — see its docblock in js/setup.mjs.
|
|
1026
|
+
const line = promptLine('[web] UI not built — run npm install && npm run build now? [Y/n] ');
|
|
1027
|
+
const answer = line === null ? 'n' : line;
|
|
1028
|
+
if (['', 'y', 'yes'].includes(answer.trim().toLowerCase())) {
|
|
1029
|
+
const code = npmBuild(pyWhich('npm'), webDir);
|
|
1030
|
+
if (code) return code;
|
|
1031
|
+
} else {
|
|
1032
|
+
pyPrint('[web] skipped. Build the UI manually:\n');
|
|
1033
|
+
pyPrint(`${manual}\n`);
|
|
1034
|
+
return 0;
|
|
1035
|
+
}
|
|
1036
|
+
}
|
|
1037
|
+
const state = webState(theme);
|
|
1038
|
+
if (!existsSync(join(state.target, GLOBAL_MANIFEST))) {
|
|
1039
|
+
pyPrint(`[web] no deployed harness at ${state.target}.\n`);
|
|
1040
|
+
pyPrint(' Run `geneseed setup` first — serving anyway (read-only UI).\n');
|
|
1041
|
+
}
|
|
1042
|
+
const token = randomBytes(24).toString('base64url');
|
|
1043
|
+
const holder = {};
|
|
1044
|
+
// `JobManager(history_path=state.target / ".geneseed-web-runs.json")` — the console's job
|
|
1045
|
+
// list survives a reload and a restart because it is a FILE, not a process's memory.
|
|
1046
|
+
const jm = new JobManager(join(state.target, '.geneseed-web-runs.json'));
|
|
1047
|
+
const srv = createServer(makeHandler(state, jm, token, dist, holder));
|
|
1048
|
+
holder.srv = srv;
|
|
1049
|
+
return new Promise((done) => {
|
|
1050
|
+
srv.on('error', () => {
|
|
1051
|
+
// The reference retries on port 0; the same fallback, one level in.
|
|
1052
|
+
srv.listen(0, '127.0.0.1', () => ready());
|
|
1053
|
+
});
|
|
1054
|
+
srv.listen(port, '127.0.0.1', () => ready());
|
|
1055
|
+
|
|
1056
|
+
function ready() {
|
|
1057
|
+
const hostPort = srv.address().port;
|
|
1058
|
+
const url = `http://127.0.0.1:${hostPort}`;
|
|
1059
|
+
if (daemon) {
|
|
1060
|
+
writeDaemon(state.target, {
|
|
1061
|
+
pid: process.pid, port: hostPort, url,
|
|
1062
|
+
token, theme: state.theme, started: Math.floor(Date.now() / 1000),
|
|
1063
|
+
});
|
|
1064
|
+
}
|
|
1065
|
+
pyPrint(`[web] Geneseed UI on ${url} (theme: ${state.theme})\n`);
|
|
1066
|
+
pyPrint(daemon ? '[web] daemon ready.\n' : '[web] Ctrl-C to stop.\n');
|
|
1067
|
+
if (openBrowser) openUrl(url);
|
|
1068
|
+
// `except KeyboardInterrupt: print("\n[web] stopped.")` around `serve_forever`. Node
|
|
1069
|
+
// kills on SIGINT by default, which would skip the message AND the record cleanup in
|
|
1070
|
+
// the `finally` below — a foreground `--daemon-internal` server stopped with Ctrl-C
|
|
1071
|
+
// would leave a record pointing at a dead port.
|
|
1072
|
+
process.on('SIGINT', () => {
|
|
1073
|
+
pyPrint('\n[web] stopped.\n');
|
|
1074
|
+
srv.close();
|
|
1075
|
+
srv.closeAllConnections();
|
|
1076
|
+
});
|
|
1077
|
+
srv.on('close', () => {
|
|
1078
|
+
if (daemon) {
|
|
1079
|
+
const st = readDaemon(state.target);
|
|
1080
|
+
if (!st || st.pid === process.pid) clearDaemon(state.target);
|
|
1081
|
+
}
|
|
1082
|
+
done(0);
|
|
1083
|
+
});
|
|
1084
|
+
}
|
|
1085
|
+
});
|
|
1086
|
+
}
|
|
1087
|
+
|
|
1088
|
+
/**
|
|
1089
|
+
* `cmd_web` — the verb `bin/geneseed-cli.mjs` dispatches to, and the reason this module is
|
|
1090
|
+
* on the CLI's import graph at all (which is what moved `js/web/jobs.mjs`'s
|
|
1091
|
+
* `_ALLOWED_SPAWNS` row from `entry: "web"` to `entry: "cli"`).
|
|
1092
|
+
*
|
|
1093
|
+
* The reference's `cmd_web` does `import web` INSIDE the function; the ESM equivalent would
|
|
1094
|
+
* be a dynamic `import()`, and it is deliberately NOT used — a static import is what lets
|
|
1095
|
+
* `test_the_cli_reaches_child_process_only_where_it_is_declared` walk the CLI's graph and
|
|
1096
|
+
* find the two spawning modules under `js/web/`. The cost is parsing the web tree on every
|
|
1097
|
+
* CLI invocation; `bin/geneseed-cli.mjs` is user-invoked and carries no latency budget (the
|
|
1098
|
+
* hook entry, which does, is a different binary for exactly this reason).
|
|
1099
|
+
*/
|
|
1100
|
+
export async function cmdWeb(args) {
|
|
1101
|
+
// `--port` is `type=int` on the reference, and `bin/geneseed-cli.mjs`'s `ints` column
|
|
1102
|
+
// refuses a non-integer before this runs — so `pyInt` cannot be null here.
|
|
1103
|
+
const port = args.port === null ? 4747 : pyInt(args.port);
|
|
1104
|
+
const openBrowser = !args.noBrowser;
|
|
1105
|
+
if (args.action === 'start') return startDaemon(args.theme, port, openBrowser);
|
|
1106
|
+
if (args.action === 'stop') return stopDaemon(args.theme);
|
|
1107
|
+
if (args.action === 'restart') return restartDaemon(args.theme, port, openBrowser);
|
|
1108
|
+
if (args.action === 'status') return statusDaemon(args.theme);
|
|
1109
|
+
return serve({ theme: args.theme, port, openBrowser, daemon: args.daemonInternal });
|
|
1110
|
+
}
|
|
1111
|
+
|
|
1112
|
+
// ---- entry -----------------------------------------------------------------
|
|
1113
|
+
//
|
|
1114
|
+
// KEPT AFTER P6h, and it is not a duplicate of the verb above. `tests/web_golden.py` drove
|
|
1115
|
+
// this module directly for all of P6a–P6g, and its 95 cells are what license the MOVE: the
|
|
1116
|
+
// same matrix now runs against `node bin/geneseed-cli.mjs web` as well, and a byte gate
|
|
1117
|
+
// that passed against one command proves nothing about the other until it is re-aimed. The
|
|
1118
|
+
// module entry is also what a debugging session reaches for when the CLI's argument layer
|
|
1119
|
+
// is the thing under suspicion.
|
|
1120
|
+
|
|
1121
|
+
async function main(argv) {
|
|
1122
|
+
let theme = null;
|
|
1123
|
+
let port = 4747;
|
|
1124
|
+
let daemon = false;
|
|
1125
|
+
let openBrowser = true;
|
|
1126
|
+
for (let i = 0; i < argv.length; i += 1) {
|
|
1127
|
+
if (argv[i] === '--theme') { theme = argv[i + 1]; i += 1; } else if (argv[i] === '--port') { port = pyInt(String(argv[i + 1])) ?? 4747; i += 1; } else if (argv[i] === '--daemon-internal') daemon = true;
|
|
1128
|
+
else if (argv[i] === '--no-browser') openBrowser = false;
|
|
1129
|
+
}
|
|
1130
|
+
return serve({ theme, port, openBrowser, daemon });
|
|
1131
|
+
}
|
|
1132
|
+
|
|
1133
|
+
if (process.argv[1] && pyResolve(process.argv[1]) === pyResolve(fileURLToPath(import.meta.url))) {
|
|
1134
|
+
process.exitCode = await main(process.argv.slice(2));
|
|
1135
|
+
}
|