quoxcli 0.2.0 → 0.3.1

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 (327) hide show
  1. package/LICENSE +1 -1
  2. package/README.md +2 -0
  3. package/bin/quox.js +99 -126
  4. package/lib/api/endpoints/admin.js +26 -0
  5. package/lib/api/endpoints/agent-schedules.js +26 -0
  6. package/lib/api/endpoints/agents.js +7 -0
  7. package/lib/api/endpoints/assistants.js +12 -0
  8. package/lib/api/endpoints/away.js +15 -0
  9. package/lib/api/endpoints/chat.js +7 -0
  10. package/lib/api/endpoints/contactsq.js +2 -0
  11. package/lib/api/endpoints/conversations.js +14 -0
  12. package/lib/api/endpoints/executions.js +13 -0
  13. package/lib/api/endpoints/fetchAllowlist.js +27 -0
  14. package/lib/api/endpoints/files.js +14 -0
  15. package/lib/api/endpoints/hat.js +30 -0
  16. package/lib/api/endpoints/inbox.js +20 -0
  17. package/lib/api/endpoints/index.js +14 -0
  18. package/lib/api/endpoints/memory.js +19 -0
  19. package/lib/api/endpoints/mind.js +13 -0
  20. package/lib/api/endpoints/mon.js +6 -0
  21. package/lib/api/endpoints/objectives.js +6 -0
  22. package/lib/api/endpoints/plugins.js +18 -0
  23. package/lib/api/endpoints/presence.js +33 -0
  24. package/lib/api/endpoints/quoxplan.js +7 -0
  25. package/lib/api/endpoints/quoxseo.js +6 -0
  26. package/lib/api/endpoints/router.js +17 -0
  27. package/lib/api/endpoints/safety.js +7 -0
  28. package/lib/api/endpoints/skills.js +20 -0
  29. package/lib/api/endpoints/stepup.js +20 -0
  30. package/lib/api/endpoints/teams.js +2 -2
  31. package/lib/api/endpoints/triggers.js +26 -0
  32. package/lib/api/endpoints/v1.js +86 -0
  33. package/lib/api/endpoints/ward.js +7 -0
  34. package/lib/api/endpoints/workflows.js +7 -0
  35. package/lib/api/endpoints/x.js +8 -0
  36. package/lib/commands/actions.js +61 -0
  37. package/lib/commands/admin.js +16 -0
  38. package/lib/commands/agent.js +163 -27
  39. package/lib/commands/agentAttach.js +94 -6
  40. package/lib/commands/approvals.js +73 -1
  41. package/lib/commands/aside.js +24 -0
  42. package/lib/commands/ask.js +144 -0
  43. package/lib/commands/assistant.js +59 -4
  44. package/lib/commands/auth.js +8 -2
  45. package/lib/commands/backup.js +8 -4
  46. package/lib/commands/brain.js +105 -14
  47. package/lib/commands/build.js +19 -5
  48. package/lib/commands/chat.js +57 -2
  49. package/lib/commands/claude.js +34 -8
  50. package/lib/commands/contacts.js +49 -0
  51. package/lib/commands/conversation.js +35 -4
  52. package/lib/commands/dashboard.js +43 -0
  53. package/lib/commands/department.js +28 -7
  54. package/lib/commands/devResident.js +15 -3
  55. package/lib/commands/devSupervisor.js +13 -1
  56. package/lib/commands/discord.js +75 -39
  57. package/lib/commands/done.js +98 -28
  58. package/lib/commands/engagement.js +17 -6
  59. package/lib/commands/fetchAllowlist.js +71 -0
  60. package/lib/commands/file.js +38 -1
  61. package/lib/commands/fleet.js +311 -4
  62. package/lib/commands/governance.js +12 -3
  63. package/lib/commands/gripe.js +44 -13
  64. package/lib/commands/hat.js +178 -9
  65. package/lib/commands/helpers.js +18 -1
  66. package/lib/commands/hyperion.js +58 -0
  67. package/lib/commands/inbox.js +74 -23
  68. package/lib/commands/licence.js +14 -155
  69. package/lib/commands/lucid.js +10 -4
  70. package/lib/commands/memory.js +85 -8
  71. package/lib/commands/mind.js +71 -0
  72. package/lib/commands/mon.js +69 -2
  73. package/lib/commands/monitoring.js +1 -1
  74. package/lib/commands/motion.js +2 -2
  75. package/lib/commands/nucleus.js +48 -17
  76. package/lib/commands/objective.js +11 -0
  77. package/lib/commands/offload.js +89 -0
  78. package/lib/commands/onboard.js +2 -1
  79. package/lib/commands/org.js +11 -1
  80. package/lib/commands/peek.js +28 -0
  81. package/lib/commands/plan.js +38 -0
  82. package/lib/commands/plugin.js +110 -0
  83. package/lib/commands/presence.js +89 -0
  84. package/lib/commands/provider.js +159 -0
  85. package/lib/commands/qlarity.js +19 -3
  86. package/lib/commands/quoxchat.js +13 -4
  87. package/lib/commands/quoxseo.js +38 -0
  88. package/lib/commands/reach.js +2 -1
  89. package/lib/commands/router.js +77 -0
  90. package/lib/commands/rows.js +358 -0
  91. package/lib/commands/run.js +64 -9
  92. package/lib/commands/server.js +292 -0
  93. package/lib/commands/setup.js +33 -7
  94. package/lib/commands/skills.js +48 -5
  95. package/lib/commands/slot.js +260 -7
  96. package/lib/commands/status.js +1 -1
  97. package/lib/commands/team.js +3 -1
  98. package/lib/commands/tips.js +23 -0
  99. package/lib/commands/tool.js +133 -45
  100. package/lib/commands/trace.js +12 -7
  101. package/lib/commands/trigger.js +95 -0
  102. package/lib/commands/update.js +46 -0
  103. package/lib/commands/vault.js +31 -8
  104. package/lib/commands/vm.js +237 -0
  105. package/lib/commands/voice.js +34 -3
  106. package/lib/commands/volt.js +45 -3
  107. package/lib/commands/walkthrough.js +32 -0
  108. package/lib/commands/watch.js +73 -21
  109. package/lib/commands/welcome.js +14 -0
  110. package/lib/commands/workflow.js +189 -32
  111. package/lib/commands/x.js +13 -4
  112. package/lib/core/actingAs.js +2 -2
  113. package/lib/core/activeSummary.js +186 -0
  114. package/lib/core/aside.js +54 -0
  115. package/lib/core/askDecision.js +60 -0
  116. package/lib/core/auth.js +164 -13
  117. package/lib/core/callerSource.js +1 -1
  118. package/lib/core/cliBuild.js +62 -0
  119. package/lib/core/client.js +144 -35
  120. package/lib/core/commandRegistrars.js +121 -0
  121. package/lib/core/config.js +142 -24
  122. package/lib/core/customHats.js +168 -0
  123. package/lib/core/doneEngineFetch.js +8 -0
  124. package/lib/core/errors.js +329 -20
  125. package/lib/core/global-flags.js +105 -5
  126. package/lib/core/hyperion.js +102 -0
  127. package/lib/core/inboxResolution.js +65 -0
  128. package/lib/core/lazyCommands.js +4 -1
  129. package/lib/core/offload.js +504 -0
  130. package/lib/core/org-override.js +1 -1
  131. package/lib/core/orgResolve.js +70 -0
  132. package/lib/core/output.js +4 -4
  133. package/lib/core/peek.js +126 -0
  134. package/lib/core/profileUsers.js +51 -0
  135. package/lib/core/promptCancel.js +18 -0
  136. package/lib/core/repoPath.js +11 -0
  137. package/lib/core/residentSeed.js +3 -1
  138. package/lib/core/rootOptions.js +39 -0
  139. package/lib/core/router.js +168 -0
  140. package/lib/core/selfUpdate.js +188 -0
  141. package/lib/core/sessionsOverview.js +64 -0
  142. package/lib/core/slotKeeper.js +248 -3
  143. package/lib/core/slotWake.js +120 -0
  144. package/lib/core/supervisorEngine.js +71 -2
  145. package/lib/core/version.js +1 -1
  146. package/lib/core/vm.js +361 -0
  147. package/lib/core/vmTui.js +86 -0
  148. package/lib/core/walkthrough.js +35 -0
  149. package/lib/hyperion/deck.js +29 -0
  150. package/lib/lucid/LucidCard.jsx +68 -0
  151. package/lib/lucid/LucidIntro.jsx +45 -58
  152. package/lib/lucid/cards.js +86 -0
  153. package/lib/lucid/markdown.js +81 -0
  154. package/lib/lucid/motion.js +56 -0
  155. package/lib/quoxon/activity.js +14 -2
  156. package/lib/quoxon/aocl.js +117 -0
  157. package/lib/quoxon/api.js +504 -25
  158. package/lib/quoxon/app.js +3000 -230
  159. package/lib/quoxon/approvalFacts.js +94 -0
  160. package/lib/quoxon/approvalsView.js +325 -0
  161. package/lib/quoxon/asideView.js +40 -0
  162. package/lib/quoxon/ask.js +121 -0
  163. package/lib/quoxon/away.js +52 -0
  164. package/lib/quoxon/bang.js +224 -0
  165. package/lib/quoxon/bulkRisk.js +17 -0
  166. package/lib/quoxon/capabilities.js +13 -6
  167. package/lib/quoxon/cells.js +53 -0
  168. package/lib/quoxon/commandShort.js +112 -0
  169. package/lib/quoxon/commands.js +363 -38
  170. package/lib/quoxon/connect.js +210 -23
  171. package/lib/quoxon/connectCard.js +19 -2
  172. package/lib/quoxon/consent.js +86 -0
  173. package/lib/quoxon/copy.js +4 -3
  174. package/lib/quoxon/countdownLine.js +11 -0
  175. package/lib/quoxon/demo.autoplay.js +29 -0
  176. package/lib/quoxon/demo.fixtures.js +1 -1
  177. package/lib/quoxon/demo.js +44 -3
  178. package/lib/quoxon/devObserver.js +20 -0
  179. package/lib/quoxon/dividerAocl.js +191 -0
  180. package/lib/quoxon/dividerRule.js +57 -0
  181. package/lib/quoxon/dragonModes.js +150 -0
  182. package/lib/quoxon/dragonView.js +41 -0
  183. package/lib/quoxon/earlyInput.js +40 -0
  184. package/lib/quoxon/echo.js +15 -0
  185. package/lib/quoxon/egg.js +2 -2
  186. package/lib/quoxon/events.js +244 -25
  187. package/lib/quoxon/feedback.js +50 -0
  188. package/lib/quoxon/gallery.js +21 -7
  189. package/lib/quoxon/handoffOffer.js +78 -0
  190. package/lib/quoxon/hatSwitch.js +37 -0
  191. package/lib/quoxon/headless.js +161 -0
  192. package/lib/quoxon/hostUrls.js +41 -0
  193. package/lib/quoxon/indicator.js +6 -2
  194. package/lib/quoxon/intent.js +12 -1
  195. package/lib/quoxon/keys.js +49 -6
  196. package/lib/quoxon/launcher.js +103 -0
  197. package/lib/quoxon/main.js +92 -160
  198. package/lib/quoxon/markdown.js +45 -21
  199. package/lib/quoxon/markdownSettle.js +19 -0
  200. package/lib/quoxon/matrixDomains.json +46 -0
  201. package/lib/quoxon/mentions.js +90 -0
  202. package/lib/quoxon/mind.js +103 -0
  203. package/lib/quoxon/mode.js +46 -0
  204. package/lib/quoxon/model.js +132 -26
  205. package/lib/quoxon/modelPrefs.js +345 -0
  206. package/lib/quoxon/motion/clock.js +3 -2
  207. package/lib/quoxon/motion/level.js +91 -0
  208. package/lib/quoxon/mouse.js +211 -0
  209. package/lib/quoxon/noai.js +57 -0
  210. package/lib/quoxon/objects/away.js +72 -0
  211. package/lib/quoxon/objects/fetch.js +17 -5
  212. package/lib/quoxon/objects/fleet.js +149 -0
  213. package/lib/quoxon/objects/health.js +186 -0
  214. package/lib/quoxon/objects/index.js +24 -3
  215. package/lib/quoxon/objects/mirror.js +137 -0
  216. package/lib/quoxon/objects/peek.js +17 -0
  217. package/lib/quoxon/objects/prove.js +130 -0
  218. package/lib/quoxon/objects/replay.js +125 -0
  219. package/lib/quoxon/objects/sitrep.js +58 -0
  220. package/lib/quoxon/objects/view.js +11 -0
  221. package/lib/quoxon/onboarding/copy.js +32 -4
  222. package/lib/quoxon/onboarding/credential.js +17 -4
  223. package/lib/quoxon/onboarding/dockerOffer.js +45 -0
  224. package/lib/quoxon/onboarding/failureCause.js +33 -0
  225. package/lib/quoxon/onboarding/finish.js +15 -0
  226. package/lib/quoxon/onboarding/index.js +34 -7
  227. package/lib/quoxon/onboarding/installRunner.js +64 -5
  228. package/lib/quoxon/onboarding/preflight.js +17 -3
  229. package/lib/quoxon/onboarding/probe.js +65 -0
  230. package/lib/quoxon/onboarding/view.js +76 -19
  231. package/lib/quoxon/orgLabel.js +10 -0
  232. package/lib/quoxon/orgSwitch.js +33 -0
  233. package/lib/quoxon/ownBroadcast.js +22 -0
  234. package/lib/quoxon/palette.js +43 -0
  235. package/lib/quoxon/print.js +9 -5
  236. package/lib/quoxon/profileSwitch.js +53 -0
  237. package/lib/quoxon/promptHistory.js +41 -0
  238. package/lib/quoxon/providers.js +48 -0
  239. package/lib/quoxon/qart.js +20 -51
  240. package/lib/quoxon/qdragon.js +410 -0
  241. package/lib/quoxon/qdragonFly.js +61 -0
  242. package/lib/quoxon/qdragonScenes.js +282 -0
  243. package/lib/quoxon/qeyes.js +213 -0
  244. package/lib/quoxon/qmotion.js +58 -37
  245. package/lib/quoxon/render.js +638 -119
  246. package/lib/quoxon/resend.js +72 -0
  247. package/lib/quoxon/roster.js +95 -9
  248. package/lib/quoxon/route.js +6 -5
  249. package/lib/quoxon/rows.js +711 -0
  250. package/lib/quoxon/rowsStore.js +80 -0
  251. package/lib/quoxon/screenContext.js +150 -0
  252. package/lib/quoxon/screenFrames.js +254 -0
  253. package/lib/quoxon/session.js +172 -2
  254. package/lib/quoxon/sessionDead.js +17 -0
  255. package/lib/quoxon/signin.js +125 -0
  256. package/lib/quoxon/slash/agents.js +112 -0
  257. package/lib/quoxon/slash/argSources.js +65 -0
  258. package/lib/quoxon/slash/args.js +65 -0
  259. package/lib/quoxon/slash/automate.js +89 -0
  260. package/lib/quoxon/slash/broadcast.js +140 -0
  261. package/lib/quoxon/slash/broadcastRun.js +108 -0
  262. package/lib/quoxon/slash/card.js +30 -0
  263. package/lib/quoxon/slash/challenge.js +114 -0
  264. package/lib/quoxon/slash/cliCommand.js +53 -0
  265. package/lib/quoxon/slash/cliLeaves.json +948 -0
  266. package/lib/quoxon/slash/compact.js +1 -1
  267. package/lib/quoxon/slash/confirm.js +30 -0
  268. package/lib/quoxon/slash/crystallize.js +69 -0
  269. package/lib/quoxon/slash/dashboard.js +136 -0
  270. package/lib/quoxon/slash/draft.js +54 -0
  271. package/lib/quoxon/slash/hatWalk.js +206 -0
  272. package/lib/quoxon/slash/inverses.js +77 -0
  273. package/lib/quoxon/slash/killer.js +24 -0
  274. package/lib/quoxon/slash/killerApi.js +16 -0
  275. package/lib/quoxon/slash/ledger.js +13 -8
  276. package/lib/quoxon/slash/listView.js +3 -1
  277. package/lib/quoxon/slash/modelCmd.js +261 -0
  278. package/lib/quoxon/slash/palette.js +21 -7
  279. package/lib/quoxon/slash/paletteController.js +117 -7
  280. package/lib/quoxon/slash/panels.js +21 -6
  281. package/lib/quoxon/slash/picker.js +20 -2
  282. package/lib/quoxon/slash/restart.js +71 -13
  283. package/lib/quoxon/slash/rowCmd.js +157 -0
  284. package/lib/quoxon/slash/rowWalk.js +175 -0
  285. package/lib/quoxon/slash/settings.js +16 -6
  286. package/lib/quoxon/slash/spawn.js +177 -0
  287. package/lib/quoxon/slash/stepUp.js +274 -0
  288. package/lib/quoxon/slash/stepUpCard.js +60 -0
  289. package/lib/quoxon/slash/stepUpCmds.js +78 -0
  290. package/lib/quoxon/slash/undoApi.js +38 -0
  291. package/lib/quoxon/slash/undoCmd.js +49 -0
  292. package/lib/quoxon/slash/undoWish.js +15 -0
  293. package/lib/quoxon/slash/watch.js +164 -0
  294. package/lib/quoxon/slash/wish.js +23 -0
  295. package/lib/quoxon/slash/wishCmd.js +32 -0
  296. package/lib/quoxon/slotChat.js +300 -0
  297. package/lib/quoxon/slotFormat.js +195 -0
  298. package/lib/quoxon/slotRun.js +131 -0
  299. package/lib/quoxon/sound/mapping.js +2 -1
  300. package/lib/quoxon/stray.js +22 -0
  301. package/lib/quoxon/suggestCommand.js +59 -0
  302. package/lib/quoxon/term.js +46 -11
  303. package/lib/quoxon/textBuffer.js +176 -2
  304. package/lib/quoxon/theme.js +58 -25
  305. package/lib/quoxon/tips.js +433 -0
  306. package/lib/quoxon/toolError.js +41 -0
  307. package/lib/quoxon/transcript.js +42 -4
  308. package/lib/quoxon/web/qface.js +18 -10
  309. package/lib/quoxon/welcome.js +1 -1
  310. package/lib/quoxon/whatsnew.js +136 -0
  311. package/lib/quoxon/whatsnew.jsonl +89 -0
  312. package/lib/quoxon/windowOrg.js +113 -0
  313. package/lib/server/diag.js +129 -0
  314. package/lib/server/doctor.js +71 -0
  315. package/lib/server/install.js +149 -0
  316. package/lib/server/pluginEnable.js +161 -0
  317. package/lib/server/skew.js +56 -0
  318. package/lib/server/snapshot.js +131 -0
  319. package/lib/server/status.js +128 -0
  320. package/lib/server/sys.js +65 -0
  321. package/lib/server/uninstall.js +63 -0
  322. package/lib/server/update.js +234 -0
  323. package/lib/toolOutputShape.js +315 -0
  324. package/lib/tui/views/HyperionDeck.jsx +79 -0
  325. package/lib/tui/views/LucidDeck.jsx +209 -217
  326. package/package.json +4 -1
  327. package/lib/core/websiteSession.js +0 -148
