@svenroth-ai/shipwright 0.24.7-next.0 → 0.26.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 (239) hide show
  1. package/client/dist/assets/{EmbeddedTerminal-D2BLGWdW.js → EmbeddedTerminal-D3wpPNZP.js} +20 -20
  2. package/client/dist/assets/{abnfDiagram-VRR7QNED-C-W_2Jd4.js → abnfDiagram-VRR7QNED-BkLIltB3.js} +1 -1
  3. package/client/dist/assets/{arc-CpqwESP5.js → arc-Sw-Gh0nI.js} +1 -1
  4. package/client/dist/assets/{architectureDiagram-ZJ3FMSHR-DZVZ895V.js → architectureDiagram-ZJ3FMSHR-rdxUi9Ks.js} +1 -1
  5. package/client/dist/assets/{blockDiagram-677ZJIJ3-CCCZiAA7.js → blockDiagram-677ZJIJ3-Ch_9dj8a.js} +1 -1
  6. package/client/dist/assets/{c4Diagram-LMCZKHZV-Bg1beZAE.js → c4Diagram-LMCZKHZV-CkFlO65x.js} +1 -1
  7. package/client/dist/assets/channel-BYPbLjRl.js +1 -0
  8. package/client/dist/assets/{chunk-2Q5K7J3B-CoaXSarh.js → chunk-2Q5K7J3B-CypX83xu.js} +1 -1
  9. package/client/dist/assets/{chunk-32BRIVSS-CeFXS-CM.js → chunk-32BRIVSS-DxVLYGdl.js} +1 -1
  10. package/client/dist/assets/{chunk-5VM5RSS4-QyJMVe0m.js → chunk-5VM5RSS4-Cxk1evhx.js} +1 -1
  11. package/client/dist/assets/{chunk-EX3LRPZG-BIOtWVF8.js → chunk-EX3LRPZG-n9q5S4yJ.js} +1 -1
  12. package/client/dist/assets/{chunk-JWPE2WC7-Dcq-lWyX.js → chunk-JWPE2WC7-jeL4xvir.js} +1 -1
  13. package/client/dist/assets/{chunk-MOJQB5TN-C95323ja.js → chunk-MOJQB5TN-DDSHN0cj.js} +1 -1
  14. package/client/dist/assets/{chunk-RYQCIY6F-C0HPwpWy.js → chunk-RYQCIY6F-C0bo383g.js} +1 -1
  15. package/client/dist/assets/{chunk-V7JOEXUC-BPrTbZqA.js → chunk-V7JOEXUC-C0jDXXAU.js} +1 -1
  16. package/client/dist/assets/{chunk-VR4S4FIN--BCnNoqt.js → chunk-VR4S4FIN-h-Rr4Pn2.js} +1 -1
  17. package/client/dist/assets/{chunk-XXDRQBXY-Dj8vtcXf.js → chunk-XXDRQBXY-BexSo0-s.js} +1 -1
  18. package/client/dist/assets/classDiagram-OUVF2IWQ-CsFIeTju.js +1 -0
  19. package/client/dist/assets/classDiagram-v2-EOCWNBFH-CsFIeTju.js +1 -0
  20. package/client/dist/assets/{cose-bilkent-JH36ORCC-y51otZKz.js → cose-bilkent-JH36ORCC-BQXzaioj.js} +1 -1
  21. package/client/dist/assets/{cynefin-VYW2F7L2-BRfS3cHo.js → cynefin-VYW2F7L2-BJIofhfe.js} +1 -1
  22. package/client/dist/assets/{cynefinDiagram-TSTJHNR4-Dpg2RtfL.js → cynefinDiagram-TSTJHNR4-DmPqTM1T.js} +1 -1
  23. package/client/dist/assets/{dagre-VKFMJZFB-BdZtXgKZ.js → dagre-VKFMJZFB-1xljvw6X.js} +1 -1
  24. package/client/dist/assets/{diagram-FQU43EPY-K-RCwWxW.js → diagram-FQU43EPY-iyL9muCG.js} +1 -1
  25. package/client/dist/assets/{diagram-G47NLZAW-CHNn7F2-.js → diagram-G47NLZAW-DREp2Th6.js} +1 -1
  26. package/client/dist/assets/{diagram-NH7WQ7WH-B3zu6xib.js → diagram-NH7WQ7WH-h0X3Is2S.js} +1 -1
  27. package/client/dist/assets/{diagram-OA4YK3LP-Wp5ju7L7.js → diagram-OA4YK3LP-Bj8YZRFJ.js} +1 -1
  28. package/client/dist/assets/{diagram-WEI45ONY-C94GpsRk.js → diagram-WEI45ONY-BZOf805d.js} +1 -1
  29. package/client/dist/assets/{ebnfDiagram-CCIWWBDH-DXrTPOrP.js → ebnfDiagram-CCIWWBDH-DO5JnXda.js} +1 -1
  30. package/client/dist/assets/{erDiagram-Q63AITRT-CGejnW0f.js → erDiagram-Q63AITRT-DRgcqg1T.js} +1 -1
  31. package/client/dist/assets/{flowDiagram-23GEKE2U-CFJMM637.js → flowDiagram-23GEKE2U-C3J9QmrW.js} +1 -1
  32. package/client/dist/assets/{ganttDiagram-NO4QXBWP-DnaH94Su.js → ganttDiagram-NO4QXBWP-BIEl3Tkm.js} +1 -1
  33. package/client/dist/assets/{gitGraphDiagram-IHSO6WYX-DT-D0gbn.js → gitGraphDiagram-IHSO6WYX-BNF0tFWC.js} +1 -1
  34. package/client/dist/assets/index-BnTOrFHb.js +829 -0
  35. package/client/dist/assets/index-CYPc6qxw.css +1 -0
  36. package/client/dist/assets/{infoDiagram-FWYZ7A6U-DW3Ps00w.js → infoDiagram-FWYZ7A6U-DEyFxiyY.js} +1 -1
  37. package/client/dist/assets/{ishikawaDiagram-FXEZZL3T-DjxjIT3n.js → ishikawaDiagram-FXEZZL3T-B_ehlg3I.js} +1 -1
  38. package/client/dist/assets/{journeyDiagram-5HDEW3XC-DTQRI93Y.js → journeyDiagram-5HDEW3XC-Dp00FyJS.js} +1 -1
  39. package/client/dist/assets/{kanban-definition-HUTT4EX6-BeDvbiEs.js → kanban-definition-HUTT4EX6-CTAf16q8.js} +1 -1
  40. package/client/dist/assets/{linear-B4foxDlh.js → linear-9vNUgmYd.js} +1 -1
  41. package/client/dist/assets/{mermaid.core-c-L4qgSB.js → mermaid.core-A6tjaEVD.js} +4 -4
  42. package/client/dist/assets/{mindmap-definition-LN4V7U3C-dh8v0J8T.js → mindmap-definition-LN4V7U3C-DRW3bmCY.js} +1 -1
  43. package/client/dist/assets/{pegDiagram-2B236MQR-D5vNrtFh.js → pegDiagram-2B236MQR-DJFCjHp2.js} +1 -1
  44. package/client/dist/assets/{pieDiagram-ENE6RG2P-CIjg_6lR.js → pieDiagram-ENE6RG2P-KRd-sGvC.js} +1 -1
  45. package/client/dist/assets/{quadrantDiagram-ABIIQ3AL-CF37qWQD.js → quadrantDiagram-ABIIQ3AL-9TLZE3Hu.js} +1 -1
  46. package/client/dist/assets/{railroadDiagram-RFXS5EU6-x0Hn30Kj.js → railroadDiagram-RFXS5EU6-y4tLtjXg.js} +1 -1
  47. package/client/dist/assets/{requirementDiagram-TGXJPOKE-BE2QnVbN.js → requirementDiagram-TGXJPOKE-B9EOQDQM.js} +1 -1
  48. package/client/dist/assets/{sankeyDiagram-HTMAVEWB-CUv6cIlV.js → sankeyDiagram-HTMAVEWB-zfwiDzkT.js} +1 -1
  49. package/client/dist/assets/{sequenceDiagram-DBY2YBRQ-C3ACj5LS.js → sequenceDiagram-DBY2YBRQ-DW1LWepL.js} +1 -1
  50. package/client/dist/assets/{sizeCapture-X5ZJPWSS-DTMIb5gX.js → sizeCapture-X5ZJPWSS-BM81Mik0.js} +1 -1
  51. package/client/dist/assets/{stateDiagram-2N3HPSRC-DI1Z8P74.js → stateDiagram-2N3HPSRC-D0EMIw1w.js} +1 -1
  52. package/client/dist/assets/stateDiagram-v2-6OUMAXLB-B3E4_xxT.js +1 -0
  53. package/client/dist/assets/{swimlanes-5IMT3BWC-CLSr9w29.js → swimlanes-5IMT3BWC-CKXNCX6M.js} +2 -2
  54. package/client/dist/assets/swimlanesDiagram-G3AALYLV-BegQBqnC.js +8 -0
  55. package/client/dist/assets/{timeline-definition-FHXFAJF6-BTWmUhDh.js → timeline-definition-FHXFAJF6-ByzIfvL5.js} +1 -1
  56. package/client/dist/assets/{vennDiagram-L72KCM5P-Ce1dlLCM.js → vennDiagram-L72KCM5P-Xep38xBH.js} +1 -1
  57. package/client/dist/assets/{wardleyDiagram-EHGQE667-ClOE-aae.js → wardleyDiagram-EHGQE667-DKWDpx6g.js} +1 -1
  58. package/client/dist/assets/{xychartDiagram-FW5EYKEG-Fa2pqWKw.js → xychartDiagram-FW5EYKEG-BGcwZTnu.js} +1 -1
  59. package/client/dist/index.html +27 -27
  60. package/lib/claude-cli.mjs +173 -173
  61. package/lib/preflight.mjs +252 -252
  62. package/lib/probe-path.mjs +84 -84
  63. package/lib/server.mjs +369 -369
  64. package/lib/util.mjs +157 -157
  65. package/lib/win32-spawn.mjs +253 -253
  66. package/package.json +1 -1
  67. package/scripts/deploy-procs.mjs +208 -208
  68. package/scripts/deploy-swap.mjs +276 -276
  69. package/scripts/kill-targets.js +140 -140
  70. package/scripts/repair-claude-json.mjs +295 -295
  71. package/server/dist/config/default-actions.json +90 -90
  72. package/server/dist/core/grade-runner.d.ts +4 -4
  73. package/server/dist/core/grade-runner.d.ts.map +1 -1
  74. package/server/dist/core/grade-runner.js +51 -26
  75. package/server/dist/core/grade-runner.js.map +1 -1
  76. package/server/dist/core/grade-target.d.ts +8 -0
  77. package/server/dist/core/grade-target.d.ts.map +1 -1
  78. package/server/dist/core/grade-target.js +10 -0
  79. package/server/dist/core/grade-target.js.map +1 -1
  80. package/server/dist/core/mission-context/association.d.ts +46 -0
  81. package/server/dist/core/mission-context/association.d.ts.map +1 -1
  82. package/server/dist/core/mission-context/association.js +69 -0
  83. package/server/dist/core/mission-context/association.js.map +1 -1
  84. package/server/dist/core/mission-context/resolver-iterate.d.ts +6 -0
  85. package/server/dist/core/mission-context/resolver-iterate.d.ts.map +1 -0
  86. package/server/dist/core/mission-context/resolver-iterate.js +202 -0
  87. package/server/dist/core/mission-context/resolver-iterate.js.map +1 -0
  88. package/server/dist/core/mission-context/resolver-parts.d.ts +20 -0
  89. package/server/dist/core/mission-context/resolver-parts.d.ts.map +1 -1
  90. package/server/dist/core/mission-context/resolver-parts.js +92 -0
  91. package/server/dist/core/mission-context/resolver-parts.js.map +1 -1
  92. package/server/dist/core/mission-context/resolver.d.ts +1 -1
  93. package/server/dist/core/mission-context/resolver.d.ts.map +1 -1
  94. package/server/dist/core/mission-context/resolver.js +17 -199
  95. package/server/dist/core/mission-context/resolver.js.map +1 -1
  96. package/server/dist/core/mission-context/run-id-recovery.d.ts +21 -5
  97. package/server/dist/core/mission-context/run-id-recovery.d.ts.map +1 -1
  98. package/server/dist/core/mission-context/run-id-recovery.js +32 -5
  99. package/server/dist/core/mission-context/run-id-recovery.js.map +1 -1
  100. package/server/dist/core/mission-context/scenario.d.ts +18 -11
  101. package/server/dist/core/mission-context/scenario.d.ts.map +1 -1
  102. package/server/dist/core/mission-context/scenario.js +40 -3
  103. package/server/dist/core/mission-context/scenario.js.map +1 -1
  104. package/server/dist/core/path-guard.d.ts +1 -1
  105. package/server/dist/core/path-guard.d.ts.map +1 -1
  106. package/server/dist/core/path-guard.js +9 -2
  107. package/server/dist/core/path-guard.js.map +1 -1
  108. package/server/dist/core/shipslog-docs-reader.d.ts +4 -0
  109. package/server/dist/core/shipslog-docs-reader.d.ts.map +1 -0
  110. package/server/dist/core/shipslog-docs-reader.js +181 -0
  111. package/server/dist/core/shipslog-docs-reader.js.map +1 -0
  112. package/server/dist/core/shipslog-docs-types.d.ts +15 -0
  113. package/server/dist/core/shipslog-docs-types.d.ts.map +1 -0
  114. package/server/dist/core/shipslog-docs-types.js +14 -0
  115. package/server/dist/core/shipslog-docs-types.js.map +1 -0
  116. package/server/dist/core/triage-cli-runner.d.ts.map +1 -1
  117. package/server/dist/core/triage-cli-runner.js +29 -10
  118. package/server/dist/core/triage-cli-runner.js.map +1 -1
  119. package/server/dist/core/uv-runner.d.ts +23 -0
  120. package/server/dist/core/uv-runner.d.ts.map +1 -0
  121. package/server/dist/core/uv-runner.js +39 -0
  122. package/server/dist/core/uv-runner.js.map +1 -0
  123. package/server/dist/external/design-review/serve.js +27 -27
  124. package/server/dist/external/file/exists-routes.d.ts +7 -0
  125. package/server/dist/external/file/exists-routes.d.ts.map +1 -0
  126. package/server/dist/external/file/exists-routes.js +91 -0
  127. package/server/dist/external/file/exists-routes.js.map +1 -0
  128. package/server/dist/external/mission-context/association-write.d.ts +22 -0
  129. package/server/dist/external/mission-context/association-write.d.ts.map +1 -0
  130. package/server/dist/external/mission-context/association-write.js +90 -0
  131. package/server/dist/external/mission-context/association-write.js.map +1 -0
  132. package/server/dist/external/mission-context/routes.d.ts.map +1 -1
  133. package/server/dist/external/mission-context/routes.js +18 -31
  134. package/server/dist/external/mission-context/routes.js.map +1 -1
  135. package/server/dist/external/org/audit-log.d.ts +27 -0
  136. package/server/dist/external/org/audit-log.d.ts.map +1 -0
  137. package/server/dist/external/org/audit-log.js +92 -0
  138. package/server/dist/external/org/audit-log.js.map +1 -0
  139. package/server/dist/external/org/beat-register.d.ts +15 -22
  140. package/server/dist/external/org/beat-register.d.ts.map +1 -1
  141. package/server/dist/external/org/beat-register.js +37 -32
  142. package/server/dist/external/org/beat-register.js.map +1 -1
  143. package/server/dist/external/org/file-read.d.ts +16 -0
  144. package/server/dist/external/org/file-read.d.ts.map +1 -1
  145. package/server/dist/external/org/file-read.js +86 -46
  146. package/server/dist/external/org/file-read.js.map +1 -1
  147. package/server/dist/external/org/file-write-core.d.ts +18 -0
  148. package/server/dist/external/org/file-write-core.d.ts.map +1 -0
  149. package/server/dist/external/org/file-write-core.js +97 -0
  150. package/server/dist/external/org/file-write-core.js.map +1 -0
  151. package/server/dist/external/org/file-write.d.ts +25 -0
  152. package/server/dist/external/org/file-write.d.ts.map +1 -1
  153. package/server/dist/external/org/file-write.js +82 -132
  154. package/server/dist/external/org/file-write.js.map +1 -1
  155. package/server/dist/external/org/last-run.d.ts +25 -16
  156. package/server/dist/external/org/last-run.d.ts.map +1 -1
  157. package/server/dist/external/org/last-run.js +78 -64
  158. package/server/dist/external/org/last-run.js.map +1 -1
  159. package/server/dist/external/org/lead-doc-read.d.ts +21 -0
  160. package/server/dist/external/org/lead-doc-read.d.ts.map +1 -0
  161. package/server/dist/external/org/lead-doc-read.js +84 -0
  162. package/server/dist/external/org/lead-doc-read.js.map +1 -0
  163. package/server/dist/external/org/mount.d.ts +12 -0
  164. package/server/dist/external/org/mount.d.ts.map +1 -0
  165. package/server/dist/external/org/mount.js +34 -0
  166. package/server/dist/external/org/mount.js.map +1 -0
  167. package/server/dist/external/org/org-chart-lookup.d.ts +13 -0
  168. package/server/dist/external/org/org-chart-lookup.d.ts.map +1 -1
  169. package/server/dist/external/org/org-chart-lookup.js +40 -5
  170. package/server/dist/external/org/org-chart-lookup.js.map +1 -1
  171. package/server/dist/external/org/org-chart.d.ts +21 -12
  172. package/server/dist/external/org/org-chart.d.ts.map +1 -1
  173. package/server/dist/external/org/org-chart.js +50 -32
  174. package/server/dist/external/org/org-chart.js.map +1 -1
  175. package/server/dist/external/org/role-extract.d.ts +8 -0
  176. package/server/dist/external/org/role-extract.d.ts.map +1 -0
  177. package/server/dist/external/org/role-extract.js +72 -0
  178. package/server/dist/external/org/role-extract.js.map +1 -0
  179. package/server/dist/external/org/usage.d.ts +15 -11
  180. package/server/dist/external/org/usage.d.ts.map +1 -1
  181. package/server/dist/external/org/usage.js +64 -54
  182. package/server/dist/external/org/usage.js.map +1 -1
  183. package/server/dist/external/routes.d.ts +1 -1
  184. package/server/dist/external/routes.d.ts.map +1 -1
  185. package/server/dist/external/routes.js +19 -17
  186. package/server/dist/external/routes.js.map +1 -1
  187. package/server/dist/external/shipslog-docs/routes.d.ts +10 -0
  188. package/server/dist/external/shipslog-docs/routes.d.ts.map +1 -0
  189. package/server/dist/external/shipslog-docs/routes.js +41 -0
  190. package/server/dist/external/shipslog-docs/routes.js.map +1 -0
  191. package/server/dist/routes/grade.d.ts.map +1 -1
  192. package/server/dist/routes/grade.js +3 -2
  193. package/server/dist/routes/grade.js.map +1 -1
  194. package/server/dist/routes/org-leads-composite.d.ts +21 -0
  195. package/server/dist/routes/org-leads-composite.d.ts.map +1 -0
  196. package/server/dist/routes/org-leads-composite.js +95 -0
  197. package/server/dist/routes/org-leads-composite.js.map +1 -0
  198. package/server/dist/routes/org.d.ts +15 -0
  199. package/server/dist/routes/org.d.ts.map +1 -0
  200. package/server/dist/routes/org.js +198 -0
  201. package/server/dist/routes/org.js.map +1 -0
  202. package/server/dist/terminal/pty-manager.d.ts +9 -0
  203. package/server/dist/terminal/pty-manager.d.ts.map +1 -1
  204. package/server/dist/terminal/pty-manager.js +15 -0
  205. package/server/dist/terminal/pty-manager.js.map +1 -1
  206. package/server/dist/terminal/replay-snapshot.d.ts +10 -1
  207. package/server/dist/terminal/replay-snapshot.d.ts.map +1 -1
  208. package/server/dist/terminal/replay-snapshot.js +36 -1
  209. package/server/dist/terminal/replay-snapshot.js.map +1 -1
  210. package/server/dist/terminal/ws-upgrade-handler.d.ts +17 -1
  211. package/server/dist/terminal/ws-upgrade-handler.d.ts.map +1 -1
  212. package/server/dist/terminal/ws-upgrade-handler.fixtures.d.ts +1 -0
  213. package/server/dist/terminal/ws-upgrade-handler.fixtures.d.ts.map +1 -1
  214. package/server/dist/terminal/ws-upgrade-handler.fixtures.js +3 -2
  215. package/server/dist/terminal/ws-upgrade-handler.fixtures.js.map +1 -1
  216. package/server/dist/terminal/ws-upgrade-handler.js +45 -4
  217. package/server/dist/terminal/ws-upgrade-handler.js.map +1 -1
  218. package/server/dist/test/python-spawn-guard-util.d.ts +14 -0
  219. package/server/dist/test/python-spawn-guard-util.d.ts.map +1 -0
  220. package/server/dist/test/python-spawn-guard-util.js +124 -0
  221. package/server/dist/test/python-spawn-guard-util.js.map +1 -0
  222. package/server/dist/types/org.d.ts +115 -0
  223. package/server/dist/types/org.d.ts.map +1 -0
  224. package/server/dist/types/org.js +16 -0
  225. package/server/dist/types/org.js.map +1 -0
  226. package/server/package.json +1 -1
  227. package/server/profiles/PROFILE_SCHEMA_VERSION +1 -1
  228. package/server/profiles/README.md +27 -27
  229. package/server/profiles/python-plugin-monorepo.json +35 -35
  230. package/server/profiles/supabase-nextjs.json +253 -253
  231. package/server/profiles/vite-hono.json +92 -92
  232. package/client/dist/assets/MarkdownEditorModal-CP5yG9OU.js +0 -212
  233. package/client/dist/assets/channel-BNXbCvDS.js +0 -1
  234. package/client/dist/assets/classDiagram-OUVF2IWQ-w-wCtLx3.js +0 -1
  235. package/client/dist/assets/classDiagram-v2-EOCWNBFH-w-wCtLx3.js +0 -1
  236. package/client/dist/assets/index-CU2QJgvx.js +0 -609
  237. package/client/dist/assets/index-ftvVcUT8.css +0 -1
  238. package/client/dist/assets/stateDiagram-v2-6OUMAXLB-DvwiHlJv.js +0 -1
  239. package/client/dist/assets/swimlanesDiagram-G3AALYLV-JTQZTlZ3.js +0 -8
