gentle-pi 4.0.0 → 4.2.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 +157 -18
- package/assets/agents/gentle-ai-explore.md +11 -4
- package/assets/agents/gentle-ai-verify.md +23 -4
- package/assets/agents/gentle-ai-worker.md +6 -6
- 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 +70 -163
- package/assets/orchestrator-memory.md +11 -4
- package/assets/orchestrator-prompts.md +48 -0
- 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 +29 -25
- 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/bin/engram-guard +9 -0
- package/bin/gentle-shell-install.mjs +302 -0
- package/bin/gentle-shell.mjs +708 -22
- 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/auto-permission-answers.md +25 -0
- package/docs/auto-permission-evidence.md +39 -0
- package/docs/bundled-install.md +399 -0
- package/docs/delegated-verification.md +3 -5
- package/docs/gentle-agents-activity.md +513 -0
- package/docs/gentle-shell-uninstall.md +136 -0
- package/docs/gentle-shell.md +90 -33
- package/docs/install-wizard.md +2040 -0
- package/docs/native-authority-architecture.md +1 -1
- package/docs/permission-modes.md +127 -0
- package/docs/prompt-history.md +1 -1
- package/docs/readme-reference.md +310 -47
- package/docs/render-cache-performance.md +28 -0
- package/docs/session-profile-format.md +39 -0
- package/docs/sound-notifications-proposal.md +191 -0
- package/docs/sound-notifications.md +114 -0
- package/docs/yolo-mode.md +11 -10
- package/extensions/ask-user-choice.ts +4 -0
- package/extensions/ask-user-question.ts +6 -1
- package/extensions/child-context.ts +11 -2
- package/extensions/child-safety.ts +4 -2
- package/extensions/codegraph-tools.ts +18 -5
- package/extensions/gentle-agents.ts +718 -87
- package/extensions/gentle-ai.ts +991 -263
- package/extensions/gentle-herdr-activity.ts +81 -0
- package/extensions/gentle-notifications.ts +104 -0
- package/extensions/gentle-shell.ts +271 -80
- package/extensions/gentle-todo.ts +123 -18
- package/extensions/history/index.ts +11 -6
- package/extensions/pi-pretty.ts +39 -3
- package/extensions/quiet-tools.ts +91 -21
- package/extensions/skill-registry.ts +175 -46
- package/extensions/startup-banner.ts +11 -2
- package/lib/agent-home.ts +10 -1
- package/lib/agent-model-resolution.ts +80 -0
- package/lib/agent-profiles.ts +13 -3
- package/lib/agents-completion-delivery.ts +11 -3
- package/lib/agents-messaging.ts +1 -1
- package/lib/agents-protocol.ts +40 -1
- package/lib/agents-runner.ts +90 -15
- package/lib/auto-permission-answers.ts +133 -0
- package/lib/auto-permission-classifier.ts +459 -0
- package/lib/auto-permission-evidence.ts +88 -0
- package/lib/auto-permission-memory.ts +69 -0
- package/lib/auto-permission-runtime.ts +484 -0
- package/lib/auto-permission-tool-context.ts +61 -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 +83 -10
- package/lib/builtin-codemode-optout.ts +166 -0
- package/lib/card-content-policy.ts +85 -0
- package/lib/child-package-injection.ts +62 -0
- package/lib/codemode-renderer.ts +14 -4
- package/lib/command-palette-catalog.ts +3 -2
- package/lib/context-threshold.ts +26 -0
- package/lib/destructive-command-guard.ts +104 -28
- package/lib/gentle-ai-binary.ts +109 -4
- package/lib/gentle-shell-bundled.ts +535 -0
- package/lib/gentle-shell-launcher.ts +75 -24
- 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/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 +6 -1
- package/lib/nan-provider.ts +33 -11
- package/lib/native-review-cli.ts +194 -73
- 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.ts +1 -1
- 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/permission-mode-policy.ts +59 -0
- package/lib/review-candidate-view-owner.ts +8 -6
- package/lib/review-candidate-view.ts +3 -2
- 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-risk-assessment.ts +77 -51
- package/lib/review-sidebar-state.ts +2 -1
- 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 +22 -7
- package/lib/session-change-capture.ts +5 -4
- package/lib/session-changes.ts +27 -22
- package/lib/session-profile-binding.ts +83 -0
- package/lib/session-profile-freeze.ts +261 -0
- package/lib/session-profile-persistence.ts +187 -0
- package/lib/session-worktree-registry.ts +74 -8
- package/lib/shell-bar.ts +41 -11
- package/lib/shell-changes-view.ts +40 -13
- package/lib/shell-changes.ts +88 -71
- package/lib/shell-prompt.ts +6 -4
- package/lib/shell-sidebar-layout.ts +5 -1
- package/lib/shell-todo.ts +81 -26
- package/lib/shell-usage.ts +4 -3
- package/lib/status-timing-diagnostics.ts +201 -0
- package/lib/telemetry-trigger.ts +3 -1
- package/lib/vim-editor-adapter.ts +27 -5
- package/lib/visual-customization-policy.ts +20 -9
- package/lib/visual-customize-view.ts +171 -20
- package/lib/writer-surfaces.ts +162 -0
- package/lib/yolo-session-policy.ts +198 -121
- package/package.json +5 -5
- package/runtime/child-package-injection.mjs +63 -0
- package/runtime/gentle-ai-binary.mjs +109 -4
- package/runtime/gentle-shell-bundled.mjs +536 -0
- package/runtime/gentle-shell-launcher.mjs +74 -23
- 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 +191 -70
- package/runtime/review-integration-v2.mjs +100 -22
- package/runtime/review-risk-assessment.mjs +77 -51
- 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 +7 -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 +79 -32
- 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/npm-pack-result.mjs +31 -0
- package/scripts/pi-update.mjs +223 -0
- package/scripts/render-cache-performance-worker.mjs +205 -0
- package/scripts/render-cache-performance.mjs +115 -0
- package/scripts/run-test-suite.mjs +22 -8
- package/scripts/test-installer-acceptance.sh +161 -0
- package/scripts/test-packed-runner.mjs +23 -5
- package/scripts/types-baseline.json +2 -92
- package/scripts/verify-package-files.mjs +82 -18
- package/skills/gentle-ai/SKILL.md +12 -11
- package/tests/agent-home.test.ts +56 -3
- package/tests/agent-model-resolution.test.ts +113 -0
- package/tests/agent-profiles.test.ts +12 -0
- package/tests/agent-risk-escalation.test.ts +123 -0
- package/tests/agents-completion-delivery.test.ts +17 -0
- package/tests/agents-protocol.test.ts +57 -0
- package/tests/agents-queries.test.ts +9 -9
- package/tests/agents-runner-process.test.ts +31 -0
- package/tests/agents-runner.test.ts +89 -4
- package/tests/append-system-prompt-route.test.ts +18 -1
- package/tests/auto-mode-human-answers.test.ts +68 -0
- package/tests/auto-mode-invariants.test.ts +276 -0
- package/tests/auto-mode-sdk.test.ts +84 -0
- package/tests/auto-mode.test.ts +49 -0
- package/tests/auto-permission-answers.test.ts +170 -0
- package/tests/auto-permission-classifier.test.ts +634 -0
- package/tests/auto-permission-compound.test.ts +24 -0
- package/tests/auto-permission-evidence.test.ts +88 -0
- package/tests/auto-permission-memory.test.ts +46 -0
- package/tests/auto-permission-public-regressions.test.ts +111 -0
- package/tests/auto-permission-runtime.test.ts +454 -0
- package/tests/autonomous-guard.test.ts +86 -16
- 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 +52 -1
- 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 +34 -2
- package/tests/child-codegraph-bootstrap.test.ts +74 -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 +12 -0
- package/tests/child-web-package.test.ts +224 -0
- package/tests/codegraph-tools.test.ts +35 -0
- package/tests/codemode-rendering.test.ts +107 -2
- package/tests/command-palette.test.ts +5 -5
- 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 -2
- package/tests/devbinary/native-review-parity.devtest.ts +1 -1
- package/tests/devbinary/pi-host-relay.devtest.ts +173 -3
- package/tests/engram-safety.test.ts +316 -0
- package/tests/generic-agent-tools.test.ts +16 -1
- package/tests/gentle-agents.test.ts +1895 -76
- package/tests/gentle-ai-binary-isolation.test.ts +135 -0
- package/tests/gentle-ai-binary.test.ts +19 -2
- 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-installer.test.ts +548 -58
- package/tests/gentle-ai-prompt-model-routing.test.ts +123 -0
- package/tests/gentle-ai-renderer.test.ts +6 -6
- package/tests/gentle-ai.test.ts +999 -31
- package/tests/gentle-herdr-activity.test.ts +100 -0
- package/tests/gentle-notifications.test.ts +427 -0
- package/tests/gentle-shell-bin.test.ts +263 -19
- package/tests/gentle-shell-bundled.test.ts +1036 -0
- package/tests/gentle-shell-launcher.test.ts +251 -25
- 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 +611 -100
- package/tests/gentle-todo.test.ts +317 -14
- package/tests/herdr-activity.test.ts +243 -0
- package/tests/history-agent-home.test.ts +81 -0
- package/tests/history-child-guard.test.ts +70 -0
- package/tests/inprocess-reviewer.test.ts +20 -0
- 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 +2159 -0
- package/tests/installer-server.test.ts +1567 -0
- package/tests/installer-windows-bootstrap.test.ts +2410 -0
- package/tests/jobs-view.test.ts +160 -0
- package/tests/main-channel-upgrade.test.ts +395 -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-package.test.ts +10 -0
- package/tests/nan-provider-runtime.test.ts +156 -0
- package/tests/nan-provider.test.ts +28 -18
- package/tests/native-binary-gate.test.ts +2 -0
- package/tests/native-review-capability-contract.test.ts +7 -1
- package/tests/native-review-cli.test.ts +236 -1
- package/tests/native-review-consent.test.ts +2 -2
- package/tests/native-review-parity-runtime.test.ts +1 -1
- 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 +14 -5
- package/tests/odd-routing-canonical-ratchet.test.ts +53 -12
- package/tests/odd-routing-contract.test.ts +80 -23
- package/tests/odd-runtime-delegation-gate.test.ts +4 -1
- package/tests/orchestrator-budget.test.ts +29 -6
- 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 +6 -5
- 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/package-manifest.test.ts +105 -21
- package/tests/packed-runner-owned-path.test.ts +35 -1
- 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 +4 -12
- package/tests/pi-pretty.test.ts +56 -0
- package/tests/pi-update-launcher.test.ts +87 -0
- package/tests/pi-update.test.ts +186 -0
- package/tests/provider-defect-handoff.test.ts +4 -3
- package/tests/quiet-tool-rendering.test.ts +160 -3
- package/tests/rdd-aware-verification-contract.test.ts +29 -41
- package/tests/rdd-status-line.test.ts +52 -0
- package/tests/render-cache-performance.test.ts +48 -0
- package/tests/review-agent-end-preflight.test.ts +62 -10
- package/tests/review-candidate-view.test.ts +101 -12
- package/tests/review-contract-prompt.test.ts +33 -4
- package/tests/review-controller-native-recovery.test.ts +327 -17
- package/tests/review-controller-native-routing.test.ts +255 -1
- package/tests/review-controller-workspace-root.test.ts +43 -2
- 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 +5 -3
- package/tests/review-host-relay.test.ts +49 -2
- package/tests/review-integration-v2-forward.test.ts +21 -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 +5 -4
- 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-risk-assessment.test.ts +109 -33
- 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-test-fixtures.ts +7 -1
- package/tests/review-transaction.test.ts +18 -1
- package/tests/run-test-suite.test.ts +83 -5
- package/tests/runtime-harness.mjs +42 -15
- package/tests/runtime-metrics-extension.test.ts +1 -1
- package/tests/runtime-metrics-native.test.ts +31 -1
- package/tests/selection-engine.test.ts +65 -5
- 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-profile-binding.test.ts +163 -0
- package/tests/session-profile-freeze.test.ts +394 -0
- package/tests/session-profile-persistence.test.ts +334 -0
- package/tests/session-subject-routing-contract.test.ts +34 -0
- package/tests/session-worktree-registry.test.ts +57 -2
- package/tests/shell-bar.test.ts +40 -0
- package/tests/shell-changes-view.test.ts +85 -34
- package/tests/shell-changes.test.ts +79 -62
- 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 +27 -2
- package/tests/shell-todo.test.ts +132 -11
- package/tests/shell-usage-view.test.ts +1 -1
- package/tests/shell-usage.test.ts +35 -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 +311 -5
- package/tests/startup-banner.test.ts +117 -3
- package/tests/startup-home-repo.test.ts +170 -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 +27 -1
- package/tests/test-strength-contract.test.ts +124 -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.test.ts +47 -21
- package/tests/visual-customization-policy.test.ts +32 -3
- package/tests/visual-customize-view.test.ts +288 -10
- package/tests/writer-edit-surface-scope.test.ts +59 -2
- package/tests/writer-surfaces.test.ts +199 -0
- package/tests/yolo-customize.test.ts +18 -3
- package/tests/yolo-mode-runtime.test.ts +37 -10
- package/tests/yolo-mode.test.ts +38 -5
- package/tests/yolo-session-policy.test.ts +1 -1
- /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,1319 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { createHash } from "node:crypto";
|
|
3
|
+
import { chmodSync, existsSync, rmSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, readlinkSync, realpathSync, renameSync, symlinkSync, writeFileSync } from "node:fs";
|
|
4
|
+
import { spawnSync } from "node:child_process";
|
|
5
|
+
import { tmpdir } from "node:os";
|
|
6
|
+
import { dirname, join, posix, win32 } from "node:path";
|
|
7
|
+
import test, { after, type TestContext } from "node:test";
|
|
8
|
+
import { crc32, gzipSync } from "node:zlib";
|
|
9
|
+
import { activateVersion, activeVersion, applyPathEntry, claimPrefix, prefixRoot, distributionFiles, engramExecutable, ensureEngram, ensureLauncher, ensureRuntime, filterNpmrc, installVersion, nodeExecutable,
|
|
10
|
+
nodeRuntimeFiles, pathEntryPlan, pnpmEnvironment, prefixLayout, pruneVersions, removePathEntry, rollbackBundled, setupEnvironment, bundledNpmCommand, ensureNpmShim, upgradeBundled, userNpmrc, writeNpmrcAuth,
|
|
11
|
+
describePathReceipt, readPathReceipt, scheduleWindowsRemoval, writePathReceipt }
|
|
12
|
+
from "../scripts/bundled-install.mjs";
|
|
13
|
+
|
|
14
|
+
// Real directories, modes, symlinks and uids need a POSIX host.
|
|
15
|
+
const posixHost = process.platform === "win32" ? "POSIX filesystem fixtures need a POSIX host" : false;
|
|
16
|
+
const posixTest = (name: string, fn: (t: TestContext) => void | Promise<void>) => test(name, { skip: posixHost }, fn);
|
|
17
|
+
const NODE = "24.21.0";
|
|
18
|
+
const PNPM = "11.1.1";
|
|
19
|
+
const NPM = "11.19.0";
|
|
20
|
+
const ENGRAM = "3.4.0";
|
|
21
|
+
|
|
22
|
+
// Every temporary home this file creates is removed when the file's tests end.
|
|
23
|
+
const roots: string[] = [];
|
|
24
|
+
after(() => {
|
|
25
|
+
for (const root of roots) {
|
|
26
|
+
try { chmodSync(root, 0o700); } catch {}
|
|
27
|
+
rmSync(root, { recursive: true, force: true });
|
|
28
|
+
}
|
|
29
|
+
});
|
|
30
|
+
function home() {
|
|
31
|
+
const root = mkdtempSync(join(realpathSync(tmpdir()), "bundled-install-"));
|
|
32
|
+
roots.push(root);
|
|
33
|
+
const dir = join(root, "home ü");
|
|
34
|
+
mkdirSync(dir, { mode: 0o700 });
|
|
35
|
+
return dir;
|
|
36
|
+
}
|
|
37
|
+
function claimed(platform = "darwin") {
|
|
38
|
+
const user = home();
|
|
39
|
+
return { home: user, layout: claimPrefix({ platform, env: { HOME: user }, home: user }) };
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// A ustar member, as the Node and pnpm tarballs carry them.
|
|
43
|
+
function tarEntry(name: string, { type = "0", data = Buffer.alloc(0), link = "", mode = "0000644" } = {}) {
|
|
44
|
+
const header = Buffer.alloc(512);
|
|
45
|
+
header.write(name, 0, 100, "utf8");
|
|
46
|
+
header.write(`${mode}\0`, 100);
|
|
47
|
+
header.write("0000000\0", 108);
|
|
48
|
+
header.write("0000000\0", 116);
|
|
49
|
+
header.write(`${data.length.toString(8).padStart(11, "0")}\0`, 124);
|
|
50
|
+
header.write("00000000000\0", 136);
|
|
51
|
+
header.write(" ", 148);
|
|
52
|
+
header.write(type, 156);
|
|
53
|
+
header.write(link, 157, 100);
|
|
54
|
+
header.write("ustar\u000000", 257);
|
|
55
|
+
let sum = 0;
|
|
56
|
+
for (const byte of header) sum += byte;
|
|
57
|
+
header.write(`${sum.toString(8).padStart(6, "0")}\0 `, 148);
|
|
58
|
+
return Buffer.concat([header, data, Buffer.alloc((512 - (data.length % 512)) % 512)]);
|
|
59
|
+
}
|
|
60
|
+
const tarGz = (entries: Buffer[]) => gzipSync(Buffer.concat([...entries, Buffer.alloc(1024)]));
|
|
61
|
+
// A stored (uncompressed) zip, as small as the Windows Node archive's shape allows.
|
|
62
|
+
function zip(files: [string, Buffer][]) {
|
|
63
|
+
const locals: Buffer[] = [];
|
|
64
|
+
const centrals: Buffer[] = [];
|
|
65
|
+
let offset = 0;
|
|
66
|
+
for (const [name, data] of files) {
|
|
67
|
+
const bytes = Buffer.from(name);
|
|
68
|
+
const local = Buffer.alloc(30);
|
|
69
|
+
local.writeUInt32LE(0x04034b50, 0); local.writeUInt16LE(20, 4); local.writeUInt32LE(crc32(data), 14);
|
|
70
|
+
local.writeUInt32LE(data.length, 18); local.writeUInt32LE(data.length, 22); local.writeUInt16LE(bytes.length, 26);
|
|
71
|
+
const central = Buffer.alloc(46);
|
|
72
|
+
central.writeUInt32LE(0x02014b50, 0); central.writeUInt16LE(20, 4); central.writeUInt16LE(20, 6); central.writeUInt32LE(crc32(data), 16);
|
|
73
|
+
central.writeUInt32LE(data.length, 20); central.writeUInt32LE(data.length, 24); central.writeUInt16LE(bytes.length, 28); central.writeUInt32LE(offset, 42);
|
|
74
|
+
locals.push(local, bytes, data);
|
|
75
|
+
centrals.push(central, bytes);
|
|
76
|
+
offset += 30 + bytes.length + data.length;
|
|
77
|
+
}
|
|
78
|
+
const directory = Buffer.concat(centrals);
|
|
79
|
+
const end = Buffer.alloc(22);
|
|
80
|
+
end.writeUInt32LE(0x06054b50, 0); end.writeUInt16LE(files.length, 8); end.writeUInt16LE(files.length, 10);
|
|
81
|
+
end.writeUInt32LE(directory.length, 12); end.writeUInt32LE(offset, 16);
|
|
82
|
+
return Buffer.concat([...locals, directory, end]);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const nodeStem = `node-v${NODE}-darwin-arm64`;
|
|
86
|
+
const nodeBytes = Buffer.from("#!/bin/sh\necho fixture node\n");
|
|
87
|
+
const nodeArchive = tarGz([
|
|
88
|
+
tarEntry(`${nodeStem}/`, { type: "5" }),
|
|
89
|
+
tarEntry(`${nodeStem}/bin/`, { type: "5" }),
|
|
90
|
+
tarEntry(`${nodeStem}/bin/node`, { data: nodeBytes }),
|
|
91
|
+
// Links and every other member are skipped, never written.
|
|
92
|
+
tarEntry(`${nodeStem}/bin/npm`, { type: "2", link: "../lib/node_modules/npm/bin/npm-cli.js" }),
|
|
93
|
+
tarEntry(`${nodeStem}/include/node/node.h`, { data: Buffer.from("header") }),
|
|
94
|
+
// npm, bundled in the same verified archive, is kept for Pi's package installs.
|
|
95
|
+
tarEntry(`${nodeStem}/lib/node_modules/npm/`, { type: "5" }),
|
|
96
|
+
tarEntry(`${nodeStem}/lib/node_modules/npm/package.json`, { data: Buffer.from(JSON.stringify({ name: "npm", version: NPM })) }),
|
|
97
|
+
tarEntry(`${nodeStem}/lib/node_modules/npm/bin/npm-cli.js`, { data: Buffer.from("// npm cli\n"), mode: "0000755" }),
|
|
98
|
+
tarEntry(`${nodeStem}/lib/node_modules/npm/bin/npx-cli.js`, { data: Buffer.from("// npx cli\n"), mode: "0000755" }),
|
|
99
|
+
tarEntry(`${nodeStem}/lib/node_modules/npm/bin/link`, { type: "2", link: "/etc/passwd" }),
|
|
100
|
+
tarEntry(`${nodeStem}/lib/node_modules/corepack/package.json`, { data: Buffer.from("{}") }),
|
|
101
|
+
]);
|
|
102
|
+
const pnpmArchive = tarGz([
|
|
103
|
+
tarEntry("package/package.json", { data: Buffer.from(JSON.stringify({ name: "pnpm", version: PNPM, engines: { node: ">=22.13" }, bin: { pnpm: "bin/pnpm.mjs" } })) }),
|
|
104
|
+
tarEntry("package/bin/pnpm.mjs", { data: Buffer.from("// fixture pnpm\n"), mode: "0000755" }),
|
|
105
|
+
// pnpm runs node-gyp from this folder: it must stay executable.
|
|
106
|
+
tarEntry("package/dist/node-gyp-bin/node-gyp", { data: Buffer.from("#!/bin/sh\n"), mode: "0000755" }),
|
|
107
|
+
]);
|
|
108
|
+
// The Engram release archive's shape: the executable at the top, beside tools/.
|
|
109
|
+
const engramBytes = Buffer.from("#!/bin/sh\necho fixture engram\n");
|
|
110
|
+
const engramArchive = tarGz([
|
|
111
|
+
tarEntry("tools/cloud-sync-projects.sh", { data: Buffer.from("#!/bin/sh\n"), mode: "0000755" }),
|
|
112
|
+
tarEntry("engram", { data: engramBytes, mode: "0000755" }),
|
|
113
|
+
]);
|
|
114
|
+
const engramUrl = `https://github.com/Gentleman-Programming/engram/releases/download/v${ENGRAM}/engram_${ENGRAM}_darwin_arm64.tar.gz`;
|
|
115
|
+
const archives: Record<string, Buffer> = { node: nodeArchive, pnpm: pnpmArchive, engram: engramArchive };
|
|
116
|
+
const sha256 = (bytes: Buffer) => createHash("sha256").update(bytes).digest("hex");
|
|
117
|
+
function adapters(overrides: Record<string, unknown> = {}) {
|
|
118
|
+
const calls: { downloads: string[]; runs: { command: string; args: string[]; cwd: string; env: Record<string, string> }[] } = { downloads: [], runs: [] };
|
|
119
|
+
return {
|
|
120
|
+
calls,
|
|
121
|
+
adapters: {
|
|
122
|
+
artifact: (name: string) => ({ name, version: name === "node" ? NODE : name === "engram" ? ENGRAM : PNPM, maxBytes: 1 << 20, integrity: `sha256-${sha256(archives[name])}`,
|
|
123
|
+
url: name === "node" ? `https://nodejs.org/dist/v${NODE}/${nodeStem}.tar.gz` : name === "engram" ? engramUrl : `https://registry.npmjs.org/pnpm/-/pnpm-${PNPM}.tgz` }),
|
|
124
|
+
download: async (descriptor: { name: string }) => { calls.downloads.push(descriptor.name); return archives[descriptor.name]; },
|
|
125
|
+
run: (command: string, args: string[], options: { cwd: string; env: Record<string, string> }) => {
|
|
126
|
+
calls.runs.push({ command, args, ...options });
|
|
127
|
+
if (args.length === 1 && args[0] === "--version") return { status: 0, stdout: `v${NODE}\n`, stderr: "" };
|
|
128
|
+
if (args.length === 1 && args[0] === "version") return { status: 0, stdout: `engram ${ENGRAM}\n`, stderr: "" };
|
|
129
|
+
if (args[1] === "--version") return { status: 0, stdout: `${args[0].endsWith("npm-cli.js") ? NPM : PNPM}\n`, stderr: "" };
|
|
130
|
+
return { status: 1, stdout: "", stderr: "unexpected" };
|
|
131
|
+
},
|
|
132
|
+
...overrides,
|
|
133
|
+
},
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// Rule 1: one prefix with every folder inside it.
|
|
138
|
+
test("prefixLayout puts every bundled folder under ~/.gentle-shell on POSIX", () => {
|
|
139
|
+
const layout = prefixLayout({ platform: "linux", env: {}, home: "/home/me" });
|
|
140
|
+
const root = "/home/me/.gentle-shell";
|
|
141
|
+
assert.equal(layout.root, root);
|
|
142
|
+
assert.equal(layout.agent, `${root}/agent`);
|
|
143
|
+
assert.equal(layout.node, `${root}/runtime/node-${NODE}/bin/node`);
|
|
144
|
+
assert.equal(layout.pnpm, `${root}/runtime/pnpm-${PNPM}/package/bin/pnpm.mjs`);
|
|
145
|
+
assert.equal(layout.goRoot, `${root}/runtime/go`);
|
|
146
|
+
assert.equal(layout.engram, `${root}/runtime/engram-${ENGRAM}/engram`);
|
|
147
|
+
assert.deepEqual([layout.pnpmHome, layout.store, layout.cache, layout.state, layout.config],
|
|
148
|
+
["pnpm", "pnpm/store", "pnpm/cache", "pnpm/state", "pnpm/config"].map((folder) => `${root}/${folder}`));
|
|
149
|
+
assert.equal(layout.versions, `${root}/versions`);
|
|
150
|
+
assert.equal(layout.current, `${root}/current`);
|
|
151
|
+
assert.equal(layout.currentKind, "symlink");
|
|
152
|
+
assert.equal(layout.launcher, `${root}/bin/gentle-shell`);
|
|
153
|
+
assert.throws(() => prefixLayout({ platform: "linux", env: {}, home: "relative" }), /home/i);
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
test("prefixLayout uses the Windows private folder and a pointer file for current", () => {
|
|
157
|
+
const env = { LOCALAPPDATA: "C:\\Users\\me\\AppData\\Local", USERPROFILE: "C:\\Users\\me" };
|
|
158
|
+
const layout = prefixLayout({ platform: "win32", env, home: "C:\\Users\\me" });
|
|
159
|
+
const root = "C:\\Users\\me\\AppData\\Local\\gentle-shell";
|
|
160
|
+
assert.equal(layout.root, root);
|
|
161
|
+
assert.equal(layout.agent, "C:\\Users\\me\\.gentle-shell\\agent");
|
|
162
|
+
assert.equal(layout.node, `${root}\\runtime\\node-${NODE}\\node.exe`);
|
|
163
|
+
assert.equal(layout.engram, `${root}\\runtime\\engram-${ENGRAM}\\engram.exe`);
|
|
164
|
+
assert.equal(layout.currentKind, "pointer");
|
|
165
|
+
assert.equal(layout.launcher, `${root}\\bin\\gentle-shell.cmd`);
|
|
166
|
+
assert.equal(layout.tmp, `${root}\\tmp`);
|
|
167
|
+
const fallback = prefixLayout({ platform: "win32", env, home: "C:\\Users\\me", root: "C:\\Users\\me\\.gentle-shell-bundle" });
|
|
168
|
+
assert.equal(fallback.versions, "C:\\Users\\me\\.gentle-shell-bundle\\versions");
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
posixTest("claimPrefix creates a private root, tightens its own group- or other-writable root and refuses a link or a non-folder", () => {
|
|
172
|
+
const user = home();
|
|
173
|
+
const layout = claimPrefix({ platform: "darwin", env: { HOME: user }, home: user });
|
|
174
|
+
const info = lstatSync(layout.root);
|
|
175
|
+
assert.ok(info.isDirectory());
|
|
176
|
+
assert.equal(info.mode & 0o777, 0o700);
|
|
177
|
+
// An existing root (the agent home lives there) is kept as it is.
|
|
178
|
+
writeFileSync(join(layout.root, "config.json"), "{}");
|
|
179
|
+
assert.equal(claimPrefix({ platform: "darwin", env: { HOME: user }, home: user }).root, layout.root);
|
|
180
|
+
assert.equal(readFileSync(join(layout.root, "config.json"), "utf8"), "{}");
|
|
181
|
+
const linked = home();
|
|
182
|
+
symlinkSync(layout.root, join(linked, ".gentle-shell"));
|
|
183
|
+
assert.throws(() => claimPrefix({ platform: "darwin", env: { HOME: linked }, home: linked }), /Unsafe bundled prefix/);
|
|
184
|
+
// T3 B3: a real folder of this user that umask 002 (or chmod) left group- or other-writable is
|
|
185
|
+
// tightened (go-w), not refused, and its contents stay.
|
|
186
|
+
for (const mode of [0o775, 0o777]) {
|
|
187
|
+
const shared = home();
|
|
188
|
+
mkdirSync(join(shared, ".gentle-shell"));
|
|
189
|
+
writeFileSync(join(shared, ".gentle-shell", "config.json"), "{}");
|
|
190
|
+
chmodSync(join(shared, ".gentle-shell"), mode);
|
|
191
|
+
assert.equal(claimPrefix({ platform: "darwin", env: { HOME: shared }, home: shared }).root, join(shared, ".gentle-shell"));
|
|
192
|
+
assert.equal(lstatSync(join(shared, ".gentle-shell")).mode & 0o777, mode & ~0o022);
|
|
193
|
+
assert.equal(readFileSync(join(shared, ".gentle-shell", "config.json"), "utf8"), "{}");
|
|
194
|
+
}
|
|
195
|
+
const file = home();
|
|
196
|
+
writeFileSync(join(file, ".gentle-shell"), "");
|
|
197
|
+
assert.throws(() => claimPrefix({ platform: "darwin", env: { HOME: file }, home: file }), /Unsafe bundled prefix|EEXIST|ENOTDIR/);
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
test("claimPrefix on Windows walks then claims %LOCALAPPDATA%, falling back to the profile only on an untrusted owner or ACL", () => {
|
|
201
|
+
const env = { LOCALAPPDATA: "C:\\Users\\me\\AppData\\Local", USERPROFILE: "C:\\Users\\me" };
|
|
202
|
+
const primary = "C:\\Users\\me\\AppData\\Local\\gentle-shell";
|
|
203
|
+
const fallback = "C:\\Users\\me\\.gentle-shell-bundle";
|
|
204
|
+
const run = (failing: Record<string, string>) => {
|
|
205
|
+
const walked: string[] = []; const claims: string[] = [];
|
|
206
|
+
const layout = claimPrefix({ platform: "win32", env, home: "C:\\Users\\me", adapters: {
|
|
207
|
+
// Only the candidates themselves are absent: each walk starts at its parent.
|
|
208
|
+
exists: (path: string) => path !== primary && path !== fallback,
|
|
209
|
+
prepare: () => {},
|
|
210
|
+
storage: (path: string) => {
|
|
211
|
+
walked.push(path);
|
|
212
|
+
const check = Object.entries(failing).find(([prefix]) => path.startsWith(prefix))?.[1];
|
|
213
|
+
if (check) throw Object.assign(new Error("Windows ACL evidence rejected"), { check });
|
|
214
|
+
},
|
|
215
|
+
claim: (path: string) => { claims.push(path); return "claimed"; },
|
|
216
|
+
} });
|
|
217
|
+
return { layout, walked, claims };
|
|
218
|
+
};
|
|
219
|
+
const trusted = run({});
|
|
220
|
+
assert.equal(trusted.layout.root, primary);
|
|
221
|
+
assert.deepEqual(trusted.claims, [primary]);
|
|
222
|
+
const moved = run({ [primary]: "ancestor-acl-mask", "C:\\Users\\me\\AppData": "parent-acl-mask" });
|
|
223
|
+
assert.equal(moved.layout.root, fallback);
|
|
224
|
+
assert.deepEqual(moved.claims, [fallback]);
|
|
225
|
+
assert.throws(() => run({ "C:\\Users\\me\\AppData": "parent-reparse" }), /ACL evidence/);
|
|
226
|
+
assert.throws(() => run({ "C:\\": "ancestor-acl-mask" }), /ACL evidence/);
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
test("the plan's prefix is the folder claimPrefix will claim, and a claim that would choose another folder changes nothing", () => {
|
|
230
|
+
const env = { LOCALAPPDATA: "C:\\Users\\me\\AppData\\Local", USERPROFILE: "C:\\Users\\me" };
|
|
231
|
+
const primary = "C:\\Users\\me\\AppData\\Local\\gentle-shell";
|
|
232
|
+
const fallback = "C:\\Users\\me\\.gentle-shell-bundle";
|
|
233
|
+
const adapters = (failing: string | null, claims: string[]) => ({
|
|
234
|
+
exists: (path: string) => path !== primary && path !== fallback,
|
|
235
|
+
prepare: () => {},
|
|
236
|
+
storage: (path: string) => { if (failing && path.startsWith(failing)) throw Object.assign(new Error("Windows ACL evidence rejected"), { check: "parent-acl-mask" }); },
|
|
237
|
+
claim: (path: string) => { claims.push(path); return "claimed"; },
|
|
238
|
+
});
|
|
239
|
+
const none: string[] = [];
|
|
240
|
+
assert.equal(prefixRoot({ platform: "win32", env, adapters: adapters(null, none) }), primary);
|
|
241
|
+
assert.equal(prefixRoot({ platform: "win32", env, adapters: adapters("C:\\Users\\me\\AppData", none) }), fallback, "the same fallback the claim takes");
|
|
242
|
+
assert.equal(prefixRoot({ platform: "linux", env: { HOME: "/home/me" } }), undefined, "POSIX keeps ~/.gentle-shell");
|
|
243
|
+
assert.deepEqual(none, [], "choosing the plan's folder claims nothing");
|
|
244
|
+
const claims: string[] = [];
|
|
245
|
+
assert.throws(() => claimPrefix({ platform: "win32", env, home: "C:\\Users\\me", root: primary, adapters: adapters("C:\\Users\\me\\AppData", claims) }),
|
|
246
|
+
(error: { check?: string; message: string }) => error.check === "prefix-changed" && error.message.includes(fallback));
|
|
247
|
+
assert.deepEqual(claims, [], "nothing is claimed when the folder differs from the consented one");
|
|
248
|
+
assert.equal(claimPrefix({ platform: "win32", env, home: "C:\\Users\\me", root: fallback, adapters: adapters("C:\\Users\\me\\AppData", claims) }).root, fallback);
|
|
249
|
+
assert.deepEqual(claims, [fallback]);
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
test("ensureWindowsPrivateFolder runs the PNPM_HOME claim with only the folder and its marker as data", async () => {
|
|
253
|
+
const windows = await import("../scripts/installer-windows.mjs");
|
|
254
|
+
const source = readFileSync(new URL("../scripts/installer-windows.mjs", import.meta.url), "utf8");
|
|
255
|
+
const pnpmHomeClaim = source.split("const pnpmHomeClaim = String.raw`")[1].split("`;")[0];
|
|
256
|
+
const expected = pnpmHomeClaim.replaceAll("$env:GENTLE_WINDOWS_PNPM_HOME", "$env:GENTLE_WINDOWS_PRIVATE_FOLDER")
|
|
257
|
+
.replaceAll("'.gentle-shell-pnpm-home'", "$env:GENTLE_WINDOWS_PRIVATE_MARKER").replaceAll("'gentle-pi private pnpm home'", "$env:GENTLE_WINDOWS_PRIVATE_TEXT");
|
|
258
|
+
const folder = "C:\\L\\gentle-shell";
|
|
259
|
+
const options = (output: string, walked: string[] = []) => ({ marker: ".gentle-shell-bundle", text: "gentle-shell bundled install", platform: "win32",
|
|
260
|
+
processAdapter: (_command: string, args: string[], env: Record<string, string>) => {
|
|
261
|
+
assert.equal(args[4], expected);
|
|
262
|
+
assert.deepEqual([env.GENTLE_WINDOWS_PRIVATE_FOLDER, env.GENTLE_WINDOWS_PRIVATE_MARKER, env.GENTLE_WINDOWS_PRIVATE_TEXT], [folder, ".gentle-shell-bundle", "gentle-shell bundled install"]);
|
|
263
|
+
return output;
|
|
264
|
+
},
|
|
265
|
+
storage: (path: string) => { walked.push(path); } });
|
|
266
|
+
const walked: string[] = [];
|
|
267
|
+
assert.equal(windows.ensureWindowsPrivateFolder(folder, { SystemRoot: "C:\\Windows" }, options("claimed", walked)), "claimed");
|
|
268
|
+
assert.deepEqual(walked, [folder, `${folder}\\tmp`]);
|
|
269
|
+
assert.throws(() => windows.ensureWindowsPrivateFolder(folder, { SystemRoot: "C:\\Windows" }, options("unsafe:foreign")), (error: { check?: string }) => error.check === "foreign");
|
|
270
|
+
assert.throws(() => windows.ensureWindowsPrivateFolder(folder, {}, { ...options("claimed"), marker: "..\\x" }), /marker/);
|
|
271
|
+
assert.throws(() => windows.ensureWindowsPrivateFolder(folder, {}, { ...options("claimed"), platform: "darwin" }), /Native Windows/);
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
// Rule 2: the pinned runtime, verified, published once and reused.
|
|
275
|
+
posixTest("ensureRuntime installs the verified Node and pnpm into runtime/ and reuses them", async () => {
|
|
276
|
+
const { layout } = claimed();
|
|
277
|
+
const { calls, adapters: fixture } = adapters();
|
|
278
|
+
const first = await ensureRuntime({ layout, platform: "darwin", arch: "arm64", adapters: fixture });
|
|
279
|
+
assert.deepEqual(first, { node: layout.node, pnpm: layout.pnpm, go: null, acquired: ["node", "pnpm"] });
|
|
280
|
+
assert.deepEqual(readFileSync(layout.node), nodeBytes);
|
|
281
|
+
assert.equal(lstatSync(layout.node).mode & 0o777, 0o700);
|
|
282
|
+
assert.equal(readFileSync(layout.pnpm, "utf8"), "// fixture pnpm\n");
|
|
283
|
+
// The executable bits the pnpm tarball declares are kept, for the owner only.
|
|
284
|
+
assert.equal(lstatSync(layout.pnpm).mode & 0o777, 0o700);
|
|
285
|
+
assert.equal(lstatSync(join(layout.pnpmDir, "package", "dist", "node-gyp-bin", "node-gyp")).mode & 0o777, 0o700);
|
|
286
|
+
assert.equal(lstatSync(join(layout.pnpmDir, "package", "package.json")).mode & 0o777, 0o600);
|
|
287
|
+
// The Node executable and its npm are published, no links: npm and npx are our own wrappers.
|
|
288
|
+
assert.deepEqual(readdirSync(join(layout.runtime, `node-${NODE}`)).sort(), [".gentle-shell-runtime", "bin", "gentle-shell", "lib"]);
|
|
289
|
+
// Pi's npmCommand entry, named npm (ensureNpmShim).
|
|
290
|
+
assert.match(readFileSync(layout.npmShim, "utf8"), /npm-cli\.js/);
|
|
291
|
+
assert.deepEqual(readdirSync(join(layout.runtime, `node-${NODE}`, "bin")).sort(), ["node", "npm", "npx"]);
|
|
292
|
+
assert.deepEqual(readdirSync(join(layout.runtime, `node-${NODE}`, "lib", "node_modules")), ["npm"]);
|
|
293
|
+
assert.deepEqual(readdirSync(join(layout.runtime, `node-${NODE}`, "lib", "node_modules", "npm", "bin")).sort(), ["npm-cli.js", "npx-cli.js"]);
|
|
294
|
+
assert.equal(readFileSync(layout.npmCli, "utf8"), "// npm cli\n");
|
|
295
|
+
assert.match(readFileSync(join(layout.nodeDir, "bin", "npm"), "utf8"), /exec "\$basedir\/node" "\$basedir\/\.\.\/lib\/node_modules\/npm\/bin\/npm-cli\.js" "\$@"/);
|
|
296
|
+
assert.equal(lstatSync(join(layout.nodeDir, "bin", "npx")).mode & 0o777, 0o700);
|
|
297
|
+
// Each is checked in its staging folder, before it is published.
|
|
298
|
+
const [nodeCheck, npmCheck, pnpmCheck] = calls.runs;
|
|
299
|
+
assert.equal(calls.runs.length, 3);
|
|
300
|
+
assert.deepEqual(nodeCheck.args, ["--version"]);
|
|
301
|
+
assert.match(nodeCheck.command, /\/runtime\/\.stage-[^/]+\/bin\/node$/);
|
|
302
|
+
assert.equal(npmCheck.command, nodeCheck.command);
|
|
303
|
+
assert.match(npmCheck.args[0], /\/runtime\/\.stage-[^/]+\/lib\/node_modules\/npm\/bin\/npm-cli\.js$/);
|
|
304
|
+
assert.equal(npmCheck.args[1], "--version");
|
|
305
|
+
assert.equal(pnpmCheck.command, layout.node);
|
|
306
|
+
assert.match(pnpmCheck.args[0], /\/runtime\/\.stage-[^/]+\/package\/bin\/pnpm\.mjs$/);
|
|
307
|
+
assert.equal(pnpmCheck.args[1], "--version");
|
|
308
|
+
assert.equal(pnpmCheck.env.pnpm_config_store_dir, layout.store);
|
|
309
|
+
const again = await ensureRuntime({ layout, platform: "darwin", arch: "arm64", adapters: fixture });
|
|
310
|
+
assert.deepEqual(again.acquired, []);
|
|
311
|
+
assert.deepEqual(calls.downloads, ["node", "pnpm"]);
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
posixTest("ensureRuntime never replaces an unmarked runtime folder and publishes nothing on a failed check", async () => {
|
|
315
|
+
const { layout } = claimed();
|
|
316
|
+
mkdirSync(join(layout.runtime, `node-${NODE}`, "bin"), { recursive: true });
|
|
317
|
+
writeFileSync(join(layout.runtime, `node-${NODE}`, "bin", "node"), "user");
|
|
318
|
+
await assert.rejects(ensureRuntime({ layout, platform: "darwin", arch: "arm64", adapters: adapters().adapters }), /Conflicting runtime/);
|
|
319
|
+
assert.equal(readFileSync(join(layout.runtime, `node-${NODE}`, "bin", "node"), "utf8"), "user");
|
|
320
|
+
const other = claimed().layout;
|
|
321
|
+
const wrong = adapters({ run: () => ({ status: 0, stdout: "v20.0.0\n", stderr: "" }) }).adapters;
|
|
322
|
+
await assert.rejects(ensureRuntime({ layout: other, platform: "darwin", arch: "arm64", adapters: wrong }), /Node/);
|
|
323
|
+
assert.deepEqual(readdirSync(other.runtime), []);
|
|
324
|
+
const tampered = adapters({ download: async () => Buffer.from("not the pinned bytes") }).adapters;
|
|
325
|
+
await assert.rejects(ensureRuntime({ layout: other, platform: "darwin", arch: "arm64", adapters: tampered }), /integrity|size/);
|
|
326
|
+
assert.deepEqual(readdirSync(other.runtime), []);
|
|
327
|
+
});
|
|
328
|
+
|
|
329
|
+
posixTest("ensureRuntime acquires the pinned Go only when asked", async () => {
|
|
330
|
+
const { layout } = claimed();
|
|
331
|
+
const asked: unknown[] = [];
|
|
332
|
+
const fixture = adapters({ acquireGo: async (options: unknown) => { asked.push(options); return { goPath: "/go/bin/go", version: "1.25.14", acquired: true }; } }).adapters;
|
|
333
|
+
assert.equal((await ensureRuntime({ layout, platform: "darwin", arch: "arm64", adapters: fixture })).go, null);
|
|
334
|
+
assert.equal(asked.length, 0);
|
|
335
|
+
const result = await ensureRuntime({ layout, platform: "darwin", arch: "arm64", go: true, adapters: fixture });
|
|
336
|
+
assert.equal(result.go, "/go/bin/go");
|
|
337
|
+
assert.equal((asked[0] as { root: string }).root, layout.goRoot);
|
|
338
|
+
});
|
|
339
|
+
|
|
340
|
+
// S9: the pinned Engram, verified, published once and reused; only when asked.
|
|
341
|
+
posixTest("ensureEngram publishes only the verified engram executable after running it, and ensureRuntime asks for it only when told", async () => {
|
|
342
|
+
const { layout } = claimed();
|
|
343
|
+
const { calls, adapters: fixture } = adapters();
|
|
344
|
+
assert.deepEqual((await ensureRuntime({ layout, platform: "darwin", arch: "arm64", adapters: fixture })).acquired, ["node", "pnpm"]);
|
|
345
|
+
assert.equal(existsSync(layout.engramDir), false);
|
|
346
|
+
assert.deepEqual((await ensureRuntime({ layout, platform: "darwin", arch: "arm64", engram: true, adapters: fixture })).acquired, ["engram"]);
|
|
347
|
+
assert.deepEqual(readdirSync(layout.engramDir).sort(), [".gentle-shell-runtime", "engram"]);
|
|
348
|
+
assert.deepEqual(readFileSync(layout.engram), engramBytes);
|
|
349
|
+
assert.equal(lstatSync(layout.engram).mode & 0o777, 0o700);
|
|
350
|
+
assert.equal(readFileSync(join(layout.engramDir, ".gentle-shell-runtime"), "utf8"), `${engramUrl}\n`);
|
|
351
|
+
// `engram version` ran from the staging folder, before publication.
|
|
352
|
+
const check = calls.runs.at(-1)!;
|
|
353
|
+
assert.deepEqual(check.args, ["version"]);
|
|
354
|
+
assert.match(check.command, /\/runtime\/\.stage-[^/]+\/engram$/);
|
|
355
|
+
assert.equal(await ensureEngram({ layout, platform: "darwin", arch: "arm64", adapters: fixture }), false, "reused");
|
|
356
|
+
assert.deepEqual(calls.downloads, ["node", "pnpm", "engram"]);
|
|
357
|
+
// Another version's answer, or a tampered archive, publishes nothing.
|
|
358
|
+
for (const broken of [adapters({ run: () => ({ status: 0, stdout: "engram 3.0.0\n", stderr: "" }) }).adapters, adapters({ download: async () => Buffer.from("x") }).adapters]) {
|
|
359
|
+
const other = claimed().layout;
|
|
360
|
+
await assert.rejects(ensureEngram({ layout: other, platform: "darwin", arch: "arm64", adapters: broken }), /Engram|integrity|size/);
|
|
361
|
+
assert.deepEqual(readdirSync(other.runtime), []);
|
|
362
|
+
}
|
|
363
|
+
});
|
|
364
|
+
|
|
365
|
+
// A4: a prefix that is not there fails before anything is downloaded.
|
|
366
|
+
posixTest("ensureEngram downloads nothing when the prefix is missing", async () => {
|
|
367
|
+
const user = home();
|
|
368
|
+
const layout = prefixLayout({ platform: "darwin", env: { HOME: user }, home: user });
|
|
369
|
+
const { calls, adapters: fixture } = adapters();
|
|
370
|
+
await assert.rejects(ensureEngram({ layout, platform: "darwin", arch: "arm64", adapters: fixture }), /ENOENT/);
|
|
371
|
+
assert.deepEqual(calls.downloads, []);
|
|
372
|
+
assert.equal(existsSync(layout.root), false);
|
|
373
|
+
});
|
|
374
|
+
|
|
375
|
+
test("engramExecutable takes only the top-level engram from the tarball and engram.exe from the zip, regular files only", () => {
|
|
376
|
+
const tarball = { url: "https://github.com/Gentleman-Programming/engram/releases/download/v3.4.0/engram_3.4.0_linux_amd64.tar.gz" };
|
|
377
|
+
const zipped = { url: "https://github.com/Gentleman-Programming/engram/releases/download/v3.4.0/engram_3.4.0_windows_amd64.zip" };
|
|
378
|
+
assert.deepEqual(engramExecutable(engramArchive, tarball), engramBytes);
|
|
379
|
+
const exe = Buffer.from("MZ engram");
|
|
380
|
+
assert.deepEqual(engramExecutable(zip([["tools/cloud-sync-projects.ps1", Buffer.from("x")], ["engram.exe", exe]]), zipped), exe);
|
|
381
|
+
assert.throws(() => engramExecutable(tarGz([tarEntry("engram", { type: "2", link: "/bin/sh" })]), tarball), /Engram archive rejected/);
|
|
382
|
+
assert.throws(() => engramExecutable(tarGz([tarEntry("bin/engram", { data: engramBytes })]), tarball), /Engram archive rejected/);
|
|
383
|
+
assert.throws(() => engramExecutable(zip([["engram", exe]]), zipped), /Engram archive rejected/);
|
|
384
|
+
});
|
|
385
|
+
|
|
386
|
+
test("nodeExecutable takes only node.exe from the Windows zip and only bin/node from the tarball", () => {
|
|
387
|
+
const stem = `node-v${NODE}-win-x64`;
|
|
388
|
+
const exe = Buffer.from("MZ fixture");
|
|
389
|
+
const descriptor = { url: `https://nodejs.org/dist/v${NODE}/${stem}.zip` };
|
|
390
|
+
const archive = zip([[`${stem}/npm.cmd`, Buffer.from("npm")], [`${stem}/node.exe`, exe]]);
|
|
391
|
+
assert.deepEqual(nodeExecutable(archive, descriptor), exe);
|
|
392
|
+
assert.throws(() => nodeExecutable(zip([[`${stem}/npm.cmd`, Buffer.from("npm")]]), descriptor), /Node archive/);
|
|
393
|
+
assert.throws(() => nodeExecutable(zip([[`${stem}/node.exe`, exe], [`${stem}/node.exe`, exe]]), descriptor), /Node archive/);
|
|
394
|
+
const tarball = { url: `https://nodejs.org/dist/v${NODE}/${nodeStem}.tar.gz` };
|
|
395
|
+
assert.deepEqual(nodeExecutable(nodeArchive, tarball), nodeBytes);
|
|
396
|
+
assert.throws(() => nodeExecutable(tarGz([tarEntry(`${nodeStem}/bin/node`, { type: "2", link: "/bin/sh" })]), tarball), /Node archive/);
|
|
397
|
+
});
|
|
398
|
+
|
|
399
|
+
// Rule 3: pnpm's whole world inside the prefix.
|
|
400
|
+
test("pnpmEnvironment keeps pnpm's home, store, cache, state and config inside the prefix", () => {
|
|
401
|
+
const layout = prefixLayout({ platform: "linux", env: {}, home: "/home/me" });
|
|
402
|
+
const env = pnpmEnvironment(layout, { platform: "linux", env: {
|
|
403
|
+
HOME: "/home/me", PATH: "/usr/local/bin:/usr/bin", PNPM_HOME: "/home/me/.local/share/pnpm", npm_config_registry: "https://user.example",
|
|
404
|
+
NPM_CONFIG_USERCONFIG: "/home/me/.npmrc", pnpm_config_store_dir: "/elsewhere", XDG_CONFIG_HOME: "/home/me/.config", NODE_OPTIONS: "--require /x.js",
|
|
405
|
+
npm_lifecycle_event: "postinstall", COREPACK_ENABLE_STRICT: "1", HTTPS_PROXY: "http://proxy:3128" } });
|
|
406
|
+
const root = "/home/me/.gentle-shell";
|
|
407
|
+
assert.equal(env.PATH, `${root}/runtime/node-${NODE}/bin:/usr/local/bin:/usr/bin`);
|
|
408
|
+
assert.equal(env.PNPM_HOME, `${root}/pnpm`);
|
|
409
|
+
assert.equal(env.pnpm_config_store_dir, `${root}/pnpm/store`);
|
|
410
|
+
assert.equal(env.pnpm_config_cache_dir, `${root}/pnpm/cache`);
|
|
411
|
+
assert.equal(env.pnpm_config_state_dir, `${root}/pnpm/state`);
|
|
412
|
+
assert.equal(env.pnpm_config_npmrc_auth_file, `${root}/pnpm/config/npmrc`);
|
|
413
|
+
assert.equal(env.XDG_CONFIG_HOME, `${root}/pnpm/config`);
|
|
414
|
+
assert.equal(env.XDG_CACHE_HOME, `${root}/pnpm/cache`);
|
|
415
|
+
assert.equal(env.XDG_STATE_HOME, `${root}/pnpm/state`);
|
|
416
|
+
// pnpm 11 has no manage-package-manager-versions setting; pm-on-fail defaults to
|
|
417
|
+
// "download", which switches to the pnpm a package.json above the folder names.
|
|
418
|
+
assert.equal(env.pnpm_config_manage_package_manager_versions, undefined);
|
|
419
|
+
assert.equal(env.pnpm_config_pm_on_fail, "ignore");
|
|
420
|
+
assert.equal(env.pnpm_config_runtime_on_fail, "ignore");
|
|
421
|
+
assert.equal(env.pnpm_config_update_notifier, "false");
|
|
422
|
+
for (const key of ["npm_config_registry", "NPM_CONFIG_USERCONFIG", "NODE_OPTIONS", "npm_lifecycle_event", "COREPACK_ENABLE_STRICT"]) assert.equal(env[key], undefined, key);
|
|
423
|
+
assert.equal(env.HTTPS_PROXY, "http://proxy:3128");
|
|
424
|
+
assert.equal(env.HOME, "/home/me");
|
|
425
|
+
const withGo = pnpmEnvironment(layout, { platform: "linux", env: { PATH: "/usr/bin" }, go: "/opt/go/bin/go" });
|
|
426
|
+
assert.equal(withGo.PATH, `${root}/runtime/node-${NODE}/bin:/opt/go/bin:/usr/bin`);
|
|
427
|
+
});
|
|
428
|
+
|
|
429
|
+
test("pnpmEnvironment on Windows replaces every Path spelling and moves TEMP into the prefix", () => {
|
|
430
|
+
const layout = prefixLayout({ platform: "win32", env: { LOCALAPPDATA: "C:\\L", USERPROFILE: "C:\\U" }, home: "C:\\U" });
|
|
431
|
+
const env = pnpmEnvironment(layout, { platform: "win32", env: { Path: "C:\\Windows\\System32", TEMP: "C:\\T", tmp: "C:\\T", Pnpm_Home: "C:\\U\\pnpm", SystemRoot: "C:\\Windows" } });
|
|
432
|
+
assert.equal(env.Path, `C:\\L\\gentle-shell\\runtime\\node-${NODE};C:\\Windows\\System32`);
|
|
433
|
+
assert.equal(Object.keys(env).filter((key) => key.toUpperCase() === "PATH").length, 1);
|
|
434
|
+
assert.equal(Object.keys(env).filter((key) => key.toUpperCase() === "PNPM_HOME").length, 1);
|
|
435
|
+
assert.equal(env.PNPM_HOME, "C:\\L\\gentle-shell\\pnpm");
|
|
436
|
+
assert.equal(env.TEMP, "C:\\L\\gentle-shell\\tmp");
|
|
437
|
+
assert.equal(env.TMP, "C:\\L\\gentle-shell\\tmp");
|
|
438
|
+
assert.equal(env.tmp, undefined);
|
|
439
|
+
assert.equal(env.SystemRoot, "C:\\Windows");
|
|
440
|
+
});
|
|
441
|
+
|
|
442
|
+
// Rule 4: one frozen install per version, verified, never replacing a folder.
|
|
443
|
+
const manifest = { shell: "4.1.0", pi: "1.0.2" };
|
|
444
|
+
function fakePnpm(versions = manifest) {
|
|
445
|
+
const runs: { command: string; args: string[]; cwd: string; env: Record<string, string>; marker: string }[] = [];
|
|
446
|
+
const run = (command: string, args: string[], options: { cwd: string; env: Record<string, string> }) => {
|
|
447
|
+
runs.push({ command, args, ...options, marker: readFileSync(join(options.cwd, ".gentle-shell-version"), "utf8") });
|
|
448
|
+
const modules = join(options.cwd, "node_modules");
|
|
449
|
+
// Like pnpm's Windows junctions: absolute links into node_modules/.pnpm.
|
|
450
|
+
const real = join(modules, ".pnpm", `gentle-pi@${versions.shell}`, "node_modules", "gentle-pi");
|
|
451
|
+
mkdirSync(join(real, "bin"), { recursive: true });
|
|
452
|
+
mkdirSync(join(modules, "@earendil-works", "pi-coding-agent"), { recursive: true });
|
|
453
|
+
writeFileSync(join(real, "package.json"), JSON.stringify({ name: "gentle-pi", version: versions.shell }));
|
|
454
|
+
writeFileSync(join(real, "bin", "gentle-shell.mjs"), "");
|
|
455
|
+
symlinkSync(real, join(modules, "gentle-pi"));
|
|
456
|
+
writeFileSync(join(modules, "@earendil-works", "pi-coding-agent", "package.json"), JSON.stringify({ name: "@earendil-works/pi-coding-agent", version: versions.pi }));
|
|
457
|
+
return { status: 0, stdout: "", stderr: "" };
|
|
458
|
+
};
|
|
459
|
+
return { runs, run };
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
posixTest("installVersion runs our pnpm frozen in the final versions/<shell>-<pi> folder and marks it valid last", async () => {
|
|
463
|
+
const { layout } = claimed();
|
|
464
|
+
const pnpm = fakePnpm();
|
|
465
|
+
const result = await installVersion({ layout, manifest, lockfile: "lockfileVersion: '9.0'\n", platform: "darwin", env: { PATH: "/usr/bin" }, adapters: { run: pnpm.run } });
|
|
466
|
+
assert.deepEqual(result, { id: "4.1.0-1.0.2", path: join(layout.versions, "4.1.0-1.0.2"), installed: true });
|
|
467
|
+
assert.equal(pnpm.runs.length, 1);
|
|
468
|
+
const [install] = pnpm.runs;
|
|
469
|
+
assert.equal(install.command, layout.node);
|
|
470
|
+
assert.deepEqual(install.args, [layout.pnpm, "install", "--frozen-lockfile"]);
|
|
471
|
+
// pnpm runs where the version stays: its absolute links (Windows junctions) never dangle.
|
|
472
|
+
assert.equal(install.cwd, result.path);
|
|
473
|
+
assert.equal(install.marker, `installing 4.1.0-1.0.2 ${process.pid}\n`);
|
|
474
|
+
assert.equal(readFileSync(join(result.path, ".gentle-shell-version"), "utf8"), "4.1.0-1.0.2\n");
|
|
475
|
+
assert.equal(realpathSync(join(result.path, "node_modules", "gentle-pi")).startsWith(`${realpathSync(result.path)}/`), true);
|
|
476
|
+
assert.equal(install.env.pnpm_config_store_dir, layout.store);
|
|
477
|
+
const files = distributionFiles(manifest);
|
|
478
|
+
assert.deepEqual(JSON.parse(files["package.json"]).dependencies, { "gentle-pi": "4.1.0", "@earendil-works/pi-coding-agent": "1.0.2" });
|
|
479
|
+
assert.match(files["pnpm-workspace.yaml"], /^allowBuilds:\n {2}gentle-pi: true\nstrictDepBuilds: false\n$/);
|
|
480
|
+
for (const [name, text] of Object.entries({ ...files, "pnpm-lock.yaml": "lockfileVersion: '9.0'\n" })) assert.equal(readFileSync(join(result.path, name), "utf8"), text, name);
|
|
481
|
+
assert.deepEqual(readdirSync(layout.versions), ["4.1.0-1.0.2"]);
|
|
482
|
+
const again = await installVersion({ layout, manifest, lockfile: "lockfileVersion: '9.0'\n", platform: "darwin", env: {}, adapters: { run: pnpm.run } });
|
|
483
|
+
assert.equal(again.installed, false);
|
|
484
|
+
assert.equal(pnpm.runs.length, 1);
|
|
485
|
+
});
|
|
486
|
+
|
|
487
|
+
posixTest("installVersion rejects a wrong installed version, a foreign folder and an unsafe id, publishing nothing", async () => {
|
|
488
|
+
const { layout } = claimed();
|
|
489
|
+
await assert.rejects(installVersion({ layout, manifest, lockfile: "x", platform: "darwin", env: {}, adapters: { run: fakePnpm({ shell: "4.1.0", pi: "1.0.3" }).run } }), /Pi version/);
|
|
490
|
+
assert.deepEqual(readdirSync(layout.versions), []);
|
|
491
|
+
await assert.rejects(installVersion({ layout, manifest, lockfile: "x", platform: "darwin", env: {}, adapters: { run: () => ({ status: 1, stdout: "", stderr: "ERR_PNPM" }) } }), /pnpm install failed/);
|
|
492
|
+
assert.deepEqual(readdirSync(layout.versions), []);
|
|
493
|
+
// pnpm prints install and postinstall failures on stdout: the error keeps both streams.
|
|
494
|
+
await assert.rejects(installVersion({ layout, manifest, lockfile: "x", platform: "darwin", env: {},
|
|
495
|
+
adapters: { run: () => ({ status: 1, stdout: "postinstall: Gentle AI build failed\n[ELIFECYCLE] exit 1", stderr: "" }) } }),
|
|
496
|
+
(error: Error & { stderr?: string }) => /pnpm install failed \(exit 1\)/.test(error.message) && /Gentle AI build failed/.test(String(error.stderr)));
|
|
497
|
+
assert.deepEqual(readdirSync(layout.versions), []);
|
|
498
|
+
mkdirSync(join(layout.versions, "4.1.0-1.0.2"));
|
|
499
|
+
writeFileSync(join(layout.versions, "4.1.0-1.0.2", "user.txt"), "keep");
|
|
500
|
+
await assert.rejects(installVersion({ layout, manifest, lockfile: "x", platform: "darwin", env: {}, adapters: { run: fakePnpm().run } }), /Conflicting version/);
|
|
501
|
+
assert.equal(readFileSync(join(layout.versions, "4.1.0-1.0.2", "user.txt"), "utf8"), "keep");
|
|
502
|
+
await assert.rejects(installVersion({ layout, manifest: { shell: "../x", pi: "1.0.2" }, lockfile: "x", platform: "darwin", env: {}, adapters: { run: fakePnpm().run } }), /version/i);
|
|
503
|
+
// A folder whose marker names another version is not ours either.
|
|
504
|
+
writeFileSync(join(layout.versions, "4.1.0-1.0.2", ".gentle-shell-version"), "installing 9.9.9-1.0.0 1\n");
|
|
505
|
+
await assert.rejects(installVersion({ layout, manifest, lockfile: "x", platform: "darwin", env: {}, adapters: { run: fakePnpm().run } }), /Conflicting version/);
|
|
506
|
+
assert.equal(readFileSync(join(layout.versions, "4.1.0-1.0.2", "user.txt"), "utf8"), "keep");
|
|
507
|
+
});
|
|
508
|
+
|
|
509
|
+
posixTest("installVersion cleans only its own interrupted install and never one still running", async () => {
|
|
510
|
+
const { layout } = claimed();
|
|
511
|
+
const folder = join(layout.versions, "4.1.0-1.0.2");
|
|
512
|
+
const dead = spawnSync(process.execPath, ["-e", ""]).pid;
|
|
513
|
+
mkdirSync(join(folder, "node_modules"), { recursive: true });
|
|
514
|
+
writeFileSync(join(folder, ".gentle-shell-version"), `installing 4.1.0-1.0.2 ${dead}\n`);
|
|
515
|
+
writeFileSync(join(folder, "node_modules", "partial"), "crash leftover");
|
|
516
|
+
const result = await installVersion({ layout, manifest, lockfile: "x", platform: "darwin", env: {}, adapters: { run: fakePnpm().run } });
|
|
517
|
+
assert.equal(result.installed, true);
|
|
518
|
+
assert.equal(existsSync(join(folder, "node_modules", "partial")), false);
|
|
519
|
+
assert.equal(readFileSync(join(folder, ".gentle-shell-version"), "utf8"), "4.1.0-1.0.2\n");
|
|
520
|
+
const other = claimed().layout;
|
|
521
|
+
const running = join(other.versions, "4.1.0-1.0.2");
|
|
522
|
+
mkdirSync(running);
|
|
523
|
+
writeFileSync(join(running, ".gentle-shell-version"), `installing 4.1.0-1.0.2 ${process.pid}\n`);
|
|
524
|
+
await assert.rejects(installVersion({ layout: other, manifest, lockfile: "x", platform: "darwin", env: {}, adapters: { run: fakePnpm().run } }), /in progress/);
|
|
525
|
+
assert.equal(readFileSync(join(running, ".gentle-shell-version"), "utf8"), `installing 4.1.0-1.0.2 ${process.pid}\n`);
|
|
526
|
+
// pruneVersions never counts or deletes an unfinished version.
|
|
527
|
+
assert.deepEqual(pruneVersions(other), []);
|
|
528
|
+
assert.equal(existsSync(running), true);
|
|
529
|
+
});
|
|
530
|
+
|
|
531
|
+
posixTest("installVersion for Windows refuses to run without the pinned Go, so a build never finds the user's go.exe", async () => {
|
|
532
|
+
const { layout } = claimed();
|
|
533
|
+
const pnpm = fakePnpm();
|
|
534
|
+
for (const go of [null, "/usr/local/go/bin/go", join(layout.goRoot, "1.25.14", "go", "bin", "go.exe")]) {
|
|
535
|
+
await assert.rejects(installVersion({ layout, manifest, lockfile: "x", platform: "win32", env: {}, go, adapters: { run: pnpm.run } }), /pinned Go/);
|
|
536
|
+
}
|
|
537
|
+
assert.equal(pnpm.runs.length, 0);
|
|
538
|
+
assert.deepEqual(readdirSync(layout.versions), []);
|
|
539
|
+
const go = join(layout.goRoot, "1.25.14", "go", "bin", "go.exe");
|
|
540
|
+
mkdirSync(posix.dirname(go), { recursive: true });
|
|
541
|
+
writeFileSync(go, "");
|
|
542
|
+
await installVersion({ layout, manifest, lockfile: "x", platform: "win32", env: {}, go, adapters: { run: pnpm.run } });
|
|
543
|
+
assert.equal(pnpm.runs[0].env.Path.split(";")[1], posix.dirname(go));
|
|
544
|
+
});
|
|
545
|
+
|
|
546
|
+
// Rule 5: atomic switch, keep two.
|
|
547
|
+
async function installed(layout: ReturnType<typeof prefixLayout>, ids: [string, string][]) {
|
|
548
|
+
for (const [shell, pi] of ids) await installVersion({ layout, manifest: { shell, pi }, lockfile: "x", platform: "darwin", env: {}, adapters: { run: fakePnpm({ shell, pi }).run } });
|
|
549
|
+
}
|
|
550
|
+
posixTest("activateVersion swaps the current symlink atomically and pruneVersions keeps the active and the previous one", async () => {
|
|
551
|
+
const { layout } = claimed();
|
|
552
|
+
await installed(layout, [["4.0.0", "1.0.0"], ["4.1.0", "1.0.1"], ["4.2.0", "1.0.2"]]);
|
|
553
|
+
assert.equal(activeVersion(layout), null);
|
|
554
|
+
for (const id of ["4.0.0-1.0.0", "4.1.0-1.0.1", "4.2.0-1.0.2"]) activateVersion(layout, id);
|
|
555
|
+
assert.equal(readlinkSync(layout.current), "versions/4.2.0-1.0.2");
|
|
556
|
+
assert.equal(activeVersion(layout), "4.2.0-1.0.2");
|
|
557
|
+
mkdirSync(join(layout.versions, "user-folder"));
|
|
558
|
+
assert.deepEqual(pruneVersions(layout), ["4.0.0-1.0.0"]);
|
|
559
|
+
assert.deepEqual(readdirSync(layout.versions).sort(), ["4.1.0-1.0.1", "4.2.0-1.0.2", "user-folder"]);
|
|
560
|
+
// Rolling back keeps both.
|
|
561
|
+
activateVersion(layout, "4.1.0-1.0.1");
|
|
562
|
+
assert.deepEqual(pruneVersions(layout), []);
|
|
563
|
+
assert.throws(() => activateVersion(layout, "user-folder"), /not an installed version/);
|
|
564
|
+
assert.throws(() => activateVersion(layout, "../x"), /version/i);
|
|
565
|
+
});
|
|
566
|
+
|
|
567
|
+
posixTest("pruneVersions deletes nothing while current names a version that is not installed", async () => {
|
|
568
|
+
const { layout } = claimed();
|
|
569
|
+
await installed(layout, [["4.0.0", "1.0.0"], ["4.1.0", "1.0.1"], ["4.2.0", "1.0.2"]]);
|
|
570
|
+
for (const id of ["4.0.0-1.0.0", "4.1.0-1.0.1"]) activateVersion(layout, id);
|
|
571
|
+
symlinkSync("versions/4.9.0-1.0.9", `${layout.current}.next`);
|
|
572
|
+
renameSync(`${layout.current}.next`, layout.current);
|
|
573
|
+
assert.equal(activeVersion(layout), "4.9.0-1.0.9");
|
|
574
|
+
assert.deepEqual(pruneVersions(layout), []);
|
|
575
|
+
assert.deepEqual(readdirSync(layout.versions).sort(), ["4.0.0-1.0.0", "4.1.0-1.0.1", "4.2.0-1.0.2"]);
|
|
576
|
+
});
|
|
577
|
+
|
|
578
|
+
posixTest("activateVersion never replaces a current that is not ours and writes a pointer file on Windows layouts", async () => {
|
|
579
|
+
const { layout } = claimed();
|
|
580
|
+
await installed(layout, [["4.0.0", "1.0.0"]]);
|
|
581
|
+
mkdirSync(layout.current);
|
|
582
|
+
assert.throws(() => activateVersion(layout, "4.0.0-1.0.0"), /Conflicting current/);
|
|
583
|
+
const other = claimed().layout;
|
|
584
|
+
const pointer = { ...other, currentKind: "pointer" };
|
|
585
|
+
await installed(pointer, [["4.0.0", "1.0.0"], ["4.1.0", "1.0.0"]]);
|
|
586
|
+
activateVersion(pointer, "4.0.0-1.0.0");
|
|
587
|
+
activateVersion(pointer, "4.1.0-1.0.0");
|
|
588
|
+
assert.equal(readFileSync(pointer.current, "utf8"), "4.1.0-1.0.0\n");
|
|
589
|
+
assert.equal(activeVersion(pointer), "4.1.0-1.0.0");
|
|
590
|
+
assert.deepEqual(readdirSync(other.root).filter((name) => name.startsWith("current")), ["current"]);
|
|
591
|
+
});
|
|
592
|
+
|
|
593
|
+
// Rule 6: the launcher and the single PATH change.
|
|
594
|
+
posixTest("ensureLauncher writes bin/gentle-shell that execs our Node with the current gentle-pi", () => {
|
|
595
|
+
const { layout } = claimed();
|
|
596
|
+
ensureLauncher(layout);
|
|
597
|
+
const text = readFileSync(layout.launcher, "utf8");
|
|
598
|
+
const quote = (value: string) => `'${value.replaceAll("'", "'\\''")}'`;
|
|
599
|
+
const entry = join(layout.current, "node_modules/gentle-pi/bin/gentle-shell.mjs");
|
|
600
|
+
assert.equal(text, ["#!/bin/sh", "# gentle-shell bundled launcher (generated; do not edit)", `if [ ! -f ${quote(entry)} ]; then`,
|
|
601
|
+
"\techo 'gentle-shell: no active Gentle Shell version; run the Gentle Shell installer again.' >&2", "\texit 1", "fi",
|
|
602
|
+
`exec ${quote(layout.node)} ${quote(entry)} "$@"`, ""].join("\n"));
|
|
603
|
+
assert.equal(lstatSync(layout.launcher).mode & 0o777, 0o755);
|
|
604
|
+
// Without an active version it says so; with one it passes every argument and the exit code.
|
|
605
|
+
const missing = spawnSync(layout.launcher, ["x"], { encoding: "utf8" });
|
|
606
|
+
assert.deepEqual([missing.status, missing.stderr], [1, "gentle-shell: no active Gentle Shell version; run the Gentle Shell installer again.\n"]);
|
|
607
|
+
mkdirSync(posix.dirname(layout.node), { recursive: true });
|
|
608
|
+
writeFileSync(layout.node, "#!/bin/sh\nprintf '%s|' \"$@\"\nexit 7\n", { mode: 0o700 });
|
|
609
|
+
mkdirSync(join(layout.versions, "v", "node_modules/gentle-pi/bin"), { recursive: true });
|
|
610
|
+
writeFileSync(join(layout.versions, "v", "node_modules/gentle-pi/bin/gentle-shell.mjs"), "");
|
|
611
|
+
symlinkSync("versions/v", layout.current);
|
|
612
|
+
const ran = spawnSync(layout.launcher, ["a b", "$HOME", "'"], { encoding: "utf8" });
|
|
613
|
+
assert.deepEqual([ran.status, ran.stdout], [7, `${entry}|a b|$HOME|'|`]);
|
|
614
|
+
ensureLauncher(layout);
|
|
615
|
+
writeFileSync(layout.launcher, "#!/bin/sh\necho user\n");
|
|
616
|
+
assert.throws(() => ensureLauncher(layout), /Conflicting launcher/);
|
|
617
|
+
});
|
|
618
|
+
|
|
619
|
+
test("ensureLauncher text for Windows reads the current pointer file", async () => {
|
|
620
|
+
const { launcherText } = await import("../scripts/bundled-install.mjs");
|
|
621
|
+
const layout = prefixLayout({ platform: "win32", env: { LOCALAPPDATA: "C:\\L", USERPROFILE: "C:\\U" }, home: "C:\\U" });
|
|
622
|
+
const entry = "%~dp0..\\versions\\%GENTLE_SHELL_CURRENT%\\node_modules\\gentle-pi\\bin\\gentle-shell.mjs";
|
|
623
|
+
assert.equal(launcherText(layout), ["@echo off", "rem gentle-shell bundled launcher (generated; do not edit)", "setlocal DisableDelayedExpansion",
|
|
624
|
+
'set "GENTLE_SHELL_CURRENT="', 'if exist "%~dp0..\\current" set /p GENTLE_SHELL_CURRENT=<"%~dp0..\\current"',
|
|
625
|
+
"if not defined GENTLE_SHELL_CURRENT goto missing", `if not exist "${entry}" goto missing`,
|
|
626
|
+
`"%~dp0..\\runtime\\node-${NODE}\\node.exe" "${entry}" %*`, "exit /b %errorlevel%", ":missing",
|
|
627
|
+
">&2 echo gentle-shell: no active Gentle Shell version; run the Gentle Shell installer again.", "exit /b 1", ""].join("\r\n"));
|
|
628
|
+
});
|
|
629
|
+
|
|
630
|
+
posixTest("pathEntryPlan links into ~/.local/bin on PATH, otherwise plans one marked profile line, and both are reversible", () => {
|
|
631
|
+
const { home: user, layout } = claimed();
|
|
632
|
+
ensureLauncher(layout);
|
|
633
|
+
const localBin = join(user, ".local", "bin");
|
|
634
|
+
mkdirSync(localBin, { recursive: true });
|
|
635
|
+
const onPath = pathEntryPlan(layout, { platform: "darwin", env: { PATH: `/usr/bin:${localBin}`, SHELL: "/bin/zsh" }, home: user });
|
|
636
|
+
assert.deepEqual(onPath, { kind: "symlink", path: join(localBin, "gentle-shell"), target: layout.launcher });
|
|
637
|
+
applyPathEntry(onPath);
|
|
638
|
+
assert.equal(readlinkSync(join(localBin, "gentle-shell")), layout.launcher);
|
|
639
|
+
assert.deepEqual(pathEntryPlan(layout, { platform: "darwin", env: { PATH: localBin }, home: user }), { kind: "none" });
|
|
640
|
+
removePathEntry(onPath);
|
|
641
|
+
assert.equal(existsSync(join(localBin, "gentle-shell")), false);
|
|
642
|
+
|
|
643
|
+
const zshrc = join(user, ".zshrc");
|
|
644
|
+
writeFileSync(zshrc, "alias ll='ls -l'\n");
|
|
645
|
+
const profile = pathEntryPlan(layout, { platform: "darwin", env: { PATH: "/usr/bin", SHELL: "/bin/zsh" }, home: user });
|
|
646
|
+
assert.deepEqual(profile, { kind: "profile", path: zshrc, line: `export PATH="${layout.bin}:$PATH" # gentle-shell bundled install`, create: false });
|
|
647
|
+
const applied = applyPathEntry(profile);
|
|
648
|
+
assert.equal(readFileSync(zshrc, "utf8"), `alias ll='ls -l'\n${profile.line}\n`);
|
|
649
|
+
assert.deepEqual(pathEntryPlan(layout, { platform: "darwin", env: { PATH: "/usr/bin", SHELL: "/bin/zsh" }, home: user }), { kind: "none" });
|
|
650
|
+
removePathEntry(applied);
|
|
651
|
+
assert.equal(readFileSync(zshrc, "utf8"), "alias ll='ls -l'\n");
|
|
652
|
+
assert.equal(pathEntryPlan(layout, { platform: "linux", env: { PATH: "/usr/bin", SHELL: "/bin/bash" }, home: user }).path, join(user, ".bashrc"));
|
|
653
|
+
assert.equal(pathEntryPlan(layout, { platform: "linux", env: { PATH: "/usr/bin" }, home: user }).path, join(user, ".profile"));
|
|
654
|
+
assert.equal(pathEntryPlan(layout, { platform: "linux", env: { PATH: "/usr/bin", SHELL: "/bin/zsh", ZDOTDIR: join(user, "zdot") }, home: user }).path, join(user, "zdot", ".zshrc"));
|
|
655
|
+
assert.deepEqual(pathEntryPlan(layout, { platform: "linux", env: { PATH: `/usr/bin:${layout.bin}` }, home: user }), { kind: "none" });
|
|
656
|
+
// Someone else's gentle-shell in ~/.local/bin is never replaced.
|
|
657
|
+
writeFileSync(join(localBin, "gentle-shell"), "user");
|
|
658
|
+
assert.equal(pathEntryPlan(layout, { platform: "linux", env: { PATH: localBin, SHELL: "/bin/bash" }, home: user }).kind, "profile");
|
|
659
|
+
});
|
|
660
|
+
|
|
661
|
+
posixTest("pathEntryPlan never writes through a symlinked, read-only or non-file profile and asks for one manual line instead", () => {
|
|
662
|
+
const { home: user, layout } = claimed();
|
|
663
|
+
const env = { PATH: "/usr/bin", SHELL: "/bin/zsh" };
|
|
664
|
+
const line = `export PATH="${layout.bin}:$PATH" # gentle-shell bundled install`;
|
|
665
|
+
// stow/chezmoi: the profile is a link into a dotfiles repository.
|
|
666
|
+
const dotfiles = join(posix.dirname(user), "dotfiles");
|
|
667
|
+
mkdirSync(dotfiles);
|
|
668
|
+
writeFileSync(join(dotfiles, "zshrc"), "alias x=y");
|
|
669
|
+
symlinkSync(join(dotfiles, "zshrc"), join(user, ".zshrc"));
|
|
670
|
+
const manual = pathEntryPlan(layout, { platform: "darwin", env, home: user });
|
|
671
|
+
assert.deepEqual(manual, { kind: "manual", path: join(user, ".zshrc"), line, reason: "symlink" });
|
|
672
|
+
applyPathEntry(manual);
|
|
673
|
+
removePathEntry(manual);
|
|
674
|
+
assert.equal(readFileSync(join(dotfiles, "zshrc"), "utf8"), "alias x=y");
|
|
675
|
+
// Once the user added the line, nothing is asked again.
|
|
676
|
+
writeFileSync(join(dotfiles, "zshrc"), `alias x=y\n${line}\n`);
|
|
677
|
+
assert.deepEqual(pathEntryPlan(layout, { platform: "darwin", env, home: user }), { kind: "none" });
|
|
678
|
+
// home-manager: a read-only file; a folder where the profile should be.
|
|
679
|
+
const readOnly = claimed();
|
|
680
|
+
writeFileSync(join(readOnly.home, ".zshrc"), "alias x=y\n");
|
|
681
|
+
chmodSync(join(readOnly.home, ".zshrc"), 0o444);
|
|
682
|
+
assert.equal(pathEntryPlan(readOnly.layout, { platform: "darwin", env, home: readOnly.home }).kind, "manual");
|
|
683
|
+
const folder = claimed();
|
|
684
|
+
mkdirSync(join(folder.home, ".zshrc"));
|
|
685
|
+
assert.equal(pathEntryPlan(folder.layout, { platform: "darwin", env, home: folder.home }).kind, "manual");
|
|
686
|
+
// A fish conf.d reached through a linked ~/.config is not created through the link.
|
|
687
|
+
const fish = claimed();
|
|
688
|
+
mkdirSync(join(dotfiles, "config", "fish", "conf.d"), { recursive: true });
|
|
689
|
+
symlinkSync(join(dotfiles, "config"), join(fish.home, ".config"));
|
|
690
|
+
const fishPlan = pathEntryPlan(fish.layout, { platform: "linux", env: { PATH: "/usr/bin", SHELL: "/usr/bin/fish" }, home: fish.home });
|
|
691
|
+
assert.equal(fishPlan.kind, "manual");
|
|
692
|
+
assert.deepEqual(readdirSync(join(dotfiles, "config", "fish", "conf.d")), []);
|
|
693
|
+
});
|
|
694
|
+
|
|
695
|
+
posixTest("applyPathEntry adds one line after a profile without a final newline, never twice, and removePathEntry restores it exactly", () => {
|
|
696
|
+
const { home: user, layout } = claimed();
|
|
697
|
+
const env = { PATH: "/usr/bin", SHELL: "/bin/zsh" };
|
|
698
|
+
const zshrc = join(user, ".zshrc");
|
|
699
|
+
writeFileSync(zshrc, "alias x=y");
|
|
700
|
+
chmodSync(zshrc, 0o640);
|
|
701
|
+
const plan = pathEntryPlan(layout, { platform: "darwin", env, home: user });
|
|
702
|
+
const applied = applyPathEntry(plan);
|
|
703
|
+
assert.equal(readFileSync(zshrc, "utf8"), `alias x=y\n${plan.line}\n`);
|
|
704
|
+
assert.deepEqual(applyPathEntry(plan), { ...plan, applied: false });
|
|
705
|
+
assert.equal(readFileSync(zshrc, "utf8"), `alias x=y\n${plan.line}\n`);
|
|
706
|
+
removePathEntry(applied);
|
|
707
|
+
assert.equal(readFileSync(zshrc, "utf8"), "alias x=y");
|
|
708
|
+
assert.equal(lstatSync(zshrc).mode & 0o777, 0o640);
|
|
709
|
+
// A profile that turned into a link after planning is never written.
|
|
710
|
+
writeFileSync(join(user, "elsewhere"), "keep");
|
|
711
|
+
const again = pathEntryPlan(layout, { platform: "darwin", env, home: user });
|
|
712
|
+
renameSync(zshrc, join(user, "zshrc.old"));
|
|
713
|
+
symlinkSync(join(user, "elsewhere"), zshrc);
|
|
714
|
+
assert.throws(() => applyPathEntry(again), /changed/);
|
|
715
|
+
assert.equal(readFileSync(join(user, "elsewhere"), "utf8"), "keep");
|
|
716
|
+
});
|
|
717
|
+
|
|
718
|
+
posixTest("pathEntryPlan for bash writes the first file login bash reads and creates ~/.bash_profile only when none exists", () => {
|
|
719
|
+
const bash = (files: string[], platform = "darwin") => {
|
|
720
|
+
const { home: user, layout } = claimed();
|
|
721
|
+
for (const file of files) writeFileSync(join(user, file), "export FROM=user\n");
|
|
722
|
+
return { user, layout, plan: pathEntryPlan(layout, { platform, env: { PATH: "/usr/bin", SHELL: "/bin/bash" }, home: user }) };
|
|
723
|
+
};
|
|
724
|
+
for (const [files, wanted] of [[[".profile"], ".profile"], [[".bash_login", ".profile"], ".bash_login"], [[".bash_profile", ".bash_login", ".profile"], ".bash_profile"]] as [string[], string][]) {
|
|
725
|
+
const { user, plan } = bash(files);
|
|
726
|
+
assert.deepEqual([plan.kind, plan.path, plan.create], ["profile", join(user, wanted), false], wanted);
|
|
727
|
+
}
|
|
728
|
+
// None: ~/.bash_profile is created, and removed again only while it holds nothing else.
|
|
729
|
+
const fresh = bash([]);
|
|
730
|
+
assert.deepEqual([fresh.plan.path, fresh.plan.create], [join(fresh.user, ".bash_profile"), true]);
|
|
731
|
+
removePathEntry(applyPathEntry(fresh.plan));
|
|
732
|
+
assert.equal(existsSync(join(fresh.user, ".bash_profile")), false);
|
|
733
|
+
const kept = bash([]);
|
|
734
|
+
const receipt = applyPathEntry(kept.plan);
|
|
735
|
+
writeFileSync(join(kept.user, ".bash_profile"), "alias l=ls\n", { flag: "a" });
|
|
736
|
+
removePathEntry(receipt);
|
|
737
|
+
assert.equal(readFileSync(join(kept.user, ".bash_profile"), "utf8"), "alias l=ls\n");
|
|
738
|
+
// Interactive non-login bash, what Linux terminals start, reads ~/.bashrc.
|
|
739
|
+
const linux = bash([".profile"], "linux");
|
|
740
|
+
assert.deepEqual([linux.plan.path, linux.plan.create], [join(linux.user, ".bashrc"), true]);
|
|
741
|
+
});
|
|
742
|
+
|
|
743
|
+
test("a login bash still reads the user's ~/.profile after the PATH line is added and removed", { skip: posixHost || (existsSync("/bin/bash") ? false : "no /bin/bash") }, () => {
|
|
744
|
+
const { home: user, layout } = claimed();
|
|
745
|
+
writeFileSync(join(user, ".profile"), "export FROM=profile\n");
|
|
746
|
+
const login = () => {
|
|
747
|
+
const out = spawnSync("/bin/bash", ["-l", "-c", "echo \"[$FROM] $PATH\""], { env: { HOME: user, PATH: "/usr/bin:/bin", SHELL: "/bin/bash" }, encoding: "utf8" }).stdout.trim();
|
|
748
|
+
return [out.slice(0, out.indexOf(" ")), out.slice(out.indexOf(" ") + 1)];
|
|
749
|
+
};
|
|
750
|
+
const plan = pathEntryPlan(layout, { platform: "darwin", env: { PATH: "/usr/bin:/bin", SHELL: "/bin/bash" }, home: user });
|
|
751
|
+
const receipt = applyPathEntry(plan);
|
|
752
|
+
// macOS's /etc/profile reorders PATH (path_helper): only the pieces are checked.
|
|
753
|
+
const [from, path] = login();
|
|
754
|
+
assert.deepEqual([from, path.split(":").includes(layout.bin)], ["[profile]", true]);
|
|
755
|
+
removePathEntry(receipt);
|
|
756
|
+
const [after, restored] = login();
|
|
757
|
+
assert.deepEqual([after, restored.split(":").includes(layout.bin)], ["[profile]", false]);
|
|
758
|
+
assert.deepEqual(readdirSync(user).filter((name) => name.startsWith(".bash")), []);
|
|
759
|
+
});
|
|
760
|
+
|
|
761
|
+
test("pathEntryPlan on Windows plans one HKCU Path entry applied through an injected adapter", () => {
|
|
762
|
+
const layout = prefixLayout({ platform: "win32", env: { LOCALAPPDATA: "C:\\L", USERPROFILE: "C:\\U" }, home: "C:\\U" });
|
|
763
|
+
const plan = pathEntryPlan(layout, { platform: "win32", env: { Path: "C:\\Windows" }, home: "C:\\U" });
|
|
764
|
+
assert.deepEqual(plan, { kind: "registry", key: "HKCU\\Environment", name: "Path", entry: "C:\\L\\gentle-shell\\bin" });
|
|
765
|
+
assert.deepEqual(pathEntryPlan(layout, { platform: "win32", env: { PATH: "C:\\Windows;c:\\l\\gentle-shell\\bin\\" }, home: "C:\\U" }), { kind: "none" });
|
|
766
|
+
const registry: string[] = [];
|
|
767
|
+
applyPathEntry(plan, { registry: { add: (entry: string) => registry.push(`+${entry}`), remove: (entry: string) => registry.push(`-${entry}`) } });
|
|
768
|
+
removePathEntry(plan, { registry: { add: () => {}, remove: (entry: string) => registry.push(`-${entry}`) } });
|
|
769
|
+
assert.deepEqual(registry, ["+C:\\L\\gentle-shell\\bin", "-C:\\L\\gentle-shell\\bin"]);
|
|
770
|
+
assert.throws(() => applyPathEntry(plan), /registry adapter/);
|
|
771
|
+
});
|
|
772
|
+
|
|
773
|
+
// T6 (S7): uninstall reverts exactly what was applied, and nothing when nothing was.
|
|
774
|
+
test("removePathEntry leaves the PATH alone for a receipt that applied nothing, and applyPathEntry applies nothing when the user Path already held the entry", () => {
|
|
775
|
+
const layout = prefixLayout({ platform: "win32", env: { LOCALAPPDATA: "C:\\L", USERPROFILE: "C:\\U" }, home: "C:\\U" });
|
|
776
|
+
const plan = pathEntryPlan(layout, { platform: "win32", env: { Path: "C:\\Windows" }, home: "C:\\U" });
|
|
777
|
+
const calls: string[] = [];
|
|
778
|
+
const registry = (added: string | undefined) => ({ add: (entry: string) => { calls.push(`+${entry}`); return added; }, remove: (entry: string) => { calls.push(`-${entry}`); } });
|
|
779
|
+
assert.deepEqual(applyPathEntry(plan, { registry: registry("present") }), { ...plan, applied: false });
|
|
780
|
+
assert.deepEqual(applyPathEntry(plan, { registry: registry("added") }), { ...plan, applied: true });
|
|
781
|
+
removePathEntry({ ...plan, applied: false }, { registry: registry(undefined) });
|
|
782
|
+
assert.deepEqual(calls, ["+C:\\L\\gentle-shell\\bin", "+C:\\L\\gentle-shell\\bin"]);
|
|
783
|
+
});
|
|
784
|
+
|
|
785
|
+
posixTest("removePathEntry keeps a profile line and a link that a receipt says were already there", () => {
|
|
786
|
+
const { home: user, layout } = claimed();
|
|
787
|
+
ensureLauncher(layout);
|
|
788
|
+
const zshrc = join(user, ".zshrc");
|
|
789
|
+
const plan = pathEntryPlan(layout, { platform: "darwin", env: { PATH: "/usr/bin", SHELL: "/bin/zsh" }, home: user });
|
|
790
|
+
writeFileSync(zshrc, `${plan.line}\n`);
|
|
791
|
+
const receipt = applyPathEntry({ ...plan, create: false });
|
|
792
|
+
assert.equal(receipt.applied, false);
|
|
793
|
+
removePathEntry(receipt);
|
|
794
|
+
assert.equal(readFileSync(zshrc, "utf8"), `${plan.line}\n`);
|
|
795
|
+
const link = join(user, ".local", "bin", "gentle-shell");
|
|
796
|
+
mkdirSync(dirname(link), { recursive: true });
|
|
797
|
+
symlinkSync(layout.launcher, link);
|
|
798
|
+
removePathEntry({ kind: "symlink", path: link, target: layout.launcher, applied: false });
|
|
799
|
+
assert.equal(readlinkSync(link), layout.launcher);
|
|
800
|
+
});
|
|
801
|
+
|
|
802
|
+
posixTest("writePathReceipt keeps only an applied receipt in the prefix, private and replaced whole, and readPathReceipt accepts only this install's entry", () => {
|
|
803
|
+
const { home: user, layout } = claimed();
|
|
804
|
+
const env = { SHELL: "/bin/zsh" };
|
|
805
|
+
const line = `export PATH="${layout.bin}:$PATH" # gentle-shell bundled install`;
|
|
806
|
+
const profile = { kind: "profile", path: join(user, ".zshrc"), line, create: true };
|
|
807
|
+
assert.equal(layout.pathReceipt, join(layout.root, "path-entry.json"));
|
|
808
|
+
assert.deepEqual(readPathReceipt(layout, { env, home: user }), { status: "none" });
|
|
809
|
+
assert.equal(writePathReceipt(layout, { ...profile, applied: false }), false);
|
|
810
|
+
assert.equal(existsSync(layout.pathReceipt), false);
|
|
811
|
+
assert.equal(writePathReceipt(layout, { ...profile, applied: true, separator: false }), true);
|
|
812
|
+
assert.equal(lstatSync(layout.pathReceipt).mode & 0o777, 0o600);
|
|
813
|
+
assert.deepEqual(JSON.parse(readFileSync(layout.pathReceipt, "utf8")), { schema: 1, ...profile, separator: false });
|
|
814
|
+
assert.deepEqual(readPathReceipt(layout, { env, home: user }), { status: "recorded", receipt: { ...profile, separator: false, applied: true } });
|
|
815
|
+
assert.match(describePathReceipt({ ...profile, separator: false, applied: true }), new RegExp(`${line.replace(/[$"]/g, "\\$&")}.*\\.zshrc`));
|
|
816
|
+
const link = { kind: "symlink", path: join(user, ".local", "bin", "gentle-shell"), target: layout.launcher };
|
|
817
|
+
writePathReceipt(layout, { ...link, applied: true });
|
|
818
|
+
assert.deepEqual(readPathReceipt(layout, { env, home: user }), { status: "recorded", receipt: { ...link, applied: true } });
|
|
819
|
+
assert.deepEqual(readdirSync(layout.root).filter((name) => name.includes("path-entry")), ["path-entry.json"]);
|
|
820
|
+
// Anything this prefix did not write is never acted on.
|
|
821
|
+
const foreign = [
|
|
822
|
+
{ ...link, target: "/usr/local/bin/gentle-shell" },
|
|
823
|
+
{ ...profile, line: "export PATH=\"/opt/x:$PATH\" # gentle-shell bundled install", separator: false },
|
|
824
|
+
{ ...profile, path: "relative/.zshrc", separator: false },
|
|
825
|
+
{ kind: "registry", key: "HKCU\\Environment", name: "Path", entry: "C:\\Windows" },
|
|
826
|
+
{ kind: "none" },
|
|
827
|
+
];
|
|
828
|
+
for (const receipt of foreign) {
|
|
829
|
+
writeFileSync(layout.pathReceipt, JSON.stringify({ schema: 1, ...receipt }));
|
|
830
|
+
assert.equal(readPathReceipt(layout, { env, home: user }).status, "invalid", JSON.stringify(receipt));
|
|
831
|
+
}
|
|
832
|
+
for (const text of ["{", JSON.stringify({ schema: 2, ...link }), "[]"]) {
|
|
833
|
+
writeFileSync(layout.pathReceipt, text);
|
|
834
|
+
assert.equal(readPathReceipt(layout, { env, home: user }).status, "invalid", text);
|
|
835
|
+
}
|
|
836
|
+
rmSync(layout.pathReceipt);
|
|
837
|
+
symlinkSync(join(user, ".zshrc"), layout.pathReceipt);
|
|
838
|
+
assert.equal(readPathReceipt(layout, { env, home: user }).status, "invalid");
|
|
839
|
+
});
|
|
840
|
+
|
|
841
|
+
// A1 (T6 verifier): a receipt is trusted only for a file or link the PATH plan
|
|
842
|
+
// itself can choose for this user; a forged one is named, never acted on.
|
|
843
|
+
posixTest("readPathReceipt accepts a profile line only in a startup file the PATH plan can choose, and a link only at ~/.local/bin/gentle-shell", () => {
|
|
844
|
+
const { home: user, layout } = claimed();
|
|
845
|
+
const { sh, fish } = { sh: `export PATH="${layout.bin}:$PATH" # gentle-shell bundled install`, fish: `set -gx PATH '${layout.bin}' $PATH # gentle-shell bundled install` };
|
|
846
|
+
const read = (receipt: Record<string, unknown>, env: Record<string, string> = {}, platform = "darwin") => {
|
|
847
|
+
writeFileSync(layout.pathReceipt, JSON.stringify({ schema: 1, create: false, separator: false, ...receipt }));
|
|
848
|
+
return readPathReceipt({ ...layout, platform }, { env, home: user }).status;
|
|
849
|
+
};
|
|
850
|
+
// Every file pathEntryPlan picks, per shell, on macOS and Linux.
|
|
851
|
+
for (const name of [".zshrc", ".bash_profile", ".bash_login", ".profile"]) assert.equal(read({ kind: "profile", path: join(user, name), line: sh }), "recorded", name);
|
|
852
|
+
assert.equal(read({ kind: "profile", path: join(user, ".bashrc"), line: sh }, {}, "linux"), "recorded");
|
|
853
|
+
assert.equal(read({ kind: "profile", path: join(user, ".config", "fish", "conf.d", "gentle-shell.fish"), line: fish }), "recorded");
|
|
854
|
+
// ZDOTDIR and XDG_CONFIG_HOME move the zsh and fish files exactly as the plan moves them.
|
|
855
|
+
const zdot = join(user, "zdot");
|
|
856
|
+
const xdg = join(user, "xdg");
|
|
857
|
+
assert.equal(read({ kind: "profile", path: join(zdot, ".zshrc"), line: sh }, { ZDOTDIR: zdot }), "recorded");
|
|
858
|
+
assert.equal(read({ kind: "profile", path: join(user, ".zshrc"), line: sh }, { ZDOTDIR: zdot }), "invalid");
|
|
859
|
+
assert.equal(read({ kind: "profile", path: join(xdg, "fish", "conf.d", "gentle-shell.fish"), line: fish }, { XDG_CONFIG_HOME: xdg }), "recorded");
|
|
860
|
+
// E1: our exact line, but in a file the plan never edits on this system; E2: a created file elsewhere.
|
|
861
|
+
assert.equal(read({ kind: "profile", path: join(user, ".bashrc"), line: sh }), "invalid");
|
|
862
|
+
assert.equal(read({ kind: "profile", path: join(user, "dotfiles", "path.sh"), line: sh, create: true }), "invalid");
|
|
863
|
+
assert.equal(read({ kind: "profile", path: join(user, ".zshenv"), line: sh }), "invalid");
|
|
864
|
+
// The fish line belongs only in the fish file, the sh line only in sh files.
|
|
865
|
+
assert.equal(read({ kind: "profile", path: join(user, ".zshrc"), line: fish }), "invalid");
|
|
866
|
+
assert.equal(read({ kind: "profile", path: join(user, ".config", "fish", "conf.d", "gentle-shell.fish"), line: sh }), "invalid");
|
|
867
|
+
// The link: only ~/.local/bin/gentle-shell to our launcher.
|
|
868
|
+
assert.equal(read({ kind: "symlink", path: join(user, ".local", "bin", "gentle-shell"), target: layout.launcher }), "recorded");
|
|
869
|
+
assert.equal(read({ kind: "symlink", path: join(user, "bin", "gentle-shell"), target: layout.launcher }), "invalid");
|
|
870
|
+
// The bundled self-uninstall, given the forged receipts, leaves both files as they are.
|
|
871
|
+
const bashrc = join(user, ".bashrc");
|
|
872
|
+
const dotfile = join(user, "dotfiles", "path.sh");
|
|
873
|
+
mkdirSync(dirname(dotfile), { recursive: true });
|
|
874
|
+
writeFileSync(bashrc, `alias l=ls\n${sh}\n`);
|
|
875
|
+
writeFileSync(dotfile, `${sh}\n`);
|
|
876
|
+
const forged = readPathReceipt(layout, { env: { SHELL: "/bin/zsh" }, home: user });
|
|
877
|
+
assert.equal(forged.status, "invalid");
|
|
878
|
+
assert.match(forged.status === "invalid" ? forged.reason : "", /does not describe this install's PATH entry/);
|
|
879
|
+
assert.deepEqual([readFileSync(bashrc, "utf8"), readFileSync(dotfile, "utf8")], [`alias l=ls\n${sh}\n`, `${sh}\n`]);
|
|
880
|
+
});
|
|
881
|
+
|
|
882
|
+
// A2: a line the user edited is kept, and the caller learns which file still holds what.
|
|
883
|
+
posixTest("removePathEntry reports a profile line it could not find and a link that no longer points at our launcher", () => {
|
|
884
|
+
const { home: user, layout } = claimed();
|
|
885
|
+
const zshrc = join(user, ".zshrc");
|
|
886
|
+
writeFileSync(zshrc, "alias x=y\n");
|
|
887
|
+
const receipt = applyPathEntry(pathEntryPlan(layout, { platform: "darwin", env: { PATH: "/usr/bin", SHELL: "/bin/zsh" }, home: user }));
|
|
888
|
+
const edited = `export PATH="$PATH:${layout.bin}" # gentle-shell bundled install`;
|
|
889
|
+
writeFileSync(zshrc, `alias x=y\n${edited}\n`);
|
|
890
|
+
const kept = removePathEntry(receipt);
|
|
891
|
+
assert.equal(readFileSync(zshrc, "utf8"), `alias x=y\n${edited}\n`);
|
|
892
|
+
assert.equal(kept.removed, false);
|
|
893
|
+
assert.ok(kept.note.includes(zshrc) && kept.note.includes(edited), kept.note);
|
|
894
|
+
writeFileSync(zshrc, `alias x=y\n${receipt.line}\n`);
|
|
895
|
+
assert.deepEqual(removePathEntry(receipt), { removed: true });
|
|
896
|
+
const link = join(user, ".local", "bin", "gentle-shell");
|
|
897
|
+
mkdirSync(dirname(link), { recursive: true });
|
|
898
|
+
symlinkSync("/usr/local/bin/other", link);
|
|
899
|
+
const other = removePathEntry({ kind: "symlink", path: link, target: layout.launcher, applied: true });
|
|
900
|
+
assert.equal(other.removed, false);
|
|
901
|
+
assert.ok(other.note.includes(link), other.note);
|
|
902
|
+
assert.equal(readlinkSync(link), "/usr/local/bin/other");
|
|
903
|
+
});
|
|
904
|
+
|
|
905
|
+
test("readPathReceipt accepts the Windows user Path entry of this prefix only", (t) => {
|
|
906
|
+
// Path arithmetic only: the receipt file itself lives in a real temporary folder.
|
|
907
|
+
const folder = home();
|
|
908
|
+
const layout = { ...prefixLayout({ platform: "win32", env: { LOCALAPPDATA: "C:\\L", USERPROFILE: "C:\\U" }, home: "C:\\U" }), pathReceipt: join(folder, "path-entry.json") };
|
|
909
|
+
const entry = { kind: "registry", key: "HKCU\\Environment", name: "Path", entry: layout.bin };
|
|
910
|
+
writeFileSync(layout.pathReceipt, JSON.stringify({ schema: 1, ...entry }));
|
|
911
|
+
assert.deepEqual(readPathReceipt(layout, { env: {}, home: "C:\\U" }), { status: "recorded", receipt: { ...entry, applied: true } });
|
|
912
|
+
assert.match(describePathReceipt({ ...entry, applied: true }), /C:\\L\\gentle-shell\\bin in your user Path/);
|
|
913
|
+
writeFileSync(layout.pathReceipt, JSON.stringify({ schema: 1, ...entry, key: "HKLM\\Environment" }));
|
|
914
|
+
assert.equal(readPathReceipt(layout, { env: {}, home: "C:\\U" }).status, "invalid");
|
|
915
|
+
t.diagnostic("simulated Windows layout, real receipt file");
|
|
916
|
+
});
|
|
917
|
+
|
|
918
|
+
// T6: the running node.exe and .cmd launcher cannot delete themselves; a detached
|
|
919
|
+
// Windows PowerShell removes them once this process exits, with every path as data.
|
|
920
|
+
test("scheduleWindowsRemoval starts one detached Windows PowerShell that waits for the process, with the paths only in its environment", () => {
|
|
921
|
+
const calls: { command: string; args: string[]; options: Record<string, any> }[] = [];
|
|
922
|
+
let unref = false;
|
|
923
|
+
const spawn = (command: string, args: string[], options: Record<string, any>) => { calls.push({ command, args, options }); return { unref: () => { unref = true; } }; };
|
|
924
|
+
const files = ["C:\\L\\gentle shell's $x;\\runtime\\node-24.21.0\\node.exe", "C:\\L\\gentle shell's $x;\\bin\\gentle-shell.cmd"];
|
|
925
|
+
const directories = ["C:\\L\\gentle shell's $x;\\runtime\\node-24.21.0", "C:\\L\\gentle shell's $x;"];
|
|
926
|
+
const last = "C:\\L\\gentle shell's $x;\\.gentle-shell-bundle";
|
|
927
|
+
scheduleWindowsRemoval({ pid: 4242, files, last, directories, env: { SystemRoot: "C:\\Windows", Path: "C:\\Windows" }, spawn });
|
|
928
|
+
assert.equal(calls.length, 1);
|
|
929
|
+
const [{ command, args, options }] = calls;
|
|
930
|
+
// cmd.exe, detached, starts PowerShell with `start`: a detached PowerShell never ran on
|
|
931
|
+
// the Windows runner, and one in Node's job object died with the exiting launcher.
|
|
932
|
+
assert.equal(command, "C:\\Windows\\System32\\cmd.exe");
|
|
933
|
+
assert.deepEqual(args.slice(0, 6), ["/d", "/c", "start", "\"\"", "/min", "C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe"]);
|
|
934
|
+
const shellArgs = args.slice(6);
|
|
935
|
+
// Only fixed tokens and base64 reach cmd's command line: no cmd metacharacter.
|
|
936
|
+
assert.equal(shellArgs.every((arg) => /^[-A-Za-z0-9+/=]+$/.test(arg)), true);
|
|
937
|
+
// The script travels base64-encoded (UTF-16LE): Windows PowerShell 5.1 does not
|
|
938
|
+
// read Node's escaped quotes in a -Command argument, so a plain script arrives broken.
|
|
939
|
+
assert.deepEqual(shellArgs.slice(0, 6), ["-NoLogo", "-NoProfile", "-NonInteractive", "-WindowStyle", "Hidden", "-EncodedCommand"]);
|
|
940
|
+
assert.equal(shellArgs.length, 7);
|
|
941
|
+
const script = Buffer.from(shellArgs[6], "base64").toString("utf16le");
|
|
942
|
+
assert.match(script, /Wait-Process -Id \$waitFor/);
|
|
943
|
+
assert.match(script, /GENTLE_SHELL_REMOVE_FILES/);
|
|
944
|
+
// A4: the marker goes only once every listed file is gone, so a retry still finds the install.
|
|
945
|
+
assert.match(script, /GENTLE_SHELL_REMOVE_LAST/);
|
|
946
|
+
assert.equal(options.env.GENTLE_SHELL_REMOVE_LAST, last);
|
|
947
|
+
for (const path of [...files, ...directories]) assert.equal(args.join(" ").includes(path), false, path);
|
|
948
|
+
assert.deepEqual([options.detached, options.stdio, options.windowsHide, options.cwd, unref], [true, "ignore", true, "C:\\Windows", true]);
|
|
949
|
+
assert.deepEqual([options.env.GENTLE_SHELL_REMOVE_AFTER_PID, options.env.GENTLE_SHELL_REMOVE_FILES, options.env.GENTLE_SHELL_REMOVE_DIRECTORIES, options.env.Path],
|
|
950
|
+
["4242", files.join("\n"), directories.join("\n"), "C:\\Windows"]);
|
|
951
|
+
for (const bad of [{ pid: 0 }, { files: ["relative\\node.exe"] }, { directories: ["\\\\server\\share"] }, { env: {} }, { last: "relative\\marker" },
|
|
952
|
+
// SystemRoot reaches cmd's command line (the PowerShell path): a cmd metacharacter refuses.
|
|
953
|
+
{ env: { SystemRoot: "C:\\Win&calc" } }, { env: { SystemRoot: "C:\\Win%PATH%" } }, { env: { SystemRoot: "C:\\Win^x" } }, { env: { SystemRoot: "C:\\Win\"x" } }]) {
|
|
954
|
+
assert.throws(() => scheduleWindowsRemoval({ pid: 4242, files, directories, env: { SystemRoot: "C:\\Windows" }, spawn, ...bad }), /deferred removal/, JSON.stringify(bad));
|
|
955
|
+
}
|
|
956
|
+
assert.equal(calls.length, 1);
|
|
957
|
+
});
|
|
958
|
+
|
|
959
|
+
// Rule 6 (T1c): Pi installs its packages with npm, so our npm comes from the same verified Node archive.
|
|
960
|
+
test("nodeRuntimeFiles takes node.exe, npm and its .cmd shims from the Windows zip, and node plus npm with our wrappers from the tarball", () => {
|
|
961
|
+
const stem = `node-v${NODE}-win-x64`;
|
|
962
|
+
const descriptor = { url: `https://nodejs.org/dist/v${NODE}/${stem}.zip` };
|
|
963
|
+
const archive = zip([[`${stem}/node.exe`, Buffer.from("MZ")], [`${stem}/npm.cmd`, Buffer.from("npm cmd")], [`${stem}/npx.cmd`, Buffer.from("npx cmd")],
|
|
964
|
+
[`${stem}/npm.ps1`, Buffer.from("ps")], [`${stem}/corepack.cmd`, Buffer.from("corepack")], [`${stem}/node_modules/npm/`, Buffer.alloc(0)],
|
|
965
|
+
[`${stem}/node_modules/npm/package.json`, Buffer.from(JSON.stringify({ name: "npm", version: NPM }))],
|
|
966
|
+
[`${stem}/node_modules/npm/bin/npm-cli.js`, Buffer.from("cli")], [`${stem}/node_modules/corepack/package.json`, Buffer.from("{}")]]);
|
|
967
|
+
const files = nodeRuntimeFiles(archive, descriptor);
|
|
968
|
+
assert.deepEqual(files.map((file: { name: string }) => file.name).sort(),
|
|
969
|
+
["node.exe", "node_modules/npm/bin/npm-cli.js", "node_modules/npm/package.json", "npm.cmd", "npx.cmd"]);
|
|
970
|
+
assert.equal(files.npm, NPM);
|
|
971
|
+
// No npm in the archive, or a member escaping the runtime folder, rejects the whole archive.
|
|
972
|
+
assert.throws(() => nodeRuntimeFiles(zip([[`${stem}/node.exe`, Buffer.from("MZ")]]), descriptor), /Node archive/);
|
|
973
|
+
assert.throws(() => nodeRuntimeFiles(zip([[`${stem}/node.exe`, Buffer.from("MZ")], [`${stem}/node_modules/npm/package.json`, Buffer.from(JSON.stringify({ name: "npm", version: NPM }))],
|
|
974
|
+
[`${stem}/node_modules/npm/bin/npm-cli.js`, Buffer.from("cli")], [`${stem}/node_modules/npm/../../evil.js`, Buffer.from("x")]]), descriptor), /Node archive/);
|
|
975
|
+
const tarball = nodeRuntimeFiles(nodeArchive, { url: `https://nodejs.org/dist/v${NODE}/${nodeStem}.tar.gz` });
|
|
976
|
+
assert.deepEqual(tarball.map((file: { name: string }) => file.name).sort(), ["bin/node", "bin/npm", "bin/npx", "lib/node_modules/npm/bin/npm-cli.js",
|
|
977
|
+
"lib/node_modules/npm/bin/npx-cli.js", "lib/node_modules/npm/package.json"]);
|
|
978
|
+
assert.deepEqual(tarball.filter((file: { executable: boolean }) => file.executable).map((file: { name: string }) => file.name).sort(),
|
|
979
|
+
["bin/node", "bin/npm", "bin/npx", "lib/node_modules/npm/bin/npm-cli.js", "lib/node_modules/npm/bin/npx-cli.js"]);
|
|
980
|
+
});
|
|
981
|
+
|
|
982
|
+
// S12: only the network, registry and authentication keys of the user's npmrc.
|
|
983
|
+
test("filterNpmrc keeps registries, per-host credentials, proxies and TLS keys and drops everything else", () => {
|
|
984
|
+
const text = [
|
|
985
|
+
"; a comment", "# another", "registry=https://registry.example.com/", "@acme:registry = https://npm.acme.dev/",
|
|
986
|
+
"//npm.acme.dev/:_authToken=${ACME_TOKEN}", "//registry.example.com/:_auth=dXNlcjpwYXNz", "//host:8443/path/:username=me",
|
|
987
|
+
"//host:8443/path/:_password=c2VjcmV0", "//host:8443/path/:certfile=/certs/me.pem", "//host:8443/path/:keyfile=/certs/me.key",
|
|
988
|
+
"proxy=http://proxy:3128", "https-proxy=http://proxy:3128", "noproxy=localhost", "no-proxy=.internal", "ca=\"-----BEGIN CERTIFICATE-----\"",
|
|
989
|
+
"ca[]=second", "cafile=/certs/ca.pem", "strict-ssl=false", "\"//quoted.example/:_authToken\"=tok",
|
|
990
|
+
"prefix=/home/me/.npm-global", "cache=/tmp/cache", "ignore-scripts=true", "save-exact=true", "_authToken=legacy", "//h/:always-auth=true",
|
|
991
|
+
"node-options=--require /x.js", "script-shell=/bin/zsh", "global=true", "not a key", "=value", "",
|
|
992
|
+
"[section]", "registry=https://inside-a-section.example/",
|
|
993
|
+
].join("\r\n");
|
|
994
|
+
assert.equal(filterNpmrc(text), [
|
|
995
|
+
"registry=https://registry.example.com/", "@acme:registry = https://npm.acme.dev/", "//npm.acme.dev/:_authToken=${ACME_TOKEN}",
|
|
996
|
+
"//registry.example.com/:_auth=dXNlcjpwYXNz", "//host:8443/path/:username=me", "//host:8443/path/:_password=c2VjcmV0",
|
|
997
|
+
"//host:8443/path/:certfile=/certs/me.pem", "//host:8443/path/:keyfile=/certs/me.key", "proxy=http://proxy:3128", "https-proxy=http://proxy:3128",
|
|
998
|
+
"noproxy=localhost", "no-proxy=.internal", "ca=\"-----BEGIN CERTIFICATE-----\"", "ca[]=second", "cafile=/certs/ca.pem", "strict-ssl=false",
|
|
999
|
+
"\"//quoted.example/:_authToken\"=tok", "",
|
|
1000
|
+
].join("\n"));
|
|
1001
|
+
assert.equal(filterNpmrc(""), "");
|
|
1002
|
+
assert.equal(filterNpmrc("save-exact=true\n"), "");
|
|
1003
|
+
});
|
|
1004
|
+
|
|
1005
|
+
posixTest("writeNpmrcAuth writes the filtered npmrc 0600 inside the prefix; userNpmrc reads NPM_CONFIG_USERCONFIG, then ~/.npmrc", () => {
|
|
1006
|
+
const { home: user, layout } = claimed();
|
|
1007
|
+
assert.equal(userNpmrc({ platform: "darwin", env: {}, home: user }), "");
|
|
1008
|
+
writeFileSync(join(user, ".npmrc"), "registry=https://home.example/\nsave-exact=true\n");
|
|
1009
|
+
const custom = join(user, "custom.npmrc");
|
|
1010
|
+
writeFileSync(custom, "registry=https://custom.example/\n");
|
|
1011
|
+
assert.equal(userNpmrc({ platform: "darwin", env: {}, home: user }), "registry=https://home.example/\nsave-exact=true\n");
|
|
1012
|
+
assert.equal(userNpmrc({ platform: "darwin", env: { npm_config_userconfig: custom }, home: user }), "registry=https://custom.example/\n");
|
|
1013
|
+
assert.equal(userNpmrc({ platform: "darwin", env: { NPM_CONFIG_USERCONFIG: custom }, home: user }), "registry=https://custom.example/\n");
|
|
1014
|
+
writeNpmrcAuth(layout, userNpmrc({ platform: "darwin", env: {}, home: user }));
|
|
1015
|
+
assert.equal(readFileSync(layout.npmrc, "utf8"), "registry=https://home.example/\n");
|
|
1016
|
+
assert.equal(lstatSync(layout.npmrc).mode & 0o777, 0o600);
|
|
1017
|
+
writeNpmrcAuth(layout, "");
|
|
1018
|
+
assert.equal(readFileSync(layout.npmrc, "utf8"), "");
|
|
1019
|
+
});
|
|
1020
|
+
|
|
1021
|
+
test("pnpmEnvironment keeps the variables the prefix npmrc references and still drops npm settings", () => {
|
|
1022
|
+
const user = home();
|
|
1023
|
+
const layout = prefixLayout({ platform: "linux", env: {}, home: user });
|
|
1024
|
+
mkdirSync(join(layout.root, "pnpm", "config"), { recursive: true });
|
|
1025
|
+
writeFileSync(layout.npmrc, "//npm.acme.dev/:_authToken=${NPM_TOKEN}\n//h/:_auth=${npm_config_x}\n");
|
|
1026
|
+
const env = pnpmEnvironment(layout, { platform: "linux", env: { PATH: "/usr/bin", NPM_TOKEN: "secret", NPM_OTHER: "o", npm_config_x: "x",
|
|
1027
|
+
npm_config_cache: "/home/me/.npm" } });
|
|
1028
|
+
// ${NPM_TOKEN} is expanded by pnpm from the environment, so it survives; npm settings and unreferenced npm_* never do.
|
|
1029
|
+
assert.equal(env.NPM_TOKEN, "secret");
|
|
1030
|
+
for (const key of ["NPM_OTHER", "npm_config_x", "npm_config_cache", "npm_config_userconfig", "npm_config_prefix"]) assert.equal(env[key], undefined, key);
|
|
1031
|
+
});
|
|
1032
|
+
|
|
1033
|
+
test("setupEnvironment runs setup with our Node and npm, then the version's Pi, and keeps the user's XDG folders", () => {
|
|
1034
|
+
const layout = prefixLayout({ platform: "linux", env: {}, home: "/home/me" });
|
|
1035
|
+
const env = setupEnvironment(layout, { platform: "linux", id: "4.1.0-1.0.2", env: { PATH: "/usr/local/bin:/usr/bin", HOME: "/home/me",
|
|
1036
|
+
XDG_CONFIG_HOME: "/home/me/.config", npm_config_registry: "https://user.example", NODE_OPTIONS: "--require /x.js", PNPM_HOME: "/p",
|
|
1037
|
+
GENTLE_BOOTSTRAP_TOOLS: "/t", GENTLE_INSTALL_PNPM_ENTRY: "/e", NPM_TOKEN: "t" } });
|
|
1038
|
+
const root = "/home/me/.gentle-shell";
|
|
1039
|
+
assert.equal(env.PATH, `${root}/runtime/node-${NODE}/bin:${root}/versions/4.1.0-1.0.2/node_modules/.bin:/usr/local/bin:/usr/bin`);
|
|
1040
|
+
assert.equal(env.XDG_CONFIG_HOME, "/home/me/.config");
|
|
1041
|
+
// npm reads the filtered prefix npmrc and writes its global prefix and cache inside the prefix.
|
|
1042
|
+
assert.equal(env.npm_config_userconfig, `${root}/pnpm/config/npmrc`);
|
|
1043
|
+
assert.equal(env.npm_config_globalconfig, `${root}/npm/npmrc`);
|
|
1044
|
+
assert.equal(env.npm_config_prefix, `${root}/npm/prefix`);
|
|
1045
|
+
assert.equal(env.npm_config_cache, `${root}/npm/cache`);
|
|
1046
|
+
assert.equal(env.npm_config_update_notifier, "false");
|
|
1047
|
+
for (const key of ["npm_config_registry", "NODE_OPTIONS", "PNPM_HOME", "GENTLE_BOOTSTRAP_TOOLS", "GENTLE_INSTALL_PNPM_ENTRY"]) assert.equal(env[key], undefined, key);
|
|
1048
|
+
assert.equal(env.NPM_TOKEN, "t");
|
|
1049
|
+
assert.equal(env.HOME, "/home/me");
|
|
1050
|
+
const windows = setupEnvironment(prefixLayout({ platform: "win32", env: { LOCALAPPDATA: "C:\\L" }, home: "C:\\U" }),
|
|
1051
|
+
{ platform: "win32", id: "4.1.0-1.0.2", env: { Path: "C:\\Windows" } });
|
|
1052
|
+
assert.equal(windows.Path, `C:\\L\\gentle-shell\\runtime\\node-${NODE};C:\\L\\gentle-shell\\versions\\4.1.0-1.0.2\\node_modules\\.bin;C:\\Windows`);
|
|
1053
|
+
assert.throws(() => setupEnvironment(layout, { platform: "linux", id: "../x", env: {} }), /Unsafe version id/);
|
|
1054
|
+
});
|
|
1055
|
+
|
|
1056
|
+
// T2 (S5, S6, S8): `gentle-shell upgrade` in a bundled install.
|
|
1057
|
+
const LATEST = { shell: "4.2.0", pi: "1.0.2" };
|
|
1058
|
+
const releaseModuleFile = (layout: ReturnType<typeof prefixLayout>, id: string) => join(layout.versions, id, "node_modules", "gentle-pi", "scripts", "bundled-install.mjs");
|
|
1059
|
+
function upgradeAdapters(layout: ReturnType<typeof prefixLayout>, { release = LATEST, status = "published", reported = release, module = {} as Record<string, unknown> } = {}) {
|
|
1060
|
+
const calls: { ensureRuntime: unknown[]; verify: { command: string; args: string[]; env: Record<string, string> }[]; loaded: string[]; launchers: unknown[] } =
|
|
1061
|
+
{ ensureRuntime: [], verify: [], loaded: [], launchers: [] };
|
|
1062
|
+
const releaseModule = { prefixLayout, ensureRuntime: async (options: unknown) => { calls.ensureRuntime.push(options); return { go: null }; },
|
|
1063
|
+
ensureLauncher: (next: unknown) => { calls.launchers.push(next); return { written: false }; }, ...module };
|
|
1064
|
+
const adapters = {
|
|
1065
|
+
latest: async () => release.shell,
|
|
1066
|
+
distribution: async (version: string) => (status === "published" ? { status, manifest: { ...release, shell: version }, lockfile: "lockfileVersion: '9.0'\n" } : { status }),
|
|
1067
|
+
ensureRuntime: async (options: unknown) => { calls.ensureRuntime.push(options); return { go: null }; },
|
|
1068
|
+
install: { run: fakePnpm(release).run },
|
|
1069
|
+
load: async (file: string) => { calls.loaded.push(file); return releaseModule; },
|
|
1070
|
+
run: async (command: string, args: string[], options: { env: Record<string, string> }) => {
|
|
1071
|
+
calls.verify.push({ command, args, env: options.env });
|
|
1072
|
+
return { status: 0, stdout: `gentle-shell ${reported.shell}\npi ${reported.pi}\nhome isolated /x\n`, stderr: "" };
|
|
1073
|
+
},
|
|
1074
|
+
};
|
|
1075
|
+
return { adapters, calls };
|
|
1076
|
+
}
|
|
1077
|
+
async function activeInstall() {
|
|
1078
|
+
const { home: user, layout } = claimed();
|
|
1079
|
+
await installed(layout, [["4.0.0", "1.0.0"], ["4.1.0", "1.0.1"]]);
|
|
1080
|
+
for (const id of ["4.0.0-1.0.0", "4.1.0-1.0.1"]) activateVersion(layout, id);
|
|
1081
|
+
return { user, layout };
|
|
1082
|
+
}
|
|
1083
|
+
|
|
1084
|
+
posixTest("upgradeBundled installs the latest release beside the active one, verifies it, switches current and keeps two", async () => {
|
|
1085
|
+
const { user, layout } = await activeInstall();
|
|
1086
|
+
const { adapters, calls } = upgradeAdapters(layout);
|
|
1087
|
+
const lines: string[] = [];
|
|
1088
|
+
const result = await upgradeBundled({ layout, home: user, platform: "darwin", arch: "arm64", env: { PATH: "/usr/bin", GENTLE_SHELL_PI: "/x/pi", PI_CODING_AGENT_DIR: "/x" },
|
|
1089
|
+
adapters, out: (line: string) => lines.push(line) });
|
|
1090
|
+
assert.deepEqual(result, { id: "4.2.0-1.0.2", changed: true, previous: "4.1.0-1.0.1", removed: ["4.0.0-1.0.0"] });
|
|
1091
|
+
assert.equal(activeVersion(layout), "4.2.0-1.0.2");
|
|
1092
|
+
assert.deepEqual(readdirSync(layout.versions).sort(), ["4.1.0-1.0.1", "4.2.0-1.0.2"]);
|
|
1093
|
+
// Only the freshly installed version's own module is loaded.
|
|
1094
|
+
assert.deepEqual(calls.loaded, [releaseModuleFile(layout, "4.2.0-1.0.2")]);
|
|
1095
|
+
const [verify] = calls.verify;
|
|
1096
|
+
assert.equal(verify.command, layout.node);
|
|
1097
|
+
assert.deepEqual(verify.args, [join(layout.versions, "4.2.0-1.0.2", "node_modules", "gentle-pi", "bin", "gentle-shell.mjs"), "--version"]);
|
|
1098
|
+
assert.equal(verify.env.GENTLE_SHELL_PI, undefined);
|
|
1099
|
+
assert.equal(verify.env.PI_CODING_AGENT_DIR, undefined);
|
|
1100
|
+
// The check runs that version in bundled mode, as if current named it (only for --version).
|
|
1101
|
+
assert.equal(verify.env.GENTLE_SHELL_VERIFY_VERSION, "4.2.0-1.0.2");
|
|
1102
|
+
assert.match(lines.join("\n"), /Gentle Shell 4\.2\.0 with Pi 1\.0\.2.*4\.1\.0-1\.0\.1.*--rollback/s);
|
|
1103
|
+
});
|
|
1104
|
+
|
|
1105
|
+
posixTest("upgradeBundled changes nothing when the latest release is already current", async () => {
|
|
1106
|
+
const { user, layout } = await activeInstall();
|
|
1107
|
+
const { adapters, calls } = upgradeAdapters(layout, { release: { shell: "4.1.0", pi: "1.0.1" } });
|
|
1108
|
+
const lines: string[] = [];
|
|
1109
|
+
const result = await upgradeBundled({ layout, home: user, platform: "darwin", arch: "arm64", env: {}, adapters, out: (line: string) => lines.push(line) });
|
|
1110
|
+
assert.deepEqual(result, { id: "4.1.0-1.0.1", changed: false });
|
|
1111
|
+
assert.deepEqual([calls.ensureRuntime.length, calls.verify.length, calls.loaded.length], [0, 0, 0]);
|
|
1112
|
+
assert.match(lines.join("\n"), /Gentle Shell 4\.1\.0 with Pi 1\.0\.1 is already current/);
|
|
1113
|
+
assert.deepEqual(readdirSync(layout.versions).sort(), ["4.0.0-1.0.0", "4.1.0-1.0.1"]);
|
|
1114
|
+
});
|
|
1115
|
+
|
|
1116
|
+
posixTest("upgradeBundled leaves current untouched when the release is unpublished or its verification fails", async () => {
|
|
1117
|
+
const { user, layout } = await activeInstall();
|
|
1118
|
+
for (const status of ["missing", "unavailable", "rejected"]) {
|
|
1119
|
+
await assert.rejects(upgradeBundled({ layout, home: user, platform: "darwin", arch: "arm64", env: {}, adapters: upgradeAdapters(layout, { status }).adapters }), /v4\.2\.0/);
|
|
1120
|
+
}
|
|
1121
|
+
const wrong = upgradeAdapters(layout, { reported: { shell: "4.2.0", pi: "1.0.0" } });
|
|
1122
|
+
await assert.rejects(upgradeBundled({ layout, home: user, platform: "darwin", arch: "arm64", env: {}, adapters: wrong.adapters }), /reported/);
|
|
1123
|
+
assert.equal(activeVersion(layout), "4.1.0-1.0.1");
|
|
1124
|
+
// The version this upgrade installed and could not verify is removed again.
|
|
1125
|
+
assert.deepEqual(readdirSync(layout.versions).sort(), ["4.0.0-1.0.0", "4.1.0-1.0.1"]);
|
|
1126
|
+
});
|
|
1127
|
+
|
|
1128
|
+
posixTest("upgradeBundled fails closed when the new version's own module is missing or lacks an export it needs", async () => {
|
|
1129
|
+
const { user, layout } = await activeInstall();
|
|
1130
|
+
for (const module of [{ ensureLauncher: undefined }, { prefixLayout: "not a function" }]) {
|
|
1131
|
+
const { adapters, calls } = upgradeAdapters(layout, { module });
|
|
1132
|
+
await assert.rejects(upgradeBundled({ layout, home: user, platform: "darwin", arch: "arm64", env: {}, adapters }), /4\.2\.0-1\.0\.2.*bundled-install\.mjs/s);
|
|
1133
|
+
assert.equal(calls.verify.length, 0);
|
|
1134
|
+
}
|
|
1135
|
+
const absent = upgradeAdapters(layout);
|
|
1136
|
+
await assert.rejects(upgradeBundled({ layout, home: user, platform: "darwin", arch: "arm64", env: {}, adapters: { ...absent.adapters, load: undefined } }), /bundled-install\.mjs/);
|
|
1137
|
+
assert.equal(activeVersion(layout), "4.1.0-1.0.1");
|
|
1138
|
+
assert.deepEqual(readdirSync(layout.versions).sort(), ["4.0.0-1.0.0", "4.1.0-1.0.1"]);
|
|
1139
|
+
});
|
|
1140
|
+
|
|
1141
|
+
posixTest("upgradeBundled installs a new release's own pinned runtime and launcher when its pins moved", async () => {
|
|
1142
|
+
const { user, layout } = await activeInstall();
|
|
1143
|
+
const next = { ...layout, node: join(layout.runtime, "node-26.0.0", "bin", "node") };
|
|
1144
|
+
const { adapters, calls } = upgradeAdapters(layout, { module: { prefixLayout: (options: { root: string; home: string }) => {
|
|
1145
|
+
assert.deepEqual([options.root, options.home], [layout.root, user]);
|
|
1146
|
+
return next;
|
|
1147
|
+
} } });
|
|
1148
|
+
await upgradeBundled({ layout, home: user, platform: "darwin", arch: "arm64", env: {}, adapters });
|
|
1149
|
+
// Ours first (the install runs on it), then the release's own, before verification runs on its Node.
|
|
1150
|
+
assert.equal(calls.ensureRuntime.length, 2);
|
|
1151
|
+
assert.equal((calls.ensureRuntime[1] as { layout: unknown }).layout, next);
|
|
1152
|
+
// The release's own runtime includes its pinned Engram (S9); an older release module ignores the flag.
|
|
1153
|
+
assert.deepEqual(calls.ensureRuntime.map((options) => (options as { engram?: boolean }).engram), [undefined, true]);
|
|
1154
|
+
assert.equal(calls.verify[0].command, next.node);
|
|
1155
|
+
assert.deepEqual(calls.launchers, [next]);
|
|
1156
|
+
assert.equal(activeVersion(layout), "4.2.0-1.0.2");
|
|
1157
|
+
});
|
|
1158
|
+
|
|
1159
|
+
posixTest("upgradeBundled puts current back when the new launcher cannot be written", async () => {
|
|
1160
|
+
const { user, layout } = await activeInstall();
|
|
1161
|
+
const { adapters } = upgradeAdapters(layout, { module: { ensureLauncher: () => { throw new Error("Conflicting launcher: /x"); } } });
|
|
1162
|
+
await assert.rejects(upgradeBundled({ layout, home: user, platform: "darwin", arch: "arm64", env: {}, adapters }), /launcher.*current stays at 4\.1\.0-1\.0\.1/s);
|
|
1163
|
+
assert.equal(activeVersion(layout), "4.1.0-1.0.1");
|
|
1164
|
+
assert.deepEqual(readdirSync(layout.versions).sort(), ["4.0.0-1.0.0", "4.1.0-1.0.1"]);
|
|
1165
|
+
});
|
|
1166
|
+
|
|
1167
|
+
posixTest("upgradeBundled reports success with a warning when only pruning fails", async () => {
|
|
1168
|
+
const { user, layout } = await activeInstall();
|
|
1169
|
+
const { adapters } = upgradeAdapters(layout);
|
|
1170
|
+
const lines: string[] = [];
|
|
1171
|
+
const result = await upgradeBundled({ layout, home: user, platform: "darwin", arch: "arm64", env: {}, adapters: { ...adapters, prune: () => { throw new Error("EBUSY"); } },
|
|
1172
|
+
out: (line: string) => lines.push(line) });
|
|
1173
|
+
assert.deepEqual(result, { id: "4.2.0-1.0.2", changed: true, previous: "4.1.0-1.0.1", removed: [], warning: "older versions were not removed (EBUSY)" });
|
|
1174
|
+
assert.equal(activeVersion(layout), "4.2.0-1.0.2");
|
|
1175
|
+
assert.match(lines.join("\n"), /Upgraded to Gentle Shell 4\.2\.0.*warning: older versions were not removed \(EBUSY\)/s);
|
|
1176
|
+
});
|
|
1177
|
+
|
|
1178
|
+
function rollbackAdapters(module: Record<string, unknown> = {}) {
|
|
1179
|
+
const calls: { loaded: string[]; runtimes: unknown[]; engram: unknown[]; launchers: unknown[] } = { loaded: [], runtimes: [], engram: [], launchers: [] };
|
|
1180
|
+
const releaseModule = { prefixLayout, ensureRuntime: async (options: { layout: unknown; engram?: boolean }) => {
|
|
1181
|
+
calls.runtimes.push(options.layout); calls.engram.push(options.engram); return { go: null }; },
|
|
1182
|
+
ensureLauncher: (next: unknown) => { calls.launchers.push(next); return { written: true }; }, ...module };
|
|
1183
|
+
return { calls, adapters: { load: async (file: string) => { calls.loaded.push(file); return releaseModule; } } };
|
|
1184
|
+
}
|
|
1185
|
+
|
|
1186
|
+
posixTest("rollbackBundled switches current to the kept previous version with its own launcher, and back", async () => {
|
|
1187
|
+
const { user, layout } = await activeInstall();
|
|
1188
|
+
const { calls, adapters } = rollbackAdapters();
|
|
1189
|
+
const options = { layout, home: user, platform: "darwin", arch: "arm64", env: {}, adapters };
|
|
1190
|
+
assert.deepEqual(await rollbackBundled(options), { from: "4.1.0-1.0.1", to: "4.0.0-1.0.0" });
|
|
1191
|
+
assert.equal(activeVersion(layout), "4.0.0-1.0.0");
|
|
1192
|
+
assert.deepEqual(calls.loaded, [releaseModuleFile(layout, "4.0.0-1.0.0")]);
|
|
1193
|
+
assert.equal(calls.runtimes.length, 1);
|
|
1194
|
+
assert.equal(calls.engram[0], true, "the target's runtime includes its pinned Engram");
|
|
1195
|
+
assert.equal(calls.launchers.length, 1);
|
|
1196
|
+
assert.deepEqual(await rollbackBundled(options), { from: "4.0.0-1.0.0", to: "4.1.0-1.0.1" });
|
|
1197
|
+
assert.equal(calls.loaded[1], releaseModuleFile(layout, "4.1.0-1.0.1"));
|
|
1198
|
+
const single = claimed();
|
|
1199
|
+
await installed(single.layout, [["4.0.0", "1.0.0"]]);
|
|
1200
|
+
activateVersion(single.layout, "4.0.0-1.0.0");
|
|
1201
|
+
await assert.rejects(rollbackBundled({ ...options, layout: single.layout, home: single.home }), /No previous Gentle Shell version/);
|
|
1202
|
+
assert.equal(activeVersion(single.layout), "4.0.0-1.0.0");
|
|
1203
|
+
});
|
|
1204
|
+
|
|
1205
|
+
posixTest("rollbackBundled fails closed without the target's own module and puts current back when its launcher fails", async () => {
|
|
1206
|
+
const { user, layout } = await activeInstall();
|
|
1207
|
+
for (const module of [{ ensureLauncher: undefined }, { ensureRuntime: 1 }]) {
|
|
1208
|
+
const { calls, adapters } = rollbackAdapters(module);
|
|
1209
|
+
await assert.rejects(rollbackBundled({ layout, home: user, platform: "darwin", arch: "arm64", env: {}, adapters }), /4\.0\.0-1\.0\.0.*bundled-install\.mjs/s);
|
|
1210
|
+
assert.equal(calls.launchers.length, 0);
|
|
1211
|
+
assert.equal(activeVersion(layout), "4.1.0-1.0.1");
|
|
1212
|
+
}
|
|
1213
|
+
// The real loader: these fixture versions ship no module at all.
|
|
1214
|
+
await assert.rejects(rollbackBundled({ layout, home: user, platform: "darwin", arch: "arm64", env: {} }), /bundled-install\.mjs/);
|
|
1215
|
+
const failing = rollbackAdapters({ ensureLauncher: () => { throw new Error("Conflicting launcher: /x"); } });
|
|
1216
|
+
await assert.rejects(rollbackBundled({ layout, home: user, platform: "darwin", arch: "arm64", env: {}, adapters: failing.adapters }), /launcher.*current stays at 4\.1\.0-1\.0\.1/s);
|
|
1217
|
+
assert.equal(activeVersion(layout), "4.1.0-1.0.1");
|
|
1218
|
+
assert.deepEqual(readdirSync(layout.versions).sort(), ["4.0.0-1.0.0", "4.1.0-1.0.1"]);
|
|
1219
|
+
});
|
|
1220
|
+
|
|
1221
|
+
test("bundledNpmCommand runs our npm through an entry named npm, with only the prefix configuration", () => {
|
|
1222
|
+
const layout = prefixLayout({ platform: "linux", env: {}, home: "/home/me" });
|
|
1223
|
+
const root = "/home/me/.gentle-shell";
|
|
1224
|
+
assert.deepEqual(bundledNpmCommand(layout), [`${root}/runtime/node-${NODE}/bin/node`, `${root}/runtime/node-${NODE}/gentle-shell/npm`,
|
|
1225
|
+
`--userconfig=${root}/pnpm/config/npmrc`, `--globalconfig=${root}/npm/npmrc`, `--cache=${root}/npm/cache`, "--update-notifier=false"]);
|
|
1226
|
+
// A --prefix flag would also set npm's local prefix: Pi's git-extension
|
|
1227
|
+
// install (npm install in the clone, no --prefix of its own) would land there.
|
|
1228
|
+
assert.equal(bundledNpmCommand(layout).some((part) => /^--prefix(?:=|$)/.test(part)), false);
|
|
1229
|
+
const windows = bundledNpmCommand(prefixLayout({ platform: "win32", env: { LOCALAPPDATA: "C:\\L" }, home: "C:\\U" }));
|
|
1230
|
+
assert.deepEqual(windows.slice(0, 2), [`C:\\L\\gentle-shell\\runtime\\node-${NODE}\\node.exe`, `C:\\L\\gentle-shell\\runtime\\node-${NODE}\\gentle-shell\\npm`]);
|
|
1231
|
+
});
|
|
1232
|
+
|
|
1233
|
+
// N1: Pi 1.1.0 names the package manager from the basenames in npmCommand
|
|
1234
|
+
// (DefaultPackageManager.getPackageManagerName) and picks its install flags from
|
|
1235
|
+
// that name. Its own code, on this host's paths.
|
|
1236
|
+
const PI_PACKAGE_MANAGER = new URL("../node_modules/@earendil-works/pi-coding-agent/dist/core/package-manager.js", import.meta.url);
|
|
1237
|
+
async function piPackageManager(command: string[]) {
|
|
1238
|
+
const { DefaultPackageManager } = await import(PI_PACKAGE_MANAGER.href);
|
|
1239
|
+
return new DefaultPackageManager({ cwd: tmpdir(), agentDir: tmpdir(), settingsManager: { getNpmCommand: () => command } });
|
|
1240
|
+
}
|
|
1241
|
+
// The same rule with Windows paths, on any runner: Pi calls node:path's
|
|
1242
|
+
// basename, which is win32's on Windows, and strips only .cmd and .exe.
|
|
1243
|
+
function windowsPackageManagerName(command: string[]) {
|
|
1244
|
+
const name = (part: string) => win32.basename(part).replace(/\.(cmd|exe)$/i, "");
|
|
1245
|
+
const supported = new Set(["npm", "pnpm", "bun"]);
|
|
1246
|
+
if (supported.has(name(command[0]))) return name(command[0]);
|
|
1247
|
+
const wrapped = [...new Set(command.slice(1).map(name).filter((part) => supported.has(part)))];
|
|
1248
|
+
if (wrapped.length > 1) throw new Error(`Ambiguous npmCommand package managers: ${wrapped.join(", ")}`);
|
|
1249
|
+
return wrapped[0] ?? name(command[0]);
|
|
1250
|
+
}
|
|
1251
|
+
|
|
1252
|
+
test("Pi reads the bundled npmCommand as npm and installs git extensions with npm's flags", async () => {
|
|
1253
|
+
const host = process.platform === "win32"
|
|
1254
|
+
? prefixLayout({ platform: "win32", env: { LOCALAPPDATA: "C:\\Users\\Ana Li\\AppData\\Local" }, home: "C:\\Users\\Ana Li" })
|
|
1255
|
+
: prefixLayout({ platform: process.platform, env: {}, home: "/home/npm" });
|
|
1256
|
+
const pi = await piPackageManager(bundledNpmCommand(host));
|
|
1257
|
+
assert.equal(pi.getPackageManagerName(), "npm");
|
|
1258
|
+
assert.deepEqual(pi.getGitDependencyInstallArgs(), ["install", "--omit=dev", "--legacy-peer-deps"]);
|
|
1259
|
+
// The command before N1 read as `node`: plain `npm install` for git extensions.
|
|
1260
|
+
const before = await piPackageManager([host.node, host.npmCli, ...bundledNpmCommand(host).slice(2)]);
|
|
1261
|
+
assert.equal(before.getPackageManagerName(), "node");
|
|
1262
|
+
assert.deepEqual(before.getGitDependencyInstallArgs(), ["install"]);
|
|
1263
|
+
// Windows-shaped paths, whatever the folder names (here "npm" and "pnpm" ones).
|
|
1264
|
+
const windows = prefixLayout({ platform: "win32", env: { LOCALAPPDATA: "C:\\pnpm\\npm" }, home: "C:\\Users\\bun" });
|
|
1265
|
+
assert.equal(windowsPackageManagerName(bundledNpmCommand(windows)), "npm");
|
|
1266
|
+
assert.equal(windowsPackageManagerName([windows.node, windows.npmCli]), "node");
|
|
1267
|
+
});
|
|
1268
|
+
|
|
1269
|
+
posixTest("ensureNpmShim writes one regular npm entry that runs our npm-cli.js, rewritten only when it differs", () => {
|
|
1270
|
+
const { layout } = claimed();
|
|
1271
|
+
assert.throws(() => ensureNpmShim(layout), /npm/);
|
|
1272
|
+
mkdirSync(posix.dirname(layout.npmCli), { recursive: true });
|
|
1273
|
+
// Prints its version and the arguments it got, like npm --version.
|
|
1274
|
+
writeFileSync(layout.npmCli, "console.log(process.argv.slice(2).includes('--version') ? '11.19.0' : JSON.stringify(process.argv.slice(2)));\n");
|
|
1275
|
+
// A user's package.json with type module above the prefix must not make the entry an ES module.
|
|
1276
|
+
writeFileSync(join(layout.root, "..", "package.json"), JSON.stringify({ type: "module" }));
|
|
1277
|
+
assert.deepEqual(ensureNpmShim(layout), { written: true });
|
|
1278
|
+
assert.equal(posix.basename(layout.npmShim), "npm");
|
|
1279
|
+
assert.equal(lstatSync(layout.npmShim).isFile(), true);
|
|
1280
|
+
const version = spawnSync(process.execPath, [layout.npmShim, "--version"], { encoding: "utf8" });
|
|
1281
|
+
assert.equal(version.status, 0, version.stderr);
|
|
1282
|
+
assert.equal(version.stdout, "11.19.0\n");
|
|
1283
|
+
assert.equal(spawnSync(process.execPath, [layout.npmShim, "install", "--omit=dev"], { encoding: "utf8" }).stdout, '["install","--omit=dev"]\n');
|
|
1284
|
+
const text = readFileSync(layout.npmShim, "utf8");
|
|
1285
|
+
assert.deepEqual(ensureNpmShim(layout), { written: false });
|
|
1286
|
+
writeFileSync(layout.npmShim, "// stale\n");
|
|
1287
|
+
assert.deepEqual(ensureNpmShim(layout), { written: true });
|
|
1288
|
+
assert.equal(readFileSync(layout.npmShim, "utf8"), text);
|
|
1289
|
+
// A link or a folder in its place is never written through or replaced.
|
|
1290
|
+
rmSync(layout.npmShim);
|
|
1291
|
+
symlinkSync(layout.npmCli, layout.npmShim);
|
|
1292
|
+
assert.throws(() => ensureNpmShim(layout), /Conflicting npm entry/);
|
|
1293
|
+
assert.equal(readlinkSync(layout.npmShim), layout.npmCli);
|
|
1294
|
+
});
|
|
1295
|
+
|
|
1296
|
+
test("publishing a runtime retries only Windows lock errors on rename, then gives up with the original error", async () => {
|
|
1297
|
+
const { renameWithWindowsRetry } = await import("../scripts/bundled-install.mjs");
|
|
1298
|
+
const lock = (code: string) => Object.assign(new Error(code), { code });
|
|
1299
|
+
const flaky = (failures: string[]) => {
|
|
1300
|
+
const calls: string[] = [];
|
|
1301
|
+
return { calls, rename: (from: string, to: string) => { calls.push(`${from}>${to}`); const code = failures.shift(); if (code) throw lock(code); } };
|
|
1302
|
+
};
|
|
1303
|
+
const noWait = { delays: [0, 0, 0, 0] };
|
|
1304
|
+
// A scanner or the just-run node.exe holding the staging folder clears after a moment.
|
|
1305
|
+
const recovers = flaky(["EPERM", "EBUSY", "EACCES"]);
|
|
1306
|
+
await renameWithWindowsRetry("s", "d", { platform: "win32", rename: recovers.rename, ...noWait });
|
|
1307
|
+
assert.equal(recovers.calls.length, 4);
|
|
1308
|
+
// A lock that never clears surfaces the original error after the last delay.
|
|
1309
|
+
const stuck = flaky(["EPERM", "EPERM", "EPERM", "EPERM", "EPERM"]);
|
|
1310
|
+
await assert.rejects(renameWithWindowsRetry("s", "d", { platform: "win32", rename: stuck.rename, ...noWait }), (error: { code?: string }) => error.code === "EPERM");
|
|
1311
|
+
assert.equal(stuck.calls.length, 5);
|
|
1312
|
+
// Any other error, or any other platform, fails at once.
|
|
1313
|
+
const other = flaky(["EEXIST"]);
|
|
1314
|
+
await assert.rejects(renameWithWindowsRetry("s", "d", { platform: "win32", rename: other.rename, ...noWait }), (error: { code?: string }) => error.code === "EEXIST");
|
|
1315
|
+
assert.equal(other.calls.length, 1);
|
|
1316
|
+
const posixLock = flaky(["EPERM"]);
|
|
1317
|
+
await assert.rejects(renameWithWindowsRetry("s", "d", { platform: "linux", rename: posixLock.rename, ...noWait }), (error: { code?: string }) => error.code === "EPERM");
|
|
1318
|
+
assert.equal(posixLock.calls.length, 1);
|
|
1319
|
+
});
|