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,1201 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, existsSync, symlinkSync, readdirSync, rmSync, lstatSync, unlinkSync, realpathSync } from "node:fs";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { dirname, join, resolve } from "node:path";
|
|
5
|
+
import { spawn, spawnSync } from "node:child_process";
|
|
6
|
+
import { pathToFileURL } from "node:url";
|
|
7
|
+
import { createHash } from "node:crypto";
|
|
8
|
+
import test, { type TestContext } from "node:test";
|
|
9
|
+
import fs from "node:fs";
|
|
10
|
+
import { syncBuiltinESMExports } from "node:module";
|
|
11
|
+
import { crc32, deflateRawSync, gzipSync } from "node:zlib";
|
|
12
|
+
import { artifactFor, verifiedDownload, compatibleEngine, ensurePnpm, launchWizard, bootstrap, removeOwnedTools, acquireGo, installedGo } from "../scripts/installer-downloads.mjs";
|
|
13
|
+
|
|
14
|
+
const script = resolve("scripts/bootstrap.sh");
|
|
15
|
+
const helper = resolve("scripts/installer-downloads.mjs");
|
|
16
|
+
const node = process.execPath;
|
|
17
|
+
// Shell and private-tools fixtures need POSIX sh, stock utilities and uids.
|
|
18
|
+
const posixHost = process.platform === "win32" ? "POSIX shell bootstrap fixtures need a POSIX host" : false;
|
|
19
|
+
const posixTest = (name: string, fn: (t: TestContext) => void | Promise<void>) => test(name, { skip: posixHost }, fn);
|
|
20
|
+
// macOS keeps several stock utilities only in /bin.
|
|
21
|
+
const systemUtility = (name: string) => ["/usr/bin", "/bin"].map((dir) => join(dir, name)).find((path) => existsSync(path)) ?? `/usr/bin/${name}`;
|
|
22
|
+
function fixture() {
|
|
23
|
+
// The bootstrap refuses symlinked HOME ancestors, such as macOS /var -> /private/var.
|
|
24
|
+
const root = mkdtempSync(join(realpathSync(tmpdir()), "bootstrap space ü-"));
|
|
25
|
+
const bin = join(root, "utilities");
|
|
26
|
+
const home = join(root, "home ü");
|
|
27
|
+
const bundle = join(root, "bundle ü");
|
|
28
|
+
for (const dir of [bin, home, join(bundle, "scripts"), join(bundle, "bin")]) mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
29
|
+
writeFileSync(join(bundle, "scripts/bootstrap.sh"), readFileSync(script));
|
|
30
|
+
writeFileSync(join(bundle, "scripts/installer-downloads.mjs"), readFileSync(helper));
|
|
31
|
+
writeFileSync(join(bundle, "package.json"), JSON.stringify({ engines: { node: ">=22.19.0" }, packageManager: "pnpm@11.1.1" }));
|
|
32
|
+
for (const name of ["dirname", "pwd", "awk", "mkdir", "mktemp", "chmod", "mv", "rm", "sleep", "wc", "cat", "id", "ls"])
|
|
33
|
+
symlinkSync(systemUtility(name), join(bin, name));
|
|
34
|
+
const executable = (name: string, body: string) => {
|
|
35
|
+
const path = join(bin, name);
|
|
36
|
+
if (existsSync(path) && lstatSync(path).isSymbolicLink()) unlinkSync(path);
|
|
37
|
+
writeFileSync(path, `#!/bin/sh\n${body}\n`, { mode: 0o755 });
|
|
38
|
+
};
|
|
39
|
+
executable("uname", 'case "$1" in -s) printf "%s\\n" "${FAKE_OS:-Linux}";; -m) printf "%s\\n" "${FAKE_ARCH:-x86_64}";; esac');
|
|
40
|
+
executable("getconf", 'printf "%s\\n" "${FAKE_LIBC:-glibc 2.36}"');
|
|
41
|
+
executable("curl", 'while [ "$#" -gt 0 ]; do if [ "$1" = "--output" ]; then shift; out=$1; fi; shift; done\nprintf archive > "$out"\nexit "${DOWNLOAD_STATUS:-0}"');
|
|
42
|
+
executable("sha256sum", 'printf "%s archive\\n" "${FAKE_HASH:-6e1db87ef58b8819e5d5402eff1536491b18edd8eb7bee5ef7897876e88dc5ff}"');
|
|
43
|
+
const nodeBody = `#!/bin/sh\nif [ "$1" = --version ]; then\nif [ -n "\${NODE_PROBE_SCRIPT:-}" ]; then exec '${node}' "$NODE_PROBE_SCRIPT"; fi\nprintf '%s\\n' "\${NODE_VERSION:-v24.21.0}"; else exec '${node}' "$@"; fi\n`;
|
|
44
|
+
executable("tar", `if [ "$1" = -tzf ]; then printf '%s\\n' "$3"; elif [ "$1" = -tvzf ]; then printf '%s\\n' '-rwxr-xr-x node'; else\nwhile [ "$#" -gt 0 ]; do if [ "$1" = -C ]; then shift; target=$1; fi; member=$1; shift; done\nmkdir -p "$target/\${member%/node}"\ncat > "$target/$member" <<'NODE'\n${nodeBody}NODE\nchmod 700 "$target/$member"\nfi`);
|
|
45
|
+
const addNode = () => writeFileSync(join(bin, "node"), nodeBody, { mode: 0o755 });
|
|
46
|
+
// "symlink" mimics a package-manager link; "shim" mimics the regular cmd-shim
|
|
47
|
+
// file pnpm 11 writes when it installs itself (target only in a comment).
|
|
48
|
+
const addPnpm = (version = "11.1.1", engine = ">=22.13.0", style: "symlink" | "shim" | "shim-without-target" = "symlink") => {
|
|
49
|
+
const pkg = join(root, "pnpm package");
|
|
50
|
+
mkdirSync(join(pkg, "bin"), { recursive: true });
|
|
51
|
+
writeFileSync(join(pkg, "package.json"), JSON.stringify({ name: "pnpm", version, engines: { node: engine } }));
|
|
52
|
+
writeFileSync(join(pkg, "bin/pnpm.mjs"), `#!/bin/sh\ncase "$1" in --version) echo '${version}';; help) echo ' --global '; esac\n`, { mode: 0o755 });
|
|
53
|
+
const entry = join(pkg, "bin/pnpm.mjs");
|
|
54
|
+
if (style === "symlink") symlinkSync(entry, join(bin, "pnpm"));
|
|
55
|
+
else writeFileSync(join(bin, "pnpm"), `#!/bin/sh\nexec '${entry}' "$@"\n${style === "shim" ? `# cmd-shim-target=${entry}\n` : ""}`, { mode: 0o755 });
|
|
56
|
+
};
|
|
57
|
+
const wizard = () => writeFileSync(join(bundle, "bin/gentle-shell-install.mjs"), "console.log('wizard-child:' + process.env.PATH);\n");
|
|
58
|
+
// A wizard that tampers with the ownership marker of the tools it runs from.
|
|
59
|
+
const markerRemovingWizard = () => writeFileSync(join(bundle, "bin/gentle-shell-install.mjs"), [
|
|
60
|
+
"import { unlinkSync } from 'node:fs'; import { resolve, join } from 'node:path';",
|
|
61
|
+
"const bin = process.env.PATH.split(':').find((entry) => entry.includes('.gentle-shell-bootstrap-tools.'));",
|
|
62
|
+
"unlinkSync(join(resolve(bin, '../..'), '.bootstrap-owned'));", ""].join("\n"));
|
|
63
|
+
const run = (extra: Record<string, string> = {}) => spawnSync("/bin/sh", [join(bundle, "scripts/bootstrap.sh")], {
|
|
64
|
+
env: { PATH: bin, HOME: home, ...extra }, encoding: "utf8", timeout: 15000,
|
|
65
|
+
});
|
|
66
|
+
return { root, bin, home, bundle, executable, addNode, addPnpm, wizard, markerRemovingWizard, run, cleanup: () => rmSync(root, { recursive: true, force: true }) };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
for (const [os, arch, target] of [["darwin", "x64", "darwin-x64"], ["darwin", "arm64", "darwin-arm64"], ["linux", "x64", "linux-x64"], ["linux", "arm64", "linux-arm64"]]) {
|
|
70
|
+
test(`fixed Node descriptor for ${target}`, () => {
|
|
71
|
+
const artifact = artifactFor("node", os, arch);
|
|
72
|
+
assert.match(artifact.url, new RegExp(`^https://nodejs.org/dist/v24\\.21\\.0/node-v24\\.21\\.0-${target}\\.tar\\.gz$`));
|
|
73
|
+
assert.match(artifact.integrity, /^sha256-[a-f0-9]{64}$/);
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
test("unknown targets and caller URLs are rejected", () => {
|
|
77
|
+
assert.throws(() => artifactFor("node", "linux", "ia32"), /Unsupported/);
|
|
78
|
+
assert.throws(() => artifactFor("https://untrusted.example"), /Unsupported/);
|
|
79
|
+
});
|
|
80
|
+
test("engine evidence supports stable lower bounds, not guessed ranges", () => {
|
|
81
|
+
assert.equal(compatibleEngine(">=22.13.0", "24.21.0"), true);
|
|
82
|
+
assert.equal(compatibleEngine(">=25.0.0", "24.21.0"), false);
|
|
83
|
+
assert.equal(compatibleEngine("*", "24.21.0"), false);
|
|
84
|
+
assert.equal(compatibleEngine(">=22.13.0", "24.0.0-rc.1"), false);
|
|
85
|
+
});
|
|
86
|
+
test("pnpm descriptor preserves the literal upstream Node engine", () => {
|
|
87
|
+
assert.equal(artifactFor("pnpm").engine, ">=22.13");
|
|
88
|
+
});
|
|
89
|
+
test("simple partial engine minima normalize only for stable version comparison", () => {
|
|
90
|
+
assert.equal(compatibleEngine(">=22.13", "22.13.0"), true);
|
|
91
|
+
assert.equal(compatibleEngine(">=22.13", "24.21.0"), true);
|
|
92
|
+
for (const version of ["22.12.0", "22.12.99", "22.13.0-rc.1", "24.0.0-rc.1", "unknown", "22.13"]) {
|
|
93
|
+
assert.equal(compatibleEngine(">=22.13", version), false, version);
|
|
94
|
+
}
|
|
95
|
+
for (const range of ["*", ">=22", ">=22.13 || >=24", ">=22.13 <25", "^22.13", ">=22.x", ">=22.13-rc.1", ">=022.13", ">=22.013", ">=22.13.00", ">=9007199254740992.13"]) {
|
|
96
|
+
assert.equal(compatibleEngine(range, "24.21.0"), false, range);
|
|
97
|
+
}
|
|
98
|
+
});
|
|
99
|
+
test("download helper fails closed on integrity, empty body and adapter failure", async () => {
|
|
100
|
+
for (const download of [async () => Buffer.from("truncated"), async () => Buffer.alloc(0), async () => { throw Error("failure"); }]) {
|
|
101
|
+
await assert.rejects(verifiedDownload("pnpm", { download }), /Download|integrity/);
|
|
102
|
+
}
|
|
103
|
+
});
|
|
104
|
+
/** A local server that, like dl.google.com, gzips the body when the client accepts it. */
|
|
105
|
+
async function compressingServer(bytes: Buffer, always = false) {
|
|
106
|
+
const { createServer } = await import("node:http");
|
|
107
|
+
const seen: string[] = [];
|
|
108
|
+
const server = createServer((request, response) => {
|
|
109
|
+
const accepted = String(request.headers["accept-encoding"] ?? "");
|
|
110
|
+
seen.push(accepted);
|
|
111
|
+
if (always || /gzip/.test(accepted)) {
|
|
112
|
+
const body = gzipSync(bytes);
|
|
113
|
+
response.writeHead(200, { "content-encoding": "gzip", "content-length": String(body.length) });
|
|
114
|
+
response.end(body);
|
|
115
|
+
} else {
|
|
116
|
+
response.writeHead(200, { "content-length": String(bytes.length) });
|
|
117
|
+
response.end(bytes);
|
|
118
|
+
}
|
|
119
|
+
});
|
|
120
|
+
await new Promise<void>((done) => server.listen(0, "127.0.0.1", () => done()));
|
|
121
|
+
const { port } = server.address() as { port: number };
|
|
122
|
+
return { url: `http://127.0.0.1:${port}/go.zip`, seen, close: () => new Promise((done) => server.close(done)) };
|
|
123
|
+
}
|
|
124
|
+
function localArtifact(url: string, bytes: Buffer) {
|
|
125
|
+
return () => ({ url, size: bytes.length, maxBytes: bytes.length, integrity: `sha256-${createHash("sha256").update(bytes).digest("hex")}` });
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
test("the real download asks for the identity encoding, so a server that would gzip the archive sends it whole", async () => {
|
|
129
|
+
const bytes = Buffer.concat([Buffer.from("PK\u0003\u0004"), Buffer.alloc(4096, 7)]);
|
|
130
|
+
const server = await compressingServer(bytes);
|
|
131
|
+
try {
|
|
132
|
+
const result = await verifiedDownload("go", { artifact: localArtifact(server.url, bytes) });
|
|
133
|
+
assert.deepEqual(result, bytes);
|
|
134
|
+
assert.deepEqual(server.seen, ["identity"]);
|
|
135
|
+
} finally { await server.close(); }
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
test("an encoded response is refused with its cause instead of being reported as truncated", async () => {
|
|
139
|
+
const bytes = Buffer.alloc(4096, 9);
|
|
140
|
+
const server = await compressingServer(bytes, true);
|
|
141
|
+
try {
|
|
142
|
+
await assert.rejects(verifiedDownload("go", { artifact: localArtifact(server.url, bytes) }),
|
|
143
|
+
(error: Error) => error.message === "Download failed" && /encoded/.test(String((error.cause as Error)?.message)));
|
|
144
|
+
} finally { await server.close(); }
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
test("a failed download keeps the transport error as its cause", async () => {
|
|
148
|
+
await assert.rejects(verifiedDownload("pnpm", { download: async () => { throw new Error("socket hang up"); } }),
|
|
149
|
+
(error: Error) => error.message === "Download failed" && (error.cause as Error)?.message === "socket hang up");
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
test("download helper validates bytes before returning them", async () => {
|
|
153
|
+
const bytes = Buffer.from("verified fixture");
|
|
154
|
+
let observed = "";
|
|
155
|
+
const result = await verifiedDownload("pnpm", {
|
|
156
|
+
download: async (descriptor: { url: string }) => { observed = descriptor.url; return bytes; },
|
|
157
|
+
digest: () => Buffer.from(artifactFor("pnpm").integrity.slice(7), "base64"),
|
|
158
|
+
});
|
|
159
|
+
assert.equal(result, bytes);
|
|
160
|
+
assert.equal(observed, "https://registry.npmjs.org/pnpm/-/pnpm-11.1.1.tgz");
|
|
161
|
+
assert.notEqual(createHash("sha512").update(bytes).digest("base64"), artifactFor("pnpm").integrity.slice(7));
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
posixTest("compatible existing tools are reused with whitespace and Unicode paths", () => {
|
|
165
|
+
const f = fixture();
|
|
166
|
+
try {
|
|
167
|
+
f.addNode(); f.addPnpm("11.5.0"); f.wizard();
|
|
168
|
+
const result = f.run();
|
|
169
|
+
assert.equal(result.status, 0, result.stderr);
|
|
170
|
+
assert.match(result.stdout, /wizard-child:/);
|
|
171
|
+
assert.deepEqual(readdirSync(f.home), []);
|
|
172
|
+
} finally { f.cleanup(); }
|
|
173
|
+
});
|
|
174
|
+
posixTest("missing wizard fails explicitly without acquiring or claiming installation", () => {
|
|
175
|
+
const f = fixture();
|
|
176
|
+
try {
|
|
177
|
+
const result = f.run();
|
|
178
|
+
assert.notEqual(result.status, 0);
|
|
179
|
+
assert.match(result.stderr, /wizard entry.*missing/i);
|
|
180
|
+
assert.deepEqual(readdirSync(f.home), []);
|
|
181
|
+
} finally { f.cleanup(); }
|
|
182
|
+
});
|
|
183
|
+
posixTest("no initial Node acquires verified native binary and refreshes child PATH", () => {
|
|
184
|
+
const f = fixture();
|
|
185
|
+
try {
|
|
186
|
+
f.addPnpm(); f.wizard();
|
|
187
|
+
const result = f.run();
|
|
188
|
+
assert.equal(result.status, 0, result.stderr);
|
|
189
|
+
assert.match(result.stdout, /wizard-child:.*bootstrap-tools.*node\/bin/);
|
|
190
|
+
assert.deepEqual(readdirSync(f.home), [], "successful wizard exit removes the owned tools");
|
|
191
|
+
} finally { f.cleanup(); }
|
|
192
|
+
});
|
|
193
|
+
posixTest("successful exit removes only the owned tools directory", () => {
|
|
194
|
+
const f = fixture();
|
|
195
|
+
try {
|
|
196
|
+
f.addPnpm(); f.wizard();
|
|
197
|
+
mkdirSync(join(f.home, ".gentle-shell-bootstrap-tools.unrelated"));
|
|
198
|
+
writeFileSync(join(f.home, ".gentle-shell-bootstrap-tools.unrelated/keep"), "preserve");
|
|
199
|
+
writeFileSync(join(f.home, "unrelated.txt"), "preserve");
|
|
200
|
+
const result = f.run();
|
|
201
|
+
assert.equal(result.status, 0, result.stderr);
|
|
202
|
+
assert.equal(result.stderr, "");
|
|
203
|
+
assert.deepEqual(readdirSync(f.home).sort(), [".gentle-shell-bootstrap-tools.unrelated", "unrelated.txt"]);
|
|
204
|
+
assert.equal(readFileSync(join(f.home, ".gentle-shell-bootstrap-tools.unrelated/keep"), "utf8"), "preserve");
|
|
205
|
+
} finally { f.cleanup(); }
|
|
206
|
+
});
|
|
207
|
+
posixTest("tools removal failure after success keeps exit 0 and names the path", () => {
|
|
208
|
+
const f = fixture();
|
|
209
|
+
try {
|
|
210
|
+
f.addPnpm(); f.wizard();
|
|
211
|
+
// Only removal of the tools root itself fails; staging/archive removal still works.
|
|
212
|
+
f.executable("rm", `case "$2" in */.gentle-shell-bootstrap-tools.*/*) ;; */.gentle-shell-bootstrap-tools.*) exit 1;; esac\nexec ${systemUtility("rm")} "$@"`);
|
|
213
|
+
const result = f.run();
|
|
214
|
+
assert.equal(result.status, 0, result.stderr);
|
|
215
|
+
const [tools] = readdirSync(f.home);
|
|
216
|
+
assert.match(tools, /^\.gentle-shell-bootstrap-tools\./);
|
|
217
|
+
assert.ok(result.stderr.includes(`temporary tools could not be removed: ${join(f.home, tools)}`), result.stderr);
|
|
218
|
+
} finally { f.cleanup(); }
|
|
219
|
+
});
|
|
220
|
+
posixTest("tools whose ownership marker vanished are kept after success with a notice", () => {
|
|
221
|
+
const f = fixture();
|
|
222
|
+
try {
|
|
223
|
+
f.addPnpm(); f.markerRemovingWizard();
|
|
224
|
+
const result = f.run();
|
|
225
|
+
assert.equal(result.status, 0, result.stderr);
|
|
226
|
+
assert.match(result.stderr, /temporary tools could not be removed: .*\.gentle-shell-bootstrap-tools\./);
|
|
227
|
+
assert.equal(readdirSync(f.home).length, 1);
|
|
228
|
+
} finally { f.cleanup(); }
|
|
229
|
+
});
|
|
230
|
+
for (const extra of [{ FAKE_HASH: "0".repeat(64) }, { DOWNLOAD_STATUS: "18" }, { NODE_VERSION: "v21.0.0" }, { NODE_VERSION: "unknown" }, { FAKE_LIBC: "musl" }]) {
|
|
231
|
+
posixTest(`acquisition fails closed: ${JSON.stringify(extra)}`, () => {
|
|
232
|
+
const f = fixture();
|
|
233
|
+
try {
|
|
234
|
+
f.addPnpm(); f.wizard();
|
|
235
|
+
const result = f.run(extra);
|
|
236
|
+
assert.notEqual(result.status, 0);
|
|
237
|
+
assert.doesNotMatch(result.stdout, /wizard-child/);
|
|
238
|
+
assert.deepEqual(readdirSync(f.home), []);
|
|
239
|
+
} finally { f.cleanup(); }
|
|
240
|
+
});
|
|
241
|
+
}
|
|
242
|
+
for (const version of ["v24.1.0-rc.1", "v25.00.1", "banana"]) {
|
|
243
|
+
posixTest(`existing Node ${version} is rejected without replacement`, () => {
|
|
244
|
+
const f = fixture();
|
|
245
|
+
try {
|
|
246
|
+
f.addNode(); f.addPnpm(); f.wizard();
|
|
247
|
+
const result = f.run({ NODE_VERSION: version });
|
|
248
|
+
assert.notEqual(result.status, 0);
|
|
249
|
+
assert.match(result.stderr, /Node.*incompatible|Node.*unknown/);
|
|
250
|
+
assert.deepEqual(readdirSync(f.home), []);
|
|
251
|
+
} finally { f.cleanup(); }
|
|
252
|
+
});
|
|
253
|
+
}
|
|
254
|
+
posixTest("an older stable Node is left unchanged and a verified Node runs the helper and wizard instead", () => {
|
|
255
|
+
const f = fixture();
|
|
256
|
+
try {
|
|
257
|
+
f.addPnpm(); f.wizard();
|
|
258
|
+
const older = `#!/bin/sh\nif [ "$1" = --version ]; then echo v22.18.0; else echo user-node-ran >&2; exit 9; fi\n`;
|
|
259
|
+
writeFileSync(join(f.bin, "node"), older, { mode: 0o755 });
|
|
260
|
+
const result = f.run();
|
|
261
|
+
assert.equal(result.status, 0, result.stderr);
|
|
262
|
+
assert.match(result.stdout, /wizard-child:[^:\n]*\.gentle-shell-bootstrap-tools\.[^:\n]*\/node\/bin:/);
|
|
263
|
+
assert.doesNotMatch(result.stderr, /user-node-ran/);
|
|
264
|
+
assert.equal(readFileSync(join(f.bin, "node"), "utf8"), older);
|
|
265
|
+
assert.deepEqual(readdirSync(f.home), [], "successful wizard exit removes the owned tools");
|
|
266
|
+
} finally { f.cleanup(); }
|
|
267
|
+
});
|
|
268
|
+
posixTest("existing pnpm with unknown engine blocks instead of acquisition", () => {
|
|
269
|
+
const f = fixture();
|
|
270
|
+
try {
|
|
271
|
+
f.addNode(); f.addPnpm("12.0.0", "*"); f.wizard();
|
|
272
|
+
const result = f.run();
|
|
273
|
+
assert.notEqual(result.status, 0);
|
|
274
|
+
assert.match(result.stderr, /pnpm.*compatibility/i);
|
|
275
|
+
assert.deepEqual(readdirSync(f.home), []);
|
|
276
|
+
} finally { f.cleanup(); }
|
|
277
|
+
});
|
|
278
|
+
posixTest("existing pnpm behind a self-installed cmd-shim is reused", () => {
|
|
279
|
+
const f = fixture();
|
|
280
|
+
try {
|
|
281
|
+
f.addNode(); f.addPnpm("11.1.1", ">=22.13.0", "shim"); f.wizard();
|
|
282
|
+
const result = f.run();
|
|
283
|
+
assert.equal(result.status, 0, result.stderr);
|
|
284
|
+
assert.match(result.stdout, /wizard-child:/);
|
|
285
|
+
assert.deepEqual(readdirSync(f.home), []);
|
|
286
|
+
} finally { f.cleanup(); }
|
|
287
|
+
});
|
|
288
|
+
posixTest("pnpm version evidence ignores the packageManager pin of the bundle it runs from", () => {
|
|
289
|
+
const f = fixture();
|
|
290
|
+
try {
|
|
291
|
+
f.addNode(); f.addPnpm("11.5.0"); f.wizard();
|
|
292
|
+
// pnpm 11 switches to the version a project pins when run inside it.
|
|
293
|
+
writeFileSync(join(f.root, "pnpm package/bin/pnpm.mjs"),
|
|
294
|
+
`#!/bin/sh\ncase "$1" in --version) if [ -f package.json ]; then echo '11.1.1'; else echo '11.5.0'; fi;; help) echo ' --global '; esac\n`, { mode: 0o755 });
|
|
295
|
+
const result = spawnSync("/bin/sh", [join(f.bundle, "scripts/bootstrap.sh")], {
|
|
296
|
+
cwd: f.bundle, env: { PATH: f.bin, HOME: f.home }, encoding: "utf8", timeout: 15000,
|
|
297
|
+
});
|
|
298
|
+
assert.equal(result.status, 0, result.stderr);
|
|
299
|
+
assert.match(result.stdout, /wizard-child:/);
|
|
300
|
+
} finally { f.cleanup(); }
|
|
301
|
+
});
|
|
302
|
+
posixTest("a pnpm shim without a cmd-shim target still blocks", () => {
|
|
303
|
+
const f = fixture();
|
|
304
|
+
try {
|
|
305
|
+
f.addNode(); f.addPnpm("11.1.1", ">=22.13.0", "shim-without-target"); f.wizard();
|
|
306
|
+
const result = f.run();
|
|
307
|
+
assert.notEqual(result.status, 0);
|
|
308
|
+
assert.match(result.stderr, /pnpm compatibility is unknown: package engine evidence missing/);
|
|
309
|
+
assert.doesNotMatch(result.stdout, /wizard-child/);
|
|
310
|
+
assert.deepEqual(readdirSync(f.home), []);
|
|
311
|
+
} finally { f.cleanup(); }
|
|
312
|
+
});
|
|
313
|
+
// mise, asdf and pnpm's own installer ship pnpm as a standalone native executable
|
|
314
|
+
// that embeds its runtime: there is no pnpm package.json to read engines from.
|
|
315
|
+
const nativeHeaders: Record<string, number[]> = {
|
|
316
|
+
"Mach-O 64-bit": [0xcf, 0xfa, 0xed, 0xfe], "Mach-O universal": [0xca, 0xfe, 0xba, 0xbe], ELF: [0x7f, 0x45, 0x4c, 0x46],
|
|
317
|
+
};
|
|
318
|
+
function standalonePnpm(f: ReturnType<typeof fixture>, header: number[], linked = false, padding = 64) {
|
|
319
|
+
const installs = join(f.root, "mise/installs/pnpm/10.27.0");
|
|
320
|
+
mkdirSync(installs, { recursive: true });
|
|
321
|
+
const binary = join(installs, "pnpm");
|
|
322
|
+
writeFileSync(binary, Buffer.concat([Buffer.from(header), Buffer.alloc(padding)]), { mode: 0o755 });
|
|
323
|
+
if (linked) symlinkSync(binary, join(f.bin, "pnpm"));
|
|
324
|
+
else writeFileSync(join(f.bin, "pnpm"), Buffer.concat([Buffer.from(header), Buffer.alloc(padding)]), { mode: 0o755 });
|
|
325
|
+
}
|
|
326
|
+
const standaloneProcess = (version: string, calls: string[][] = []) => (_command: string, args: string[]) => {
|
|
327
|
+
calls.push(args);
|
|
328
|
+
return args.at(-1) === "--version" ? version : " --global ";
|
|
329
|
+
};
|
|
330
|
+
posixTest("a standalone native pnpm (mise, pnpm installer) is reused after version and capability proof", async () => {
|
|
331
|
+
// A real executable is far larger than the 64 KiB a shim may have; only scripts are read as shims.
|
|
332
|
+
for (const [label, header] of Object.entries(nativeHeaders)) {
|
|
333
|
+
for (const [linked, padding] of [[false, 64], [true, 64], [false, 128 * 1024]] as const) {
|
|
334
|
+
const f = fixture();
|
|
335
|
+
try {
|
|
336
|
+
standalonePnpm(f, header, linked, padding);
|
|
337
|
+
const calls: string[][] = [];
|
|
338
|
+
const result = await ensurePnpm({ tools: f.home, env: { PATH: f.bin }, nodeVersion: "24.21.0", adapters: {
|
|
339
|
+
process: standaloneProcess("11.5.0", calls), download: () => { throw new Error("must not download"); },
|
|
340
|
+
} });
|
|
341
|
+
assert.equal(result.acquired, false, `${label} linked=${linked} padding=${padding}`);
|
|
342
|
+
assert.deepEqual(calls, [["--version"], ["help", "add"], ["help", "bin"]], `${label} linked=${linked} padding=${padding}`);
|
|
343
|
+
assert.deepEqual(readdirSync(f.home), [], "no private tooling is created");
|
|
344
|
+
} finally { f.cleanup(); }
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
});
|
|
348
|
+
posixTest("an existing pnpm of another major, older than the pin or engine-incompatible is left unchanged and the verified pnpm is acquired", async () => {
|
|
349
|
+
for (const [version, engine] of [["10.27.0", ">=18.12"], ["11.0.0", ">=22.13"], ["12.0.0", ">=22.13"], ["11.5.0", ">=99.0.0"]]) {
|
|
350
|
+
const f = fixture();
|
|
351
|
+
try {
|
|
352
|
+
f.addPnpm(version, engine);
|
|
353
|
+
const metadata = readFileSync(join(f.root, "pnpm package/package.json"), "utf8");
|
|
354
|
+
const adapters = acquisitionAdapters();
|
|
355
|
+
const result = await ensurePnpm({ tools: f.home, env: { PATH: f.bin }, nodeVersion: "24.21.0", adapters });
|
|
356
|
+
assert.equal(result.acquired, true, version);
|
|
357
|
+
assert.ok(result.env.PATH.startsWith(`${join(f.home, "pnpm/bin")}:`), version);
|
|
358
|
+
assert.equal(readFileSync(join(f.root, "pnpm package/package.json"), "utf8"), metadata);
|
|
359
|
+
assert.ok(lstatSync(join(f.bin, "pnpm")).isSymbolicLink());
|
|
360
|
+
assert.ok(adapters.calls.every((call) => !call.startsWith(join(f.bin, "pnpm"))), version);
|
|
361
|
+
} finally { f.cleanup(); }
|
|
362
|
+
}
|
|
363
|
+
// A standalone native pnpm (mise) of another major: only its version is read.
|
|
364
|
+
const f = fixture();
|
|
365
|
+
try {
|
|
366
|
+
standalonePnpm(f, nativeHeaders.ELF);
|
|
367
|
+
const calls: string[][] = [];
|
|
368
|
+
const adapters = acquisitionAdapters();
|
|
369
|
+
const standalone = { ...adapters, process: (command: string, args: string[]) => command === join(f.bin, "pnpm")
|
|
370
|
+
? standaloneProcess("10.27.0", calls)(command, args) : adapters.process(command, args) };
|
|
371
|
+
const result = await ensurePnpm({ tools: f.home, env: { PATH: f.bin }, nodeVersion: "24.21.0", adapters: standalone });
|
|
372
|
+
assert.equal(result.acquired, true);
|
|
373
|
+
assert.deepEqual(calls, [["--version"]]);
|
|
374
|
+
} finally { f.cleanup(); }
|
|
375
|
+
});
|
|
376
|
+
posixTest("the bootstrap claims private tools for the verified pnpm when the existing one is incompatible", async () => {
|
|
377
|
+
const f = fixture();
|
|
378
|
+
try {
|
|
379
|
+
f.addPnpm("10.27.0", ">=18.12");
|
|
380
|
+
writeFileSync(join(f.bundle, "bin/gentle-shell-install.mjs"), "import { writeFileSync } from 'node:fs';\n" +
|
|
381
|
+
`writeFileSync(${JSON.stringify(join(f.root, "wizard-path"))}, process.env.PATH);\n`);
|
|
382
|
+
await bootstrap(f.bundle, "", { env: { PATH: f.bin, HOME: f.home }, adapters: acquisitionAdapters() });
|
|
383
|
+
assert.match(readFileSync(join(f.root, "wizard-path"), "utf8"), /^[^:]*\.gentle-shell-bootstrap-tools\.[^:]*\/pnpm\/bin:/);
|
|
384
|
+
assert.deepEqual(readdirSync(f.home), [], "the helper-created tools are removed after the wizard");
|
|
385
|
+
} finally { f.cleanup(); }
|
|
386
|
+
});
|
|
387
|
+
posixTest("a standalone native pnpm without a semver version or global capability still blocks", async () => {
|
|
388
|
+
const f = fixture();
|
|
389
|
+
try {
|
|
390
|
+
standalonePnpm(f, nativeHeaders["Mach-O 64-bit"]);
|
|
391
|
+
const adapters = (process: (command: string, args: string[]) => string) => ({ process, download: () => { throw new Error("must not download"); } });
|
|
392
|
+
for (const version of ["", "10.27", "not pnpm", "10.27.0-beta.1"]) {
|
|
393
|
+
await assert.rejects(ensurePnpm({ tools: f.home, env: { PATH: f.bin }, nodeVersion: "24.21.0", adapters: adapters(standaloneProcess(version)) }), /pnpm version rejected/, version);
|
|
394
|
+
}
|
|
395
|
+
await assert.rejects(ensurePnpm({ tools: f.home, env: { PATH: f.bin }, nodeVersion: "24.21.0", adapters: adapters((_c, args) => args.at(-1) === "--version" ? "11.5.0" : "no global flag") }),
|
|
396
|
+
/pnpm global-install capability evidence missing/);
|
|
397
|
+
} finally { f.cleanup(); }
|
|
398
|
+
});
|
|
399
|
+
posixTest("a script pnpm without a package is not treated as standalone", async () => {
|
|
400
|
+
const f = fixture();
|
|
401
|
+
try {
|
|
402
|
+
writeFileSync(join(f.bin, "pnpm"), "#!/bin/sh\necho 10.27.0\n", { mode: 0o755 });
|
|
403
|
+
await assert.rejects(ensurePnpm({ tools: f.home, env: { PATH: f.bin }, nodeVersion: "24.21.0", adapters: { process: () => { throw new Error("must not run"); } } }),
|
|
404
|
+
/package engine evidence missing/);
|
|
405
|
+
} finally { f.cleanup(); }
|
|
406
|
+
});
|
|
407
|
+
// $PNPM_HOME layouts captured from real macOS installs (get.pnpm.io + `pnpm setup`,
|
|
408
|
+
// `pnpm self-update`, `pnpm add -g`), shims trimmed to the lines naming the target.
|
|
409
|
+
interface PnpmHomeLayout {
|
|
410
|
+
bin: string; target: string; shim: (home: string) => string; native: boolean;
|
|
411
|
+
pkg?: { dir: string; json: Record<string, unknown> }; store?: string;
|
|
412
|
+
}
|
|
413
|
+
const setupShim = (target: string) => (home: string) => `#!/bin/sh\nbasedir_abs=$(CDPATH= cd -P -- "$basedir" && pwd -P) || exit $?\nbasedir="$basedir_abs"\n\n` +
|
|
414
|
+
`exec "$basedir_abs/../${target}" "$@"\nexit $?\n# cmd-shim-target=${home}/${target}\n`;
|
|
415
|
+
const legacyShim = (target: string) => () => `#!/bin/sh\nbasedir=$(dirname "$(echo "$0" | sed -e 's,\\\\,/,g')")\n\n\n"$basedir/${target}" "$@"\nexit $?\n`;
|
|
416
|
+
const addGlobalShim = (target: string) => () => `#!/bin/sh\nbasedir=$(dirname "$(echo "$0" | sed -e 's,\\\\,/,g')")\n\nif [ -x "$basedir/node" ]; then\n` +
|
|
417
|
+
` exec "$basedir/node" "$basedir/${target}" "$@"\nelse\n exec node "$basedir/${target}" "$@"\nfi\n`;
|
|
418
|
+
const pnpmHomeLayouts: Record<string, PnpmHomeLayout & { version: string; outcome: "reused" | "acquired" }> = {
|
|
419
|
+
"pnpm 11 setup or self-update (@pnpm/exe in $PNPM_HOME/bin)": {
|
|
420
|
+
bin: "bin", target: "global/v11/933b1afb/node_modules/@pnpm/exe/pnpm", shim: setupShim("global/v11/933b1afb/node_modules/@pnpm/exe/pnpm"),
|
|
421
|
+
native: true, store: "store/v11/links/@pnpm/exe/directory/643afacf/node_modules/@pnpm/exe",
|
|
422
|
+
pkg: { dir: "global/v11/933b1afb/node_modules/@pnpm/exe", json: { name: "@pnpm/exe", version: "11.28.2", bin: { pnpm: "pnpm", pn: "pnpm" } } },
|
|
423
|
+
version: "11.28.2", outcome: "reused",
|
|
424
|
+
},
|
|
425
|
+
"pnpm 10 self-update to 11 (@pnpm/exe in $PNPM_HOME/.tools)": {
|
|
426
|
+
bin: "", target: ".tools/@pnpm+exe/11.28.2/node_modules/@pnpm/exe/pnpm", shim: legacyShim(".tools/@pnpm+exe/11.28.2/node_modules/@pnpm/exe/pnpm"),
|
|
427
|
+
native: true, pkg: { dir: ".tools/@pnpm+exe/11.28.2/node_modules/@pnpm/exe", json: { name: "@pnpm/exe", version: "11.28.2" } },
|
|
428
|
+
version: "11.28.2", outcome: "reused",
|
|
429
|
+
},
|
|
430
|
+
"pnpm 10 setup (bare executable in $PNPM_HOME/.tools)": {
|
|
431
|
+
bin: "", target: ".tools/pnpm-exe/10.34.6/pnpm", shim: legacyShim(".tools/pnpm-exe/10.34.6/pnpm"), native: true,
|
|
432
|
+
version: "10.34.6", outcome: "acquired",
|
|
433
|
+
},
|
|
434
|
+
"pnpm 12 setup (native pnpm package without engines)": {
|
|
435
|
+
bin: "bin", target: "global/v11/82859147/node_modules/pnpm/pnpm", shim: setupShim("global/v11/82859147/node_modules/pnpm/pnpm"),
|
|
436
|
+
native: true, pkg: { dir: "global/v11/82859147/node_modules/pnpm", json: { name: "pnpm", version: "12.10.1", bin: { pnpm: "pnpm", pn: "pnpm" } } },
|
|
437
|
+
version: "12.10.1", outcome: "acquired",
|
|
438
|
+
},
|
|
439
|
+
"pnpm 10 add -g pnpm (JS package in $PNPM_HOME/global/5)": {
|
|
440
|
+
bin: "", target: "global/5/.pnpm/pnpm@10.34.6/node_modules/pnpm/bin/pnpm.cjs", shim: addGlobalShim("global/5/.pnpm/pnpm@10.34.6/node_modules/pnpm/bin/pnpm.cjs"),
|
|
441
|
+
native: false, pkg: { dir: "global/5/.pnpm/pnpm@10.34.6/node_modules/pnpm", json: { name: "pnpm", version: "10.34.6", engines: { node: ">=18.12" } } },
|
|
442
|
+
version: "10.34.6", outcome: "acquired",
|
|
443
|
+
},
|
|
444
|
+
};
|
|
445
|
+
function pnpmHome(f: ReturnType<typeof fixture>, layout: PnpmHomeLayout) {
|
|
446
|
+
const home = join(f.root, "Library/pnpm");
|
|
447
|
+
const dir = join(home, layout.bin);
|
|
448
|
+
const target = join(home, layout.target);
|
|
449
|
+
mkdirSync(dir, { recursive: true });
|
|
450
|
+
if (layout.store) {
|
|
451
|
+
// pnpm 11 links the global package directory into its store.
|
|
452
|
+
mkdirSync(join(home, layout.store), { recursive: true });
|
|
453
|
+
mkdirSync(dirname(dirname(target)), { recursive: true });
|
|
454
|
+
symlinkSync(join(home, layout.store), dirname(target));
|
|
455
|
+
} else mkdirSync(dirname(target), { recursive: true });
|
|
456
|
+
if (layout.pkg) {
|
|
457
|
+
mkdirSync(join(home, layout.pkg.dir), { recursive: true });
|
|
458
|
+
writeFileSync(join(home, layout.pkg.dir, "package.json"), JSON.stringify(layout.pkg.json));
|
|
459
|
+
}
|
|
460
|
+
writeFileSync(target, layout.native ? Buffer.concat([Buffer.from(nativeHeaders["Mach-O 64-bit"]), Buffer.alloc(64)]) : "#!/usr/bin/env node\n", { mode: 0o755 });
|
|
461
|
+
writeFileSync(join(dir, "pnpm"), layout.shim(home), { mode: 0o755 });
|
|
462
|
+
return { shim: join(dir, "pnpm"), env: { PATH: `${dir}:${f.bin}` } };
|
|
463
|
+
}
|
|
464
|
+
posixTest("pnpm installed in $PNPM_HOME by pnpm itself is recognized behind its shim", async () => {
|
|
465
|
+
for (const [label, layout] of Object.entries(pnpmHomeLayouts)) {
|
|
466
|
+
const f = fixture();
|
|
467
|
+
try {
|
|
468
|
+
const { shim, env } = pnpmHome(f, layout);
|
|
469
|
+
const calls: string[][] = [];
|
|
470
|
+
const acquisition = acquisitionAdapters();
|
|
471
|
+
const adapters = { ...acquisition, process: (command: string, args: string[]) => command === shim
|
|
472
|
+
? standaloneProcess(layout.version, calls)(command, args) : acquisition.process(command, args) };
|
|
473
|
+
const result = await ensurePnpm({ tools: f.home, env, nodeVersion: "24.21.0", adapters });
|
|
474
|
+
assert.equal(result.acquired, layout.outcome === "acquired", label);
|
|
475
|
+
if (layout.outcome === "reused") assert.deepEqual(calls, [["--version"], ["help", "add"], ["help", "bin"]], label);
|
|
476
|
+
// An incompatible JS pnpm is judged by its package; a native one only by --version.
|
|
477
|
+
else assert.deepEqual(calls, layout.native ? [["--version"]] : [], label);
|
|
478
|
+
} finally { f.cleanup(); }
|
|
479
|
+
}
|
|
480
|
+
});
|
|
481
|
+
// A shim is read whole up to 64 KiB, so a target named after the first 4 KiB is still found.
|
|
482
|
+
const paddedShim = (shim: (home: string) => string, size: number) => (home: string) => {
|
|
483
|
+
const [first, ...rest] = shim(home).split("\n");
|
|
484
|
+
const text = [first, ...rest].join("\n");
|
|
485
|
+
return `${first}\n${"#".repeat(Math.max(0, size - Buffer.byteLength(text) - 1))}\n${rest.join("\n")}`;
|
|
486
|
+
};
|
|
487
|
+
posixTest("a $PNPM_HOME shim larger than 4 KiB keeps its target; one above 64 KiB fails closed", async () => {
|
|
488
|
+
const setup = pnpmHomeLayouts["pnpm 11 setup or self-update (@pnpm/exe in $PNPM_HOME/bin)"];
|
|
489
|
+
const legacy = pnpmHomeLayouts["pnpm 10 self-update to 11 (@pnpm/exe in $PNPM_HOME/.tools)"];
|
|
490
|
+
for (const [label, layout] of [["cmd-shim-target comment", setup], ["single basedir target", legacy]] as const) {
|
|
491
|
+
for (const size of [4097, 64 * 1024]) {
|
|
492
|
+
const f = fixture();
|
|
493
|
+
try {
|
|
494
|
+
const { shim, env } = pnpmHome(f, { ...layout, shim: paddedShim(layout.shim, size) });
|
|
495
|
+
assert.equal(readFileSync(shim).length, size, label);
|
|
496
|
+
const calls: string[][] = [];
|
|
497
|
+
const adapters = { process: (command: string, args: string[]) => {
|
|
498
|
+
if (command !== shim) throw new Error("must not run");
|
|
499
|
+
return standaloneProcess(layout.version, calls)(command, args);
|
|
500
|
+
}, download: () => { throw new Error("must not download"); } };
|
|
501
|
+
const result = await ensurePnpm({ tools: f.home, env, nodeVersion: "24.21.0", adapters });
|
|
502
|
+
assert.equal(result.acquired, false, `${label} ${size}`);
|
|
503
|
+
assert.deepEqual(calls, [["--version"], ["help", "add"], ["help", "bin"]], `${label} ${size}`);
|
|
504
|
+
} finally { f.cleanup(); }
|
|
505
|
+
}
|
|
506
|
+
const f = fixture();
|
|
507
|
+
try {
|
|
508
|
+
const { env } = pnpmHome(f, { ...layout, shim: paddedShim(layout.shim, 64 * 1024 + 1) });
|
|
509
|
+
const adapters = { process: () => { throw new Error("must not run"); }, download: () => { throw new Error("must not download"); } };
|
|
510
|
+
await assert.rejects(ensurePnpm({ tools: f.home, env, nodeVersion: "24.21.0", adapters }), /pnpm compatibility is unknown: shim larger than 64 KiB/, label);
|
|
511
|
+
assert.deepEqual(readdirSync(f.home), [], label);
|
|
512
|
+
} finally { f.cleanup(); }
|
|
513
|
+
}
|
|
514
|
+
});
|
|
515
|
+
posixTest("pnpm behind a $PNPM_HOME shim still fails closed without matching evidence", async () => {
|
|
516
|
+
const setup = pnpmHomeLayouts["pnpm 11 setup or self-update (@pnpm/exe in $PNPM_HOME/bin)"];
|
|
517
|
+
const addGlobal = pnpmHomeLayouts["pnpm 10 add -g pnpm (JS package in $PNPM_HOME/global/5)"];
|
|
518
|
+
const missing = legacyShim(".tools/pnpm-exe/10.34.6/pnpm");
|
|
519
|
+
const ambiguous = () => `${legacyShim(".tools/a/pnpm")()}"$basedir/.tools/b/pnpm" "$@"\n`;
|
|
520
|
+
const cases: [string, PnpmHomeLayout, string | undefined, RegExp][] = [
|
|
521
|
+
["native version differs from its package", setup, "11.27.0", /pnpm version rejected/],
|
|
522
|
+
["JS target not named pnpm", { ...addGlobal, pkg: { dir: addGlobal.pkg!.dir, json: { ...addGlobal.pkg!.json, name: "not-pnpm" } } }, undefined, /package engine evidence missing/],
|
|
523
|
+
["target missing", { bin: "", target: "elsewhere/pnpm", shim: missing, native: true }, undefined, /package engine evidence missing/],
|
|
524
|
+
["two targets", { bin: "", target: ".tools/a/pnpm", shim: ambiguous, native: true }, undefined, /package engine evidence missing/],
|
|
525
|
+
];
|
|
526
|
+
for (const [label, layout, version, error] of cases) {
|
|
527
|
+
const f = fixture();
|
|
528
|
+
try {
|
|
529
|
+
const { shim, env } = pnpmHome(f, layout);
|
|
530
|
+
const adapters = { process: (command: string, args: string[]) => {
|
|
531
|
+
if (command !== shim || version === undefined) throw new Error("must not run");
|
|
532
|
+
return standaloneProcess(version)(command, args);
|
|
533
|
+
}, download: () => { throw new Error("must not download"); } };
|
|
534
|
+
await assert.rejects(ensurePnpm({ tools: f.home, env, nodeVersion: "24.21.0", adapters }), error, label);
|
|
535
|
+
assert.deepEqual(readdirSync(f.home), [], label);
|
|
536
|
+
} finally { f.cleanup(); }
|
|
537
|
+
}
|
|
538
|
+
});
|
|
539
|
+
posixTest("missing required shell utility is named", () => {
|
|
540
|
+
const f = fixture();
|
|
541
|
+
try {
|
|
542
|
+
f.wizard();
|
|
543
|
+
unlinkSync(join(f.bin, "curl"));
|
|
544
|
+
const result = f.run();
|
|
545
|
+
assert.notEqual(result.status, 0);
|
|
546
|
+
assert.match(result.stderr, /Required utility missing: curl/);
|
|
547
|
+
} finally { f.cleanup(); }
|
|
548
|
+
});
|
|
549
|
+
posixTest("symlink HOME and conflicting staging are refused without touching targets", () => {
|
|
550
|
+
const f = fixture();
|
|
551
|
+
try {
|
|
552
|
+
f.addPnpm(); f.wizard();
|
|
553
|
+
const link = join(f.root, "linked-home"); symlinkSync(f.home, link);
|
|
554
|
+
assert.match(f.run({ HOME: link }).stderr, /symlink|unsafe/i);
|
|
555
|
+
f.executable("mktemp", `printf '%s\\n' '${f.bin}'`);
|
|
556
|
+
const result = f.run();
|
|
557
|
+
assert.notEqual(result.status, 0);
|
|
558
|
+
assert.ok(existsSync(join(f.bin, "pnpm")));
|
|
559
|
+
} finally { f.cleanup(); }
|
|
560
|
+
});
|
|
561
|
+
posixTest("pnpm acquisition refuses conflicting and symlink destinations before download", async () => {
|
|
562
|
+
const f = fixture();
|
|
563
|
+
try {
|
|
564
|
+
mkdirSync(join(f.home, "pnpm"));
|
|
565
|
+
let downloads = 0;
|
|
566
|
+
await assert.rejects(ensurePnpm({ tools: f.home, env: { PATH: f.bin }, nodeVersion: "24.21.0", adapters: {
|
|
567
|
+
download: async () => { downloads += 1; return Buffer.alloc(0); },
|
|
568
|
+
} }), /destination/);
|
|
569
|
+
assert.equal(downloads, 0);
|
|
570
|
+
} finally { f.cleanup(); }
|
|
571
|
+
});
|
|
572
|
+
posixTest("wizard child failure is propagated and missing entry is explicit", async () => {
|
|
573
|
+
await assert.rejects(launchWizard({ bundle: "/missing-bundle", env: {} }), /wizard entry.*missing/i);
|
|
574
|
+
const f = fixture();
|
|
575
|
+
try {
|
|
576
|
+
writeFileSync(join(f.bundle, "bin/gentle-shell-install.mjs"), "process.exit(7);\n");
|
|
577
|
+
await assert.rejects(launchWizard({ bundle: f.bundle, env: {} }), /Wizard child failed/);
|
|
578
|
+
f.addPnpm();
|
|
579
|
+
assert.notEqual(f.run().status, 0);
|
|
580
|
+
assert.deepEqual(readdirSync(f.home), []);
|
|
581
|
+
} finally { f.cleanup(); }
|
|
582
|
+
});
|
|
583
|
+
|
|
584
|
+
for (const [os, arch, platform, architecture] of [["Darwin", "arm64", "darwin", "arm64"], ["Darwin", "x86_64", "darwin", "x64"], ["Linux", "aarch64", "linux", "arm64"]]) {
|
|
585
|
+
posixTest(`shell artifact selection ${os}/${arch} (simulated, not native proof)`, () => {
|
|
586
|
+
const f = fixture();
|
|
587
|
+
try {
|
|
588
|
+
f.addPnpm(); f.wizard();
|
|
589
|
+
const hash = artifactFor("node", platform, architecture).integrity.slice(7);
|
|
590
|
+
assert.equal(f.run({ FAKE_OS: os, FAKE_ARCH: arch, FAKE_HASH: hash }).status, 0);
|
|
591
|
+
} finally { f.cleanup(); }
|
|
592
|
+
});
|
|
593
|
+
}
|
|
594
|
+
for (const extra of [{ FAKE_ARCH: "riscv64" }, { FAKE_OS: "FreeBSD" }, { FAKE_LIBC: "glibc 2.27" }]) {
|
|
595
|
+
posixTest(`unsupported acquisition blocks before download: ${JSON.stringify(extra)}`, () => {
|
|
596
|
+
const f = fixture();
|
|
597
|
+
try {
|
|
598
|
+
f.wizard();
|
|
599
|
+
assert.notEqual(f.run(extra).status, 0);
|
|
600
|
+
assert.deepEqual(readdirSync(f.home), []);
|
|
601
|
+
} finally { f.cleanup(); }
|
|
602
|
+
});
|
|
603
|
+
}
|
|
604
|
+
for (const [utility, body, message] of [
|
|
605
|
+
["curl", 'while [ "$#" -gt 0 ]; do if [ "$1" = --output ]; then shift; out=$1; fi; shift; done; : > "$out"', /download/i],
|
|
606
|
+
["sha256sum", "exit 2", /SHA256 process failed/],
|
|
607
|
+
["tar", "exit 2", /archive is invalid/],
|
|
608
|
+
["tar", "echo 'lrwxrwxrwx malicious-link'", /not a regular file/],
|
|
609
|
+
] as [string, string, RegExp][]) {
|
|
610
|
+
posixTest(`native acquisition process rejection: ${utility}/${message}`, () => {
|
|
611
|
+
const f = fixture();
|
|
612
|
+
try {
|
|
613
|
+
f.addPnpm(); f.wizard(); f.executable(utility, body);
|
|
614
|
+
const result = f.run();
|
|
615
|
+
assert.notEqual(result.status, 0);
|
|
616
|
+
assert.match(result.stderr, message);
|
|
617
|
+
assert.deepEqual(readdirSync(f.home), []);
|
|
618
|
+
} finally { f.cleanup(); }
|
|
619
|
+
});
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
interface AcquisitionOptions {
|
|
623
|
+
names?: string;
|
|
624
|
+
types?: string;
|
|
625
|
+
version?: string;
|
|
626
|
+
help?: string;
|
|
627
|
+
failProcess?: boolean;
|
|
628
|
+
}
|
|
629
|
+
function acquisitionAdapters(options: AcquisitionOptions = {}) {
|
|
630
|
+
const calls: string[] = [];
|
|
631
|
+
return {
|
|
632
|
+
calls,
|
|
633
|
+
download: async () => Buffer.from("verified fixture"),
|
|
634
|
+
digest: () => Buffer.from(artifactFor("pnpm").integrity.slice(7), "base64"),
|
|
635
|
+
process: (command: string, args: string[]) => {
|
|
636
|
+
calls.push(`${command}:${args[0]}`);
|
|
637
|
+
if (options.failProcess) throw Error("injected process failure");
|
|
638
|
+
if (args[0] === "-tzf") return options.names ?? "package/\npackage/package.json\npackage/bin/pnpm.mjs";
|
|
639
|
+
if (args[0] === "-tvzf") return options.types ?? "drwx------ package/\n-rw------- package/package.json\n-rwx------ package/bin/pnpm.mjs";
|
|
640
|
+
if (args[0] === "-xzf") {
|
|
641
|
+
const pkg = join(args[3], "package");
|
|
642
|
+
mkdirSync(join(pkg, "bin"), { recursive: true, mode: 0o700 });
|
|
643
|
+
// Literal parent-verified upstream metadata, intentionally not descriptor-derived.
|
|
644
|
+
writeFileSync(join(pkg, "package.json"), JSON.stringify({ name: "pnpm", version: "11.1.1", engines: { node: ">=22.13" } }));
|
|
645
|
+
writeFileSync(join(pkg, "bin/pnpm.mjs"), "console.log(process.argv.includes('--version') ? '11.1.1' : '--global');\n");
|
|
646
|
+
return "";
|
|
647
|
+
}
|
|
648
|
+
if (args.includes("--version")) return options.version ?? "11.1.1";
|
|
649
|
+
return options.help ?? "--global";
|
|
650
|
+
},
|
|
651
|
+
};
|
|
652
|
+
}
|
|
653
|
+
posixTest("verified pnpm with literal upstream >=22.13 engine publishes its owned wrapper", async () => {
|
|
654
|
+
const f = fixture();
|
|
655
|
+
try {
|
|
656
|
+
const adapters = acquisitionAdapters();
|
|
657
|
+
const result = await ensurePnpm({ tools: f.home, env: { PATH: f.bin }, nodeVersion: "24.21.0", adapters });
|
|
658
|
+
assert.equal(result.acquired, true);
|
|
659
|
+
assert.ok(result.env.PATH.startsWith(join(f.home, "pnpm/bin")));
|
|
660
|
+
assert.deepEqual(readdirSync(f.home), ["pnpm"]);
|
|
661
|
+
const wrapper = join(f.home, "pnpm/bin/pnpm");
|
|
662
|
+
assert.equal(spawnSync(wrapper, ["--version"], { encoding: "utf8" }).stdout.trim(), "11.1.1");
|
|
663
|
+
assert.ok(adapters.calls.every((call) => call.startsWith(join(f.bin, "tar")) || call.startsWith(node)));
|
|
664
|
+
} finally { f.cleanup(); }
|
|
665
|
+
});
|
|
666
|
+
for (const options of [{ names: "package/../escape" }, { names: "/absolute/path" }, { types: "lrwxrwxrwx package/link" }, { types: "hrw------- package/hardlink" }, { version: "11.1.0" }, { help: "--global-bin-dir" }, { failProcess: true }]) {
|
|
667
|
+
posixTest(`pnpm extraction/validation fails without publication: ${JSON.stringify(options)}`, async () => {
|
|
668
|
+
const f = fixture();
|
|
669
|
+
try {
|
|
670
|
+
await assert.rejects(ensurePnpm({ tools: f.home, env: { PATH: f.bin }, nodeVersion: "24.21.0", adapters: acquisitionAdapters(options) }), /acquisition failed/);
|
|
671
|
+
assert.deepEqual(readdirSync(f.home), []);
|
|
672
|
+
} finally { f.cleanup(); }
|
|
673
|
+
});
|
|
674
|
+
}
|
|
675
|
+
function silentWizard(bundle: string, body = "") {
|
|
676
|
+
writeFileSync(join(bundle, "bin/gentle-shell-install.mjs"), body);
|
|
677
|
+
}
|
|
678
|
+
posixTest("helper-created pnpm tools are removed after a successful wizard exit", async (t) => {
|
|
679
|
+
const f = fixture();
|
|
680
|
+
try {
|
|
681
|
+
silentWizard(f.bundle);
|
|
682
|
+
writeFileSync(join(f.home, "unrelated.txt"), "preserve");
|
|
683
|
+
const notices = t.mock.method(console, "error", () => {});
|
|
684
|
+
await bootstrap(f.bundle, "", { env: { PATH: f.bin, HOME: f.home }, adapters: acquisitionAdapters() });
|
|
685
|
+
assert.equal(notices.mock.callCount(), 0);
|
|
686
|
+
assert.deepEqual(readdirSync(f.home), ["unrelated.txt"]);
|
|
687
|
+
} finally { f.cleanup(); }
|
|
688
|
+
});
|
|
689
|
+
posixTest("helper-created tools are removed after wizard failure and kept with a notice when unprovable", async (t) => {
|
|
690
|
+
const f = fixture();
|
|
691
|
+
try {
|
|
692
|
+
silentWizard(f.bundle, "process.exit(3);\n");
|
|
693
|
+
await assert.rejects(bootstrap(f.bundle, "", { env: { PATH: f.bin, HOME: f.home }, adapters: acquisitionAdapters() }), /Wizard child failed/);
|
|
694
|
+
assert.deepEqual(readdirSync(f.home), []);
|
|
695
|
+
f.markerRemovingWizard();
|
|
696
|
+
const notices = t.mock.method(console, "error", () => {});
|
|
697
|
+
await bootstrap(f.bundle, "", { env: { PATH: f.bin, HOME: f.home }, adapters: acquisitionAdapters() });
|
|
698
|
+
const [tools] = readdirSync(f.home);
|
|
699
|
+
assert.match(tools, /^\.gentle-shell-bootstrap-tools\./);
|
|
700
|
+
assert.equal(notices.mock.callCount(), 1);
|
|
701
|
+
assert.ok(String(notices.mock.calls[0].arguments[0]).includes(`temporary tools could not be removed: ${join(f.home, tools)}`));
|
|
702
|
+
} finally { f.cleanup(); }
|
|
703
|
+
});
|
|
704
|
+
posixTest("owned tools removal refuses anything but the exact marked private directory", () => {
|
|
705
|
+
const f = fixture();
|
|
706
|
+
try {
|
|
707
|
+
const marked = (name: string, marker = "gentle-pi prerequisite tooling only\n") => {
|
|
708
|
+
const path = join(f.home, name);
|
|
709
|
+
mkdirSync(path, { mode: 0o700 });
|
|
710
|
+
writeFileSync(join(path, "keep"), "preserve");
|
|
711
|
+
if (marker) writeFileSync(join(path, ".bootstrap-owned"), marker);
|
|
712
|
+
return path;
|
|
713
|
+
};
|
|
714
|
+
const target = marked("target");
|
|
715
|
+
const link = join(f.home, ".gentle-shell-bootstrap-tools.link");
|
|
716
|
+
symlinkSync(target, link);
|
|
717
|
+
const outside = join(f.root, ".gentle-shell-bootstrap-tools.outside");
|
|
718
|
+
mkdirSync(outside); writeFileSync(join(outside, ".bootstrap-owned"), "gentle-pi prerequisite tooling only\n");
|
|
719
|
+
const nested = join(marked(".gentle-shell-bootstrap-tools.parent"), ".gentle-shell-bootstrap-tools.child");
|
|
720
|
+
mkdirSync(nested); writeFileSync(join(nested, ".bootstrap-owned"), "gentle-pi prerequisite tooling only\n");
|
|
721
|
+
const markerLink = marked(".gentle-shell-bootstrap-tools.markerlink", "");
|
|
722
|
+
symlinkSync(join(target, ".bootstrap-owned"), join(markerLink, ".bootstrap-owned"));
|
|
723
|
+
const refused = [link, outside, nested, marked("other-name"), marked(".gentle-shell-bootstrap-tools.nomarker", ""),
|
|
724
|
+
marked(".gentle-shell-bootstrap-tools.wrongmarker", "something else\n"), markerLink, `${f.home}/./.gentle-shell-bootstrap-tools.parent`, "", undefined];
|
|
725
|
+
for (const path of refused) assert.equal(removeOwnedTools(path, f.home), false, String(path));
|
|
726
|
+
assert.equal(readFileSync(join(target, "keep"), "utf8"), "preserve");
|
|
727
|
+
assert.ok(existsSync(outside) && existsSync(nested));
|
|
728
|
+
const owned = marked(".gentle-shell-bootstrap-tools.owned");
|
|
729
|
+
assert.equal(removeOwnedTools(owned, f.home), true);
|
|
730
|
+
assert.equal(existsSync(owned), false);
|
|
731
|
+
assert.equal(removeOwnedTools(owned, f.home), false, "already removed");
|
|
732
|
+
} finally { f.cleanup(); }
|
|
733
|
+
});
|
|
734
|
+
posixTest("pnpm symlink conflict does not touch its target", async () => {
|
|
735
|
+
const f = fixture();
|
|
736
|
+
try {
|
|
737
|
+
symlinkSync(f.bin, join(f.home, "pnpm"));
|
|
738
|
+
await assert.rejects(ensurePnpm({ tools: f.home, env: { PATH: f.bin }, nodeVersion: "24.21.0", adapters: acquisitionAdapters() }), /destination/);
|
|
739
|
+
assert.ok(existsSync(join(f.bin, "curl")));
|
|
740
|
+
} finally { f.cleanup(); }
|
|
741
|
+
});
|
|
742
|
+
posixTest("native Node destination symlink is refused without following its target", () => {
|
|
743
|
+
const f = fixture();
|
|
744
|
+
try {
|
|
745
|
+
f.addPnpm(); f.wizard();
|
|
746
|
+
const tar = readFileSync(join(f.bin, "tar"), "utf8");
|
|
747
|
+
f.executable("tar", tar.replace('mkdir -p "$target/', `${systemUtility("ln")} -s '${f.bin}' "$target/../node"\nmkdir -p "$target/`));
|
|
748
|
+
const result = f.run();
|
|
749
|
+
assert.notEqual(result.status, 0);
|
|
750
|
+
assert.match(result.stderr, /Conflicting Node destination/);
|
|
751
|
+
assert.ok(existsSync(join(f.bin, "pnpm")));
|
|
752
|
+
assert.deepEqual(readdirSync(f.home), []);
|
|
753
|
+
} finally { f.cleanup(); }
|
|
754
|
+
});
|
|
755
|
+
posixTest("existing Node failed probe is not repaired", () => {
|
|
756
|
+
const f = fixture();
|
|
757
|
+
try {
|
|
758
|
+
f.addNode(); f.wizard(); f.executable("node", "exit 4");
|
|
759
|
+
assert.match(f.run().stderr, /Existing Node version probe failed/);
|
|
760
|
+
assert.deepEqual(readdirSync(f.home), []);
|
|
761
|
+
} finally { f.cleanup(); }
|
|
762
|
+
});
|
|
763
|
+
posixTest("pnpm without required global capabilities is not replaced", () => {
|
|
764
|
+
const f = fixture();
|
|
765
|
+
try {
|
|
766
|
+
f.addNode(); f.addPnpm(); f.wizard();
|
|
767
|
+
writeFileSync(join(f.bin, "pnpm"), "#!/bin/sh\n[ \"$1\" != --version ] || echo 11.1.1\n", { mode: 0o755 });
|
|
768
|
+
assert.match(f.run().stderr, /capability evidence missing/);
|
|
769
|
+
assert.deepEqual(readdirSync(f.home), []);
|
|
770
|
+
} finally { f.cleanup(); }
|
|
771
|
+
});
|
|
772
|
+
posixTest("missing tar blocks pnpm before any download", async () => {
|
|
773
|
+
const f = fixture();
|
|
774
|
+
try {
|
|
775
|
+
unlinkSync(join(f.bin, "tar"));
|
|
776
|
+
let downloaded = false;
|
|
777
|
+
await assert.rejects(ensurePnpm({ tools: f.home, env: { PATH: f.bin }, nodeVersion: "24.21.0", adapters: {
|
|
778
|
+
download: async () => { downloaded = true; return Buffer.alloc(0); },
|
|
779
|
+
} }), /Required utility missing: tar/);
|
|
780
|
+
assert.equal(downloaded, false);
|
|
781
|
+
} finally { f.cleanup(); }
|
|
782
|
+
});
|
|
783
|
+
test("oversized verified-download input is rejected before digest", async () => {
|
|
784
|
+
let hashed = false;
|
|
785
|
+
await assert.rejects(verifiedDownload("pnpm", {
|
|
786
|
+
download: async () => Buffer.alloc(artifactFor("pnpm").maxBytes + 1),
|
|
787
|
+
digest: () => { hashed = true; return Buffer.alloc(0); },
|
|
788
|
+
}), /size rejected/);
|
|
789
|
+
assert.equal(hashed, false);
|
|
790
|
+
});
|
|
791
|
+
interface GuardedResult {
|
|
792
|
+
status: number | null;
|
|
793
|
+
signal: string | null;
|
|
794
|
+
stdout: string;
|
|
795
|
+
stderr: string;
|
|
796
|
+
guardKilled: boolean;
|
|
797
|
+
elapsed: number;
|
|
798
|
+
}
|
|
799
|
+
|
|
800
|
+
// Independent asynchronous guard: even a blocked spawnSync inside the fixture
|
|
801
|
+
// cannot block this test process. Only this fresh detached fixture group is killed.
|
|
802
|
+
async function guardedFixture(command: string, args: string[], env: Record<string, string>, limit: number): Promise<GuardedResult> {
|
|
803
|
+
const start = Date.now();
|
|
804
|
+
const child = spawn(command, args, { env, detached: true, stdio: ["ignore", "pipe", "pipe"] });
|
|
805
|
+
const group = child.pid;
|
|
806
|
+
let stdout = "";
|
|
807
|
+
let stderr = "";
|
|
808
|
+
let guardKilled = false;
|
|
809
|
+
const killOwnedGroup = () => {
|
|
810
|
+
if (group === undefined) return;
|
|
811
|
+
try { process.kill(-group, "SIGKILL"); }
|
|
812
|
+
catch (error) { if ((error as NodeJS.ErrnoException).code !== "ESRCH") throw error; }
|
|
813
|
+
};
|
|
814
|
+
const timer = setTimeout(() => { guardKilled = true; killOwnedGroup(); }, limit);
|
|
815
|
+
child.stdout.on("data", (data: Buffer) => { stdout += data.toString(); });
|
|
816
|
+
child.stderr.on("data", (data: Buffer) => { stderr += data.toString(); });
|
|
817
|
+
try {
|
|
818
|
+
return await new Promise<GuardedResult>((resolveChild, reject) => {
|
|
819
|
+
child.once("error", reject);
|
|
820
|
+
child.once("close", (status, signal) => resolveChild({ status, signal, stdout, stderr, guardKilled, elapsed: Date.now() - start }));
|
|
821
|
+
});
|
|
822
|
+
} finally {
|
|
823
|
+
clearTimeout(timer);
|
|
824
|
+
// Also clean residual fixture descendants if the group leader exits early.
|
|
825
|
+
killOwnedGroup();
|
|
826
|
+
}
|
|
827
|
+
}
|
|
828
|
+
function termIgnoringProbe(root: string, output: string) {
|
|
829
|
+
const probe = join(root, "term-ignoring-probe.mjs");
|
|
830
|
+
const pid = join(root, "owned-probe.pid");
|
|
831
|
+
writeFileSync(probe, `import { writeFileSync } from 'node:fs';\nprocess.on('SIGTERM', () => {});\nwriteFileSync(process.env.PROBE_PID, String(process.pid));\nconsole.log(${JSON.stringify(output)});\nsetInterval(() => {}, 1000);\n`);
|
|
832
|
+
return { probe, pid };
|
|
833
|
+
}
|
|
834
|
+
function assertProbeReaped(path: string) {
|
|
835
|
+
const pid = Number(readFileSync(path, "utf8"));
|
|
836
|
+
assert.ok(Number.isSafeInteger(pid) && pid > 0);
|
|
837
|
+
assert.throws(() => process.kill(pid, 0), { code: "ESRCH" }, "owned probe must be reaped before the parent returns");
|
|
838
|
+
}
|
|
839
|
+
|
|
840
|
+
posixTest("production shell deadline rejects TERM-ignored acquired Node and cleans owned tooling", async () => {
|
|
841
|
+
const f = fixture();
|
|
842
|
+
try {
|
|
843
|
+
f.addPnpm(); f.wizard();
|
|
844
|
+
writeFileSync(join(f.home, "unrelated.txt"), "preserve");
|
|
845
|
+
const { probe, pid } = termIgnoringProbe(f.root, "v24.21.0");
|
|
846
|
+
const result = await guardedFixture("/bin/sh", [join(f.bundle, "scripts/bootstrap.sh")], {
|
|
847
|
+
PATH: f.bin, HOME: f.home, NODE_PROBE_SCRIPT: probe, PROBE_PID: pid,
|
|
848
|
+
}, 13000);
|
|
849
|
+
assert.equal(result.guardKilled, false, `shell required outer SIGKILL after ${result.elapsed}ms`);
|
|
850
|
+
assert.equal(result.status, 1);
|
|
851
|
+
assert.equal(result.signal, null);
|
|
852
|
+
assert.match(result.stderr, /Acquired Node cannot execute/);
|
|
853
|
+
assert.doesNotMatch(result.stdout, /wizard-child/);
|
|
854
|
+
assertProbeReaped(pid);
|
|
855
|
+
assert.deepEqual(readdirSync(f.home), ["unrelated.txt"]);
|
|
856
|
+
assert.equal(readFileSync(join(f.home, "unrelated.txt"), "utf8"), "preserve");
|
|
857
|
+
} finally { f.cleanup(); }
|
|
858
|
+
});
|
|
859
|
+
|
|
860
|
+
function productionPnpmRunner(root: string, home: string, bin: string) {
|
|
861
|
+
const runner = join(root, "production-pnpm-runner.mjs");
|
|
862
|
+
// Inject only verified-byte transport/digest, not the production process adapter.
|
|
863
|
+
writeFileSync(runner, `import { artifactFor, ensurePnpm } from ${JSON.stringify(pathToFileURL(helper).href)};\ntry {\nawait ensurePnpm({ tools: ${JSON.stringify(home)}, env: { PATH: ${JSON.stringify(bin)}, PROBE_PID: process.env.PROBE_PID }, nodeVersion: '24.21.0', adapters: {\ndownload: async () => Buffer.from('verified fixture'),\ndigest: () => Buffer.from(artifactFor('pnpm').integrity.slice(7), 'base64'),\n} });\nconsole.log('unexpected acquisition success');\n} catch { console.error('acquisition rejected'); process.exitCode = 1; }\n`);
|
|
864
|
+
return runner;
|
|
865
|
+
}
|
|
866
|
+
posixTest("production Node process deadline rejects TERM-ignored tar and cleans owned staging", async () => {
|
|
867
|
+
const f = fixture();
|
|
868
|
+
try {
|
|
869
|
+
writeFileSync(join(f.home, "unrelated.txt"), "preserve");
|
|
870
|
+
const { probe, pid } = termIgnoringProbe(f.root, "package/");
|
|
871
|
+
f.executable("tar", `exec '${node}' '${probe}'`);
|
|
872
|
+
const result = await guardedFixture(node, [productionPnpmRunner(f.root, f.home, f.bin)], { PROBE_PID: pid }, 18000);
|
|
873
|
+
assert.equal(result.guardKilled, false, `Node process check required outer SIGKILL after ${result.elapsed}ms`);
|
|
874
|
+
assert.equal(result.status, 1);
|
|
875
|
+
assert.equal(result.signal, null);
|
|
876
|
+
assert.match(result.stderr, /acquisition rejected/);
|
|
877
|
+
assert.doesNotMatch(result.stdout, /unexpected acquisition success/);
|
|
878
|
+
assertProbeReaped(pid);
|
|
879
|
+
assert.deepEqual(readdirSync(f.home), ["unrelated.txt"]);
|
|
880
|
+
assert.equal(readFileSync(join(f.home, "unrelated.txt"), "utf8"), "preserve");
|
|
881
|
+
} finally { f.cleanup(); }
|
|
882
|
+
});
|
|
883
|
+
posixTest("production Node process adapter propagates ordinary nonzero tar exit with cleanup", async () => {
|
|
884
|
+
const f = fixture();
|
|
885
|
+
try {
|
|
886
|
+
writeFileSync(join(f.home, "unrelated.txt"), "preserve");
|
|
887
|
+
f.executable("tar", "exit 7");
|
|
888
|
+
const result = await guardedFixture(node, [productionPnpmRunner(f.root, f.home, f.bin)], {}, 3000);
|
|
889
|
+
assert.equal(result.guardKilled, false);
|
|
890
|
+
assert.equal(result.status, 1);
|
|
891
|
+
assert.match(result.stderr, /acquisition rejected/);
|
|
892
|
+
assert.deepEqual(readdirSync(f.home), ["unrelated.txt"]);
|
|
893
|
+
} finally { f.cleanup(); }
|
|
894
|
+
});
|
|
895
|
+
|
|
896
|
+
posixTest("failed pnpm download never reaches archive/process adapters", async () => {
|
|
897
|
+
const f = fixture();
|
|
898
|
+
try {
|
|
899
|
+
let processes = 0;
|
|
900
|
+
await assert.rejects(ensurePnpm({ tools: f.home, env: { PATH: f.bin }, nodeVersion: "24.21.0", adapters: {
|
|
901
|
+
download: async () => Buffer.from("truncated"),
|
|
902
|
+
process: () => { processes += 1; return ""; },
|
|
903
|
+
} }), /acquisition failed/);
|
|
904
|
+
assert.equal(processes, 0);
|
|
905
|
+
assert.deepEqual(readdirSync(f.home), []);
|
|
906
|
+
} finally { f.cleanup(); }
|
|
907
|
+
});
|
|
908
|
+
|
|
909
|
+
// --- Pinned Go toolchain: official go.dev archives, verified, extracted in process ---------------
|
|
910
|
+
const goPins = {
|
|
911
|
+
"darwin-arm64": ["go1.25.14.darwin-arm64.tar.gz", "5b26c0b6f308240fca2614fb02f622cfcc8c0cc3b69c78bba4845489a4590259", 58123934],
|
|
912
|
+
"darwin-x64": ["go1.25.14.darwin-amd64.tar.gz", "b09087a67d5792a8b0fcbf74212d62560c94ac8a8fd750ff920d8cb5f1e20118", 60622678],
|
|
913
|
+
"linux-x64": ["go1.25.14.linux-amd64.tar.gz", "a21ae5633a269bcd7e90cf767e48225633795e99d831742cbf3397064fee7712", 59909419],
|
|
914
|
+
"linux-arm64": ["go1.25.14.linux-arm64.tar.gz", "9bf234ea70ffec9347fdf6b22ce4add51717d3386a38a441e8c8743fceb5eaee", 57360344],
|
|
915
|
+
"win32-x64": ["go1.25.14.windows-amd64.zip", "119044a92b3987c341cd6aebb256676dd4780d292f7b4e72a3e9976677841697", 67591780],
|
|
916
|
+
"win32-arm64": ["go1.25.14.windows-arm64.zip", "96fb31ae26b288b5311bd31d8252d4a62c8a661e4dbb64d504cc646e4d10a57f", 64735369],
|
|
917
|
+
} as const;
|
|
918
|
+
for (const [target, [file, sha256, size]] of Object.entries(goPins)) {
|
|
919
|
+
test(`fixed Go descriptor for ${target} is the go.dev archive and checksum`, () => {
|
|
920
|
+
const [platform, arch] = target.split("-");
|
|
921
|
+
const artifact = artifactFor("go", platform, arch);
|
|
922
|
+
assert.equal(artifact.version, "1.25.14");
|
|
923
|
+
assert.equal(artifact.url, `https://dl.google.com/go/${file}`);
|
|
924
|
+
assert.equal(artifact.integrity, `sha256-${sha256}`);
|
|
925
|
+
assert.equal(artifact.size, size);
|
|
926
|
+
});
|
|
927
|
+
}
|
|
928
|
+
test("Go has no descriptor for other targets", () => {
|
|
929
|
+
for (const [platform, arch] of [["linux", "ia32"], ["freebsd", "x64"], ["win32", "ia32"]]) {
|
|
930
|
+
assert.throws(() => artifactFor("go", platform, arch), /Unsupported/);
|
|
931
|
+
}
|
|
932
|
+
});
|
|
933
|
+
|
|
934
|
+
// The Engram release the bundled install ships (S9), from its checksums.txt and
|
|
935
|
+
// the GitHub asset digests; every archive was downloaded and re-hashed.
|
|
936
|
+
const engramPins = {
|
|
937
|
+
"darwin-arm64": ["darwin_arm64.tar.gz", "d0aa59c9a83d17ae3fc7f5dd31fdab6a26fd8c00fc5e692195753cd0719294f7", 7470202],
|
|
938
|
+
"darwin-x64": ["darwin_amd64.tar.gz", "3c5bdc73d226af5629cf4f4a781e223142e37c1ebb443c4b596f2e284352e381", 7961674],
|
|
939
|
+
"linux-x64": ["linux_amd64.tar.gz", "715fda625556e46d65ddb4526bb7f88a3fee5339ca3c44ce44cc652f17fa57f1", 7821191],
|
|
940
|
+
"linux-arm64": ["linux_arm64.tar.gz", "3309e9c0bbb539d183fd5aa9f9a13cd7aa0e81ec2512b5bedefb8b2bdd35b3e5", 7176726],
|
|
941
|
+
"win32-x64": ["windows_amd64.zip", "3065736ec7ff8137eb3fc607275f542ea13bb743a5157318c8a8770f11ce3289", 8021225],
|
|
942
|
+
"win32-arm64": ["windows_arm64.zip", "7463872a85a5e5434cf958bdffc71dda1926bced30d3b6b38c80fec9bb98fbba", 7231641],
|
|
943
|
+
} as const;
|
|
944
|
+
test("fixed Engram descriptors are the GitHub release archives and checksums, and nothing else", () => {
|
|
945
|
+
for (const [target, [suffix, sha256, size]] of Object.entries(engramPins)) {
|
|
946
|
+
const [platform, arch] = target.split("-");
|
|
947
|
+
const artifact = artifactFor("engram", platform, arch);
|
|
948
|
+
assert.equal(artifact.version, "3.4.0");
|
|
949
|
+
assert.equal(artifact.url, `https://github.com/Gentleman-Programming/engram/releases/download/v3.4.0/engram_3.4.0_${suffix}`);
|
|
950
|
+
assert.equal(artifact.integrity, `sha256-${sha256}`);
|
|
951
|
+
assert.deepEqual([artifact.size, artifact.maxBytes], [size, size]);
|
|
952
|
+
// GitHub serves release assets from its asset hosts.
|
|
953
|
+
assert.deepEqual(artifact.redirectHosts, ["objects.githubusercontent.com", "release-assets.githubusercontent.com"]);
|
|
954
|
+
}
|
|
955
|
+
for (const [platform, arch] of [["linux", "ia32"], ["freebsd", "x64"], ["win32", "ia32"]]) {
|
|
956
|
+
assert.throws(() => artifactFor("engram", platform, arch), /Unsupported/);
|
|
957
|
+
}
|
|
958
|
+
});
|
|
959
|
+
|
|
960
|
+
function tarHeader(name: string, size: number, { type = "0", mode = 0o644, link = "", prefix = "" } = {}) {
|
|
961
|
+
const header = Buffer.alloc(512);
|
|
962
|
+
header.write(name, 0, 100, "utf8");
|
|
963
|
+
header.write(`${mode.toString(8).padStart(7, "0")}\0`, 100);
|
|
964
|
+
header.write("0000000\0", 108);
|
|
965
|
+
header.write("0000000\0", 116);
|
|
966
|
+
header.write(`${size.toString(8).padStart(11, "0")}\0`, 124);
|
|
967
|
+
header.write("00000000000\0", 136);
|
|
968
|
+
header.write(" ", 148);
|
|
969
|
+
header.write(type, 156);
|
|
970
|
+
header.write(link, 157);
|
|
971
|
+
header.write("ustar\0", 257);
|
|
972
|
+
header.write("00", 263);
|
|
973
|
+
header.write(prefix, 345);
|
|
974
|
+
let sum = 0;
|
|
975
|
+
for (const byte of header) sum += byte;
|
|
976
|
+
header.write(`${sum.toString(8).padStart(6, "0")}\0 `, 148);
|
|
977
|
+
return header;
|
|
978
|
+
}
|
|
979
|
+
type GoEntry = { name: string; body?: string; type?: string; mode?: number; link?: string; prefix?: string };
|
|
980
|
+
function tarEntry({ name, body = "", ...options }: GoEntry) {
|
|
981
|
+
const data = Buffer.from(body);
|
|
982
|
+
return Buffer.concat([tarHeader(name, data.length, options), data, Buffer.alloc((512 - (data.length % 512)) % 512)]);
|
|
983
|
+
}
|
|
984
|
+
function paxPath(path: string) {
|
|
985
|
+
const record = (length: number) => `${length} path=${path}\n`;
|
|
986
|
+
let length = Buffer.byteLength(record(0));
|
|
987
|
+
while (Buffer.byteLength(record(length)) !== length) length = Buffer.byteLength(record(length));
|
|
988
|
+
return tarEntry({ name: "go/PaxHeaders.0/x", type: "x", body: record(length) });
|
|
989
|
+
}
|
|
990
|
+
const goTarGz = (...entries: Buffer[]) => gzipSync(Buffer.concat([...entries, Buffer.alloc(1024)]));
|
|
991
|
+
function goZip(files: Array<{ name: string; body: string; mode?: number }>) {
|
|
992
|
+
const locals: Buffer[] = [];
|
|
993
|
+
const centrals: Buffer[] = [];
|
|
994
|
+
let offset = 0;
|
|
995
|
+
for (const { name, body, mode = 0o100644 } of files) {
|
|
996
|
+
const data = deflateRawSync(Buffer.from(body));
|
|
997
|
+
const nameBytes = Buffer.from(name);
|
|
998
|
+
const local = Buffer.alloc(30);
|
|
999
|
+
local.writeUInt32LE(0x04034b50, 0);
|
|
1000
|
+
local.writeUInt16LE(20, 4);
|
|
1001
|
+
local.writeUInt16LE(0x800, 6);
|
|
1002
|
+
local.writeUInt16LE(8, 8);
|
|
1003
|
+
local.writeUInt32LE(crc32(Buffer.from(body)), 14);
|
|
1004
|
+
local.writeUInt32LE(data.length, 18);
|
|
1005
|
+
local.writeUInt32LE(Buffer.byteLength(body), 22);
|
|
1006
|
+
local.writeUInt16LE(nameBytes.length, 26);
|
|
1007
|
+
const central = Buffer.alloc(46);
|
|
1008
|
+
central.writeUInt32LE(0x02014b50, 0);
|
|
1009
|
+
central.writeUInt16LE((3 << 8) | 20, 4);
|
|
1010
|
+
central.writeUInt16LE(20, 6);
|
|
1011
|
+
central.writeUInt16LE(0x800, 8);
|
|
1012
|
+
central.writeUInt16LE(8, 10);
|
|
1013
|
+
central.writeUInt32LE(crc32(Buffer.from(body)), 16);
|
|
1014
|
+
central.writeUInt32LE(data.length, 20);
|
|
1015
|
+
central.writeUInt32LE(Buffer.byteLength(body), 24);
|
|
1016
|
+
central.writeUInt16LE(nameBytes.length, 28);
|
|
1017
|
+
central.writeUInt32LE((mode << 16) >>> 0, 38);
|
|
1018
|
+
central.writeUInt32LE(offset, 42);
|
|
1019
|
+
locals.push(local, nameBytes, data);
|
|
1020
|
+
centrals.push(central, nameBytes);
|
|
1021
|
+
offset += local.length + nameBytes.length + data.length;
|
|
1022
|
+
}
|
|
1023
|
+
const directory = Buffer.concat(centrals);
|
|
1024
|
+
const end = Buffer.alloc(22);
|
|
1025
|
+
end.writeUInt32LE(0x06054b50, 0);
|
|
1026
|
+
end.writeUInt16LE(files.length, 8);
|
|
1027
|
+
end.writeUInt16LE(files.length, 10);
|
|
1028
|
+
end.writeUInt32LE(directory.length, 12);
|
|
1029
|
+
end.writeUInt32LE(offset, 16);
|
|
1030
|
+
return Buffer.concat([...locals, directory, end]);
|
|
1031
|
+
}
|
|
1032
|
+
const goVersionFile = "go1.25.14\ntime 2026-10-01T00:00:00Z\n";
|
|
1033
|
+
const goTree = (extra: Buffer[] = []) => goTarGz(
|
|
1034
|
+
tarEntry({ name: "go/", type: "5", mode: 0o755 }),
|
|
1035
|
+
tarEntry({ name: "go/VERSION", body: goVersionFile }),
|
|
1036
|
+
tarEntry({ name: "go/bin/go", body: "#!/bin/sh\necho 'go version go1.25.14 darwin/arm64'\n", mode: 0o755 }),
|
|
1037
|
+
tarEntry({ name: "long-name-with-a-ustar-prefix.go", prefix: "go/src/internal/trace/testdata/generators", body: "package x\n" }),
|
|
1038
|
+
paxPath("go/test/fixedbugs/issue27836.dir/\u00defoo.go"),
|
|
1039
|
+
tarEntry({ name: "go/test/fixedbugs/issue27836.dir/foo.go", body: "package foo\n" }),
|
|
1040
|
+
...extra);
|
|
1041
|
+
/** Trusted test adapters standing in for the pinned download: the fixture's own size and hash. */
|
|
1042
|
+
function goAdapters(bytes: Buffer, counter = { downloads: 0 }) {
|
|
1043
|
+
return {
|
|
1044
|
+
artifact: (name: string, platform: string, arch: string) => ({ ...artifactFor(name, platform, arch), size: bytes.length, maxBytes: bytes.length,
|
|
1045
|
+
integrity: `sha256-${createHash("sha256").update(bytes).digest("hex")}` }),
|
|
1046
|
+
download: async () => { counter.downloads += 1; return bytes; },
|
|
1047
|
+
};
|
|
1048
|
+
}
|
|
1049
|
+
function goRoot() {
|
|
1050
|
+
const base = mkdtempSync(join(realpathSync(tmpdir()), "pinned-go-"));
|
|
1051
|
+
return { base, root: join(base, "config", "tools", "go"), cleanup: () => rmSync(base, { recursive: true, force: true }) };
|
|
1052
|
+
}
|
|
1053
|
+
|
|
1054
|
+
test("the pinned Go is verified, extracted in process and published once into the installer's own directory", async () => {
|
|
1055
|
+
const r = goRoot();
|
|
1056
|
+
try {
|
|
1057
|
+
const counter = { downloads: 0 };
|
|
1058
|
+
const result = await acquireGo({ root: r.root, platform: "darwin", arch: "arm64", adapters: goAdapters(goTree(), counter) });
|
|
1059
|
+
const published = join(r.root, "1.25.14");
|
|
1060
|
+
assert.deepEqual(result, { goPath: join(published, "go", "bin", "go"), version: "1.25.14", acquired: true });
|
|
1061
|
+
assert.equal(readFileSync(join(published, "go", "VERSION"), "utf8"), goVersionFile);
|
|
1062
|
+
assert.equal(readFileSync(join(published, "go/src/internal/trace/testdata/generators/long-name-with-a-ustar-prefix.go"), "utf8"), "package x\n");
|
|
1063
|
+
assert.equal(readFileSync(join(published, "go/test/fixedbugs/issue27836.dir/\u00defoo.go"), "utf8"), "package foo\n");
|
|
1064
|
+
assert.equal(existsSync(join(published, "go/test/fixedbugs/issue27836.dir/foo.go")), false);
|
|
1065
|
+
if (process.platform !== "win32") {
|
|
1066
|
+
assert.equal(lstatSync(result.goPath).mode & 0o777, 0o755);
|
|
1067
|
+
assert.equal(lstatSync(join(published, "go", "VERSION")).mode & 0o777, 0o644);
|
|
1068
|
+
assert.equal(lstatSync(published).mode & 0o077, 0);
|
|
1069
|
+
}
|
|
1070
|
+
assert.deepEqual(readdirSync(r.root), ["1.25.14"], "no staging directory is left behind");
|
|
1071
|
+
assert.equal(installedGo(r.root, "darwin", "arm64"), result.goPath);
|
|
1072
|
+
// A later run reuses the published copy: no download, nothing replaced.
|
|
1073
|
+
const again = await acquireGo({ root: r.root, platform: "darwin", arch: "arm64", adapters: goAdapters(goTree(), counter) });
|
|
1074
|
+
assert.deepEqual(again, { ...result, acquired: false });
|
|
1075
|
+
assert.equal(counter.downloads, 1);
|
|
1076
|
+
// The marker names the exact pinned archive, so another target's copy is not reused.
|
|
1077
|
+
assert.equal(installedGo(r.root, "linux", "x64"), null);
|
|
1078
|
+
} finally { r.cleanup(); }
|
|
1079
|
+
});
|
|
1080
|
+
|
|
1081
|
+
test("the Windows Go zip is extracted in process into go\\bin\\go.exe", async () => {
|
|
1082
|
+
const r = goRoot();
|
|
1083
|
+
try {
|
|
1084
|
+
const bytes = goZip([{ name: "go/VERSION", body: goVersionFile }, { name: "go/bin/go.exe", body: "MZ", mode: 0o100755 },
|
|
1085
|
+
{ name: "go/src/cmd/go/main.go", body: "package main\n" }]);
|
|
1086
|
+
const result = await acquireGo({ root: r.root, platform: "win32", arch: "x64", adapters: goAdapters(bytes) });
|
|
1087
|
+
assert.equal(result.goPath, join(r.root, "1.25.14", "go", "bin", "go.exe"));
|
|
1088
|
+
assert.equal(readFileSync(result.goPath, "utf8"), "MZ");
|
|
1089
|
+
assert.equal(readFileSync(join(r.root, "1.25.14", "go/src/cmd/go/main.go"), "utf8"), "package main\n");
|
|
1090
|
+
} finally { r.cleanup(); }
|
|
1091
|
+
});
|
|
1092
|
+
|
|
1093
|
+
test("a Go download with the wrong size or checksum fails closed before anything is extracted", async () => {
|
|
1094
|
+
const r = goRoot();
|
|
1095
|
+
try {
|
|
1096
|
+
const bytes = goTree();
|
|
1097
|
+
const wrongHash = { ...goAdapters(bytes), digest: () => Buffer.alloc(32) };
|
|
1098
|
+
const wrongSize = { ...goAdapters(bytes), download: async () => Buffer.concat([bytes, Buffer.from("x")]) };
|
|
1099
|
+
const truncated = { ...goAdapters(bytes), download: async () => bytes.subarray(1) };
|
|
1100
|
+
for (const [adapters, cause] of [[wrongHash, /integrity/], [wrongSize, /size/], [truncated, /size/]] as const) {
|
|
1101
|
+
await assert.rejects(acquireGo({ root: r.root, platform: "darwin", arch: "arm64", adapters }),
|
|
1102
|
+
(error: Error) => /Go verified acquisition failed/.test(error.message) && cause.test(String((error.cause as Error)?.message)));
|
|
1103
|
+
assert.deepEqual(existsSync(r.root) ? readdirSync(r.root) : [], []);
|
|
1104
|
+
}
|
|
1105
|
+
// The production descriptor is the exact pinned size: anything else is rejected.
|
|
1106
|
+
await assert.rejects(acquireGo({ root: r.root, platform: "darwin", arch: "arm64", adapters: { download: async () => bytes } }),
|
|
1107
|
+
/Go verified acquisition failed/);
|
|
1108
|
+
} finally { r.cleanup(); }
|
|
1109
|
+
});
|
|
1110
|
+
|
|
1111
|
+
test("an unsafe Go archive fails closed and publishes nothing", async () => {
|
|
1112
|
+
const unsafe: Array<[string, Buffer]> = [
|
|
1113
|
+
["traversal", goTree([tarEntry({ name: "go/../escape", body: "x" })])],
|
|
1114
|
+
["absolute", goTree([tarEntry({ name: "/go/escape", body: "x" })])],
|
|
1115
|
+
["outside go/", goTree([tarEntry({ name: "other/file", body: "x" })])],
|
|
1116
|
+
["backslash", goTree([tarEntry({ name: "go\\..\\escape", body: "x" })])],
|
|
1117
|
+
["symlink", goTree([tarEntry({ name: "go/link", type: "2", link: "/etc/passwd" })])],
|
|
1118
|
+
["hard link", goTree([tarEntry({ name: "go/hard", type: "1", link: "go/VERSION" })])],
|
|
1119
|
+
["duplicate", goTree([tarEntry({ name: "go/VERSION", body: "go1.25.14\n" })])],
|
|
1120
|
+
["unknown pax key", goTree([tarEntry({ name: "go/PaxHeaders.0/y", type: "x", body: "19 linkpath=/etc/x\n" }), tarEntry({ name: "go/y", body: "y" })])],
|
|
1121
|
+
["missing go binary", goTarGz(tarEntry({ name: "go/VERSION", body: goVersionFile }))],
|
|
1122
|
+
["other version", goTarGz(tarEntry({ name: "go/VERSION", body: "go1.25.13\n" }), tarEntry({ name: "go/bin/go", body: "x", mode: 0o755 }))],
|
|
1123
|
+
["not a tar", gzipSync(Buffer.from("not a tar archive"))],
|
|
1124
|
+
["zip symlink", goZip([{ name: "go/VERSION", body: goVersionFile }, { name: "go/bin/go.exe", body: "MZ" }, { name: "go/link", body: "/etc", mode: 0o120777 }])],
|
|
1125
|
+
["zip traversal", goZip([{ name: "go/VERSION", body: goVersionFile }, { name: "go/bin/go.exe", body: "MZ" }, { name: "go/../../escape", body: "x" }])],
|
|
1126
|
+
];
|
|
1127
|
+
for (const [label, bytes] of unsafe) {
|
|
1128
|
+
const r = goRoot();
|
|
1129
|
+
try {
|
|
1130
|
+
const platform = label.startsWith("zip") ? "win32" : "darwin";
|
|
1131
|
+
await assert.rejects(acquireGo({ root: r.root, platform, arch: "arm64", adapters: goAdapters(bytes) }), /Go verified acquisition failed/, label);
|
|
1132
|
+
assert.deepEqual(readdirSync(r.root), [], label);
|
|
1133
|
+
assert.equal(existsSync(join(r.base, "escape")), false, label);
|
|
1134
|
+
} finally { r.cleanup(); }
|
|
1135
|
+
}
|
|
1136
|
+
});
|
|
1137
|
+
|
|
1138
|
+
test("an existing Go destination the installer did not publish is never replaced", async () => {
|
|
1139
|
+
const r = goRoot();
|
|
1140
|
+
try {
|
|
1141
|
+
mkdirSync(join(r.root, "1.25.14", "go", "bin"), { recursive: true });
|
|
1142
|
+
writeFileSync(join(r.root, "1.25.14", "go", "bin", "go"), "user's own");
|
|
1143
|
+
const counter = { downloads: 0 };
|
|
1144
|
+
// The cause names the folder in the way, so the wizard can say which one to remove.
|
|
1145
|
+
await assert.rejects(acquireGo({ root: r.root, platform: "darwin", arch: "arm64", adapters: goAdapters(goTree(), counter) }),
|
|
1146
|
+
(error: Error) => /Go verified acquisition failed/.test(error.message) &&
|
|
1147
|
+
(error.cause as Error)?.message === `Conflicting Go destination: ${join(r.root, "1.25.14")}`);
|
|
1148
|
+
assert.equal(counter.downloads, 0, "nothing is downloaded next to a conflicting folder");
|
|
1149
|
+
assert.equal(readFileSync(join(r.root, "1.25.14", "go", "bin", "go"), "utf8"), "user's own");
|
|
1150
|
+
assert.deepEqual(readdirSync(r.root), ["1.25.14"]);
|
|
1151
|
+
assert.equal(installedGo(r.root, "darwin", "arm64"), null);
|
|
1152
|
+
} finally { r.cleanup(); }
|
|
1153
|
+
});
|
|
1154
|
+
|
|
1155
|
+
test("the pinned Go destination appears only complete and marked, so an interrupted run never leaves it half published", async () => {
|
|
1156
|
+
const r = goRoot();
|
|
1157
|
+
const destination = join(r.root, "1.25.14");
|
|
1158
|
+
const marker = join(destination, ".gentle-shell-go");
|
|
1159
|
+
const violations: string[] = [];
|
|
1160
|
+
const originals = { mkdirSync: fs.mkdirSync, renameSync: fs.renameSync, writeFileSync: fs.writeFileSync };
|
|
1161
|
+
// Every filesystem mutation is a point where the process could die: after each
|
|
1162
|
+
// one, the destination is either absent or already carries its marker.
|
|
1163
|
+
for (const name of Object.keys(originals) as Array<keyof typeof originals>) {
|
|
1164
|
+
(fs as Record<string, unknown>)[name] = (...args: unknown[]) => {
|
|
1165
|
+
const result = (originals[name] as (...a: unknown[]) => unknown)(...args);
|
|
1166
|
+
if (existsSync(destination) && !existsSync(marker)) violations.push(`${name} ${String(args[0])}`);
|
|
1167
|
+
return result;
|
|
1168
|
+
};
|
|
1169
|
+
}
|
|
1170
|
+
syncBuiltinESMExports();
|
|
1171
|
+
try {
|
|
1172
|
+
// A staging directory left by an earlier interrupted run does not get in the way.
|
|
1173
|
+
mkdirSync(join(r.root, ".stage-interrupted", "go"), { recursive: true, mode: 0o700 });
|
|
1174
|
+
const result = await acquireGo({ root: r.root, platform: "darwin", arch: "arm64", adapters: goAdapters(goTree()) });
|
|
1175
|
+
assert.equal(result.acquired, true);
|
|
1176
|
+
assert.deepEqual(violations, []);
|
|
1177
|
+
assert.equal(readFileSync(marker, "utf8"), `${artifactFor("go", "darwin", "arm64").url}\n`);
|
|
1178
|
+
if (process.platform !== "win32") assert.equal(lstatSync(destination).mode & 0o077, 0);
|
|
1179
|
+
assert.equal(installedGo(r.root, "darwin", "arm64"), result.goPath);
|
|
1180
|
+
} finally {
|
|
1181
|
+
Object.assign(fs, originals);
|
|
1182
|
+
syncBuiltinESMExports();
|
|
1183
|
+
r.cleanup();
|
|
1184
|
+
}
|
|
1185
|
+
});
|
|
1186
|
+
|
|
1187
|
+
// CI runs the "native Windows" lane on windows-latest and requires every one of
|
|
1188
|
+
// those tests to pass with 0 skips: its count is the real number of native tests,
|
|
1189
|
+
// the literal ones plus one per pre-Node process primitive mode.
|
|
1190
|
+
test("the CI native Windows gate requires exactly the native tests the Windows bootstrap suite defines", () => {
|
|
1191
|
+
const source = readFileSync(new URL("./installer-windows-bootstrap.test.ts", import.meta.url), "utf8");
|
|
1192
|
+
const literal = source.match(/^test\("native Windows[^"]*"/gm)?.length ?? 0;
|
|
1193
|
+
const modes = /^const probeModes = \[([^\]]*)\] as const;$/m.exec(source)?.[1].split(",").filter((mode) => mode.trim().length > 0).length ?? 0;
|
|
1194
|
+
assert.ok(/^for \(const mode of probeModes\) \{\n\ttest\(`native Windows: /m.test(source.slice(source.indexOf("const probeModes"))));
|
|
1195
|
+
const ci = readFileSync(new URL("../.github/workflows/ci.yml", import.meta.url), "utf8");
|
|
1196
|
+
const gate = /\[int\]\$passes\.Groups\[1\]\.Value -ne (\d+) -or/.exec(ci);
|
|
1197
|
+
assert.ok(gate, "the gate compares the passed count exactly");
|
|
1198
|
+
assert.ok(/\[int\]\$skips\.Groups\[1\]\.Value -ne 0/.test(ci), "0 skips stays required");
|
|
1199
|
+
assert.equal(literal + modes, 38);
|
|
1200
|
+
assert.equal(Number(gate[1]), literal + modes);
|
|
1201
|
+
});
|