@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.
- package/deploy/compose/backup.sh +45 -7
- package/deploy/compose/setup.sh +7 -0
- package/deploy/stack-manifests/v0.9.json +40 -1
- package/dist/{active-agent-R2KMWMR6.js → active-agent-DGTIJN2U.js} +2 -2
- package/dist/{active-agent-CYMM3QQA.js → active-agent-HVMLG6FH.js} +2 -2
- package/dist/{agentic-ontology-GKAKYNPE.js → agentic-ontology-S54AFODT.js} +1 -1
- package/dist/{backfill-metadata-Z5SYUWAV.js → backfill-metadata-74IWETRF.js} +4 -3
- package/dist/{behaviors-QGU6XI5R.js → behaviors-LZVAVHTC.js} +2 -2
- package/dist/bin/agentic-ontology-backfill.js +5 -4
- package/dist/bin/agentic-reflection-backfill.js +6 -5
- package/dist/bin/agentic-semantic-label.js +5 -4
- package/dist/bin/backfill-conversations.js +5 -4
- package/dist/bin/backfill-responses.js +5 -4
- package/dist/bin/backfill-vectors.js +6 -5
- package/dist/bin/bulk-sync-postgres.js +6 -5
- package/dist/bin/cleanup-stale-review-tasks.js +9 -9
- package/dist/bin/cli.js +16 -13
- package/dist/bin/daily-summary.js +0 -217
- package/dist/bin/deferred-daemon-restart.js +8 -0
- package/dist/bin/exe-agent-config.js +1 -1
- package/dist/bin/exe-agent.js +10 -10
- package/dist/bin/exe-assign.js +7 -6
- package/dist/bin/exe-boot.js +20 -19
- package/dist/bin/exe-call.js +4 -4
- package/dist/bin/exe-cloud.js +3 -3
- package/dist/bin/exe-dispatch.js +9 -9
- package/dist/bin/exe-doctor.js +1 -1
- package/dist/bin/exe-export-behaviors.js +7 -6
- package/dist/bin/exe-forget.js +6 -5
- package/dist/bin/exe-gateway.js +5 -5
- package/dist/bin/exe-heartbeat.js +9 -9
- package/dist/bin/exe-kill.js +13 -12
- package/dist/bin/exe-launch-agent.js +11 -10
- package/dist/bin/exe-new-employee.js +6 -6
- package/dist/bin/exe-pending-messages.js +10 -10
- package/dist/bin/exe-pending-notifications.js +9 -9
- package/dist/bin/exe-pending-reviews.js +9 -9
- package/dist/bin/exe-rename.js +4 -4
- package/dist/bin/exe-review.js +12 -11
- package/dist/bin/exe-search.js +5 -4
- package/dist/bin/exe-session-cleanup.js +19 -14
- package/dist/bin/exe-settings.js +3 -3
- package/dist/bin/exe-start-codex.js +11 -10
- package/dist/bin/exe-start-opencode.js +8 -7
- package/dist/bin/exe-status.js +10 -10
- package/dist/bin/exe-team.js +2 -2
- package/dist/bin/git-sweep.js +9 -9
- package/dist/bin/graph-backfill.js +4 -3
- package/dist/bin/graph-export.js +5 -4
- package/dist/bin/import-history.js +171 -0
- package/dist/bin/install-launchd.js +41 -0
- package/dist/bin/install.js +50 -74
- package/dist/bin/intercom-check.js +4 -4
- package/dist/bin/postgres-agentic-reflection-backfill.js +2 -2
- package/dist/bin/postgres-agentic-semantic-backfill.js +4 -4
- package/dist/bin/pre-publish.js +1 -1
- package/dist/bin/scan-tasks.js +22 -12
- package/dist/bin/setup.js +1 -1
- package/dist/bin/shard-migrate.js +4 -3
- package/dist/bin/stack-update.js +61 -857
- package/dist/bin/vps-backup.js +170 -0
- package/dist/bin/vps-health-gate.js +232 -0
- package/dist/{capacity-monitor-ZEAE4WP2.js → capacity-monitor-JBZB2S4P.js} +10 -10
- package/dist/{catchup-brief-OGWCHENC.js → catchup-brief-HE2EMZS5.js} +12 -11
- package/dist/{chunk-DJJNB47C.js → chunk-27DO3EZO.js} +1 -1
- package/dist/{chunk-45FYZIHI.js → chunk-32YUET3Y.js} +2 -2
- package/dist/{chunk-Y75ECPO5.js → chunk-3FW5LUGI.js} +2 -2
- package/dist/{chunk-4OZGQZ4U.js → chunk-3M3O56VT.js} +636 -179
- package/dist/{chunk-77WQOD6J.js → chunk-4CXUZ4NI.js} +2 -2
- package/dist/{chunk-PBXWPHEK.js → chunk-4VEHJZ6R.js} +1 -1
- package/dist/{chunk-TH22QIEC.js → chunk-6A4COFDG.js} +1 -1
- package/dist/{chunk-ACBTCC2L.js → chunk-7OJH2A6I.js} +1 -1
- package/dist/{chunk-NHCOTCI6.js → chunk-A7SGEBXJ.js} +2 -2
- package/dist/{chunk-5MPQSNZF.js → chunk-AUTCT6AY.js} +1 -1
- package/dist/{chunk-OEKSTOTE.js → chunk-AZAZ2C75.js} +1 -1
- package/dist/chunk-CHCA3ZM2.js +167 -0
- package/dist/{chunk-X347L57O.js → chunk-CSTJQDOE.js} +4 -3
- package/dist/{chunk-B234R3VW.js → chunk-D7WLV6WD.js} +2 -2
- package/dist/{chunk-GMXF3AHJ.js → chunk-DGAONW36.js} +1 -1
- package/dist/chunk-EAT5YL3W.js +229 -0
- package/dist/{chunk-OD4H5YCJ.js → chunk-EKTQE2R5.js} +8 -8
- package/dist/{chunk-Z44PC42G.js → chunk-ELUBA7XL.js} +2 -2
- package/dist/{chunk-ZWS6XQER.js → chunk-F5AKOE4P.js} +7 -7
- package/dist/{chunk-T5YULDDO.js → chunk-FVI4UBKO.js} +27 -4
- package/dist/{chunk-ESRI7MFI.js → chunk-GAN7PW6G.js} +28 -24
- package/dist/{chunk-K4OWYJSP.js → chunk-GM2WZTG3.js} +2 -2
- package/dist/{chunk-TAB5QGIK.js → chunk-GZYQTPTF.js} +3 -3
- package/dist/{chunk-CXDU5DE3.js → chunk-IAUNGATJ.js} +1 -1
- package/dist/{chunk-YS63NS6M.js → chunk-IHSM5GR4.js} +1 -1
- package/dist/{chunk-23PTS2ZD.js → chunk-IP7KJAUW.js} +117 -15
- package/dist/{chunk-D6IMJAV2.js → chunk-J64P2LB2.js} +2 -2
- package/dist/{chunk-CXAVSQZM.js → chunk-JXMSCKRM.js} +1 -1
- package/dist/{chunk-RQMK3IQH.js → chunk-K4OTJP6N.js} +14 -7
- package/dist/{chunk-L7ROZR2H.js → chunk-KXAUMIOX.js} +1 -1
- package/dist/{chunk-TPC3LAP7.js → chunk-LGY2BIOT.js} +13 -0
- package/dist/{chunk-RPIDSBK7.js → chunk-LLHRJEE4.js} +3 -3
- package/dist/{chunk-6WG2VIKC.js → chunk-LM7H6XU4.js} +1 -1
- package/dist/{chunk-Y6GMKZZ2.js → chunk-LOFFGJSY.js} +150 -23
- package/dist/{chunk-W7SDGBEC.js → chunk-MFI5OXYW.js} +52 -84
- package/dist/{chunk-KNPEVPYG.js → chunk-MSSQWF6X.js} +2 -2
- package/dist/{chunk-QIQAO3VG.js → chunk-NEFFFKMD.js} +3 -3
- package/dist/{chunk-YUC552KZ.js → chunk-NEHONJJC.js} +3 -3
- package/dist/{chunk-KZ7SXZ2V.js → chunk-NFMQRLCD.js} +1 -1
- package/dist/{chunk-52HCNDPG.js → chunk-O4TATDOV.js} +1 -1
- package/dist/{chunk-AR3OYGLB.js → chunk-PEFBRL4S.js} +28 -6
- package/dist/{chunk-AEUXUEJG.js → chunk-PEXVU3HU.js} +5 -3
- package/dist/chunk-Q2G5C3HV.js +217 -0
- package/dist/{chunk-KOO56JVC.js → chunk-Q6N6LDEJ.js} +1 -1
- package/dist/{chunk-TXSJ2L5O.js → chunk-QI4IXJN7.js} +1 -1
- package/dist/{chunk-HLVQ5Y7B.js → chunk-RE4VLK45.js} +1 -1
- package/dist/{chunk-TF6SZGDT.js → chunk-SA2PH6WY.js} +1 -1
- package/dist/{chunk-5RSYY7BE.js → chunk-SJYOPYXH.js} +117 -9
- package/dist/{chunk-PJGHBANY.js → chunk-TTJE7CCU.js} +1 -1
- package/dist/{chunk-A7KEWR6S.js → chunk-TXWQPL2U.js} +1 -1
- package/dist/{chunk-XXSJ35J5.js → chunk-U5ZH52FB.js} +2 -2
- package/dist/{chunk-G4FDG3LK.js → chunk-UVNDLF74.js} +63 -40
- package/dist/{chunk-5OD3AFRW.js → chunk-V6RCZ25F.js} +1 -1
- package/dist/{chunk-LHMBIFKD.js → chunk-VYNNN2S3.js} +4 -4
- package/dist/chunk-WCYT54XP.js +934 -0
- package/dist/{chunk-5AMSQRHT.js → chunk-XGYSTVUH.js} +1 -1
- package/dist/{chunk-MKZBHM6A.js → chunk-XLWF3C4R.js} +4 -4
- package/dist/{chunk-YL36L2SN.js → chunk-Y7YHLV57.js} +1 -1
- package/dist/{chunk-HZC4MR4H.js → chunk-YBKB2PXY.js} +1 -1
- package/dist/{chunk-NWM3A4TK.js → chunk-ZDNLKXZA.js} +1 -1
- package/dist/{chunk-O7KW6QMH.js → chunk-ZW4TKQUM.js} +15 -5
- package/dist/{chunk-6BURHBE6.js → chunk-ZXB44R3E.js} +32 -11
- package/dist/co-occurrence-WCED475N.js +73 -0
- package/dist/{code-context-index-B6VIWPSF.js → code-context-index-LSZ3DKTJ.js} +2 -2
- package/dist/{crdt-sync-XA22KI3S.js → crdt-sync-PBXZTHZC.js} +1 -1
- package/dist/{crm-webhook-CIZNOEY4.js → crm-webhook-W7Q25VZU.js} +2 -2
- package/dist/{cto-delegation-gate-H5IULFRC.js → cto-delegation-gate-JKULOLMC.js} +8 -8
- package/dist/{daemon-orchestration-VO5XQIJL.js → daemon-orchestration-CHV6MB42.js} +13 -11
- package/dist/{exe-drift-DMT75WR3.js → exe-drift-PW36OULT.js} +2 -2
- package/dist/{exe-export-2RZWOSX6.js → exe-export-XQOD3KE6.js} +6 -5
- package/dist/{exe-import-NFNYATHL.js → exe-import-QOFP67LW.js} +6 -5
- package/dist/{exe-key-4D7CF3BU.js → exe-key-WQ34UZR6.js} +1 -1
- package/dist/{fast-db-init-LAEISZQ2.js → fast-db-init-UKETGWQI.js} +1 -1
- package/dist/gateway/index.js +6 -6
- package/dist/{git-staleness-M46AYLPP.js → git-staleness-ATV5CGAP.js} +1 -1
- package/dist/{git-task-sweep-PXOS56YT.js → git-task-sweep-KXZRIP4T.js} +9 -9
- package/dist/{global-procedures-KROQQX54.js → global-procedures-G6IKCYKM.js} +3 -3
- package/dist/{graph-auto-extract-QJ2BBJM2.js → graph-auto-extract-ZJXJOLE2.js} +1 -1
- package/dist/hooks/bug-report-worker.js +10 -10
- package/dist/hooks/codex-stop-task-finalizer.js +10 -10
- package/dist/hooks/commit-complete.js +11 -11
- package/dist/hooks/error-recall.js +8 -7
- package/dist/hooks/exe-heartbeat-hook.js +2 -2
- package/dist/hooks/ingest-worker.js +3 -3
- package/dist/hooks/ingest.js +9 -9
- package/dist/hooks/instructions-loaded.js +3 -3
- package/dist/hooks/notification.js +3 -3
- package/dist/hooks/post-compact.js +10 -10
- package/dist/hooks/post-tool-combined.js +5 -5
- package/dist/hooks/pre-compact.js +16 -16
- package/dist/hooks/pre-tool-use.js +14 -14
- package/dist/hooks/prompt-submit.js +30 -29
- package/dist/hooks/session-end.js +46 -25
- package/dist/hooks/session-start.js +48 -10
- package/dist/hooks/stop.js +17 -17
- package/dist/hooks/subagent-stop.js +10 -10
- package/dist/hooks/summary-worker.js +17 -16
- package/dist/index.js +17 -17
- package/dist/{installer-SDBLJBAB.js → installer-DE2LH5EC.js} +4 -4
- package/dist/{installer-ZA6QNQ4P.js → installer-M2MDS7HC.js} +4 -4
- package/dist/{installer-6KAY6LD6.js → installer-VE23YFXU.js} +4 -4
- package/dist/{intercom-queue-K3DVKSPJ.js → intercom-queue-RNM6EPGA.js} +1 -1
- package/dist/keyword-extractor-UJHFWVZE.js +11 -0
- package/dist/lib/cloud-sync.js +3 -3
- package/dist/lib/consolidation.js +5 -4
- package/dist/lib/database.js +1 -1
- package/dist/lib/db-daemon-client.js +1 -1
- package/dist/lib/db.js +1 -1
- package/dist/lib/embed-worker.js +98 -0
- package/dist/lib/embedder.js +2 -2
- package/dist/lib/employee-templates.js +4 -4
- package/dist/lib/employees.js +1 -1
- package/dist/lib/exe-daemon-client.js +1 -1
- package/dist/lib/exe-daemon.js +523 -500
- package/dist/lib/hybrid-search.js +5 -6
- package/dist/lib/identity.js +1 -1
- package/dist/lib/messaging.js +9 -9
- package/dist/lib/reminders.js +2 -2
- package/dist/lib/schedules.js +5 -4
- package/dist/lib/skill-learning.js +3 -3
- package/dist/lib/store.js +4 -3
- package/dist/lib/task-router.js +2 -2
- package/dist/lib/tasks.js +9 -9
- package/dist/lib/tmux-routing.js +8 -8
- package/dist/lib/tmux-transport.js +1 -1
- package/dist/lib/token-spend.js +2 -2
- package/dist/lib/transport.js +2 -2
- package/dist/lib/ws-client.js +3 -1
- package/dist/mcp/register-tools.js +54 -51
- package/dist/mcp/server.js +58 -55
- package/dist/mcp/tools/complete-reminder.js +3 -3
- package/dist/mcp/tools/create-reminder.js +3 -3
- package/dist/mcp/tools/create-task.js +11 -11
- package/dist/mcp/tools/deactivate-behavior.js +4 -4
- package/dist/mcp/tools/list-reminders.js +3 -3
- package/dist/mcp/tools/list-tasks.js +11 -11
- package/dist/mcp/tools/send-message.js +11 -11
- package/dist/mcp/tools/update-task.js +10 -10
- package/dist/{mcp-http-config-LK2EDOEJ.js → mcp-http-config-Z2E4VUOF.js} +2 -2
- package/dist/{memory-cards-V3DKSRWL.js → memory-cards-SFDKDIAW.js} +1 -1
- package/dist/memory-graph-extractor-YD4GNH7T.js +16 -0
- package/dist/{memory-poisoning-defense-3B75HS74.js → memory-poisoning-defense-VEGNFELN.js} +1 -1
- package/dist/{memory-queue-client-LFPZPPQA.js → memory-queue-client-5HB2XUH7.js} +2 -2
- package/dist/{memory-reflection-HTDAUUE5.js → memory-reflection-MTPRQNI6.js} +2 -2
- package/dist/{notifications-76VCYXWW.js → notifications-6TCE6OBG.js} +8 -8
- package/dist/{orchestrator-CBNSBI5P.js → orchestrator-W2GYJR23.js} +10 -10
- package/dist/{plan-limits-SOR3QXKV.js → plan-limits-4EP46323.js} +2 -2
- package/dist/{projection-worker-FK5YOEIL.js → projection-worker-EBUYNMU2.js} +1 -1
- package/dist/{review-polling-ZLNDUKL4.js → review-polling-2N7KQFZZ.js} +9 -9
- package/dist/runtime/index.js +15 -15
- package/dist/{session-events-CUSPL25D.js → session-events-K47FHAXJ.js} +9 -9
- package/dist/{session-kill-telemetry-FLBRHBDP.js → session-kill-telemetry-275YUXM5.js} +2 -2
- package/dist/{session-scope-PX2ABSJO.js → session-scope-XSFJZEER.js} +8 -8
- package/dist/{setup-wizard-Y6PBZGFX.js → setup-wizard-UEO7HYLQ.js} +1 -1
- package/dist/{skill-refinement-L7PGKCYO.js → skill-refinement-WXBTANDQ.js} +1 -1
- package/dist/stack-update-2B2UXREV.js +50 -0
- package/dist/{task-enforcement-7FUILB63.js → task-enforcement-2JIJSXPU.js} +14 -16
- package/dist/{task-scope-2N45TE32.js → task-scope-W73Z3XWE.js} +8 -8
- package/dist/{tasks-crud-ADLCGHGH.js → tasks-crud-HPJKI3QQ.js} +8 -8
- package/dist/{tasks-review-PJ2DUI6N.js → tasks-review-MXLMPGNZ.js} +8 -8
- package/dist/{token-budget-T5DFXVTM.js → token-budget-BA46CVHX.js} +1 -1
- package/dist/{tool-capability-index-6JJN6ZRC.js → tool-capability-index-42VVN5BS.js} +1 -1
- package/dist/{tool-telemetry-72PVO5HV.js → tool-telemetry-GZ5E2AUL.js} +1 -1
- package/dist/tui/App.js +22 -18
- package/dist/{tui-data-63JHE6EZ.js → tui-data-PVXWQCJX.js} +8 -8
- package/dist/{worker-gate-REVBJUZ6.js → worker-gate-WTTK64TK.js} +1 -1
- package/dist/{workflow-engine-W2WNHJG5.js → workflow-engine-LT3WTT7V.js} +2 -2
- package/package.json +1 -1
- package/release-notes.json +209 -209
- /package/dist/{chunk-BNOZUS6J.js → chunk-6VVCAVRT.js} +0 -0
- /package/dist/{chunk-IC7GKK6I.js → chunk-CWQZZ7X3.js} +0 -0
- /package/dist/{chunk-ZI2ZVERO.js → chunk-EIW5GOBW.js} +0 -0
- /package/dist/{chunk-2BGGDNRD.js → chunk-IPPJEM26.js} +0 -0
- /package/dist/{chunk-4ISDU5KR.js → chunk-K5UR73PM.js} +0 -0
- /package/dist/{chunk-ZWRTVUQ6.js → chunk-KIMO5S45.js} +0 -0
- /package/dist/{chunk-S2FX5KJ4.js → chunk-WBLILGAP.js} +0 -0
- /package/dist/{core-memory-PCJ3L46L.js → core-memory-RAC6M67J.js} +0 -0
- /package/dist/{entity-boost-GHFPE6A2.js → entity-boost-5FIRFRDC.js} +0 -0
- /package/dist/{message-queue-client-CHRQYBH5.js → message-queue-client-PTQ2S7D7.js} +0 -0
- /package/dist/{wiki-acl-QYRAYYVQ.js → wiki-acl-MSDRCIAI.js} +0 -0
package/dist/bin/stack-update.js
CHANGED
|
@@ -1,7 +1,18 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import {
|
|
3
|
-
|
|
4
|
-
|
|
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
|
|
14
|
-
import { spawnSync
|
|
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 =
|
|
889
|
-
else if (arg.startsWith("--public-key=")) opts.manifestPublicKey =
|
|
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
|
|
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
|
|
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 =
|
|
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 =
|
|
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 =
|
|
1081
|
-
opts.composeFile =
|
|
1082
|
-
opts.envFile =
|
|
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 =
|
|
1103
|
-
const
|
|
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,
|
|
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
|
-
|
|
340
|
+
readFileSync(filePath);
|
|
1137
341
|
return true;
|
|
1138
342
|
} catch {
|
|
1139
343
|
return false;
|