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
package/js/update.mjs
ADDED
|
@@ -0,0 +1,910 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `rituals/_update.py` — self-update: `git pull` the install's own origin, validate, rebuild.
|
|
3
|
+
*
|
|
4
|
+
* The verb this whole port exists for. Every other subcommand can be Python for one more
|
|
5
|
+
* release; `upgrade` is the one operation a "no Python needed" install cannot delegate,
|
|
6
|
+
* because a Node install that shelled out to the Python harness to update itself
|
|
7
|
+
* would have Python as a hard dependency of the only command a user MUST be able to run.
|
|
8
|
+
*
|
|
9
|
+
* WHY THIS MODULE SPAWNS, AND WHY THE ARGUMENT IS NOT THE JOB RUNNER'S. `js/web/jobs.mjs`
|
|
10
|
+
* re-executes this program for ISOLATION — a synchronous verb in the daemon's event loop
|
|
11
|
+
* would freeze the poll that watches it. Here the reason is different and, for once,
|
|
12
|
+
* unarguable: **the code on disk changes halfway through.** `_pull_and_validate`
|
|
13
|
+
* fast-forwards the working tree and then has to ask the PULLED source whether it is sound;
|
|
14
|
+
* `_rebuild_bundle` has to render with the PULLED generator. An in-process call answers with
|
|
15
|
+
* the modules this process loaded before the merge — it would validate the old source, pass,
|
|
16
|
+
* and then render the old source into the bundle, reporting success. That is not a slower
|
|
17
|
+
* version of the right answer, it is the wrong one. So `runDoctor`, `rebuildBundle` and
|
|
18
|
+
* `rebuildInstalls` each start a fresh interpreter over the tree as it now is, exactly as the
|
|
19
|
+
* reference does, and `_ALLOWED_SPAWNS` in `tests/test_hook_cli_parity.py` carries the argv.
|
|
20
|
+
*
|
|
21
|
+
* `git` is the fourth spawn and the plainest: there is no git library in the Node stdlib,
|
|
22
|
+
* and the port will not vendor one. Every git call goes through the `gitRun` seam —
|
|
23
|
+
* which-guarded, credential-redacted, and it never throws.
|
|
24
|
+
*
|
|
25
|
+
* WHAT THE GATE CANNOT SEE, stated rather than implied. `upgrade/*` in
|
|
26
|
+
* `tests/harness_golden.py` runs each arm against a private clone of a bare origin the
|
|
27
|
+
* fixture builds, so no cell can reach a network — which is a safety property and a blind
|
|
28
|
+
* spot at once: `git fetch --progress` over a local path prints NOTHING, so
|
|
29
|
+
* `fetchStreaming`'s reader, its phase filter and its 15s heartbeat are reachable in every
|
|
30
|
+
* cell and varied in none. The phase filter is gated as a corpus in
|
|
31
|
+
* `tests/test_pure_function_parity.py`; the heartbeat and the hard deadline are gated by
|
|
32
|
+
* neither and are declared in the P8a handoff.
|
|
33
|
+
*
|
|
34
|
+
* NOT HERE, DELIBERATELY: `sync_self`, `main` (the launchers' self-heal entry) and
|
|
35
|
+
* `cmd_bootstrap` are P8b's, and `update`'s row in `js/web/jobs.mjs`' `NOT_PORTED_ACTIONS`
|
|
36
|
+
* is P8c's one-line payment. `parseOrigin` is ported in full here even though only `upgrade`
|
|
37
|
+
* needs its fallback, because P8c's docs `about` kind needs the slug half and splitting one
|
|
38
|
+
* pure function across two phases is how a second classifier gets written.
|
|
39
|
+
*/
|
|
40
|
+
import { spawn, spawnSync } from 'node:child_process';
|
|
41
|
+
import { cpSync, existsSync, mkdirSync, rmSync, statSync } from 'node:fs';
|
|
42
|
+
import os from 'node:os';
|
|
43
|
+
import path from 'node:path';
|
|
44
|
+
|
|
45
|
+
import { ROOT } from './checkout.mjs';
|
|
46
|
+
import { exportImprovements, flushExportNotes } from './diff.mjs';
|
|
47
|
+
import { opencodeConfigDir } from './hosts.mjs';
|
|
48
|
+
import {
|
|
49
|
+
PY_SPACE, appendText, copyFile, pyInt, pyPathStr, pyPrint, pyPrintErr, pyStripSpace, pyWhich,
|
|
50
|
+
readText, writeText,
|
|
51
|
+
} from './lib/fs.mjs';
|
|
52
|
+
import { NO_WINDOW } from './lib/proc.mjs';
|
|
53
|
+
import { restartDaemon } from './web/server.mjs';
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* `_CREDS_RE` / `_redact_url_creds` — strip a `user[:token]@` userinfo out of any URL in
|
|
57
|
+
* `text` so a tokened remote never reaches a log line or an HTTP response.
|
|
58
|
+
*
|
|
59
|
+
* `PY_SPACE` rather than `\s`: the class is NEGATED, so the two definitions disagree about
|
|
60
|
+
* whether a `\uFEFF` or a `\u001c` ends the userinfo. Unreachable through a git remote and
|
|
61
|
+
* spelled correctly anyway, for the reason `js/lib/fs.mjs` gives — an approximated
|
|
62
|
+
* primitive is the one that is wrong the day something reaches it.
|
|
63
|
+
*/
|
|
64
|
+
const CREDS_RE = new RegExp(`(://)[^/@${PY_SPACE}]+@`, 'g');
|
|
65
|
+
|
|
66
|
+
export function redactUrlCreds(text) {
|
|
67
|
+
return (text || '').replace(CREDS_RE, '$1');
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// `_NO_WINDOW` was defined here and PRIVATE to this module, which is how `js/doctor.mjs`
|
|
71
|
+
// and `js/hooks.mjs` came to spawn without it — see `js/lib/proc.mjs` for the flag, the
|
|
72
|
+
// capturing-only rule behind it, and why a miss here is invisible to every byte gate.
|
|
73
|
+
|
|
74
|
+
/** `subprocess.run(..., capture_output=True, encoding="utf-8")` reads in UNIVERSAL NEWLINES. */
|
|
75
|
+
function universal(s) {
|
|
76
|
+
return (s || '').replaceAll('\r\n', '\n').replaceAll('\r', '\n');
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export const DEFAULT_ORIGIN = { url: 'https://github.com/Arylmera/Geneseed',
|
|
80
|
+
githubSlug: 'Arylmera/Geneseed' };
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* `_git(*args, timeout=10, network=False)` — `git -C ROOT <args>` per the shared contract:
|
|
84
|
+
* which-guarded, no-window, stripped + credential-redacted, NEVER raises. Returns
|
|
85
|
+
* `[rc, out, err]`; `rc` is null when git is absent or the spawn failed.
|
|
86
|
+
*
|
|
87
|
+
* `pyWhich` and not a bare `'git'`: the reference answers `(None, "", "")` — which
|
|
88
|
+
* `preflight` reports as "git is not installed or not on PATH" — when the lookup misses,
|
|
89
|
+
* and a port that let the OS resolve the name would get an ENOENT instead and have to guess
|
|
90
|
+
* which of the two it was.
|
|
91
|
+
*
|
|
92
|
+
* `maxBuffer` is raised because Python has no such limit; the default 1 MB is reachable by
|
|
93
|
+
* `git status --porcelain` in a large dirty tree and a truncated read there is silently
|
|
94
|
+
* "clean enough".
|
|
95
|
+
*/
|
|
96
|
+
export function gitRun(args, { timeout = 10, network = false } = {}) {
|
|
97
|
+
const exe = pyWhich('git');
|
|
98
|
+
if (!exe) return [null, '', ''];
|
|
99
|
+
const cmd = ['-C', String(ROOT)];
|
|
100
|
+
let env;
|
|
101
|
+
if (network) {
|
|
102
|
+
env = { ...process.env, GIT_TERMINAL_PROMPT: '0' };
|
|
103
|
+
cmd.push('-c', 'http.lowSpeedLimit=1000', '-c', 'http.lowSpeedTime=15');
|
|
104
|
+
}
|
|
105
|
+
cmd.push(...args.map(String));
|
|
106
|
+
const r = spawnSync(exe, cmd, {
|
|
107
|
+
encoding: 'utf8', timeout: timeout * 1000, env, maxBuffer: 64 * 1024 * 1024, ...NO_WINDOW,
|
|
108
|
+
});
|
|
109
|
+
// `except Exception` — a spawn error, an OS error, or the timeout Python raises on. A
|
|
110
|
+
// signal-killed git has `status === null` too and Python would report a negative code for
|
|
111
|
+
// it; no call site here distinguishes the two, and treating it as "git did not answer" is
|
|
112
|
+
// the arm the reference reaches for every other failure.
|
|
113
|
+
if (r.error || r.status === null) return [null, '', ''];
|
|
114
|
+
return [r.status,
|
|
115
|
+
redactUrlCreds(pyStripSpace(universal(r.stdout))),
|
|
116
|
+
redactUrlCreds(pyStripSpace(universal(r.stderr)))];
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* `urlsplit(o)`, reduced to the two fields `parseOrigin` reads.
|
|
121
|
+
*
|
|
122
|
+
* `new URL()` is NOT this: it throws on `C:\Users\me\origin.git` (a fixture's remote, and a
|
|
123
|
+
* plain relative remote on any machine) where `urlsplit` answers with a scheme of `c` and no
|
|
124
|
+
* host at all, which is what sends `parseOrigin` to its DEFAULT_ORIGIN fallback. Reproduced
|
|
125
|
+
* rather than approximated because the fallback is the arm every local checkout takes.
|
|
126
|
+
*/
|
|
127
|
+
function urlsplitHostPath(o) {
|
|
128
|
+
let rest = o.split('#', 1)[0];
|
|
129
|
+
rest = rest.split('?', 1)[0];
|
|
130
|
+
// A scheme is `[A-Za-z][A-Za-z0-9+.-]*` before the first `:` — and only then. `urlsplit`
|
|
131
|
+
// leaves `foo:bar` alone when `foo` is not a legal scheme, which is what keeps an scp-form
|
|
132
|
+
// remote out of this branch (the caller checks for that shape first anyway).
|
|
133
|
+
const m = /^([A-Za-z][A-Za-z0-9+.\-]*):(.*)$/s.exec(rest);
|
|
134
|
+
if (m) rest = m[2];
|
|
135
|
+
let netloc = '';
|
|
136
|
+
if (rest.startsWith('//')) {
|
|
137
|
+
const after = rest.slice(2);
|
|
138
|
+
const cut = after.search(/[/?#]/);
|
|
139
|
+
netloc = cut === -1 ? after : after.slice(0, cut);
|
|
140
|
+
rest = cut === -1 ? '' : after.slice(cut);
|
|
141
|
+
}
|
|
142
|
+
// `.hostname`: userinfo dropped at the LAST `@`, port dropped at the last `:` outside
|
|
143
|
+
// brackets, IPv6 brackets stripped, lowercased.
|
|
144
|
+
let host = netloc.includes('@') ? netloc.slice(netloc.lastIndexOf('@') + 1) : netloc;
|
|
145
|
+
if (host.startsWith('[')) host = host.slice(1, host.indexOf(']') === -1 ? undefined
|
|
146
|
+
: host.indexOf(']'));
|
|
147
|
+
else if (host.includes(':')) host = host.slice(0, host.indexOf(':'));
|
|
148
|
+
return { host: host.toLowerCase(), path: rest };
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* `_parse_origin(origin)` — `(browser url, github_slug)` from a git remote URL of any scheme.
|
|
153
|
+
* Userinfo and port dropped from the url; slug set only for a two-segment github.com path.
|
|
154
|
+
*/
|
|
155
|
+
export function parseOrigin(origin) {
|
|
156
|
+
const o = pyStripSpace(origin || '');
|
|
157
|
+
let host = '';
|
|
158
|
+
let p = '';
|
|
159
|
+
if (!o.includes('://') && o.includes('@') && o.split('@').slice(1).join('@').includes(':')) {
|
|
160
|
+
// scp-form `git@host:owner/repo`. `split("@", 1)[1]` keeps everything after the FIRST
|
|
161
|
+
// `@`, and `split(":", 1)` everything after the first colon.
|
|
162
|
+
const afterAt = o.slice(o.indexOf('@') + 1);
|
|
163
|
+
host = afterAt.slice(0, afterAt.indexOf(':'));
|
|
164
|
+
p = afterAt.slice(afterAt.indexOf(':') + 1);
|
|
165
|
+
} else {
|
|
166
|
+
const u = urlsplitHostPath(o);
|
|
167
|
+
host = u.host;
|
|
168
|
+
p = u.path;
|
|
169
|
+
}
|
|
170
|
+
host = host.toLowerCase();
|
|
171
|
+
p = p.replace(/^\/+/, '').replace(/\/+$/, '');
|
|
172
|
+
if (p.toLowerCase().endsWith('.git')) p = p.slice(0, -4);
|
|
173
|
+
if (!(host && p)) return DEFAULT_ORIGIN;
|
|
174
|
+
const url = `https://${host}/${p}`;
|
|
175
|
+
let slug = null;
|
|
176
|
+
if (host === 'github.com') {
|
|
177
|
+
const segs = p.split('/').filter(Boolean);
|
|
178
|
+
if (segs.length === 2) slug = segs.join('/');
|
|
179
|
+
}
|
|
180
|
+
return { url, githubSlug: slug };
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/** `_origin_display()` — the install's origin as a display record, or DEFAULT_ORIGIN. */
|
|
184
|
+
export function originDisplay() {
|
|
185
|
+
const [rc, out] = gitRun(['remote', 'get-url', 'origin']);
|
|
186
|
+
if (rc !== 0 || !out) return DEFAULT_ORIGIN;
|
|
187
|
+
return parseOrigin(out);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
const PRE_MSG = {
|
|
191
|
+
no_git_exe: ['info', 'git is not installed or not on PATH — install git to enable updates.'],
|
|
192
|
+
// Names BOTH routes rather than detecting which one this is. Since the npm package, the
|
|
193
|
+
// commonest way to reach this arm is an `npm i -g geneseed` install, for which "re-clone
|
|
194
|
+
// it with git" was exactly the wrong instruction.
|
|
195
|
+
not_git: ['info', "This Geneseed install isn't a git checkout — update it with `npm install -g geneseed@latest`, or re-clone it with git."],
|
|
196
|
+
detached: ['info', 'HEAD is detached (a tag/commit is checked out). Run `git checkout <branch>` to re-enable updates.'],
|
|
197
|
+
no_upstream: ['info', 'Your branch has no upstream — set one with `git branch --set-upstream-to`.'],
|
|
198
|
+
dirty: ['info', 'You have local changes in the Geneseed folder. Commit or stash them, then update.'],
|
|
199
|
+
ready: ['info', ''],
|
|
200
|
+
};
|
|
201
|
+
|
|
202
|
+
function pre(code) {
|
|
203
|
+
const [kind, message] = PRE_MSG[code];
|
|
204
|
+
return { ok: code === 'ready', code, kind, message };
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/** `_preflight()` — Phase A, local only, no network. Never raises. */
|
|
208
|
+
export function preflight() {
|
|
209
|
+
let [rc, out] = gitRun(['rev-parse', '--is-inside-work-tree']);
|
|
210
|
+
if (rc === null) return pre('no_git_exe');
|
|
211
|
+
if (rc !== 0 || out !== 'true') return pre('not_git');
|
|
212
|
+
[rc, out] = gitRun(['symbolic-ref', '-q', 'HEAD']);
|
|
213
|
+
if (rc !== 0 || !out) return pre('detached');
|
|
214
|
+
[rc] = gitRun(['rev-parse', '--abbrev-ref', '--symbolic-full-name', '@{u}']);
|
|
215
|
+
if (rc !== 0) return pre('no_upstream');
|
|
216
|
+
[rc, out] = gitRun(['-c', 'core.fileMode=false', '-c', 'core.autocrlf=false',
|
|
217
|
+
'status', '--porcelain', '--untracked-files=no']);
|
|
218
|
+
if (rc !== 0) return pre('not_git');
|
|
219
|
+
if (out) return pre('dirty');
|
|
220
|
+
return pre('ready');
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/** `_fetch_timeout()`. `pyInt` is null where `int()` raises, which is the `except` arm. */
|
|
224
|
+
export function fetchTimeout() {
|
|
225
|
+
const n = pyInt(process.env.GENESEED_NET_TIMEOUT ?? '120');
|
|
226
|
+
if (n === null) return 120;
|
|
227
|
+
return Math.max(30, n);
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* `_count(s)` — `int(s) if s.isdigit() else 0`.
|
|
232
|
+
*
|
|
233
|
+
* ASCII-only on purpose. `str.isdigit()` is true for `'٣'` and `'²'`, on which `int()` then
|
|
234
|
+
* RAISES — a reference crash no caller could survive, and one no git output can produce
|
|
235
|
+
* (`rev-list --count` prints ASCII). Reproducing the crash would be reproducing a bug; the
|
|
236
|
+
* divergence is confined to input git cannot emit and is recorded rather than matched.
|
|
237
|
+
*/
|
|
238
|
+
export function pyCount(s) {
|
|
239
|
+
const t = pyStripSpace(s || '');
|
|
240
|
+
return /^[0-9]+$/.test(t) ? Number(t) : 0;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* `_kill_tree(p)` — kill a fetch AND its helpers (git-remote-https). Killing only `git`
|
|
245
|
+
* leaves the helper holding the output pipe (and `.git` lock files on Windows), which is
|
|
246
|
+
* how a timed-out fetch used to hang the updater forever.
|
|
247
|
+
*/
|
|
248
|
+
export function killTree(child) {
|
|
249
|
+
try {
|
|
250
|
+
if (process.platform !== 'win32') process.kill(-child.pid, 'SIGKILL');
|
|
251
|
+
else spawnSync('taskkill', ['/F', '/T', '/PID', String(child.pid)],
|
|
252
|
+
{ encoding: 'utf8', timeout: 15000, ...NO_WINDOW });
|
|
253
|
+
} catch {
|
|
254
|
+
try { child.kill(); } catch { /* already gone */ }
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/**
|
|
259
|
+
* `_reader`'s phase filter — log a progress line only when its PHASE changes.
|
|
260
|
+
*
|
|
261
|
+
* Pure, and exported for the corpus: git repaints `Receiving objects: 43%` hundreds of
|
|
262
|
+
* times and universal-newline decoding turns every `\r` repaint into its own line, so
|
|
263
|
+
* without this a 30-second fetch writes a thousand lines into the install log. No cell can
|
|
264
|
+
* reach it — a local-path fetch prints nothing at all — so
|
|
265
|
+
* `tests/test_pure_function_parity.py` runs it over a recorded fetch transcript instead.
|
|
266
|
+
*
|
|
267
|
+
* Returns `[linesToLog, lastPhase]`, splitting the reference's loop from its side effects.
|
|
268
|
+
*/
|
|
269
|
+
export function fetchPhases(rawLines, last = '') {
|
|
270
|
+
const out = [];
|
|
271
|
+
for (const raw of rawLines) {
|
|
272
|
+
const line = redactUrlCreds(pyStripSpace(raw));
|
|
273
|
+
if (!line) continue;
|
|
274
|
+
const phase = line.split(':')[0];
|
|
275
|
+
if (phase !== last) {
|
|
276
|
+
out.push(line);
|
|
277
|
+
last = phase;
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
return [out, last];
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/**
|
|
284
|
+
* `_fetch_streaming(log)` — `git fetch --progress` with live output, a heartbeat, and a HARD
|
|
285
|
+
* deadline. Returns `[rc, tail]`; `rc` null on spawn failure or timeout.
|
|
286
|
+
*
|
|
287
|
+
* ASYNCHRONOUS where the reference is a thread plus a poll loop, and that is the honest
|
|
288
|
+
* translation rather than a shortcut: Python needs the reader thread because `for raw in
|
|
289
|
+
* p.stdout` blocks, and the 0.25s poll exists to give the deadline somewhere to live. Node's
|
|
290
|
+
* stream is already event-driven, so the same three behaviours — stream, beat, deadline —
|
|
291
|
+
* are a `data` handler and two timers, and `bin/geneseed-cli.mjs` has awaited its verbs
|
|
292
|
+
* since P6h.
|
|
293
|
+
*/
|
|
294
|
+
export async function fetchStreaming(log = null) {
|
|
295
|
+
const exe = pyWhich('git');
|
|
296
|
+
if (!exe) return [null, 'git is not installed or not on PATH'];
|
|
297
|
+
const timeout = fetchTimeout();
|
|
298
|
+
const args = ['-C', String(ROOT),
|
|
299
|
+
'-c', 'http.lowSpeedLimit=1000', '-c', 'http.lowSpeedTime=15',
|
|
300
|
+
'fetch', '--progress'];
|
|
301
|
+
let child;
|
|
302
|
+
try {
|
|
303
|
+
child = spawn(exe, args, {
|
|
304
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
305
|
+
// `start_new_session=True` on POSIX — the process GROUP `killTree` signals.
|
|
306
|
+
detached: process.platform !== 'win32',
|
|
307
|
+
env: { ...process.env, GIT_TERMINAL_PROMPT: '0' },
|
|
308
|
+
...NO_WINDOW,
|
|
309
|
+
});
|
|
310
|
+
} catch (e) {
|
|
311
|
+
return [null, String(e && e.message ? e.message : e)];
|
|
312
|
+
}
|
|
313
|
+
const lines = [];
|
|
314
|
+
let last = '';
|
|
315
|
+
let pending = '';
|
|
316
|
+
const feed = (chunk) => {
|
|
317
|
+
// Python reads this pipe in TEXT mode, so `\r` — git's progress repaint — is a line
|
|
318
|
+
// break as much as `\n` is. A port that split on `\n` alone would hand one enormous
|
|
319
|
+
// line to the phase filter and log the whole fetch as a single entry.
|
|
320
|
+
pending += chunk;
|
|
321
|
+
const parts = pending.split(/\r\n|\n|\r/);
|
|
322
|
+
pending = parts.pop();
|
|
323
|
+
const [emit, next] = fetchPhases(parts, last);
|
|
324
|
+
last = next;
|
|
325
|
+
for (const p of parts) {
|
|
326
|
+
const s = redactUrlCreds(pyStripSpace(p));
|
|
327
|
+
if (s) lines.push(s);
|
|
328
|
+
}
|
|
329
|
+
if (log) for (const l of emit) log(`[geneseed] ${l}`);
|
|
330
|
+
};
|
|
331
|
+
// `stderr=subprocess.STDOUT`: git writes its progress to stderr and the reference merges
|
|
332
|
+
// the two into one ordered stream before reading.
|
|
333
|
+
child.stdout.setEncoding('utf8');
|
|
334
|
+
child.stderr.setEncoding('utf8');
|
|
335
|
+
child.stdout.on('data', feed);
|
|
336
|
+
child.stderr.on('data', feed);
|
|
337
|
+
|
|
338
|
+
const start = Date.now();
|
|
339
|
+
let nextBeat = 15000;
|
|
340
|
+
let timedOut = false;
|
|
341
|
+
const beat = setInterval(() => {
|
|
342
|
+
const elapsed = Date.now() - start;
|
|
343
|
+
if (elapsed >= timeout * 1000) {
|
|
344
|
+
timedOut = true;
|
|
345
|
+
killTree(child);
|
|
346
|
+
return;
|
|
347
|
+
}
|
|
348
|
+
if (log && elapsed >= nextBeat) {
|
|
349
|
+
log(`[geneseed] ... still fetching (${Math.floor(elapsed / 1000)}s elapsed)`);
|
|
350
|
+
nextBeat += 15000;
|
|
351
|
+
}
|
|
352
|
+
}, 250);
|
|
353
|
+
const status = await new Promise((resolve) => {
|
|
354
|
+
child.on('error', () => resolve(null));
|
|
355
|
+
child.on('close', (code) => resolve(code));
|
|
356
|
+
});
|
|
357
|
+
clearInterval(beat);
|
|
358
|
+
if (pending) feed('\n');
|
|
359
|
+
const tail = lines.slice(-5).join('\n');
|
|
360
|
+
if (timedOut) {
|
|
361
|
+
if (log) log(`[geneseed] ✗ fetch produced nothing for ${timeout}s — killed it.`);
|
|
362
|
+
return [null, tail];
|
|
363
|
+
}
|
|
364
|
+
return [status, tail];
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
/**
|
|
368
|
+
* `_measure_upstream(log)` — Phase B: fetch (streamed), then classify. Returns
|
|
369
|
+
* `[code, behind, err]` with `code` ∈ {ready, fetch_failed, unrelated, diverged, uptodate}.
|
|
370
|
+
*/
|
|
371
|
+
export async function measureUpstream(log = null) {
|
|
372
|
+
const [rc, err] = await fetchStreaming(log);
|
|
373
|
+
if (rc !== 0) return ['fetch_failed', 0, err];
|
|
374
|
+
const [, aheadRaw] = gitRun(['rev-list', '--count', '@{u}..HEAD']);
|
|
375
|
+
const [, behindRaw] = gitRun(['rev-list', '--count', 'HEAD..@{u}']);
|
|
376
|
+
const ahead = pyCount(aheadRaw);
|
|
377
|
+
const behind = pyCount(behindRaw);
|
|
378
|
+
if (ahead > 0) {
|
|
379
|
+
const [mrc] = gitRun(['merge-base', 'HEAD', '@{u}']);
|
|
380
|
+
return [mrc === 0 ? 'diverged' : 'unrelated', 0, ''];
|
|
381
|
+
}
|
|
382
|
+
if (behind === 0) return ['uptodate', 0, ''];
|
|
383
|
+
return ['ready', behind, ''];
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
export const DOCTOR_LEGEND = [
|
|
387
|
+
'[geneseed] doctor problem legend — what the lines above mean / how to fix:',
|
|
388
|
+
" • 'dead link' → a skill/agent body links a sibling as <dir>/<name>.md; use the BARE <name>.md (source bug)",
|
|
389
|
+
" • 'unresolved token' → a {{TOKEN}} is missing from a theme; add it to ALL theme JSONs",
|
|
390
|
+
" • 'incomplete source' → AGENT.md lists a skill whose file isn't in this snapshot (usually a mid-publish cache — retry)",
|
|
391
|
+
" • 'stale' / 'missing' → the rendered Harness/ is out of sync (rebuild locally; harmless on a fresh clone)",
|
|
392
|
+
" • 'parity' → the themes disagree on which tokens exist",
|
|
393
|
+
" • 'escapes the bundle' → an absolute or ../ path leaked into a rendered file",
|
|
394
|
+
];
|
|
395
|
+
|
|
396
|
+
/**
|
|
397
|
+
* `_run_doctor(cand)` — validate a source tree with its OWN `doctor --all --no-bundle`.
|
|
398
|
+
* Fail-closed: any nonzero exit, timeout or spawn error is a failure.
|
|
399
|
+
*
|
|
400
|
+
* THE SPAWN THAT CANNOT BE AN IMPORT. `cand` is the tree as it exists AFTER the merge, and
|
|
401
|
+
* this process's module graph was loaded before it. `cmdDoctor` in-process would validate the
|
|
402
|
+
* source this program started with and pass, which is the one answer that must never be
|
|
403
|
+
* given here — the gate exists to catch a bad upstream commit.
|
|
404
|
+
*/
|
|
405
|
+
export function runDoctor(cand) {
|
|
406
|
+
const r = spawnSync(process.execPath,
|
|
407
|
+
[path.join(String(cand), 'bin', 'geneseed-cli.mjs'), 'doctor', '--all', '--no-bundle'],
|
|
408
|
+
{ encoding: 'utf8', timeout: 300 * 1000, maxBuffer: 256 * 1024 * 1024, ...NO_WINDOW });
|
|
409
|
+
if (r.error || r.status === null) {
|
|
410
|
+
return [false, `[geneseed] doctor gate could not run: ${r.error || 'no exit status'}`];
|
|
411
|
+
}
|
|
412
|
+
// `stderr=subprocess.STDOUT` — the reference gives the child ONE handle, so its two
|
|
413
|
+
// streams are interleaved in write order and arrive as `proc.stdout`. `spawnSync` has no
|
|
414
|
+
// way to hand two descriptors the same pipe, so the two are concatenated instead: equal
|
|
415
|
+
// whenever the child writes to only one of them, which `doctor` does (`js/doctor.mjs`
|
|
416
|
+
// reports every problem through `pyPrint`), and the reason the two doctor cells below are
|
|
417
|
+
// the gate on this rather than a comment. If a future check reports on stderr the
|
|
418
|
+
// ORDERING here is what would differ, not the content.
|
|
419
|
+
return [r.status === 0, universal(r.stdout) + universal(r.stderr)];
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
/** `_pull_and_validate(log)` — fast-forward to `@{u}`, then doctor-gate with exact rollback. */
|
|
423
|
+
export function pullAndValidate(log) {
|
|
424
|
+
const [rc0, old] = gitRun(['rev-parse', 'HEAD']);
|
|
425
|
+
if (rc0 !== 0 || !old) return [false, 'not_git', 'could not read HEAD'];
|
|
426
|
+
log('[geneseed] fast-forwarding to upstream ...');
|
|
427
|
+
const [rc1, , err] = gitRun(['merge', '--ff-only', '@{u}'], { timeout: 60 });
|
|
428
|
+
if (rc1 !== 0) {
|
|
429
|
+
return [false, 'collision',
|
|
430
|
+
'Update blocked — a new upstream file collides with a local untracked '
|
|
431
|
+
+ 'file. Move or remove it, then update.\n' + err];
|
|
432
|
+
}
|
|
433
|
+
const [rc2, pulled] = gitRun(['log', '--oneline', '--no-decorate', '-20', `${old}..HEAD`]);
|
|
434
|
+
if (rc2 === 0 && pulled) {
|
|
435
|
+
log('[geneseed] pulled:');
|
|
436
|
+
for (const line of pulled.split('\n')) log(` ${line}`);
|
|
437
|
+
}
|
|
438
|
+
log('[geneseed] validating the pulled source (doctor — can take a minute) ...');
|
|
439
|
+
const [passed, output] = runDoctor(ROOT);
|
|
440
|
+
log(output.replace(/\n+$/, ''));
|
|
441
|
+
if (!passed) {
|
|
442
|
+
gitRun(['reset', '--hard', old], { timeout: 60 });
|
|
443
|
+
for (const line of DOCTOR_LEGEND) log(line);
|
|
444
|
+
return [false, 'doctor_fail',
|
|
445
|
+
'the pulled source FAILS validation — rolled back to the previous commit. '
|
|
446
|
+
+ 'Fix the problems listed above.'];
|
|
447
|
+
}
|
|
448
|
+
return [true, 'ready', ''];
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
/** `_logfile()` — persistent install log, overridable with `$GENESEED_LOG`. */
|
|
452
|
+
export function logfile() {
|
|
453
|
+
const env = process.env.GENESEED_LOG;
|
|
454
|
+
// `Path(env)`, stringified with the platform separator when `upgrade` prints it — the same
|
|
455
|
+
// rule `GENESEED_OUT` needed. No cell can set this (`cell_env` clears every `GENESEED_*`
|
|
456
|
+
// and no upgrade cell re-adds it), so it is spelled from the rule rather than from a gate.
|
|
457
|
+
if (env) return pyPathStr(env);
|
|
458
|
+
return path.join(os.homedir(), '.geneseed-install.log');
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
/** `_Log` — print (flushed) and append, with a temp-dir fallback when `$HOME` is read-only. */
|
|
462
|
+
export class Log {
|
|
463
|
+
constructor() {
|
|
464
|
+
this.path = logfile();
|
|
465
|
+
try {
|
|
466
|
+
writeText(this.path, ''); // truncate
|
|
467
|
+
} catch {
|
|
468
|
+
this.path = path.join(os.tmpdir(), 'geneseed-install.log');
|
|
469
|
+
try { writeText(this.path, ''); } catch { this.path = null; }
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
call(msg) {
|
|
474
|
+
pyPrint(`${msg}\n`);
|
|
475
|
+
if (this.path !== null) {
|
|
476
|
+
try { appendText(this.path, `${msg}\n`); } catch { /* OSError: pass */ }
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
/** `_resolve_emit` — `$GENESEED_EMIT` > global-config marker > bundle marker > files. */
|
|
482
|
+
export function resolveEmit(cfg, out) {
|
|
483
|
+
const env = process.env.GENESEED_EMIT;
|
|
484
|
+
if (env) return env;
|
|
485
|
+
for (const marker of [path.join(String(cfg), '.geneseed-emit'),
|
|
486
|
+
path.join(String(out), '.geneseed-emit')]) {
|
|
487
|
+
try {
|
|
488
|
+
const val = pyStripSpace(readText(marker));
|
|
489
|
+
if (val) return val;
|
|
490
|
+
} catch { /* OSError: pass */ }
|
|
491
|
+
}
|
|
492
|
+
return 'files';
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
/** `_marker_theme` — global-config marker > bundle marker > "" (none). */
|
|
496
|
+
export function markerTheme(cfg, out) {
|
|
497
|
+
for (const marker of [path.join(String(cfg), '.geneseed-theme'),
|
|
498
|
+
path.join(String(out), '.geneseed-theme')]) {
|
|
499
|
+
try {
|
|
500
|
+
const val = pyStripSpace(readText(marker));
|
|
501
|
+
if (val) return val;
|
|
502
|
+
} catch { /* OSError: pass */ }
|
|
503
|
+
}
|
|
504
|
+
return '';
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
/**
|
|
508
|
+
* `_config_theme(here)` — the theme the LOCAL `harness.config.json` asks for, captured
|
|
509
|
+
* before SYNC overwrites it with upstream's. Fallback only; a bundle marker still wins.
|
|
510
|
+
*/
|
|
511
|
+
export function configTheme(here) {
|
|
512
|
+
try {
|
|
513
|
+
const data = JSON.parse(readText(path.join(String(here), 'harness.config.json')));
|
|
514
|
+
return String(data.theme || '');
|
|
515
|
+
} catch { return ''; }
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
/**
|
|
519
|
+
* `_migrate_stray_bundle` — move host state (context.json, memory/) from an OLD in-folder
|
|
520
|
+
* bundle to the canonical sibling `out` BEFORE rebuilding, then drop the stray.
|
|
521
|
+
*/
|
|
522
|
+
export function migrateStrayBundle(here, out, log) {
|
|
523
|
+
const stray = path.join(String(here), 'Harness');
|
|
524
|
+
const outStr = String(out);
|
|
525
|
+
if (outStr === stray) return;
|
|
526
|
+
try { if (!statSync(stray).isDirectory()) return; } catch { return; }
|
|
527
|
+
mkdirSync(outStr, { recursive: true });
|
|
528
|
+
const ctx = path.join(stray, 'context.json');
|
|
529
|
+
if (existsSync(ctx) && statSync(ctx).isFile() && !existsSync(path.join(outStr, 'context.json'))) {
|
|
530
|
+
copyFile(ctx, path.join(outStr, 'context.json'));
|
|
531
|
+
log(`[geneseed] rescued context.json from ${stray} -> ${outStr}`);
|
|
532
|
+
}
|
|
533
|
+
for (const mem of ['memory', 'anamnesis']) {
|
|
534
|
+
const src = path.join(stray, mem);
|
|
535
|
+
if (existsSync(src) && statSync(src).isDirectory() && !existsSync(path.join(outStr, mem))) {
|
|
536
|
+
// `shutil.copytree`, which copies with `copy2` semantics — `preserveTimestamps` is
|
|
537
|
+
// the half that matters (the permission bits are the deliberate deviation
|
|
538
|
+
// `js/lib/fs.mjs`'s `copyFile` already documents for the same reason).
|
|
539
|
+
cpSync(src, path.join(outStr, mem), { recursive: true, preserveTimestamps: true });
|
|
540
|
+
log(`[geneseed] rescued ${mem}/ from ${stray} -> ${outStr}`);
|
|
541
|
+
}
|
|
542
|
+
}
|
|
543
|
+
log(`[geneseed] removing stray in-folder bundle ${stray} (canonical: ${outStr})`);
|
|
544
|
+
rmSync(stray, { recursive: true, force: true });
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
/**
|
|
548
|
+
* `sys.stdout.flush()` before an INHERITING spawn — `_harness_core.run`'s second half (P5f),
|
|
549
|
+
* which this module needs for the same reason and by a different mechanism.
|
|
550
|
+
*
|
|
551
|
+
* Python block-buffers a redirected stdout, so a parent that prints and then spawns a child
|
|
552
|
+
* sharing the handle has its own line arrive last. Node's `process.stdout` is asynchronous
|
|
553
|
+
* for a PIPE on macOS (synchronous on Windows and Linux), so the same reordering is a
|
|
554
|
+
* platform-specific bug here — invisible to every cell on this machine, which is exactly the
|
|
555
|
+
* kind of thing that ships. Awaiting the drain costs one tick and closes it everywhere.
|
|
556
|
+
*/
|
|
557
|
+
async function drainStdout() {
|
|
558
|
+
if (process.stdout.writableLength === 0) return;
|
|
559
|
+
await new Promise((resolve) => { process.stdout.write('', resolve); });
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
/**
|
|
563
|
+
* `_rebuild_bundle` — render the bundle from the (already-updated) source and bounce a
|
|
564
|
+
* running web daemon. Returns the build subprocess returncode (0 = ok).
|
|
565
|
+
*/
|
|
566
|
+
export async function rebuildBundle(here, out, theme, emit, rootDir, log) {
|
|
567
|
+
const buildArgs = ['--out', String(out)];
|
|
568
|
+
if (theme) buildArgs.push('--theme', theme);
|
|
569
|
+
if (emit === 'opencode') buildArgs.push('--emit', 'opencode', '--root', String(rootDir));
|
|
570
|
+
else if (emit === 'opencode-global') buildArgs.push('--emit', 'opencode-global');
|
|
571
|
+
log(`[geneseed] rebuilding bundle -> ${out} (theme: ${theme || 'config default'}, emit: ${emit}) ...`);
|
|
572
|
+
await drainStdout();
|
|
573
|
+
// THE GENERATOR, RE-EXECUTED. `js/generate.mjs`'s `cmdBuild` calls `driverMain` in-process
|
|
574
|
+
// and is right to — nothing has changed under it. Here the source on disk was replaced
|
|
575
|
+
// three statements ago, so this must be a new process reading the new files.
|
|
576
|
+
const proc = spawnSync(process.execPath,
|
|
577
|
+
[path.join(String(here), 'bin', 'geneseed.mjs'), ...buildArgs],
|
|
578
|
+
{ cwd: String(here), stdio: 'inherit' });
|
|
579
|
+
if (proc.status !== 0) return proc.status === null ? 1 : proc.status;
|
|
580
|
+
// If a web daemon is running, bounce it so the new source and the freshly rebuilt web/dist
|
|
581
|
+
// take effect — otherwise the open PWA keeps hitting the old code. EXCEPT when this
|
|
582
|
+
// upgrade IS a web-daemon job: restarting the daemon here kills the process tracking this
|
|
583
|
+
// very job (console stuck on 'running' forever); in that case the server restarts itself
|
|
584
|
+
// once the job is recorded as finished. `js/web/jobs.mjs` sets the variable; this is the
|
|
585
|
+
// side that reads it, and `upgrade/the-web-job-marker-...` is what gates the pair.
|
|
586
|
+
if (process.env.GENESEED_WEB_JOB) {
|
|
587
|
+
log('[geneseed] web daemon will restart itself after this job to load the new code.');
|
|
588
|
+
return 0;
|
|
589
|
+
}
|
|
590
|
+
try {
|
|
591
|
+
await restartDaemon(theme, 4747, false, true);
|
|
592
|
+
} catch (e) { // never fail an upgrade on this
|
|
593
|
+
log(`[geneseed] ⚠️ could not refresh the web daemon (${e && e.message ? e.message : e}) — \`geneseed web restart\` manually if it was running.`);
|
|
594
|
+
}
|
|
595
|
+
return 0;
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
/**
|
|
599
|
+
* `_rebuild_installs` — refresh every registered ACTIVE install via `rebuild-all`. The
|
|
600
|
+
* emit-marker rebuild only covers THIS checkout's own bundle; without this pass a
|
|
601
|
+
* claude-global or bob install keeps serving the OLD render after every upgrade, silently.
|
|
602
|
+
*/
|
|
603
|
+
export async function rebuildInstalls(here, log) {
|
|
604
|
+
log('[geneseed] refreshing every active install (rebuild-all) ...');
|
|
605
|
+
await drainStdout();
|
|
606
|
+
const proc = spawnSync(process.execPath,
|
|
607
|
+
[path.join(String(here), 'bin', 'geneseed-cli.mjs'), 'rebuild-all'],
|
|
608
|
+
{ cwd: String(here), stdio: 'inherit' });
|
|
609
|
+
return proc.status === null ? 1 : proc.status;
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
/**
|
|
613
|
+
* `upgrade(ref, theme_arg, zip_arg)` — update from the install's own git origin
|
|
614
|
+
* (fast-forward only), doctor-gate, and rebuild the bundle plus every registered install.
|
|
615
|
+
* `ref`/`zip_arg` are accepted for back-compat but IGNORED. Returns a process exit code:
|
|
616
|
+
* 0 ok/up-to-date, 3 info precondition, 1 error.
|
|
617
|
+
*/
|
|
618
|
+
export async function upgrade(ref = null, themeArg = null) {
|
|
619
|
+
const logger = new Log();
|
|
620
|
+
const log = (m) => logger.call(m);
|
|
621
|
+
if (ref) {
|
|
622
|
+
log(`[geneseed] ⚠️ ref '${ref}' is IGNORED — updates follow the checkout's `
|
|
623
|
+
+ 'current branch (tag/branch pinning was removed with the zip path).');
|
|
624
|
+
}
|
|
625
|
+
const here = String(ROOT);
|
|
626
|
+
// `Path(os.environ.get(...) or ...)`, and `pyPathStr` is the `Path(str)` half of it: the
|
|
627
|
+
// reference stringifies with the PLATFORM separator, so a `GENESEED_OUT` spelled
|
|
628
|
+
// `C:/x/bundle` — which is how a JSON config, a shell script and this harness's own
|
|
629
|
+
// `{sb/}` placeholder all spell it — reaches the log as `C:\x\bundle`. Six cells differed
|
|
630
|
+
// on exactly that and nothing else.
|
|
631
|
+
const out = pyPathStr(process.env.GENESEED_OUT || path.join(path.dirname(here), 'Harness'));
|
|
632
|
+
const rootDir = pyPathStr(process.env.GENESEED_ROOT || path.dirname(out));
|
|
633
|
+
const cfg = opencodeConfigDir();
|
|
634
|
+
const emit = resolveEmit(cfg, out);
|
|
635
|
+
|
|
636
|
+
// The LOCAL theme, before the pull overwrites harness.config.json.
|
|
637
|
+
const cfgTheme = configTheme(here);
|
|
638
|
+
|
|
639
|
+
const origin = originDisplay();
|
|
640
|
+
const [, branch] = gitRun(['rev-parse', '--abbrev-ref', 'HEAD']);
|
|
641
|
+
log(`[geneseed] update source: ${origin.url}${branch ? ` (branch: ${branch})` : ''}`);
|
|
642
|
+
|
|
643
|
+
log('[geneseed] preflight: checking the local checkout ...');
|
|
644
|
+
const p = preflight();
|
|
645
|
+
if (!p.ok) {
|
|
646
|
+
log(`[geneseed] ${p.message}`);
|
|
647
|
+
return p.kind === 'info' ? 3 : 1;
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
log(`[geneseed] fetching from origin (git: ${pyWhich('git') || 'git'}, `
|
|
651
|
+
+ `timeout: ${fetchTimeout()}s) ...`);
|
|
652
|
+
const [code, behind, err] = await measureUpstream(log);
|
|
653
|
+
if (code === 'fetch_failed') {
|
|
654
|
+
log('[geneseed] ✗ could not reach the remote: '
|
|
655
|
+
+ `${err || 'git fetch hung without any output.'}`);
|
|
656
|
+
log('[geneseed] If `git pull` works in your terminal but not here, this '
|
|
657
|
+
+ 'daemon likely lacks your shell\'s environment (VPN/proxy vars, SSO or '
|
|
658
|
+
+ 'Kerberos credentials). Restart it from that terminal: `geneseed web restart`.');
|
|
659
|
+
return 1;
|
|
660
|
+
}
|
|
661
|
+
if (code === 'unrelated') {
|
|
662
|
+
log('[geneseed] Upstream history was rewritten; back up local work, then re-clone '
|
|
663
|
+
+ 'or `git reset --hard @{u}`.');
|
|
664
|
+
return 3;
|
|
665
|
+
}
|
|
666
|
+
if (code === 'diverged') {
|
|
667
|
+
log("[geneseed] Your branch has local commits and can't fast-forward — push/rebase "
|
|
668
|
+
+ 'or reset first.');
|
|
669
|
+
return 3;
|
|
670
|
+
}
|
|
671
|
+
if (code === 'ready') {
|
|
672
|
+
log(`[geneseed] ${behind} new commit(s) upstream — updating ...`);
|
|
673
|
+
const [ok, fcode, msg] = pullAndValidate(log);
|
|
674
|
+
if (!ok) {
|
|
675
|
+
log(`[geneseed] ${msg}`);
|
|
676
|
+
return fcode === 'collision' ? 3 : 1;
|
|
677
|
+
}
|
|
678
|
+
} else { // uptodate
|
|
679
|
+
log('[geneseed] already up to date.');
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
const theme = themeArg || markerTheme(cfg, out) || cfgTheme;
|
|
683
|
+
// Best-effort rescue, never a gate: a locked file in the stray bundle must not abort the
|
|
684
|
+
// upgrade AFTER the pull has already been applied.
|
|
685
|
+
try {
|
|
686
|
+
migrateStrayBundle(here, out, log);
|
|
687
|
+
} catch (e) {
|
|
688
|
+
log(`[geneseed] ⚠️ could not migrate the old in-folder bundle (${e && e.message ? e.message : e}) — continuing.`);
|
|
689
|
+
}
|
|
690
|
+
let rc = await rebuildBundle(here, out, theme, emit, rootDir, log);
|
|
691
|
+
if (rc !== 0) {
|
|
692
|
+
log(`[geneseed][E-BUILD] ✗ the bundle build FAILED (theme: ${theme || 'default'}, emit: ${emit}).`);
|
|
693
|
+
return 1;
|
|
694
|
+
}
|
|
695
|
+
rc = await rebuildInstalls(here, log);
|
|
696
|
+
if (rc !== 0) {
|
|
697
|
+
log('[geneseed][E-BUILD] ✗ one or more installs failed to rebuild — '
|
|
698
|
+
+ 'run `geneseed rebuild-all` to retry (details above).');
|
|
699
|
+
return 1;
|
|
700
|
+
}
|
|
701
|
+
log(`[geneseed] ✓ upgrade complete.${logger.path ? ` (full log: ${logger.path})` : ''}`);
|
|
702
|
+
return 0;
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
/**
|
|
706
|
+
* `_harness_lifecycle.cmd_upgrade` — the harness verb, which is `export_improvements()` and
|
|
707
|
+
* then `upgrade()`.
|
|
708
|
+
*
|
|
709
|
+
* THE EXPORT RUNS FIRST AND MUST NEVER BLOCK. The self-improvement loops edit the deployed
|
|
710
|
+
* harness in place and the rebuild is about to overwrite those edits, so the drift has to be
|
|
711
|
+
* captured against the PRE-refresh source. Anything it throws is a warning on stderr and the
|
|
712
|
+
* upgrade continues — a failed export is a lost note, a blocked upgrade is a broken install.
|
|
713
|
+
*/
|
|
714
|
+
export async function cmdUpgrade(args) {
|
|
715
|
+
try {
|
|
716
|
+
const [ipath] = exportImprovements();
|
|
717
|
+
if (ipath) {
|
|
718
|
+
pyPrint(`[upgrade] deployed harness carries local edits — saved to ${ipath}\n`);
|
|
719
|
+
pyPrint('[upgrade] hand that file to your agent to back-port them into src/.\n');
|
|
720
|
+
}
|
|
721
|
+
} catch (e) {
|
|
722
|
+
pyPrintErr(`[upgrade] ⚠️ could not export local edits (${e && e.message ? e.message : e}) — `
|
|
723
|
+
+ 'run `geneseed diff --out FILE` before upgrading to keep them.\n');
|
|
724
|
+
}
|
|
725
|
+
let { ref } = args;
|
|
726
|
+
let theme = args.theme;
|
|
727
|
+
// Back-compat: the legacy hidden `ref` positional eats the FIRST argument, so
|
|
728
|
+
// `geneseed upgrade imperial` lands in `ref` (which `upgrade` ignores) and the theme
|
|
729
|
+
// silently drops. `ref` is dead — reinterpret it as the theme when it names one.
|
|
730
|
+
if (theme === null && ref && existsSync(path.join(String(ROOT), 'themes', `${ref}.json`))) {
|
|
731
|
+
theme = ref;
|
|
732
|
+
ref = null;
|
|
733
|
+
}
|
|
734
|
+
return upgrade(ref, theme);
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
/**
|
|
738
|
+
* `_update.sync_self` — an ALIAS of `upgrade`, and the argument it drops is the behaviour.
|
|
739
|
+
*
|
|
740
|
+
* One `git pull` refreshes the launchers AND the factory, so the two verbs became one
|
|
741
|
+
* operation; the separate subcommand is kept for the stable contract. `ref` is bound by the
|
|
742
|
+
* parser for back-compat and then dropped HERE — this calls `upgrade()` with no arguments
|
|
743
|
+
* rather than forwarding anything.
|
|
744
|
+
*
|
|
745
|
+
* "IGNORED" IS NOT SILENT, which is what makes the dropping observable and is a correction the
|
|
746
|
+
* mutations made to this docblock. `upgrade(ref)` does not quietly disregard a ref — it opens by
|
|
747
|
+
* LOGGING `⚠️ ref '<x>' is IGNORED — updates follow the checkout's current branch`. So
|
|
748
|
+
* `syncSelf` forwarding its argument would make `sync-self v1.2.3` print a warning the reference
|
|
749
|
+
* never prints (mutation M22), and wiring the verb table's row straight at `cmdUpgrade` would
|
|
750
|
+
* additionally make `sync-self cyberpunk` rebuild in cyberpunk, because `cmdUpgrade` re-reads
|
|
751
|
+
* the dead positional as a THEME when `themes/<ref>.json` exists (M21). Two different defects
|
|
752
|
+
* behind one plausible-looking shortcut, and
|
|
753
|
+
* `sync-self/the-ref-positional-is-accepted-and-never-re-read-as-a-theme` is the cell for both.
|
|
754
|
+
*/
|
|
755
|
+
export function syncSelf(_ref = null) {
|
|
756
|
+
return upgrade();
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
/** `_harness_lifecycle.cmd_sync_self`. */
|
|
760
|
+
export function cmdSyncSelf(args) {
|
|
761
|
+
return syncSelf(args.ref);
|
|
762
|
+
}
|
|
763
|
+
|
|
764
|
+
// ---------------------------------------------------------------------------------------
|
|
765
|
+
// bootstrap — the update STEP RUNNER
|
|
766
|
+
// ---------------------------------------------------------------------------------------
|
|
767
|
+
//
|
|
768
|
+
// WHAT CROSSES AND WHAT DOES NOT, measured rather than assumed (`rituals/_harness_lifecycle.py`
|
|
769
|
+
// `cmd_bootstrap` and its closure, ~284 lines over eleven functions):
|
|
770
|
+
//
|
|
771
|
+
// * `_bootstrap_plain` and everything under it CROSS — the step banner, the 0/3/other exit
|
|
772
|
+
// rule, `_diagnose_failed_step`'s persistence to the install log, `_flush_export_notes`
|
|
773
|
+
// and `_reexec`.
|
|
774
|
+
// * `_bootstrap_progress` / `_run_steps` / `_run_logged` / `_bootstrap_draw` / `_clean_line`
|
|
775
|
+
// / `_pipe_select_ok` — 141 lines of CURSES progress UI — do NOT. P5i ported `cmd_setup`'s
|
|
776
|
+
// LINE wizard and declared its curses arm on exactly these terms; this is the same arm of
|
|
777
|
+
// the same fork (`sys.stdin.isatty()`), and it belongs with P7's panel. The consequence is
|
|
778
|
+
// stated plainly: an interactive `geneseed bootstrap` shows a progress screen under Python
|
|
779
|
+
// and plain lines under Node, and every non-interactive invocation — which is every cell,
|
|
780
|
+
// every script and every CI run — is identical.
|
|
781
|
+
// * `_harness_supports` and `_stale_factory_hint` do not cross either, and for a reason that
|
|
782
|
+
// is not "unreachable from a cell". They exist because a PARTIAL update can leave a new
|
|
783
|
+
// launcher over a `harness.py` that has never heard of `upgrade`, so `_update_step_cmd`
|
|
784
|
+
// probes the subcommand with a `--help` spawn and falls back to `rituals/_update.py`. The
|
|
785
|
+
// Node twin's step is a function it statically imported: there is no version of this
|
|
786
|
+
// program in which `upgrade` is missing and no argparse `invalid choice` to detect. A twin
|
|
787
|
+
// that spawned a `--help` probe to answer a question it already knows would be ceremony.
|
|
788
|
+
//
|
|
789
|
+
// THE STEP IS AN IMPORT, NOT A SPAWN, and this is the one place in this module where that is
|
|
790
|
+
// the right answer. `_ALLOWED_SPAWNS`' argument for the other re-executions is that THE CODE ON
|
|
791
|
+
// DISK CHANGES HALFWAY THROUGH — `pullAndValidate` must ask the PULLED source, `rebuildBundle`
|
|
792
|
+
// must render with the PULLED generator. Nothing has changed when the update step STARTS, so
|
|
793
|
+
// `upgrade()` here is the same program either way, and the reference's own spawn is an artifact
|
|
794
|
+
// of the progress UI needing a pipe to stream. What is kept is the ARGV, because the install
|
|
795
|
+
// log promises the user a command they can re-run.
|
|
796
|
+
|
|
797
|
+
/**
|
|
798
|
+
* `_update_step_cmd(here, sub)`, reduced to what survives the paragraph above: the command that
|
|
799
|
+
* reproduces this step, for the log and the diagnosis.
|
|
800
|
+
*
|
|
801
|
+
* The stale-factory fallback is not reproduced (see the block comment). This names the running
|
|
802
|
+
* interpreter by absolute path and this repo's own CLI — the same spelling every other argv in
|
|
803
|
+
* this port uses, and no spelling of `python` can reach it.
|
|
804
|
+
*/
|
|
805
|
+
function updateStepCmd(sub) {
|
|
806
|
+
return [process.execPath, path.join(String(ROOT), 'bin', 'geneseed-cli.mjs'), sub];
|
|
807
|
+
}
|
|
808
|
+
|
|
809
|
+
/**
|
|
810
|
+
* `_harness_lifecycle._diagnose_failed_step` — the diagnosis for a failed update step, PERSISTED
|
|
811
|
+
* before it is printed.
|
|
812
|
+
*
|
|
813
|
+
* The persistence is the point rather than a nicety: the curses log pane is ephemeral and the
|
|
814
|
+
* plain path's child output scrolls past, so without the file the only trace of WHY a step
|
|
815
|
+
* failed is gone the moment the screen tears down — and the error two frames up tells the user
|
|
816
|
+
* to look for details.
|
|
817
|
+
*
|
|
818
|
+
* `output` reaches the reference as a captured re-probe used to recognise argparse's
|
|
819
|
+
* `invalid choice`; the twin has no such probe and passes `''`, so the stale-factory lines
|
|
820
|
+
* cannot appear here. The branch that writes it to the log is kept because it is the
|
|
821
|
+
* reference's, and the cell that reaches this function proves the two agree with it empty.
|
|
822
|
+
*/
|
|
823
|
+
export function diagnoseFailedStep(n, total, title, cmd, rc, output) {
|
|
824
|
+
const lines = [`[geneseed] ✗ step ${n}/${total} FAILED (exit ${rc}): ${title}`];
|
|
825
|
+
// `_install_logfile()` in the reference is `_update._logfile()` behind an import guard that
|
|
826
|
+
// cannot fail here, and its fallback is the same path `logfile()` already returns.
|
|
827
|
+
const logpath = logfile();
|
|
828
|
+
if (logpath !== null) {
|
|
829
|
+
try {
|
|
830
|
+
let body = `\n==== geneseed update: step ${n}/${total} '${title}' `
|
|
831
|
+
+ `FAILED (exit ${rc}) ====\n`;
|
|
832
|
+
body += `command: ${cmd.map(String).join(' ')}\n`;
|
|
833
|
+
if (output.trim()) body += `${output.replace(/\n+$/, '')}\n`;
|
|
834
|
+
appendText(logpath, body);
|
|
835
|
+
lines.push(`[geneseed] ── full install log: ${logpath}`);
|
|
836
|
+
} catch { /* OSError: pass */ }
|
|
837
|
+
}
|
|
838
|
+
return lines;
|
|
839
|
+
}
|
|
840
|
+
|
|
841
|
+
/**
|
|
842
|
+
* `_harness_lifecycle._bootstrap_plain` — run the update step with plain output, never fatally.
|
|
843
|
+
*
|
|
844
|
+
* EXIT 3 IS NOT A FAILURE. `upgrade` returns 3 for an info precondition — a dirty tree, no
|
|
845
|
+
* upstream, already up to date — and a bootstrap that reported those as failures would tell
|
|
846
|
+
* every user with local changes that their install broke. `rc not in (0, 3)`, spelled here as
|
|
847
|
+
* two comparisons, is the whole rule; `rc !== 0` is the plausible wrong version of it.
|
|
848
|
+
*
|
|
849
|
+
* Returns true when a step FAILED, so scripted callers get a real exit code.
|
|
850
|
+
*/
|
|
851
|
+
async function bootstrapPlain() {
|
|
852
|
+
// One `git pull` refreshes launchers + factory together, then rebuilds — a single step.
|
|
853
|
+
//
|
|
854
|
+
// `cmdUpgrade`, NOT `upgrade`. The step the reference runs is the SUBCOMMAND — its argv is
|
|
855
|
+
// `harness.py upgrade` — and `cmd_upgrade` is where `export_improvements()` lives, ahead of
|
|
856
|
+
// the update it must run before. A step wired at the module function skips it, and the
|
|
857
|
+
// deployed harness's local edits are overwritten by the rebuild with nothing recorded.
|
|
858
|
+
// Caught by `bootstrap/an-improvements-file-the-step-exported-is-re-announced-afterwards`,
|
|
859
|
+
// which was written for `_flush_export_notes` and found this instead.
|
|
860
|
+
const steps = [['Update & rebuild', updateStepCmd('upgrade'),
|
|
861
|
+
() => cmdUpgrade({ ref: null, theme: null })]];
|
|
862
|
+
let failed = false;
|
|
863
|
+
for (let i = 0; i < steps.length; i += 1) {
|
|
864
|
+
const [title, cmd, step] = steps[i];
|
|
865
|
+
pyPrint(`[geneseed] step ${i + 1}/${steps.length}: ${title} ...\n`);
|
|
866
|
+
// eslint-disable-next-line no-await-in-loop
|
|
867
|
+
const rc = await step();
|
|
868
|
+
if (rc !== 0 && rc !== 3) {
|
|
869
|
+
failed = true;
|
|
870
|
+
for (const ln of diagnoseFailedStep(i + 1, steps.length, title, cmd, rc, '')) {
|
|
871
|
+
pyPrint(`${ln}\n`);
|
|
872
|
+
}
|
|
873
|
+
}
|
|
874
|
+
}
|
|
875
|
+
if (!failed) pyPrint('[geneseed] ✓ update complete.\n');
|
|
876
|
+
return failed;
|
|
877
|
+
}
|
|
878
|
+
|
|
879
|
+
/**
|
|
880
|
+
* `_harness_lifecycle._reexec` — hand off to a FRESH process so the just-updated code on disk
|
|
881
|
+
* runs, not the modules this one loaded before the pull.
|
|
882
|
+
*
|
|
883
|
+
* Node has no `execv`, so this is always the reference's WINDOWS branch: run the child normally
|
|
884
|
+
* and exit with its status. That branch exists in the reference for its own reason (`os.execv`
|
|
885
|
+
* on Windows spawns and kills the parent, handing the console back to the launcher's cmd.exe
|
|
886
|
+
* mid-run so the two then fight over input), and on POSIX the only difference here is that a
|
|
887
|
+
* process id survives the handoff. Nothing observes it.
|
|
888
|
+
*/
|
|
889
|
+
function reexec(argv) {
|
|
890
|
+
const r = spawnSync(argv[0], argv.slice(1), { stdio: 'inherit', ...NO_WINDOW });
|
|
891
|
+
return r.status === null ? 1 : r.status;
|
|
892
|
+
}
|
|
893
|
+
|
|
894
|
+
/**
|
|
895
|
+
* `_harness_lifecycle.cmd_bootstrap` — update everything, then hand off to a fresh `setup`.
|
|
896
|
+
*
|
|
897
|
+
* `flushExportNotes()` runs BEFORE the handoff, deliberately: the re-exec replaces this process
|
|
898
|
+
* and the update step's own improvements notice has scrolled past (or, under the reference's
|
|
899
|
+
* curses arm, died with the alternate screen).
|
|
900
|
+
*/
|
|
901
|
+
export async function cmdBootstrap(args) {
|
|
902
|
+
const failed = await bootstrapPlain();
|
|
903
|
+
flushExportNotes();
|
|
904
|
+
if (!args.noSetup) {
|
|
905
|
+
return reexec([process.execPath,
|
|
906
|
+
path.join(String(ROOT), 'bin', 'geneseed-cli.mjs'), 'setup']);
|
|
907
|
+
}
|
|
908
|
+
// Scripted `bootstrap --no-setup` must not exit 0 over a failed update.
|
|
909
|
+
return failed ? 1 : 0;
|
|
910
|
+
}
|