@caelo-cms/provisioning 0.10.47 → 0.10.48

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 (315) hide show
  1. package/dist/agent/apply.d.ts +45 -0
  2. package/dist/agent/apply.d.ts.map +1 -0
  3. package/dist/agent/apply.js +83 -0
  4. package/dist/agent/apply.js.map +1 -0
  5. package/dist/agent/cli-provenance.d.ts +42 -0
  6. package/dist/agent/cli-provenance.d.ts.map +1 -0
  7. package/dist/agent/cli-provenance.js +138 -0
  8. package/dist/agent/cli-provenance.js.map +1 -0
  9. package/dist/agent/command.d.ts +79 -0
  10. package/dist/agent/command.d.ts.map +1 -0
  11. package/dist/agent/command.js +454 -0
  12. package/dist/agent/command.js.map +1 -0
  13. package/dist/agent/config.d.ts +30 -0
  14. package/dist/agent/config.d.ts.map +1 -0
  15. package/dist/agent/config.js +60 -0
  16. package/dist/agent/config.js.map +1 -0
  17. package/dist/agent/lock.d.ts +13 -0
  18. package/dist/agent/lock.d.ts.map +1 -0
  19. package/dist/agent/lock.js +100 -0
  20. package/dist/agent/lock.js.map +1 -0
  21. package/dist/agent/queue.d.ts +35 -0
  22. package/dist/agent/queue.d.ts.map +1 -0
  23. package/dist/agent/queue.js +50 -0
  24. package/dist/agent/queue.js.map +1 -0
  25. package/dist/agent/run.d.ts +97 -0
  26. package/dist/agent/run.d.ts.map +1 -0
  27. package/dist/agent/run.js +198 -0
  28. package/dist/agent/run.js.map +1 -0
  29. package/dist/agent/systemd.d.ts +55 -0
  30. package/dist/agent/systemd.d.ts.map +1 -0
  31. package/dist/agent/systemd.js +171 -0
  32. package/dist/agent/systemd.js.map +1 -0
  33. package/dist/agent/units.d.ts +44 -0
  34. package/dist/agent/units.d.ts.map +1 -0
  35. package/dist/agent/units.js +127 -0
  36. package/dist/agent/units.js.map +1 -0
  37. package/dist/agent-db.js +18362 -0
  38. package/dist/cli.js +119 -16
  39. package/dist/cli.js.map +1 -1
  40. package/dist/cloudsql-iam-user.d.ts +39 -0
  41. package/dist/cloudsql-iam-user.d.ts.map +1 -0
  42. package/dist/cloudsql-iam-user.js +119 -0
  43. package/dist/cloudsql-iam-user.js.map +1 -0
  44. package/dist/compose.d.ts +16 -0
  45. package/dist/compose.d.ts.map +1 -1
  46. package/dist/compose.js +26 -1
  47. package/dist/compose.js.map +1 -1
  48. package/dist/dns/index.d.ts +2 -0
  49. package/dist/dns/index.d.ts.map +1 -1
  50. package/dist/dns/index.js +3 -2
  51. package/dist/dns/index.js.map +1 -1
  52. package/dist/dns/manual.d.ts +5 -0
  53. package/dist/dns/manual.d.ts.map +1 -1
  54. package/dist/dns/manual.js +28 -12
  55. package/dist/dns/manual.js.map +1 -1
  56. package/dist/gcloud.d.ts +2 -0
  57. package/dist/gcloud.d.ts.map +1 -1
  58. package/dist/gcloud.js +1 -1
  59. package/dist/gcloud.js.map +1 -1
  60. package/dist/gcp-names.d.ts +15 -0
  61. package/dist/gcp-names.d.ts.map +1 -1
  62. package/dist/gcp-names.js +23 -0
  63. package/dist/gcp-names.js.map +1 -1
  64. package/dist/index.d.ts +6 -0
  65. package/dist/index.d.ts.map +1 -1
  66. package/dist/index.js +4 -0
  67. package/dist/index.js.map +1 -1
  68. package/dist/infra-contract.d.ts +1 -1
  69. package/dist/infra-contract.js +7 -1
  70. package/dist/infra-contract.js.map +1 -1
  71. package/dist/install-job-runner.d.ts +13 -0
  72. package/dist/install-job-runner.d.ts.map +1 -0
  73. package/dist/install-job-runner.js +155 -0
  74. package/dist/install-job-runner.js.map +1 -0
  75. package/dist/install-job.d.ts +210 -0
  76. package/dist/install-job.d.ts.map +1 -0
  77. package/dist/install-job.js +449 -0
  78. package/dist/install-job.js.map +1 -0
  79. package/dist/install-state.d.ts +10 -1
  80. package/dist/install-state.d.ts.map +1 -1
  81. package/dist/install-state.js +10 -2
  82. package/dist/install-state.js.map +1 -1
  83. package/dist/lifecycle.d.ts.map +1 -1
  84. package/dist/lifecycle.js +19 -4
  85. package/dist/lifecycle.js.map +1 -1
  86. package/dist/migration-runner.d.ts +5 -0
  87. package/dist/migration-runner.d.ts.map +1 -1
  88. package/dist/migration-runner.js +1 -1
  89. package/dist/migration-runner.js.map +1 -1
  90. package/dist/operator-access.d.ts +14 -8
  91. package/dist/operator-access.d.ts.map +1 -1
  92. package/dist/operator-access.js +16 -102
  93. package/dist/operator-access.js.map +1 -1
  94. package/dist/run-mode.d.ts +27 -0
  95. package/dist/run-mode.d.ts.map +1 -0
  96. package/dist/run-mode.js +22 -0
  97. package/dist/run-mode.js.map +1 -0
  98. package/dist/runtime-secrets.d.ts +19 -0
  99. package/dist/runtime-secrets.d.ts.map +1 -1
  100. package/dist/runtime-secrets.js +44 -0
  101. package/dist/runtime-secrets.js.map +1 -1
  102. package/dist/self-hosted-roles.d.ts +22 -1
  103. package/dist/self-hosted-roles.d.ts.map +1 -1
  104. package/dist/self-hosted-roles.js +81 -12
  105. package/dist/self-hosted-roles.js.map +1 -1
  106. package/dist/self-hosted-upgrade.d.ts +17 -0
  107. package/dist/self-hosted-upgrade.d.ts.map +1 -1
  108. package/dist/self-hosted-upgrade.js +16 -1
  109. package/dist/self-hosted-upgrade.js.map +1 -1
  110. package/dist/stack-contract.d.ts +6 -0
  111. package/dist/stack-contract.d.ts.map +1 -1
  112. package/dist/stack-contract.js +10 -1
  113. package/dist/stack-contract.js.map +1 -1
  114. package/dist/upgrade/converge.d.ts +6 -0
  115. package/dist/upgrade/converge.d.ts.map +1 -1
  116. package/dist/upgrade/converge.js +59 -2
  117. package/dist/upgrade/converge.js.map +1 -1
  118. package/dist/upgrade/gcp-lookup.d.ts +6 -0
  119. package/dist/upgrade/gcp-lookup.d.ts.map +1 -1
  120. package/dist/upgrade/gcp-lookup.js +25 -0
  121. package/dist/upgrade/gcp-lookup.js.map +1 -1
  122. package/dist/upgrade/release-images.d.ts +12 -0
  123. package/dist/upgrade/release-images.d.ts.map +1 -1
  124. package/dist/upgrade/release-images.js +31 -0
  125. package/dist/upgrade/release-images.js.map +1 -1
  126. package/dist/upgrade/upgrade.d.ts +5 -0
  127. package/dist/upgrade/upgrade.d.ts.map +1 -1
  128. package/dist/upgrade/upgrade.js +28 -1
  129. package/dist/upgrade/upgrade.js.map +1 -1
  130. package/dist/upgrader-assess.d.ts +82 -0
  131. package/dist/upgrader-assess.d.ts.map +1 -0
  132. package/dist/upgrader-assess.js +116 -0
  133. package/dist/upgrader-assess.js.map +1 -0
  134. package/dist/upgrader-gcp.d.ts +17 -0
  135. package/dist/upgrader-gcp.d.ts.map +1 -0
  136. package/dist/upgrader-gcp.js +87 -0
  137. package/dist/upgrader-gcp.js.map +1 -0
  138. package/dist/upgrader-job.d.ts +131 -0
  139. package/dist/upgrader-job.d.ts.map +1 -0
  140. package/dist/upgrader-job.js +259 -0
  141. package/dist/upgrader-job.js.map +1 -0
  142. package/dist/upgrader-reporter.d.ts +35 -0
  143. package/dist/upgrader-reporter.d.ts.map +1 -0
  144. package/dist/upgrader-reporter.js +79 -0
  145. package/dist/upgrader-reporter.js.map +1 -0
  146. package/dist/upgrader-setup.d.ts +59 -0
  147. package/dist/upgrader-setup.d.ts.map +1 -0
  148. package/dist/upgrader-setup.js +308 -0
  149. package/dist/upgrader-setup.js.map +1 -0
  150. package/dist/wizard.d.ts +6 -0
  151. package/dist/wizard.d.ts.map +1 -1
  152. package/dist/wizard.js +32 -4
  153. package/dist/wizard.js.map +1 -1
  154. package/dist/wizards/gcp-firebase.d.ts +3 -0
  155. package/dist/wizards/gcp-firebase.d.ts.map +1 -1
  156. package/dist/wizards/gcp-firebase.js +1 -0
  157. package/dist/wizards/gcp-firebase.js.map +1 -1
  158. package/dist/wizards/gcp-install-job.d.ts +74 -0
  159. package/dist/wizards/gcp-install-job.d.ts.map +1 -0
  160. package/dist/wizards/gcp-install-job.js +398 -0
  161. package/dist/wizards/gcp-install-job.js.map +1 -0
  162. package/dist/wizards/gcp-prompts.d.ts +3 -0
  163. package/dist/wizards/gcp-prompts.d.ts.map +1 -0
  164. package/dist/wizards/gcp-prompts.js +25 -0
  165. package/dist/wizards/gcp-prompts.js.map +1 -0
  166. package/dist/wizards/gcp-pulumi.d.ts +4 -2
  167. package/dist/wizards/gcp-pulumi.d.ts.map +1 -1
  168. package/dist/wizards/gcp-pulumi.js +1 -1
  169. package/dist/wizards/gcp-pulumi.js.map +1 -1
  170. package/dist/wizards/gcp.d.ts +40 -0
  171. package/dist/wizards/gcp.d.ts.map +1 -1
  172. package/dist/wizards/gcp.js +223 -59
  173. package/dist/wizards/gcp.js.map +1 -1
  174. package/package.json +6 -4
  175. package/src/adapter.ts +97 -0
  176. package/src/adc-impersonation.ts +176 -0
  177. package/src/admin-domain.test.ts +254 -0
  178. package/src/admin-domain.ts +245 -0
  179. package/src/admin-memory.test.ts +151 -0
  180. package/src/agent/apply.test.ts +185 -0
  181. package/src/agent/apply.ts +124 -0
  182. package/src/agent/cli-provenance.test.ts +159 -0
  183. package/src/agent/cli-provenance.ts +191 -0
  184. package/src/agent/command.test.ts +77 -0
  185. package/src/agent/command.ts +564 -0
  186. package/src/agent/config.ts +86 -0
  187. package/src/agent/lock-config.test.ts +97 -0
  188. package/src/agent/lock.ts +110 -0
  189. package/src/agent/queue.test.ts +10 -0
  190. package/src/agent/queue.ts +91 -0
  191. package/src/agent/run.test.ts +382 -0
  192. package/src/agent/run.ts +295 -0
  193. package/src/agent/systemd.ts +209 -0
  194. package/src/agent/units.test.ts +223 -0
  195. package/src/agent/units.ts +163 -0
  196. package/src/azure-front-door.test.ts +119 -0
  197. package/src/azure-front-door.ts +56 -0
  198. package/src/bootstrap-token.ts +20 -0
  199. package/src/caddy.ts +112 -0
  200. package/src/cdn-copy.ts +91 -0
  201. package/src/cert-wait.test.ts +194 -0
  202. package/src/cert-wait.ts +163 -0
  203. package/src/checkpoint-secrets.test.ts +380 -0
  204. package/src/checkpoint-secrets.ts +283 -0
  205. package/src/cli-routing.test.ts +45 -0
  206. package/src/cli-routing.ts +43 -0
  207. package/src/cli.ts +1491 -0
  208. package/src/cloudsql-iam-user.ts +147 -0
  209. package/src/compose.ts +334 -0
  210. package/src/default-firewall.test.ts +411 -0
  211. package/src/default-firewall.ts +342 -0
  212. package/src/deployed-release.test.ts +231 -0
  213. package/src/deployed-release.ts +220 -0
  214. package/src/dns/cloudflare.ts +182 -0
  215. package/src/dns/groups.test.ts +76 -0
  216. package/src/dns/groups.ts +86 -0
  217. package/src/dns/index.ts +56 -0
  218. package/src/dns/manual.ts +130 -0
  219. package/src/dns/types.ts +27 -0
  220. package/src/gateway-credentials.test.ts +180 -0
  221. package/src/gateway-credentials.ts +144 -0
  222. package/src/gcloud-components.test.ts +76 -0
  223. package/src/gcloud-components.ts +85 -0
  224. package/src/gcloud-retry.ts +41 -0
  225. package/src/gcloud-runner.test.ts +61 -0
  226. package/src/gcloud.test.ts +99 -0
  227. package/src/gcloud.ts +322 -0
  228. package/src/gcp-names.ts +135 -0
  229. package/src/index.test.ts +332 -0
  230. package/src/index.ts +114 -0
  231. package/src/infra-contract.ts +32 -0
  232. package/src/install-job-runner.ts +166 -0
  233. package/src/install-job.test.ts +566 -0
  234. package/src/install-job.ts +542 -0
  235. package/src/install-state.test.ts +239 -0
  236. package/src/install-state.ts +583 -0
  237. package/src/internal-token.ts +35 -0
  238. package/src/lifecycle.ts +900 -0
  239. package/src/mcp-iap-stacks.test.ts +58 -0
  240. package/src/mcp-iap.test.ts +149 -0
  241. package/src/mcp-iap.ts +131 -0
  242. package/src/media-volume.test.ts +296 -0
  243. package/src/migration-runner.ts +365 -0
  244. package/src/operator-access-stacks.test.ts +55 -0
  245. package/src/operator-access.test.ts +332 -0
  246. package/src/operator-access.ts +554 -0
  247. package/src/ops-backend.test.ts +320 -0
  248. package/src/ops-backend.ts +414 -0
  249. package/src/provisioner-identity.test.ts +482 -0
  250. package/src/provisioner-identity.ts +407 -0
  251. package/src/redirects-emit.ts +175 -0
  252. package/src/region-data.ts +428 -0
  253. package/src/regions.test.ts +277 -0
  254. package/src/regions.ts +370 -0
  255. package/src/release-contract.ts +57 -0
  256. package/src/release-manifest.test.ts +293 -0
  257. package/src/release-manifest.ts +430 -0
  258. package/src/run-mode.ts +40 -0
  259. package/src/runtime-secrets.test.ts +627 -0
  260. package/src/runtime-secrets.ts +754 -0
  261. package/src/self-command.test.ts +14 -0
  262. package/src/self-command.ts +21 -0
  263. package/src/self-hosted-compose.test.ts +177 -0
  264. package/src/self-hosted-release.ts +240 -0
  265. package/src/self-hosted-roles.integration.test.ts +161 -0
  266. package/src/self-hosted-roles.test.ts +124 -0
  267. package/src/self-hosted-roles.ts +203 -0
  268. package/src/self-hosted-upgrade.test.ts +394 -0
  269. package/src/self-hosted-upgrade.ts +548 -0
  270. package/src/stack-contract-parity.test.ts +199 -0
  271. package/src/stack-contract.test.ts +391 -0
  272. package/src/stack-contract.ts +888 -0
  273. package/src/stack-converge-secrets.test.ts +421 -0
  274. package/src/stack-converge.test.ts +833 -0
  275. package/src/stack-converge.ts +909 -0
  276. package/src/staging-admin.test.ts +246 -0
  277. package/src/staging-admin.ts +324 -0
  278. package/src/state-attach.test.ts +211 -0
  279. package/src/state-attach.ts +132 -0
  280. package/src/state-backend.test.ts +145 -0
  281. package/src/state-backend.ts +262 -0
  282. package/src/state-command.ts +293 -0
  283. package/src/state-migrate.test.ts +479 -0
  284. package/src/state-migrate.ts +434 -0
  285. package/src/state-pulumi.ts +289 -0
  286. package/src/state-store.test.ts +212 -0
  287. package/src/state-store.ts +243 -0
  288. package/src/upgrade/app-roll.ts +486 -0
  289. package/src/upgrade/backup.ts +109 -0
  290. package/src/upgrade/converge.ts +437 -0
  291. package/src/upgrade/gcp-lookup.ts +167 -0
  292. package/src/upgrade/health.test.ts +279 -0
  293. package/src/upgrade/health.ts +305 -0
  294. package/src/upgrade/release-images.test.ts +119 -0
  295. package/src/upgrade/release-images.ts +391 -0
  296. package/src/upgrade/reporter.ts +46 -0
  297. package/src/upgrade/upgrade.test.ts +626 -0
  298. package/src/upgrade/upgrade.ts +249 -0
  299. package/src/upgrader-assess.test.ts +265 -0
  300. package/src/upgrader-assess.ts +233 -0
  301. package/src/upgrader-gcp.ts +123 -0
  302. package/src/upgrader-job.test.ts +408 -0
  303. package/src/upgrader-job.ts +499 -0
  304. package/src/upgrader-reporter.test.ts +46 -0
  305. package/src/upgrader-reporter.ts +105 -0
  306. package/src/upgrader-setup.test.ts +169 -0
  307. package/src/upgrader-setup.ts +355 -0
  308. package/src/wizard.ts +425 -0
  309. package/src/wizards/gcp-cost.ts +188 -0
  310. package/src/wizards/gcp-firebase.ts +72 -0
  311. package/src/wizards/gcp-install-job.ts +506 -0
  312. package/src/wizards/gcp-prompts.ts +26 -0
  313. package/src/wizards/gcp-pulumi.test.ts +246 -0
  314. package/src/wizards/gcp-pulumi.ts +516 -0
  315. package/src/wizards/gcp.ts +1935 -0
