@caelo-cms/provisioning 0.10.38 → 0.10.40

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 (66) hide show
  1. package/dist/admin-domain.d.ts +21 -3
  2. package/dist/admin-domain.d.ts.map +1 -1
  3. package/dist/admin-domain.js +88 -16
  4. package/dist/admin-domain.js.map +1 -1
  5. package/dist/cert-wait.d.ts +84 -0
  6. package/dist/cert-wait.d.ts.map +1 -0
  7. package/dist/cert-wait.js +95 -0
  8. package/dist/cert-wait.js.map +1 -0
  9. package/dist/checkpoint-secrets.d.ts +88 -0
  10. package/dist/checkpoint-secrets.d.ts.map +1 -0
  11. package/dist/checkpoint-secrets.js +245 -0
  12. package/dist/checkpoint-secrets.js.map +1 -0
  13. package/dist/cli.js +160 -28
  14. package/dist/cli.js.map +1 -1
  15. package/dist/dns/groups.d.ts +36 -0
  16. package/dist/dns/groups.d.ts.map +1 -0
  17. package/dist/dns/groups.js +46 -0
  18. package/dist/dns/groups.js.map +1 -0
  19. package/dist/gcloud-components.d.ts +42 -0
  20. package/dist/gcloud-components.d.ts.map +1 -0
  21. package/dist/gcloud-components.js +53 -0
  22. package/dist/gcloud-components.js.map +1 -0
  23. package/dist/gcloud.d.ts +14 -0
  24. package/dist/gcloud.d.ts.map +1 -1
  25. package/dist/gcloud.js +18 -5
  26. package/dist/gcloud.js.map +1 -1
  27. package/dist/install-state.d.ts +19 -1
  28. package/dist/install-state.d.ts.map +1 -1
  29. package/dist/install-state.js +37 -3
  30. package/dist/install-state.js.map +1 -1
  31. package/dist/lifecycle.d.ts.map +1 -1
  32. package/dist/lifecycle.js +24 -3
  33. package/dist/lifecycle.js.map +1 -1
  34. package/dist/redirects-emit.d.ts +0 -13
  35. package/dist/redirects-emit.d.ts.map +1 -1
  36. package/dist/redirects-emit.js +19 -0
  37. package/dist/redirects-emit.js.map +1 -1
  38. package/dist/regions.d.ts.map +1 -1
  39. package/dist/regions.js +4 -1
  40. package/dist/regions.js.map +1 -1
  41. package/dist/runtime-secrets.d.ts +13 -1
  42. package/dist/runtime-secrets.d.ts.map +1 -1
  43. package/dist/runtime-secrets.js +40 -4
  44. package/dist/runtime-secrets.js.map +1 -1
  45. package/dist/self-hosted-roles.d.ts.map +1 -1
  46. package/dist/self-hosted-roles.js +2 -0
  47. package/dist/self-hosted-roles.js.map +1 -1
  48. package/dist/stack-contract.d.ts +16 -2
  49. package/dist/stack-contract.d.ts.map +1 -1
  50. package/dist/stack-contract.js +24 -1
  51. package/dist/stack-contract.js.map +1 -1
  52. package/dist/wizard.d.ts +5 -19
  53. package/dist/wizard.d.ts.map +1 -1
  54. package/dist/wizard.js +53 -4
  55. package/dist/wizard.js.map +1 -1
  56. package/dist/wizards/gcp-pulumi.d.ts +21 -0
  57. package/dist/wizards/gcp-pulumi.d.ts.map +1 -1
  58. package/dist/wizards/gcp-pulumi.js +25 -5
  59. package/dist/wizards/gcp-pulumi.js.map +1 -1
  60. package/dist/wizards/gcp.d.ts.map +1 -1
  61. package/dist/wizards/gcp.js +160 -116
  62. package/dist/wizards/gcp.js.map +1 -1
  63. package/package.json +3 -3
  64. package/stacks/gcp/index.ts +20 -3
  65. package/stacks/gcp-firebase/index.ts +20 -5
  66. package/stacks/self-hosted/index.ts +3 -1
