@svenroth-ai/shipwright 0.25.0 → 0.27.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 (206) hide show
  1. package/client/dist/assets/{EmbeddedTerminal-5QvKe3_a.js → EmbeddedTerminal-CqCQUDtp.js} +1 -1
  2. package/client/dist/assets/{abnfDiagram-VRR7QNED-CXi9HegQ.js → abnfDiagram-VRR7QNED-DX2objTP.js} +1 -1
  3. package/client/dist/assets/{arc-DLh4GiqR.js → arc-DQmjbsFU.js} +1 -1
  4. package/client/dist/assets/{architectureDiagram-ZJ3FMSHR-DUypQ8dQ.js → architectureDiagram-ZJ3FMSHR-DAvCsPCW.js} +1 -1
  5. package/client/dist/assets/{blockDiagram-677ZJIJ3-D2cVMKJG.js → blockDiagram-677ZJIJ3-DC-dlyYI.js} +1 -1
  6. package/client/dist/assets/{c4Diagram-LMCZKHZV-DtruBGJw.js → c4Diagram-LMCZKHZV-DSlmXfhE.js} +1 -1
  7. package/client/dist/assets/channel-BfmIcrp7.js +1 -0
  8. package/client/dist/assets/{chunk-2Q5K7J3B-CAfQkFw2.js → chunk-2Q5K7J3B-C111EHL6.js} +1 -1
  9. package/client/dist/assets/{chunk-32BRIVSS-CPWXoLnQ.js → chunk-32BRIVSS-DrCYs2XG.js} +1 -1
  10. package/client/dist/assets/{chunk-5VM5RSS4-xVc3oppQ.js → chunk-5VM5RSS4-DIejoaXS.js} +1 -1
  11. package/client/dist/assets/{chunk-EX3LRPZG-CUpl6fRR.js → chunk-EX3LRPZG-CGWNeH2g.js} +1 -1
  12. package/client/dist/assets/{chunk-JWPE2WC7-NejUp56x.js → chunk-JWPE2WC7-CCH0SRFY.js} +1 -1
  13. package/client/dist/assets/{chunk-MOJQB5TN-C9-2lLg0.js → chunk-MOJQB5TN-B3OpZGRv.js} +1 -1
  14. package/client/dist/assets/{chunk-RYQCIY6F-BAPtwABy.js → chunk-RYQCIY6F-BKhdp_8v.js} +1 -1
  15. package/client/dist/assets/{chunk-V7JOEXUC-Dwzihw26.js → chunk-V7JOEXUC-B6Bu8x1y.js} +1 -1
  16. package/client/dist/assets/{chunk-VR4S4FIN-9-iArFJM.js → chunk-VR4S4FIN-OgFi2_iB.js} +1 -1
  17. package/client/dist/assets/{chunk-XXDRQBXY-DiWHOrqe.js → chunk-XXDRQBXY-xyjuuhxl.js} +1 -1
  18. package/client/dist/assets/classDiagram-OUVF2IWQ-HfUQZ6Uc.js +1 -0
  19. package/client/dist/assets/classDiagram-v2-EOCWNBFH-HfUQZ6Uc.js +1 -0
  20. package/client/dist/assets/{cose-bilkent-JH36ORCC-Cp04EOcH.js → cose-bilkent-JH36ORCC-BQIH3SCK.js} +1 -1
  21. package/client/dist/assets/{cynefin-VYW2F7L2-D--GN1v8.js → cynefin-VYW2F7L2-oyLAz8te.js} +1 -1
  22. package/client/dist/assets/{cynefinDiagram-TSTJHNR4-BGH0o_qp.js → cynefinDiagram-TSTJHNR4-DoLCsH0l.js} +1 -1
  23. package/client/dist/assets/{dagre-VKFMJZFB-DSKkIKlj.js → dagre-VKFMJZFB-D7djx1_Y.js} +1 -1
  24. package/client/dist/assets/{diagram-FQU43EPY-Cs_C4lb-.js → diagram-FQU43EPY-DFuYE0Vf.js} +1 -1
  25. package/client/dist/assets/{diagram-G47NLZAW-oDtAA8O_.js → diagram-G47NLZAW-CffCZVXk.js} +1 -1
  26. package/client/dist/assets/{diagram-NH7WQ7WH-BiFMjqSH.js → diagram-NH7WQ7WH-BhrHtUlt.js} +1 -1
  27. package/client/dist/assets/{diagram-OA4YK3LP-Ccs3thCj.js → diagram-OA4YK3LP-CwQPbOn0.js} +1 -1
  28. package/client/dist/assets/{diagram-WEI45ONY-CdZNqgO3.js → diagram-WEI45ONY-abtYnYqH.js} +1 -1
  29. package/client/dist/assets/{ebnfDiagram-CCIWWBDH-CapTA0VL.js → ebnfDiagram-CCIWWBDH-DfV7gT4m.js} +1 -1
  30. package/client/dist/assets/{erDiagram-Q63AITRT-BcRNEP8s.js → erDiagram-Q63AITRT-vtbCprgH.js} +1 -1
  31. package/client/dist/assets/{flowDiagram-23GEKE2U-CJ_zF7e_.js → flowDiagram-23GEKE2U-CVCgcyDF.js} +1 -1
  32. package/client/dist/assets/{ganttDiagram-NO4QXBWP-CnJdcg9A.js → ganttDiagram-NO4QXBWP-BclyOgMb.js} +1 -1
  33. package/client/dist/assets/{gitGraphDiagram-IHSO6WYX-YTUQ_ELk.js → gitGraphDiagram-IHSO6WYX-BXT4rIKj.js} +1 -1
  34. package/client/dist/assets/index-CKU9dY18.css +1 -0
  35. package/client/dist/assets/index-DOMl6atu.js +847 -0
  36. package/client/dist/assets/{infoDiagram-FWYZ7A6U-CLOP_ZWD.js → infoDiagram-FWYZ7A6U-Dr4mN24J.js} +1 -1
  37. package/client/dist/assets/{ishikawaDiagram-FXEZZL3T-aFTIzr7o.js → ishikawaDiagram-FXEZZL3T-BBUFWq5C.js} +1 -1
  38. package/client/dist/assets/{journeyDiagram-5HDEW3XC-CbPJB4xd.js → journeyDiagram-5HDEW3XC-CmclipcQ.js} +1 -1
  39. package/client/dist/assets/{kanban-definition-HUTT4EX6-BoyhytIS.js → kanban-definition-HUTT4EX6-eIzUeZ03.js} +1 -1
  40. package/client/dist/assets/{linear-1VXA-uBg.js → linear-DGiT7uKK.js} +1 -1
  41. package/client/dist/assets/{mermaid.core-D_lO2yK9.js → mermaid.core-BcLNGbxW.js} +4 -4
  42. package/client/dist/assets/{mindmap-definition-LN4V7U3C-iFK7LXMx.js → mindmap-definition-LN4V7U3C-C4ANnNiP.js} +1 -1
  43. package/client/dist/assets/{pegDiagram-2B236MQR-DNmcmfA-.js → pegDiagram-2B236MQR-DnMeh3Yh.js} +1 -1
  44. package/client/dist/assets/{pieDiagram-ENE6RG2P-ekx83O5b.js → pieDiagram-ENE6RG2P-CKV_rkhF.js} +1 -1
  45. package/client/dist/assets/{quadrantDiagram-ABIIQ3AL-DayyD1l8.js → quadrantDiagram-ABIIQ3AL-BuUsGwHh.js} +1 -1
  46. package/client/dist/assets/{railroadDiagram-RFXS5EU6-Bdgz7S6G.js → railroadDiagram-RFXS5EU6-BwwdKnFL.js} +1 -1
  47. package/client/dist/assets/{requirementDiagram-TGXJPOKE-szP3Muld.js → requirementDiagram-TGXJPOKE-CpQZouJV.js} +1 -1
  48. package/client/dist/assets/{sankeyDiagram-HTMAVEWB-Ct3rzgUj.js → sankeyDiagram-HTMAVEWB-4fnDUNVa.js} +1 -1
  49. package/client/dist/assets/{sequenceDiagram-DBY2YBRQ-_69EfmOU.js → sequenceDiagram-DBY2YBRQ-BOcaWlM0.js} +1 -1
  50. package/client/dist/assets/{sizeCapture-X5ZJPWSS-BSOU06J2.js → sizeCapture-X5ZJPWSS-_pv-9R0c.js} +1 -1
  51. package/client/dist/assets/{stateDiagram-2N3HPSRC-HmOmFwQm.js → stateDiagram-2N3HPSRC-CFwCOonY.js} +1 -1
  52. package/client/dist/assets/stateDiagram-v2-6OUMAXLB-srkiu3Ib.js +1 -0
  53. package/client/dist/assets/{swimlanes-5IMT3BWC-CVg_4oT7.js → swimlanes-5IMT3BWC-DFa2tzxu.js} +2 -2
  54. package/client/dist/assets/swimlanesDiagram-G3AALYLV-Dq-0K9Zm.js +8 -0
  55. package/client/dist/assets/{timeline-definition-FHXFAJF6-CBBaDKXm.js → timeline-definition-FHXFAJF6-rhGYElgn.js} +1 -1
  56. package/client/dist/assets/{vennDiagram-L72KCM5P-CBXiWPMc.js → vennDiagram-L72KCM5P-BoGjRxyA.js} +1 -1
  57. package/client/dist/assets/{wardleyDiagram-EHGQE667-uhBCZoW0.js → wardleyDiagram-EHGQE667-D-IGv5bH.js} +1 -1
  58. package/client/dist/assets/{xychartDiagram-FW5EYKEG-xxE_wY5O.js → xychartDiagram-FW5EYKEG-D9PFTCfm.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/probes.mjs +10 -4
  64. package/lib/server.mjs +506 -369
  65. package/lib/util.mjs +157 -157
  66. package/lib/win32-spawn.mjs +253 -253
  67. package/package.json +68 -68
  68. package/scripts/deploy-procs.mjs +208 -208
  69. package/scripts/deploy-swap.mjs +276 -276
  70. package/scripts/kill-targets.js +140 -140
  71. package/scripts/repair-claude-json.mjs +295 -295
  72. package/server/dist/config/default-actions.json +90 -90
  73. package/server/dist/core/mission-context/association.d.ts +46 -0
  74. package/server/dist/core/mission-context/association.d.ts.map +1 -1
  75. package/server/dist/core/mission-context/association.js +69 -0
  76. package/server/dist/core/mission-context/association.js.map +1 -1
  77. package/server/dist/core/mission-context/resolver-iterate.d.ts +6 -0
  78. package/server/dist/core/mission-context/resolver-iterate.d.ts.map +1 -0
  79. package/server/dist/core/mission-context/resolver-iterate.js +202 -0
  80. package/server/dist/core/mission-context/resolver-iterate.js.map +1 -0
  81. package/server/dist/core/mission-context/resolver-parts.d.ts +20 -0
  82. package/server/dist/core/mission-context/resolver-parts.d.ts.map +1 -1
  83. package/server/dist/core/mission-context/resolver-parts.js +92 -0
  84. package/server/dist/core/mission-context/resolver-parts.js.map +1 -1
  85. package/server/dist/core/mission-context/resolver.d.ts +1 -1
  86. package/server/dist/core/mission-context/resolver.d.ts.map +1 -1
  87. package/server/dist/core/mission-context/resolver.js +17 -199
  88. package/server/dist/core/mission-context/resolver.js.map +1 -1
  89. package/server/dist/core/mission-context/run-id-recovery.d.ts +21 -5
  90. package/server/dist/core/mission-context/run-id-recovery.d.ts.map +1 -1
  91. package/server/dist/core/mission-context/run-id-recovery.js +32 -5
  92. package/server/dist/core/mission-context/run-id-recovery.js.map +1 -1
  93. package/server/dist/core/mission-context/scenario.d.ts +18 -11
  94. package/server/dist/core/mission-context/scenario.d.ts.map +1 -1
  95. package/server/dist/core/mission-context/scenario.js +40 -3
  96. package/server/dist/core/mission-context/scenario.js.map +1 -1
  97. package/server/dist/core/path-guard.d.ts +1 -1
  98. package/server/dist/core/path-guard.d.ts.map +1 -1
  99. package/server/dist/core/path-guard.js +9 -2
  100. package/server/dist/core/path-guard.js.map +1 -1
  101. package/server/dist/core/sdk-sessions-merge.d.ts +19 -0
  102. package/server/dist/core/sdk-sessions-merge.d.ts.map +1 -1
  103. package/server/dist/core/sdk-sessions-merge.js +19 -5
  104. package/server/dist/core/sdk-sessions-merge.js.map +1 -1
  105. package/server/dist/core/sdk-sessions-refresh.d.ts +50 -0
  106. package/server/dist/core/sdk-sessions-refresh.d.ts.map +1 -0
  107. package/server/dist/core/sdk-sessions-refresh.js +69 -0
  108. package/server/dist/core/sdk-sessions-refresh.js.map +1 -0
  109. package/server/dist/core/sdk-sessions-store.d.ts +2 -0
  110. package/server/dist/core/sdk-sessions-store.d.ts.map +1 -1
  111. package/server/dist/core/sdk-sessions-store.js +7 -9
  112. package/server/dist/core/sdk-sessions-store.js.map +1 -1
  113. package/server/dist/core/shipslog-docs-reader.d.ts +4 -0
  114. package/server/dist/core/shipslog-docs-reader.d.ts.map +1 -0
  115. package/server/dist/core/shipslog-docs-reader.js +181 -0
  116. package/server/dist/core/shipslog-docs-reader.js.map +1 -0
  117. package/server/dist/core/shipslog-docs-types.d.ts +15 -0
  118. package/server/dist/core/shipslog-docs-types.d.ts.map +1 -0
  119. package/server/dist/core/shipslog-docs-types.js +14 -0
  120. package/server/dist/core/shipslog-docs-types.js.map +1 -0
  121. package/server/dist/core/shipwright-plugin-version-reader.d.ts +15 -0
  122. package/server/dist/core/shipwright-plugin-version-reader.d.ts.map +1 -0
  123. package/server/dist/core/shipwright-plugin-version-reader.js +49 -0
  124. package/server/dist/core/shipwright-plugin-version-reader.js.map +1 -0
  125. package/server/dist/external/design-review/serve.js +27 -27
  126. package/server/dist/external/inbox/_derive.d.ts +2 -25
  127. package/server/dist/external/inbox/_derive.d.ts.map +1 -1
  128. package/server/dist/external/inbox/_derive.js.map +1 -1
  129. package/server/dist/external/inbox/_lead.d.ts +23 -0
  130. package/server/dist/external/inbox/_lead.d.ts.map +1 -0
  131. package/server/dist/external/inbox/_lead.js +83 -0
  132. package/server/dist/external/inbox/_lead.js.map +1 -0
  133. package/server/dist/external/inbox/_types.d.ts +32 -0
  134. package/server/dist/external/inbox/_types.d.ts.map +1 -0
  135. package/server/dist/external/inbox/_types.js +10 -0
  136. package/server/dist/external/inbox/_types.js.map +1 -0
  137. package/server/dist/external/inbox/routes.d.ts.map +1 -1
  138. package/server/dist/external/inbox/routes.js +29 -1
  139. package/server/dist/external/inbox/routes.js.map +1 -1
  140. package/server/dist/external/launch/claim-holder-gate.d.ts +42 -0
  141. package/server/dist/external/launch/claim-holder-gate.d.ts.map +1 -0
  142. package/server/dist/external/launch/claim-holder-gate.js +87 -0
  143. package/server/dist/external/launch/claim-holder-gate.js.map +1 -0
  144. package/server/dist/external/launch/routes.d.ts.map +1 -1
  145. package/server/dist/external/launch/routes.js +23 -19
  146. package/server/dist/external/launch/routes.js.map +1 -1
  147. package/server/dist/external/mission-context/association-write.d.ts +22 -0
  148. package/server/dist/external/mission-context/association-write.d.ts.map +1 -0
  149. package/server/dist/external/mission-context/association-write.js +90 -0
  150. package/server/dist/external/mission-context/association-write.js.map +1 -0
  151. package/server/dist/external/mission-context/routes.d.ts.map +1 -1
  152. package/server/dist/external/mission-context/routes.js +18 -31
  153. package/server/dist/external/mission-context/routes.js.map +1 -1
  154. package/server/dist/external/org/lead-question-threads.d.ts +46 -0
  155. package/server/dist/external/org/lead-question-threads.d.ts.map +1 -0
  156. package/server/dist/external/org/lead-question-threads.js +132 -0
  157. package/server/dist/external/org/lead-question-threads.js.map +1 -0
  158. package/server/dist/external/org/mount.d.ts +3 -0
  159. package/server/dist/external/org/mount.d.ts.map +1 -1
  160. package/server/dist/external/org/mount.js +2 -1
  161. package/server/dist/external/org/mount.js.map +1 -1
  162. package/server/dist/external/org/usage.d.ts.map +1 -1
  163. package/server/dist/external/org/usage.js +20 -2
  164. package/server/dist/external/org/usage.js.map +1 -1
  165. package/server/dist/external/routes.d.ts.map +1 -1
  166. package/server/dist/external/routes.js +10 -13
  167. package/server/dist/external/routes.js.map +1 -1
  168. package/server/dist/external/shipslog-docs/routes.d.ts +10 -0
  169. package/server/dist/external/shipslog-docs/routes.d.ts.map +1 -0
  170. package/server/dist/external/shipslog-docs/routes.js +41 -0
  171. package/server/dist/external/shipslog-docs/routes.js.map +1 -0
  172. package/server/dist/routes/diagnostics.d.ts +12 -0
  173. package/server/dist/routes/diagnostics.d.ts.map +1 -1
  174. package/server/dist/routes/diagnostics.js +4 -1
  175. package/server/dist/routes/diagnostics.js.map +1 -1
  176. package/server/dist/routes/org-threads-composite.d.ts +24 -0
  177. package/server/dist/routes/org-threads-composite.d.ts.map +1 -0
  178. package/server/dist/routes/org-threads-composite.js +51 -0
  179. package/server/dist/routes/org-threads-composite.js.map +1 -0
  180. package/server/dist/routes/org.d.ts +5 -0
  181. package/server/dist/routes/org.d.ts.map +1 -1
  182. package/server/dist/routes/org.js +13 -0
  183. package/server/dist/routes/org.js.map +1 -1
  184. package/server/dist/terminal/pty-manager.d.ts +23 -1
  185. package/server/dist/terminal/pty-manager.d.ts.map +1 -1
  186. package/server/dist/terminal/pty-manager.js +22 -3
  187. package/server/dist/terminal/pty-manager.js.map +1 -1
  188. package/server/dist/terminal/pty-write-chunker.d.ts +109 -0
  189. package/server/dist/terminal/pty-write-chunker.d.ts.map +1 -0
  190. package/server/dist/terminal/pty-write-chunker.js +234 -0
  191. package/server/dist/terminal/pty-write-chunker.js.map +1 -0
  192. package/server/dist/types/org.d.ts +19 -0
  193. package/server/dist/types/org.d.ts.map +1 -1
  194. package/server/package.json +1 -1
  195. package/server/profiles/PROFILE_SCHEMA_VERSION +1 -1
  196. package/server/profiles/README.md +27 -27
  197. package/server/profiles/python-plugin-monorepo.json +35 -35
  198. package/server/profiles/supabase-nextjs.json +253 -253
  199. package/server/profiles/vite-hono.json +92 -92
  200. package/client/dist/assets/channel-C_k-9jsa.js +0 -1
  201. package/client/dist/assets/classDiagram-OUVF2IWQ-CsEPq9Ac.js +0 -1
  202. package/client/dist/assets/classDiagram-v2-EOCWNBFH-CsEPq9Ac.js +0 -1
  203. package/client/dist/assets/index-6nZ1bt0a.css +0 -1
  204. package/client/dist/assets/index-irQhGNpn.js +0 -828
  205. package/client/dist/assets/stateDiagram-v2-6OUMAXLB-BW8H33dv.js +0 -1
  206. package/client/dist/assets/swimlanesDiagram-G3AALYLV-BaRlxcPD.js +0 -8
package/lib/server.mjs CHANGED
@@ -1,369 +1,506 @@
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 on the address `resolveProbeHost` resolves (127.0.0.1 by default; a
5
+ * profile-derived address under SHIPWRIGHT_NETWORK_PROFILE — see below). 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
+ * webui#415: under `SHIPWRIGHT_NETWORK_PROFILE=tailscale` the server binds
12
+ * ONLY the tailnet interface, never loopback — a hardcoded 127.0.0.1 probe
13
+ * never sees it, so attach/swap/foreign are all unreachable and every launch
14
+ * redundantly re-boots and fails on EADDRINUSE while a healthy incumbent runs.
15
+ * `resolveProbeHost` fixes this by reusing the server's OWN `resolveHonoHost`
16
+ * (staged into `pkgRoot/server/dist`, exactly like `bootSpawnPlan` already
17
+ * reaches into that same directory) instead of re-deriving profile precedence
18
+ * here — a second implementation would drift the moment the server's changes.
19
+ *
20
+ * This module issues NO kill/taskkill/process.kill — ever. The incumbent on
21
+ * :3847 may be hosting the very terminal this command runs in (PR #249, the
22
+ * deploy self-kill). The swap's kill lives ONLY inside the detached swapper,
23
+ * which is spawned BEFORE any kill so it outlives the caller's death cascade.
24
+ */
25
+
26
+ import path from "node:path";
27
+ import { spawn } from "node:child_process";
28
+ import { readFileSync, openSync, closeSync, mkdirSync } from "node:fs";
29
+ import os from "node:os";
30
+ import { pathToFileURL } from "node:url";
31
+ import { isIP } from "node:net";
32
+
33
+ import { compareSemverFull } from "./util.mjs";
34
+ import { tcpOccupied, checkNativePty } from "./probes.mjs";
35
+ import { win32CmdWrap } from "./win32-spawn.mjs";
36
+
37
+ // Re-exported so callers/tests keep a single `server.mjs` surface.
38
+ export { tcpOccupied, checkNativePty } from "./probes.mjs";
39
+
40
+ const DEFAULT_PORT = 3847;
41
+
42
+ /**
43
+ * The Command Center's wire-protocol identity — MUST match `APP_NAME` in the
44
+ * server's diagnostics route. A foreign process that happens to answer
45
+ * `/api/diagnostics` with an `app.version` but NOT this exact name is treated
46
+ * as FOREIGN (left alive, never killed), so a version alone can never
47
+ * misidentify a stranger's server as ours.
48
+ */
49
+ export const APP_IDENTITY = "shipwright-command-center";
50
+
51
+ /**
52
+ * Guards against shell/URL METACHARACTERS only — NOT a judgment on whether
53
+ * `host` is an appropriate network target. A probe host is safe here when it
54
+ * is either a valid IPv4/IPv6 literal (`net.isIP`) or a plain hostname
55
+ * (letters/digits/`.`/`-` only — covers "localhost" and any profile-provided
56
+ * value). Anything else (e.g. a stray `%`, `&`, quote) is REJECTED rather
57
+ * than passed through: `resolveProbeHost`'s result is later spliced into a
58
+ * fetch URL and, via `ensureServer`'s reported `url`, into `openBrowserPlan`'s
59
+ * win32 `cmd.exe` invocation, where an unvalidated `%VAR%` would expand
60
+ * inside the quoted argument — so anything reaching it from HONO_HOST must be
61
+ * validated HERE, at the source, not trusted downstream (see
62
+ * `openBrowserPlan`'s own comment for the receiving side). A value that
63
+ * passes here can still be a real external hostname the bootstrapper CLI
64
+ * will itself issue outbound TCP + `/api/diagnostics` HTTP requests to
65
+ * (webui#415 doubt review) — that egress is the intended behaviour of probing
66
+ * where the server will actually bind, not a gap this function is meant to
67
+ * close.
68
+ * @param {string} host
69
+ * @returns {boolean}
70
+ */
71
+ function isSafeProbeHost(host) {
72
+ return typeof host === "string" && host.length > 0 && (isIP(host) !== 0 || /^[A-Za-z0-9.-]+$/.test(host));
73
+ }
74
+
75
+ /**
76
+ * Format a probe host for use as a URL authority — bracket an IPv6 literal
77
+ * (`::1` -> `[::1]`), otherwise pass through unchanged. Without this, a
78
+ * bracket-less IPv6 host makes `new URL()` throw inside `fetchImpl`
79
+ * (`probeServer`'s catch then misreports the probe as FOREIGN — a loud,
80
+ * wrong "port held by a non-Shipwright process" for the user's own healthy
81
+ * server; webui#415 code review).
82
+ * @param {string} host
83
+ * @returns {string}
84
+ */
85
+ function formatHostForUrl(host) {
86
+ return host.includes(":") ? `[${host}]` : host;
87
+ }
88
+
89
+ /**
90
+ * Resolve the address to PROBE — not necessarily the address the server
91
+ * BINDS. Reuses the server's own `resolveHonoHost` (compiled into
92
+ * `pkgRoot/server/dist/lib/`, which ships in this package the same way
93
+ * `bootSpawnPlan` already targets `pkgRoot/server/dist/index.js`) so
94
+ * SHIPWRIGHT_NETWORK_PROFILE precedence is never re-implemented here.
95
+ *
96
+ * A wildcard bind ("0.0.0.0" — `open` profile, or "::" — `HONO_HOST=true`)
97
+ * still accepts loopback connections, so it maps back down to 127.0.0.1;
98
+ * anything concrete (the `tailscale` profile's resolved IP, or an explicit
99
+ * non-wildcard HONO_HOST) is probed directly, since loopback would not see
100
+ * it (webui#415).
101
+ *
102
+ * Reverse-direction gap, accepted (not fixed here): if the INCUMBENT is
103
+ * bound to loopback while this env resolves to a non-loopback host (e.g. a
104
+ * previously-launched server still on `local`, now re-launched with
105
+ * SHIPWRIGHT_NETWORK_PROFILE=tailscale), the probe never sees it — no
106
+ * EADDRINUSE, but two live Command Centers on the same port, sharing
107
+ * `sdk-sessions.json` / the scrollback store / two `PtyManager`s. The
108
+ * issue's alternative fix (probe loopback FIRST, then fall back to the
109
+ * profile address) would cover both directions; this function implements
110
+ * the issue's PRIMARY suggestion (resolve the intended bind host directly)
111
+ * instead, which is simpler and covers the reported failure mode.
112
+ *
113
+ * Never fatal: an import failure (no build staged, e.g. running from
114
+ * source), a resolution failure (`tailscale ip -4` unreachable, an invalid
115
+ * SHIPWRIGHT_NETWORK_PROFILE value), or an unsafe/malformed resolved host
116
+ * all fall back to 127.0.0.1 — the pre-fix probe, not a crash. `onFallback`
117
+ * (best-effort logging only, never throws itself) is called with the reason
118
+ * so a real misconfiguration (as opposed to "no build staged") is not
119
+ * silently swallowed — see `ensureServer`, which wires it to its own `log`.
120
+ *
121
+ * Accepted, narrow window (webui#415 doubt review): this resolution runs
122
+ * fresh on EVERY launch, not pinned to what an already-running incumbent
123
+ * bound to at ITS boot. If `tailscale ip -4` fails only on THIS invocation
124
+ * (the 2s exec timeout in `resolveTailscaleIp`, a transient daemon hiccup)
125
+ * while a healthy incumbent is genuinely bound to a tailscale IP from an
126
+ * earlier, successful resolution, this falls back to loopback, reads the
127
+ * healthy incumbent as "port free", and triggers a redundant boot attempt —
128
+ * the SAME symptom `SHIPWRIGHT_NETWORK_PROFILE=tailscale` always produced
129
+ * pre-fix (this was the reported bug), now rare instead of constant. Not
130
+ * chased further: differentiating "resolution failed" from "server not
131
+ * running" would need `ensureServer` to treat a probe-host fallback as a
132
+ * distinct outcome from a genuinely free port, which is more machinery than
133
+ * this narrow, already-rare window (a transient CLI failure, not a
134
+ * SHIPWRIGHT_NETWORK_PROFILE=tailscale user's steady state) earns.
135
+ * @param {string} pkgRoot
136
+ * @param {NodeJS.ProcessEnv} [env]
137
+ * @param {(specifier: string) => Promise<any>} [importFn]
138
+ * @param {(reason: string) => void} [onFallback]
139
+ * @returns {Promise<string>}
140
+ */
141
+ export async function resolveProbeHost(pkgRoot, env = process.env, importFn = (m) => import(m), onFallback = () => {}) {
142
+ try {
143
+ const modUrl = pathToFileURL(path.join(pkgRoot, "server", "dist", "lib", "resolveHonoHost.js")).href;
144
+ const { resolveHonoHost } = await importFn(modUrl);
145
+ const host = resolveHonoHost(env);
146
+ if (host === "0.0.0.0" || host === "::") return "127.0.0.1";
147
+ if (!isSafeProbeHost(host)) {
148
+ try { onFallback(`resolved host "${host}" is not a safe IP/hostname`); } catch { /* logging must never throw */ }
149
+ return "127.0.0.1";
150
+ }
151
+ return host;
152
+ } catch (e) {
153
+ try { onFallback(String(e?.message ?? e)); } catch { /* logging must never throw */ }
154
+ return "127.0.0.1";
155
+ }
156
+ }
157
+
158
+ /**
159
+ * Classify a port. TCP occupancy is decided FIRST, independent of HTTP: a
160
+ * process that accepts connections but never answers /api/diagnostics (or is
161
+ * not HTTP at all) is FOREIGN, not free — booting a second server onto it is
162
+ * exactly the bug AC4 forbids. Only a Shipwright server (200 +
163
+ * `app.name === APP_IDENTITY` + `app.version`) counts as ours. Offline-safe.
164
+ * @param {number} port
165
+ * @param {{ fetchImpl?: typeof fetch, timeoutMs?: number, tcpProbe?: typeof tcpOccupied, host?: string }} [deps]
166
+ * @returns {Promise<{ reachable: boolean, shipwright: boolean, version: string | null }>}
167
+ */
168
+ export async function probeServer(port, deps = {}) {
169
+ const {
170
+ fetchImpl = typeof fetch === "function" ? fetch : undefined,
171
+ timeoutMs = 2000,
172
+ tcpProbe = tcpOccupied,
173
+ host = "127.0.0.1",
174
+ } = deps;
175
+
176
+ const occupied = await tcpProbe(port, { host, timeoutMs: Math.min(timeoutMs, 1500) });
177
+ if (!occupied) return { reachable: false, shipwright: false, version: null };
178
+ if (!fetchImpl) return { reachable: true, shipwright: false, version: null };
179
+
180
+ const ctrl = new AbortController();
181
+ const timer = setTimeout(() => ctrl.abort(), timeoutMs);
182
+ try {
183
+ const res = await fetchImpl(`http://${formatHostForUrl(host)}:${port}/api/diagnostics`, { signal: ctrl.signal });
184
+ if (!res || !res.ok) return { reachable: true, shipwright: false, version: null };
185
+ const body = await res.json();
186
+ const app = body && typeof body.app === "object" ? body.app : null;
187
+ const isOurs = app != null && app.name === APP_IDENTITY && typeof app.version === "string";
188
+ return { reachable: true, shipwright: isOurs, version: isOurs ? app.version : null };
189
+ } catch {
190
+ // Occupied but not identifiable over HTTP → foreign (reachable, not ours).
191
+ return { reachable: true, shipwright: false, version: null };
192
+ } finally {
193
+ clearTimeout(timer);
194
+ }
195
+ }
196
+
197
+ /**
198
+ * Decide the action from a probe result. Pure — the heart of AC1c/AC4.
199
+ * @param {{ reachable: boolean, shipwright: boolean, version: string | null }} probe
200
+ * @param {string} packageVersion
201
+ * @returns {"boot"|"attach"|"swap"|"foreign"}
202
+ */
203
+ export function decideAction(probe, packageVersion) {
204
+ if (!probe.reachable) return "boot";
205
+ if (!probe.shipwright) return "foreign";
206
+ // Older running server → swap (a naive attach serves the OLD UI: silent
207
+ // no-op update). Same or newer → attach (never needlessly restart or downgrade).
208
+ // Full compare (pre-release tail included): successive `@next` builds share a
209
+ // triple and differ only in `-next.N`; a triple-only compare would call them
210
+ // equal and ATTACH to the stale server, leaving the freshly published build
211
+ // un-run (verified 2026-08-23 — new client served, old server code in memory).
212
+ return compareSemverFull(probe.version ?? "", packageVersion) < 0 ? "swap" : "attach";
213
+ }
214
+
215
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
216
+
217
+ /**
218
+ * Poll the probe until `predicate(probe)` holds or the deadline passes.
219
+ * @returns {Promise<{ ok: boolean, probe: Awaited<ReturnType<typeof probeServer>> }>}
220
+ */
221
+ export async function pollUntil(probeFn, predicate, { timeoutMs = 15000, intervalMs = 500 } = {}) {
222
+ const deadline = Date.now() + timeoutMs;
223
+ let last = { reachable: false, shipwright: false, version: null };
224
+ while (Date.now() < deadline) {
225
+ last = await probeFn();
226
+ if (predicate(last)) return { ok: true, probe: last };
227
+ await sleep(intervalMs);
228
+ }
229
+ return { ok: false, probe: last };
230
+ }
231
+
232
+ /**
233
+ * Pure spawn PLAN for booting the packaged server. Detached so it outlives the
234
+ * short-lived npx process; env points the resolver at the packaged static +
235
+ * profiles dirs. Exported so the detached property is unit-testable without a
236
+ * real spawn.
237
+ */
238
+ export function bootSpawnPlan(port, pkgRoot) {
239
+ return {
240
+ command: process.execPath,
241
+ args: [path.join(pkgRoot, "server", "dist", "index.js")],
242
+ options: {
243
+ env: {
244
+ ...process.env,
245
+ PORT: String(port),
246
+ SHIPWRIGHT_STATIC_DIR: path.join(pkgRoot, "client", "dist"),
247
+ SHIPWRIGHT_PROFILES_DIR: path.join(pkgRoot, "server", "profiles"),
248
+ },
249
+ detached: true,
250
+ stdio: "ignore",
251
+ shell: false,
252
+ },
253
+ };
254
+ }
255
+
256
+ /**
257
+ * Pure spawn PLAN for the DETACHED swapper. It — not the bootstrapper — owns
258
+ * the kill, and it is spawned detached BEFORE any kill so an invocation from
259
+ * inside the Command Center's own terminal survives the death cascade (AC1c).
260
+ */
261
+ export function swapperSpawnPlan(port, pkgRoot) {
262
+ return {
263
+ command: process.execPath,
264
+ args: [path.join(pkgRoot, "scripts", "deploy-swap.mjs"), "--port", String(port)],
265
+ options: { detached: true, stdio: "ignore", shell: false },
266
+ };
267
+ }
268
+
269
+ function runPlan(plan) {
270
+ const child = spawn(plan.command, plan.args, plan.options);
271
+ child.unref();
272
+ return child.pid ?? null;
273
+ }
274
+
275
+ /**
276
+ * The file the DETACHED packaged server's stdout+stderr is redirected to — and
277
+ * the SAME path the boot readiness-timeout error tells the user to read. Kept as
278
+ * one exported function so the two can never drift apart again: the boot used to
279
+ * spawn with `stdio:"ignore"` (output discarded) while the error pointed the
280
+ * user at this log, which nothing wrote — an undiagnosable dead end.
281
+ */
282
+ export function bootLogPath() {
283
+ return path.join(os.homedir(), ".shipwright-webui", "server-manual.log");
284
+ }
285
+
286
+ /** Open the boot log for append, creating its dir. Returns an fd, or null when
287
+ * it cannot be opened — logging is best-effort and must NEVER block the boot. */
288
+ function defaultOpenBootLog() {
289
+ try {
290
+ mkdirSync(path.dirname(bootLogPath()), { recursive: true });
291
+ return openSync(bootLogPath(), "a");
292
+ } catch {
293
+ return null;
294
+ }
295
+ }
296
+
297
+ /**
298
+ * Resolve the `stdio` for the detached boot: route BOTH stdout and stderr at the
299
+ * log fd when it opens, else fall back to `"ignore"` (the pre-fix behaviour, now
300
+ * only the degraded path). Pure over its `open` seam so both branches are
301
+ * unit-testable without touching the filesystem.
302
+ * @param {() => number | null} [open]
303
+ */
304
+ export function resolveBootStdio(open = defaultOpenBootLog) {
305
+ const fd = open();
306
+ return fd == null ? "ignore" : ["ignore", fd, fd];
307
+ }
308
+
309
+ const defaultBootServer = (port, pkgRoot) => {
310
+ const plan = bootSpawnPlan(port, pkgRoot);
311
+ const stdio = resolveBootStdio();
312
+ const child = spawn(plan.command, plan.args, { ...plan.options, stdio });
313
+ child.unref();
314
+ // The child dups the fd at spawn time; drop the parent's copy so the
315
+ // short-lived npx process leaves no handle open (both slots share one fd).
316
+ if (Array.isArray(stdio) && typeof stdio[1] === "number") {
317
+ try { closeSync(stdio[1]); } catch { /* already gone — nothing to do */ }
318
+ }
319
+ return child.pid ?? null;
320
+ };
321
+ const defaultSpawnSwapper = (port, pkgRoot) => runPlan(swapperSpawnPlan(port, pkgRoot));
322
+
323
+ /** Default: read the swapper's durable verdict (pid + ok) if present. */
324
+ function defaultReadDeployStatus() {
325
+ try {
326
+ const p = path.join(os.homedir(), ".shipwright-webui", "deploy-status.json");
327
+ return JSON.parse(readFileSync(p, "utf-8"));
328
+ } catch {
329
+ return null;
330
+ }
331
+ }
332
+
333
+ /**
334
+ * Pure spawn PLAN for opening the browser. Exported so the shell-free property
335
+ * is unit-testable on every platform without a real spawn.
336
+ *
337
+ * Windows needs cmd.exe because `start` is a cmd BUILTIN, not an executable —
338
+ * but it gets it EXPLICITLY (the resolved ComSpec, discrete argv, `shell: false`)
339
+ * rather than by asking Node to build a shell command line for us. Nothing here
340
+ * needs PATHEXT resolution, so this is the one of the four remediated sites that
341
+ * reaches for `win32ComSpec` directly instead of `resolveSpawn`.
342
+ */
343
+ export function openBrowserPlan(url, platform = process.platform, env = process.env) {
344
+ if (platform === "win32") {
345
+ // Built through the SAME wrap as every other cmd.exe invocation rather than
346
+ // splicing the url into discrete argv. That is not decoration: a url with a
347
+ // query string (`http://h/?a=1&b=2`) would otherwise reach cmd.exe with a
348
+ // bare `&` and run `b=2` as a second command. This is exported public API in
349
+ // a published package, so it must not depend on what today's caller passes.
350
+ // The empty string is `start`'s window-TITLE argument; without it `start`
351
+ // consumes a quoted URL as the title and opens nothing.
352
+ //
353
+ // A literal `"` is the ONE character quoting cannot contain — it would close
354
+ // the quoted region early and let the rest be read as a command — so it is
355
+ // percent-encoded here rather than trusted. `"` is never valid unescaped in a
356
+ // URL, so this cannot corrupt a legitimate one. Residual, stated rather than
357
+ // implied: `%VAR%` still expands inside double quotes, so a url carrying a
358
+ // real environment-variable reference would be substituted — closed at the
359
+ // SOURCE since webui#415, not here: `ensureServer`'s host is no longer
360
+ // always `localhost` (a profile/HONO_HOST-derived value is possible), but
361
+ // `resolveProbeHost`'s `isSafeProbeHost` rejects anything that is not a
362
+ // valid IP literal or a plain `[A-Za-z0-9.-]+` hostname before it ever
363
+ // reaches this function — a `%`, `&`, or `"` in HONO_HOST cannot arrive here.
364
+ return win32CmdWrap("start", ["", url.replace(/"/g, "%22")], env);
365
+ }
366
+ return { command: platform === "darwin" ? "open" : "xdg-open", args: [url] };
367
+ }
368
+
369
+ /**
370
+ * Default: open the browser per platform. Never fatal.
371
+ *
372
+ * Exported ONLY so the never-fatal contract is testable — without a way to call
373
+ * it, the async-error guard below could not be falsified, and a guard that
374
+ * cannot be falsified is decoration.
375
+ */
376
+ export function defaultOpenBrowser(url) {
377
+ const plan = openBrowserPlan(url);
378
+ try {
379
+ const child = spawn(plan.command, plan.args, { detached: true, stdio: "ignore", shell: false });
380
+ // `spawn` reports a launch failure ASYNCHRONOUSLY via the "error" event, so
381
+ // the try/catch above cannot see it — and an unhandled "error" on an
382
+ // EventEmitter throws, which would take the bootstrapper down and break the
383
+ // "Never fatal" contract one line above. A bad ComSpec or a missing
384
+ // xdg-open is exactly that case.
385
+ child.on("error", () => {
386
+ /* headless / no opener — the URL is printed by the caller regardless */
387
+ });
388
+ child.unref();
389
+ } catch {
390
+ /* synchronous spawn failure — same non-fatal outcome */
391
+ }
392
+ }
393
+
394
+ /**
395
+ * Orchestrate boot / attach / swap + browser open. Pure over its seams.
396
+ * @param {{
397
+ * port?: number, pkgRoot: string, packageVersion: string, noOpen?: boolean,
398
+ * probeFn?: () => Promise<Awaited<ReturnType<typeof probeServer>>>,
399
+ * resolveProbeHost?: (pkgRoot: string) => Promise<string>,
400
+ * bootServer?: (port:number, pkgRoot:string) => number | null,
401
+ * spawnSwapper?: (port:number, pkgRoot:string) => number | null,
402
+ * readDeployStatus?: () => any,
403
+ * openBrowser?: (url:string) => void,
404
+ * log?: (msg:string) => void,
405
+ * timeoutMs?: number,
406
+ * }} opts
407
+ */
408
+ export async function ensureServer(opts) {
409
+ const port = opts.port ?? DEFAULT_PORT;
410
+ const log = opts.log ?? (() => {});
411
+ // Under the tailscale profile this resolves `tailscale ip -4` once here; the
412
+ // BOOTED server (bootSpawnPlan, below) independently resolves it again at its
413
+ // own startup. Deliberately not forwarded as SHIPWRIGHT_TAILSCALE_IP — that
414
+ // would change the packaged server's own env-precedence semantics for a
415
+ // bootstrapper-internal convenience. Paid at most once per launch either way
416
+ // (not a loop), and bounded by resolveTailscaleIp's own 2s exec timeout.
417
+ const resolveHost = opts.resolveProbeHost
418
+ ?? ((pkgRoot) => resolveProbeHost(pkgRoot, process.env, undefined, (reason) => log(`probe host resolution fell back to loopback: ${reason}`)));
419
+ const probeHost = await resolveHost(opts.pkgRoot);
420
+ // "localhost" (not the literal 127.0.0.1) preserves today's reported/opened
421
+ // address for the loopback case — only a genuinely non-loopback resolution
422
+ // (webui#415: tailscale) changes what the user is shown and what opens.
423
+ // formatHostForUrl brackets an IPv6 literal (e.g. HONO_HOST=::1) so it forms
424
+ // a valid URL authority instead of throwing inside probeServer's fetch.
425
+ const url = probeHost === "127.0.0.1" ? `http://localhost:${port}` : `http://${formatHostForUrl(probeHost)}:${port}`;
426
+ const probeFn = opts.probeFn ?? (() => probeServer(port, { host: probeHost }));
427
+ const bootServer = opts.bootServer ?? defaultBootServer;
428
+ const spawnSwapper = opts.spawnSwapper ?? defaultSpawnSwapper;
429
+ const readDeployStatus = opts.readDeployStatus ?? defaultReadDeployStatus;
430
+ const openBrowser = opts.openBrowser ?? defaultOpenBrowser;
431
+ const nativePtyCheck = opts.nativePtyCheck ?? checkNativePty;
432
+ const timeoutMs = opts.timeoutMs ?? 15000;
433
+
434
+ const probe = await probeFn();
435
+ const action = decideAction(probe, opts.packageVersion);
436
+ let result = { action, url, port, version: probe.version };
437
+
438
+ if (action === "foreign") {
439
+ throw new Error(
440
+ `port ${port} is held by a NON-Shipwright process — it was left running and NOT killed. ` +
441
+ `Free the port or pick another: re-run with PORT=<n> npx @svenroth-ai/shipwright@latest`,
442
+ );
443
+ }
444
+
445
+ // Starting a new server (boot OR swap) requires a working native terminal —
446
+ // never start one that cannot spawn (spec §1). Attach skips this: the
447
+ // incumbent already proved itself.
448
+ if (action === "boot" || action === "swap") {
449
+ const pty = await nativePtyCheck();
450
+ if (!pty.ok) {
451
+ throw new Error(
452
+ `the embedded terminal's native module (@lydell/node-pty) failed to load: ${pty.error}. ` +
453
+ `Ensure Node >= 20.12 and your platform's build tools are present, then re-run. ` +
454
+ `A Command Center whose terminal cannot spawn was NOT started.`,
455
+ );
456
+ }
457
+ }
458
+
459
+ if (action === "attach") {
460
+ log(`Shipwright ${probe.version} already running on ${url} — attaching (no second server).`);
461
+ } else if (action === "boot") {
462
+ log(`Port ${port} free — starting the Command Center.`);
463
+ const pid = bootServer(port, opts.pkgRoot);
464
+ const ready = await pollUntil(probeFn, (p) => p.shipwright, { timeoutMs });
465
+ if (!ready.ok) {
466
+ throw new Error(
467
+ `the packaged server did not become ready on ${url} within ${timeoutMs} ms — ` +
468
+ `check ${bootLogPath()} (if that file is empty or absent, its directory was unwritable — ` +
469
+ `re-run from a terminal to see the server's output directly)`,
470
+ );
471
+ }
472
+ result = { ...result, pid, version: ready.probe.version };
473
+ } else if (action === "swap") {
474
+ log(`Older server (${probe.version}) on ${url} — swapping to ${opts.packageVersion} via the detached swapper.`);
475
+ const swapperPid = spawnSwapper(port, opts.pkgRoot);
476
+ // Readiness = the port now serves EXACTLY the package version (AC1c: "the
477
+ // version the server reports afterwards equals the package's"). `>=` would
478
+ // let an unexpected newer instance winning the race masquerade as our swap.
479
+ const ready = await pollUntil(
480
+ probeFn,
481
+ // Full compare: readiness is the port serving EXACTLY the package version,
482
+ // pre-release tail included — otherwise a stale `@next` at the same triple
483
+ // (the very thing we are swapping away from) would satisfy `=== 0` and the
484
+ // swap would report success while the old server still holds the port.
485
+ (p) => p.shipwright && compareSemverFull(p.version ?? "", opts.packageVersion) === 0,
486
+ { timeoutMs: Math.max(timeoutMs, 20000) },
487
+ );
488
+ const status = readDeployStatus();
489
+ if (!ready.ok) {
490
+ throw new Error(
491
+ `the swap did not reach ${opts.packageVersion} on ${url} in time — ` +
492
+ `see ~/.shipwright-webui/deploy-swap.log${status?.error ? ` (${status.error})` : ""}`,
493
+ );
494
+ }
495
+ result = {
496
+ ...result,
497
+ swapperPid,
498
+ newPid: status && typeof status.pid === "number" ? status.pid : null,
499
+ previousVersion: probe.version,
500
+ version: ready.probe.version,
501
+ };
502
+ }
503
+
504
+ if (!opts.noOpen) openBrowser(url);
505
+ return result;
506
+ }