@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
@@ -28,7 +28,7 @@ import { randomBytes } from "node:crypto";
28
28
  import { existsSync, readFileSync } from "node:fs";
29
29
  import { homedir } from "node:os";
30
30
  import { join } from "node:path";
31
- import { cancel, confirm, isCancel, log, note, select, spinner, text } from "@clack/prompts";
31
+ import { cancel, confirm, isCancel, log, note, select, spinner } from "@clack/prompts";
32
32
  import { bold, cyan, dim, green, red, yellow } from "kleur/colors";
33
33
  import { checkAdcImpersonation } from "../adc-impersonation.js";
34
34
  import { CERT_WAIT_TIMEOUT_MS, lbCertProbe, waitForCert } from "../cert-wait.js";
@@ -39,8 +39,10 @@ import { formatDnsRecordGroups, groupDnsRecords } from "../dns/groups.js";
39
39
  import { pickDnsAdapter } from "../dns/index.js";
40
40
  import { ensureGatewayRolePassword, findSqlInstance } from "../gateway-credentials.js";
41
41
  import { activeAccount, createProject, createServiceAccount, enableApis, gcloud, gcloudEnv, grantProvisionerRoles, linkBilling, listBillingAccounts, PROVISIONER_ROLE_LIST, projectExists, REQUIRED_API_LIST, serviceAccountExists, } from "../gcloud.js";
42
- import { GCP_STACK_ENV } from "../gcp-names.js";
43
- import { adoptStateBackend, ensureInstallDir, installRoot, installStagingHost, isStepDone, markStepDone, readLocalMetadata, readMetadata, readSecret, recordedImageDigests, recordImageDigests, writeMetadata, writeSecret, } from "../install-state.js";
42
+ import { GCP_STACK_ENV, upgraderServiceAccountEmail } from "../gcp-names.js";
43
+ import { INFRA_CONTRACT_VERSION } from "../infra-contract.js";
44
+ import { INSTALL_JOB_NAME, INSTALL_JOB_ROLES, INSTALL_SETUP_LINK_SECRET, storeSetupLink, } from "../install-job.js";
45
+ import { adoptStateBackend, ensureInstallDir, installRoot, installStagingHost, isStepDone, markStepDone, readLocalMetadata, readMetadata, readSecret, recordedImageDigests, recordImageDigests, recordInfraContractVersion, writeMetadata, writeSecret, } from "../install-state.js";
44
46
  import { ensureOperatorAccessSync, installIapAllowlist, resolveOperatorAccessTarget, } from "../operator-access.js";
45
47
  import { ensureOpsBackend } from "../ops-backend.js";
46
48
  import { convergeKeylessProvisioner, PROVISIONER_ACCOUNT_ID, provisionerServiceAccountEmail, } from "../provisioner-identity.js";
@@ -49,10 +51,15 @@ import { selfCommand } from "../self-command.js";
49
51
  import { ABSENT_DEFAULT_FIREWALL_RULES } from "../stack-contract.js";
50
52
  import { chooseInstallBackend, describeBackend, gcsBackendRecord, hasLocalPulumiState, installBackend, parseStateBackend, resolveBackend, } from "../state-backend.js";
51
53
  import { acquireStateLock, GcsStateStore } from "../state-store.js";
54
+ import { releaseVersionForDigest, resolveOpsImageRef, verifyCosignAll, } from "../upgrade/release-images.js";
55
+ import { ensureUpgraderJob, resolveUpgraderTarget } from "../upgrader-setup.js";
52
56
  import { estimateGcpCost } from "./gcp-cost.js";
57
+ import { promptProjectId } from "./gcp-prompts.js";
53
58
  import { GCP_STACK_KNOB_DEFAULTS, pulumiUpGcp, readStackOutput } from "./gcp-pulumi.js";