@@ -0,0 +1,245 @@
1
+ // SPDX-License-Identifier: MPL-2.0
2
+ /**
3
+ * Keeps secret material out of the files the CLI writes under
4
+ * `~/.caelo-<install-id>/` (#656).
5
+ *
6
+ * Only `secrets/` may hold secrets (CLAUDE.md §7, §11.C); `progress.json`
7
+ * and `install.json` are plain bookkeeping that tooling, backups, sync
8
+ * clients and agents read. Installs from before #576 checkpointed the full
9
+ * Pulumi outputs, whose database URLs carried admin_role's and
10
+ * public_role's passwords, and every install so far checkpointed the Owner
11
+ * setup link with its token. This module
12
+ *
13
+ * - finds secret material in a JSON value ({@link findSecrets}), which
14
+ * `markStepDone` / `writeMetadata` use to refuse writing it;
15
+ * - removes it from an existing checkpoint ({@link scrubProgressFile}),
16
+ * which `upgrade` and wizard re-runs call, and names the secrets to
17
+ * rotate ({@link rotationHints});
18
+ * - masks credentials in text the CLI prints ({@link redactCredentials}).
19
+ *
20
+ * Detection is by shape (a URL with a password, a URL with a token-like
21
+ * query parameter) and by key name (password, secret, token, ...), so a
22
+ * future secret output is caught without being listed here.
23
+ */
24
+ import { existsSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
25
+ import { DATABASE_ROLE_SECRET } from "./stack-contract.js";
26
+ /** `scheme://user:password@` — the user-info of a URL that carries a password. */
27
+ const CREDENTIAL_URL = /[a-z][a-z0-9+.-]{0,31}:\/\/([^\s:@/]*):([^\s@/]+)@/gi;
28
+ /** A URL query parameter that carries a credential (the Owner setup link's `?token=`). */
29
+ const TOKEN_PARAM = /[?&](?:token|secret|password|passwd|pwd|api[_-]?key|access[_-]?key|sig|signature)=[^&\s#]+/i;
30
+ /**
31
+ * Key names whose value is a secret, after lower-casing, dropping non
32
+ * alphanumerics and a Pulumi `Out` suffix: `postgresPassword`,
33
+ * `caeloSecretKek`, `bootstrap_token`, `anthropicApiKey`.
34
+ */
35
+ const SECRET_KEY = /(password|passwd|passphrase|secret|secretkek|kek|token|apikey|privatekey)$/;
36
+ function isSecretKey(key) {
37
+ const k = key
38
+ .toLowerCase()
39
+ .replace(/[^a-z0-9]/g, "")
40
+ .replace(/out$/, "");
41
+ return SECRET_KEY.test(k);
42
+ }
43
+ function urlUser(raw) {
44
+ try {
45
+ return decodeURIComponent(raw);
46
+ }
47
+ catch {
48
+ return raw;
49
+ }
50
+ }
51
+ function stringFindings(value, path) {
52
+ const found = [];
53
+ for (const m of value.matchAll(CREDENTIAL_URL)) {
54
+ found.push({ path, kind: "credential-url", user: urlUser(m[1] ?? "") });
55
+ }
56
+ if (found.length === 0 && /:\/\//.test(value) && TOKEN_PARAM.test(value)) {
57
+ found.push({ path, kind: "token-url" });
58
+ }
59
+ return found;
60
+ }
61
+ /**
62
+ * Every place `value` holds secret material.
63
+ *
64
+ * @param value any JSON-shaped value (a checkpoint payload, install.json)
65
+ * @param path the path prefix to report findings under
66
+ */
67
+ export function findSecrets(value, path = "") {
68
+ if (typeof value === "string")
69
+ return stringFindings(value, path || "(value)");
70
+ const join = (k) => (path ? `${path}.${k}` : String(k));
71
+ if (Array.isArray(value))
72
+ return value.flatMap((v, i) => findSecrets(v, join(i)));
73
+ if (value !== null && typeof value === "object") {
74
+ return Object.entries(value).flatMap(([k, v]) => {
75
+ // A secret-named key is only a finding when it holds something; a
76
+ // null / false / 0 marker is not secret material.
77
+ if (isSecretKey(k) && v !== null && v !== undefined && v !== "" && typeof v !== "boolean") {
78
+ return [{ path: join(k), kind: "secret-key" }];
79
+ }
80
+ return findSecrets(v, join(k));
81
+ });
82
+ }
83
+ return [];
84
+ }
85
+ /**
86
+ * Throw when `value` holds secret material. The message names the paths,
87
+ * never the values, so the error itself cannot leak them.
88
+ *
89
+ * @param what what was about to be written, for the message
90
+ */
91
+ export function assertNoSecrets(value, what) {
92
+ refuseFindings(findSecrets(value), what);
93
+ }
94
+ /** {@link assertNoSecrets} for a whole checkpoint (see {@link findCheckpointSecrets}). */
95
+ export function assertNoCheckpointSecrets(checkpoint, what) {
96
+ refuseFindings(findCheckpointSecrets(checkpoint), what);
97
+ }
98
+ function refuseFindings(found, what) {
99
+ if (found.length === 0)
100
+ return;
101
+ throw new Error(`Refusing to write secret material to ${what} (${found.map((f) => `${f.path}: ${f.kind}`).join(", ")}). ` +
102
+ "Secrets belong in Secret Manager or the install's secrets/ directory; read them at the moment of use instead (#656).");
103
+ }
104
+ function isRecord(v) {
105
+ return v !== null && typeof v === "object" && !Array.isArray(v);
106
+ }
107
+ /**
108
+ * {@link findSecrets} for a progress.json checkpoint. Its step names are
109
+ * derived from operator input (`project-<project id>`), so they are
110
+ * identifiers, not field names: a project called `acme-token` must not read
111
+ * as a secret. Only the step payloads (and the other top-level fields) are
112
+ * inspected.
113
+ */
114
+ export function findCheckpointSecrets(checkpoint) {
115
+ if (!isRecord(checkpoint))
116
+ return findSecrets(checkpoint);
117
+ const { steps, ...rest } = checkpoint;
118
+ const inSteps = isRecord(steps)
119
+ ? Object.entries(steps).flatMap(([name, payload]) => findSecrets(payload, `steps.${name}`))
120
+ : findSecrets(steps, "steps");
121
+ return [...findSecrets(rest), ...inSteps];
122
+ }
123
+ /** {@link withoutSecrets} for a checkpoint: every step is kept, its payload cleaned. */
124
+ function checkpointWithoutSecrets(checkpoint) {
125
+ if (!isRecord(checkpoint))
126
+ return withoutSecrets(checkpoint);
127
+ const { steps, ...rest } = checkpoint;
128
+ return {
129
+ ...withoutSecrets(rest),
130
+ steps: isRecord(steps)
131
+ ? Object.fromEntries(Object.entries(steps).map(([n, p]) => [n, withoutSecrets(p)]))
132
+ : withoutSecrets(steps),
133
+ };
134
+ }
135
+ /**
136
+ * `value` without its secret material: secret-named keys and strings that
137
+ * hold a credential or token URL are dropped. Everything else is kept.
138
+ */
139
+ export function withoutSecrets(value) {
140
+ if (typeof value === "string")
141
+ return value;
142
+ if (Array.isArray(value)) {
143
+ return value
144
+ .filter((v) => typeof v !== "string" || findSecrets(v).length === 0)
145
+ .map(withoutSecrets);
146
+ }
147
+ if (value !== null && typeof value === "object") {
148
+ const out = {};
149
+ for (const [k, v] of Object.entries(value)) {
150
+ if (findSecrets({ [k]: v }).length > 0 && (isSecretKey(k) || typeof v === "string"))
151
+ continue;
152
+ out[k] = withoutSecrets(v);
153
+ }
154
+ return out;
155
+ }
156
+ return value;
157
+ }
158
+ /**
159
+ * The Pulumi outputs worth checkpointing: everything except outputs Pulumi
160
+ * marks secret and anything else that holds secret material (the Owner
161
+ * setup link). Nothing reads secrets back from the checkpoint; code that
162
+ * needs one reads the stack (`readStackOutput`) when it needs it.
163
+ *
164
+ * @param outputs the stack outputs as the Automation API returns them
165
+ */
166
+ export function checkpointableOutputs(outputs) {
167
+ const kept = {};
168
+ for (const [k, o] of Object.entries(outputs)) {
169
+ if (o.secret)
170
+ continue;
171
+ if (findSecrets({ [k]: o.value }).length > 0)
172
+ continue;
173
+ kept[k] = o.value;
174
+ }
175
+ return kept;
176
+ }
177
+ /**
178
+ * Remove secret material from a `progress.json` in place. The rewrite is
179
+ * atomic (temp file in the same directory, then rename) and the result is
180
+ * mode 0600, so an interrupted scrub never leaves a truncated checkpoint
181
+ * and wizard re-runs still find every completed step.
182
+ *
183
+ * @param path the checkpoint file; a missing file is a no-op
184
+ */
185
+ export function scrubProgressFile(path) {
186
+ if (!existsSync(path))
187
+ return { removed: [] };
188
+ const checkpoint = JSON.parse(readFileSync(path, "utf8"));
189
+ const removed = findCheckpointSecrets(checkpoint);
190
+ if (removed.length === 0)
191
+ return { removed };
192
+ writeJsonAtomic(path, checkpointWithoutSecrets(checkpoint));
193
+ return { removed };
194
+ }
195
+ /**
196
+ * Write `value` as JSON to `path` atomically with mode 0600: a temp file in
197
+ * the same directory (so the rename stays on one filesystem), then rename.
198
+ */
199
+ export function writeJsonAtomic(path, value) {
200
+ const tmp = `${path}.tmp-${process.pid}`;
201
+ try {
202
+ writeFileSync(tmp, `${JSON.stringify(value, null, 2)}\n`, { mode: 0o600 });
203
+ renameSync(tmp, path);
204
+ }
205
+ catch (e) {
206
+ rmSync(tmp, { force: true });
207
+ throw e;
208
+ }
209
+ }
210
+ const ROLE_BY_USER = DATABASE_ROLE_SECRET;
211
+ /**
212
+ * What the operator should do about secrets a scrub removed: one line per
213
+ * affected secret, with the exact command where one exists.
214
+ *
215
+ * @param removed the scrub's findings
216
+ * @param installId the install, named in each command so it acts on the right one
217
+ */
218
+ export function rotationHints(removed, installId) {
219
+ const rotate = (secret) => `bunx @caelo-cms/provisioning rotate-secret ${secret} --install ${installId}`;
220
+ const hints = new Set();
221
+ for (const f of removed) {
222
+ if (f.kind === "credential-url") {
223
+ const secret = f.user ? ROLE_BY_USER[f.user] : undefined;
224
+ hints.add(secret
225
+ ? `${f.user}'s password was stored in progress.json. Rotate it: ${rotate(secret)}`
226
+ : `A password for database user "${f.user ?? "?"}" was stored in progress.json (${f.path}); change it.`);
227
+ }
228
+ else if (f.kind === "token-url") {
229
+ hints.add("The Owner setup link (with its one-time token) was stored in progress.json. It expires 24 hours after it was issued and stops working once the Owner account exists; nothing to rotate.");
230
+ }
231
+ else {
232
+ hints.add(`A secret value was stored in progress.json (${f.path}). Rotate whatever it belongs to.`);
233
+ }
234
+ }
235
+ return [...hints];
236
+ }
237
+ /**
238
+ * `text` with every URL password masked (`postgresql://admin_role:***@`),
239
+ * for anything the CLI prints that could carry one (errors from Pulumi or
240
+ * gcloud, for instance). Same masking as the API gateway's error log.
241
+ */
242
+ export function redactCredentials(text) {
243
+ return text.replace(/([a-z][a-z0-9+.-]{0,31}:\/\/[^\s:@/]*):[^\s@/]+@/gi, "$1:***@");
244
+ }
245
+ //# sourceMappingURL=checkpoint-secrets.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"checkpoint-secrets.js","sourceRoot":"","sources":["../src/checkpoint-secrets.ts"],"names":[],"mappings":"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"}
package/dist/cli.js CHANGED
@@ -22,8 +22,10 @@
22
22
  */
23
23
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
24
24
  import { resolve } from "node:path";
25
+ import { defaultStagingHost, normalizeStagingHost, stagingHostProblem } from "@caelo-cms/shared";
25
26
  import { generateBootstrapToken } from "./bootstrap-token.js";
26
27
  import { generateCaddyfile } from "./caddy.js";
28
+ import { redactCredentials } from "./checkpoint-secrets.js";
27
29
  import { initDelegatesToWizard, resolveCliRoute } from "./cli-routing.js";
28
30
  import { generateDockerCompose } from "./compose.js";
29
31
  import { convergeSelfHostedRoles, decideUp } from "./self-hosted-roles.js";
@@ -104,6 +106,10 @@ function emitConfig(cfg, extraDomains = []) {
104
106
  cfg.caeloSecretKek = randomSecret(32);
105
107
  saveConfig(cfg);
106
108
  }
109
+ if (!cfg.stagingDomain) {
110
+ cfg.stagingDomain = defaultStagingHost(cfg.domain);
111
+ saveConfig(cfg);
112
+ }
107
113
  if (!cfg.rolePasswords) {
108
114
  // `init` sets them and `up` back-fills + applies them before calling here.
109
115
  throw new Error("config has no rolePasswords — run `cms-provision up`");
@@ -127,7 +133,7 @@ function emitConfig(cfg, extraDomains = []) {
127
133
  const baseDomains = [
128
134
  { hostname: cfg.domain, kind: "admin", env: "production" },
129
135
  { hostname: cfg.domain, kind: "public", env: "production" },
130
- { hostname: `staging.${cfg.domain}`, kind: "public", env: "staging" },
136
+ { hostname: cfg.stagingDomain, kind: "public", env: "staging" },
131
137
  ];
132
138
  const caddy = generateCaddyfile({
133
139
  ownerEmail: cfg.ownerEmail,
@@ -172,7 +178,7 @@ async function init() {
172
178
  return;
173
179
  }
174
180
  if (!domain || !ownerEmail) {
175
- console.error("Usage: cms-provision init [--provider self-hosted] --domain example.com --owner-email me@example.com\n" +
181
+ console.error("Usage: cms-provision init [--provider self-hosted] --domain example.com [--staging-domain staging.example.com] --owner-email me@example.com\n" +
176
182
  "Cloud providers (gcp | gcp-firebase | aws | azure) run the wizard: cms-provision --provider <name>");
177
183
  process.exit(2);
178
184
  }
@@ -180,9 +186,21 @@ async function init() {
180
186
  console.error(`config already exists at ${CONFIG_PATH}; run \`cms-provision up\` to re-deploy`);
181
187
  process.exit(2);
182
188
  }
189
+ const stagingArg = arg("staging-domain");
190
+ const stagingProblem = stagingArg
191
+ ? stagingHostProblem(stagingArg, {
192
+ liveHosts: [domain, `www.${domain}`],
193
+ adminHosts: [`admin.${domain}`],
194
+ })
195
+ : null;
196
+ if (stagingProblem) {
197
+ console.error(`--staging-domain: ${stagingProblem}`);
198
+ process.exit(2);
199
+ }
183
200
  saveProvider(providerArg);
184
201
  const cfg = {
185
202
  domain,
203
+ stagingDomain: stagingArg ? normalizeStagingHost(stagingArg) : defaultStagingHost(domain),
186
204
  ownerEmail,
187
205
  postgresPassword: randomSecret(32),
188
206
  rolePasswords: newRolePasswords(),
@@ -667,6 +685,7 @@ const handlers = {
667
685
  destroy: lifecycleDestroy,
668
686
  truncate: lifecycleTruncate,
669
687
  "admin-domain": adminDomain,
688
+ "staging-domain": stagingDomain,
670
689
  // Misc
671
690
  "pulumi-output-sync": pulumiOutputSync,
672
691
  version,
@@ -725,20 +744,51 @@ async function lifecycleTruncate() {
725
744
  * (see admin-domain.ts for why this runs as the operator, not Pulumi).
726
745
  */
727
746
  async function adminDomain() {
747
+ await domainMappingCommand("admin");
748
+ }
749
+ /**
750
+ * #650 `staging-domain enable [--staging-domain <host>]` — bind the install's
751
+ * staging host (its setting, default staging.<domain>) to the gcp-firebase
752
+ * gateway, which serves the protected staging site. `--staging-domain`
753
+ * records a new host for the install first (after it was changed in the
754
+ * admin under Security → Domains, or to choose one).
755
+ */
756
+ async function stagingDomain() {
757
+ await domainMappingCommand("staging");
758
+ }
759
+ async function domainMappingCommand(kind) {
760
+ const command = `${kind}-domain`;
728
761
  const sub = process.argv[3];
729
762
  if (sub !== "enable") {
730
- console.log("Usage: cms-provision admin-domain enable [--install <install-id>]");
763
+ console.log(`Usage: cms-provision ${command} enable [--install <install-id>]${kind === "staging" ? " [--staging-domain <host>]" : ""}`);
731
764
  process.exit(2);
732
765
  }
733
- const { installFlag, listInstalls, selectInstall } = await import("./install-state.js");
734
- const { enableAdminDomain } = await import("./admin-domain.js");
766
+ const { installFlag, installStagingHost, listInstalls, selectInstall, writeMetadata } = await import("./install-state.js");
767
+ const { enableAdminDomain, enableStagingDomain } = await import("./admin-domain.js");
735
768
  // Guessing among several installs could map the domain in the wrong project.
736
769
  const selected = selectInstall(listInstalls().filter((m) => m.projectId), installFlag(process.argv));
737
770
  if (!selected.ok) {
738
771
  console.error(selected.message);
739
772
  process.exit(1);
740
773
  }
741
- const meta = selected.meta;
774
+ let meta = selected.meta;
775
+ if (kind === "staging") {
776
+ // The install's staging host setting; `--staging-domain` changes it.
777
+ // Validated either way: the recorded/default host may predate a check
778
+ // (e.g. a long default staging.<domain> a Cloud Run mapping refuses).
779
+ const requested = arg("staging-domain");
780
+ const { normalizeStagingHost, stagingHostProblem } = await import("@caelo-cms/shared");
781
+ const host = requested ?? installStagingHost(meta);
782
+ const problem = stagingHostProblem(host, { liveHosts: [meta.domain, `www.${meta.domain}`], adminHosts: [`admin.${meta.domain}`] }, { provider: meta.provider });
783
+ if (problem) {
784
+ console.error(requested
785
+ ? `--staging-domain: ${problem}`
786
+ : `the install's staging host: ${problem}. Pass --staging-domain <host>.`);
787
+ process.exit(1);
788
+ }
789
+ meta = { ...meta, stagingDomain: normalizeStagingHost(host) };
790
+ writeMetadata(meta.installId, meta);
791
+ }
742
792
  if (!meta.projectId)
743
793
  process.exit(1);
744
794
  if (!meta.region) {
@@ -746,32 +796,105 @@ async function adminDomain() {
746
796
  process.exit(1);
747
797
  }
748
798
  if (meta.provider !== "gcp-firebase") {
749
- console.log(meta.provider === "gcp"
750
- ? `Nothing to do: on gcp the load balancer already serves admin.${meta.domain}.`
751
- : `admin-domain is for gcp-firebase installs; this one is ${meta.provider}.`);
799
+ console.log(kind === "admin"
800
+ ? meta.provider === "gcp"
801
+ ? `Nothing to do: on gcp the load balancer already serves admin.${meta.domain}.`
802
+ : `admin-domain is for gcp-firebase installs; this one is ${meta.provider}.`
803
+ : meta.provider === "self-hosted"
804
+ ? `Recorded staging host ${installStagingHost(meta)}; Caddy serves it after \`cms-provision regenerate-caddy\`.`
805
+ : `staging-domain is for gcp-firebase installs; on ${meta.provider} the protected staging domain is not available yet (issue #650 follow-up).`);
752
806
  return;
753
807
  }
754
- const r = await enableAdminDomain({
755
- projectId: meta.projectId,
756
- region: meta.region,
757
- domain: meta.domain,
758
- });
808
+ const target = { projectId: meta.projectId, region: meta.region, domain: meta.domain };
809
+ const r = kind === "admin"
810
+ ? await enableAdminDomain(target)
811
+ : await enableStagingDomain({ ...target, stagingHost: installStagingHost(meta) });
759
812
  if (r.status === "failed") {
760
- console.error(`admin-domain: ${r.error}`);
813
+ console.error(`${command}: ${r.error}`);
761
814
  process.exit(1);
762
815
  }
763
816
  if (r.status === "needs-verification") {
764
817
  console.log(`Google only maps ${r.hostname} for a verified owner of ${r.verifyDomain}.\n` +
765
818
  "Search Console was opened for you: add the TXT record it shows at your registrar,\n" +
766
- "click Verify, then run `cms-provision admin-domain enable` again.");
819
+ `click Verify, then run \`cms-provision ${command} enable\` again.`);
767
820
  process.exit(3);
768
821
  }
769
- console.log(`${r.hostname} ${r.created ? "is now mapped" : "was already mapped"} to the admin.` +
770
- (r.records.length > 0
771
- ? `\nAdd at your registrar (TLS is issued once DNS resolves, typically 15-60 min):\n${r.records
772
- .map((d) => ` ${d.type.padEnd(6)} ${d.name} -> ${d.value}`)
773
- .join("\n")}`
774
- : "\nGoogle has not published the DNS records yet; run the command again in a minute."));
822
+ const mappedTo = kind === "admin" ? "the admin" : "the gateway (protected staging site)";
823
+ console.log(`${r.hostname} ${r.created ? "is now mapped" : "was already mapped"} to ${mappedTo}.`);
824
+ if (kind === "staging") {
825
+ console.log("Staging access (who may open it, crawler credentials): admin → Security → Staging access.\n" +
826
+ "If you changed the host here, set the same host in the admin (Security → Domains) so Stage renders it.");
827
+ }
828
+ if (r.records.length === 0) {
829
+ console.log(`Google has not published the DNS records yet; run \`cms-provision ${command} enable\` again in a minute.`);
830
+ return;
831
+ }
832
+ const waited = await waitForDomainMappingCert({
833
+ command,
834
+ hostname: r.hostname,
835
+ records: r.records,
836
+ projectId: meta.projectId,
837
+ region: meta.region,
838
+ });
839
+ if (!waited)
840
+ process.exit(4);
841
+ console.log(kind === "admin"
842
+ ? `Admin login: https://${r.hostname}/login`
843
+ : `Protected staging site: https://${r.hostname}/`);
844
+ }
845
+ /**
846
+ * After `<kind>-domain enable` mapped the host: print the DNS records, then
847
+ * poll the mapping until Google has issued its certificate (cert-wait.ts),
848
+ * with a spinner. Returns false on timeout or a gcloud failure, after saying
849
+ * how to check the status. Ctrl-C is safe: the command is idempotent, and
850
+ * re-running it finds the mapping and resumes the wait.
851
+ */
852
+ async function waitForDomainMappingCert(target) {
853
+ const { spinner } = await import("@clack/prompts");
854
+ const { CERT_WAIT_TIMEOUT_MS, domainMappingProbe, waitForCert } = await import("./cert-wait.js");
855
+ const { gcloud } = await import("./gcloud.js");
856
+ const width = Math.max(...target.records.map((d) => d.name.length));
857
+ console.log(`\nSet this DNS record at your registrar (if you have not already):\n${target.records
858
+ .map((d) => ` ${d.type.padEnd(6)} ${d.name.padEnd(width)} → ${d.value}`)
859
+ .join("\n")}\n` +
860
+ "Google issues the TLS certificate once the record resolves — usually 15-60 minutes.\n" +
861
+ `Safe to stop with Ctrl-C: \`cms-provision ${target.command} enable\` again resumes waiting.\n`);
862
+ // clack's spinner swallows SIGINT; exit explicitly so Ctrl-C stops the wait.
863
+ const s = spinner({
864
+ cancelMessage: `Stopped. Run \`cms-provision ${target.command} enable\` again to resume waiting.`,
865
+ onCancel: () => process.exit(130),
866
+ });
867
+ s.start(`Waiting for the certificate of ${target.hostname} (DNS set? this takes 15-60 min)`);
868
+ const r = await waitForCert({
869
+ probe: () => domainMappingProbe(gcloud, {
870
+ hostname: target.hostname,
871
+ region: target.region,
872
+ projectId: target.projectId,
873
+ }),
874
+ timeoutMs: CERT_WAIT_TIMEOUT_MS,
875
+ intervalMs: 30 * 1000,
876
+ onUpdate: (detail) => s.message(`${target.hostname}: ${detail}`),
877
+ });
878
+ if (r.ok) {
879
+ s.stop(`Certificate for ${target.hostname} is ready.`);
880
+ return true;
881
+ }
882
+ const check = `gcloud beta run domain-mappings describe --domain=${target.hostname} --region=${target.region} --project=${target.projectId}`;
883
+ if (r.reason === "error") {
884
+ s.error(`Could not read the domain mapping: ${r.error}`);
885
+ console.error(`Check it by hand with:\n ${check}`);
886
+ return false;
887
+ }
888
+ s.error(`No certificate for ${target.hostname} after 60 minutes.`);
889
+ console.error([
890
+ r.lastDetail ? `Google reports: ${r.lastDetail}` : "",
891
+ `Check that the record resolves: dig +short ${target.hostname} ${target.records[0]?.type ?? "CNAME"}`,
892
+ `Check the mapping's status: ${check}`,
893
+ `Once DNS resolves, run \`cms-provision ${target.command} enable\` again to resume waiting.`,
894
+ ]
895
+ .filter(Boolean)
896
+ .join("\n"));
897
+ return false;
775
898
  }
776
899
  /**
777
900
  * §11.C — interactive wizard. Loaded lazily so the bare-CLI startup
@@ -785,6 +908,7 @@ async function wizardCommand() {
785
908
  nonInteractive: process.argv.includes("--non-interactive"),
786
909
  provider: arg("provider"),
787
910
  domain: arg("domain"),
911
+ stagingDomain: arg("staging-domain"),
788
912
  ownerEmail: arg("owner-email"),
789
913
  projectId: arg("project-id"),
790
914
  region: arg("region"),
@@ -794,14 +918,22 @@ async function wizardCommand() {
794
918
  // documented one-command form) and a bare `cms-provision` drop into the
795
919
  // wizard. `--no-wizard` / `--help` keep the legacy "print usage" shape.
796
920
  const route = resolveCliRoute(process.argv, Object.keys(handlers));
797
- if (route.kind === "handler") {
798
- await handlers[route.name]?.();
799
- }
800
- else if (route.kind === "wizard") {
801
- await wizardCommand();
921
+ if (route.kind === "handler" || route.kind === "wizard") {
922
+ try {
923
+ if (route.kind === "handler")
924
+ await handlers[route.name]?.();
925
+ else
926
+ await wizardCommand();
927
+ }
928
+ catch (e) {
929
+ // #656 — an error from Pulumi, gcloud or a database client can quote a
930
+ // connection URL; print it with any password masked.
931
+ console.error(redactCredentials(e instanceof Error ? (e.stack ?? e.message) : String(e)));
932
+ process.exit(1);
933
+ }
802
934
  }
803
935
  else {
804
- console.log("Usage: cms-provision [wizard] [--provider <name> --domain <d> --owner-email <e> --region <r>] | <init|up|status|upgrade|backup|restore|rotate-secret|truncate|destroy|regenerate-caddy|admin-domain|pulumi-output-sync|version> [options]\n" +
936
+ console.log("Usage: cms-provision [wizard] [--provider <name> --domain <d> [--staging-domain <host>] --owner-email <e> --region <r>] | <init|up|status|upgrade|backup|restore|rotate-secret|truncate|destroy|regenerate-caddy|admin-domain|staging-domain|pulumi-output-sync|version> [options]\n" +
805
937
  "With several installs on this machine, pick one with --install <install-id or domain> (status, upgrade and backup also take --install all); in a terminal you are asked otherwise.\n" +
806
938
  "Pass --no-wizard with no sub-command to print this usage instead of the wizard.");
807
939
  process.exit(cmd && !cmd.startsWith("-") ? 2 : 0);