@askexenow/exe-os 0.9.166 → 0.9.167

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (244) hide show
  1. package/deploy/compose/backup.sh +45 -7
  2. package/deploy/compose/setup.sh +7 -0
  3. package/deploy/stack-manifests/v0.9.json +40 -1
  4. package/dist/{active-agent-R2KMWMR6.js → active-agent-DGTIJN2U.js} +2 -2
  5. package/dist/{active-agent-CYMM3QQA.js → active-agent-HVMLG6FH.js} +2 -2
  6. package/dist/{agentic-ontology-GKAKYNPE.js → agentic-ontology-S54AFODT.js} +1 -1
  7. package/dist/{backfill-metadata-Z5SYUWAV.js → backfill-metadata-74IWETRF.js} +4 -3
  8. package/dist/{behaviors-QGU6XI5R.js → behaviors-LZVAVHTC.js} +2 -2
  9. package/dist/bin/agentic-ontology-backfill.js +5 -4
  10. package/dist/bin/agentic-reflection-backfill.js +6 -5
  11. package/dist/bin/agentic-semantic-label.js +5 -4
  12. package/dist/bin/backfill-conversations.js +5 -4
  13. package/dist/bin/backfill-responses.js +5 -4
  14. package/dist/bin/backfill-vectors.js +6 -5
  15. package/dist/bin/bulk-sync-postgres.js +6 -5
  16. package/dist/bin/cleanup-stale-review-tasks.js +9 -9
  17. package/dist/bin/cli.js +16 -13
  18. package/dist/bin/daily-summary.js +0 -217
  19. package/dist/bin/deferred-daemon-restart.js +8 -0
  20. package/dist/bin/exe-agent-config.js +1 -1
  21. package/dist/bin/exe-agent.js +10 -10
  22. package/dist/bin/exe-assign.js +7 -6
  23. package/dist/bin/exe-boot.js +20 -19
  24. package/dist/bin/exe-call.js +4 -4
  25. package/dist/bin/exe-cloud.js +3 -3
  26. package/dist/bin/exe-dispatch.js +9 -9
  27. package/dist/bin/exe-doctor.js +1 -1
  28. package/dist/bin/exe-export-behaviors.js +7 -6
  29. package/dist/bin/exe-forget.js +6 -5
  30. package/dist/bin/exe-gateway.js +5 -5
  31. package/dist/bin/exe-heartbeat.js +9 -9
  32. package/dist/bin/exe-kill.js +13 -12
  33. package/dist/bin/exe-launch-agent.js +11 -10
  34. package/dist/bin/exe-new-employee.js +6 -6
  35. package/dist/bin/exe-pending-messages.js +10 -10
  36. package/dist/bin/exe-pending-notifications.js +9 -9
  37. package/dist/bin/exe-pending-reviews.js +9 -9
  38. package/dist/bin/exe-rename.js +4 -4
  39. package/dist/bin/exe-review.js +12 -11
  40. package/dist/bin/exe-search.js +5 -4
  41. package/dist/bin/exe-session-cleanup.js +19 -14
  42. package/dist/bin/exe-settings.js +3 -3
  43. package/dist/bin/exe-start-codex.js +11 -10
  44. package/dist/bin/exe-start-opencode.js +8 -7
  45. package/dist/bin/exe-status.js +10 -10
  46. package/dist/bin/exe-team.js +2 -2
  47. package/dist/bin/git-sweep.js +9 -9
  48. package/dist/bin/graph-backfill.js +4 -3
  49. package/dist/bin/graph-export.js +5 -4
  50. package/dist/bin/import-history.js +171 -0
  51. package/dist/bin/install-launchd.js +41 -0
  52. package/dist/bin/install.js +50 -74
  53. package/dist/bin/intercom-check.js +4 -4
  54. package/dist/bin/postgres-agentic-reflection-backfill.js +2 -2
  55. package/dist/bin/postgres-agentic-semantic-backfill.js +4 -4
  56. package/dist/bin/pre-publish.js +1 -1
  57. package/dist/bin/scan-tasks.js +22 -12
  58. package/dist/bin/setup.js +1 -1
  59. package/dist/bin/shard-migrate.js +4 -3
  60. package/dist/bin/stack-update.js +61 -857
  61. package/dist/bin/vps-backup.js +170 -0
  62. package/dist/bin/vps-health-gate.js +232 -0
  63. package/dist/{capacity-monitor-ZEAE4WP2.js → capacity-monitor-JBZB2S4P.js} +10 -10
  64. package/dist/{catchup-brief-OGWCHENC.js → catchup-brief-HE2EMZS5.js} +12 -11
  65. package/dist/{chunk-DJJNB47C.js → chunk-27DO3EZO.js} +1 -1
  66. package/dist/{chunk-45FYZIHI.js → chunk-32YUET3Y.js} +2 -2
  67. package/dist/{chunk-Y75ECPO5.js → chunk-3FW5LUGI.js} +2 -2
  68. package/dist/{chunk-4OZGQZ4U.js → chunk-3M3O56VT.js} +636 -179
  69. package/dist/{chunk-77WQOD6J.js → chunk-4CXUZ4NI.js} +2 -2
  70. package/dist/{chunk-PBXWPHEK.js → chunk-4VEHJZ6R.js} +1 -1
  71. package/dist/{chunk-TH22QIEC.js → chunk-6A4COFDG.js} +1 -1
  72. package/dist/{chunk-ACBTCC2L.js → chunk-7OJH2A6I.js} +1 -1
  73. package/dist/{chunk-NHCOTCI6.js → chunk-A7SGEBXJ.js} +2 -2
  74. package/dist/{chunk-5MPQSNZF.js → chunk-AUTCT6AY.js} +1 -1
  75. package/dist/{chunk-OEKSTOTE.js → chunk-AZAZ2C75.js} +1 -1
  76. package/dist/chunk-CHCA3ZM2.js +167 -0
  77. package/dist/{chunk-X347L57O.js → chunk-CSTJQDOE.js} +4 -3
  78. package/dist/{chunk-B234R3VW.js → chunk-D7WLV6WD.js} +2 -2
  79. package/dist/{chunk-GMXF3AHJ.js → chunk-DGAONW36.js} +1 -1
  80. package/dist/chunk-EAT5YL3W.js +229 -0
  81. package/dist/{chunk-OD4H5YCJ.js → chunk-EKTQE2R5.js} +8 -8
  82. package/dist/{chunk-Z44PC42G.js → chunk-ELUBA7XL.js} +2 -2
  83. package/dist/{chunk-ZWS6XQER.js → chunk-F5AKOE4P.js} +7 -7
  84. package/dist/{chunk-T5YULDDO.js → chunk-FVI4UBKO.js} +27 -4
  85. package/dist/{chunk-ESRI7MFI.js → chunk-GAN7PW6G.js} +28 -24
  86. package/dist/{chunk-K4OWYJSP.js → chunk-GM2WZTG3.js} +2 -2
  87. package/dist/{chunk-TAB5QGIK.js → chunk-GZYQTPTF.js} +3 -3
  88. package/dist/{chunk-CXDU5DE3.js → chunk-IAUNGATJ.js} +1 -1
  89. package/dist/{chunk-YS63NS6M.js → chunk-IHSM5GR4.js} +1 -1
  90. package/dist/{chunk-23PTS2ZD.js → chunk-IP7KJAUW.js} +117 -15
  91. package/dist/{chunk-D6IMJAV2.js → chunk-J64P2LB2.js} +2 -2
  92. package/dist/{chunk-CXAVSQZM.js → chunk-JXMSCKRM.js} +1 -1
  93. package/dist/{chunk-RQMK3IQH.js → chunk-K4OTJP6N.js} +14 -7
  94. package/dist/{chunk-L7ROZR2H.js → chunk-KXAUMIOX.js} +1 -1
  95. package/dist/{chunk-TPC3LAP7.js → chunk-LGY2BIOT.js} +13 -0
  96. package/dist/{chunk-RPIDSBK7.js → chunk-LLHRJEE4.js} +3 -3
  97. package/dist/{chunk-6WG2VIKC.js → chunk-LM7H6XU4.js} +1 -1
  98. package/dist/{chunk-Y6GMKZZ2.js → chunk-LOFFGJSY.js} +150 -23
  99. package/dist/{chunk-W7SDGBEC.js → chunk-MFI5OXYW.js} +52 -84
  100. package/dist/{chunk-KNPEVPYG.js → chunk-MSSQWF6X.js} +2 -2
  101. package/dist/{chunk-QIQAO3VG.js → chunk-NEFFFKMD.js} +3 -3
  102. package/dist/{chunk-YUC552KZ.js → chunk-NEHONJJC.js} +3 -3
  103. package/dist/{chunk-KZ7SXZ2V.js → chunk-NFMQRLCD.js} +1 -1
  104. package/dist/{chunk-52HCNDPG.js → chunk-O4TATDOV.js} +1 -1
  105. package/dist/{chunk-AR3OYGLB.js → chunk-PEFBRL4S.js} +28 -6
  106. package/dist/{chunk-AEUXUEJG.js → chunk-PEXVU3HU.js} +5 -3
  107. package/dist/chunk-Q2G5C3HV.js +217 -0
  108. package/dist/{chunk-KOO56JVC.js → chunk-Q6N6LDEJ.js} +1 -1
  109. package/dist/{chunk-TXSJ2L5O.js → chunk-QI4IXJN7.js} +1 -1
  110. package/dist/{chunk-HLVQ5Y7B.js → chunk-RE4VLK45.js} +1 -1
  111. package/dist/{chunk-TF6SZGDT.js → chunk-SA2PH6WY.js} +1 -1
  112. package/dist/{chunk-5RSYY7BE.js → chunk-SJYOPYXH.js} +117 -9
  113. package/dist/{chunk-PJGHBANY.js → chunk-TTJE7CCU.js} +1 -1
  114. package/dist/{chunk-A7KEWR6S.js → chunk-TXWQPL2U.js} +1 -1
  115. package/dist/{chunk-XXSJ35J5.js → chunk-U5ZH52FB.js} +2 -2
  116. package/dist/{chunk-G4FDG3LK.js → chunk-UVNDLF74.js} +63 -40
  117. package/dist/{chunk-5OD3AFRW.js → chunk-V6RCZ25F.js} +1 -1
  118. package/dist/{chunk-LHMBIFKD.js → chunk-VYNNN2S3.js} +4 -4
  119. package/dist/chunk-WCYT54XP.js +934 -0
  120. package/dist/{chunk-5AMSQRHT.js → chunk-XGYSTVUH.js} +1 -1
  121. package/dist/{chunk-MKZBHM6A.js → chunk-XLWF3C4R.js} +4 -4
  122. package/dist/{chunk-YL36L2SN.js → chunk-Y7YHLV57.js} +1 -1
  123. package/dist/{chunk-HZC4MR4H.js → chunk-YBKB2PXY.js} +1 -1
  124. package/dist/{chunk-NWM3A4TK.js → chunk-ZDNLKXZA.js} +1 -1
  125. package/dist/{chunk-O7KW6QMH.js → chunk-ZW4TKQUM.js} +15 -5
  126. package/dist/{chunk-6BURHBE6.js → chunk-ZXB44R3E.js} +32 -11
  127. package/dist/co-occurrence-WCED475N.js +73 -0
  128. package/dist/{code-context-index-B6VIWPSF.js → code-context-index-LSZ3DKTJ.js} +2 -2
  129. package/dist/{crdt-sync-XA22KI3S.js → crdt-sync-PBXZTHZC.js} +1 -1
  130. package/dist/{crm-webhook-CIZNOEY4.js → crm-webhook-W7Q25VZU.js} +2 -2
  131. package/dist/{cto-delegation-gate-H5IULFRC.js → cto-delegation-gate-JKULOLMC.js} +8 -8
  132. package/dist/{daemon-orchestration-VO5XQIJL.js → daemon-orchestration-CHV6MB42.js} +13 -11
  133. package/dist/{exe-drift-DMT75WR3.js → exe-drift-PW36OULT.js} +2 -2
  134. package/dist/{exe-export-2RZWOSX6.js → exe-export-XQOD3KE6.js} +6 -5
  135. package/dist/{exe-import-NFNYATHL.js → exe-import-QOFP67LW.js} +6 -5
  136. package/dist/{exe-key-4D7CF3BU.js → exe-key-WQ34UZR6.js} +1 -1
  137. package/dist/{fast-db-init-LAEISZQ2.js → fast-db-init-UKETGWQI.js} +1 -1
  138. package/dist/gateway/index.js +6 -6
  139. package/dist/{git-staleness-M46AYLPP.js → git-staleness-ATV5CGAP.js} +1 -1
  140. package/dist/{git-task-sweep-PXOS56YT.js → git-task-sweep-KXZRIP4T.js} +9 -9
  141. package/dist/{global-procedures-KROQQX54.js → global-procedures-G6IKCYKM.js} +3 -3
  142. package/dist/{graph-auto-extract-QJ2BBJM2.js → graph-auto-extract-ZJXJOLE2.js} +1 -1
  143. package/dist/hooks/bug-report-worker.js +10 -10
  144. package/dist/hooks/codex-stop-task-finalizer.js +10 -10
  145. package/dist/hooks/commit-complete.js +11 -11
  146. package/dist/hooks/error-recall.js +8 -7
  147. package/dist/hooks/exe-heartbeat-hook.js +2 -2
  148. package/dist/hooks/ingest-worker.js +3 -3
  149. package/dist/hooks/ingest.js +9 -9
  150. package/dist/hooks/instructions-loaded.js +3 -3
  151. package/dist/hooks/notification.js +3 -3
  152. package/dist/hooks/post-compact.js +10 -10
  153. package/dist/hooks/post-tool-combined.js +5 -5
  154. package/dist/hooks/pre-compact.js +16 -16
  155. package/dist/hooks/pre-tool-use.js +14 -14
  156. package/dist/hooks/prompt-submit.js +30 -29
  157. package/dist/hooks/session-end.js +46 -25
  158. package/dist/hooks/session-start.js +48 -10
  159. package/dist/hooks/stop.js +17 -17
  160. package/dist/hooks/subagent-stop.js +10 -10
  161. package/dist/hooks/summary-worker.js +17 -16
  162. package/dist/index.js +17 -17
  163. package/dist/{installer-SDBLJBAB.js → installer-DE2LH5EC.js} +4 -4
  164. package/dist/{installer-ZA6QNQ4P.js → installer-M2MDS7HC.js} +4 -4
  165. package/dist/{installer-6KAY6LD6.js → installer-VE23YFXU.js} +4 -4
  166. package/dist/{intercom-queue-K3DVKSPJ.js → intercom-queue-RNM6EPGA.js} +1 -1
  167. package/dist/keyword-extractor-UJHFWVZE.js +11 -0
  168. package/dist/lib/cloud-sync.js +3 -3
  169. package/dist/lib/consolidation.js +5 -4
  170. package/dist/lib/database.js +1 -1
  171. package/dist/lib/db-daemon-client.js +1 -1
  172. package/dist/lib/db.js +1 -1
  173. package/dist/lib/embed-worker.js +98 -0
  174. package/dist/lib/embedder.js +2 -2
  175. package/dist/lib/employee-templates.js +4 -4
  176. package/dist/lib/employees.js +1 -1
  177. package/dist/lib/exe-daemon-client.js +1 -1
  178. package/dist/lib/exe-daemon.js +523 -500
  179. package/dist/lib/hybrid-search.js +5 -6
  180. package/dist/lib/identity.js +1 -1
  181. package/dist/lib/messaging.js +9 -9
  182. package/dist/lib/reminders.js +2 -2
  183. package/dist/lib/schedules.js +5 -4
  184. package/dist/lib/skill-learning.js +3 -3
  185. package/dist/lib/store.js +4 -3
  186. package/dist/lib/task-router.js +2 -2
  187. package/dist/lib/tasks.js +9 -9
  188. package/dist/lib/tmux-routing.js +8 -8
  189. package/dist/lib/tmux-transport.js +1 -1
  190. package/dist/lib/token-spend.js +2 -2
  191. package/dist/lib/transport.js +2 -2
  192. package/dist/lib/ws-client.js +3 -1
  193. package/dist/mcp/register-tools.js +54 -51
  194. package/dist/mcp/server.js +58 -55
  195. package/dist/mcp/tools/complete-reminder.js +3 -3
  196. package/dist/mcp/tools/create-reminder.js +3 -3
  197. package/dist/mcp/tools/create-task.js +11 -11
  198. package/dist/mcp/tools/deactivate-behavior.js +4 -4
  199. package/dist/mcp/tools/list-reminders.js +3 -3
  200. package/dist/mcp/tools/list-tasks.js +11 -11
  201. package/dist/mcp/tools/send-message.js +11 -11
  202. package/dist/mcp/tools/update-task.js +10 -10
  203. package/dist/{mcp-http-config-LK2EDOEJ.js → mcp-http-config-Z2E4VUOF.js} +2 -2
  204. package/dist/{memory-cards-V3DKSRWL.js → memory-cards-SFDKDIAW.js} +1 -1
  205. package/dist/memory-graph-extractor-YD4GNH7T.js +16 -0
  206. package/dist/{memory-poisoning-defense-3B75HS74.js → memory-poisoning-defense-VEGNFELN.js} +1 -1
  207. package/dist/{memory-queue-client-LFPZPPQA.js → memory-queue-client-5HB2XUH7.js} +2 -2
  208. package/dist/{memory-reflection-HTDAUUE5.js → memory-reflection-MTPRQNI6.js} +2 -2
  209. package/dist/{notifications-76VCYXWW.js → notifications-6TCE6OBG.js} +8 -8
  210. package/dist/{orchestrator-CBNSBI5P.js → orchestrator-W2GYJR23.js} +10 -10
  211. package/dist/{plan-limits-SOR3QXKV.js → plan-limits-4EP46323.js} +2 -2
  212. package/dist/{projection-worker-FK5YOEIL.js → projection-worker-EBUYNMU2.js} +1 -1
  213. package/dist/{review-polling-ZLNDUKL4.js → review-polling-2N7KQFZZ.js} +9 -9
  214. package/dist/runtime/index.js +15 -15
  215. package/dist/{session-events-CUSPL25D.js → session-events-K47FHAXJ.js} +9 -9
  216. package/dist/{session-kill-telemetry-FLBRHBDP.js → session-kill-telemetry-275YUXM5.js} +2 -2
  217. package/dist/{session-scope-PX2ABSJO.js → session-scope-XSFJZEER.js} +8 -8
  218. package/dist/{setup-wizard-Y6PBZGFX.js → setup-wizard-UEO7HYLQ.js} +1 -1
  219. package/dist/{skill-refinement-L7PGKCYO.js → skill-refinement-WXBTANDQ.js} +1 -1
  220. package/dist/stack-update-2B2UXREV.js +50 -0
  221. package/dist/{task-enforcement-7FUILB63.js → task-enforcement-2JIJSXPU.js} +14 -16
  222. package/dist/{task-scope-2N45TE32.js → task-scope-W73Z3XWE.js} +8 -8
  223. package/dist/{tasks-crud-ADLCGHGH.js → tasks-crud-HPJKI3QQ.js} +8 -8
  224. package/dist/{tasks-review-PJ2DUI6N.js → tasks-review-MXLMPGNZ.js} +8 -8
  225. package/dist/{token-budget-T5DFXVTM.js → token-budget-BA46CVHX.js} +1 -1
  226. package/dist/{tool-capability-index-6JJN6ZRC.js → tool-capability-index-42VVN5BS.js} +1 -1
  227. package/dist/{tool-telemetry-72PVO5HV.js → tool-telemetry-GZ5E2AUL.js} +1 -1
  228. package/dist/tui/App.js +22 -18
  229. package/dist/{tui-data-63JHE6EZ.js → tui-data-PVXWQCJX.js} +8 -8
  230. package/dist/{worker-gate-REVBJUZ6.js → worker-gate-WTTK64TK.js} +1 -1
  231. package/dist/{workflow-engine-W2WNHJG5.js → workflow-engine-LT3WTT7V.js} +2 -2
  232. package/package.json +1 -1
  233. package/release-notes.json +209 -209
  234. /package/dist/{chunk-BNOZUS6J.js → chunk-6VVCAVRT.js} +0 -0
  235. /package/dist/{chunk-IC7GKK6I.js → chunk-CWQZZ7X3.js} +0 -0
  236. /package/dist/{chunk-ZI2ZVERO.js → chunk-EIW5GOBW.js} +0 -0
  237. /package/dist/{chunk-2BGGDNRD.js → chunk-IPPJEM26.js} +0 -0
  238. /package/dist/{chunk-4ISDU5KR.js → chunk-K5UR73PM.js} +0 -0
  239. /package/dist/{chunk-ZWRTVUQ6.js → chunk-KIMO5S45.js} +0 -0
  240. /package/dist/{chunk-S2FX5KJ4.js → chunk-WBLILGAP.js} +0 -0
  241. /package/dist/{core-memory-PCJ3L46L.js → core-memory-RAC6M67J.js} +0 -0
  242. /package/dist/{entity-boost-GHFPE6A2.js → entity-boost-5FIRFRDC.js} +0 -0
  243. /package/dist/{message-queue-client-CHRQYBH5.js → message-queue-client-PTQ2S7D7.js} +0 -0
  244. /package/dist/{wiki-acl-QYRAYYVQ.js → wiki-acl-MSDRCIAI.js} +0 -0
