@caelo-cms/provisioning 0.1.1 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (65) hide show
  1. package/dist/cli.js +92 -7
  2. package/dist/cli.js.map +1 -1
  3. package/dist/compose.d.ts +7 -0
  4. package/dist/compose.d.ts.map +1 -1
  5. package/dist/compose.js +10 -9
  6. package/dist/compose.js.map +1 -1
  7. package/dist/dns/cloudflare.d.ts +9 -0
  8. package/dist/dns/cloudflare.d.ts.map +1 -0
  9. package/dist/dns/cloudflare.js +160 -0
  10. package/dist/dns/cloudflare.js.map +1 -0
  11. package/dist/dns/index.d.ts +12 -0
  12. package/dist/dns/index.d.ts.map +1 -0
  13. package/dist/dns/index.js +42 -0
  14. package/dist/dns/index.js.map +1 -0
  15. package/dist/dns/manual.d.ts +5 -0
  16. package/dist/dns/manual.d.ts.map +1 -0
  17. package/dist/dns/manual.js +96 -0
  18. package/dist/dns/manual.js.map +1 -0
  19. package/dist/dns/types.d.ts +23 -0
  20. package/dist/dns/types.d.ts.map +1 -0
  21. package/dist/dns/types.js +3 -0
  22. package/dist/dns/types.js.map +1 -0
  23. package/dist/gcloud.d.ts +42 -0
  24. package/dist/gcloud.d.ts.map +1 -0
  25. package/dist/gcloud.js +187 -0
  26. package/dist/gcloud.js.map +1 -0
  27. package/dist/install-state.d.ts +54 -0
  28. package/dist/install-state.d.ts.map +1 -0
  29. package/dist/install-state.js +118 -0
  30. package/dist/install-state.js.map +1 -0
  31. package/dist/lifecycle.d.ts +19 -0
  32. package/dist/lifecycle.d.ts.map +1 -0
  33. package/dist/lifecycle.js +589 -0
  34. package/dist/lifecycle.js.map +1 -0
  35. package/dist/migration-runner.d.ts +15 -0
  36. package/dist/migration-runner.d.ts.map +1 -0
  37. package/dist/migration-runner.js +174 -0
  38. package/dist/migration-runner.js.map +1 -0
  39. package/dist/redirects-emit.d.ts.map +1 -1
  40. package/dist/redirects-emit.js +4 -1
  41. package/dist/redirects-emit.js.map +1 -1
  42. package/dist/wizard.d.ts +35 -0
  43. package/dist/wizard.d.ts.map +1 -0
  44. package/dist/wizard.js +160 -0
  45. package/dist/wizard.js.map +1 -0
  46. package/dist/wizards/gcp-cost.d.ts +27 -0
  47. package/dist/wizards/gcp-cost.d.ts.map +1 -0
  48. package/dist/wizards/gcp-cost.js +77 -0
  49. package/dist/wizards/gcp-cost.js.map +1 -0
  50. package/dist/wizards/gcp-pulumi.d.ts +37 -0
  51. package/dist/wizards/gcp-pulumi.d.ts.map +1 -0
  52. package/dist/wizards/gcp-pulumi.js +100 -0
  53. package/dist/wizards/gcp-pulumi.js.map +1 -0
  54. package/dist/wizards/gcp.d.ts +9 -0
  55. package/dist/wizards/gcp.d.ts.map +1 -0
  56. package/dist/wizards/gcp.js +895 -0
  57. package/dist/wizards/gcp.js.map +1 -0
  58. package/package.json +13 -2
  59. package/stacks/aws/index.ts +6 -7
  60. package/stacks/azure/index.ts +11 -11
  61. package/stacks/gcp/Pulumi.production.yaml +16 -0
  62. package/stacks/gcp/Pulumi.yaml +52 -6
  63. package/stacks/gcp/index.ts +569 -188
  64. package/stacks/self-hosted/index.ts +3 -3
  65. package/static/welcome.html +155 -0
