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,881 @@
|
|
|
1
|
+
import { createHash, randomBytes, timingSafeEqual } from "node:crypto";
|
|
2
|
+
import { readFile } from "node:fs/promises";
|
|
3
|
+
import { createServer } from "node:http";
|
|
4
|
+
import { dirname, join } from "node:path";
|
|
5
|
+
import { PI_INSTALL_VERSION, goAcquisition, requirements } from "./installer-preflight.mjs";
|
|
6
|
+
|
|
7
|
+
// Local wizard host: a dependency-free loopback HTTP server with a fixed route
|
|
8
|
+
// allowlist. The browser never sends commands, paths, plans or environment:
|
|
9
|
+
// plans are built here, stored here and handed to the injected runner here.
|
|
10
|
+
|
|
11
|
+
const HOST = "127.0.0.1";
|
|
12
|
+
const COOKIE = "gentle_install_session";
|
|
13
|
+
const SECOND = 1000;
|
|
14
|
+
const MINUTE = 60 * SECOND;
|
|
15
|
+
const defaultLimits = Object.freeze({
|
|
16
|
+
codeTtlMs: 2 * MINUTE,
|
|
17
|
+
idleMs: 30 * MINUTE,
|
|
18
|
+
idleCheckMs: 15 * SECOND,
|
|
19
|
+
bodyBytes: 1024,
|
|
20
|
+
logEntries: 200,
|
|
21
|
+
headersTimeoutMs: 10 * SECOND,
|
|
22
|
+
requestTimeoutMs: 30 * SECOND,
|
|
23
|
+
});
|
|
24
|
+
const securityHeaders = Object.freeze({
|
|
25
|
+
"Content-Security-Policy": "default-src 'self'; frame-ancestors 'none'; base-uri 'none'; form-action 'none'",
|
|
26
|
+
"X-Content-Type-Options": "nosniff",
|
|
27
|
+
"Cache-Control": "no-store",
|
|
28
|
+
"Referrer-Policy": "no-referrer",
|
|
29
|
+
"X-Frame-Options": "DENY",
|
|
30
|
+
"Cross-Origin-Resource-Policy": "same-origin",
|
|
31
|
+
"Cross-Origin-Opener-Policy": "same-origin",
|
|
32
|
+
});
|
|
33
|
+
// Fixed asset names only: the request path never reaches the filesystem.
|
|
34
|
+
const assets = Object.freeze({
|
|
35
|
+
"/": { file: "index.html", type: "text/html; charset=utf-8" },
|
|
36
|
+
"/wizard.js": { file: "wizard.js", type: "text/javascript; charset=utf-8" },
|
|
37
|
+
"/wizard.css": { file: "wizard.css", type: "text/css; charset=utf-8" },
|
|
38
|
+
});
|
|
39
|
+
const routes = Object.freeze({
|
|
40
|
+
...Object.fromEntries(Object.keys(assets).map((path) => [path, "GET"])),
|
|
41
|
+
"/session": "GET",
|
|
42
|
+
"/api/plan": "GET",
|
|
43
|
+
"/api/progress": "GET",
|
|
44
|
+
"/api/install": "POST",
|
|
45
|
+
"/api/shutdown": "POST",
|
|
46
|
+
});
|
|
47
|
+
// Served on a valid code instead of a 303: when the session URL is reached from
|
|
48
|
+
// the file: redirect page, a redirect chain counts as cross-site and a
|
|
49
|
+
// SameSite=Strict cookie could be withheld on `/`. This same-origin page starts
|
|
50
|
+
// the navigation, so the cookie is sent. No script or style, as the CSP requires.
|
|
51
|
+
const sessionPage = [
|
|
52
|
+
"<!doctype html>",
|
|
53
|
+
'<meta charset="utf-8">',
|
|
54
|
+
'<meta http-equiv="refresh" content="0; url=/">',
|
|
55
|
+
"<title>Gentle Shell installation wizard</title>",
|
|
56
|
+
'<p><a href="/">Continue to the Gentle Shell installation wizard</a></p>',
|
|
57
|
+
"",
|
|
58
|
+
].join("\n");
|
|
59
|
+
|
|
60
|
+
/** Fixed English descriptions for every action planPreflight can emit. */
|
|
61
|
+
export const actionDescriptions = Object.freeze({
|
|
62
|
+
"acquire-node": "Download a verified Node.js runtime.",
|
|
63
|
+
"verify-node": "Check that the downloaded Node.js runtime runs.",
|
|
64
|
+
"acquire-pnpm": "Download a verified pnpm package manager.",
|
|
65
|
+
"verify-pnpm": "Check that the downloaded pnpm runs.",
|
|
66
|
+
"setup-global-bin": "Run `pnpm setup` so new terminals find globally installed commands.",
|
|
67
|
+
"persist-node": "Install Node.js under PNPM_HOME with `pnpm runtime set node -g`.",
|
|
68
|
+
"persist-package-managers": "Install npm and pnpm under PNPM_HOME with one `pnpm add -g`.",
|
|
69
|
+
"configure-npm-prefix": "Point npm's user-level global prefix at PNPM_HOME when its default would be inside pnpm's store.",
|
|
70
|
+
"persist-npm": "Install npm under PNPM_HOME with `pnpm add -g`.",
|
|
71
|
+
"persist-pnpm": "Install pnpm under PNPM_HOME with `pnpm add -g`.",
|
|
72
|
+
"acquire-go": "Download the installer's pinned Go from go.dev and verify it, only to build Gentle AI.",
|
|
73
|
+
"verify-go": "Check that the downloaded Go runs and reports the pinned version.",
|
|
74
|
+
"install-pi": "Install the Pi coding agent globally with pnpm.",
|
|
75
|
+
"install-shell": `Install Gentle Shell (gentle-pi) globally with pnpm. pnpm may put a newer Pi than ${PI_INSTALL_VERSION} next to it ` +
|
|
76
|
+
`(at least Pi ${requirements.pi}), and Gentle Shell runs that Pi.`,
|
|
77
|
+
"provision-native": "Provision the package-native Gentle AI binary with the existing installer.",
|
|
78
|
+
"setup-shell": "Run the normal `gentle-shell setup`.",
|
|
79
|
+
"verify-readiness": "Verify that the installed stack is ready.",
|
|
80
|
+
"build-gentle-ai-main": "Build Gentle AI from the latest commit of its `main` branch with Go, verified by Go's checksum database, and use it instead of the pinned release binary.",
|
|
81
|
+
"install-shell-main": "Install Gentle Shell from the latest commit of its `main` branch with pnpm, replacing the release package.",
|
|
82
|
+
"record-channel": "Remember the `main` channel, so `gentle-shell upgrade` keeps following `main`.",
|
|
83
|
+
"update-shell-release": "Update Gentle Shell to the latest release with the package manager that installed it (pnpm or npm).",
|
|
84
|
+
"update-shell-main": "Update Gentle Shell and Gentle AI to the latest commits of `main`, built on this computer, with the package manager that installed Gentle Shell.",
|
|
85
|
+
"update-pi": "Update Pi with the package manager that installed it (pnpm or npm).",
|
|
86
|
+
// The bundled install (T1c): the plan's own versions and paths come from bundledDescription.
|
|
87
|
+
"bundled-prefix": "Create Gentle Shell's private folder for its own runtimes, versions and launcher.",
|
|
88
|
+
"bundled-runtime": "Download Gentle Shell's pinned Node.js with its npm, and pnpm, and verify their pinned checksums.",
|
|
89
|
+
"bundled-version": "Install Gentle Shell with its pinned Pi from the release's lockfile.",
|
|
90
|
+
"bundled-activate": "Make this version the active one and write the `gentle-shell` launcher.",
|
|
91
|
+
"bundled-path": "Make new terminals find `gentle-shell`.",
|
|
92
|
+
"bundled-setup": "Run `gentle-shell setup` with the bundled launcher.",
|
|
93
|
+
"remove-global-shell": "Remove the old global gentle-pi with the package manager that installed it.",
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
const tryAgain = "Fix the cause, then run the installer again.";
|
|
97
|
+
/** Fixed English guidance for every runner blocked reason, failed step and preflight blocker. */
|
|
98
|
+
export const guidance = Object.freeze({
|
|
99
|
+
blocked: Object.freeze({
|
|
100
|
+
"invalid-request": "The installation request was not an unmodified plan. Reload the wizard to get a fresh plan.",
|
|
101
|
+
"consent-required": "Nothing was installed because consent was not given. Review the plan and confirm to continue.",
|
|
102
|
+
"preflight-blocked": "Preflight found a blocker, so nothing was installed. Resolve the listed blockers and run the installer again.",
|
|
103
|
+
"go-required": `Windows needs Go ${requirements.go} or newer before Gentle AI can be provisioned, and this plan neither reuses nor downloads one. Run the installer again to check this computer again.`,
|
|
104
|
+
"unsupported-plan": "This machine needs steps the wizard does not run yet, such as updating an existing installation. Use `gentle-shell upgrade` or follow the README.",
|
|
105
|
+
"pnpm-home-unknown": "The pnpm home directory could not be determined. Set PNPM_HOME to an absolute directory, then run the installer again.",
|
|
106
|
+
"pnpm-home-changed": "The pnpm home folder is not the one the plan was made for. Nothing was installed; run the installer again to check this computer again.",
|
|
107
|
+
"node-unavailable": "The installer could not find its own Node.js executable. Run the installer again from the bootstrap script.",
|
|
108
|
+
"pnpm-unavailable": "pnpm could not be started. Run the installer again from the bootstrap script so it can provide pnpm.",
|
|
109
|
+
"npm-unavailable": "No working npm was found on PATH. Gentle AI needs it; check that `npm --version` works in a new terminal, then run the installer again.",
|
|
110
|
+
"npm-shadowed": "Another `npm` program appears on PATH before Node.js's npm. Remove or reorder it, then run the installer again.",
|
|
111
|
+
"global-bin-mismatch": "pnpm reported a different global bin directory than expected. Check PNPM_HOME, then run the installer again.",
|
|
112
|
+
"global-list-unavailable": "pnpm could not list global packages. Check that `pnpm list -g` works, then run the installer again.",
|
|
113
|
+
"existing-stack": "Pi or Gentle Shell is already installed globally. Nothing was changed; use `gentle-shell upgrade` instead.",
|
|
114
|
+
"existing-stack-unverified": "The installed Pi and Gentle Shell changed after the plan was made, or are no longer the versions this installer set up. Nothing was changed; run the installer again to check this computer again.",
|
|
115
|
+
}),
|
|
116
|
+
failed: Object.freeze({
|
|
117
|
+
"prepare-pnpm-home": "The private pnpm folder (%USERPROFILE%\\.pnpm) could not be created, or kept, with access for you, SYSTEM and Administrators only, or it changed after the plan was made. Nothing was installed. " +
|
|
118
|
+
"If that folder holds files this installer did not put there, move them aside. Then run the installer again.",
|
|
119
|
+
"persist-node": `Installing Node.js under PNPM_HOME failed. Check your network connection. ${tryAgain}`,
|
|
120
|
+
"persist-package-managers": `Installing npm and pnpm under PNPM_HOME failed. Check your network connection. ${tryAgain}`,
|
|
121
|
+
"persist-npm": `Installing npm under PNPM_HOME failed. Check your network connection. ${tryAgain}`,
|
|
122
|
+
"persist-pnpm": `Installing pnpm under PNPM_HOME failed. Check your network connection. ${tryAgain}`,
|
|
123
|
+
"verify-persistent-runtime": `Node.js and npm were not found in the pnpm global bin directory after installation. ${tryAgain}`,
|
|
124
|
+
"verify-persistent-pnpm": `pnpm was not found in the pnpm global bin directory after installation. ${tryAgain}`,
|
|
125
|
+
"check-npm": `The installed npm could not be verified as genuine npm. ${tryAgain}`,
|
|
126
|
+
"configure-npm-prefix": `npm's global prefix could not be checked or set to PNPM_HOME. Your npm configuration was left as it was. ${tryAgain}`,
|
|
127
|
+
"acquire-go": `Go ${goAcquisition.version} could not be downloaded from go.dev and verified against its pinned checksum, so nothing was installed. Check your network connection. ${tryAgain}`,
|
|
128
|
+
"verify-go": `The downloaded Go did not run or did not report Go ${goAcquisition.version}, so nothing was installed. ${tryAgain}`,
|
|
129
|
+
"install-global": `Installing Pi and Gentle Shell with pnpm failed. Check your network connection. ${tryAgain}`,
|
|
130
|
+
"verify-global-list": `The installed Pi and Gentle Shell packages could not be verified under PNPM_HOME. ${tryAgain}`,
|
|
131
|
+
"verify-shell-bin": `The gentle-shell command was not found in the pnpm global bin directory. ${tryAgain}`,
|
|
132
|
+
"verify-gentle-ai": `The package-native Gentle AI binary could not be verified. ${tryAgain}`,
|
|
133
|
+
"shell-setup": "`gentle-shell setup` did not finish. Run `gentle-shell setup` in a terminal to see the details.",
|
|
134
|
+
"persist-path": "`pnpm setup` could not add the global bin directory to your PATH. Run `pnpm setup` in a terminal, then open a new terminal.",
|
|
135
|
+
"build-gentle-ai-main": `Gentle AI could not be built from its latest \`main\` commit. Check your network connection and that \`go\` runs in a terminal. ${tryAgain}`,
|
|
136
|
+
"install-shell-main": `Gentle Shell could not be installed from its latest \`main\` commit. Check your network connection. ${tryAgain}`,
|
|
137
|
+
"record-channel": "Gentle Shell is installed from `main`, but the channel could not be saved under `~/.pi/gentle-ai`. Check that folder's permissions, then run the installer again.",
|
|
138
|
+
"install-pi": `Installing Pi with pnpm failed. Your existing Gentle Shell was not changed. Check your network connection. ${tryAgain}`,
|
|
139
|
+
"update-shell": "Updating Gentle Shell failed. Run `gentle-shell upgrade` in a terminal to see the details.",
|
|
140
|
+
"verify-updated-shell": "Gentle Shell did not report the expected version after the update. Run `gentle-shell --version`, then `gentle-shell upgrade` in a terminal.",
|
|
141
|
+
"update-pi": `Updating Pi with the package manager that installed it failed. Gentle Shell was not changed. Check your network connection. ${tryAgain}`,
|
|
142
|
+
"verify-updated-pi": "After the update, this installer could not find a single Pi at the expected version from the same package manager. Gentle Shell was not changed. Run `pi --version` in a terminal, then run the installer again.",
|
|
143
|
+
"verify-installed-pi": "After installing Pi, this installer could not find it at the expected version in pnpm's global packages. Gentle Shell and your other Pi were not changed. Run `pnpm list -g` in a terminal, then run the installer again.",
|
|
144
|
+
"claim-prefix": "Gentle Shell's private folder could not be created, or kept private to your account, so nothing was installed. If that folder holds files this installer did not put there, or another account can change it, fix that, then run the installer again.",
|
|
145
|
+
"copy-npm-settings": `Your npm registry, authentication and proxy settings could not be read from your npm configuration (~/.npmrc, or NPM_CONFIG_USERCONFIG) and copied into Gentle Shell's private folder. Check that the file is readable. ${tryAgain}`,
|
|
146
|
+
"install-runtime": `Gentle Shell's pinned Node.js, npm and pnpm (and Go on Windows) could not be downloaded and verified against their pinned checksums. Check your network connection. ${tryAgain}`,
|
|
147
|
+
"install-version": `Installing Gentle Shell from the release's lockfile failed. Check your network connection and your npm registry and authentication settings. ${tryAgain}`,
|
|
148
|
+
"activate-version": `The installed version could not be made the active one. ${tryAgain}`,
|
|
149
|
+
"write-launcher": `The \`gentle-shell\` launcher could not be written in Gentle Shell's private folder. ${tryAgain}`,
|
|
150
|
+
"bundled-path": "Gentle Shell is installed, but the PATH change could not be made, or it is no longer the one the plan showed, so nothing was changed. Run the installer again to see the current PATH plan.",
|
|
151
|
+
"verify-launcher": `The new \`gentle-shell\` launcher did not report the expected Gentle Shell and Pi versions, so your old global Gentle Shell was kept. ${tryAgain}`,
|
|
152
|
+
}),
|
|
153
|
+
blockers: Object.freeze({
|
|
154
|
+
"unsupported-target": "This operating system or CPU is not supported by the wizard. Follow the README for a manual installation.",
|
|
155
|
+
"unknown-tool": "A required tool could not be checked safely. Make sure it runs from a terminal, or remove the broken installation, then run the installer again.",
|
|
156
|
+
"incompatible-tool": "A required tool is installed at an incompatible version. Update it, then run the installer again.",
|
|
157
|
+
"untrusted-pnpm-home": "Another account can change the pnpm home folder (PNPM_HOME), where pnpm installs and runs programs, so nothing was installed. " +
|
|
158
|
+
"Remove that account's write access, or set PNPM_HOME to a private folder such as %USERPROFILE%\\.pnpm, then select Check again.",
|
|
159
|
+
}),
|
|
160
|
+
outcomes: Object.freeze({
|
|
161
|
+
ready: "Gentle Shell is installed. Run `gentle-shell` in a terminal.",
|
|
162
|
+
"terminal-action-required": "Gentle Shell is installed. Open a new terminal so it picks up the updated PATH, then run `gentle-shell`.",
|
|
163
|
+
"add-path-line": "Gentle Shell is installed. Add the line below to your shell profile, open a new terminal, then run `gentle-shell`.",
|
|
164
|
+
}),
|
|
165
|
+
// A migration whose old global gentle-pi could not be removed: the install stays.
|
|
166
|
+
removeGlobalFailed: "The old global gentle-pi could not be removed, so a terminal may still find it first. Remove it with the command below.",
|
|
167
|
+
// A failed install, main-channel or update step whose detail shows GitHub's anonymous API limit.
|
|
168
|
+
githubRateLimit: "GitHub's limit for anonymous API requests was reached on this network, so the installation could not finish. Wait up to an hour, then run the installer again.",
|
|
169
|
+
// A failed shell-setup whose detail shows GitHub's anonymous API limit.
|
|
170
|
+
setupRateLimit: "`gentle-shell setup` could not finish because GitHub's limit for anonymous API requests was reached on this network. Wait up to an hour, then run the installer again.",
|
|
171
|
+
// A failed acquire-go whose detail names a Go folder the installer did not publish.
|
|
172
|
+
goDestinationConflict: `A Go folder from an earlier, interrupted installer run is in the way, and the installer never replaces a folder it cannot prove it published. Remove that folder (shown below), then run the installer again. Nothing was installed.`,
|
|
173
|
+
// A failed persist-path whose detail shows pnpm could not tell the shell (POSIX SHELL missing or unsupported).
|
|
174
|
+
persistPathShell: "`pnpm setup` could not tell which shell profile to edit because the SHELL environment variable is missing or names an unsupported shell. Open a regular terminal and run the installer again, or add `$PNPM_HOME/bin` to your PATH yourself.",
|
|
175
|
+
fallback: "The installation stopped for an unexpected reason. Nothing else will run; check the terminal and run the installer again.",
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
/** The runner's setup detail, bounded again: text only, no control characters,
|
|
179
|
+
* at most 300 characters. Null when nothing remains.
|
|
180
|
+
*/
|
|
181
|
+
function setupDetail(value) {
|
|
182
|
+
if (typeof value !== "string") return null;
|
|
183
|
+
const text = Array.from(value.replace(/[\u0000-\u001f\u007f-\u009f\u200b-\u200f\u202a-\u202e\u2066-\u2069]/g, "").trim()).slice(0, 300).join("");
|
|
184
|
+
return text.length > 0 ? text : null;
|
|
185
|
+
}
|
|
186
|
+
const rateLimited = (detail) => /rate limit/i.test(detail) || (/GitHub API/i.test(detail) && /\b403\b/.test(detail));
|
|
187
|
+
const unknownShell = (detail) => /ERR_PNPM_(?:UNKNOWN|UNSUPPORTED)_SHELL/.test(detail);
|
|
188
|
+
const goConflict = (detail) => detail.startsWith("Conflicting Go destination: ");
|
|
189
|
+
// The main channel's typed error when GitHub refused the latest main commit with HTTP 403.
|
|
190
|
+
const githubLimited = (detail) => rateLimited(detail) || (/\bmain-commit-unavailable\b/.test(detail) && /\bHTTP 403\b/.test(detail));
|
|
191
|
+
// Fixed steps whose sanitized last error line may reach the browser.
|
|
192
|
+
// The bundled steps send a detail only for a migration (its error message); a new install's send none.
|
|
193
|
+
const bundledDetail = Object.fromEntries(["claim-prefix", "copy-npm-settings", "install-runtime", "activate-version", "write-launcher", "verify-launcher", "bundled-path"]
|
|
194
|
+
.map((step) => [step, () => false]));
|
|
195
|
+
const detailSteps = Object.freeze({ ...bundledDetail, "shell-setup": rateLimited, "install-version": () => false, "persist-path": unknownShell, "acquire-go": goConflict,
|
|
196
|
+
"install-global": githubLimited, "install-shell-main": githubLimited, "build-gentle-ai-main": githubLimited, "update-shell": githubLimited });
|
|
197
|
+
const detailGuidance = Object.freeze({ "shell-setup": "setupRateLimit", "persist-path": "persistPathShell", "acquire-go": "goDestinationConflict",
|
|
198
|
+
"install-global": "githubRateLimit", "install-shell-main": "githubRateLimit", "build-gentle-ai-main": "githubRateLimit", "update-shell": "githubRateLimit" });
|
|
199
|
+
|
|
200
|
+
const ID = /^[a-z][a-z0-9-]{0,63}$/;
|
|
201
|
+
function identifier(value, fallback = "unknown") {
|
|
202
|
+
return typeof value === "string" && ID.test(value) ? value : fallback;
|
|
203
|
+
}
|
|
204
|
+
// Preflight tool names include camelCase keys such as gentleAi and globalBin.
|
|
205
|
+
const TOOL = /^[a-z][A-Za-z0-9-]{0,63}$/;
|
|
206
|
+
function toolName(value) {
|
|
207
|
+
return typeof value === "string" && TOOL.test(value) ? value : "unknown";
|
|
208
|
+
}
|
|
209
|
+
function plainObject(value) {
|
|
210
|
+
return typeof value === "object" && value !== null && !Array.isArray(value) && Object.getPrototypeOf(value) === Object.prototype;
|
|
211
|
+
}
|
|
212
|
+
function token(random, bytes) {
|
|
213
|
+
return random(bytes).toString("base64url");
|
|
214
|
+
}
|
|
215
|
+
function sameSecret(expected, actual) {
|
|
216
|
+
const [a, b] = [Buffer.from(expected), Buffer.from(String(actual))];
|
|
217
|
+
return a.length === b.length && timingSafeEqual(a, b);
|
|
218
|
+
}
|
|
219
|
+
/** What must not change between review and install. The reused-folder notice
|
|
220
|
+
* (tools.folders) is advisory and changes no action, so it is left out: a walk
|
|
221
|
+
* that times out on re-inventory must not turn into a plan-changed loop. */
|
|
222
|
+
function fingerprint(collected) {
|
|
223
|
+
const tools = Object.fromEntries(Object.entries(collected.plan?.tools ?? {}).filter(([name]) => name !== "folders"));
|
|
224
|
+
return createHash("sha256").update(JSON.stringify({ plan: { ...collected.plan, tools }, binDir: collected.inventory?.globalBin?.path ?? null }))
|
|
225
|
+
.digest("hex");
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/** Browser view of a server-held plan: fixed descriptions, never commands. */
|
|
229
|
+
// Tools whose requirement is a minimum version, with their fixed labels. The
|
|
230
|
+
// Shell probe only inspects pnpm's global packages, so its remedy names pnpm.
|
|
231
|
+
const minimumTools = Object.freeze({
|
|
232
|
+
node: { label: "Node.js", where: "" },
|
|
233
|
+
pi: { label: "Pi", where: "" },
|
|
234
|
+
shell: { label: "Gentle Shell", where: " globally with pnpm",
|
|
235
|
+
remedy: (required) => `Update it with \`pnpm add -g gentle-pi@${required}\`, or remove it with \`pnpm remove -g gentle-pi\`` },
|
|
236
|
+
});
|
|
237
|
+
// A Pi whose version cannot be read, or a Gentle Shell neither pnpm nor npm manages.
|
|
238
|
+
const unmanaged = Object.freeze({
|
|
239
|
+
pi: "Pi is already installed, but `pi --version` did not report a version this installer can check. Make sure `pi --version` works in a terminal, then select Check again.",
|
|
240
|
+
shell: "Gentle Shell is already installed, but neither pnpm nor npm manages it (a linked source checkout, for example), so this installer cannot update it. Nothing was replaced. Update it the way you installed it, then select Check again.",
|
|
241
|
+
});
|
|
242
|
+
const STABLE = /^v?(\d{1,6})\.(\d{1,6})\.(\d{1,6})$/;
|
|
243
|
+
function older(found, required) {
|
|
244
|
+
const [a, b] = [STABLE.exec(found), STABLE.exec(required)];
|
|
245
|
+
if (!a || !b) return false;
|
|
246
|
+
for (let index = 1; index <= 3; index += 1) {
|
|
247
|
+
if (Number(a[index]) !== Number(b[index])) return Number(a[index]) < Number(b[index]);
|
|
248
|
+
}
|
|
249
|
+
return false;
|
|
250
|
+
}
|
|
251
|
+
/** A user's pnpm in $PNPM_HOME/bin, where the installer would persist its own:
|
|
252
|
+
* never replaced or downgraded, so the guidance says how to update an older one
|
|
253
|
+
* with pnpm itself (`pnpm self-update <version>`). */
|
|
254
|
+
function globalBinPnpm(blocker, found, required) {
|
|
255
|
+
const where = "is installed in the pnpm global bin directory, where this installer would put its own pnpm,";
|
|
256
|
+
const update = `\`pnpm self-update ${required}\``;
|
|
257
|
+
if (blocker.code === "unknown-tool" || !STABLE.test(found ?? "")) {
|
|
258
|
+
return `A pnpm ${where} but its version could not be checked. Nothing was replaced. ` +
|
|
259
|
+
`If \`pnpm --version\` reports a version older than ${required}, update it with ${update}, then select Check again.`;
|
|
260
|
+
}
|
|
261
|
+
// Only an older pnpm is incompatible: the pin or newer, of any major, is used as it is.
|
|
262
|
+
return `pnpm ${found} ${where} but this installer needs pnpm ${required} or newer. Nothing was replaced. ` +
|
|
263
|
+
`Update it with ${update}, then select Check again.`;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
// S6: what the storage walk found on a Windows PNPM_HOME candidate, from the
|
|
267
|
+
// wizard's own walk (folder, principal and rights), as one plain phrase.
|
|
268
|
+
function plainText(value) {
|
|
269
|
+
return typeof value === "string" ? value.replace(/[\u0000-\u001f\u007f-\u009f\u200b-\u200f\u202a-\u202e\u2066-\u2069]/g, "").slice(0, 300) : null;
|
|
270
|
+
}
|
|
271
|
+
function findingText(finding) {
|
|
272
|
+
const at = plainText(finding?.at) ?? "a folder above it";
|
|
273
|
+
const sid = plainText(finding?.sid);
|
|
274
|
+
const account = plainText(finding?.account);
|
|
275
|
+
const who = sid ? `${sid}${account ? ` (${account})` : ""}` : "another account";
|
|
276
|
+
const check = String(finding?.check ?? "");
|
|
277
|
+
if (check === "unchecked") return `the permissions of ${at} could not be checked`;
|
|
278
|
+
if (check.endsWith("-acl-mask")) return `${who} can change ${at} (allowed rights ${plainText(finding.rights) ?? "beyond read and execute"})`;
|
|
279
|
+
if (check.endsWith("-owner")) return `${at} is owned by ${who}, which this installer does not trust`;
|
|
280
|
+
if (check.endsWith("-reparse")) return `${at} is a link (reparse point) to another location`;
|
|
281
|
+
return `${at} did not pass the permission check`;
|
|
282
|
+
}
|
|
283
|
+
const privateRemedy = "set PNPM_HOME to a private folder such as %USERPROFILE%\\.pnpm and reinstall your global pnpm packages there";
|
|
284
|
+
function pnpmHomeGuidance(home) {
|
|
285
|
+
const path = plainText(home?.path);
|
|
286
|
+
if (!path || typeof home.untrusted !== "object" || home.untrusted === null) return guidance.blockers["untrusted-pnpm-home"];
|
|
287
|
+
const found = findingText(home.untrusted);
|
|
288
|
+
const at = plainText(home.untrusted.at) ?? path;
|
|
289
|
+
if (home.source === "user") {
|
|
290
|
+
return `PNPM_HOME is set to ${path}, but ${found}. pnpm installs and runs programs there, so nothing was installed. ` +
|
|
291
|
+
`Remove that account's write access, or ${privateRemedy}, then select Check again.`;
|
|
292
|
+
}
|
|
293
|
+
const intro = `pnpm's default folder ${path} is not private: ${found}.`;
|
|
294
|
+
if (home.installed === true) {
|
|
295
|
+
return `${intro} It already holds files. This installer never runs programs from a folder another account can change, and never moves or deletes an existing installation, so nothing was installed. ` +
|
|
296
|
+
`Remove that account's write access to ${at}, or ${privateRemedy}, then select Check again.`;
|
|
297
|
+
}
|
|
298
|
+
const own = plainText(home.private?.path);
|
|
299
|
+
if (own && home.private.foreign === true) {
|
|
300
|
+
return `${intro} ${own}, the private folder this installer would use instead, already holds files this installer did not create, so nothing was changed. ` +
|
|
301
|
+
`Remove that account's write access to ${at}, or move ${own} aside, then select Check again.`;
|
|
302
|
+
}
|
|
303
|
+
if (own && typeof home.private.untrusted === "object") {
|
|
304
|
+
return `${intro} ${own}, the private folder this installer would use instead, is not private either: ${findingText(home.private.untrusted)}. Nothing was changed. ` +
|
|
305
|
+
"Remove the extra access shown, then select Check again.";
|
|
306
|
+
}
|
|
307
|
+
return `${intro} Nothing was installed. Remove that account's write access to ${at}, or ${privateRemedy}, then select Check again.`;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
/** Names the found and required versions when an incompatible tool is simply
|
|
311
|
+
* older than its minimum, and explains a Pi or Shell installed outside pnpm,
|
|
312
|
+
* a pnpm in the global bin directory or a PNPM_HOME that is not private;
|
|
313
|
+
* anything else keeps the fixed guidance. */
|
|
314
|
+
function blockerGuidance(blocker, inventory, plan) {
|
|
315
|
+
const fixed = guidance.blockers[blocker.code] ?? guidance.fallback;
|
|
316
|
+
if (blocker.code === "untrusted-pnpm-home") return pnpmHomeGuidance(inventory?.pnpmHome);
|
|
317
|
+
if (blocker.tool === "pnpmHome" && blocker.code === "unknown-tool") {
|
|
318
|
+
return "The permissions of the pnpm home folder (PNPM_HOME) could not be checked, so nothing was installed. " +
|
|
319
|
+
"Make sure Windows PowerShell runs in a terminal, then select Check again.";
|
|
320
|
+
}
|
|
321
|
+
const pnpmRequired = plan.tools?.pnpm?.required;
|
|
322
|
+
if (blocker.tool === "pnpm" && ["incompatible-tool", "unknown-tool"].includes(blocker.code) &&
|
|
323
|
+
inventory?.pnpm?.inGlobalBin === true && STABLE.test(pnpmRequired ?? "")) {
|
|
324
|
+
return globalBinPnpm(blocker, inventory.pnpm.version, pnpmRequired);
|
|
325
|
+
}
|
|
326
|
+
if (blocker.code === "unknown-tool" && Object.hasOwn(unmanaged, blocker.tool) && inventory?.[blocker.tool]?.outsidePnpm === true) {
|
|
327
|
+
return unmanaged[blocker.tool];
|
|
328
|
+
}
|
|
329
|
+
const tool = Object.hasOwn(minimumTools, blocker.tool) ? minimumTools[blocker.tool] : null;
|
|
330
|
+
if (blocker.code !== "incompatible-tool" || tool === null) return fixed;
|
|
331
|
+
// An older Node left next to the bootstrap's copy is recorded in the plan.
|
|
332
|
+
const found = plan.tools?.[blocker.tool]?.found ?? inventory?.[blocker.tool]?.version;
|
|
333
|
+
const required = plan.tools?.[blocker.tool]?.required;
|
|
334
|
+
if (typeof found !== "string" || typeof required !== "string" || !older(found, required)) return fixed;
|
|
335
|
+
const version = found.replace(/^v/, "");
|
|
336
|
+
const remedy = tool.remedy ? tool.remedy(required) : "Update it";
|
|
337
|
+
return `${tool.label} ${version} is installed${tool.where}, but this installer needs ${required} or newer. Nothing was replaced. ${remedy}, then select Check again.`;
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
/** An older Pi's update names its found and target versions and the package
|
|
341
|
+
* manager that owns it, and the installer's Pi added next to an older one names
|
|
342
|
+
* both versions, from the plan's own record; anything else is fixed text. */
|
|
343
|
+
function actionDescription(action, plan) {
|
|
344
|
+
const pi = plan.tools?.pi;
|
|
345
|
+
const stable = (...versions) => versions.every((version) => typeof version === "string" && STABLE.test(version));
|
|
346
|
+
const bare = (version) => version.replace(/^v/, "");
|
|
347
|
+
if (action.id === "update-pi" && ["pnpm", "npm"].includes(pi?.owner) && stable(pi.version, pi.required, action.version)) {
|
|
348
|
+
return `Update Pi ${bare(pi.version)} to ${bare(action.version)} with ${pi.owner}, the package manager that installed it. ` +
|
|
349
|
+
`Gentle Shell needs Pi ${bare(pi.required)} or newer.`;
|
|
350
|
+
}
|
|
351
|
+
if (action.id === "install-pi" && pi?.status === "needs-install" && stable(pi.version)) {
|
|
352
|
+
return `Install Pi ${PI_INSTALL_VERSION} globally with pnpm for Gentle Shell. The Pi ${bare(pi.version)} already on this computer ` +
|
|
353
|
+
"was not installed with pnpm or npm, so it is left unchanged. Gentle Shell uses the installer's Pi; the `pi` command in a terminal may still run the older one.";
|
|
354
|
+
}
|
|
355
|
+
// The pinned Go: its version, the Go found (older, unchecked or missing), and what stays unchanged.
|
|
356
|
+
const go = plan.tools?.go;
|
|
357
|
+
if (action.id === "acquire-go" && go?.status === "needs-acquire" && stable(action.version, go.required)) {
|
|
358
|
+
const found = stable(go.found) ? `Go ${bare(go.found)} on this computer is older than ${bare(go.required)}`
|
|
359
|
+
: go.unchecked === true ? "The Go on this computer did not report a version this installer can check" : "Go is missing on this computer";
|
|
360
|
+
return `${found}, so the installer downloads Go ${bare(action.version)} from go.dev, verifies its pinned SHA-256 checksum and uses it only to build Gentle AI. ` +
|
|
361
|
+
"It is kept in the installer's own folder (~/.pi/gentle-ai/tools/go); your Go, PATH and shell profile are not changed.";
|
|
362
|
+
}
|
|
363
|
+
return actionDescriptions[action.id] ?? "Prepare the installation.";
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
/** An older Node or incompatible pnpm the plan records as left in place next to
|
|
367
|
+
* the installer's pinned copy: found and pinned versions, from the plan itself.
|
|
368
|
+
* Nothing promises PATH precedence: a version manager may prepend its own again.
|
|
369
|
+
*/
|
|
370
|
+
function alongsideNotes(plan) {
|
|
371
|
+
const notes = [];
|
|
372
|
+
const node = plan.tools?.node;
|
|
373
|
+
if (STABLE.test(node?.found ?? "") && STABLE.test(node.version ?? "")) {
|
|
374
|
+
notes.push(`Node.js ${node.found} on this computer is older than ${node.required}, so the installer uses its pinned Node.js ${node.version} ` +
|
|
375
|
+
`and installs it under $PNPM_HOME. Your Node.js ${node.found} is left unchanged.`);
|
|
376
|
+
}
|
|
377
|
+
const pnpm = plan.tools?.pnpm;
|
|
378
|
+
if (STABLE.test(pnpm?.found ?? "") && STABLE.test(pnpm.version ?? "")) {
|
|
379
|
+
notes.push(`pnpm ${pnpm.found} on this computer is not a pnpm ${pnpm.version.split(".")[0]} release this installer can use, ` +
|
|
380
|
+
`so the installer uses its pinned pnpm ${pnpm.version} and installs it under $PNPM_HOME. Your pnpm ${pnpm.found} is left unchanged.`);
|
|
381
|
+
}
|
|
382
|
+
if (notes.length > 0) notes.push("A version manager such as `mise activate` may still put your own versions first on PATH in new terminals.");
|
|
383
|
+
return notes;
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
/** The private PNPM_HOME the plan uses instead of pnpm's default, and why. */
|
|
387
|
+
function privateHomeDescription(home) {
|
|
388
|
+
const path = plainText(home.path);
|
|
389
|
+
const fallback = plainText(home.default);
|
|
390
|
+
return `pnpm's default folder ${fallback} is not private: ${findingText(home.finding)}. So the installer uses a new private folder, ${path}, as PNPM_HOME, ` +
|
|
391
|
+
`which only you, SYSTEM and Administrators can change. \`pnpm setup\` will save PNPM_HOME=${path} in your user environment and add ${path}\\bin ` +
|
|
392
|
+
`to your user PATH, so new terminals use it. Nothing in ${fallback} is changed. Open a new terminal afterwards. ` +
|
|
393
|
+
// S13: pnpm reads its configuration folder only from XDG_CONFIG_HOME or %LOCALAPPDATA%\pnpm\config.
|
|
394
|
+
"Only the installer's own pnpm steps keep pnpm's configuration, cache and state inside that private folder: pnpm commands you run later keep " +
|
|
395
|
+
"pnpm's default configuration, cache and state under %LOCALAPPDATA%, which pnpm offers no safe persistent setting to move. This is an accepted risk.";
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
// A missing Git (never a blocker): installing does not need it, repository features do.
|
|
399
|
+
const gitInstall = Object.freeze({
|
|
400
|
+
darwin: "`xcode-select --install` (Apple's Command Line Tools)",
|
|
401
|
+
win32: "`winget install --id Git.Git -e`",
|
|
402
|
+
linux: "your package manager, for example `sudo apt-get install -y git` on Debian or Ubuntu",
|
|
403
|
+
});
|
|
404
|
+
function gitNoticeView(inventory) {
|
|
405
|
+
if (inventory?.git?.available !== false) return null;
|
|
406
|
+
const install = gitInstall[inventory.platform] ?? gitInstall.linux;
|
|
407
|
+
return { description: "Git was not found on this computer. Gentle Shell installs and starts without it, but code review, worktrees and other " +
|
|
408
|
+
`repository features need it. Install it with ${install}, then open a new terminal. This is not a blocker.` };
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
// S6 notice: reused tools whose folders another account can change (never a blocker).
|
|
412
|
+
const folderTools = Object.freeze({ node: "Node.js", npm: "npm", go: "Go", pi: "Pi", shell: "Gentle Shell" });
|
|
413
|
+
function sharedFoldersView(plan) {
|
|
414
|
+
const reused = Array.isArray(plan.tools?.folders?.reused) ? plan.tools.folders.reused.filter((entry) => Object.hasOwn(folderTools, entry?.tool)) : [];
|
|
415
|
+
if (reused.length === 0) return null;
|
|
416
|
+
const found = reused.map((entry) => `${folderTools[entry.tool]}: ${findingText(entry)}`).join("; ");
|
|
417
|
+
const unchecked = reused.some((entry) => entry.check === "unchecked");
|
|
418
|
+
const weak = reused.some((entry) => entry.check !== "unchecked");
|
|
419
|
+
const where = [weak ? "from folders another account can change" : null, unchecked ? "from folders whose permissions could not be checked" : null].filter(Boolean).join(", or ");
|
|
420
|
+
const remedies = [weak ? "remove that account's write access" : null,
|
|
421
|
+
unchecked ? "make sure your account can read the permissions of the paths that could not be checked, on a local drive" : null].filter(Boolean).join(", and ");
|
|
422
|
+
return {
|
|
423
|
+
tools: reused.map((entry) => entry.tool),
|
|
424
|
+
description: `These tools are reused as they are, ${where}. ${found}. ` +
|
|
425
|
+
"The installer never creates or runs its own programs there, but whoever can change those folders can change what these tools run for you, including for Gentle Shell. " +
|
|
426
|
+
`This is an accepted risk and not a blocker. To remove it, ${remedies}, then select Check again.`,
|
|
427
|
+
};
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
/** A bundled action's text, from the plan's own versions and paths (plain text). */
|
|
431
|
+
function bundledDescription(action, bundled) {
|
|
432
|
+
const [root, bin, file] = [plainText(bundled.root), plainText(bundled.bin), plainText(bundled.path?.file)];
|
|
433
|
+
const go = STABLE.test(bundled.go ?? "") ? ` and Go ${bundled.go}` : "";
|
|
434
|
+
switch (action.id) {
|
|
435
|
+
case "bundled-prefix": return `Create Gentle Shell's private folder, ${root}, for its own runtimes, its installed versions and the \`gentle-shell\` launcher.`;
|
|
436
|
+
case "bundled-runtime": return `Download Gentle Shell's pinned Node.js ${plainText(bundled.node)} with its npm and pnpm ${plainText(bundled.pnpm)}${go}, verify their pinned checksums and keep them in that folder. ` +
|
|
437
|
+
"Your own Node.js, npm, pnpm and Go are not used or changed.";
|
|
438
|
+
case "bundled-version": return `Install Gentle Shell ${plainText(bundled.shell)} with Pi ${plainText(bundled.pi)} from the release's lockfile with \`pnpm install --frozen-lockfile\`, ` +
|
|
439
|
+
"so every package is the exact version the release verified. Your npm registry, authentication and proxy settings are respected. Any Pi already on this computer is not used or changed.";
|
|
440
|
+
case "bundled-path": return bundled.path?.kind === "registry" ? `Add ${bin} to your user PATH so new terminals find \`gentle-shell\`.`
|
|
441
|
+
: bundled.path?.kind === "symlink" ? `Link ${file} to the \`gentle-shell\` launcher; that folder is already on your PATH.`
|
|
442
|
+
: `Add one line to ${file} so new terminals find \`gentle-shell\`.`;
|
|
443
|
+
case "bundled-setup": return "Run `gentle-shell setup` with the bundled launcher to set up Gentle Shell's own Pi home (~/.gentle-shell/agent).";
|
|
444
|
+
case "remove-global-shell": return `Once the new launcher reports Gentle Shell ${plainText(bundled.shell)} with Pi ${plainText(bundled.pi)}, remove the old global gentle-pi with ` +
|
|
445
|
+
`\`${plainText(bundled.migrate?.command)}\`. Your Pi and ~/.gentle-shell/agent are not changed.`;
|
|
446
|
+
default: return actionDescriptions[action.id] ?? "Prepare the installation.";
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
/** The bundled plan's view: the same shape as planView's, with `bundled: true`. */
|
|
450
|
+
function bundledView(planId, plan, inventory) {
|
|
451
|
+
const bundled = plan.bundled;
|
|
452
|
+
const [root, bin, file, line] = [plainText(bundled.root), plainText(bundled.bin), plainText(bundled.path?.file), plainText(bundled.path?.line)];
|
|
453
|
+
const kind = bundled.path?.kind;
|
|
454
|
+
const profile = {
|
|
455
|
+
symlink: `The installer links ${file} to the \`gentle-shell\` launcher. That folder is already on your PATH, so no shell profile changes.`,
|
|
456
|
+
profile: `The installer adds one line to ${file} so new terminals find ${bin}. Open a new terminal afterwards.`,
|
|
457
|
+
registry: `The installer adds ${bin} to your user PATH so new terminals find \`gentle-shell\`. Open a new terminal afterwards.`,
|
|
458
|
+
manual: `The installer may not edit ${file} (a link, or a file it cannot write), so it changes nothing there: add this line to ${file} yourself, then open a new terminal: ${line}`,
|
|
459
|
+
}[kind] ?? `Your PATH already finds ${bin}; no shell profile or PATH change is planned.`;
|
|
460
|
+
const go = STABLE.test(bundled.go ?? "");
|
|
461
|
+
return {
|
|
462
|
+
planId,
|
|
463
|
+
ready: false,
|
|
464
|
+
bundled: true,
|
|
465
|
+
actions: plan.actions.map((action) => ({ id: identifier(action.id), description: bundledDescription(action, bundled) })),
|
|
466
|
+
blockers: [],
|
|
467
|
+
profileChange: { changesProfile: ["symlink", "profile", "registry"].includes(kind), command: null, binDir: bin, description: profile },
|
|
468
|
+
sharedFolders: null,
|
|
469
|
+
gitNotice: gitNoticeView(inventory),
|
|
470
|
+
persistence: {
|
|
471
|
+
tools: ["node", "npm", "pnpm", ...(go ? ["go"] : [])],
|
|
472
|
+
pnpmHome: root,
|
|
473
|
+
description: `Gentle Shell installs as one self-contained product: its own pinned Node.js, npm, pnpm${go ? ", Go" : ""} and Pi, kept in ${root}. ` +
|
|
474
|
+
"Your own Node.js, npm, pnpm, Go and Pi are not used or changed. Only your npm registry, authentication and proxy settings (from ~/.npmrc) are copied into that folder and respected.",
|
|
475
|
+
},
|
|
476
|
+
};
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
function planView(planId, { inventory, plan }) {
|
|
480
|
+
if (plainObject(plan.bundled)) return bundledView(planId, plan, inventory);
|
|
481
|
+
const ids = plan.actions.map((action) => action.id);
|
|
482
|
+
const binDir = typeof inventory?.globalBin?.path === "string" ? inventory.globalBin.path : null;
|
|
483
|
+
// A private PNPM_HOME (S6) is always persisted by `pnpm setup` (the runner's persist-path).
|
|
484
|
+
const privateHome = plan.tools?.pnpmHome?.status === "private" ? plan.tools.pnpmHome : null;
|
|
485
|
+
const pnpmHome = privateHome ? plainText(privateHome.path) : binDir === null ? null : dirname(binDir);
|
|
486
|
+
const changesProfile = ids.includes("setup-global-bin") || (privateHome !== null && inventory?.globalBin?.onPath !== true);
|
|
487
|
+
let tools = [];
|
|
488
|
+
const alongside = alongsideNotes(plan);
|
|
489
|
+
// A persistent pnpm next to a bootstrap-only Node is kept (persist-npm), never replaced.
|
|
490
|
+
if (ids.includes("persist-node")) tools = ids.includes("persist-npm") ? ["node", "npm"] : ["node", "npm", "pnpm"];
|
|
491
|
+
else if (ids.includes("persist-package-managers")) tools = ["npm", "pnpm"];
|
|
492
|
+
else if (ids.includes("persist-npm")) tools = ["npm"];
|
|
493
|
+
else if (ids.includes("persist-pnpm")) tools = ["pnpm"];
|
|
494
|
+
// A user's pnpm in $PNPM_HOME/bin that could not be checked: kept, never run again.
|
|
495
|
+
const unchecked = plan.tools?.pnpm?.unchecked === true
|
|
496
|
+
? "The pnpm in the pnpm global bin directory could not be checked, so it was left as it is, and the installer uses its own verified pnpm for this installation."
|
|
497
|
+
: null;
|
|
498
|
+
return {
|
|
499
|
+
planId,
|
|
500
|
+
ready: plan.ready === true,
|
|
501
|
+
actions: plan.actions.map((action) => ({
|
|
502
|
+
id: identifier(action.id),
|
|
503
|
+
description: actionDescription(action, plan),
|
|
504
|
+
})),
|
|
505
|
+
blockers: plan.blockers.map((blocker) => ({
|
|
506
|
+
code: identifier(blocker.code),
|
|
507
|
+
tool: toolName(blocker.tool),
|
|
508
|
+
guidance: blockerGuidance(blocker, inventory, plan),
|
|
509
|
+
})),
|
|
510
|
+
profileChange: {
|
|
511
|
+
changesProfile,
|
|
512
|
+
command: "pnpm setup",
|
|
513
|
+
binDir,
|
|
514
|
+
description: privateHome ? privateHomeDescription(privateHome) : changesProfile
|
|
515
|
+
? `\`pnpm setup\` will add ${binDir ?? "the pnpm global bin directory"} to your PATH: it edits your shell profile on macOS and Linux, or your user PATH on Windows. Open a new terminal afterwards.`
|
|
516
|
+
: "Your PATH already contains the pnpm global bin directory; no shell profile or PATH change is planned.",
|
|
517
|
+
},
|
|
518
|
+
sharedFolders: sharedFoldersView(plan),
|
|
519
|
+
gitNotice: gitNoticeView(inventory),
|
|
520
|
+
persistence: {
|
|
521
|
+
tools,
|
|
522
|
+
pnpmHome,
|
|
523
|
+
description: tools.length > 0
|
|
524
|
+
? [`${tools.join(", ")} will be installed under $PNPM_HOME (${pnpmHome ?? "pnpm's home directory"}) so new terminals keep working after the temporary installer tools are removed. Existing installations are not replaced.`, ...alongside, ...(unchecked ? [unchecked] : [])].join(" ")
|
|
525
|
+
: unchecked ? `No runtime needs to be installed under $PNPM_HOME. ${unchecked}`
|
|
526
|
+
: "No runtime needs to be installed under $PNPM_HOME; your existing Node.js, npm and pnpm are reused.",
|
|
527
|
+
},
|
|
528
|
+
};
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
function outcomeView(result) {
|
|
532
|
+
if (!plainObject(result) || !["ready", "terminal-action-required", "blocked", "failed"].includes(result.outcome)) {
|
|
533
|
+
return { outcome: "failed", failedStep: null, completed: [], guidance: guidance.fallback };
|
|
534
|
+
}
|
|
535
|
+
const view = { outcome: result.outcome, completed: Array.isArray(result.completed) ? result.completed.map((step) => identifier(step)) : [] };
|
|
536
|
+
if (result.outcome === "blocked") {
|
|
537
|
+
view.reason = identifier(result.reason);
|
|
538
|
+
view.guidance = guidance.blocked[result.reason] ?? guidance.fallback;
|
|
539
|
+
} else if (result.outcome === "failed") {
|
|
540
|
+
view.failedStep = identifier(result.failedStep);
|
|
541
|
+
view.guidance = guidance.failed[result.failedStep] ?? guidance.fallback;
|
|
542
|
+
const step = Object.hasOwn(detailSteps, result.failedStep) ? result.failedStep : null;
|
|
543
|
+
const detail = step ? setupDetail(result.detail) : null;
|
|
544
|
+
if (detail) {
|
|
545
|
+
view.detail = detail;
|
|
546
|
+
if (detailSteps[step](detail)) view.guidance = guidance[detailGuidance[step]];
|
|
547
|
+
}
|
|
548
|
+
} else {
|
|
549
|
+
view.guidance = guidance.outcomes[result.outcome];
|
|
550
|
+
// The Pi Gentle Shell runs, when pnpm put one other than the installer's next to it.
|
|
551
|
+
if (typeof result.piVersion === "string" && STABLE.test(result.piVersion) && result.piVersion.replace(/^v/, "") !== PI_INSTALL_VERSION) {
|
|
552
|
+
view.guidance += ` Gentle Shell runs Pi ${result.piVersion.replace(/^v/, "")}, which pnpm installed next to it.`;
|
|
553
|
+
}
|
|
554
|
+
if (result.action === "open-new-terminal") view.action = "open-new-terminal";
|
|
555
|
+
// The bundled install's line for a profile it may not edit: plain text only.
|
|
556
|
+
const pathLine = result.action === "add-path-line" && plainObject(result.pathLine) ? result.pathLine : null;
|
|
557
|
+
if (pathLine) {
|
|
558
|
+
view.action = "add-path-line";
|
|
559
|
+
view.guidance = guidance.outcomes["add-path-line"];
|
|
560
|
+
const [file, line] = [setupDetail(pathLine.file), setupDetail(pathLine.line)];
|
|
561
|
+
if (file === pathLine.file && line === pathLine.line && file && line) view.pathLine = { file, line };
|
|
562
|
+
}
|
|
563
|
+
if (["configured", "unchanged"].includes(result.npmPrefix)) view.npmPrefix = result.npmPrefix;
|
|
564
|
+
// Only the two fixed removal commands are ever shown.
|
|
565
|
+
const remove = plainObject(result.removeGlobal) && result.removeGlobal.status === "failed" ? result.removeGlobal.command : null;
|
|
566
|
+
if (["pnpm remove -g gentle-pi", "npm uninstall -g gentle-pi"].includes(remove)) {
|
|
567
|
+
view.removeCommand = remove;
|
|
568
|
+
view.guidance += ` ${guidance.removeGlobalFailed}`;
|
|
569
|
+
}
|
|
570
|
+
}
|
|
571
|
+
return view;
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
/**
|
|
575
|
+
* createInstallerServer({ collectPlan, runInstall, assetsDir, now?, random?, limits?, onRedeemed? })
|
|
576
|
+
* collectPlan(channel) -> { inventory, plan } for "release" or "main" (fresh preflight, trusted local code);
|
|
577
|
+
* runInstall({ plan, consent: true }, log) -> runner result; onRedeemed() runs once
|
|
578
|
+
* when the one-time code is used (it must not throw). Returns
|
|
579
|
+
* { listen(), close(reason), closed, checkIdle(), outcome() }.
|
|
580
|
+
*/
|
|
581
|
+
export function createInstallerServer({ collectPlan, runInstall, assetsDir, now = Date.now, random = randomBytes, limits = {},
|
|
582
|
+
onRedeemed = () => {} }) {
|
|
583
|
+
const limit = { ...defaultLimits, ...limits };
|
|
584
|
+
const secret = token(random, 32);
|
|
585
|
+
let code = token(random, 32);
|
|
586
|
+
let codeExpires = 0;
|
|
587
|
+
let port = 0;
|
|
588
|
+
let origin = "";
|
|
589
|
+
let lastActivity = now();
|
|
590
|
+
let stored = null;
|
|
591
|
+
const planning = { release: null, main: null };
|
|
592
|
+
let installing = false;
|
|
593
|
+
let lastOutcome = null;
|
|
594
|
+
let seq = 0;
|
|
595
|
+
const log = [];
|
|
596
|
+
let idleTimer = null;
|
|
597
|
+
let closing = null;
|
|
598
|
+
let resolveClosed;
|
|
599
|
+
const closed = new Promise((resolve) => { resolveClosed = resolve; });
|
|
600
|
+
|
|
601
|
+
// requireHostHeader:false lets a missing Host get the same 421 as a wrong one.
|
|
602
|
+
const server = createServer({ maxHeaderSize: 16 * 1024, requireHostHeader: false }, (req, res) => {
|
|
603
|
+
handle(req, res).catch(() => {
|
|
604
|
+
if (!res.headersSent) reply(res, 500, { error: "internal" });
|
|
605
|
+
else res.destroy();
|
|
606
|
+
});
|
|
607
|
+
});
|
|
608
|
+
server.headersTimeout = limit.headersTimeoutMs;
|
|
609
|
+
server.requestTimeout = limit.requestTimeoutMs;
|
|
610
|
+
server.keepAliveTimeout = 5 * SECOND;
|
|
611
|
+
|
|
612
|
+
function reply(res, status, body, headers = {}) {
|
|
613
|
+
const payload = typeof body === "string" || Buffer.isBuffer(body) ? body : JSON.stringify(body);
|
|
614
|
+
res.writeHead(status, {
|
|
615
|
+
...securityHeaders,
|
|
616
|
+
"Content-Type": "application/json; charset=utf-8",
|
|
617
|
+
...headers,
|
|
618
|
+
"Content-Length": Buffer.byteLength(payload),
|
|
619
|
+
});
|
|
620
|
+
res.end(payload);
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
function hasSession(req) {
|
|
624
|
+
const pairs = String(req.headers.cookie ?? "").split(";").map((part) => part.trim())
|
|
625
|
+
.filter((part) => part.startsWith(`${COOKIE}=`));
|
|
626
|
+
return pairs.length === 1 && sameSecret(secret, pairs[0].slice(COOKIE.length + 1));
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
async function readJson(req, res) {
|
|
630
|
+
const declared = Number(req.headers["content-length"]);
|
|
631
|
+
if (Number.isFinite(declared) && declared > limit.bodyBytes) {
|
|
632
|
+
reply(res, 413, { error: "body-too-large" }, { Connection: "close" });
|
|
633
|
+
return undefined;
|
|
634
|
+
}
|
|
635
|
+
const chunks = [];
|
|
636
|
+
let size = 0;
|
|
637
|
+
for await (const chunk of req) {
|
|
638
|
+
size += chunk.length;
|
|
639
|
+
if (size > limit.bodyBytes) {
|
|
640
|
+
reply(res, 413, { error: "body-too-large" }, { Connection: "close" });
|
|
641
|
+
return undefined;
|
|
642
|
+
}
|
|
643
|
+
chunks.push(chunk);
|
|
644
|
+
}
|
|
645
|
+
try {
|
|
646
|
+
return { value: JSON.parse(Buffer.concat(chunks).toString("utf8") || "null") };
|
|
647
|
+
} catch {
|
|
648
|
+
return { value: undefined };
|
|
649
|
+
}
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
function record(entry) {
|
|
653
|
+
const value = plainObject(entry) ? entry : {};
|
|
654
|
+
seq += 1;
|
|
655
|
+
log.push({ seq, step: identifier(value.step), status: identifier(value.status),
|
|
656
|
+
reason: value.reason === undefined || value.reason === null ? null : identifier(value.reason) });
|
|
657
|
+
while (log.length > limit.logEntries) log.shift();
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
// One in-flight inventory per channel: the channels plan different steps.
|
|
661
|
+
function collect(channel) {
|
|
662
|
+
planning[channel] ??= Promise.resolve().then(() => collectPlan(channel)).finally(() => { planning[channel] = null; });
|
|
663
|
+
return planning[channel];
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
async function getPlan(query, res) {
|
|
667
|
+
// Only `?channel=release` or `?channel=main`; no query means release.
|
|
668
|
+
const params = new URLSearchParams(query);
|
|
669
|
+
const keys = [...params.keys()];
|
|
670
|
+
const channel = query === "" ? "release" : params.get("channel");
|
|
671
|
+
if ((query !== "" && (keys.length !== 1 || keys[0] !== "channel")) || !["release", "main"].includes(channel)) {
|
|
672
|
+
reply(res, 400, { error: "invalid-request" });
|
|
673
|
+
return;
|
|
674
|
+
}
|
|
675
|
+
// Probing while the runner mutates the machine would describe a moving target.
|
|
676
|
+
if (installing) {
|
|
677
|
+
reply(res, 409, { error: "install-running" });
|
|
678
|
+
return;
|
|
679
|
+
}
|
|
680
|
+
let collected;
|
|
681
|
+
try {
|
|
682
|
+
collected = await collect(channel);
|
|
683
|
+
} catch {
|
|
684
|
+
reply(res, 500, { error: "plan-unavailable" });
|
|
685
|
+
return;
|
|
686
|
+
}
|
|
687
|
+
if (installing) {
|
|
688
|
+
reply(res, 409, { error: "install-running" });
|
|
689
|
+
return;
|
|
690
|
+
}
|
|
691
|
+
stored = { planId: token(random, 18), channel, collected: structuredClone(collected), fingerprint: fingerprint(collected) };
|
|
692
|
+
reply(res, 200, { ...planView(stored.planId, stored.collected), channel });
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
async function install(req, res) {
|
|
696
|
+
const body = await readJson(req, res);
|
|
697
|
+
if (body === undefined) return;
|
|
698
|
+
const { value } = body;
|
|
699
|
+
const keys = plainObject(value) ? Object.keys(value).sort() : [];
|
|
700
|
+
if (keys.length !== 2 || keys[0] !== "consent" || keys[1] !== "planId" || value.consent !== true ||
|
|
701
|
+
typeof value.planId !== "string" || !/^[A-Za-z0-9_-]{1,128}$/.test(value.planId)) {
|
|
702
|
+
reply(res, 400, { error: "invalid-request" });
|
|
703
|
+
return;
|
|
704
|
+
}
|
|
705
|
+
if (installing) {
|
|
706
|
+
reply(res, 409, { error: "install-running" });
|
|
707
|
+
return;
|
|
708
|
+
}
|
|
709
|
+
// One installation per wizard run: a final outcome is never replaced.
|
|
710
|
+
if (lastOutcome !== null) {
|
|
711
|
+
reply(res, 409, { error: "already-completed" });
|
|
712
|
+
return;
|
|
713
|
+
}
|
|
714
|
+
if (stored === null || !sameSecret(stored.planId, value.planId)) {
|
|
715
|
+
reply(res, 409, { error: "plan-changed" });
|
|
716
|
+
return;
|
|
717
|
+
}
|
|
718
|
+
installing = true;
|
|
719
|
+
const current = stored;
|
|
720
|
+
let fresh;
|
|
721
|
+
try {
|
|
722
|
+
// Re-inventory: install only when the machine still matches the consented plan.
|
|
723
|
+
fresh = await collect(current.channel);
|
|
724
|
+
} catch {
|
|
725
|
+
fresh = null;
|
|
726
|
+
}
|
|
727
|
+
if (fresh === null || stored !== current || fingerprint(fresh) !== current.fingerprint) {
|
|
728
|
+
installing = false;
|
|
729
|
+
stored = null;
|
|
730
|
+
reply(res, 409, { error: "plan-changed" });
|
|
731
|
+
return;
|
|
732
|
+
}
|
|
733
|
+
stored = null;
|
|
734
|
+
const request = { plan: structuredClone(current.collected.plan), consent: true };
|
|
735
|
+
// Viewing an untrusted result can throw; the installation must still end
|
|
736
|
+
// with a failed outcome and release the single-flight and idle locks.
|
|
737
|
+
Promise.resolve()
|
|
738
|
+
.then(() => runInstall(request, record))
|
|
739
|
+
.then((result) => {
|
|
740
|
+
try {
|
|
741
|
+
return outcomeView(result);
|
|
742
|
+
} catch {
|
|
743
|
+
return outcomeView(null);
|
|
744
|
+
}
|
|
745
|
+
}, () => outcomeView(null))
|
|
746
|
+
.then((view) => {
|
|
747
|
+
lastOutcome = view;
|
|
748
|
+
})
|
|
749
|
+
.finally(() => {
|
|
750
|
+
installing = false;
|
|
751
|
+
lastActivity = now();
|
|
752
|
+
});
|
|
753
|
+
reply(res, 202, { started: true });
|
|
754
|
+
}
|
|
755
|
+
|
|
756
|
+
function progress(query, res) {
|
|
757
|
+
const params = new URLSearchParams(query);
|
|
758
|
+
const keys = [...params.keys()];
|
|
759
|
+
const after = params.get("after") ?? "0";
|
|
760
|
+
if (keys.some((key) => key !== "after") || keys.length > 1 || !/^(0|[1-9]\d{0,15})$/.test(after)) {
|
|
761
|
+
reply(res, 400, { error: "invalid-request" });
|
|
762
|
+
return;
|
|
763
|
+
}
|
|
764
|
+
const from = Number(after);
|
|
765
|
+
reply(res, 200, { entries: log.filter((entry) => entry.seq > from), running: installing, outcome: lastOutcome });
|
|
766
|
+
}
|
|
767
|
+
|
|
768
|
+
async function session(query, res) {
|
|
769
|
+
const params = new URLSearchParams(query);
|
|
770
|
+
const supplied = params.get("code");
|
|
771
|
+
const valid = code !== null && now() <= codeExpires && [...params.keys()].length === 1 && supplied !== null && sameSecret(code, supplied);
|
|
772
|
+
if (!valid) {
|
|
773
|
+
reply(res, 401, { error: "unauthorized" });
|
|
774
|
+
return;
|
|
775
|
+
}
|
|
776
|
+
code = null;
|
|
777
|
+
lastActivity = now();
|
|
778
|
+
try {
|
|
779
|
+
onRedeemed();
|
|
780
|
+
} catch {
|
|
781
|
+
// Cleanup hooks are best effort and never break the session.
|
|
782
|
+
}
|
|
783
|
+
reply(res, 200, sessionPage, {
|
|
784
|
+
"Content-Type": "text/html; charset=utf-8",
|
|
785
|
+
"Set-Cookie": `${COOKIE}=${secret}; HttpOnly; SameSite=Strict; Path=/`,
|
|
786
|
+
});
|
|
787
|
+
}
|
|
788
|
+
|
|
789
|
+
async function asset(path, res) {
|
|
790
|
+
let content;
|
|
791
|
+
try {
|
|
792
|
+
content = await readFile(join(assetsDir, assets[path].file));
|
|
793
|
+
} catch {
|
|
794
|
+
reply(res, 404, { error: "not-found" });
|
|
795
|
+
return;
|
|
796
|
+
}
|
|
797
|
+
reply(res, 200, content, { "Content-Type": assets[path].type });
|
|
798
|
+
}
|
|
799
|
+
|
|
800
|
+
async function handle(req, res) {
|
|
801
|
+
// Only authenticated requests (and a code redemption) count as activity.
|
|
802
|
+
if (req.headers.host !== `${HOST}:${port}`) return reply(res, 421, { error: "misdirected" });
|
|
803
|
+
const raw = String(req.url ?? "");
|
|
804
|
+
const mark = raw.indexOf("?");
|
|
805
|
+
const path = mark === -1 ? raw : raw.slice(0, mark);
|
|
806
|
+
const query = mark === -1 ? "" : raw.slice(mark + 1);
|
|
807
|
+
const method = routes[Object.hasOwn(routes, path) ? path : ""];
|
|
808
|
+
if (method === undefined) return reply(res, 404, { error: "not-found" });
|
|
809
|
+
if (req.method !== method) return reply(res, 405, { error: "method-not-allowed" }, { Allow: method });
|
|
810
|
+
// Browsers send Origin on cross-origin GETs; only our own origin may.
|
|
811
|
+
if (req.headers.origin !== undefined && req.headers.origin !== origin) return reply(res, 403, { error: "forbidden" });
|
|
812
|
+
if (path === "/session") return session(query, res);
|
|
813
|
+
if (!hasSession(req)) return reply(res, 401, { error: "unauthorized" });
|
|
814
|
+
lastActivity = now();
|
|
815
|
+
// A custom header makes every cross-origin API fetch need a CORS preflight, which never succeeds.
|
|
816
|
+
if (path.startsWith("/api/") && req.headers["x-gentle-install"] !== "1") return reply(res, 403, { error: "forbidden" });
|
|
817
|
+
if (method === "POST") {
|
|
818
|
+
if (req.headers.origin !== origin) return reply(res, 403, { error: "forbidden" });
|
|
819
|
+
const type = String(req.headers["content-type"] ?? "").split(";")[0].trim().toLowerCase();
|
|
820
|
+
if (type !== "application/json") return reply(res, 415, { error: "unsupported-media-type" });
|
|
821
|
+
}
|
|
822
|
+
if (query !== "" && path !== "/api/progress" && path !== "/api/plan") return reply(res, 400, { error: "invalid-request" });
|
|
823
|
+
if (Object.hasOwn(assets, path)) return asset(path, res);
|
|
824
|
+
if (path === "/api/plan") return getPlan(query, res);
|
|
825
|
+
if (path === "/api/progress") return progress(query, res);
|
|
826
|
+
if (path === "/api/install") return install(req, res);
|
|
827
|
+
// /api/shutdown
|
|
828
|
+
const body = await readJson(req, res);
|
|
829
|
+
if (body === undefined) return undefined;
|
|
830
|
+
if (body.value !== null && !(plainObject(body.value) && Object.keys(body.value).length === 0)) {
|
|
831
|
+
return reply(res, 400, { error: "invalid-request" });
|
|
832
|
+
}
|
|
833
|
+
if (installing) return reply(res, 409, { error: "install-running" });
|
|
834
|
+
res.once("finish", () => close("shutdown"));
|
|
835
|
+
reply(res, 200, { closing: true });
|
|
836
|
+
return undefined;
|
|
837
|
+
}
|
|
838
|
+
|
|
839
|
+
function checkIdle() {
|
|
840
|
+
if (closing || installing || now() - lastActivity < limit.idleMs) return false;
|
|
841
|
+
close("idle");
|
|
842
|
+
return true;
|
|
843
|
+
}
|
|
844
|
+
|
|
845
|
+
function close(reason) {
|
|
846
|
+
closing ??= new Promise((resolve) => {
|
|
847
|
+
if (idleTimer) clearInterval(idleTimer);
|
|
848
|
+
code = null;
|
|
849
|
+
server.close(() => resolve());
|
|
850
|
+
server.closeIdleConnections();
|
|
851
|
+
setImmediate(() => server.closeAllConnections());
|
|
852
|
+
}).then(() => {
|
|
853
|
+
resolveClosed({ reason, outcome: lastOutcome });
|
|
854
|
+
});
|
|
855
|
+
return closing;
|
|
856
|
+
}
|
|
857
|
+
|
|
858
|
+
async function listen() {
|
|
859
|
+
await new Promise((resolve, reject) => {
|
|
860
|
+
server.once("error", reject);
|
|
861
|
+
server.listen({ host: HOST, port: 0, exclusive: true }, () => {
|
|
862
|
+
server.off("error", reject);
|
|
863
|
+
resolve();
|
|
864
|
+
});
|
|
865
|
+
});
|
|
866
|
+
const address = server.address();
|
|
867
|
+
if (typeof address !== "object" || address === null || address.address !== HOST || address.family !== "IPv4") {
|
|
868
|
+
await close("bind-mismatch");
|
|
869
|
+
throw new Error("Wizard host is not bound to 127.0.0.1");
|
|
870
|
+
}
|
|
871
|
+
port = address.port;
|
|
872
|
+
origin = `http://${HOST}:${port}`;
|
|
873
|
+
codeExpires = now() + limit.codeTtlMs;
|
|
874
|
+
lastActivity = now();
|
|
875
|
+
idleTimer = setInterval(checkIdle, limit.idleCheckMs);
|
|
876
|
+
idleTimer.unref();
|
|
877
|
+
return { port, address: address.address, origin, url: `${origin}/session?code=${code}` };
|
|
878
|
+
}
|
|
879
|
+
|
|
880
|
+
return { listen, close, closed, checkIdle, outcome: () => lastOutcome };
|
|
881
|
+
}
|