@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.
- package/client/dist/assets/{EmbeddedTerminal-5QvKe3_a.js → EmbeddedTerminal-CqCQUDtp.js} +1 -1
- package/client/dist/assets/{abnfDiagram-VRR7QNED-CXi9HegQ.js → abnfDiagram-VRR7QNED-DX2objTP.js} +1 -1
- package/client/dist/assets/{arc-DLh4GiqR.js → arc-DQmjbsFU.js} +1 -1
- package/client/dist/assets/{architectureDiagram-ZJ3FMSHR-DUypQ8dQ.js → architectureDiagram-ZJ3FMSHR-DAvCsPCW.js} +1 -1
- package/client/dist/assets/{blockDiagram-677ZJIJ3-D2cVMKJG.js → blockDiagram-677ZJIJ3-DC-dlyYI.js} +1 -1
- package/client/dist/assets/{c4Diagram-LMCZKHZV-DtruBGJw.js → c4Diagram-LMCZKHZV-DSlmXfhE.js} +1 -1
- package/client/dist/assets/channel-BfmIcrp7.js +1 -0
- package/client/dist/assets/{chunk-2Q5K7J3B-CAfQkFw2.js → chunk-2Q5K7J3B-C111EHL6.js} +1 -1
- package/client/dist/assets/{chunk-32BRIVSS-CPWXoLnQ.js → chunk-32BRIVSS-DrCYs2XG.js} +1 -1
- package/client/dist/assets/{chunk-5VM5RSS4-xVc3oppQ.js → chunk-5VM5RSS4-DIejoaXS.js} +1 -1
- package/client/dist/assets/{chunk-EX3LRPZG-CUpl6fRR.js → chunk-EX3LRPZG-CGWNeH2g.js} +1 -1
- package/client/dist/assets/{chunk-JWPE2WC7-NejUp56x.js → chunk-JWPE2WC7-CCH0SRFY.js} +1 -1
- package/client/dist/assets/{chunk-MOJQB5TN-C9-2lLg0.js → chunk-MOJQB5TN-B3OpZGRv.js} +1 -1
- package/client/dist/assets/{chunk-RYQCIY6F-BAPtwABy.js → chunk-RYQCIY6F-BKhdp_8v.js} +1 -1
- package/client/dist/assets/{chunk-V7JOEXUC-Dwzihw26.js → chunk-V7JOEXUC-B6Bu8x1y.js} +1 -1
- package/client/dist/assets/{chunk-VR4S4FIN-9-iArFJM.js → chunk-VR4S4FIN-OgFi2_iB.js} +1 -1
- package/client/dist/assets/{chunk-XXDRQBXY-DiWHOrqe.js → chunk-XXDRQBXY-xyjuuhxl.js} +1 -1
- package/client/dist/assets/classDiagram-OUVF2IWQ-HfUQZ6Uc.js +1 -0
- package/client/dist/assets/classDiagram-v2-EOCWNBFH-HfUQZ6Uc.js +1 -0
- package/client/dist/assets/{cose-bilkent-JH36ORCC-Cp04EOcH.js → cose-bilkent-JH36ORCC-BQIH3SCK.js} +1 -1
- package/client/dist/assets/{cynefin-VYW2F7L2-D--GN1v8.js → cynefin-VYW2F7L2-oyLAz8te.js} +1 -1
- package/client/dist/assets/{cynefinDiagram-TSTJHNR4-BGH0o_qp.js → cynefinDiagram-TSTJHNR4-DoLCsH0l.js} +1 -1
- package/client/dist/assets/{dagre-VKFMJZFB-DSKkIKlj.js → dagre-VKFMJZFB-D7djx1_Y.js} +1 -1
- package/client/dist/assets/{diagram-FQU43EPY-Cs_C4lb-.js → diagram-FQU43EPY-DFuYE0Vf.js} +1 -1
- package/client/dist/assets/{diagram-G47NLZAW-oDtAA8O_.js → diagram-G47NLZAW-CffCZVXk.js} +1 -1
- package/client/dist/assets/{diagram-NH7WQ7WH-BiFMjqSH.js → diagram-NH7WQ7WH-BhrHtUlt.js} +1 -1
- package/client/dist/assets/{diagram-OA4YK3LP-Ccs3thCj.js → diagram-OA4YK3LP-CwQPbOn0.js} +1 -1
- package/client/dist/assets/{diagram-WEI45ONY-CdZNqgO3.js → diagram-WEI45ONY-abtYnYqH.js} +1 -1
- package/client/dist/assets/{ebnfDiagram-CCIWWBDH-CapTA0VL.js → ebnfDiagram-CCIWWBDH-DfV7gT4m.js} +1 -1
- package/client/dist/assets/{erDiagram-Q63AITRT-BcRNEP8s.js → erDiagram-Q63AITRT-vtbCprgH.js} +1 -1
- package/client/dist/assets/{flowDiagram-23GEKE2U-CJ_zF7e_.js → flowDiagram-23GEKE2U-CVCgcyDF.js} +1 -1
- package/client/dist/assets/{ganttDiagram-NO4QXBWP-CnJdcg9A.js → ganttDiagram-NO4QXBWP-BclyOgMb.js} +1 -1
- package/client/dist/assets/{gitGraphDiagram-IHSO6WYX-YTUQ_ELk.js → gitGraphDiagram-IHSO6WYX-BXT4rIKj.js} +1 -1
- package/client/dist/assets/index-CKU9dY18.css +1 -0
- package/client/dist/assets/index-DOMl6atu.js +847 -0
- package/client/dist/assets/{infoDiagram-FWYZ7A6U-CLOP_ZWD.js → infoDiagram-FWYZ7A6U-Dr4mN24J.js} +1 -1
- package/client/dist/assets/{ishikawaDiagram-FXEZZL3T-aFTIzr7o.js → ishikawaDiagram-FXEZZL3T-BBUFWq5C.js} +1 -1
- package/client/dist/assets/{journeyDiagram-5HDEW3XC-CbPJB4xd.js → journeyDiagram-5HDEW3XC-CmclipcQ.js} +1 -1
- package/client/dist/assets/{kanban-definition-HUTT4EX6-BoyhytIS.js → kanban-definition-HUTT4EX6-eIzUeZ03.js} +1 -1
- package/client/dist/assets/{linear-1VXA-uBg.js → linear-DGiT7uKK.js} +1 -1
- package/client/dist/assets/{mermaid.core-D_lO2yK9.js → mermaid.core-BcLNGbxW.js} +4 -4
- package/client/dist/assets/{mindmap-definition-LN4V7U3C-iFK7LXMx.js → mindmap-definition-LN4V7U3C-C4ANnNiP.js} +1 -1
- package/client/dist/assets/{pegDiagram-2B236MQR-DNmcmfA-.js → pegDiagram-2B236MQR-DnMeh3Yh.js} +1 -1
- package/client/dist/assets/{pieDiagram-ENE6RG2P-ekx83O5b.js → pieDiagram-ENE6RG2P-CKV_rkhF.js} +1 -1
- package/client/dist/assets/{quadrantDiagram-ABIIQ3AL-DayyD1l8.js → quadrantDiagram-ABIIQ3AL-BuUsGwHh.js} +1 -1
- package/client/dist/assets/{railroadDiagram-RFXS5EU6-Bdgz7S6G.js → railroadDiagram-RFXS5EU6-BwwdKnFL.js} +1 -1
- package/client/dist/assets/{requirementDiagram-TGXJPOKE-szP3Muld.js → requirementDiagram-TGXJPOKE-CpQZouJV.js} +1 -1
- package/client/dist/assets/{sankeyDiagram-HTMAVEWB-Ct3rzgUj.js → sankeyDiagram-HTMAVEWB-4fnDUNVa.js} +1 -1
- package/client/dist/assets/{sequenceDiagram-DBY2YBRQ-_69EfmOU.js → sequenceDiagram-DBY2YBRQ-BOcaWlM0.js} +1 -1
- package/client/dist/assets/{sizeCapture-X5ZJPWSS-BSOU06J2.js → sizeCapture-X5ZJPWSS-_pv-9R0c.js} +1 -1
- package/client/dist/assets/{stateDiagram-2N3HPSRC-HmOmFwQm.js → stateDiagram-2N3HPSRC-CFwCOonY.js} +1 -1
- package/client/dist/assets/stateDiagram-v2-6OUMAXLB-srkiu3Ib.js +1 -0
- package/client/dist/assets/{swimlanes-5IMT3BWC-CVg_4oT7.js → swimlanes-5IMT3BWC-DFa2tzxu.js} +2 -2
- package/client/dist/assets/swimlanesDiagram-G3AALYLV-Dq-0K9Zm.js +8 -0
- package/client/dist/assets/{timeline-definition-FHXFAJF6-CBBaDKXm.js → timeline-definition-FHXFAJF6-rhGYElgn.js} +1 -1
- package/client/dist/assets/{vennDiagram-L72KCM5P-CBXiWPMc.js → vennDiagram-L72KCM5P-BoGjRxyA.js} +1 -1
- package/client/dist/assets/{wardleyDiagram-EHGQE667-uhBCZoW0.js → wardleyDiagram-EHGQE667-D-IGv5bH.js} +1 -1
- package/client/dist/assets/{xychartDiagram-FW5EYKEG-xxE_wY5O.js → xychartDiagram-FW5EYKEG-D9PFTCfm.js} +1 -1
- package/client/dist/index.html +27 -27
- package/lib/claude-cli.mjs +173 -173
- package/lib/preflight.mjs +252 -252
- package/lib/probe-path.mjs +84 -84
- package/lib/probes.mjs +10 -4
- package/lib/server.mjs +506 -369
- package/lib/util.mjs +157 -157
- package/lib/win32-spawn.mjs +253 -253
- package/package.json +68 -68
- package/scripts/deploy-procs.mjs +208 -208
- package/scripts/deploy-swap.mjs +276 -276
- package/scripts/kill-targets.js +140 -140
- package/scripts/repair-claude-json.mjs +295 -295
- package/server/dist/config/default-actions.json +90 -90
- package/server/dist/core/mission-context/association.d.ts +46 -0
- package/server/dist/core/mission-context/association.d.ts.map +1 -1
- package/server/dist/core/mission-context/association.js +69 -0
- package/server/dist/core/mission-context/association.js.map +1 -1
- package/server/dist/core/mission-context/resolver-iterate.d.ts +6 -0
- package/server/dist/core/mission-context/resolver-iterate.d.ts.map +1 -0
- package/server/dist/core/mission-context/resolver-iterate.js +202 -0
- package/server/dist/core/mission-context/resolver-iterate.js.map +1 -0
- package/server/dist/core/mission-context/resolver-parts.d.ts +20 -0
- package/server/dist/core/mission-context/resolver-parts.d.ts.map +1 -1
- package/server/dist/core/mission-context/resolver-parts.js +92 -0
- package/server/dist/core/mission-context/resolver-parts.js.map +1 -1
- package/server/dist/core/mission-context/resolver.d.ts +1 -1
- package/server/dist/core/mission-context/resolver.d.ts.map +1 -1
- package/server/dist/core/mission-context/resolver.js +17 -199
- package/server/dist/core/mission-context/resolver.js.map +1 -1
- package/server/dist/core/mission-context/run-id-recovery.d.ts +21 -5
- package/server/dist/core/mission-context/run-id-recovery.d.ts.map +1 -1
- package/server/dist/core/mission-context/run-id-recovery.js +32 -5
- package/server/dist/core/mission-context/run-id-recovery.js.map +1 -1
- package/server/dist/core/mission-context/scenario.d.ts +18 -11
- package/server/dist/core/mission-context/scenario.d.ts.map +1 -1
- package/server/dist/core/mission-context/scenario.js +40 -3
- package/server/dist/core/mission-context/scenario.js.map +1 -1
- package/server/dist/core/path-guard.d.ts +1 -1
- package/server/dist/core/path-guard.d.ts.map +1 -1
- package/server/dist/core/path-guard.js +9 -2
- package/server/dist/core/path-guard.js.map +1 -1
- package/server/dist/core/sdk-sessions-merge.d.ts +19 -0
- package/server/dist/core/sdk-sessions-merge.d.ts.map +1 -1
- package/server/dist/core/sdk-sessions-merge.js +19 -5
- package/server/dist/core/sdk-sessions-merge.js.map +1 -1
- package/server/dist/core/sdk-sessions-refresh.d.ts +50 -0
- package/server/dist/core/sdk-sessions-refresh.d.ts.map +1 -0
- package/server/dist/core/sdk-sessions-refresh.js +69 -0
- package/server/dist/core/sdk-sessions-refresh.js.map +1 -0
- package/server/dist/core/sdk-sessions-store.d.ts +2 -0
- package/server/dist/core/sdk-sessions-store.d.ts.map +1 -1
- package/server/dist/core/sdk-sessions-store.js +7 -9
- package/server/dist/core/sdk-sessions-store.js.map +1 -1
- package/server/dist/core/shipslog-docs-reader.d.ts +4 -0
- package/server/dist/core/shipslog-docs-reader.d.ts.map +1 -0
- package/server/dist/core/shipslog-docs-reader.js +181 -0
- package/server/dist/core/shipslog-docs-reader.js.map +1 -0
- package/server/dist/core/shipslog-docs-types.d.ts +15 -0
- package/server/dist/core/shipslog-docs-types.d.ts.map +1 -0
- package/server/dist/core/shipslog-docs-types.js +14 -0
- package/server/dist/core/shipslog-docs-types.js.map +1 -0
- package/server/dist/core/shipwright-plugin-version-reader.d.ts +15 -0
- package/server/dist/core/shipwright-plugin-version-reader.d.ts.map +1 -0
- package/server/dist/core/shipwright-plugin-version-reader.js +49 -0
- package/server/dist/core/shipwright-plugin-version-reader.js.map +1 -0
- package/server/dist/external/design-review/serve.js +27 -27
- package/server/dist/external/inbox/_derive.d.ts +2 -25
- package/server/dist/external/inbox/_derive.d.ts.map +1 -1
- package/server/dist/external/inbox/_derive.js.map +1 -1
- package/server/dist/external/inbox/_lead.d.ts +23 -0
- package/server/dist/external/inbox/_lead.d.ts.map +1 -0
- package/server/dist/external/inbox/_lead.js +83 -0
- package/server/dist/external/inbox/_lead.js.map +1 -0
- package/server/dist/external/inbox/_types.d.ts +32 -0
- package/server/dist/external/inbox/_types.d.ts.map +1 -0
- package/server/dist/external/inbox/_types.js +10 -0
- package/server/dist/external/inbox/_types.js.map +1 -0
- package/server/dist/external/inbox/routes.d.ts.map +1 -1
- package/server/dist/external/inbox/routes.js +29 -1
- package/server/dist/external/inbox/routes.js.map +1 -1
- package/server/dist/external/launch/claim-holder-gate.d.ts +42 -0
- package/server/dist/external/launch/claim-holder-gate.d.ts.map +1 -0
- package/server/dist/external/launch/claim-holder-gate.js +87 -0
- package/server/dist/external/launch/claim-holder-gate.js.map +1 -0
- package/server/dist/external/launch/routes.d.ts.map +1 -1
- package/server/dist/external/launch/routes.js +23 -19
- package/server/dist/external/launch/routes.js.map +1 -1
- package/server/dist/external/mission-context/association-write.d.ts +22 -0
- package/server/dist/external/mission-context/association-write.d.ts.map +1 -0
- package/server/dist/external/mission-context/association-write.js +90 -0
- package/server/dist/external/mission-context/association-write.js.map +1 -0
- package/server/dist/external/mission-context/routes.d.ts.map +1 -1
- package/server/dist/external/mission-context/routes.js +18 -31
- package/server/dist/external/mission-context/routes.js.map +1 -1
- package/server/dist/external/org/lead-question-threads.d.ts +46 -0
- package/server/dist/external/org/lead-question-threads.d.ts.map +1 -0
- package/server/dist/external/org/lead-question-threads.js +132 -0
- package/server/dist/external/org/lead-question-threads.js.map +1 -0
- package/server/dist/external/org/mount.d.ts +3 -0
- package/server/dist/external/org/mount.d.ts.map +1 -1
- package/server/dist/external/org/mount.js +2 -1
- package/server/dist/external/org/mount.js.map +1 -1
- package/server/dist/external/org/usage.d.ts.map +1 -1
- package/server/dist/external/org/usage.js +20 -2
- package/server/dist/external/org/usage.js.map +1 -1
- package/server/dist/external/routes.d.ts.map +1 -1
- package/server/dist/external/routes.js +10 -13
- package/server/dist/external/routes.js.map +1 -1
- package/server/dist/external/shipslog-docs/routes.d.ts +10 -0
- package/server/dist/external/shipslog-docs/routes.d.ts.map +1 -0
- package/server/dist/external/shipslog-docs/routes.js +41 -0
- package/server/dist/external/shipslog-docs/routes.js.map +1 -0
- package/server/dist/routes/diagnostics.d.ts +12 -0
- package/server/dist/routes/diagnostics.d.ts.map +1 -1
- package/server/dist/routes/diagnostics.js +4 -1
- package/server/dist/routes/diagnostics.js.map +1 -1
- package/server/dist/routes/org-threads-composite.d.ts +24 -0
- package/server/dist/routes/org-threads-composite.d.ts.map +1 -0
- package/server/dist/routes/org-threads-composite.js +51 -0
- package/server/dist/routes/org-threads-composite.js.map +1 -0
- package/server/dist/routes/org.d.ts +5 -0
- package/server/dist/routes/org.d.ts.map +1 -1
- package/server/dist/routes/org.js +13 -0
- package/server/dist/routes/org.js.map +1 -1
- package/server/dist/terminal/pty-manager.d.ts +23 -1
- package/server/dist/terminal/pty-manager.d.ts.map +1 -1
- package/server/dist/terminal/pty-manager.js +22 -3
- package/server/dist/terminal/pty-manager.js.map +1 -1
- package/server/dist/terminal/pty-write-chunker.d.ts +109 -0
- package/server/dist/terminal/pty-write-chunker.d.ts.map +1 -0
- package/server/dist/terminal/pty-write-chunker.js +234 -0
- package/server/dist/terminal/pty-write-chunker.js.map +1 -0
- package/server/dist/types/org.d.ts +19 -0
- package/server/dist/types/org.d.ts.map +1 -1
- package/server/package.json +1 -1
- package/server/profiles/PROFILE_SCHEMA_VERSION +1 -1
- package/server/profiles/README.md +27 -27
- package/server/profiles/python-plugin-monorepo.json +35 -35
- package/server/profiles/supabase-nextjs.json +253 -253
- package/server/profiles/vite-hono.json +92 -92
- package/client/dist/assets/channel-C_k-9jsa.js +0 -1
- package/client/dist/assets/classDiagram-OUVF2IWQ-CsEPq9Ac.js +0 -1
- package/client/dist/assets/classDiagram-v2-EOCWNBFH-CsEPq9Ac.js +0 -1
- package/client/dist/assets/index-6nZ1bt0a.css +0 -1
- package/client/dist/assets/index-irQhGNpn.js +0 -828
- package/client/dist/assets/stateDiagram-v2-6OUMAXLB-BW8H33dv.js +0 -1
- 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
|
|
5
|
-
*
|
|
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
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
export
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
*
|
|
44
|
-
*
|
|
45
|
-
* `
|
|
46
|
-
*
|
|
47
|
-
*
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
*
|
|
80
|
-
*
|
|
81
|
-
*
|
|
82
|
-
* @
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
*
|
|
100
|
-
*
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
*
|
|
115
|
-
*
|
|
116
|
-
*
|
|
117
|
-
*
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
}
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
*
|
|
139
|
-
*
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
}
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
*
|
|
160
|
-
*
|
|
161
|
-
*
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
}
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
}
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
}
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
}
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
*
|
|
219
|
-
*
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
}
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
*
|
|
277
|
-
*
|
|
278
|
-
*
|
|
279
|
-
*
|
|
280
|
-
*
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
const
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
}
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
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
|
+
}
|