package/lib/server.mjs CHANGED
@@ -1,369 +1,369 @@
1
- /**
2
- * server.mjs — AC1c + AC4: port-check, then boot / attach / swap. Open browser.
3
- *
4
- * Probe :3847 on 127.0.0.1 (IPv4 — Node resolves `localhost` → ::1 but Hono
5
- * binds v4; a known trap in this repo). Then:
6
- * free → boot the packaged server (detached, outlives npx)
7
- * Shipwright, same-or-newer → ATTACH (open browser, no 2nd server)
8
- * Shipwright, OLDER → SWAP via the DETACHED deploy-swap.mjs
9
- * foreign → FAIL LOUD with a PORT= remediation, incumbent ALIVE
10
- *
11
- * This module issues NO kill/taskkill/process.kill — ever. The incumbent on
12
- * :3847 may be hosting the very terminal this command runs in (PR #249, the
13
- * deploy self-kill). The swap's kill lives ONLY inside the detached swapper,
14
- * which is spawned BEFORE any kill so it outlives the caller's death cascade.
15
- */
16
-
17
- import path from "node:path";
18
- import { spawn } from "node:child_process";
19
- import { readFileSync, openSync, closeSync, mkdirSync } from "node:fs";
20
- import os from "node:os";
21
-
22
- import { compareSemverFull } from "./util.mjs";
23
- import { tcpOccupied, checkNativePty } from "./probes.mjs";
24
- import { win32CmdWrap } from "./win32-spawn.mjs";
25
-
26
- // Re-exported so callers/tests keep a single `server.mjs` surface.
27
- export { tcpOccupied, checkNativePty } from "./probes.mjs";
28
-
29
- const DEFAULT_PORT = 3847;
30
-
31
- /**
32
- * The Command Center's wire-protocol identity — MUST match `APP_NAME` in the
33
- * server's diagnostics route. A foreign process that happens to answer
34
- * `/api/diagnostics` with an `app.version` but NOT this exact name is treated
35
- * as FOREIGN (left alive, never killed), so a version alone can never
36
- * misidentify a stranger's server as ours.
37
- */
38
- export const APP_IDENTITY = "shipwright-command-center";
39
-
40
- /**
41
- * Classify a port. TCP occupancy is decided FIRST, independent of HTTP: a
42
- * process that accepts connections but never answers /api/diagnostics (or is
43
- * not HTTP at all) is FOREIGN, not free — booting a second server onto it is
44
- * exactly the bug AC4 forbids. Only a Shipwright server (200 +
45
- * `app.name === APP_IDENTITY` + `app.version`) counts as ours. Offline-safe.
46
- * @param {number} port
47
- * @param {{ fetchImpl?: typeof fetch, timeoutMs?: number, tcpProbe?: typeof tcpOccupied }} [deps]
48
- * @returns {Promise<{ reachable: boolean, shipwright: boolean, version: string | null }>}
49
- */
50
- export async function probeServer(port, deps = {}) {
51
- const {
52
- fetchImpl = typeof fetch === "function" ? fetch : undefined,
53
- timeoutMs = 2000,
54
- tcpProbe = tcpOccupied,
55
- } = deps;
56
-
57
- const occupied = await tcpProbe(port, { timeoutMs: Math.min(timeoutMs, 1500) });
58
- if (!occupied) return { reachable: false, shipwright: false, version: null };
59
- if (!fetchImpl) return { reachable: true, shipwright: false, version: null };
60
-
61
- const ctrl = new AbortController();
62
- const timer = setTimeout(() => ctrl.abort(), timeoutMs);
63
- try {
64
- const res = await fetchImpl(`http://127.0.0.1:${port}/api/diagnostics`, { signal: ctrl.signal });
65
- if (!res || !res.ok) return { reachable: true, shipwright: false, version: null };
66
- const body = await res.json();
67
- const app = body && typeof body.app === "object" ? body.app : null;
68
- const isOurs = app != null && app.name === APP_IDENTITY && typeof app.version === "string";
69
- return { reachable: true, shipwright: isOurs, version: isOurs ? app.version : null };
70
- } catch {
71
- // Occupied but not identifiable over HTTP → foreign (reachable, not ours).
72
- return { reachable: true, shipwright: false, version: null };
73
- } finally {
74
- clearTimeout(timer);
75
- }
76
- }
77
-
78
- /**
79
- * Decide the action from a probe result. Pure — the heart of AC1c/AC4.
80
- * @param {{ reachable: boolean, shipwright: boolean, version: string | null }} probe
81
- * @param {string} packageVersion
82
- * @returns {"boot"|"attach"|"swap"|"foreign"}
83
- */
84
- export function decideAction(probe, packageVersion) {
85
- if (!probe.reachable) return "boot";
86
- if (!probe.shipwright) return "foreign";
87
- // Older running server → swap (a naive attach serves the OLD UI: silent
88
- // no-op update). Same or newer → attach (never needlessly restart or downgrade).
89
- // Full compare (pre-release tail included): successive `@next` builds share a
90
- // triple and differ only in `-next.N`; a triple-only compare would call them
91
- // equal and ATTACH to the stale server, leaving the freshly published build
92
- // un-run (verified 2026-08-23 — new client served, old server code in memory).
93
- return compareSemverFull(probe.version ?? "", packageVersion) < 0 ? "swap" : "attach";
94
- }
95
-
96
- const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
97
-
98
- /**
99
- * Poll the probe until `predicate(probe)` holds or the deadline passes.
100
- * @returns {Promise<{ ok: boolean, probe: Awaited<ReturnType<typeof probeServer>> }>}
101
- */
102
- export async function pollUntil(probeFn, predicate, { timeoutMs = 15000, intervalMs = 500 } = {}) {
103
- const deadline = Date.now() + timeoutMs;
104
- let last = { reachable: false, shipwright: false, version: null };
105
- while (Date.now() < deadline) {
106
- last = await probeFn();
107
- if (predicate(last)) return { ok: true, probe: last };
108
- await sleep(intervalMs);
109
- }
110
- return { ok: false, probe: last };
111
- }
112
-
113
- /**
114
- * Pure spawn PLAN for booting the packaged server. Detached so it outlives the
115
- * short-lived npx process; env points the resolver at the packaged static +
116
- * profiles dirs. Exported so the detached property is unit-testable without a
117
- * real spawn.
118
- */
119
- export function bootSpawnPlan(port, pkgRoot) {
120
- return {
121
- command: process.execPath,
122
- args: [path.join(pkgRoot, "server", "dist", "index.js")],
123
- options: {
124
- env: {
125
- ...process.env,
126
- PORT: String(port),
127
- SHIPWRIGHT_STATIC_DIR: path.join(pkgRoot, "client", "dist"),
128
- SHIPWRIGHT_PROFILES_DIR: path.join(pkgRoot, "server", "profiles"),
129
- },
130
- detached: true,
131
- stdio: "ignore",
132
- shell: false,
133
- },
134
- };
135
- }
136
-
137
- /**
138
- * Pure spawn PLAN for the DETACHED swapper. It — not the bootstrapper — owns
139
- * the kill, and it is spawned detached BEFORE any kill so an invocation from
140
- * inside the Command Center's own terminal survives the death cascade (AC1c).
141
- */
142
- export function swapperSpawnPlan(port, pkgRoot) {
143
- return {
144
- command: process.execPath,
145
- args: [path.join(pkgRoot, "scripts", "deploy-swap.mjs"), "--port", String(port)],
146
- options: { detached: true, stdio: "ignore", shell: false },
147
- };
148
- }
149
-
150
- function runPlan(plan) {
151
- const child = spawn(plan.command, plan.args, plan.options);
152
- child.unref();
153
- return child.pid ?? null;
154
- }
155
-
156
- /**
157
- * The file the DETACHED packaged server's stdout+stderr is redirected to — and
158
- * the SAME path the boot readiness-timeout error tells the user to read. Kept as
159
- * one exported function so the two can never drift apart again: the boot used to
160
- * spawn with `stdio:"ignore"` (output discarded) while the error pointed the
161
- * user at this log, which nothing wrote — an undiagnosable dead end.
162
- */
163
- export function bootLogPath() {
164
- return path.join(os.homedir(), ".shipwright-webui", "server-manual.log");
165
- }
166
-
167
- /** Open the boot log for append, creating its dir. Returns an fd, or null when
168
- * it cannot be opened — logging is best-effort and must NEVER block the boot. */
169
- function defaultOpenBootLog() {
170
- try {
171
- mkdirSync(path.dirname(bootLogPath()), { recursive: true });
172
- return openSync(bootLogPath(), "a");
173
- } catch {
174
- return null;
175
- }
176
- }
177
-
178
- /**
179
- * Resolve the `stdio` for the detached boot: route BOTH stdout and stderr at the
180
- * log fd when it opens, else fall back to `"ignore"` (the pre-fix behaviour, now
181
- * only the degraded path). Pure over its `open` seam so both branches are
182
- * unit-testable without touching the filesystem.
183
- * @param {() => number | null} [open]
184
- */
185
- export function resolveBootStdio(open = defaultOpenBootLog) {
186
- const fd = open();
187
- return fd == null ? "ignore" : ["ignore", fd, fd];
188
- }
189
-
190
- const defaultBootServer = (port, pkgRoot) => {
191
- const plan = bootSpawnPlan(port, pkgRoot);
192
- const stdio = resolveBootStdio();
193
- const child = spawn(plan.command, plan.args, { ...plan.options, stdio });
194
- child.unref();
195
- // The child dups the fd at spawn time; drop the parent's copy so the
196
- // short-lived npx process leaves no handle open (both slots share one fd).
197
- if (Array.isArray(stdio) && typeof stdio[1] === "number") {
198
- try { closeSync(stdio[1]); } catch { /* already gone — nothing to do */ }
199
- }
200
- return child.pid ?? null;
201
- };
202
- const defaultSpawnSwapper = (port, pkgRoot) => runPlan(swapperSpawnPlan(port, pkgRoot));
203
-
204
- /** Default: read the swapper's durable verdict (pid + ok) if present. */
205
- function defaultReadDeployStatus() {
206
- try {
207
- const p = path.join(os.homedir(), ".shipwright-webui", "deploy-status.json");
208
- return JSON.parse(readFileSync(p, "utf-8"));
209
- } catch {
210
- return null;
211
- }
212
- }
213
-
214
- /**
215
- * Pure spawn PLAN for opening the browser. Exported so the shell-free property
216
- * is unit-testable on every platform without a real spawn.
217
- *
218
- * Windows needs cmd.exe because `start` is a cmd BUILTIN, not an executable —
219
- * but it gets it EXPLICITLY (the resolved ComSpec, discrete argv, `shell: false`)
220
- * rather than by asking Node to build a shell command line for us. Nothing here
221
- * needs PATHEXT resolution, so this is the one of the four remediated sites that
222
- * reaches for `win32ComSpec` directly instead of `resolveSpawn`.
223
- */
224
- export function openBrowserPlan(url, platform = process.platform, env = process.env) {
225
- if (platform === "win32") {
226
- // Built through the SAME wrap as every other cmd.exe invocation rather than
227
- // splicing the url into discrete argv. That is not decoration: a url with a
228
- // query string (`http://h/?a=1&b=2`) would otherwise reach cmd.exe with a
229
- // bare `&` and run `b=2` as a second command. Today's only caller passes
230
- // `http://localhost:<validated port>`, but this is exported public API in a
231
- // published package, so it must not depend on that.
232
- // The empty string is `start`'s window-TITLE argument; without it `start`
233
- // consumes a quoted URL as the title and opens nothing.
234
- //
235
- // A literal `"` is the ONE character quoting cannot contain — it would close
236
- // the quoted region early and let the rest be read as a command — so it is
237
- // percent-encoded here rather than trusted. `"` is never valid unescaped in a
238
- // URL, so this cannot corrupt a legitimate one. Residual, stated rather than
239
- // implied: `%VAR%` still expands inside double quotes, so a url carrying a
240
- // real environment-variable reference would be substituted. Today's only
241
- // caller passes `http://localhost:<validated port>`.
242
- return win32CmdWrap("start", ["", url.replace(/"/g, "%22")], env);
243
- }
244
- return { command: platform === "darwin" ? "open" : "xdg-open", args: [url] };
245
- }
246
-
247
- /**
248
- * Default: open the browser per platform. Never fatal.
249
- *
250
- * Exported ONLY so the never-fatal contract is testable — without a way to call
251
- * it, the async-error guard below could not be falsified, and a guard that
252
- * cannot be falsified is decoration.
253
- */
254
- export function defaultOpenBrowser(url) {
255
- const plan = openBrowserPlan(url);
256
- try {
257
- const child = spawn(plan.command, plan.args, { detached: true, stdio: "ignore", shell: false });
258
- // `spawn` reports a launch failure ASYNCHRONOUSLY via the "error" event, so
259
- // the try/catch above cannot see it — and an unhandled "error" on an
260
- // EventEmitter throws, which would take the bootstrapper down and break the
261
- // "Never fatal" contract one line above. A bad ComSpec or a missing
262
- // xdg-open is exactly that case.
263
- child.on("error", () => {
264
- /* headless / no opener — the URL is printed by the caller regardless */
265
- });
266
- child.unref();
267
- } catch {
268
- /* synchronous spawn failure — same non-fatal outcome */
269
- }
270
- }
271
-
272
- /**
273
- * Orchestrate boot / attach / swap + browser open. Pure over its seams.
274
- * @param {{
275
- * port?: number, pkgRoot: string, packageVersion: string, noOpen?: boolean,
276
- * probeFn?: () => Promise<Awaited<ReturnType<typeof probeServer>>>,
277
- * bootServer?: (port:number, pkgRoot:string) => number | null,
278
- * spawnSwapper?: (port:number, pkgRoot:string) => number | null,
279
- * readDeployStatus?: () => any,
280
- * openBrowser?: (url:string) => void,
281
- * log?: (msg:string) => void,
282
- * timeoutMs?: number,
283
- * }} opts
284
- */
285
- export async function ensureServer(opts) {
286
- const port = opts.port ?? DEFAULT_PORT;
287
- const url = `http://localhost:${port}`;
288
- const log = opts.log ?? (() => {});
289
- const probeFn = opts.probeFn ?? (() => probeServer(port));
290
- const bootServer = opts.bootServer ?? defaultBootServer;
291
- const spawnSwapper = opts.spawnSwapper ?? defaultSpawnSwapper;
292
- const readDeployStatus = opts.readDeployStatus ?? defaultReadDeployStatus;
293
- const openBrowser = opts.openBrowser ?? defaultOpenBrowser;
294
- const nativePtyCheck = opts.nativePtyCheck ?? checkNativePty;
295
- const timeoutMs = opts.timeoutMs ?? 15000;
296
-
297
- const probe = await probeFn();
298
- const action = decideAction(probe, opts.packageVersion);
299
- let result = { action, url, port, version: probe.version };
300
-
301
- if (action === "foreign") {
302
- throw new Error(
303
- `port ${port} is held by a NON-Shipwright process — it was left running and NOT killed. ` +
304
- `Free the port or pick another: re-run with PORT=<n> npx @svenroth-ai/shipwright@latest`,
305
- );
306
- }
307
-
308
- // Starting a new server (boot OR swap) requires a working native terminal —
309
- // never start one that cannot spawn (spec §1). Attach skips this: the
310
- // incumbent already proved itself.
311
- if (action === "boot" || action === "swap") {
312
- const pty = await nativePtyCheck();
313
- if (!pty.ok) {
314
- throw new Error(
315
- `the embedded terminal's native module (@lydell/node-pty) failed to load: ${pty.error}. ` +
316
- `Ensure Node >= 20.12 and your platform's build tools are present, then re-run. ` +
317
- `A Command Center whose terminal cannot spawn was NOT started.`,
318
- );
319
- }
320
- }
321
-
322
- if (action === "attach") {
323
- log(`Shipwright ${probe.version} already running on ${url} — attaching (no second server).`);
324
- } else if (action === "boot") {
325
- log(`Port ${port} free — starting the Command Center.`);
326
- const pid = bootServer(port, opts.pkgRoot);
327
- const ready = await pollUntil(probeFn, (p) => p.shipwright, { timeoutMs });
328
- if (!ready.ok) {
329
- throw new Error(
330
- `the packaged server did not become ready on ${url} within ${timeoutMs} ms — ` +
331
- `check ${bootLogPath()} (if that file is empty or absent, its directory was unwritable — ` +
332
- `re-run from a terminal to see the server's output directly)`,
333
- );
334
- }
335
- result = { ...result, pid, version: ready.probe.version };
336
- } else if (action === "swap") {
337
- log(`Older server (${probe.version}) on ${url} — swapping to ${opts.packageVersion} via the detached swapper.`);
338
- const swapperPid = spawnSwapper(port, opts.pkgRoot);
339
- // Readiness = the port now serves EXACTLY the package version (AC1c: "the
340
- // version the server reports afterwards equals the package's"). `>=` would
341
- // let an unexpected newer instance winning the race masquerade as our swap.
342
- const ready = await pollUntil(
343
- probeFn,
344
- // Full compare: readiness is the port serving EXACTLY the package version,
345
- // pre-release tail included — otherwise a stale `@next` at the same triple
346
- // (the very thing we are swapping away from) would satisfy `=== 0` and the
347
- // swap would report success while the old server still holds the port.
348
- (p) => p.shipwright && compareSemverFull(p.version ?? "", opts.packageVersion) === 0,
349
- { timeoutMs: Math.max(timeoutMs, 20000) },
350
- );
351
- const status = readDeployStatus();
352
- if (!ready.ok) {
353
- throw new Error(
354
- `the swap did not reach ${opts.packageVersion} on ${url} in time — ` +
355
- `see ~/.shipwright-webui/deploy-swap.log${status?.error ? ` (${status.error})` : ""}`,
356
- );
357
- }
358
- result = {
359
- ...result,
360
- swapperPid,
361
- newPid: status && typeof status.pid === "number" ? status.pid : null,
362
- previousVersion: probe.version,
363
- version: ready.probe.version,
364
- };
365
- }
366
-
367
- if (!opts.noOpen) openBrowser(url);
368
- return result;
369
- }
1
+ /**
2
+ * server.mjs — AC1c + AC4: port-check, then boot / attach / swap. Open browser.
3
+ *
4
+ * Probe :3847 on 127.0.0.1 (IPv4 — Node resolves `localhost` → ::1 but Hono
5
+ * binds v4; a known trap in this repo). Then:
6
+ * free → boot the packaged server (detached, outlives npx)
7
+ * Shipwright, same-or-newer → ATTACH (open browser, no 2nd server)
8
+ * Shipwright, OLDER → SWAP via the DETACHED deploy-swap.mjs
9
+ * foreign → FAIL LOUD with a PORT= remediation, incumbent ALIVE
10
+ *
11
+ * This module issues NO kill/taskkill/process.kill — ever. The incumbent on
12
+ * :3847 may be hosting the very terminal this command runs in (PR #249, the
13
+ * deploy self-kill). The swap's kill lives ONLY inside the detached swapper,
14
+ * which is spawned BEFORE any kill so it outlives the caller's death cascade.
15
+ */
16
+
17
+ import path from "node:path";
18
+ import { spawn } from "node:child_process";
19
+ import { readFileSync, openSync, closeSync, mkdirSync } from "node:fs";
20
+ import os from "node:os";
21
+
22
+ import { compareSemverFull } from "./util.mjs";
23
+ import { tcpOccupied, checkNativePty } from "./probes.mjs";
24
+ import { win32CmdWrap } from "./win32-spawn.mjs";
25
+
26
+ // Re-exported so callers/tests keep a single `server.mjs` surface.
27
+ export { tcpOccupied, checkNativePty } from "./probes.mjs";
28
+
29
+ const DEFAULT_PORT = 3847;
30
+
31
+ /**
32
+ * The Command Center's wire-protocol identity — MUST match `APP_NAME` in the
33
+ * server's diagnostics route. A foreign process that happens to answer
34
+ * `/api/diagnostics` with an `app.version` but NOT this exact name is treated
35
+ * as FOREIGN (left alive, never killed), so a version alone can never
36
+ * misidentify a stranger's server as ours.
37
+ */
38
+ export const APP_IDENTITY = "shipwright-command-center";
39
+
40
+ /**
41
+ * Classify a port. TCP occupancy is decided FIRST, independent of HTTP: a
42
+ * process that accepts connections but never answers /api/diagnostics (or is
43
+ * not HTTP at all) is FOREIGN, not free — booting a second server onto it is
44
+ * exactly the bug AC4 forbids. Only a Shipwright server (200 +
45
+ * `app.name === APP_IDENTITY` + `app.version`) counts as ours. Offline-safe.
46
+ * @param {number} port
47
+ * @param {{ fetchImpl?: typeof fetch, timeoutMs?: number, tcpProbe?: typeof tcpOccupied }} [deps]
48
+ * @returns {Promise<{ reachable: boolean, shipwright: boolean, version: string | null }>}
49
+ */
50
+ export async function probeServer(port, deps = {}) {
51
+ const {
52
+ fetchImpl = typeof fetch === "function" ? fetch : undefined,
53
+ timeoutMs = 2000,
54
+ tcpProbe = tcpOccupied,
55
+ } = deps;
56
+
57
+ const occupied = await tcpProbe(port, { timeoutMs: Math.min(timeoutMs, 1500) });
58
+ if (!occupied) return { reachable: false, shipwright: false, version: null };
59
+ if (!fetchImpl) return { reachable: true, shipwright: false, version: null };
60
+
61
+ const ctrl = new AbortController();
62
+ const timer = setTimeout(() => ctrl.abort(), timeoutMs);
63
+ try {
64
+ const res = await fetchImpl(`http://127.0.0.1:${port}/api/diagnostics`, { signal: ctrl.signal });
65
+ if (!res || !res.ok) return { reachable: true, shipwright: false, version: null };
66
+ const body = await res.json();
67
+ const app = body && typeof body.app === "object" ? body.app : null;
68
+ const isOurs = app != null && app.name === APP_IDENTITY && typeof app.version === "string";
69
+ return { reachable: true, shipwright: isOurs, version: isOurs ? app.version : null };
70
+ } catch {
71
+ // Occupied but not identifiable over HTTP → foreign (reachable, not ours).
72
+ return { reachable: true, shipwright: false, version: null };
73
+ } finally {
74
+ clearTimeout(timer);
75
+ }
76
+ }
77
+
78
+ /**
79
+ * Decide the action from a probe result. Pure — the heart of AC1c/AC4.
80
+ * @param {{ reachable: boolean, shipwright: boolean, version: string | null }} probe
81
+ * @param {string} packageVersion
82
+ * @returns {"boot"|"attach"|"swap"|"foreign"}
83
+ */
84
+ export function decideAction(probe, packageVersion) {
85
+ if (!probe.reachable) return "boot";
86
+ if (!probe.shipwright) return "foreign";
87
+ // Older running server → swap (a naive attach serves the OLD UI: silent
88
+ // no-op update). Same or newer → attach (never needlessly restart or downgrade).
89
+ // Full compare (pre-release tail included): successive `@next` builds share a
90
+ // triple and differ only in `-next.N`; a triple-only compare would call them
91
+ // equal and ATTACH to the stale server, leaving the freshly published build
92
+ // un-run (verified 2026-08-23 — new client served, old server code in memory).
93
+ return compareSemverFull(probe.version ?? "", packageVersion) < 0 ? "swap" : "attach";
94
+ }
95
+
96
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
97
+
98
+ /**
99
+ * Poll the probe until `predicate(probe)` holds or the deadline passes.
100
+ * @returns {Promise<{ ok: boolean, probe: Awaited<ReturnType<typeof probeServer>> }>}
101
+ */
102
+ export async function pollUntil(probeFn, predicate, { timeoutMs = 15000, intervalMs = 500 } = {}) {
103
+ const deadline = Date.now() + timeoutMs;
104
+ let last = { reachable: false, shipwright: false, version: null };
105
+ while (Date.now() < deadline) {
106
+ last = await probeFn();
107
+ if (predicate(last)) return { ok: true, probe: last };
108
+ await sleep(intervalMs);
109
+ }
110
+ return { ok: false, probe: last };
111
+ }
112
+
113
+ /**
114
+ * Pure spawn PLAN for booting the packaged server. Detached so it outlives the
115
+ * short-lived npx process; env points the resolver at the packaged static +
116
+ * profiles dirs. Exported so the detached property is unit-testable without a
117
+ * real spawn.
118
+ */
119
+ export function bootSpawnPlan(port, pkgRoot) {
120
+ return {
121
+ command: process.execPath,
122
+ args: [path.join(pkgRoot, "server", "dist", "index.js")],
123
+ options: {
124
+ env: {
125
+ ...process.env,
126
+ PORT: String(port),
127
+ SHIPWRIGHT_STATIC_DIR: path.join(pkgRoot, "client", "dist"),
128
+ SHIPWRIGHT_PROFILES_DIR: path.join(pkgRoot, "server", "profiles"),
129
+ },
130
+ detached: true,
131
+ stdio: "ignore",
132
+ shell: false,
133
+ },
134
+ };
135
+ }
136
+
137
+ /**
138
+ * Pure spawn PLAN for the DETACHED swapper. It — not the bootstrapper — owns
139
+ * the kill, and it is spawned detached BEFORE any kill so an invocation from
140
+ * inside the Command Center's own terminal survives the death cascade (AC1c).
141
+ */
142
+ export function swapperSpawnPlan(port, pkgRoot) {
143
+ return {
144
+ command: process.execPath,
145
+ args: [path.join(pkgRoot, "scripts", "deploy-swap.mjs"), "--port", String(port)],
146
+ options: { detached: true, stdio: "ignore", shell: false },
147
+ };
148
+ }
149
+
150
+ function runPlan(plan) {
151
+ const child = spawn(plan.command, plan.args, plan.options);
152
+ child.unref();
153
+ return child.pid ?? null;
154
+ }
155
+
156
+ /**
157
+ * The file the DETACHED packaged server's stdout+stderr is redirected to — and
158
+ * the SAME path the boot readiness-timeout error tells the user to read. Kept as
159
+ * one exported function so the two can never drift apart again: the boot used to
160
+ * spawn with `stdio:"ignore"` (output discarded) while the error pointed the
161
+ * user at this log, which nothing wrote — an undiagnosable dead end.
162
+ */
163
+ export function bootLogPath() {
164
+ return path.join(os.homedir(), ".shipwright-webui", "server-manual.log");
165
+ }
166
+
167
+ /** Open the boot log for append, creating its dir. Returns an fd, or null when
168
+ * it cannot be opened — logging is best-effort and must NEVER block the boot. */
169
+ function defaultOpenBootLog() {
170
+ try {
171
+ mkdirSync(path.dirname(bootLogPath()), { recursive: true });
172
+ return openSync(bootLogPath(), "a");
173
+ } catch {
174
+ return null;
175
+ }
176
+ }
177
+
178
+ /**
179
+ * Resolve the `stdio` for the detached boot: route BOTH stdout and stderr at the
180
+ * log fd when it opens, else fall back to `"ignore"` (the pre-fix behaviour, now
181
+ * only the degraded path). Pure over its `open` seam so both branches are
182
+ * unit-testable without touching the filesystem.
183
+ * @param {() => number | null} [open]
184
+ */
185
+ export function resolveBootStdio(open = defaultOpenBootLog) {
186
+ const fd = open();
187
+ return fd == null ? "ignore" : ["ignore", fd, fd];
188
+ }
189
+
190
+ const defaultBootServer = (port, pkgRoot) => {
191
+ const plan = bootSpawnPlan(port, pkgRoot);
192
+ const stdio = resolveBootStdio();
193
+ const child = spawn(plan.command, plan.args, { ...plan.options, stdio });
194
+ child.unref();
195
+ // The child dups the fd at spawn time; drop the parent's copy so the
196
+ // short-lived npx process leaves no handle open (both slots share one fd).
197
+ if (Array.isArray(stdio) && typeof stdio[1] === "number") {
198
+ try { closeSync(stdio[1]); } catch { /* already gone — nothing to do */ }
199
+ }
200
+ return child.pid ?? null;
201
+ };
202
+ const defaultSpawnSwapper = (port, pkgRoot) => runPlan(swapperSpawnPlan(port, pkgRoot));
203
+
204
+ /** Default: read the swapper's durable verdict (pid + ok) if present. */
205
+ function defaultReadDeployStatus() {
206
+ try {
207
+ const p = path.join(os.homedir(), ".shipwright-webui", "deploy-status.json");
208
+ return JSON.parse(readFileSync(p, "utf-8"));
209
+ } catch {
210
+ return null;
211
+ }
212
+ }
213
+
214
+ /**
215
+ * Pure spawn PLAN for opening the browser. Exported so the shell-free property
216
+ * is unit-testable on every platform without a real spawn.
217
+ *
218
+ * Windows needs cmd.exe because `start` is a cmd BUILTIN, not an executable —
219
+ * but it gets it EXPLICITLY (the resolved ComSpec, discrete argv, `shell: false`)
220
+ * rather than by asking Node to build a shell command line for us. Nothing here
221
+ * needs PATHEXT resolution, so this is the one of the four remediated sites that
222
+ * reaches for `win32ComSpec` directly instead of `resolveSpawn`.
223
+ */
224
+ export function openBrowserPlan(url, platform = process.platform, env = process.env) {
225
+ if (platform === "win32") {
226
+ // Built through the SAME wrap as every other cmd.exe invocation rather than
227
+ // splicing the url into discrete argv. That is not decoration: a url with a
228
+ // query string (`http://h/?a=1&b=2`) would otherwise reach cmd.exe with a
229
+ // bare `&` and run `b=2` as a second command. Today's only caller passes
230
+ // `http://localhost:<validated port>`, but this is exported public API in a
231
+ // published package, so it must not depend on that.
232
+ // The empty string is `start`'s window-TITLE argument; without it `start`
233
+ // consumes a quoted URL as the title and opens nothing.
234
+ //
235
+ // A literal `"` is the ONE character quoting cannot contain — it would close
236
+ // the quoted region early and let the rest be read as a command — so it is
237
+ // percent-encoded here rather than trusted. `"` is never valid unescaped in a
238
+ // URL, so this cannot corrupt a legitimate one. Residual, stated rather than
239
+ // implied: `%VAR%` still expands inside double quotes, so a url carrying a
240
+ // real environment-variable reference would be substituted. Today's only
241
+ // caller passes `http://localhost:<validated port>`.
242
+ return win32CmdWrap("start", ["", url.replace(/"/g, "%22")], env);
243
+ }
244
+ return { command: platform === "darwin" ? "open" : "xdg-open", args: [url] };
245
+ }
246
+
247
+ /**
248
+ * Default: open the browser per platform. Never fatal.
249
+ *
250
+ * Exported ONLY so the never-fatal contract is testable — without a way to call
251
+ * it, the async-error guard below could not be falsified, and a guard that
252
+ * cannot be falsified is decoration.
253
+ */
254
+ export function defaultOpenBrowser(url) {
255
+ const plan = openBrowserPlan(url);
256
+ try {
257
+ const child = spawn(plan.command, plan.args, { detached: true, stdio: "ignore", shell: false });
258
+ // `spawn` reports a launch failure ASYNCHRONOUSLY via the "error" event, so
259
+ // the try/catch above cannot see it — and an unhandled "error" on an
260
+ // EventEmitter throws, which would take the bootstrapper down and break the
261
+ // "Never fatal" contract one line above. A bad ComSpec or a missing
262
+ // xdg-open is exactly that case.
263
+ child.on("error", () => {
264
+ /* headless / no opener — the URL is printed by the caller regardless */
265
+ });
266
+ child.unref();
267
+ } catch {
268
+ /* synchronous spawn failure — same non-fatal outcome */
269
+ }
270
+ }
271
+
272
+ /**
273
+ * Orchestrate boot / attach / swap + browser open. Pure over its seams.
274
+ * @param {{
275
+ * port?: number, pkgRoot: string, packageVersion: string, noOpen?: boolean,
276
+ * probeFn?: () => Promise<Awaited<ReturnType<typeof probeServer>>>,
277
+ * bootServer?: (port:number, pkgRoot:string) => number | null,
278
+ * spawnSwapper?: (port:number, pkgRoot:string) => number | null,
279
+ * readDeployStatus?: () => any,
280
+ * openBrowser?: (url:string) => void,
281
+ * log?: (msg:string) => void,
282
+ * timeoutMs?: number,
283
+ * }} opts
284
+ */
285
+ export async function ensureServer(opts) {
286
+ const port = opts.port ?? DEFAULT_PORT;
287
+ const url = `http://localhost:${port}`;
288
+ const log = opts.log ?? (() => {});
289
+ const probeFn = opts.probeFn ?? (() => probeServer(port));
290
+ const bootServer = opts.bootServer ?? defaultBootServer;
291
+ const spawnSwapper = opts.spawnSwapper ?? defaultSpawnSwapper;
292
+ const readDeployStatus = opts.readDeployStatus ?? defaultReadDeployStatus;
293
+ const openBrowser = opts.openBrowser ?? defaultOpenBrowser;
294
+ const nativePtyCheck = opts.nativePtyCheck ?? checkNativePty;
295
+ const timeoutMs = opts.timeoutMs ?? 15000;
296
+
297
+ const probe = await probeFn();
298
+ const action = decideAction(probe, opts.packageVersion);
299
+ let result = { action, url, port, version: probe.version };
300
+
301
+ if (action === "foreign") {
302
+ throw new Error(
303
+ `port ${port} is held by a NON-Shipwright process — it was left running and NOT killed. ` +
304
+ `Free the port or pick another: re-run with PORT=<n> npx @svenroth-ai/shipwright@latest`,
305
+ );
306
+ }
307
+
308
+ // Starting a new server (boot OR swap) requires a working native terminal —
309
+ // never start one that cannot spawn (spec §1). Attach skips this: the
310
+ // incumbent already proved itself.
311
+ if (action === "boot" || action === "swap") {
312
+ const pty = await nativePtyCheck();
313
+ if (!pty.ok) {
314
+ throw new Error(
315
+ `the embedded terminal's native module (@lydell/node-pty) failed to load: ${pty.error}. ` +
316
+ `Ensure Node >= 20.12 and your platform's build tools are present, then re-run. ` +
317
+ `A Command Center whose terminal cannot spawn was NOT started.`,
318
+ );
319
+ }
320
+ }
321
+
322
+ if (action === "attach") {
323
+ log(`Shipwright ${probe.version} already running on ${url} — attaching (no second server).`);
324
+ } else if (action === "boot") {
325
+ log(`Port ${port} free — starting the Command Center.`);
326
+ const pid = bootServer(port, opts.pkgRoot);
327
+ const ready = await pollUntil(probeFn, (p) => p.shipwright, { timeoutMs });
328
+ if (!ready.ok) {
329
+ throw new Error(
330
+ `the packaged server did not become ready on ${url} within ${timeoutMs} ms — ` +
331
+ `check ${bootLogPath()} (if that file is empty or absent, its directory was unwritable — ` +
332
+ `re-run from a terminal to see the server's output directly)`,
333
+ );
334
+ }
335
+ result = { ...result, pid, version: ready.probe.version };
336
+ } else if (action === "swap") {
337
+ log(`Older server (${probe.version}) on ${url} — swapping to ${opts.packageVersion} via the detached swapper.`);
338
+ const swapperPid = spawnSwapper(port, opts.pkgRoot);
339
+ // Readiness = the port now serves EXACTLY the package version (AC1c: "the
340
+ // version the server reports afterwards equals the package's"). `>=` would
341
+ // let an unexpected newer instance winning the race masquerade as our swap.
342
+ const ready = await pollUntil(
343
+ probeFn,
344
+ // Full compare: readiness is the port serving EXACTLY the package version,
345
+ // pre-release tail included — otherwise a stale `@next` at the same triple
346
+ // (the very thing we are swapping away from) would satisfy `=== 0` and the
347
+ // swap would report success while the old server still holds the port.
348
+ (p) => p.shipwright && compareSemverFull(p.version ?? "", opts.packageVersion) === 0,
349
+ { timeoutMs: Math.max(timeoutMs, 20000) },
350
+ );
351
+ const status = readDeployStatus();
352
+ if (!ready.ok) {
353
+ throw new Error(
354
+ `the swap did not reach ${opts.packageVersion} on ${url} in time — ` +
355
+ `see ~/.shipwright-webui/deploy-swap.log${status?.error ? ` (${status.error})` : ""}`,
356
+ );
357
+ }
358
+ result = {
359
+ ...result,
360
+ swapperPid,
361
+ newPid: status && typeof status.pid === "number" ? status.pid : null,
362
+ previousVersion: probe.version,
363
+ version: ready.probe.version,
364
+ };
365
+ }
366
+
367
+ if (!opts.noOpen) openBrowser(url);
368
+ return result;
369
+ }