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,1308 @@
|
|
|
1
|
+
// The bundled Gentle Shell install (S2, S3, S6, S7): one private prefix with our
|
|
2
|
+
// own pinned Node with its npm and pnpm (and Go only when asked), every pnpm and npm folder inside it,
|
|
3
|
+
// one frozen install per version, an atomic `current`, a launcher and one PATH
|
|
4
|
+
// entry. The user's node, npm, pnpm and go are never run, read or changed.
|
|
5
|
+
// Shared by the web installer and `gentle-shell upgrade`; every effect outside
|
|
6
|
+
// plain path arithmetic goes through an injectable adapter for tests.
|
|
7
|
+
import { createHash, randomBytes } from "node:crypto";
|
|
8
|
+
import { accessSync, chmodSync, closeSync, constants, fchmodSync, fstatSync, lstatSync, mkdirSync, mkdtempSync, openSync, readFileSync, readdirSync, readlinkSync, realpathSync, renameSync,
|
|
9
|
+
rmSync, symlinkSync, unlinkSync, writeFileSync, writeSync } from "node:fs";
|
|
10
|
+
import { posix, win32 } from "node:path";
|
|
11
|
+
import { spawn } from "node:child_process";
|
|
12
|
+
import { pathToFileURL } from "node:url";
|
|
13
|
+
import { crc32, gunzipSync, inflateRawSync } from "node:zlib";
|
|
14
|
+
import { acquireGo, artifactFor, engramPinVersion, verifiedDownload } from "./installer-downloads.mjs";
|
|
15
|
+
import { ensureWindowsPrivateFolder, readWindowsPnpmArchive, verifyWindowsStorage } from "./installer-windows.mjs";
|
|
16
|
+
|
|
17
|
+
// The acquisition pins already verified for the installer; never a second copy.
|
|
18
|
+
const pins = Object.freeze({ node: artifactFor("node", "linux", "x64").version, pnpm: artifactFor("pnpm").version });
|
|
19
|
+
const RUNTIME_MARKER = ".gentle-shell-runtime";
|
|
20
|
+
const VERSION_MARKER = ".gentle-shell-version";
|
|
21
|
+
const WINDOWS_MARKER = Object.freeze({ marker: ".gentle-shell-bundle", text: "gentle-shell bundled install" });
|
|
22
|
+
const LAUNCHER_MARK = "gentle-shell bundled launcher (generated; do not edit)";
|
|
23
|
+
const LAUNCHER_MISSING = "gentle-shell: no active Gentle Shell version; run the Gentle Shell installer again.";
|
|
24
|
+
const PROFILE_MARK = "# gentle-shell bundled install";
|
|
25
|
+
const SEMVER = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$/;
|
|
26
|
+
const VERSION_ID = /^[0-9A-Za-z][0-9A-Za-z.-]*$/;
|
|
27
|
+
const MAX_EXPANDED = 512 * 1024 * 1024;
|
|
28
|
+
const INSTALL_TIMEOUT = 15 * 60 * 1000;
|
|
29
|
+
|
|
30
|
+
const pathFor = (platform) => (platform === "win32" ? win32 : posix);
|
|
31
|
+
function stat(path) {
|
|
32
|
+
try { return lstatSync(path); }
|
|
33
|
+
catch (error) { if (error.code === "ENOENT" || error.code === "ENOTDIR") return null; throw error; }
|
|
34
|
+
}
|
|
35
|
+
const regular = (path) => stat(path)?.isFile() === true;
|
|
36
|
+
const read = (path) => (regular(path) ? readFileSync(path, "utf8") : null);
|
|
37
|
+
/** A value from a Windows environment, whose names are case-insensitive. */
|
|
38
|
+
function envValue(env, name, platform) {
|
|
39
|
+
const key = platform === "win32" ? Object.keys(env).find((candidate) => candidate.toUpperCase() === name) : name;
|
|
40
|
+
return key === undefined ? undefined : env[key];
|
|
41
|
+
}
|
|
42
|
+
/** %LOCALAPPDATA%\gentle-shell, then %USERPROFILE%\.gentle-shell-bundle, as the bootstrap claims its tools. */
|
|
43
|
+
function windowsCandidates(env) {
|
|
44
|
+
const usable = (value) => typeof value === "string" && win32.isAbsolute(value) && !value.startsWith("\\\\");
|
|
45
|
+
const local = envValue(env, "LOCALAPPDATA", "win32");
|
|
46
|
+
const profile = envValue(env, "USERPROFILE", "win32");
|
|
47
|
+
return [usable(local) ? win32.join(local, "gentle-shell") : null, usable(profile) ? win32.join(profile, ".gentle-shell-bundle") : null].filter(Boolean);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Every bundled path, from plain path arithmetic only. POSIX: `~/.gentle-shell`.
|
|
51
|
+
* Windows: `root` as claimPrefix chose it, otherwise the %LOCALAPPDATA% candidate.
|
|
52
|
+
* The agent home stays `<home>/.gentle-shell/agent` on every platform.
|
|
53
|
+
*/
|
|
54
|
+
export function prefixLayout({ platform, env = {}, home, root }) {
|
|
55
|
+
const path = pathFor(platform);
|
|
56
|
+
if (typeof home !== "string" || !path.isAbsolute(home)) throw new Error("The bundled install needs an absolute home directory");
|
|
57
|
+
const windows = platform === "win32";
|
|
58
|
+
const base = root ?? (windows ? windowsCandidates(env)[0] : path.join(home, ".gentle-shell"));
|
|
59
|
+
if (typeof base !== "string" || !path.isAbsolute(base)) throw new Error("The bundled install has no private folder");
|
|
60
|
+
const runtime = path.join(base, "runtime");
|
|
61
|
+
const nodeDir = path.join(runtime, `node-${pins.node}`);
|
|
62
|
+
const pnpmDir = path.join(runtime, `pnpm-${pins.pnpm}`);
|
|
63
|
+
const pnpmHome = path.join(base, "pnpm");
|
|
64
|
+
const npmHome = path.join(base, "npm");
|
|
65
|
+
const bin = path.join(base, "bin");
|
|
66
|
+
return Object.freeze({
|
|
67
|
+
platform, root: base, agent: path.join(home, ".gentle-shell", "agent"), runtime,
|
|
68
|
+
nodeDir, node: windows ? path.join(nodeDir, "node.exe") : path.join(nodeDir, "bin", "node"),
|
|
69
|
+
// The npm bundled in the same Node archive, for Pi's own package installs.
|
|
70
|
+
npmCli: path.join(nodeDir, ...(windows ? [] : ["lib"]), "node_modules", "npm", "bin", "npm-cli.js"),
|
|
71
|
+
// The entry Pi's npmCommand runs that npm through (ensureNpmShim).
|
|
72
|
+
npmShim: path.join(nodeDir, "gentle-shell", "npm"),
|
|
73
|
+
npmHome, npmPrefix: path.join(npmHome, "prefix"), npmCache: path.join(npmHome, "cache"), npmGlobalConfig: path.join(npmHome, "npmrc"),
|
|
74
|
+
pnpmDir, pnpm: path.join(pnpmDir, "package", "bin", "pnpm.mjs"),
|
|
75
|
+
// acquireGo publishes `<goRoot>/<version>/go`.
|
|
76
|
+
goRoot: path.join(runtime, "go"),
|
|
77
|
+
// The pinned Engram (ensureEngram), given to Gentle AI and gentle-engram (S9).
|
|
78
|
+
engramDir: path.join(runtime, `engram-${engramPinVersion}`),
|
|
79
|
+
engram: path.join(runtime, `engram-${engramPinVersion}`, windows ? "engram.exe" : "engram"),
|
|
80
|
+
pnpmHome, store: path.join(pnpmHome, "store"), cache: path.join(pnpmHome, "cache"), state: path.join(pnpmHome, "state"),
|
|
81
|
+
config: path.join(pnpmHome, "config"), npmrc: path.join(pnpmHome, "config", "npmrc"), tmp: path.join(base, "tmp"),
|
|
82
|
+
versions: path.join(base, "versions"), history: path.join(base, "versions.history"),
|
|
83
|
+
// The applied PATH entry (writePathReceipt), which self-uninstall reverts exactly.
|
|
84
|
+
pathReceipt: path.join(base, "path-entry.json"),
|
|
85
|
+
current: path.join(base, "current"), currentKind: windows ? "pointer" : "symlink",
|
|
86
|
+
bin, launcher: path.join(bin, windows ? "gentle-shell.cmd" : "gentle-shell"),
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** POSIX: a real directory owned by this user that no one else can write. */
|
|
91
|
+
function privateDirectory(path) {
|
|
92
|
+
const info = stat(path);
|
|
93
|
+
if (!info?.isDirectory() || info.isSymbolicLink() || info.uid !== process.getuid() || (info.mode & 0o022)) throw new Error(`Unsafe bundled prefix: ${path}`);
|
|
94
|
+
}
|
|
95
|
+
/** POSIX: removes group and other write access from a real directory of this
|
|
96
|
+
* user, through a descriptor opened without following links; anything else is
|
|
97
|
+
* left for privateDirectory to refuse.
|
|
98
|
+
*/
|
|
99
|
+
function tightenOwnDirectory(path) {
|
|
100
|
+
let fd;
|
|
101
|
+
try { fd = openSync(path, constants.O_RDONLY | constants.O_DIRECTORY | constants.O_NOFOLLOW); }
|
|
102
|
+
catch { return; }
|
|
103
|
+
try {
|
|
104
|
+
const info = fstatSync(fd);
|
|
105
|
+
if (info.isDirectory() && info.uid === process.getuid() && (info.mode & 0o022)) fchmodSync(fd, info.mode & 0o7755);
|
|
106
|
+
} finally {
|
|
107
|
+
closeSync(fd);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
function ensureDirectory(path, platform) {
|
|
111
|
+
try { mkdirSync(path, { mode: 0o700 }); }
|
|
112
|
+
catch (error) { if (error.code !== "EEXIST") throw error; }
|
|
113
|
+
if (platform !== "win32") privateDirectory(path);
|
|
114
|
+
}
|
|
115
|
+
/** The prefix folders and the empty npmrc pnpm reads instead of ~/.npmrc. */
|
|
116
|
+
function prepareFolders(layout) {
|
|
117
|
+
for (const path of [layout.runtime, layout.pnpmHome, layout.store, layout.cache, layout.state, layout.config, layout.npmHome, layout.npmPrefix, layout.npmCache,
|
|
118
|
+
layout.versions, layout.bin, layout.tmp]) {
|
|
119
|
+
ensureDirectory(path, layout.platform);
|
|
120
|
+
}
|
|
121
|
+
if (!stat(layout.npmrc)) writeFileSync(layout.npmrc, "", { flag: "wx", mode: 0o600 });
|
|
122
|
+
if (!regular(layout.npmrc)) throw new Error(`Unsafe bundled prefix: ${layout.npmrc}`);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** Claims the prefix and returns its layout. POSIX: `~/.gentle-shell` is created
|
|
126
|
+
* 0700, or kept when it is a real directory of this user (the agent home lives
|
|
127
|
+
* there); group or other write access, which umask 002 leaves, is removed
|
|
128
|
+
* (go-w). A link, a non-folder or another owner's folder is refused. Windows: each candidate's nearest existing
|
|
129
|
+
* folder is walked like the bootstrap's tools (owner, ACL, reparse); only an
|
|
130
|
+
* untrusted owner or ACL on %LOCALAPPDATA% moves to the profile candidate. The
|
|
131
|
+
* chosen folder is claimed with the bootstrap's protected DACL and marked
|
|
132
|
+
* (ensureWindowsPrivateFolder); a non-empty unmarked folder is never adopted.
|
|
133
|
+
* `root`, the consented plan's folder: when the choice differs, it throws
|
|
134
|
+
* (check "prefix-changed") before anything is created or claimed.
|
|
135
|
+
*/
|
|
136
|
+
export function claimPrefix({ platform, env = {}, home, root, adapters = {} }) {
|
|
137
|
+
if (platform !== "win32") {
|
|
138
|
+
const layout = prefixLayout({ platform, env, home });
|
|
139
|
+
if (root !== undefined && root !== layout.root) throw Object.assign(new Error(`The bundled folder changed since the plan: ${layout.root}`), { check: "prefix-changed" });
|
|
140
|
+
if (realpathSync(home) !== posix.resolve(home)) throw new Error(`Unsafe bundled prefix: ${home}`);
|
|
141
|
+
const info = lstatSync(home);
|
|
142
|
+
if (!info.isDirectory() || info.uid !== process.getuid() || (info.mode & 0o022)) throw new Error(`Unsafe bundled prefix: ${home}`);
|
|
143
|
+
if (stat(layout.root)?.isDirectory()) tightenOwnDirectory(layout.root);
|
|
144
|
+
ensureDirectory(layout.root, platform);
|
|
145
|
+
(adapters.prepare ?? prepareFolders)(layout);
|
|
146
|
+
return layout;
|
|
147
|
+
}
|
|
148
|
+
const storage = adapters.storage ?? verifyWindowsStorage;
|
|
149
|
+
const claim = adapters.claim ?? ((folder) => ensureWindowsPrivateFolder(folder, env, { ...WINDOWS_MARKER, storage }));
|
|
150
|
+
const candidate = prefixRoot({ platform, env, adapters });
|
|
151
|
+
// The consented plan named a folder: a claim that would choose another one changes nothing.
|
|
152
|
+
if (root !== undefined && root !== candidate) throw Object.assign(new Error(`The bundled folder changed since the plan: ${candidate}`), { check: "prefix-changed" });
|
|
153
|
+
claim(candidate);
|
|
154
|
+
const layout = prefixLayout({ platform, env, home, root: candidate });
|
|
155
|
+
(adapters.prepare ?? prepareFolders)(layout);
|
|
156
|
+
return layout;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** The folder claimPrefix will claim, chosen with read-only checks only, so a
|
|
160
|
+
* plan shows the same folder the installation uses. POSIX: undefined (the
|
|
161
|
+
* prefix is always `~/.gentle-shell`). Windows: the first candidate whose
|
|
162
|
+
* nearest existing folder passes the storage walk; an untrusted owner or ACL
|
|
163
|
+
* moves to the next candidate, any other failure throws.
|
|
164
|
+
*/
|
|
165
|
+
export function prefixRoot({ platform, env = {}, adapters = {} }) {
|
|
166
|
+
if (platform !== "win32") return undefined;
|
|
167
|
+
const exists = adapters.exists ?? ((path) => stat(path) !== null);
|
|
168
|
+
const storage = adapters.storage ?? verifyWindowsStorage;
|
|
169
|
+
const candidates = windowsCandidates(env);
|
|
170
|
+
if (candidates.length === 0) throw new Error("The bundled install has no private folder");
|
|
171
|
+
for (const [index, candidate] of candidates.entries()) {
|
|
172
|
+
let target = candidate;
|
|
173
|
+
while (!exists(target) && win32.dirname(target) !== target) target = win32.dirname(target);
|
|
174
|
+
try {
|
|
175
|
+
storage(target, env);
|
|
176
|
+
} catch (error) {
|
|
177
|
+
if (index + 1 < candidates.length && /^(?:target|parent|ancestor)-(?:owner|acl-mask)$/.test(error?.check ?? "")) continue;
|
|
178
|
+
throw error;
|
|
179
|
+
}
|
|
180
|
+
return candidate;
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/** The one executable a verified Node archive must contain: `<stem>/bin/node`
|
|
185
|
+
* from the POSIX `.tar.gz` or `<stem>/node.exe` from the Windows `.zip`, a
|
|
186
|
+
* regular file found exactly once. Every other member, links included, is
|
|
187
|
+
* skipped and never written: pnpm runs on node alone, so npm is not needed.
|
|
188
|
+
*/
|
|
189
|
+
export function nodeExecutable(bytes, descriptor) {
|
|
190
|
+
const { file, stem } = nodeArchive(descriptor);
|
|
191
|
+
return file.endsWith(".zip") ? zipMember(bytes, `${stem}/node.exe`) : tarMember(bytes, `${stem}/bin/node`);
|
|
192
|
+
}
|
|
193
|
+
function nodeArchive(descriptor) {
|
|
194
|
+
const file = String(descriptor?.url ?? "").split("/").pop();
|
|
195
|
+
const stem = file.replace(/\.(?:tar\.gz|zip)$/, "");
|
|
196
|
+
if (stem === file || !/^node-v\d+\.\d+\.\d+-[a-z0-9]+-[a-z0-9]+$/.test(stem)) throw new Error("Node archive rejected");
|
|
197
|
+
return { file, stem };
|
|
198
|
+
}
|
|
199
|
+
// Our own POSIX npm and npx: our Node runs npm's entry next to it, whatever `node` is on PATH.
|
|
200
|
+
const npmWrapper = (entry) => ["#!/bin/sh", "# gentle-shell bundled npm (generated; do not edit)", 'basedir=$(dirname "$0")',
|
|
201
|
+
`exec "$basedir/node" "$basedir/../lib/node_modules/npm/bin/${entry}" "$@"`, ""].join("\n");
|
|
202
|
+
/** Every file runtime/node-<v>/ holds, from one verified Node archive, as
|
|
203
|
+
* [{ name (relative, `/`-separated), data, executable }] with `npm` set to the
|
|
204
|
+
* bundled npm version: the Node executable (as nodeExecutable finds it) and the
|
|
205
|
+
* npm bundled with it, which Pi runs to install its packages. Windows: npm's
|
|
206
|
+
* package under node_modules/npm and its npm.cmd and npx.cmd, which run the
|
|
207
|
+
* node.exe beside them. POSIX: npm's package under lib/node_modules/npm and our
|
|
208
|
+
* own bin/npm and bin/npx wrappers in place of the archive's links. Links,
|
|
209
|
+
* Corepack and every other member are skipped; a member leaving its folder, or
|
|
210
|
+
* an archive without npm, is rejected.
|
|
211
|
+
*/
|
|
212
|
+
export function nodeRuntimeFiles(bytes, descriptor) {
|
|
213
|
+
const fail = () => { throw new Error("Node archive rejected"); };
|
|
214
|
+
const { file, stem } = nodeArchive(descriptor);
|
|
215
|
+
const windows = file.endsWith(".zip");
|
|
216
|
+
const npm = windows ? "node_modules/npm/" : "lib/node_modules/npm/";
|
|
217
|
+
const files = [{ name: windows ? "node.exe" : "bin/node", data: nodeExecutable(bytes, descriptor), executable: true }];
|
|
218
|
+
const keep = (name, data, executable) => {
|
|
219
|
+
const relative = name.slice(stem.length + 1);
|
|
220
|
+
if (!name.startsWith(`${stem}/`) || !(relative.startsWith(npm) || (windows && /^np[mx]\.cmd$/.test(relative)))) return;
|
|
221
|
+
if (relative.split("/").some((part) => part === "" || part === "." || part === "..") || relative.includes("\\")) fail();
|
|
222
|
+
files.push({ name: relative, data, executable });
|
|
223
|
+
};
|
|
224
|
+
if (windows) {
|
|
225
|
+
for (const entry of zipEntries(bytes, fail)) if (!entry.name.endsWith("/") && !(entry.unix && (entry.mode & 0o170000) !== 0o100000)) keep(entry.name, entry.read(), false);
|
|
226
|
+
} else {
|
|
227
|
+
for (const member of tarMembers(bytes, "Node archive rejected")) if (member.type === "0" || member.type === "\0") keep(member.name, Buffer.from(member.data), (member.mode & 0o100) !== 0);
|
|
228
|
+
files.push({ name: "bin/npm", data: Buffer.from(npmWrapper("npm-cli.js")), executable: true }, { name: "bin/npx", data: Buffer.from(npmWrapper("npx-cli.js")), executable: true });
|
|
229
|
+
}
|
|
230
|
+
let metadata = null;
|
|
231
|
+
try { metadata = JSON.parse(files.find((entry) => entry.name === `${npm}package.json`)?.data.toString("utf8") ?? "null"); }
|
|
232
|
+
catch { fail(); }
|
|
233
|
+
if (metadata?.name !== "npm" || !SEMVER.test(metadata.version ?? "") || !files.some((entry) => entry.name === `${npm}bin/npm-cli.js`)) fail();
|
|
234
|
+
return Object.assign(files, { npm: metadata.version });
|
|
235
|
+
}
|
|
236
|
+
function tarMember(bytes, wanted, message = "Node archive rejected") {
|
|
237
|
+
let found = null;
|
|
238
|
+
for (const member of tarMembers(bytes, message)) {
|
|
239
|
+
if (member.name !== wanted) continue;
|
|
240
|
+
if ((member.type !== "0" && member.type !== "\0") || found) throw new Error(message);
|
|
241
|
+
found = Buffer.from(member.data);
|
|
242
|
+
}
|
|
243
|
+
if (!found?.length) throw new Error(message);
|
|
244
|
+
return found;
|
|
245
|
+
}
|
|
246
|
+
/** Every member of a `.tar.gz`: { name, type, mode, data }. Headers are
|
|
247
|
+
* checksummed; anything malformed throws `message`.
|
|
248
|
+
*/
|
|
249
|
+
function* tarMembers(bytes, message) {
|
|
250
|
+
const fail = () => { throw new Error(message); };
|
|
251
|
+
const tar = gunzipSync(bytes, { maxOutputLength: MAX_EXPANDED });
|
|
252
|
+
const text = (block, start, length) => block.subarray(start, start + length).toString("utf8").replace(/\0[^]*$/, "");
|
|
253
|
+
const number = (block, start, length) => {
|
|
254
|
+
const value = text(block, start, length).trim();
|
|
255
|
+
if (!/^[0-7]{1,12}$/.test(value)) fail();
|
|
256
|
+
return parseInt(value, 8);
|
|
257
|
+
};
|
|
258
|
+
let renamed = null;
|
|
259
|
+
for (let offset = 0; ;) {
|
|
260
|
+
if (offset + 512 > tar.length) fail();
|
|
261
|
+
const block = tar.subarray(offset, offset + 512);
|
|
262
|
+
if (block.every((byte) => byte === 0)) return;
|
|
263
|
+
let checksum = 0;
|
|
264
|
+
for (let index = 0; index < 512; index += 1) checksum += index >= 148 && index < 156 ? 0x20 : block[index];
|
|
265
|
+
if (checksum !== number(block, 148, 8) || block.subarray(257, 262).toString("latin1") !== "ustar") fail();
|
|
266
|
+
const size = number(block, 124, 12);
|
|
267
|
+
const type = String.fromCharCode(block[156]);
|
|
268
|
+
const start = offset + 512;
|
|
269
|
+
if (start + size > tar.length) fail();
|
|
270
|
+
offset = start + Math.ceil(size / 512) * 512;
|
|
271
|
+
const data = tar.subarray(start, start + size);
|
|
272
|
+
// PAX `path=` records and GNU long names rename the next member only.
|
|
273
|
+
if (type === "x") { renamed = /(?:^|\n)\d+ path=([^\n]*)\n/.exec(data.toString("utf8"))?.[1] ?? renamed; continue; }
|
|
274
|
+
if (type === "L") { renamed = data.toString("utf8").replace(/\0[^]*$/, ""); continue; }
|
|
275
|
+
if (type === "g" || type === "K") continue;
|
|
276
|
+
// Only the POSIX ustar format has a name prefix; old GNU keeps times there.
|
|
277
|
+
const prefix = block.subarray(257, 263).toString("latin1") === "ustar\0" ? text(block, 345, 155) : "";
|
|
278
|
+
const name = renamed ?? (prefix ? `${prefix}/${text(block, 0, 100)}` : text(block, 0, 100));
|
|
279
|
+
renamed = null;
|
|
280
|
+
yield { name, type, mode: number(block, 100, 8), data };
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
function zipMember(bytes, wanted, message = "Node archive rejected") {
|
|
284
|
+
const fail = () => { throw new Error(message); };
|
|
285
|
+
let found = null;
|
|
286
|
+
for (const entry of zipEntries(bytes, fail)) {
|
|
287
|
+
if (entry.name !== wanted) continue;
|
|
288
|
+
if (found || (entry.unix && (entry.mode & 0o170000) !== 0o100000)) fail();
|
|
289
|
+
found = entry.read();
|
|
290
|
+
}
|
|
291
|
+
if (!found?.length) fail();
|
|
292
|
+
return found;
|
|
293
|
+
}
|
|
294
|
+
/** Every central-directory entry of a zip: { name, unix, mode, read() }; read()
|
|
295
|
+
* checks the entry (no encryption or data descriptor, stored or deflated, its
|
|
296
|
+
* local header and CRC) and returns its bytes. Anything malformed calls `fail`.
|
|
297
|
+
*/
|
|
298
|
+
function* zipEntries(bytes, fail) {
|
|
299
|
+
let end = -1;
|
|
300
|
+
for (let at = bytes.length - 22; at >= Math.max(0, bytes.length - 22 - 0xffff); at -= 1) {
|
|
301
|
+
if (bytes.readUInt32LE(at) === 0x06054b50 && at + 22 + bytes.readUInt16LE(at + 20) === bytes.length) { end = at; break; }
|
|
302
|
+
}
|
|
303
|
+
if (end < 0) fail();
|
|
304
|
+
const count = bytes.readUInt16LE(end + 10);
|
|
305
|
+
const directory = bytes.readUInt32LE(end + 16);
|
|
306
|
+
if (count === 0xffff || directory === 0xffffffff || directory + bytes.readUInt32LE(end + 12) > end) fail();
|
|
307
|
+
for (let at = directory, index = 0; index < count; index += 1) {
|
|
308
|
+
if (at + 46 > end || bytes.readUInt32LE(at) !== 0x02014b50) fail();
|
|
309
|
+
const [flags, method, crc, compressed, size] = [bytes.readUInt16LE(at + 8), bytes.readUInt16LE(at + 10), bytes.readUInt32LE(at + 16),
|
|
310
|
+
bytes.readUInt32LE(at + 20), bytes.readUInt32LE(at + 24)];
|
|
311
|
+
const nameLength = bytes.readUInt16LE(at + 28);
|
|
312
|
+
const mode = bytes.readUInt32LE(at + 38) >>> 16;
|
|
313
|
+
const unix = bytes[at + 5] === 3;
|
|
314
|
+
const local = bytes.readUInt32LE(at + 42);
|
|
315
|
+
const name = bytes.subarray(at + 46, at + 46 + nameLength).toString("utf8");
|
|
316
|
+
at += 46 + nameLength + bytes.readUInt16LE(at + 30) + bytes.readUInt16LE(at + 32);
|
|
317
|
+
yield { name, unix, mode, read: () => {
|
|
318
|
+
if ((flags & 0x41) || (method !== 0 && method !== 8) || compressed === 0xffffffff || size === 0xffffffff) fail();
|
|
319
|
+
if (local + 30 > directory || bytes.readUInt32LE(local) !== 0x04034b50) fail();
|
|
320
|
+
const localName = bytes.readUInt16LE(local + 26);
|
|
321
|
+
const data = local + 30 + localName + bytes.readUInt16LE(local + 28);
|
|
322
|
+
if (bytes.subarray(local + 30, local + 30 + localName).toString("utf8") !== name || data + compressed > directory) fail();
|
|
323
|
+
const raw = bytes.subarray(data, data + compressed);
|
|
324
|
+
const content = method === 0 ? Buffer.from(raw) : inflateRawSync(raw, { maxOutputLength: Math.max(size, 1) });
|
|
325
|
+
if (content.length !== size || crc32(content) !== crc) fail();
|
|
326
|
+
return content;
|
|
327
|
+
} };
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
/** The one executable a verified Engram release archive must contain: the
|
|
332
|
+
* top-level `engram` of the POSIX `.tar.gz` or `engram.exe` of the Windows
|
|
333
|
+
* `.zip`, a regular file found exactly once. Its tools/ scripts and anything
|
|
334
|
+
* else are never written.
|
|
335
|
+
*/
|
|
336
|
+
export function engramExecutable(bytes, descriptor) {
|
|
337
|
+
const message = "Engram archive rejected";
|
|
338
|
+
return String(descriptor?.url ?? "").endsWith(".zip") ? zipMember(bytes, "engram.exe", message) : tarMember(bytes, "engram", message);
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
/** Runs a command without a shell; resolves { status, stdout, stderr }. */
|
|
342
|
+
export function runCommand(command, args, { cwd, env, timeout = 15000 }) {
|
|
343
|
+
return new Promise((resolve) => {
|
|
344
|
+
const child = spawn(command, args, { cwd, env, shell: false, windowsHide: true, stdio: ["ignore", "pipe", "pipe"] });
|
|
345
|
+
const output = { stdout: "", stderr: "" };
|
|
346
|
+
for (const stream of ["stdout", "stderr"]) child[stream].on("data", (chunk) => { output[stream] = (output[stream] + chunk).slice(-1024 * 1024); });
|
|
347
|
+
const timer = setTimeout(() => child.kill("SIGKILL"), timeout);
|
|
348
|
+
child.once("error", () => { clearTimeout(timer); resolve({ status: null, ...output }); });
|
|
349
|
+
child.once("close", (status) => { clearTimeout(timer); resolve({ status, ...output }); });
|
|
350
|
+
});
|
|
351
|
+
}
|
|
352
|
+
const ownedRuntime = (directory, url, entry) => read(pathFor(process.platform).join(directory, RUNTIME_MARKER)) === `${url}\n` && regular(entry);
|
|
353
|
+
|
|
354
|
+
// Windows publication can lose a race with the node.exe just run from the
|
|
355
|
+
// staging folder or a real-time scanner still holding it (EPERM, EBUSY,
|
|
356
|
+
// EACCES). Only those codes, and only on Windows, are retried after each
|
|
357
|
+
// delay; a lock that never clears surfaces the original error.
|
|
358
|
+
const WINDOWS_RENAME_RETRY_DELAYS_MS = [200, 400, 800, 1600];
|
|
359
|
+
export async function renameWithWindowsRetry(from, to, { platform = process.platform, rename = renameSync, delays = WINDOWS_RENAME_RETRY_DELAYS_MS } = {}) {
|
|
360
|
+
if (platform !== "win32") return rename(from, to);
|
|
361
|
+
for (let attempt = 0; ; attempt += 1) {
|
|
362
|
+
try { return rename(from, to); }
|
|
363
|
+
catch (error) {
|
|
364
|
+
if (attempt >= delays.length || !["EPERM", "EBUSY", "EACCES"].includes(error?.code)) throw error;
|
|
365
|
+
await new Promise((resolve) => setTimeout(resolve, delays[attempt]));
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
/** Publishes one verified runtime as `directory`: an existing one marked with the
|
|
371
|
+
* same pinned URL is reused, an unmarked one is never replaced. `fill` writes and
|
|
372
|
+
* checks the staging folder; one rename publishes it. Renaming is safe here,
|
|
373
|
+
* unlike after a pnpm install: the extracted trees hold regular files and
|
|
374
|
+
* folders only (both archive readers reject links), so nothing points at the
|
|
375
|
+
* staging path.
|
|
376
|
+
*/
|
|
377
|
+
async function publishRuntime(layout, name, directory, entry, { platform, arch, adapters }, fill) {
|
|
378
|
+
const descriptor = (adapters.artifact ?? artifactFor)(name, platform, arch);
|
|
379
|
+
if (ownedRuntime(directory, descriptor.url, entry)) return false;
|
|
380
|
+
if (stat(directory)) throw new Error(`Conflicting runtime destination: ${directory}`);
|
|
381
|
+
// Before downloading: a prefix that is not there fails without a download.
|
|
382
|
+
ensureDirectory(layout.runtime, layout.platform);
|
|
383
|
+
const bytes = await verifiedDownload(name, adapters, platform, arch);
|
|
384
|
+
let stage = mkdtempSync(pathFor(process.platform).join(layout.runtime, ".stage-"));
|
|
385
|
+
try {
|
|
386
|
+
await fill(stage, bytes, descriptor);
|
|
387
|
+
writeFileSync(pathFor(process.platform).join(stage, RUNTIME_MARKER), `${descriptor.url}\n`, { flag: "wx", mode: 0o600 });
|
|
388
|
+
if (stat(directory)) throw new Error(`Conflicting runtime destination: ${directory}`);
|
|
389
|
+
await renameWithWindowsRetry(stage, directory);
|
|
390
|
+
stage = null;
|
|
391
|
+
return true;
|
|
392
|
+
} finally {
|
|
393
|
+
if (stage) rmSync(stage, { recursive: true, force: true });
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
/** The pinned Engram in runtime/engram-<v>/ (S9): downloaded through
|
|
398
|
+
* verifiedDownload, only its executable written, `engram version` run from the
|
|
399
|
+
* staging folder, marked, then published. Gentle AI's setup uses it through
|
|
400
|
+
* GENTLE_AI_ENGRAM_BIN and gentle-engram through ENGRAM_BIN, so neither
|
|
401
|
+
* installs or finds another Engram. Returns whether it was acquired.
|
|
402
|
+
*/
|
|
403
|
+
export async function ensureEngram({ layout, platform, arch, env = {}, adapters = {} }) {
|
|
404
|
+
const run = adapters.run ?? runCommand;
|
|
405
|
+
const path = pathFor(process.platform);
|
|
406
|
+
const childEnv = pnpmEnvironment(layout, { platform: layout.platform, env });
|
|
407
|
+
return publishRuntime(layout, "engram", layout.engramDir, layout.engram, { platform, arch, adapters }, async (stage, bytes, descriptor) => {
|
|
408
|
+
const binary = path.join(stage, path.relative(layout.engramDir, layout.engram));
|
|
409
|
+
writeFileSync(binary, engramExecutable(bytes, descriptor), { flag: "wx", mode: 0o700 });
|
|
410
|
+
const result = await run(binary, ["version"], { cwd: layout.runtime, env: childEnv });
|
|
411
|
+
if (result.status !== 0 || String(result.stdout).trim() !== `engram ${descriptor.version}`) throw new Error("Bundled Engram verification failed");
|
|
412
|
+
});
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
/** Our pinned Node with its bundled npm, and pnpm, in runtime/ (and the pinned
|
|
416
|
+
* Go only when `go`, the pinned Engram only when `engram`), each downloaded
|
|
417
|
+
* through verifiedDownload, checked by running it from its staging folder,
|
|
418
|
+
* marked, then published. Returns the paths and what was acquired.
|
|
419
|
+
*/
|
|
420
|
+
export async function ensureRuntime({ layout, platform, arch, go = false, engram = false, env = {}, adapters = {} }) {
|
|
421
|
+
const run = adapters.run ?? runCommand;
|
|
422
|
+
const path = pathFor(process.platform);
|
|
423
|
+
const childEnv = pnpmEnvironment(layout, { platform: layout.platform, env });
|
|
424
|
+
const acquired = [];
|
|
425
|
+
const node = await publishRuntime(layout, "node", layout.nodeDir, layout.node, { platform, arch, adapters }, async (stage, bytes, descriptor) => {
|
|
426
|
+
const files = nodeRuntimeFiles(bytes, descriptor);
|
|
427
|
+
for (const file of files) {
|
|
428
|
+
const target = path.join(stage, ...file.name.split("/"));
|
|
429
|
+
mkdirSync(path.dirname(target), { recursive: true, mode: 0o700 });
|
|
430
|
+
writeFileSync(target, file.data, { flag: "wx", mode: file.executable ? 0o700 : 0o600 });
|
|
431
|
+
}
|
|
432
|
+
const binary = path.join(stage, path.relative(layout.nodeDir, layout.node));
|
|
433
|
+
const result = await run(binary, ["--version"], { cwd: layout.runtime, env: childEnv });
|
|
434
|
+
if (result.status !== 0 || String(result.stdout).trim() !== `v${descriptor.version}`) throw new Error("Bundled Node verification failed");
|
|
435
|
+
const npm = await run(binary, [path.join(stage, path.relative(layout.nodeDir, layout.npmCli)), "--version"], { cwd: layout.runtime, env: childEnv });
|
|
436
|
+
if (npm.status !== 0 || String(npm.stdout).trim() !== files.npm) throw new Error("Bundled npm verification failed");
|
|
437
|
+
});
|
|
438
|
+
if (node) acquired.push("node");
|
|
439
|
+
// Also for a runtime published before the entry existed; one from before npm
|
|
440
|
+
// was published (T1a) has no npm to run, and the launcher then gives Pi none.
|
|
441
|
+
if (regular(layout.npmCli)) ensureNpmShim(layout);
|
|
442
|
+
const pnpm = await publishRuntime(layout, "pnpm", layout.pnpmDir, layout.pnpm, { platform, arch, adapters }, async (stage, bytes, descriptor) => {
|
|
443
|
+
// The tarball's executable bits are kept (bin/pnpm.mjs, dist/node-gyp-bin), for the owner only.
|
|
444
|
+
const modes = new Map([...tarMembers(bytes, "pnpm archive rejected")].map((member) => [member.name, member.mode]));
|
|
445
|
+
for (const entry of readWindowsPnpmArchive(bytes)) {
|
|
446
|
+
const target = path.join(stage, ...entry.name.replace(/\/$/, "").split("/"));
|
|
447
|
+
if (entry.directory) mkdirSync(target, { recursive: true, mode: 0o700 });
|
|
448
|
+
else {
|
|
449
|
+
mkdirSync(path.dirname(target), { recursive: true, mode: 0o700 });
|
|
450
|
+
writeFileSync(target, entry.bytes, { flag: "wx", mode: (modes.get(entry.name) ?? 0) & 0o100 ? 0o700 : 0o600 });
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
const metadata = JSON.parse(readFileSync(path.join(stage, "package", "package.json"), "utf8"));
|
|
454
|
+
const entry = path.join(stage, "package", "bin", "pnpm.mjs");
|
|
455
|
+
if (metadata.name !== "pnpm" || metadata.version !== descriptor.version || metadata.bin?.pnpm !== "bin/pnpm.mjs" || !regular(entry)) {
|
|
456
|
+
throw new Error("Bundled pnpm metadata rejected");
|
|
457
|
+
}
|
|
458
|
+
const result = await run(layout.node, [entry, "--version"], { cwd: layout.runtime, env: childEnv });
|
|
459
|
+
if (result.status !== 0 || String(result.stdout).trim() !== descriptor.version) throw new Error("Bundled pnpm verification failed");
|
|
460
|
+
});
|
|
461
|
+
if (pnpm) acquired.push("pnpm");
|
|
462
|
+
if (engram && await ensureEngram({ layout, platform, arch, env, adapters })) acquired.push("engram");
|
|
463
|
+
let goPath = null;
|
|
464
|
+
if (go) {
|
|
465
|
+
const result = await (adapters.acquireGo ?? acquireGo)({ root: layout.goRoot, platform, arch, adapters });
|
|
466
|
+
goPath = result.goPath;
|
|
467
|
+
if (result.acquired) acquired.push("go");
|
|
468
|
+
}
|
|
469
|
+
return { node: layout.node, pnpm: layout.pnpm, go: goPath, acquired };
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
/** The environment for our pnpm and the scripts it runs: our Node (then our Go,
|
|
473
|
+
* when given) first on PATH; PNPM_HOME, store, cache, state and config inside the
|
|
474
|
+
* prefix through the variables pnpm 11 reads (`pnpm_config_*`, XDG_*); an empty
|
|
475
|
+
* npmrc in place of ~/.npmrc; no switching to the pnpm a package.json above the
|
|
476
|
+
* folder names (pm-on-fail otherwise defaults to "download") or to a Node runtime
|
|
477
|
+
* a root manifest asks to download (runtime-on-fail), and no update check; on
|
|
478
|
+
* Windows TEMP/TMP in the private folder. Every inherited npm/pnpm/Corepack setting, NODE_OPTIONS and
|
|
479
|
+
* NODE_PATH is dropped, except a variable the prefix npmrc references as
|
|
480
|
+
* `${NAME}` (an auth token such as NPM_TOKEN, which pnpm and npm expand); the
|
|
481
|
+
* rest (HOME, proxies, SystemRoot) is kept.
|
|
482
|
+
*/
|
|
483
|
+
export function pnpmEnvironment(layout, { platform = layout.platform, env = {}, go = null } = {}) {
|
|
484
|
+
const windows = platform === "win32";
|
|
485
|
+
const path = pathFor(platform);
|
|
486
|
+
const dropped = new RegExp(`^(?:npm_.*|pnpm_.*|corepack_.*|node_options|node_path|xdg_(?:config|cache|state|data)_home|path${windows ? "|temp|tmp" : ""})$`, "i");
|
|
487
|
+
const referenced = new Set([...(read(layout.npmrc) ?? "").matchAll(/\$\{([A-Za-z_][A-Za-z0-9_]*)\}/g)].map((match) => match[1])
|
|
488
|
+
.filter((name) => !/^(?:npm_config_.*|pnpm_.*)$/i.test(name)));
|
|
489
|
+
const kept = Object.fromEntries(Object.entries(env).filter(([key]) => !dropped.test(key) || referenced.has(key)));
|
|
490
|
+
const rest = String(envValue(env, "PATH", platform) ?? "").split(path.delimiter).filter((entry) => entry.length > 0);
|
|
491
|
+
const first = [path.dirname(layout.node), ...(go ? [path.dirname(go)] : [])];
|
|
492
|
+
return {
|
|
493
|
+
...kept,
|
|
494
|
+
[windows ? "Path" : "PATH"]: [...first, ...rest].join(path.delimiter),
|
|
495
|
+
PNPM_HOME: layout.pnpmHome,
|
|
496
|
+
XDG_CONFIG_HOME: layout.config, XDG_CACHE_HOME: layout.cache, XDG_STATE_HOME: layout.state,
|
|
497
|
+
pnpm_config_store_dir: layout.store, pnpm_config_cache_dir: layout.cache, pnpm_config_state_dir: layout.state,
|
|
498
|
+
pnpm_config_npmrc_auth_file: layout.npmrc,
|
|
499
|
+
pnpm_config_pm_on_fail: "ignore", pnpm_config_runtime_on_fail: "ignore", pnpm_config_update_notifier: "false",
|
|
500
|
+
...(windows ? { TEMP: layout.tmp, TMP: layout.tmp } : {}),
|
|
501
|
+
};
|
|
502
|
+
}
|
|
503
|
+
/** Our npm's settings, for the children that run npm (setupEnvironment; pnpm
|
|
504
|
+
* never runs it): the prefix npmrc as its user config, and its global config,
|
|
505
|
+
* global prefix and cache inside the prefix, so nothing reads the user's npm
|
|
506
|
+
* settings or writes the user's npm folders. The prefix also keeps Windows'
|
|
507
|
+
* npm.cmd on our npm: it prefers an npm installed in the global prefix.
|
|
508
|
+
*/
|
|
509
|
+
function npmEnvironment(layout) {
|
|
510
|
+
return { npm_config_userconfig: layout.npmrc, npm_config_globalconfig: layout.npmGlobalConfig, npm_config_prefix: layout.npmPrefix,
|
|
511
|
+
npm_config_cache: layout.npmCache, npm_config_update_notifier: "false" };
|
|
512
|
+
}
|
|
513
|
+
/** The environment `gentle-shell setup` runs in from the bundled install: our
|
|
514
|
+
* Node and its npm first on PATH, then the version's node_modules/.bin (its
|
|
515
|
+
* `pi`), then the user's PATH; npm's settings inside the prefix
|
|
516
|
+
* (npmEnvironment). Inherited npm and pnpm settings, NODE_OPTIONS, NODE_PATH
|
|
517
|
+
* and the wizard's GENTLE_BOOTSTRAP_ and GENTLE_INSTALL_ handoff are dropped;
|
|
518
|
+
* the user's XDG folders are kept, since Pi and Gentle AI read their own
|
|
519
|
+
* configuration through them.
|
|
520
|
+
*/
|
|
521
|
+
export function setupEnvironment(layout, { platform = layout.platform, env = {}, id }) {
|
|
522
|
+
if (typeof id !== "string" || !VERSION_ID.test(id) || id.includes("..")) throw new Error("Unsafe version id");
|
|
523
|
+
const path = pathFor(platform);
|
|
524
|
+
const dropped = /^(?:npm_config_.*|pnpm_.*|corepack_.*|node_options|node_path|path|gentle_(?:bootstrap|install)_.*)$/i;
|
|
525
|
+
const kept = Object.fromEntries(Object.entries(env).filter(([key]) => !dropped.test(key)));
|
|
526
|
+
const rest = String(envValue(env, "PATH", platform) ?? "").split(path.delimiter).filter((entry) => entry.length > 0);
|
|
527
|
+
const bin = path.join(layout.versions, id, "node_modules", ".bin");
|
|
528
|
+
return { ...kept, [platform === "win32" ? "Path" : "PATH"]: [path.dirname(layout.node), bin, ...rest].join(path.delimiter), ...npmEnvironment(layout) };
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
// S12: the only npmrc keys our pnpm and npm read from the user's npmrc.
|
|
532
|
+
const NPMRC_KEYS = /^(?:registry|@[^\s:=]+:registry|\/\/\S+:(?:_authToken|_auth|username|_password|certfile|keyfile)|proxy|https-proxy|noproxy|no-proxy|ca|ca\[\]|cafile|strict-ssl)$/;
|
|
533
|
+
/** The user's npmrc reduced to its network, registry and authentication keys:
|
|
534
|
+
* registry, @scope:registry, per-host //host/:_authToken, _auth, username,
|
|
535
|
+
* _password, certfile and keyfile, proxy, https-proxy, noproxy (no-proxy), ca
|
|
536
|
+
* (ca[]), cafile and strict-ssl. Each kept line is copied verbatim, so `${VAR}`
|
|
537
|
+
* references stay for pnpm and npm to expand. Comments and every other key are
|
|
538
|
+
* dropped, and so is everything from the first [section] on: an ini section
|
|
539
|
+
* never applies at the top level.
|
|
540
|
+
*/
|
|
541
|
+
export function filterNpmrc(text) {
|
|
542
|
+
const kept = [];
|
|
543
|
+
for (const raw of String(text ?? "").split(/\r\n|\r|\n/)) {
|
|
544
|
+
const line = raw.trim();
|
|
545
|
+
if (line.startsWith("[")) break;
|
|
546
|
+
const at = line.indexOf("=");
|
|
547
|
+
if (line.startsWith("#") || line.startsWith(";") || at <= 0) continue;
|
|
548
|
+
const key = line.slice(0, at).trim().replace(/^(["'])(.*)\1$/, "$2");
|
|
549
|
+
if (NPMRC_KEYS.test(key)) kept.push(line);
|
|
550
|
+
}
|
|
551
|
+
return kept.length > 0 ? `${kept.join("\n")}\n` : "";
|
|
552
|
+
}
|
|
553
|
+
/** The user's npmrc text: NPM_CONFIG_USERCONFIG (any case) when it is an
|
|
554
|
+
* absolute path, otherwise ~/.npmrc; empty when that file is missing. At most
|
|
555
|
+
* 1 MiB is read.
|
|
556
|
+
*/
|
|
557
|
+
export function userNpmrc({ platform, env = {}, home }) {
|
|
558
|
+
const path = pathFor(platform);
|
|
559
|
+
const key = Object.keys(env).find((name) => name.toLowerCase() === "npm_config_userconfig");
|
|
560
|
+
const file = key && path.isAbsolute(env[key] ?? "") ? env[key] : path.join(home, ".npmrc");
|
|
561
|
+
let text;
|
|
562
|
+
try { text = readFileSync(file); }
|
|
563
|
+
catch (error) { if (error.code === "ENOENT" || error.code === "ENOTDIR") return ""; throw error; }
|
|
564
|
+
if (text.length > 1024 * 1024) throw new Error(`The npm user configuration is too large: ${file}`);
|
|
565
|
+
return text.toString("utf8");
|
|
566
|
+
}
|
|
567
|
+
/** Replaces the prefix npmrc (layout.npmrc, the file pnpm and npm read in place
|
|
568
|
+
* of ~/.npmrc) with filterNpmrc(text), mode 0600, with one rename.
|
|
569
|
+
*/
|
|
570
|
+
export function writeNpmrcAuth(layout, text) {
|
|
571
|
+
const content = filterNpmrc(text);
|
|
572
|
+
replaceAtomically(layout.npmrc, (temp) => {
|
|
573
|
+
writeFileSync(temp, content, { flag: "wx", mode: 0o600 });
|
|
574
|
+
if (layout.platform !== "win32") chmodSync(temp, 0o600);
|
|
575
|
+
});
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
function versionsOf(manifest) {
|
|
579
|
+
const { shell, pi } = manifest ?? {};
|
|
580
|
+
if (!SEMVER.test(shell ?? "") || !SEMVER.test(pi ?? "")) throw new Error("Distribution manifest version rejected");
|
|
581
|
+
return { shell, pi, id: `${shell}-${pi}` };
|
|
582
|
+
}
|
|
583
|
+
/** The exact project the release lockfile is built from and installed with:
|
|
584
|
+
* gentle-pi and Pi pinned exactly, and only gentle-pi's build scripts allowed,
|
|
585
|
+
* as the global install allows it. Other dependencies' build scripts are
|
|
586
|
+
* skipped, not fatal (pnpm 11 fails on them by default).
|
|
587
|
+
*/
|
|
588
|
+
export function distributionFiles(manifest) {
|
|
589
|
+
const { shell, pi } = versionsOf(manifest);
|
|
590
|
+
const project = { name: "gentle-shell-distribution", version: shell, private: true, dependencies: { "gentle-pi": shell, "@earendil-works/pi-coding-agent": pi } };
|
|
591
|
+
return { "package.json": `${JSON.stringify(project, null, "\t")}\n`, "pnpm-workspace.yaml": "allowBuilds:\n gentle-pi: true\nstrictDepBuilds: false\n" };
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
/** The two release assets (T1b), under stable names. */
|
|
595
|
+
export const DISTRIBUTION_ASSETS = Object.freeze({ distribution: "gentle-shell-distribution.json", lockfile: "gentle-shell-distribution-lock.yaml" });
|
|
596
|
+
const DISTRIBUTION_SCHEMA = 1;
|
|
597
|
+
const sha256 = (text) => createHash("sha256").update(text, "utf8").digest("hex");
|
|
598
|
+
/** The distribution asset text: the exact versions, the pinned Node and pnpm
|
|
599
|
+
* the lockfile was resolved with, the files installVersion writes next to it
|
|
600
|
+
* and the lockfile's sha256. Plain data in a fixed order, so the same inputs
|
|
601
|
+
* always give the same bytes.
|
|
602
|
+
*/
|
|
603
|
+
export function distributionAsset(manifest, lockfile) {
|
|
604
|
+
const { shell, pi, id } = versionsOf(manifest);
|
|
605
|
+
if (typeof lockfile !== "string" || lockfile.length === 0) throw new Error("Release lockfile missing");
|
|
606
|
+
const asset = { schema: DISTRIBUTION_SCHEMA, shell, pi, id, generatedWith: { node: pins.node, pnpm: pins.pnpm }, files: distributionFiles({ shell, pi }),
|
|
607
|
+
lockfile: { name: DISTRIBUTION_ASSETS.lockfile, sha256: sha256(lockfile) } };
|
|
608
|
+
return `${JSON.stringify(asset, null, "\t")}\n`;
|
|
609
|
+
}
|
|
610
|
+
/** Reads a downloaded asset pair: returns { manifest, lockfile } only for exact
|
|
611
|
+
* versions, a lockfile whose sha256 the asset records, and workspace files equal
|
|
612
|
+
* to the ones this module's installVersion writes (a frozen install with other
|
|
613
|
+
* settings was never verified). generatedWith is recorded, not compared.
|
|
614
|
+
*/
|
|
615
|
+
export function readDistribution(text, lockfile) {
|
|
616
|
+
let asset;
|
|
617
|
+
try { asset = JSON.parse(text); }
|
|
618
|
+
catch { throw new Error("Distribution asset rejected"); }
|
|
619
|
+
if (asset?.schema !== DISTRIBUTION_SCHEMA) throw new Error("Distribution asset schema rejected");
|
|
620
|
+
const manifest = { shell: asset.shell, pi: asset.pi };
|
|
621
|
+
const { id } = versionsOf(manifest);
|
|
622
|
+
const files = distributionFiles(manifest);
|
|
623
|
+
const recorded = asset.files ?? {};
|
|
624
|
+
if (asset.id !== id || Object.keys(recorded).length !== Object.keys(files).length || Object.entries(files).some(([name, content]) => recorded[name] !== content)) {
|
|
625
|
+
throw new Error("Distribution asset workspace files rejected");
|
|
626
|
+
}
|
|
627
|
+
if (typeof lockfile !== "string" || asset.lockfile?.name !== DISTRIBUTION_ASSETS.lockfile || asset.lockfile?.sha256 !== sha256(lockfile)) {
|
|
628
|
+
throw new Error("Distribution lockfile sha256 mismatch");
|
|
629
|
+
}
|
|
630
|
+
return { manifest, lockfile };
|
|
631
|
+
}
|
|
632
|
+
function versionDirectory(layout, id) {
|
|
633
|
+
if (typeof id !== "string" || !VERSION_ID.test(id) || id.includes("..")) throw new Error("Unsafe version id");
|
|
634
|
+
return pathFor(process.platform).join(layout.versions, id);
|
|
635
|
+
}
|
|
636
|
+
/** A published version: a real directory whose marker names it. While pnpm
|
|
637
|
+
* runs, the marker reads `installing <id> <pid>` instead.
|
|
638
|
+
*/
|
|
639
|
+
function installedVersion(layout, id) {
|
|
640
|
+
const directory = versionDirectory(layout, id);
|
|
641
|
+
return stat(directory)?.isDirectory() === true && read(pathFor(process.platform).join(directory, VERSION_MARKER)) === `${id}\n`;
|
|
642
|
+
}
|
|
643
|
+
function installedPackage(stage, name, expected, label) {
|
|
644
|
+
const path = pathFor(process.platform);
|
|
645
|
+
const directory = realpathSync(path.join(stage, "node_modules", ...name.split("/")));
|
|
646
|
+
if (!directory.startsWith(`${realpathSync(stage)}${path.sep}`)) throw new Error(`Installed ${label} is outside the version folder`);
|
|
647
|
+
const metadata = JSON.parse(readFileSync(path.join(directory, "package.json"), "utf8"));
|
|
648
|
+
if (metadata.name !== name || metadata.version !== expected) throw new Error(`Installed ${label} version mismatch: expected ${expected}, found ${metadata.version}`);
|
|
649
|
+
return directory;
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
function processAlive(pid) {
|
|
653
|
+
try { process.kill(pid, 0); return true; }
|
|
654
|
+
catch (error) { return error.code === "EPERM"; }
|
|
655
|
+
}
|
|
656
|
+
/** The pinned Go acquireGo published under runtime/go: a regular file there. */
|
|
657
|
+
function pinnedGo(layout, go) {
|
|
658
|
+
const path = pathFor(layout.platform);
|
|
659
|
+
if (typeof go !== "string" || !path.isAbsolute(go)) return false;
|
|
660
|
+
const relative = path.relative(layout.goRoot, go);
|
|
661
|
+
return relative !== "" && !relative.startsWith("..") && !path.isAbsolute(relative) && regular(go);
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
/** Installs `<shell>-<pi>` into versions/<shell>-<pi>/ in place: that folder
|
|
665
|
+
* is claimed and marked `installing <id> <pid>` first, the distribution files
|
|
666
|
+
* and the release lockfile are written, our pnpm runs `install --frozen-lockfile`
|
|
667
|
+
* there, the installed gentle-pi and Pi versions are checked exactly, and only
|
|
668
|
+
* then the marker is replaced by `<id>`. The folder is never renamed after pnpm
|
|
669
|
+
* ran: on Windows pnpm links packages with absolute junctions when symlinks are
|
|
670
|
+
* not allowed. An installed version is reused; a folder left by an interrupted
|
|
671
|
+
* install of this id (its process gone) is removed and installed again; any
|
|
672
|
+
* other folder is never replaced. On Windows the pinned Go is required, so
|
|
673
|
+
* gentle-pi's postinstall never builds with the user's go.exe. Adapters (run,
|
|
674
|
+
* and distribution for local fixture packages in place of the registry) are
|
|
675
|
+
* trusted local test code only.
|
|
676
|
+
*/
|
|
677
|
+
export async function installVersion({ layout, manifest, lockfile, platform = layout.platform, env = {}, go = null, adapters = {} }) {
|
|
678
|
+
const files = (adapters.distribution ?? distributionFiles)(manifest);
|
|
679
|
+
const { shell, pi, id } = versionsOf(manifest);
|
|
680
|
+
const destination = versionDirectory(layout, id);
|
|
681
|
+
const path = pathFor(process.platform);
|
|
682
|
+
const marker = path.join(destination, VERSION_MARKER);
|
|
683
|
+
if (installedVersion(layout, id)) return { id, path: destination, installed: false };
|
|
684
|
+
if (platform === "win32" && !pinnedGo(layout, go)) throw new Error("A Windows version install needs the pinned Go from ensureRuntime({ go: true })");
|
|
685
|
+
if (typeof lockfile !== "string" || lockfile.length === 0) throw new Error("Release lockfile missing");
|
|
686
|
+
const existing = stat(destination);
|
|
687
|
+
if (existing) {
|
|
688
|
+
const pending = existing.isDirectory() ? /^installing (\S+) ([1-9]\d*)\n$/.exec(read(marker) ?? "") : null;
|
|
689
|
+
if (pending?.[1] !== id) throw new Error(`Conflicting version destination: ${destination}`);
|
|
690
|
+
if (processAlive(Number(pending[2]))) throw new Error(`An installation of ${id} is in progress (process ${pending[2]})`);
|
|
691
|
+
rmSync(destination, { recursive: true });
|
|
692
|
+
}
|
|
693
|
+
ensureDirectory(layout.versions, layout.platform);
|
|
694
|
+
// mkdir is the atomic claim: a folder created meanwhile by anyone else fails here.
|
|
695
|
+
mkdirSync(destination, { mode: 0o700 });
|
|
696
|
+
try {
|
|
697
|
+
writeFileSync(marker, `installing ${id} ${process.pid}\n`, { flag: "wx", mode: 0o600 });
|
|
698
|
+
for (const [name, text] of Object.entries({ ...files, "pnpm-lock.yaml": lockfile })) writeFileSync(path.join(destination, name), text, { flag: "wx", mode: 0o600 });
|
|
699
|
+
const result = await (adapters.run ?? runCommand)(layout.node, [layout.pnpm, "install", "--frozen-lockfile"],
|
|
700
|
+
{ cwd: destination, env: pnpmEnvironment(layout, { platform, env, go }), timeout: INSTALL_TIMEOUT });
|
|
701
|
+
// pnpm prints install and postinstall failures on stdout: keep both tails.
|
|
702
|
+
if (result.status !== 0) {
|
|
703
|
+
throw Object.assign(new Error(`pnpm install failed (exit ${result.status})`),
|
|
704
|
+
{ stderr: [String(result.stdout ?? "").slice(-6000), String(result.stderr ?? "").slice(-4000)].filter(Boolean).join("\n") });
|
|
705
|
+
}
|
|
706
|
+
const shellDirectory = installedPackage(destination, "gentle-pi", shell, "gentle-pi");
|
|
707
|
+
installedPackage(destination, "@earendil-works/pi-coding-agent", pi, "Pi");
|
|
708
|
+
if (!regular(path.join(shellDirectory, "bin", "gentle-shell.mjs"))) throw new Error("Installed gentle-pi has no launcher entry");
|
|
709
|
+
replaceAtomically(marker, (temp) => writeFileSync(temp, `${id}\n`, { flag: "wx", mode: 0o600 }));
|
|
710
|
+
return { id, path: destination, installed: true };
|
|
711
|
+
} catch (error) {
|
|
712
|
+
// The folder is this call's own claim: nothing else was ever in it.
|
|
713
|
+
rmSync(destination, { recursive: true, force: true });
|
|
714
|
+
throw error;
|
|
715
|
+
}
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
/** The active version id, or null when `current` is absent. A `current` this
|
|
719
|
+
* module did not write (not our symlink or pointer file) throws.
|
|
720
|
+
*/
|
|
721
|
+
export function activeVersion(layout) {
|
|
722
|
+
const info = stat(layout.current);
|
|
723
|
+
if (!info) return null;
|
|
724
|
+
const id = layout.currentKind === "symlink"
|
|
725
|
+
? (info.isSymbolicLink() ? /^versions\/([^/]+)$/.exec(readlinkSync(layout.current))?.[1] : undefined)
|
|
726
|
+
: (info.isFile() ? /^([^\r\n]+)\n$/.exec(readFileSync(layout.current, "utf8"))?.[1] : undefined);
|
|
727
|
+
if (id === undefined || !VERSION_ID.test(id)) throw new Error(`Conflicting current: ${layout.current}`);
|
|
728
|
+
return id;
|
|
729
|
+
}
|
|
730
|
+
function readHistory(layout) {
|
|
731
|
+
return (read(layout.history) ?? "").split("\n").filter((id) => VERSION_ID.test(id) && !id.includes(".."));
|
|
732
|
+
}
|
|
733
|
+
/** Replaces `path` by one rename of a freshly written sibling. */
|
|
734
|
+
function replaceAtomically(path, write) {
|
|
735
|
+
const temp = pathFor(process.platform).join(pathFor(process.platform).dirname(path), `.${pathFor(process.platform).basename(path)}-${randomBytes(6).toString("hex")}`);
|
|
736
|
+
try {
|
|
737
|
+
write(temp);
|
|
738
|
+
renameSync(temp, path);
|
|
739
|
+
} catch (error) {
|
|
740
|
+
if (stat(temp)) unlinkSync(temp);
|
|
741
|
+
throw error;
|
|
742
|
+
}
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
/** Switches `current` to an installed version with one rename (a relative
|
|
746
|
+
* symlink on POSIX, a pointer file on Windows) and records it first in the
|
|
747
|
+
* activation history. Returns { id, previous }.
|
|
748
|
+
*/
|
|
749
|
+
export function activateVersion(layout, id) {
|
|
750
|
+
versionDirectory(layout, id);
|
|
751
|
+
if (!installedVersion(layout, id)) throw new Error(`${id} is not an installed version`);
|
|
752
|
+
const previous = activeVersion(layout);
|
|
753
|
+
replaceAtomically(layout.current, (temp) => {
|
|
754
|
+
if (layout.currentKind === "symlink") symlinkSync(`versions/${id}`, temp);
|
|
755
|
+
else writeFileSync(temp, `${id}\n`, { flag: "wx", mode: 0o600 });
|
|
756
|
+
});
|
|
757
|
+
const history = [...new Set([id, ...(previous ? [previous] : []), ...readHistory(layout)])];
|
|
758
|
+
replaceAtomically(layout.history, (temp) => writeFileSync(temp, `${history.join("\n")}\n`, { flag: "wx", mode: 0o600 }));
|
|
759
|
+
return { id, previous };
|
|
760
|
+
}
|
|
761
|
+
|
|
762
|
+
/** Deletes installed versions beyond the active one and the most recently
|
|
763
|
+
* activated others, `keep` in all. Nothing is deleted unless `current` names an
|
|
764
|
+
* installed version; the active one, unfinished installs and any folder
|
|
765
|
+
* without our marker are never deleted. Returns the deleted ids.
|
|
766
|
+
*/
|
|
767
|
+
export function pruneVersions(layout, keep = 2) {
|
|
768
|
+
const active = activeVersion(layout);
|
|
769
|
+
if (!active || !installedVersion(layout, active)) return [];
|
|
770
|
+
const kept = new Set([active]);
|
|
771
|
+
for (const id of readHistory(layout)) if (kept.size < Math.max(keep, 1) && installedVersion(layout, id)) kept.add(id);
|
|
772
|
+
const removed = [];
|
|
773
|
+
for (const name of readdirSync(layout.versions).sort()) {
|
|
774
|
+
if (name.startsWith(".") || kept.has(name) || !VERSION_ID.test(name) || !installedVersion(layout, name)) continue;
|
|
775
|
+
rmSync(versionDirectory(layout, name), { recursive: true });
|
|
776
|
+
removed.push(name);
|
|
777
|
+
}
|
|
778
|
+
if (removed.length > 0) replaceAtomically(layout.history, (temp) => writeFileSync(temp, `${readHistory(layout).filter((id) => !removed.includes(id)).join("\n")}\n`, { flag: "wx", mode: 0o600 }));
|
|
779
|
+
return removed;
|
|
780
|
+
}
|
|
781
|
+
|
|
782
|
+
const UNPUBLISHED = Object.freeze({
|
|
783
|
+
missing: (version) => `release v${version} does not publish the bundled distribution yet; try again later`,
|
|
784
|
+
unavailable: (version) => `the bundled distribution of release v${version} could not be downloaded`,
|
|
785
|
+
rejected: (version) => `the bundled distribution of release v${version} did not verify`,
|
|
786
|
+
});
|
|
787
|
+
const RELEASE_EXPORTS = Object.freeze(["prefixLayout", "ensureRuntime", "ensureLauncher"]);
|
|
788
|
+
/** The variable that makes a version's launcher run in bundled mode for
|
|
789
|
+
* `--version` only, as if `current` named that version (lib/gentle-shell-bundled.ts).
|
|
790
|
+
*/
|
|
791
|
+
const VERIFY_ENV = "GENTLE_SHELL_VERIFY_VERSION";
|
|
792
|
+
|
|
793
|
+
const NPM_SHIM_MARK = "gentle-shell bundled npm entry (generated; do not edit)";
|
|
794
|
+
/** Writes runtime/node-<v>/gentle-shell/npm, a CommonJS file that runs the npm
|
|
795
|
+
* bundled with that Node, and a package.json beside it that keeps it CommonJS
|
|
796
|
+
* under any package.json with "type": "module" above the prefix. Pi names the
|
|
797
|
+
* package manager from the basenames in npmCommand (getPackageManagerName in
|
|
798
|
+
* Pi's core/package-manager.js), so this entry is named exactly `npm`; on
|
|
799
|
+
* Windows it also avoids npm.cmd, which prefers an npm in the global prefix.
|
|
800
|
+
* Each file is replaced with one rename, only when its content differs; a link
|
|
801
|
+
* or anything but a regular file in its place stops it. Returns { written }.
|
|
802
|
+
*/
|
|
803
|
+
export function ensureNpmShim(layout) {
|
|
804
|
+
const path = pathFor(layout.platform);
|
|
805
|
+
if (!regular(layout.npmCli)) throw new Error(`Bundled npm missing: ${layout.npmCli}`);
|
|
806
|
+
const folder = path.dirname(layout.npmShim);
|
|
807
|
+
const cli = path.relative(folder, layout.npmCli).split(path.sep).map((part) => JSON.stringify(part)).join(", ");
|
|
808
|
+
const files = {
|
|
809
|
+
"package.json": '{ "type": "commonjs" }\n',
|
|
810
|
+
[path.basename(layout.npmShim)]: [`// ${NPM_SHIM_MARK}`, `require(require("node:path").join(__dirname, ${cli}));`, ""].join("\n"),
|
|
811
|
+
};
|
|
812
|
+
const info = stat(folder);
|
|
813
|
+
if (!info) mkdirSync(folder, { mode: 0o700 });
|
|
814
|
+
else if (!info.isDirectory()) throw new Error(`Conflicting npm entry: ${folder}`);
|
|
815
|
+
let written = false;
|
|
816
|
+
for (const [name, text] of Object.entries(files)) {
|
|
817
|
+
const target = path.join(folder, name);
|
|
818
|
+
const existing = stat(target);
|
|
819
|
+
if (existing && !existing.isFile()) throw new Error(`Conflicting npm entry: ${target}`);
|
|
820
|
+
if (existing && readFileSync(target, "utf8") === text) continue;
|
|
821
|
+
replaceAtomically(target, (temp) => writeFileSync(temp, text, { flag: "wx", mode: 0o600 }));
|
|
822
|
+
written = true;
|
|
823
|
+
}
|
|
824
|
+
return { written };
|
|
825
|
+
}
|
|
826
|
+
|
|
827
|
+
/** The command Pi runs for npm in a bundled install (its `npmCommand` setting):
|
|
828
|
+
* our npm on our Node, through the entry named npm (ensureNpmShim), so Pi treats
|
|
829
|
+
* it as npm; it reads only the prefix npmrc and global config, with its cache
|
|
830
|
+
* inside the prefix. npm ranks command-line flags above npm_config_* variables,
|
|
831
|
+
* so the user's environment cannot redirect these. No --prefix: on the command
|
|
832
|
+
* line it also sets npm's local prefix, and Pi installs a git extension's
|
|
833
|
+
* dependencies with `npm install` in the clone, without a --prefix of its own
|
|
834
|
+
* (its npm: installs pass one). No other part's basename is npm, pnpm or bun,
|
|
835
|
+
* which Pi would reject as ambiguous.
|
|
836
|
+
*/
|
|
837
|
+
export function bundledNpmCommand(layout) {
|
|
838
|
+
return [layout.node, layout.npmShim, `--userconfig=${layout.npmrc}`, `--globalconfig=${layout.npmGlobalConfig}`, `--cache=${layout.npmCache}`,
|
|
839
|
+
"--update-notifier=false"];
|
|
840
|
+
}
|
|
841
|
+
|
|
842
|
+
/** The module an installed version ships (its own pins and launcher), only from
|
|
843
|
+
* a regular file whose real path stays inside that version folder.
|
|
844
|
+
*/
|
|
845
|
+
async function releaseModule(file, versionPath) {
|
|
846
|
+
if (!regular(file) || !realpathSync(file).startsWith(`${realpathSync(versionPath)}${pathFor(process.platform).sep}`)) return null;
|
|
847
|
+
return import(pathToFileURL(file).href);
|
|
848
|
+
}
|
|
849
|
+
/** An installed version's own scripts/bundled-install.mjs and its layout of this
|
|
850
|
+
* prefix; it must export prefixLayout, ensureRuntime and ensureLauncher as
|
|
851
|
+
* functions. `active` names the version `current` stays at otherwise.
|
|
852
|
+
*/
|
|
853
|
+
async function loadRelease({ layout, id, home, platform, env, adapters, active }) {
|
|
854
|
+
const path = pathFor(process.platform);
|
|
855
|
+
const versionPath = versionDirectory(layout, id);
|
|
856
|
+
const file = path.join(versionPath, "node_modules", "gentle-pi", "scripts", "bundled-install.mjs");
|
|
857
|
+
const module = await (adapters.load ?? releaseModule)(file, versionPath);
|
|
858
|
+
if (!module || RELEASE_EXPORTS.some((name) => typeof module[name] !== "function")) {
|
|
859
|
+
throw new Error(`Gentle Shell ${id} has no usable scripts/bundled-install.mjs (${RELEASE_EXPORTS.join(", ")}); current stays at ${active}`);
|
|
860
|
+
}
|
|
861
|
+
return { module, next: module.prefixLayout({ platform, env, home, root: layout.root }) };
|
|
862
|
+
}
|
|
863
|
+
/** Switches `current` to `id` and writes that version's launcher; when the
|
|
864
|
+
* launcher cannot be written, `current` goes back to `active` (one rename each).
|
|
865
|
+
*/
|
|
866
|
+
function activateWithLauncher(layout, id, active, { module, next }) {
|
|
867
|
+
activateVersion(layout, id);
|
|
868
|
+
try {
|
|
869
|
+
module.ensureLauncher(next);
|
|
870
|
+
} catch (error) {
|
|
871
|
+
if (active) activateVersion(layout, active);
|
|
872
|
+
else unlinkSync(layout.current);
|
|
873
|
+
throw new Error(`the launcher for Gentle Shell ${id} could not be written (${error.message}); current stays at ${active}`);
|
|
874
|
+
}
|
|
875
|
+
}
|
|
876
|
+
|
|
877
|
+
/** `gentle-shell upgrade` in a bundled install (S5, S6, S8): the latest
|
|
878
|
+
* release's distribution assets (adapters.latest, adapters.distribution) are
|
|
879
|
+
* installed beside the active version with our runtime, then the new version's
|
|
880
|
+
* own module (loaded only from that version folder) installs its pinned runtime
|
|
881
|
+
* when the pins moved. Its launcher, run in bundled mode for that version, must
|
|
882
|
+
* print exactly the release's gentle-pi and Pi before `current` switches; the
|
|
883
|
+
* release's launcher is then written (a failure puts `current` back) and two
|
|
884
|
+
* versions are kept (a pruning failure is only a warning). An already current
|
|
885
|
+
* release changes nothing; any failure leaves `current` as it was and removes a
|
|
886
|
+
* version this call installed. Returns { id, changed, previous?, removed?, warning? }.
|
|
887
|
+
*/
|
|
888
|
+
export async function upgradeBundled({ layout, home, platform = layout.platform, arch = process.arch, env = {}, adapters, out = () => {} }) {
|
|
889
|
+
const path = pathFor(process.platform);
|
|
890
|
+
const latest = await adapters.latest();
|
|
891
|
+
const release = await adapters.distribution(latest);
|
|
892
|
+
if (release?.status !== "published") throw new Error((UNPUBLISHED[release?.status] ?? UNPUBLISHED.unavailable)(latest));
|
|
893
|
+
const { manifest, lockfile } = release;
|
|
894
|
+
const { shell, pi, id } = versionsOf(manifest);
|
|
895
|
+
const active = activeVersion(layout);
|
|
896
|
+
if (active === id) {
|
|
897
|
+
out(`Gentle Shell ${shell} with Pi ${pi} is already current.`);
|
|
898
|
+
return { id, changed: false };
|
|
899
|
+
}
|
|
900
|
+
const go = platform === "win32";
|
|
901
|
+
const runtime = await (adapters.ensureRuntime ?? ensureRuntime)({ layout, platform, arch, go, env });
|
|
902
|
+
const version = await installVersion({ layout, manifest, lockfile, platform, env, go: runtime.go, adapters: adapters.install ?? {} });
|
|
903
|
+
try {
|
|
904
|
+
const loaded = await loadRelease({ layout, id, home, platform, env, adapters, active });
|
|
905
|
+
// The release's own pins, its Engram included (an older module ignores `engram`).
|
|
906
|
+
await loaded.module.ensureRuntime({ layout: loaded.next, platform, arch, go, engram: true, env });
|
|
907
|
+
const check = await checkLauncher(loaded.next, { id, shell, pi, platform, env, run: adapters.run });
|
|
908
|
+
if (!check.ok) {
|
|
909
|
+
throw Object.assign(new Error(`Gentle Shell ${id} reported ${JSON.stringify(check.reported)}, not gentle-shell ${shell} with pi ${pi}; current stays at ${active}`),
|
|
910
|
+
{ stderr: check.stderr });
|
|
911
|
+
}
|
|
912
|
+
activateWithLauncher(layout, id, active, loaded);
|
|
913
|
+
} catch (error) {
|
|
914
|
+
if (version.installed && activeVersion(layout) !== id) rmSync(version.path, { recursive: true, force: true });
|
|
915
|
+
throw error;
|
|
916
|
+
}
|
|
917
|
+
out(`Upgraded to Gentle Shell ${shell} with Pi ${pi} (from ${active ?? "no active version"}). Run 'gentle-shell upgrade --rollback' to switch back.`);
|
|
918
|
+
try {
|
|
919
|
+
return { id, changed: true, previous: active, removed: (adapters.prune ?? pruneVersions)(layout, 2) };
|
|
920
|
+
} catch (error) {
|
|
921
|
+
const warning = `older versions were not removed (${error.message})`;
|
|
922
|
+
out(`warning: ${warning}`);
|
|
923
|
+
return { id, changed: true, previous: active, removed: [], warning };
|
|
924
|
+
}
|
|
925
|
+
}
|
|
926
|
+
|
|
927
|
+
/** Whether version `id` reports exactly `gentle-shell <shell>` and `pi <pi>` to
|
|
928
|
+
* `--version`: its entry run by our Node, or with `launcher` on POSIX the
|
|
929
|
+
* bin/gentle-shell launcher itself (it runs the version `current` names).
|
|
930
|
+
* The environment is the one our pnpm ran in, without any Pi or Gentle override
|
|
931
|
+
* that could point the launcher at another Pi or agent home.
|
|
932
|
+
* Returns { ok, reported, stderr }.
|
|
933
|
+
*/
|
|
934
|
+
export async function checkLauncher(layout, { id, shell, pi, platform = layout.platform, env = {}, run = runCommand, launcher = false }) {
|
|
935
|
+
// The environment our pnpm ran in, without any Pi or Gentle override that
|
|
936
|
+
// could point the launcher at another Pi or agent home, plus the one input
|
|
937
|
+
// that runs this version in bundled mode for its --version (exact Pi).
|
|
938
|
+
const launchEnv = { ...Object.fromEntries(Object.entries(pnpmEnvironment(layout, { platform, env })).filter(([key]) => !/^(?:gentle_|pi_)/i.test(key))),
|
|
939
|
+
[VERIFY_ENV]: id };
|
|
940
|
+
const entry = pathFor(platform).join(layout.versions, id, "node_modules", "gentle-pi", "bin", "gentle-shell.mjs");
|
|
941
|
+
const [command, args] = launcher && platform !== "win32" ? [layout.launcher, ["--version"]] : [layout.node, [entry, "--version"]];
|
|
942
|
+
const result = await (run ?? runCommand)(command, args, { cwd: layout.root, env: launchEnv, timeout: 120000 });
|
|
943
|
+
const reported = String(result.stdout ?? "").trim();
|
|
944
|
+
const [shellLine, piLine] = reported.split(/\r?\n/);
|
|
945
|
+
return { ok: result.status === 0 && shellLine === `gentle-shell ${shell}` && piLine === `pi ${pi}`, reported, stderr: String(result.stderr ?? "").slice(-4000) };
|
|
946
|
+
}
|
|
947
|
+
|
|
948
|
+
/** `gentle-shell upgrade --rollback`: `current` switches back to the most
|
|
949
|
+
* recently active other version still installed, with that version's own
|
|
950
|
+
* launcher and runtime pins (its own scripts/bundled-install.mjs, loaded like
|
|
951
|
+
* upgradeBundled loads it). Without a usable module nothing changes; when its
|
|
952
|
+
* launcher cannot be written `current` goes back. Returns { from, to }.
|
|
953
|
+
*/
|
|
954
|
+
export async function rollbackBundled({ layout, home, platform = layout.platform, arch = process.arch, env = {}, adapters = {} }) {
|
|
955
|
+
const active = activeVersion(layout);
|
|
956
|
+
const previous = readHistory(layout).find((id) => id !== active && installedVersion(layout, id));
|
|
957
|
+
if (!previous) throw new Error("No previous Gentle Shell version is kept to roll back to");
|
|
958
|
+
const loaded = await loadRelease({ layout, id: previous, home, platform, env, adapters, active });
|
|
959
|
+
await loaded.module.ensureRuntime({ layout: loaded.next, platform, arch, go: platform === "win32", engram: true, env });
|
|
960
|
+
activateWithLauncher(layout, previous, active, loaded);
|
|
961
|
+
return { from: active, to: previous };
|
|
962
|
+
}
|
|
963
|
+
|
|
964
|
+
/** The launcher text: our Node running the current gentle-pi's
|
|
965
|
+
* bin/gentle-shell.mjs, passing every argument and the exit code. Without an
|
|
966
|
+
* active version it says so and exits 1. The Windows one reads the pointer file
|
|
967
|
+
* with delayed expansion off, so `!`, `%`, `^` or `&` in the prefix path stay
|
|
968
|
+
* literal inside its quotes.
|
|
969
|
+
*/
|
|
970
|
+
export function launcherText(layout) {
|
|
971
|
+
if (layout.platform === "win32") {
|
|
972
|
+
const node = win32.relative(layout.root, layout.node);
|
|
973
|
+
const entry = "%~dp0..\\versions\\%GENTLE_SHELL_CURRENT%\\node_modules\\gentle-pi\\bin\\gentle-shell.mjs";
|
|
974
|
+
return ["@echo off", `rem ${LAUNCHER_MARK}`, "setlocal DisableDelayedExpansion", 'set "GENTLE_SHELL_CURRENT="',
|
|
975
|
+
'if exist "%~dp0..\\current" set /p GENTLE_SHELL_CURRENT=<"%~dp0..\\current"', "if not defined GENTLE_SHELL_CURRENT goto missing",
|
|
976
|
+
`if not exist "${entry}" goto missing`, `"%~dp0..\\${node}" "${entry}" %*`, "exit /b %errorlevel%", ":missing",
|
|
977
|
+
`>&2 echo ${LAUNCHER_MISSING}`, "exit /b 1", ""].join("\r\n");
|
|
978
|
+
}
|
|
979
|
+
const quote = (value) => `'${value.replaceAll("'", "'\\''")}'`;
|
|
980
|
+
const entry = posix.join(layout.current, "node_modules/gentle-pi/bin/gentle-shell.mjs");
|
|
981
|
+
return ["#!/bin/sh", `# ${LAUNCHER_MARK}`, `if [ ! -f ${quote(entry)} ]; then`, `\techo '${LAUNCHER_MISSING}' >&2`, "\texit 1", "fi",
|
|
982
|
+
`exec ${quote(layout.node)} ${quote(entry)} "$@"`, ""].join("\n");
|
|
983
|
+
}
|
|
984
|
+
/** Writes bin/gentle-shell (bin/gentle-shell.cmd on Windows). A launcher this
|
|
985
|
+
* module wrote is refreshed; any other file there is never replaced.
|
|
986
|
+
*/
|
|
987
|
+
export function ensureLauncher(layout) {
|
|
988
|
+
const text = launcherText(layout);
|
|
989
|
+
const existing = stat(layout.launcher);
|
|
990
|
+
if (existing && (!existing.isFile() || !readFileSync(layout.launcher, "utf8").split(/\r?\n/)[1]?.endsWith(LAUNCHER_MARK))) {
|
|
991
|
+
throw new Error(`Conflicting launcher: ${layout.launcher}`);
|
|
992
|
+
}
|
|
993
|
+
if (existing && readFileSync(layout.launcher, "utf8") === text) return { written: false };
|
|
994
|
+
ensureDirectory(layout.bin, layout.platform);
|
|
995
|
+
replaceAtomically(layout.launcher, (temp) => {
|
|
996
|
+
writeFileSync(temp, text, { flag: "wx", mode: 0o755 });
|
|
997
|
+
if (layout.platform !== "win32") chmodSync(temp, 0o755);
|
|
998
|
+
});
|
|
999
|
+
return { written: true };
|
|
1000
|
+
}
|
|
1001
|
+
|
|
1002
|
+
/** The startup file that shell reads for the terminals users open:
|
|
1003
|
+
* - zsh: $ZDOTDIR/.zshrc (interactive shells);
|
|
1004
|
+
* - bash on macOS, where terminals start login shells: the first of
|
|
1005
|
+
* ~/.bash_profile, ~/.bash_login and ~/.profile that exists, as login bash
|
|
1006
|
+
* reads exactly one of them ("INVOCATION" in bash(1)); ~/.bash_profile only
|
|
1007
|
+
* when none exists, since creating it would hide the user's ~/.profile;
|
|
1008
|
+
* - bash elsewhere: ~/.bashrc, what an interactive non-login bash (a Linux
|
|
1009
|
+
* terminal) reads; distributions' ~/.profile sources it for login shells;
|
|
1010
|
+
* - fish: our own conf.d file; anything else: ~/.profile.
|
|
1011
|
+
*/
|
|
1012
|
+
function profileFor(platform, env, home) {
|
|
1013
|
+
const files = profileFiles(platform, env, home);
|
|
1014
|
+
const shell = posix.basename(String(env.SHELL ?? ""));
|
|
1015
|
+
if (shell === "bash" && platform === "darwin") return files.bash.find((path) => stat(path)) ?? files.bash[0];
|
|
1016
|
+
return (files[shell] ?? files.other)[0];
|
|
1017
|
+
}
|
|
1018
|
+
/** Every startup file profileFor can choose on `platform`, per shell, in its
|
|
1019
|
+
* order of preference: the one source for planning a line and for trusting a
|
|
1020
|
+
* receipt (readPathReceipt).
|
|
1021
|
+
*/
|
|
1022
|
+
function profileFiles(platform, env, home) {
|
|
1023
|
+
const config = posix.isAbsolute(env.XDG_CONFIG_HOME ?? "") ? env.XDG_CONFIG_HOME : posix.join(home, ".config");
|
|
1024
|
+
return {
|
|
1025
|
+
zsh: [posix.join(posix.isAbsolute(env.ZDOTDIR ?? "") ? env.ZDOTDIR : home, ".zshrc")],
|
|
1026
|
+
bash: platform === "darwin" ? [".bash_profile", ".bash_login", ".profile"].map((name) => posix.join(home, name)) : [posix.join(home, ".bashrc")],
|
|
1027
|
+
fish: [posix.join(config, "fish", "conf.d", "gentle-shell.fish")],
|
|
1028
|
+
other: [posix.join(home, ".profile")],
|
|
1029
|
+
};
|
|
1030
|
+
}
|
|
1031
|
+
/** Whether the installer may edit `path`: "file" (a regular file this user can
|
|
1032
|
+
* write, in real folders) or "absent" (creatable inside real, writable folders);
|
|
1033
|
+
* otherwise why not: "symlink" (the file or a folder above it under home is a
|
|
1034
|
+
* link, as stow, chezmoi and home-manager lay them out), "not-a-file" or
|
|
1035
|
+
* "not-writable".
|
|
1036
|
+
*/
|
|
1037
|
+
function profileState(path, home) {
|
|
1038
|
+
const realHome = realpathSync(home);
|
|
1039
|
+
const realFolder = (folder) => {
|
|
1040
|
+
const relative = posix.relative(home, folder);
|
|
1041
|
+
return relative.startsWith("..") || posix.isAbsolute(relative) || realpathSync(folder) === posix.join(realHome, relative);
|
|
1042
|
+
};
|
|
1043
|
+
const writable = (target) => {
|
|
1044
|
+
try { accessSync(target, constants.W_OK); return true; }
|
|
1045
|
+
catch { return false; }
|
|
1046
|
+
};
|
|
1047
|
+
const info = stat(path);
|
|
1048
|
+
if (info) {
|
|
1049
|
+
if (info.isSymbolicLink()) return "symlink";
|
|
1050
|
+
if (!info.isFile()) return "not-a-file";
|
|
1051
|
+
if (!realFolder(posix.dirname(path))) return "symlink";
|
|
1052
|
+
return writable(path) ? "file" : "not-writable";
|
|
1053
|
+
}
|
|
1054
|
+
let folder = posix.dirname(path);
|
|
1055
|
+
while (!stat(folder)) {
|
|
1056
|
+
if (posix.dirname(folder) === folder) return "not-a-file";
|
|
1057
|
+
folder = posix.dirname(folder);
|
|
1058
|
+
}
|
|
1059
|
+
const existing = stat(folder);
|
|
1060
|
+
if (existing.isSymbolicLink()) return "symlink";
|
|
1061
|
+
if (!existing.isDirectory()) return "not-a-file";
|
|
1062
|
+
if (!realFolder(folder)) return "symlink";
|
|
1063
|
+
return writable(folder) ? "absent" : "not-writable";
|
|
1064
|
+
}
|
|
1065
|
+
/** The marked line that puts `bin` first on PATH, for fish and for sh-like shells. */
|
|
1066
|
+
function profileLines(bin) {
|
|
1067
|
+
return { fish: `set -gx PATH '${bin}' $PATH ${PROFILE_MARK}`, sh: `export PATH="${bin}:$PATH" ${PROFILE_MARK}` };
|
|
1068
|
+
}
|
|
1069
|
+
/** Whether `path`, followed through links, already holds `line`. */
|
|
1070
|
+
function holdsLine(path, line) {
|
|
1071
|
+
try { return readFileSync(path, "utf8").split("\n").includes(line); }
|
|
1072
|
+
catch { return false; }
|
|
1073
|
+
}
|
|
1074
|
+
/** The single PATH change, planned and not applied:
|
|
1075
|
+
* - { kind: "none" } when bin/ or our ~/.local/bin link is already on PATH, or
|
|
1076
|
+
* the profile (even through a link) already holds our line;
|
|
1077
|
+
* - POSIX { kind: "symlink", path, target }: ~/.local/bin/gentle-shell when
|
|
1078
|
+
* ~/.local/bin is on PATH and that name is free;
|
|
1079
|
+
* - POSIX { kind: "profile", path, line, create }: otherwise one marked line in
|
|
1080
|
+
* the shell's startup file (profileFor), only when profileState allows it;
|
|
1081
|
+
* - POSIX { kind: "manual", path, line, reason }: that file may not be edited;
|
|
1082
|
+
* the user adds `line` and nothing is written;
|
|
1083
|
+
* - Windows { kind: "registry", key, name, entry }: bin/ in the HKCU user Path.
|
|
1084
|
+
*/
|
|
1085
|
+
export function pathEntryPlan(layout, { platform = layout.platform, env = {}, home }) {
|
|
1086
|
+
if (platform === "win32") {
|
|
1087
|
+
const normal = (value) => win32.normalize(value).replace(/(.)\\+$/, "$1").toLowerCase();
|
|
1088
|
+
const entries = String(envValue(env, "PATH", platform) ?? "").split(";").filter(Boolean).map(normal);
|
|
1089
|
+
if (entries.includes(normal(layout.bin))) return { kind: "none" };
|
|
1090
|
+
return { kind: "registry", key: "HKCU\\Environment", name: "Path", entry: layout.bin };
|
|
1091
|
+
}
|
|
1092
|
+
const entries = String(env.PATH ?? "").split(":").filter(Boolean).map((entry) => posix.normalize(entry).replace(/(.)\/+$/, "$1"));
|
|
1093
|
+
if (entries.includes(layout.bin)) return { kind: "none" };
|
|
1094
|
+
const localBin = posix.join(home, ".local", "bin");
|
|
1095
|
+
if (entries.includes(localBin) && stat(localBin)?.isDirectory()) {
|
|
1096
|
+
const link = posix.join(localBin, "gentle-shell");
|
|
1097
|
+
const existing = stat(link);
|
|
1098
|
+
if (!existing) return { kind: "symlink", path: link, target: layout.launcher };
|
|
1099
|
+
if (existing.isSymbolicLink() && readlinkSync(link) === layout.launcher) return { kind: "none" };
|
|
1100
|
+
}
|
|
1101
|
+
const profile = profileFor(platform, env, home);
|
|
1102
|
+
const fish = profile.endsWith(".fish");
|
|
1103
|
+
if (fish ? /['\\\n]/.test(layout.bin) : /["\\$`\n]/.test(layout.bin)) throw new Error(`Unsafe bundled bin path for a shell profile: ${layout.bin}`);
|
|
1104
|
+
const line = profileLines(layout.bin)[fish ? "fish" : "sh"];
|
|
1105
|
+
if (holdsLine(profile, line)) return { kind: "none" };
|
|
1106
|
+
const state = profileState(profile, home);
|
|
1107
|
+
if (state === "file" || state === "absent") return { kind: "profile", path: profile, line, create: state === "absent" };
|
|
1108
|
+
return { kind: "manual", path: profile, line, reason: state };
|
|
1109
|
+
}
|
|
1110
|
+
|
|
1111
|
+
/** Applies a pathEntryPlan and returns its receipt ({ ...plan, applied }, plus
|
|
1112
|
+
* `separator` for a profile line), which removePathEntry needs to revert it
|
|
1113
|
+
* exactly. Nothing existing is replaced and nothing is written for "none" or
|
|
1114
|
+
* "manual". A profile that changed since the plan (now a link, or created
|
|
1115
|
+
* meanwhile) throws. Windows needs an injected `registry` adapter
|
|
1116
|
+
* ({ add(entry), remove(entry) }); an add that reports "present" (the user Path
|
|
1117
|
+
* already held the entry) applied nothing.
|
|
1118
|
+
*/
|
|
1119
|
+
export function applyPathEntry(plan, { registry } = {}) {
|
|
1120
|
+
if (plan.kind === "symlink") symlinkSync(plan.target, plan.path);
|
|
1121
|
+
else if (plan.kind === "profile") {
|
|
1122
|
+
const changed = () => new Error(`The shell profile changed since the PATH plan: ${plan.path}`);
|
|
1123
|
+
if (plan.create) {
|
|
1124
|
+
let folder = posix.dirname(plan.path);
|
|
1125
|
+
while (!stat(folder)) folder = posix.dirname(folder);
|
|
1126
|
+
if (!stat(folder).isDirectory()) throw changed();
|
|
1127
|
+
mkdirSync(posix.dirname(plan.path), { recursive: true });
|
|
1128
|
+
// wx: never through a file or link created meanwhile.
|
|
1129
|
+
try { writeFileSync(plan.path, `${plan.line}\n`, { flag: "wx", mode: 0o644 }); }
|
|
1130
|
+
catch (error) { if (error.code === "EEXIST") throw changed(); throw error; }
|
|
1131
|
+
return { ...plan, applied: true, separator: false };
|
|
1132
|
+
}
|
|
1133
|
+
if (!stat(plan.path)?.isFile()) throw changed();
|
|
1134
|
+
const text = readFileSync(plan.path, "utf8");
|
|
1135
|
+
if (text.split("\n").includes(plan.line)) return { ...plan, applied: false };
|
|
1136
|
+
const separator = text.length > 0 && !text.endsWith("\n");
|
|
1137
|
+
// O_NOFOLLOW: a link swapped in after the check above is refused, not followed.
|
|
1138
|
+
const fd = openSync(plan.path, constants.O_WRONLY | constants.O_APPEND | constants.O_NOFOLLOW);
|
|
1139
|
+
try { writeSync(fd, `${separator ? "\n" : ""}${plan.line}\n`); }
|
|
1140
|
+
finally { closeSync(fd); }
|
|
1141
|
+
return { ...plan, applied: true, separator };
|
|
1142
|
+
} else if (plan.kind === "registry") {
|
|
1143
|
+
if (typeof registry?.add !== "function") throw new Error("The Windows PATH change needs a registry adapter");
|
|
1144
|
+
if (registry.add(plan.entry) === "present") return { ...plan, applied: false };
|
|
1145
|
+
} else return { ...plan, applied: false };
|
|
1146
|
+
return { ...plan, applied: true };
|
|
1147
|
+
}
|
|
1148
|
+
/** Reverts an applied receipt: only our symlink, only our exact marked line
|
|
1149
|
+
* (with the newline added before it, so the file reads as before), or only our
|
|
1150
|
+
* registry entry is removed. A profile applyPathEntry created is deleted when
|
|
1151
|
+
* nothing else is left in it. A profile that is no longer a regular file is
|
|
1152
|
+
* left alone, and so is everything a receipt with `applied: false` names: it
|
|
1153
|
+
* was there before. Returns { removed: true }, or { removed: false, note }
|
|
1154
|
+
* saying what was kept and why (a line the user edited, a link replaced).
|
|
1155
|
+
*/
|
|
1156
|
+
export function removePathEntry(plan, { registry } = {}) {
|
|
1157
|
+
const removed = { removed: true };
|
|
1158
|
+
if (plan.applied === false) return { removed: false, note: "it was there before the install" };
|
|
1159
|
+
if (plan.kind === "symlink") {
|
|
1160
|
+
const info = stat(plan.path);
|
|
1161
|
+
if (!info) return removed;
|
|
1162
|
+
if (!info.isSymbolicLink() || readlinkSync(plan.path) !== plan.target) return { removed: false, note: `${plan.path} no longer links to ${plan.target}, so it was kept` };
|
|
1163
|
+
unlinkSync(plan.path);
|
|
1164
|
+
} else if (plan.kind === "profile") {
|
|
1165
|
+
const info = stat(plan.path);
|
|
1166
|
+
if (!info) return removed;
|
|
1167
|
+
if (!info.isFile() || !plan.line.endsWith(PROFILE_MARK)) return { removed: false, note: `${plan.path} is no longer a regular file, so it was kept` };
|
|
1168
|
+
const lines = readFileSync(plan.path, "utf8").split("\n");
|
|
1169
|
+
const index = lines.indexOf(plan.line);
|
|
1170
|
+
if (index < 0) {
|
|
1171
|
+
// Edited by the user: nothing is rewritten; the marked lines left there are named.
|
|
1172
|
+
// Without any, the user already took the line out.
|
|
1173
|
+
const marked = lines.filter((line) => line.includes(PROFILE_MARK));
|
|
1174
|
+
return marked.length > 0 ? { removed: false, note: `${plan.path} still holds ${marked.join(" and ")} (edited, so it was kept)` } : removed;
|
|
1175
|
+
}
|
|
1176
|
+
lines.splice(index, 1);
|
|
1177
|
+
let text = lines.join("\n");
|
|
1178
|
+
if (plan.separator && index === lines.length - 1 && text.endsWith("\n")) text = text.slice(0, -1);
|
|
1179
|
+
if (plan.create && text === "") {
|
|
1180
|
+
unlinkSync(plan.path);
|
|
1181
|
+
return removed;
|
|
1182
|
+
}
|
|
1183
|
+
const mode = info.mode & 0o777;
|
|
1184
|
+
replaceAtomically(plan.path, (temp) => {
|
|
1185
|
+
writeFileSync(temp, text, { flag: "wx", mode });
|
|
1186
|
+
chmodSync(temp, mode);
|
|
1187
|
+
});
|
|
1188
|
+
} else if (plan.kind === "registry") {
|
|
1189
|
+
if (typeof registry?.remove !== "function") throw new Error("The Windows PATH change needs a registry adapter");
|
|
1190
|
+
registry.remove(plan.entry);
|
|
1191
|
+
}
|
|
1192
|
+
return removed;
|
|
1193
|
+
}
|
|
1194
|
+
|
|
1195
|
+
// The PATH receipt (S7): the one PATH change applyPathEntry made, kept in the
|
|
1196
|
+
// prefix so `gentle-shell self-uninstall` reverts exactly that change.
|
|
1197
|
+
const RECEIPT_SCHEMA = 1;
|
|
1198
|
+
const RECEIPT_FIELDS = Object.freeze({ symlink: ["path", "target"], profile: ["path", "line", "create", "separator"], registry: ["key", "name", "entry"] });
|
|
1199
|
+
/** Writes an applied receipt to layout.pathReceipt (mode 0600, replaced with
|
|
1200
|
+
* one rename) and returns true; a receipt that applied nothing writes nothing
|
|
1201
|
+
* and returns false, so an earlier receipt stays.
|
|
1202
|
+
*/
|
|
1203
|
+
export function writePathReceipt(layout, receipt) {
|
|
1204
|
+
if (receipt?.applied !== true) return false;
|
|
1205
|
+
const fields = RECEIPT_FIELDS[receipt.kind];
|
|
1206
|
+
if (!fields) throw new Error(`Unknown PATH entry kind: ${receipt.kind}`);
|
|
1207
|
+
const text = `${JSON.stringify({ schema: RECEIPT_SCHEMA, kind: receipt.kind, ...Object.fromEntries(fields.map((field) => [field, receipt[field]])) }, null, "\t")}\n`;
|
|
1208
|
+
replaceAtomically(layout.pathReceipt, (temp) => {
|
|
1209
|
+
writeFileSync(temp, text, { flag: "wx", mode: 0o600 });
|
|
1210
|
+
if (layout.platform !== "win32") chmodSync(temp, 0o600);
|
|
1211
|
+
});
|
|
1212
|
+
return true;
|
|
1213
|
+
}
|
|
1214
|
+
/** Whether a receipt names only what this prefix writes: our launcher linked as
|
|
1215
|
+
* ~/.local/bin/gentle-shell, our exact marked line for bin/ in a startup file
|
|
1216
|
+
* pathEntryPlan can choose for this user (profileFiles, with the same ZDOTDIR
|
|
1217
|
+
* and XDG_CONFIG_HOME rules), or bin/ in the HKCU user Path.
|
|
1218
|
+
*/
|
|
1219
|
+
function ownPathEntry(layout, receipt, { env, home }) {
|
|
1220
|
+
if (layout.platform === "win32") return receipt.kind === "registry" && receipt.key === "HKCU\\Environment" && receipt.name === "Path" && receipt.entry === layout.bin;
|
|
1221
|
+
if (typeof home !== "string" || !posix.isAbsolute(home)) return false;
|
|
1222
|
+
if (receipt.kind === "symlink") return receipt.path === posix.join(home, ".local", "bin", "gentle-shell") && receipt.target === layout.launcher;
|
|
1223
|
+
if (receipt.kind !== "profile" || typeof receipt.create !== "boolean" || typeof receipt.separator !== "boolean") return false;
|
|
1224
|
+
// Only a file pathEntryPlan can choose here, holding the line it writes there.
|
|
1225
|
+
const lines = profileLines(layout.bin);
|
|
1226
|
+
const files = profileFiles(layout.platform, env, home);
|
|
1227
|
+
return Object.entries(files).some(([shell, paths]) => paths.includes(receipt.path) && receipt.line === lines[shell === "fish" ? "fish" : "sh"]);
|
|
1228
|
+
}
|
|
1229
|
+
/** The persisted receipt: { status: "recorded", receipt } (applied: true, as
|
|
1230
|
+
* removePathEntry takes it), { status: "none" } without one, or
|
|
1231
|
+
* { status: "invalid", reason } for anything that is not a regular file this
|
|
1232
|
+
* prefix wrote (never acted on). `env` and `home` are the user's, as
|
|
1233
|
+
* pathEntryPlan read them.
|
|
1234
|
+
*/
|
|
1235
|
+
export function readPathReceipt(layout, { env = {}, home } = {}) {
|
|
1236
|
+
const info = stat(layout.pathReceipt);
|
|
1237
|
+
if (!info) return { status: "none" };
|
|
1238
|
+
const invalid = (reason) => ({ status: "invalid", reason: `${layout.pathReceipt} ${reason}` });
|
|
1239
|
+
if (!info.isFile()) return invalid("is not a regular file");
|
|
1240
|
+
if (info.size > 64 * 1024) return invalid("is too large");
|
|
1241
|
+
let data;
|
|
1242
|
+
try { data = JSON.parse(readFileSync(layout.pathReceipt, "utf8")); }
|
|
1243
|
+
catch { return invalid("is not valid JSON"); }
|
|
1244
|
+
if (data === null || typeof data !== "object" || Array.isArray(data) || data.schema !== RECEIPT_SCHEMA || !Object.hasOwn(RECEIPT_FIELDS, data.kind)) {
|
|
1245
|
+
return invalid("has an unknown format");
|
|
1246
|
+
}
|
|
1247
|
+
const receipt = { kind: data.kind, ...Object.fromEntries(RECEIPT_FIELDS[data.kind].map((field) => [field, data[field]])), applied: true };
|
|
1248
|
+
return ownPathEntry(layout, receipt, { env, home }) ? { status: "recorded", receipt } : invalid("does not describe this install's PATH entry");
|
|
1249
|
+
}
|
|
1250
|
+
/** What a recorded receipt changed, for the uninstall plan. */
|
|
1251
|
+
export function describePathReceipt(receipt) {
|
|
1252
|
+
if (receipt.kind === "symlink") return `the link ${receipt.path} to ${receipt.target}`;
|
|
1253
|
+
if (receipt.kind === "profile") return `the line ${receipt.line} in ${receipt.path}${receipt.create ? " (and that file, when nothing else is left in it)" : ""}`;
|
|
1254
|
+
return `${receipt.entry} in your user Path (${receipt.key})`;
|
|
1255
|
+
}
|
|
1256
|
+
|
|
1257
|
+
// Windows: once process GENTLE_SHELL_REMOVE_AFTER_PID exits, deletes each file
|
|
1258
|
+
// in GENTLE_SHELL_REMOVE_FILES (retrying while it is still in use), then
|
|
1259
|
+
// GENTLE_SHELL_REMOVE_LAST (the prefix marker) only when nothing is left at any
|
|
1260
|
+
// of those paths, so a failed removal can be retried with self-uninstall, then each
|
|
1261
|
+
// folder in GENTLE_SHELL_REMOVE_DIRECTORIES only when it is empty (never
|
|
1262
|
+
// recursively, so no link is followed). Every path is data, never script text.
|
|
1263
|
+
const deferredRemovalScript = `$ErrorActionPreference = 'SilentlyContinue'
|
|
1264
|
+
$waitFor = [int]$env:GENTLE_SHELL_REMOVE_AFTER_PID
|
|
1265
|
+
try { Wait-Process -Id $waitFor -Timeout 600 -ErrorAction Stop } catch {}
|
|
1266
|
+
Start-Sleep -Seconds 2
|
|
1267
|
+
$files = @($env:GENTLE_SHELL_REMOVE_FILES -split "\`n" | Where-Object { $_ -ne '' })
|
|
1268
|
+
foreach ($file in $files) {
|
|
1269
|
+
for ($try = 0; $try -lt 60; $try++) {
|
|
1270
|
+
try { if ([IO.File]::Exists($file)) { [IO.File]::Delete($file) }; break } catch { Start-Sleep -Milliseconds 500 }
|
|
1271
|
+
}
|
|
1272
|
+
}
|
|
1273
|
+
$last = $env:GENTLE_SHELL_REMOVE_LAST
|
|
1274
|
+
if ($last -and @($files | Where-Object { Test-Path -LiteralPath $_ }).Count -eq 0) { try { [IO.File]::Delete($last) } catch {} }
|
|
1275
|
+
foreach ($folder in @($env:GENTLE_SHELL_REMOVE_DIRECTORIES -split "\`n" | Where-Object { $_ -ne '' })) {
|
|
1276
|
+
try { [IO.Directory]::Delete($folder, $false) } catch {}
|
|
1277
|
+
}
|
|
1278
|
+
exit 0`;
|
|
1279
|
+
/** Windows self-uninstall: the running node.exe and the .cmd launcher cannot
|
|
1280
|
+
* delete themselves, so one detached, hidden Windows PowerShell removes `files`,
|
|
1281
|
+
* then `last` once nothing is left at those paths, then the empty
|
|
1282
|
+
* `directories` (deepest first), after process `pid` exits.
|
|
1283
|
+
* cmd.exe (detached) starts it with `start`, so it outlives the launcher; the
|
|
1284
|
+
* script goes as -EncodedCommand (UTF-16LE base64). Absolute local
|
|
1285
|
+
* paths only, passed in the environment; `spawn` is injectable for tests.
|
|
1286
|
+
*/
|
|
1287
|
+
export function scheduleWindowsRemoval({ pid, files, last, directories, env = {}, spawn: start = spawn }) {
|
|
1288
|
+
const systemRoot = envValue(env, "SYSTEMROOT", "win32");
|
|
1289
|
+
const safe = (path) => typeof path === "string" && win32.isAbsolute(path) && !path.startsWith("\\\\") && !/[\r\n]/.test(path);
|
|
1290
|
+
// SystemRoot is part of cmd's command line (the PowerShell path): no cmd metacharacter.
|
|
1291
|
+
const cmdSafe = (path) => safe(path) && !/[&|<>^%!"]/.test(path);
|
|
1292
|
+
if (!Number.isInteger(pid) || pid <= 0 || !cmdSafe(systemRoot) || ![...files, ...directories, ...(last === undefined ? [] : [last])].every(safe)) {
|
|
1293
|
+
throw new Error("Unsafe deferred removal request");
|
|
1294
|
+
}
|
|
1295
|
+
// A detached PowerShell never ran on the Windows runner, and a non-detached one
|
|
1296
|
+
// sits in Node's job object and dies with the exiting launcher. A detached
|
|
1297
|
+
// cmd.exe leaves that job and `start` gives PowerShell its own console, so it
|
|
1298
|
+
// outlives the launcher. Only fixed tokens and base64 reach cmd's command line.
|
|
1299
|
+
const powershell = win32.join(systemRoot, "System32", "WindowsPowerShell", "v1.0", "powershell.exe");
|
|
1300
|
+
const encoded = Buffer.from(deferredRemovalScript, "utf16le").toString("base64");
|
|
1301
|
+
const child = start(win32.join(systemRoot, "System32", "cmd.exe"), ["/d", "/c", "start", "\"\"", "/min", powershell,
|
|
1302
|
+
"-NoLogo", "-NoProfile", "-NonInteractive", "-WindowStyle", "Hidden", "-EncodedCommand", encoded], {
|
|
1303
|
+
cwd: systemRoot, detached: true, stdio: "ignore", windowsHide: true,
|
|
1304
|
+
env: { ...env, GENTLE_SHELL_REMOVE_AFTER_PID: String(pid), GENTLE_SHELL_REMOVE_FILES: files.join("\n"), GENTLE_SHELL_REMOVE_DIRECTORIES: directories.join("\n"),
|
|
1305
|
+
GENTLE_SHELL_REMOVE_LAST: last ?? "" },
|
|
1306
|
+
});
|
|
1307
|
+
child.unref();
|
|
1308
|
+
}
|