@@ -1,7 +1,18 @@
1
1
  #!/usr/bin/env node
2
2
  import {
3
- loadLicense
4
- } from "../chunk-MVMMULOJ.js";
3
+ assertDeploymentScopeAllowed,
4
+ assertHostReadyForApply,
5
+ assertProductionDeployGate,
6
+ bootstrapStackHost,
7
+ createStackUpdatePlan,
8
+ defaultStackPaths,
9
+ listAvailableVersions,
10
+ loadStackManifest,
11
+ patchEnv,
12
+ readCurrentStackVersion,
13
+ runStackUpdate
14
+ } from "../chunk-WCYT54XP.js";
15
+ import "../chunk-MVMMULOJ.js";
5
16
  import "../chunk-4GXRETYL.js";
6
17
  import "../chunk-LYH5HE24.js";
7
18
  import {
@@ -10,849 +21,9 @@ import {
10
21
  import "../chunk-MLKGABMK.js";
11
22
 
12
23
  // src/bin/stack-update.ts
13
- import { readFileSync as readFileSync2 } from "fs";
14
- import { spawnSync as spawnSync2 } from "child_process";
15
- import path2 from "path";
16
-
17
- // src/lib/stack-update.ts
18
- import { execFileSync, spawnSync } from "child_process";
19
- import { createVerify, randomBytes, verify as verifySignature } from "crypto";
20
- import { copyFileSync, existsSync, mkdirSync, readdirSync, readFileSync, renameSync, writeFileSync } from "fs";
21
- import http from "http";
22
- import https from "https";
24
+ import { readFileSync } from "fs";
25
+ import { spawnSync } from "child_process";
23
26
  import path from "path";
24
- import { fileURLToPath } from "url";
25
- function isSignedEnvelope(value) {
26
- return !!value && typeof value === "object" && "manifest" in value && "signature" in value;
27
- }
28
- function canonicalizeStackManifest(manifest) {
29
- const clone = JSON.parse(JSON.stringify(manifest));
30
- delete clone.signature;
31
- return stableJson(clone);
32
- }
33
- function verifyStackManifestSignature(manifest, publicKeyPem) {
34
- const signature = manifest.signature;
35
- if (!signature) throw new Error("Stack manifest signature required but missing");
36
- const payload = Buffer.from(canonicalizeStackManifest(manifest));
37
- const sig = Buffer.from(signature.signature, "base64");
38
- let ok = false;
39
- if (signature.alg === "ed25519") {
40
- ok = verifySignature(null, payload, publicKeyPem, sig);
41
- } else if (signature.alg === "rsa-sha256") {
42
- const verifier = createVerify("RSA-SHA256");
43
- verifier.update(payload);
44
- verifier.end();
45
- ok = verifier.verify(publicKeyPem, sig);
46
- } else {
47
- throw new Error(`Unsupported stack manifest signature alg: ${signature.alg}`);
48
- }
49
- if (!ok) throw new Error("Stack manifest signature verification failed");
50
- }
51
- function stableJson(value) {
52
- if (value === null || typeof value !== "object") return JSON.stringify(value);
53
- if (Array.isArray(value)) return `[${value.map(stableJson).join(",")}]`;
54
- const obj = value;
55
- return `{${Object.keys(obj).sort().map((key) => `${JSON.stringify(key)}:${stableJson(obj[key])}`).join(",")}}`;
56
- }
57
- function findLatestBackupEnvFile(envFile) {
58
- const backupDir = path.join(path.dirname(envFile), ".exe-stack-backups");
59
- if (!existsSync(backupDir)) return null;
60
- const backups = readdirSync(backupDir).filter((name) => name.startsWith("env-") && name.endsWith(".bak")).sort();
61
- const latest = backups.at(-1);
62
- return latest ? path.join(backupDir, latest) : null;
63
- }
64
- async function rollbackStackUpdate(options) {
65
- const exec = options.exec ?? defaultExec;
66
- const backupEnvFile = options.lockFile && existsSync(options.lockFile) ? JSON.parse(readFileSync(options.lockFile, "utf8")).backupEnvFile : void 0;
67
- const rollbackEnv = backupEnvFile && existsSync(backupEnvFile) ? backupEnvFile : findLatestBackupEnvFile(options.envFile);
68
- if (!rollbackEnv) throw new Error(`No stack backup env found beside ${options.envFile}`);
69
- const preRollbackBackup = options.envFile + `.pre-rollback-${Date.now()}`;
70
- try {
71
- if (existsSync(options.envFile)) copyFileSync(options.envFile, preRollbackBackup);
72
- } catch {
73
- }
74
- writeFileSync(options.envFile, readFileSync(rollbackEnv), { mode: 384 });
75
- const composeArgs = ["compose", "--file", options.composeFile, "--env-file", options.envFile];
76
- exec("docker", [...composeArgs, "up", "-d"]);
77
- return { status: "rolled_back", targetVersion: "previous", changes: [], backupEnvFile: rollbackEnv, lockFile: options.lockFile ?? path.join(path.dirname(options.envFile), ".exe-stack-lock.json") };
78
- }
79
- function parseStackManifest(raw, publicKey) {
80
- const parsedRaw = JSON.parse(raw);
81
- const parsed = isSignedEnvelope(parsedRaw) ? { ...parsedRaw.manifest, signature: parsedRaw.signature } : parsedRaw;
82
- if (publicKey) verifyStackManifestSignature(parsed, publicKey);
83
- if (parsed.schemaVersion !== 1) throw new Error("Unsupported stack manifest schemaVersion");
84
- if (!parsed.latest || !parsed.stacks || typeof parsed.stacks !== "object") {
85
- throw new Error("Invalid stack manifest: latest and stacks are required");
86
- }
87
- for (const [version, release] of Object.entries(parsed.stacks)) {
88
- if (!release.version) release.version = version;
89
- if (!release.services || typeof release.services !== "object") {
90
- throw new Error(`Invalid stack manifest: release ${version} has no services`);
91
- }
92
- for (const [serviceName, service] of Object.entries(release.services)) {
93
- if (!service.image || !service.env) {
94
- throw new Error(`Invalid stack manifest: ${version}.${serviceName} requires image and env`);
95
- }
96
- }
97
- }
98
- return parsed;
99
- }
100
- async function loadStackManifest(ref, fetchText = defaultFetchText, publicKey, authToken) {
101
- if (/^https?:\/\//.test(ref)) return parseStackManifest(await fetchTextWithAuth(ref, fetchText, authToken), publicKey);
102
- return parseStackManifest(readFileSync(ref, "utf8"), publicKey);
103
- }
104
- async function fetchTextWithAuth(ref, fetchText, authToken) {
105
- if (!authToken || fetchText !== defaultFetchText) return fetchText(ref);
106
- return defaultFetchText(ref, authToken);
107
- }
108
- function parseEnv(raw) {
109
- const env = /* @__PURE__ */ new Map();
110
- for (const line of raw.split(/\r?\n/)) {
111
- const trimmed = line.trim();
112
- if (!trimmed || trimmed.startsWith("#")) continue;
113
- const idx = line.indexOf("=");
114
- if (idx <= 0) continue;
115
- env.set(line.slice(0, idx).trim(), line.slice(idx + 1));
116
- }
117
- return env;
118
- }
119
- function patchEnv(raw, updates) {
120
- const seen = /* @__PURE__ */ new Set();
121
- const lines = raw.replace(/\n$/, "").split(/\r?\n/);
122
- const patched = lines.map((line) => {
123
- const idx = line.indexOf("=");
124
- if (idx <= 0 || line.trim().startsWith("#")) return line;
125
- const key = line.slice(0, idx).trim();
126
- if (!(key in updates)) return line;
127
- seen.add(key);
128
- return `${key}=${updates[key]}`;
129
- });
130
- for (const [key, value] of Object.entries(updates)) {
131
- if (!seen.has(key)) patched.push(`${key}=${value}`);
132
- }
133
- return patched.join("\n").replace(/\n*$/, "\n");
134
- }
135
- function createStackUpdatePlan(manifest, envRaw, targetVersion) {
136
- const version = targetVersion ?? manifest.latest;
137
- const release = manifest.stacks[version];
138
- if (!release) throw new Error(`Stack version ${version} not found in manifest`);
139
- const env = parseEnv(envRaw);
140
- const changes = [];
141
- for (const [serviceName, service] of Object.entries(release.services)) {
142
- const before = env.get(service.env);
143
- if (before !== service.image) {
144
- changes.push({ key: service.env, before, after: service.image, service: serviceName });
145
- }
146
- }
147
- return {
148
- manifest,
149
- release,
150
- targetVersion: version,
151
- changes,
152
- breakingChanges: release.breakingChanges ?? []
153
- };
154
- }
155
- var ASKEXE_GHCR_IMAGE = /^(?:ghcr\.io\/askexe|registry\.askexe\.com\/askexe)\/[a-z0-9._/-]+(?::[^:@$/{]+|@sha256:[a-f0-9]{64})$/i;
156
- function validatePinnedGhcrImage(image, label) {
157
- const trimmed = image.trim().replace(/^['"]|['"]$/g, "");
158
- if (!trimmed) return `${label} is empty`;
159
- if (trimmed.includes("${")) return null;
160
- if (!trimmed.startsWith("ghcr.io/askexe/") && !trimmed.startsWith("registry.askexe.com/askexe/")) return `${label} must use ghcr.io/askexe/* or registry.askexe.com/askexe/*, got ${trimmed}`;
161
- if (/:latest(?:$|[\s#])/.test(trimmed)) return `${label} must not use :latest (${trimmed})`;
162
- if (!ASKEXE_GHCR_IMAGE.test(trimmed)) return `${label} must be pinned with an explicit tag or sha256 digest from ghcr.io/askexe or registry.askexe.com/askexe, got ${trimmed}`;
163
- return null;
164
- }
165
- function validateComposeImageLiteral(image, label) {
166
- const trimmed = image.trim().replace(/^['"]|['"]$/g, "");
167
- if (!trimmed) return `${label} is empty`;
168
- if (trimmed.startsWith("ghcr.io/askexe/") || trimmed.startsWith("registry.askexe.com/askexe/")) return validatePinnedGhcrImage(trimmed, label);
169
- if (/^(postgres|pgvector\/pgvector|clickhouse\/clickhouse-server|redis|nginx|postgrest\/postgrest|supabase\/gotrue):[^:]+$/i.test(trimmed)) return null;
170
- return `${label} uses unsupported non-AskExe image ${trimmed}; customer app images must come from pinned ghcr.io/askexe images`;
171
- }
172
- function collectProductionDeployGateIssues(plan, envRaw, composeRaw) {
173
- const issues = [];
174
- const env = parseEnv(envRaw);
175
- for (const [serviceName, service] of Object.entries(plan.release.services)) {
176
- const manifestIssue = validatePinnedGhcrImage(service.image, `manifest ${plan.targetVersion}.${serviceName}.image`);
177
- if (manifestIssue) issues.push({ kind: "manifest-image", message: manifestIssue });
178
- const envImage = env.get(service.env);
179
- if (envImage) {
180
- const envIssue = validatePinnedGhcrImage(envImage, `env ${service.env}`);
181
- if (envIssue) issues.push({ kind: "env-image", message: envIssue });
182
- }
183
- }
184
- const lines = composeRaw.split(/\r?\n/);
185
- lines.forEach((line, index) => {
186
- if (/^\s*build\s*:/.test(line)) {
187
- issues.push({ kind: "compose-build", message: `compose line ${index + 1} contains build:, production deploys must pull images` });
188
- }
189
- const imageMatch = line.match(/^\s*image\s*:\s*(.+?)\s*(?:#.*)?$/);
190
- if (imageMatch) {
191
- const image = imageMatch[1].trim();
192
- if (image.includes("${")) {
193
- const fallback = image.match(/:-([^}]+)}/)?.[1];
194
- if (fallback) {
195
- const composeIssue = validateComposeImageLiteral(fallback, `compose image fallback on line ${index + 1}`);
196
- if (composeIssue) issues.push({ kind: "compose-image", message: composeIssue });
197
- }
198
- } else {
199
- const composeIssue = validateComposeImageLiteral(image, `compose image on line ${index + 1}`);
200
- if (composeIssue) issues.push({ kind: "compose-image", message: composeIssue });
201
- }
202
- }
203
- });
204
- return issues;
205
- }
206
- function assertDeploymentScopeAllowed(plan, persona = "customer") {
207
- if (persona === "askexe-control-plane") return;
208
- const blocked = Object.entries(plan.release.services).filter(([, service]) => service.deploymentScope === "askexe-control-plane").map(([name]) => name);
209
- if (blocked.length > 0) {
210
- throw new Error(
211
- `Customer deployment manifest includes AskExe control-plane service(s): ${blocked.join(", ")}. Customer VPSs may deploy customer services and optional agents only.`
212
- );
213
- }
214
- }
215
- function assertProductionDeployGate(plan, envRaw, composeRaw, options = {}) {
216
- const issues = collectProductionDeployGateIssues(plan, envRaw, composeRaw);
217
- if (issues.length === 0) return;
218
- if (options.breakGlassReason?.trim()) {
219
- writeBreakGlassAudit(plan, issues, options);
220
- return;
221
- }
222
- const details = issues.map((issue) => `- [${issue.kind}] ${issue.message}`).join("\n");
223
- throw new Error(
224
- `Production deploy gate failed. Exe OS deploys must use pinned ghcr.io/askexe or registry.askexe.com/askexe images and must not build from source on the VPS.
225
- ${details}
226
- Emergency override requires --break-glass <reason> and writes an audit file.`
227
- );
228
- }
229
- function writeBreakGlassAudit(plan, issues, options) {
230
- const now = options.now ?? (() => /* @__PURE__ */ new Date());
231
- const stamp = now().toISOString().replace(/[:.]/g, "-");
232
- const defaultDir = existsSync("exe/output") ? "exe/output" : path.dirname(options.envFile ?? ".");
233
- const auditFile = options.breakGlassAuditFile ?? path.join(defaultDir, `stack-update-break-glass-${stamp}.md`);
234
- mkdirSync(path.dirname(auditFile), { recursive: true });
235
- const body = [
236
- `# Stack Update Break-Glass Audit \u2014 ${now().toISOString()}`,
237
- "",
238
- `Target version: ${plan.targetVersion}`,
239
- `Reason: ${options.breakGlassReason?.trim()}`,
240
- "",
241
- "## Gate failures overridden",
242
- ...issues.map((issue) => `- [${issue.kind}] ${issue.message}`),
243
- "",
244
- "## Required follow-up",
245
- "Return this deployment to the standard pinned GHCR image path immediately after the emergency is resolved.",
246
- ""
247
- ].join("\n");
248
- writeFileSync(auditFile, body, { mode: 384 });
249
- console.warn(`[stack-update] BREAK-GLASS deploy override recorded: ${auditFile}`);
250
- }
251
- function assertBreakingChangesAllowed(plan, allowedIds) {
252
- const required = plan.breakingChanges.filter((c) => c.requiresConfirmation !== false);
253
- const missing = required.filter((c) => !allowedIds.includes(c.id));
254
- if (missing.length > 0) {
255
- const details = missing.map((c) => `- ${c.id}: ${c.title}
256
- ${c.description}
257
- Action: ${c.requiredAction ?? "Review release notes."}`).join("\n");
258
- throw new Error(
259
- `Stack ${plan.targetVersion} has breaking changes that require confirmation:
260
- ${details}
261
- Re-run with --allow-breaking ${missing.map((c) => c.id).join(",")}`
262
- );
263
- }
264
- }
265
- function commandSucceeds(cmd, args = []) {
266
- const res = spawnSync(cmd, args, { stdio: "ignore" });
267
- return res.status === 0;
268
- }
269
- function shellSucceeds(command) {
270
- const res = spawnSync("sh", ["-lc", command], { stdio: "ignore" });
271
- return res.status === 0;
272
- }
273
- function resolvePackageRoot() {
274
- const here = path.dirname(fileURLToPath(import.meta.url));
275
- const candidates = [
276
- path.resolve(here, "..", ".."),
277
- path.resolve(here, ".."),
278
- process.cwd()
279
- ];
280
- for (const c of candidates) {
281
- if (existsSync(path.join(c, "package.json")) && existsSync(path.join(c, "deploy", "compose", "docker-compose.yml"))) return c;
282
- }
283
- return process.cwd();
284
- }
285
- function copyTemplateIfMissing(srcRel, dest, created) {
286
- if (existsSync(dest)) return;
287
- const src = path.join(resolvePackageRoot(), srcRel);
288
- if (!existsSync(src)) throw new Error(`Missing packaged stack template: ${srcRel}. Reinstall/update exe-os and retry.`);
289
- try {
290
- mkdirSync(path.dirname(dest), { recursive: true });
291
- } catch (err) {
292
- if (err.code === "EACCES") {
293
- const dir = path.dirname(dest);
294
- throw new Error(
295
- `Permission denied creating ${dir}. Run this first:
296
-
297
- sudo mkdir -p ${dir} && sudo chown $(whoami) ${dir}
298
-
299
- Then re-run stack-update.`
300
- );
301
- }
302
- throw err;
303
- }
304
- copyFileSync(src, dest);
305
- created.push(dest);
306
- }
307
- function installDockerUbuntu(exec) {
308
- if (process.platform !== "linux") throw new Error("Docker auto-install is only supported on Linux. Install Docker manually, then retry.");
309
- if (!existsSync("/etc/os-release")) throw new Error("Cannot detect Linux distro; install Docker manually, then retry.");
310
- const osRelease = readFileSync("/etc/os-release", "utf8");
311
- if (!/ID=(ubuntu|debian)|ID_LIKE=.*debian/.test(osRelease)) {
312
- throw new Error("Docker auto-install currently supports Ubuntu/Debian only. Install Docker manually, then retry.");
313
- }
314
- const script = [
315
- "set -e",
316
- "sudo apt-get update",
317
- "sudo apt-get install -y ca-certificates curl gnupg",
318
- "sudo install -m 0755 -d /etc/apt/keyrings",
319
- "if [ ! -f /etc/apt/keyrings/docker.gpg ]; then curl -fsSL https://download.docker.com/linux/ubuntu/gpg | sudo gpg --dearmor -o /etc/apt/keyrings/docker.gpg; sudo chmod a+r /etc/apt/keyrings/docker.gpg; fi",
320
- ". /etc/os-release",
321
- 'CODENAME="${VERSION_CODENAME:-bookworm}"',
322
- 'if [ "${ID:-}" = "debian" ]; then DOCKER_DISTRO=debian; else DOCKER_DISTRO=ubuntu; fi',
323
- `printf 'deb [arch=%s signed-by=/etc/apt/keyrings/docker.gpg] https://download.docker.com/linux/%s %s stable\\n' "$(dpkg --print-architecture)" "$DOCKER_DISTRO" "$CODENAME" | sudo tee /etc/apt/sources.list.d/docker.list >/dev/null`,
324
- "sudo apt-get update",
325
- "sudo apt-get install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin",
326
- "sudo systemctl enable --now docker",
327
- "sudo docker version >/dev/null",
328
- "sudo docker compose version >/dev/null"
329
- ].join("\n");
330
- exec("sh", ["-lc", script]);
331
- }
332
- function randomSecret(bytes = 32) {
333
- return randomBytes(bytes).toString("base64url");
334
- }
335
- function randomHexSecret(bytes = 24) {
336
- return randomBytes(bytes).toString("hex");
337
- }
338
- function hydrateEnv(raw, opts) {
339
- let next = raw;
340
- const license = opts.licenseKey || process.env.EXE_LICENSE_KEY || loadLicense() || "";
341
- const domain = opts.domain || process.env.EXE_STACK_DOMAIN || process.env.CUSTOMER_DOMAIN || "";
342
- const replacements = {};
343
- const env = parseEnv(raw);
344
- for (const [key, value] of env.entries()) {
345
- if (!/CHANGEME/.test(value)) continue;
346
- if (key === "EXE_LICENSE_KEY" && license) replacements[key] = license;
347
- else if (key === "MONITOR_AGENT_TOKEN" || key === "MONITOR_AGENT_KEY") continue;
348
- else if (key === "EXE_GATEWAY_WS_RELAY_AUTH_TOKEN") replacements[key] = randomHexSecret(24);
349
- else if (key.endsWith("_PASSWORD")) replacements[key] = randomSecret(24);
350
- else if (key.endsWith("_TOKEN")) replacements[key] = randomHexSecret(32);
351
- else if (key.endsWith("_SECRET") || key.endsWith("_SALT")) replacements[key] = randomSecret(32);
352
- else if (key.endsWith("_KEY") && key !== "EXE_LICENSE_KEY") continue;
353
- }
354
- if (domain) next = next.replaceAll("CHANGEME_DOMAIN", domain);
355
- next = patchEnv(next, replacements);
356
- const remaining = [...parseEnv(next).entries()].filter(([, value]) => /CHANGEME/.test(value)).map(([key, value]) => `${key}=${value}`);
357
- return { raw: next, hadPlaceholders: /CHANGEME/.test(raw), remaining: [...new Set(remaining)] };
358
- }
359
- async function pairMonitorAgent(hubUrl, licenseKey, domain, envFile) {
360
- if (!hubUrl || !licenseKey || !domain) {
361
- return { paired: false, error: "Missing hubUrl, licenseKey, or domain for monitor pairing" };
362
- }
363
- const registrationUrl = `${hubUrl.replace(/\/+$/, "")}/api/register-agent`;
364
- try {
365
- const res = await fetch(registrationUrl, {
366
- method: "POST",
367
- headers: {
368
- "content-type": "application/json",
369
- authorization: `Bearer ${licenseKey}`
370
- },
371
- body: JSON.stringify({ name: domain, host: domain, port: 45876 }),
372
- signal: AbortSignal.timeout(15e3)
373
- });
374
- if (!res.ok) {
375
- const body = await res.text().catch(() => "");
376
- return { paired: false, error: `Monitor hub returned HTTP ${res.status}: ${body}` };
377
- }
378
- const data = await res.json();
379
- if (!data.token || !data.key) {
380
- return { paired: false, error: "Monitor hub response missing token or key" };
381
- }
382
- if (existsSync(envFile)) {
383
- const envRaw = readFileSync(envFile, "utf8");
384
- const patched = patchEnv(envRaw, {
385
- MONITOR_AGENT_TOKEN: data.token,
386
- MONITOR_AGENT_KEY: data.key
387
- });
388
- if (patched !== envRaw) {
389
- writeFileSync(envFile, patched, { mode: 384 });
390
- }
391
- }
392
- return { paired: true, systemName: data.name ?? domain };
393
- } catch (err) {
394
- const reason = err instanceof Error ? err.message : String(err);
395
- return { paired: false, error: `Monitor hub unreachable: ${reason}` };
396
- }
397
- }
398
- function bootstrapStackHost(options) {
399
- const exec = options.exec ?? defaultExec;
400
- const createdFiles = [];
401
- const actions = [];
402
- let dockerInstalled = commandSucceeds("docker", ["version"]);
403
- let dockerComposeInstalled = shellSucceeds("docker compose version");
404
- if ((!dockerInstalled || !dockerComposeInstalled) && options.installDocker) {
405
- actions.push("install_docker");
406
- installDockerUbuntu(exec);
407
- dockerInstalled = commandSucceeds("docker", ["version"]);
408
- dockerComposeInstalled = shellSucceeds("docker compose version");
409
- }
410
- copyTemplateIfMissing("deploy/compose/docker-compose.yml", options.composeFile, createdFiles);
411
- copyTemplateIfMissing("deploy/compose/.env.customer.example", options.envFile, createdFiles);
412
- const brandingDest = path.join(path.dirname(options.envFile), "branding.json");
413
- copyTemplateIfMissing("deploy/compose/gateway.json", path.join(path.dirname(options.envFile), "gateway.json"), createdFiles);
414
- if (!existsSync(brandingDest)) writeFileSync(brandingDest, JSON.stringify({ brandName: "Hygo", productName: "Hygo OS" }, null, 2) + "\n", { mode: 384 });
415
- let envHadPlaceholders = false;
416
- let envRemainingPlaceholders = [];
417
- if (existsSync(options.envFile)) {
418
- const envRaw = readFileSync(options.envFile, "utf8");
419
- const hydrated = hydrateEnv(envRaw, options);
420
- envHadPlaceholders = hydrated.hadPlaceholders;
421
- envRemainingPlaceholders = hydrated.remaining;
422
- if (hydrated.raw !== envRaw) {
423
- const backupPath = options.envFile + `.bak-${Date.now()}`;
424
- try {
425
- copyFileSync(options.envFile, backupPath);
426
- } catch {
427
- }
428
- writeFileSync(options.envFile, hydrated.raw, { mode: 384 });
429
- actions.push("hydrate_env_secrets");
430
- }
431
- }
432
- return {
433
- dockerInstalled,
434
- dockerComposeInstalled,
435
- composeFileExists: existsSync(options.composeFile),
436
- envFileExists: existsSync(options.envFile),
437
- envHadPlaceholders,
438
- envRemainingPlaceholders,
439
- licensePresent: !!(options.licenseKey || process.env.EXE_LICENSE_KEY || loadLicense()),
440
- createdFiles,
441
- actions
442
- };
443
- }
444
- function assertHostReadyForApply(report) {
445
- const blockers = [];
446
- if (!report.dockerInstalled) blockers.push("Docker is not installed/running. Re-run with --yes to auto-install on Ubuntu/Debian, or install Docker manually.");
447
- if (!report.dockerComposeInstalled) blockers.push("Docker Compose plugin is missing. Re-run with --yes to auto-install on Ubuntu/Debian, or install docker-compose-plugin manually.");
448
- if (!report.composeFileExists) blockers.push("docker-compose.yml is missing and could not be created.");
449
- if (!report.envFileExists) blockers.push(".env is missing and could not be created.");
450
- if (!report.licensePresent) blockers.push("Exe OS license key is missing. Run `exe-os setup`, `exe-os --activate <key>`, or pass --license-key.");
451
- const hardPlaceholders = report.envRemainingPlaceholders.filter((p) => !/WHATSAPP|API_ROUTER|MONITOR_AGENT/.test(p));
452
- if (hardPlaceholders.length > 0) blockers.push(`Required .env placeholders remain: ${hardPlaceholders.join(", ")}`);
453
- if (blockers.length > 0) throw new Error(`Stack host is not ready:
454
- - ${blockers.join("\n- ")}`);
455
- }
456
- function areStackContainersRunning(composeFile, envFile) {
457
- try {
458
- const result = spawnSync("docker", ["compose", "--file", composeFile, "--env-file", envFile, "ps", "-q"], {
459
- stdio: ["pipe", "pipe", "pipe"],
460
- timeout: 15e3
461
- });
462
- return result.status === 0 && (result.stdout?.toString().trim().length ?? 0) > 0;
463
- } catch {
464
- return false;
465
- }
466
- }
467
- var CRITICAL_BIND_MOUNTS = [
468
- { file: ".env", description: "secrets and image tags" },
469
- { file: "gateway.json", description: "gateway connector config" }
470
- ];
471
- var CRITICAL_VOLUMES = [
472
- "postgres_data",
473
- "gateway_data",
474
- "wiki_data",
475
- "exe_os_data"
476
- ];
477
- function assertBindMountsExist(stackDir) {
478
- const missing = [];
479
- for (const mount of CRITICAL_BIND_MOUNTS) {
480
- const fullPath = path.join(stackDir, mount.file);
481
- if (!existsSync(fullPath)) {
482
- missing.push(`${mount.file} (${mount.description})`);
483
- }
484
- }
485
- if (missing.length > 0) {
486
- console.warn(`[stack-update] \u26A0 Missing critical bind mounts:
487
- - ${missing.join("\n - ")}`);
488
- console.warn("[stack-update] These files contain customer config that will be lost if not present.");
489
- }
490
- }
491
- function assertVolumesIntact() {
492
- const missing = [];
493
- for (const vol of CRITICAL_VOLUMES) {
494
- const result = spawnSync("docker", ["volume", "inspect", `exe-os_${vol}`], {
495
- stdio: ["pipe", "pipe", "pipe"],
496
- timeout: 5e3
497
- });
498
- if (result.status !== 0) {
499
- missing.push(vol);
500
- }
501
- }
502
- if (missing.length > 0) {
503
- console.warn(`[stack-update] \u26A0 Critical volumes missing after update: ${missing.join(", ")}`);
504
- console.warn("[stack-update] Data may have been lost. Check if 'docker compose down -v' was used.");
505
- }
506
- }
507
- async function runStackUpdate(options) {
508
- const exec = options.exec ?? defaultExec;
509
- const now = options.now ?? (() => /* @__PURE__ */ new Date());
510
- if (options.rollback) return rollbackStackUpdate(options);
511
- if (options.bootstrap !== false) {
512
- const report = bootstrapStackHost({ ...options, installDocker: options.installDocker ?? !!options.yes });
513
- if (!options.dryRun) assertHostReadyForApply(report);
514
- }
515
- assertBindMountsExist(path.dirname(options.envFile));
516
- if (!options.dryRun) {
517
- try {
518
- const { runPreflight } = await import("../preflight-3KY5JETE.js");
519
- const preflightReport = runPreflight({
520
- composeFile: options.composeFile,
521
- envFile: options.envFile
522
- });
523
- if (!preflightReport.passed) {
524
- const failures = preflightReport.results.filter((r) => r.status === "fail").map((r) => `${r.check}: ${r.message}`);
525
- throw new Error(`Preflight blocked deploy:
526
- - ${failures.join("\n- ")}`);
527
- }
528
- console.log("[stack-update] \u2713 Preflight passed");
529
- } catch (preflightErr) {
530
- if (preflightErr instanceof Error && preflightErr.message.startsWith("Preflight blocked")) {
531
- throw preflightErr;
532
- }
533
- console.warn("[stack-update] Preflight check skipped (module not available)");
534
- }
535
- }
536
- const hubUrl = options.monitorHubUrl || parseEnv(readFileSync(options.envFile, "utf8")).get("MONITOR_HUB_URL") || "";
537
- const pairLicense = options.licenseKey || process.env.EXE_LICENSE_KEY || loadLicense() || "";
538
- const pairDomain = options.domain || process.env.EXE_STACK_DOMAIN || process.env.CUSTOMER_DOMAIN || "";
539
- if (hubUrl && pairLicense && pairDomain) {
540
- const envBefore = readFileSync(options.envFile, "utf8");
541
- const hasPlaceholder = /CHANGEME/.test(parseEnv(envBefore).get("MONITOR_AGENT_TOKEN") ?? "");
542
- if (hasPlaceholder) {
543
- const pair = options.pairMonitor ? options.pairMonitor(hubUrl, pairLicense, pairDomain, options.envFile) : pairMonitorAgent(hubUrl, pairLicense, pairDomain, options.envFile);
544
- const result = await pair;
545
- if (result.paired) {
546
- console.log(`[stack-update] Monitor agent paired: ${result.systemName}`);
547
- } else {
548
- console.warn(`[stack-update] Monitor pairing skipped: ${result.error}`);
549
- }
550
- }
551
- }
552
- const manifest = await loadStackManifest(options.manifestRef, options.fetchText, options.manifestPublicKey, options.manifestAuthToken);
553
- const envRaw = readFileSync(options.envFile, "utf8");
554
- const plan = createStackUpdatePlan(manifest, envRaw, options.targetVersion);
555
- assertBreakingChangesAllowed(plan, options.allowedBreakingChangeIds ?? []);
556
- assertDeploymentScopeAllowed(plan, options.deploymentPersona ?? "customer");
557
- const plannedEnvRaw = patchEnv(envRaw, Object.fromEntries(plan.changes.map((c) => [c.key, c.after])));
558
- const composeRaw = readFileSync(options.composeFile, "utf8");
559
- assertProductionDeployGate(plan, plannedEnvRaw, composeRaw, {
560
- breakGlassReason: options.breakGlassReason,
561
- breakGlassAuditFile: options.breakGlassAuditFile,
562
- now,
563
- envFile: options.envFile
564
- });
565
- const lockFile = options.lockFile ?? path.join(path.dirname(options.envFile), ".exe-stack-lock.json");
566
- const previousVersion = readCurrentStackVersion(lockFile);
567
- const containersRunning = plan.changes.length === 0 ? areStackContainersRunning(options.composeFile, options.envFile) : true;
568
- if (options.dryRun || plan.changes.length === 0 && containersRunning) {
569
- return { status: "planned", targetVersion: plan.targetVersion, changes: plan.changes, lockFile };
570
- }
571
- await postDeployAudit(options, "started", plan.targetVersion, previousVersion);
572
- const stackDir = path.dirname(options.envFile);
573
- const backupDir = path.join(stackDir, ".exe-stack-backups");
574
- mkdirSync(backupDir, { recursive: true });
575
- const stamp = now().toISOString().replace(/[:.]/g, "-");
576
- const updateBackupDir = path.join(backupDir, `pre-update-${stamp}`);
577
- mkdirSync(updateBackupDir, { recursive: true });
578
- const backupEnvFile = path.join(updateBackupDir, "env.bak");
579
- writeFileSync(backupEnvFile, envRaw, { mode: 384 });
580
- const protectedFiles = ["gateway.json", "branding.json"];
581
- for (const f of protectedFiles) {
582
- const src = path.join(stackDir, f);
583
- try {
584
- if (existsSync(src)) {
585
- copyFileSync(src, path.join(updateBackupDir, f));
586
- }
587
- } catch {
588
- }
589
- }
590
- const cfDir = path.join(stackDir, "cloudflared");
591
- try {
592
- if (existsSync(cfDir)) {
593
- const cfBackup = path.join(updateBackupDir, "cloudflared");
594
- mkdirSync(cfBackup, { recursive: true });
595
- for (const f of readdirSync(cfDir)) {
596
- copyFileSync(path.join(cfDir, f), path.join(cfBackup, f));
597
- }
598
- }
599
- } catch {
600
- }
601
- console.log(`[stack-update] Config backed up to ${updateBackupDir}`);
602
- const updates = Object.fromEntries(plan.changes.map((c) => [c.key, c.after]));
603
- const patched = patchEnv(envRaw, updates);
604
- const tmp = `${options.envFile}.tmp-${process.pid}`;
605
- writeFileSync(tmp, patched, { mode: 384 });
606
- renameSync(tmp, options.envFile);
607
- const composeArgs = ["compose", "--file", options.composeFile, "--env-file", options.envFile];
608
- let registryForLogout;
609
- try {
610
- const creds = await fetchImageCredentials(options);
611
- if (creds) {
612
- (options.dockerLogin ?? defaultDockerLogin)(creds);
613
- registryForLogout = creds.registry;
614
- }
615
- exec("docker", [...composeArgs, "pull"]);
616
- for (const [serviceName, service] of Object.entries(plan.release.services)) {
617
- if (!service.migrations?.command) continue;
618
- const composeServiceName = service.composeService ?? serviceName;
619
- console.log(`[stack-update] Running migrations for ${composeServiceName}: ${service.migrations.command}`);
620
- try {
621
- const migrationArgs = service.migrations.command.split(/\s+/);
622
- exec("docker", [
623
- ...composeArgs,
624
- "run",
625
- "--rm",
626
- "--no-deps",
627
- composeServiceName,
628
- ...migrationArgs
629
- ]);
630
- console.log(`[stack-update] \u2713 Migrations for ${composeServiceName} completed`);
631
- } catch (migErr) {
632
- const reason = migErr instanceof Error ? migErr.message : String(migErr);
633
- console.error(`[stack-update] \u2717 Migration failed for ${composeServiceName}: ${reason}`);
634
- throw new Error(`Migration failed for ${composeServiceName} \u2014 aborting update. Fix the migration and retry. Error: ${reason}`);
635
- }
636
- }
637
- const RESTART_ORDER = [
638
- "exe-db",
639
- // data layer — must be healthy before apps
640
- "clickhouse",
641
- "redis",
642
- "exed",
643
- // daemon — has its own health endpoint
644
- "exe-crm",
645
- // CRM app
646
- "exe-crm-worker",
647
- // CRM background worker
648
- "exe-gateway",
649
- // gateway — WhatsApp connections
650
- "exe-wiki"
651
- // wiki
652
- ];
653
- const preSnapshot = spawnSync("docker", ["ps", "--format", "json"], { encoding: "utf8", timeout: 1e4 });
654
- const preContainerCount = preSnapshot.stdout?.split("\n").filter(Boolean).length ?? 0;
655
- console.log(`[stack-update] Pre-update snapshot: ${preContainerCount} containers`);
656
- for (const service of RESTART_ORDER) {
657
- const psResult = spawnSync("docker", [...composeArgs, "ps", "--quiet", service], {
658
- encoding: "utf8",
659
- stdio: ["pipe", "pipe", "pipe"],
660
- timeout: 1e4
661
- });
662
- if (psResult.status !== 0) continue;
663
- exec("docker", [...composeArgs, "up", "-d", "--no-deps", service]);
664
- const maxWait = 60;
665
- let healthy = false;
666
- for (let i = 0; i < maxWait; i++) {
667
- const inspectResult = spawnSync(
668
- "docker",
669
- ["inspect", "--format", "{{.State.Health.Status}}", service],
670
- { encoding: "utf8", timeout: 5e3 }
671
- );
672
- const status = inspectResult.stdout?.trim();
673
- if (status === "healthy") {
674
- healthy = true;
675
- break;
676
- }
677
- if (status === "" || inspectResult.status !== 0) {
678
- healthy = true;
679
- break;
680
- }
681
- await new Promise((r) => setTimeout(r, 1e3));
682
- }
683
- if (!healthy) {
684
- throw new Error(`Service ${service} failed health check after ${maxWait}s \u2014 aborting update`);
685
- }
686
- console.log(`[stack-update] \u2713 ${service} restarted and healthy`);
687
- }
688
- const postSnapshot = spawnSync("docker", ["ps", "--format", "json"], { encoding: "utf8", timeout: 1e4 });
689
- const postContainerCount = postSnapshot.stdout?.split("\n").filter(Boolean).length ?? 0;
690
- console.log(`[stack-update] Post-update snapshot: ${postContainerCount} containers (was ${preContainerCount})`);
691
- if (postContainerCount < preContainerCount) {
692
- console.warn(`[stack-update] \u26A0 Container count dropped from ${preContainerCount} to ${postContainerCount}`);
693
- }
694
- await verifyReleaseHealth(plan.release, options.healthRetries ?? 12, options.healthDelayMs ?? 5e3);
695
- assertVolumesIntact();
696
- try {
697
- const { runVerifyStack } = await import("./verify-stack.js");
698
- const verifyReport = await runVerifyStack({ composeFile: options.composeFile, envFile: options.envFile });
699
- for (const r of verifyReport.results) {
700
- if (r.status === "fail") console.warn(`[verify-stack] FAIL: ${r.check}: ${r.message}`);
701
- }
702
- } catch {
703
- }
704
- writeFileSync(lockFile, JSON.stringify({ stackVersion: plan.targetVersion, updatedAt: now().toISOString(), backupEnvFile, services: plan.release.services }, null, 2) + "\n");
705
- await postDeployAudit(options, "success", plan.targetVersion, previousVersion, void 0, { changes: plan.changes.length });
706
- return { status: "updated", targetVersion: plan.targetVersion, changes: plan.changes, backupEnvFile, lockFile };
707
- } catch (err) {
708
- writeFileSync(options.envFile, envRaw, { mode: 384 });
709
- try {
710
- exec("docker", [...composeArgs, "up", "-d"]);
711
- } catch {
712
- }
713
- const reason = err instanceof Error ? err.message : String(err);
714
- await postDeployAudit(options, "failed", plan.targetVersion, previousVersion, reason, { rollbackAttempted: true });
715
- throw new Error(`Stack update failed and rollback was attempted: ${reason}`);
716
- } finally {
717
- if (registryForLogout) {
718
- try {
719
- (options.dockerLogout ?? defaultDockerLogout)(registryForLogout);
720
- } catch {
721
- }
722
- }
723
- }
724
- }
725
- async function fetchImageCredentials(options) {
726
- if (!options.imageCredentialsUrl) return null;
727
- const res = await fetch(options.imageCredentialsUrl, {
728
- method: "POST",
729
- headers: {
730
- "content-type": "application/json",
731
- ...options.manifestAuthToken ? { authorization: `Bearer ${options.manifestAuthToken}` } : {}
732
- },
733
- body: JSON.stringify({ deviceId: options.deviceId, licenseKey: options.licenseKey })
734
- });
735
- if (!res.ok) throw new Error(`Failed to fetch image credentials: HTTP ${res.status}`);
736
- return await res.json();
737
- }
738
- function readCurrentStackVersion(lockFile) {
739
- if (!existsSync(lockFile)) return void 0;
740
- try {
741
- const parsed = JSON.parse(readFileSync(lockFile, "utf8"));
742
- return parsed.stackVersion;
743
- } catch {
744
- return void 0;
745
- }
746
- }
747
- async function postDeployAudit(options, status, stackVersion, previousVersion, error, metadata) {
748
- if (!options.auditUrl) return;
749
- const postJson = options.postJson ?? defaultPostJson;
750
- try {
751
- await postJson(options.auditUrl, {
752
- stackVersion,
753
- previousVersion,
754
- status,
755
- error,
756
- metadata,
757
- deviceId: options.deviceId,
758
- licenseKey: options.licenseKey
759
- }, options.manifestAuthToken);
760
- } catch (err) {
761
- const reason = err instanceof Error ? err.message : String(err);
762
- console.warn(`[stack-update] deploy audit failed: ${reason}`);
763
- }
764
- }
765
- async function verifyReleaseHealth(release, retries, delayMs) {
766
- for (const [serviceName, service] of Object.entries(release.services)) {
767
- if (!service.healthUrl) continue;
768
- await waitForHttpOk(service.healthUrl, retries, delayMs, serviceName);
769
- }
770
- }
771
- async function waitForHttpOk(url, retries, delayMs, label) {
772
- let last = "";
773
- for (let i = 0; i < retries; i++) {
774
- try {
775
- const status = await httpStatus(url);
776
- if (status >= 200 && status < 300) return;
777
- last = `HTTP ${status}`;
778
- } catch (err) {
779
- last = err instanceof Error ? err.message : String(err);
780
- }
781
- if (i < retries - 1) await new Promise((resolve) => setTimeout(resolve, delayMs));
782
- }
783
- throw new Error(`Health check failed for ${label} (${url}): ${last}`);
784
- }
785
- function httpStatus(urlString) {
786
- return new Promise((resolve, reject) => {
787
- const url = new URL(urlString);
788
- const mod = url.protocol === "https:" ? https : http;
789
- const req = mod.request(url, { method: "GET", timeout: 5e3 }, (res) => {
790
- res.resume();
791
- resolve(res.statusCode ?? 0);
792
- });
793
- req.on("timeout", () => req.destroy(new Error("timeout")));
794
- req.on("error", reject);
795
- req.end();
796
- });
797
- }
798
- function defaultExec(cmd, args, opts) {
799
- execFileSync(cmd, args, { stdio: "inherit", cwd: opts?.cwd });
800
- }
801
- function defaultDockerLogin(creds) {
802
- execFileSync("docker", ["login", creds.registry, "-u", creds.username, "--password-stdin"], {
803
- input: creds.password,
804
- stdio: ["pipe", "inherit", "inherit"]
805
- });
806
- }
807
- function defaultDockerLogout(registry) {
808
- execFileSync("docker", ["logout", registry], { stdio: "ignore" });
809
- }
810
- async function defaultFetchText(ref, authToken) {
811
- const res = await fetch(ref, {
812
- headers: authToken ? { authorization: `Bearer ${authToken}` } : void 0
813
- });
814
- if (!res.ok) throw new Error(`Failed to fetch ${ref}: HTTP ${res.status}`);
815
- return res.text();
816
- }
817
- async function defaultPostJson(url, body, authToken) {
818
- const res = await fetch(url, {
819
- method: "POST",
820
- headers: {
821
- "content-type": "application/json",
822
- ...authToken ? { authorization: `Bearer ${authToken}` } : {}
823
- },
824
- body: JSON.stringify(body)
825
- });
826
- if (!res.ok) throw new Error(`Failed to POST ${url}: HTTP ${res.status}`);
827
- }
828
- function defaultStackPaths() {
829
- const cwdCompose = path.resolve("docker-compose.yml");
830
- const cwdEnv = path.resolve(".env");
831
- const packagedManifest = path.join(resolvePackageRoot(), "deploy", "stack-manifests", "v0.9.json");
832
- const manifestRef = process.env.EXE_STACK_MANIFEST || (existsSync(packagedManifest) ? packagedManifest : "https://api.askexe.com/stack-manifest.json");
833
- return {
834
- composeFile: process.env.EXE_STACK_COMPOSE_FILE || (existsSync(cwdCompose) ? cwdCompose : "/opt/exe-stack/docker-compose.yml"),
835
- envFile: process.env.EXE_STACK_ENV_FILE || (existsSync(cwdEnv) ? cwdEnv : "/opt/exe-stack/.env"),
836
- manifestRef,
837
- // Only call api.askexe.com if explicitly configured or if a remote manifest was requested.
838
- // Packaged manifests keep cold-start installs unblocked even before update-service entitlements are provisioned.
839
- auditUrl: process.env.EXE_STACK_AUDIT_URL || (/^https?:\/\//.test(manifestRef) ? "https://api.askexe.com/v1/deploy-audits" : void 0),
840
- imageCredentialsUrl: process.env.EXE_STACK_IMAGE_CREDENTIALS_URL || (/^https?:\/\//.test(manifestRef) ? "https://api.askexe.com/v1/image-credentials" : void 0),
841
- // License key IS the auth token for api.askexe.com — no separate update token needed.
842
- // EXE_STACK_UPDATE_TOKEN kept as legacy fallback during migration.
843
- manifestAuthToken: process.env.EXE_LICENSE_KEY || loadLicense() || process.env.EXE_STACK_UPDATE_TOKEN || void 0,
844
- manifestPublicKey: loadDefaultPublicKey()
845
- };
846
- }
847
- function loadDefaultPublicKey() {
848
- if (process.env.EXE_STACK_PUBLIC_KEY) return process.env.EXE_STACK_PUBLIC_KEY;
849
- if (process.env.EXE_STACK_PUBLIC_KEY_FILE && existsSync(process.env.EXE_STACK_PUBLIC_KEY_FILE)) {
850
- return readFileSync(process.env.EXE_STACK_PUBLIC_KEY_FILE, "utf8");
851
- }
852
- return void 0;
853
- }
854
-
855
- // src/bin/stack-update.ts
856
27
  function parseArgs(args) {
857
28
  const defaults = defaultStackPaths();
858
29
  const opts = {
@@ -885,8 +56,8 @@ function parseArgs(args) {
885
56
  else if (arg === "--env-file" || arg === "--stack-env-file") opts.envFile = next();
886
57
  else if (arg.startsWith("--env-file=") || arg.startsWith("--stack-env-file=")) opts.envFile = arg.split("=").slice(1).join("=");
887
58
  else if (arg === "--lock-file") opts.lockFile = next();
888
- else if (arg === "--public-key") opts.manifestPublicKey = readFileSync2(next(), "utf8");
889
- else if (arg.startsWith("--public-key=")) opts.manifestPublicKey = readFileSync2(arg.split("=").slice(1).join("="), "utf8");
59
+ else if (arg === "--public-key") opts.manifestPublicKey = readFileSync(next(), "utf8");
60
+ else if (arg.startsWith("--public-key=")) opts.manifestPublicKey = readFileSync(arg.split("=").slice(1).join("="), "utf8");
890
61
  else if (arg === "--auth-token") opts.manifestAuthToken = next();
891
62
  else if (arg.startsWith("--auth-token=")) opts.manifestAuthToken = arg.split("=").slice(1).join("=");
892
63
  else if (arg === "--auth-token-env") opts.manifestAuthToken = process.env[next()] ?? "";
@@ -933,11 +104,12 @@ function printHelp() {
933
104
  console.log(`exe-os stack-update \u2014 update a self-hosted Exe OS stack from a pinned manifest
934
105
 
935
106
  Usage:
936
- exe-os stack-update [--manifest <path-or-url>] [--target <version>] [--yes]
107
+ exe-os stack-update --check Show installed vs available versions
108
+ exe-os stack-update --target <version> --yes Update to a specific version
937
109
 
938
110
  Options:
939
111
  --manifest <ref> Stack manifest JSON path or URL (default: update.askexe.com)
940
- --target <version> Stack version to install (default: manifest.latest)
112
+ --target <version> Stack version to install (required for updates; fresh installs default to latest)
941
113
  --compose-file <path> docker-compose.yml path (default: ./docker-compose.yml or /opt/exe-stack/docker-compose.yml)
942
114
  --stack-env-file <path> .env path (default: ./.env or /opt/exe-stack/.env)
943
115
  --env-file <path> Alias; prefer --stack-env-file because Node 22 reserves --env-file
@@ -999,7 +171,7 @@ function printChanges(changes, composeFile, envFile) {
999
171
  }
1000
172
  function areCliContainersRunning(composeFile, envFile) {
1001
173
  try {
1002
- const result = spawnSync2("docker", ["compose", "--file", composeFile, "--env-file", envFile, "ps", "-q"], {
174
+ const result = spawnSync("docker", ["compose", "--file", composeFile, "--env-file", envFile, "ps", "-q"], {
1003
175
  stdio: ["pipe", "pipe", "pipe"],
1004
176
  timeout: 15e3
1005
177
  });
@@ -1010,7 +182,7 @@ function areCliContainersRunning(composeFile, envFile) {
1010
182
  }
1011
183
  function getContainerHealth(composeFile, envFile) {
1012
184
  try {
1013
- const result = spawnSync2(
185
+ const result = spawnSync(
1014
186
  "docker",
1015
187
  ["compose", "--file", composeFile, "--env-file", envFile, "ps", "--format", "json"],
1016
188
  { stdio: ["pipe", "pipe", "pipe"], timeout: 15e3 }
@@ -1077,9 +249,9 @@ async function main(args = process.argv.slice(2)) {
1077
249
  const opts = parseArgs(args);
1078
250
  let usingPackagedCheckTemplates = false;
1079
251
  if (opts.check && !opts.noBootstrap && !existsForCli(opts.composeFile) && !existsForCli(opts.envFile)) {
1080
- const packageRoot = path2.resolve(new URL("../..", import.meta.url).pathname);
1081
- opts.composeFile = path2.join(packageRoot, "deploy", "compose", "docker-compose.yml");
1082
- opts.envFile = path2.join(packageRoot, "deploy", "compose", ".env.customer.example");
252
+ const packageRoot = path.resolve(new URL("../..", import.meta.url).pathname);
253
+ opts.composeFile = path.join(packageRoot, "deploy", "compose", "docker-compose.yml");
254
+ opts.envFile = path.join(packageRoot, "deploy", "compose", ".env.customer.example");
1083
255
  opts.noBootstrap = true;
1084
256
  usingPackagedCheckTemplates = true;
1085
257
  }
@@ -1099,20 +271,47 @@ async function main(args = process.argv.slice(2)) {
1099
271
  if (!opts.check && !opts.dryRun) assertHostReadyForApply(hostReport);
1100
272
  }
1101
273
  const manifest = await loadStackManifest(opts.manifestRef, void 0, opts.manifestPublicKey, opts.manifestAuthToken);
1102
- const envRaw = readFileSync2(opts.envFile, "utf8");
1103
- const plan = createStackUpdatePlan(manifest, envRaw, opts.targetVersion);
274
+ const envRaw = readFileSync(opts.envFile, "utf8");
275
+ const lockFilePath = opts.lockFile ?? path.join(path.dirname(opts.envFile), ".exe-stack-lock.json");
276
+ const installedVersion = readCurrentStackVersion(lockFilePath);
277
+ const effectiveTarget = opts.targetVersion ?? (installedVersion ?? manifest.latest);
278
+ const plan = createStackUpdatePlan(manifest, envRaw, effectiveTarget);
1104
279
  assertDeploymentScopeAllowed(plan, opts.deploymentPersona);
1105
280
  const plannedEnvRaw = patchEnv(envRaw, Object.fromEntries(plan.changes.map((c) => [c.key, c.after])));
1106
- assertProductionDeployGate(plan, plannedEnvRaw, readFileSync2(opts.composeFile, "utf8"), {
281
+ assertProductionDeployGate(plan, plannedEnvRaw, readFileSync(opts.composeFile, "utf8"), {
1107
282
  breakGlassReason: opts.breakGlassReason,
1108
283
  breakGlassAuditFile: opts.breakGlassAuditFile,
1109
284
  envFile: opts.envFile
1110
285
  });
1111
286
  console.log(`Exe OS stack target: ${plan.targetVersion}`);
287
+ if (installedVersion) {
288
+ console.log(`Installed version: ${installedVersion}`);
289
+ }
290
+ console.log(`Latest available: ${manifest.latest}`);
1112
291
  console.log(`Manifest: ${opts.manifestRef}`);
1113
292
  console.log(`Compose: ${opts.composeFile}`);
1114
293
  console.log(`Env: ${opts.envFile}
1115
294
  `);
295
+ if (opts.check) {
296
+ const versions = listAvailableVersions(manifest, installedVersion);
297
+ if (versions.length > 0) {
298
+ console.log("Available stack versions:");
299
+ for (const v of versions) {
300
+ const markers = [];
301
+ if (v.isCurrent) markers.push("installed");
302
+ if (v.isLatest) markers.push("latest");
303
+ const markerStr = markers.length > 0 ? ` \u2190 ${markers.join(", ")}` : "";
304
+ console.log(` ${v.version}${markerStr}${v.releasedAt ? ` (${v.releasedAt.slice(0, 10)})` : ""}`);
305
+ }
306
+ console.log("");
307
+ if (installedVersion && installedVersion !== manifest.latest) {
308
+ console.log(`To update: exe-os stack-update --target ${manifest.latest} --yes`);
309
+ } else if (installedVersion === manifest.latest) {
310
+ console.log("Stack is up to date.");
311
+ }
312
+ console.log("");
313
+ }
314
+ }
1116
315
  const unhealthyCount = printChanges(plan.changes, opts.composeFile, opts.envFile);
1117
316
  printBreaking(plan.breakingChanges);
1118
317
  if (opts.check || opts.dryRun) {
@@ -1121,6 +320,11 @@ async function main(args = process.argv.slice(2)) {
1121
320
  } else if (unhealthyCount > 0) process.exitCode = 1;
1122
321
  return;
1123
322
  }
323
+ if (!opts.targetVersion && installedVersion && plan.changes.length === 0) {
324
+ console.log(`Stack is pinned at v${installedVersion}. To update, specify --target <version>.`);
325
+ console.log(`Available: exe-os stack-update --check`);
326
+ return;
327
+ }
1124
328
  if (!opts.yes) {
1125
329
  console.error("\nRefusing to update without --yes. Re-run with --yes after reviewing the plan.");
1126
330
  process.exit(2);
@@ -1133,7 +337,7 @@ async function main(args = process.argv.slice(2)) {
1133
337
  }
1134
338
  function existsForCli(filePath) {
1135
339
  try {
1136
- readFileSync2(filePath);
340
+ readFileSync(filePath);
1137
341
  return true;
1138
342
  } catch {
1139
343
  return false;