@@ -0,0 +1,128 @@
1
+ /**
2
+ * C4: `quox server status` and /server. Read-only: version, health per service, disk, ports, last update.
3
+ * Anything that could not be read is said as such, never filled in.
4
+ */
5
+ import { readFileSync, writeFileSync, mkdirSync, appendFileSync, renameSync } from 'node:fs';
6
+ import { join, dirname } from 'node:path';
7
+ import { readInstalled } from './install.js';
8
+
9
+ const STATE_FILE = 'lifecycle-state.json';
10
+ const HISTORY_FILE = 'history.jsonl';
11
+
12
+ export function readState(root) { try { return JSON.parse(readFileSync(join(root, STATE_FILE), 'utf8')); } catch { return null; } }
13
+ export function writeState(root, state) {
14
+ mkdirSync(root, { recursive: true });
15
+ const p = join(root, STATE_FILE); const tmp = `${p}.${process.pid}.tmp`;
16
+ writeFileSync(tmp, JSON.stringify(state, null, 2) + '\n', { mode: 0o600 });
17
+ renameSync(tmp, p);
18
+ }
19
+ export function appendHistory(root, event) {
20
+ mkdirSync(root, { recursive: true });
21
+ appendFileSync(join(root, HISTORY_FILE), JSON.stringify(event) + '\n', { mode: 0o600 });
22
+ }
23
+ export function readHistory(root) {
24
+ try { return readFileSync(join(root, HISTORY_FILE), 'utf8').split('\n').filter(Boolean).map((l) => { try { return JSON.parse(l); } catch { return null; } }).filter(Boolean); } catch { return []; }
25
+ }
26
+
27
+ /** `docker compose ps --format json` is a JSON array on some versions and NDJSON on others. */
28
+ export function parseComposePs(text) {
29
+ const t = String(text || '').trim();
30
+ if (!t) return [];
31
+ try { const j = JSON.parse(t); return Array.isArray(j) ? j : [j]; } catch { /* NDJSON */ }
32
+ return t.split('\n').map((l) => { try { return JSON.parse(l); } catch { return null; } }).filter(Boolean);
33
+ }
34
+
35
+ export const compose = (install, args) => ['compose', '-p', install.project, ...args];
36
+
37
+ export async function composeServices(install, sys) {
38
+ const r = await sys.run('docker', compose(install, ['ps', '-a', '--format', 'json']), { cwd: install.repoDir });
39
+ if (r.code !== 0) return { ok: false, error: (r.stderr || '').trim().split('\n').pop() || `docker compose ps exited ${r.code}` };
40
+ return {
41
+ ok: true,
42
+ services: parseComposePs(r.stdout).map((s) => ({
43
+ service: s.Service || s.Name,
44
+ name: s.Name,
45
+ state: s.State || '?',
46
+ health: s.Health || null,
47
+ ports: [...new Set((s.Publishers || []).filter((p) => p.PublishedPort).map((p) => `${p.URL && p.URL !== '0.0.0.0' && p.URL !== '::' ? p.URL + ':' : ''}${p.PublishedPort}->${p.TargetPort}`))]
48
+ })).sort((a, b) => String(a.service).localeCompare(String(b.service)))
49
+ };
50
+ }
51
+
52
+ export async function diskFree(path, sys) {
53
+ const r = await sys.run('df', ['-Pk', path]);
54
+ if (r.code !== 0) return { ok: false, error: `df exited ${r.code}` };
55
+ const line = r.stdout.trim().split('\n').pop().split(/\s+/);
56
+ const availKb = Number(line[3]); const sizeKb = Number(line[1]);
57
+ if (!Number.isFinite(availKb)) return { ok: false, error: 'could not read df output' };
58
+ return { ok: true, path, freeGB: +(availKb / 1048576).toFixed(1), sizeGB: +(sizeKb / 1048576).toFixed(1), usedPct: line[4] };
59
+ }
60
+
61
+ async function getJson(url, fetchImpl, timeout = 3000) {
62
+ try {
63
+ const r = await fetchImpl(url, { signal: AbortSignal.timeout(timeout) });
64
+ if (!r.ok) return { ok: false, status: r.status, error: `HTTP ${r.status}` };
65
+ let body = null; try { body = await r.json(); } catch { /* health may be text */ }
66
+ return { ok: true, status: r.status, body };
67
+ } catch (e) { return { ok: false, error: e?.cause?.code || e?.message || 'no answer' }; }
68
+ }
69
+
70
+ /** The running build, from the collector's public /version. */
71
+ export async function runningVersion(collectorUrl, fetchImpl = globalThis.fetch) {
72
+ const r = await getJson(`${collectorUrl.replace(/\/+$/, '')}/version`, fetchImpl);
73
+ if (!r.ok) return { ok: false, error: r.error };
74
+ return { ok: true, commit: r.body?.version || null, release: r.body?.release || null, stamped: r.body?.stamped ?? null };
75
+ }
76
+
77
+ export async function probeHttp(urls, fetchImpl = globalThis.fetch) {
78
+ return Promise.all(Object.entries(urls).map(async ([name, base]) => {
79
+ const t0 = Date.now();
80
+ const r = await getJson(`${base.replace(/\/+$/, '')}/health`, fetchImpl);
81
+ return { name, url: base, ok: r.ok, detail: r.ok ? `HTTP ${r.status}` : r.error, ms: Date.now() - t0 };
82
+ }));
83
+ }
84
+
85
+ export function defaultUrls(config) {
86
+ const get = (s, fb) => { try { return config?.getServiceUrl?.(s) || fb; } catch { return fb; } };
87
+ return { auth: get('auth', 'http://127.0.0.1:3101'), collector: get('collector', 'http://127.0.0.1:9848') };
88
+ }
89
+
90
+ /** -> the full status object. Never throws for a missing piece. */
91
+ export async function gatherStatus({ install, sys, fetchImpl = globalThis.fetch, urls, backups }) {
92
+ const [installed, running, svc, disk, http] = await Promise.all([
93
+ readInstalled(install, sys),
94
+ runningVersion(urls.collector, fetchImpl),
95
+ composeServices(install, sys),
96
+ diskFree(install.root, sys),
97
+ probeHttp(urls, fetchImpl)
98
+ ]);
99
+ const hist = backups ? readHistory(backups) : [];
100
+ const lastUpdate = [...hist].reverse().find((e) => e.op === 'update' || e.op === 'rollback') || null;
101
+ const state = backups ? readState(backups) : null;
102
+ return { install, installed, running, services: svc, disk, http, lastUpdate, inFlight: state && state.status === 'running' ? state : null };
103
+ }
104
+
105
+ const svcIcon = (s) => (s.state === 'running' ? (s.health && s.health !== 'healthy' ? '⚠' : '✔') : '✖');
106
+
107
+ /** Rows for the terminal and for /server (kind/key/text like the other panels). */
108
+ export function statusRows(st) {
109
+ const row = (key, text) => ({ kind: 'row', key, text: String(text) });
110
+ const out = [{ kind: 'head', text: 'Server' }];
111
+ out.push(row('Install', `${st.install.repoDir} (found via ${st.install.source}, project ${st.install.project})`));
112
+ const i = st.installed;
113
+ out.push(row('Version', `${i.release || '? no VERSION file'}${i.tag ? ` (${i.tag})` : ''} · checkout ${i.commit || '?'}${i.dirty ? ' · local changes' : ''}`));
114
+ const rv = st.running;
115
+ out.push(row('Running', rv.ok ? `${rv.commit || '?'}${rv.stamped === false ? ' (unstamped build)' : ''}${rv.commit && i.commit && !String(rv.commit).startsWith(i.commit) && !String(i.commit).startsWith(rv.commit) ? ' · differs from the checkout' : ''}` : `? ${rv.error}`));
116
+ for (const h of st.http) out.push(row(`${h.ok ? '✔' : '✖'} ${h.name}`, `${h.url} · ${h.detail} · ${h.ms}ms`));
117
+ if (!st.services.ok) out.push(row('✖ Services', `? ${st.services.error}`));
118
+ else {
119
+ const up = st.services.services.filter((s) => s.state === 'running').length;
120
+ out.push(row('Services', `${up} of ${st.services.services.length} running`));
121
+ for (const s of st.services.services) out.push({ kind: s.state === 'running' && (!s.health || s.health === 'healthy') ? 'dim' : 'row', key: ` ${svcIcon(s)} ${s.service}`, text: [s.state + (s.health ? ` (${s.health})` : ''), s.ports.join(' ')].filter(Boolean).join(' · ') });
122
+ }
123
+ out.push(row('Disk', st.disk.ok ? `${st.disk.freeGB} GB free of ${st.disk.sizeGB} GB (${st.disk.usedPct} used) at ${st.disk.path}` : `? ${st.disk.error}`));
124
+ const lu = st.lastUpdate;
125
+ out.push(row('Last update', lu ? `${lu.op} ${lu.status} ${lu.at}${lu.from ? ` · ${lu.from} -> ${lu.to || '?'}` : ''}` : 'none recorded by quox server update'));
126
+ if (st.inFlight) out.push(row('⚠ In flight', `an update to ${st.inFlight.target} stopped after ${st.inFlight.done?.slice(-1)[0] || 'preflight'}; quox server update resumes it`));
127
+ return out;
128
+ }
@@ -0,0 +1,65 @@
1
+ /**
2
+ * C4 lifecycle: the one place that runs host commands (docker, docker compose, git, df, tar, openssl).
3
+ *
4
+ * Every lifecycle module takes a `sys` and never spawns on its own, so tests pass a stub and a dry run
5
+ * can prove it never reached a mutating command. A call marked { ro: true } is read-only and still runs
6
+ * in a dry run (it is how the plan learns what is there); anything else is recorded, never run, when
7
+ * dry. That rule lives here, once, in `gate()`.
8
+ */
9
+ import { spawn as defaultSpawn } from 'node:child_process';
10
+ import { createWriteStream, createReadStream } from 'node:fs';
11
+
12
+ /** -> { run(cmd, args, opts) -> Promise<{ code, stdout, stderr }> } */
13
+ export function createSys({ spawnImpl = defaultSpawn } = {}) {
14
+ return {
15
+ kind: 'real',
16
+ run(cmd, args = [], { cwd, env, stdoutFile, stdinFile, stdinText, timeoutMs = 0 } = {}) {
17
+ return new Promise((resolve) => {
18
+ let child;
19
+ try {
20
+ child = spawnImpl(cmd, args, { cwd, env: env ? { ...process.env, ...env } : process.env, stdio: [stdinFile || typeof stdinText === 'string' ? 'pipe' : 'ignore', 'pipe', 'pipe'] });
21
+ } catch (e) { resolve({ code: 127, stdout: '', stderr: e.message }); return; }
22
+ let stdout = ''; let stderr = '';
23
+ const sink = stdoutFile ? createWriteStream(stdoutFile, { mode: 0o600 }) : null;
24
+ if (sink) child.stdout.pipe(sink); else child.stdout.on('data', (d) => { if (stdout.length < 4e6) stdout += d; });
25
+ child.stderr.on('data', (d) => { if (stderr.length < 1e6) stderr += d; });
26
+ if (stdinFile) createReadStream(stdinFile).pipe(child.stdin);
27
+ else if (typeof stdinText === 'string') child.stdin.end(stdinText); // secrets via stdin, never argv
28
+ const timer = timeoutMs ? setTimeout(() => { try { child.kill('SIGKILL'); } catch { /* gone */ } }, timeoutMs) : null;
29
+ child.on('error', (e) => { clearTimeout(timer); resolve({ code: 127, stdout, stderr: stderr || e.message }); });
30
+ child.on('close', (code, sig) => {
31
+ clearTimeout(timer);
32
+ const done = () => resolve({ code: sig ? 128 : code, stdout, stderr });
33
+ if (sink) sink.end(done); else done();
34
+ });
35
+ });
36
+ }
37
+ };
38
+ }
39
+
40
+ export const show = (cmd, args = []) => [cmd, ...args].map((a) => (/^[\w./:=@%+,-]+$/.test(a) ? a : `'${String(a).replace(/'/g, `'\\''`)}'`)).join(' ');
41
+
42
+ /**
43
+ * A step runner over sys: ro calls always run; mutating calls run only when !dryRun, else they are
44
+ * recorded in `planned` and answered { code: 0, dry: true }. log(line) receives every command shown.
45
+ */
46
+ export function gate(sys, { dryRun = false, log = () => {} } = {}) {
47
+ const planned = [];
48
+ return {
49
+ dryRun,
50
+ planned,
51
+ async ro(cmd, args, opts) { return sys.run(cmd, args, opts); },
52
+ async mut(cmd, args, opts = {}) {
53
+ const line = show(cmd, args) + (opts.stdoutFile ? ` > ${opts.stdoutFile}` : '') + (opts.stdinFile ? ` < ${opts.stdinFile}` : '');
54
+ if (dryRun) { planned.push(line); log(`[dry run] ${line}`); return { code: 0, stdout: '', stderr: '', dry: true }; }
55
+ log(`$ ${line}`);
56
+ return sys.run(cmd, args, opts);
57
+ },
58
+ /** A filesystem change (or any non-command effect): described, and only performed when !dryRun. */
59
+ async fx(desc, fn) {
60
+ if (dryRun) { planned.push(desc); log(`[dry run] ${desc}`); return undefined; }
61
+ log(desc);
62
+ return fn();
63
+ }
64
+ };
65
+ }
@@ -0,0 +1,63 @@
1
+ /**
2
+ * C4: `quox server uninstall`. Lists exactly what goes first; two-step confirmation (the command layer);
3
+ * keeps backups unless --purge, and takes a final snapshot first unless --no-backup or --purge.
4
+ */
5
+ import { existsSync, readdirSync, rmSync } from 'node:fs';
6
+ import { homedir } from 'node:os';
7
+ import { join, resolve } from 'node:path';
8
+ import { SIBLINGS } from './install.js';
9
+ import { compose, composeServices } from './status.js';
10
+ import { createSnapshot, listSnapshots, stackVolumes } from './snapshot.js';
11
+ import { readInstalled } from './install.js';
12
+ import { gate } from './sys.js';
13
+
14
+ const DANGEROUS = (p, home) => {
15
+ const r = resolve(p);
16
+ return r === '/' || r === resolve(home) || r.split('/').filter(Boolean).length < 2;
17
+ };
18
+
19
+ /** Everything that will be removed and kept. Read-only. */
20
+ export async function planUninstall({ install, sys, backups, purge = false, journeyFile = join(homedir(), '.config', 'quox', 'onboarding.json'), home = homedir() }) {
21
+ const svc = await composeServices(install, sys);
22
+ const vols = await stackVolumes(install, (c, a, o) => sys.run(c, a, o));
23
+ const imgs = await sys.run('docker', compose(install, ['config', '--images']), { cwd: install.repoDir });
24
+ const dirs = [install.repoDir, ...SIBLINGS.map((s) => join(install.root, s)).filter((d) => existsSync(d))];
25
+ const refused = dirs.filter((d) => DANGEROUS(d, home));
26
+ let rootToo = false;
27
+ try { rootToo = !DANGEROUS(install.root, home) && readdirSync(install.root).every((f) => dirs.includes(join(install.root, f))); } catch { /* unreadable */ }
28
+ const snaps = listSnapshots(backups);
29
+ const backupsInside = dirs.some((d) => resolve(backups).startsWith(resolve(d) + '/')) || (rootToo && resolve(backups).startsWith(resolve(install.root) + '/'));
30
+ return {
31
+ backupsInside,
32
+ containers: svc.ok ? svc.services.map((s) => s.name) : [],
33
+ containersError: svc.ok ? null : svc.error,
34
+ volumes: vols.volumes,
35
+ images: imgs.code === 0 ? [...new Set(imgs.stdout.split('\n').map((s) => s.trim()).filter(Boolean))] : [],
36
+ networks: `the ${install.project} compose networks`,
37
+ dirs: dirs.filter((d) => !refused.includes(d)),
38
+ root: rootToo ? install.root : null,
39
+ refused,
40
+ journeyFile: existsSync(journeyFile) ? journeyFile : null,
41
+ backups: { dir: backups, count: snaps.length, removed: purge }
42
+ };
43
+ }
44
+
45
+ /** Executes the plan. Confirmation is the caller's job and must have happened already. */
46
+ export async function runUninstall({ install, sys, backups, purge = false, noBackup = false, dryRun = false, log = () => {}, plan, now }) {
47
+ const g = gate(sys, { dryRun, log });
48
+ if (plan.backupsInside && !purge) return { ok: false, step: 'plan', error: `the backups directory ${backups} sits inside the install, so removing the install would delete them. Set QUOX_SERVER_BACKUPS to a path outside it, or pass --purge.` };
49
+ let finalSnapshot = null;
50
+ if (!noBackup && !purge) {
51
+ const installed = await readInstalled(install, sys);
52
+ const snap = await createSnapshot({ install, installed, backups, g, reason: 'before uninstall', now, log });
53
+ if (!snap.ok) return { ok: false, step: 'backup', error: `${snap.error}. Nothing was removed; pass --no-backup to uninstall without a final snapshot.` };
54
+ finalSnapshot = snap.dir;
55
+ }
56
+ const r = await g.mut('docker', compose(install, ['down', '--volumes', '--rmi', 'local', '--remove-orphans']), { cwd: install.repoDir });
57
+ if (r.code !== 0) return { ok: false, step: 'down', error: `docker compose down failed (exit ${r.code}); the install directory was left in place` };
58
+ for (const d of plan.dirs) await g.fx(`remove ${d}`, () => rmSync(d, { recursive: true, force: true }));
59
+ if (plan.root) await g.fx(`remove ${plan.root} (now empty)`, () => rmSync(plan.root, { recursive: true, force: true }));
60
+ if (plan.journeyFile) await g.fx(`remove ${plan.journeyFile}`, () => rmSync(plan.journeyFile, { force: true }));
61
+ if (purge) await g.fx(`remove every backup in ${backups}`, () => rmSync(backups, { recursive: true, force: true }));
62
+ return { ok: true, dryRun, finalSnapshot, planned: g.planned };
63
+ }
@@ -0,0 +1,234 @@
1
+ /**
2
+ * C4: `quox server update` and `quox server rollback`.
3
+ *
4
+ * update: preflight -> backup -> fetch -> build -> migrate -> health, with the state written after every
5
+ * step (resumable: a re-run continues after the last completed step). A failure from fetch on
6
+ * rolls back automatically: before `migrate` only the code moves back (data was never touched);
7
+ * from `migrate` on, the full rollback restores the pre-update snapshot.
8
+ * rollback: previous snapshot + previous version: checkout, build, restore DB + config, start, health.
9
+ *
10
+ * Migrations run on service boot (each service applies its own on start), so `migrate` = `up -d`, and
11
+ * `health` proves they finished: every service answers and the collector's /version is the new commit.
12
+ */
13
+ import { join } from 'node:path';
14
+ import { existsSync } from 'node:fs';
15
+ import { readInstalled, readEnvKeys, fetchAvailable, cmpSemver, parseSemver, vtag, compareUrl, releaseNotesUrl, PUBLIC_REPO, SIBLINGS } from './install.js';
16
+ import { readState, writeState, appendHistory, compose, diskFree, runningVersion, probeHttp } from './status.js';
17
+ import { createSnapshot, restoreSnapshot, latestSnapshot, listSnapshots } from './snapshot.js';
18
+ import { gate } from './sys.js';
19
+
20
+ export const UPDATE_STEPS = ['preflight', 'backup', 'fetch', 'build', 'migrate', 'health'];
21
+ export const MIN_FREE_GB = 5;
22
+
23
+ const sleepDefault = (ms) => new Promise((r) => setTimeout(r, ms));
24
+ const lastLine = (s) => String(s || '').trim().split('\n').pop();
25
+
26
+ /** Poll auth + collector /health, and (when known) the collector /version == expectCommit. */
27
+ export async function waitHealthy({ urls, fetchImpl, expectCommit = null, tries = 40, intervalMs = 3000, sleep = sleepDefault, log = () => {} }) {
28
+ let last = '';
29
+ for (let i = 0; i < Math.max(1, tries); i++) {
30
+ const http = await probeHttp(urls, fetchImpl);
31
+ const bad = http.filter((h) => !h.ok);
32
+ if (!bad.length) {
33
+ if (!expectCommit) return { ok: true };
34
+ const v = await runningVersion(urls.collector, fetchImpl);
35
+ if (v.ok && v.commit && (String(v.commit).startsWith(expectCommit) || String(expectCommit).startsWith(v.commit))) return { ok: true, commit: v.commit };
36
+ last = v.ok ? `collector reports ${v.commit}, expected ${expectCommit}` : `collector /version: ${v.error}`;
37
+ } else last = bad.map((h) => `${h.name}: ${h.detail}`).join(', ');
38
+ log(`waiting for health: ${last}`);
39
+ if (i + 1 < tries) await sleep(intervalMs);
40
+ }
41
+ return { ok: false, error: last || 'not healthy' };
42
+ }
43
+
44
+ async function checkoutCode({ install, g, ref, siblings = {}, pullSiblings = false, log }) {
45
+ const r = await g.mut('git', ['-C', install.repoDir, 'checkout', '--detach', ref]);
46
+ if (r.code !== 0) return { ok: false, error: `git checkout ${ref} failed: ${lastLine(r.stderr)}` };
47
+ for (const s of SIBLINGS) {
48
+ const d = join(install.root, s);
49
+ if (!existsSync(join(d, '.git'))) continue;
50
+ const sr = pullSiblings ? await g.mut('git', ['-C', d, 'pull', '--ff-only'])
51
+ : siblings[s] ? await g.mut('git', ['-C', d, 'checkout', '--detach', siblings[s]]) : { code: 0 };
52
+ if (sr.code !== 0) log(`warning: ${s} did not move (${lastLine(sr.stderr) || `exit ${sr.code}`}); it stays where it was`);
53
+ }
54
+ return { ok: true };
55
+ }
56
+
57
+ async function composeBuild({ install, g, commit }) {
58
+ const gid = readEnvKeys(install.repoDir, ['DOCKER_GID']).DOCKER_GID;
59
+ const r = await g.mut('docker', compose(install, ['build']), { cwd: install.repoDir, env: { QUOXCORE_VERSION: commit, ...(gid ? { DOCKER_GID: gid } : {}) } });
60
+ return r.code === 0 ? { ok: true } : { ok: false, error: `docker compose build failed (exit ${r.code}): ${lastLine(r.stderr)}` };
61
+ }
62
+
63
+ async function composeUp({ install, g }) {
64
+ const r = await g.mut('docker', compose(install, ['up', '-d', '--remove-orphans']), { cwd: install.repoDir });
65
+ return r.code === 0 ? { ok: true } : { ok: false, error: `docker compose up -d failed (exit ${r.code}): ${lastLine(r.stderr)}` };
66
+ }
67
+
68
+ /**
69
+ * Plan + run an update. opts: { install, sys, backups, urls, fetchImpl, dryRun, to, force, restart, noRollback,
70
+ * log, now, healthTries, healthIntervalMs, sleep, repo }
71
+ * -> { ok, upToDate?, dryRun?, plan, planned?, failedStep?, error?, rolledBack?, rollback? }
72
+ */
73
+ export async function runUpdate(opts) {
74
+ const { install, sys, backups, urls, fetchImpl = globalThis.fetch, dryRun = false, force = false, restart = false, noRollback = false,
75
+ log = () => {}, now = () => new Date(), healthTries = 40, healthIntervalMs = 3000, sleep = sleepDefault, repo = PUBLIC_REPO } = opts;
76
+ const g = gate(sys, { dryRun, log });
77
+
78
+ // ---- preflight (read-only) ----------------------------------------------------------------------
79
+ const installed = await readInstalled(install, sys);
80
+ let target = opts.to ? vtag(opts.to) : null;
81
+ if (!target) {
82
+ const av = await fetchAvailable({ fetchImpl, repo });
83
+ if (!av.ok) return { ok: false, failedStep: 'preflight', error: `could not read the available versions: ${av.error}` };
84
+ target = av.latest;
85
+ } else if (!parseSemver(target)) return { ok: false, failedStep: 'preflight', error: `--to ${opts.to} is not a version like 4.3.0` };
86
+ const current = installed.release ? vtag(installed.release) : null;
87
+ const plan = {
88
+ current, currentCommit: installed.commit, target,
89
+ changes: current ? compareUrl(current, target, repo) : null, releaseNotes: releaseNotesUrl(target, repo),
90
+ backups, steps: UPDATE_STEPS
91
+ };
92
+ const cmp = current ? cmpSemver(target, current) : 1;
93
+ if (cmp !== null && cmp <= 0 && !force) return { ok: true, upToDate: true, plan };
94
+
95
+ const problems = [];
96
+ const forceable = [];
97
+ if (installed.dirty) forceable.push(`the checkout at ${install.repoDir} has local changes (git status); commit or discard them, or pass --force`);
98
+ if (installed.dirty === null) problems.push(`${install.repoDir} is not a git checkout, so it cannot be moved to ${target}`);
99
+ const disk = await diskFree(install.root, sys);
100
+ if (disk.ok && disk.freeGB < MIN_FREE_GB) forceable.push(`only ${disk.freeGB} GB free at ${install.root}; an update needs at least ${MIN_FREE_GB} GB (backup + images)`);
101
+ const dv = await sys.run('docker', ['compose', 'version', '--short']);
102
+ if (dv.code !== 0) problems.push('docker compose is not available to this user');
103
+ plan.problems = [...problems, ...forceable];
104
+ if (problems.length || (forceable.length && !force)) return { ok: false, failedStep: 'preflight', error: plan.problems.join('; '), plan };
105
+
106
+ // ---- resume -------------------------------------------------------------------------------------
107
+ const prev = readState(backups);
108
+ let state = null;
109
+ if (prev && prev.op === 'update' && prev.status === 'running') {
110
+ if (prev.target !== target && !restart) {
111
+ return { ok: false, failedStep: 'preflight', error: `an update to ${prev.target} stopped after ${prev.done.slice(-1)[0] || 'preflight'}. Re-run without --to to resume it, or pass --restart to abandon it.`, plan };
112
+ }
113
+ if (prev.target === target) {
114
+ state = prev;
115
+ plan.resumedAfter = state.done.slice(-1)[0] || 'preflight';
116
+ log(`resuming the update to ${target} after ${plan.resumedAfter}`);
117
+ }
118
+ }
119
+ if (!state) state = { op: 'update', status: 'running', target, from: { release: installed.release, commit: installed.commit, siblings: installed.siblings }, done: ['preflight'], snapshot: null, startedAt: now().toISOString() };
120
+ const save = () => { if (!dryRun) writeState(backups, { ...state, updatedAt: now().toISOString() }); };
121
+ const mark = (s) => { if (!state.done.includes(s)) state.done.push(s); save(); };
122
+ const doneStep = (s) => state.done.includes(s);
123
+ const finish = (status, extra = {}) => {
124
+ state.status = status; save();
125
+ if (!dryRun) appendHistory(backups, { op: 'update', status, at: now().toISOString(), from: state.from.release || state.from.commit, to: target, snapshot: state.snapshot, ...extra });
126
+ };
127
+ save();
128
+
129
+ const fail = async (step, error) => {
130
+ log(`✖ ${step}: ${error}`);
131
+ if (noRollback || dryRun) { finish('failed', { failedStep: step, error }); return { ok: false, failedStep: step, error, plan, rolledBack: false }; }
132
+ log('rolling back automatically');
133
+ let rb;
134
+ if (['fetch', 'build'].includes(step)) {
135
+ const c = await checkoutCode({ install, g, ref: state.from.commit, siblings: state.from.siblings, log });
136
+ const h = c.ok ? await waitHealthy({ urls, fetchImpl, tries: healthTries, intervalMs: healthIntervalMs, sleep, log }) : c;
137
+ rb = { ok: c.ok && h.ok, scope: 'code only (data was never touched)', error: c.ok ? h.error : c.error };
138
+ } else {
139
+ rb = await runRollback({ ...opts, snapshotDir: state.snapshot, fromUpdate: true });
140
+ rb.scope = 'code + data from the pre-update snapshot';
141
+ }
142
+ finish(rb.ok ? 'rolled-back' : 'rollback-failed', { failedStep: step, error });
143
+ return { ok: false, failedStep: step, error, plan, rolledBack: rb.ok, rollback: rb };
144
+ };
145
+
146
+ // ---- backup -------------------------------------------------------------------------------------
147
+ if (!doneStep('backup')) {
148
+ const snap = await createSnapshot({ install, installed, backups, g, reason: `before update to ${target}`, now, log });
149
+ if (!snap.ok) {
150
+ finish('failed', { failedStep: 'backup', error: snap.error });
151
+ return { ok: false, failedStep: 'backup', error: `${snap.error}. Nothing was changed.`, plan, rolledBack: false };
152
+ }
153
+ state.snapshot = snap.dir; mark('backup');
154
+ }
155
+ plan.snapshot = state.snapshot;
156
+
157
+ // ---- fetch --------------------------------------------------------------------------------------
158
+ if (!doneStep('fetch')) {
159
+ const tagRef = `refs/tags/${target}`;
160
+ const has = await g.ro('git', ['-C', install.repoDir, 'rev-parse', '-q', '--verify', tagRef]);
161
+ if (has.code !== 0) {
162
+ const f1 = await g.mut('git', ['-C', install.repoDir, 'fetch', '--tags', 'origin']);
163
+ const again = dryRun ? { code: 1 } : await g.ro('git', ['-C', install.repoDir, 'rev-parse', '-q', '--verify', tagRef]);
164
+ if (f1.code !== 0 || again.code !== 0) {
165
+ const f2 = await g.mut('git', ['-C', install.repoDir, 'fetch', `https://github.com/${repo}.git`, `+${tagRef}:${tagRef}`]);
166
+ if (f2.code !== 0) return fail('fetch', `could not fetch ${target} from origin or ${repo}`);
167
+ }
168
+ }
169
+ const c = await checkoutCode({ install, g, ref: target, pullSiblings: true, log });
170
+ if (!c.ok) return fail('fetch', c.error);
171
+ mark('fetch');
172
+ }
173
+
174
+ // ---- build --------------------------------------------------------------------------------------
175
+ const rc = await g.ro('git', ['-C', install.repoDir, 'rev-parse', '--short', `${target}^{commit}`]);
176
+ const newCommit = rc.code === 0 && rc.stdout.trim() ? rc.stdout.trim() : null;
177
+ plan.targetCommit = newCommit;
178
+ if (!doneStep('build')) {
179
+ const b = await composeBuild({ install, g, commit: newCommit || target });
180
+ if (!b.ok) return fail('build', b.error);
181
+ mark('build');
182
+ }
183
+
184
+ // ---- migrate (on boot) --------------------------------------------------------------------------
185
+ if (!doneStep('migrate')) {
186
+ const u = await composeUp({ install, g });
187
+ if (!u.ok) return fail('migrate', u.error);
188
+ mark('migrate');
189
+ }
190
+
191
+ // ---- health -------------------------------------------------------------------------------------
192
+ if (dryRun) {
193
+ log(`[dry run] would wait for auth + collector /health and the collector /version to report ${newCommit || target}`);
194
+ return { ok: true, dryRun: true, plan, planned: g.planned };
195
+ }
196
+ const h = await waitHealthy({ urls, fetchImpl, expectCommit: newCommit, tries: healthTries, intervalMs: healthIntervalMs, sleep, log });
197
+ if (!h.ok) return fail('health', h.error);
198
+ mark('health');
199
+ finish('ok');
200
+ return { ok: true, plan };
201
+ }
202
+
203
+ /** opts: { install, sys, backups, urls, fetchImpl, dryRun, snapshotDir, log, healthTries, ... } */
204
+ export async function runRollback(opts) {
205
+ const { install, sys, backups, urls, fetchImpl = globalThis.fetch, dryRun = false, log = () => {}, now = () => new Date(),
206
+ healthTries = 40, healthIntervalMs = 3000, sleep = sleepDefault, fromUpdate = false } = opts;
207
+ const g = gate(sys, { dryRun, log });
208
+ const snaps = listSnapshots(backups);
209
+ const snap = opts.snapshotDir ? snaps.find((s) => s.dir === opts.snapshotDir || s.dir.endsWith(`/${opts.snapshotDir}`)) : latestSnapshot(backups);
210
+ if (!snap) return { ok: false, error: opts.snapshotDir ? `no complete snapshot at ${opts.snapshotDir}` : `no snapshot in ${backups}; nothing to roll back to` };
211
+ const plan = { snapshot: snap.dir, release: snap.release, commit: snap.commit, createdAt: snap.createdAt };
212
+ const record = (status, extra = {}) => {
213
+ if (!dryRun && !fromUpdate) appendHistory(backups, { op: 'rollback', status, at: now().toISOString(), to: snap.release || snap.commit, snapshot: snap.dir, ...extra });
214
+ };
215
+ if (!snap.commit) return { ok: false, error: 'the snapshot does not record which commit it came from', plan };
216
+
217
+ const c = await checkoutCode({ install, g, ref: snap.commit, siblings: snap.siblings, log });
218
+ if (!c.ok) { record('failed', { error: c.error }); return { ok: false, step: 'checkout', error: c.error, plan }; }
219
+ const b = await composeBuild({ install, g, commit: snap.commit });
220
+ if (!b.ok) { record('failed', { error: b.error }); return { ok: false, step: 'build', error: b.error, plan }; }
221
+ const r = await restoreSnapshot({ install, snap, g, sleep });
222
+ if (!r.ok) { record('failed', { error: r.error }); return { ok: false, step: 'restore', error: r.error, plan }; }
223
+ const u = await composeUp({ install, g });
224
+ if (!u.ok) { record('failed', { error: u.error }); return { ok: false, step: 'start', error: u.error, plan }; }
225
+ if (dryRun) {
226
+ log(`[dry run] would wait for health and the collector /version to report ${snap.commit}`);
227
+ return { ok: true, dryRun: true, plan, planned: g.planned };
228
+ }
229
+ const h = await waitHealthy({ urls, fetchImpl, expectCommit: snap.commit, tries: healthTries, intervalMs: healthIntervalMs, sleep, log });
230
+ if (!h.ok) { record('failed', { error: h.error }); return { ok: false, step: 'health', error: h.error, plan }; }
231
+ if (!fromUpdate) writeState(backups, { op: 'rollback', status: 'ok', snapshot: snap.dir, at: now().toISOString() });
232
+ record('ok');
233
+ return { ok: true, plan };
234
+ }