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