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,505 @@
1
+ import assert from "node:assert/strict";
2
+ import { createHash } from "node:crypto";
3
+ import { createServer } from "node:http";
4
+ import test from "node:test";
5
+ import { distributionAsset, prefixLayout, setupEnvironment } from "../scripts/bundled-install.mjs";
6
+ import { DISTRIBUTION_RELEASES, bundledBlockedReasons, bundledFailedSteps, bundledGate, bundledPlan, fetchDistribution, migrateGlobalShell, migrationGate,
7
+ runBundledInstall, shadowingCommand, windowsPathRegistry } from "../scripts/bundled-wizard.mjs";
8
+ import { download } from "../scripts/installer-downloads.mjs";
9
+
10
+ const manifest = { shell: "4.1.0", pi: "1.0.2" };
11
+ const lockfile = "lockfileVersion: '9.0'\n";
12
+ const sha256 = (text: string) => createHash("sha256").update(text).digest("hex");
13
+ const published = { status: "published", manifest, lockfile };
14
+ const newComputer = { platform: "linux", arch: "x64", shell: { available: false } };
15
+
16
+ // Rule 1: the gate. Only a new installation of the release channel whose release publishes the assets.
17
+ test("bundledGate says yes only for a new release installation whose release publishes the distribution assets", () => {
18
+ const gate = (changes: Record<string, unknown> = {}) => bundledGate({ channel: "release", inventory: newComputer, distribution: published, version: "4.1.0", ...changes });
19
+ assert.equal(gate(), true);
20
+ for (const platform of ["darwin", "linux", "win32"]) for (const arch of ["x64", "arm64"]) assert.equal(gate({ inventory: { platform, arch, shell: { available: false } } }), true);
21
+ assert.equal(gate({ channel: "main" }), false);
22
+ // An existing Gentle Shell (any owner) or one that could not be checked keeps the current path.
23
+ assert.equal(gate({ inventory: { ...newComputer, shell: { available: true, version: "4.0.0", owner: "pnpm" } } }), false);
24
+ assert.equal(gate({ inventory: { ...newComputer, shell: { available: null } } }), false);
25
+ assert.equal(gate({ inventory: { platform: "win32", arch: "x64" } }), false);
26
+ for (const status of ["missing", "unavailable", "rejected"]) assert.equal(gate({ distribution: { status } }), false, status);
27
+ assert.equal(gate({ distribution: null }), false);
28
+ // The assets must be this package's own release.
29
+ assert.equal(gate({ version: "4.2.0" }), false);
30
+ assert.equal(gate({ inventory: { ...newComputer, platform: "freebsd" } }), false);
31
+ assert.equal(gate({ inventory: { ...newComputer, arch: "ia32" } }), false);
32
+ });
33
+
34
+ function fakeDownload(files: Record<string, string | number | Error>) {
35
+ const seen: { url: string; maxBytes: number; redirectHosts: string[] }[] = [];
36
+ return {
37
+ seen,
38
+ download: async (descriptor: { url: string; maxBytes: number; redirectHosts: string[] }) => {
39
+ seen.push(descriptor);
40
+ const value = files[descriptor.url.split("/").pop()!];
41
+ if (value instanceof Error) throw value;
42
+ if (typeof value === "number") throw Object.assign(new Error(`Download responded ${value}`), { status: value });
43
+ return Buffer.from(value);
44
+ },
45
+ };
46
+ }
47
+
48
+ test("fetchDistribution reads the asset pair of v<version> and tells published, missing, unavailable and rejected apart", async () => {
49
+ assert.equal(DISTRIBUTION_RELEASES, "https://github.com/Gentleman-Programming/gentle-shell/releases/download");
50
+ const asset = distributionAsset(manifest, lockfile);
51
+ const both = fakeDownload({ "gentle-shell-distribution.json": asset, "gentle-shell-distribution-lock.yaml": lockfile });
52
+ assert.deepEqual(await fetchDistribution({ version: "4.1.0", download: both.download }), published);
53
+ assert.deepEqual(both.seen.map((descriptor) => descriptor.url), [`${DISTRIBUTION_RELEASES}/v4.1.0/gentle-shell-distribution.json`,
54
+ `${DISTRIBUTION_RELEASES}/v4.1.0/gentle-shell-distribution-lock.yaml`]);
55
+ assert.deepEqual(both.seen.map((descriptor) => descriptor.maxBytes), [64 * 1024, 8 * 1024 * 1024]);
56
+ assert.deepEqual(both.seen[0].redirectHosts, ["github.com", "objects.githubusercontent.com", "release-assets.githubusercontent.com"]);
57
+ // A release without the assets (v4.0.0 today): the lockfile is not even requested.
58
+ const missing = fakeDownload({ "gentle-shell-distribution.json": 404 });
59
+ assert.deepEqual(await fetchDistribution({ version: "4.0.0", download: missing.download }), { status: "missing" });
60
+ assert.equal(missing.seen.length, 1);
61
+ assert.deepEqual(await fetchDistribution({ version: "4.1.0", download: fakeDownload({ "gentle-shell-distribution.json": asset,
62
+ "gentle-shell-distribution-lock.yaml": 404 }).download }), { status: "missing" });
63
+ assert.deepEqual(await fetchDistribution({ version: "4.1.0", download: fakeDownload({ "gentle-shell-distribution.json": new Error("offline") }).download }),
64
+ { status: "unavailable" });
65
+ assert.deepEqual(await fetchDistribution({ version: "4.1.0", download: fakeDownload({ "gentle-shell-distribution.json": 500 }).download }), { status: "unavailable" });
66
+ // A lockfile whose sha256 is not the recorded one, or another schema, is never installed.
67
+ assert.deepEqual(await fetchDistribution({ version: "4.1.0", download: fakeDownload({ "gentle-shell-distribution.json": asset,
68
+ "gentle-shell-distribution-lock.yaml": `${lockfile}# changed\n` }).download }), { status: "rejected" });
69
+ assert.deepEqual(await fetchDistribution({ version: "4.1.0", download: fakeDownload({ "gentle-shell-distribution.json": "{}",
70
+ "gentle-shell-distribution-lock.yaml": lockfile }).download }), { status: "rejected" });
71
+ const local = fakeDownload({ "gentle-shell-distribution.json": 404 });
72
+ await fetchDistribution({ version: "4.1.0", base: "http://127.0.0.1:9/assets", download: local.download });
73
+ assert.equal(local.seen[0].url, "http://127.0.0.1:9/assets/v4.1.0/gentle-shell-distribution.json");
74
+ });
75
+
76
+ test("download reports the HTTP status and follows a redirect only to an allowed https host", async () => {
77
+ const server = createServer((req, res) => {
78
+ if (req.url === "/missing") { res.writeHead(404); res.end(); return; }
79
+ res.writeHead(302, { location: req.url === "/to-http" ? "http://127.0.0.1/x" : "https://elsewhere.example/x" });
80
+ res.end();
81
+ });
82
+ await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
83
+ const base = `http://127.0.0.1:${(server.address() as { port: number }).port}`;
84
+ try {
85
+ await assert.rejects(download({ url: `${base}/missing`, maxBytes: 10 }), (error: { status?: number }) => error.status === 404);
86
+ const hosts = ["github.com", "127.0.0.1"];
87
+ await assert.rejects(download({ url: `${base}/to-http`, maxBytes: 10, redirectHosts: hosts }), /redirect rejected/);
88
+ await assert.rejects(download({ url: `${base}/to-elsewhere`, maxBytes: 10, redirectHosts: hosts }), /redirect rejected/);
89
+ // Without allowed hosts, any redirect is refused, as for the pinned runtimes.
90
+ await assert.rejects(download({ url: `${base}/to-elsewhere`, maxBytes: 10 }));
91
+ } finally {
92
+ server.close();
93
+ }
94
+ });
95
+
96
+ // Rule 2: the plan the user consents to.
97
+ const posixLayout = prefixLayout({ platform: "linux", env: {}, home: "/home/u" });
98
+ const profile = { kind: "profile", path: "/home/u/.bashrc", line: "export PATH=\"/home/u/.gentle-shell/bin:$PATH\" # gentle-shell bundled install", create: false };
99
+ test("bundledPlan lists the bundled steps, the versions and the single PATH change", () => {
100
+ const plan = bundledPlan({ platform: "linux", distribution: published, layout: posixLayout, path: profile });
101
+ assert.deepEqual(plan.actions.map((action: { id: string }) => action.id),
102
+ ["bundled-prefix", "bundled-runtime", "bundled-version", "bundled-activate", "bundled-path", "bundled-setup"]);
103
+ assert.deepEqual(plan.blockers, []);
104
+ assert.equal(plan.ready, false);
105
+ assert.deepEqual(plan.bundled, { shell: "4.1.0", pi: "1.0.2", id: "4.1.0-1.0.2", lockfile: sha256(lockfile), root: "/home/u/.gentle-shell",
106
+ bin: "/home/u/.gentle-shell/bin", node: "24.21.0", pnpm: "11.1.1", go: null, path: { kind: "profile", file: "/home/u/.bashrc", line: profile.line } });
107
+ // Nothing to change, a link in ~/.local/bin, or a line the user adds: only the link is a step.
108
+ const ids = (path: Record<string, unknown>) => bundledPlan({ platform: "linux", distribution: published, layout: posixLayout, path }).actions.map((a: { id: string }) => a.id);
109
+ assert.equal(ids({ kind: "none" }).includes("bundled-path"), false);
110
+ assert.equal(ids({ kind: "symlink", path: "/home/u/.local/bin/gentle-shell", target: "/home/u/.gentle-shell/bin/gentle-shell" }).includes("bundled-path"), true);
111
+ const manual = bundledPlan({ platform: "linux", distribution: published, layout: posixLayout, path: { ...profile, kind: "manual", reason: "symlink" } });
112
+ assert.equal(manual.actions.some((action: { id: string }) => action.id === "bundled-path"), false);
113
+ assert.deepEqual(manual.bundled.path, { kind: "manual", file: "/home/u/.bashrc", line: profile.line });
114
+ const windowsLayout = prefixLayout({ platform: "win32", env: { LOCALAPPDATA: "C:\\L" }, home: "C:\\U" });
115
+ const windows = bundledPlan({ platform: "win32", distribution: published, layout: windowsLayout,
116
+ path: { kind: "registry", key: "HKCU\\Environment", name: "Path", entry: windowsLayout.bin } });
117
+ assert.equal(windows.bundled.go, "1.25.14");
118
+ assert.deepEqual(windows.bundled.path, { kind: "registry", entry: "C:\\L\\gentle-shell\\bin" });
119
+ });
120
+
121
+ // Rule 3: the fixed step order, logged like the standard runner's steps.
122
+ function harness({ platform = "linux", path = profile as Record<string, unknown>, failAt = "", setup = { status: 0, stdout: "", stderr: "" },
123
+ changes = {} as Record<string, unknown>, migrate = undefined as Record<string, unknown> | undefined, launcher = { ok: true, reported: "", stderr: "" },
124
+ removed = true, applied = true } = {}) {
125
+ const layout = platform === "win32" ? prefixLayout({ platform, env: { LOCALAPPDATA: "C:\\L" }, home: "C:\\U" }) : posixLayout;
126
+ const plan = bundledPlan({ platform, distribution: published, layout, path, ...(migrate ? { migrate } : {}) });
127
+ const calls: string[] = [];
128
+ const log: { step: string; status: string; reason?: string }[] = [];
129
+ const runs: { command: string; args: string[]; options: { env: Record<string, string>; cwd: string } }[] = [];
130
+ const step = (name: string, value: unknown) => {
131
+ calls.push(name);
132
+ if (failAt === name) throw Object.assign(new Error(`${name} failed`), { stderr: "ERR_PNPM_FETCH_401 GET https://npm.acme.dev/x: Unauthorized - 401" });
133
+ return value;
134
+ };
135
+ const registry = { add: () => {}, remove: () => {} };
136
+ const adapters = {
137
+ platform, arch: "x64", env: { PATH: "/usr/bin", HOME: "/home/u" }, home: platform === "win32" ? "C:\\U" : "/home/u", distribution: published, registry,
138
+ log: (entry: { step: string; status: string }) => log.push(entry),
139
+ removeGlobal: async (manager: string) => step(`remove ${manager}`, removed),
140
+ operations: {
141
+ checkLauncher: async (_: unknown, options: { id: string; shell: string; pi: string; launcher: boolean }) =>
142
+ step(`check ${options.id} ${options.shell} ${options.pi} launcher=${options.launcher}`, launcher),
143
+ // The runner claims exactly the consented folder (claimPrefix refuses another one).
144
+ claimPrefix: (options: { root?: string }) => { assert.equal(options.root, plan.bundled.root); return step("claim", layout); },
145
+ userNpmrc: () => "registry=https://r.example/\nsave-exact=true\n",
146
+ writeNpmrcAuth: (_: unknown, text: string) => step(`npmrc ${text.trim()}`, undefined),
147
+ ensureRuntime: async (options: { go: boolean }) => step(`runtime go=${options.go}`, { node: layout.node, pnpm: layout.pnpm, go: options.go ? "C:\\L\\gentle-shell\\runtime\\go\\1.25.14\\go\\bin\\go.exe" : null, acquired: [] }),
148
+ installVersion: async (options: { manifest: typeof manifest; lockfile: string; go: string | null }) =>
149
+ step(`install ${options.manifest.shell}-${options.manifest.pi} ${options.lockfile === lockfile} go=${options.go !== null}`, { id: "4.1.0-1.0.2", path: "", installed: true }),
150
+ activateVersion: (_: unknown, id: string) => step(`activate ${id}`, { id, previous: null }),
151
+ ensureLauncher: () => step("launcher", { written: true }),
152
+ pathEntryPlan: () => path,
153
+ applyPathEntry: (plan: { kind: string }, options: { registry: unknown }) => step(`path ${plan.kind} ${options.registry === registry}`, { ...plan, applied }),
154
+ writePathReceipt: (written: unknown, receipt: { kind: string }) => step(`receipt ${receipt.kind} ${written === layout}`, true),
155
+ removePathEntry: (receipt: { kind: string }, options: { registry: unknown }) => step(`unpath ${receipt.kind} ${options.registry === registry}`, undefined),
156
+ run: async (command: string, args: string[], options: { env: Record<string, string>; cwd: string }) => {
157
+ runs.push({ command, args, options });
158
+ return step("setup", setup);
159
+ },
160
+ },
161
+ ...changes,
162
+ };
163
+ return { layout, plan, calls, log, runs, adapters, run: (request: Record<string, unknown> = { plan, consent: true }) => runBundledInstall(request, adapters) };
164
+ }
165
+
166
+ test("runBundledInstall claims, copies the npm settings, installs runtimes and the version, activates, writes the launcher and the PATH entry, then sets up", async () => {
167
+ const h = harness();
168
+ const result = await h.run();
169
+ const steps = ["claim-prefix", "copy-npm-settings", "install-runtime", "install-version", "activate-version", "write-launcher", "bundled-path", "shell-setup"];
170
+ assert.deepEqual(result, { outcome: "terminal-action-required", action: "open-new-terminal", completed: steps });
171
+ assert.deepEqual(h.log, steps.map((step) => ({ step, status: "done" })));
172
+ // writeNpmrcAuth gets the user's npmrc text and keeps only the S12 keys itself.
173
+ assert.deepEqual(h.calls, ["claim", "npmrc registry=https://r.example/\nsave-exact=true", "runtime go=false", "install 4.1.0-1.0.2 true go=false", "activate 4.1.0-1.0.2",
174
+ "launcher", "path profile true", "receipt profile true", "setup"]);
175
+ // Setup runs through the launcher, in the bundled setup environment.
176
+ assert.equal(h.runs[0].command, h.layout.launcher);
177
+ assert.deepEqual(h.runs[0].args, ["setup"]);
178
+ assert.deepEqual(h.runs[0].options.env, setupEnvironment(h.layout, { platform: "linux", env: h.adapters.env, id: "4.1.0-1.0.2" }));
179
+ // No PATH change needed, or a link into a folder already on PATH: ready at once.
180
+ for (const path of [{ kind: "none" }, { kind: "symlink", path: "/home/u/.local/bin/gentle-shell", target: "/home/u/.gentle-shell/bin/gentle-shell" }]) {
181
+ const ready = await harness({ path }).run();
182
+ assert.equal(ready.outcome, "ready", path.kind);
183
+ assert.equal(ready.completed.includes("bundled-path"), path.kind === "symlink");
184
+ }
185
+ // A profile the installer may not edit: the user adds the exact line.
186
+ const manual = await harness({ path: { ...profile, kind: "manual", reason: "symlink" } }).run();
187
+ assert.deepEqual(manual, { outcome: "terminal-action-required", action: "add-path-line", pathLine: { file: profile.path, line: profile.line },
188
+ completed: steps.filter((step) => step !== "bundled-path") });
189
+ });
190
+
191
+ test("runBundledInstall on Windows installs the pinned Go, writes the user Path through the registry and runs setup with our Node", async () => {
192
+ const h = harness({ platform: "win32", path: { kind: "registry", key: "HKCU\\Environment", name: "Path", entry: "C:\\L\\gentle-shell\\bin" } });
193
+ const result = await h.run();
194
+ assert.equal(result.outcome, "terminal-action-required");
195
+ assert.deepEqual(h.calls.slice(2, 4), ["runtime go=true", "install 4.1.0-1.0.2 true go=true"]);
196
+ assert.ok(h.calls.includes("path registry true"));
197
+ // A .cmd launcher would need cmd.exe: setup runs the same Node and entry it runs.
198
+ assert.equal(h.runs[0].command, h.layout.node);
199
+ assert.deepEqual(h.runs[0].args, ["C:\\L\\gentle-shell\\versions\\4.1.0-1.0.2\\node_modules\\gentle-pi\\bin\\gentle-shell.mjs", "setup"]);
200
+ });
201
+
202
+ // T6 (S7): the applied PATH entry is kept in the prefix, so self-uninstall reverts exactly it.
203
+ test("runBundledInstall keeps the receipt of the PATH entry it applied, writes none when nothing was applied, and reverts the entry when the receipt cannot be kept", async () => {
204
+ const windows = harness({ platform: "win32", path: { kind: "registry", key: "HKCU\\Environment", name: "Path", entry: "C:\\L\\gentle-shell\\bin" } });
205
+ await windows.run();
206
+ assert.deepEqual(windows.calls.filter((call) => /^(path|receipt|unpath) /.test(call)), ["path registry true", "receipt registry true"]);
207
+ const present = harness({ applied: false });
208
+ assert.equal((await present.run()).outcome, "terminal-action-required");
209
+ assert.deepEqual(present.calls.filter((call) => /^(path|receipt|unpath) /.test(call)), ["path profile true"]);
210
+ const unwritable = harness();
211
+ unwritable.adapters.operations.writePathReceipt = () => { throw new Error("EACCES"); };
212
+ const failed = await unwritable.run();
213
+ assert.equal(failed.failedStep, "bundled-path");
214
+ assert.deepEqual(unwritable.calls.filter((call) => /^(path|receipt|unpath) /.test(call)), ["path profile true", "unpath profile true"]);
215
+ assert.equal(unwritable.calls.includes("setup"), false);
216
+ });
217
+
218
+ test("runBundledInstall stops at the first failed step, keeps what finished and reports pnpm's or setup's last error line", async () => {
219
+ const h = harness({ failAt: "install 4.1.0-1.0.2 true go=false" });
220
+ assert.deepEqual(await h.run(), { outcome: "failed", failedStep: "install-version", completed: ["claim-prefix", "copy-npm-settings", "install-runtime"],
221
+ detail: "ERR_PNPM_FETCH_401 GET https://npm.acme.dev/x: Unauthorized - 401" });
222
+ assert.equal(h.log.at(-1)?.status, "failed");
223
+ assert.equal(h.calls.includes("launcher"), false);
224
+ const setup = await harness({ setup: { status: 1, stdout: "", stderr: "working\nError: GitHub API rate limit exceeded\n" } }).run();
225
+ assert.equal(setup.failedStep, "shell-setup");
226
+ assert.equal(setup.detail, "Error: GitHub API rate limit exceeded");
227
+ const claim = await harness({ failAt: "claim" }).run();
228
+ assert.deepEqual(claim, { outcome: "failed", failedStep: "claim-prefix", completed: [] });
229
+ // The PATH change differs from the consented one: nothing is written.
230
+ const moved = harness();
231
+ moved.adapters.operations.pathEntryPlan = () => ({ ...profile, path: "/home/u/.profile" });
232
+ const changed = await moved.run();
233
+ assert.equal(changed.failedStep, "bundled-path");
234
+ assert.equal(moved.calls.some((call) => call.startsWith("path ")), false);
235
+ for (const step of [...new Set(["claim-prefix", "copy-npm-settings", "install-runtime", "install-version", "activate-version", "write-launcher", "bundled-path", "shell-setup"])]) {
236
+ assert.ok(bundledFailedSteps.includes(step), step);
237
+ }
238
+ });
239
+
240
+ test("runBundledInstall blocks before any change without consent, on a modified plan, or when the assets are not the consented ones", async () => {
241
+ const h = harness();
242
+ const cases: [Record<string, unknown>, string, Record<string, unknown>?][] = [
243
+ [{ plan: h.plan, consent: false }, "consent-required"],
244
+ [{ plan: { ...h.plan, bundled: undefined }, consent: true }, "invalid-request"],
245
+ [{ plan: { ...h.plan, actions: h.plan.actions.slice(1) }, consent: true }, "invalid-request"],
246
+ [{ plan: { ...h.plan, bundled: { ...h.plan.bundled, id: "4.1.0-9.9.9" } }, consent: true }, "invalid-request"],
247
+ [{ plan: h.plan, consent: true }, "invalid-request", { distribution: { ...published, lockfile: "other" } }],
248
+ [{ plan: h.plan, consent: true }, "invalid-request", { distribution: null }],
249
+ ];
250
+ for (const [request, reason, changes] of cases) {
251
+ const blocked = harness({ changes });
252
+ assert.deepEqual(await blocked.run(request), { outcome: "blocked", reason, completed: [] });
253
+ assert.deepEqual(blocked.calls, []);
254
+ assert.deepEqual(blocked.log, [{ step: "gate", status: "blocked", reason }]);
255
+ assert.ok(bundledBlockedReasons.includes(reason));
256
+ }
257
+ });
258
+
259
+ test("windowsPathRegistry edits only the HKCU user Path through Windows PowerShell, with the entry as data", () => {
260
+ const seen: { command: string; args: string[]; env: Record<string, string> }[] = [];
261
+ let answer = "added";
262
+ const registry = windowsPathRegistry({ SystemRoot: "C:\\Windows", Path: "C:\\Windows" }, (command: string, args: string[], env: Record<string, string>) => {
263
+ seen.push({ command, args, env });
264
+ return answer;
265
+ });
266
+ assert.equal(registry.add("C:\\L\\gentle-shell\\bin"), "added");
267
+ answer = "removed";
268
+ registry.remove("C:\\L\\gentle-shell\\bin");
269
+ assert.equal(seen[0].command, "C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe");
270
+ assert.deepEqual(seen[0].args.slice(0, 4), ["-NoLogo", "-NoProfile", "-NonInteractive", "-Command"]);
271
+ // The script is fixed: the entry and the mode travel as environment data only.
272
+ assert.equal(seen[0].args[4], seen[1].args[4]);
273
+ assert.equal(seen[0].args[4].includes("gentle-shell\\bin"), false);
274
+ assert.match(seen[0].args[4], /DoNotExpandEnvironmentNames/);
275
+ assert.deepEqual([seen[0].env.GENTLE_BUNDLED_PATH_ENTRY, seen[0].env.GENTLE_BUNDLED_PATH_MODE], ["C:\\L\\gentle-shell\\bin", "add"]);
276
+ assert.equal(seen[1].env.GENTLE_BUNDLED_PATH_MODE, "remove");
277
+ // What applyPathEntry reads: an entry the user Path already held was not applied.
278
+ answer = "present";
279
+ assert.equal(registry.add("C:\\L\\gentle-shell\\bin"), "present");
280
+ answer = "something else";
281
+ assert.throws(() => registry.add("C:\\L\\gentle-shell\\bin"), /user Path/);
282
+ assert.throws(() => registry.add("relative\\bin"), /user Path/);
283
+ });
284
+
285
+ // T3 (S4): an existing Gentle Shell owned by a global pnpm or npm moves to the bundled prefix.
286
+ const owned = (owner: unknown, version = "4.0.0") => ({ platform: "linux", arch: "x64", shell: { available: true, version, usable: true, global: true, owner } });
287
+ test("migrationGate names the owner only for a pnpm or npm Gentle Shell on release whose release publishes the assets", () => {
288
+ const gate = (changes: Record<string, unknown> = {}) => migrationGate({ channel: "release", inventory: owned("pnpm"), distribution: published, version: "4.1.0", ...changes });
289
+ assert.equal(gate(), "pnpm");
290
+ assert.equal(gate({ inventory: owned("npm") }), "npm");
291
+ assert.equal(gate({ inventory: owned("npm", "4.1.0") }), "npm", "the same version moves too");
292
+ // An npm link or an unknown owner, main, no assets or another release: never migrated.
293
+ for (const owner of [null, undefined, "linked"]) assert.equal(gate({ inventory: owned(owner) }), null, String(owner));
294
+ assert.equal(gate({ channel: "main" }), null);
295
+ for (const status of ["missing", "unavailable", "rejected"]) assert.equal(gate({ distribution: { status } }), null, status);
296
+ assert.equal(gate({ version: "4.2.0" }), null);
297
+ assert.equal(gate({ inventory: { ...owned("pnpm"), platform: "freebsd" } }), null);
298
+ assert.equal(gate({ inventory: newComputer }), null);
299
+ // A newer Gentle Shell than this release is never replaced by an older one.
300
+ assert.equal(gate({ inventory: owned("pnpm", "4.2.0") }), null);
301
+ // The new-installation gate is unchanged for an existing Gentle Shell.
302
+ assert.equal(bundledGate({ channel: "release", inventory: owned("pnpm"), distribution: published, version: "4.1.0" }), false);
303
+ });
304
+
305
+ test("bundledPlan for a migration adds the final remove-global-shell action, or leaves setup and the removal question to the upgrade", () => {
306
+ const plan = bundledPlan({ platform: "linux", distribution: published, layout: posixLayout, path: profile, migrate: { manager: "pnpm", removal: "plan" } });
307
+ assert.deepEqual(plan.actions.map((action: { id: string }) => action.id),
308
+ ["bundled-prefix", "bundled-runtime", "bundled-version", "bundled-activate", "bundled-path", "bundled-setup", "remove-global-shell"]);
309
+ assert.deepEqual(plan.bundled.migrate, { manager: "pnpm", command: "pnpm remove -g gentle-pi", removal: "plan" });
310
+ const ask = bundledPlan({ platform: "linux", distribution: published, layout: posixLayout, path: profile, migrate: { manager: "npm", removal: "ask" } });
311
+ assert.deepEqual(ask.actions.map((action: { id: string }) => action.id), ["bundled-prefix", "bundled-runtime", "bundled-version", "bundled-activate", "bundled-path"]);
312
+ assert.deepEqual(ask.bundled.migrate, { manager: "npm", command: "npm uninstall -g gentle-pi", removal: "ask" });
313
+ });
314
+
315
+ test("runBundledInstall for a migration checks the launcher's versions, then removes the old global gentle-pi last", async () => {
316
+ const h = harness({ migrate: { manager: "pnpm", removal: "plan" } });
317
+ const steps = ["claim-prefix", "copy-npm-settings", "install-runtime", "install-version", "activate-version", "write-launcher", "verify-launcher", "bundled-path",
318
+ "shell-setup", "remove-global-shell"];
319
+ assert.deepEqual(await h.run(), { outcome: "terminal-action-required", action: "open-new-terminal", completed: steps,
320
+ removeGlobal: { status: "removed", command: "pnpm remove -g gentle-pi" } });
321
+ assert.deepEqual(h.calls.slice(-5), ["check 4.1.0-1.0.2 4.1.0 1.0.2 launcher=true", "path profile true", "receipt profile true", "setup", "remove pnpm"]);
322
+ // A launcher that does not report both versions: no PATH change, and the old global gentle-pi is never removed.
323
+ // The detail names what the launcher reported and what was expected (A1).
324
+ const broken = harness({ migrate: { manager: "pnpm", removal: "plan" }, launcher: { ok: false, reported: "gentle-shell 4.0.0\npi 1.0.0", stderr: "Error: no Pi\n" } });
325
+ assert.deepEqual(await broken.run(), { outcome: "failed", failedStep: "verify-launcher", completed: steps.slice(0, 6),
326
+ detail: 'gentle-shell --version reported "gentle-shell 4.0.0 / pi 1.0.0" instead of "gentle-shell 4.1.0 / pi 1.0.2"' });
327
+ const silent = harness({ migrate: { manager: "pnpm", removal: "plan" }, launcher: { ok: false, reported: "", stderr: "Error: Cannot find module /home/u/x\n" } });
328
+ assert.equal((await silent.run()).detail, 'gentle-shell --version reported nothing instead of "gentle-shell 4.1.0 / pi 1.0.2": Error: Cannot find module ~/x');
329
+ assert.equal(broken.calls.some((call) => call.startsWith("remove") || call.startsWith("path ")), false);
330
+ assert.ok(bundledFailedSteps.includes("verify-launcher"));
331
+ // B3: any failed migration step carries its error message as the detail; a new install's does not.
332
+ assert.deepEqual(await harness({ migrate: { manager: "pnpm", removal: "plan" }, failAt: "claim" }).run(),
333
+ { outcome: "failed", failedStep: "claim-prefix", completed: [], detail: "claim failed" });
334
+ assert.deepEqual(await harness({ failAt: "claim" }).run(), { outcome: "failed", failedStep: "claim-prefix", completed: [] });
335
+ // A failed removal keeps the bundled install and names the manual command.
336
+ const kept = await harness({ migrate: { manager: "npm", removal: "plan" }, removed: false }).run();
337
+ assert.equal(kept.outcome, "terminal-action-required");
338
+ assert.deepEqual(kept.removeGlobal, { status: "failed", command: "npm uninstall -g gentle-pi" });
339
+ // A plan whose removal command was changed is not the consented one.
340
+ const forged = harness({ migrate: { manager: "pnpm", removal: "plan" } });
341
+ const plan = { ...forged.plan, bundled: { ...forged.plan.bundled, migrate: { ...forged.plan.bundled.migrate, command: "rm -rf ~" } } };
342
+ assert.deepEqual(await forged.run({ plan, consent: true }), { outcome: "blocked", reason: "invalid-request", completed: [] });
343
+ // The upgrade's plan has no setup and no removal step; its launcher check still runs.
344
+ const upgrade = harness({ migrate: { manager: "pnpm", removal: "ask" } });
345
+ assert.deepEqual((await upgrade.run()).completed, steps.slice(0, 8));
346
+ assert.equal(upgrade.runs.length, 0);
347
+ });
348
+
349
+ // A POSIX or Windows PATH, looked up through an injected filesystem only.
350
+ function lookup(files: Record<string, string>) {
351
+ return { exists: (path: string) => Object.hasOwn(files, path), realpath: (path: string) => files[path] ?? path };
352
+ }
353
+ test("shadowingCommand names the old gentle-shell a new terminal would still find before the bundled launcher", () => {
354
+ const launcher = posixLayout.launcher;
355
+ const oldBin = "/home/u/Library/pnpm/gentle-shell";
356
+ const fs = lookup({ [oldBin]: oldBin, [launcher]: launcher, "/home/u/.local/bin/gentle-shell": launcher });
357
+ const env = { PATH: "/home/u/Library/pnpm:/home/u/.local/bin:/usr/bin" };
358
+ const check = (pathKind: string, changes: Record<string, unknown> = {}) =>
359
+ shadowingCommand({ platform: "linux", env, layout: posixLayout, pathKind, fs, ...changes });
360
+ // A link in ~/.local/bin after PNPM_HOME on PATH: the old one still runs.
361
+ assert.equal(check("symlink"), oldBin);
362
+ // A profile line puts the bundled bin first.
363
+ assert.equal(check("profile"), null);
364
+ assert.equal(check("manual"), null);
365
+ assert.equal(check("symlink", { env: { PATH: "/home/u/.local/bin:/home/u/Library/pnpm" } }), null);
366
+ // zsh (.zshrc, after .zprofile) and bash (the login file or .bashrc, our line appended last) put the bundled bin first.
367
+ for (const profile of ["/home/u/.zshrc", "/home/u/.bash_profile", "/home/u/.bashrc", "/home/u/.profile"]) {
368
+ assert.equal(check("profile", { profile }), null, profile);
369
+ }
370
+ // B1: fish reads conf.d/gentle-shell.fish before config.fish, so anything config.fish prepends
371
+ // (pnpm setup's PNPM_HOME block) comes first: the bundled bin counts as last.
372
+ const fish = "/home/u/.config/fish/conf.d/gentle-shell.fish";
373
+ assert.equal(check("profile", { profile: fish }), oldBin);
374
+ assert.equal(check("manual", { profile: fish }), oldBin);
375
+ assert.equal(check("profile", { profile: fish, fs: lookup({ [launcher]: launcher }) }), null);
376
+ assert.equal(check("none", { env: { PATH: `/home/u/Library/pnpm:${posixLayout.bin}` } }), oldBin);
377
+ assert.equal(check("none", { fs: lookup({ [launcher]: launcher, "/home/u/.local/bin/gentle-shell": launcher }) }), null);
378
+ // Windows: the user Path entry comes after everything already on PATH.
379
+ const windows = prefixLayout({ platform: "win32", env: { LOCALAPPDATA: "C:\\L" }, home: "C:\\U" });
380
+ const oldCmd = "C:\\U\\AppData\\Local\\pnpm\\gentle-shell.cmd";
381
+ const winFs = lookup({ [oldCmd]: oldCmd, [windows.launcher]: windows.launcher });
382
+ assert.equal(shadowingCommand({ platform: "win32", env: { Path: "C:\\U\\AppData\\Local\\pnpm;C:\\Windows" }, layout: windows, pathKind: "registry", fs: winFs }), oldCmd);
383
+ assert.equal(shadowingCommand({ platform: "win32", env: { Path: "C:\\Windows" }, layout: windows, pathKind: "registry", fs: winFs }), null);
384
+ });
385
+
386
+ /** migrateGlobalShell with fake operations: the bundled steps and the manager run are recorded. */
387
+ function migration({ owner = "pnpm" as string | null, distribution = published as Record<string, unknown>, interactive = true, answer = "y", removal = 0,
388
+ failAt = "", pathKind = "profile", shadow = false, currentVersion = "4.0.0", fish = false } = {}) {
389
+ const calls: string[] = [];
390
+ let ownerCalls = 0;
391
+ const lines: string[] = [];
392
+ const asked: string[] = [];
393
+ const fishLine = "set -gx PATH '/home/u/.gentle-shell/bin' $PATH # gentle-shell bundled install";
394
+ const path = pathKind === "profile" ? (fish ? { kind: "profile", path: "/home/u/.config/fish/conf.d/gentle-shell.fish", line: fishLine, create: true } : profile)
395
+ : { kind: pathKind };
396
+ const home = "/home/u";
397
+ const oldBin = "/home/u/Library/pnpm/gentle-shell";
398
+ const files: Record<string, string> = { [posixLayout.launcher]: posixLayout.launcher, ...(shadow ? { [oldBin]: oldBin } : {}) };
399
+ const manager = { name: owner, command: `/usr/bin/${owner}`, prefix: [] };
400
+ const operations = {
401
+ prefixRoot: () => undefined,
402
+ claimPrefix: () => {
403
+ calls.push("claim");
404
+ if (failAt === "claim") throw new Error("Unsafe bundled prefix: /home/u/.gentle-shell");
405
+ return posixLayout;
406
+ },
407
+ userNpmrc: () => "",
408
+ writeNpmrcAuth: () => calls.push("npmrc"),
409
+ ensureRuntime: async () => { calls.push("runtime"); return { node: posixLayout.node, pnpm: posixLayout.pnpm, go: null }; },
410
+ installVersion: async () => {
411
+ calls.push("install");
412
+ if (failAt === "install") throw new Error("pnpm install failed");
413
+ return { id: "4.1.0-1.0.2", path: "", installed: true };
414
+ },
415
+ activateVersion: () => calls.push("activate"),
416
+ ensureLauncher: () => calls.push("launcher"),
417
+ pathEntryPlan: () => { if (failAt === "plan") throw new Error("Unsafe bundled bin path"); return path; },
418
+ applyPathEntry: () => calls.push("path"),
419
+ checkLauncher: async () => { calls.push("check"); return { ok: failAt !== "check", reported: "", stderr: "" }; },
420
+ run: async () => { calls.push("setup"); return { status: 0 }; },
421
+ };
422
+ const run = async (command: string, argv: string[]) => { calls.push(`${command} ${argv.join(" ")}`); return { code: removal, stdout: "" }; };
423
+ const migrate = () => migrateGlobalShell({ latest: "4.1.0", currentVersion, platform: "linux", arch: "x64",
424
+ env: { PATH: "/home/u/Library/pnpm:/usr/bin", HOME: home }, home, interactive,
425
+ ask: async (question: string) => { asked.push(question); return answer; }, out: (line: string) => lines.push(line),
426
+ adapters: { owner: async () => { ownerCalls += 1; if (!owner) throw new Error("neither pnpm nor npm owns /src/gentle-shell"); return manager; },
427
+ distribution: async (version: string) => { calls.push(`assets ${version}`); return distribution; }, run, operations, fs: lookup(files) } });
428
+ return { calls, lines, asked, migrate, owners: () => ownerCalls };
429
+ }
430
+
431
+ test("migrateGlobalShell explains, installs the bundled prefix, checks the launcher, then asks once and removes the old gentle-pi with its own manager", async () => {
432
+ const m = migration();
433
+ assert.deepEqual(await m.migrate(), { code: 0, installed: true });
434
+ assert.deepEqual(m.calls, ["assets 4.1.0", "claim", "npmrc", "runtime", "install", "activate", "launcher", "check", "path", "/usr/bin/pnpm remove -g gentle-pi"]);
435
+ const text = m.lines.join("\n");
436
+ assert.match(text, /\/home\/u\/\.gentle-shell/);
437
+ assert.match(text, /Pi 1\.0\.2/);
438
+ assert.match(text, /Node\.js, npm, pnpm and Pi are not used or changed/);
439
+ assert.match(text, /~\/\.gentle-shell\/agent/);
440
+ assert.deepEqual(m.asked, ["Remove the old global gentle-pi with pnpm (pnpm remove -g gentle-pi)? [y/N] "]);
441
+ assert.match(text, /Removed the old global gentle-pi/);
442
+ assert.match(text, /Open a new terminal/);
443
+ });
444
+
445
+ test("migrateGlobalShell keeps the old gentle-pi on No, on EOF and without a terminal, and says which gentle-shell a new terminal runs", async () => {
446
+ for (const answer of ["", "n", "no"]) {
447
+ const m = migration({ answer, pathKind: "symlink", shadow: true });
448
+ assert.deepEqual(await m.migrate(), { code: 0, installed: true }, answer);
449
+ assert.equal(m.calls.some((call) => call.includes(" remove ")), false);
450
+ const text = m.lines.join("\n");
451
+ assert.match(text, /pnpm remove -g gentle-pi/);
452
+ // The old global bin comes before ~/.local/bin, so the old one still runs.
453
+ assert.match(text, /\/home\/u\/Library\/pnpm\/gentle-shell/);
454
+ }
455
+ const batch = migration({ interactive: false });
456
+ assert.deepEqual(await batch.migrate(), { code: 0, installed: true });
457
+ assert.deepEqual(batch.asked, []);
458
+ assert.equal(batch.calls.some((call) => call.includes(" remove ")), false);
459
+ assert.match(batch.lines.join("\n"), /To remove it, run: pnpm remove -g gentle-pi/);
460
+ });
461
+
462
+ test("migrateGlobalShell never removes after a failed step: it names the step and its detail, then hands back to the global update", async () => {
463
+ // B3: null, so `gentle-shell upgrade` continues with the global package's own update this run.
464
+ const expected: Record<string, RegExp> = { plan: /could not be planned: Unsafe bundled bin path/, claim: /stopped at claim-prefix: Unsafe bundled prefix: ~\/\.gentle-shell/,
465
+ install: /stopped at install-version/, check: /stopped at verify-launcher/ };
466
+ for (const failAt of ["plan", "claim", "install", "check"]) {
467
+ const m = migration({ failAt });
468
+ assert.equal(await m.migrate(), null, failAt);
469
+ assert.deepEqual(m.asked, []);
470
+ assert.equal(m.calls.some((call) => call.includes(" remove ")), false);
471
+ const text = m.lines.join("\n");
472
+ assert.match(text, expected[failAt], failAt);
473
+ assert.match(text, /global gentle-pi was left untouched/);
474
+ }
475
+ const npm = migration({ owner: "npm", removal: 1 });
476
+ assert.deepEqual(await npm.migrate(), { code: 1, installed: true });
477
+ assert.ok(npm.calls.includes("/usr/bin/npm uninstall -g gentle-pi"));
478
+ assert.match(npm.lines.join("\n"), /could not be removed.*npm uninstall -g gentle-pi/s);
479
+ });
480
+
481
+ test("migrateGlobalShell leaves an npm link, a release without the assets and main alone", async () => {
482
+ const linked = migration({ owner: null });
483
+ assert.equal(await linked.migrate(), null);
484
+ assert.deepEqual(linked.calls, ["assets 4.1.0"]);
485
+ assert.match(linked.lines.join("\n"), /not a global pnpm or npm package/);
486
+ // B2: without published assets, or with a newer installed Gentle Shell, the owner (npm root -g) is never probed.
487
+ for (const status of ["missing", "unavailable", "rejected"]) {
488
+ const m = migration({ distribution: { status } });
489
+ assert.equal(await m.migrate(), null, status);
490
+ assert.deepEqual(m.calls, ["assets 4.1.0"]);
491
+ assert.deepEqual(m.lines, []);
492
+ assert.equal(m.owners(), 0, status);
493
+ }
494
+ const newer = migration({ currentVersion: "4.2.0" });
495
+ assert.equal(await newer.migrate(), null);
496
+ assert.equal(newer.owners(), 0);
497
+ assert.equal(linked.owners(), 1);
498
+ });
499
+
500
+ test("migrateGlobalShell in fish reports the kept old gentle-shell, which config.fish puts before the conf.d line", async () => {
501
+ const m = migration({ fish: true, shadow: true, answer: "n" });
502
+ assert.deepEqual(await m.migrate(), { code: 0, installed: true });
503
+ assert.match(m.lines.join("\n"), /New terminals still run \/home\/u\/Library\/pnpm\/gentle-shell/);
504
+ assert.match(m.lines.join("\n"), /pnpm remove -g gentle-pi/);
505
+ });
@@ -0,0 +1,68 @@
1
+ import { test } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { chmodSync, existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
4
+ import { tmpdir } from "node:os";
5
+ import { join } from "node:path";
6
+ import { CARD_CONTENT, CARD_CONTENT_SCHEMA, cardContent, parseCardContentFile, resolveCardContent, setCardContent, writeCardContent } from "../lib/card-content-policy.ts";
7
+
8
+ const home = () => mkdtempSync(join(tmpdir(), "gentle-card-content-"));
9
+
10
+ test("a missing preference means the default content level", () => {
11
+ const dir = home();
12
+ assert.deepEqual(resolveCardContent({ gentlePiConfigHome: dir }), { content: "default", source: "default", malformed: false, globalFile: join(dir, "card-content.json") });
13
+ });
14
+
15
+ test("the preference round-trips through the atomic writer", () => {
16
+ const dir = home();
17
+ const path = writeCardContent(CARD_CONTENT.MINIMAL, { gentlePiConfigHome: dir });
18
+ assert.equal(path, join(dir, "card-content.json"));
19
+ assert.deepEqual(JSON.parse(readFileSync(path, "utf8")), { schema: CARD_CONTENT_SCHEMA, content: "minimal" });
20
+ assert.deepEqual(resolveCardContent({ gentlePiConfigHome: dir }), { content: "minimal", source: "global_file", malformed: false, globalFile: path });
21
+ writeCardContent(CARD_CONTENT.DEFAULT, { gentlePiConfigHome: dir });
22
+ assert.equal(resolveCardContent({ gentlePiConfigHome: dir }).content, "default");
23
+ assert.deepEqual(readdirSync(dir), ["card-content.json"], "no temporary file survives the rename");
24
+ });
25
+
26
+ test("the writer rejects values outside the content domain", () => {
27
+ const dir = home();
28
+ assert.throws(() => writeCardContent("clean" as never, { gentlePiConfigHome: dir }), TypeError);
29
+ assert.equal(existsSync(join(dir, "card-content.json")), false);
30
+ });
31
+
32
+ test("invalid or unreadable preference files read as default and are never overwritten", () => {
33
+ for (const raw of ["", "{", "[]", "null", `{"schema":"${CARD_CONTENT_SCHEMA}","content":"clean"}`, `{"schema":"other/v1","content":"minimal"}`, `{"schema":"${CARD_CONTENT_SCHEMA}","content":"default","extra":1}`]) {
34
+ assert.equal(parseCardContentFile(raw), undefined, raw);
35
+ const dir = home();
36
+ const path = join(dir, "card-content.json");
37
+ writeFileSync(path, raw);
38
+ assert.deepEqual(resolveCardContent({ gentlePiConfigHome: dir }), { content: "default", source: "global_file", malformed: true, globalFile: path }, raw);
39
+ assert.throws(() => writeCardContent(CARD_CONTENT.MINIMAL, { gentlePiConfigHome: dir }), /Cannot update malformed or unreadable card content preference/, raw);
40
+ assert.equal(readFileSync(path, "utf8"), raw, "the malformed file is preserved");
41
+ }
42
+ const dir = home();
43
+ mkdirSync(join(dir, "card-content.json"));
44
+ assert.equal(resolveCardContent({ gentlePiConfigHome: dir }).malformed, true, "a directory in its place is unreadable, not missing");
45
+ if (process.getuid?.() !== 0) {
46
+ const locked = home();
47
+ writeCardContent(CARD_CONTENT.MINIMAL, { gentlePiConfigHome: locked });
48
+ chmodSync(join(locked, "card-content.json"), 0o000);
49
+ try {
50
+ assert.deepEqual({ ...resolveCardContent({ gentlePiConfigHome: locked }), globalFile: "" }, { content: "default", source: "global_file", malformed: true, globalFile: "" });
51
+ } finally {
52
+ chmodSync(join(locked, "card-content.json"), 0o600);
53
+ }
54
+ }
55
+ });
56
+
57
+ test("the live slot shares one content level per process and an unset slot reads default", (t) => {
58
+ const slot = Symbol.for("gentle-pi.card-content");
59
+ const state = globalThis as typeof globalThis & { [slot]?: unknown };
60
+ const found = state[slot];
61
+ t.after(() => { state[slot] = found; });
62
+ delete state[slot];
63
+ assert.equal(cardContent(), CARD_CONTENT.DEFAULT);
64
+ setCardContent(CARD_CONTENT.DEFAULT);
65
+ assert.equal(cardContent(), CARD_CONTENT.DEFAULT);
66
+ setCardContent(CARD_CONTENT.MINIMAL);
67
+ assert.equal(cardContent(), CARD_CONTENT.MINIMAL);
68
+ });
@@ -1,6 +1,7 @@
1
1
  import { test } from "node:test";
2
2
  import assert from "node:assert/strict";
3
- import { chmodSync, existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
3
+ import fs, { chmodSync, existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
4
+ import { syncBuiltinESMExports } from "node:module";
4
5
  import { tmpdir } from "node:os";
5
6
  import { join } from "node:path";
6
7
  import { CARD_STYLE_SCHEMA, parseCardStyleFile, resolveCardStyle, writeCardStyle } from "../lib/card-style-policy.ts";
@@ -29,6 +30,35 @@ test("the writer rejects values outside the style domain", () => {
29
30
  assert.equal(existsSync(join(dir, "card-style.json")), false);
30
31
  });
31
32
 
33
+ test("a denied preference read preserves the stored bytes and refuses every write", (t) => {
34
+ const dir = home();
35
+ t.after(() => fs.rmSync(dir, { recursive: true, force: true }));
36
+ const path = writeCardStyle("neon", { gentlePiConfigHome: dir });
37
+ const before = readFileSync(path);
38
+ const originalRead = fs.readFileSync;
39
+ const originalWrite = fs.writeFileSync;
40
+ let writes = 0;
41
+ t.mock.method(fs, "readFileSync", (...args: Parameters<typeof fs.readFileSync>) => {
42
+ if (args[0] === path) throw Object.assign(new Error("fixture access denied"), { code: "EACCES" });
43
+ return originalRead(...args);
44
+ });
45
+ t.mock.method(fs, "writeFileSync", (...args: Parameters<typeof fs.writeFileSync>) => {
46
+ writes++;
47
+ return originalWrite(...args);
48
+ });
49
+ syncBuiltinESMExports();
50
+ try {
51
+ assert.deepEqual(resolveCardStyle({ gentlePiConfigHome: dir }), { style: "float", source: "global_file", malformed: true, globalFile: path });
52
+ assert.throws(() => writeCardStyle("float", { gentlePiConfigHome: dir }), { message: `Cannot update malformed or unreadable card style preference: ${path}` });
53
+ assert.equal(writes, 0);
54
+ } finally {
55
+ t.mock.restoreAll();
56
+ syncBuiltinESMExports();
57
+ }
58
+ assert.deepEqual(readFileSync(path), before);
59
+ assert.deepEqual(readdirSync(dir), ["card-style.json"]);
60
+ });
61
+
32
62
  test("invalid or unreadable preference files read as float and are never overwritten", () => {
33
63
  for (const raw of ["", "{", "[]", "null", `{"schema":"${CARD_STYLE_SCHEMA}","style":"default"}`, `{"schema":"other/v1","style":"float"}`, `{"schema":"${CARD_STYLE_SCHEMA}","style":"float","extra":1}`]) {
34
64
  assert.equal(parseCardStyleFile(raw), undefined, raw);
@@ -42,7 +72,9 @@ test("invalid or unreadable preference files read as float and are never overwri
42
72
  const dir = home();
43
73
  mkdirSync(join(dir, "card-style.json"));
44
74
  assert.equal(resolveCardStyle({ gentlePiConfigHome: dir }).malformed, true, "a directory in its place is unreadable, not missing");
45
- if (process.getuid?.() !== 0) {
75
+ // chmod(000) is a POSIX probe, not a portable access-denial fixture.
76
+ // The EACCES test above exercises the refusal contract on every platform.
77
+ if (process.platform !== "win32" && process.getuid?.() !== 0) {
46
78
  const locked = home();
47
79
  writeCardStyle("float", { gentlePiConfigHome: locked });
48
80
  chmodSync(join(locked, "card-style.json"), 0o000);