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,71 @@
1
+ /**
2
+ * C4: `quox server doctor` = the `quox doctor` checks (runDoctorChecks, extracted in f491a2394) plus the
3
+ * server-side checks only the box itself can answer. All read-only. Same row shape as runDoctorChecks:
4
+ * { name, status: 'pass'|'warn'|'fail'|'skip', detail }.
5
+ */
6
+ import { existsSync } from 'node:fs';
7
+ import { join } from 'node:path';
8
+ import { envKeyNames, readInstalled } from './install.js';
9
+ import { composeServices, diskFree, runningVersion } from './status.js';
10
+ import { latestSnapshot } from './snapshot.js';
11
+
12
+ export const REQUIRED_ENV = ['JWT_SECRET', 'POSTGRES_PASSWORD', 'INTERNAL_SERVICE_KEY'];
13
+
14
+ export async function serverChecks({ install, sys, fetchImpl = globalThis.fetch, urls, backups, now = () => Date.now() }) {
15
+ const out = [];
16
+ const add = (name, status, detail) => out.push({ name, status, detail });
17
+
18
+ const dk = await sys.run('docker', ['info', '--format', '{{.ServerVersion}}']);
19
+ add('Docker engine', dk.code === 0 ? 'pass' : 'fail', dk.code === 0 ? `server ${dk.stdout.trim()}` : 'docker info failed (is the daemon running, is this user in the docker group?)');
20
+
21
+ const svc = await composeServices(install, sys);
22
+ if (!svc.ok) add('Compose services', 'fail', svc.error);
23
+ else {
24
+ const down = svc.services.filter((s) => s.state !== 'running');
25
+ const sick = svc.services.filter((s) => s.state === 'running' && s.health && s.health !== 'healthy');
26
+ add('Compose services', down.length ? 'fail' : sick.length ? 'warn' : 'pass',
27
+ `${svc.services.length - down.length} of ${svc.services.length} running${down.length ? `; not running: ${down.map((s) => s.service).join(', ')}` : ''}${sick.length ? `; unhealthy: ${sick.map((s) => `${s.service} (${s.health})`).join(', ')}` : ''}`);
28
+ }
29
+
30
+ const disk = await diskFree(install.root, sys);
31
+ add('Disk space', !disk.ok ? 'warn' : disk.freeGB < 2 ? 'fail' : disk.freeGB < 5 ? 'warn' : 'pass', disk.ok ? `${disk.freeGB} GB free at ${disk.path}` : disk.error);
32
+
33
+ const keys = envKeyNames(install.repoDir);
34
+ if (!keys) add('Config (.env)', 'fail', `no .env in ${install.repoDir}`);
35
+ else {
36
+ const missing = REQUIRED_ENV.filter((k) => !keys.includes(k));
37
+ add('Config (.env)', missing.length ? 'fail' : 'pass', missing.length ? `missing ${missing.join(', ')}` : `${keys.length} keys, required ones present (values not read)`);
38
+ }
39
+
40
+ const cert = join(install.repoDir, 'ssl', 'quox.crt');
41
+ if (!existsSync(cert)) add('TLS certificate', 'warn', `no ${cert}`);
42
+ else {
43
+ const r = await sys.run('openssl', ['x509', '-enddate', '-noout', '-in', cert]);
44
+ const end = r.code === 0 ? Date.parse(r.stdout.replace(/^notAfter=/, '').trim()) : NaN;
45
+ if (!Number.isFinite(end)) add('TLS certificate', 'warn', 'could not read its expiry (openssl)');
46
+ else {
47
+ const days = Math.floor((end - now()) / 86400000);
48
+ add('TLS certificate', days < 0 ? 'fail' : days < 14 ? 'warn' : 'pass', days < 0 ? `expired ${-days} days ago` : `expires in ${days} days`);
49
+ }
50
+ }
51
+
52
+ const installed = await readInstalled(install, sys);
53
+ add('Checkout', installed.dirty ? 'warn' : installed.dirty === null ? 'warn' : 'pass',
54
+ installed.dirty === null ? 'not a git checkout; quox server update cannot move it' : `${installed.release || '?'} at ${installed.commit}${installed.dirty ? ', with local changes (update will refuse without --force)' : ', clean'}`);
55
+
56
+ const rv = await runningVersion(urls.collector, fetchImpl);
57
+ if (!rv.ok) add('Running build', 'warn', `collector /version: ${rv.error}`);
58
+ else if (rv.stamped === false) add('Running build', 'warn', 'the collector build is unstamped (0.0.0); rebuild with QUOXCORE_VERSION set');
59
+ else {
60
+ const same = installed.commit && rv.commit && (String(rv.commit).startsWith(installed.commit) || String(installed.commit).startsWith(rv.commit));
61
+ add('Running build', same || !installed.commit ? 'pass' : 'warn', same ? `${rv.commit}, matches the checkout` : `running ${rv.commit}, checkout is ${installed.commit} (rebuilt since? quox server update rebuilds)`);
62
+ }
63
+
64
+ const snap = backups ? latestSnapshot(backups) : null;
65
+ if (!snap) add('Backups', 'warn', `no snapshot yet in ${backups}; quox server update takes one first`);
66
+ else {
67
+ const days = Math.floor((now() - Date.parse(snap.createdAt)) / 86400000);
68
+ add('Backups', days > 30 ? 'warn' : 'pass', `latest ${snap.createdAt} (${days} days ago, ${snap.release || snap.commit})`);
69
+ }
70
+ return out;
71
+ }
@@ -0,0 +1,149 @@
1
+ /**
2
+ * C4 lifecycle: which install are we talking about, what version is it, what is available.
3
+ *
4
+ * Layout (get.quox.ai -> install.sh -> scripts/setup.sh): $QUOX_DIR/{quox,quoxflow,quoxmcp}; the quox
5
+ * checkout holds docker-compose.yml, VERSION, .env and ssl/. Compose project = the checkout's dir name
6
+ * (setup.sh runs `docker compose` there), unless the running containers say otherwise.
7
+ */
8
+ import { existsSync, readFileSync } from 'node:fs';
9
+ import { homedir } from 'node:os';
10
+ import { join, dirname, basename, resolve } from 'node:path';
11
+
12
+ export const PUBLIC_REPO = 'quoxai/quoxcore';
13
+ export const SIBLINGS = ['quoxflow', 'quoxmcp'];
14
+
15
+ export function backupsRoot(env = process.env, home = homedir()) {
16
+ if (env.QUOX_SERVER_BACKUPS) return resolve(env.QUOX_SERVER_BACKUPS);
17
+ return join(env.XDG_DATA_HOME || join(home, '.local', 'share'), 'quox', 'server-backups');
18
+ }
19
+
20
+ function normalize(p) {
21
+ if (!p) return null;
22
+ const abs = resolve(p);
23
+ if (existsSync(join(abs, 'docker-compose.yml'))) return { repoDir: abs, root: dirname(abs) };
24
+ if (existsSync(join(abs, 'quox', 'docker-compose.yml'))) return { repoDir: join(abs, 'quox'), root: abs };
25
+ return null;
26
+ }
27
+
28
+ /** install.json that scripts/setup.sh writes: { dir } (the install root or the quox checkout). */
29
+ export function installRecordPath(env = process.env, home = homedir()) {
30
+ return join(env.XDG_CONFIG_HOME || join(home, '.config'), 'quox', 'install.json');
31
+ }
32
+ function readRecordedDir(env, home) {
33
+ try { const d = JSON.parse(readFileSync(installRecordPath(env, home), 'utf8'))?.dir; return typeof d === 'string' && d.startsWith('/') ? d : null; } catch { return null; }
34
+ }
35
+
36
+ /**
37
+ * Where the install lives: --dir, then QUOX_INSTALL_DIR, then the Getting started journey
38
+ * (~/.config/quox/onboarding.json installDir), then ~/.config/quox/install.json (written by setup.sh), then
39
+ * the running containers' compose label, then the installer's default ~/quox-install.
40
+ * -> { repoDir, root, project, source } | { error }
41
+ */
42
+ export async function resolveInstall({ dir, env = process.env, sys, journey = null, home = homedir() } = {}) {
43
+ if (dir) {
44
+ const n = normalize(dir);
45
+ if (!n) return { error: `${dir} is not a Quox install (no docker-compose.yml there or in ${join(dir, 'quox')})` };
46
+ return { ...n, project: await projectFor(n.repoDir, sys), source: '--dir' };
47
+ }
48
+ if (env.QUOX_INSTALL_DIR) {
49
+ const n = normalize(env.QUOX_INSTALL_DIR);
50
+ if (n) return { ...n, project: await projectFor(n.repoDir, sys), source: 'QUOX_INSTALL_DIR' };
51
+ }
52
+ let j = journey;
53
+ if (j === null) { try { const { loadJourney } = await import('../quoxon/onboarding/journey.js'); j = loadJourney(); } catch { j = null; } }
54
+ if (j?.installDir) {
55
+ const n = normalize(j.installDir);
56
+ if (n) return { ...n, project: await projectFor(n.repoDir, sys), source: 'Getting started' };
57
+ }
58
+ const rec = normalize(readRecordedDir(env, home));
59
+ if (rec) return { ...rec, project: await projectFor(rec.repoDir, sys), source: 'install.json' };
60
+ if (sys) {
61
+ const r = await sys.run('docker', ['inspect', 'quox-auth', '--format', '{{index .Config.Labels "com.docker.compose.project.working_dir"}}|{{index .Config.Labels "com.docker.compose.project"}}']);
62
+ if (r.code === 0) {
63
+ const [wd, project] = r.stdout.trim().split('|');
64
+ const n = normalize(wd);
65
+ if (n) return { ...n, project: project || basename(n.repoDir), source: 'running containers' };
66
+ }
67
+ }
68
+ const def = normalize(join(home, 'quox-install'));
69
+ if (def) return { ...def, project: await projectFor(def.repoDir, sys), source: 'default ~/quox-install' };
70
+ return { error: 'no Quox install found here. Pass --dir <path>, or set QUOX_INSTALL_DIR (setup.sh records it in ~/.config/quox/install.json).' };
71
+ }
72
+
73
+ async function projectFor(repoDir, sys) {
74
+ if (sys) {
75
+ const r = await sys.run('docker', ['inspect', 'quox-auth', '--format', '{{index .Config.Labels "com.docker.compose.project.working_dir"}}|{{index .Config.Labels "com.docker.compose.project"}}']);
76
+ if (r.code === 0) {
77
+ const [wd, project] = r.stdout.trim().split('|');
78
+ if (wd && resolve(wd) === repoDir && project) return project;
79
+ }
80
+ }
81
+ return basename(repoDir);
82
+ }
83
+
84
+ /** Read named keys from .env. ALLOWLIST: only the keys asked for come back; nothing else is parsed out. */
85
+ export function readEnvKeys(repoDir, keys) {
86
+ const out = {};
87
+ let text = '';
88
+ try { text = readFileSync(join(repoDir, '.env'), 'utf8'); } catch { return out; }
89
+ for (const line of text.split('\n')) {
90
+ const m = /^([A-Z0-9_]+)=(.*)$/.exec(line.trim());
91
+ if (m && keys.includes(m[1])) out[m[1]] = m[2].replace(/^"(.*)"$/, '$1');
92
+ }
93
+ return out;
94
+ }
95
+
96
+ /** Names only (never values) of every key in .env. */
97
+ export function envKeyNames(repoDir) {
98
+ try {
99
+ return readFileSync(join(repoDir, '.env'), 'utf8').split('\n').map((l) => /^([A-Z0-9_]+)=/.exec(l.trim())?.[1]).filter(Boolean);
100
+ } catch { return null; }
101
+ }
102
+
103
+ /** -> { release, commit, tag, dirty, siblings: { quoxflow: commit|null, ... } } (read-only git). */
104
+ export async function readInstalled({ repoDir, root }, sys) {
105
+ let release = null;
106
+ try { release = readFileSync(join(repoDir, 'VERSION'), 'utf8').trim() || null; } catch { /* no VERSION */ }
107
+ const git = async (dir, args) => { const r = await sys.run('git', ['-C', dir, ...args]); return r.code === 0 ? r.stdout.trim() : null; };
108
+ const commit = await git(repoDir, ['rev-parse', '--short', 'HEAD']);
109
+ const tag = await git(repoDir, ['describe', '--tags', '--exact-match', 'HEAD']);
110
+ const porcelain = await git(repoDir, ['status', '--porcelain', '--untracked-files=no']);
111
+ const siblings = {};
112
+ for (const s of SIBLINGS) {
113
+ const d = join(root, s);
114
+ siblings[s] = existsSync(join(d, '.git')) ? await git(d, ['rev-parse', '--short', 'HEAD']) : null;
115
+ }
116
+ return { release, commit, tag, dirty: porcelain === null ? null : porcelain.length > 0, siblings };
117
+ }
118
+
119
+ export function parseSemver(v) {
120
+ const m = /^v?(\d+)\.(\d+)\.(\d+)$/.exec(String(v || '').trim());
121
+ return m ? [Number(m[1]), Number(m[2]), Number(m[3])] : null;
122
+ }
123
+ export function cmpSemver(a, b) {
124
+ const x = parseSemver(a); const y = parseSemver(b);
125
+ if (!x || !y) return null;
126
+ for (let i = 0; i < 3; i++) if (x[i] !== y[i]) return x[i] < y[i] ? -1 : 1;
127
+ return 0;
128
+ }
129
+
130
+ /**
131
+ * Tags on the public repo (read-only, unauthenticated GitHub API). Status checked before parsing.
132
+ * -> { ok: true, latest, tags } | { ok: false, error }
133
+ */
134
+ export async function fetchAvailable({ fetchImpl = globalThis.fetch, repo = PUBLIC_REPO, timeoutMs = 8000 } = {}) {
135
+ const url = `https://api.github.com/repos/${repo}/tags?per_page=100`;
136
+ let r;
137
+ try { r = await fetchImpl(url, { headers: { accept: 'application/vnd.github+json', 'user-agent': 'quox-cli' }, signal: AbortSignal.timeout(timeoutMs) }); } catch (e) { return { ok: false, error: `could not reach ${url}: ${e?.message || e}` }; }
138
+ if (!r.ok) return { ok: false, error: `${url} answered HTTP ${r.status}` };
139
+ let body;
140
+ try { body = await r.json(); } catch { return { ok: false, error: `${url} did not return JSON` }; }
141
+ if (!Array.isArray(body)) return { ok: false, error: `${url} returned an unexpected shape` };
142
+ const tags = body.map((t) => t?.name).filter((n) => parseSemver(n)).sort((a, b) => cmpSemver(b, a));
143
+ if (!tags.length) return { ok: false, error: `no release tags on ${repo}` };
144
+ return { ok: true, latest: tags[0], tags };
145
+ }
146
+
147
+ export const releaseNotesUrl = (tag, repo = PUBLIC_REPO) => `https://github.com/${repo}/releases/tag/${tag}`;
148
+ export const compareUrl = (from, to, repo = PUBLIC_REPO) => `https://github.com/${repo}/compare/${from}...${to}`;
149
+ export const vtag = (v) => (String(v).startsWith('v') ? String(v) : `v${v}`);
@@ -0,0 +1,161 @@
1
+ /**
2
+ * PLUGIN-IMG #1012: `quox plugin enable <id>` starts a paid plugin from its
3
+ * prebuilt PRIVATE image on a self-hosted install.
4
+ *
5
+ * Order, and why:
6
+ * 1. Licence check FIRST. Not entitled: refuse with a clear message and
7
+ * run nothing on the box (no login, no pull, no compose). The entitlement
8
+ * read is the same resolved set auth's has-plugin uses (verified,
9
+ * instance-bound, unexpired leases), via the user-facing
10
+ * /api/setup/licensing/entitlements read.
11
+ * 2. Pull credential (TODO hook below): the licence server will issue a
12
+ * short-lived, package-scoped GHCR credential to an instance whose lease
13
+ * covers the plugin. Until that endpoint ships, the hook returns a clear
14
+ * "pending" error and enable stops there, unless the image is already
15
+ * present locally (for example a proving VM where it was loaded by hand),
16
+ * in which case it starts with --pull never.
17
+ * 3. docker compose -f docker-compose.yml -f docker-compose.images.yml
18
+ * -f docker-compose.plugins.yml --profile <profile> up -d <service>
19
+ * 4. Health: poll the container's healthcheck until healthy (or time out).
20
+ *
21
+ * Everything that touches the host goes through an injected `sys`
22
+ * (lib/server/sys.js), so tests prove what would run without running it.
23
+ */
24
+
25
+ /** Plugins that ship as prebuilt images. QuoxPlan only for now. */
26
+ export const PLUGIN_IMAGES = Object.freeze({
27
+ quoxplan: Object.freeze({
28
+ service: 'quoxplan',
29
+ profile: 'with-quoxplan',
30
+ container: 'quox-quoxplan',
31
+ image: 'ghcr.io/quoxai/quoxplan',
32
+ registry: 'ghcr.io',
33
+ }),
34
+ });
35
+
36
+ export const COMPOSE_FILES = Object.freeze(['docker-compose.yml', 'docker-compose.images.yml', 'docker-compose.plugins.yml']);
37
+
38
+ /** The image tag: the install's release tag (QUOX_IMAGE_TAG in .env), else latest. */
39
+ export function imageRef(spec, tag) {
40
+ return `${spec.image}:${tag || 'latest'}`;
41
+ }
42
+
43
+ /** The docker compose argv `enable` runs (after `docker`). */
44
+ export function composeUpArgs({ project }, spec, { pullNever = false } = {}) {
45
+ return [
46
+ 'compose',
47
+ ...(project ? ['-p', project] : []),
48
+ ...COMPOSE_FILES.flatMap((f) => ['-f', f]),
49
+ '--profile', spec.profile,
50
+ 'up', '-d',
51
+ ...(pullNever ? ['--pull', 'never'] : []),
52
+ spec.service,
53
+ ];
54
+ }
55
+
56
+ /**
57
+ * TODO(PLUGIN-IMG #1012, owner-gated): fetch a short-lived, package-scoped
58
+ * pull credential from the licence server for this instance's lease. The
59
+ * endpoint does not exist yet (licence-server change, owner deploy word).
60
+ * Contract when it lands: -> { ok: true, registry, username, token, expires_at }
61
+ * and enable logs in with `docker login <registry> -u <username> --password-stdin`.
62
+ */
63
+ export async function getPluginPullCredential(_pluginId, _ctx = {}) {
64
+ return {
65
+ ok: false,
66
+ pending: true,
67
+ error: 'pull credential pending licence-server endpoint: the licence server does not issue plugin image pull credentials yet (#1012)',
68
+ };
69
+ }
70
+
71
+ /**
72
+ * Same rule as `quox plugin licence`: a governed plugin is entitled when the
73
+ * resolved entitlements carry it (bare or banded) and, when it is banded,
74
+ * this org holds a seat.
75
+ */
76
+ export function entitlementFrom(data, pluginId) {
77
+ const entitlements = Array.isArray(data?.entitlements) ? data.entitlements : [];
78
+ const governed = (Array.isArray(data?.governed) ? data.governed : []).includes(pluginId);
79
+ const match = entitlements.find((e) => e === pluginId || String(e).startsWith(`${pluginId}@`)) || null;
80
+ const band = (data?.org_bands && data.org_bands[pluginId]) || null;
81
+ if (!governed) return { entitled: true, governed: false };
82
+ if (!match) return { entitled: false, governed: true, reason: 'no_entitlement' };
83
+ if (band && band.org_licensed === false) return { entitled: false, governed: true, reason: 'org_not_in_band' };
84
+ return { entitled: true, governed: true };
85
+ }
86
+
87
+ /**
88
+ * @param {object} o
89
+ * @param {string} o.pluginId
90
+ * @param {(pluginId: string) => Promise<{ entitled: boolean, reason?: string }>} o.checkEntitlement
91
+ * @param {{ repoDir: string, project?: string }} o.install
92
+ * @param {{ run: Function }} o.sys
93
+ * @param {string} [o.tag] image tag (QUOX_IMAGE_TAG)
94
+ * @param {Function} [o.getPullCredential]
95
+ * @param {(ms: number) => Promise<void>} [o.sleep]
96
+ * @param {number} [o.healthTries]
97
+ * @param {(line: string) => void} [o.log]
98
+ * @returns {Promise<{ ok: boolean, stage: string, message: string, ran: string[][] }>}
99
+ */
100
+ export async function enablePlugin({
101
+ pluginId, checkEntitlement, install, sys, tag,
102
+ getPullCredential = getPluginPullCredential,
103
+ sleep = (ms) => new Promise((r) => setTimeout(r, ms)),
104
+ healthTries = 30, healthIntervalMs = 2000,
105
+ log = () => {},
106
+ }) {
107
+ const ran = [];
108
+ const run = async (cmd, args, opts) => { ran.push([cmd, ...args]); return sys.run(cmd, args, opts); };
109
+ const spec = PLUGIN_IMAGES[pluginId];
110
+ if (!spec) {
111
+ const known = Object.keys(PLUGIN_IMAGES).join(', ');
112
+ return { ok: false, stage: 'unknown', message: `${pluginId} does not ship as a prebuilt plugin image yet (available: ${known}).`, ran };
113
+ }
114
+
115
+ let ent;
116
+ try {
117
+ ent = await checkEntitlement(pluginId);
118
+ } catch (err) {
119
+ return { ok: false, stage: 'licence', message: `Could not check the ${pluginId} licence (${err.message}). Nothing was pulled or started.`, ran };
120
+ }
121
+ if (!ent || !ent.entitled) {
122
+ const why = ent?.reason === 'org_not_in_band' ? ' This organisation does not hold one of its seats.' : '';
123
+ return {
124
+ ok: false,
125
+ stage: 'licence',
126
+ message: `${pluginId} is not licensed on this install.${why} Nothing was pulled or started. Run \`quox plugin licence ${pluginId}\` to see the entitlement, then renew or buy the licence.`,
127
+ ran,
128
+ };
129
+ }
130
+
131
+ const ref = imageRef(spec, tag);
132
+ let pullNever = false;
133
+ const cred = await getPullCredential(pluginId, { install });
134
+ if (cred && cred.ok) {
135
+ const login = await run('docker', ['login', cred.registry || spec.registry, '-u', cred.username, '--password-stdin'], { stdinText: cred.token });
136
+ if (login.code !== 0) return { ok: false, stage: 'login', message: `docker login to ${cred.registry || spec.registry} failed: ${(login.stderr || '').trim().slice(0, 300)}`, ran };
137
+ } else {
138
+ const local = await run('docker', ['image', 'inspect', ref, '--format', '{{.Id}}']);
139
+ if (local.code !== 0) {
140
+ return { ok: false, stage: 'pull-credential', message: `${pluginId} is licensed, but its image ${ref} is private and ${cred?.error || 'no pull credential is available'}. Nothing was started.`, ran };
141
+ }
142
+ pullNever = true;
143
+ log(`${ref} is already present locally; starting it without a pull.`);
144
+ }
145
+
146
+ const up = await run('docker', composeUpArgs(install, spec, { pullNever }), { cwd: install.repoDir });
147
+ if (up.code !== 0) return { ok: false, stage: 'compose', message: `docker compose up for ${spec.service} failed: ${(up.stderr || '').trim().slice(-400)}`, ran };
148
+
149
+ for (let i = 0; i < healthTries; i += 1) {
150
+ const h = await run('docker', ['inspect', '--format', '{{if .State.Health}}{{.State.Health.Status}}{{else}}{{.State.Status}}{{end}}', spec.container]);
151
+ const status = (h.stdout || '').trim();
152
+ if (h.code === 0 && status === 'healthy') {
153
+ return { ok: true, stage: 'healthy', message: `${pluginId} is running (${ref}) and healthy.`, ran };
154
+ }
155
+ if (h.code === 0 && status === 'unhealthy') {
156
+ return { ok: false, stage: 'health', message: `${spec.container} started but reports unhealthy. Check \`docker logs ${spec.container}\`.`, ran };
157
+ }
158
+ await sleep(healthIntervalMs);
159
+ }
160
+ return { ok: false, stage: 'health', message: `${spec.container} did not report healthy in time. Check \`docker logs ${spec.container}\`.`, ran };
161
+ }
@@ -0,0 +1,56 @@
1
+ /**
2
+ * C4: version skew between this CLI and the server it talks to. Warns at most once per day, and only when
3
+ * major or minor differ (a patch difference does not matter). Silent whenever either side is unknown.
4
+ *
5
+ * CLI side: the platform release the CLI was built from (the repo VERSION file beside cli/).
6
+ * Server side: the collector's public /version `release` (the image's /app/VERSION).
7
+ */
8
+ import { readFileSync, writeFileSync, mkdirSync } from 'node:fs';
9
+ import { homedir } from 'node:os';
10
+ import { join, dirname, resolve } from 'node:path';
11
+ import { fileURLToPath } from 'node:url';
12
+ import { parseSemver, cmpSemver } from './install.js';
13
+
14
+ const CLI_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..', '..');
15
+ export const DAY_MS = 86400000;
16
+
17
+ export function cliRelease(root = CLI_ROOT) {
18
+ try { const v = readFileSync(join(root, '..', 'VERSION'), 'utf8').trim(); return parseSemver(v) ? v : null; } catch { return null; }
19
+ }
20
+
21
+ export function skewCachePath(env = process.env) {
22
+ return join(env.XDG_CACHE_HOME || join(homedir(), '.cache'), 'quox', 'version-skew.json');
23
+ }
24
+
25
+ /** -> null | { cli, server, newer: 'cli'|'server', message } */
26
+ export function skewOf(cli, server) {
27
+ const a = parseSemver(cli); const b = parseSemver(server);
28
+ if (!a || !b) return null;
29
+ if (a[0] === b[0] && a[1] === b[1]) return null;
30
+ const newer = cmpSemver(cli, server) > 0 ? 'cli' : 'server';
31
+ const message = newer === 'cli'
32
+ ? `this quox CLI is ${cli} but the server runs ${server}. Update the server: quox server update (on the server; quox server update --dry-run shows the plan first).`
33
+ : `this quox CLI is ${cli} but the server runs ${server}. Update the CLI to ${server} so its commands match the server.`;
34
+ return { cli, server, newer, message };
35
+ }
36
+
37
+ /**
38
+ * Once per day: read the server release, compare, return the warning (or null). Never throws.
39
+ * The cache is stamped BEFORE the network call, so a slow or failing server costs one try per day.
40
+ */
41
+ export async function checkSkewOncePerDay({ collectorUrl, fetchImpl = globalThis.fetch, now = () => Date.now(), cachePath = skewCachePath(), cli = cliRelease(), timeoutMs = 1500 } = {}) {
42
+ try {
43
+ if (!cli || !collectorUrl) return null;
44
+ let cache = {};
45
+ try { cache = JSON.parse(readFileSync(cachePath, 'utf8')) || {}; } catch { /* first run */ }
46
+ if (cache.checkedAt && now() - cache.checkedAt < DAY_MS) return null;
47
+ mkdirSync(dirname(cachePath), { recursive: true });
48
+ writeFileSync(cachePath, JSON.stringify({ ...cache, checkedAt: now() }) + '\n');
49
+ const r = await fetchImpl(`${collectorUrl.replace(/\/+$/, '')}/version`, { signal: AbortSignal.timeout(timeoutMs) });
50
+ if (!r.ok) return null;
51
+ const body = await r.json();
52
+ const sk = skewOf(cli, body?.release);
53
+ if (sk) writeFileSync(cachePath, JSON.stringify({ checkedAt: now(), warnedAt: now(), cli, server: body.release }) + '\n');
54
+ return sk;
55
+ } catch { return null; }
56
+ }
@@ -0,0 +1,131 @@
1
+ /**
2
+ * C4: snapshots = DB + config, taken before every update, restored by rollback.
3
+ *
4
+ * <backups>/<stamp>-<release>-<commit>/
5
+ * manifest.json written LAST with complete:true; a dir without it is not a snapshot
6
+ * postgres.sql pg_dumpall from the running postgres service
7
+ * volumes/<v>.tgz every other compose volume (the SQLite stores), tarred with the stack stopped
8
+ * config/.env, config/ssl/, config/docker-compose.override.yml (mode 0600 / as found)
9
+ */
10
+ import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync, cpSync, statSync } from 'node:fs';
11
+ import { join, basename } from 'node:path';
12
+ import { readEnvKeys } from './install.js';
13
+ import { compose } from './status.js';
14
+
15
+ export const HELPER_IMAGE = 'alpine:3.20';
16
+ const CONFIG_ITEMS = ['.env', 'ssl', 'docker-compose.override.yml'];
17
+
18
+ export function listSnapshots(backups) {
19
+ let names = [];
20
+ try { names = readdirSync(backups); } catch { return []; }
21
+ return names.map((n) => {
22
+ const dir = join(backups, n);
23
+ try { const m = JSON.parse(readFileSync(join(dir, 'manifest.json'), 'utf8')); return m?.complete ? { ...m, dir } : null; } catch { return null; }
24
+ }).filter(Boolean).sort((a, b) => String(b.createdAt).localeCompare(String(a.createdAt)));
25
+ }
26
+ export const latestSnapshot = (backups) => listSnapshots(backups)[0] || null;
27
+
28
+ const stampOf = (d) => d.toISOString().replace(/[:.]/g, '-').replace(/-\d{3}Z$/, 'Z');
29
+
30
+ /**
31
+ * The stack's volumes, from the containers' own mounts (compose volumes carry fixed `name:`s whose project
32
+ * label depends on who created them first, so a label filter misses some, postgres included, on real boxes).
33
+ * -> { volumes: [names], pgVolume: name|null }
34
+ */
35
+ export async function stackVolumes(install, run) {
36
+ const ids = await run('docker', compose(install, ['ps', '-a', '-q']), { cwd: install.repoDir });
37
+ const list = ids.code === 0 ? ids.stdout.split('\n').map((s) => s.trim()).filter(Boolean) : [];
38
+ if (!list.length) return { volumes: [], pgVolume: null };
39
+ const r = await run('docker', ['inspect', '--format', '{{range .Mounts}}{{if eq .Type "volume"}}{{.Name}}>{{.Destination}};{{end}}{{end}}', ...list]);
40
+ if (r.code !== 0) return { volumes: [], pgVolume: null };
41
+ const seen = new Map();
42
+ for (const part of r.stdout.split(/[;\n]/).map((s) => s.trim()).filter(Boolean)) {
43
+ const [name, dest] = part.split('>');
44
+ if (name && !seen.has(name)) seen.set(name, dest || '');
45
+ }
46
+ const volumes = [...seen.keys()].sort();
47
+ return { volumes, pgVolume: volumes.find((v) => seen.get(v) === '/var/lib/postgresql/data') || null };
48
+ }
49
+
50
+ /**
51
+ * -> { ok, dir, manifest } | { ok:false, error }. g = gate(sys, { dryRun }).
52
+ * Order: dump postgres live, stop the stack, tar the other volumes, start the stack again.
53
+ */
54
+ export async function createSnapshot({ install, installed, backups, g, reason = 'manual', now = () => new Date(), log = () => {} }) {
55
+ const createdAt = now();
56
+ const dir = join(backups, `${stampOf(createdAt)}-${installed.release || 'unknown'}-${installed.commit || 'nocommit'}`);
57
+ const pgUser = readEnvKeys(install.repoDir, ['POSTGRES_USER']).POSTGRES_USER || 'quoxflow';
58
+ const { volumes, pgVolume } = await stackVolumes(install, g.ro);
59
+ const tarVolumes = volumes.filter((v) => v !== pgVolume);
60
+ const cfg = CONFIG_ITEMS.filter((f) => existsSync(join(install.repoDir, f)));
61
+
62
+ await g.fx(`create ${dir}`, () => mkdirSync(join(dir, 'volumes'), { recursive: true, mode: 0o700 }));
63
+ await g.fx(`copy config (${cfg.join(', ') || 'nothing found'}) to ${join(dir, 'config')}`, () => {
64
+ mkdirSync(join(dir, 'config'), { recursive: true, mode: 0o700 });
65
+ for (const f of cfg) cpSync(join(install.repoDir, f), join(dir, 'config', f), { recursive: true, preserveTimestamps: true });
66
+ });
67
+ let postgres = null;
68
+ if (pgVolume) {
69
+ const r = await g.mut('docker', compose(install, ['exec', '-T', 'postgres', 'pg_dumpall', '-U', pgUser]), { cwd: install.repoDir, stdoutFile: join(dir, 'postgres.sql') });
70
+ if (r.code !== 0) return { ok: false, error: `pg_dumpall failed (exit ${r.code}): ${(r.stderr || '').trim().split('\n').pop()}` };
71
+ postgres = 'postgres.sql';
72
+ }
73
+ if (tarVolumes.length) {
74
+ const s = await g.mut('docker', compose(install, ['stop']), { cwd: install.repoDir });
75
+ if (s.code !== 0) return { ok: false, error: `docker compose stop failed (exit ${s.code})` };
76
+ try {
77
+ for (const v of tarVolumes) {
78
+ const r = await g.mut('docker', ['run', '--rm', '-v', `${v}:/v:ro`, '-v', `${join(dir, 'volumes')}:/b`, HELPER_IMAGE, 'tar', 'czf', `/b/${v}.tgz`, '-C', '/v', '.']);
79
+ if (r.code !== 0) return { ok: false, error: `could not archive volume ${v} (exit ${r.code})` };
80
+ }
81
+ } finally {
82
+ await g.mut('docker', compose(install, ['start']), { cwd: install.repoDir });
83
+ }
84
+ }
85
+ const manifest = {
86
+ version: 1, complete: true, createdAt: createdAt.toISOString(), reason,
87
+ release: installed.release, commit: installed.commit, tag: installed.tag || null, siblings: installed.siblings || {},
88
+ project: install.project, repoDir: install.repoDir, root: install.root,
89
+ config: cfg, postgres, postgresUser: pgUser, pgVolume, volumes: tarVolumes
90
+ };
91
+ await g.fx(`write ${join(dir, 'manifest.json')} (marks the snapshot complete)`, () => writeFileSync(join(dir, 'manifest.json'), JSON.stringify(manifest, null, 2) + '\n', { mode: 0o600 }));
92
+ return { ok: true, dir, manifest };
93
+ }
94
+
95
+ /** Restore data + config from a snapshot into the install. Caller has already checked out the code and built. */
96
+ export async function restoreSnapshot({ install, snap, g, pgReadyTries = 30, sleep = (ms) => new Promise((r) => setTimeout(r, ms)) }) {
97
+ const dc = (args, opts = {}) => g.mut('docker', compose(install, args), { cwd: install.repoDir, ...opts });
98
+ await g.fx(`restore config (${(snap.config || []).join(', ') || 'none'}) from ${snap.dir}`, () => {
99
+ for (const f of snap.config || []) cpSync(join(snap.dir, 'config', f), join(install.repoDir, f), { recursive: true, force: true, preserveTimestamps: true });
100
+ });
101
+ let r = await dc(['down', '--remove-orphans']);
102
+ if (r.code !== 0) return { ok: false, error: `docker compose down failed (exit ${r.code})` };
103
+ for (const v of snap.volumes || []) {
104
+ r = await g.mut('docker', ['run', '--rm', '-v', `${v}:/v`, '-v', `${join(snap.dir, 'volumes')}:/b:ro`, HELPER_IMAGE, 'sh', '-c', `find /v -mindepth 1 -delete && tar xzf /b/${basename(v)}.tgz -C /v`]);
105
+ if (r.code !== 0) return { ok: false, error: `could not restore volume ${v} (exit ${r.code})` };
106
+ }
107
+ if (snap.postgres && snap.pgVolume) {
108
+ r = await g.mut('docker', ['volume', 'rm', snap.pgVolume]);
109
+ if (r.code !== 0) return { ok: false, error: `could not reset ${snap.pgVolume} (exit ${r.code})` };
110
+ r = await dc(['up', '-d', 'postgres']);
111
+ if (r.code !== 0) return { ok: false, error: `could not start postgres (exit ${r.code})` };
112
+ if (!g.dryRun) {
113
+ let ready = false;
114
+ for (let i = 0; i < pgReadyTries && !ready; i++) {
115
+ const p = await g.ro('docker', compose(install, ['exec', '-T', 'postgres', 'pg_isready', '-U', snap.postgresUser || 'quoxflow']), { cwd: install.repoDir });
116
+ ready = p.code === 0; if (!ready) await sleep(2000);
117
+ }
118
+ if (!ready) return { ok: false, error: 'postgres did not become ready to restore into' };
119
+ }
120
+ r = await dc(['exec', '-T', 'postgres', 'psql', '-q', '-U', snap.postgresUser || 'quoxflow', '-d', 'postgres'], { stdinFile: join(snap.dir, snap.postgres) });
121
+ if (r.code !== 0) return { ok: false, error: `psql restore failed (exit ${r.code})` };
122
+ }
123
+ return { ok: true };
124
+ }
125
+
126
+ export function snapshotSize(dir) {
127
+ let n = 0;
128
+ const walk = (d) => { for (const f of readdirSync(d)) { const p = join(d, f); const s = statSync(p); if (s.isDirectory()) walk(p); else n += s.size; } };
129
+ try { walk(dir); } catch { /* best effort */ }
130
+ return n;
131
+ }