@@ -0,0 +1,900 @@
1
+ // SPDX-License-Identifier: MPL-2.0
2
+
3
+ /**
4
+ * Lifecycle commands per CLAUDE.md §11.C: every install gets a
5
+ * `caelo-cms` CLI binary with first-class `upgrade / backup / restore
6
+ * / rotate-secret / status / destroy` operations. These are the
7
+ * day-2 operations operators do regularly — they shouldn't drop
8
+ * into provider tools for any of them.
9
+ *
10
+ * Each command:
11
+ * - acts on the install named by `--install <install-id or domain>`, or
12
+ * on the only install on this machine; with several and no flag it
13
+ * stops and lists them (selectInstall in install-state.ts)
14
+ * - dispatches to provider-specific implementations (gcp / aws /
15
+ * azure / self-hosted)
16
+ * - emits human-readable progress + a final summary
17
+ */
18
+
19
+ import { join, resolve as resolvePath } from "node:path";
20
+ import { provisioningUpgradeCommand } from "@caelo-cms/shared";
21
+ import { cancel, confirm, isCancel, log, note, select, spinner } from "@clack/prompts";
22
+ import { bold, cyan, dim, green, red, yellow } from "kleur/colors";
23
+ import { activeAccount, gcloud } from "./gcloud.js";
24
+ import { type GcloudRunner, realSleep } from "./gcloud-retry.js";
25
+ import { GCP_STACK_ENV } from "./gcp-names.js";
26
+ import { INFRA_CONTRACT_VERSION } from "./infra-contract.js";
27
+ import {
28
+ currentMetadata,
29
+ type ImageDigests,
30
+ type InstallMetadata,
31
+ installFlag,
32
+ installRoot,
33
+ installStagingHost,
34
+ listInstalls,
35
+ readMetadata,
36
+ readSecret,
37
+ recordImageDigests,
38
+ recordInfraContractVersion,
39
+ scrubInstallProgress,
40
+ selectInstall,
41
+ writeMetadata,
42
+ } from "./install-state.js";
43
+ import { runMigrationsViaCloudRunJob } from "./migration-runner.js";
44
+ import {
45
+ convergeKeylessProvisioner,
46
+ provisionerServiceAccountEmail,
47
+ } from "./provisioner-identity.js";
48
+ import { assertRegionUnchanged } from "./regions.js";
49
+ import {
50
+ type HttpFetch,
51
+ ROTATABLE_SECRETS,
52
+ type RotatableSecret,
53
+ rolesOfSecret,
54
+ rotateRuntimeSecret,
55
+ rotationRefusal,
56
+ } from "./runtime-secrets.js";
57
+ import { selfCommand } from "./self-command.js";
58
+ import {
59
+ backendShellEnv,
60
+ localStateNotice,
61
+ type ResolvedBackend,
62
+ resolveBackend,
63
+ } from "./state-backend.js";
64
+ import {
65
+ type AppRollPlan,
66
+ type ReleaseImage,
67
+ ROLL_ORDER,
68
+ type RollSlug,
69
+ } from "./upgrade/app-roll.js";
70
+ import { restoreCommand, takeSqlBackup } from "./upgrade/backup.js";
71
+ import { findServiceUrl, resolveGcpResourceName } from "./upgrade/gcp-lookup.js";
72
+ import { createIapHealthProbe, type HealthProbe } from "./upgrade/health.js";
73
+ import {
74
+ canonicalReleaseTag,
75
+ fetchLatestReleaseTag,
76
+ RELEASE_REGISTRY,
77
+ releaseImageRef,
78
+ releaseTag,
79
+ releaseVersionForDigest,
80
+ resolveOpsImageRef,
81
+ resolveTagDigest,
82
+ verifyCosignAll,
83
+ } from "./upgrade/release-images.js";
84
+ import { clackReporter } from "./upgrade/reporter.js";
85
+ import { upgradeGcpInstall } from "./upgrade/upgrade.js";
86
+
87
+ /**
88
+ * The installs a command acts on. `--install <id or domain>` names one
89
+ * (`--install all` every one, where the command allows it). Without the flag:
90
+ * the only install on this machine; with several, an interactive terminal
91
+ * asks which one (or all), and a non-interactive run stops with the list.
92
+ * Never guesses — acting on the wrong install upgrades or wipes the wrong
93
+ * site (see selectInstall).
94
+ */
95
+ async function chooseInstalls(opts: {
96
+ readonly verb: string;
97
+ readonly allowAll: boolean;
98
+ }): Promise<InstallMetadata[]> {
99
+ const installs = listInstalls();
100
+ let wanted: string | undefined;
101
+ try {
102
+ wanted = installFlag(process.argv);
103
+ } catch (e) {
104
+ log.error(red(e instanceof Error ? e.message : String(e)));
105
+ process.exit(2);
106
+ }
107
+ if (wanted === "all") {
108
+ if (!opts.allowAll) {
109
+ log.error(
110
+ red(`${opts.verb} runs on one install at a time; pass --install <install-id or domain>.`),
111
+ );
112
+ process.exit(2);
113
+ }
114
+ if (installs.length === 0) return exitNoInstall("No Caelo install found on this machine.");
115
+ return installs.map(currentMetadata);
116
+ }
117
+ if (wanted === undefined && installs.length > 1 && process.stdin.isTTY) {
118
+ const choice = await select<string>({
119
+ message: `Which install should ${opts.verb} act on?`,
120
+ options: [
121
+ ...installs.map((m) => ({
122
+ value: m.installId,
123
+ label: m.domain,
124
+ hint: `${m.installId}, ${m.provider}`,
125
+ })),
126
+ ...(opts.allowAll
127
+ ? [{ value: "all", label: `All ${installs.length} installs`, hint: "one after another" }]
128
+ : []),
129
+ ],
130
+ });
131
+ if (isCancel(choice)) {
132
+ cancel("Aborted.");
133
+ process.exit(1);
134
+ }
135
+ if (choice === "all") return installs.map(currentMetadata);
136
+ wanted = choice;
137
+ }
138
+ const selected = selectInstall(installs, wanted);
139
+ if (!selected.ok) return exitNoInstall(selected.message);
140
+ return [currentMetadata(selected.meta)];
141
+ }
142
+
143
+ function exitNoInstall(message: string): never {
144
+ log.error(red(message));
145
+ if (listInstalls().length === 0) {
146
+ log.warn(
147
+ `Run ${bold(selfCommand())} first to provision an install, OR copy ${dim("~/.caelo-<install-id>/")} from the provisioning machine.`,
148
+ );
149
+ }
150
+ process.exit(1);
151
+ }
152
+
153
+ /** The one install a single-install command (rotate-secret, truncate, destroy) acts on. */
154
+ async function requireInstall(verb: string): Promise<{ installId: string; meta: InstallMetadata }> {
155
+ const [meta] = await chooseInstalls({ verb, allowAll: false });
156
+ if (!meta) return exitNoInstall("No Caelo install found on this machine.");
157
+ return { installId: meta.installId, meta };
158
+ }
159
+
160
+ /**
161
+ * Run `run` for each chosen install in turn, with a header when there are
162
+ * several. `run` reports whether the install succeeded; the first failure
163
+ * stops the batch (the installs after it are left untouched) and sets a
164
+ * non-zero exit code, so scripts see it too.
165
+ *
166
+ * @param check validates the chosen set before anything runs (e.g. a
167
+ * --region that cannot match several installs); returns an error or null
168
+ */
169
+ async function forEachInstall(
170
+ verb: string,
171
+ run: (installId: string, meta: InstallMetadata) => Promise<boolean>,
172
+ check?: (chosen: readonly InstallMetadata[]) => string | null,
173
+ ): Promise<void> {
174
+ const chosen = await chooseInstalls({ verb, allowAll: true });
175
+ const problem = check?.(chosen) ?? null;
176
+ if (problem) {
177
+ log.error(red(problem));
178
+ process.exit(2);
179
+ }
180
+ for (const [i, meta] of chosen.entries()) {
181
+ if (chosen.length > 1) {
182
+ note(`${meta.domain} ${dim(`(${meta.installId})`)}`, `${verb} ${i + 1}/${chosen.length}`);
183
+ }
184
+ if (await run(meta.installId, meta)) continue;
185
+ process.exitCode = 1;
186
+ const skipped = chosen.slice(i + 1);
187
+ if (skipped.length > 0) {
188
+ log.warn(
189
+ `${verb} failed for ${meta.domain}; stopped before ${skipped.map((m) => m.domain).join(", ")}.`,
190
+ );
191
+ }
192
+ return;
193
+ }
194
+ }
195
+
196
+ /**
197
+ * The install's recorded region (#607); exits with the actionable reason
198
+ * when install.json has none, or when `requested` names a different one.
199
+ */
200
+ function installRegion(meta: InstallMetadata, requested?: string): string {
201
+ try {
202
+ return assertRegionUnchanged(meta, requested);
203
+ } catch (e) {
204
+ log.error(red(e instanceof Error ? e.message : String(e)));
205
+ process.exit(1);
206
+ }
207
+ }
208
+
209
+ // =========================================================================
210
+ // status — health check + monthly cost
211
+ // =========================================================================
212
+
213
+ export async function statusCommand(): Promise<void> {
214
+ await forEachInstall("status", statusOf);
215
+ }
216
+
217
+ async function statusOf(installId: string, meta: InstallMetadata): Promise<boolean> {
218
+ log.info(`Install: ${bold(installId)} ${dim(`(${meta.provider})`)}`);
219
+ log.info(`Domain: ${bold(meta.domain)}`);
220
+ log.info(`Project: ${bold(meta.projectId ?? "<self-hosted>")}`);
221
+
222
+ // #696 — an install made (or resumed) through the install job: how that
223
+ // job stands, from the same record the installer attaches with.
224
+ if (
225
+ (meta.provider === "gcp" || meta.provider === "gcp-firebase") &&
226
+ meta.stateBackend?.kind === "gcs"
227
+ ) {
228
+ const { installJobStatusLines } = await import("./wizards/gcp-install-job.js");
229
+ for (const line of await installJobStatusLines(meta)) log.info(line);
230
+ }
231
+
232
+ if (meta.provider === "gcp") {
233
+ await gcpStatus(meta);
234
+ } else {
235
+ log.warn(`status command for provider ${meta.provider} not yet implemented.`);
236
+ }
237
+ return true;
238
+ }
239
+
240
+ /**
241
+ * P20 — fetch the latest stable Caelo release tag from GitHub.
242
+ * Returns null on any failure (network, rate limit, no releases yet)
243
+ * — status output skips the "newer available" line in that case.
244
+ * Caches in-process for 10 minutes to keep repeated `status` calls
245
+ * polite to GitHub's unauthenticated rate limit (60 req/hr).
246
+ */
247
+ let releaseCheckCache: { fetchedAt: number; latest: string | null } | null = null;
248
+ async function getLatestReleaseTag(): Promise<string | null> {
249
+ const now = Date.now();
250
+ if (releaseCheckCache && now - releaseCheckCache.fetchedAt < 10 * 60 * 1000) {
251
+ return releaseCheckCache.latest;
252
+ }
253
+ const r = await fetchLatestReleaseTag();
254
+ const latest = r.ok ? r.tag : null;
255
+ releaseCheckCache = { fetchedAt: now, latest };
256
+ return latest;
257
+ }
258
+
259
+ async function gcpStatus(meta: InstallMetadata): Promise<void> {
260
+ if (!meta.projectId) return;
261
+ const s = spinner();
262
+ s.start("Resolving deployed resources + checking health...");
263
+
264
+ const region = installRegion(meta);
265
+ const adminName = await resolveGcpResourceName(
266
+ gcloud,
267
+ "run-service",
268
+ "caelo-production-admin",
269
+ meta.projectId,
270
+ region,
271
+ );
272
+ const sqlName = await resolveGcpResourceName(
273
+ gcloud,
274
+ "sql-instance",
275
+ "caelo-production-pg",
276
+ meta.projectId,
277
+ );
278
+ if (!adminName || !sqlName) {
279
+ s.stop(red("Could not resolve deployed resource names — is the install live?"));
280
+ return;
281
+ }
282
+
283
+ const adminUri = await gcloud([
284
+ "run",
285
+ "services",
286
+ "describe",
287
+ adminName,
288
+ "--region",
289
+ region,
290
+ "--project",
291
+ meta.projectId,
292
+ "--format=value(status.url)",
293
+ ]);
294
+ const sqlState = await gcloud([
295
+ "sql",
296
+ "instances",
297
+ "describe",
298
+ sqlName,
299
+ "--project",
300
+ meta.projectId,
301
+ "--format=value(state)",
302
+ ]);
303
+ s.stop(green("Health check complete"));
304
+
305
+ // P20 — show running version vs latest available release. Pulled
306
+ // from @caelo-cms/shared (kept in lockstep by scripts/release.ts).
307
+ const { CAELO_VERSION } = await import("@caelo-cms/shared");
308
+ const latestTag = await getLatestReleaseTag();
309
+ const latestStable = latestTag?.replace(/^v/, "") ?? null;
310
+ // #696 — the upgrade runs as the new release's CLI and rolls exactly that
311
+ // release, on exactly this install (same command Settings → Updates shows).
312
+ const upgradeHint =
313
+ latestStable && latestStable !== CAELO_VERSION
314
+ ? `${yellow(`v${latestStable} available`)} — run \`${provisioningUpgradeCommand({ version: latestStable, install: meta.domain })}\``
315
+ : latestStable === CAELO_VERSION
316
+ ? green("up to date")
317
+ : dim("(latest unknown)");
318
+
319
+ note(
320
+ [
321
+ `${dim("Admin Cloud Run URL")} ${adminUri.ok ? bold(adminUri.stdout.trim()) : red("error")}`,
322
+ `${dim("Cloud SQL state")} ${sqlState.ok ? bold(sqlState.stdout.trim()) : red("error")}`,
323
+ `${dim("Public site")} ${cyan(`https://${meta.domain}`)}`,
324
+ `${dim("Admin (IAP-gated)")} ${cyan(`https://admin.${meta.domain}`)}`,
325
+ `${dim("CLI version")} v${CAELO_VERSION} ${upgradeHint}`,
326
+ ].join("\n"),
327
+ "Status",
328
+ );
329
+ }
330
+
331
+ // =========================================================================
332
+ // upgrade — converge the infrastructure, then roll Cloud Run to a release
333
+ // =========================================================================
334
+
335
+ interface UpgradeOpts {
336
+ /** Explicit semver to roll to (e.g. "0.5.3"). Defaults to "latest". */
337
+ readonly version?: string;
338
+ /** Pre-release channel: "stable" (default), "rc", "beta". */
339
+ readonly channel?: "stable" | "rc" | "beta";
340
+ /**
341
+ * P21 ship 4 — escape hatch for forks / staging environments using
342
+ * unsigned images. Default = verify with cosign; refuse to roll on
343
+ * mismatch.
344
+ */
345
+ readonly skipVerify?: boolean;
346
+ /**
347
+ * #696 — roll with Cloud Run's readiness check only, without the deep
348
+ * health check (for installs the probe cannot reach, or a roll to a
349
+ * release that predates the deep health endpoint).
350
+ */
351
+ readonly skipHealthCheck?: boolean;
352
+ /**
353
+ * #607 — the region the operator expects the install in. Optional; when
354
+ * given it must match install.json, because upgrade never moves regions.
355
+ */
356
+ readonly region?: string;
357
+ /**
358
+ * The install to upgrade, already chosen by the caller (the wizard's
359
+ * "Upgrade <install>" entry). Without it the install comes from
360
+ * `--install` or the picker (chooseInstalls).
361
+ */
362
+ readonly installId?: string;
363
+ }
364
+
365
+ export async function upgradeCommand(opts: UpgradeOpts = {}): Promise<void> {
366
+ if (opts.installId) {
367
+ const selected = selectInstall(listInstalls(), opts.installId);
368
+ if (!selected.ok) return exitNoInstall(selected.message);
369
+ const meta = currentMetadata(selected.meta);
370
+ if (!(await upgradeInstall(meta.installId, meta, opts))) process.exitCode = 1;
371
+ return;
372
+ }
373
+ await forEachInstall(
374
+ "upgrade",
375
+ (installId, meta) => upgradeInstall(installId, meta, opts),
376
+ // Each install has its own recorded region (#607); one --region can't
377
+ // match several, and finding out halfway would leave a partial batch.
378
+ (chosen) =>
379
+ opts.region && chosen.length > 1
380
+ ? "--region names one install's region; drop it when upgrading several installs."
381
+ : null,
382
+ );
383
+ }
384
+
385
+ /**
386
+ * Resolve both release images' digests from the public registry. Via the
387
+ * registry's anonymous V2 API, not `gcloud artifacts docker tags list`: that
388
+ * needs IAM read on Caelo's registry project, which operators don't have.
389
+ */
390
+ async function resolveReleaseImages(
391
+ tag: string,
392
+ ): Promise<{ ok: true; images: Record<RollSlug, ReleaseImage> } | { ok: false; error: string }> {
393
+ const images: Partial<Record<RollSlug, ReleaseImage>> = {};
394
+ for (const slug of ROLL_ORDER) {
395
+ const res = await resolveTagDigest(slug, tag);
396
+ if (!res.ok) {
397
+ const r = RELEASE_REGISTRY;
398
+ return {
399
+ ok: false,
400
+ error:
401
+ `Couldn't resolve image digest for ${slug}:${tag}: the public registry returned ${res.reason}.\n` +
402
+ `Verify the tag exists at https://${r.region}-docker.pkg.dev/${r.project}/${r.repo}/${slug}:${tag}\n` +
403
+ "(latest releases live at https://github.com/caelo-cms/caelo-cms/releases — pass --version vX.Y.Z to pin.)",
404
+ };
405
+ }
406
+ images[slug] = { digest: res.digest, imageRef: releaseImageRef(slug, res.digest) };
407
+ }
408
+ return { ok: true, images: images as Record<RollSlug, ReleaseImage> };
409
+ }
410
+
411
+ /**
412
+ * `upgrade`'s step of the keyless provisioner migration (#696,
413
+ * provisioner-identity.ts) — a converge step (upgrade/converge.ts runs it
414
+ * first). Idempotent: on an install that already runs keyless it only
415
+ * confirms the grant.
416
+ *
417
+ * @returns false when the upgrade must stop (the message is printed)
418
+ */
419
+ async function upgradeKeylessProvisioner(installId: string, projectId: string): Promise<boolean> {
420
+ const account = await activeAccount();
421
+ if (!account) {
422
+ log.error(red("No active gcloud account. Run `gcloud auth login`, then re-run upgrade."));
423
+ return false;
424
+ }
425
+ const s = spinner();
426
+ s.start("Ensuring the provisioner service account is used without a key...");
427
+ const r = await convergeKeylessProvisioner({
428
+ projectId,
429
+ secretsDir: join(installRoot(installId), "secrets"),
430
+ operatorAccount: account,
431
+ });
432
+ if (!r.ok) {
433
+ s.stop(red("Could not move the provisioner to impersonation. Aborting upgrade."));
434
+ log.error(r.error);
435
+ log.warn("Nothing was rolled and no migrations ran. Fix the above and re-run.");
436
+ return false;
437
+ }
438
+ if (r.grant === "no-provisioner") {
439
+ s.stop(
440
+ green(`No ${provisionerServiceAccountEmail(projectId)} in this project — nothing to do`),
441
+ );
442
+ return true;
443
+ }
444
+ s.stop(
445
+ green(
446
+ r.legacyKey === "removed"
447
+ ? "Provisioner moved to impersonation; its long-lived key is deleted"
448
+ : `Provisioner keyless ok ${dim(`(${account} may impersonate it)`)}`,
449
+ ),
450
+ );
451
+ if (r.legacyKey === "removed") {
452
+ note(
453
+ [
454
+ `This install no longer keeps a service-account key on your machine (#696).`,
455
+ ...r.lines.map((l) => ` ${l}`),
456
+ "",
457
+ `Infrastructure runs (re-running the installer) now use your own Google login:`,
458
+ `${account} impersonates ${provisionerServiceAccountEmail(projectId)} with short-lived tokens.`,
459
+ `They need Application Default Credentials once on this machine: ${bold("gcloud auth application-default login")}`,
460
+ ].join("\n"),
461
+ "Service-account key removed",
462
+ );
463
+ }
464
+ return true;
465
+ }
466
+
467
+ /**
468
+ * Upgrade one install: converge, then app-roll (upgrade/upgrade.ts holds the
469
+ * sequence; this wires it to gcloud, the registry, cosign and install.json).
470
+ */
471
+ async function upgradeInstall(
472
+ installId: string,
473
+ meta: InstallMetadata,
474
+ opts: UpgradeOpts,
475
+ ): Promise<boolean> {
476
+ // #656 — earlier installers checkpointed database URLs with role
477
+ // passwords (and the setup link) into progress.json. Scrub them first,
478
+ // on every provider and whether or not the roll below succeeds.
479
+ for (const line of scrubInstallProgress(installId)) log.warn(yellow(line));
480
+
481
+ // gcp and gcp-firebase share the admin + gateway shape on Cloud Run; only
482
+ // the static-site layer differs, which upgrade doesn't touch.
483
+ if (meta.provider !== "gcp" && meta.provider !== "gcp-firebase") {
484
+ log.warn(`upgrade for provider ${meta.provider} not yet implemented.`);
485
+ return false;
486
+ }
487
+ if (!meta.projectId) return false;
488
+ const provider = meta.provider;
489
+ const projectId = meta.projectId;
490
+
491
+ // #607 — upgrade never moves an install: a `--region` other than the
492
+ // recorded one is refused before anything rolls.
493
+ const region = installRegion(meta, opts.region);
494
+ const targetTag = releaseTag(opts);
495
+ log.info(`Upgrading admin + gateway to ${bold(targetTag)}`);
496
+ // #650 — an install from before the staging host setting converges to
497
+ // the default (staging.<domain>) without operator action: recorded here,
498
+ // handed to the admin as CAELO_STAGING_HOST, which seeds its setting.
499
+ const stagingHost = installStagingHost(meta);
500
+ if (!meta.stagingDomain) writeMetadata(installId, { ...meta, stagingDomain: stagingHost });
501
+
502
+ const run: GcloudRunner = gcloud;
503
+ const http = fetch as unknown as HttpFetch;
504
+ const ok = await upgradeGcpInstall(
505
+ {
506
+ provider,
507
+ projectId,
508
+ region,
509
+ env: GCP_STACK_ENV,
510
+ domain: meta.domain,
511
+ stagingHost,
512
+ ownerEmail: meta.ownerEmail,
513
+ targetTag,
514
+ infraContractVersion: INFRA_CONTRACT_VERSION,
515
+ skipVerify: opts.skipVerify === true,
516
+ skipHealthCheck: opts.skipHealthCheck === true,
517
+ },
518
+ {
519
+ run,
520
+ http,
521
+ reporter: clackReporter(),
522
+ resolveImages: resolveReleaseImages,
523
+ verifyImages: verifyCosignAll,
524
+ migrate: (imageRef) =>
525
+ runMigrationsViaCloudRunJob({ projectId, region, imageOverride: imageRef }, { run }),
526
+ healthProbe: (plan) => gcpHealthProbe(run, http, plan),
527
+ recordDigests: async (digests) =>
528
+ recordRolledDigests(
529
+ installId,
530
+ provider,
531
+ digests,
532
+ // #696 — the version the upgrader job judges later updates by.
533
+ canonicalReleaseTag(targetTag)?.replace(/^v/, "") ??
534
+ (await releaseVersionForDigest(run, "admin", digests.admin)),
535
+ ),
536
+ resolveOpsImage: resolveOpsImageRef,
537
+ recordInfraContractVersion: (version) => recordInfraContractVersion(installId, version),
538
+ provisionerIdentity: () => upgradeKeylessProvisioner(installId, projectId),
539
+ },
540
+ );
541
+ // #696 — upgrade never moves Pulumi state; it says how to.
542
+ const stateNotice = localStateNotice(meta);
543
+ if (stateNotice) note(stateNotice, "Pulumi state is on this machine");
544
+ if (ok && provider === "gcp-firebase") {
545
+ // #650 — staging builds now render the staging host; the gateway
546
+ // serves it once it is mapped. The command is idempotent, so this
547
+ // reminder is safe to repeat on every upgrade.
548
+ log.info(
549
+ `Protected staging site: run ${bold("cms-provision staging-domain enable")} once to serve the staging host (it maps the host the admin uses — ${bold(stagingHost)} unless changed there —, turns on password protection, prints the login and the DNS record). Host and access: admin → Settings → Domains / Staging access.`,
550
+ );
551
+ }
552
+ return ok;
553
+ }
554
+
555
+ /** The deep health probe for `plan`'s install, through IAP. */
556
+ async function gcpHealthProbe(
557
+ run: GcloudRunner,
558
+ http: HttpFetch,
559
+ plan: AppRollPlan,
560
+ ): Promise<{ ok: true; probe: HealthProbe } | { ok: false; error: string }> {
561
+ const { install } = plan;
562
+ const urls: Partial<Record<RollSlug, string>> = {};
563
+ for (const s of plan.services) {
564
+ const url = await findServiceUrl(run, install.projectId, install.region, s.serviceName);
565
+ if (!url) {
566
+ return {
567
+ ok: false,
568
+ error: `Could not read the ${s.slug} service's URL for the health check.`,
569
+ };
570
+ }
571
+ urls[s.slug] = url;
572
+ }
573
+ return {
574
+ ok: true,
575
+ probe: createIapHealthProbe(
576
+ {
577
+ provider: install.provider,
578
+ projectId: install.projectId,
579
+ domain: install.domain,
580
+ adminServiceUrl: urls.admin as string,
581
+ gatewayServiceUrl: urls.gateway as string,
582
+ },
583
+ { run, http, sleep: realSleep },
584
+ ),
585
+ };
586
+ }
587
+
588
+ /**
589
+ * Record the digests just rolled so nothing re-deploys an older release:
590
+ * install.json (what a wizard re-run deploys) and the Pulumi stack config
591
+ * (what a plain `pulumi up` deploys). The roll already succeeded, so each
592
+ * write that fails warns with the exact manual fix instead of failing the
593
+ * upgrade, and one failing never skips the other.
594
+ */
595
+ async function recordRolledDigests(
596
+ installId: string,
597
+ provider: "gcp" | "gcp-firebase",
598
+ digests: ImageDigests,
599
+ releaseVersion: string | null,
600
+ ): Promise<void> {
601
+ const reason = (e: unknown) => (e instanceof Error ? e.message.split("\n")[0] : String(e));
602
+ if (!releaseVersion) {
603
+ log.warn(
604
+ yellow(
605
+ "Could not tell which Caelo release these images are (no version tag points at them), so install.json records none; in-product updates stay unavailable until an upgrade to a released version.",
606
+ ),
607
+ );
608
+ }
609
+ try {
610
+ recordImageDigests(installId, digests, releaseVersion);
611
+ } catch (e) {
612
+ log.warn(
613
+ yellow(
614
+ `Could not record the rolled digests in the install.json of ${installId} (${reason(e)}). Add this before re-running the installer, or it deploys the newest release instead:\n "imageDigests": ${JSON.stringify(digests)}`,
615
+ ),
616
+ );
617
+ }
618
+ const meta = readMetadata(installId);
619
+ const manual = stackConfigRecovery(installId, meta ?? {}, provider, digests);
620
+ let backend: ResolvedBackend;
621
+ try {
622
+ if (!meta) throw new Error(`no install.json for '${installId}'`);
623
+ backend = resolveBackend(meta, {
624
+ installRoot: installRoot(installId),
625
+ readPassphrase: () => readSecret(installId, "pulumi-passphrase"),
626
+ });
627
+ } catch (e) {
628
+ log.warn(
629
+ yellow(
630
+ `Stack config not updated (${reason(e)}). Before any manual \`pulumi up\`, run:\n${manual}`,
631
+ ),
632
+ );
633
+ return;
634
+ }
635
+ try {
636
+ const { writeImageDigestsToStack } = await import("./wizards/gcp-pulumi.js");
637
+ const { removedOverrides } = await writeImageDigestsToStack({
638
+ backend,
639
+ provider,
640
+ digests,
641
+ });
642
+ log.info(dim("Pinned the rolled image digests in the Pulumi stack config."));
643
+ if (removedOverrides.length > 0) {
644
+ log.warn(
645
+ yellow(
646
+ `Removed ${removedOverrides.join(", ")} from the stack config: those image overrides win over the digest pins, so a later \`pulumi up\` would have rolled back to them.`,
647
+ ),
648
+ );
649
+ }
650
+ } catch (e) {
651
+ log.warn(
652
+ yellow(
653
+ `Could not pin the rolled digests in the Pulumi stack config (${reason(e)}). Before any manual \`pulumi up\`, run:\n${manual}`,
654
+ ),
655
+ );
656
+ }
657
+ }
658
+
659
+ /**
660
+ * Shell commands that pin `digests` in the install's own Pulumi stack, with
661
+ * the same workspace, backend, stack and passphrase the CLI uses — run from
662
+ * any directory, they cannot hit an unrelated stack.
663
+ */
664
+ function stackConfigRecovery(
665
+ installId: string,
666
+ meta: Pick<InstallMetadata, "stateBackend">,
667
+ provider: "gcp" | "gcp-firebase",
668
+ digests: ImageDigests,
669
+ ): string {
670
+ const env = backendShellEnv(meta, installRoot(installId));
671
+ const cwd = `--cwd ${resolvePath(import.meta.dir, "../stacks", provider)} --stack ${GCP_STACK_ENV}`;
672
+ const lines = Object.entries(digests).map(
673
+ ([svc, d]) => ` ${env} pulumi config set ${cwd} caelo-${provider}:image-digest-${svc} ${d}`,
674
+ );
675
+ if (provider === "gcp") {
676
+ lines.push(
677
+ ` ${env} pulumi config rm ${cwd} caelo-gcp:image-admin # only if set`,
678
+ ` ${env} pulumi config rm ${cwd} caelo-gcp:image-gateway # only if set`,
679
+ );
680
+ }
681
+ return lines.join("\n");
682
+ }
683
+
684
+ // =========================================================================
685
+ // backup — Cloud SQL on-demand backup
686
+ // =========================================================================
687
+
688
+ export async function backupCommand(): Promise<void> {
689
+ await forEachInstall("backup", (_installId, meta) => backupOf(meta));
690
+ }
691
+
692
+ /** Both GCP providers keep their data in the install's Cloud SQL instance. */
693
+ async function backupOf(meta: InstallMetadata): Promise<boolean> {
694
+ if (meta.provider !== "gcp" && meta.provider !== "gcp-firebase") {
695
+ log.warn(`backup for provider ${meta.provider} not yet implemented.`);
696
+ return false;
697
+ }
698
+ if (!meta.projectId) return false;
699
+
700
+ const s = spinner();
701
+ s.start("Resolving Cloud SQL instance + triggering on-demand backup...");
702
+ const r = await takeSqlBackup(
703
+ gcloud,
704
+ { projectId: meta.projectId, env: GCP_STACK_ENV },
705
+ "backup",
706
+ );
707
+ if (!r.ok) {
708
+ s.stop(red(`Failed: ${r.error}`));
709
+ return false;
710
+ }
711
+ s.stop(green(`Backup ${r.backup.backupId} of ${r.backup.instance} created.`));
712
+ log.info(`Restore it (overwrites all data written since): ${bold(restoreCommand(r.backup))}`);
713
+ return true;
714
+ }
715
+
716
+ // =========================================================================
717
+ // rotate-secret <name>
718
+ // =========================================================================
719
+
720
+ export async function rotateSecretCommand(name: string | undefined): Promise<void> {
721
+ if (!name) {
722
+ log.error(red("Usage: caelo-cms rotate-secret <name>"));
723
+ log.warn(`Names: ${ROTATABLE_SECRETS.join(", ")}`);
724
+ process.exit(2);
725
+ }
726
+ const refusal = rotationRefusal(name);
727
+ if (refusal) {
728
+ log.error(red(refusal));
729
+ process.exit(2);
730
+ }
731
+ const secret = name as RotatableSecret;
732
+ const { meta } = await requireInstall("rotate-secret");
733
+ if (meta.provider !== "gcp" && meta.provider !== "gcp-firebase") {
734
+ log.warn(`rotate-secret for provider ${meta.provider} not yet implemented.`);
735
+ return;
736
+ }
737
+ if (!meta.projectId) return;
738
+ const region = installRegion(meta);
739
+
740
+ const s = spinner();
741
+ s.start(`Rotating ${secret}...`);
742
+ const services: Partial<Record<"admin" | "gateway", string>> = {};
743
+ for (const slug of ["admin", "gateway"] as const) {
744
+ const serviceName = await resolveGcpResourceName(
745
+ gcloud,
746
+ "run-service",
747
+ `caelo-production-${slug}`,
748
+ meta.projectId,
749
+ region,
750
+ );
751
+ if (!serviceName) {
752
+ s.stop(red(`Could not find caelo-production-${slug}* Cloud Run service — nothing rotated`));
753
+ return;
754
+ }
755
+ services[slug] = serviceName;
756
+ }
757
+ const sqlInstance =
758
+ rolesOfSecret(secret).length > 0
759
+ ? await resolveGcpResourceName(gcloud, "sql-instance", "caelo-production-pg", meta.projectId)
760
+ : null;
761
+ const report = await rotateRuntimeSecret(
762
+ {
763
+ projectId: meta.projectId,
764
+ region,
765
+ env: GCP_STACK_ENV,
766
+ services: services as Record<"admin" | "gateway", string>,
767
+ ...(sqlInstance ? { sqlInstance } : {}),
768
+ },
769
+ secret,
770
+ );
771
+ if (report.ok) {
772
+ s.stop(green(`${secret} rotated`));
773
+ } else {
774
+ s.stop(red(`Rotating ${secret} failed`));
775
+ }
776
+ for (const step of report.steps) log.info(` ${step}`);
777
+ if (report.error) log.error(red(report.error));
778
+ }
779
+
780
+ // =========================================================================
781
+ // destroy — pulumi destroy + gcloud projects delete
782
+ // =========================================================================
783
+
784
+ /**
785
+ * v0.4.0 — Truncate all content + history tables on the install's Cloud
786
+ * SQL, preserving identity (users / roles / providers / domains /
787
+ * site_defaults / provisioning_outputs / site_ai_memory). The operator
788
+ * can re-author from scratch against the current schema without
789
+ * destroying infra.
790
+ *
791
+ * Useful after a schema-changing release (e.g. v0.4.0's module/content
792
+ * split) when AI-authored content built against the old shape no
793
+ * longer renders cleanly.
794
+ */
795
+ export async function truncateCommand(): Promise<void> {
796
+ const { meta } = await requireInstall("truncate");
797
+
798
+ log.warn(
799
+ yellow(
800
+ `${bold("Truncate")} wipes ALL pages, modules, templates, layouts, redirects, nav menus, theme tokens, media assets, chat history, audit log, snapshots, and site memory on ${bold(meta.domain)}.\n` +
801
+ `Preserved: users / roles / AI providers / domains / site_defaults / provisioning_outputs.`,
802
+ ),
803
+ );
804
+ const confirm1 = await confirm({
805
+ message: `Truncate content on ${bold(meta.domain)} (${bold(meta.projectId ?? "self-hosted")})?`,
806
+ initialValue: false,
807
+ });
808
+ if (isCancel(confirm1) || !confirm1) {
809
+ cancel("Cancelled.");
810
+ process.exit(0);
811
+ }
812
+ const typed = await import("@clack/prompts").then((m) =>
813
+ m.text({
814
+ message: `Type the domain to confirm: ${bold(meta.domain)}`,
815
+ validate: (v) => (v === meta.domain ? undefined : "Domain doesn't match — aborting"),
816
+ }),
817
+ );
818
+ if (isCancel(typed)) {
819
+ cancel("Cancelled.");
820
+ process.exit(0);
821
+ }
822
+
823
+ if (meta.provider === "self-hosted") {
824
+ log.warn(
825
+ "Self-hosted truncate not yet wired. Run the SQL directly via " +
826
+ `${cyan("docker compose -f .caelo/docker-compose.yml exec postgres psql -U admin_role cms_admin")} ` +
827
+ "and TRUNCATE the content tables manually.",
828
+ );
829
+ return;
830
+ }
831
+
832
+ if ((meta.provider === "gcp" || meta.provider === "gcp-firebase") && meta.projectId) {
833
+ const { truncateViaCloudRunJob } = await import("./migration-runner.js");
834
+ const region = installRegion(meta);
835
+ const r = await truncateViaCloudRunJob({ projectId: meta.projectId, region });
836
+ if (!r.ok) {
837
+ cancel(`Truncate failed: ${r.error}.`);
838
+ process.exit(1);
839
+ }
840
+ note(
841
+ [
842
+ green(`✓ Content truncated on ${meta.domain}.`),
843
+ "",
844
+ "Next: visit the admin and start a fresh chat. The AI will author against",
845
+ "the current schema (v0.4.0 → module = code + fields; content per page).",
846
+ ].join("\n"),
847
+ "Done",
848
+ );
849
+ return;
850
+ }
851
+
852
+ log.warn(`Truncate not implemented for provider=${meta.provider}.`);
853
+ }
854
+
855
+ export async function destroyCommand(): Promise<void> {
856
+ const { installId, meta } = await requireInstall("destroy");
857
+
858
+ log.warn(
859
+ red(
860
+ `${bold("Destroy will PERMANENTLY delete")} the GCP project + every Caelo resource. This is irreversible after the 30-day undelete window.`,
861
+ ),
862
+ );
863
+ const confirm1 = await confirm({
864
+ message: `Destroy the install for ${bold(meta.domain)} (${bold(meta.projectId ?? "self-hosted")})?`,
865
+ initialValue: false,
866
+ });
867
+ if (isCancel(confirm1) || !confirm1) {
868
+ cancel("Cancelled.");
869
+ process.exit(0);
870
+ }
871
+ const typed = await import("@clack/prompts").then((m) =>
872
+ m.text({
873
+ message: `Type the domain to confirm: ${bold(meta.domain)}`,
874
+ validate: (v) => (v === meta.domain ? undefined : "Domain doesn't match — aborting"),
875
+ }),
876
+ );
877
+ if (isCancel(typed)) {
878
+ cancel("Cancelled.");
879
+ process.exit(0);
880
+ }
881
+
882
+ if (meta.provider === "gcp" && meta.projectId) {
883
+ const s = spinner();
884
+ s.start(`Deleting GCP project ${meta.projectId}...`);
885
+ const r = await gcloud(["projects", "delete", meta.projectId, "--quiet"]);
886
+ if (!r.ok) {
887
+ s.stop(red(`Failed: ${r.stderr.trim()}`));
888
+ log.warn(
889
+ `You can delete the project manually via the Cloud Console: https://console.cloud.google.com/iam-admin/settings?project=${meta.projectId}`,
890
+ );
891
+ } else {
892
+ s.stop(green(`Project ${meta.projectId} marked for deletion (30-day undelete window).`));
893
+ }
894
+ }
895
+
896
+ log.info(
897
+ `Local state at ${dim(installRoot(installId))} preserved. Remove manually if you want a clean slate: ${bold(`rm -rf ${installRoot(installId)}`)}.`,
898
+ );
899
+ void readSecret; // unused-import guard
900
+ }