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.
Files changed (425) hide show
  1. package/README.md +157 -18
  2. package/assets/agents/gentle-ai-explore.md +11 -4
  3. package/assets/agents/gentle-ai-verify.md +23 -4
  4. package/assets/agents/gentle-ai-worker.md +6 -6
  5. package/assets/install-wizard/index.html +47 -0
  6. package/assets/install-wizard/wizard.css +1279 -0
  7. package/assets/install-wizard/wizard.js +951 -0
  8. package/assets/orchestrator-delegation.md +70 -163
  9. package/assets/orchestrator-memory.md +11 -4
  10. package/assets/orchestrator-prompts.md +48 -0
  11. package/assets/orchestrator-tracking.md +53 -0
  12. package/assets/orchestrator-verification.md +28 -0
  13. package/assets/orchestrator-writer.md +38 -0
  14. package/assets/orchestrator.md +29 -25
  15. package/assets/sounds/LICENSE.md +52 -0
  16. package/assets/sounds/attention.wav +0 -0
  17. package/assets/sounds/error.wav +0 -0
  18. package/assets/sounds/success.wav +0 -0
  19. package/bin/engram-guard +9 -0
  20. package/bin/gentle-shell-install.mjs +302 -0
  21. package/bin/gentle-shell.mjs +708 -22
  22. package/contracts/review-provider-contract-mirror/provider-contract.lock.json +7 -7
  23. package/contracts/review-provider-contract-mirror/{v1.2.0 → v1.3.0}/bundle/manifest.json +2 -2
  24. package/contracts/review-provider-contract-mirror/{v1.2.0 → v1.3.0}/bundle/orchestration/pi.md +16 -4
  25. package/contracts/review-provider-contract-mirror/{v1.2.0 → v1.3.0}/generated/provider-capabilities.baseline.json +2 -2
  26. package/contracts/review-provider-contract-mirror/{v1.2.0 → v1.3.0}/generated/provider-roles.baseline.json +1 -1
  27. package/docs/auto-permission-answers.md +25 -0
  28. package/docs/auto-permission-evidence.md +39 -0
  29. package/docs/bundled-install.md +399 -0
  30. package/docs/delegated-verification.md +3 -5
  31. package/docs/gentle-agents-activity.md +513 -0
  32. package/docs/gentle-shell-uninstall.md +136 -0
  33. package/docs/gentle-shell.md +90 -33
  34. package/docs/install-wizard.md +2040 -0
  35. package/docs/native-authority-architecture.md +1 -1
  36. package/docs/permission-modes.md +127 -0
  37. package/docs/prompt-history.md +1 -1
  38. package/docs/readme-reference.md +310 -47
  39. package/docs/render-cache-performance.md +28 -0
  40. package/docs/session-profile-format.md +39 -0
  41. package/docs/sound-notifications-proposal.md +191 -0
  42. package/docs/sound-notifications.md +114 -0
  43. package/docs/yolo-mode.md +11 -10
  44. package/extensions/ask-user-choice.ts +4 -0
  45. package/extensions/ask-user-question.ts +6 -1
  46. package/extensions/child-context.ts +11 -2
  47. package/extensions/child-safety.ts +4 -2
  48. package/extensions/codegraph-tools.ts +18 -5
  49. package/extensions/gentle-agents.ts +718 -87
  50. package/extensions/gentle-ai.ts +991 -263
  51. package/extensions/gentle-herdr-activity.ts +81 -0
  52. package/extensions/gentle-notifications.ts +104 -0
  53. package/extensions/gentle-shell.ts +271 -80
  54. package/extensions/gentle-todo.ts +123 -18
  55. package/extensions/history/index.ts +11 -6
  56. package/extensions/pi-pretty.ts +39 -3
  57. package/extensions/quiet-tools.ts +91 -21
  58. package/extensions/skill-registry.ts +175 -46
  59. package/extensions/startup-banner.ts +11 -2
  60. package/lib/agent-home.ts +10 -1
  61. package/lib/agent-model-resolution.ts +80 -0
  62. package/lib/agent-profiles.ts +13 -3
  63. package/lib/agents-completion-delivery.ts +11 -3
  64. package/lib/agents-messaging.ts +1 -1
  65. package/lib/agents-protocol.ts +40 -1
  66. package/lib/agents-runner.ts +90 -15
  67. package/lib/auto-permission-answers.ts +133 -0
  68. package/lib/auto-permission-classifier.ts +459 -0
  69. package/lib/auto-permission-evidence.ts +88 -0
  70. package/lib/auto-permission-memory.ts +69 -0
  71. package/lib/auto-permission-runtime.ts +484 -0
  72. package/lib/auto-permission-tool-context.ts +61 -0
  73. package/lib/background-jobs-tools.ts +202 -0
  74. package/lib/background-jobs.ts +253 -0
  75. package/lib/background-monitor.ts +154 -0
  76. package/lib/bounded-writer-admission.ts +83 -10
  77. package/lib/builtin-codemode-optout.ts +166 -0
  78. package/lib/card-content-policy.ts +85 -0
  79. package/lib/child-package-injection.ts +62 -0
  80. package/lib/codemode-renderer.ts +14 -4
  81. package/lib/command-palette-catalog.ts +3 -2
  82. package/lib/context-threshold.ts +26 -0
  83. package/lib/destructive-command-guard.ts +104 -28
  84. package/lib/gentle-ai-binary.ts +109 -4
  85. package/lib/gentle-shell-bundled.ts +535 -0
  86. package/lib/gentle-shell-launcher.ts +75 -24
  87. package/lib/gentle-shell-startup-flow.ts +54 -0
  88. package/lib/gentle-shell-startup-update.ts +92 -0
  89. package/lib/gentle-shell-uninstall.ts +840 -0
  90. package/lib/gentle-shell-update-panel.ts +231 -0
  91. package/lib/herdr-activity.ts +183 -0
  92. package/lib/inprocess-reviewer.ts +3 -0
  93. package/lib/jobs-sidebar-state.ts +20 -0
  94. package/lib/jobs-view.ts +161 -0
  95. package/lib/model-price-ratio.ts +83 -0
  96. package/lib/model-routing-authority.ts +6 -1
  97. package/lib/nan-provider.ts +33 -11
  98. package/lib/native-review-cli.ts +194 -73
  99. package/lib/notification-audio-native.ts +162 -0
  100. package/lib/notification-audio-windows.ts +399 -0
  101. package/lib/notification-audio.ts +332 -0
  102. package/lib/notification-customize.ts +241 -0
  103. package/lib/notification-events.ts +113 -0
  104. package/lib/notification-policy.ts +148 -0
  105. package/lib/notification-pulse-client.ts +367 -0
  106. package/lib/notification-pulse-protocol.ts +305 -0
  107. package/lib/notification-pulse-stream.ts +213 -0
  108. package/lib/notification-pulse-worker.ts +66 -0
  109. package/lib/notification-scheduler.ts +173 -0
  110. package/lib/notification-service.ts +202 -0
  111. package/lib/notification-ui.ts +87 -0
  112. package/lib/odd-phase.ts +1 -1
  113. package/lib/orchestrator-alias.ts +20 -0
  114. package/lib/orchestrator-catalog.ts +79 -0
  115. package/lib/orchestrator-consultation.ts +61 -0
  116. package/lib/orchestrator-discovery.ts +49 -0
  117. package/lib/orchestrator-helper-consent.ts +122 -0
  118. package/lib/orchestrator-helper.ts +167 -0
  119. package/lib/orchestrator-message-card.ts +105 -0
  120. package/lib/orchestrator-presence.ts +115 -14
  121. package/lib/orchestrator-scope.ts +80 -0
  122. package/lib/orchestrator-state.ts +107 -0
  123. package/lib/orchestrator-tool-card.ts +280 -0
  124. package/lib/orchestrator-work-search.ts +218 -0
  125. package/lib/orchestrator-work.ts +79 -0
  126. package/lib/permission-mode-policy.ts +59 -0
  127. package/lib/review-candidate-view-owner.ts +8 -6
  128. package/lib/review-candidate-view.ts +3 -2
  129. package/lib/review-compact-contract.ts +2 -2
  130. package/lib/review-host-relay.ts +7 -2
  131. package/lib/review-integration-v2.ts +100 -22
  132. package/lib/review-publication-gate.ts +1 -1
  133. package/lib/review-risk-assessment.ts +77 -51
  134. package/lib/review-sidebar-state.ts +2 -1
  135. package/lib/review-transaction.ts +72 -3
  136. package/lib/runtime-metrics-children.ts +1 -1
  137. package/lib/runtime-metrics-native.ts +3 -2
  138. package/lib/selection-engine.ts +22 -7
  139. package/lib/session-change-capture.ts +5 -4
  140. package/lib/session-changes.ts +27 -22
  141. package/lib/session-profile-binding.ts +83 -0
  142. package/lib/session-profile-freeze.ts +261 -0
  143. package/lib/session-profile-persistence.ts +187 -0
  144. package/lib/session-worktree-registry.ts +74 -8
  145. package/lib/shell-bar.ts +41 -11
  146. package/lib/shell-changes-view.ts +40 -13
  147. package/lib/shell-changes.ts +88 -71
  148. package/lib/shell-prompt.ts +6 -4
  149. package/lib/shell-sidebar-layout.ts +5 -1
  150. package/lib/shell-todo.ts +81 -26
  151. package/lib/shell-usage.ts +4 -3
  152. package/lib/status-timing-diagnostics.ts +201 -0
  153. package/lib/telemetry-trigger.ts +3 -1
  154. package/lib/vim-editor-adapter.ts +27 -5
  155. package/lib/visual-customization-policy.ts +20 -9
  156. package/lib/visual-customize-view.ts +171 -20
  157. package/lib/writer-surfaces.ts +162 -0
  158. package/lib/yolo-session-policy.ts +198 -121
  159. package/package.json +5 -5
  160. package/runtime/child-package-injection.mjs +63 -0
  161. package/runtime/gentle-ai-binary.mjs +109 -4
  162. package/runtime/gentle-shell-bundled.mjs +536 -0
  163. package/runtime/gentle-shell-launcher.mjs +74 -23
  164. package/runtime/gentle-shell-startup-flow.mjs +55 -0
  165. package/runtime/gentle-shell-startup-update.mjs +93 -0
  166. package/runtime/gentle-shell-uninstall.mjs +841 -0
  167. package/runtime/gentle-shell-update-panel.mjs +232 -0
  168. package/runtime/native-review-cli.mjs +191 -70
  169. package/runtime/review-integration-v2.mjs +100 -22
  170. package/runtime/review-risk-assessment.mjs +77 -51
  171. package/runtime/status-timing-diagnostics.mjs +202 -0
  172. package/runtime/telemetry-trigger.mjs +3 -1
  173. package/scripts/bootstrap.cmd +255 -0
  174. package/scripts/bootstrap.sh +168 -0
  175. package/scripts/build-distribution.mjs +157 -0
  176. package/scripts/build-installer-bundles.mjs +136 -0
  177. package/scripts/build-runtime-modules.mjs +7 -0
  178. package/scripts/bundled-install.mjs +1308 -0
  179. package/scripts/bundled-wizard.mjs +399 -0
  180. package/scripts/check-types.mjs +4 -6
  181. package/scripts/gentle-ai-installer.mjs +79 -32
  182. package/scripts/install-gentle-ai.mjs +48 -1
  183. package/scripts/install-tui-mode-setting.mjs +10 -1
  184. package/scripts/install-wizard-preview.mjs +154 -0
  185. package/scripts/installer-downloads.mjs +648 -0
  186. package/scripts/installer-preflight.mjs +387 -0
  187. package/scripts/installer-probes.mjs +564 -0
  188. package/scripts/installer-runner.mjs +1102 -0
  189. package/scripts/installer-server.mjs +881 -0
  190. package/scripts/installer-windows-artifacts.json +15 -0
  191. package/scripts/installer-windows.mjs +752 -0
  192. package/scripts/main-channel.mjs +452 -0
  193. package/scripts/npm-pack-result.mjs +31 -0
  194. package/scripts/pi-update.mjs +223 -0
  195. package/scripts/render-cache-performance-worker.mjs +205 -0
  196. package/scripts/render-cache-performance.mjs +115 -0
  197. package/scripts/run-test-suite.mjs +22 -8
  198. package/scripts/test-installer-acceptance.sh +161 -0
  199. package/scripts/test-packed-runner.mjs +23 -5
  200. package/scripts/types-baseline.json +2 -92
  201. package/scripts/verify-package-files.mjs +82 -18
  202. package/skills/gentle-ai/SKILL.md +12 -11
  203. package/tests/agent-home.test.ts +56 -3
  204. package/tests/agent-model-resolution.test.ts +113 -0
  205. package/tests/agent-profiles.test.ts +12 -0
  206. package/tests/agent-risk-escalation.test.ts +123 -0
  207. package/tests/agents-completion-delivery.test.ts +17 -0
  208. package/tests/agents-protocol.test.ts +57 -0
  209. package/tests/agents-queries.test.ts +9 -9
  210. package/tests/agents-runner-process.test.ts +31 -0
  211. package/tests/agents-runner.test.ts +89 -4
  212. package/tests/append-system-prompt-route.test.ts +18 -1
  213. package/tests/auto-mode-human-answers.test.ts +68 -0
  214. package/tests/auto-mode-invariants.test.ts +276 -0
  215. package/tests/auto-mode-sdk.test.ts +84 -0
  216. package/tests/auto-mode.test.ts +49 -0
  217. package/tests/auto-permission-answers.test.ts +170 -0
  218. package/tests/auto-permission-classifier.test.ts +634 -0
  219. package/tests/auto-permission-compound.test.ts +24 -0
  220. package/tests/auto-permission-evidence.test.ts +88 -0
  221. package/tests/auto-permission-memory.test.ts +46 -0
  222. package/tests/auto-permission-public-regressions.test.ts +111 -0
  223. package/tests/auto-permission-runtime.test.ts +454 -0
  224. package/tests/autonomous-guard.test.ts +86 -16
  225. package/tests/background-cache-warming.test.ts +2 -1
  226. package/tests/background-jobs.test.ts +227 -0
  227. package/tests/background-monitor.test.ts +195 -0
  228. package/tests/background-subagents-default-mode.test.ts +28 -5
  229. package/tests/bounded-stop-contract.test.ts +85 -0
  230. package/tests/bounded-writer-admission.test.ts +52 -1
  231. package/tests/bridge-wake-lifecycle.test.ts +129 -0
  232. package/tests/bridge-wake-rendering.test.ts +56 -0
  233. package/tests/build-distribution.test.ts +269 -0
  234. package/tests/builtin-codemode-optout.test.ts +242 -0
  235. package/tests/bundled-install.test.ts +1319 -0
  236. package/tests/bundled-wizard.test.ts +505 -0
  237. package/tests/card-content-policy.test.ts +68 -0
  238. package/tests/card-style-policy.test.ts +34 -2
  239. package/tests/child-codegraph-bootstrap.test.ts +74 -0
  240. package/tests/child-package-entrypoints.test.ts +65 -0
  241. package/tests/child-package-injection.test.ts +74 -0
  242. package/tests/child-safety.test.ts +12 -0
  243. package/tests/child-web-package.test.ts +224 -0
  244. package/tests/codegraph-tools.test.ts +35 -0
  245. package/tests/codemode-rendering.test.ts +107 -2
  246. package/tests/command-palette.test.ts +5 -5
  247. package/tests/context-threshold.test.ts +31 -0
  248. package/tests/delegate-for-reason-contract.test.ts +327 -0
  249. package/tests/delegated-key-learnings-contract.test.ts +3 -2
  250. package/tests/devbinary/native-review-parity.devtest.ts +1 -1
  251. package/tests/devbinary/pi-host-relay.devtest.ts +173 -3
  252. package/tests/engram-safety.test.ts +316 -0
  253. package/tests/generic-agent-tools.test.ts +16 -1
  254. package/tests/gentle-agents.test.ts +1895 -76
  255. package/tests/gentle-ai-binary-isolation.test.ts +135 -0
  256. package/tests/gentle-ai-binary.test.ts +19 -2
  257. package/tests/gentle-ai-child-guards.test.ts +202 -0
  258. package/tests/gentle-ai-dev-binary-surfacing.test.ts +125 -15
  259. package/tests/gentle-ai-installer.test.ts +548 -58
  260. package/tests/gentle-ai-prompt-model-routing.test.ts +123 -0
  261. package/tests/gentle-ai-renderer.test.ts +6 -6
  262. package/tests/gentle-ai.test.ts +999 -31
  263. package/tests/gentle-herdr-activity.test.ts +100 -0
  264. package/tests/gentle-notifications.test.ts +427 -0
  265. package/tests/gentle-shell-bin.test.ts +263 -19
  266. package/tests/gentle-shell-bundled.test.ts +1036 -0
  267. package/tests/gentle-shell-launcher.test.ts +251 -25
  268. package/tests/gentle-shell-startup-flow.test.ts +75 -0
  269. package/tests/gentle-shell-startup-update.test.ts +85 -0
  270. package/tests/gentle-shell-uninstall.test.ts +978 -0
  271. package/tests/gentle-shell-update-panel.test.ts +158 -0
  272. package/tests/gentle-shell.test.ts +611 -100
  273. package/tests/gentle-todo.test.ts +317 -14
  274. package/tests/herdr-activity.test.ts +243 -0
  275. package/tests/history-agent-home.test.ts +81 -0
  276. package/tests/history-child-guard.test.ts +70 -0
  277. package/tests/inprocess-reviewer.test.ts +20 -0
  278. package/tests/install-gentle-ai-failure.test.ts +69 -0
  279. package/tests/install-tui-mode-setting.test.ts +15 -0
  280. package/tests/install-wizard.test.ts +1223 -0
  281. package/tests/installer-bundles.test.ts +122 -0
  282. package/tests/installer-posix-bootstrap.test.ts +1201 -0
  283. package/tests/installer-preflight.test.ts +750 -0
  284. package/tests/installer-probes.test.ts +1106 -0
  285. package/tests/installer-runner.test.ts +2159 -0
  286. package/tests/installer-server.test.ts +1567 -0
  287. package/tests/installer-windows-bootstrap.test.ts +2410 -0
  288. package/tests/jobs-view.test.ts +160 -0
  289. package/tests/main-channel-upgrade.test.ts +395 -0
  290. package/tests/main-channel.test.ts +344 -0
  291. package/tests/maintainer/provider-relay.maintest.ts +13 -12
  292. package/tests/model-price-ratio.test.ts +116 -0
  293. package/tests/model-profiles-preservation.test.ts +153 -0
  294. package/tests/model-routing-package.test.ts +10 -0
  295. package/tests/nan-provider-runtime.test.ts +156 -0
  296. package/tests/nan-provider.test.ts +28 -18
  297. package/tests/native-binary-gate.test.ts +2 -0
  298. package/tests/native-review-capability-contract.test.ts +7 -1
  299. package/tests/native-review-cli.test.ts +236 -1
  300. package/tests/native-review-consent.test.ts +2 -2
  301. package/tests/native-review-parity-runtime.test.ts +1 -1
  302. package/tests/native-review-parity.test.ts +12 -48
  303. package/tests/native-risk-evidence-parity.test.ts +21 -0
  304. package/tests/no-self-pause-contract.test.ts +87 -0
  305. package/tests/notification-audio-native.test.ts +281 -0
  306. package/tests/notification-audio-windows.test.ts +581 -0
  307. package/tests/notification-audio.test.ts +548 -0
  308. package/tests/notification-customize.test.ts +655 -0
  309. package/tests/notification-events.test.ts +115 -0
  310. package/tests/notification-policy.test.ts +201 -0
  311. package/tests/notification-pulse-client.test.ts +356 -0
  312. package/tests/notification-pulse-protocol.test.ts +213 -0
  313. package/tests/notification-pulse-stream.test.ts +320 -0
  314. package/tests/notification-scheduler.test.ts +258 -0
  315. package/tests/notification-ui.test.ts +157 -0
  316. package/tests/npm-pack-result.test.ts +41 -0
  317. package/tests/odd-integration.test.ts +14 -5
  318. package/tests/odd-routing-canonical-ratchet.test.ts +53 -12
  319. package/tests/odd-routing-contract.test.ts +80 -23
  320. package/tests/odd-runtime-delegation-gate.test.ts +4 -1
  321. package/tests/orchestrator-budget.test.ts +29 -6
  322. package/tests/orchestrator-catalog.test.ts +146 -0
  323. package/tests/orchestrator-consultation-sdk.test.ts +614 -0
  324. package/tests/orchestrator-consultation.test.ts +111 -0
  325. package/tests/orchestrator-discovery.test.ts +241 -0
  326. package/tests/orchestrator-helper-consent.test.ts +171 -0
  327. package/tests/orchestrator-helper.test.ts +297 -0
  328. package/tests/orchestrator-message-card.test.ts +190 -0
  329. package/tests/orchestrator-presence.test.ts +23 -0
  330. package/tests/orchestrator-rdd-ownership.test.ts +6 -5
  331. package/tests/orchestrator-scope.test.ts +107 -0
  332. package/tests/orchestrator-state.test.ts +114 -0
  333. package/tests/orchestrator-status-publication-contract.test.ts +34 -0
  334. package/tests/orchestrator-tool-card.test.ts +398 -0
  335. package/tests/orchestrator-work-search.test.ts +318 -0
  336. package/tests/orchestrator-work.test.ts +147 -0
  337. package/tests/package-manifest.test.ts +105 -21
  338. package/tests/packed-runner-owned-path.test.ts +35 -1
  339. package/tests/parallel-reason-contract.test.ts +21 -0
  340. package/tests/parallel-writer-admission.test.ts +210 -0
  341. package/tests/permission-mode-policy.test.ts +194 -0
  342. package/tests/persona-single-channel.test.ts +4 -12
  343. package/tests/pi-pretty.test.ts +56 -0
  344. package/tests/pi-update-launcher.test.ts +87 -0
  345. package/tests/pi-update.test.ts +186 -0
  346. package/tests/provider-defect-handoff.test.ts +4 -3
  347. package/tests/quiet-tool-rendering.test.ts +160 -3
  348. package/tests/rdd-aware-verification-contract.test.ts +29 -41
  349. package/tests/rdd-status-line.test.ts +52 -0
  350. package/tests/render-cache-performance.test.ts +48 -0
  351. package/tests/review-agent-end-preflight.test.ts +62 -10
  352. package/tests/review-candidate-view.test.ts +101 -12
  353. package/tests/review-contract-prompt.test.ts +33 -4
  354. package/tests/review-controller-native-recovery.test.ts +327 -17
  355. package/tests/review-controller-native-routing.test.ts +255 -1
  356. package/tests/review-controller-workspace-root.test.ts +43 -2
  357. package/tests/review-controller.test.ts +2 -2
  358. package/tests/review-correction-lifecycle.test.ts +2 -1
  359. package/tests/review-gate.test.ts +7 -4
  360. package/tests/review-graph-schema.test.ts +2 -2
  361. package/tests/review-host-relay-routing.test.ts +5 -3
  362. package/tests/review-host-relay.test.ts +49 -2
  363. package/tests/review-integration-v2-forward.test.ts +21 -1
  364. package/tests/review-json-arguments.test.ts +167 -0
  365. package/tests/review-last-event-closure.test.ts +157 -8
  366. package/tests/review-ledger-contract.test.ts +5 -4
  367. package/tests/review-policy-judgment-day.test.ts +1 -1
  368. package/tests/review-policy-ordinary.test.ts +2 -2
  369. package/tests/review-relay-transport-agent.test.ts +66 -1
  370. package/tests/review-risk-assessment.test.ts +109 -33
  371. package/tests/review-session-standing-permission-controller.test.ts +16 -17
  372. package/tests/review-session-standing-permission-ipc.test.ts +6 -5
  373. package/tests/review-test-fixtures.ts +7 -1
  374. package/tests/review-transaction.test.ts +18 -1
  375. package/tests/run-test-suite.test.ts +83 -5
  376. package/tests/runtime-harness.mjs +42 -15
  377. package/tests/runtime-metrics-extension.test.ts +1 -1
  378. package/tests/runtime-metrics-native.test.ts +31 -1
  379. package/tests/selection-engine.test.ts +65 -5
  380. package/tests/session-change-capture.test.ts +10 -10
  381. package/tests/session-changes-shell.test.ts +242 -48
  382. package/tests/session-changes.test.ts +17 -24
  383. package/tests/session-profile-binding.test.ts +163 -0
  384. package/tests/session-profile-freeze.test.ts +394 -0
  385. package/tests/session-profile-persistence.test.ts +334 -0
  386. package/tests/session-subject-routing-contract.test.ts +34 -0
  387. package/tests/session-worktree-registry.test.ts +57 -2
  388. package/tests/shell-bar.test.ts +40 -0
  389. package/tests/shell-changes-view.test.ts +85 -34
  390. package/tests/shell-changes.test.ts +79 -62
  391. package/tests/shell-render-cache-runtime.test.ts +69 -0
  392. package/tests/shell-render-cache.test.ts +129 -0
  393. package/tests/shell-sidebar-layout.test.ts +27 -2
  394. package/tests/shell-todo.test.ts +132 -11
  395. package/tests/shell-usage-view.test.ts +1 -1
  396. package/tests/shell-usage.test.ts +35 -0
  397. package/tests/short-check-routing-contract.test.ts +49 -0
  398. package/tests/skill-registry-watcher.test.ts +119 -0
  399. package/tests/skill-registry.test.ts +311 -5
  400. package/tests/startup-banner.test.ts +117 -3
  401. package/tests/startup-home-repo.test.ts +170 -0
  402. package/tests/status-timing-diagnostics.test.ts +283 -0
  403. package/tests/support/orchestrator-modules.ts +19 -0
  404. package/tests/task-size-routing-contract.test.ts +241 -0
  405. package/tests/telemetry-trigger.test.ts +27 -1
  406. package/tests/test-strength-contract.test.ts +124 -0
  407. package/tests/verify-agent-contract.test.ts +90 -0
  408. package/tests/verify-first-launch-contract.test.ts +139 -0
  409. package/tests/verify-package-files.test.ts +56 -0
  410. package/tests/vim-editor-adapter.test.ts +47 -21
  411. package/tests/visual-customization-policy.test.ts +32 -3
  412. package/tests/visual-customize-view.test.ts +288 -10
  413. package/tests/writer-edit-surface-scope.test.ts +59 -2
  414. package/tests/writer-surfaces.test.ts +199 -0
  415. package/tests/yolo-customize.test.ts +18 -3
  416. package/tests/yolo-mode-runtime.test.ts +37 -10
  417. package/tests/yolo-mode.test.ts +38 -5
  418. package/tests/yolo-session-policy.test.ts +1 -1
  419. /package/contracts/review-provider-contract-mirror/{v1.2.0 → v1.3.0}/bundle/README.md +0 -0
  420. /package/contracts/review-provider-contract-mirror/{v1.2.0 → v1.3.0}/bundle/schemas/lens.schema.json +0 -0
  421. /package/contracts/review-provider-contract-mirror/{v1.2.0 → v1.3.0}/bundle/schemas/refuter.schema.json +0 -0
  422. /package/contracts/review-provider-contract-mirror/{v1.2.0 → v1.3.0}/bundle/schemas/targeted-validator.schema.json +0 -0
  423. /package/contracts/review-provider-contract-mirror/{v1.2.0 → v1.3.0}/bundle/vectors/lens.json +0 -0
  424. /package/contracts/review-provider-contract-mirror/{v1.2.0 → v1.3.0}/bundle/vectors/refuter.json +0 -0
  425. /package/contracts/review-provider-contract-mirror/{v1.2.0 → v1.3.0}/bundle/vectors/targeted-validator.json +0 -0
