@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/scripts/deploy-swap.mjs
CHANGED
|
@@ -1,276 +1,276 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
/**
|
|
3
|
-
* deploy-swap.mjs — the detached half of the production deploy.
|
|
4
|
-
*
|
|
5
|
-
* The deploy scripts used to kill the old server and start the new one inline.
|
|
6
|
-
* Run from the Command Center's embedded terminal, the caller is a DESCENDANT of
|
|
7
|
-
* the Hono server it kills (Hono -> node-pty shell -> claude -> the script), so
|
|
8
|
-
* the kill tore down the ConPTY and took the caller with it: the "start the new
|
|
9
|
-
* server" step never ran. Fresh build on disk, NO server, no diagnostic — the
|
|
10
|
-
* process that would have printed one was the one that died (outage 2026-07-14).
|
|
11
|
-
*
|
|
12
|
-
* The caller spawns THIS helper detached *before* any kill happens, so it outlives
|
|
13
|
-
* that cascade and finishes what the caller cannot. Empirically verified: a
|
|
14
|
-
* `Start-Process` (Windows) / `nohup` (POSIX) child survives the kill.
|
|
15
|
-
*
|
|
16
|
-
* Contract — the caller owns install + build (a failed build must leave the running
|
|
17
|
-
* server untouched: it never gets here); this helper owns kill -> start ->
|
|
18
|
-
* readiness -> post-restart heal -> durable status. Full story + evidence:
|
|
19
|
-
* .shipwright/planning/iterate/2026-07-14-deploy-self-kill.md
|
|
20
|
-
*
|
|
21
|
-
* Usage (from the deploy scripts, detached): node scripts/deploy-swap.mjs --port 3847
|
|
22
|
-
*/
|
|
23
|
-
|
|
24
|
-
import fs from 'node:fs';
|
|
25
|
-
import os from 'node:os';
|
|
26
|
-
import path from 'node:path';
|
|
27
|
-
import { spawn, spawnSync } from 'node:child_process';
|
|
28
|
-
import { fileURLToPath } from 'node:url';
|
|
29
|
-
import { stopOldServer, waitForServerUp } from './deploy-procs.mjs';
|
|
30
|
-
|
|
31
|
-
const DEFAULT_PORT = 3847;
|
|
32
|
-
const READY_TIMEOUT_MS = 12_000;
|
|
33
|
-
const PORT_FREE_TIMEOUT_MS = 5_000;
|
|
34
|
-
const SWAP_LOG_CAP_BYTES = 256 * 1024;
|
|
35
|
-
|
|
36
|
-
const here = path.dirname(fileURLToPath(import.meta.url));
|
|
37
|
-
const repo = path.resolve(here, '..');
|
|
38
|
-
const serverDir = path.join(repo, 'server');
|
|
39
|
-
const logDir = path.join(os.homedir(), '.shipwright-webui');
|
|
40
|
-
const SERVER_LOG = path.join(logDir, 'server-manual.log');
|
|
41
|
-
const SWAP_LOG = path.join(logDir, 'deploy-swap.log');
|
|
42
|
-
const STATUS_FILE = path.join(logDir, 'deploy-status.json');
|
|
43
|
-
|
|
44
|
-
// --- Pure helpers (unit-tested in deploy-swap.test.mjs) --------------------
|
|
45
|
-
|
|
46
|
-
/**
|
|
47
|
-
* Resolve the Hono port. Mirrors the callers' guards (the .ps1's `^\d{1,5}$`,
|
|
48
|
-
* which also keeps the cast under Int32.MaxValue, and the .sh's `${PORT:-3847}`)
|
|
49
|
-
* so caller and swapper can never disagree on the target.
|
|
50
|
-
*
|
|
51
|
-
* @param {Record<string, unknown>} [env]
|
|
52
|
-
* @returns {number}
|
|
53
|
-
*/
|
|
54
|
-
const isValidPort = (raw) => /^\d{1,5}$/.test(raw) && Number(raw) > 0;
|
|
55
|
-
|
|
56
|
-
export function resolvePort(env = {}) {
|
|
57
|
-
const raw = env?.PORT === undefined || env?.PORT === null ? '' : String(env.PORT);
|
|
58
|
-
return isValidPort(raw) ? Number(raw) : DEFAULT_PORT;
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
/**
|
|
62
|
-
* `--port` (passed by the caller, which already resolved it for its own readiness
|
|
63
|
-
* poll and operator messages) wins over the environment. One rule, one resolved
|
|
64
|
-
* value in every sink; a malformed flag degrades instead of throwing.
|
|
65
|
-
*
|
|
66
|
-
* @param {string[]} [argv]
|
|
67
|
-
* @param {Record<string, unknown>} [env]
|
|
68
|
-
* @returns {{ port: number }}
|
|
69
|
-
*/
|
|
70
|
-
export function parseArgs(argv = [], env = {}) {
|
|
71
|
-
const i = argv.indexOf('--port');
|
|
72
|
-
const raw = i !== -1 && argv[i + 1] !== undefined ? String(argv[i + 1]) : '';
|
|
73
|
-
return { port: isValidPort(raw) ? Number(raw) : resolvePort(env) };
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
/**
|
|
77
|
-
* The durable record of the deploy outcome. Inside an embedded terminal the caller
|
|
78
|
-
* is killed mid-deploy, so it cannot be the one to report success or failure —
|
|
79
|
-
* that is precisely how the 2026-07-14 outage stayed invisible for hours.
|
|
80
|
-
*
|
|
81
|
-
* @param {{ok: unknown, port: unknown, pid?: unknown, ts: unknown, error?: unknown, readiness?: string}} o
|
|
82
|
-
*/
|
|
83
|
-
export function buildStatus({ ok, port, pid = null, ts, error = null, readiness = 'listener' }) {
|
|
84
|
-
return {
|
|
85
|
-
ok: Boolean(ok),
|
|
86
|
-
port: Number(port),
|
|
87
|
-
pid: pid === null || pid === undefined ? null : Number(pid),
|
|
88
|
-
ts: Number(ts),
|
|
89
|
-
error: error ?? null,
|
|
90
|
-
// How `ok` was established: 'listener' (the new child owns the port — the
|
|
91
|
-
// strong claim) or 'process-alive' (no lsof on this host; the child was
|
|
92
|
-
// merely still running). The caller reports the verdict, so it must be able
|
|
93
|
-
// to tell the two apart.
|
|
94
|
-
readiness,
|
|
95
|
-
};
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
// --- Side-effecting steps --------------------------------------------------
|
|
99
|
-
|
|
100
|
-
function log(msg) {
|
|
101
|
-
const line = `[deploy-swap] ${new Date().toISOString()} ${msg}`;
|
|
102
|
-
console.log(line);
|
|
103
|
-
try {
|
|
104
|
-
fs.appendFileSync(SWAP_LOG, line + '\n');
|
|
105
|
-
} catch {
|
|
106
|
-
// The log is a convenience; never fail the deploy over it.
|
|
107
|
-
}
|
|
108
|
-
}
|
|
109
|
-
|
|
110
|
-
/** Last lines of the server log — the only thing that explains an EADDRINUSE or a crash on boot. */
|
|
111
|
-
function serverLogTail(lines = 12) {
|
|
112
|
-
try {
|
|
113
|
-
return fs
|
|
114
|
-
.readFileSync(SERVER_LOG, 'utf-8')
|
|
115
|
-
.split(/\r?\n/)
|
|
116
|
-
.filter(Boolean)
|
|
117
|
-
.slice(-lines)
|
|
118
|
-
.join(' | ');
|
|
119
|
-
} catch {
|
|
120
|
-
return '(no server log)';
|
|
121
|
-
}
|
|
122
|
-
}
|
|
123
|
-
|
|
124
|
-
/**
|
|
125
|
-
* Open the server log — and NEVER let it block the deploy.
|
|
126
|
-
*
|
|
127
|
-
* `w` (truncate) matches the old `> $log`. But the file can be held by another
|
|
128
|
-
* instance: a second webui on a different PORT shares this path, and a server
|
|
129
|
-
* launched the old way (a `cmd` redirect) holds it with restrictive sharing, so
|
|
130
|
-
* openSync throws EBUSY. Losing the log is a nuisance; losing the SERVER because
|
|
131
|
-
* we could not open a log file would be the very outage this helper exists to
|
|
132
|
-
* prevent. Degrade: truncate -> append -> no log at all.
|
|
133
|
-
*/
|
|
134
|
-
function openServerLog() {
|
|
135
|
-
for (const flags of ['w', 'a']) {
|
|
136
|
-
try {
|
|
137
|
-
return fs.openSync(SERVER_LOG, flags);
|
|
138
|
-
} catch { /* held by another instance — try the next mode */ }
|
|
139
|
-
}
|
|
140
|
-
log(`WARNING: ${SERVER_LOG} is not writable — starting the server without a log`);
|
|
141
|
-
return 'ignore';
|
|
142
|
-
}
|
|
143
|
-
|
|
144
|
-
/** Start the freshly built server, detached, so it outlives THIS helper too. */
|
|
145
|
-
function startServer(port) {
|
|
146
|
-
const out = openServerLog();
|
|
147
|
-
const child = spawn(
|
|
148
|
-
process.execPath,
|
|
149
|
-
['--env-file-if-exists=../.env.local', 'dist/index.js'],
|
|
150
|
-
{
|
|
151
|
-
cwd: serverDir,
|
|
152
|
-
env: { ...process.env, PORT: String(port) },
|
|
153
|
-
detached: true,
|
|
154
|
-
stdio: ['ignore', out, out],
|
|
155
|
-
shell: false,
|
|
156
|
-
},
|
|
157
|
-
);
|
|
158
|
-
child.unref();
|
|
159
|
-
return child;
|
|
160
|
-
}
|
|
161
|
-
|
|
162
|
-
/**
|
|
163
|
-
* Post-restart ~/.claude.json heal. It used to sit in the CALLER — i.e. in code
|
|
164
|
-
* already dead by the time it matters, because the server-kill it waits on is the
|
|
165
|
-
* same kill that takes the caller down. Best effort: never gated on.
|
|
166
|
-
*/
|
|
167
|
-
function heal(label) {
|
|
168
|
-
try {
|
|
169
|
-
spawnSync(process.execPath, [path.join(here, 'repair-claude-json.mjs')], {
|
|
170
|
-
stdio: 'ignore',
|
|
171
|
-
});
|
|
172
|
-
log(`~/.claude.json integrity check done (${label})`);
|
|
173
|
-
} catch {
|
|
174
|
-
log(`~/.claude.json integrity check skipped (${label})`);
|
|
175
|
-
}
|
|
176
|
-
}
|
|
177
|
-
|
|
178
|
-
function writeStatus(status) {
|
|
179
|
-
try {
|
|
180
|
-
fs.writeFileSync(STATUS_FILE, JSON.stringify(status, null, 2) + '\n');
|
|
181
|
-
} catch (e) {
|
|
182
|
-
log(`could not write ${STATUS_FILE}: ${String(e)}`);
|
|
183
|
-
}
|
|
184
|
-
}
|
|
185
|
-
|
|
186
|
-
/**
|
|
187
|
-
* Keep the swapper's history, but bounded. server-manual.log is truncated on every
|
|
188
|
-
* deploy; this one is appended to (it must survive the restart that truncates the
|
|
189
|
-
* other), so without a cap it grows forever.
|
|
190
|
-
*/
|
|
191
|
-
function capSwapLog() {
|
|
192
|
-
try {
|
|
193
|
-
if (fs.statSync(SWAP_LOG).size <= SWAP_LOG_CAP_BYTES) return;
|
|
194
|
-
const kept = fs.readFileSync(SWAP_LOG, 'utf-8').slice(-SWAP_LOG_CAP_BYTES / 2);
|
|
195
|
-
fs.writeFileSync(SWAP_LOG, kept);
|
|
196
|
-
} catch { /* no log yet, or unreadable — nothing to cap */ }
|
|
197
|
-
}
|
|
198
|
-
|
|
199
|
-
async function main() {
|
|
200
|
-
fs.mkdirSync(logDir, { recursive: true });
|
|
201
|
-
capSwapLog();
|
|
202
|
-
// A stale status file from a PREVIOUS deploy must never be mistaken for this
|
|
203
|
-
// run's verdict by the (possibly still alive) caller.
|
|
204
|
-
fs.rmSync(STATUS_FILE, { force: true });
|
|
205
|
-
|
|
206
|
-
const { port } = parseArgs(process.argv.slice(2), process.env);
|
|
207
|
-
const finish = (ok, extra) => {
|
|
208
|
-
// The verdict is written BEFORE the (best-effort, possibly slow) heal, so a
|
|
209
|
-
// caller waiting on it is never blocked by a step that cannot change it.
|
|
210
|
-
writeStatus(buildStatus({ ok, port, ts: Date.now(), ...extra }));
|
|
211
|
-
log(ok ? `OK — ${extra.pid ? `server up on ${port} (pid ${extra.pid})` : 'done'}` : `FAILED — ${extra.error}`);
|
|
212
|
-
return ok;
|
|
213
|
-
};
|
|
214
|
-
|
|
215
|
-
log(`swap starting (port ${port}, pid ${process.pid})`);
|
|
216
|
-
|
|
217
|
-
// 1. Stop the old server. From here on the caller may die at any moment — this
|
|
218
|
-
// kill tears down the ConPTY that hosts it.
|
|
219
|
-
const { freed, killed, survivor } = await stopOldServer(port, {
|
|
220
|
-
timeoutMs: PORT_FREE_TIMEOUT_MS,
|
|
221
|
-
});
|
|
222
|
-
log(killed.length ? `stopped PID(s): ${killed.join(' ')}` : `nothing was listening on ${port}`);
|
|
223
|
-
|
|
224
|
-
if (!freed) {
|
|
225
|
-
// The port never came free. Starting now would only hit EADDRINUSE and leave
|
|
226
|
-
// the machine with NOTHING. The old server is still up — worse than a fresh
|
|
227
|
-
// one, far better than none. Say so loudly and stop here.
|
|
228
|
-
heal('kill-failed');
|
|
229
|
-
return finish(false, {
|
|
230
|
-
readiness: 'listener',
|
|
231
|
-
error:
|
|
232
|
-
`port ${port} is still held by PID ${survivor} after ${PORT_FREE_TIMEOUT_MS} ms — ` +
|
|
233
|
-
`the OLD server is still running and was NOT replaced (nothing new was started)`,
|
|
234
|
-
});
|
|
235
|
-
}
|
|
236
|
-
|
|
237
|
-
// 2. Start the fresh build.
|
|
238
|
-
const child = startServer(port);
|
|
239
|
-
log(`started node dist/index.js (pid ${child.pid})`);
|
|
240
|
-
|
|
241
|
-
// 3. Confirm OUR child took the port (or, without lsof, that it is alive).
|
|
242
|
-
const { ok, readiness, exited, exitCode } = await waitForServerUp(port, child, {
|
|
243
|
-
timeoutMs: READY_TIMEOUT_MS,
|
|
244
|
-
});
|
|
245
|
-
|
|
246
|
-
// 4. Heal ~/.claude.json in the clean window: the old embedded sessions are
|
|
247
|
-
// dead and a UI reload has not spawned new ones yet.
|
|
248
|
-
heal('post-restart');
|
|
249
|
-
|
|
250
|
-
return finish(ok, {
|
|
251
|
-
pid: ok ? child.pid : null,
|
|
252
|
-
readiness,
|
|
253
|
-
error: ok
|
|
254
|
-
? null
|
|
255
|
-
: exited
|
|
256
|
-
? `the new server exited on startup (code ${exitCode}) — ${serverLogTail()}`
|
|
257
|
-
: `the new server did not take port ${port} within ${READY_TIMEOUT_MS} ms — ${serverLogTail()}`,
|
|
258
|
-
});
|
|
259
|
-
}
|
|
260
|
-
|
|
261
|
-
// Only run when invoked as a script — importing it (tests) must stay side-effect free.
|
|
262
|
-
if (process.argv[1] && path.resolve(process.argv[1]) === path.resolve(fileURLToPath(import.meta.url))) {
|
|
263
|
-
main()
|
|
264
|
-
.then((ok) => process.exit(ok ? 0 : 1))
|
|
265
|
-
.catch((e) => {
|
|
266
|
-
// A throw AFTER the kill (spawn EACCES, an unwritable log) would otherwise
|
|
267
|
-
// leave no server AND no status file — the failure mode that made the
|
|
268
|
-
// original outage invisible. The record survives even the unexpected.
|
|
269
|
-
try {
|
|
270
|
-
const { port } = parseArgs(process.argv.slice(2), process.env);
|
|
271
|
-
writeStatus(buildStatus({ ok: false, port, ts: Date.now(), error: `swap crashed: ${String(e)}` }));
|
|
272
|
-
} catch { /* nothing left to do */ }
|
|
273
|
-
log(`CRASHED — ${String(e)}`);
|
|
274
|
-
process.exit(1);
|
|
275
|
-
});
|
|
276
|
-
}
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* deploy-swap.mjs — the detached half of the production deploy.
|
|
4
|
+
*
|
|
5
|
+
* The deploy scripts used to kill the old server and start the new one inline.
|
|
6
|
+
* Run from the Command Center's embedded terminal, the caller is a DESCENDANT of
|
|
7
|
+
* the Hono server it kills (Hono -> node-pty shell -> claude -> the script), so
|
|
8
|
+
* the kill tore down the ConPTY and took the caller with it: the "start the new
|
|
9
|
+
* server" step never ran. Fresh build on disk, NO server, no diagnostic — the
|
|
10
|
+
* process that would have printed one was the one that died (outage 2026-07-14).
|
|
11
|
+
*
|
|
12
|
+
* The caller spawns THIS helper detached *before* any kill happens, so it outlives
|
|
13
|
+
* that cascade and finishes what the caller cannot. Empirically verified: a
|
|
14
|
+
* `Start-Process` (Windows) / `nohup` (POSIX) child survives the kill.
|
|
15
|
+
*
|
|
16
|
+
* Contract — the caller owns install + build (a failed build must leave the running
|
|
17
|
+
* server untouched: it never gets here); this helper owns kill -> start ->
|
|
18
|
+
* readiness -> post-restart heal -> durable status. Full story + evidence:
|
|
19
|
+
* .shipwright/planning/iterate/2026-07-14-deploy-self-kill.md
|
|
20
|
+
*
|
|
21
|
+
* Usage (from the deploy scripts, detached): node scripts/deploy-swap.mjs --port 3847
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import fs from 'node:fs';
|
|
25
|
+
import os from 'node:os';
|
|
26
|
+
import path from 'node:path';
|
|
27
|
+
import { spawn, spawnSync } from 'node:child_process';
|
|
28
|
+
import { fileURLToPath } from 'node:url';
|
|
29
|
+
import { stopOldServer, waitForServerUp } from './deploy-procs.mjs';
|
|
30
|
+
|
|
31
|
+
const DEFAULT_PORT = 3847;
|
|
32
|
+
const READY_TIMEOUT_MS = 12_000;
|
|
33
|
+
const PORT_FREE_TIMEOUT_MS = 5_000;
|
|
34
|
+
const SWAP_LOG_CAP_BYTES = 256 * 1024;
|
|
35
|
+
|
|
36
|
+
const here = path.dirname(fileURLToPath(import.meta.url));
|
|
37
|
+
const repo = path.resolve(here, '..');
|
|
38
|
+
const serverDir = path.join(repo, 'server');
|
|
39
|
+
const logDir = path.join(os.homedir(), '.shipwright-webui');
|
|
40
|
+
const SERVER_LOG = path.join(logDir, 'server-manual.log');
|
|
41
|
+
const SWAP_LOG = path.join(logDir, 'deploy-swap.log');
|
|
42
|
+
const STATUS_FILE = path.join(logDir, 'deploy-status.json');
|
|
43
|
+
|
|
44
|
+
// --- Pure helpers (unit-tested in deploy-swap.test.mjs) --------------------
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Resolve the Hono port. Mirrors the callers' guards (the .ps1's `^\d{1,5}$`,
|
|
48
|
+
* which also keeps the cast under Int32.MaxValue, and the .sh's `${PORT:-3847}`)
|
|
49
|
+
* so caller and swapper can never disagree on the target.
|
|
50
|
+
*
|
|
51
|
+
* @param {Record<string, unknown>} [env]
|
|
52
|
+
* @returns {number}
|
|
53
|
+
*/
|
|
54
|
+
const isValidPort = (raw) => /^\d{1,5}$/.test(raw) && Number(raw) > 0;
|
|
55
|
+
|
|
56
|
+
export function resolvePort(env = {}) {
|
|
57
|
+
const raw = env?.PORT === undefined || env?.PORT === null ? '' : String(env.PORT);
|
|
58
|
+
return isValidPort(raw) ? Number(raw) : DEFAULT_PORT;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* `--port` (passed by the caller, which already resolved it for its own readiness
|
|
63
|
+
* poll and operator messages) wins over the environment. One rule, one resolved
|
|
64
|
+
* value in every sink; a malformed flag degrades instead of throwing.
|
|
65
|
+
*
|
|
66
|
+
* @param {string[]} [argv]
|
|
67
|
+
* @param {Record<string, unknown>} [env]
|
|
68
|
+
* @returns {{ port: number }}
|
|
69
|
+
*/
|
|
70
|
+
export function parseArgs(argv = [], env = {}) {
|
|
71
|
+
const i = argv.indexOf('--port');
|
|
72
|
+
const raw = i !== -1 && argv[i + 1] !== undefined ? String(argv[i + 1]) : '';
|
|
73
|
+
return { port: isValidPort(raw) ? Number(raw) : resolvePort(env) };
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* The durable record of the deploy outcome. Inside an embedded terminal the caller
|
|
78
|
+
* is killed mid-deploy, so it cannot be the one to report success or failure —
|
|
79
|
+
* that is precisely how the 2026-07-14 outage stayed invisible for hours.
|
|
80
|
+
*
|
|
81
|
+
* @param {{ok: unknown, port: unknown, pid?: unknown, ts: unknown, error?: unknown, readiness?: string}} o
|
|
82
|
+
*/
|
|
83
|
+
export function buildStatus({ ok, port, pid = null, ts, error = null, readiness = 'listener' }) {
|
|
84
|
+
return {
|
|
85
|
+
ok: Boolean(ok),
|
|
86
|
+
port: Number(port),
|
|
87
|
+
pid: pid === null || pid === undefined ? null : Number(pid),
|
|
88
|
+
ts: Number(ts),
|
|
89
|
+
error: error ?? null,
|
|
90
|
+
// How `ok` was established: 'listener' (the new child owns the port — the
|
|
91
|
+
// strong claim) or 'process-alive' (no lsof on this host; the child was
|
|
92
|
+
// merely still running). The caller reports the verdict, so it must be able
|
|
93
|
+
// to tell the two apart.
|
|
94
|
+
readiness,
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// --- Side-effecting steps --------------------------------------------------
|
|
99
|
+
|
|
100
|
+
function log(msg) {
|
|
101
|
+
const line = `[deploy-swap] ${new Date().toISOString()} ${msg}`;
|
|
102
|
+
console.log(line);
|
|
103
|
+
try {
|
|
104
|
+
fs.appendFileSync(SWAP_LOG, line + '\n');
|
|
105
|
+
} catch {
|
|
106
|
+
// The log is a convenience; never fail the deploy over it.
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** Last lines of the server log — the only thing that explains an EADDRINUSE or a crash on boot. */
|
|
111
|
+
function serverLogTail(lines = 12) {
|
|
112
|
+
try {
|
|
113
|
+
return fs
|
|
114
|
+
.readFileSync(SERVER_LOG, 'utf-8')
|
|
115
|
+
.split(/\r?\n/)
|
|
116
|
+
.filter(Boolean)
|
|
117
|
+
.slice(-lines)
|
|
118
|
+
.join(' | ');
|
|
119
|
+
} catch {
|
|
120
|
+
return '(no server log)';
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Open the server log — and NEVER let it block the deploy.
|
|
126
|
+
*
|
|
127
|
+
* `w` (truncate) matches the old `> $log`. But the file can be held by another
|
|
128
|
+
* instance: a second webui on a different PORT shares this path, and a server
|
|
129
|
+
* launched the old way (a `cmd` redirect) holds it with restrictive sharing, so
|
|
130
|
+
* openSync throws EBUSY. Losing the log is a nuisance; losing the SERVER because
|
|
131
|
+
* we could not open a log file would be the very outage this helper exists to
|
|
132
|
+
* prevent. Degrade: truncate -> append -> no log at all.
|
|
133
|
+
*/
|
|
134
|
+
function openServerLog() {
|
|
135
|
+
for (const flags of ['w', 'a']) {
|
|
136
|
+
try {
|
|
137
|
+
return fs.openSync(SERVER_LOG, flags);
|
|
138
|
+
} catch { /* held by another instance — try the next mode */ }
|
|
139
|
+
}
|
|
140
|
+
log(`WARNING: ${SERVER_LOG} is not writable — starting the server without a log`);
|
|
141
|
+
return 'ignore';
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** Start the freshly built server, detached, so it outlives THIS helper too. */
|
|
145
|
+
function startServer(port) {
|
|
146
|
+
const out = openServerLog();
|
|
147
|
+
const child = spawn(
|
|
148
|
+
process.execPath,
|
|
149
|
+
['--env-file-if-exists=../.env.local', 'dist/index.js'],
|
|
150
|
+
{
|
|
151
|
+
cwd: serverDir,
|
|
152
|
+
env: { ...process.env, PORT: String(port) },
|
|
153
|
+
detached: true,
|
|
154
|
+
stdio: ['ignore', out, out],
|
|
155
|
+
shell: false,
|
|
156
|
+
},
|
|
157
|
+
);
|
|
158
|
+
child.unref();
|
|
159
|
+
return child;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Post-restart ~/.claude.json heal. It used to sit in the CALLER — i.e. in code
|
|
164
|
+
* already dead by the time it matters, because the server-kill it waits on is the
|
|
165
|
+
* same kill that takes the caller down. Best effort: never gated on.
|
|
166
|
+
*/
|
|
167
|
+
function heal(label) {
|
|
168
|
+
try {
|
|
169
|
+
spawnSync(process.execPath, [path.join(here, 'repair-claude-json.mjs')], {
|
|
170
|
+
stdio: 'ignore',
|
|
171
|
+
});
|
|
172
|
+
log(`~/.claude.json integrity check done (${label})`);
|
|
173
|
+
} catch {
|
|
174
|
+
log(`~/.claude.json integrity check skipped (${label})`);
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function writeStatus(status) {
|
|
179
|
+
try {
|
|
180
|
+
fs.writeFileSync(STATUS_FILE, JSON.stringify(status, null, 2) + '\n');
|
|
181
|
+
} catch (e) {
|
|
182
|
+
log(`could not write ${STATUS_FILE}: ${String(e)}`);
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Keep the swapper's history, but bounded. server-manual.log is truncated on every
|
|
188
|
+
* deploy; this one is appended to (it must survive the restart that truncates the
|
|
189
|
+
* other), so without a cap it grows forever.
|
|
190
|
+
*/
|
|
191
|
+
function capSwapLog() {
|
|
192
|
+
try {
|
|
193
|
+
if (fs.statSync(SWAP_LOG).size <= SWAP_LOG_CAP_BYTES) return;
|
|
194
|
+
const kept = fs.readFileSync(SWAP_LOG, 'utf-8').slice(-SWAP_LOG_CAP_BYTES / 2);
|
|
195
|
+
fs.writeFileSync(SWAP_LOG, kept);
|
|
196
|
+
} catch { /* no log yet, or unreadable — nothing to cap */ }
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
async function main() {
|
|
200
|
+
fs.mkdirSync(logDir, { recursive: true });
|
|
201
|
+
capSwapLog();
|
|
202
|
+
// A stale status file from a PREVIOUS deploy must never be mistaken for this
|
|
203
|
+
// run's verdict by the (possibly still alive) caller.
|
|
204
|
+
fs.rmSync(STATUS_FILE, { force: true });
|
|
205
|
+
|
|
206
|
+
const { port } = parseArgs(process.argv.slice(2), process.env);
|
|
207
|
+
const finish = (ok, extra) => {
|
|
208
|
+
// The verdict is written BEFORE the (best-effort, possibly slow) heal, so a
|
|
209
|
+
// caller waiting on it is never blocked by a step that cannot change it.
|
|
210
|
+
writeStatus(buildStatus({ ok, port, ts: Date.now(), ...extra }));
|
|
211
|
+
log(ok ? `OK — ${extra.pid ? `server up on ${port} (pid ${extra.pid})` : 'done'}` : `FAILED — ${extra.error}`);
|
|
212
|
+
return ok;
|
|
213
|
+
};
|
|
214
|
+
|
|
215
|
+
log(`swap starting (port ${port}, pid ${process.pid})`);
|
|
216
|
+
|
|
217
|
+
// 1. Stop the old server. From here on the caller may die at any moment — this
|
|
218
|
+
// kill tears down the ConPTY that hosts it.
|
|
219
|
+
const { freed, killed, survivor } = await stopOldServer(port, {
|
|
220
|
+
timeoutMs: PORT_FREE_TIMEOUT_MS,
|
|
221
|
+
});
|
|
222
|
+
log(killed.length ? `stopped PID(s): ${killed.join(' ')}` : `nothing was listening on ${port}`);
|
|
223
|
+
|
|
224
|
+
if (!freed) {
|
|
225
|
+
// The port never came free. Starting now would only hit EADDRINUSE and leave
|
|
226
|
+
// the machine with NOTHING. The old server is still up — worse than a fresh
|
|
227
|
+
// one, far better than none. Say so loudly and stop here.
|
|
228
|
+
heal('kill-failed');
|
|
229
|
+
return finish(false, {
|
|
230
|
+
readiness: 'listener',
|
|
231
|
+
error:
|
|
232
|
+
`port ${port} is still held by PID ${survivor} after ${PORT_FREE_TIMEOUT_MS} ms — ` +
|
|
233
|
+
`the OLD server is still running and was NOT replaced (nothing new was started)`,
|
|
234
|
+
});
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// 2. Start the fresh build.
|
|
238
|
+
const child = startServer(port);
|
|
239
|
+
log(`started node dist/index.js (pid ${child.pid})`);
|
|
240
|
+
|
|
241
|
+
// 3. Confirm OUR child took the port (or, without lsof, that it is alive).
|
|
242
|
+
const { ok, readiness, exited, exitCode } = await waitForServerUp(port, child, {
|
|
243
|
+
timeoutMs: READY_TIMEOUT_MS,
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
// 4. Heal ~/.claude.json in the clean window: the old embedded sessions are
|
|
247
|
+
// dead and a UI reload has not spawned new ones yet.
|
|
248
|
+
heal('post-restart');
|
|
249
|
+
|
|
250
|
+
return finish(ok, {
|
|
251
|
+
pid: ok ? child.pid : null,
|
|
252
|
+
readiness,
|
|
253
|
+
error: ok
|
|
254
|
+
? null
|
|
255
|
+
: exited
|
|
256
|
+
? `the new server exited on startup (code ${exitCode}) — ${serverLogTail()}`
|
|
257
|
+
: `the new server did not take port ${port} within ${READY_TIMEOUT_MS} ms — ${serverLogTail()}`,
|
|
258
|
+
});
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
// Only run when invoked as a script — importing it (tests) must stay side-effect free.
|
|
262
|
+
if (process.argv[1] && path.resolve(process.argv[1]) === path.resolve(fileURLToPath(import.meta.url))) {
|
|
263
|
+
main()
|
|
264
|
+
.then((ok) => process.exit(ok ? 0 : 1))
|
|
265
|
+
.catch((e) => {
|
|
266
|
+
// A throw AFTER the kill (spawn EACCES, an unwritable log) would otherwise
|
|
267
|
+
// leave no server AND no status file — the failure mode that made the
|
|
268
|
+
// original outage invisible. The record survives even the unexpected.
|
|
269
|
+
try {
|
|
270
|
+
const { port } = parseArgs(process.argv.slice(2), process.env);
|
|
271
|
+
writeStatus(buildStatus({ ok: false, port, ts: Date.now(), error: `swap crashed: ${String(e)}` }));
|
|
272
|
+
} catch { /* nothing left to do */ }
|
|
273
|
+
log(`CRASHED — ${String(e)}`);
|
|
274
|
+
process.exit(1);
|
|
275
|
+
});
|
|
276
|
+
}
|