54
59
  export async function runGcpWizard(opts) {
55
- const { installId, domain, ownerEmail } = opts;
60
+ const { installId } = opts;
61
+ const provider = opts.provider ?? "gcp";
62
+ const runMode = opts.runMode ?? "local";
56
63
  const { secretsDir } = ensureInstallDir(installId);
57
64
  // === 1. gcloud auth ===
58
65
  await stepActiveGcloud();
@@ -75,7 +82,7 @@ export async function runGcpWizard(opts) {
75
82
  // === 4. Billing link ===
76
83
  await stepBillingLink(installId, projectId, opts.nonInteractive);
77
84
  // === 5. Enable APIs ===
78
- await stepEnableApis(installId, projectId, opts.provider ?? "gcp");
85
+ await stepEnableApis(installId, projectId, provider);
79
86
  // === 6. Service account + roles + impersonation (no key, #696) ===
80
87
  // v0.3.12 — dropped the firebase-hosting-identity step. The
81
88
  // Service Usage GenerateServiceIdentity API doesn't support
@@ -89,19 +96,19 @@ export async function runGcpWizard(opts) {
89
96
  // didn't.
90
97
  const saEmail = provisionerServiceAccountEmail(projectId);
91
98
  await stepServiceAccount(installId, projectId, saEmail);
92
- await stepGrantRoles(installId, projectId, saEmail);
93
- await stepKeylessProvisioner(projectId, secretsDir, opts.nonInteractive);
99
+ await stepGrantRoles(installId, projectId, saEmail, runMode);
100
+ // Pulumi runs here only in local mode; the install job runs AS the
101
+ // provisioner, so this terminal needs no Application Default Credentials.
102
+ await stepKeylessProvisioner(projectId, secretsDir, {
103
+ nonInteractive: opts.nonInteractive,
104
+ checkAdc: runMode === "local",
105
+ });
94
106
  // === 8. Cost-estimate pre-flight ===
95
107
  // v0.3.3 — provider variant threads through so gcp-firebase
96
108
  // drops the LB / Cloud CDN / Cloud Armor lines (saves ~$19/mo
97
109
  // vs gcp). Default 'gcp' for backwards compatibility with
98
110
  // installs that don't set the field.
99
- const costInputs = {
100
- ...GCP_STACK_KNOB_DEFAULTS,
101
- provider: opts.provider ?? "gcp",
102
- region,
103
- };
104
- const estimate = estimateGcpCost(costInputs);
111
+ const estimate = estimateGcpCost({ ...GCP_STACK_KNOB_DEFAULTS, provider, region });
105
112
  note([
106
113
  bold("Estimated monthly cost (resource floor — actual usage adds AI calls + egress + storage growth)"),
107
114
  ` ${estimate.regionNote}`,
@@ -111,6 +118,11 @@ export async function runGcpWizard(opts) {
111
118
  ` ${bold("TOTAL".padEnd(40))} ${bold(`$${estimate.totalUsd}`.padStart(5))}/mo`,
112
119
  "",
113
120
  ` ${dim(`Also deletes the default network's pre-populated firewall rules ${ABSENT_DEFAULT_FIREWALL_RULES.map((r) => r.name).join(" + ")} (Caelo runs no VMs and does not use that network).`)}`,
121
+ ...(runMode === "job"
122
+ ? [
123
+ ` ${dim(`The install itself runs as a one-off Cloud Run job (${INSTALL_JOB_NAME}) in the project: about ${INSTALL_JOB_COST_NOTE} for one install run, nothing while idle.`)}`,
124
+ ]
125
+ : []),
114
126
  ].join("\n"), "Pre-flight");
115
127
  if (!opts.nonInteractive) {
116
128
  const proceed = await confirm({
@@ -126,7 +138,48 @@ export async function runGcpWizard(opts) {
126
138
  // A new install: the ops bucket + KMS key in its own project — after the
127
139
  // cost confirmation, which lists them (CLAUDE.md §11.C: nothing billable
128
140
  // before the y/N). An older install keeps its local state + passphrase.
129
- const backend = await stepStateBackend(installId, projectId, region, saEmail);
141
+ const backend = await stepStateBackend(installId, projectId, region, saEmail, runMode);
142
+ // === 9. The rest runs where the run mode says (run-mode.ts) ===
143
+ if (runMode === "job") {
144
+ const { launchInstallJob } = await import("./gcp-install-job.js");
145
+ await launchInstallJob({
146
+ installId,
147
+ projectId,
148
+ region,
149
+ provisionerEmail: saEmail,
150
+ nonInteractive: opts.nonInteractive,
151
+ });
152
+ return;
153
+ }
154
+ await runGcpConverge({
155
+ installId,
156
+ projectId,
157
+ region,
158
+ provider,
159
+ impersonateServiceAccount: saEmail,
160
+ backend,
161
+ nonInteractive: opts.nonInteractive,
162
+ setupLink: "print",
163
+ });
164
+ }
165
+ /** One-off cost of an install-job run (2 vCPU + 4 GiB for up to ~2 h). */
166
+ const INSTALL_JOB_COST_NOTE = "$0.20-0.40";
167
+ /**
168
+ * The half of an install that needs no operator identity: image digests,
169
+ * `pulumi up`, DNS + certificate waits, migrations, database logins, the
170
+ * operator-access job, the placeholder page and the summary. Runs in the
171
+ * operator's terminal (local mode) or in the install job (install-job.ts).
172
+ * Every step is idempotent or checkpointed in progress.json, so a re-run —
173
+ * or a new job execution — resumes where the last one stopped.
174
+ */
175
+ export async function runGcpConverge(opts) {
176
+ const { installId, projectId, region, provider, backend } = opts;
177
+ const phase = opts.onPhase ?? (() => { });
178
+ const meta = readMetadata(installId);
179
+ if (!meta)
180
+ throw new Error(`No install.json for install '${installId}'.`);
181
+ const { domain, ownerEmail } = meta;
182
+ const costInputs = { ...GCP_STACK_KNOB_DEFAULTS, provider, region };
130
183
  // === 9. Image digests to deploy ===
131
184
  // An install that already runs a recorded release (from an earlier
132
185
  // wizard run or from `upgrade`) keeps it: re-running the wizard converges
@@ -137,7 +190,10 @@ export async function runGcpWizard(opts) {
137
190
  // An install provisioned before the record existed keeps what its
138
191
  // services run. A new install resolves the floating `:latest` tag to a
139
192
  // fixed sha256 digest: Cloud Run keys revisions by image reference, so a
140
- // tag would never trigger a new revision.
193
+ // tag would never trigger a new revision. (The install job's launcher
194
+ // records the digests of its signed release first, so a job install never
195
+ // reaches `:latest`.)
196
+ phase("Choosing the release images");
141
197
  const deployed = isStepDone(installId, `pulumi-up-${projectId}`);
142
198
  if (deployed) {
143
199
  // The kept release must be able to use the env the stack deploys.
@@ -148,7 +204,7 @@ export async function runGcpWizard(opts) {
148
204
  }
149
205
  }
150
206
  const choice = await chooseImageDigests({
151
- recorded: meta ? recordedImageDigests(meta) : null,
207
+ recorded: recordedImageDigests(meta),
152
208
  deployed,
153
209
  readLive: () => readDeployedImageDigests({ projectId, region }),
154
210
  resolveLatest: resolveImageDigests,
@@ -174,17 +230,15 @@ export async function runGcpWizard(opts) {
174
230
  // Pre-built signed images live at the Caelo-team public AR repo
175
231
  // (caelo-website/caelo-cms-images by default). Cloud Run reads them
176
232
  // directly with no operator-side IAM binding — anonymous public pull.
233
+ phase("Provisioning the infrastructure (pulumi up, 8-15 min)");
177
234
  await stepPulumiUp(installId, {
178
235
  projectId,
179
236
  domain,
180
237
  // #650 — the wizard recorded it (or the install predates the setting).
181
- stagingDomain: installStagingHost({
182
- domain,
183
- stagingDomain: readMetadata(installId)?.stagingDomain,
184
- }),
238
+ stagingDomain: installStagingHost({ domain, stagingDomain: meta.stagingDomain }),
185
239
  ownerEmail,
186
240
  region,
187
- impersonateServiceAccount: saEmail,
241
+ impersonateServiceAccount: opts.impersonateServiceAccount,
188
242
  backend,
189
243
  cloudSqlTier: costInputs.cloudSqlTier,
190
244
  cloudSqlHa: costInputs.cloudSqlHa,
@@ -197,7 +251,7 @@ export async function runGcpWizard(opts) {
197
251
  imageDigests,
198
252
  // v0.3.1 — route pulumi up at the right stack folder + use the
199
253
  // matching config namespace.
200
- provider: opts.provider ?? "gcp",
254
+ provider,
201
255
  });
202
256
  recordImageDigests(installId, imageDigests);
203
257
  // === 10.1. Remove the default network's pre-populated firewall rules ===
@@ -220,8 +274,9 @@ export async function runGcpWizard(opts) {
220
274
  // The DNS records come first: the certificate only validates once the
221
275
  // admin host resolves to the load balancer. Only the "set now" records
222
276
  // are applied here — the main domain moves at go-live (dns/groups.ts).
223
- if ((opts.provider ?? "gcp") === "gcp") {
224
- await stepGcpDnsRecords(installId);
277
+ if (provider === "gcp") {
278
+ await stepGcpDnsRecords(installId, { nonInteractive: opts.nonInteractive, onPhase: phase });
279
+ phase(`Waiting for the TLS certificate of admin.${domain} (15-60 min)`);
225
280
  await stepWaitForCertActive(projectId, `admin.${domain}`);
226
281
  }
227
282
  // === 11. Apply DB migrations against Cloud SQL via a one-shot Job ===
@@ -233,6 +288,7 @@ export async function runGcpWizard(opts) {
233
288
  // P21 ship 3 — shared with `cms-provision upgrade`. Aborts the
234
289
  // wizard with a clear error if migrations fail (vs. silently
235
290
  // continuing with a half-migrated DB).
291
+ phase("Applying database migrations");
236
292
  {
237
293
  const { runMigrationsViaCloudRunJob } = await import("../migration-runner.js");
238
294
  const r = await runMigrationsViaCloudRunJob({ projectId, region });
@@ -246,9 +302,27 @@ export async function runGcpWizard(opts) {
246
302
  // gateway-role-password secret's value here. The gateway (deployed by
247
303
  // Pulumi before migrations) retries its database on every request, so it
248
304
  // starts serving /api/* as soon as this lands — no restart.
305
+ phase("Finishing: database logins, operator access, welcome page");
249
306
  await stepGatewayRolePassword(projectId);
307
+ // === 11.4. The upgrader job: Settings → Updates → Install update (#696) ===
308
+ // Before operator access, which keeps the upgrader's caelo-mcp grant.
309
+ const releaseVersion = await releaseVersionForDigest(gcloud, "admin", imageDigests.admin);
310
+ const upgraderReady = await stepUpgraderJob(projectId, region, releaseVersion);
311
+ const keepSigner = upgraderReady || (await upgraderExists(projectId));
250
312
  // === 11.5. Operator access: IAP follows the Caelo user list ===
251
- await stepOperatorAccessSync(projectId, region, opts.provider ?? "gcp", ownerEmail);
313
+ await stepOperatorAccessSync(projectId, region, provider, ownerEmail, keepSigner ? [`serviceAccount:${upgraderServiceAccountEmail(projectId, GCP_STACK_ENV)}`] : []);
314
+ // A fresh install ran every converge step above, so it is at this CLI's
315
+ // infrastructure contract — recorded only when the upgrader is in place
316
+ // (the last step a contract bump added). With the release version, the
317
+ // admin can install the next update itself.
318
+ if (upgraderReady) {
319
+ const version = releaseVersion;
320
+ recordImageDigests(installId, imageDigests, version);
321
+ recordInfraContractVersion(installId, INFRA_CONTRACT_VERSION);
322
+ if (!version) {
323
+ log.warn(yellow("Could not tell which Caelo release the admin runs, so in-product updates stay off until the first `upgrade`."));
324
+ }
325
+ }
252
326
  // === 12. Upload the fresh-install placeholder to the static bucket ===
253
327
  // Without this, https://<domain>/ returns the raw GCS NoSuchKey XML
254
328
  // until the operator publishes their first deploy. Idempotent: skips
@@ -259,16 +333,13 @@ export async function runGcpWizard(opts) {
259
333
  // Hosting, not a GCS bucket; the static-publisher's first deploy
260
334
  // creates the initial Firebase Hosting version. Skip the GCS
261
335
  // placeholder upload on that provider.
262
- if ((opts.provider ?? "gcp") === "gcp") {
336
+ if (provider === "gcp") {
263
337
  await stepUploadStaticPlaceholder(installId, projectId, domain);
264
338
  }
265
339
  // === 12. DNS records + bootstrap URL ===
266
340
  // IAP is enabled directly on the LB BackendService (Pulumi-managed);
267
341
  // no post-up gcloud step needed.
268
- await stepFinalize(installId);
269
- // Reference unused params to silence the linter.
270
- void domain;
271
- void ownerEmail;
342
+ await stepFinalize(installId, opts.setupLink);
272
343
  }
273
344
  /**
274
345
  * Resolve the floating `:latest` tag on each service's public AR image
@@ -343,25 +414,7 @@ async function stepProjectId(opts) {
343
414
  return opts.projectId;
344
415
  }
345
416
  const guess = opts.domain.split(".")[0]?.replace(/[^a-z0-9-]/g, "-") ?? "caelo";
346
- const value = await text({
347
- message: "GCP project id (will be created if absent)",
348
- placeholder: guess,
349
- defaultValue: guess,
350
- validate: (v) => {
351
- if (!v || v.length < 6 || v.length > 30) {
352
- return "GCP project id must be 6–30 chars";
353
- }
354
- if (!/^[a-z][a-z0-9-]*[a-z0-9]$/.test(v)) {
355
- return "GCP project id: lowercase letters, digits, hyphens; start with letter; no trailing hyphen";
356
- }
357
- return undefined;
358
- },
359
- });
360
- if (isCancel(value)) {
361
- cancel("Cancelled.");
362
- process.exit(0);
363
- }
364
- return value;
417
+ return promptProjectId(guess);
365
418
  }
366
419
  /**
367
420
  * Settle the install region (#607). A recorded region — or, for an install
@@ -566,7 +619,35 @@ async function stepServiceAccount(installId, projectId, saEmail) {
566
619
  s.stop(green(`Provisioner SA ${bold(saEmail)} created`));
567
620
  markStepDone(installId, stepName, { created: true, saEmail });
568
621
  }
569
- async function stepGrantRoles(installId, projectId, saEmail) {
622
+ async function stepGrantRoles(installId, projectId, saEmail, runMode) {
623
+ await stepGrantBaseRoles(installId, projectId, saEmail);
624
+ if (runMode === "job")
625
+ await stepGrantInstallJobRoles(installId, projectId, saEmail);
626
+ }
627
+ /**
628
+ * #696 — the roles the provisioner needs when it runs the install job
629
+ * itself (install-job.ts INSTALL_JOB_ROLES). Only for installs that use it.
630
+ */
631
+ async function stepGrantInstallJobRoles(installId, projectId, saEmail) {
632
+ const stepName = `grant-install-job-roles-${projectId}`;
633
+ if (isStepDone(installId, stepName)) {
634
+ log.success(`Install-job roles granted ${dim("(checkpointed)")}`);
635
+ return;
636
+ }
637
+ const s = spinner();
638
+ s.start(`Granting the provisioner ${INSTALL_JOB_ROLES.length} more roles to run the install job...`);
639
+ const { granted, failed } = await grantProvisionerRoles(projectId, saEmail, {
640
+ roles: INSTALL_JOB_ROLES,
641
+ });
642
+ if (failed.length > 0) {
643
+ s.stop(red(`Failed: ${failed.map((f) => `${f.role}: ${f.error}`).join("; ")}`));
644
+ cancel("Aborted — re-run the installer (idempotent).");
645
+ process.exit(1);
646
+ }
647
+ s.stop(green(`${granted} install-job roles granted`));
648
+ markStepDone(installId, stepName, { granted });
649
+ }
650
+ async function stepGrantBaseRoles(installId, projectId, saEmail) {
570
651
  const stepName = `grant-roles-${projectId}`;
571
652
  if (isStepDone(installId, stepName)) {
572
653
  log.success(`IAM roles granted ${dim(`(${PROVISIONER_ROLE_LIST.length} roles, checkpointed)`)}`);
@@ -608,13 +689,69 @@ async function stepGatewayRolePassword(projectId) {
608
689
  }
609
690
  s.stop(green("gateway_role ready"));
610
691
  }
692
+ /**
693
+ * Set up the upgrader job (upgrader-setup.ts, #696): what the admin's
694
+ * "Install update" starts. After migrations, because the job's database
695
+ * user gets the role migration 0267 creates. A failure does not stop the
696
+ * install — the site works; updates then go through the CLI until a later
697
+ * `upgrade` sets the job up — so it only warns. Idempotent.
698
+ *
699
+ * @returns whether the job is ready
700
+ */
701
+ async function stepUpgraderJob(projectId, region, releaseVersion) {
702
+ // The upgrader of the release the install runs (a re-run keeps an older
703
+ // release — a newer upgrader would meet a schema it does not know yet).
704
+ const opsImageRef = releaseVersion ? await resolveOpsImageRef(releaseVersion) : null;
705
+ const digest = opsImageRef ? /@(sha256:[0-9a-f]{64})$/.exec(opsImageRef)?.[1] : undefined;
706
+ // The job runs with rights over the whole app: only a verified image.
707
+ if (digest && !(await verifyCosignAll([{ slug: "ops", digest }]))) {
708
+ log.warn(`The upgrader is not set up: its image's signature did not verify. The install works; ${bold(selfCommand("upgrade"))} sets it up later.`);
709
+ return false;
710
+ }
711
+ const s = spinner();
712
+ s.start("Setting up the upgrader job (install updates from the admin)...");
713
+ const resolved = opsImageRef
714
+ ? await resolveUpgraderTarget({ projectId, region, env: GCP_STACK_ENV, opsImageRef })
715
+ : {
716
+ ok: false,
717
+ error: releaseVersion
718
+ ? `release ${releaseVersion} has no caelo-ops image in the registry`
719
+ : "the release this install runs could not be told from its images",
720
+ };
721
+ const r = resolved.ok
722
+ ? await ensureUpgraderJob(resolved.target)
723
+ : { ok: false, done: [], error: resolved.error };
724
+ if (!r.ok) {
725
+ s.stop(yellow(`Upgrader job not set up: ${r.error}`));
726
+ log.warn(`The install works; Settings → Updates shows the upgrade command instead of an Install button until ${bold(selfCommand("upgrade"))} sets the job up.`);
727
+ return false;
728
+ }
729
+ s.stop(green(`Upgrader job ready (${r.done.length} steps)`));
730
+ return true;
731
+ }
732
+ /**
733
+ * Whether an upgrader set up by an earlier run exists, so a failed setup
734
+ * this time still keeps its caelo-mcp signing grant (the operator-access
735
+ * job would otherwise remove it, breaking its health check).
736
+ */
737
+ async function upgraderExists(projectId) {
738
+ const r = await gcloud([
739
+ "iam",
740
+ "service-accounts",
741
+ "describe",
742
+ upgraderServiceAccountEmail(projectId, GCP_STACK_ENV),
743
+ `--project=${projectId}`,
744
+ "--format=value(email)",
745
+ ]);
746
+ return r.ok;
747
+ }
611
748
  /**
612
749
  * Set up the operator-access sync job (operator-access.ts): the only
613
750
  * principal that may change who passes IAP, run as the operator's gcloud
614
751
  * identity. After migrations, because the job's database user gets the role
615
752
  * migration 0239 creates. Idempotent, so it runs on every wizard pass.
616
753
  */
617
- async function stepOperatorAccessSync(projectId, region, provider, ownerEmail) {
754
+ async function stepOperatorAccessSync(projectId, region, provider, ownerEmail, mcpSigners) {
618
755
  const s = spinner();
619
756
  s.start("Setting up the operator-access sync job (Google IAP follows the user list)...");
620
757
  const resolved = await resolveOperatorAccessTarget({
@@ -625,7 +762,7 @@ async function stepOperatorAccessSync(projectId, region, provider, ownerEmail) {
625
762
  ownerEmail,
626
763
  });
627
764
  const r = resolved.ok
628
- ? await ensureOperatorAccessSync(resolved.target)
765
+ ? await ensureOperatorAccessSync({ ...resolved.target, mcpSigners })
629
766
  : { ok: false, done: [], error: resolved.error };
630
767
  if (!r.ok) {
631
768
  s.stop(red(`Failed: ${r.error}`));
@@ -667,7 +804,7 @@ async function stepRemoveDefaultFirewallRules(projectId) {
667
804
  * Application Default Credentials impersonating the SA — is verified before
668
805
  * anything billable runs. Idempotent; runs on every wizard pass.
669
806
  */
670
- async function stepKeylessProvisioner(projectId, secretsDir, nonInteractive) {
807
+ async function stepKeylessProvisioner(projectId, secretsDir, opts) {
671
808
  const sa = provisionerServiceAccountEmail(projectId);
672
809
  const account = await activeAccount();
673
810
  if (!account) {
@@ -702,6 +839,8 @@ async function stepKeylessProvisioner(projectId, secretsDir, nonInteractive) {
702
839
  for (const line of converged.lines)
703
840
  log.info(` ${line}`);
704
841
  }
842
+ if (!opts.checkAdc)
843
+ return;
705
844
  const check = async () => checkAdcImpersonation({
706
845
  projectId,
707
846
  operatorAccount: account,
@@ -710,7 +849,7 @@ async function stepKeylessProvisioner(projectId, secretsDir, nonInteractive) {
710
849
  const sAdc = spinner();
711
850
  sAdc.start("Checking that your Application Default Credentials can impersonate it...");
712
851
  let adc = await check();
713
- if (!adc.ok && adc.reason === "no-adc" && !nonInteractive) {
852
+ if (!adc.ok && adc.reason === "no-adc" && !opts.nonInteractive) {
714
853
  sAdc.stop(yellow("No Application Default Credentials yet"));
715
854
  const login = await confirm({
716
855
  message: `Run ${bold("gcloud auth application-default login")} now (opens your browser; sign in as ${account})?`,
@@ -745,12 +884,18 @@ async function stepKeylessProvisioner(projectId, secretsDir, nonInteractive) {
745
884
  * that install's record; this install's own copy from an interrupted run is
746
885
  * taken over.
747
886
  */
748
- async function stepStateBackend(installId, projectId, region, provisionerEmail) {
887
+ async function stepStateBackend(installId, projectId, region, provisionerEmail, runMode) {
749
888
  const root = installRoot(installId);
750
889
  const meta = readMetadata(installId);
751
890
  if (!meta)
752
891
  throw new Error(`No install.json for install '${installId}'.`);
753
892
  const kind = chooseInstallBackend(meta, hasLocalPulumiState(root), isStepDone(installId, `pulumi-up-${projectId}`));
893
+ if (kind === "local" && runMode === "job") {
894
+ // The job reads the state from the project; a passphrase-encrypted
895
+ // state on this machine is out of its reach.
896
+ cancel(`This install's Pulumi state is on this machine (${describeBackend(meta, root)}), so the install job can't run it. Move it into project ${projectId} first — ${selfCommand("state", "migrate", "--install", installId)} — or re-run with --local.`);
897
+ process.exit(1);
898
+ }
754
899
  if (kind === "local") {
755
900
  stepPulumiPassphrase(installId);
756
901
  log.info(`Pulumi state: ${dim(describeBackend(meta, root))} (this install predates state in project ${projectId}). Move it there, so any machine you sign in on can manage the install: ${bold(selfCommand("state", "migrate", "--install", installId))}`);
@@ -1272,13 +1417,14 @@ const ADAPTER_RECORD_TYPES = new Set(["A", "AAAA", "CNAME", "TXT"]);
1272
1417
  * live site are never applied here — the summary lists them for go-live.
1273
1418
  * Idempotent: a finished step is skipped on re-runs.
1274
1419
  */
1275
- async function stepGcpDnsRecords(installId) {
1420
+ async function stepGcpDnsRecords(installId, opts) {
1276
1421
  const meta = readMetadata(installId);
1277
1422
  if (!meta?.projectId)
1278
1423
  return;
1279
1424
  const stepName = `dns-${meta.projectId}`;
1280
1425
  if (isStepDone(installId, stepName))
1281
1426
  return;
1427
+ opts.onPhase("Setting up DNS");
1282
1428
  const records = stackDnsRecords(await recordedStackOutputs(installId, meta.projectId));
1283
1429
  if (records.length === 0) {
1284
1430
  cancel("The stack reported no DNS records (dnsRecordsRequiredOut). Re-run the installer to refresh its outputs.");
@@ -1289,8 +1435,12 @@ async function stepGcpDnsRecords(installId) {
1289
1435
  log.warn(yellow(`Not touching ${meta.domain} itself: its records switch a live website to Caelo and are listed at the end for go-live.`));
1290
1436
  }
1291
1437
  const toApply = setNow.filter((r) => ADAPTER_RECORD_TYPES.has(r.type));
1292
- const adapter = await pickDnsAdapter({ domain: meta.domain });
1438
+ const adapter = await pickDnsAdapter({
1439
+ domain: meta.domain,
1440
+ nonInteractive: opts.nonInteractive,
1441
+ });
1293
1442
  log.info(`DNS adapter: ${bold(adapter.name)}`);
1443
+ opts.onPhase("Waiting for DNS", `Set at your registrar: ${toApply.map((r) => `${r.type} ${r.hostname} → ${r.value}`).join("; ")}`);
1294
1444
  try {
1295
1445
  await adapter.applyRecords(toApply.map((r) => ({ hostname: r.hostname, type: r.type, value: r.value })));
1296
1446
  markStepDone(installId, stepName, { adapter: adapter.name });
@@ -1302,7 +1452,7 @@ async function stepGcpDnsRecords(installId) {
1302
1452
  process.exit(1);
1303
1453
  }
1304
1454
  }
1305
- async function stepFinalize(installId) {
1455
+ async function stepFinalize(installId, setupLink) {
1306
1456
  const meta = readMetadata(installId);
1307
1457
  if (!meta?.projectId)
1308
1458
  return;
@@ -1324,6 +1474,20 @@ async function stepFinalize(installId) {
1324
1474
  const bootstrapUrl = provider === "gcp-firebase" && adminCloudRunUrl.length > 0
1325
1475
  ? `${adminCloudRunUrl}/setup?token=${bootstrapToken}`
1326
1476
  : stackBootstrapUrl;
1477
+ // #696 — the install job's output lands in Cloud Logging: the link goes to
1478
+ // Secret Manager, where the operator's terminal reads it (install-job.ts).
1479
+ if (setupLink === "secret") {
1480
+ if (!meta.region)
1481
+ throw new Error(`install.json of ${installId} records no region.`);
1482
+ const stored = await storeSetupLink(gcloud, { projectId: meta.projectId, region: meta.region }, bootstrapUrl);
1483
+ if (!stored.ok) {
1484
+ cancel(`Could not store the Owner setup link: ${stored.error}`);
1485
+ process.exit(1);
1486
+ }
1487
+ }
1488
+ const setupLinkLine = setupLink === "print"
1489
+ ? ` ${cyan(String(bootstrapUrl))}`
1490
+ : ` ${dim(`(shown in your Cloud Shell terminal; or: gcloud secrets versions access latest --secret=${INSTALL_SETUP_LINK_SECRET} --project=${meta.projectId})`)}`;
1327
1491
  // Both providers report their records in `dnsRecordsRequiredOut`; the
1328
1492
  // summary lists them in two groups so the main-domain records that take a
1329
1493
  // live site offline are not set during the install (dns/groups.ts). On gcp
@@ -1363,7 +1527,7 @@ async function stepFinalize(installId) {
1363
1527
  "",
1364
1528
  bold("Next steps:"),
1365
1529
  ` 1. Owner setup — open in your browser (sign in with ${meta.ownerEmail}):`,
1366
- ` ${cyan(String(bootstrapUrl))}`,
1530
+ setupLinkLine,
1367
1531
  ` 2. AI provider — visit ${cyan(`${adminBase}/settings/ai`)} and paste your Anthropic API key`,
1368
1532
  ` (encrypted under the project KEK and stored in the database, no env vars).`,
1369
1533
  ` 3. Optional: connect Claude Code or another MCP client — at ${cyan(`${adminBase}/settings/mcp`)}`,