@@ -0,0 +1,2159 @@
1
+ import assert from "node:assert/strict";
2
+ import { chmodSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import test from "node:test";
6
+ import { gentleAiBinaryPath } from "../runtime/gentle-ai-binary.mjs";
7
+ import { MainChannelError } from "../scripts/main-channel.mjs";
8
+ import { goAcquisition, persistencePins, planPreflight, requirements } from "../scripts/installer-preflight.mjs";
9
+ import {
10
+ PI_INSTALL_VERSION,
11
+ PI_PACKAGE as PI_PACKAGE_NAME,
12
+ blockedReasons,
13
+ childEnvironment,
14
+ failedSteps,
15
+ genuineNpm,
16
+ lookPath,
17
+ packageNativeGentleAi,
18
+ pnpmInvocation,
19
+ runStandardInstall as runUnobserved,
20
+ setupErrorDetail,
21
+ upgradeInvocation,
22
+ windowsInvocation,
23
+ } from "../scripts/installer-runner.mjs";
24
+
25
+ // Every scenario below runs through this wrapper, which records the blocked
26
+ // reasons and failed steps the runner actually returns.
27
+ const observed = { reasons: new Set<string>(), steps: new Set<string>() };
28
+ async function runStandardInstall(...args: Parameters<typeof runUnobserved>) {
29
+ const result = await runUnobserved(...args);
30
+ if (result.outcome === "blocked") observed.reasons.add(result.reason);
31
+ if (result.outcome === "failed") observed.steps.add(result.failedStep);
32
+ return result;
33
+ }
34
+
35
+ const HOME = "/home/u";
36
+ const PNPM_HOME = "/home/u/.local/share/pnpm";
37
+ const BIN = `${PNPM_HOME}/bin`;
38
+ const NODE = "/opt/node/bin/node";
39
+ const ENTRY = "/tools/pnpm/bin/pnpm.mjs";
40
+ const NPM_CLI = "/opt/node/lib/node_modules/npm/bin/npm-cli.js";
41
+ const PACKAGE_ROOT = `${PNPM_HOME}/global/v11/node_modules/gentle-pi`;
42
+ const SHELL_ENTRY = `${PACKAGE_ROOT}/bin/gentle-shell.mjs`;
43
+
44
+ const W_NODE_DIR = "C:\\Program Files\\nodejs";
45
+ const W_NODE = `${W_NODE_DIR}\\node.exe`;
46
+ const W_ENTRY = "C:\\Tools\\pnpm\\bin\\pnpm.mjs";
47
+ const W_NPM_CMD = `${W_NODE_DIR}\\npm.cmd`;
48
+ const W_NPM_CLI = `${W_NODE_DIR}\\node_modules\\npm\\bin\\npm-cli.js`;
49
+ const W_PNPM_HOME = "C:\\Users\\u\\AppData\\Local\\pnpm";
50
+ const W_BIN = `${W_PNPM_HOME}\\bin`;
51
+ const W_ROOT = `${W_PNPM_HOME}\\global\\v11\\node_modules\\gentle-pi`;
52
+
53
+ // Runtime persisted through pnpm: node in $PNPM_HOME/bin plus a pnpm-global npm shim.
54
+ const PERSISTENT_NODE = `${BIN}/node`;
55
+ const PM_NPM_DIR = `${PNPM_HOME}/global/v11/abc/node_modules/npm`;
56
+ const PM_NPM_CLI = `${PM_NPM_DIR}/bin/npm-cli.js`;
57
+ const STORE = `${PNPM_HOME}/store`;
58
+ // `pnpm store path` reports the versioned store directory.
59
+ const STORE_PATH = `${STORE}/v11`;
60
+ const STORE_PREFIX = `${STORE_PATH}/links/node/24.21.0/hash`;
61
+ const PM_PNPM_DIR = `${PNPM_HOME}/global/v11/abc/node_modules/pnpm`;
62
+ const PM_PNPM_ENTRY = `${PM_PNPM_DIR}/bin/pnpm.mjs`;
63
+ const POSIX_SHIM = `#!/bin/sh
64
+ basedir=$(dirname "$(echo "$0" | sed -e 's,\\\\,/,g')")
65
+ if [ -x "$basedir/node" ]; then
66
+ exec "$basedir/node" "$basedir/../global/v11/abc/node_modules/npm/bin/npm-cli.js" "$@"
67
+ else
68
+ exec node "$basedir/../global/v11/abc/node_modules/npm/bin/npm-cli.js" "$@"
69
+ fi
70
+ `;
71
+ const POSIX_PNPM_SHIM = POSIX_SHIM.replaceAll("npm/bin/npm-cli.js", "pnpm/bin/pnpm.mjs");
72
+ const W_PERSISTENT_NODE = `${W_BIN}\\node.exe`;
73
+ const W_PM_PNPM_DIR = `${W_PNPM_HOME}\\global\\v11\\abc\\node_modules\\pnpm`;
74
+ const W_PM_PNPM_ENTRY = `${W_PM_PNPM_DIR}\\bin\\pnpm.mjs`;
75
+ const W_PM_NPM_DIR = `${W_PNPM_HOME}\\global\\v11\\abc\\node_modules\\npm`;
76
+ const W_PM_NPM_CLI = `${W_PM_NPM_DIR}\\bin\\npm-cli.js`;
77
+ const W_STORE = `${W_PNPM_HOME}\\store`;
78
+ const W_STORE_PATH = `${W_STORE}\\v11`;
79
+ const W_STORE_PREFIX = `${W_STORE_PATH}\\links\\node\\24.21.0\\hash`;
80
+ // Node.js's own npm.cmd (npm 10-11 bin/npm.cmd, verbatim from registry.npmjs.org npm@11.19.0).
81
+ const NODE_NPM_CMD = ":: Created by npm, please don't edit manually.\r\n@ECHO OFF\r\n\r\nSETLOCAL\r\n\r\nSET \"NODE_EXE=%~dp0\\node.exe\"\r\nIF NOT EXIST \"%NODE_EXE%\" (\r\n SET \"NODE_EXE=node\"\r\n)\r\n\r\nSET \"NPM_PREFIX_JS=%~dp0\\node_modules\\npm\\bin\\npm-prefix.js\"\r\nSET \"NPM_CLI_JS=%~dp0\\node_modules\\npm\\bin\\npm-cli.js\"\r\nFOR /F \"delims=\" %%F IN ('CALL \"%NODE_EXE%\" \"%NPM_PREFIX_JS%\"') DO (\r\n SET \"NPM_PREFIX_NPM_CLI_JS=%%F\\node_modules\\npm\\bin\\npm-cli.js\"\r\n)\r\nIF EXIST \"%NPM_PREFIX_NPM_CLI_JS%\" (\r\n SET \"NPM_CLI_JS=%NPM_PREFIX_NPM_CLI_JS%\"\r\n)\r\n\r\n\"%NODE_EXE%\" \"%NPM_CLI_JS%\" %*\r\n";
82
+ const WINDOWS_SHIM = `@IF EXIST "%~dp0\\node.exe" (\r\n "%~dp0\\node.exe" "%~dp0\\..\\global\\v11\\abc\\node_modules\\npm\\bin\\npm-cli.js" %*\r\n) ELSE (\r\n node "%~dp0\\..\\global\\v11\\abc\\node_modules\\npm\\bin\\npm-cli.js" %*\r\n)\r\n`;
83
+
84
+ type Call = { command: string; args: string[]; env: Record<string, string>; deadlineMs: number; stderrTail?: number; cwd?: string };
85
+ type Result = { code: number | null; signal?: string | null; timedOut?: boolean; stdout?: string; stderrTail?: string };
86
+ type Layout = { platform: string; node: string; entry: string; npmCli: string; shellEntry: string; bin: string;
87
+ root: string; env: Record<string, string>; files: string[]; realpaths: Record<string, string>; texts: Record<string, string>;
88
+ persistentNode: string; pmNpmCli: string; pmPnpmEntry: string; pmHome: string; storePath: string; storePrefix: string;
89
+ creates: Record<string, string[]> };
90
+
91
+ const npmPackage = JSON.stringify({ name: "npm", version: "11.19.0" });
92
+ const pnpmPackage = JSON.stringify({ name: "pnpm", version: "11.1.1" });
93
+ // pnpm 11 links gentle-pi's optional peer Pi beside gentle-pi in gentle-pi's own
94
+ // global group, at the latest version unless one is already there; the launcher
95
+ // resolves that Pi (a nested node_modules first) from gentle-pi.
96
+ const piPackage = (version = PI_INSTALL_VERSION) => JSON.stringify({ name: "@earendil-works/pi-coding-agent", version });
97
+ const besidePi = (root: string, sep = "/") => `${root.slice(0, root.lastIndexOf(sep))}${sep}@earendil-works${sep}pi-coding-agent${sep}package.json`;
98
+ const nestedPi = (root: string) => `${root}/node_modules/@earendil-works/pi-coding-agent/package.json`;
99
+ const posixLayout: Layout = {
100
+ platform: "linux", node: NODE, entry: ENTRY, npmCli: NPM_CLI, shellEntry: SHELL_ENTRY, bin: BIN, root: PACKAGE_ROOT,
101
+ env: { HOME, PATH: `${BIN}:/opt/node/bin:/usr/bin`, GENTLE_INSTALL_PNPM_NODE: NODE, GENTLE_INSTALL_PNPM_ENTRY: ENTRY },
102
+ files: ["/opt/node/bin/npm", NPM_CLI, `${BIN}/gentle-shell`],
103
+ realpaths: { "/opt/node/bin/npm": NPM_CLI, [PNPM_HOME]: PNPM_HOME, [PACKAGE_ROOT]: PACKAGE_ROOT,
104
+ [`${BIN}/npm`]: `${BIN}/npm`, [PM_NPM_CLI]: PM_NPM_CLI, [STORE]: STORE, [STORE_PATH]: STORE_PATH, [STORE_PREFIX]: STORE_PREFIX,
105
+ [`${BIN}/pnpm`]: `${BIN}/pnpm`, [PM_PNPM_ENTRY]: PM_PNPM_ENTRY },
106
+ texts: { "/opt/node/lib/node_modules/npm/package.json": npmPackage, [`${BIN}/npm`]: POSIX_SHIM,
107
+ [`${PM_NPM_DIR}/package.json`]: npmPackage, [`${BIN}/pnpm`]: POSIX_PNPM_SHIM, [`${PM_PNPM_DIR}/package.json`]: pnpmPackage,
108
+ [besidePi(PACKAGE_ROOT)]: piPackage() },
109
+ persistentNode: PERSISTENT_NODE, pmNpmCli: PM_NPM_CLI, pmPnpmEntry: PM_PNPM_ENTRY, pmHome: PNPM_HOME, storePath: STORE_PATH,
110
+ storePrefix: STORE_PREFIX,
111
+ creates: { "runtime set node 24.21.0 -g": [PERSISTENT_NODE], "add-pm": [`${BIN}/npm`, PM_NPM_CLI, `${BIN}/pnpm`, PM_PNPM_ENTRY],
112
+ "add-npm": [`${BIN}/npm`, PM_NPM_CLI], "add-pnpm": [`${BIN}/pnpm`, PM_PNPM_ENTRY] },
113
+ };
114
+ // The user's Path already holds the global bin, spelled with different case.
115
+ const windowsLayout: Layout = {
116
+ platform: "win32", node: W_NODE, entry: W_ENTRY, npmCli: W_NPM_CLI, shellEntry: `${W_ROOT}\\bin\\gentle-shell.mjs`,
117
+ bin: W_BIN, root: W_ROOT,
118
+ env: { LOCALAPPDATA: "C:\\Users\\u\\AppData\\Local", USERPROFILE: "C:\\Users\\u",
119
+ Path: `c:\\users\\u\\appdata\\local\\PNPM\\bin\\;${W_NODE_DIR};C:\\Windows`,
120
+ GENTLE_INSTALL_PNPM_NODE: W_NODE, GENTLE_INSTALL_PNPM_ENTRY: W_ENTRY },
121
+ files: [W_NODE, W_NPM_CMD, W_NPM_CLI, `${W_BIN}\\gentle-shell.cmd`],
122
+ realpaths: { [W_PNPM_HOME]: W_PNPM_HOME, [W_ROOT]: W_ROOT, [W_PM_NPM_CLI]: W_PM_NPM_CLI, [W_STORE]: W_STORE,
123
+ [W_STORE_PATH]: W_STORE_PATH, [W_STORE_PREFIX]: W_STORE_PREFIX, [W_PM_PNPM_ENTRY]: W_PM_PNPM_ENTRY },
124
+ texts: { [`${W_NODE_DIR}\\node_modules\\npm\\package.json`]: npmPackage, [W_NPM_CMD]: NODE_NPM_CMD, [`${W_BIN}\\npm.cmd`]: WINDOWS_SHIM,
125
+ [`${W_PM_NPM_DIR}\\package.json`]: npmPackage,
126
+ [`${W_BIN}\\pnpm.cmd`]: WINDOWS_SHIM.replaceAll("npm\\bin\\npm-cli.js", "pnpm\\bin\\pnpm.mjs"),
127
+ [`${W_PM_PNPM_DIR}\\package.json`]: pnpmPackage, [besidePi(W_ROOT, "\\")]: piPackage() },
128
+ persistentNode: W_PERSISTENT_NODE, pmNpmCli: W_PM_NPM_CLI, pmPnpmEntry: W_PM_PNPM_ENTRY,
129
+ pmHome: W_PNPM_HOME, storePath: W_STORE_PATH, storePrefix: W_STORE_PREFIX,
130
+ creates: { "runtime set node 24.21.0 -g": [W_PERSISTENT_NODE], "add-pm": [`${W_BIN}\\npm.cmd`, W_PM_NPM_CLI],
131
+ "add-pnpm": [`${W_BIN}\\pnpm.cmd`, W_PM_PNPM_ENTRY] },
132
+ };
133
+
134
+ const absent = { available: false };
135
+ const tool = (version: string) => ({ available: true, version, usable: true });
136
+ function plan(platform = "linux", change: object = {}) {
137
+ return planPreflight({ platform, arch: "x64", node: tool("24.18.0"), pnpm: { ...tool("11.1.1"), compatible: true },
138
+ pi: absent, shell: absent, gentleAi: absent, go: platform === "win32" ? tool("1.26.0") : absent,
139
+ globalBin: { available: true, path: BIN, writable: true, onPath: true }, setup: false, ...change });
140
+ }
141
+ function listing(pi = PI_INSTALL_VERSION, shell = requirements.shell, path = PACKAGE_ROOT) {
142
+ return JSON.stringify([{ path: `${PNPM_HOME}/global/v11`, dependencies: {
143
+ "@earendil-works/pi-coding-agent": { version: pi },
144
+ "gentle-pi": { version: shell, path },
145
+ } }]);
146
+ }
147
+
148
+ // The same response key may hold a sequence: the first list -g precedes installation.
149
+ const LIST = "list -g --depth 0 --json";
150
+ const emptyList = { code: 0, stdout: "[]" };
151
+
152
+ function harness({ env = {}, results = {}, files = [] as string[], integrity = { ok: true } as object, layout = posixLayout,
153
+ realpaths: extraRealpaths = {} as Record<string, string>, texts: extraTexts = {} as Record<string, string>,
154
+ creates = layout.creates } = {}) {
155
+ const calls: Call[] = [];
156
+ const logs: object[] = [];
157
+ const fileSet = new Set([...layout.files, ...files]);
158
+ const realpaths = { ...layout.realpaths, ...extraRealpaths };
159
+ const texts = { ...layout.texts, ...extraTexts };
160
+ const defaults: Record<string, Result | Result[]> = {
161
+ "--version": { code: 0, stdout: "11.19.0\n" },
162
+ "config get prefix": { code: 0, stdout: "/opt/node\n" },
163
+ "bin -g": { code: 0, stdout: `${layout.bin}\n` },
164
+ add: { code: 0 },
165
+ [LIST]: [emptyList, { code: 0, stdout: listing(PI_INSTALL_VERSION, requirements.shell, layout.root) }],
166
+ "gentle-shell setup": { code: 0 },
167
+ "pnpm setup": { code: 0 },
168
+ "runtime set node 24.21.0 -g": { code: 0 },
169
+ "add-pm": { code: 0 },
170
+ "add-npm": { code: 0 },
171
+ "add-pnpm": { code: 0 },
172
+ "store path": { code: 0, stdout: `${layout.storePath}\n` },
173
+ "pm-pnpm --version": { code: 0, stdout: "11.1.1\n" },
174
+ "persistent-node --version": { code: 0, stdout: "v24.21.0\n" },
175
+ "pm-npm --version": { code: 0, stdout: "11.19.0\n" },
176
+ "pm-npm config get prefix": [{ code: 0, stdout: `${layout.storePrefix}\n` }, { code: 0, stdout: `${layout.pmHome}\n` }],
177
+ [`pm-npm config set prefix ${layout.pmHome} --location=user`]: { code: 0 },
178
+ };
179
+ const responses = { ...defaults, ...results } as Record<string, Result | Result[]>;
180
+ const seen: Record<string, number> = {};
181
+ function key(command: string, args: string[]) {
182
+ if (command === layout.node && args[0] === layout.npmCli) return args.slice(1).join(" ");
183
+ if (args[0] === layout.pmNpmCli) return `pm-npm ${args.slice(1).join(" ")}`;
184
+ if (args[0] === layout.pmPnpmEntry) return `pm-pnpm ${args.slice(1).join(" ")}`;
185
+ if (command === layout.persistentNode) return `persistent-node ${args.join(" ")}`;
186
+ if (command === layout.node && args[0] === layout.shellEntry) return `gentle-shell ${args.slice(1).join(" ")}`;
187
+ const rest = command === layout.node && args[0] === layout.entry ? args.slice(1) : args;
188
+ if (rest[0] === "add") return ({ [PM_ADD]: "add-pm", [NPM_ADD]: "add-npm", [PNPM_ADD]: "add-pnpm" } as Record<string, string>)[rest.join(" ")] ?? "add";
189
+ if (rest.join(" ") === "setup") return "pnpm setup";
190
+ return rest.join(" ");
191
+ }
192
+ const integrityCalls: object[] = [];
193
+ const adapters = {
194
+ platform: layout.platform,
195
+ nodePath: layout.node,
196
+ env: { ...layout.env, ...env } as Record<string, string>,
197
+ run: async (command: string, args: string[], options: { env: Record<string, string>; deadlineMs: number; stderrTail?: number; cwd?: string }) => {
198
+ calls.push({ command, args, env: options.env, deadlineMs: options.deadlineMs,
199
+ ...(options.stderrTail === undefined ? {} : { stderrTail: options.stderrTail }),
200
+ ...(options.cwd === undefined ? {} : { cwd: options.cwd }) });
201
+ const k = key(command, args);
202
+ const entry = responses[k];
203
+ assert.ok(entry, `unexpected command ${command} ${args.join(" ")}`);
204
+ const index = seen[k] ?? 0;
205
+ seen[k] = index + 1;
206
+ const response = Array.isArray(entry) ? entry[Math.min(index, entry.length - 1)] : entry;
207
+ if (response.code === 0) for (const file of creates[k] ?? []) fileSet.add(file);
208
+ return { signal: null, timedOut: false, stdout: "", stderr: "", ...response };
209
+ },
210
+ fs: {
211
+ isFile: async (path: string) => fileSet.has(path),
212
+ realpath: async (path: string) => {
213
+ if (!(path in realpaths)) throw new Error(`ENOENT ${path}`);
214
+ return realpaths[path];
215
+ },
216
+ readText: async (path: string) => {
217
+ if (!(path in texts)) throw new Error(`ENOENT ${path}`);
218
+ return texts[path];
219
+ },
220
+ },
221
+ verifyGentleAi: async (request: object) => {
222
+ integrityCalls.push(request);
223
+ return integrity;
224
+ },
225
+ log: (entry: object) => logs.push(entry),
226
+ };
227
+ const pnpmCalls = () => calls.filter((call) => call.args[0] === layout.entry).map((call) => call.args.slice(1).join(" "));
228
+ return { adapters, calls, logs, pnpmCalls, integrityCalls };
229
+ }
230
+
231
+ const INSTALL = `add -g @earendil-works/pi-coding-agent@${PI_INSTALL_VERSION} gentle-pi@${requirements.shell} --allow-build=gentle-pi`;
232
+ const PM_ADD = "add -g npm@11.19.0 pnpm@11.1.1";
233
+ const NPM_ADD = "add -g npm@11.19.0";
234
+ const PNPM_ADD = "add -g pnpm@11.1.1";
235
+ const RUNTIME_SET = "runtime set node 24.21.0 -g";
236
+
237
+ test("declined or missing consent runs no commands", async () => {
238
+ for (const consent of [false, undefined, "yes", 1]) {
239
+ const h = harness();
240
+ const result = await runStandardInstall({ plan: plan(), consent }, h.adapters);
241
+ assert.equal(result.outcome, "blocked");
242
+ assert.equal(result.reason, "consent-required");
243
+ assert.deepEqual(h.calls, []);
244
+ }
245
+ });
246
+
247
+ test("caller-supplied commands, URLs, roots or env are rejected before any command", async () => {
248
+ const base = plan();
249
+ const injected = [
250
+ { plan: base, consent: true, commands: [["rm", "-rf", "/"]] },
251
+ { plan: base, consent: true, env: { PATH: "/evil" } },
252
+ { plan: { ...base, commands: ["curl https://example.invalid"] }, consent: true },
253
+ { plan: { ...base, actions: [...base.actions, { id: "run", kind: "exec", target: "sh", command: "sh" }] }, consent: true },
254
+ { plan: { ...base, actions: base.actions.map((a: object) => ({ ...a, url: "https://example.invalid" })) }, consent: true },
255
+ { plan: { ...base, actions: base.actions.map((a: { id: string }) => a.id === "install-shell" ? { ...a, version: "9.9.9" } : a) }, consent: true },
256
+ { plan: { ...base, root: "/elsewhere" }, consent: true },
257
+ null,
258
+ ];
259
+ for (const request of injected) {
260
+ const h = harness();
261
+ const result = await runStandardInstall(request, h.adapters);
262
+ assert.equal(result.outcome, "blocked");
263
+ assert.equal(result.reason, "invalid-request");
264
+ assert.deepEqual(h.calls, []);
265
+ }
266
+ });
267
+
268
+ test("preflight blockers and unsupported T4 plans are blocked before any command", async () => {
269
+ const cases = [
270
+ plan("linux", { node: tool("20.0.0") }),
271
+ plan("linux", { node: absent }),
272
+ // (Pi present with Shell missing is the shell-only installation, covered below.)
273
+ plan("linux", { pi: tool("1.2.0"), shell: { ...tool("4.0.0"), global: true } }),
274
+ ];
275
+ for (const fixed of cases) {
276
+ const h = harness();
277
+ const result = await runStandardInstall({ plan: fixed, consent: true }, h.adapters);
278
+ assert.equal(result.outcome, "blocked");
279
+ assert.match(result.reason, /^(preflight-blocked|unsupported-plan)$/);
280
+ assert.deepEqual(h.calls, []);
281
+ }
282
+ });
283
+
284
+ // The installer's pinned Go: published under its own config directory, never on the user's PATH.
285
+ const PINNED_GO = `${HOME}/.pi/gentle-ai/tools/go/${goAcquisition.version}/go/bin/go`;
286
+ const W_PINNED_GO = `C:\\Users\\u\\.pi\\gentle-ai\\tools\\go\\${goAcquisition.version}\\go\\bin\\go.exe`;
287
+ const goVersion = (platform = "linux") => ({ code: 0, stdout: `go version go${goAcquisition.version} ${platform === "win32" ? "windows" : platform}/amd64\n` });
288
+ /** A harness whose acquireGo publishes the pinned Go (or fails), counting calls. */
289
+ function withGo<T extends { adapters: object }>(h: T, goPath: string | Error = PINNED_GO) {
290
+ const acquisitions: number[] = [];
291
+ const adapters = { ...h.adapters, acquireGo: async () => {
292
+ acquisitions.push(1);
293
+ if (goPath instanceof Error) throw goPath;
294
+ return { goPath, version: goAcquisition.version, acquired: true };
295
+ } };
296
+ return { ...h, adapters, acquisitions };
297
+ }
298
+
299
+ test("Windows with a missing or older Go acquires the pinned Go after consent and builds with it first on PATH", async () => {
300
+ for (const go of [absent, tool("1.25.9")]) {
301
+ const fixed = plan("win32", { go });
302
+ assert.deepEqual(fixed.blockers, []);
303
+ const h = withGo(harness({ layout: windowsLayout, results: { version: goVersion("win32") } }), W_PINNED_GO);
304
+ const declined = await runStandardInstall({ plan: fixed, consent: false }, h.adapters);
305
+ assert.equal(declined.reason, "consent-required");
306
+ assert.deepEqual(h.acquisitions, [], "nothing is downloaded before consent");
307
+ const result = await runStandardInstall({ plan: fixed, consent: true }, h.adapters);
308
+ assert.equal(result.outcome, "ready");
309
+ assert.deepEqual(result.completed, ["check-npm", "check-global-bin", "check-existing-stack", "acquire-go", "verify-go",
310
+ "install-global", "verify-global-list", "verify-shell-bin", "verify-gentle-ai", "shell-setup"]);
311
+ assert.deepEqual(h.acquisitions, [1]);
312
+ const verify = h.calls.find((call) => call.command === W_PINNED_GO);
313
+ assert.deepEqual(verify?.args, ["version"]);
314
+ assert.equal(verify?.env.GOTOOLCHAIN, "local");
315
+ // gentle-pi's postinstall finds go.exe on PATH: the pinned Go comes first, only for that child.
316
+ const install = h.calls.find((call) => call.args[1] === "add");
317
+ assert.equal(install?.env.Path.split(";")[0], "C:\\Users\\u\\.pi\\gentle-ai\\tools\\go\\1.25.14\\go\\bin");
318
+ assert.equal(install?.env.Path.split(";")[1], W_BIN);
319
+ for (const call of h.calls.filter((call) => call !== install && call !== verify)) assert.equal(call.env.Path.includes("tools\\go"), false);
320
+ assert.equal(h.adapters.env.Path.includes("tools\\go"), false, "the user's environment is unchanged");
321
+ }
322
+ });
323
+
324
+ test("a failed or unverified Go acquisition stops before anything is installed", async () => {
325
+ const failed = withGo(harness({ layout: windowsLayout }), new Error("Go verified acquisition failed"));
326
+ const result = await runStandardInstall({ plan: plan("win32", { go: absent }), consent: true }, failed.adapters);
327
+ assert.deepEqual([result.outcome, result.failedStep], ["failed", "acquire-go"]);
328
+ assert.deepEqual(result.completed, ["check-npm", "check-global-bin", "check-existing-stack"]);
329
+ assert.equal(failed.calls.some((call) => call.args[1] === "add"), false);
330
+ // Without an acquisition adapter the step fails the same way.
331
+ const missing = harness({ layout: windowsLayout });
332
+ assert.equal((await runStandardInstall({ plan: plan("win32", { go: absent }), consent: true }, missing.adapters)).failedStep, "acquire-go");
333
+ for (const [goPath, version] of [[W_PINNED_GO, { code: 0, stdout: "go version go1.25.13 windows/amd64\n" }], [W_PINNED_GO, { code: 1 }],
334
+ ["go.exe", goVersion("win32")]] as const) {
335
+ const h = withGo(harness({ layout: windowsLayout, results: { version } }), goPath);
336
+ const outcome = await runStandardInstall({ plan: plan("win32", { go: absent }), consent: true }, h.adapters);
337
+ assert.deepEqual([outcome.outcome, outcome.failedStep], ["failed", "verify-go"], goPath);
338
+ assert.equal(h.calls.some((call) => call.args[1] === "add"), false);
339
+ }
340
+ });
341
+
342
+ test("an acquisition the plan does not need, or a build without one, is never run", async () => {
343
+ // A release install on macOS or Linux needs no Go: a forged acquisition is rejected.
344
+ const forged = plan("linux");
345
+ forged.tools.go = { status: "needs-acquire", required: requirements.go, version: goAcquisition.version };
346
+ forged.actions.unshift({ id: "acquire-go", kind: "acquire", target: "go", version: goAcquisition.version }, { id: "verify-go", kind: "verify", target: "go" });
347
+ // Half an acquisition, or one whose tools record does not ask for it.
348
+ const half = plan("win32", { go: absent });
349
+ half.actions = half.actions.filter((action: { id: string }) => action.id !== "verify-go");
350
+ const unrecorded = plan("win32", { go: absent });
351
+ unrecorded.tools.go = { status: "reusable", required: requirements.go };
352
+ for (const fixed of [forged, half, unrecorded]) {
353
+ const h = withGo(harness({ layout: fixed === forged ? posixLayout : windowsLayout }));
354
+ const result = await runStandardInstall({ plan: fixed, consent: true }, h.adapters);
355
+ assert.deepEqual([result.outcome, result.reason], ["blocked", "unsupported-plan"]);
356
+ assert.deepEqual([h.calls, h.acquisitions], [[], []]);
357
+ }
358
+ // Windows still refuses to build when Go is neither reusable nor acquired.
359
+ const stripped = plan("win32", { go: absent });
360
+ stripped.actions = stripped.actions.filter((action: { target: string }) => action.target !== "go");
361
+ const h = withGo(harness({ layout: windowsLayout }));
362
+ assert.equal((await runStandardInstall({ plan: stripped, consent: true }, h.adapters)).reason, "go-required");
363
+ assert.deepEqual(h.acquisitions, []);
364
+ });
365
+
366
+ test("Windows uses the case-insensitive Path key for the child env and bin -g comparison", async () => {
367
+ const h = harness({ layout: windowsLayout, results: { "bin -g": { code: 0, stdout: "c:\\users\\u\\appdata\\local\\PNPM\\BIN\\\r\n" } } });
368
+ const result = await runStandardInstall({ plan: plan("win32"), consent: true }, h.adapters);
369
+ assert.equal(result.outcome, "ready");
370
+ assert.ok(h.calls.length > 0);
371
+ for (const call of h.calls) {
372
+ assert.equal(call.env.PATH, undefined);
373
+ assert.equal(call.env.PNPM_HOME, W_PNPM_HOME);
374
+ assert.equal(call.env.Path.split(";")[0], W_BIN);
375
+ }
376
+ assert.deepEqual(h.calls.at(-1)?.args, [`${W_ROOT}\\bin\\gentle-shell.mjs`, "setup"]);
377
+ const missing = harness({ layout: windowsLayout, env: { Path: `${W_NODE_DIR};C:\\Windows` } });
378
+ const persisted = await runStandardInstall({ plan: plan("win32"), consent: true }, missing.adapters);
379
+ assert.equal(persisted.outcome, "terminal-action-required");
380
+ assert.equal(missing.pnpmCalls().at(-1), "setup");
381
+ });
382
+
383
+ test("Windows npm resolves like Go exec.LookPath: PATH order, then PATHEXT order", async () => {
384
+ const shadows = [
385
+ { files: [`${W_NODE_DIR}\\npm.bat`] },
386
+ { files: [`${W_NODE_DIR}\\npm.com`] },
387
+ { files: [`${W_NODE_DIR}\\npm.ps1`, `${W_NODE_DIR}\\npm.exe`], env: { PathExt: ".PS1;.CMD" } },
388
+ ];
389
+ for (const { files, env } of shadows) {
390
+ const h = harness({ layout: windowsLayout, files, env });
391
+ const result = await runStandardInstall({ plan: plan("win32"), consent: true }, h.adapters);
392
+ assert.equal(result.outcome, "blocked");
393
+ assert.equal(result.reason, "npm-shadowed");
394
+ assert.equal(h.pnpmCalls().some((call) => call.startsWith("add")), false);
395
+ }
396
+ // An npm.exe found first (a Volta or mise shim) runs as it is, with shell:false.
397
+ for (const { files, env, npm } of [
398
+ { files: [`${W_NODE_DIR}\\npm.exe`], npm: `${W_NODE_DIR}\\npm.exe` },
399
+ { files: ["C:\\Early\\npm.exe"], env: { Path: `${W_BIN};C:\\Early;${W_NODE_DIR}` }, npm: "C:\\Early\\npm.exe" },
400
+ ]) {
401
+ const h = harness({ layout: windowsLayout, files, env });
402
+ assert.equal((await runStandardInstall({ plan: plan("win32"), consent: true }, h.adapters)).outcome, "ready");
403
+ assert.deepEqual(h.calls.filter((call) => call.command === npm).map((call) => [call.args.join(" "), call.cwd]),
404
+ [["--version", "C:\\"], ["config get prefix", "C:\\"]]);
405
+ }
406
+ // An npm.cmd that is no known shim (here unreadable) is never run.
407
+ const fake = harness({ layout: windowsLayout, files: ["C:\\Early\\npm.cmd"], env: { Path: `C:\\Early;${W_NODE_DIR}` } });
408
+ assert.equal((await runStandardInstall({ plan: plan("win32"), consent: true }, fake.adapters)).reason, "npm-unavailable");
409
+ // PATHEXT order wins inside one directory: .CMD before .EXE resolves Node.js's own npm.cmd.
410
+ const ordered = harness({ layout: windowsLayout, files: [`${W_NODE_DIR}\\npm.exe`], env: { PATHEXT: "cmd;.EXE" } });
411
+ assert.equal((await runStandardInstall({ plan: plan("win32"), consent: true }, ordered.adapters)).outcome, "ready");
412
+ assert.equal(ordered.calls.some((call) => call.command.endsWith("npm.exe")), false);
413
+ const noCmd = harness({ layout: windowsLayout, env: { PATHEXT: ".EXE;.COM" } });
414
+ assert.equal((await runStandardInstall({ plan: plan("win32"), consent: true }, noCmd.adapters)).reason, "npm-unavailable");
415
+ // Windows PowerShell 5.1 appends .CPL, and the wizard inherits that environment.
416
+ // .CPL is resolved in its PATHEXT place like any other extension, never skipped.
417
+ const powershell = ".COM;.EXE;.BAT;.CMD;.VBS;.VBE;.JS;.JSE;.WSF;.WSH;.MSC;.CPL";
418
+ const cpl = harness({ layout: windowsLayout, env: { PATHEXT: powershell } });
419
+ assert.equal((await runStandardInstall({ plan: plan("win32"), consent: true }, cpl.adapters)).outcome, "ready");
420
+ const cplShadow = harness({ layout: windowsLayout, files: ["C:\\Early\\npm.cpl"], env: { PATHEXT: powershell, Path: `C:\\Early;${W_NODE_DIR}` } });
421
+ const shadowed = await runStandardInstall({ plan: plan("win32"), consent: true }, cplShadow.adapters);
422
+ assert.equal(shadowed.reason, "npm-shadowed");
423
+ assert.equal(cplShadow.pnpmCalls().some((call) => call.startsWith("add")), false);
424
+ });
425
+
426
+ // Windows command shapes, verbatim from their generators (see installer-windows-bootstrap.test.ts).
427
+ const CMD_SHIM_HEAD = "@ECHO off\r\nGOTO start\r\n:find_dp0\r\nSET dp0=%~dp0\r\nEXIT /b\r\n:start\r\nSETLOCAL\r\nCALL :find_dp0\r\n";
428
+ const cmdShim = (target: string) => `${CMD_SHIM_HEAD}\r\nIF EXIST "%dp0%\\node.exe" (\r\n SET "_prog=%dp0%\\node.exe"\r\n) ELSE (\r\n SET "_prog=node"\r\n SET PATHEXT=%PATHEXT:;.JS;=;%\r\n)\r\n\r\nendLocal & goto #_undefined_# 2>NUL || title %COMSPEC% & "%_prog%" "%dp0%\\${target}" %*\r\n`;
429
+ const W_APPDATA_NPM = "C:\\Users\\u\\AppData\\Roaming\\npm";
430
+ const W_REDIRECTED_CLI = `${W_APPDATA_NPM}\\node_modules\\npm\\bin\\npm-cli.js`;
431
+ const W_PREFIX_JS = `${W_NODE_DIR}\\node_modules\\npm\\bin\\npm-prefix.js`;
432
+ function windowsShapes({ files = [] as string[], texts = {} as Record<string, string>, results = {} as Record<string, Result> } = {}) {
433
+ const calls: { command: string; args: string[]; cwd?: string }[] = [];
434
+ const fileSet = new Set(files);
435
+ const adapters = {
436
+ fs: { isFile: async (path: string) => fileSet.has(path), readText: async (path: string) => {
437
+ if (!(path in texts)) throw new Error(`ENOENT ${path}`);
438
+ return texts[path];
439
+ } },
440
+ run: async (command: string, args: string[], options: { cwd?: string }) => {
441
+ calls.push({ command, args, cwd: options.cwd });
442
+ return { signal: null, timedOut: false, stdout: "", ...(results[[command, ...args].join(" ")] ?? { code: 1 }) };
443
+ },
444
+ };
445
+ return { adapters, calls };
446
+ }
447
+ test("windowsInvocation runs what a Windows npm or pnpm shim runs, never through cmd.exe", async () => {
448
+ const env = { Path: `${W_APPDATA_NPM};C:\\PathNode;${W_NODE_DIR}`, PATHEXT: ".COM;.EXE;.BAT;.CMD" };
449
+ // Node.js's own npm.cmd: its sibling node.exe, and the npm under npm's global prefix when it is there.
450
+ for (const [redirected, entry] of [[true, W_REDIRECTED_CLI], [false, W_NPM_CLI]] as const) {
451
+ const h = windowsShapes({ files: [W_NODE, W_NPM_CLI, W_PREFIX_JS, ...(redirected ? [W_REDIRECTED_CLI] : [])], texts: { [W_NPM_CMD]: NODE_NPM_CMD },
452
+ results: { [`${W_NODE} ${W_PREFIX_JS}`]: { code: 0, stdout: `${W_APPDATA_NPM}\r\n` } } });
453
+ assert.deepEqual(await windowsInvocation(W_NPM_CMD, env, h.adapters), { command: W_NODE, prefix: [entry] });
454
+ assert.deepEqual(h.calls, [{ command: W_NODE, args: [W_PREFIX_JS], cwd: "C:\\" }], "the fixed prefix query, from the drive root");
455
+ }
456
+ // npm 6-9's npm.cmd asks npm-cli.js itself; a failed query keeps the bundled npm.
457
+ const older = windowsShapes({ files: [W_NODE, W_NPM_CLI, W_REDIRECTED_CLI], texts: { [W_NPM_CMD]: NODE_NPM_CMD.replace(/SET "NPM_PREFIX_JS=[^\r]*\r\n/, "")
458
+ .replace('"%NPM_PREFIX_JS%"', '"%NPM_CLI_JS%" prefix -g') } });
459
+ assert.deepEqual(await windowsInvocation(W_NPM_CMD, env, older.adapters), { command: W_NODE, prefix: [W_NPM_CLI] });
460
+ assert.deepEqual(older.calls.map((call) => call.args), [[W_NPM_CLI, "prefix", "-g"]]);
461
+ // npm's cmd-shim in its global prefix: no sibling node.exe, so the first node.exe on PATH.
462
+ const shim = `${W_APPDATA_NPM}\\npm.cmd`;
463
+ const appdata = windowsShapes({ files: ["C:\\PathNode\\node.exe", W_REDIRECTED_CLI], texts: { [shim]: cmdShim("node_modules\\npm\\bin\\npm-cli.js") } });
464
+ assert.deepEqual(await windowsInvocation(shim, env, appdata.adapters), { command: "C:\\PathNode\\node.exe", prefix: [W_REDIRECTED_CLI] });
465
+ // ...never a node.cmd found first, a missing entry, an unknown shim, a .bat or an unreadable file.
466
+ const nodeCmd = windowsShapes({ files: ["C:\\PathNode\\node.cmd", "C:\\PathNode\\node.exe", W_REDIRECTED_CLI], texts: { [shim]: cmdShim("node_modules\\npm\\bin\\npm-cli.js") } });
467
+ assert.equal(await windowsInvocation(shim, { ...env, PATHEXT: ".CMD;.EXE" }, nodeCmd.adapters), null);
468
+ assert.equal(await windowsInvocation(shim, env, windowsShapes({ files: ["C:\\PathNode\\node.exe"], texts: { [shim]: cmdShim("node_modules\\npm\\bin\\npm-cli.js") } }).adapters), null);
469
+ const mise = windowsShapes({ files: ["C:\\PathNode\\node.exe"], texts: { [shim]: "@echo off\r\nsetlocal\r\nmise x -- %*\r\n" } });
470
+ assert.equal(await windowsInvocation(shim, env, mise.adapters), null);
471
+ assert.equal(await windowsInvocation(`${W_APPDATA_NPM}\\npm.bat`, env, mise.adapters), null);
472
+ assert.equal(await windowsInvocation(`${W_APPDATA_NPM}\\missing.cmd`, env, mise.adapters), null);
473
+ assert.deepEqual(mise.calls, []);
474
+ // An npm.exe (Volta, mise) runs as it is.
475
+ const volta = "C:\\Program Files\\Volta\\npm.exe";
476
+ assert.deepEqual(await windowsInvocation(volta, env, windowsShapes({ files: [volta] }).adapters), { command: volta, prefix: [] });
477
+ assert.equal(await windowsInvocation(volta, env, windowsShapes().adapters), null);
478
+ // pnpm's own shim with the node.exe pnpm pins, and with a native target.
479
+ const pinnedNode = "C:\\Users\\u\\AppData\\Local\\pnpm\\nodejs\\24.21.0\\node.exe";
480
+ const pnpmShim = `${W_BIN}\\pnpm.cmd`;
481
+ const pinned = windowsShapes({ files: [pinnedNode, W_PM_PNPM_ENTRY], texts: { [pnpmShim]: `@SETLOCAL\r\n@"${pinnedNode}" "%~dp0\\..\\global\\v11\\abc\\node_modules\\pnpm\\bin\\pnpm.mjs" %*\r\n` } });
482
+ assert.deepEqual(await windowsInvocation(pnpmShim, env, pinned.adapters), { command: pinnedNode, prefix: [W_PM_PNPM_ENTRY] });
483
+ const exe = `${W_PNPM_HOME}\\global\\v11\\abc\\node_modules\\@pnpm\\exe\\pnpm.exe`;
484
+ const native = windowsShapes({ files: [exe], texts: { [pnpmShim]: "@SETLOCAL\r\n@\"%~dp0\\..\\global\\v11\\abc\\node_modules\\@pnpm\\exe\\pnpm\" %*\r\n" } });
485
+ assert.deepEqual(await windowsInvocation(pnpmShim, env, native.adapters), { command: exe, prefix: [] }, "@pnpm/exe's extensionless hard link runs as its .exe twin");
486
+ });
487
+
488
+ // S10: only local drive paths. A UNC, `\\?\` or drive-less rooted path is never
489
+ // searched, read or run (even a lookup can reach a remote share).
490
+ const nonLocal = ["\\\\server\\share\\bin", "\\\\?\\C:\\bin", "\\\\.\\C:\\bin", "\\bin"];
491
+ function recordedShapes(options: Parameters<typeof windowsShapes>[0] = {}) {
492
+ const shapes = windowsShapes(options);
493
+ const touched: string[] = [];
494
+ const { isFile, readText } = shapes.adapters.fs;
495
+ shapes.adapters.fs = { isFile: async (path: string) => { touched.push(path); return isFile(path); }, readText: async (path: string) => { touched.push(path); return readText(path); } };
496
+ return { ...shapes, touched };
497
+ }
498
+ test("Windows lookPath and windowsInvocation accept only local drive paths for the command, the PATH node and the npm prefix", async () => {
499
+ const env = { Path: [...nonLocal, "C:\\Local"].join(";"), PATHEXT: ".COM;.EXE;.BAT;.CMD" };
500
+ const everywhere = recordedShapes({ files: [...nonLocal, "C:\\Local"].map((directory) => `${directory}\\node.exe`) });
501
+ assert.equal(await lookPath("node", env, "win32", everywhere.adapters.fs), "C:\\Local\\node.exe");
502
+ assert.deepEqual(everywhere.touched.filter((path) => !/^[A-Za-z]:\\/.test(path)), [], "no non-local directory is searched");
503
+ for (const directory of nonLocal) {
504
+ const shapes = recordedShapes({ files: [`${directory}\\npm.exe`, `${directory}\\npm.cmd`, W_NODE, W_NPM_CLI], texts: { [`${directory}\\npm.cmd`]: NODE_NPM_CMD } });
505
+ for (const file of [`${directory}\\npm.exe`, `${directory}\\npm.cmd`]) assert.equal(await windowsInvocation(file, env, shapes.adapters), null, file);
506
+ assert.deepEqual([shapes.touched, shapes.calls], [[], []], directory);
507
+ }
508
+ // The PATH node of a shim without a sibling node.exe: only a local one runs.
509
+ const shim = `${W_APPDATA_NPM}\\npm.cmd`;
510
+ const remoteNode = recordedShapes({ files: ["\\\\server\\share\\bin\\node.exe", W_REDIRECTED_CLI], texts: { [shim]: cmdShim("node_modules\\npm\\bin\\npm-cli.js") } });
511
+ assert.equal(await windowsInvocation(shim, { ...env, Path: "\\\\server\\share\\bin" }, remoteNode.adapters), null);
512
+ // npm's global prefix: anything but a local drive path keeps the bundled npm.
513
+ for (const prefix of ["\\\\server\\share\\npm", "\\\\?\\C:\\Users\\u\\AppData\\Roaming\\npm", "\\npm"]) {
514
+ const h = recordedShapes({ files: [W_NODE, W_NPM_CLI, W_PREFIX_JS, `${prefix}\\node_modules\\npm\\bin\\npm-cli.js`], texts: { [W_NPM_CMD]: NODE_NPM_CMD },
515
+ results: { [`${W_NODE} ${W_PREFIX_JS}`]: { code: 0, stdout: `${prefix}\r\n` } } });
516
+ assert.deepEqual(await windowsInvocation(W_NPM_CMD, env, h.adapters), { command: W_NODE, prefix: [W_NPM_CLI] }, prefix);
517
+ assert.equal(h.touched.some((path) => path.startsWith(prefix)), false, prefix);
518
+ }
519
+ });
520
+
521
+ // S11: CMD runs an extensionless shim target through PATHEXT, in PATHEXT order.
522
+ test("an extensionless native shim target runs only when the first PATHEXT match is an .exe", async () => {
523
+ const pnpmShim = `${W_BIN}\\pnpm.cmd`;
524
+ const target = `${W_PNPM_HOME}\\global\\v11\\abc\\node_modules\\@pnpm\\exe\\pnpm`;
525
+ const texts = { [pnpmShim]: "@SETLOCAL\r\n@\"%~dp0\\..\\global\\v11\\abc\\node_modules\\@pnpm\\exe\\pnpm\" %*\r\n" };
526
+ const invoke = (files: string[], PATHEXT = ".COM;.EXE;.BAT;.CMD") => windowsInvocation(pnpmShim, { Path: W_BIN, PATHEXT }, windowsShapes({ files, texts }).adapters);
527
+ assert.deepEqual(await invoke([target, `${target}.exe`, `${target}.cmd`]), { command: `${target}.exe`, prefix: [] });
528
+ assert.deepEqual(await invoke([`${target}.exe`], "exe;.CMD"), { command: `${target}.exe`, prefix: [] }, "PATHEXT entries are lowercased and dot-prefixed");
529
+ for (const [files, PATHEXT] of [[[`${target}.com`, `${target}.exe`]], [[`${target}.cmd`, `${target}.exe`], ".CMD;.EXE"], [[`${target}.bat`]], [[target]]] as [string[], string?][]) {
530
+ assert.equal(await invoke(files, PATHEXT), null, `${files.join(",")} ${PATHEXT ?? ""}`);
531
+ }
532
+ });
533
+
534
+ // S8: how `gentle-shell upgrade` (and the wizard's update) runs npm and pnpm on Windows.
535
+ test("upgradeInvocation resolves npm and pnpm on Windows without cmd.exe, and is absent on POSIX", async () => {
536
+ for (const platform of ["linux", "darwin"]) assert.equal(upgradeInvocation({ platform, env: {}, ...windowsShapes().adapters }), undefined);
537
+ const env = { Path: `${W_BIN};${W_NODE_DIR}`, PATHEXT: ".COM;.EXE;.BAT;.CMD", GENTLE_INSTALL_PNPM_NODE: "C:\\Tools\\node.exe", GENTLE_INSTALL_PNPM_ENTRY: W_ENTRY };
538
+ const exe = `${W_PNPM_HOME}\\global\\v11\\abc\\node_modules\\@pnpm\\exe\\pnpm.exe`;
539
+ const h = windowsShapes({ files: [W_NODE, W_NPM_CMD, W_NPM_CLI, `${W_BIN}\\pnpm.cmd`, exe, `${W_BIN}\\other.cmd`],
540
+ texts: { [W_NPM_CMD]: NODE_NPM_CMD, [`${W_BIN}\\pnpm.cmd`]: "@SETLOCAL\r\n@\"%~dp0\\..\\global\\v11\\abc\\node_modules\\@pnpm\\exe\\pnpm.exe\" %*\r\n",
541
+ [`${W_BIN}\\other.cmd`]: "@echo off\r\nvolta run %~n0 %*\r\n" } });
542
+ const cli = upgradeInvocation({ platform: "win32", env, ...h.adapters });
543
+ assert.deepEqual(await cli!("npm"), { command: W_NODE, prefix: [W_NPM_CLI] });
544
+ assert.deepEqual(await cli!("pnpm"), { command: exe, prefix: [] }, "the CLI runs the user's own pnpm, as its shim runs it");
545
+ assert.equal(await cli!("other"), null, "a shim no structure resolves never runs");
546
+ assert.equal(await cli!("missing"), null);
547
+ // The wizard hands pnpm over from the bootstrap: its verified pnpm runs the update.
548
+ const wizard = upgradeInvocation({ platform: "win32", env, ...h.adapters, handoff: true });
549
+ assert.deepEqual(await wizard!("pnpm"), { command: "C:\\Tools\\node.exe", prefix: [W_ENTRY] });
550
+ assert.deepEqual(await wizard!("npm"), { command: W_NODE, prefix: [W_NPM_CLI] });
551
+ assert.equal(h.calls.some((call) => /\.(cmd|bat)$/i.test(call.command)), false);
552
+ });
553
+
554
+ // S9: Corepack's pnpm.cmd runs Corepack, never pnpm's own entry: it is not pnpm.
555
+ test("windowsInvocation runs a pnpm shim only when it names pnpm's own bin entry", async () => {
556
+ const env = { Path: W_NODE_DIR, PATHEXT: ".COM;.EXE;.BAT;.CMD" };
557
+ const pnpmCmd = `${W_NODE_DIR}\\pnpm.cmd`;
558
+ for (const entry of ["node_modules\\corepack\\dist\\pnpm.js", "node_modules\\pnpm\\dist\\pnpm.cjs", "node_modules\\other\\bin\\pnpm.cjs"]) {
559
+ const h = windowsShapes({ files: [W_NODE, `${W_NODE_DIR}\\${entry}`], texts: { [pnpmCmd]: cmdShim(entry) } });
560
+ assert.equal(await windowsInvocation(pnpmCmd, env, h.adapters), null, entry);
561
+ assert.deepEqual(h.calls, []);
562
+ }
563
+ for (const file of ["pnpm.cjs", "pnpm.mjs"]) {
564
+ const entry = `${W_NODE_DIR}\\node_modules\\pnpm\\bin\\${file}`;
565
+ const h = windowsShapes({ files: [W_NODE, entry], texts: { [pnpmCmd]: cmdShim(`node_modules\\pnpm\\bin\\${file}`) } });
566
+ assert.deepEqual(await windowsInvocation(pnpmCmd, env, h.adapters), { command: W_NODE, prefix: [entry] }, file);
567
+ }
568
+ // Any other command keeps its JS entry, whatever it is called (here Corepack's own shim).
569
+ const corepackCmd = `${W_NODE_DIR}\\corepack.cmd`;
570
+ const corepackEntry = `${W_NODE_DIR}\\node_modules\\corepack\\dist\\corepack.js`;
571
+ const corepack = windowsShapes({ files: [W_NODE, corepackEntry], texts: { [corepackCmd]: cmdShim("node_modules\\corepack\\dist\\corepack.js") } });
572
+ assert.deepEqual(await windowsInvocation(corepackCmd, env, corepack.adapters), { command: W_NODE, prefix: [corepackEntry] });
573
+ });
574
+
575
+ test("a Windows npm is usable only by behavior: a stable --version and one absolute prefix", async () => {
576
+ const child = { Path: W_NODE_DIR, PATHEXT: ".COM;.EXE;.BAT;.CMD" };
577
+ const npm = (version: Result, prefix: Result) => windowsShapes({ files: [W_NODE, W_NPM_CMD, W_NPM_CLI], texts: { [W_NPM_CMD]: NODE_NPM_CMD },
578
+ results: { [`${W_NODE} ${W_NPM_CLI} --version`]: version, [`${W_NODE} ${W_NPM_CLI} config get prefix`]: prefix } });
579
+ const working = npm({ code: 0, stdout: "11.19.0\r\n" }, { code: 0, stdout: "C:\\Users\\u\\AppData\\Roaming\\npm\r\n" });
580
+ assert.deepEqual(await genuineNpm(child, "win32", "C:\\bootstrap\\node.exe", working.adapters), { command: W_NODE, prefix: [W_NPM_CLI] });
581
+ assert.deepEqual(working.calls.map((call) => [call.command, call.args.slice(1).join(" "), call.cwd]), [[W_NODE, "--version", "C:\\"], [W_NODE, "config get prefix", "C:\\"]],
582
+ "run with the Node npm.cmd selects, never the installer's");
583
+ for (const [version, prefix] of [[{ code: 1 }, { code: 0, stdout: "C:\\x" }], [{ code: 0, stdout: "11.19.0-pre" }, { code: 0, stdout: "C:\\x" }],
584
+ [{ code: 0, stdout: "11.19.0" }, { code: 0, stdout: "relative\\prefix" }], [{ code: 0, stdout: "11.19.0" }, { code: 0, stdout: "C:\\a\r\nC:\\b" }],
585
+ [{ code: 0, stdout: "11.19.0" }, { code: null, timedOut: true, stdout: "C:\\x" }]] as Result[][]) {
586
+ assert.equal(await genuineNpm(child, "win32", "C:\\bootstrap\\node.exe", npm(version, prefix).adapters), false, JSON.stringify([version, prefix]));
587
+ }
588
+ });
589
+
590
+ test("Windows hands a native pnpm.exe off as the command itself; nothing else is accepted in its place", async () => {
591
+ const exe = `${W_PNPM_HOME}\\pnpm.exe`;
592
+ const fs = { isFile: async () => false };
593
+ assert.deepEqual(await pnpmInvocation({ GENTLE_INSTALL_PNPM_COMMAND: exe }, "win32", fs), { command: exe, prefix: [] });
594
+ for (const command of ["pnpm.exe", `${W_PNPM_HOME}\\pnpm.cmd`, `${W_PNPM_HOME}\\pnpm`, ""]) {
595
+ assert.equal(await pnpmInvocation({ GENTLE_INSTALL_PNPM_COMMAND: command }, "win32", fs), null, command);
596
+ }
597
+ // Node plus entry still wins, and POSIX never reads the Windows-only key.
598
+ assert.deepEqual(await pnpmInvocation({ GENTLE_INSTALL_PNPM_COMMAND: exe, GENTLE_INSTALL_PNPM_NODE: W_NODE, GENTLE_INSTALL_PNPM_ENTRY: W_ENTRY }, "win32", fs),
599
+ { command: W_NODE, prefix: [W_ENTRY] });
600
+ assert.equal(await pnpmInvocation({ GENTLE_INSTALL_PNPM_COMMAND: "/opt/pnpm", PATH: "/usr/bin" }, "linux", fs), null);
601
+ const h = harness({ layout: windowsLayout, env: { GENTLE_INSTALL_PNPM_NODE: "", GENTLE_INSTALL_PNPM_ENTRY: "", GENTLE_INSTALL_PNPM_COMMAND: exe } });
602
+ const result = await runStandardInstall({ plan: plan("win32"), consent: true }, h.adapters);
603
+ assert.equal(result.outcome, "ready");
604
+ assert.deepEqual(h.calls.filter((call) => call.command === exe).map((call) => call.args.join(" ")), ["bin -g", LIST, INSTALL, LIST]);
605
+ });
606
+
607
+ test("Windows without the direct pnpm handoff is blocked rather than spawning a .cmd shim", async () => {
608
+ const h = harness({ env: { GENTLE_INSTALL_PNPM_NODE: "", GENTLE_INSTALL_PNPM_ENTRY: "" } });
609
+ h.adapters.platform = "win32";
610
+ h.adapters.env = { LOCALAPPDATA: "C:\\Users\\u\\AppData\\Local", Path: "C:\\Windows" };
611
+ const result = await runStandardInstall({ plan: plan("win32"), consent: true }, h.adapters);
612
+ assert.equal(result.outcome, "blocked");
613
+ assert.equal(result.reason, "pnpm-unavailable");
614
+ assert.deepEqual(h.calls, []);
615
+ });
616
+
617
+ // Version-manager npm commands: a mise shim (a symlink to the mise binary) and a Volta-like shim script.
618
+ const MISE_NPM = "/home/u/.local/share/mise/shims/npm";
619
+ const VOLTA_NPM = "/home/u/.volta/bin/npm";
620
+ const userNpm = (npm: string) => ({ env: { PATH: `${BIN}:${npm.slice(0, npm.lastIndexOf("/"))}:/usr/bin` }, files: [npm],
621
+ realpaths: npm === MISE_NPM ? { [MISE_NPM]: "/home/u/.local/bin/mise" } : {},
622
+ texts: npm === VOLTA_NPM ? { [VOLTA_NPM]: "#!/bin/sh\nexec volta-shim npm \"$@\"\n" } : {},
623
+ results: { "--version": { code: 0, stdout: "10.9.2\n" }, "config get prefix": { code: 0, stdout: "/home/u/.local/share/mise/installs/node/22.18.0\n" } } });
624
+
625
+ test("missing or non-working npm blocks before installation", async () => {
626
+ const variants: object[] = [
627
+ { env: { PATH: `${BIN}:/usr/bin` } },
628
+ { results: { "--version": { code: 1, stdout: "" } } },
629
+ { results: { "--version": { code: 0, timedOut: true, stdout: "11.19.0\n" } } },
630
+ { results: { "--version": { code: 0, stdout: "11.19.0-pre.1\n" } } },
631
+ { results: { "--version": { code: 0, stdout: "npm 11\n" } } },
632
+ { results: { "config get prefix": { code: 1, stdout: "" } } },
633
+ { results: { "config get prefix": { code: 0, stdout: "relative/prefix\n" } } },
634
+ { results: { "config get prefix": { code: 0, stdout: "\n" } } },
635
+ { results: { "config get prefix": { code: 0, stdout: "/opt/node\n/elsewhere\n" } } },
636
+ ];
637
+ for (const variant of variants) {
638
+ const h = harness(variant);
639
+ const result = await runStandardInstall({ plan: plan(), consent: true }, h.adapters);
640
+ assert.equal(result.outcome, "blocked", JSON.stringify(variant));
641
+ assert.equal(result.reason, "npm-unavailable");
642
+ assert.equal(h.pnpmCalls().some((call) => call.startsWith("add")), false);
643
+ }
644
+ });
645
+
646
+ test("POSIX accepts the first npm on PATH that works, whatever installed it, by running that npm itself", async () => {
647
+ for (const npm of ["/opt/node/bin/npm", MISE_NPM, VOLTA_NPM]) {
648
+ const h = harness(npm === "/opt/node/bin/npm" ? {} : userNpm(npm));
649
+ const result = await runStandardInstall({ plan: plan(), consent: true }, h.adapters);
650
+ assert.equal(result.outcome, "ready", npm);
651
+ assert.equal(result.completed[0], "check-npm");
652
+ const runs = h.calls.filter((call) => call.command === npm);
653
+ assert.deepEqual(runs.map((call) => call.args), [["--version"], ["config", "get", "prefix"]]);
654
+ for (const call of runs) {
655
+ // The user's env with $PNPM_HOME/bin first, from `/` so no project-local tool config applies.
656
+ assert.equal(call.cwd, "/");
657
+ assert.equal(call.env.PATH.split(":")[0], BIN);
658
+ assert.equal(call.env.HOME, HOME);
659
+ assert.ok(call.deadlineMs > 0);
660
+ }
661
+ // npm's own CLI is never run with the installer's Node: a shim has none to find.
662
+ assert.equal(h.calls.some((call) => call.command === NODE && /npm-cli\.js$/.test(call.args[0] ?? "")), false);
663
+ assert.deepEqual(h.pnpmCalls(), ["bin -g", LIST, INSTALL, LIST]);
664
+ }
665
+ });
666
+
667
+ test("an npm in $PNPM_HOME/bin still needs the persistence pin even when it works", async () => {
668
+ const script = harness({ files: [`${BIN}/npm`], texts: { [`${BIN}/npm`]: "#!/bin/sh\nexec /opt/fake/npm \"$@\"\n" } });
669
+ const blocked = await runStandardInstall({ plan: plan(), consent: true }, script.adapters);
670
+ assert.equal(blocked.outcome, "blocked");
671
+ assert.equal(blocked.reason, "npm-unavailable");
672
+ assert.equal(script.calls.some((call) => call.command === `${BIN}/npm`), false);
673
+ });
674
+
675
+ test("verified clean install runs one exact add -g with a package-scoped build approval", async () => {
676
+ const h = harness();
677
+ const result = await runStandardInstall({ plan: plan(), consent: true }, h.adapters);
678
+ assert.equal(result.outcome, "ready");
679
+ assert.deepEqual(h.pnpmCalls(), ["bin -g", LIST, INSTALL, LIST]);
680
+ const adds = h.calls.filter((call) => call.args[1] === "add");
681
+ assert.equal(adds.length, 1);
682
+ assert.equal(adds[0].args.some((arg) => /dangerously|allow-build=\*|--allow-build$|approve-builds/.test(arg)), false);
683
+ assert.equal(adds[0].command, NODE);
684
+ assert.deepEqual(h.calls.at(-1)?.args, [SHELL_ENTRY, "setup"]);
685
+ assert.deepEqual(result.completed, ["check-npm", "check-global-bin", "check-existing-stack", "install-global",
686
+ "verify-global-list", "verify-shell-bin", "verify-gentle-ai", "shell-setup"]);
687
+ assert.deepEqual(h.integrityCalls, [{ packageRoot: PACKAGE_ROOT, platform: "linux", env: h.adapters.env, home: HOME }]);
688
+ assert.equal(JSON.stringify(h.logs).includes("11.19.0"), false);
689
+ });
690
+
691
+ test("every child env has PNPM_HOME and $PNPM_HOME/bin first on PATH", async () => {
692
+ const h = harness({ env: { PATH: "/opt/node/bin:/usr/bin" } });
693
+ await runStandardInstall({ plan: plan("linux", { globalBin: { available: true, path: BIN, writable: true, onPath: false } }), consent: true }, h.adapters);
694
+ assert.ok(h.calls.length > 0);
695
+ for (const call of h.calls) {
696
+ assert.equal(call.env.PNPM_HOME, PNPM_HOME);
697
+ assert.equal(call.env.PATH.split(":")[0], BIN);
698
+ assert.ok(call.deadlineMs > 0);
699
+ }
700
+ assert.equal(h.adapters.env.PNPM_HOME, undefined);
701
+ });
702
+
703
+ test("bin -g mismatch or failure is blocked before installation", async () => {
704
+ for (const result of [{ code: 0, stdout: `${PNPM_HOME}\n` }, { code: 1, stdout: "" }, { code: 0, stdout: "/elsewhere/bin\n" }]) {
705
+ const h = harness({ results: { "bin -g": result } });
706
+ const outcome = await runStandardInstall({ plan: plan(), consent: true }, h.adapters);
707
+ assert.equal(outcome.outcome, "blocked");
708
+ assert.equal(outcome.reason, "global-bin-mismatch");
709
+ assert.equal(h.pnpmCalls().some((call) => call.startsWith("add")), false);
710
+ }
711
+ });
712
+
713
+ test("install nonzero, signal or deadline fails and reports completed steps", async () => {
714
+ for (const add of [{ code: 1 }, { code: null, signal: "SIGKILL" }, { code: 0, timedOut: true }]) {
715
+ const h = harness({ results: { add } });
716
+ const result = await runStandardInstall({ plan: plan(), consent: true }, h.adapters);
717
+ assert.equal(result.outcome, "failed");
718
+ assert.equal(result.failedStep, "install-global");
719
+ assert.deepEqual(result.completed, ["check-npm", "check-global-bin", "check-existing-stack"]);
720
+ assert.equal(h.pnpmCalls().length, 3);
721
+ }
722
+ });
723
+
724
+ test("an existing global Pi or gentle-pi blocks before installation regardless of the plan", async () => {
725
+ const existing = [
726
+ listing(),
727
+ JSON.stringify([{ dependencies: { "@earendil-works/pi-coding-agent": { version: "1.2.1" } } }]),
728
+ JSON.stringify([{ dependencies: {} }, { dependencies: { "gentle-pi": { version: "3.0.0" } } }]),
729
+ JSON.stringify([{ optionalDependencies: { "gentle-pi": { version: "4.0.0" } } }]),
730
+ ];
731
+ for (const stdout of existing) {
732
+ const h = harness({ results: { [LIST]: { code: 0, stdout } } });
733
+ const result = await runStandardInstall({ plan: plan(), consent: true }, h.adapters);
734
+ assert.equal(result.outcome, "blocked");
735
+ assert.equal(result.reason, "existing-stack");
736
+ assert.equal(h.pnpmCalls().some((call) => call.startsWith("add")), false);
737
+ }
738
+ const unrelated = harness({ results: { [LIST]: [{ code: 0, stdout: JSON.stringify([{ dependencies: { typescript: { version: "5.0.0" } } }]) },
739
+ { code: 0, stdout: listing() }] } });
740
+ assert.equal((await runStandardInstall({ plan: plan(), consent: true }, unrelated.adapters)).outcome, "ready");
741
+ });
742
+
743
+ test("a failed or unparseable pre-install global list blocks before mutation", async () => {
744
+ for (const list of [{ code: 1, stdout: "[]" }, { code: 0, timedOut: true, stdout: "[]" }, { code: 0, stdout: "not json" },
745
+ { code: 0, stdout: "" }, { code: 0, stdout: "{}" }, { code: 0, stdout: JSON.stringify([{ dependencies: [] }]) }]) {
746
+ const h = harness({ results: { [LIST]: list } });
747
+ const result = await runStandardInstall({ plan: plan(), consent: true }, h.adapters);
748
+ assert.equal(result.outcome, "blocked");
749
+ assert.equal(result.reason, "global-list-unavailable");
750
+ assert.deepEqual(h.pnpmCalls(), ["bin -g", LIST]);
751
+ }
752
+ });
753
+
754
+ test("global list missing a package, wrong version or unconfined path fails", async () => {
755
+ const listings = [
756
+ JSON.stringify([{ dependencies: { "gentle-pi": { version: requirements.shell, path: PACKAGE_ROOT } } }]),
757
+ listing("0.99.1"),
758
+ listing(PI_INSTALL_VERSION, "3.0.0"),
759
+ listing(PI_INSTALL_VERSION, requirements.shell, "/elsewhere/gentle-pi"),
760
+ "not json",
761
+ ];
762
+ for (const stdout of listings) {
763
+ const h = harness({ results: { [LIST]: [emptyList, { code: 0, stdout }] } });
764
+ const result = await runStandardInstall({ plan: plan(), consent: true }, h.adapters);
765
+ assert.equal(result.outcome, "failed");
766
+ assert.equal(result.failedStep, "verify-global-list");
767
+ assert.equal(h.calls.some((call) => call.args[0] === SHELL_ENTRY), false);
768
+ }
769
+ });
770
+
771
+ test("missing gentle-shell bin in $PNPM_HOME/bin fails", async () => {
772
+ const h = harness();
773
+ h.adapters.fs.isFile = async (path: string) => path !== `${BIN}/gentle-shell` && [NPM_CLI, "/opt/node/bin/npm"].includes(path);
774
+ const result = await runStandardInstall({ plan: plan(), consent: true }, h.adapters);
775
+ assert.equal(result.outcome, "failed");
776
+ assert.equal(result.failedStep, "verify-shell-bin");
777
+ });
778
+
779
+ test("integrity failure or development override fails before setup", async () => {
780
+ for (const integrity of [{ ok: false, reason: "package-local-binary-missing" }, { ok: false, reason: "development-override" }, {}]) {
781
+ const h = harness({ integrity });
782
+ const result = await runStandardInstall({ plan: plan(), consent: true }, h.adapters);
783
+ assert.equal(result.outcome, "failed");
784
+ assert.equal(result.failedStep, "verify-gentle-ai");
785
+ assert.equal(h.calls.some((call) => call.args[0] === SHELL_ENTRY), false);
786
+ }
787
+ });
788
+
789
+ test("gentle-shell setup nonzero, signal or deadline fails", async () => {
790
+ for (const setup of [{ code: 2 }, { code: null, signal: "SIGTERM" }, { code: 0, timedOut: true }]) {
791
+ const h = harness({ results: { "gentle-shell setup": setup } });
792
+ const result = await runStandardInstall({ plan: plan(), consent: true }, h.adapters);
793
+ assert.equal(result.outcome, "failed");
794
+ assert.equal(result.failedStep, "shell-setup");
795
+ assert.ok(result.completed.includes("verify-gentle-ai"));
796
+ }
797
+ });
798
+
799
+ test("a failed gentle-shell setup reports its last error line as a sanitized detail", async () => {
800
+ const stderrTail = `fetching engram\n\u001b[31mError: execute install pipeline: download engram binary: fetch latest engram version: GitHub API returned HTTP 403\u001b[0m (${HOME}/.gentle-shell/agent)\ninstalled via go install\n`;
801
+ const h = harness({ results: { "gentle-shell setup": { code: 1, stderrTail } } });
802
+ const result = await runStandardInstall({ plan: plan(), consent: true }, h.adapters);
803
+ assert.equal(result.outcome, "failed");
804
+ assert.equal(result.failedStep, "shell-setup");
805
+ assert.equal(result.detail, "Error: execute install pipeline: download engram binary: fetch latest engram version: GitHub API returned HTTP 403 (~/.gentle-shell/agent)");
806
+ // The setup child gets its stderr tail captured, as pnpm's global add does (detail below).
807
+ assert.deepEqual(h.calls.filter((call) => call.stderrTail !== undefined).map((call) => [call.args.at(-1), call.stderrTail]),
808
+ [["--allow-build=gentle-pi", 4096], ["setup", 4096]]);
809
+ for (const setup of [{ code: 2 }, { code: 2, stderrTail: " \n\t\n" }]) {
810
+ const silent = await runStandardInstall({ plan: plan(), consent: true }, harness({ results: { "gentle-shell setup": setup } }).adapters);
811
+ assert.equal(silent.failedStep, "shell-setup");
812
+ assert.equal("detail" in silent, false);
813
+ }
814
+ const succeeded = await runStandardInstall({ plan: plan(), consent: true }, harness({ results: { "gentle-shell setup": { code: 0, stderrTail: "Error: ignored" } } }).adapters);
815
+ assert.equal(succeeded.outcome, "ready");
816
+ assert.equal("detail" in succeeded, false);
817
+ // A step without a detail rule never carries one.
818
+ const early = await runStandardInstall({ plan: plan(), consent: true }, harness({ results: { "bin -g": { code: 0, stdout: "/elsewhere\n" }, add: { code: 1, stderrTail: "Error: x" } } }).adapters);
819
+ assert.equal(early.reason, "global-bin-mismatch");
820
+ assert.equal("detail" in early, false);
821
+ });
822
+
823
+ test("a gentle-shell setup detail is unchanged next to the pnpm install's own detail rule", async () => {
824
+ const stderrTail = `Error: GitHub API returned HTTP 403 (${HOME}/.gentle-shell/agent)\n`;
825
+ const h = harness({ results: { add: { code: 0, stdout: "Error: an earlier warning\n", stderrTail: "Error: ignored\n" }, "gentle-shell setup": { code: 1, stderrTail } } });
826
+ const result = await runStandardInstall({ plan: plan(), consent: true }, h.adapters);
827
+ assert.deepEqual([result.outcome, result.failedStep, result.detail], ["failed", "shell-setup", "Error: GitHub API returned HTTP 403 (~/.gentle-shell/agent)"]);
828
+ });
829
+
830
+ // Observed shape: pnpm reports a failed postinstall on stdout, its own summary last.
831
+ const PNPM_ADD_POSTINSTALL = `Progress: resolved 120, reused 119, downloaded 1, added 2\n.../gentle-pi postinstall: gentle-pi could not install its package-local Gentle AI v4.0.0 binary: Gentle AI Go SumDB source installation failed.\n.../gentle-pi postinstall: caused by Error: Command failed: ${HOME}/go/bin/go install (code 1)\n.../gentle-pi postinstall: root cause Error: go: open ${HOME}/x: The directory name is invalid.\n ELIFECYCLE Command failed with exit code 1.\n`;
832
+
833
+ test("a failed pnpm install of Pi and Gentle Shell reports pnpm's last error line, from stderr else stdout", async () => {
834
+ const h = harness({ results: { add: { code: 1, stdout: PNPM_ADD_POSTINSTALL, stderrTail: "" } } });
835
+ const result = await runStandardInstall({ plan: plan(), consent: true }, h.adapters);
836
+ assert.equal(result.failedStep, "install-global");
837
+ assert.equal(result.detail, ".../gentle-pi postinstall: root cause Error: go: open ~/x: The directory name is invalid.");
838
+ assert.deepEqual(h.calls.filter((call) => call.stderrTail !== undefined).map((call) => [call.args.slice(1).join(" "), call.stderrTail]), [[INSTALL, 4096]]);
839
+ const stderr = await runStandardInstall({ plan: plan(), consent: true }, harness({ results: { add: { code: 1, stdout: PNPM_ADD_POSTINSTALL,
840
+ stderrTail: `[ERR_PNPM_FETCH_404] GET https://registry.npmjs.org/gentle-pi: Not Found\n` } } }).adapters);
841
+ assert.equal(stderr.detail, "[ERR_PNPM_FETCH_404] GET https://registry.npmjs.org/gentle-pi: Not Found");
842
+ const silent = await runStandardInstall({ plan: plan(), consent: true }, harness({ results: { add: { code: 1 } } }).adapters);
843
+ assert.deepEqual([silent.failedStep, "detail" in silent], ["install-global", false]);
844
+ });
845
+
846
+ // Observed with pnpm 11.1.1 and no SHELL: the error goes to stdout after the global CLI install output.
847
+ const PNPM_SETUP_NO_SHELL = `Installing pnpm CLI globally from /usr/bin\nProgress: resolved 1, reused 1, downloaded 0, added 1, done\n[WARN] Failed to create bin at ${HOME}/.local/share/pnpm/bin/pnpm.\n\nDone in 393ms using pnpm v11.1.1\n[ERR_PNPM_UNKNOWN_SHELL] Could not infer shell type.\n\nSet the SHELL environment variable to your active shell.\nSupported shell languages are bash, zsh, fish, ksh, dash, sh, and nushell.\n`;
848
+
849
+ test("a failed pnpm setup reports its error line from stderr, else from stdout", async () => {
850
+ const noPath = { PATH: "/opt/node/bin:/usr/bin" };
851
+ const h = harness({ env: noPath, results: { "pnpm setup": { code: 1, stdout: PNPM_SETUP_NO_SHELL, stderrTail: "" } } });
852
+ const result = await runStandardInstall({ plan: plan(), consent: true }, h.adapters);
853
+ assert.equal(result.failedStep, "persist-path");
854
+ assert.equal(result.detail, "[ERR_PNPM_UNKNOWN_SHELL] Could not infer shell type.");
855
+ // Both fixed setup commands request the same bounded stderr tail, as pnpm's global add does.
856
+ assert.deepEqual(h.calls.filter((call) => call.stderrTail !== undefined).map((call) => [call.args.at(-1), call.stderrTail]),
857
+ [["--allow-build=gentle-pi", 4096], ["setup", 4096], ["setup", 4096]]);
858
+ const stderr = await runStandardInstall({ plan: plan(), consent: true }, harness({ env: noPath,
859
+ results: { "pnpm setup": { code: 1, stdout: PNPM_SETUP_NO_SHELL, stderrTail: "Error: EACCES: permission denied, open '/home/u/.bashrc'\n" } } }).adapters);
860
+ assert.equal(stderr.detail, "Error: EACCES: permission denied, open '~/.bashrc'");
861
+ const silent = await runStandardInstall({ plan: plan(), consent: true }, harness({ env: noPath, results: { "pnpm setup": { code: 1 } } }).adapters);
862
+ assert.equal(silent.failedStep, "persist-path");
863
+ assert.equal("detail" in silent, false);
864
+ const passed = await runStandardInstall({ plan: plan(), consent: true }, harness({ env: noPath, results: { "pnpm setup": { code: 0, stdout: PNPM_SETUP_NO_SHELL } } }).adapters);
865
+ assert.equal(passed.outcome, "terminal-action-required");
866
+ assert.equal("detail" in passed, false);
867
+ });
868
+
869
+ test("setup error detail picks the last Error line, else the last line, bounded and without control characters", () => {
870
+ assert.equal(setupErrorDetail(PNPM_SETUP_NO_SHELL, HOME, "linux"), "[ERR_PNPM_UNKNOWN_SHELL] Could not infer shell type.", "pnpm error codes count as error lines");
871
+ assert.equal(setupErrorDetail("Error: first\nmore\nError: second\ntrailer\n", HOME, "linux"), "Error: second");
872
+ assert.equal(setupErrorDetail("one\r\ntwo\r\n\r\n", HOME, "linux"), "two");
873
+ assert.equal(setupErrorDetail(`bad\u0000\u0007 \u009b31mtext\u001b]0;title\u0007 at ${HOME}/x and ${HOME}`, HOME, "linux"), "bad text at ~/x and ~");
874
+ assert.equal(setupErrorDetail("x".repeat(500), HOME, "linux")?.length, 300);
875
+ assert.equal(setupErrorDetail("C:\\USERS\\U\\.gentle-shell failed", "C:\\Users\\u", "win32"), "~\\.gentle-shell failed");
876
+ assert.equal(setupErrorDetail("/home/user/x", "/home/u", "linux"), "/home/user/x", "only the exact HOME path is replaced");
877
+ for (const empty of ["", "\n \n", "\u001b[0m", undefined, null, 42]) assert.equal(setupErrorDetail(empty, HOME, "linux"), null);
878
+ assert.equal(setupErrorDetail("Error: plain", undefined, "linux"), "Error: plain");
879
+ });
880
+
881
+ test("$PNPM_HOME/bin absent from the user PATH runs pnpm setup and requires a new terminal", async () => {
882
+ const h = harness({ env: { PATH: `${PNPM_HOME}:/opt/node/bin:/usr/bin` } });
883
+ const result = await runStandardInstall({ plan: plan(), consent: true }, h.adapters);
884
+ assert.equal(result.outcome, "terminal-action-required");
885
+ assert.equal(result.action, "open-new-terminal");
886
+ assert.equal(h.pnpmCalls().at(-1), "setup");
887
+ assert.equal(result.completed.at(-1), "persist-path");
888
+ const failing = harness({ env: { PATH: "/opt/node/bin:/usr/bin" }, results: { "pnpm setup": { code: 1 } } });
889
+ const failed = await runStandardInstall({ plan: plan(), consent: true }, failing.adapters);
890
+ assert.equal(failed.outcome, "failed");
891
+ assert.equal(failed.failedStep, "persist-path");
892
+ });
893
+
894
+ test("pnpm setup gets the network setup deadline, not the short probe deadline", async () => {
895
+ // pnpm setup installs @pnpm/exe from the registry, so it shares the shell-setup budget.
896
+ const h = harness({ env: { PATH: "/opt/node/bin:/usr/bin" } });
897
+ await runStandardInstall({ plan: plan(), consent: true }, h.adapters);
898
+ const setupCalls = h.calls.filter((call) => call.args.at(-1) === "setup");
899
+ assert.equal(setupCalls.length, 2);
900
+ assert.equal(setupCalls[0].args.at(-2), SHELL_ENTRY);
901
+ assert.equal(setupCalls[1], h.calls.at(-1));
902
+ assert.equal(h.pnpmCalls().at(-1), "setup");
903
+ assert.deepEqual(setupCalls.map((call) => call.deadlineMs), [20 * 60 * 1000, 20 * 60 * 1000]);
904
+ });
905
+
906
+ test("already on PATH and fully verified is ready without pnpm setup", async () => {
907
+ const h = harness();
908
+ const result = await runStandardInstall({ plan: plan(), consent: true }, h.adapters);
909
+ assert.equal(result.outcome, "ready");
910
+ assert.equal(h.pnpmCalls().includes("setup"), false);
911
+ });
912
+
913
+ test("an unexpected adapter exception fails closed without leaking its message", async () => {
914
+ const h = harness();
915
+ h.adapters.run = async () => { throw new Error("private /home/u/secret"); };
916
+ const result = await runStandardInstall({ plan: plan(), consent: true }, h.adapters);
917
+ assert.equal(result.outcome, "blocked");
918
+ assert.equal(JSON.stringify(result).includes("secret"), false);
919
+ assert.equal(JSON.stringify(h.logs).includes("secret"), false);
920
+ });
921
+
922
+ test("package-native integrity rejects development overrides and unverified package roots", async () => {
923
+ const dir = mkdtempSync(join(tmpdir(), "gentle-runner-integrity-"));
924
+ try {
925
+ const dev = join(dir, "dev-gentle-ai");
926
+ writeFileSync(dev, "#!/bin/sh\n");
927
+ chmodSync(dev, 0o755);
928
+ const overridden = await packageNativeGentleAi({ packageRoot: dir, platform: "linux", home: dir,
929
+ env: { GENTLE_PI_GENTLE_AI_DEV_BINARY: dev } });
930
+ assert.deepEqual(overridden, { ok: false, reason: "development-override" });
931
+ const missing = await packageNativeGentleAi({ packageRoot: dir, platform: "linux", home: dir, env: {} });
932
+ assert.deepEqual(missing, { ok: false, reason: "package-native-unverified" });
933
+ const seen: unknown[][] = [];
934
+ const verified = await packageNativeGentleAi({ packageRoot: dir, platform: "linux", home: dir, env: {} },
935
+ (...args: unknown[]) => { seen.push(args); return gentleAiBinaryPath(dir, "linux"); });
936
+ assert.deepEqual(verified, { ok: true });
937
+ assert.equal(seen.length, 1);
938
+ assert.deepEqual(seen[0].slice(0, 2), [dir, "linux"]);
939
+ assert.deepEqual(seen[0][3], { env: {}, home: dir });
940
+ const elsewhere = await packageNativeGentleAi({ packageRoot: dir, platform: "linux", home: dir, env: {} }, () => dev);
941
+ assert.deepEqual(elsewhere, { ok: false, reason: "package-native-unverified" });
942
+ let called = false;
943
+ await packageNativeGentleAi({ packageRoot: dir, platform: "linux", home: dir, env: { GENTLE_PI_GENTLE_AI_DEV_BINARY: dev } },
944
+ () => { called = true; return gentleAiBinaryPath(dir, "linux"); });
945
+ assert.equal(called, false);
946
+ } finally {
947
+ rmSync(dir, { recursive: true, force: true });
948
+ }
949
+ });
950
+
951
+ // Plans whose inventory says Node is bootstrap-only (or npm is not genuine) include fixed persistence steps.
952
+ // The bootstrap's pnpm is bootstrap-only too, so it is persisted with npm.
953
+ const bootstrapNode = { ...tool("24.18.0"), persistent: false, npm: false };
954
+ const bootstrapPnpm = { ...tool("11.1.1"), compatible: true, persistent: false };
955
+ function persistPlan(platform = "linux", node: object = bootstrapNode) {
956
+ return plan(platform, { node, pnpm: bootstrapPnpm });
957
+ }
958
+ // pnpm 11 keeps each global add in its own project: npm+pnpm and the stack are listed separately.
959
+ function persistedListing(root = PACKAGE_ROOT, home = PNPM_HOME) {
960
+ return JSON.stringify([
961
+ { path: `${home}/global/v11/abc`, dependencies: { npm: { version: "11.19.0" }, pnpm: { version: "11.1.1" } } },
962
+ { path: `${home}/global/v11/def`, dependencies: { "@earendil-works/pi-coding-agent": { version: PI_INSTALL_VERSION },
963
+ "gentle-pi": { version: requirements.shell, path: root } } },
964
+ ]);
965
+ }
966
+ const persistedList = { [LIST]: [emptyList, { code: 0, stdout: persistedListing() }] };
967
+ const PERSISTED_STEPS = ["check-global-bin", "check-existing-stack", "persist-node", "persist-package-managers",
968
+ "verify-persistent-runtime", "check-npm", "configure-npm-prefix"];
969
+
970
+ test("persistence pins are fixed: Node 24.21.0 with its bundled npm 11.19.0 and the pnpm acquisition pin", () => {
971
+ assert.deepEqual(persistencePins, { node: "24.21.0", npm: "11.19.0", pnpm: requirements.pnpm });
972
+ });
973
+
974
+ test("bootstrap-only Node persists node, npm and pnpm under PNPM_HOME before the stack install", async () => {
975
+ const h = harness({ results: persistedList });
976
+ const result = await runStandardInstall({ plan: persistPlan(), consent: true }, h.adapters);
977
+ assert.equal(result.outcome, "ready");
978
+ assert.equal(result.npmPrefix, "configured");
979
+ assert.deepEqual(h.pnpmCalls(), ["bin -g", LIST, RUNTIME_SET, PM_ADD, "store path", INSTALL, LIST]);
980
+ assert.deepEqual(result.completed, [...PERSISTED_STEPS, "install-global", "verify-global-list", "verify-shell-bin",
981
+ "verify-gentle-ai", "shell-setup"]);
982
+ for (const call of h.calls.filter((c) => c.args[1] === "add" || c.args[1] === "runtime")) {
983
+ assert.equal(call.command, NODE);
984
+ assert.equal(call.args.some((arg) => /dangerously|allow-build=\*|--allow-build$|approve-builds/.test(arg)), false);
985
+ assert.equal(call.env.PNPM_HOME, PNPM_HOME);
986
+ assert.equal(call.env.PATH.split(":")[0], BIN);
987
+ }
988
+ assert.equal(h.pnpmCalls().filter((call) => call === PM_ADD)[0].includes("allow-build"), false);
989
+ // After persistence, node and npm resolve from $PNPM_HOME/bin in the child env.
990
+ assert.deepEqual(h.calls.filter((call) => call.args[0] === PM_NPM_CLI).map((call) => [call.command, ...call.args]), [
991
+ [NODE, PM_NPM_CLI, "--version"],
992
+ [PERSISTENT_NODE, PM_NPM_CLI, "config", "get", "prefix"],
993
+ [PERSISTENT_NODE, PM_NPM_CLI, "config", "set", "prefix", PNPM_HOME, "--location=user"],
994
+ [PERSISTENT_NODE, PM_NPM_CLI, "config", "get", "prefix"],
995
+ ]);
996
+ assert.deepEqual(h.calls.find((call) => call.command === PERSISTENT_NODE)?.args, ["--version"]);
997
+ });
998
+
999
+ test("after persist-node, check-npm and configure-npm-prefix use the pinned npm even next to a working user npm", async () => {
1000
+ // $PNPM_HOME/bin is first in the child env, so the persisted shim resolves before any user npm.
1001
+ const h = harness({ ...userNpm(MISE_NPM), results: { ...persistedList, ...userNpm(MISE_NPM).results } });
1002
+ const result = await runStandardInstall({ plan: persistPlan(), consent: true }, h.adapters);
1003
+ assert.equal(result.outcome, "ready");
1004
+ assert.equal(result.npmPrefix, "configured");
1005
+ assert.deepEqual(result.completed.slice(0, PERSISTED_STEPS.length), PERSISTED_STEPS);
1006
+ assert.equal(h.calls.some((call) => call.command === MISE_NPM), false);
1007
+ assert.deepEqual(h.calls.filter((call) => call.args.includes("config")).map((call) => [call.command, call.args[0]]),
1008
+ Array(3).fill([PERSISTENT_NODE, PM_NPM_CLI]));
1009
+ });
1010
+
1011
+ test("an older Node and incompatible pnpm left alongside: the pinned copies run every step and are persisted", async () => {
1012
+ const fixed = plan("linux", { node: { ...bootstrapNode, version: "24.21.0", found: "22.18.0" },
1013
+ pnpm: { ...tool("11.1.1"), compatible: true, persistent: false, found: "10.27.0" } });
1014
+ assert.equal(fixed.tools.node.found, "22.18.0");
1015
+ const h = harness({ results: persistedList });
1016
+ const result = await runStandardInstall({ plan: fixed, consent: true }, h.adapters);
1017
+ assert.equal(result.outcome, "ready");
1018
+ assert.deepEqual(h.pnpmCalls(), ["bin -g", LIST, RUNTIME_SET, PM_ADD, "store path", INSTALL, LIST]);
1019
+ // pnpm always runs through the installer's own copy, never a pnpm found on PATH.
1020
+ for (const call of h.calls.filter((c) => c.args[0] === ENTRY)) assert.equal(call.command, NODE);
1021
+ assert.equal(h.calls.some((call) => /\/pnpm$/.test(call.command)), false);
1022
+ assert.equal(h.calls.at(-1)?.command, NODE);
1023
+ });
1024
+
1025
+ test("the full persistence group runs exactly when Node is bootstrap-only", async () => {
1026
+ // pnpm is added with npm only when it is bootstrap-only too.
1027
+ for (const [pnpm, add] of [[{ ...tool("11.1.1"), compatible: true, persistent: true }, NPM_ADD], [bootstrapPnpm, PM_ADD]] as const) {
1028
+ const h = harness({ results: persistedList });
1029
+ const result = await runStandardInstall({ plan: plan("linux", { node: { ...tool("24.18.0"), persistent: false, npm: true }, pnpm }),
1030
+ consent: true }, h.adapters);
1031
+ assert.equal(result.outcome, "ready");
1032
+ assert.deepEqual(h.pnpmCalls().slice(2, 4), [RUNTIME_SET, add]);
1033
+ }
1034
+ const h = harness();
1035
+ const result = await runStandardInstall({ plan: plan("linux", { node: { ...tool("24.18.0"), persistent: true, npm: true } }), consent: true }, h.adapters);
1036
+ assert.equal(result.outcome, "ready");
1037
+ assert.equal(result.npmPrefix, undefined);
1038
+ assert.deepEqual(h.pnpmCalls(), ["bin -g", LIST, INSTALL, LIST]);
1039
+ assert.equal(h.calls.some((call) => call.args[0] === PM_NPM_CLI || call.command === PERSISTENT_NODE), false);
1040
+ });
1041
+
1042
+ test("bootstrap-only Node next to a persistent newer pnpm 11 adds only npm: that pnpm is never downgraded", async () => {
1043
+ const newer = { ...tool("11.5.0"), compatible: true, persistent: true, inGlobalBin: true };
1044
+ const fixed = plan("linux", { node: { ...tool("24.18.0"), persistent: false, npm: true }, pnpm: newer });
1045
+ assert.deepEqual(fixed.actions.slice(0, 3).map((action: { id: string }) => action.id), ["persist-node", "persist-npm", "configure-npm-prefix"]);
1046
+ const h = harness({ results: persistedList });
1047
+ const result = await runStandardInstall({ plan: fixed, consent: true }, h.adapters);
1048
+ assert.equal(result.outcome, "ready");
1049
+ assert.equal(result.npmPrefix, "configured");
1050
+ assert.deepEqual(h.pnpmCalls(), ["bin -g", LIST, RUNTIME_SET, NPM_ADD, "store path", INSTALL, LIST]);
1051
+ assert.deepEqual(result.completed, ["check-global-bin", "check-existing-stack", "persist-node", "persist-npm", "verify-persistent-runtime",
1052
+ "check-npm", "configure-npm-prefix", ...AFTER_STACK]);
1053
+ assert.equal(h.pnpmCalls().some((call) => call.includes("pnpm@")), false);
1054
+ // A failed npm add stops before the stack install, after the runtime.
1055
+ const failing = harness({ results: { ...persistedList, "add-npm": { code: 1 } } });
1056
+ const failed = await runStandardInstall({ plan: fixed, consent: true }, failing.adapters);
1057
+ assert.equal(failed.failedStep, "persist-npm");
1058
+ assert.deepEqual(failed.completed, ["check-global-bin", "check-existing-stack", "persist-node"]);
1059
+ assert.deepEqual(failing.pnpmCalls(), ["bin -g", LIST, RUNTIME_SET, NPM_ADD]);
1060
+ });
1061
+
1062
+ test("a failed persistence step stops before the stack install and reports completed steps", async () => {
1063
+ const cases: [string, object, string[]][] = [
1064
+ ["persist-node", { [RUNTIME_SET]: { code: 1 } }, [RUNTIME_SET]],
1065
+ ["persist-node", { [RUNTIME_SET]: { code: 0, timedOut: true } }, [RUNTIME_SET]],
1066
+ ["persist-package-managers", { "add-pm": { code: 1 } }, [RUNTIME_SET, PM_ADD]],
1067
+ ["verify-persistent-runtime", { "persistent-node --version": { code: 0, stdout: "v24.18.0\n" } }, [RUNTIME_SET, PM_ADD]],
1068
+ ];
1069
+ for (const [failedStep, results, mutations] of cases) {
1070
+ const h = harness({ results: { ...persistedList, ...results } });
1071
+ const result = await runStandardInstall({ plan: persistPlan(), consent: true }, h.adapters);
1072
+ assert.equal(result.outcome, "failed");
1073
+ assert.equal(result.failedStep, failedStep);
1074
+ assert.deepEqual(result.completed, PERSISTED_STEPS.slice(0, PERSISTED_STEPS.indexOf(failedStep)));
1075
+ assert.deepEqual(h.pnpmCalls(), ["bin -g", LIST, ...mutations]);
1076
+ }
1077
+ // pnpm reports success but node or npm is not in $PNPM_HOME/bin.
1078
+ for (const creates of [{ "add-pm": [`${BIN}/npm`, PM_NPM_CLI] }, { [RUNTIME_SET]: [PERSISTENT_NODE] }]) {
1079
+ const h = harness({ results: persistedList, creates });
1080
+ const result = await runStandardInstall({ plan: persistPlan(), consent: true }, h.adapters);
1081
+ assert.equal(result.failedStep, "verify-persistent-runtime");
1082
+ assert.equal(h.pnpmCalls().includes(INSTALL), false);
1083
+ }
1084
+ });
1085
+
1086
+ test("pnpm-global npm shim: wrong version, foreign target or ambiguous shim is rejected", async () => {
1087
+ // Each variant is otherwise a working npm, so only the rule under test rejects it.
1088
+ const foreign = "/elsewhere/node_modules/npm/bin/npm-cli.js";
1089
+ const variants = [
1090
+ { texts: { [`${PM_NPM_DIR}/package.json`]: JSON.stringify({ name: "npm", version: "11.18.0" }) },
1091
+ results: { "pm-npm --version": { code: 0, stdout: "11.18.0\n" } } },
1092
+ { texts: { [`${PM_NPM_DIR}/package.json`]: JSON.stringify({ name: "not-npm", version: "11.19.0" }) } },
1093
+ { realpaths: { [PM_NPM_CLI]: foreign }, files: [foreign],
1094
+ texts: { "/elsewhere/node_modules/npm/package.json": npmPackage },
1095
+ results: { [`${foreign} --version`]: { code: 0, stdout: "11.19.0\n" } } },
1096
+ { texts: { [`${BIN}/npm`]: POSIX_SHIM.replace("abc/node_modules/npm/bin/npm-cli.js\" \"$@\"\nelse", "xyz/node_modules/npm/bin/npm-cli.js\" \"$@\"\nelse") } },
1097
+ { texts: { [`${BIN}/npm`]: "#!/bin/sh\nexec /opt/fake/npm \"$@\"\n" } },
1098
+ { results: { "pm-npm --version": { code: 0, stdout: "10.0.0\n" } } },
1099
+ ];
1100
+ for (const variant of variants) {
1101
+ const h = harness({ ...variant, results: { ...persistedList, ...(variant as { results?: object }).results } });
1102
+ const result = await runStandardInstall({ plan: persistPlan(), consent: true }, h.adapters);
1103
+ assert.equal(result.outcome, "failed");
1104
+ assert.equal(result.failedStep, "check-npm");
1105
+ assert.equal(h.pnpmCalls().includes(INSTALL), false);
1106
+ }
1107
+ });
1108
+
1109
+ test("an existing pnpm-global npm shim first on PATH satisfies check-npm without persistence", async () => {
1110
+ const files = [`${BIN}/npm`, PM_NPM_CLI];
1111
+ const h = harness({ files });
1112
+ const result = await runStandardInstall({ plan: plan(), consent: true }, h.adapters);
1113
+ assert.equal(result.outcome, "ready");
1114
+ assert.deepEqual(h.pnpmCalls(), ["bin -g", LIST, INSTALL, LIST]);
1115
+ assert.deepEqual(h.calls[0].args, [PM_NPM_CLI, "--version"]);
1116
+ const wrong = harness({ files, texts: { [`${PM_NPM_DIR}/package.json`]: JSON.stringify({ name: "npm", version: "11.20.0" }) },
1117
+ results: { "pm-npm --version": { code: 0, stdout: "11.20.0\n" } } });
1118
+ const blocked = await runStandardInstall({ plan: plan(), consent: true }, wrong.adapters);
1119
+ assert.equal(blocked.outcome, "blocked");
1120
+ assert.equal(blocked.reason, "npm-unavailable");
1121
+ });
1122
+
1123
+ test("npm prefix is set only when inside the pnpm store and not explicit; never overwritten", async () => {
1124
+ const set = `pm-npm config set prefix ${PNPM_HOME} --location=user`;
1125
+ const cases: [object, object][] = [
1126
+ [{ "pm-npm config get prefix": { code: 0, stdout: "/home/u/.npm-global\n" } }, {}],
1127
+ [{}, { NPM_CONFIG_PREFIX: STORE_PREFIX }],
1128
+ [{}, { npm_config_prefix: STORE_PREFIX }],
1129
+ [{}, { Npm_Config_Prefix: STORE_PREFIX }],
1130
+ [{}, { PREFIX: STORE_PREFIX }],
1131
+ ];
1132
+ for (const [results, env] of cases) {
1133
+ const h = harness({ env, results: { ...persistedList, ...results } });
1134
+ const result = await runStandardInstall({ plan: persistPlan(), consent: true }, h.adapters);
1135
+ assert.equal(result.outcome, "ready");
1136
+ assert.equal(result.npmPrefix, "unchanged");
1137
+ assert.equal(h.calls.some((call) => call.args[0] === PM_NPM_CLI && call.args.includes("set")), false);
1138
+ assert.ok(result.completed.includes("configure-npm-prefix"));
1139
+ // An explicit prefix never needs the store; an effective one is compared with `pnpm store path`.
1140
+ assert.equal(h.pnpmCalls().includes("store path"), Object.keys(env).length === 0);
1141
+ }
1142
+ const failures = [
1143
+ { [set]: { code: 1 } },
1144
+ { "pm-npm config get prefix": [{ code: 0, stdout: `${STORE_PREFIX}\n` }, { code: 0, stdout: `${STORE_PREFIX}\n` }] },
1145
+ { "pm-npm config get prefix": { code: 1, stdout: "" } },
1146
+ { "pm-npm config get prefix": { code: 0, stdout: "relative/prefix\n" } },
1147
+ // The store must be resolved by pnpm itself; an unknown store is a failure, never "unchanged".
1148
+ { "store path": { code: 1, stdout: "" } },
1149
+ { "store path": { code: 0, timedOut: true, stdout: `${STORE_PATH}\n` } },
1150
+ { "store path": { code: 0, stdout: "store/v11\n" } },
1151
+ { "store path": { code: 0, stdout: "/missing/store/v11\n" } },
1152
+ ];
1153
+ for (const results of failures) {
1154
+ const h = harness({ results: { ...persistedList, ...results } });
1155
+ const result = await runStandardInstall({ plan: persistPlan(), consent: true }, h.adapters);
1156
+ assert.equal(result.outcome, "failed");
1157
+ assert.equal(result.failedStep, "configure-npm-prefix");
1158
+ assert.deepEqual(result.completed, PERSISTED_STEPS.slice(0, -1));
1159
+ assert.equal(h.pnpmCalls().includes(INSTALL), false);
1160
+ }
1161
+ });
1162
+
1163
+ test("Windows persistence resolves node.exe and the npm.cmd shim from $PNPM_HOME\\bin", async () => {
1164
+ const h = harness({ layout: windowsLayout, results: { [LIST]: [emptyList, { code: 0, stdout: persistedListing(W_ROOT, W_PNPM_HOME) }] } });
1165
+ const result = await runStandardInstall({ plan: persistPlan("win32"), consent: true }, h.adapters);
1166
+ assert.equal(result.outcome, "ready");
1167
+ assert.equal(result.npmPrefix, "configured");
1168
+ assert.deepEqual(h.calls.filter((call) => call.args[0] === W_PM_NPM_CLI).map((call) => [call.command, ...call.args]), [
1169
+ [W_NODE, W_PM_NPM_CLI, "--version"],
1170
+ [W_PERSISTENT_NODE, W_PM_NPM_CLI, "config", "get", "prefix"],
1171
+ [W_PERSISTENT_NODE, W_PM_NPM_CLI, "config", "set", "prefix", W_PNPM_HOME, "--location=user"],
1172
+ [W_PERSISTENT_NODE, W_PM_NPM_CLI, "config", "get", "prefix"],
1173
+ ]);
1174
+ // An npm.exe beside the shim resolves first in PATHEXT order and is not accepted.
1175
+ const shadowed = harness({ layout: windowsLayout, creates: { ...windowsLayout.creates, "add-pm": [`${W_BIN}\\npm.exe`, `${W_BIN}\\npm.cmd`, W_PM_NPM_CLI] },
1176
+ results: { [LIST]: [emptyList, { code: 0, stdout: persistedListing(W_ROOT, W_PNPM_HOME) }] } });
1177
+ const failed = await runStandardInstall({ plan: persistPlan("win32"), consent: true }, shadowed.adapters);
1178
+ assert.equal(failed.outcome, "failed");
1179
+ assert.equal(failed.failedStep, "check-npm");
1180
+ });
1181
+
1182
+ test("partial or altered persistence actions are rejected before any command", async () => {
1183
+ const base = persistPlan();
1184
+ const partial = { ...base, actions: base.actions.filter((action: { id: string }) => action.id !== "configure-npm-prefix") };
1185
+ const h = harness();
1186
+ assert.equal((await runStandardInstall({ plan: partial, consent: true }, h.adapters)).reason, "unsupported-plan");
1187
+ const altered = { ...base, actions: base.actions.map((action: { id: string }) => action.id === "persist-node" ? { ...action, version: "25.0.0" } : action) };
1188
+ assert.equal((await runStandardInstall({ plan: altered, consent: true }, h.adapters)).reason, "invalid-request");
1189
+ assert.deepEqual(h.calls, []);
1190
+ });
1191
+
1192
+ test("gentle-shell setup receives no GENTLE_BOOTSTRAP_* or GENTLE_INSTALL_* keys", async () => {
1193
+ const h = harness({ env: { GENTLE_BOOTSTRAP_TOOLS: "/home/u/.gentle-shell-bootstrap-tools.x", gentle_install_lower: "kept", KEEP: "1" } });
1194
+ assert.equal((await runStandardInstall({ plan: plan(), consent: true }, h.adapters)).outcome, "ready");
1195
+ const setup = h.calls.find((call) => call.args[0] === SHELL_ENTRY);
1196
+ assert.ok(setup);
1197
+ assert.deepEqual(Object.keys(setup.env).filter((key) => /^gentle_/i.test(key)), ["gentle_install_lower"]);
1198
+ assert.equal(setup.env.KEEP, "1");
1199
+ assert.equal(setup.env.PNPM_HOME, PNPM_HOME);
1200
+ // pnpm still receives the bootstrap handoff; only setup is stripped.
1201
+ assert.equal(h.calls.find((call) => call.args[1] === "add")?.env.GENTLE_INSTALL_PNPM_ENTRY, ENTRY);
1202
+ const w = harness({ layout: windowsLayout, env: { gentle_bootstrap_tools: "C:\\x", Gentle_Install_Other: "y" } });
1203
+ assert.equal((await runStandardInstall({ plan: plan("win32"), consent: true }, w.adapters)).outcome, "ready");
1204
+ const wSetup = w.calls.find((call) => call.args[0] === `${W_ROOT}\\bin\\gentle-shell.mjs`);
1205
+ assert.deepEqual(Object.keys(wSetup?.env ?? {}).filter((key) => /^gentle_/i.test(key)), []);
1206
+ });
1207
+
1208
+ test("npm prefix compares with the store pnpm reports, not an assumed $PNPM_HOME/store", async () => {
1209
+ const custom = "/srv/pnpm-store/v11";
1210
+ const prefix = `${custom}/links/node/24.21.0/hash`;
1211
+ const h = harness({ realpaths: { [custom]: custom, [prefix]: prefix }, results: { ...persistedList,
1212
+ "store path": { code: 0, stdout: `${custom}\n` },
1213
+ "pm-npm config get prefix": [{ code: 0, stdout: `${prefix}\n` }, { code: 0, stdout: `${PNPM_HOME}\n` }] } });
1214
+ const result = await runStandardInstall({ plan: persistPlan(), consent: true }, h.adapters);
1215
+ assert.equal(result.outcome, "ready");
1216
+ assert.equal(result.npmPrefix, "configured");
1217
+ const storeCall = h.calls.find((call) => call.args[1] === "store");
1218
+ assert.deepEqual(storeCall?.args, [ENTRY, "store", "path"]);
1219
+ assert.equal(storeCall?.env.PNPM_HOME, PNPM_HOME);
1220
+ assert.ok((storeCall?.deadlineMs ?? 0) > 0);
1221
+ // The default $PNPM_HOME/store is no longer assumed when pnpm reports another store.
1222
+ const elsewhere = harness({ realpaths: { [custom]: custom }, results: { ...persistedList, "store path": { code: 0, stdout: `${custom}\n` } } });
1223
+ const unchanged = await runStandardInstall({ plan: persistPlan(), consent: true }, elsewhere.adapters);
1224
+ assert.equal(unchanged.npmPrefix, "unchanged");
1225
+ });
1226
+
1227
+ test("a symlinked $PNPM_HOME/bin/npm must resolve inside PNPM_HOME to npm@11.19.0", async () => {
1228
+ const outside = "/usr/lib/node_modules/npm/bin/npm-cli.js";
1229
+ const foreign = harness({ files: [`${BIN}/npm`, outside], realpaths: { [`${BIN}/npm`]: outside },
1230
+ texts: { "/usr/lib/node_modules/npm/package.json": npmPackage },
1231
+ results: { [`${outside} --version`]: { code: 0, stdout: "11.19.0\n" } } });
1232
+ const blocked = await runStandardInstall({ plan: plan(), consent: true }, foreign.adapters);
1233
+ assert.equal(blocked.outcome, "blocked");
1234
+ assert.equal(blocked.reason, "npm-unavailable");
1235
+ assert.equal(foreign.pnpmCalls().some((call) => call.startsWith("add")), false);
1236
+ const old = harness({ files: [`${BIN}/npm`, PM_NPM_CLI], realpaths: { [`${BIN}/npm`]: PM_NPM_CLI },
1237
+ texts: { [`${PM_NPM_DIR}/package.json`]: JSON.stringify({ name: "npm", version: "11.18.0" }) },
1238
+ results: { "pm-npm --version": { code: 0, stdout: "11.18.0\n" } } });
1239
+ assert.equal((await runStandardInstall({ plan: plan(), consent: true }, old.adapters)).reason, "npm-unavailable");
1240
+ const linked = harness({ files: [`${BIN}/npm`, PM_NPM_CLI], realpaths: { [`${BIN}/npm`]: PM_NPM_CLI } });
1241
+ assert.equal((await runStandardInstall({ plan: plan(), consent: true }, linked.adapters)).outcome, "ready");
1242
+ assert.deepEqual(linked.calls[0].args, [PM_NPM_CLI, "--version"]);
1243
+ });
1244
+
1245
+ // Node already persistent: only the missing package managers, in one add -g, never runtime set.
1246
+ const persistentNode = (npm: boolean) => ({ ...tool("24.18.0"), persistent: true, npm });
1247
+ const pnpmTool = (persistent: boolean) => ({ ...tool("11.1.1"), compatible: true, persistent });
1248
+ const AFTER_STACK = ["install-global", "verify-global-list", "verify-shell-bin", "verify-gentle-ai", "shell-setup"];
1249
+
1250
+ test("persistent Node adds only the missing npm and/or pnpm with exact fixed argv", async () => {
1251
+ const cases: [boolean, boolean, string, string[]][] = [
1252
+ [false, true, NPM_ADD, ["check-global-bin", "check-existing-stack", "persist-npm", "check-npm"]],
1253
+ [true, false, PNPM_ADD, ["check-npm", "check-global-bin", "check-existing-stack", "persist-pnpm", "verify-persistent-pnpm"]],
1254
+ [false, false, PM_ADD, ["check-global-bin", "check-existing-stack", "persist-package-managers", "check-npm", "verify-persistent-pnpm"]],
1255
+ ];
1256
+ for (const [npm, pnpmPersistent, add, steps] of cases) {
1257
+ const fixed = plan("linux", { node: persistentNode(npm), pnpm: pnpmTool(pnpmPersistent) });
1258
+ const h = harness({ results: persistedList });
1259
+ const result = await runStandardInstall({ plan: fixed, consent: true }, h.adapters);
1260
+ assert.equal(result.outcome, "ready", add);
1261
+ assert.deepEqual(h.pnpmCalls(), ["bin -g", LIST, add, INSTALL, LIST]);
1262
+ assert.deepEqual(result.completed, [...steps, ...AFTER_STACK]);
1263
+ assert.equal(result.npmPrefix, undefined);
1264
+ // No runtime and no prefix change: check-npm only reads the user's npm.
1265
+ assert.equal(h.calls.some((call) => call.command === PERSISTENT_NODE || call.args.includes("set")), false);
1266
+ const persisted = h.calls.find((call) => call.args[1] === "add" && call.args.join(" ") !== `${ENTRY} ${INSTALL}`);
1267
+ assert.equal(persisted?.env.PATH.split(":")[0], BIN);
1268
+ if (!pnpmPersistent) assert.ok(h.calls.some((call) => call.command === NODE && call.args.join(" ") === `${PM_PNPM_ENTRY} --version`));
1269
+ }
1270
+ const none = harness();
1271
+ const reused = await runStandardInstall({ plan: plan("linux", { node: persistentNode(true), pnpm: pnpmTool(true) }), consent: true }, none.adapters);
1272
+ assert.deepEqual(none.pnpmCalls(), ["bin -g", LIST, INSTALL, LIST]);
1273
+ assert.equal(reused.outcome, "ready");
1274
+ });
1275
+
1276
+ test("an added pnpm must resolve from $PNPM_HOME/bin at 11.1.1", async () => {
1277
+ const fixed = plan("linux", { node: persistentNode(true), pnpm: pnpmTool(false) });
1278
+ const variants = [
1279
+ { creates: { "add-pnpm": [] } },
1280
+ { texts: { [`${PM_PNPM_DIR}/package.json`]: JSON.stringify({ name: "pnpm", version: "11.2.0" }) },
1281
+ results: { "pm-pnpm --version": { code: 0, stdout: "11.2.0\n" } } },
1282
+ { results: { "pm-pnpm --version": { code: 0, stdout: "10.0.0\n" } } },
1283
+ { realpaths: { [PM_PNPM_ENTRY]: "/elsewhere/node_modules/pnpm/bin/pnpm.mjs" } },
1284
+ { texts: { [`${BIN}/pnpm`]: "#!/bin/sh\nexec /opt/fake/pnpm \"$@\"\n" } },
1285
+ ];
1286
+ for (const variant of variants) {
1287
+ const h = harness({ ...variant, results: { ...persistedList, ...(variant as { results?: object }).results } });
1288
+ const result = await runStandardInstall({ plan: fixed, consent: true }, h.adapters);
1289
+ assert.equal(result.outcome, "failed");
1290
+ assert.equal(result.failedStep, "verify-persistent-pnpm");
1291
+ assert.equal(h.pnpmCalls().includes(INSTALL), false);
1292
+ }
1293
+ const npmFixed = plan("linux", { node: persistentNode(false), pnpm: pnpmTool(true) });
1294
+ const wrongNpm = harness({ results: { ...persistedList, "pm-npm --version": { code: 0, stdout: "11.18.0\n" } },
1295
+ texts: { [`${PM_NPM_DIR}/package.json`]: JSON.stringify({ name: "npm", version: "11.18.0" }) } });
1296
+ const failed = await runStandardInstall({ plan: npmFixed, consent: true }, wrongNpm.adapters);
1297
+ assert.equal(failed.failedStep, "check-npm");
1298
+ assert.equal(wrongNpm.pnpmCalls().includes(RUNTIME_SET), false);
1299
+ });
1300
+
1301
+ test("Windows persistent Node adds only pnpm and verifies the pnpm.cmd shim target", async () => {
1302
+ const fixed = plan("win32", { node: persistentNode(true), pnpm: pnpmTool(false) });
1303
+ const h = harness({ layout: windowsLayout, results: { [LIST]: [emptyList, { code: 0, stdout: persistedListing(W_ROOT, W_PNPM_HOME) }] } });
1304
+ const result = await runStandardInstall({ plan: fixed, consent: true }, h.adapters);
1305
+ assert.equal(result.outcome, "ready");
1306
+ assert.ok(result.completed.includes("verify-persistent-pnpm"));
1307
+ assert.ok(h.calls.some((call) => call.command === W_NODE && call.args.join(" ") === `${W_PM_PNPM_ENTRY} --version`));
1308
+ });
1309
+
1310
+ test("persistence variants are accepted only as fixed sets", async () => {
1311
+ const npmAction = { id: "persist-npm", kind: "install-global", target: "npm", version: "11.19.0" };
1312
+ const pnpmAction = { id: "persist-pnpm", kind: "install-global", target: "pnpm", version: "11.1.1" };
1313
+ const both = { id: "persist-package-managers", kind: "install-global", target: "package-managers" };
1314
+ const prefix = { id: "configure-npm-prefix", kind: "configure", target: "npm-prefix" };
1315
+ const node = { id: "persist-node", kind: "persist-runtime", target: "node", version: "24.21.0" };
1316
+ const base = plan();
1317
+ for (const extra of [[npmAction, pnpmAction], [both, prefix], [node, pnpmAction, prefix], [node, npmAction], [node, both], [npmAction, prefix]]) {
1318
+ const h = harness();
1319
+ const result = await runStandardInstall({ plan: { ...base, actions: [...extra, ...base.actions] }, consent: true }, h.adapters);
1320
+ assert.equal(result.outcome, "blocked");
1321
+ assert.equal(result.reason, "unsupported-plan");
1322
+ assert.deepEqual(h.calls, []);
1323
+ }
1324
+ const altered = { ...base, actions: [{ ...npmAction, version: "11.20.0" }, ...base.actions] };
1325
+ assert.equal((await runStandardInstall({ plan: altered, consent: true }, harness().adapters)).reason, "invalid-request");
1326
+ });
1327
+
1328
+ test("gates without PNPM_HOME or an absolute host Node block before any command", async () => {
1329
+ const h = harness();
1330
+ const noHome = await runStandardInstall({ plan: plan(), consent: true }, { ...h.adapters, env: { PATH: "/usr/bin" } });
1331
+ assert.equal(noHome.reason, "pnpm-home-unknown");
1332
+ const relativeNode = await runStandardInstall({ plan: plan(), consent: true }, { ...h.adapters, nodePath: "node" });
1333
+ assert.equal(relativeNode.reason, "node-unavailable");
1334
+ assert.deepEqual(h.calls, []);
1335
+ });
1336
+
1337
+ // Setup recovery: the pinned stack this pnpm installed is present and only its setup did not finish.
1338
+ function recoveryPlan(platform = "linux", change: object = {}) {
1339
+ return plan(platform, { pi: tool(PI_INSTALL_VERSION), shell: { ...tool(requirements.shell), global: true },
1340
+ gentleAi: { ...tool(requirements.gentleAi), compatible: true }, setup: { available: true, recoverable: true }, ...change });
1341
+ }
1342
+ const installedList = { [LIST]: { code: 0, stdout: listing() } };
1343
+ const RECOVERY_STEPS = ["check-npm", "check-global-bin", "check-recoverable-stack", "verify-global-list", "verify-shell-bin",
1344
+ "verify-gentle-ai", "shell-setup"];
1345
+ const actionIds = (fixed: { actions: { id: string }[] }) => fixed.actions.map((action) => action.id);
1346
+
1347
+ test("setup recovery re-verifies the installed stack and reruns setup without add -g", async () => {
1348
+ const fixed = recoveryPlan();
1349
+ assert.deepEqual(actionIds(fixed), ["setup-shell", "verify-readiness"]);
1350
+ const h = harness({ results: installedList });
1351
+ const result = await runStandardInstall({ plan: fixed, consent: true }, h.adapters);
1352
+ assert.equal(result.outcome, "ready");
1353
+ assert.deepEqual(result.completed, RECOVERY_STEPS);
1354
+ assert.deepEqual(h.pnpmCalls(), ["bin -g", LIST, LIST]);
1355
+ assert.equal(h.calls.some((call) => call.args.includes("add")), false);
1356
+ assert.deepEqual(h.calls.at(-1)?.args, [SHELL_ENTRY, "setup"]);
1357
+ assert.deepEqual(h.integrityCalls, [{ packageRoot: PACKAGE_ROOT, platform: "linux", env: h.adapters.env, home: HOME }]);
1358
+ // npm and pnpm persisted by the earlier run live in their own project.
1359
+ const persisted = harness({ results: { [LIST]: { code: 0, stdout: persistedListing() } } });
1360
+ assert.equal((await runStandardInstall({ plan: fixed, consent: true }, persisted.adapters)).outcome, "ready");
1361
+ });
1362
+
1363
+ test("setup recovery off PATH persists PATH, needs a new terminal, and reports failed steps", async () => {
1364
+ const env = { PATH: "/opt/node/bin:/usr/bin" };
1365
+ const fixed = recoveryPlan("linux", { globalBin: { available: true, path: BIN, writable: true, onPath: false } });
1366
+ assert.deepEqual(actionIds(fixed), ["setup-global-bin", "setup-shell", "verify-readiness"]);
1367
+ const h = harness({ env, results: installedList });
1368
+ const result = await runStandardInstall({ plan: fixed, consent: true }, h.adapters);
1369
+ assert.equal(result.outcome, "terminal-action-required");
1370
+ assert.deepEqual(result.completed, [...RECOVERY_STEPS, "persist-path"]);
1371
+ assert.deepEqual(h.pnpmCalls(), ["bin -g", LIST, LIST, "setup"]);
1372
+ const failures: [object, string][] = [
1373
+ [{ "gentle-shell setup": { code: 1, stderrTail: "Error: API rate limit exceeded\n" } }, "shell-setup"],
1374
+ [{ "pnpm setup": { code: 1 } }, "persist-path"],
1375
+ ];
1376
+ for (const [results, step] of failures) {
1377
+ const failing = harness({ env, results: { ...installedList, ...results } });
1378
+ const failed = await runStandardInstall({ plan: fixed, consent: true }, failing.adapters);
1379
+ assert.equal(failed.outcome, "failed");
1380
+ assert.equal(failed.failedStep, step);
1381
+ assert.equal(failing.calls.some((call) => call.args.includes("add")), false);
1382
+ }
1383
+ const unverified = harness({ results: installedList, integrity: { ok: false } });
1384
+ const failed = await runStandardInstall({ plan: recoveryPlan(), consent: true }, unverified.adapters);
1385
+ assert.equal(failed.failedStep, "verify-gentle-ai");
1386
+ assert.equal(unverified.calls.some((call) => call.args[0] === SHELL_ENTRY), false);
1387
+ });
1388
+
1389
+ test("setup recovery blocks when the installed stack changed between plan and run", async () => {
1390
+ const stack = JSON.parse(listing());
1391
+ const changed = [
1392
+ "[]",
1393
+ listing("1.2.1"),
1394
+ listing(PI_INSTALL_VERSION, "3.0.0"),
1395
+ listing(PI_INSTALL_VERSION, requirements.shell, "/elsewhere/gentle-pi"),
1396
+ JSON.stringify([...stack, { dependencies: { "gentle-pi": { version: requirements.shell, path: PACKAGE_ROOT } } }]),
1397
+ JSON.stringify([...stack, { optionalDependencies: { "@earendil-works/pi-coding-agent": { version: PI_INSTALL_VERSION } } }]),
1398
+ "{}",
1399
+ "not json",
1400
+ ];
1401
+ for (const stdout of changed) {
1402
+ const h = harness({ results: { [LIST]: { code: 0, stdout } }, realpaths: { "/elsewhere/gentle-pi": "/elsewhere/gentle-pi" } });
1403
+ const result = await runStandardInstall({ plan: recoveryPlan(), consent: true }, h.adapters);
1404
+ assert.equal(result.outcome, "blocked", stdout);
1405
+ assert.equal(result.reason, "existing-stack-unverified", stdout);
1406
+ assert.deepEqual(result.completed, ["check-npm", "check-global-bin"]);
1407
+ assert.deepEqual(h.pnpmCalls(), ["bin -g", LIST]);
1408
+ assert.equal(h.calls.some((call) => call.args[0] === SHELL_ENTRY), false);
1409
+ }
1410
+ // A root that now resolves outside PNPM_HOME through a symlink is not this pnpm's install.
1411
+ const moved = harness({ results: installedList, realpaths: { [PACKAGE_ROOT]: "/elsewhere/gentle-pi" } });
1412
+ assert.equal((await runStandardInstall({ plan: recoveryPlan(), consent: true }, moved.adapters)).reason, "existing-stack-unverified");
1413
+ const failing = harness({ results: { [LIST]: { code: 1, stdout: listing() } } });
1414
+ assert.equal((await runStandardInstall({ plan: recoveryPlan(), consent: true }, failing.adapters)).reason, "global-list-unavailable");
1415
+ });
1416
+
1417
+ test("the setup recovery variant is accepted only as a fixed set", async () => {
1418
+ const clean = plan();
1419
+ const descriptor = (id: string) => clean.actions.find((action: { id: string }) => action.id === id);
1420
+ const persistNpm = { id: "persist-npm", kind: "install-global", target: "npm", version: "11.19.0" };
1421
+ const provision = { id: "provision-native", kind: "existing-installer", target: "gentleAi", version: requirements.gentleAi };
1422
+ const base = recoveryPlan();
1423
+ const offPath = recoveryPlan("linux", { globalBin: { available: true, path: BIN, writable: true, onPath: false } });
1424
+ const variants = [
1425
+ [descriptor("install-pi"), ...base.actions],
1426
+ [descriptor("install-shell"), ...base.actions],
1427
+ [persistNpm, ...base.actions],
1428
+ [provision, ...base.actions],
1429
+ base.actions.filter((action: { id: string }) => action.id !== "setup-shell"),
1430
+ offPath.actions.filter((action: { id: string }) => action.id !== "setup-shell"),
1431
+ base.actions.filter((action: { id: string }) => action.id !== "verify-readiness"),
1432
+ ];
1433
+ for (const actions of variants) {
1434
+ const h = harness({ results: installedList });
1435
+ const result = await runStandardInstall({ plan: { ...base, actions }, consent: true }, h.adapters);
1436
+ assert.equal(result.outcome, "blocked");
1437
+ assert.equal(result.reason, "unsupported-plan");
1438
+ assert.deepEqual(h.calls, []);
1439
+ }
1440
+ });
1441
+
1442
+ test("Windows setup recovery needs no Go: it never runs add -g, and the native binary is verified", async () => {
1443
+ const fixed = recoveryPlan("win32", { go: absent });
1444
+ assert.equal(fixed.tools.go.status, "not-required");
1445
+ const h = harness({ layout: windowsLayout, results: { [LIST]: { code: 0, stdout: listing(PI_INSTALL_VERSION, requirements.shell, W_ROOT) } } });
1446
+ const result = await runStandardInstall({ plan: fixed, consent: true }, h.adapters);
1447
+ assert.equal(result.outcome, "ready");
1448
+ assert.deepEqual(result.completed, RECOVERY_STEPS);
1449
+ assert.equal(h.pnpmCalls().some((call) => call.startsWith("add")), false);
1450
+ assert.deepEqual(h.calls.at(-1)?.args, [`${W_ROOT}\\bin\\gentle-shell.mjs`, "setup"]);
1451
+ // A clean Windows install still provides Go before gentle-pi's postinstall.
1452
+ assert.ok(plan("win32", { go: absent }).actions.some((action: { id: string }) => action.id === "acquire-go"));
1453
+ });
1454
+
1455
+ // Declared last: node:test runs a file's top-level tests in order.
1456
+ const AI_SHA = "1f9d5e6423e37f7d2316859045f379ba9b5d8c3a";
1457
+ const SHELL_SHA = "6e7e3a18f794223396527a54c7c36d19c7d236c6";
1458
+ const MAIN_VERSION = `${requirements.shell}-main.6e7e3a18f794`;
1459
+ const MAIN_ROOT = `${PNPM_HOME}/global/v11/def/node_modules/gentle-pi`;
1460
+ const MAIN_TGZ = `${HOME}/.pi/gentle-ai/main/packages/gentle-pi-${MAIN_VERSION}.tgz`;
1461
+ function mainPlan(change: object = {}) {
1462
+ return planPreflight({ platform: "linux", arch: "x64", node: tool("24.18.0"), pnpm: { ...tool("11.1.1"), compatible: true },
1463
+ pi: absent, shell: absent, gentleAi: absent, go: tool("1.26.0"),
1464
+ globalBin: { available: true, path: BIN, writable: true, onPath: true }, setup: false, ...change }, { channel: "main" });
1465
+ }
1466
+ function mainChannel() {
1467
+ const calls: Array<[string, object]> = [];
1468
+ return {
1469
+ calls,
1470
+ adapter: {
1471
+ resolveCommit: async (repository: string) => {
1472
+ calls.push(["resolveCommit", { repository }]);
1473
+ return repository.endsWith("/gentle-ai") ? AI_SHA : SHELL_SHA;
1474
+ },
1475
+ buildGentleAi: async (request: { commit: string; goPath: string; platform: string; ctx: object }) => {
1476
+ calls.push(["buildGentleAi", { commit: request.commit, goPath: request.goPath, platform: request.platform, ctx: request.ctx }]);
1477
+ return { binaryPath: "/main/gentle-ai", version: "4.0.1-0.20261008202137-1f9d5e6423e3" };
1478
+ },
1479
+ packShell: async (request: { commit: string; ctx: object; platform: string }) => {
1480
+ calls.push(["packShell", { commit: request.commit, ctx: request.ctx, platform: request.platform }]);
1481
+ return MAIN_TGZ;
1482
+ },
1483
+ writeChannel: async (ctx: object, state: object) => {
1484
+ calls.push(["writeChannel", { ctx, state }]);
1485
+ },
1486
+ },
1487
+ };
1488
+ }
1489
+ const MAIN_ADD = `add -g ${MAIN_TGZ} --allow-build=gentle-pi`;
1490
+ function mainHarness(mainListing = listing(PI_INSTALL_VERSION, MAIN_VERSION, MAIN_ROOT), extra: { texts?: Record<string, string>; results?: object } = {}) {
1491
+ const h = harness({ files: ["/usr/bin/go"], realpaths: { [MAIN_ROOT]: MAIN_ROOT },
1492
+ results: { [LIST]: [emptyList, { code: 0, stdout: listing() }, { code: 0, stdout: mainListing }],
1493
+ [`${MAIN_ROOT}/bin/gentle-shell.mjs setup`]: { code: 0 } }, ...extra,
1494
+ texts: { [besidePi(MAIN_ROOT)]: piPackage(), ...extra.texts } });
1495
+ const main = mainChannel();
1496
+ return { ...h, main, adapters: { ...h.adapters, mainChannel: main.adapter } };
1497
+ }
1498
+
1499
+ test("the main plan builds Gentle AI and installs the main Shell after the verified release install, then sets up the main Shell", async () => {
1500
+ const h = mainHarness();
1501
+ const result = await runStandardInstall({ plan: mainPlan(), consent: true }, h.adapters);
1502
+ assert.equal(result.outcome, "ready");
1503
+ assert.deepEqual(result.completed, ["check-npm", "check-global-bin", "check-existing-stack", "install-global",
1504
+ "verify-global-list", "verify-shell-bin", "verify-gentle-ai", "build-gentle-ai-main", "install-shell-main", "record-channel", "shell-setup"]);
1505
+ assert.deepEqual(h.pnpmCalls(), ["bin -g", LIST, INSTALL, LIST, MAIN_ADD, LIST]);
1506
+ const ctx = { env: h.adapters.env, home: HOME };
1507
+ assert.deepEqual(h.main.calls, [
1508
+ ["resolveCommit", { repository: "Gentleman-Programming/gentle-ai" }],
1509
+ ["buildGentleAi", { commit: AI_SHA, goPath: "/usr/bin/go", platform: "linux", ctx }],
1510
+ ["resolveCommit", { repository: "Gentleman-Programming/gentle-shell" }],
1511
+ ["packShell", { commit: SHELL_SHA, ctx, platform: "linux" }],
1512
+ ["writeChannel", { ctx, state: { channel: "main", shellCommit: SHELL_SHA, gentleAiCommit: AI_SHA } }],
1513
+ ]);
1514
+ assert.deepEqual(h.calls.at(-1)?.args, [`${MAIN_ROOT}/bin/gentle-shell.mjs`, "setup"]);
1515
+ // The release package's own integrity check ran before the override was registered.
1516
+ assert.deepEqual(h.integrityCalls, [{ packageRoot: PACKAGE_ROOT, platform: "linux", env: h.adapters.env, home: HOME }]);
1517
+ });
1518
+
1519
+ test("a main Shell that pnpm does not list at the main version of that commit fails before recording the channel", async () => {
1520
+ for (const mainListing of [listing(), listing(PI_INSTALL_VERSION, `${requirements.shell}-main.aaaaaaaaaaaa`, MAIN_ROOT)]) {
1521
+ const h = mainHarness(mainListing);
1522
+ const result = await runStandardInstall({ plan: mainPlan(), consent: true }, h.adapters);
1523
+ assert.equal(result.outcome, "failed");
1524
+ assert.equal(result.failedStep, "install-shell-main");
1525
+ assert.equal(h.main.calls.some(([name]) => name === "writeChannel"), false);
1526
+ assert.equal(h.calls.some((call) => call.args[1] === "setup"), false);
1527
+ }
1528
+ });
1529
+
1530
+ test("the main build fails without Go on PATH and runs nothing after it", async () => {
1531
+ const h = harness({ results: { [LIST]: [emptyList, { code: 0, stdout: listing() }] } });
1532
+ const main = mainChannel();
1533
+ const result = await runStandardInstall({ plan: mainPlan(), consent: true }, { ...h.adapters, mainChannel: main.adapter });
1534
+ assert.equal(result.outcome, "failed");
1535
+ assert.equal(result.failedStep, "build-gentle-ai-main");
1536
+ assert.deepEqual(main.calls.map(([name]) => name), []);
1537
+ });
1538
+
1539
+ test("a main plan with a missing or older Go builds Gentle AI with the pinned Go and never runs the user's Go", async () => {
1540
+ for (const go of [absent, tool("1.24.0")]) {
1541
+ const fixed = mainPlan({ go });
1542
+ assert.deepEqual(fixed.blockers, []);
1543
+ const h = withGo(mainHarness(undefined, { results: { [LIST]: [emptyList, { code: 0, stdout: listing() },
1544
+ { code: 0, stdout: listing(PI_INSTALL_VERSION, MAIN_VERSION, MAIN_ROOT) }], [`${MAIN_ROOT}/bin/gentle-shell.mjs setup`]: { code: 0 },
1545
+ version: goVersion() } }));
1546
+ const result = await runStandardInstall({ plan: fixed, consent: true }, h.adapters);
1547
+ assert.equal(result.outcome, "ready");
1548
+ assert.deepEqual(result.completed, ["check-npm", "check-global-bin", "check-existing-stack", "acquire-go", "verify-go", "install-global",
1549
+ "verify-global-list", "verify-shell-bin", "verify-gentle-ai", "build-gentle-ai-main", "install-shell-main", "record-channel", "shell-setup"]);
1550
+ const build = h.main.calls.find(([name]) => name === "buildGentleAi")?.[1] as { goPath: string };
1551
+ assert.equal(build.goPath, PINNED_GO);
1552
+ assert.equal(h.calls.some((call) => call.command === "/usr/bin/go"), false, "the user's Go is never run");
1553
+ }
1554
+ });
1555
+
1556
+ test("a failed pinned Go download stops before the runtime is persisted, so nothing was installed", async () => {
1557
+ const fixed = mainPlan({ node: bootstrapNode, go: absent });
1558
+ assert.deepEqual(fixed.blockers, []);
1559
+ const h = withGo(mainHarness(), new Error("Go verified acquisition failed; nothing was published", { cause: new Error("size") }));
1560
+ const result = await runStandardInstall({ plan: fixed, consent: true }, h.adapters);
1561
+ assert.equal(result.outcome, "failed");
1562
+ assert.equal(result.failedStep, "acquire-go");
1563
+ assert.deepEqual(result.completed, ["check-global-bin", "check-existing-stack"]);
1564
+ assert.equal(h.pnpmCalls().includes(RUNTIME_SET), false, "Node is not persisted");
1565
+ assert.equal("detail" in result, false, "a download failure carries no detail");
1566
+ });
1567
+
1568
+ test("a Go destination left by an earlier run fails acquire-go with the folder to remove, the home shortened to ~", async () => {
1569
+ const folder = `${HOME}/.pi/gentle-ai/tools/go/${goAcquisition.version}`;
1570
+ const h = withGo(mainHarness(), new Error("Go verified acquisition failed; nothing was published",
1571
+ { cause: new Error(`Conflicting Go destination: ${folder}`) }));
1572
+ const result = await runStandardInstall({ plan: mainPlan({ go: absent }), consent: true }, h.adapters);
1573
+ assert.equal(result.outcome, "failed");
1574
+ assert.equal(result.failedStep, "acquire-go");
1575
+ assert.equal(result.detail, `Conflicting Go destination: ~/.pi/gentle-ai/tools/go/${goAcquisition.version}`);
1576
+ });
1577
+
1578
+ test("a failed main step reports the main channel's error and its cause, the home as ~, never another error's text", async () => {
1579
+ const build = mainHarness();
1580
+ build.adapters.mainChannel.buildGentleAi = async () => {
1581
+ throw new MainChannelError("main-gentle-ai-build-failed", "go install github.com/gentleman-programming/gentle-ai/v4/cmd/gentle-ai@x failed",
1582
+ new Error(`go: downloading golang.org/x/sys\ngolang.org/x/sys/cpu: fork/exec ${HOME}/.pi/asm: The directory name is invalid.`));
1583
+ };
1584
+ const built = await runStandardInstall({ plan: mainPlan(), consent: true }, build.adapters);
1585
+ assert.deepEqual([built.failedStep, built.detail], ["build-gentle-ai-main", "golang.org/x/sys/cpu: fork/exec ~/.pi/asm: The directory name is invalid."]);
1586
+ const limited = mainHarness();
1587
+ limited.adapters.mainChannel.resolveCommit = async (repository: string) => {
1588
+ if (repository.endsWith("/gentle-ai")) return AI_SHA;
1589
+ throw new MainChannelError("main-commit-unavailable", `GitHub did not return the latest main commit of ${repository} (HTTP 403)`);
1590
+ };
1591
+ const resolved = await runStandardInstall({ plan: mainPlan(), consent: true }, limited.adapters);
1592
+ assert.deepEqual([resolved.failedStep, resolved.detail],
1593
+ ["install-shell-main", "main-commit-unavailable: GitHub did not return the latest main commit of Gentleman-Programming/gentle-shell (HTTP 403)"]);
1594
+ // The main package's own pnpm add reports pnpm's line, as install-global does.
1595
+ const add = mainHarness(undefined, { results: { [LIST]: [emptyList, { code: 0, stdout: listing() }], add: [{ code: 0 }, { code: 1, stdout: PNPM_ADD_POSTINSTALL }] } });
1596
+ const added = await runStandardInstall({ plan: mainPlan(), consent: true }, add.adapters);
1597
+ assert.deepEqual([added.failedStep, added.detail], ["install-shell-main", ".../gentle-pi postinstall: root cause Error: go: open ~/x: The directory name is invalid."]);
1598
+ assert.equal(add.calls.filter((call) => call.stderrTail === 4096 && call.args.includes(MAIN_TGZ)).length, 1);
1599
+ // Any other error keeps its text out of the result.
1600
+ const plain = mainHarness();
1601
+ plain.adapters.mainChannel.buildGentleAi = async () => { throw new Error(`EACCES ${HOME}/.pi`); };
1602
+ const hidden = await runStandardInstall({ plan: mainPlan(), consent: true }, plain.adapters);
1603
+ assert.deepEqual([hidden.failedStep, "detail" in hidden], ["build-gentle-ai-main", false]);
1604
+ });
1605
+
1606
+ test("a channel that cannot be recorded fails the record step after the main Shell is installed", async () => {
1607
+ const h = mainHarness();
1608
+ h.adapters.mainChannel.writeChannel = async () => { throw new Error("EACCES /home/u/.pi"); };
1609
+ const result = await runStandardInstall({ plan: mainPlan(), consent: true }, h.adapters);
1610
+ assert.equal(result.outcome, "failed");
1611
+ assert.equal(result.failedStep, "record-channel");
1612
+ assert.equal(JSON.stringify(result).includes("EACCES"), false);
1613
+ });
1614
+
1615
+ test("a release plan never uses the main channel", async () => {
1616
+ const h = harness();
1617
+ const main = mainChannel();
1618
+ const result = await runStandardInstall({ plan: plan(), consent: true }, { ...h.adapters, mainChannel: main.adapter });
1619
+ assert.equal(result.outcome, "ready");
1620
+ assert.deepEqual(main.calls, []);
1621
+ });
1622
+
1623
+ test("a partial set of main steps is an unsupported plan", async () => {
1624
+ const partial = mainPlan();
1625
+ partial.actions = partial.actions.filter((action: { id: string }) => action.id !== "record-channel");
1626
+ const h = mainHarness();
1627
+ const result = await runStandardInstall({ plan: partial, consent: true }, h.adapters);
1628
+ assert.deepEqual([result.outcome, result.reason], ["blocked", "unsupported-plan"]);
1629
+ assert.deepEqual(h.calls, []);
1630
+ });
1631
+
1632
+ // --- Existing installations: install only Gentle Shell, or update it ----------------------------
1633
+ function existingPlan(change: object, channel = "release") {
1634
+ return planPreflight({ platform: "linux", arch: "x64", node: tool("24.18.0"), pnpm: { ...tool("11.1.1"), compatible: true },
1635
+ pi: tool("1.2.0"), shell: absent, gentleAi: absent, go: tool("1.26.0"),
1636
+ globalBin: { available: true, path: BIN, writable: true, onPath: true }, setup: false, ...change }, { channel });
1637
+ }
1638
+ const SHELL_ONLY_ADD = `add -g gentle-pi@${requirements.shell} --allow-build=gentle-pi`;
1639
+ const NPM_SHELL_ROOT = "/usr/local/lib/node_modules/gentle-pi";
1640
+
1641
+ test("with a compatible Pi already installed, only Gentle Shell is added and Pi is left as it is", async () => {
1642
+ // A pnpm Pi newer than PI_INSTALL_VERSION is kept too: gentle-pi gets its own pnpm group, nothing is replaced.
1643
+ for (const version of ["1.2.1", PI_INSTALL_VERSION]) {
1644
+ const h = harness({ results: { [LIST]: [piOnlyList(version), { code: 0, stdout: listing(version) }], [SHELL_ONLY_ADD]: { code: 0 } } });
1645
+ const result = await runStandardInstall({ plan: existingPlan({ pi: tool(version) }), consent: true }, h.adapters);
1646
+ assert.equal(result.outcome, "ready", version);
1647
+ assert.deepEqual(h.pnpmCalls(), ["bin -g", LIST, SHELL_ONLY_ADD, LIST]);
1648
+ assert.deepEqual(result.completed, ["check-npm", "check-global-bin", "check-existing-shell", "install-global",
1649
+ "verify-global-list", "verify-shell-bin", "verify-gentle-ai", "shell-setup"]);
1650
+ assert.equal(h.calls.some((call) => call.args.includes(`${PI_PACKAGE_NAME}@${PI_INSTALL_VERSION}`)), false);
1651
+ }
1652
+ });
1653
+
1654
+ test("adding only Gentle Shell still refuses a gentle-pi that pnpm already lists", async () => {
1655
+ const h = harness({ results: { [LIST]: { code: 0, stdout: listing("1.2.1") } } });
1656
+ const result = await runStandardInstall({ plan: existingPlan({}), consent: true }, h.adapters);
1657
+ assert.deepEqual([result.outcome, result.reason], ["blocked", "existing-stack"]);
1658
+ assert.equal(h.calls.some((call) => call.args[1] === "add"), false);
1659
+ });
1660
+
1661
+ // --- The Pi that Gentle Shell runs: pnpm 11 installs every `add -g` argument as its own
1662
+ // group, so gentle-pi may get a newer Pi beside it than PI_INSTALL_VERSION (at least the minimum).
1663
+ test("Pi and gentle-pi are added as separate pnpm groups, so a later `pi update` keeps gentle-pi", async () => {
1664
+ const separate = ["add", "-g", `${PI_PACKAGE_NAME}@${PI_INSTALL_VERSION}`, `gentle-pi@${requirements.shell}`, "--allow-build=gentle-pi"];
1665
+ const h = harness();
1666
+ const result = await runStandardInstall({ plan: plan(), consent: true }, h.adapters);
1667
+ assert.equal(result.outcome, "ready");
1668
+ assert.deepEqual(h.calls.filter((call) => call.args[1] === "add").map((call) => call.args.slice(1)), [separate]);
1669
+ assert.equal(h.calls.some((call) => call.args.some((arg) => arg.includes(","))), false);
1670
+ const w = harness({ layout: windowsLayout });
1671
+ assert.equal((await runStandardInstall({ plan: plan("win32"), consent: true }, w.adapters)).outcome, "ready");
1672
+ assert.deepEqual(w.calls.filter((call) => call.args[1] === "add").map((call) => call.args.slice(1)), [separate]);
1673
+ });
1674
+
1675
+ test("verify-global-list accepts the Pi the launcher runs beside gentle-pi at the minimum or newer, and reports one other than the pin", async () => {
1676
+ // pnpm's latest peer beside gentle-pi (or nested in it): Gentle Shell runs it, and the outcome says which.
1677
+ for (const texts of [{ [besidePi(PACKAGE_ROOT)]: piPackage("1.2.1") }, { [nestedPi(PACKAGE_ROOT)]: piPackage("1.2.1") },
1678
+ { [besidePi(PACKAGE_ROOT)]: piPackage(requirements.pi) }]) {
1679
+ const h = harness({ texts });
1680
+ const result = await runStandardInstall({ plan: plan(), consent: true }, h.adapters);
1681
+ assert.equal(result.outcome, "ready", JSON.stringify(texts));
1682
+ const selectedVersion = JSON.parse(Object.values(texts)[0]).version;
1683
+ assert.equal(result.piVersion, selectedVersion === PI_INSTALL_VERSION ? undefined : selectedVersion);
1684
+ }
1685
+ // The pin itself adds nothing to the outcome.
1686
+ assert.equal("piVersion" in (await runStandardInstall({ plan: plan(), consent: true }, harness().adapters)), false);
1687
+ const wrong = [
1688
+ { [besidePi(PACKAGE_ROOT)]: piPackage("0.99.0") },
1689
+ // A Pi nested in gentle-pi resolves first, even below the minimum.
1690
+ { [nestedPi(PACKAGE_ROOT)]: piPackage("0.99.0"), [besidePi(PACKAGE_ROOT)]: piPackage("1.2.1") },
1691
+ { [besidePi(PACKAGE_ROOT)]: piPackage("1.2.0-beta.1") },
1692
+ { [besidePi(PACKAGE_ROOT)]: JSON.stringify({ name: "not-pi", version: PI_INSTALL_VERSION }) },
1693
+ { [besidePi(PACKAGE_ROOT)]: "not json" },
1694
+ ];
1695
+ for (const texts of wrong) {
1696
+ const h = harness({ texts });
1697
+ const result = await runStandardInstall({ plan: plan(), consent: true }, h.adapters);
1698
+ assert.deepEqual([result.outcome, result.failedStep], ["failed", "verify-global-list"], JSON.stringify(texts));
1699
+ assert.equal(h.calls.some((call) => call.args[0] === SHELL_ENTRY), false);
1700
+ }
1701
+ // No Pi beside gentle-pi at all: the launcher would run any `pi` on PATH.
1702
+ const bare = `${PNPM_HOME}/global/v11/xyz/node_modules/gentle-pi`;
1703
+ const missing = harness({ realpaths: { [bare]: bare },
1704
+ results: { [LIST]: [emptyList, { code: 0, stdout: listing(PI_INSTALL_VERSION, requirements.shell, bare) }] } });
1705
+ const failed = await runStandardInstall({ plan: plan(), consent: true }, missing.adapters);
1706
+ assert.deepEqual([failed.outcome, failed.failedStep], ["failed", "verify-global-list"]);
1707
+ });
1708
+
1709
+ test("the main Shell is added alone and its adjacent Pi is verified like the release one", async () => {
1710
+ const h = mainHarness(undefined, { texts: { [besidePi(MAIN_ROOT)]: piPackage("1.2.1") } });
1711
+ const result = await runStandardInstall({ plan: mainPlan(), consent: true }, h.adapters);
1712
+ assert.equal(result.outcome, "ready");
1713
+ assert.equal(result.piVersion, "1.2.1");
1714
+ assert.deepEqual(h.calls.filter((call) => call.args[1] === "add").map((call) => call.args.slice(1)),
1715
+ [INSTALL.split(" "), MAIN_ADD.split(" ")]);
1716
+ const below = mainHarness(undefined, { texts: { [besidePi(MAIN_ROOT)]: piPackage("0.99.0") } });
1717
+ const failed = await runStandardInstall({ plan: mainPlan(), consent: true }, below.adapters);
1718
+ assert.deepEqual([failed.outcome, failed.failedStep], ["failed", "install-shell-main"]);
1719
+ });
1720
+
1721
+ test("a pnpm-owned Gentle Shell update is verified with a Pi at the minimum or newer beside it; an npm-owned one is not", async () => {
1722
+ const OLD_ROOT = `${PNPM_HOME}/global/v11/old/node_modules/gentle-pi`;
1723
+ const fixed = existingPlan({ shell: { available: true, version: "3.9.0", usable: true, global: true, owner: "pnpm" },
1724
+ gentleAi: { available: null }, setup: { available: null } });
1725
+ const located = [{ root: OLD_ROOT, version: "3.9.0", owner: "pnpm" }, { root: PACKAGE_ROOT, version: requirements.shell, owner: "pnpm" }];
1726
+ const h = updateHarness({ located, texts: { [besidePi(PACKAGE_ROOT)]: piPackage("1.2.1") } });
1727
+ const result = await runStandardInstall({ plan: fixed, consent: true }, h.adapters);
1728
+ assert.equal(result.outcome, "ready");
1729
+ assert.equal(result.piVersion, "1.2.1");
1730
+ assert.deepEqual(result.completed.slice(3, 5), ["update-shell", "verify-updated-shell"]);
1731
+ for (const texts of [{ [besidePi(PACKAGE_ROOT)]: piPackage("0.99.0") }, { [nestedPi(PACKAGE_ROOT)]: piPackage("0.99.0") }]) {
1732
+ const wrong = updateHarness({ located, texts });
1733
+ const failed = await runStandardInstall({ plan: fixed, consent: true }, wrong.adapters);
1734
+ assert.deepEqual([failed.outcome, failed.failedStep], ["failed", "verify-updated-shell"]);
1735
+ }
1736
+ // npm keeps resolving its own peer; nothing beside an npm Shell is read.
1737
+ const npm = updateHarness();
1738
+ const npmResult = await runStandardInstall({ plan: existingPlan({ shell: { available: true, version: "3.9.0", usable: true, global: true, owner: "npm" },
1739
+ gentleAi: { available: null }, setup: { available: null } }), consent: true }, npm.adapters);
1740
+ assert.equal(npmResult.outcome, "ready");
1741
+ });
1742
+
1743
+ test("a setup recovery accepts a newer Pi beside gentle-pi but not one below the minimum", async () => {
1744
+ const newer = harness({ results: installedList, texts: { [besidePi(PACKAGE_ROOT)]: piPackage("1.2.1") } });
1745
+ assert.equal((await runStandardInstall({ plan: recoveryPlan(), consent: true }, newer.adapters)).outcome, "ready");
1746
+ const h = harness({ results: installedList, texts: { [besidePi(PACKAGE_ROOT)]: piPackage("0.99.0") } });
1747
+ const result = await runStandardInstall({ plan: recoveryPlan(), consent: true }, h.adapters);
1748
+ assert.deepEqual([result.outcome, result.reason], ["blocked", "existing-stack-unverified"]);
1749
+ assert.equal(h.calls.some((call) => call.args[0] === SHELL_ENTRY), false);
1750
+ });
1751
+
1752
+ function updateHarness({ located = [{ root: NPM_SHELL_ROOT, version: "3.9.0", owner: "npm" }, { root: NPM_SHELL_ROOT, version: requirements.shell, owner: "npm" }] as Array<object | null>,
1753
+ upgrade = async () => true as boolean, results = {} as Record<string, Result>, texts = {} as Record<string, string> } = {}) {
1754
+ const h = harness({ results: { [`${NPM_SHELL_ROOT}/bin/gentle-shell.mjs setup`]: { code: 0 }, ...results }, texts });
1755
+ const upgrades: object[] = [];
1756
+ let locates = 0;
1757
+ const adapters = { ...h.adapters,
1758
+ locateShell: async () => located[Math.min(locates++, located.length - 1)],
1759
+ upgradeShell: async (request: object) => {
1760
+ upgrades.push(request);
1761
+ return upgrade();
1762
+ } };
1763
+ return { ...h, adapters, upgrades };
1764
+ }
1765
+
1766
+ test("an older Gentle Shell is updated to the latest release with its owner's logic, verified, then set up", async () => {
1767
+ const h = updateHarness();
1768
+ const result = await runStandardInstall({ plan: existingPlan({ shell: { available: true, version: "3.9.0", usable: true, global: true, owner: "npm" },
1769
+ gentleAi: { available: null }, setup: { available: null } }), consent: true }, h.adapters);
1770
+ assert.equal(result.outcome, "ready");
1771
+ assert.deepEqual(result.completed, ["check-npm", "check-global-bin", "check-installed-shell", "update-shell", "verify-updated-shell",
1772
+ "verify-gentle-ai", "shell-setup"]);
1773
+ assert.deepEqual(h.upgrades, [{ channel: "release", packageRoot: NPM_SHELL_ROOT, currentVersion: "3.9.0" }]);
1774
+ assert.deepEqual(h.integrityCalls, [{ packageRoot: NPM_SHELL_ROOT, platform: "linux", env: h.adapters.env, home: HOME }]);
1775
+ assert.deepEqual(h.calls.at(-1)?.args, [`${NPM_SHELL_ROOT}/bin/gentle-shell.mjs`, "setup"]);
1776
+ assert.equal(h.calls.some((call) => call.args[1] === "add"), false);
1777
+ });
1778
+
1779
+ test("updating to main requires a main version afterwards and skips the pinned binary check", async () => {
1780
+ const mainVersion = `${requirements.shell}-main.6e7e3a18f794`;
1781
+ const plan = existingPlan({ shell: { available: true, version: requirements.shell, usable: true, global: true, owner: "npm" },
1782
+ gentleAi: { available: null }, setup: { available: null } }, "main");
1783
+ const h = updateHarness({ located: [{ root: NPM_SHELL_ROOT, version: requirements.shell, owner: "npm" }, { root: NPM_SHELL_ROOT, version: mainVersion, owner: "npm" }] });
1784
+ const result = await runStandardInstall({ plan, consent: true }, h.adapters);
1785
+ assert.equal(result.outcome, "ready");
1786
+ assert.deepEqual(h.upgrades, [{ channel: "main", packageRoot: NPM_SHELL_ROOT, currentVersion: requirements.shell }]);
1787
+ assert.equal(result.completed.includes("verify-gentle-ai"), false);
1788
+ const stale = updateHarness({ located: [{ root: NPM_SHELL_ROOT, version: requirements.shell, owner: "npm" }] });
1789
+ const failed = await runStandardInstall({ plan, consent: true }, stale.adapters);
1790
+ assert.deepEqual([failed.outcome, failed.failedStep], ["failed", "verify-updated-shell"]);
1791
+ });
1792
+
1793
+ test("updating to main without a usable Go acquires the pinned Go first and hands it to the upgrade", async () => {
1794
+ const mainVersion = `${requirements.shell}-main.6e7e3a18f794`;
1795
+ const fixed = existingPlan({ shell: { available: true, version: requirements.shell, usable: true, global: true, owner: "npm" },
1796
+ gentleAi: { available: null }, setup: { available: null }, go: absent }, "main");
1797
+ assert.deepEqual(fixed.actions.map((action: { id: string }) => action.id), ["acquire-go", "verify-go", "update-shell-main", "setup-shell", "verify-readiness"]);
1798
+ const h = withGo(updateHarness({ located: [{ root: NPM_SHELL_ROOT, version: requirements.shell, owner: "npm" }, { root: NPM_SHELL_ROOT, version: mainVersion, owner: "npm" }],
1799
+ results: { version: goVersion() } }));
1800
+ const result = await runStandardInstall({ plan: fixed, consent: true }, h.adapters);
1801
+ assert.equal(result.outcome, "ready");
1802
+ assert.deepEqual(result.completed.slice(3, 6), ["acquire-go", "verify-go", "update-shell"]);
1803
+ assert.deepEqual(h.upgrades, [{ channel: "main", packageRoot: NPM_SHELL_ROOT, currentVersion: requirements.shell, goPath: PINNED_GO }]);
1804
+ });
1805
+
1806
+ test("a missing Pi is installed before the existing Gentle Shell is updated", async () => {
1807
+ const h = updateHarness();
1808
+ const plan = existingPlan({ pi: absent, shell: { available: true, version: "3.9.0", usable: true, global: true, owner: "npm" },
1809
+ gentleAi: { available: null }, setup: { available: null } });
1810
+ const result = await runStandardInstall({ plan, consent: true }, h.adapters);
1811
+ assert.equal(result.outcome, "ready");
1812
+ assert.deepEqual(result.completed.slice(3, 5), ["install-pi", "update-shell"]);
1813
+ assert.ok(h.pnpmCalls().includes(`add -g ${PI_PACKAGE_NAME}@${PI_INSTALL_VERSION}`));
1814
+ });
1815
+
1816
+ test("a failed Pi installation stops before the existing Gentle Shell is touched", async () => {
1817
+ const h = updateHarness({ results: { add: { code: 1 } } });
1818
+ const plan = existingPlan({ pi: absent, shell: { available: true, version: "3.9.0", usable: true, global: true, owner: "npm" },
1819
+ gentleAi: { available: null }, setup: { available: null } });
1820
+ const result = await runStandardInstall({ plan, consent: true }, h.adapters);
1821
+ assert.deepEqual([result.outcome, result.failedStep], ["failed", "install-pi"]);
1822
+ assert.deepEqual(h.upgrades, []);
1823
+ });
1824
+
1825
+ test("an update stops before changing anything when the installed Gentle Shell or its owner cannot be confirmed", async () => {
1826
+ const plan = existingPlan({ shell: { available: true, version: "3.9.0", usable: true, global: true, owner: "npm" },
1827
+ gentleAi: { available: null }, setup: { available: null } });
1828
+ for (const located of [[null], [{ root: "/home/u/work/gentle-pi", version: "3.9.0", owner: null }]]) {
1829
+ const h = updateHarness({ located });
1830
+ const result = await runStandardInstall({ plan, consent: true }, h.adapters);
1831
+ assert.deepEqual([result.outcome, result.reason], ["blocked", "existing-stack-unverified"]);
1832
+ assert.deepEqual(h.upgrades, []);
1833
+ }
1834
+ const h = updateHarness({ upgrade: async () => { throw new Error("npm ERR! EACCES /usr/local"); } });
1835
+ const result = await runStandardInstall({ plan, consent: true }, h.adapters);
1836
+ assert.deepEqual([result.outcome, result.failedStep], ["failed", "update-shell"]);
1837
+ assert.equal(JSON.stringify(result).includes("EACCES"), false);
1838
+ });
1839
+
1840
+ test("a failed Gentle Shell update reports the upgrade's main-channel error, the home as ~", async () => {
1841
+ const plan = existingPlan({ shell: { available: true, version: "3.9.0", usable: true, global: true, owner: "npm" },
1842
+ gentleAi: { available: null }, setup: { available: null } });
1843
+ const h = updateHarness({ upgrade: async () => {
1844
+ throw new MainChannelError("main-shell-pack-failed", "the Gentle Shell source archive could not be extracted",
1845
+ new Error(`tar (child): Cannot connect to D: resolve failed\ntar: Error is not recoverable: exiting now (${HOME}/.pi)`));
1846
+ } });
1847
+ const result = await runStandardInstall({ plan, consent: true }, h.adapters);
1848
+ assert.deepEqual([result.failedStep, result.detail], ["update-shell", "tar: Error is not recoverable: exiting now (~/.pi)"]);
1849
+ });
1850
+
1851
+ // --- An older Pi: updated with the package manager that owns it, before any Gentle Shell step ------
1852
+ const PI_ROOT = `${PNPM_HOME}/global/v11/abc/node_modules/${PI_PACKAGE_NAME}`;
1853
+ const NEW_PI_ROOT = `${PNPM_HOME}/global/v11/def/node_modules/${PI_PACKAGE_NAME}`;
1854
+ const NPM_ROOT = "/usr/local/lib/node_modules";
1855
+ const NPM_PI_ROOT = `${NPM_ROOT}/${PI_PACKAGE_NAME}`;
1856
+ const USER_NPM = "/opt/node/bin/npm";
1857
+ const PI_UPDATE = `add -g ${PI_PACKAGE_NAME}@${PI_INSTALL_VERSION}`;
1858
+ const NPM_PI_UPDATE = `install -g ${PI_PACKAGE_NAME}@${PI_INSTALL_VERSION}`;
1859
+ const olderPi = (owner: string, version = "0.87.1") => ({ available: true, version, usable: true, owner });
1860
+ const piBefore = { root: PI_ROOT, version: "0.87.1", owner: "pnpm" };
1861
+ const piAfter = { root: NEW_PI_ROOT, version: PI_INSTALL_VERSION, owner: "pnpm" };
1862
+ const npmPiBefore = { root: NPM_PI_ROOT, version: "0.87.1", owner: "npm" };
1863
+ const npmPiAfter = { root: NPM_PI_ROOT, version: PI_INSTALL_VERSION, owner: "npm" };
1864
+ const piOnlyList = (version: string) => ({ code: 0, stdout: JSON.stringify([{ path: `${PNPM_HOME}/global/v11`, dependencies: {
1865
+ [PI_PACKAGE_NAME]: { version } } }]) });
1866
+
1867
+ /** Adds a locatePi adapter answering each call with the next entry (the last one repeats). */
1868
+ function locatingPi<T extends { adapters: object }>(h: T, located: Array<object | null>) {
1869
+ let locates = 0;
1870
+ return { ...h, adapters: { ...h.adapters, locatePi: async () => located[Math.min(locates++, located.length - 1)] } };
1871
+ }
1872
+ const npmResults = { "root -g": { code: 0, stdout: `${NPM_ROOT}\n` }, [NPM_PI_UPDATE]: { code: 0 } };
1873
+
1874
+ test("an older pnpm Pi is updated with pnpm and verified before only Gentle Shell is added", async () => {
1875
+ const h = locatingPi(harness({ results: { [LIST]: [piOnlyList("0.87.1"), { code: 0, stdout: listing(PI_INSTALL_VERSION) }] } }),
1876
+ [piBefore, piAfter]);
1877
+ const result = await runStandardInstall({ plan: existingPlan({ pi: olderPi("pnpm") }), consent: true }, h.adapters);
1878
+ assert.equal(result.outcome, "ready");
1879
+ assert.deepEqual(result.completed, ["check-npm", "check-global-bin", "check-existing-shell", "check-installed-pi", "update-pi",
1880
+ "verify-updated-pi", "install-global", "verify-global-list", "verify-shell-bin", "verify-gentle-ai", "shell-setup"]);
1881
+ assert.deepEqual(h.pnpmCalls(), ["bin -g", LIST, PI_UPDATE, SHELL_ONLY_ADD, LIST]);
1882
+ });
1883
+
1884
+ test("an older npm Pi is updated with npm in its own global root, never with pnpm", async () => {
1885
+ const h = locatingPi(harness({ results: { ...npmResults, [LIST]: [emptyList, { code: 0, stdout: listing(PI_INSTALL_VERSION) }] },
1886
+ realpaths: { [NPM_ROOT]: NPM_ROOT } }), [npmPiBefore, npmPiAfter]);
1887
+ const result = await runStandardInstall({ plan: existingPlan({ pi: olderPi("npm") }), consent: true }, h.adapters);
1888
+ assert.equal(result.outcome, "ready");
1889
+ assert.deepEqual(result.completed.slice(3, 6), ["check-installed-pi", "update-pi", "verify-updated-pi"]);
1890
+ const npmCalls = h.calls.filter((call) => call.command === USER_NPM);
1891
+ // check-npm first reads that same npm in the child env; the update then runs it in the user's env.
1892
+ assert.deepEqual(npmCalls.map((call) => call.args.join(" ")), ["--version", "config get prefix", "root -g", NPM_PI_UPDATE]);
1893
+ for (const call of npmCalls.slice(2)) assert.deepEqual(call.env, h.adapters.env);
1894
+ assert.deepEqual(h.pnpmCalls(), ["bin -g", LIST, SHELL_ONLY_ADD, LIST]);
1895
+ });
1896
+
1897
+ test("Windows: an older npm Pi is updated through what Node.js's npm.cmd runs, never through cmd.exe", async () => {
1898
+ const npmRoot = `${W_APPDATA_NPM}\\node_modules`;
1899
+ const npmPi = `${npmRoot}\\@earendil-works\\pi-coding-agent`;
1900
+ const shell = { available: true, version: requirements.shell, usable: true, global: true, owner: "npm" };
1901
+ const fixed = planPreflight({ platform: "win32", arch: "x64", node: tool("24.18.0"), pnpm: { ...tool("11.1.1"), compatible: true },
1902
+ pi: olderPi("npm"), shell, gentleAi: absent, go: absent, globalBin: { available: true, path: W_BIN, writable: true, onPath: true }, setup: false });
1903
+ assert.deepEqual(fixed.actions.map((action: { id: string }) => action.id), ["update-pi", "verify-readiness"]);
1904
+ // npm reports its root in another case: the same directory on Windows.
1905
+ for (const reported of [npmRoot, npmRoot.toLowerCase()]) {
1906
+ const h = locatingPi(harness({ layout: windowsLayout, results: { "root -g": { code: 0, stdout: `${reported}\r\n` }, [NPM_PI_UPDATE]: { code: 0 } },
1907
+ realpaths: { [reported]: reported } }), [{ root: npmPi, version: "0.87.1", owner: "npm" }, { root: npmPi, version: PI_INSTALL_VERSION, owner: "npm" }]);
1908
+ const result = await runStandardInstall({ plan: fixed, consent: true }, h.adapters);
1909
+ assert.equal(result.outcome, "ready", reported);
1910
+ assert.deepEqual(h.calls.filter((call) => call.command === W_NODE && call.args[0] === W_NPM_CLI).map((call) => call.args.slice(1).join(" ")),
1911
+ ["--version", "config get prefix", "root -g", NPM_PI_UPDATE]);
1912
+ assert.equal(h.calls.some((call) => /(?:^|\\)cmd(?:\.exe)?$|\.cmd$/i.test(call.command)), false);
1913
+ }
1914
+ // An npm root that does not hold the Pi blocks before any change.
1915
+ const foreign = locatingPi(harness({ layout: windowsLayout, results: { "root -g": { code: 0, stdout: "C:\\Other\\node_modules\r\n" } },
1916
+ realpaths: { "C:\\Other\\node_modules": "C:\\Other\\node_modules" } }), [{ root: npmPi, version: "0.87.1", owner: "npm" }]);
1917
+ const blocked = await runStandardInstall({ plan: fixed, consent: true }, foreign.adapters);
1918
+ assert.deepEqual([blocked.outcome, blocked.reason], ["blocked", "existing-stack-unverified"]);
1919
+ assert.equal(foreign.calls.some((call) => call.args.includes("install")), false);
1920
+ });
1921
+
1922
+ test("a failed or unverified Pi update stops before any Gentle Shell change", async () => {
1923
+ const plan = existingPlan({ pi: olderPi("pnpm") });
1924
+ const failed = locatingPi(harness({ results: { [LIST]: piOnlyList("0.87.1"), add: { code: 1 } } }), [piBefore, piAfter]);
1925
+ const result = await runStandardInstall({ plan, consent: true }, failed.adapters);
1926
+ assert.deepEqual([result.outcome, result.failedStep], ["failed", "update-pi"]);
1927
+ assert.deepEqual(failed.pnpmCalls(), ["bin -g", LIST, PI_UPDATE]);
1928
+ // Still old, another owner, listed twice (null) or a pre-release afterwards: never a single reusable Pi.
1929
+ for (const after of [piBefore, { ...piAfter, owner: "npm" }, null, { ...piAfter, version: `${PI_INSTALL_VERSION}-rc.1` }]) {
1930
+ const h = locatingPi(harness({ results: { [LIST]: piOnlyList("0.87.1") } }), [piBefore, after]);
1931
+ const outcome = await runStandardInstall({ plan, consent: true }, h.adapters);
1932
+ assert.deepEqual([outcome.outcome, outcome.failedStep], ["failed", "verify-updated-pi"], JSON.stringify(after));
1933
+ assert.deepEqual(h.pnpmCalls(), ["bin -g", LIST, PI_UPDATE]);
1934
+ }
1935
+ // npm must leave the update in the same global root.
1936
+ const moved = locatingPi(harness({ results: { ...npmResults, [LIST]: emptyList }, realpaths: { [NPM_ROOT]: NPM_ROOT } }),
1937
+ [npmPiBefore, { ...npmPiAfter, root: "/elsewhere/node_modules/@earendil-works/pi-coding-agent" }]);
1938
+ const outcome = await runStandardInstall({ plan: existingPlan({ pi: olderPi("npm") }), consent: true }, moved.adapters);
1939
+ assert.deepEqual([outcome.outcome, outcome.failedStep], ["failed", "verify-updated-pi"]);
1940
+ });
1941
+
1942
+ test("a Pi update is refused before any change when the installed Pi is not the one in the plan", async () => {
1943
+ const plan = existingPlan({ pi: olderPi("pnpm") });
1944
+ // Gone, unattributed, another version, another owner, or already current: never reinstalled, never downgraded.
1945
+ for (const before of [null, { ...piBefore, owner: null }, { ...piBefore, version: "0.90.0" }, { ...piBefore, owner: "npm" },
1946
+ { ...piBefore, version: "1.2.1" }, { ...piBefore, version: PI_INSTALL_VERSION }, { ...piBefore, root: "relative/pi" }]) {
1947
+ const h = locatingPi(harness({ results: { [LIST]: piOnlyList("0.87.1") } }), [before]);
1948
+ const result = await runStandardInstall({ plan, consent: true }, h.adapters);
1949
+ assert.deepEqual([result.outcome, result.reason], ["blocked", "existing-stack-unverified"], JSON.stringify(before));
1950
+ assert.equal(h.calls.some((call) => ["add", "install"].includes(call.args[1]) || call.args[0] === "install"), false);
1951
+ }
1952
+ // The plan's own record of an already current Pi is never accepted as an update.
1953
+ const current = existingPlan({ pi: olderPi("pnpm") });
1954
+ current.tools.pi = { ...current.tools.pi, version: PI_INSTALL_VERSION };
1955
+ const h = locatingPi(harness({ results: { [LIST]: piOnlyList(PI_INSTALL_VERSION) } }), [{ ...piBefore, version: PI_INSTALL_VERSION }]);
1956
+ const result = await runStandardInstall({ plan: current, consent: true }, h.adapters);
1957
+ assert.deepEqual([result.outcome, result.reason], ["blocked", "existing-stack-unverified"]);
1958
+ // An npm whose global root does not hold the installed Pi would install somewhere else.
1959
+ const foreign = locatingPi(harness({ results: { ...npmResults, "root -g": { code: 0, stdout: "/opt/other/lib/node_modules\n" },
1960
+ [LIST]: emptyList }, realpaths: { "/opt/other/lib/node_modules": "/opt/other/lib/node_modules" } }), [npmPiBefore]);
1961
+ const npm = await runStandardInstall({ plan: existingPlan({ pi: olderPi("npm") }), consent: true }, foreign.adapters);
1962
+ assert.deepEqual([npm.outcome, npm.reason], ["blocked", "existing-stack-unverified"]);
1963
+ assert.equal(foreign.calls.some((call) => call.args[0] === "install"), false);
1964
+ });
1965
+
1966
+ test("a Pi update is only accepted for a Pi that preflight found older and owned", async () => {
1967
+ const forged = existingPlan({ pi: olderPi("pnpm") });
1968
+ forged.tools.pi = { status: "reusable", required: requirements.pi };
1969
+ const both = existingPlan({ pi: olderPi("pnpm") });
1970
+ both.actions.unshift({ id: "install-pi", kind: "install-global", target: "pi", version: requirements.pi });
1971
+ for (const plan of [forged, both]) {
1972
+ const h = locatingPi(harness(), [piBefore]);
1973
+ const result = await runStandardInstall({ plan, consent: true }, h.adapters);
1974
+ assert.deepEqual([result.outcome, result.reason], ["blocked", "unsupported-plan"]);
1975
+ assert.deepEqual(h.calls, []);
1976
+ }
1977
+ });
1978
+
1979
+ test("an older Pi is updated before an existing Gentle Shell is updated; its failure leaves the Shell untouched", async () => {
1980
+ const plan = existingPlan({ pi: olderPi("pnpm"), shell: { available: true, version: "3.9.0", usable: true, global: true, owner: "npm" },
1981
+ gentleAi: { available: null }, setup: { available: null } });
1982
+ const h = locatingPi(updateHarness(), [piBefore, piAfter]);
1983
+ const result = await runStandardInstall({ plan, consent: true }, h.adapters);
1984
+ assert.equal(result.outcome, "ready");
1985
+ assert.deepEqual(result.completed, ["check-npm", "check-global-bin", "check-installed-shell", "check-installed-pi", "update-pi",
1986
+ "verify-updated-pi", "update-shell", "verify-updated-shell", "verify-gentle-ai", "shell-setup"]);
1987
+ assert.deepEqual(h.pnpmCalls(), ["bin -g", PI_UPDATE]);
1988
+ const failed = locatingPi(updateHarness({ results: { add: { code: 1 } } }), [piBefore, piAfter]);
1989
+ const outcome = await runStandardInstall({ plan, consent: true }, failed.adapters);
1990
+ assert.deepEqual([outcome.outcome, outcome.failedStep], ["failed", "update-pi"]);
1991
+ assert.deepEqual(failed.upgrades, []);
1992
+ });
1993
+
1994
+ test("with a current Gentle Shell, only the older Pi is updated: no setup, PATH change or Go", async () => {
1995
+ const shell = { available: true, version: requirements.shell, usable: true, global: true, owner: "npm" };
1996
+ const plan = existingPlan({ pi: olderPi("npm"), shell, gentleAi: { available: null }, setup: { available: null } });
1997
+ assert.deepEqual(plan.actions.map((action: { id: string }) => action.id), ["update-pi", "verify-readiness"]);
1998
+ const h = locatingPi(harness({ env: { PATH: "/opt/node/bin:/usr/bin" }, results: npmResults, realpaths: { [NPM_ROOT]: NPM_ROOT } }),
1999
+ [npmPiBefore, npmPiAfter]);
2000
+ const result = await runStandardInstall({ plan, consent: true }, h.adapters);
2001
+ assert.equal(result.outcome, "ready");
2002
+ assert.deepEqual(result.completed, ["check-npm", "check-global-bin", "check-installed-pi", "update-pi", "verify-updated-pi"]);
2003
+ assert.deepEqual(h.pnpmCalls(), ["bin -g"]);
2004
+ assert.equal(h.integrityCalls.length, 0);
2005
+ // Windows: a pnpm-owned Pi is updated through the pnpm handoff without Go.
2006
+ const windows = planPreflight({ platform: "win32", arch: "x64", node: tool("24.18.0"), pnpm: { ...tool("11.1.1"), compatible: true },
2007
+ pi: olderPi("pnpm"), shell, gentleAi: { available: null }, go: absent,
2008
+ globalBin: { available: true, path: W_BIN, writable: true, onPath: true }, setup: { available: null } });
2009
+ const w = locatingPi(harness({ layout: windowsLayout }), [{ ...piBefore, root: `${W_PNPM_HOME}\\global\\v11\\abc` }, { ...piAfter, root: `${W_PNPM_HOME}\\global\\v11\\def` }]);
2010
+ const wResult = await runStandardInstall({ plan: windows, consent: true }, w.adapters);
2011
+ assert.equal(wResult.outcome, "ready");
2012
+ assert.deepEqual(w.pnpmCalls(), ["bin -g", PI_UPDATE]);
2013
+ });
2014
+
2015
+ // --- An older Pi that neither pnpm nor npm owns: the installer's Pi is installed alongside ----------
2016
+ const externalPi = { available: true, version: "0.87.1", usable: true, external: true };
2017
+ const EXTERNAL_PI = "/home/u/.local/share/mise/installs/pi/bin/pi";
2018
+ // check-npm's read-only `npm --version` and `npm config get prefix` are not about Pi.
2019
+ const touchesExternalPi = (calls: Call[]) => calls.some((call) => call.command === EXTERNAL_PI ||
2020
+ (call.command === USER_NPM && !["--version", "config get prefix"].includes(call.args.join(" "))) ||
2021
+ call.args.some((arg) => arg.includes("mise")));
2022
+
2023
+ test("an older Pi that neither pnpm nor npm owns gets the installer's Pi alongside, exactly as when Pi is absent", async () => {
2024
+ const plan = existingPlan({ pi: externalPi });
2025
+ assert.deepEqual(plan.actions.map((action: { id: string }) => action.id), ["install-pi", "install-shell", "setup-shell", "verify-readiness"]);
2026
+ const h = harness();
2027
+ const result = await runStandardInstall({ plan, consent: true }, h.adapters);
2028
+ assert.equal(result.outcome, "ready");
2029
+ assert.deepEqual(result.completed, ["check-npm", "check-global-bin", "check-existing-stack", "install-global", "verify-global-list",
2030
+ "verify-shell-bin", "verify-gentle-ai", "shell-setup"]);
2031
+ assert.deepEqual(h.pnpmCalls(), ["bin -g", LIST, INSTALL, LIST]);
2032
+ assert.equal(touchesExternalPi(h.calls), false);
2033
+ });
2034
+
2035
+ test("with a current Gentle Shell, only the installer's Pi is added next to the older one, then found in pnpm's list", async () => {
2036
+ const shell = { available: true, version: requirements.shell, usable: true, global: true, owner: "npm" };
2037
+ const plan = existingPlan({ pi: externalPi, shell, gentleAi: { available: null }, setup: { available: null } });
2038
+ assert.deepEqual(plan.actions.map((action: { id: string }) => action.id), ["install-pi", "verify-readiness"]);
2039
+ const h = locatingPi(harness({ env: { PATH: "/opt/node/bin:/usr/bin" }, results: { [LIST]: emptyList } }), [piAfter]);
2040
+ const result = await runStandardInstall({ plan, consent: true }, h.adapters);
2041
+ assert.equal(result.outcome, "ready");
2042
+ assert.deepEqual(result.completed, ["check-npm", "check-global-bin", "check-existing-pi", "install-pi", "verify-installed-pi"]);
2043
+ assert.deepEqual(h.pnpmCalls(), ["bin -g", LIST, PI_UPDATE]);
2044
+ assert.equal(touchesExternalPi(h.calls), false);
2045
+ // pnpm already lists a Pi: never reinstalled.
2046
+ const listed = locatingPi(harness({ results: { [LIST]: piOnlyList(PI_INSTALL_VERSION) } }), [piAfter]);
2047
+ const existing = await runStandardInstall({ plan, consent: true }, listed.adapters);
2048
+ assert.deepEqual([existing.outcome, existing.reason], ["blocked", "existing-stack"]);
2049
+ assert.deepEqual(listed.pnpmCalls(), ["bin -g", LIST]);
2050
+ // Afterwards the next probe must find one pnpm-global Pi at the target version.
2051
+ for (const after of [null, { ...piAfter, owner: "npm" }, { ...piAfter, version: "0.99.1" }]) {
2052
+ const unverified = locatingPi(harness({ results: { [LIST]: emptyList } }), [after]);
2053
+ const outcome = await runStandardInstall({ plan, consent: true }, unverified.adapters);
2054
+ assert.deepEqual([outcome.outcome, outcome.failedStep], ["failed", "verify-installed-pi"], JSON.stringify(after));
2055
+ }
2056
+ const failed = locatingPi(harness({ results: { [LIST]: emptyList, add: { code: 1 } } }), [piAfter]);
2057
+ const outcome = await runStandardInstall({ plan, consent: true }, failed.adapters);
2058
+ assert.deepEqual([outcome.outcome, outcome.failedStep], ["failed", "install-pi"]);
2059
+ });
2060
+
2061
+ // S6: a private Windows PNPM_HOME is claimed first, after consent and before any
2062
+ // command, then every pnpm child gets it and pnpm's own folders inside it.
2063
+ const W_LOCAL_DEFAULT = "C:\\Users\\u\\AppData\\Local\\pnpm-weak";
2064
+ function privatePlan(change: object = {}, path = W_PNPM_HOME) {
2065
+ return plan("win32", { pnpmHome: { available: true, path, source: "private", rejected: { path: W_LOCAL_DEFAULT, check: "target-acl-mask" } }, ...change });
2066
+ }
2067
+ function preparing(h: ReturnType<typeof harness>, prepare: (home: string) => unknown = () => "claimed", windows = true) {
2068
+ const prepared: Array<{ home: string; calls: number }> = [];
2069
+ return { ...h, prepared, adapters: { ...h.adapters, env: { ...h.adapters.env, ...(windows ? { PNPM_HOME: W_PNPM_HOME } : {}) },
2070
+ preparePnpmHome: async (home: string) => { prepared.push({ home, calls: h.calls.length }); return prepare(home); } } };
2071
+ }
2072
+ const xdgKeys = ["XDG_CONFIG_HOME", "XDG_CACHE_HOME", "XDG_STATE_HOME"];
2073
+ test("a private Windows PNPM_HOME is claimed before any command and holds pnpm's config, cache and state", async () => {
2074
+ const h = preparing(harness({ layout: windowsLayout, env: { Path: `${W_NODE_DIR};C:\\Windows` } }));
2075
+ const result = await runStandardInstall({ plan: privatePlan(), consent: true }, h.adapters);
2076
+ assert.equal(result.outcome, "terminal-action-required");
2077
+ assert.deepEqual(h.prepared, [{ home: W_PNPM_HOME, calls: 0 }]);
2078
+ assert.equal(result.completed[0], "prepare-pnpm-home");
2079
+ assert.equal(h.pnpmCalls().at(-1), "setup", "pnpm setup persists the private PNPM_HOME and its bin");
2080
+ for (const call of h.calls) {
2081
+ assert.equal(call.env.PNPM_HOME, W_PNPM_HOME);
2082
+ assert.deepEqual(xdgKeys.map((key) => call.env[key]), [".config", ".cache", ".state"].map((folder) => `${W_PNPM_HOME}\\${folder}`));
2083
+ }
2084
+ // Folders the user set are kept.
2085
+ const own = preparing(harness({ layout: windowsLayout, env: { XDG_CACHE_HOME: "D:\\cache" } }));
2086
+ await runStandardInstall({ plan: privatePlan(), consent: true }, own.adapters);
2087
+ assert.ok(own.calls.every((call) => call.env.XDG_CACHE_HOME === "D:\\cache"));
2088
+ // Without a private PNPM_HOME nothing is claimed and no XDG folder is set, on Windows or POSIX.
2089
+ for (const [layout, fixed] of [[windowsLayout, plan("win32")], [posixLayout, plan()]] as const) {
2090
+ const plain = preparing(harness({ layout }), undefined, layout === windowsLayout);
2091
+ assert.equal((await runStandardInstall({ plan: fixed, consent: true }, plain.adapters)).outcome, "ready");
2092
+ assert.deepEqual(plain.prepared, []);
2093
+ assert.ok(plain.calls.length > 0 && plain.calls.every((call) => xdgKeys.every((key) => !(key in call.env))));
2094
+ }
2095
+ // The private record is Windows-only: a POSIX plan carrying one claims nothing.
2096
+ const posix = preparing(harness(), undefined, false);
2097
+ await runStandardInstall({ plan: { ...plan(), tools: { ...plan().tools, pnpmHome: privatePlan().tools.pnpmHome } }, consent: true }, posix.adapters);
2098
+ assert.deepEqual(posix.prepared, []);
2099
+ });
2100
+
2101
+ test("a private PNPM_HOME that changed, or could not be claimed, stops before any command", async () => {
2102
+ const moved = preparing(harness({ layout: windowsLayout }));
2103
+ const changed = await runStandardInstall({ plan: privatePlan({}, "C:\\Users\\u\\.pnpm"), consent: true }, moved.adapters);
2104
+ assert.deepEqual([changed.outcome, changed.reason], ["blocked", "pnpm-home-changed"]);
2105
+ assert.deepEqual([moved.prepared, moved.calls], [[], []]);
2106
+ for (const prepare of [() => { throw new Error("Windows PNPM_HOME claim rejected"); }, () => Promise.reject(new Error("x"))]) {
2107
+ const h = preparing(harness({ layout: windowsLayout }), prepare);
2108
+ const failed = await runStandardInstall({ plan: privatePlan(), consent: true }, h.adapters);
2109
+ assert.deepEqual([failed.outcome, failed.failedStep, failed.completed], ["failed", "prepare-pnpm-home", []]);
2110
+ assert.deepEqual(h.calls, []);
2111
+ }
2112
+ const h = harness({ layout: windowsLayout, env: { PNPM_HOME: W_PNPM_HOME } });
2113
+ const missing = await runStandardInstall({ plan: privatePlan(), consent: true }, h.adapters);
2114
+ assert.deepEqual([missing.outcome, missing.failedStep], ["failed", "prepare-pnpm-home"]);
2115
+ assert.deepEqual(h.calls, []);
2116
+ });
2117
+
2118
+ test("an update with a private PNPM_HOME still persists it with pnpm setup", async () => {
2119
+ const root = "C:\\Users\\u\\AppData\\Roaming\\npm\\node_modules\\gentle-pi";
2120
+ const fixed = plan("win32", { pi: tool("1.2.0"), shell: { available: true, version: "3.9.0", usable: true, global: true, owner: "npm" },
2121
+ gentleAi: { available: null }, setup: { available: null } });
2122
+ const h = preparing(harness({ layout: windowsLayout, env: { Path: `${W_NODE_DIR};C:\\Windows` }, results: { [`${root}\\bin\\gentle-shell.mjs setup`]: { code: 0 } } }));
2123
+ let locates = 0;
2124
+ const located = [{ root, version: "3.9.0", owner: "npm" }, { root, version: requirements.shell, owner: "npm" }];
2125
+ const adapters = { ...h.adapters, locateShell: async () => located[Math.min(locates++, 1)], upgradeShell: async () => true };
2126
+ const withHome = { ...fixed, tools: { ...fixed.tools, pnpmHome: privatePlan().tools.pnpmHome } };
2127
+ const result = await runStandardInstall({ plan: withHome, consent: true }, adapters);
2128
+ assert.equal(result.outcome, "terminal-action-required");
2129
+ assert.equal(h.pnpmCalls().at(-1), "setup");
2130
+ const plain = await runStandardInstall({ plan: fixed, consent: true }, { ...adapters, preparePnpmHome: undefined });
2131
+ assert.equal(plain.outcome, "ready", "an update keeps the PATH its installation already uses");
2132
+ });
2133
+
2134
+ test("exported blocked reasons and failed steps match what the scenarios observed", () => {
2135
+ assert.ok(Object.isFrozen(blockedReasons) && Object.isFrozen(failedSteps));
2136
+ assert.equal(new Set(blockedReasons).size, blockedReasons.length);
2137
+ assert.equal(new Set(failedSteps).size, failedSteps.length);
2138
+ for (const reason of observed.reasons) assert.ok(blockedReasons.includes(reason), reason);
2139
+ for (const step of observed.steps) assert.ok(failedSteps.includes(step), step);
2140
+ // Every reason is reached; only the single pnpm add failure has no scenario above.
2141
+ assert.deepEqual(blockedReasons.filter((reason) => !observed.reasons.has(reason)), []);
2142
+ assert.deepEqual(failedSteps.filter((step) => !observed.steps.has(step)), ["persist-pnpm"]);
2143
+ });
2144
+
2145
+ // R2: in private mode the installer's children also get TEMP and TMP inside the
2146
+ // claimed private home, so a postinstall's os.tmpdir() (the Gentle AI source
2147
+ // build) never uses a %LOCALAPPDATA%\Temp another account may write.
2148
+ test("childEnvironment puts TEMP and TMP under a private Windows PNPM_HOME only", () => {
2149
+ const globalBin = { pnpmHome: W_PNPM_HOME, path: W_BIN, onPath: false };
2150
+ const base = { Path: "C:\\Windows", Temp: "C:\\Users\\u\\AppData\\Local\\Temp", TMP: "C:\\Users\\u\\AppData\\Local\\Temp" };
2151
+ const isolated = childEnvironment(base, "win32", globalBin, { privateHome: true });
2152
+ const temps = (env: Record<string, string>) => Object.entries(env).filter(([key]) => /^(TEMP|TMP)$/i.test(key));
2153
+ assert.deepEqual(temps(isolated), [["TEMP", `${W_PNPM_HOME}\\tmp`], ["TMP", `${W_PNPM_HOME}\\tmp`]], "one key each, any spelling replaced");
2154
+ assert.deepEqual(temps(childEnvironment(base, "win32", globalBin)), [["Temp", base.Temp], ["TMP", base.TMP]], "unchanged outside private mode");
2155
+ const posix = { PATH: "/usr/bin", TMPDIR: "/tmp/x" };
2156
+ assert.deepEqual(childEnvironment(posix, "linux", { pnpmHome: PNPM_HOME, path: BIN, onPath: false }, { privateHome: true }),
2157
+ { ...posix, PNPM_HOME, PATH: `${BIN}:/usr/bin` }, "POSIX unchanged");
2158
+ assert.equal(base.Temp, "C:\\Users\\u\\AppData\\Local\\Temp", "the caller's env is not modified");
2159
+ });