@@ -0,0 +1,895 @@
1
+ // SPDX-License-Identifier: MPL-2.0
2
+ /**
3
+ * GCP provider wizard — end-to-end automation per §11.C:
4
+ * 1. Detects active gcloud account; prompts `gcloud auth login` if none
5
+ * 2. Lists billing accounts; user picks
6
+ * 3. Captures GCP project id (default suggested from domain)
7
+ * 4. Creates the project (skips if exists)
8
+ * 5. Links billing
9
+ * 6. Enables 13 APIs in one call
10
+ * 7. Creates the provisioner SA + grants 15 IAM roles
11
+ * 8. Mints a JSON SA key into `~/.caelo-<install-id>/secrets/sa-key.json`
12
+ * 9. Captures Anthropic API key (input-hidden) → secrets/anthropic-api-key
13
+ * 10. Generates Pulumi passphrase if absent → secrets/pulumi-passphrase
14
+ * 11. Pre-flight cost-estimate table; single y/N confirm
15
+ * 12. Pulumi up via the Automation SDK; streams progress
16
+ * 13. Prints DNS records + bootstrap URL (IAP runs on the LB
17
+ * BackendService; no post-up gcloud step needed)
18
+ */
19
+ import { randomBytes } from "node:crypto";
20
+ import { existsSync, readFileSync } from "node:fs";
21
+ import { homedir } from "node:os";
22
+ import { join } from "node:path";
23
+ import { cancel, confirm, isCancel, log, note, password, select, spinner, text, } from "@clack/prompts";
24
+ import { bold, cyan, dim, green, red, yellow } from "kleur/colors";
25
+ import { pickDnsAdapter } from "../dns/index.js";
26
+ import { activeAccount, createProject, createServiceAccount, createServiceAccountKey, enableApis, gcloud, grantProvisionerRoles, linkBilling, listBillingAccounts, PROVISIONER_ROLE_LIST, projectExists, REQUIRED_API_LIST, serviceAccountExists, } from "../gcloud.js";
27
+ import { ensureInstallDir, installRoot, isStepDone, markStepDone, readMetadata, readSecret, writeMetadata, writeSecret, } from "../install-state.js";
28
+ import { estimateGcpCost } from "./gcp-cost.js";
29
+ import { pulumiUpGcp } from "./gcp-pulumi.js";
30
+ const SA_ACCOUNT_ID = "caelo-provisioner";
31
+ export async function runGcpWizard(opts) {
32
+ const { installId, domain, ownerEmail } = opts;
33
+ const { secretsDir } = ensureInstallDir(installId);
34
+ // === 1. gcloud auth ===
35
+ await stepActiveGcloud();
36
+ // === 2. Project id ===
37
+ const projectId = await stepProjectId(opts);
38
+ // Persist the project id once we have it.
39
+ const meta = readMetadata(installId);
40
+ if (meta) {
41
+ const updated = { ...meta, projectId };
42
+ writeMetadata(installId, updated);
43
+ }
44
+ // === 3. Project create ===
45
+ await stepProjectCreate(installId, projectId);
46
+ // === 4. Billing link ===
47
+ await stepBillingLink(installId, projectId, opts.nonInteractive);
48
+ // === 5. Enable APIs ===
49
+ await stepEnableApis(installId, projectId);
50
+ // === 6. Service account + roles + key ===
51
+ const saEmail = `${SA_ACCOUNT_ID}@${projectId}.iam.gserviceaccount.com`;
52
+ await stepServiceAccount(installId, projectId, saEmail);
53
+ await stepGrantRoles(installId, projectId, saEmail);
54
+ const keyPath = await stepMintKey(installId, projectId, saEmail, secretsDir);
55
+ // === 7. Anthropic API key + Pulumi passphrase ===
56
+ const anthropicKey = await stepAnthropicKey(installId, opts.nonInteractive);
57
+ const pulumiPassphrase = stepPulumiPassphrase(installId);
58
+ const region = "europe-west1";
59
+ if (meta)
60
+ writeMetadata(installId, { ...meta, projectId, region });
61
+ // === 8. Cost-estimate pre-flight ===
62
+ const costInputs = {
63
+ cloudSqlTier: "db-f1-micro",
64
+ cloudSqlHa: false,
65
+ adminMinInstances: 0,
66
+ gatewayMinInstances: 0,
67
+ wafAdaptiveProtection: false,
68
+ };
69
+ const estimate = estimateGcpCost(costInputs);
70
+ note([
71
+ bold("Estimated monthly cost (resource floor — actual usage adds AI calls + egress + storage growth)"),
72
+ "",
73
+ ...estimate.lines.map((l) => ` ${dim(l.name.padEnd(40))} ${green(`$${l.monthlyUsd}`.padStart(5))}/mo ${dim(l.notes ?? "")}`),
74
+ "",
75
+ ` ${bold("TOTAL".padEnd(40))} ${bold(`$${estimate.totalUsd}`.padStart(5))}/mo`,
76
+ ].join("\n"), "Pre-flight");
77
+ if (!opts.nonInteractive) {
78
+ const proceed = await confirm({
79
+ message: `Provision ~${estimate.totalUsd} USD/mo on GCP project ${bold(projectId)}?`,
80
+ initialValue: true,
81
+ });
82
+ if (isCancel(proceed) || !proceed) {
83
+ cancel("Cancelled at cost confirmation.");
84
+ process.exit(0);
85
+ }
86
+ }
87
+ // === 9. Resolve image digests so each pulumi up rolls Cloud Run ===
88
+ // Cloud Run keys revisions by image reference; if the reference is
89
+ // a floating tag like ":main", a fresh image push doesn't trigger a
90
+ // new revision because the reference text is unchanged. Resolve the
91
+ // floating tag to its current sha256 digest so each provisioning run
92
+ // pulls the freshest published image.
93
+ const imageDigests = await resolveImageDigests(["admin", "gateway"]);
94
+ // === 9.5. Drain the legacy `caelo_admin` SQL user before pulumi-up ===
95
+ // The 3a81c37 rename (caelo_admin → admin_role) leaves the old role
96
+ // in pg_roles owning N objects (sequences/grants survive ALTER TABLE
97
+ // OWNER). Pulumi's User-resource delete fails with "role cannot be
98
+ // dropped because some objects depend on it" (Postgres 400). REASSIGN
99
+ // OWNED + DROP OWNED clears the dependencies first; the DO block
100
+ // is a no-op when caelo_admin already doesn't exist (fresh installs +
101
+ // post-cleanup re-runs both pass through cleanly).
102
+ await stepDrainLegacyCaeloAdminUser(projectId, region);
103
+ // === 10. Pulumi up via Automation SDK ===
104
+ // Pre-built signed images live at the Caelo-team public AR repo
105
+ // (caelo-website/caelo-cms-images by default). Cloud Run reads them
106
+ // directly with no operator-side IAM binding — anonymous public pull.
107
+ await stepPulumiUp(installId, {
108
+ projectId,
109
+ domain,
110
+ ownerEmail,
111
+ region,
112
+ saKeyPath: keyPath,
113
+ pulumiPassphrase,
114
+ anthropicApiKey: anthropicKey,
115
+ cloudSqlTier: costInputs.cloudSqlTier,
116
+ cloudSqlHa: costInputs.cloudSqlHa,
117
+ adminMinInstances: costInputs.adminMinInstances,
118
+ gatewayMinInstances: costInputs.gatewayMinInstances,
119
+ wafAdaptiveProtection: costInputs.wafAdaptiveProtection,
120
+ iapAllowlist: [`user:${ownerEmail}`],
121
+ imageDigests,
122
+ });
123
+ // === 10. Wait for managed cert to flip from PROVISIONING → ACTIVE ===
124
+ // Pulumi reports the cert "created" the moment GCP queues it; the
125
+ // actual issuance + DNS validation takes 5-30 min depending on
126
+ // load + DNS propagation. The bootstrap URL is meaningless until
127
+ // ACTIVE — without this step the wizard would tell the operator
128
+ // "you're done" while HTTPS still ERR_CONNECTION_CLOSEDs.
129
+ await stepWaitForCertActive(projectId);
130
+ // === 11. Apply DB migrations against Cloud SQL via a one-shot Job ===
131
+ // Cloud SQL lives on a private VPC IP that's only reachable from
132
+ // inside the VPC. Spawn a Cloud Run Job using the same admin image
133
+ // (which carries packages/migrations) to apply admin + public schema.
134
+ // Idempotent — drizzle's __drizzle_migrations table tracks applied
135
+ // versions, so re-runs only apply NEW migrations on subsequent ups.
136
+ // P21 ship 3 — shared with `cms-provision upgrade`. Aborts the
137
+ // wizard with a clear error if migrations fail (vs. silently
138
+ // continuing with a half-migrated DB).
139
+ {
140
+ const { runMigrationsViaCloudRunJob } = await import("../migration-runner.js");
141
+ const r = await runMigrationsViaCloudRunJob({ projectId, region });
142
+ if (!r.ok) {
143
+ cancel(`Migrations failed: ${r.error}. Inspect the Cloud Run Job logs and re-run.`);
144
+ process.exit(1);
145
+ }
146
+ }
147
+ // === 12. Upload the fresh-install placeholder to the static bucket ===
148
+ // Without this, https://<domain>/ returns the raw GCS NoSuchKey XML
149
+ // until the operator publishes their first deploy. Idempotent: skips
150
+ // if any object already exists at the bucket root (i.e. the static
151
+ // generator has already published).
152
+ await stepUploadStaticPlaceholder(installId, projectId, domain);
153
+ // === 12. DNS records + bootstrap URL ===
154
+ // IAP is enabled directly on the LB BackendService (Pulumi-managed);
155
+ // no post-up gcloud step needed.
156
+ await stepFinalize(installId);
157
+ // Reference unused params to silence the linter.
158
+ void region;
159
+ void domain;
160
+ void ownerEmail;
161
+ }
162
+ /**
163
+ * Resolve the floating `:main` tag on each service's public AR image
164
+ * to its current sha256 digest, so the Pulumi stack can pin Cloud Run
165
+ * to the exact image rather than the mutable tag. Without this, a
166
+ * fresh release-images push doesn't trigger a new Cloud Run revision
167
+ * because the image reference in the stack (":main") is unchanged.
168
+ */
169
+ async function resolveImageDigests(services) {
170
+ const project = "caelo-website";
171
+ const region = "europe-west1";
172
+ const repo = "caelo-cms-images";
173
+ const out = {};
174
+ for (const service of services) {
175
+ const s = spinner();
176
+ s.start(`Resolving ${service}:main → digest...`);
177
+ // gcloud's --filter='tag=main' is in a transitional state (warns + matches
178
+ // nothing) and 'tag:main' substring-matches main-<sha> too. List all tags
179
+ // and grep for the exact-match row in JS.
180
+ const r = await gcloud([
181
+ "artifacts",
182
+ "docker",
183
+ "tags",
184
+ "list",
185
+ `${region}-docker.pkg.dev/${project}/${repo}/${service}`,
186
+ "--format=value(tag,version)",
187
+ ]);
188
+ if (!r.ok) {
189
+ s.stop(red(`Could not list ${service} tags: ${r.stderr.trim() || "(no output)"}`));
190
+ cancel("Aborted.");
191
+ process.exit(1);
192
+ }
193
+ const exact = r.stdout
194
+ .split("\n")
195
+ .map((line) => line.trim().split(/\s+/))
196
+ .find(([tag]) => tag === "main");
197
+ const digest = exact?.[1] ?? "";
198
+ if (!/^sha256:[0-9a-f]{64}$/.test(digest)) {
199
+ s.stop(red(`Unexpected digest format for ${service}: ${digest}`));
200
+ cancel("Aborted.");
201
+ process.exit(1);
202
+ }
203
+ s.stop(green(`${service}: ${dim(`${digest.slice(0, 19)}...`)}`));
204
+ out[service] = digest;
205
+ }
206
+ return out;
207
+ }
208
+ // =========================================================================
209
+ // Per-step helpers
210
+ // =========================================================================
211
+ async function stepActiveGcloud() {
212
+ const stepName = "gcloud-active";
213
+ const account = await activeAccount();
214
+ if (!account) {
215
+ log.error(red("No active gcloud account."));
216
+ log.warn(`Run ${bold("gcloud auth login")} in another terminal, then re-run ${bold("bunx @caelo-cms/provisioning")}.`);
217
+ cancel("Aborted — no gcloud auth.");
218
+ process.exit(2);
219
+ }
220
+ log.success(`gcloud auth: ${bold(account)}`);
221
+ void stepName;
222
+ }
223
+ async function stepProjectId(opts) {
224
+ if (opts.projectId) {
225
+ log.info(`Project id: ${bold(opts.projectId)} ${dim("(supplied via --project-id)")}`);
226
+ return opts.projectId;
227
+ }
228
+ const guess = opts.domain.split(".")[0]?.replace(/[^a-z0-9-]/g, "-") ?? "caelo";
229
+ const value = await text({
230
+ message: "GCP project id (will be created if absent)",
231
+ placeholder: guess,
232
+ defaultValue: guess,
233
+ validate: (v) => {
234
+ if (!v || v.length < 6 || v.length > 30) {
235
+ return "GCP project id must be 6–30 chars";
236
+ }
237
+ if (!/^[a-z][a-z0-9-]*[a-z0-9]$/.test(v)) {
238
+ return "GCP project id: lowercase letters, digits, hyphens; start with letter; no trailing hyphen";
239
+ }
240
+ return undefined;
241
+ },
242
+ });
243
+ if (isCancel(value)) {
244
+ cancel("Cancelled.");
245
+ process.exit(0);
246
+ }
247
+ return value;
248
+ }
249
+ async function stepProjectCreate(installId, projectId) {
250
+ const stepName = `project-create-${projectId}`;
251
+ if (isStepDone(installId, stepName)) {
252
+ log.success(`Project ${bold(projectId)} ${dim("(already created)")}`);
253
+ return;
254
+ }
255
+ if (await projectExists(projectId)) {
256
+ log.success(`Project ${bold(projectId)} ${dim("(exists)")}`);
257
+ markStepDone(installId, stepName, { existed: true });
258
+ return;
259
+ }
260
+ const s = spinner();
261
+ s.start(`Creating GCP project ${projectId}...`);
262
+ const r = await createProject(projectId, "Caelo CMS");
263
+ if (!r.ok) {
264
+ s.stop(red(`Failed: ${r.stderr.trim()}`));
265
+ log.error(`gcloud projects create failed. Check that the id ${bold(projectId)} is globally unique + that you have project-create rights on the org.`);
266
+ cancel("Aborted.");
267
+ process.exit(1);
268
+ }
269
+ s.stop(green(`Project ${bold(projectId)} created`));
270
+ markStepDone(installId, stepName, { created: true });
271
+ }
272
+ async function stepBillingLink(installId, projectId, nonInteractive) {
273
+ const stepName = `billing-link-${projectId}`;
274
+ if (isStepDone(installId, stepName)) {
275
+ log.success(`Billing already linked ${dim("(checkpoint)")}`);
276
+ return;
277
+ }
278
+ const accounts = await listBillingAccounts();
279
+ const open = accounts.filter((a) => a.open);
280
+ if (open.length === 0) {
281
+ log.error(red("No open billing accounts found."));
282
+ log.warn(`Open one at https://console.cloud.google.com/billing then re-run ${bold("bunx @caelo-cms/provisioning")}.`);
283
+ cancel("Aborted.");
284
+ process.exit(1);
285
+ }
286
+ let chosen;
287
+ if (open.length === 1) {
288
+ [chosen] = open;
289
+ log.info(`Billing account: ${bold(chosen.displayName)} ${dim(`(${chosen.id})`)}`);
290
+ }
291
+ else if (nonInteractive) {
292
+ log.error(red(`Multiple billing accounts but --non-interactive passed. Re-run with --billing-account=<id> (one of: ${open.map((a) => a.id).join(", ")}).`));
293
+ cancel("Aborted.");
294
+ process.exit(1);
295
+ }
296
+ else {
297
+ const choice = await select({
298
+ message: "Pick a billing account",
299
+ options: open.map((a) => ({
300
+ value: a.id,
301
+ label: a.displayName,
302
+ hint: a.id,
303
+ })),
304
+ });
305
+ if (isCancel(choice)) {
306
+ cancel("Cancelled.");
307
+ process.exit(0);
308
+ }
309
+ const found = open.find((a) => a.id === choice);
310
+ if (!found) {
311
+ cancel("Picked account not found in list — internal error.");
312
+ process.exit(1);
313
+ }
314
+ chosen = found;
315
+ }
316
+ const s = spinner();
317
+ s.start(`Linking billing ${chosen.id}...`);
318
+ const r = await linkBilling(projectId, chosen.id);
319
+ if (!r.ok) {
320
+ s.stop(red(`Failed: ${r.stderr.trim()}`));
321
+ cancel("Aborted.");
322
+ process.exit(1);
323
+ }
324
+ s.stop(green("Billing linked"));
325
+ markStepDone(installId, stepName, { billingAccountId: chosen.id });
326
+ }
327
+ async function stepEnableApis(installId, projectId) {
328
+ const stepName = `enable-apis-${projectId}`;
329
+ if (isStepDone(installId, stepName)) {
330
+ log.success(`APIs enabled ${dim(`(${REQUIRED_API_LIST.length} services, checkpointed)`)}`);
331
+ return;
332
+ }
333
+ const s = spinner();
334
+ s.start(`Enabling ${REQUIRED_API_LIST.length} GCP APIs (15-30s)...`);
335
+ const r = await enableApis(projectId);
336
+ if (!r.ok) {
337
+ s.stop(red(`Failed: ${r.stderr.trim()}`));
338
+ cancel("Aborted.");
339
+ process.exit(1);
340
+ }
341
+ s.stop(green(`${REQUIRED_API_LIST.length} APIs enabled`));
342
+ markStepDone(installId, stepName, { count: REQUIRED_API_LIST.length });
343
+ }
344
+ async function stepServiceAccount(installId, projectId, saEmail) {
345
+ const stepName = `service-account-${SA_ACCOUNT_ID}`;
346
+ if (isStepDone(installId, stepName)) {
347
+ log.success(`Provisioner SA ${dim("(already created)")}`);
348
+ return;
349
+ }
350
+ if (await serviceAccountExists(projectId, saEmail)) {
351
+ log.success(`Provisioner SA ${bold(saEmail)} ${dim("(exists)")}`);
352
+ markStepDone(installId, stepName, { existed: true, saEmail });
353
+ return;
354
+ }
355
+ const s = spinner();
356
+ s.start(`Creating provisioner service account...`);
357
+ const r = await createServiceAccount(projectId, SA_ACCOUNT_ID, "Caelo provisioner");
358
+ if (!r.ok) {
359
+ s.stop(red(`Failed: ${r.stderr.trim()}`));
360
+ cancel("Aborted.");
361
+ process.exit(1);
362
+ }
363
+ s.stop(green(`Provisioner SA ${bold(saEmail)} created`));
364
+ markStepDone(installId, stepName, { created: true, saEmail });
365
+ }
366
+ async function stepGrantRoles(installId, projectId, saEmail) {
367
+ const stepName = `grant-roles-${projectId}`;
368
+ if (isStepDone(installId, stepName)) {
369
+ log.success(`IAM roles granted ${dim(`(${PROVISIONER_ROLE_LIST.length} roles, checkpointed)`)}`);
370
+ return;
371
+ }
372
+ const s = spinner();
373
+ s.start(`Granting ${PROVISIONER_ROLE_LIST.length} IAM roles to the provisioner SA...`);
374
+ const { granted, failed } = await grantProvisionerRoles(projectId, saEmail);
375
+ if (failed.length > 0) {
376
+ s.stop(red(`Granted ${granted}; failed ${failed.length}: ${failed.join(", ")}`));
377
+ log.error(`Some role bindings failed. You can re-run safely (idempotent), or grant the failed roles manually via gcloud.`);
378
+ cancel("Aborted.");
379
+ process.exit(1);
380
+ }
381
+ s.stop(green(`${granted} IAM roles granted`));
382
+ markStepDone(installId, stepName, { granted });
383
+ }
384
+ async function stepMintKey(installId, projectId, saEmail, secretsDir) {
385
+ const stepName = `mint-key-${projectId}`;
386
+ const keyPath = join(secretsDir, "sa-key.json");
387
+ if (isStepDone(installId, stepName) && existsSync(keyPath)) {
388
+ log.success(`SA key ${dim("(already minted, kept)")}`);
389
+ return keyPath;
390
+ }
391
+ const s = spinner();
392
+ s.start(`Minting SA key → ${dim(keyPath)}`);
393
+ const r = await createServiceAccountKey(saEmail, keyPath);
394
+ if (!r.ok) {
395
+ s.stop(red(`Failed: ${r.stderr.trim()}`));
396
+ cancel("Aborted.");
397
+ process.exit(1);
398
+ }
399
+ s.stop(green(`SA key minted (mode 600)`));
400
+ markStepDone(installId, stepName, { path: keyPath });
401
+ return keyPath;
402
+ }
403
+ async function stepAnthropicKey(installId, nonInteractive) {
404
+ const existing = readSecret(installId, "anthropic-api-key");
405
+ if (existing) {
406
+ log.success(`Anthropic API key ${dim("(reused from secrets/, not re-prompted)")}`);
407
+ return existing;
408
+ }
409
+ if (nonInteractive) {
410
+ log.error(red("Missing Anthropic API key + --non-interactive — cannot proceed."));
411
+ log.warn(`Write the key to ${bold(`~/.caelo-${installId}/secrets/anthropic-api-key`)} (mode 600) then re-run.`);
412
+ cancel("Aborted.");
413
+ process.exit(1);
414
+ }
415
+ const value = await password({
416
+ message: "Anthropic API key (input hidden; saved to secrets/anthropic-api-key)",
417
+ validate: (v) => {
418
+ if (!v || v.length < 20)
419
+ return "Looks too short — Anthropic keys start with sk-ant-";
420
+ if (!v.startsWith("sk-"))
421
+ return "Should start with sk-";
422
+ return undefined;
423
+ },
424
+ });
425
+ if (isCancel(value)) {
426
+ cancel("Cancelled.");
427
+ process.exit(0);
428
+ }
429
+ const key = value.trim();
430
+ writeSecret(installId, "anthropic-api-key", key);
431
+ log.success(`Anthropic key saved → ${dim(`~/.caelo-${installId}/secrets/anthropic-api-key`)}`);
432
+ return key;
433
+ }
434
+ function stepPulumiPassphrase(installId) {
435
+ const existing = readSecret(installId, "pulumi-passphrase");
436
+ if (existing) {
437
+ log.success(`Pulumi passphrase ${dim("(reused from secrets/, not regenerated)")}`);
438
+ return existing;
439
+ }
440
+ const passphrase = randomBytes(32).toString("hex");
441
+ writeSecret(installId, "pulumi-passphrase", passphrase);
442
+ log.success(`Pulumi passphrase generated → ${dim(`~/.caelo-${installId}/secrets/pulumi-passphrase`)}`);
443
+ return passphrase;
444
+ }
445
+ async function stepPulumiUp(installId, opts) {
446
+ const stepName = `pulumi-up-${opts.projectId}`;
447
+ if (isStepDone(installId, stepName)) {
448
+ log.success(`Pulumi up ${dim("(checkpointed — re-running for drift refresh)")}`);
449
+ }
450
+ const { secretsDir } = ensureInstallDir(installId);
451
+ const root = installRoot(installId);
452
+ log.info(`Pulumi up — wall-clock 8–15 min (Cloud SQL is the long pole). Streaming progress...`);
453
+ let resourceCount = 0;
454
+ const result = await pulumiUpGcp({
455
+ installId,
456
+ installRoot: root,
457
+ secretsDir,
458
+ ...opts,
459
+ }, (kind, message) => {
460
+ if (kind === "resource") {
461
+ resourceCount++;
462
+ if (resourceCount % 5 === 0) {
463
+ process.stdout.write(`\r${dim(` ${resourceCount} resources updated`)}`);
464
+ }
465
+ }
466
+ else if (kind === "error") {
467
+ process.stdout.write("\n");
468
+ log.error(red(message));
469
+ }
470
+ // logs: silent — too noisy for a live progress UI; pulumi
471
+ // diagnostics will print on failure via the SDK's onOutput.
472
+ });
473
+ process.stdout.write("\n");
474
+ log.success(green(`Pulumi up complete — ${result.resourceCount.created} created, ${result.resourceCount.updated} updated, ${result.resourceCount.deleted} deleted`));
475
+ markStepDone(installId, stepName, { outputs: result.outputs });
476
+ }
477
+ /**
478
+ * Poll the LB managed SSL cert until both domains report ACTIVE.
479
+ * Pulumi reports the cert "created" the moment GCP queues it; the actual
480
+ * ACME-style validation against the LB takes 5-30 min. Without this
481
+ * step the wizard would tell the operator "Done. Welcome to Caelo CMS"
482
+ * while HTTPS still ERR_CONNECTION_CLOSEDs and the bootstrap URL is
483
+ * useless. Times out after 35 min with a clear escalation note.
484
+ */
485
+ async function stepWaitForCertActive(projectId) {
486
+ const s = spinner();
487
+ s.start("Waiting for managed TLS cert to validate (typically 5-15 min)...");
488
+ const deadline = Date.now() + 35 * 60 * 1000;
489
+ let lastStatus = "";
490
+ while (Date.now() < deadline) {
491
+ const r = await gcloud([
492
+ "compute",
493
+ "target-https-proxies",
494
+ "list",
495
+ "--project",
496
+ projectId,
497
+ "--format=value(sslCertificates)",
498
+ ]);
499
+ if (!r.ok) {
500
+ s.stop(red(`gcloud target-https-proxies list failed: ${r.stderr.trim()}`));
501
+ cancel("Aborted.");
502
+ process.exit(1);
503
+ }
504
+ const certUrl = r.stdout.trim().split(/\s+/)[0] ?? "";
505
+ const certName = certUrl.split("/").pop() ?? "";
506
+ if (!certName) {
507
+ s.stop(red("Could not resolve managed cert name from HTTPS proxy."));
508
+ cancel("Aborted.");
509
+ process.exit(1);
510
+ }
511
+ const desc = await gcloud([
512
+ "compute",
513
+ "ssl-certificates",
514
+ "describe",
515
+ certName,
516
+ "--global",
517
+ "--project",
518
+ projectId,
519
+ "--format=value(managed.status,managed.domainStatus)",
520
+ ]);
521
+ const text = (desc.stdout || "").trim();
522
+ if (text !== lastStatus) {
523
+ s.message(`Cert ${certName}: ${text || "(no status yet)"}`);
524
+ lastStatus = text;
525
+ }
526
+ // Status format: "ACTIVE\t{'caelo-cms.com': 'ACTIVE', 'admin.caelo-cms.com': 'ACTIVE'}"
527
+ if (/^ACTIVE\b/.test(text) && !/FAILED/.test(text) && !/PROVISIONING/.test(text)) {
528
+ s.stop(green(`Managed TLS cert is ACTIVE for all domains`));
529
+ return;
530
+ }
531
+ if (/FAILED_NOT_VISIBLE/.test(text)) {
532
+ s.message(`${cyan(certName)} validation can't reach the LB yet — check DNS A records resolve to LB IP. Will keep polling.`);
533
+ }
534
+ await new Promise((res) => setTimeout(res, 30 * 1000));
535
+ }
536
+ s.stop(yellow("Cert still not ACTIVE after 35 min."));
537
+ log.warn([
538
+ "The cert may still finish on its own — Google Managed Cert validation can take up to 60 min.",
539
+ "Check status anytime:",
540
+ ` ${bold(`gcloud compute ssl-certificates list --global --project=${projectId}`)}`,
541
+ "Common causes if it never flips ACTIVE:",
542
+ " - DNS A records don't resolve to the LB IP yet (check `dig +short caelo-cms.com`)",
543
+ " - The LB's HTTP port-80 listener isn't reachable from the public internet",
544
+ ].join("\n"));
545
+ }
546
+ /**
547
+ * Run REASSIGN OWNED BY caelo_admin TO admin_role + DROP OWNED BY
548
+ * caelo_admin in both cms_admin + cms_public, via a one-shot Cloud Run
549
+ * Job. Idempotent: the SQL is wrapped in DO IF EXISTS so fresh installs
550
+ * (no caelo_admin) + post-cleanup re-runs both pass through cleanly.
551
+ *
552
+ * Why this exists: the SQL-user rename in commit 3a81c37 left
553
+ * `caelo_admin` in pg_roles owning N sequences/grants the prior
554
+ * ALTER TABLE OWNER fix didn't reach. Pulumi's User-delete subsequently
555
+ * fails with "role cannot be dropped because some objects depend on
556
+ * it." REASSIGN/DROP OWNED clears them first.
557
+ *
558
+ * Skipped silently when no admin Cloud Run service exists (fresh
559
+ * installs that haven't booted the admin yet).
560
+ */
561
+ async function stepDrainLegacyCaeloAdminUser(projectId, region) {
562
+ const s = spinner();
563
+ s.start("Draining legacy caelo_admin postgres user (idempotent)...");
564
+ // Resolve current admin image; skip if missing (fresh install).
565
+ const adminDescr = await gcloud([
566
+ "run",
567
+ "services",
568
+ "describe",
569
+ "caelo-production-admin-3efcfea",
570
+ "--region",
571
+ region,
572
+ "--project",
573
+ projectId,
574
+ "--format=json",
575
+ ]);
576
+ if (!adminDescr.ok) {
577
+ s.stop(green("No admin service yet — nothing to drain (fresh install)"));
578
+ return;
579
+ }
580
+ let adminImg = "";
581
+ let networkRef = "";
582
+ let subnetRef = "";
583
+ try {
584
+ const d = JSON.parse(adminDescr.stdout);
585
+ adminImg = d.spec.template.spec.containers[0]?.image ?? "";
586
+ // Cloud Run v2 with Direct VPC stores network refs in the
587
+ // `run.googleapis.com/network-interfaces` annotation (JSON-encoded
588
+ // array), NOT in spec.template.spec.vpcAccess. Read both — newer
589
+ // installs use the annotation; older ones use vpcAccess.
590
+ const annotations = d.spec.template.metadata?.annotations ?? {};
591
+ const niAnnotation = annotations["run.googleapis.com/network-interfaces"];
592
+ if (niAnnotation) {
593
+ try {
594
+ const parsed = JSON.parse(niAnnotation);
595
+ networkRef = parsed[0]?.network ?? "";
596
+ subnetRef = parsed[0]?.subnetwork ?? "";
597
+ }
598
+ catch {
599
+ // fall through to vpcAccess path
600
+ }
601
+ }
602
+ if (!networkRef || !subnetRef) {
603
+ const ni = d.spec.template.spec.vpcAccess?.networkInterfaces?.[0];
604
+ networkRef ||= ni?.network ?? "";
605
+ subnetRef ||= ni?.subnetwork ?? "";
606
+ }
607
+ }
608
+ catch {
609
+ s.stop(yellow("Could not parse admin service describe — skipping drain"));
610
+ return;
611
+ }
612
+ if (!adminImg) {
613
+ s.stop(green("No admin image to spawn drain job — skipping"));
614
+ return;
615
+ }
616
+ if (!networkRef || !subnetRef) {
617
+ s.stop(yellow("Could not resolve VPC network from admin service — skipping drain"));
618
+ return;
619
+ }
620
+ // Read postgres superuser password from Secret Manager.
621
+ const secretFetch = await gcloud([
622
+ "secrets",
623
+ "versions",
624
+ "access",
625
+ "latest",
626
+ "--secret",
627
+ "caelo-production-postgres-password",
628
+ "--project",
629
+ projectId,
630
+ ]);
631
+ if (!secretFetch.ok) {
632
+ s.stop(yellow("Could not read postgres-password secret — skipping drain"));
633
+ return;
634
+ }
635
+ const pgPass = secretFetch.stdout.trim();
636
+ // Sync the SQL `postgres` user's password to the Secret Manager value
637
+ // (Pulumi may have rotated the secret without applying to the user yet).
638
+ await gcloud([
639
+ "sql",
640
+ "users",
641
+ "set-password",
642
+ "postgres",
643
+ "--instance",
644
+ "caelo-production-pg-1d65811",
645
+ "--project",
646
+ projectId,
647
+ "--password",
648
+ pgPass,
649
+ ]);
650
+ // Resolve the SQL instance's private IP from gcloud (avoids reading
651
+ // Pulumi outputs from inside the wizard).
652
+ const sqlDescr = await gcloud([
653
+ "sql",
654
+ "instances",
655
+ "describe",
656
+ "caelo-production-pg-1d65811",
657
+ "--project",
658
+ projectId,
659
+ "--format=value(ipAddresses[0].ipAddress)",
660
+ ]);
661
+ const pgHost = (sqlDescr.stdout || "").trim();
662
+ if (!pgHost) {
663
+ s.stop(yellow("Could not resolve Cloud SQL private IP — skipping drain"));
664
+ return;
665
+ }
666
+ const adminPg = `postgres://postgres:${pgPass}@${pgHost}:5432/cms_admin`;
667
+ const publicPg = `postgres://postgres:${pgPass}@${pgHost}:5432/cms_public`;
668
+ // The cleanup script — uses bun's globalThis.Bun.SQL (already proven
669
+ // pattern in hooks.server.ts + the migration runner). DO block makes
670
+ // it safe to re-run.
671
+ //
672
+ // Per-step rationale (validated against caelo-website production):
673
+ // - WITH INHERIT TRUE on both grants: PG 16 default is NOINHERIT,
674
+ // so plain "GRANT caelo_admin TO postgres" makes postgres a member
675
+ // but doesn't give it caelo_admin's privileges. REASSIGN OWNED
676
+ // fails with "Only roles with privileges of role caelo_admin may
677
+ // reassign objects owned by it." WITH INHERIT TRUE fixes it.
678
+ // - ALTER SCHEMA public OWNER TO admin_role: in PG 16 the default
679
+ // `public` schema is owned by cloudsqladmin (Cloud SQL) /
680
+ // pg_database_owner (vanilla). The REASSIGN's per-function ACL
681
+ // check ("permission denied for schema public") fails until
682
+ // admin_role owns it.
683
+ // - We count pg_proc (not pg_class): the legacy caelo_admin role
684
+ // left behind owned FUNCTIONS, not tables. The before count was
685
+ // 31 on the caelo-website install; after was 0.
686
+ const script = `const SQL = globalThis.Bun.SQL; async function fix(name, u){ const s = new SQL(u); console.log(\`[\${name}] connected\`); const before = await s.unsafe(\`SELECT count(*)::int AS n FROM pg_proc p JOIN pg_roles r ON p.proowner=r.oid WHERE r.rolname = 'caelo_admin';\`); console.log(\`[\${name}] caelo_admin functions=\`, before); await s.unsafe(\`DO $$ BEGIN IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname='caelo_admin') THEN EXECUTE 'GRANT caelo_admin TO CURRENT_USER WITH INHERIT TRUE'; EXECUTE 'GRANT admin_role TO CURRENT_USER WITH INHERIT TRUE'; EXECUTE 'ALTER SCHEMA public OWNER TO admin_role'; EXECUTE 'REASSIGN OWNED BY caelo_admin TO admin_role'; EXECUTE 'DROP OWNED BY caelo_admin'; END IF; END $$;\`); const after = await s.unsafe(\`SELECT count(*)::int AS n FROM pg_proc p JOIN pg_roles r ON p.proowner=r.oid WHERE r.rolname = 'caelo_admin';\`); console.log(\`[\${name}] after functions=\`, after); await s.end(); } await fix('cms_admin', process.env.ADMIN_PG); await fix('cms_public', process.env.PUBLIC_PG); console.log('drained');`;
687
+ // Delete-then-create so we always run with the freshest config.
688
+ await gcloud([
689
+ "run",
690
+ "jobs",
691
+ "delete",
692
+ "caelo-drain-legacy-user",
693
+ "--region",
694
+ region,
695
+ "--project",
696
+ projectId,
697
+ "--quiet",
698
+ ]);
699
+ const create = await gcloud([
700
+ "run",
701
+ "jobs",
702
+ "create",
703
+ "caelo-drain-legacy-user",
704
+ `--image=${adminImg}`,
705
+ "--region",
706
+ region,
707
+ "--project",
708
+ projectId,
709
+ "--service-account",
710
+ `caelo-production-run-sa@${projectId}.iam.gserviceaccount.com`,
711
+ "--network",
712
+ networkRef.split("/").pop() ?? networkRef,
713
+ "--subnet",
714
+ subnetRef.split("/").pop() ?? subnetRef,
715
+ "--vpc-egress=private-ranges-only",
716
+ "--command=bun",
717
+ // ^|^ delimiter form — JS contains commas so we can't use the default.
718
+ // Wrap in async IIFE so top-level `await` in SCRIPT works (sync eval()
719
+ // doesn't support TLA; new Function('return (async () => { ... })()')
720
+ // does, and avoids needing to write SCRIPT to disk).
721
+ `--args=^|^--bun|-e|(new Function('return (async () => { ' + process.env.SCRIPT + ' })()'))()`,
722
+ `--set-env-vars=^|^SCRIPT=${script}|ADMIN_PG=${adminPg}|PUBLIC_PG=${publicPg}`,
723
+ "--max-retries=0",
724
+ "--task-timeout=2m",
725
+ "--quiet",
726
+ ]);
727
+ if (!create.ok) {
728
+ s.stop(yellow(`Drain job create failed: ${create.stderr.trim()} — continuing`));
729
+ return;
730
+ }
731
+ const exec = await gcloud([
732
+ "run",
733
+ "jobs",
734
+ "execute",
735
+ "caelo-drain-legacy-user",
736
+ "--region",
737
+ region,
738
+ "--project",
739
+ projectId,
740
+ "--wait",
741
+ ]);
742
+ if (!exec.ok) {
743
+ s.stop(red(`Drain job failed: ${exec.stderr.trim()}`));
744
+ cancel("Could not drain legacy caelo_admin user. Inspect the Cloud Run Job logs " +
745
+ "(`gcloud logging read 'resource.type=cloud_run_job AND " +
746
+ "resource.labels.job_name=caelo-drain-legacy-user' --project=" +
747
+ projectId +
748
+ "`) and re-run the wizard. Aborting before pulumi-up to avoid the " +
749
+ "predictable pg user-delete failure downstream.");
750
+ process.exit(1);
751
+ }
752
+ s.stop(green("Legacy caelo_admin user drained"));
753
+ }
754
+ /**
755
+ * Upload the static placeholder so https://<domain>/ shows a friendly
756
+ * "Coming soon" landing instead of GCS's raw NoSuchKey XML. Skipped
757
+ * if anything is already in the bucket root (the static-generator's
758
+ * first publish replaces this transparently).
759
+ */
760
+ async function stepUploadStaticPlaceholder(installId, projectId, domain) {
761
+ const stepName = `static-placeholder-${projectId}`;
762
+ if (isStepDone(installId, stepName)) {
763
+ log.success(`Static placeholder uploaded ${dim("(checkpointed)")}`);
764
+ return;
765
+ }
766
+ // Resolve bucket name from Pulumi outputs.
767
+ const lsRoot = await gcloud([
768
+ "storage",
769
+ "buckets",
770
+ "list",
771
+ "--project",
772
+ projectId,
773
+ "--filter",
774
+ "name~caelo-production-static",
775
+ "--format=value(name)",
776
+ ]);
777
+ const bucketName = (lsRoot.stdout || "").trim().split(/\s+/)[0];
778
+ if (!bucketName) {
779
+ log.warn(yellow("Static bucket not found; skipping placeholder upload."));
780
+ return;
781
+ }
782
+ // Idempotency: skip if anything is in the bucket root already.
783
+ const existing = await gcloud([
784
+ "storage",
785
+ "ls",
786
+ `gs://${bucketName}/index.html`,
787
+ "--project",
788
+ projectId,
789
+ ]);
790
+ if (existing.ok) {
791
+ log.success(`Static bucket already populated ${dim("(skipping placeholder)")}`);
792
+ markStepDone(installId, stepName, { skipped: "exists" });
793
+ return;
794
+ }
795
+ // Resolve the placeholder asset relative to this file (works in dev
796
+ // from src/ and in the published tarball from dist/).
797
+ const here = new URL(import.meta.url).pathname;
798
+ const candidates = [
799
+ join(here, "..", "..", "..", "static", "welcome.html"),
800
+ join(here, "..", "..", "..", "..", "static", "welcome.html"),
801
+ ];
802
+ let html = "";
803
+ for (const path of candidates) {
804
+ if (existsSync(path)) {
805
+ html = readFileSync(path, "utf8");
806
+ break;
807
+ }
808
+ }
809
+ if (!html) {
810
+ log.warn(yellow("welcome.html template not found; skipping placeholder upload."));
811
+ return;
812
+ }
813
+ // Substitute the admin URL.
814
+ html = html.replaceAll("{{ADMIN_URL}}", `https://admin.${domain}`);
815
+ const s = spinner();
816
+ s.start(`Uploading welcome page → gs://${bucketName}/index.html`);
817
+ // Pipe via stdin so we don't write a temp file.
818
+ const { spawn } = await import("node:child_process");
819
+ const upload = await new Promise((resolve) => {
820
+ const child = spawn("gcloud", [
821
+ "storage",
822
+ "cp",
823
+ "--content-type",
824
+ "text/html; charset=utf-8",
825
+ "--cache-control",
826
+ "no-cache, max-age=60",
827
+ "-",
828
+ `gs://${bucketName}/index.html`,
829
+ "--project",
830
+ projectId,
831
+ ], { stdio: ["pipe", "inherit", "pipe"] });
832
+ let stderr = "";
833
+ child.stderr.on("data", (d) => {
834
+ stderr += d.toString();
835
+ });
836
+ child.on("close", (code) => resolve({ ok: code === 0, stderr }));
837
+ child.stdin.write(html);
838
+ child.stdin.end();
839
+ });
840
+ if (!upload.ok) {
841
+ s.stop(red(`Upload failed: ${upload.stderr.trim()}`));
842
+ return;
843
+ }
844
+ s.stop(green(`Welcome page live at https://${domain}/`));
845
+ markStepDone(installId, stepName, { bucket: bucketName });
846
+ }
847
+ async function stepFinalize(installId) {
848
+ const meta = readMetadata(installId);
849
+ const progress = readSecret(installId, "pulumi-passphrase"); // touch to verify state still present
850
+ void progress;
851
+ if (!meta)
852
+ return;
853
+ const upStep = `pulumi-up-${meta.projectId}`;
854
+ const upPayload = (await import("../install-state.js")).getStepPayload(installId, upStep);
855
+ const outputs = upPayload?.outputs ?? {};
856
+ const lbIp = String(outputs.lbIpOut ?? "");
857
+ const bootstrapUrl = outputs.bootstrapUrlOut ?? "<unknown>";
858
+ const adminDomainOut = String(outputs.adminDomainOut ?? `admin.${meta.domain}`);
859
+ // Auto-create DNS via Cloudflare if CLOUDFLARE_API_TOKEN is set;
860
+ // otherwise the manual adapter prints + verify-polls.
861
+ if (lbIp.length > 0 && lbIp !== "<unknown>") {
862
+ const stepName = `dns-${meta.projectId}`;
863
+ if (!isStepDone(installId, stepName)) {
864
+ const adapter = await pickDnsAdapter({ domain: meta.domain });
865
+ log.info(`DNS adapter: ${bold(adapter.name)}`);
866
+ try {
867
+ await adapter.applyRecords([
868
+ { hostname: meta.domain, type: "A", value: lbIp },
869
+ { hostname: adminDomainOut, type: "CNAME", value: "ghs.googlehosted.com." },
870
+ ]);
871
+ markStepDone(installId, stepName, { adapter: adapter.name });
872
+ }
873
+ catch (e) {
874
+ log.warn(yellow(`DNS auto-create did not fully succeed: ${e instanceof Error ? e.message : String(e)}`));
875
+ log.warn(`Verify your DNS records manually + re-run ${bold("bunx @caelo-cms/provisioning")} to resume.`);
876
+ }
877
+ }
878
+ }
879
+ note([
880
+ green(`✓ ${meta.domain} provisioned.`),
881
+ "",
882
+ bold("Owner setup (open in your browser):"),
883
+ ` ${cyan(String(bootstrapUrl))}`,
884
+ "",
885
+ bold("Lifecycle commands:"),
886
+ ` ${dim("bunx @caelo-cms/provisioning status")} — health check + monthly cost`,
887
+ ` ${dim("bunx @caelo-cms/provisioning upgrade")} — pull latest images + roll Cloud Run`,
888
+ ` ${dim("bunx @caelo-cms/provisioning destroy")} — tear everything down (irreversible)`,
889
+ ].join("\n"), "Done");
890
+ void homedir; // unused-import guard
891
+ void readFileSync;
892
+ }
893
+ // kleur unused-import guard for the yellow color helper kept for future warnings.
894
+ void yellow;
895
+ //# sourceMappingURL=gcp.js.map