gentle-pi 3.7.0 → 4.1.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/README.md +155 -10
- package/assets/agents/gentle-ai-explore.md +13 -6
- package/assets/agents/gentle-ai-verify.md +27 -6
- package/assets/agents/gentle-ai-worker.md +13 -15
- package/assets/install-wizard/index.html +47 -0
- package/assets/install-wizard/wizard.css +1279 -0
- package/assets/install-wizard/wizard.js +951 -0
- package/assets/orchestrator-delegation.md +75 -169
- package/assets/orchestrator-memory.md +12 -26
- package/assets/orchestrator-prompts.md +48 -0
- package/assets/orchestrator-skills.md +1 -1
- package/assets/orchestrator-tracking.md +53 -0
- package/assets/orchestrator-verification.md +28 -0
- package/assets/orchestrator-writer.md +38 -0
- package/assets/orchestrator.md +30 -38
- package/assets/sounds/LICENSE.md +52 -0
- package/assets/sounds/attention.wav +0 -0
- package/assets/sounds/error.wav +0 -0
- package/assets/sounds/success.wav +0 -0
- package/assets/support/strict-tdd-verify.md +4 -266
- package/assets/support/strict-tdd.md +8 -360
- package/bin/engram-guard +9 -0
- package/bin/gentle-shell-install.mjs +302 -0
- package/bin/gentle-shell.mjs +956 -50
- package/contracts/review-provider-contract-mirror/provider-contract.lock.json +7 -7
- package/contracts/review-provider-contract-mirror/{v1.2.0 → v1.3.0}/bundle/manifest.json +2 -2
- package/contracts/review-provider-contract-mirror/{v1.2.0 → v1.3.0}/bundle/orchestration/pi.md +16 -4
- package/contracts/review-provider-contract-mirror/{v1.2.0 → v1.3.0}/generated/provider-capabilities.baseline.json +2 -2
- package/contracts/review-provider-contract-mirror/{v1.2.0 → v1.3.0}/generated/provider-roles.baseline.json +1 -1
- package/docs/bundled-install.md +397 -0
- package/docs/delegated-verification.md +27 -4
- package/docs/gentle-agents-activity.md +537 -0
- package/docs/gentle-shell-uninstall.md +136 -0
- package/docs/gentle-shell.md +165 -51
- package/docs/install-wizard.md +2040 -0
- package/docs/native-authority-architecture.md +3 -3
- package/docs/permission-modes.md +82 -0
- package/docs/prompt-history.md +280 -0
- package/docs/readme-reference.md +364 -231
- package/docs/render-cache-performance.md +28 -0
- package/docs/sound-notifications-proposal.md +191 -0
- package/docs/sound-notifications.md +114 -0
- package/docs/telemetry.md +1 -1
- package/docs/yolo-mode.md +86 -0
- package/extensions/child-context.ts +35 -0
- package/extensions/child-safety.ts +25 -0
- package/extensions/codegraph-tools.ts +18 -5
- package/extensions/gentle-agents.ts +1093 -326
- package/extensions/gentle-ai.ts +1636 -890
- package/extensions/gentle-herdr-activity.ts +81 -0
- package/extensions/gentle-notifications.ts +104 -0
- package/extensions/gentle-shell.ts +1619 -136
- package/extensions/gentle-stats.ts +101 -0
- package/extensions/gentle-todo.ts +126 -20
- package/extensions/history/atomic-write.ts +38 -0
- package/extensions/history/hide-prompts.ts +183 -0
- package/extensions/history/index.ts +1424 -0
- package/extensions/history/load-shared-history.ts +39 -0
- package/extensions/history/selector-helpers.ts +538 -0
- package/extensions/history/session-scan.ts +233 -0
- package/extensions/history/store.ts +1119 -0
- package/extensions/nan-provider.ts +6 -0
- package/extensions/pi-pretty.ts +39 -3
- package/extensions/quiet-tools.ts +254 -92
- package/extensions/resume-hint.ts +60 -0
- package/extensions/skill-registry.ts +191 -58
- package/extensions/startup-banner.ts +71 -33
- package/lib/agent-assets.ts +604 -0
- package/lib/agent-home.ts +10 -1
- package/lib/agent-model-resolution.ts +80 -0
- package/lib/agent-profile-pin.ts +12 -0
- package/lib/agent-profiles.ts +13 -3
- package/lib/agents-completion-delivery.ts +11 -3
- package/lib/agents-message-delivery.ts +181 -0
- package/lib/agents-messaging.ts +1 -1
- package/lib/agents-protocol.ts +99 -0
- package/lib/agents-runner.ts +180 -107
- package/lib/agents-view.ts +26 -3
- package/lib/agents-widget.ts +16 -9
- package/lib/append-system-prompt.ts +21 -0
- package/lib/auto-permission-classifier.ts +280 -0
- package/lib/auto-permission-runtime.ts +268 -0
- package/lib/background-jobs-tools.ts +202 -0
- package/lib/background-jobs.ts +253 -0
- package/lib/background-monitor.ts +154 -0
- package/lib/bounded-writer-admission.ts +220 -0
- package/lib/builtin-codemode-optout.ts +166 -0
- package/lib/card-content-policy.ts +85 -0
- package/lib/card-style-policy.ts +60 -0
- package/lib/child-context-files.ts +166 -0
- package/lib/child-package-injection.ts +62 -0
- package/lib/codemode-renderer.ts +195 -0
- package/lib/command-palette-catalog.ts +5 -10
- package/lib/command-palette.ts +25 -14
- package/lib/context-threshold.ts +26 -0
- package/lib/destructive-command-guard.ts +144 -0
- package/lib/gentle-ai-binary.ts +109 -4
- package/lib/gentle-ai-elapsed-store.ts +87 -0
- package/lib/gentle-ai-renderer.ts +196 -39
- package/lib/gentle-shell-bundled.ts +517 -0
- package/lib/gentle-shell-launcher.ts +89 -27
- package/lib/gentle-shell-resume-hint.ts +176 -0
- package/lib/gentle-shell-startup-flow.ts +54 -0
- package/lib/gentle-shell-startup-update.ts +92 -0
- package/lib/gentle-shell-uninstall.ts +840 -0
- package/lib/gentle-shell-update-panel.ts +231 -0
- package/lib/herdr-activity.ts +183 -0
- package/lib/history-capture-policy.ts +95 -0
- package/lib/inprocess-reviewer.ts +3 -0
- package/lib/jobs-sidebar-state.ts +20 -0
- package/lib/jobs-view.ts +161 -0
- package/lib/model-price-ratio.ts +83 -0
- package/lib/model-routing-authority.ts +5 -1
- package/lib/nan-provider.ts +249 -0
- package/lib/native-review-cli.ts +242 -180
- package/lib/notification-audio-native.ts +162 -0
- package/lib/notification-audio-windows.ts +399 -0
- package/lib/notification-audio.ts +332 -0
- package/lib/notification-customize.ts +241 -0
- package/lib/notification-events.ts +113 -0
- package/lib/notification-policy.ts +148 -0
- package/lib/notification-pulse-client.ts +367 -0
- package/lib/notification-pulse-protocol.ts +305 -0
- package/lib/notification-pulse-stream.ts +213 -0
- package/lib/notification-pulse-worker.ts +66 -0
- package/lib/notification-scheduler.ts +173 -0
- package/lib/notification-service.ts +202 -0
- package/lib/notification-ui.ts +87 -0
- package/lib/odd-phase-inference.ts +231 -0
- package/lib/odd-phase.ts +141 -0
- package/lib/orchestrator-alias.ts +20 -0
- package/lib/orchestrator-catalog.ts +79 -0
- package/lib/orchestrator-consultation.ts +61 -0
- package/lib/orchestrator-discovery.ts +49 -0
- package/lib/orchestrator-helper-consent.ts +122 -0
- package/lib/orchestrator-helper.ts +167 -0
- package/lib/orchestrator-message-card.ts +105 -0
- package/lib/orchestrator-presence.ts +115 -14
- package/lib/orchestrator-scope.ts +80 -0
- package/lib/orchestrator-state.ts +107 -0
- package/lib/orchestrator-tool-card.ts +280 -0
- package/lib/orchestrator-work-search.ts +218 -0
- package/lib/orchestrator-work.ts +79 -0
- package/lib/overlay-repaint.ts +26 -0
- package/lib/permission-mode-policy.ts +59 -0
- package/lib/pi-tui-keys.ts +53 -0
- package/lib/review-candidate-view-owner.ts +73 -21
- package/lib/review-candidate-view.ts +115 -27
- package/lib/review-compact-contract.ts +2 -2
- package/lib/review-host-relay.ts +7 -2
- package/lib/review-integration-v2.ts +100 -22
- package/lib/review-publication-gate.ts +1 -1
- package/lib/review-reminder-receipt.ts +48 -8
- package/lib/review-risk-assessment.ts +227 -56
- package/lib/review-sidebar-state.ts +224 -0
- package/lib/review-transaction.ts +72 -3
- package/lib/runtime-metrics-children.ts +1 -1
- package/lib/runtime-metrics-native.ts +3 -2
- package/lib/selection-engine.ts +527 -0
- package/lib/session-change-capture.ts +5 -4
- package/lib/session-changes.ts +27 -22
- package/lib/session-messaging-grants.ts +135 -0
- package/lib/session-profile-binding.ts +83 -0
- package/lib/session-profile-freeze.ts +266 -0
- package/lib/session-worktree-registry.ts +87 -9
- package/lib/shell-bar.ts +215 -30
- package/lib/shell-card.ts +287 -18
- package/lib/shell-changes-view.ts +42 -14
- package/lib/shell-changes.ts +86 -71
- package/lib/shell-prompt.ts +102 -6
- package/lib/shell-sidebar-layout.ts +83 -15
- package/lib/shell-sidebar.ts +15 -1
- package/lib/shell-todo.ts +84 -31
- package/lib/shell-usage-view.ts +9 -4
- package/lib/shell-usage.ts +70 -13
- package/lib/stats-collector.ts +381 -0
- package/lib/stats-view.ts +431 -0
- package/lib/status-timing-diagnostics.ts +201 -0
- package/lib/telemetry-trigger.ts +3 -1
- package/lib/theme-customization.ts +52 -0
- package/lib/vim-editor-adapter.ts +383 -0
- package/lib/vim-normal-engine.ts +154 -0
- package/lib/vim-operator-engine.ts +416 -0
- package/lib/vim-policy.ts +49 -0
- package/lib/vim-visual-engine.ts +107 -0
- package/lib/visual-customization-policy.ts +119 -0
- package/lib/visual-customize-view.ts +481 -0
- package/lib/visual-profiles.ts +228 -0
- package/lib/writer-surfaces.ts +162 -0
- package/lib/yolo-session-policy.ts +317 -0
- package/package.json +20 -8
- package/runtime/child-package-injection.mjs +63 -0
- package/runtime/gentle-ai-binary.mjs +109 -4
- package/runtime/gentle-shell-bundled.mjs +518 -0
- package/runtime/gentle-shell-launcher.mjs +87 -25
- package/runtime/gentle-shell-resume-hint.mjs +177 -0
- package/runtime/gentle-shell-startup-flow.mjs +55 -0
- package/runtime/gentle-shell-startup-update.mjs +93 -0
- package/runtime/gentle-shell-uninstall.mjs +841 -0
- package/runtime/gentle-shell-update-panel.mjs +232 -0
- package/runtime/native-review-cli.mjs +239 -177
- package/runtime/review-integration-v2.mjs +100 -22
- package/runtime/review-risk-assessment.mjs +225 -54
- package/runtime/status-timing-diagnostics.mjs +202 -0
- package/runtime/telemetry-trigger.mjs +3 -1
- package/scripts/bootstrap.cmd +255 -0
- package/scripts/bootstrap.sh +168 -0
- package/scripts/build-distribution.mjs +157 -0
- package/scripts/build-installer-bundles.mjs +136 -0
- package/scripts/build-runtime-modules.mjs +8 -0
- package/scripts/bundled-install.mjs +1308 -0
- package/scripts/bundled-wizard.mjs +399 -0
- package/scripts/check-types.mjs +4 -6
- package/scripts/gentle-ai-installer.mjs +83 -35
- package/scripts/install-gentle-ai.mjs +48 -1
- package/scripts/install-tui-mode-setting.mjs +10 -1
- package/scripts/install-wizard-preview.mjs +154 -0
- package/scripts/installer-downloads.mjs +648 -0
- package/scripts/installer-preflight.mjs +387 -0
- package/scripts/installer-probes.mjs +564 -0
- package/scripts/installer-runner.mjs +1102 -0
- package/scripts/installer-server.mjs +881 -0
- package/scripts/installer-windows-artifacts.json +15 -0
- package/scripts/installer-windows.mjs +752 -0
- package/scripts/main-channel.mjs +452 -0
- package/scripts/mirror-odd-routing.mjs +2 -2
- package/scripts/npm-pack-result.mjs +31 -0
- package/scripts/render-cache-performance-worker.mjs +205 -0
- package/scripts/render-cache-performance.mjs +115 -0
- package/scripts/run-test-suite.mjs +90 -0
- package/scripts/test-installer-acceptance.sh +161 -0
- package/scripts/test-packed-runner.mjs +54 -19
- package/scripts/types-baseline.json +2 -92
- package/scripts/verify-package-files.mjs +91 -38
- package/skills/branch-pr/SKILL.md +24 -52
- package/skills/chained-pr/SKILL.md +31 -15
- package/skills/chained-pr/references/chaining-details.md +31 -20
- package/skills/gentle-ai/SKILL.md +16 -21
- package/skills/issue-creation/SKILL.md +8 -2
- package/skills/issue-creation/references/delegated-workflow-actions.md +19 -0
- package/skills/work-unit-commits/SKILL.md +4 -3
- package/tests/agent-home.test.ts +56 -3
- package/tests/agent-model-resolution.test.ts +113 -0
- package/tests/agent-profiles.test.ts +30 -0
- package/tests/agent-risk-escalation.test.ts +123 -0
- package/tests/agents-completion-delivery.test.ts +17 -0
- package/tests/agents-fake-child.ts +2 -2
- package/tests/agents-message-delivery.test.ts +106 -0
- package/tests/agents-protocol.test.ts +97 -0
- package/tests/agents-queries.test.ts +9 -9
- package/tests/agents-runner-process.test.ts +31 -0
- package/tests/agents-runner.test.ts +467 -93
- package/tests/agents-view-thread-identity.test.ts +169 -0
- package/tests/agents-view.test.ts +8 -2
- package/tests/agents-widget.test.ts +154 -15
- package/tests/append-system-prompt-route.test.ts +160 -0
- package/tests/append-system-prompt.test.ts +46 -0
- package/tests/artifact-language.test.ts +19 -213
- package/tests/ask-user-question.test.ts +44 -1
- package/tests/asset-installation-runtime.test.ts +5 -16
- package/tests/auto-mode-invariants.test.ts +258 -0
- package/tests/auto-mode-sdk.test.ts +57 -0
- package/tests/auto-mode.test.ts +49 -0
- package/tests/auto-permission-classifier.test.ts +283 -0
- package/tests/auto-permission-runtime.test.ts +196 -0
- package/tests/autonomous-guard.test.ts +69 -1
- package/tests/background-cache-warming.test.ts +2 -1
- package/tests/background-jobs.test.ts +227 -0
- package/tests/background-monitor.test.ts +195 -0
- package/tests/background-subagents-default-mode.test.ts +28 -5
- package/tests/bounded-stop-contract.test.ts +85 -0
- package/tests/bounded-writer-admission.test.ts +146 -0
- package/tests/branch-pr-skill.test.ts +43 -0
- package/tests/bridge-wake-lifecycle.test.ts +129 -0
- package/tests/bridge-wake-rendering.test.ts +56 -0
- package/tests/build-distribution.test.ts +269 -0
- package/tests/builtin-codemode-optout.test.ts +242 -0
- package/tests/bundled-install.test.ts +1319 -0
- package/tests/bundled-wizard.test.ts +505 -0
- package/tests/card-content-policy.test.ts +68 -0
- package/tests/card-style-policy.test.ts +87 -0
- package/tests/chained-pr-skill.test.ts +124 -0
- package/tests/child-codegraph-bootstrap.test.ts +74 -0
- package/tests/child-context-files.test.ts +255 -0
- package/tests/child-package-entrypoints.test.ts +65 -0
- package/tests/child-package-injection.test.ts +74 -0
- package/tests/child-safety.test.ts +94 -0
- package/tests/child-web-package.test.ts +224 -0
- package/tests/codegraph-tools.test.ts +35 -0
- package/tests/codemode-rendering.test.ts +596 -0
- package/tests/command-palette.test.ts +43 -7
- package/tests/context-threshold.test.ts +31 -0
- package/tests/delegate-for-reason-contract.test.ts +327 -0
- package/tests/delegated-key-learnings-contract.test.ts +3 -78
- package/tests/destructive-command-guard.test.ts +84 -0
- package/tests/devbinary/native-review-parity.devtest.ts +171 -3
- package/tests/devbinary/non-git-subagent-bootstrap.devtest.ts +193 -0
- package/tests/devbinary/pi-host-relay.devtest.ts +173 -3
- package/tests/engram-safety.test.ts +288 -0
- package/tests/fixtures/stats/sessions/--work-alpha--/2026-09-28T10-00-00-000Z_aaa.jsonl +7 -0
- package/tests/fixtures/stats/sessions/--work-alpha--/2026-09-29T23-00-00-000Z_bbb.jsonl +3 -0
- package/tests/fixtures/stats/sessions/--work-alpha--/2026-09-30T08-00-00-000Z_ddd.jsonl +2 -0
- package/tests/fixtures/stats/sessions/--work-alpha--/2026-09-30T09-00-00-000Z_eee.jsonl +3 -0
- package/tests/fixtures/stats/sessions/--work-alpha--/run-1/session.jsonl +2 -0
- package/tests/fixtures/stats/sessions/--work-beta--/2026-09-01T12-00-00-000Z_ccc.jsonl +2 -0
- package/tests/fixtures/stats/user-pi/sessions/--work-alpha--/2026-09-28T10-00-00-000Z_aaa.jsonl +2 -0
- package/tests/fixtures/stats/user-pi/sessions/--work-alpha--/2026-09-29T23-00-00-000Z_bbb.jsonl +4 -0
- package/tests/fixtures/stats/user-pi/sessions/--work-gamma--/2026-09-20T09-00-00-000Z_fff.jsonl +3 -0
- package/tests/generic-agent-tools.test.ts +69 -0
- package/tests/gentle-agents.test.ts +2804 -346
- package/tests/gentle-ai-binary-isolation.test.ts +135 -0
- package/tests/gentle-ai-binary.test.ts +21 -4
- package/tests/gentle-ai-child-guards.test.ts +202 -0
- package/tests/gentle-ai-dev-binary-surfacing.test.ts +125 -15
- package/tests/gentle-ai-elapsed-store.test.ts +68 -0
- package/tests/gentle-ai-installer.test.ts +566 -62
- package/tests/gentle-ai-prompt-model-routing.test.ts +123 -0
- package/tests/gentle-ai-renderer.test.ts +493 -14
- package/tests/gentle-ai.test.ts +1427 -236
- package/tests/gentle-card-text.ts +2 -1
- package/tests/gentle-herdr-activity.test.ts +100 -0
- package/tests/gentle-notifications.test.ts +427 -0
- package/tests/gentle-shell-bin.test.ts +896 -115
- package/tests/gentle-shell-bundled.test.ts +1002 -0
- package/tests/gentle-shell-launcher.test.ts +333 -65
- package/tests/gentle-shell-resume-hint.test.ts +270 -0
- package/tests/gentle-shell-startup-flow.test.ts +75 -0
- package/tests/gentle-shell-startup-update.test.ts +85 -0
- package/tests/gentle-shell-uninstall.test.ts +978 -0
- package/tests/gentle-shell-update-panel.test.ts +158 -0
- package/tests/gentle-shell.test.ts +4216 -119
- package/tests/gentle-stats.test.ts +152 -0
- package/tests/gentle-theme.test.ts +4 -1
- package/tests/gentle-todo.test.ts +333 -10
- package/tests/herdr-activity.test.ts +243 -0
- package/tests/history-agent-home.test.ts +81 -0
- package/tests/history-atomic-write.test.ts +57 -0
- package/tests/history-capture-policy.test.ts +102 -0
- package/tests/history-child-guard.test.ts +70 -0
- package/tests/history-command-registration.test.ts +164 -0
- package/tests/history-dedupe-entries.test.ts +123 -0
- package/tests/history-delete-backfill.test.ts +190 -0
- package/tests/history-delete-confirm.test.ts +460 -0
- package/tests/history-dispatch.test.ts +180 -0
- package/tests/history-drain-hidden.test.ts +110 -0
- package/tests/history-drain-order.test.ts +98 -0
- package/tests/history-expanded-globals.test.ts +62 -0
- package/tests/history-gc.test.ts +832 -0
- package/tests/history-header-layout.test.ts +265 -0
- package/tests/history-hide-prompts.test.ts +275 -0
- package/tests/history-lazy-windowing.test.ts +508 -0
- package/tests/history-legacy-migrate-v2.test.ts +297 -0
- package/tests/history-load-shared-history.test.ts +53 -0
- package/tests/history-max-results-cap.test.ts +76 -0
- package/tests/history-multi-reader.test.ts +203 -0
- package/tests/history-off-path.test.ts +170 -0
- package/tests/history-openflow-integration.test.ts +173 -0
- package/tests/history-overlay-margin.test.ts +326 -0
- package/tests/history-preview-layout.test.ts +93 -0
- package/tests/history-registry.test.ts +143 -0
- package/tests/history-scope-delete.test.ts +411 -0
- package/tests/history-search-caret-keys.test.ts +142 -0
- package/tests/history-seed-bootstrap.test.ts +170 -0
- package/tests/history-seed-regen.test.ts +129 -0
- package/tests/history-selector-windowing.test.ts +94 -0
- package/tests/history-session-scan-directory.test.ts +87 -0
- package/tests/history-session-scan-extract.test.ts +583 -0
- package/tests/history-session-writer.test.ts +351 -0
- package/tests/history-store-paths.test.ts +79 -0
- package/tests/history-tombstone-exact.test.ts +139 -0
- package/tests/history-wheel-mouse.test.ts +242 -0
- package/tests/inprocess-reviewer.test.ts +49 -19
- package/tests/install-gentle-ai-failure.test.ts +69 -0
- package/tests/install-tui-mode-setting.test.ts +15 -0
- package/tests/install-wizard.test.ts +1223 -0
- package/tests/installer-bundles.test.ts +122 -0
- package/tests/installer-posix-bootstrap.test.ts +1201 -0
- package/tests/installer-preflight.test.ts +750 -0
- package/tests/installer-probes.test.ts +1106 -0
- package/tests/installer-runner.test.ts +2158 -0
- package/tests/installer-server.test.ts +1567 -0
- package/tests/installer-windows-bootstrap.test.ts +2410 -0
- package/tests/issue-creation-skill.test.ts +61 -0
- package/tests/jobs-view.test.ts +160 -0
- package/tests/main-channel-upgrade.test.ts +389 -0
- package/tests/main-channel.test.ts +344 -0
- package/tests/maintainer/provider-relay.maintest.ts +13 -12
- package/tests/model-price-ratio.test.ts +116 -0
- package/tests/model-profiles-preservation.test.ts +153 -0
- package/tests/model-routing-authority.test.ts +16 -0
- package/tests/model-routing-package.test.ts +10 -0
- package/tests/nan-provider-runtime.test.ts +156 -0
- package/tests/nan-provider.test.ts +481 -0
- package/tests/native-binary-gate.test.ts +2 -0
- package/tests/native-review-capability-contract.test.ts +13 -1
- package/tests/native-review-cli.test.ts +239 -118
- package/tests/native-review-consent.test.ts +2 -2
- package/tests/native-review-parity-runtime.test.ts +101 -4
- package/tests/native-review-parity.test.ts +12 -48
- package/tests/native-risk-evidence-parity.test.ts +21 -0
- package/tests/no-self-pause-contract.test.ts +87 -0
- package/tests/notification-audio-native.test.ts +281 -0
- package/tests/notification-audio-windows.test.ts +581 -0
- package/tests/notification-audio.test.ts +548 -0
- package/tests/notification-customize.test.ts +655 -0
- package/tests/notification-events.test.ts +115 -0
- package/tests/notification-policy.test.ts +201 -0
- package/tests/notification-pulse-client.test.ts +356 -0
- package/tests/notification-pulse-protocol.test.ts +213 -0
- package/tests/notification-pulse-stream.test.ts +320 -0
- package/tests/notification-scheduler.test.ts +258 -0
- package/tests/notification-ui.test.ts +157 -0
- package/tests/npm-pack-result.test.ts +41 -0
- package/tests/odd-integration.test.ts +76 -0
- package/tests/odd-phase-inference.test.ts +213 -0
- package/tests/odd-phase-loader.test.ts +253 -0
- package/tests/odd-phase.test.ts +307 -0
- package/tests/odd-routing-canonical-ratchet.test.ts +60 -14
- package/tests/odd-routing-contract.test.ts +158 -50
- package/tests/orchestrator-budget.test.ts +43 -45
- package/tests/orchestrator-catalog.test.ts +146 -0
- package/tests/orchestrator-consultation-sdk.test.ts +614 -0
- package/tests/orchestrator-consultation.test.ts +111 -0
- package/tests/orchestrator-discovery.test.ts +241 -0
- package/tests/orchestrator-helper-consent.test.ts +171 -0
- package/tests/orchestrator-helper.test.ts +297 -0
- package/tests/orchestrator-message-card.test.ts +190 -0
- package/tests/orchestrator-presence.test.ts +23 -0
- package/tests/orchestrator-rdd-ownership.test.ts +8 -7
- package/tests/orchestrator-scope.test.ts +107 -0
- package/tests/orchestrator-state.test.ts +114 -0
- package/tests/orchestrator-status-publication-contract.test.ts +34 -0
- package/tests/orchestrator-tool-card.test.ts +398 -0
- package/tests/orchestrator-work-search.test.ts +318 -0
- package/tests/orchestrator-work.test.ts +147 -0
- package/tests/overlay-repaint.test.ts +74 -0
- package/tests/package-manifest.test.ts +344 -123
- package/tests/packed-runner-owned-path.test.ts +80 -0
- package/tests/parallel-reason-contract.test.ts +21 -0
- package/tests/parallel-writer-admission.test.ts +210 -0
- package/tests/permission-mode-policy.test.ts +194 -0
- package/tests/persona-single-channel.test.ts +10 -18
- package/tests/pi-pretty.test.ts +56 -0
- package/tests/provider-defect-handoff.test.ts +7 -14
- package/tests/quiet-bash-runtime.test.ts +76 -0
- package/tests/quiet-tool-rendering.test.ts +569 -187
- package/tests/rdd-aware-verification-contract.test.ts +103 -40
- package/tests/rdd-status-line.test.ts +9 -4
- package/tests/render-cache-performance.test.ts +48 -0
- package/tests/resume-hint-extension.test.ts +122 -0
- package/tests/review-agent-end-preflight.test.ts +237 -21
- package/tests/review-candidate-owner-retry.test.ts +22 -1
- package/tests/review-candidate-view.test.ts +399 -12
- package/tests/review-contract-prompt.test.ts +138 -44
- package/tests/review-controller-lock-status.test.ts +0 -1
- package/tests/review-controller-native-recovery.test.ts +327 -17
- package/tests/review-controller-native-routing.test.ts +866 -6
- package/tests/review-controller-workspace-root.test.ts +206 -6
- package/tests/review-controller.test.ts +2 -2
- package/tests/review-correction-lifecycle.test.ts +2 -1
- package/tests/review-gate.test.ts +7 -4
- package/tests/review-graph-schema.test.ts +2 -2
- package/tests/review-host-relay-routing.test.ts +342 -4
- package/tests/review-host-relay.test.ts +49 -2
- package/tests/review-integration-v2-forward.test.ts +221 -1
- package/tests/review-json-arguments.test.ts +167 -0
- package/tests/review-last-event-closure.test.ts +157 -8
- package/tests/review-ledger-contract.test.ts +14 -37
- package/tests/review-policy-judgment-day.test.ts +1 -1
- package/tests/review-policy-ordinary.test.ts +2 -2
- package/tests/review-relay-transport-agent.test.ts +66 -1
- package/tests/review-reminder-receipt.test.ts +47 -1
- package/tests/review-risk-assessment.test.ts +594 -26
- package/tests/review-session-standing-permission-controller.test.ts +16 -17
- package/tests/review-session-standing-permission-ipc.test.ts +6 -5
- package/tests/review-sidebar-state.test.ts +402 -0
- package/tests/review-test-fixtures.ts +7 -1
- package/tests/review-transaction.test.ts +18 -1
- package/tests/run-test-suite.test.ts +202 -0
- package/tests/runtime-harness.mjs +186 -800
- package/tests/runtime-metrics-children.test.ts +16 -23
- package/tests/runtime-metrics-extension.test.ts +1 -1
- package/tests/runtime-metrics-native.test.ts +31 -1
- package/tests/selection-engine.test.ts +451 -0
- package/tests/session-change-capture.test.ts +10 -10
- package/tests/session-changes-shell.test.ts +242 -48
- package/tests/session-changes.test.ts +17 -24
- package/tests/session-messaging-grants.test.ts +255 -0
- package/tests/session-profile-binding.test.ts +163 -0
- package/tests/session-profile-freeze.test.ts +381 -0
- package/tests/session-subject-routing-contract.test.ts +34 -0
- package/tests/session-worktree-registry.test.ts +134 -2
- package/tests/shell-bar.test.ts +422 -1
- package/tests/shell-card.test.ts +353 -1
- package/tests/shell-changes-view.test.ts +135 -32
- package/tests/shell-changes.test.ts +61 -62
- package/tests/shell-prompt.test.ts +94 -2
- package/tests/shell-render-cache-runtime.test.ts +69 -0
- package/tests/shell-render-cache.test.ts +129 -0
- package/tests/shell-sidebar-layout.test.ts +351 -22
- package/tests/shell-sidebar-scroll-benchmark.test.ts +255 -0
- package/tests/shell-todo.test.ts +207 -3
- package/tests/shell-usage-view.test.ts +28 -1
- package/tests/shell-usage.test.ts +108 -0
- package/tests/short-check-routing-contract.test.ts +49 -0
- package/tests/skill-registry-watcher.test.ts +119 -0
- package/tests/skill-registry.test.ts +357 -2
- package/tests/startup-banner.test.ts +246 -4
- package/tests/startup-home-repo.test.ts +170 -0
- package/tests/stats-collector.test.ts +195 -0
- package/tests/stats-view.test.ts +202 -0
- package/tests/status-timing-diagnostics.test.ts +283 -0
- package/tests/support/orchestrator-modules.ts +19 -0
- package/tests/task-size-routing-contract.test.ts +241 -0
- package/tests/telemetry-trigger.test.ts +107 -20
- package/tests/test-strength-contract.test.ts +124 -0
- package/tests/theme-customization.test.ts +72 -0
- package/tests/verify-agent-contract.test.ts +90 -0
- package/tests/verify-first-launch-contract.test.ts +139 -0
- package/tests/verify-package-files.test.ts +56 -0
- package/tests/vim-editor-adapter-host-resolution.test.ts +37 -0
- package/tests/vim-editor-adapter.test.ts +804 -0
- package/tests/vim-normal-engine.test.ts +101 -0
- package/tests/vim-operator-engine.test.ts +215 -0
- package/tests/vim-policy.test.ts +19 -0
- package/tests/vim-visual-engine.test.ts +52 -0
- package/tests/visual-customization-policy.test.ts +139 -0
- package/tests/visual-customize-view.test.ts +696 -0
- package/tests/visual-profiles.test.ts +87 -0
- package/tests/writer-edit-surface-scope.test.ts +59 -2
- package/tests/writer-surfaces.test.ts +199 -0
- package/tests/yolo-customize.test.ts +271 -0
- package/tests/yolo-mode-runtime.test.ts +188 -0
- package/tests/yolo-mode.test.ts +289 -0
- package/tests/yolo-session-policy.test.ts +59 -0
- package/themes/Gentle.json +2 -1
- package/themes/Gentleman-Cute.json +2 -1
- package/themes/Gentleman-Sexy.json +2 -1
- package/assets/agents/sdd-apply.md +0 -159
- package/assets/agents/sdd-archive.md +0 -228
- package/assets/agents/sdd-design.md +0 -49
- package/assets/agents/sdd-explore.md +0 -48
- package/assets/agents/sdd-init.md +0 -56
- package/assets/agents/sdd-onboard.md +0 -52
- package/assets/agents/sdd-proposal.md +0 -64
- package/assets/agents/sdd-remediate.md +0 -37
- package/assets/agents/sdd-research.md +0 -49
- package/assets/agents/sdd-spec.md +0 -192
- package/assets/agents/sdd-status.md +0 -54
- package/assets/agents/sdd-tasks.md +0 -108
- package/assets/agents/sdd-verify.md +0 -124
- package/assets/chains/sdd-full.chain.md +0 -83
- package/assets/chains/sdd-plan.chain.md +0 -56
- package/assets/chains/sdd-verify.chain.md +0 -43
- package/assets/sdd-orchestrator-workflow.md +0 -319
- package/assets/support/sdd-status-contract.md +0 -77
- package/docs/assets/diagrams/sdd-cycle.svg +0 -14
- package/extensions/sdd-init.ts +0 -816
- package/lib/openspec-deltas.ts +0 -156
- package/lib/sdd-preflight.ts +0 -1066
- package/lib/sdd-research-capabilities.ts +0 -94
- package/lib/sdd-status.ts +0 -26
- package/tests/fixtures/legacy/sdd-research-v2.5.0.md +0 -54
- package/tests/fixtures/native-review-cli/v2.1.3/bind-sdd.json +0 -25
- package/tests/fixtures/v0.10.7/assets/agents/sdd-apply.md +0 -132
- package/tests/openspec-deltas.test.ts +0 -209
- package/tests/sdd-agent-tools.test.ts +0 -156
- package/tests/sdd-archive-replay.test.ts +0 -82
- package/tests/sdd-classical-continuation.test.ts +0 -74
- package/tests/sdd-execution-routing-contract.test.ts +0 -44
- package/tests/sdd-managed-runtime-settlement.test.ts +0 -155
- package/tests/sdd-native-managed-uptake.test.ts +0 -243
- package/tests/sdd-no-attempts-contract.test.ts +0 -15
- package/tests/sdd-odd-integration.test.ts +0 -33
- package/tests/sdd-optional-research.test.ts +0 -124
- package/tests/sdd-planning-routing-contract.test.ts +0 -45
- package/tests/sdd-preflight-rpc-input.test.ts +0 -125
- package/tests/sdd-preflight.test.ts +0 -541
- package/tests/sdd-research-capabilities.test.ts +0 -114
- package/tests/sdd-research-live.test.ts +0 -241
- package/tests/sdd-selection-transport.test.ts +0 -653
- package/tests/sdd-status.test.ts +0 -9
- package/tests/sdd-task-truth.test.ts +0 -43
- /package/contracts/review-provider-contract-mirror/{v1.2.0 → v1.3.0}/bundle/README.md +0 -0
- /package/contracts/review-provider-contract-mirror/{v1.2.0 → v1.3.0}/bundle/schemas/lens.schema.json +0 -0
- /package/contracts/review-provider-contract-mirror/{v1.2.0 → v1.3.0}/bundle/schemas/refuter.schema.json +0 -0
- /package/contracts/review-provider-contract-mirror/{v1.2.0 → v1.3.0}/bundle/schemas/targeted-validator.schema.json +0 -0
- /package/contracts/review-provider-contract-mirror/{v1.2.0 → v1.3.0}/bundle/vectors/lens.json +0 -0
- /package/contracts/review-provider-contract-mirror/{v1.2.0 → v1.3.0}/bundle/vectors/refuter.json +0 -0
- /package/contracts/review-provider-contract-mirror/{v1.2.0 → v1.3.0}/bundle/vectors/targeted-validator.json +0 -0
|
@@ -0,0 +1,1102 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
import { posix, win32 } from "node:path";
|
|
3
|
+
import {
|
|
4
|
+
gentleAiBinaryPath,
|
|
5
|
+
gentleAiDevBinaryOverrideConfigured,
|
|
6
|
+
resolveGentleAiBinary,
|
|
7
|
+
} from "../runtime/gentle-ai-binary.mjs";
|
|
8
|
+
import { PI_INSTALL_VERSION, goAcquisition, persistencePins, pnpmGlobalBin, requirements } from "./installer-preflight.mjs";
|
|
9
|
+
import { GENTLE_AI_REPOSITORY, MainChannelError, SHELL_REPOSITORY, installOwner, mainVersion } from "./main-channel.mjs";
|
|
10
|
+
import { windowsShim } from "./installer-windows.mjs";
|
|
11
|
+
|
|
12
|
+
// Standard installation runner: one fixed, consented global pnpm installation
|
|
13
|
+
// of Pi plus gentle-pi, then the public `gentle-shell setup`. Every adapter is
|
|
14
|
+
// injected by trusted local code; requests carry no commands, URLs, roots or env.
|
|
15
|
+
// When Node is bootstrap-only or no usable npm resolves, fixed steps first persist
|
|
16
|
+
// node, npm and pnpm under PNPM_HOME through pnpm itself. When an earlier run
|
|
17
|
+
// installed the pinned stack but stopped in setup, a fixed recovery re-verifies
|
|
18
|
+
// that stack and reruns only `gentle-shell setup` (and `pnpm setup`), never `add -g`.
|
|
19
|
+
// An older Pi that pnpm or npm owns is first updated with that package manager.
|
|
20
|
+
// When a build needs Go and the user's is missing, older or unchecked, the installer's pinned
|
|
21
|
+
// Go is acquired first and given only to the build children, first on PATH.
|
|
22
|
+
|
|
23
|
+
/** Pi version installed next to gentle-pi (optional peer, resolved in one add). */
|
|
24
|
+
export { PI_INSTALL_VERSION };
|
|
25
|
+
export const PI_PACKAGE = "@earendil-works/pi-coding-agent";
|
|
26
|
+
export const SHELL_PACKAGE = "gentle-pi";
|
|
27
|
+
|
|
28
|
+
/** Every `reason` a `blocked` outcome can carry (for host guidance; no behavior). */
|
|
29
|
+
export const blockedReasons = Object.freeze([
|
|
30
|
+
"invalid-request",
|
|
31
|
+
"consent-required",
|
|
32
|
+
"preflight-blocked",
|
|
33
|
+
"go-required",
|
|
34
|
+
"unsupported-plan",
|
|
35
|
+
"pnpm-home-unknown",
|
|
36
|
+
"pnpm-home-changed",
|
|
37
|
+
"node-unavailable",
|
|
38
|
+
"pnpm-unavailable",
|
|
39
|
+
"npm-unavailable",
|
|
40
|
+
"npm-shadowed",
|
|
41
|
+
"global-bin-mismatch",
|
|
42
|
+
"global-list-unavailable",
|
|
43
|
+
"existing-stack",
|
|
44
|
+
"existing-stack-unverified",
|
|
45
|
+
]);
|
|
46
|
+
/** Every `failedStep` a `failed` outcome can carry (for host guidance; no behavior). */
|
|
47
|
+
export const failedSteps = Object.freeze([
|
|
48
|
+
"prepare-pnpm-home",
|
|
49
|
+
"persist-node",
|
|
50
|
+
"persist-package-managers",
|
|
51
|
+
"persist-npm",
|
|
52
|
+
"persist-pnpm",
|
|
53
|
+
"verify-persistent-runtime",
|
|
54
|
+
"verify-persistent-pnpm",
|
|
55
|
+
"check-npm",
|
|
56
|
+
"configure-npm-prefix",
|
|
57
|
+
"acquire-go",
|
|
58
|
+
"verify-go",
|
|
59
|
+
"install-global",
|
|
60
|
+
"verify-global-list",
|
|
61
|
+
"verify-shell-bin",
|
|
62
|
+
"verify-gentle-ai",
|
|
63
|
+
"shell-setup",
|
|
64
|
+
"persist-path",
|
|
65
|
+
"build-gentle-ai-main",
|
|
66
|
+
"install-shell-main",
|
|
67
|
+
"record-channel",
|
|
68
|
+
"install-pi",
|
|
69
|
+
"update-shell",
|
|
70
|
+
"verify-updated-shell",
|
|
71
|
+
"update-pi",
|
|
72
|
+
"verify-updated-pi",
|
|
73
|
+
"verify-installed-pi",
|
|
74
|
+
]);
|
|
75
|
+
|
|
76
|
+
const SECOND = 1000;
|
|
77
|
+
const MINUTE = 60 * SECOND;
|
|
78
|
+
const deadlines = Object.freeze({ probe: 30 * SECOND, install: 20 * MINUTE, setup: 20 * MINUTE });
|
|
79
|
+
|
|
80
|
+
// Exact descriptors planPreflight can emit; anything else is a foreign request.
|
|
81
|
+
const knownActions = Object.freeze({
|
|
82
|
+
"acquire-node": { kind: "acquire", target: "node", version: requirements.node },
|
|
83
|
+
"verify-node": { kind: "verify", target: "node" },
|
|
84
|
+
"acquire-pnpm": { kind: "acquire", target: "pnpm", version: requirements.pnpm },
|
|
85
|
+
"verify-pnpm": { kind: "verify", target: "pnpm" },
|
|
86
|
+
"setup-global-bin": { kind: "setup", target: "globalBin" },
|
|
87
|
+
"persist-node": { kind: "persist-runtime", target: "node", version: persistencePins.node },
|
|
88
|
+
"persist-package-managers": { kind: "install-global", target: "package-managers" },
|
|
89
|
+
"configure-npm-prefix": { kind: "configure", target: "npm-prefix" },
|
|
90
|
+
"persist-npm": { kind: "install-global", target: "npm", version: persistencePins.npm },
|
|
91
|
+
"persist-pnpm": { kind: "install-global", target: "pnpm", version: persistencePins.pnpm },
|
|
92
|
+
"acquire-go": { kind: "acquire", target: "go", version: goAcquisition.version },
|
|
93
|
+
"verify-go": { kind: "verify", target: "go" },
|
|
94
|
+
"install-pi": { kind: "install-global", target: "pi", version: requirements.pi },
|
|
95
|
+
"install-shell": { kind: "install-global", target: "shell", version: requirements.shell },
|
|
96
|
+
"provision-native": { kind: "existing-installer", target: "gentleAi", version: requirements.gentleAi },
|
|
97
|
+
"setup-shell": { kind: "normal-setup", target: "shell" },
|
|
98
|
+
"verify-readiness": { kind: "verify", target: "stack" },
|
|
99
|
+
"build-gentle-ai-main": { kind: "build-native-main", target: "gentleAi" },
|
|
100
|
+
"install-shell-main": { kind: "install-global", target: "shell" },
|
|
101
|
+
"record-channel": { kind: "configure", target: "channel" },
|
|
102
|
+
"update-shell-release": { kind: "upgrade", target: "shell" },
|
|
103
|
+
"update-shell-main": { kind: "upgrade", target: "shell" },
|
|
104
|
+
"update-pi": { kind: "upgrade", target: "pi", version: PI_INSTALL_VERSION },
|
|
105
|
+
});
|
|
106
|
+
// The clean-stack path: both global packages are missing.
|
|
107
|
+
const requiredActions = ["install-pi", "install-shell", "setup-shell", "verify-readiness"];
|
|
108
|
+
const optionalActions = ["setup-global-bin"];
|
|
109
|
+
// Pi already installed: only Gentle Shell is added (never Pi).
|
|
110
|
+
const shellOnlyActions = ["install-shell", "setup-shell", "verify-readiness"];
|
|
111
|
+
// An existing Gentle Shell: exactly one update, then setup; a missing Pi is installed first.
|
|
112
|
+
const updateActions = ["update-shell-release", "update-shell-main"];
|
|
113
|
+
// Pi alone (a current Gentle Shell): an older owned Pi's update, or the installer's
|
|
114
|
+
// Pi added next to an older one neither pnpm nor npm owns; then readiness.
|
|
115
|
+
const piOnlyVariants = Object.freeze([["update-pi", "verify-readiness"], ["install-pi", "verify-readiness"]]);
|
|
116
|
+
// The main channel overlay: all three after a release installation, or none.
|
|
117
|
+
const mainActions = ["build-gentle-ai-main", "install-shell-main", "record-channel"];
|
|
118
|
+
// The pinned Go: both, only when preflight recorded a missing, older or unchecked Go, or none.
|
|
119
|
+
const goActions = ["acquire-go", "verify-go"];
|
|
120
|
+
// Setup recovery: the pinned stack this pnpm installed is present (planPreflight
|
|
121
|
+
// saw a recoverable setup), so only setup and the optional PATH step remain.
|
|
122
|
+
const recoveryActions = ["setup-shell", "verify-readiness"];
|
|
123
|
+
// Runtime persistence is one of these exact sets planPreflight emits, or nothing:
|
|
124
|
+
// bootstrap-only Node (with pnpm, or only npm next to a persistent pnpm), or a
|
|
125
|
+
// persistent Node missing npm and/or pnpm (one add -g).
|
|
126
|
+
const persistenceVariants = Object.freeze([
|
|
127
|
+
["persist-node", "persist-package-managers", "configure-npm-prefix"],
|
|
128
|
+
["persist-node", "persist-npm", "configure-npm-prefix"],
|
|
129
|
+
["persist-package-managers"],
|
|
130
|
+
["persist-npm"],
|
|
131
|
+
["persist-pnpm"],
|
|
132
|
+
]);
|
|
133
|
+
const persistenceActions = [...new Set(persistenceVariants.flat())];
|
|
134
|
+
|
|
135
|
+
const MAIN_VERSION = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)-main\.[0-9a-f]{12}$/;
|
|
136
|
+
function stable(version) {
|
|
137
|
+
const match = typeof version === "string" && /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/.exec(version);
|
|
138
|
+
return match ? match.slice(1).map(Number) : null;
|
|
139
|
+
}
|
|
140
|
+
function atLeast(version, minimum) {
|
|
141
|
+
const [left, right] = [stable(version), stable(minimum)];
|
|
142
|
+
if (!left || !right) return false;
|
|
143
|
+
for (let i = 0; i < 3; i += 1) if (left[i] !== right[i]) return left[i] > right[i];
|
|
144
|
+
return true;
|
|
145
|
+
}
|
|
146
|
+
if (!atLeast(PI_INSTALL_VERSION, requirements.pi)) throw new Error("Pi install pin is below the peer minimum");
|
|
147
|
+
|
|
148
|
+
function plainObject(value) {
|
|
149
|
+
return typeof value === "object" && value !== null && !Array.isArray(value) && Object.getPrototypeOf(value) === Object.prototype;
|
|
150
|
+
}
|
|
151
|
+
function onlyKeys(value, allowed) {
|
|
152
|
+
return plainObject(value) && Object.keys(value).every((key) => allowed.includes(key));
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** Accept only `{ plan, consent }` where plan is an unmodified planPreflight result. */
|
|
156
|
+
function validRequest(request) {
|
|
157
|
+
if (!onlyKeys(request, ["plan", "consent"]) || !onlyKeys(request.plan, ["tools", "blockers", "actions", "ready"])) return false;
|
|
158
|
+
const { tools, blockers, actions, ready } = request.plan;
|
|
159
|
+
if (!plainObject(tools) || !Array.isArray(blockers) || !Array.isArray(actions) || typeof ready !== "boolean") return false;
|
|
160
|
+
const ids = new Set();
|
|
161
|
+
for (const action of actions) {
|
|
162
|
+
const expected = knownActions[action?.id];
|
|
163
|
+
if (!expected || ids.has(action.id) || !onlyKeys(action, ["id", "kind", "target", "version"])) return false;
|
|
164
|
+
if (Object.keys(action).length !== Object.keys(expected).length + 1) return false;
|
|
165
|
+
if (Object.entries(expected).some(([key, value]) => action[key] !== value)) return false;
|
|
166
|
+
ids.add(action.id);
|
|
167
|
+
}
|
|
168
|
+
return true;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** Gates that need no process: returns a blocked reason or null. */
|
|
172
|
+
function planGate(plan, platform) {
|
|
173
|
+
if (plan.blockers.length > 0) return "preflight-blocked";
|
|
174
|
+
const all = plan.actions.map((action) => action.id);
|
|
175
|
+
const acquiringGo = goActions.filter((id) => all.includes(id)).length;
|
|
176
|
+
if (acquiringGo === 1 || (acquiringGo === 2 && plan.tools.go?.status !== "needs-acquire")) return "unsupported-plan";
|
|
177
|
+
const goReady = plan.tools.go?.status === "reusable" || acquiringGo === 2;
|
|
178
|
+
const overlay = mainActions.filter((id) => all.includes(id));
|
|
179
|
+
if (overlay.length > 0 && (overlay.length !== mainActions.length || !goReady)) return "unsupported-plan";
|
|
180
|
+
const ids = all.filter((id) => !mainActions.includes(id) && !goActions.includes(id));
|
|
181
|
+
const only = (allowed) => ids.every((id) => allowed.includes(id));
|
|
182
|
+
// All or nothing, like the persistence variants: never part of an installation.
|
|
183
|
+
const recovery = recoveryActions.every((id) => ids.includes(id)) && only([...recoveryActions, ...optionalActions]);
|
|
184
|
+
// A Pi update needs the older, owned Pi preflight recorded, and never installs Pi as well.
|
|
185
|
+
const updatingPi = ids.includes("update-pi");
|
|
186
|
+
if (updatingPi && (ids.includes("install-pi") || plan.tools.pi?.status !== "needs-update")) return "unsupported-plan";
|
|
187
|
+
const piOnly = overlay.length === 0 && plan.tools.shell?.status === "reusable" &&
|
|
188
|
+
piOnlyVariants.some((variant) => variant.every((id) => ids.includes(id)) && only(variant)) &&
|
|
189
|
+
(updatingPi || ["unavailable", "needs-install"].includes(plan.tools.pi?.status));
|
|
190
|
+
// gentle-pi's postinstall may build Gentle AI from source on Windows. A recovery
|
|
191
|
+
// runs no postinstall: its binary is verified. A Pi-only update touches no
|
|
192
|
+
// Gentle Shell package either. Go is acquired only for a build that uses it.
|
|
193
|
+
const windowsBuild = platform === "win32" && !recovery && !piOnly;
|
|
194
|
+
if (acquiringGo === 2 && overlay.length === 0 && !ids.includes("update-shell-main") && !windowsBuild) return "unsupported-plan";
|
|
195
|
+
if (windowsBuild && !goReady) return "go-required";
|
|
196
|
+
const persisting = persistenceActions.filter((id) => ids.includes(id));
|
|
197
|
+
if (persisting.length > 0 && !persistenceVariants.some((variant) =>
|
|
198
|
+
variant.length === persisting.length && variant.every((id) => persisting.includes(id)))) return "unsupported-plan";
|
|
199
|
+
const updating = updateActions.filter((id) => ids.includes(id));
|
|
200
|
+
if (updating.length > 0) {
|
|
201
|
+
const valid = updating.length === 1 && overlay.length === 0 && ["setup-shell", "verify-readiness"].every((id) => ids.includes(id)) &&
|
|
202
|
+
only([...updating, "install-pi", "update-pi", "setup-shell", "verify-readiness"]) &&
|
|
203
|
+
(updating[0] !== "update-shell-main" || goReady);
|
|
204
|
+
return valid ? null : "unsupported-plan";
|
|
205
|
+
}
|
|
206
|
+
const clean = requiredActions.every((id) => ids.includes(id)) && only([...requiredActions, ...optionalActions, ...persistenceActions]);
|
|
207
|
+
// The same actions as a forged recovery plus install-shell: the tools must say Shell
|
|
208
|
+
// is missing and Pi reusable, or older and updated first.
|
|
209
|
+
const shellOnly = shellOnlyActions.every((id) => ids.includes(id)) && !ids.includes("install-pi") &&
|
|
210
|
+
plan.tools.shell?.status === "unavailable" && plan.tools.pi?.status === (updatingPi ? "needs-update" : "reusable") &&
|
|
211
|
+
only([...shellOnlyActions, "update-pi", ...optionalActions, ...persistenceActions]);
|
|
212
|
+
return clean || shellOnly || recovery || piOnly ? null : "unsupported-plan";
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
function pathKeyOf(env, platform) {
|
|
216
|
+
return platform === "win32" ? Object.keys(env).find((key) => key.toUpperCase() === "PATH") ?? "Path" : "PATH";
|
|
217
|
+
}
|
|
218
|
+
function envValue(env, name, platform) {
|
|
219
|
+
const key = platform === "win32" ? Object.keys(env).find((candidate) => candidate.toUpperCase() === name) : name;
|
|
220
|
+
return key === undefined ? undefined : env[key];
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/** Child env: the user's env plus PNPM_HOME and `$PNPM_HOME/bin` first on PATH.
|
|
224
|
+
* With a private Windows PNPM_HOME (S6), pnpm's config, cache and state move into
|
|
225
|
+
* it too, unless the user set those folders: pnpm otherwise keeps them under
|
|
226
|
+
* %LOCALAPPDATA%, which another principal may write, and trusts its cached registry
|
|
227
|
+
* metadata for exact versions. TEMP and TMP always move to its `tmp` folder
|
|
228
|
+
* (created by prepare-pnpm-home), so a postinstall's os.tmpdir(), such as the
|
|
229
|
+
* Gentle AI source build, stays private. Only these children get them; nothing
|
|
230
|
+
* persists them.
|
|
231
|
+
*/
|
|
232
|
+
export function childEnvironment(env, platform, globalBin, { privateHome = false } = {}) {
|
|
233
|
+
const path = platform === "win32" ? win32 : posix;
|
|
234
|
+
const key = pathKeyOf(env, platform);
|
|
235
|
+
const rest = String(env[key] ?? "").split(path.delimiter).filter((entry) => entry.length > 0);
|
|
236
|
+
const isolate = platform === "win32" && privateHome;
|
|
237
|
+
const xdg = isolate ? Object.fromEntries([["XDG_CONFIG_HOME", ".config"], ["XDG_CACHE_HOME", ".cache"], ["XDG_STATE_HOME", ".state"]]
|
|
238
|
+
.filter(([name]) => envValue(env, name, platform) === undefined).map(([name, folder]) => [name, win32.join(globalBin.pnpmHome, folder)])) : {};
|
|
239
|
+
const temp = isolate ? win32.join(globalBin.pnpmHome, "tmp") : null;
|
|
240
|
+
const kept = isolate ? Object.fromEntries(Object.entries(env).filter(([name]) => !/^(?:TEMP|TMP)$/i.test(name))) : env;
|
|
241
|
+
return { ...kept, PNPM_HOME: globalBin.pnpmHome, ...xdg, ...(temp ? { TEMP: temp, TMP: temp } : {}), [key]: [globalBin.path, ...rest].join(path.delimiter) };
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/** A build child's env: `env` with the pinned Go's bin directory first on PATH. */
|
|
245
|
+
export function goFirstEnvironment(env, platform, goPath) {
|
|
246
|
+
const path = platform === "win32" ? win32 : posix;
|
|
247
|
+
const key = pathKeyOf(env, platform);
|
|
248
|
+
const rest = String(env[key] ?? "").split(path.delimiter).filter((entry) => entry.length > 0);
|
|
249
|
+
return { ...env, [key]: [path.dirname(goPath), ...rest].join(path.delimiter) };
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/** gentle-shell setup gets no bootstrap handoff keys (case-insensitive on Windows). */
|
|
253
|
+
function setupEnvironment(child, platform) {
|
|
254
|
+
const bootstrap = platform === "win32" ? /^GENTLE_(BOOTSTRAP|INSTALL)_/i : /^GENTLE_(BOOTSTRAP|INSTALL)_/;
|
|
255
|
+
return Object.fromEntries(Object.entries(child).filter(([key]) => !bootstrap.test(key)));
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/** pnpm comes from the bootstrap handoff, or a PATH executable on POSIX only. */
|
|
259
|
+
export async function pnpmInvocation(env, platform, fs) {
|
|
260
|
+
const path = platform === "win32" ? win32 : posix;
|
|
261
|
+
const node = env.GENTLE_INSTALL_PNPM_NODE;
|
|
262
|
+
const entry = env.GENTLE_INSTALL_PNPM_ENTRY;
|
|
263
|
+
if (node && entry) {
|
|
264
|
+
return path.isAbsolute(node) && path.isAbsolute(entry) ? { command: node, prefix: [entry] } : null;
|
|
265
|
+
}
|
|
266
|
+
// A Windows .cmd shim cannot be spawned with shell:false; require the direct
|
|
267
|
+
// handoff: Node plus pnpm's JS entry, or a native pnpm.exe on its own.
|
|
268
|
+
if (platform === "win32") {
|
|
269
|
+
const command = env.GENTLE_INSTALL_PNPM_COMMAND;
|
|
270
|
+
return command && win32.isAbsolute(command) && win32.extname(command).toLowerCase() === ".exe" ? { command, prefix: [] } : null;
|
|
271
|
+
}
|
|
272
|
+
for (const directory of String(env.PATH ?? "").split(path.delimiter)) {
|
|
273
|
+
if (!path.isAbsolute(directory)) continue;
|
|
274
|
+
const candidate = path.join(directory, "pnpm");
|
|
275
|
+
if (await fs.isFile(candidate)) return { command: candidate, prefix: [] };
|
|
276
|
+
}
|
|
277
|
+
return null;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/** Windows PATHEXT extensions in order: lowercased, dot-prefixed, default .com/.exe/.bat/.cmd. */
|
|
281
|
+
function pathExtensions(env) {
|
|
282
|
+
return String(envValue(env, "PATHEXT", "win32") || ".com;.exe;.bat;.cmd").toLowerCase().split(";")
|
|
283
|
+
.filter((extension) => extension.length > 0).map((extension) => extension.startsWith(".") ? extension : `.${extension}`);
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/** A path on a local drive (`C:\...`). UNC, `\\?\`, `\\.\` and drive-less rooted
|
|
287
|
+
* paths are not: even looking one up can reach a remote share. */
|
|
288
|
+
const LOCAL_DRIVE = /^[A-Za-z]:\\/;
|
|
289
|
+
|
|
290
|
+
/** First `name` the way Go's exec.LookPath (used by Gentle AI) finds it: each
|
|
291
|
+
* absolute PATH directory in order and, on Windows, every PATHEXT extension in
|
|
292
|
+
* PATHEXT order (lowercased, dot-prefixed, default .com/.exe/.bat/.cmd). On
|
|
293
|
+
* Windows only local drive directories are searched; any other one is skipped,
|
|
294
|
+
* like a relative one, and never touched.
|
|
295
|
+
*/
|
|
296
|
+
export async function lookPath(name, env, platform, fs) {
|
|
297
|
+
const path = platform === "win32" ? win32 : posix;
|
|
298
|
+
const extensions = platform === "win32" ? pathExtensions(env) : [""];
|
|
299
|
+
for (const directory of String(env[pathKeyOf(env, platform)] ?? "").split(path.delimiter)) {
|
|
300
|
+
if (!path.isAbsolute(directory) || (platform === "win32" && !LOCAL_DRIVE.test(directory))) continue;
|
|
301
|
+
for (const extension of extensions) {
|
|
302
|
+
const candidate = path.join(directory, `${name}${extension}`);
|
|
303
|
+
if (await fs.isFile(candidate)) return candidate;
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
return null;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
/** A Windows `.cmd`/`.bat` shim cannot run with shell:false; executables can. */
|
|
310
|
+
export function spawnable(file, platform) {
|
|
311
|
+
return platform !== "win32" || [".exe", ".com"].includes(win32.extname(file).toLowerCase());
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
/** How a Windows command runs with shell:false, never through cmd.exe: an .exe
|
|
315
|
+
* as it is, or what a known shim (windowsShim) runs, its native target or its
|
|
316
|
+
* JS entry with the Node it selects (an absolute node.exe it names, its sibling
|
|
317
|
+
* node.exe, else the first node on PATH, which must be an .exe). A native target
|
|
318
|
+
* without an extension resolves like CMD resolves it, through PATHEXT in order,
|
|
319
|
+
* and runs only when that first match is an .exe. A `pnpm.cmd` runs only pnpm's
|
|
320
|
+
* own `node_modules\pnpm\bin\pnpm.[cm]js` entry (never Corepack's). npm's own
|
|
321
|
+
* npm.cmd keeps its redirect: it asks npm for the global prefix (from the drive
|
|
322
|
+
* root, like any probe) and runs the npm-cli.js installed there, when there is
|
|
323
|
+
* one. The command, the Node and npm's prefix must be local drive paths.
|
|
324
|
+
* Returns { command, prefix } or null when the command is anything else.
|
|
325
|
+
*/
|
|
326
|
+
export async function windowsInvocation(file, env, adapters) {
|
|
327
|
+
const { fs } = adapters;
|
|
328
|
+
if (!LOCAL_DRIVE.test(file)) return null;
|
|
329
|
+
const extension = win32.extname(file).toLowerCase();
|
|
330
|
+
if (extension === ".exe") return (await fs.isFile(file)) ? { command: file, prefix: [] } : null;
|
|
331
|
+
if (extension !== ".cmd") return null;
|
|
332
|
+
const shim = windowsShim(await fs.readText(file).catch(() => null));
|
|
333
|
+
if (!shim) return null;
|
|
334
|
+
const directory = win32.dirname(file);
|
|
335
|
+
if (shim.exe !== undefined) {
|
|
336
|
+
let exe = win32.resolve(directory, shim.exe);
|
|
337
|
+
// @pnpm/exe hard-links its binary under both names; the shim may name either.
|
|
338
|
+
if (win32.extname(exe) === "") {
|
|
339
|
+
let first = null;
|
|
340
|
+
for (const candidate of pathExtensions(env)) if (first === null && (await fs.isFile(`${exe}${candidate}`))) first = `${exe}${candidate}`;
|
|
341
|
+
if (first === null) return null;
|
|
342
|
+
exe = first;
|
|
343
|
+
}
|
|
344
|
+
return win32.extname(exe).toLowerCase() === ".exe" && (await fs.isFile(exe)) ? { command: exe, prefix: [] } : null;
|
|
345
|
+
}
|
|
346
|
+
const sibling = win32.join(directory, "node.exe");
|
|
347
|
+
const node = shim.node ?? ((await fs.isFile(sibling)) ? sibling : await lookPath("node", env, "win32", fs));
|
|
348
|
+
if (!node || !LOCAL_DRIVE.test(node) || !spawnable(node, "win32") || !(await fs.isFile(node))) return null;
|
|
349
|
+
const npmCli = (root) => win32.join(root, "node_modules", "npm", "bin", "npm-cli.js");
|
|
350
|
+
let entry = shim.npm ? npmCli(directory) : win32.resolve(directory, shim.entry);
|
|
351
|
+
const pnpmEntry = (path) => entryShape(path, win32, "pnpm", "pnpm.cjs") || entryShape(path, win32, "pnpm", "pnpm.mjs");
|
|
352
|
+
if (win32.basename(file).toLowerCase() === "pnpm.cmd" && (shim.npm || !pnpmEntry(entry))) return null;
|
|
353
|
+
if (shim.npm) {
|
|
354
|
+
const query = shim.npm === "prefix-js" ? [win32.join(directory, "node_modules", "npm", "bin", "npm-prefix.js")] : [entry, "prefix", "-g"];
|
|
355
|
+
// Without npm-prefix.js, npm.cmd's FOR /F reads no line and keeps the bundled npm.
|
|
356
|
+
if (await fs.isFile(query[0])) {
|
|
357
|
+
const result = await adapters.run(node, query, { env, cwd: win32.parse(node).root, deadlineMs: deadlines.probe });
|
|
358
|
+
const prefix = succeeded(result) && result.truncated !== true
|
|
359
|
+
? String(result.stdout ?? "").split(/\r?\n/).map((line) => line.trim()).filter(Boolean).at(-1) : undefined;
|
|
360
|
+
if (prefix && LOCAL_DRIVE.test(prefix) && (await fs.isFile(npmCli(prefix)))) entry = npmCli(prefix);
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
return (await fs.isFile(entry)) ? { command: node, prefix: [entry] } : null;
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
/** The user's npm as { command, prefix }: the first npm on PATH, run through
|
|
367
|
+
* windowsInvocation on Windows. Null when none resolves.
|
|
368
|
+
*/
|
|
369
|
+
export async function npmInvocation(env, platform, adapters) {
|
|
370
|
+
const npm = await lookPath("npm", env, platform, adapters.fs);
|
|
371
|
+
if (!npm) return null;
|
|
372
|
+
return platform === "win32" ? windowsInvocation(npm, env, adapters).catch(() => null) : { command: npm, prefix: [] };
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
/** runUpgrade's `invocation` adapter on Windows: npm through npmInvocation, any
|
|
376
|
+
* other command (pnpm) through windowsInvocation of the first one on PATH, or,
|
|
377
|
+
* with `handoff` (the wizard), pnpm from the bootstrap handoff (pnpmInvocation).
|
|
378
|
+
* Never a .cmd with shell:false. Undefined on POSIX, where the command `which`
|
|
379
|
+
* finds runs as it is.
|
|
380
|
+
*/
|
|
381
|
+
export function upgradeInvocation({ platform, env, run, fs, handoff = false }) {
|
|
382
|
+
if (platform !== "win32") return undefined;
|
|
383
|
+
const adapters = { run, fs };
|
|
384
|
+
return async (name) => {
|
|
385
|
+
if (name === "npm") return npmInvocation(env, platform, adapters);
|
|
386
|
+
if (name === "pnpm" && handoff) return pnpmInvocation(env, platform, fs);
|
|
387
|
+
const file = await lookPath(name, env, platform, fs);
|
|
388
|
+
return file ? windowsInvocation(file, env, adapters).catch(() => null) : null;
|
|
389
|
+
};
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
/** `.../node_modules/<name>/bin/<file>`: a package's own bin entry. */
|
|
393
|
+
function entryShape(entry, path, name, file) {
|
|
394
|
+
const packageDir = path.dirname(path.dirname(entry));
|
|
395
|
+
return path.basename(entry) === file && path.basename(path.dirname(entry)) === "bin" &&
|
|
396
|
+
path.basename(packageDir) === name && path.basename(path.dirname(packageDir)) === "node_modules";
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
function escapeRegExp(text) {
|
|
400
|
+
return text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
/** The single `node_modules/<name>/bin/<file>` target a pnpm shim names, absolute
|
|
404
|
+
* or relative to the shim directory (`$basedir/` on POSIX, `%~dp0\` or `%dp0%\`
|
|
405
|
+
* on Windows).
|
|
406
|
+
*/
|
|
407
|
+
function shimTarget(text, shim, platform, name, file) {
|
|
408
|
+
const path = platform === "win32" ? win32 : posix;
|
|
409
|
+
const tail = `node_modules${path.sep}${name}${path.sep}bin${path.sep}${file}`;
|
|
410
|
+
const pattern = platform === "win32"
|
|
411
|
+
? new RegExp(`"(%~?dp0%?\\\\)?([^"%]*\\\\${escapeRegExp(tail)})"`, "gi")
|
|
412
|
+
: new RegExp(`"(\\$basedir\\/)?([^"$]*\\/${escapeRegExp(tail)})"`, "g");
|
|
413
|
+
const targets = new Set();
|
|
414
|
+
for (const [, relative, target] of String(text).matchAll(pattern)) {
|
|
415
|
+
const resolved = relative ? path.join(path.dirname(shim), target) : target;
|
|
416
|
+
if (!path.isAbsolute(resolved)) return null;
|
|
417
|
+
targets.add(path.normalize(resolved));
|
|
418
|
+
}
|
|
419
|
+
return targets.size === 1 ? [...targets][0] : null;
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
/** The package entry a pnpm global shim in `$PNPM_HOME/bin` runs: a POSIX
|
|
423
|
+
* symlink to it, or the single target named in the shim text. Either way its
|
|
424
|
+
* realpath must be a `<name>` bin entry inside PNPM_HOME. Returns it or null.
|
|
425
|
+
*/
|
|
426
|
+
async function globalShimEntry(shim, name, file, platform, globalBin, fs) {
|
|
427
|
+
const path = platform === "win32" ? win32 : posix;
|
|
428
|
+
let entry = platform === "win32" ? null : await fs.realpath(shim);
|
|
429
|
+
if (entry === null || !entryShape(entry, path, name, file)) {
|
|
430
|
+
const target = shimTarget(await fs.readText(shim), shim, platform, name, file);
|
|
431
|
+
if (!target) return null;
|
|
432
|
+
entry = await fs.realpath(target);
|
|
433
|
+
}
|
|
434
|
+
if (!entryShape(entry, path, name, file)) return null;
|
|
435
|
+
return contains(await fs.realpath(globalBin.pnpmHome), entry, platform) ? entry : null;
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
/** The entry's package.json names `name` at `version` (any stable version when
|
|
439
|
+
* null), and `node <entry> --version` prints that version.
|
|
440
|
+
*/
|
|
441
|
+
async function runsAsPackage(entry, name, version, nodePath, child, platform, adapters) {
|
|
442
|
+
const path = platform === "win32" ? win32 : posix;
|
|
443
|
+
if (!(await adapters.fs.isFile(entry))) return false;
|
|
444
|
+
const metadata = JSON.parse(await adapters.fs.readText(path.join(path.dirname(path.dirname(entry)), "package.json")));
|
|
445
|
+
if (metadata?.name !== name || !stable(metadata.version) || (version !== null && metadata.version !== version)) return false;
|
|
446
|
+
const result = await adapters.run(nodePath, [entry, "--version"], { env: child, deadlineMs: deadlines.probe });
|
|
447
|
+
return succeeded(result) && String(result.stdout ?? "").trim() === metadata.version;
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
function inGlobalBin(file, platform, globalBin) {
|
|
451
|
+
const path = platform === "win32" ? win32 : posix;
|
|
452
|
+
return globalBin !== null && samePath(path.dirname(file), globalBin.path, platform);
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
/** The npm the user runs works in the user's environment: run from the drive
|
|
456
|
+
* root (`/` on POSIX) with the child env, `npm --version` prints a stable version
|
|
457
|
+
* and `npm config get prefix` one absolute path. Whatever installed it (Node,
|
|
458
|
+
* Homebrew, nvm, fnm, or a mise, asdf or Volta shim), this is how Gentle AI's
|
|
459
|
+
* Engram step will run it. `npm` is { command, prefix }.
|
|
460
|
+
*/
|
|
461
|
+
async function worksAsNpm(npm, child, adapters, platform) {
|
|
462
|
+
const path = platform === "win32" ? win32 : posix;
|
|
463
|
+
const output = async (args) => {
|
|
464
|
+
const result = await adapters.run(npm.command, [...npm.prefix, ...args], { env: child, cwd: path.parse(npm.command).root, deadlineMs: deadlines.probe });
|
|
465
|
+
return succeeded(result) && result.truncated !== true ? String(result.stdout ?? "").trim() : null;
|
|
466
|
+
};
|
|
467
|
+
if (stable(await output(["--version"])) === null) return false;
|
|
468
|
+
const prefix = await output(["config", "get", "prefix"]);
|
|
469
|
+
return prefix !== null && path.isAbsolute(prefix) && !/[\r\n]/.test(prefix);
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
/** A usable npm: the first npm resolved in the child env.
|
|
473
|
+
* Any npm outside `$PNPM_HOME/bin` that works there (worksAsNpm) is accepted as
|
|
474
|
+
* { command, prefix }: on POSIX the command itself, on Windows an npm.exe (a
|
|
475
|
+
* Volta or mise shim) or what a known npm.cmd runs (windowsInvocation: Node.js's
|
|
476
|
+
* own npm.cmd, also behind nvm-windows or fnm, or an npm cmd-shim). For any npm
|
|
477
|
+
* in `$PNPM_HOME/bin` (shim or symlink), pnpm's global npm must resolve inside
|
|
478
|
+
* PNPM_HOME at the persistence pin and run, returned as { cli }.
|
|
479
|
+
* Returns { command, prefix }, { cli }, "npm-shadowed" (Windows: an earlier npm
|
|
480
|
+
* that is neither .cmd nor .exe wins) or false.
|
|
481
|
+
*/
|
|
482
|
+
export async function genuineNpm(child, platform, nodePath, adapters, globalBin = null) {
|
|
483
|
+
const path = platform === "win32" ? win32 : posix;
|
|
484
|
+
const { fs } = adapters;
|
|
485
|
+
const first = await lookPath("npm", child, platform, fs);
|
|
486
|
+
if (!first) return false;
|
|
487
|
+
const extension = path.extname(first).toLowerCase();
|
|
488
|
+
if (inGlobalBin(first, platform, globalBin)) {
|
|
489
|
+
if (platform === "win32" && extension !== ".cmd") return "npm-shadowed";
|
|
490
|
+
const cli = await globalShimEntry(first, "npm", "npm-cli.js", platform, globalBin, fs);
|
|
491
|
+
if (!cli || !entryShape(cli, path, "npm", "npm-cli.js")) return false;
|
|
492
|
+
return (await runsAsPackage(cli, "npm", persistencePins.npm, nodePath, child, platform, adapters)) ? { cli } : false;
|
|
493
|
+
}
|
|
494
|
+
if (platform === "win32" && ![".cmd", ".exe"].includes(extension)) return "npm-shadowed";
|
|
495
|
+
const npm = platform === "win32" ? await windowsInvocation(first, child, adapters) : { command: first, prefix: [] };
|
|
496
|
+
return npm !== null && (await worksAsNpm(npm, child, adapters, platform)) ? npm : false;
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
/** After adding pnpm, the first pnpm in the child env is pnpm's global shim in
|
|
500
|
+
* `$PNPM_HOME/bin` for pnpm at the persistence pin, which then runs.
|
|
501
|
+
*/
|
|
502
|
+
async function persistentPnpm(child, platform, nodePath, globalBin, adapters) {
|
|
503
|
+
const path = platform === "win32" ? win32 : posix;
|
|
504
|
+
const first = await lookPath("pnpm", child, platform, adapters.fs);
|
|
505
|
+
if (!first || !inGlobalBin(first, platform, globalBin)) return false;
|
|
506
|
+
if (platform === "win32" && path.extname(first).toLowerCase() !== ".cmd") return false;
|
|
507
|
+
const entry = await globalShimEntry(first, "pnpm", "pnpm.mjs", platform, globalBin, adapters.fs);
|
|
508
|
+
return entry !== null && runsAsPackage(entry, "pnpm", persistencePins.pnpm, nodePath, child, platform, adapters);
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
export function succeeded(result) {
|
|
512
|
+
return result?.code === 0 && !result.signal && result.timedOut !== true;
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
export function samePath(left, right, platform) {
|
|
516
|
+
const path = platform === "win32" ? win32 : posix;
|
|
517
|
+
const normal = (value) => path.normalize(value).replace(/(.)[\\/]+$/, "$1");
|
|
518
|
+
const [a, b] = [normal(left), normal(right)];
|
|
519
|
+
return platform === "win32" ? a.toLowerCase() === b.toLowerCase() : a === b;
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
/** True when `child` is strictly inside `parent` (case-insensitive on Windows). */
|
|
523
|
+
export function contains(parent, child, platform) {
|
|
524
|
+
const path = platform === "win32" ? win32 : posix;
|
|
525
|
+
const inside = path.relative(parent, child);
|
|
526
|
+
return inside.length > 0 && !inside.startsWith("..") && !path.isAbsolute(inside);
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
/** How many times Pi and gentle-pi are listed across every project and
|
|
530
|
+
* dependency field of a `list -g --json` output, or null when the shape is unknown.
|
|
531
|
+
*/
|
|
532
|
+
function stackListings(stdout) {
|
|
533
|
+
const projects = JSON.parse(stdout);
|
|
534
|
+
if (!Array.isArray(projects)) return null;
|
|
535
|
+
let listed = 0;
|
|
536
|
+
for (const project of projects) {
|
|
537
|
+
if (!plainObject(project)) return null;
|
|
538
|
+
for (const field of ["dependencies", "devDependencies", "optionalDependencies"]) {
|
|
539
|
+
const dependencies = project[field];
|
|
540
|
+
if (dependencies === undefined) continue;
|
|
541
|
+
if (!plainObject(dependencies)) return null;
|
|
542
|
+
listed += [PI_PACKAGE, SHELL_PACKAGE].filter((name) => Object.hasOwn(dependencies, name)).length;
|
|
543
|
+
}
|
|
544
|
+
}
|
|
545
|
+
return listed;
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
/** Shell-only (or Pi-only) pre-install `list -g --json`: true when no project
|
|
549
|
+
* lists the package, "existing-stack" when one does, false when the shape is unknown.
|
|
550
|
+
*/
|
|
551
|
+
function notListed(stdout, name) {
|
|
552
|
+
const projects = JSON.parse(stdout);
|
|
553
|
+
if (!Array.isArray(projects) || !projects.every(plainObject)) return false;
|
|
554
|
+
return projects.some((project) => ["dependencies", "devDependencies", "optionalDependencies"]
|
|
555
|
+
.some((field) => plainObject(project[field]) && Object.hasOwn(project[field], name))) ? "existing-stack" : true;
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
/** The version of the Pi the launcher runs from gentle-pi at `root`: Node's
|
|
559
|
+
* resolution from gentle-pi, nested in its node_modules first, then beside it
|
|
560
|
+
* (where pnpm 11 links its peer). Null when neither is a readable Pi package.
|
|
561
|
+
*/
|
|
562
|
+
export async function adjacentPiVersion(root, platform, fs) {
|
|
563
|
+
const path = platform === "win32" ? win32 : posix;
|
|
564
|
+
const nested = path.join(root, "node_modules", PI_PACKAGE, "package.json");
|
|
565
|
+
const beside = path.join(path.dirname(root), PI_PACKAGE, "package.json");
|
|
566
|
+
for (const file of [nested, beside]) {
|
|
567
|
+
const text = await fs.readText(file).catch(() => null);
|
|
568
|
+
if (text === null) continue;
|
|
569
|
+
try {
|
|
570
|
+
const manifest = JSON.parse(text);
|
|
571
|
+
return manifest?.name === PI_PACKAGE && typeof manifest.version === "string" ? manifest.version : null;
|
|
572
|
+
} catch {
|
|
573
|
+
return null;
|
|
574
|
+
}
|
|
575
|
+
}
|
|
576
|
+
return null;
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
/** That Pi's version when Gentle Shell can run it: a stable version at
|
|
580
|
+
* requirements.pi or newer. pnpm 11 installs gentle-pi's optional peer Pi in
|
|
581
|
+
* gentle-pi's own global group, at its latest version, so it may be newer than
|
|
582
|
+
* PI_INSTALL_VERSION. Null otherwise (missing, unreadable, prerelease or older).
|
|
583
|
+
*/
|
|
584
|
+
async function runnablePi(root, platform, fs) {
|
|
585
|
+
const version = await adjacentPiVersion(root, platform, fs);
|
|
586
|
+
return stable(version) !== null && atLeast(version, requirements.pi) ? version : null;
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
/** Pre-install `list -g --json`: true when neither package is listed in any
|
|
590
|
+
* project, "existing-stack" when one is, false when the shape is unknown.
|
|
591
|
+
*/
|
|
592
|
+
function noExistingStack(stdout) {
|
|
593
|
+
const listed = stackListings(stdout);
|
|
594
|
+
if (listed === null) return false;
|
|
595
|
+
return listed === 0 ? true : "existing-stack";
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
/** Installed gentle-pi root from `list -g --json`, confined under PNPM_HOME, with
|
|
599
|
+
* the version of the Pi the launcher runs from it ({ root, pi }), or null.
|
|
600
|
+
* Exactly one listed project may own gentle-pi and list Pi at the pin (unless
|
|
601
|
+
* only Gentle Shell was added), and that runnable Pi (runnablePi) must exist;
|
|
602
|
+
* other projects (such as the persisted npm and pnpm) are ignored.
|
|
603
|
+
*/
|
|
604
|
+
async function verifiedPackageRoot(stdout, pnpmHome, platform, fs, shellVersion = requirements.shell, requirePi = true) {
|
|
605
|
+
const path = platform === "win32" ? win32 : posix;
|
|
606
|
+
const projects = JSON.parse(stdout);
|
|
607
|
+
if (!Array.isArray(projects)) return null;
|
|
608
|
+
const owners = projects.filter((project) => plainObject(project?.dependencies) && Object.hasOwn(project.dependencies, SHELL_PACKAGE));
|
|
609
|
+
const dependencies = owners.length === 1 ? owners[0].dependencies : null;
|
|
610
|
+
if (!plainObject(dependencies)) return null;
|
|
611
|
+
const pi = dependencies[PI_PACKAGE];
|
|
612
|
+
const shell = dependencies[SHELL_PACKAGE];
|
|
613
|
+
if ((requirePi && pi?.version !== PI_INSTALL_VERSION) || shell?.version !== shellVersion) return null;
|
|
614
|
+
if (typeof shell.path !== "string" || !path.isAbsolute(shell.path)) return null;
|
|
615
|
+
const [root, home] = [await fs.realpath(shell.path), await fs.realpath(pnpmHome)];
|
|
616
|
+
if (!contains(home, root, platform)) return null;
|
|
617
|
+
const runs = await runnablePi(root, platform, fs);
|
|
618
|
+
return runs === null ? null : { root, pi: runs };
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
/** A stack this pnpm installed whose setup may only be rerun (setup recovery):
|
|
622
|
+
* Pi and gentle-pi are each listed exactly once, in the single project that
|
|
623
|
+
* verifiedPackageRoot accepts (Pi at PI_INSTALL_VERSION, gentle-pi at this
|
|
624
|
+
* package version, realpath confined under PNPM_HOME). Returns the root or null.
|
|
625
|
+
*/
|
|
626
|
+
export async function recoverableStackRoot(stdout, pnpmHome, platform, fs) {
|
|
627
|
+
return stackListings(stdout) === 2 ? (await verifiedPackageRoot(stdout, pnpmHome, platform, fs))?.root ?? null : null;
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
/** After persistence, node and npm must resolve from `$PNPM_HOME/bin` in the
|
|
631
|
+
* child env and that node must report the pinned version. Returns its path.
|
|
632
|
+
*/
|
|
633
|
+
async function persistentRuntime(child, platform, globalBin, adapters) {
|
|
634
|
+
const path = platform === "win32" ? win32 : posix;
|
|
635
|
+
const node = await lookPath("node", child, platform, adapters.fs);
|
|
636
|
+
const npm = await lookPath("npm", child, platform, adapters.fs);
|
|
637
|
+
const fromBin = (file) => file !== null && samePath(path.dirname(file), globalBin.path, platform);
|
|
638
|
+
if (!fromBin(node) || !fromBin(npm) || !spawnable(node, platform)) return null;
|
|
639
|
+
const result = await adapters.run(node, ["--version"], { env: child, deadlineMs: deadlines.probe });
|
|
640
|
+
return succeeded(result) && String(result.stdout ?? "").trim() === `v${persistencePins.node}` ? node : null;
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
/** npm's prefix setting from the environment, which npm reads case-insensitively. */
|
|
644
|
+
function explicitNpmPrefix(env, platform) {
|
|
645
|
+
return Object.keys(env).some((key) => key.toLowerCase() === "npm_config_prefix" ||
|
|
646
|
+
(platform === "win32" ? key.toUpperCase() === "PREFIX" : key === "PREFIX"));
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
/** With a pnpm-managed node, npm's default prefix is derived from a path inside
|
|
650
|
+
* pnpm's store, so `npm install -g` would write there. Only then, and only when
|
|
651
|
+
* the user has no explicit prefix (otherwise npm would report it), set the
|
|
652
|
+
* user-level prefix to PNPM_HOME. The store is the one `pnpm store path`
|
|
653
|
+
* reports; when it is unknown the step fails. Returns "configured",
|
|
654
|
+
* "unchanged" or null.
|
|
655
|
+
*/
|
|
656
|
+
async function configureNpmPrefix({ node, cli, child, platform, globalBin, adapters, storePath }) {
|
|
657
|
+
const path = platform === "win32" ? win32 : posix;
|
|
658
|
+
// Only the persisted npm's { cli } reaches this step ($PNPM_HOME/bin is first); anything else fails closed.
|
|
659
|
+
if (typeof cli !== "string") return null;
|
|
660
|
+
const npm = (args) => adapters.run(node, [cli, ...args], { env: child, deadlineMs: deadlines.probe });
|
|
661
|
+
const effective = async () => {
|
|
662
|
+
const result = await npm(["config", "get", "prefix"]);
|
|
663
|
+
const value = String(result.stdout ?? "").trim();
|
|
664
|
+
return succeeded(result) && path.isAbsolute(value) ? value : null;
|
|
665
|
+
};
|
|
666
|
+
const current = await effective();
|
|
667
|
+
if (current === null) return null;
|
|
668
|
+
if (explicitNpmPrefix(child, platform)) return "unchanged";
|
|
669
|
+
const reported = await storePath();
|
|
670
|
+
const value = String(reported?.stdout ?? "").trim();
|
|
671
|
+
if (!succeeded(reported) || !path.isAbsolute(value) || /[\r\n]/.test(value)) return null;
|
|
672
|
+
const store = await adapters.fs.realpath(value).catch(() => null);
|
|
673
|
+
if (store === null) return null;
|
|
674
|
+
const resolved = await adapters.fs.realpath(current).catch(() => current);
|
|
675
|
+
if (!contains(store, resolved, platform)) return "unchanged";
|
|
676
|
+
if (!succeeded(await npm(["config", "set", "prefix", globalBin.pnpmHome, "--location=user"]))) return null;
|
|
677
|
+
const after = await effective();
|
|
678
|
+
return after !== null && samePath(after, globalBin.pnpmHome, platform) ? "configured" : null;
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
/** Default integrity adapter: the package-local resolver, never a dev override.
|
|
682
|
+
* `resolve` is injectable only so trusted tests can stand in for a pinned binary.
|
|
683
|
+
*/
|
|
684
|
+
export async function packageNativeGentleAi({ packageRoot, platform, env, home }, resolve = resolveGentleAiBinary) {
|
|
685
|
+
const environment = { env, home };
|
|
686
|
+
if (gentleAiDevBinaryOverrideConfigured(environment)) return { ok: false, reason: "development-override" };
|
|
687
|
+
try {
|
|
688
|
+
const binary = resolve(packageRoot, platform, readFileSync, environment);
|
|
689
|
+
if (binary === gentleAiBinaryPath(packageRoot, platform)) return { ok: true };
|
|
690
|
+
} catch {
|
|
691
|
+
// Resolver errors name local paths; report only the classification.
|
|
692
|
+
}
|
|
693
|
+
return { ok: false, reason: "package-native-unverified" };
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
/** The failed steps whose sanitized detail line the outcome may carry. */
|
|
697
|
+
const detailSteps = Object.freeze(["shell-setup", "persist-path", "acquire-go", "install-global", "install-shell-main", "build-gentle-ai-main", "update-shell"]);
|
|
698
|
+
|
|
699
|
+
/** One displayable line from the bounded output of a failed `gentle-shell
|
|
700
|
+
* setup` or `pnpm setup`: the last error line (`Error:` or a pnpm `ERR_` code),
|
|
701
|
+
* else the last non-empty line.
|
|
702
|
+
* Terminal escapes, control and bidi characters are removed, the user's home
|
|
703
|
+
* becomes `~`, and the result has at most 300 characters. Null when nothing
|
|
704
|
+
* remains. Untrusted output: the host still shows it only as text.
|
|
705
|
+
*/
|
|
706
|
+
export function setupErrorDetail(text, home, platform) {
|
|
707
|
+
if (typeof text !== "string") return null;
|
|
708
|
+
const lines = text.split(/\r\n|\r|\n/).map((line) => line
|
|
709
|
+
.replace(/\u001b\][^\u0007\u001b]*(?:\u0007|\u001b\\)?/g, "")
|
|
710
|
+
.replace(/(?:\u001b\[|\u009b)[0-?]*[ -/]*[@-~]/g, "")
|
|
711
|
+
.replace(/\t/g, " ")
|
|
712
|
+
.replace(/[\u0000-\u001f\u007f-\u009f\u200b-\u200f\u202a-\u202e\u2066-\u2069]/g, "")
|
|
713
|
+
.trim()).filter((line) => line.length > 0);
|
|
714
|
+
let line = lines.findLast((candidate) => /Error:|\bERR_[A-Z0-9_]+/.test(candidate)) ?? lines.at(-1);
|
|
715
|
+
if (line === undefined) return null;
|
|
716
|
+
const path = platform === "win32" ? win32 : posix;
|
|
717
|
+
const root = typeof home === "string" && path.isAbsolute(home) ? home.replace(/[\\/]+$/, "") : "";
|
|
718
|
+
if (root.length > 1) {
|
|
719
|
+
// Only the whole home path, not a longer sibling such as /home/user for /home/u.
|
|
720
|
+
line = line.replace(new RegExp(`${escapeRegExp(root)}(?=$|[\\\\/\\s"'():,;\\]])`, platform === "win32" ? "gi" : "g"), "~");
|
|
721
|
+
}
|
|
722
|
+
return Array.from(line).slice(0, 300).join("");
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
/**
|
|
726
|
+
* runStandardInstall({ plan, consent }, adapters) -> { outcome, ... }
|
|
727
|
+
* Outcomes: blocked (nothing installed), failed (stopped after `completed`),
|
|
728
|
+
* terminal-action-required (installed; user PATH persisted, open a new
|
|
729
|
+
* terminal) or ready. A failed `shell-setup`, `persist-path`, `install-global`,
|
|
730
|
+
* `install-shell-main`, `build-gentle-ai-main` or `update-shell` may add `detail`, one
|
|
731
|
+
* sanitized line (setupErrorDetail) of the failed command's output or of the main
|
|
732
|
+
* channel's error, and a failed `acquire-go` the Go folder in its way, with the home
|
|
733
|
+
* as ~; no other output is kept. When the plan persists the Node runtime, successful
|
|
734
|
+
* outcomes also report npmPrefix: "configured" or "unchanged". When the Pi beside a pnpm
|
|
735
|
+
* gentle-pi (the one Gentle Shell runs) is not PI_INSTALL_VERSION, they also report its
|
|
736
|
+
* piVersion (pnpm 11 may install a newer peer Pi, at least requirements.pi). Adapters: platform, nodePath, env (user env), home?,
|
|
737
|
+
* run(command, argv, { env, deadlineMs, stderrTail? }) with shell:false semantics returning
|
|
738
|
+
* { code, signal, timedOut, stdout, stderrTail? }, fs { isFile, realpath, readText },
|
|
739
|
+
* verifyGentleAi({ packageRoot, platform, env, home }) and log({ step, status }).
|
|
740
|
+
* An update plan also uses locateShell() and upgradeShell({ channel, packageRoot,
|
|
741
|
+
* currentVersion, goPath? }); a plan that updates an older Pi uses locatePi(), which returns
|
|
742
|
+
* the single installed Pi as { root, version, owner } or null. A plan that acquires
|
|
743
|
+
* Go uses acquireGo(), which returns the published pinned Go as { goPath }. A plan
|
|
744
|
+
* with a private Windows PNPM_HOME (tools.pnpmHome, S6) uses preparePnpmHome(home)
|
|
745
|
+
* before any command, then persists it with `pnpm setup`.
|
|
746
|
+
* Nothing is ever deleted; no provisioning marker is written. A setup-recovery
|
|
747
|
+
* plan replaces check-existing-stack with check-recoverable-stack and skips
|
|
748
|
+
* install-global; every later step and outcome rule is the same.
|
|
749
|
+
*/
|
|
750
|
+
export async function runStandardInstall(request, adapters) {
|
|
751
|
+
const { platform, env, log = () => {} } = adapters;
|
|
752
|
+
const completed = [];
|
|
753
|
+
const blocked = (reason) => {
|
|
754
|
+
log({ step: "gate", status: "blocked", reason });
|
|
755
|
+
return { outcome: "blocked", reason, completed };
|
|
756
|
+
};
|
|
757
|
+
if (!validRequest(request)) return blocked("invalid-request");
|
|
758
|
+
if (request.consent !== true) return blocked("consent-required");
|
|
759
|
+
const gate = planGate(request.plan, platform);
|
|
760
|
+
if (gate) return blocked(gate);
|
|
761
|
+
|
|
762
|
+
const path = platform === "win32" ? win32 : posix;
|
|
763
|
+
const globalBin = pnpmGlobalBin({ platform, env });
|
|
764
|
+
if (!globalBin) return blocked("pnpm-home-unknown");
|
|
765
|
+
// A private Windows PNPM_HOME (S6): exactly the folder preflight recorded.
|
|
766
|
+
const privateHome = platform === "win32" && request.plan.tools.pnpmHome?.status === "private";
|
|
767
|
+
if (privateHome && !samePath(String(request.plan.tools.pnpmHome.path ?? ""), globalBin.pnpmHome, platform)) return blocked("pnpm-home-changed");
|
|
768
|
+
if (!path.isAbsolute(adapters.nodePath ?? "")) return blocked("node-unavailable");
|
|
769
|
+
const child = childEnvironment(env, platform, globalBin, { privateHome });
|
|
770
|
+
const pnpm = await pnpmInvocation(env, platform, adapters.fs).catch(() => null);
|
|
771
|
+
if (!pnpm) return blocked("pnpm-unavailable");
|
|
772
|
+
const runPnpm = (args, deadlineMs, environment = child, options = {}) => adapters.run(pnpm.command, [...pnpm.prefix, ...args], { env: environment, deadlineMs, ...options });
|
|
773
|
+
const home = adapters.home ?? (platform === "win32" ? env.USERPROFILE : env.HOME);
|
|
774
|
+
let setupDetail = null;
|
|
775
|
+
// pnpm prints its own errors, such as ERR_PNPM_UNKNOWN_SHELL or a failed postinstall, on stdout.
|
|
776
|
+
const outputDetail = (result) => setupErrorDetail(result?.stderrTail, home, platform) ?? setupErrorDetail(result?.stdout, home, platform);
|
|
777
|
+
// Only the main channel's own errors: their message, then the failed command's stderr tail.
|
|
778
|
+
const mainChannelDetail = (error) => (error instanceof MainChannelError
|
|
779
|
+
? setupErrorDetail([error.message, error.cause?.message].filter((text) => typeof text === "string").join("\n"), home, platform) : null);
|
|
780
|
+
// A pnpm global add whose failure keeps pnpm's last error line as the detail.
|
|
781
|
+
const addGlobal = async (args, environment) => {
|
|
782
|
+
const result = await runPnpm(["add", "-g", ...args], deadlines.install, environment, { stderrTail: 4096 });
|
|
783
|
+
if (succeeded(result)) return true;
|
|
784
|
+
setupDetail = outputDetail(result);
|
|
785
|
+
return false;
|
|
786
|
+
};
|
|
787
|
+
// A main-channel call whose MainChannelError becomes the detail; any other error stays hidden.
|
|
788
|
+
const withMainDetail = (step) => async () => {
|
|
789
|
+
try {
|
|
790
|
+
return await step();
|
|
791
|
+
} catch (error) {
|
|
792
|
+
setupDetail = mainChannelDetail(error);
|
|
793
|
+
return false;
|
|
794
|
+
}
|
|
795
|
+
};
|
|
796
|
+
|
|
797
|
+
const list = () => runPnpm(["list", "-g", "--depth", "0", "--json"], deadlines.probe);
|
|
798
|
+
const ids = request.plan.actions.map((action) => action.id);
|
|
799
|
+
// planGate accepted either the clean-stack path or the fixed setup recovery.
|
|
800
|
+
const update = ids.find((id) => updateActions.includes(id));
|
|
801
|
+
// An older Pi is updated first; alone, nothing else runs (planGate: piOnlyVariants).
|
|
802
|
+
const updatingPi = ids.includes("update-pi");
|
|
803
|
+
const piOnly = (updatingPi || ids.includes("install-pi")) && !ids.includes("install-shell") && !update;
|
|
804
|
+
const recovering = !ids.includes("install-shell") && !update && !piOnly;
|
|
805
|
+
const shellOnly = ids.includes("install-shell") && !ids.includes("install-pi");
|
|
806
|
+
const main = ids.includes("install-shell-main");
|
|
807
|
+
// Fixed variants (planGate): runtime + both managers, or one add of the missing ones.
|
|
808
|
+
const persistRuntime = ids.includes("persist-node");
|
|
809
|
+
const addNpm = ids.includes("persist-package-managers") || ids.includes("persist-npm");
|
|
810
|
+
const addPnpm = ids.includes("persist-package-managers") || ids.includes("persist-pnpm");
|
|
811
|
+
let npmCli = null;
|
|
812
|
+
const npmCheck = async () => {
|
|
813
|
+
const verdict = await genuineNpm(child, platform, adapters.nodePath, adapters, globalBin);
|
|
814
|
+
if (typeof verdict !== "object" || verdict === null) return verdict;
|
|
815
|
+
npmCli = verdict.cli ?? null;
|
|
816
|
+
return true;
|
|
817
|
+
};
|
|
818
|
+
|
|
819
|
+
// Pre-install gates: a false check or adapter exception blocks before mutation;
|
|
820
|
+
// a check may return a more specific blocked reason instead of false.
|
|
821
|
+
// An npm that is about to be persisted is checked only after it has been added.
|
|
822
|
+
let installed = null;
|
|
823
|
+
let installedPi = null;
|
|
824
|
+
let npmCommand = null;
|
|
825
|
+
// The Pi to update is still exactly the older one preflight recorded, from the
|
|
826
|
+
// same owner: never a reinstall or downgrade. For npm, the npm on PATH must
|
|
827
|
+
// install into the global root that holds it.
|
|
828
|
+
const checkInstalledPi = async () => {
|
|
829
|
+
const recorded = request.plan.tools.pi;
|
|
830
|
+
const found = await adapters.locatePi?.();
|
|
831
|
+
if (!found || !["pnpm", "npm"].includes(found.owner) || found.owner !== recorded?.owner || found.version !== recorded?.version) return false;
|
|
832
|
+
if (stable(found.version) === null || atLeast(found.version, requirements.pi)) return false;
|
|
833
|
+
if (typeof found.root !== "string" || !path.isAbsolute(found.root)) return false;
|
|
834
|
+
if (found.owner === "npm") {
|
|
835
|
+
// On Windows, what npm.cmd runs (windowsInvocation), never cmd.exe.
|
|
836
|
+
const npm = await npmInvocation(env, platform, adapters);
|
|
837
|
+
if (!npm) return false;
|
|
838
|
+
const result = await adapters.run(npm.command, [...npm.prefix, "root", "-g"], { env, deadlineMs: deadlines.probe });
|
|
839
|
+
const reported = succeeded(result) ? String(result.stdout ?? "").trim().split(/\r?\n/).at(-1).trim() : "";
|
|
840
|
+
const npmRoot = path.isAbsolute(reported) ? await adapters.fs.realpath(reported) : null;
|
|
841
|
+
if (installOwner({ packageRoot: found.root, pnpmHome: null, npmRoot, name: PI_PACKAGE, platform }) !== "npm") return false;
|
|
842
|
+
npmCommand = npm;
|
|
843
|
+
}
|
|
844
|
+
installedPi = found;
|
|
845
|
+
return true;
|
|
846
|
+
};
|
|
847
|
+
const checks = [
|
|
848
|
+
...(addNpm ? [] : [["check-npm", "npm-unavailable", npmCheck]]),
|
|
849
|
+
["check-global-bin", "global-bin-mismatch", async () => {
|
|
850
|
+
const result = await runPnpm(["bin", "-g"], deadlines.probe);
|
|
851
|
+
const reported = String(result.stdout ?? "").trim();
|
|
852
|
+
return succeeded(result) && path.isAbsolute(reported) && samePath(reported, globalBin.path, platform);
|
|
853
|
+
}],
|
|
854
|
+
// Never rely on the caller's plan alone: an existing Pi or gentle-pi is not overwritten,
|
|
855
|
+
// and a recovery still finds exactly the pinned stack this pnpm installed.
|
|
856
|
+
...(piOnly ? [] : [update
|
|
857
|
+
? ["check-installed-shell", "existing-stack-unverified", async () => {
|
|
858
|
+
installed = await adapters.locateShell?.();
|
|
859
|
+
return ["pnpm", "npm"].includes(installed?.owner) && typeof installed.root === "string" && path.isAbsolute(installed.root);
|
|
860
|
+
}]
|
|
861
|
+
: shellOnly
|
|
862
|
+
? ["check-existing-shell", "global-list-unavailable", async () => {
|
|
863
|
+
const result = await list();
|
|
864
|
+
return succeeded(result) && notListed(String(result.stdout ?? ""), SHELL_PACKAGE);
|
|
865
|
+
}]
|
|
866
|
+
: recovering
|
|
867
|
+
? ["check-recoverable-stack", "global-list-unavailable", async () => {
|
|
868
|
+
const result = await list();
|
|
869
|
+
if (!succeeded(result)) return false;
|
|
870
|
+
const root = await recoverableStackRoot(String(result.stdout ?? ""), globalBin.pnpmHome, platform, adapters.fs)
|
|
871
|
+
.catch(() => null);
|
|
872
|
+
return root !== null || "existing-stack-unverified";
|
|
873
|
+
}]
|
|
874
|
+
: ["check-existing-stack", "global-list-unavailable", async () => {
|
|
875
|
+
const result = await list();
|
|
876
|
+
return succeeded(result) && noExistingStack(String(result.stdout ?? ""));
|
|
877
|
+
}]]),
|
|
878
|
+
...(updatingPi ? [["check-installed-pi", "existing-stack-unverified", checkInstalledPi]] : []),
|
|
879
|
+
// The installer's Pi added on its own: pnpm must not list any Pi yet.
|
|
880
|
+
...(piOnly && !updatingPi ? [["check-existing-pi", "global-list-unavailable", async () => {
|
|
881
|
+
const result = await list();
|
|
882
|
+
return succeeded(result) && notListed(String(result.stdout ?? ""), PI_PACKAGE);
|
|
883
|
+
}]] : []),
|
|
884
|
+
];
|
|
885
|
+
// The private PNPM_HOME is claimed (or the one this flow created is kept) before
|
|
886
|
+
// any pnpm command could create it with inherited permissions.
|
|
887
|
+
if (privateHome) {
|
|
888
|
+
const claimed = await Promise.resolve().then(() => adapters.preparePnpmHome(globalBin.pnpmHome)).then(() => true, () => false);
|
|
889
|
+
if (!claimed) {
|
|
890
|
+
log({ step: "prepare-pnpm-home", status: "failed" });
|
|
891
|
+
return { outcome: "failed", failedStep: "prepare-pnpm-home", completed };
|
|
892
|
+
}
|
|
893
|
+
completed.push("prepare-pnpm-home");
|
|
894
|
+
log({ step: "prepare-pnpm-home", status: "done" });
|
|
895
|
+
}
|
|
896
|
+
for (const [step, reason, check] of checks) {
|
|
897
|
+
const verdict = await check().catch(() => false);
|
|
898
|
+
if (verdict !== true) return blocked(typeof verdict === "string" ? verdict : reason);
|
|
899
|
+
completed.push(step);
|
|
900
|
+
log({ step, status: "done" });
|
|
901
|
+
}
|
|
902
|
+
|
|
903
|
+
// Mutating and post-install steps: a false result or exception is a failure.
|
|
904
|
+
let packageRoot = null;
|
|
905
|
+
// The Pi the launcher runs beside a pnpm gentle-pi, once verified.
|
|
906
|
+
let runsPi = null;
|
|
907
|
+
let persistentNode = null;
|
|
908
|
+
let npmPrefix = null;
|
|
909
|
+
// One add of only the missing managers (fixed versions only: never a blanket build approval).
|
|
910
|
+
const addOnly = ids.find((id) => ["persist-package-managers", "persist-npm", "persist-pnpm"].includes(id));
|
|
911
|
+
const addManagers = [addOnly, async () => succeeded(await runPnpm(["add", "-g", ...(addNpm ? [`npm@${persistencePins.npm}`] : []),
|
|
912
|
+
...(addPnpm ? [`pnpm@${persistencePins.pnpm}`] : [])], deadlines.install))];
|
|
913
|
+
const persistence = [
|
|
914
|
+
["persist-node", async () => succeeded(await runPnpm(["runtime", "set", "node", persistencePins.node, "-g"], deadlines.install))],
|
|
915
|
+
// npm, plus pnpm unless a persistent pnpm (never downgraded) is already there.
|
|
916
|
+
addManagers,
|
|
917
|
+
["verify-persistent-runtime", async () => (persistentNode = await persistentRuntime(child, platform, globalBin, adapters)) !== null],
|
|
918
|
+
["check-npm", async () => (await npmCheck()) === true],
|
|
919
|
+
["configure-npm-prefix", async () => (npmPrefix = await configureNpmPrefix({ node: persistentNode, cli: npmCli, child,
|
|
920
|
+
platform, globalBin, adapters, storePath: () => runPnpm(["store", "path"], deadlines.probe) })) !== null],
|
|
921
|
+
];
|
|
922
|
+
// A persistent Node is never replaced: one add of only the missing managers.
|
|
923
|
+
const packageManagers = [
|
|
924
|
+
addManagers,
|
|
925
|
+
...(addNpm ? [["check-npm", async () => (await npmCheck()) === true]] : []),
|
|
926
|
+
...(addPnpm ? [["verify-persistent-pnpm", () => persistentPnpm(child, platform, adapters.nodePath, globalBin, adapters)]] : []),
|
|
927
|
+
];
|
|
928
|
+
// A recovery never runs `add -g`: the installed packages are verified as they are.
|
|
929
|
+
// The older Pi, updated with the package manager that owns it, then found again:
|
|
930
|
+
// the next probe must see a single Pi (one listed twice is located as null)
|
|
931
|
+
// from the same owner at the target version, never a downgrade.
|
|
932
|
+
const piSteps = updatingPi ? [
|
|
933
|
+
["update-pi", async () => {
|
|
934
|
+
const spec = `${PI_PACKAGE}@${PI_INSTALL_VERSION}`;
|
|
935
|
+
const result = installedPi.owner === "npm"
|
|
936
|
+
? await adapters.run(npmCommand.command, [...npmCommand.prefix, "install", "-g", spec], { env, deadlineMs: deadlines.install })
|
|
937
|
+
: await runPnpm(["add", "-g", spec], deadlines.install);
|
|
938
|
+
return succeeded(result);
|
|
939
|
+
}],
|
|
940
|
+
["verify-updated-pi", async () => {
|
|
941
|
+
const after = await adapters.locatePi();
|
|
942
|
+
if (!after || after.owner !== installedPi.owner || typeof after.root !== "string" || !path.isAbsolute(after.root)) return false;
|
|
943
|
+
if (stable(after.version) === null || !atLeast(after.version, PI_INSTALL_VERSION)) return false;
|
|
944
|
+
// npm replaces the package in place; pnpm 11 moves it to a new global directory.
|
|
945
|
+
return after.owner === "pnpm" || samePath(after.root, installedPi.root, platform);
|
|
946
|
+
}],
|
|
947
|
+
] : [];
|
|
948
|
+
// The installer's pinned Go, when acquired: never on the user's PATH, only first
|
|
949
|
+
// on the PATH of the children that may build Gentle AI (gentle-pi's Windows
|
|
950
|
+
// postinstall finds go.exe there) and passed by path to the main build.
|
|
951
|
+
let pinnedGo = null;
|
|
952
|
+
const buildEnv = () => (pinnedGo ? goFirstEnvironment(child, platform, pinnedGo) : child);
|
|
953
|
+
const goSteps = ids.includes("acquire-go") ? [
|
|
954
|
+
["acquire-go", async () => {
|
|
955
|
+
try {
|
|
956
|
+
pinnedGo = (await adapters.acquireGo())?.goPath ?? null;
|
|
957
|
+
} catch (error) {
|
|
958
|
+
// Only a folder in the way is reported, by its path with the home as ~.
|
|
959
|
+
const cause = String(error?.cause?.message ?? "");
|
|
960
|
+
if (cause.startsWith("Conflicting Go destination: ")) setupDetail = setupErrorDetail(cause, home, platform);
|
|
961
|
+
return false;
|
|
962
|
+
}
|
|
963
|
+
return typeof pinnedGo === "string";
|
|
964
|
+
}],
|
|
965
|
+
// It runs and reports exactly the pinned version; GOTOOLCHAIN=local keeps it from switching toolchains.
|
|
966
|
+
["verify-go", async () => {
|
|
967
|
+
if (!path.isAbsolute(pinnedGo) || !spawnable(pinnedGo, platform)) return false;
|
|
968
|
+
const result = await adapters.run(pinnedGo, ["version"], { env: { ...child, GOTOOLCHAIN: "local" }, cwd: path.dirname(pinnedGo),
|
|
969
|
+
deadlineMs: deadlines.probe });
|
|
970
|
+
return succeeded(result) && new RegExp(`^go version go${escapeRegExp(goAcquisition.version)} \\S+$`).test(String(result.stdout ?? "").trim());
|
|
971
|
+
}],
|
|
972
|
+
] : [];
|
|
973
|
+
// Pi and gentle-pi stay separate pnpm groups, so `pi update` (which replaces Pi's
|
|
974
|
+
// own group) never removes gentle-pi; gentle-pi gets pnpm's peer Pi beside it.
|
|
975
|
+
const install = ["install-global", () => addGlobal([...(shellOnly ? [] : [`${PI_PACKAGE}@${PI_INSTALL_VERSION}`]),
|
|
976
|
+
`${SHELL_PACKAGE}@${requirements.shell}`, `--allow-build=${SHELL_PACKAGE}`], buildEnv())];
|
|
977
|
+
function mainSteps() {
|
|
978
|
+
const channel = adapters.mainChannel;
|
|
979
|
+
const ctx = { env, home };
|
|
980
|
+
const runIn = (command, argv, options = {}) => adapters.run(command, argv, { env: options.env ?? child, cwd: options.cwd, deadlineMs: options.deadlineMs ?? deadlines.install,
|
|
981
|
+
...(options.stderrTail === undefined ? {} : { stderrTail: options.stderrTail }) });
|
|
982
|
+
let gentleAiCommit = null;
|
|
983
|
+
let shellCommit = null;
|
|
984
|
+
return [
|
|
985
|
+
["build-gentle-ai-main", withMainDetail(async () => {
|
|
986
|
+
const goPath = pinnedGo ?? await lookPath("go", env, platform, adapters.fs);
|
|
987
|
+
if (!goPath || !channel) return false;
|
|
988
|
+
gentleAiCommit = await channel.resolveCommit(GENTLE_AI_REPOSITORY);
|
|
989
|
+
await channel.buildGentleAi({ commit: gentleAiCommit, goPath, platform, ctx, run: runIn });
|
|
990
|
+
return true;
|
|
991
|
+
})],
|
|
992
|
+
["install-shell-main", withMainDetail(async () => {
|
|
993
|
+
shellCommit = await channel.resolveCommit(SHELL_REPOSITORY);
|
|
994
|
+
const tgz = await channel.packShell({ commit: shellCommit, ctx, run: runIn, pnpm, platform });
|
|
995
|
+
if (!(await addGlobal([tgz, `--allow-build=${SHELL_PACKAGE}`], buildEnv()))) return false;
|
|
996
|
+
const result = await list();
|
|
997
|
+
if (!succeeded(result)) return false;
|
|
998
|
+
const verified = await verifiedPackageRoot(String(result.stdout ?? ""), globalBin.pnpmHome, platform, adapters.fs,
|
|
999
|
+
mainVersion(requirements.shell, shellCommit), !shellOnly);
|
|
1000
|
+
if (verified === null) return false;
|
|
1001
|
+
({ root: packageRoot, pi: runsPi } = verified);
|
|
1002
|
+
return true;
|
|
1003
|
+
})],
|
|
1004
|
+
["record-channel", async () => {
|
|
1005
|
+
await channel.writeChannel(ctx, { channel: "main", shellCommit, gentleAiCommit });
|
|
1006
|
+
return true;
|
|
1007
|
+
}],
|
|
1008
|
+
];
|
|
1009
|
+
}
|
|
1010
|
+
const steps = [
|
|
1011
|
+
// The pinned Go first: a failed download leaves nothing persisted or installed.
|
|
1012
|
+
...goSteps,
|
|
1013
|
+
...(persistRuntime ? persistence : addOnly ? packageManagers : []),
|
|
1014
|
+
...piSteps,
|
|
1015
|
+
...(recovering ? [] : [install]),
|
|
1016
|
+
["verify-global-list", async () => {
|
|
1017
|
+
const result = await list();
|
|
1018
|
+
if (!succeeded(result)) return false;
|
|
1019
|
+
const verified = await verifiedPackageRoot(String(result.stdout ?? ""), globalBin.pnpmHome, platform, adapters.fs, requirements.shell, !shellOnly);
|
|
1020
|
+
if (verified === null) return false;
|
|
1021
|
+
({ root: packageRoot, pi: runsPi } = verified);
|
|
1022
|
+
return true;
|
|
1023
|
+
}],
|
|
1024
|
+
["verify-shell-bin", () => adapters.fs.isFile(path.join(globalBin.path, platform === "win32" ? "gentle-shell.cmd" : "gentle-shell"))],
|
|
1025
|
+
["verify-gentle-ai", async () => (await adapters.verifyGentleAi({ packageRoot, platform, env, home }))?.ok === true],
|
|
1026
|
+
// Main overlays the verified release stack: Gentle AI built from the latest
|
|
1027
|
+
// main commit (registered as the override), then Gentle Shell packed from
|
|
1028
|
+
// it. Setup below then runs from the main package.
|
|
1029
|
+
...(main ? mainSteps() : []),
|
|
1030
|
+
// The setup commands are the only ones whose stderr tail is requested.
|
|
1031
|
+
["shell-setup", async () => {
|
|
1032
|
+
const result = await adapters.run(adapters.nodePath, [path.join(packageRoot, "bin", "gentle-shell.mjs"), "setup"],
|
|
1033
|
+
{ env: setupEnvironment(child, platform), deadlineMs: deadlines.setup, stderrTail: 4096 });
|
|
1034
|
+
if (succeeded(result)) return true;
|
|
1035
|
+
setupDetail = setupErrorDetail(result?.stderrTail, home, platform);
|
|
1036
|
+
return false;
|
|
1037
|
+
}],
|
|
1038
|
+
];
|
|
1039
|
+
if (update) {
|
|
1040
|
+
// Update an existing Gentle Shell with its owner's package manager (the same
|
|
1041
|
+
// logic as `gentle-shell upgrade`), confirm the result, then set it up.
|
|
1042
|
+
const channel = update === "update-shell-main" ? "main" : "release";
|
|
1043
|
+
const setupStep = steps.find(([name]) => name === "shell-setup");
|
|
1044
|
+
steps.splice(0, steps.length,
|
|
1045
|
+
...goSteps,
|
|
1046
|
+
...(ids.includes("install-pi") ? [["install-pi", async () => succeeded(await runPnpm(["add", "-g", `${PI_PACKAGE}@${PI_INSTALL_VERSION}`], deadlines.install))]] : []),
|
|
1047
|
+
...piSteps,
|
|
1048
|
+
["update-shell", withMainDetail(async () => (await adapters.upgradeShell({ channel, packageRoot: installed.root, currentVersion: installed.version,
|
|
1049
|
+
...(pinnedGo ? { goPath: pinnedGo } : {}) })) === true)],
|
|
1050
|
+
["verify-updated-shell", async () => {
|
|
1051
|
+
const after = await adapters.locateShell();
|
|
1052
|
+
const version = String(after?.version ?? "");
|
|
1053
|
+
const expected = channel === "main" ? MAIN_VERSION.test(version)
|
|
1054
|
+
: stable(version) !== null && (stable(installed.version) === null || atLeast(version, installed.version));
|
|
1055
|
+
if (!expected || after.owner !== installed.owner || !path.isAbsolute(after.root)) return false;
|
|
1056
|
+
// pnpm gives the updated gentle-pi its peer Pi beside it: the one Gentle Shell runs.
|
|
1057
|
+
if (after.owner === "pnpm" && (runsPi = await runnablePi(after.root, platform, adapters.fs)) === null) return false;
|
|
1058
|
+
packageRoot = after.root;
|
|
1059
|
+
return true;
|
|
1060
|
+
}],
|
|
1061
|
+
...(channel === "release" ? [["verify-gentle-ai", async () => (await adapters.verifyGentleAi({ packageRoot, platform, env, home }))?.ok === true]] : []),
|
|
1062
|
+
setupStep);
|
|
1063
|
+
}
|
|
1064
|
+
// A child PATH never proves a fresh terminal; persist it with pnpm's own setup.
|
|
1065
|
+
// globalBin.onPath was computed from the user's own PATH, not the child env.
|
|
1066
|
+
// pnpm setup installs @pnpm/exe over the network, so it gets the setup deadline.
|
|
1067
|
+
// An update keeps the PATH its existing installation already uses, except in a
|
|
1068
|
+
// new private PNPM_HOME, which pnpm setup must persist for new terminals.
|
|
1069
|
+
if (piOnly) {
|
|
1070
|
+
// The installer's Pi alone: the same `add -g` as install-pi, then found in pnpm's list.
|
|
1071
|
+
steps.splice(0, steps.length, ...(updatingPi ? piSteps : [
|
|
1072
|
+
["install-pi", async () => succeeded(await runPnpm(["add", "-g", `${PI_PACKAGE}@${PI_INSTALL_VERSION}`], deadlines.install))],
|
|
1073
|
+
["verify-installed-pi", async () => {
|
|
1074
|
+
const after = await adapters.locatePi();
|
|
1075
|
+
return after?.owner === "pnpm" && typeof after.root === "string" && path.isAbsolute(after.root) &&
|
|
1076
|
+
stable(after.version) !== null && atLeast(after.version, PI_INSTALL_VERSION);
|
|
1077
|
+
}],
|
|
1078
|
+
]));
|
|
1079
|
+
}
|
|
1080
|
+
const persistPath = !globalBin.onPath && (privateHome || (!update && !piOnly));
|
|
1081
|
+
if (persistPath) {
|
|
1082
|
+
steps.push(["persist-path", async () => {
|
|
1083
|
+
const result = await adapters.run(pnpm.command, [...pnpm.prefix, "setup"], { env: child, deadlineMs: deadlines.setup, stderrTail: 4096 });
|
|
1084
|
+
if (succeeded(result)) return true;
|
|
1085
|
+
setupDetail = outputDetail(result);
|
|
1086
|
+
return false;
|
|
1087
|
+
}]);
|
|
1088
|
+
}
|
|
1089
|
+
for (const [step, run] of steps) {
|
|
1090
|
+
if (!(await Promise.resolve().then(run).catch(() => false))) {
|
|
1091
|
+
log({ step, status: "failed" });
|
|
1092
|
+
const detail = detailSteps.includes(step) && setupDetail ? { detail: setupDetail } : {};
|
|
1093
|
+
return { outcome: "failed", failedStep: step, completed, ...detail };
|
|
1094
|
+
}
|
|
1095
|
+
completed.push(step);
|
|
1096
|
+
log({ step, status: "done" });
|
|
1097
|
+
}
|
|
1098
|
+
// Reported only when Gentle Shell runs a Pi other than the installer's.
|
|
1099
|
+
const prefix = { ...(persistRuntime ? { npmPrefix } : {}), ...(runsPi !== null && runsPi !== PI_INSTALL_VERSION ? { piVersion: runsPi } : {}) };
|
|
1100
|
+
if (persistPath) return { outcome: "terminal-action-required", action: "open-new-terminal", completed, ...prefix };
|
|
1101
|
+
return { outcome: "ready", completed, ...prefix };
|
|
1102
|
+
}
|