ninepm 0.72.0 → 0.79.0

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.
package/dist/index.js CHANGED
@@ -13,7 +13,9 @@ import { Agent, fetch as undiciFetch } from "undici";
13
13
  import { MAX_DEPLOY_DESCRIPTION_LENGTH, normalizeDeployDescription } from "./deploy-description.js";
14
14
  import { managedHostnamesIn, siblingCallWarning, siblingsOf } from "./sibling-hostnames.js";
15
15
  import { MAX_STATIC_ASSET_FILE_BYTES, MAX_WORKER_ENTRY_COMPRESSED_BYTES, MAX_WORKER_SCRIPT_COMPRESSED_BYTES, WORKER_ENTRY_COMPRESSED_WARN_BYTES, assetFileTooLargeMessage, formatMebibytes, formatMegabytes, oversizedAssets, workerBundleNearLimitMessage, workerBundleTooLargeMessage, workerEntryNearLimitMessage, workerEntryTooLargeMessage, } from "./deploy-limits.js";
16
- import { guessZoneApex, relativeRecordName } from "./dns-host.js";
16
+ import { guessZoneApex, relativeRecordName, zoneApexFor } from "./dns-host.js";
17
+ import { dnsProviderProfile, recordAtProvider } from "./dns-providers.js";
18
+ import { cacheMinutes, deriveDomainStatus, domainRecordStatuses, showsUseWww, } from "./domain-state.js";
17
19
  import { buildFileManifest, readManifestFile } from "./manifest.js";
18
20
  import { NO_PUBLIC_ASSETS, normalizeAssetsDirectory, privateLookingPaths, publicAssetPath } from "./public-assets.js";
19
21
  import { API_KEY_HELP, API_URL_HELP, CLI_CLIENT_ID, DEFAULT_API_URL, reportCliError } from "./cli-config.js";
@@ -50,7 +52,7 @@ export function deployDatabaseClause(databases) {
50
52
  const listed = databases.map((database) => (database.name ? `${database.bindingName}:${database.name}` : database.bindingName)).join(", ");
51
53
  return `${databases.length === 1 ? "database" : "databases"} ${listed}`;
52
54
  }
53
- export const CLI_VERSION = "0.72.0";
55
+ export const CLI_VERSION = "0.79.0";
54
56
  const PUBLIC_DASHBOARD_URL = "https://9pm.ai/dashboard";
55
57
  const TOKEN_SERVICE = "9pm";
56
58
  const TOKEN_ACCOUNT = "default";
@@ -2917,7 +2919,7 @@ export function parseArgs(argv) {
2917
2919
  options.runtime !== "container") {
2918
2920
  throw new Error("--always-on, --volume, --allow-data-wipe, --allow-namespace-reset, --cpus, and --memory require --runtime container.");
2919
2921
  }
2920
- return { kind: "deploy", options };
2922
+ return { kind: "deploy", options, apiUrlFrom: apiUrlSource(flags) };
2921
2923
  }
2922
2924
  function parseDeleteArgs(argv) {
2923
2925
  const flags = parseFlags(argv);
@@ -3218,91 +3220,53 @@ async function runDeploymentsCommand(command) {
3218
3220
  });
3219
3221
  console.log(`Deleted deployment ${result.deleted}.`);
3220
3222
  }
3221
- // The server has no field for an expired trace: it marks one by this exact provisioningError
3222
- // (CUSTOM_DOMAIN_EXPIRED_MESSAGE in src/server/routes/shared.ts; a test holds the two equal).
3223
- export const CUSTOM_DOMAIN_EXPIRED_TEXT = "This domain was removed because its DNS verification was never completed. Add the domain again to retry.";
3223
+ // `9pm domains` (#2000): custom domains on the existing REST API. The state, reason and next
3224
+ // step come from the server when it sends them (#1994); against an older server they are
3225
+ // derived here from the same shared definition, so both paths agree.
3226
+ export { CUSTOM_DOMAIN_EXPIRED_TEXT, deriveDomainState, deriveDomainStatus, domainRecordStatuses, } from "./domain-state.js";
3224
3227
  export const DOMAIN_WAIT_EXIT = { live: 0, ownerAction: 10, platform: 11, failed: 20 };
3225
3228
  function normalizeDnsName(value) {
3226
3229
  return value.trim().toLowerCase().replace(/\.$/, "");
3227
3230
  }
3228
- // The value the server's DNS check compares against: TXT verbatim, names lowercased without a root dot.
3229
- function checkedRecordValue(type, value) {
3230
- return type === "TXT" ? value : normalizeDnsName(value);
3231
- }
3232
- export function domainRecordStatuses(domain) {
3233
- const apex = guessZoneApex(domain.hostname);
3234
- return domain.dnsInstructions.map((instruction) => {
3235
- const type = instruction.type.toUpperCase();
3236
- const fqdn = normalizeDnsName(instruction.host);
3237
- const expected = checkedRecordValue(type, instruction.value);
3238
- // Matched on the expected value as well as host and type: the two TXT values of one name
3239
- // are separate rows, and keying on host and type alone gives both rows one verdict (#1986).
3240
- const seen = domain.dnsCheck?.records.find((entry) => entry.type.toUpperCase() === type && normalizeDnsName(entry.host) === fqdn && entry.expected === expected);
3241
- const atApex = type === "CNAME" && fqdn === apex;
3242
- const check = domainRecordCheck(seen, atApex);
3243
- return {
3244
- type,
3245
- name: relativeRecordName(fqdn, apex),
3246
- fqdn,
3247
- expected: instruction.value,
3248
- current: seen ? seen.current : null,
3249
- ok: check === "found" ? true : check === "missing" || check === "different" ? false : null,
3250
- check,
3251
- apex: atApex,
3252
- optional: instruction.optional === true,
3253
- };
3231
+ // The provider fields are an enrichment: anything malformed in them falls back to the record
3232
+ // as the server stored it rather than failing the whole status.
3233
+ function recordsAtProvider(records, profile) {
3234
+ return records.map((record) => {
3235
+ try {
3236
+ const at = recordAtProvider({ type: record.type, name: record.name, value: record.expected, apex: record.apex }, profile);
3237
+ return { ...record, typeAtProvider: at.type, nameAtProvider: at.name, valueAtProvider: at.value };
3238
+ }
3239
+ catch {
3240
+ return { ...record, typeAtProvider: record.type, nameAtProvider: record.name, valueAtProvider: record.expected };
3241
+ }
3254
3242
  });
3255
3243
  }
3256
- const IP_ADDRESS = /^(\d{1,3}(\.\d{1,3}){3}|[0-9a-f:]*:[0-9a-f:.]+)$/i;
3257
- // The server looks a CNAME up and, finding none, falls back to the name's A records. At the
3258
- // zone root that is exactly what a correct ALIAS/ANAME or flattened CNAME answers: addresses,
3259
- // never the target name. So addresses at the apex are not evidence of a wrong record; they
3260
- // cannot be compared from outside at all.
3261
- function domainRecordCheck(seen, atApex) {
3262
- if (!seen)
3263
- return "not_checked";
3264
- if (seen.matches)
3265
- return "found";
3266
- if (seen.current.length === 0)
3267
- return "missing";
3268
- if (atApex && seen.current.every((value) => IP_ADDRESS.test(value.trim())))
3269
- return "apex_addresses";
3270
- return "different";
3271
- }
3272
- export function deriveDomainState(domain, records = domainRecordStatuses(domain)) {
3273
- if (domain.provisioningError === CUSTOM_DOMAIN_EXPIRED_TEXT)
3274
- return "expired";
3275
- if (domain.routingStatus === "failed" || domain.tlsStatus === "failed" || domain.provisioningError)
3276
- return "failed";
3277
- if (domain.routingStatus === "active" && domain.tlsStatus === "active")
3278
- return "live";
3279
- const required = records.filter((record) => !record.optional);
3280
- if (required.some((record) => record.ok === false))
3281
- return "waiting_for_dns";
3282
- const routingActive = domain.routingStatus === "active";
3283
- // An apex answering with addresses defers to the platform's own routing check: active means
3284
- // it found the record, pending means it has not yet.
3285
- const unproven = required.some((record) => record.check === "not_checked" || (record.check === "apex_addresses" && !routingActive));
3286
- if (unproven || (!domain.dnsCheck && required.length === 0)) {
3287
- // Routing still pending is the platform's own evidence that it has not seen the records.
3288
- return routingActive ? "dns_unverified" : "waiting_for_dns";
3289
- }
3290
- return "issuing_certificate";
3244
+ function stringOrNull(value) {
3245
+ return typeof value === "string" && value !== "" ? value : null;
3291
3246
  }
3292
3247
  export function domainStatusJson(domain, app, checkedAt) {
3293
- const records = domainRecordStatuses(domain);
3248
+ const providerId = domain.dnsCheck?.provider ? stringOrNull(domain.dnsCheck.providerId) : null;
3249
+ const records = recordsAtProvider(domainRecordStatuses(domain), dnsProviderProfile(providerId));
3250
+ const derived = deriveDomainStatus(domain);
3251
+ // The server's own status wins when it sent one; it is computed from the same definition.
3252
+ const fromServer = typeof domain.state === "string" && domain.state !== "";
3294
3253
  return {
3295
3254
  hostname: domain.hostname,
3296
3255
  app: domain.app?.slug ?? app,
3297
- state: deriveDomainState(domain, records),
3256
+ state: fromServer ? domain.state : derived.state,
3298
3257
  routing: domain.routingStatus,
3299
3258
  tls: domain.tlsStatus,
3300
3259
  provider: domain.dnsCheck?.provider ?? null,
3260
+ providerId,
3301
3261
  nameservers: domain.dnsCheck?.nameservers ?? [],
3262
+ zone: stringOrNull(domain.dnsCheck?.zone),
3302
3263
  records,
3264
+ advisories: (domain.dnsCheck?.advisories ?? []).map(({ code, message }) => ({ code, message })),
3303
3265
  error: domain.provisioningError,
3304
3266
  expiresAt: domain.expiresAt ?? domain.expireAfter ?? null,
3305
3267
  checkedAt,
3268
+ reason: fromServer ? (domain.reason ?? null) : derived.reason,
3269
+ next: fromServer ? (domain.next ?? derived.next) : derived.next,
3306
3270
  };
3307
3271
  }
3308
3272
  const DOMAIN_STATE_LABELS = {
@@ -3313,33 +3277,164 @@ const DOMAIN_STATE_LABELS = {
3313
3277
  failed: "failed",
3314
3278
  expired: "expired",
3315
3279
  };
3280
+ // A state newer than this CLI is printed as the server named it rather than guessed at.
3316
3281
  export function domainStateLabel(state) {
3317
- return DOMAIN_STATE_LABELS[state];
3282
+ return Object.hasOwn(DOMAIN_STATE_LABELS, state) ? DOMAIN_STATE_LABELS[state] : state;
3283
+ }
3284
+ // What `wait` exits with once it stops polling. Who acts next decides between 10 and 11, so a
3285
+ // CAA record blocking the certificate (every record found, but the owner must change CAA) exits
3286
+ // 10, and a state this CLI does not know is still mapped by who the server says acts.
3287
+ export function domainWaitExitCode(status) {
3288
+ if (status.state === "live")
3289
+ return DOMAIN_WAIT_EXIT.live;
3290
+ if (status.state === "failed" || status.state === "expired")
3291
+ return DOMAIN_WAIT_EXIT.failed;
3292
+ if (status.state === "waiting_for_dns" || status.state === "dns_unverified")
3293
+ return DOMAIN_WAIT_EXIT.ownerAction;
3294
+ return status.next?.who === "owner" ? DOMAIN_WAIT_EXIT.ownerAction : DOMAIN_WAIT_EXIT.platform;
3295
+ }
3296
+ // The CAA advice follows who controls the deciding record: the owner's own zone, a CNAME
3297
+ // target only its operator can change, or a name the resolver did not attribute.
3298
+ function caaAdviceLines(next) {
3299
+ const caa = next.caa;
3300
+ if (!caa)
3301
+ return [];
3302
+ const issuers = caa.platformIssuers.join(", ");
3303
+ if (next.action === "ask_caa_controller" && caa.via) {
3304
+ return [
3305
+ `A CAA record at ${caa.recordName}, which ${caa.via} points to, stops the certificate from being issued.`,
3306
+ `Whoever runs ${caa.recordName} controls it, not your DNS: ask them to allow one of ${issuers}, or stop pointing ${caa.via} at it.`,
3307
+ ];
3308
+ }
3309
+ if (next.action === "check_caa") {
3310
+ return [
3311
+ `A CAA record found when looking up ${caa.recordName} stops the certificate from being issued.`,
3312
+ `If ${caa.recordName} is in your own DNS, add an issue entry there for one of ${issuers}. If it points to another service, that service controls it.`,
3313
+ ];
3314
+ }
3315
+ const example = next.records?.[0];
3316
+ return [
3317
+ `A CAA record at ${caa.recordName} stops the certificate from being issued.`,
3318
+ `Add an issue entry there for one of ${issuers}${example ? ` (for example: ${example.type} ${example.value})` : ""}. Your existing entries can stay.`,
3319
+ ];
3320
+ }
3321
+ function providerMessageLine(reason) {
3322
+ const message = reason?.providerMessage;
3323
+ if (!message)
3324
+ return null;
3325
+ // Only the certificate's own validation errors are the certificate authority's; anything else
3326
+ // (the hostname's verification note, or a source this CLI does not know) is worded neutrally.
3327
+ const who = message.source === "certificate_authority" ? "The certificate authority" : "Our provider";
3328
+ return `${who} reported (${message.seenAt}): ${message.text}`;
3318
3329
  }
3319
3330
  const TXT_KEEP_OTHERS = "add this value as its own TXT record; other TXT values can stay";
3331
+ // The server's next background check as "HH:MM UTC" (UTC so the line reads the same on any
3332
+ // machine); null when there is none or it is not a date, so no time is promised from it.
3333
+ export function nextCheckText(nextCheckAt) {
3334
+ if (typeof nextCheckAt !== "string" || nextCheckAt === "")
3335
+ return null;
3336
+ const at = new Date(nextCheckAt);
3337
+ return Number.isNaN(at.getTime()) ? null : `${at.toISOString().slice(11, 16)} UTC`;
3338
+ }
3339
+ // "up to N min" for a cache's time left, or nothing when the resolver did not say.
3340
+ function forUpTo(ttlSeconds) {
3341
+ return typeof ttlSeconds === "number" ? ` for up to ${cacheMinutes(ttlSeconds)} min` : "";
3342
+ }
3343
+ function answerText(current) {
3344
+ return current.length > 0 ? current.join(", ") : "nothing";
3345
+ }
3320
3346
  function recordStateText(record, routing) {
3321
3347
  if (record.ok === true)
3322
3348
  return "found";
3349
+ if (record.check === "resolvers_disagree") {
3350
+ // Two caches that disagree prove neither answer current: no "found", no "replace", and neither answer called old.
3351
+ const answers = record.resolvers ?? [];
3352
+ const seeing = answers.filter((answer) => answer.matches).map((answer) => answer.resolver);
3353
+ const others = answers
3354
+ .filter((answer) => !answer.matches)
3355
+ .map((answer) => `${answer.resolver} sees ${answerText(answer.current)}${typeof answer.ttlSeconds === "number" ? ` (and may keep that answer${forUpTo(answer.ttlSeconds)})` : ""}`);
3356
+ return `public resolvers disagree: ${seeing.join(" and ")} ${seeing.length > 1 ? "see" : "sees"} the value shown, ${others.join("; ")}. That usually means a recent change is still spreading; make sure your DNS host shows the value shown, then check again`;
3357
+ }
3358
+ if (record.check === "doubled") {
3359
+ // The same name the NAME column shows, so the hint never contradicts the host's convention.
3360
+ const name = record.nameAtProvider ?? record.name;
3361
+ return `found at ${record.doubledAt}: your DNS provider added the domain twice; ${name === "" ? "leave the Host field empty" : `enter only ${name}`}`;
3362
+ }
3323
3363
  // An optional record never holds the domain back, so its state would only be noise.
3324
3364
  if (record.optional)
3325
3365
  return "optional, only needed if the CNAME cannot be pointed first";
3326
3366
  if (record.check === "apex_addresses") {
3327
3367
  return routing === "active"
3328
3368
  ? "looks connected: the root answers with addresses, as an ALIAS does, and routing is active"
3329
- : `answers with addresses (${(record.current ?? []).join(", ")}); an ALIAS can't be verified from outside, so this waits on the platform's routing check`;
3369
+ : // A parking page answers with addresses too, so nothing here says the record is right: the owner confirms it.
3370
+ `answers with addresses (${(record.current ?? []).join(", ")}); an ALIAS can't be verified from outside, and a parking page answers with addresses too: make sure the ALIAS or ANAME record at the root points at the value shown. The platform's routing check confirms it`;
3330
3371
  }
3331
3372
  if (record.ok === null || record.current === null)
3332
3373
  return "not checked: couldn't read your DNS just now";
3374
+ // TXT before wildcard: a name may hold several TXT values another certificate still needs, so a
3375
+ // TXT value is always added beside them, never "changed", whatever answers at the name.
3333
3376
  if (record.type === "TXT") {
3334
- const now = record.current.length === 0 ? "not published yet" : `currently ${record.current.join(", ")}`;
3377
+ const from = record.check === "wildcard" && record.wildcard ? ` (from the wildcard ${record.wildcard})` : "";
3378
+ const now = record.current.length === 0 ? "not published yet" : `currently ${record.current.join(", ")}${from}`;
3335
3379
  return `${now}: ${TXT_KEEP_OTHERS}`;
3336
3380
  }
3381
+ if (record.check === "wildcard") {
3382
+ const name = record.nameAtProvider ?? record.name;
3383
+ // A name holding a CNAME can hold nothing else, so an explicit record equal to the wildcard's answer is changed, not joined.
3384
+ return `no ${record.type} record of its own yet: your DNS host's wildcard ${record.wildcard ?? ""} answers for it (${record.current.join(", ")}). Add one at ${name === "" ? "the root" : name} with the value shown; the wildcard can stay. If your DNS host already lists a record at this name, change that one instead`;
3385
+ }
3337
3386
  if (record.current.length === 0)
3338
3387
  return "not published yet";
3339
- return `currently ${record.current.join(", ")}: point it at the value shown`;
3340
- }
3341
- function domainCommand(subcommand, app, hostname, owner) {
3342
- return `9pm domains ${subcommand} ${quoteSiteArg(app)} ${hostname}${owner ? ` --owner ${quoteSiteArg(owner)}` : ""}`;
3388
+ // A changed record can keep its old answer in public caches for its TTL, which is said so the owner does not change it twice.
3389
+ const recent = typeof record.ttlSeconds === "number" ? ` (if you changed it in the last ${cacheMinutes(record.ttlSeconds)} min, this may be the old answer still cached)` : "";
3390
+ // Two resolvers can each hold a different old answer; both are shown, as neither is the expected value.
3391
+ const shown = record.current.join(", ");
3392
+ const others = (record.resolvers ?? []).filter((answer) => answer.current.join(", ") !== shown);
3393
+ const also = others.map((answer) => `; ${answer.resolver} answers ${answerText(answer.current)}`).join("");
3394
+ return `currently ${shown}${also}${recent}: point it at the value shown`;
3395
+ }
3396
+ // A printed command carries --owner and, when the destination is not the built-in default,
3397
+ // --api-url: pasted into a shell without NINEPM_API_URL it would otherwise act on production.
3398
+ // Never a token.
3399
+ function domainCommand(subcommand, app, hostname, options) {
3400
+ const owner = options.owner ? ` --owner ${quoteSiteArg(options.owner)}` : "";
3401
+ return `9pm domains ${subcommand} ${quoteSiteArg(app)} ${hostname}${owner}${apiUrlFlagFor(options.target)}`;
3402
+ }
3403
+ // Notes from src/shared/dns-providers.ts; only verified ones are printed.
3404
+ export const DNS_PROVIDER_NOTES = {
3405
+ gandiLiveDns: "At Gandi, an ALIAS works only with LiveDNS, and no A or AAAA record may stay at @ beside it.",
3406
+ hostingerCnameAtRoot: "At Hostinger, add the root record as a CNAME with name @: Hostinger shows it as ALIAS. A domain can have only one.",
3407
+ // Unverified, so never printed; its text is here so a leak would show.
3408
+ route53TxtQuotes: "In the Route 53 console, a TXT value may need to be entered in quotes.",
3409
+ };
3410
+ // A root domain at a DNS host that cannot point one at a hostname: what can stand in for the
3411
+ // root record, in the profile's order. Forwarding is text only, and unverified forwarding is
3412
+ // a possibility, never a step.
3413
+ function noApexLines(status, provider, fallbacks, options) {
3414
+ const host = status.hostname;
3415
+ const www = status.next?.hostname ?? `www.${host}`;
3416
+ const lines = [`${provider} can't point a root domain like ${host} at another address. Connect ${www} instead: it works with an ordinary CNAME.`];
3417
+ for (const fallback of fallbacks) {
3418
+ if (fallback.kind === "use_www") {
3419
+ lines.push(` Add it: ${domainCommand("add", status.app, www, options)}`);
3420
+ }
3421
+ else if (fallback.kind === "provider_forwarding") {
3422
+ if (!fallback.verified) {
3423
+ lines.push(` ${provider} may offer forwarding that sends ${host} to ${www}; we haven't confirmed it.`);
3424
+ }
3425
+ else {
3426
+ lines.push(` Then use ${provider}'s forwarding to send ${host} to https://${www}.${fallback.https === "yes" ? "" : ` We haven't confirmed that its forwarding works over HTTPS, so open https://${host} once it is set up to check.`}`);
3427
+ }
3428
+ }
3429
+ else if (fallback.kind === "aws_redirect") {
3430
+ lines.push(` For ${host} itself, you can set up a redirect to ${www} inside AWS yourself, for example an S3 redirect bucket behind CloudFront.`);
3431
+ }
3432
+ else {
3433
+ lines.push(" Or move your DNS hosting to a provider that supports ALIAS records at the root. Copy every existing record first, MX and TXT especially, or your email stops working.");
3434
+ lines.push(" If DNSSEC is on at your registrar (it holds a DS record for the domain), turn it off first and wait for that to clear before you change the nameservers: otherwise the whole domain, email included, stops resolving for many visitors.");
3435
+ }
3436
+ }
3437
+ return lines;
3343
3438
  }
3344
3439
  function renderColumns(rows) {
3345
3440
  const widths = (rows[0] ?? []).map((_, column) => Math.max(...rows.map((row) => (row[column] ?? "").length)));
@@ -3356,58 +3451,133 @@ export function formatDomainStatus(status, options = {}) {
3356
3451
  }
3357
3452
  if (status.state === "expired") {
3358
3453
  lines.push(status.error ?? "This domain expired before its DNS records were published.");
3359
- lines.push(`Add it again: ${domainCommand("add", status.app, status.hostname, options.owner)}`);
3454
+ lines.push(`Add it again: ${domainCommand("add", status.app, status.hostname, options)}`);
3360
3455
  return lines;
3361
3456
  }
3362
3457
  if (status.state === "failed" && status.error) {
3363
3458
  lines.push(status.error);
3364
3459
  }
3365
- const apex = guessZoneApex(status.hostname);
3460
+ lines.push(...status.advisories.map((advisory) => advisory.message));
3461
+ const apex = zoneApexFor(status.hostname, status.zone);
3462
+ const atRoot = status.hostname.toLowerCase().replace(/\.$/, "") === apex;
3463
+ // The profile is read only for a host the server named; an id without a name is not trusted.
3464
+ const profile = status.provider ? dnsProviderProfile(status.providerId) : undefined;
3465
+ const provider = status.provider ?? "";
3466
+ // The same decision as the status: never the profile alone, which would say "connect www"
3467
+ // over routing that is already active. The profile only supplies the stand-ins to list.
3468
+ const useWwwFallbacks = atRoot && showsUseWww(status.next) ? (profile?.apex.support === "none" ? profile.apex.fallbacks : [{ kind: "use_www" }]) : undefined;
3366
3469
  if (status.records.length > 0) {
3470
+ const elsewhere = "Changes made where you bought the domain won't take effect if that is another company.";
3367
3471
  lines.push(status.provider
3368
- ? `DNS for ${apex} is managed at ${status.provider}${status.nameservers.length > 0 ? ` (${status.nameservers.join(", ")})` : ""}. Add these records there:`
3472
+ ? `Your DNS is managed at ${status.provider}${status.nameservers.length > 0 ? ` (${status.nameservers.join(", ")})` : ""}, where your domain's nameservers point: make these changes there. ${elsewhere}`
3369
3473
  : status.nameservers.length > 0
3370
- ? `DNS for ${apex} is served by ${status.nameservers.join(", ")}. Add these records there:`
3371
- : `Add these records where the DNS for ${apex} is managed:`);
3372
- lines.push("");
3373
- const rows = [
3374
- ["TYPE", "NAME", "VALUE", "STATE"],
3375
- ...status.records.map((record) => [record.apex ? "ALIAS" : record.type, record.name, record.expected, recordStateText(record, status.routing)]),
3376
- ];
3377
- lines.push(...renderColumns(rows).map((line) => ` ${line}`));
3378
- lines.push("");
3379
- if (status.records.some((record) => record.apex)) {
3380
- lines.push(`ALIAS at @: the root of ${apex} cannot hold a CNAME, so add an ALIAS or ANAME record there (or a CNAME your provider flattens), pointing at the value shown.`);
3474
+ ? `Your DNS is served by ${status.nameservers.join(", ")}: make these changes at the company that runs them. ${elsewhere}`
3475
+ : `Add these records where the DNS for ${apex} is hosted, which is where its nameservers point and may not be where you bought it:`);
3476
+ for (const note of profile?.notes ?? []) {
3477
+ const text = note.verified ? DNS_PROVIDER_NOTES[note.id] : undefined;
3478
+ if (text)
3479
+ lines.push(text);
3480
+ }
3481
+ const shown = useWwwFallbacks ? status.records.filter((record) => !record.apex) : status.records;
3482
+ if (shown.length > 0) {
3483
+ lines.push("");
3484
+ const rows = [
3485
+ ["TYPE", "NAME", "VALUE", "STATE"],
3486
+ ...shown.map((record) => [
3487
+ // null: a root the host has no record type for, shown only once routing is already active.
3488
+ record.typeAtProvider ?? "-",
3489
+ record.nameAtProvider === "" ? "(leave empty)" : record.nameAtProvider,
3490
+ record.valueAtProvider,
3491
+ recordStateText(record, status.routing),
3492
+ ]),
3493
+ ];
3494
+ lines.push(...renderColumns(rows).map((line) => ` ${line}`));
3495
+ lines.push("");
3496
+ }
3497
+ if (useWwwFallbacks) {
3498
+ lines.push(...noApexLines(status, status.provider ?? "Your DNS host", useWwwFallbacks, options));
3499
+ }
3500
+ else if (status.records.some((record) => record.apex)) {
3501
+ const support = profile?.apex;
3502
+ if (support?.support === "alias") {
3503
+ lines.push(`${support.typeLabel} at the root: ${provider} uses an ${support.typeLabel} record there. ${support.hostField === "blank" ? "Leave the Host field empty" : "Enter @ as the Host"} and point it at the value shown.`);
3504
+ }
3505
+ else if (support?.support === "none") {
3506
+ lines.push(`${provider} has no record type that can point a root domain at this value, but routing for ${status.hostname} is ${status.routing === "active" ? "already active" : "being checked by the platform"}: keep the record that serves it as it is.`);
3507
+ }
3508
+ else if (support?.support === "cname_flattening") {
3509
+ lines.push(`CNAME at the root: ${provider} flattens a CNAME at the root automatically, so add it at @ pointing at the value shown.`);
3510
+ }
3511
+ else {
3512
+ lines.push(`ALIAS at @: the root of ${apex} cannot hold a CNAME, so add an ALIAS or ANAME record there (or a CNAME your provider flattens), pointing at the value shown.`);
3513
+ lines.push("If your DNS host has no ALIAS or ANAME record, don't put a CNAME at the root: it would conflict with your MX and TXT records. Connect www instead.");
3514
+ }
3515
+ }
3516
+ // "Replace the record at @" would take MX and TXT with it: the root names the website record types and keeps the rest.
3517
+ if (atRoot) {
3518
+ lines.push(`Change only the website record for ${status.hostname} (A, AAAA, ALIAS/ANAME or CNAME). Keep every MX and TXT record: they carry your email and other verifications.`);
3381
3519
  }
3382
3520
  if (status.records.some((record) => !record.optional && record.check === "not_checked")) {
3383
3521
  lines.push("Couldn't check your DNS records just now, so their state is unknown. Make sure they are published as shown.");
3384
3522
  }
3385
- if (status.records.some((record) => record.name !== record.fqdn)) {
3386
- lines.push(`Enter the name as shown: most providers add ".${apex}" themselves. If yours asks for the full name, append ".${apex}".`);
3523
+ if (shown.some((record) => record.nameAtProvider !== "" && record.name !== record.fqdn)) {
3524
+ lines.push(profile?.appendsZone === true
3525
+ ? `Enter the name as shown: ${provider} adds ".${apex}" itself, so typing the full name would double it.`
3526
+ : `Enter the name as shown: most providers add ".${apex}" themselves. If yours asks for the full name, append ".${apex}".`);
3387
3527
  }
3388
3528
  }
3529
+ const reported = providerMessageLine(status.reason);
3530
+ if (reported)
3531
+ lines.push(reported);
3389
3532
  if (status.state === "failed") {
3390
- lines.push(`Re-check: ${domainCommand("check", status.app, status.hostname, options.owner)}`);
3391
- lines.push(`Start over (the records may change): ${domainCommand("restart", status.app, status.hostname, options.owner)}`);
3533
+ if (status.next.action === "contact_support") {
3534
+ lines.push("This needs the platform's support to resolve; nothing in your DNS is known to fix it.");
3535
+ }
3536
+ lines.push(`Re-check: ${domainCommand("check", status.app, status.hostname, options)}`);
3537
+ // Offered only where a new certificate attempt is the remedy; a restart never speeds anything up.
3538
+ if (status.next.action === "start_over") {
3539
+ lines.push(`Start over (the records may change): ${domainCommand("restart", status.app, status.hostname, options)}`);
3540
+ }
3392
3541
  return lines;
3393
3542
  }
3394
- if (status.state === "issuing_certificate") {
3543
+ // Waiting on this domain cannot finish it: the next step is a different hostname, printed above.
3544
+ if (status.next?.action === "use_www")
3545
+ return lines;
3546
+ const caaLines = caaAdviceLines(status.next);
3547
+ if (caaLines.length > 0) {
3548
+ lines.push(...caaLines);
3549
+ }
3550
+ else if (status.state === "issuing_certificate") {
3395
3551
  lines.push("Every required record is published. The platform finishes on its own, usually within minutes.");
3552
+ const nextCheck = nextCheckText(status.next?.nextCheckAt);
3553
+ // A restart is not offered here: nothing measured says it speeds issuance up.
3554
+ if (nextCheck)
3555
+ lines.push(`The platform checks it again around ${nextCheck} on its own; nothing needs restarting.`);
3396
3556
  }
3397
- lines.push(`Then run: ${domainCommand("wait", status.app, status.hostname, options.owner)}`);
3557
+ lines.push(`Then run: ${domainCommand("wait", status.app, status.hostname, options)}`);
3398
3558
  return lines;
3399
3559
  }
3400
3560
  // What a timed-out wait says. "Missing" is claimed only for a record the check actually saw
3401
3561
  // absent or different; a record nobody could read is reported as unchecked, whatever the
3402
3562
  // routing status, because telling the owner to publish what may already be there misleads.
3403
3563
  export function domainWaitTimeoutMessage(status) {
3564
+ if (status.next?.action === "use_www") {
3565
+ return `Stopped waiting: no record at the root can finish ${status.hostname} at its DNS host. Connect ${status.next.hostname ?? `www.${status.hostname}`} as shown above, then wait on that instead.`;
3566
+ }
3567
+ if (status.next.caa) {
3568
+ return "Timed out: a CAA record stops the certificate from being issued. Change it as described above, then wait again.";
3569
+ }
3404
3570
  if (status.state === "issuing_certificate") {
3405
- return "Timed out: the records are published and the platform is still finishing. Wait again; nothing needs changing.";
3571
+ const nextCheck = nextCheckText(status.next?.nextCheckAt);
3572
+ return `Timed out: the records are published and the platform is still finishing. Wait again; nothing needs changing.${nextCheck ? ` Its next automatic check is around ${nextCheck}.` : ""}`;
3406
3573
  }
3407
3574
  const required = status.records.filter((record) => !record.optional);
3408
3575
  if (required.some((record) => record.ok === false)) {
3409
3576
  return "Timed out: records above are still missing. Publish them, then wait again.";
3410
3577
  }
3578
+ if (required.some((record) => record.check === "resolvers_disagree")) {
3579
+ return "Timed out: public resolvers still disagree about a record above. Make sure your DNS host shows the value shown, then wait again.";
3580
+ }
3411
3581
  return "Timed out: the DNS records could not be checked. Make sure they are published as shown, then wait again.";
3412
3582
  }
3413
3583
  export function formatDomainsList(statuses) {
@@ -3513,6 +3683,72 @@ export async function removeDomain(options) {
3513
3683
  });
3514
3684
  return result.domain;
3515
3685
  }
3686
+ // The domains read is a reconcile plus a DNS lookup per record, so it can be slow; the deploy
3687
+ // has already succeeded and must not wait on it for longer than this.
3688
+ export const DEPLOY_DOMAINS_TIMEOUT_MS = 4500;
3689
+ // `null` when the read failed or timed out: unknown, which the caller must not print as
3690
+ // "this app has no custom domains".
3691
+ export async function deployCustomDomainStatuses(options) {
3692
+ const controller = new AbortController();
3693
+ let timer;
3694
+ const timeout = new Promise((resolve) => {
3695
+ timer = setTimeout(() => {
3696
+ controller.abort();
3697
+ resolve(null);
3698
+ }, options.timeoutMs ?? DEPLOY_DOMAINS_TIMEOUT_MS);
3699
+ });
3700
+ // By id, not slug: the id names this deploy's app whatever owner it belongs to, so no
3701
+ // `?owner=` is needed to disambiguate. One chain, one catch: the request, the body and the
3702
+ // conversion of every entry. Anything that throws - a malformed entry included - is an
3703
+ // unknown answer, never a failed deploy.
3704
+ const read = (async () => {
3705
+ const result = await domainsRequest(options, `/v1/sites/${encodeURIComponent(options.projectId)}/domains`, {
3706
+ method: "GET",
3707
+ signal: controller.signal,
3708
+ });
3709
+ if (!Array.isArray(result?.domains) || !result.domains.every(isCliDomainShape))
3710
+ return null;
3711
+ const checkedAt = (options.now ?? (() => new Date()))().toISOString();
3712
+ return result.domains.map((domain) => domainStatusJson(domain, options.slug, checkedAt));
3713
+ })().catch(() => null);
3714
+ try {
3715
+ return await Promise.race([read, timeout]);
3716
+ }
3717
+ finally {
3718
+ clearTimeout(timer);
3719
+ }
3720
+ }
3721
+ // The fields the state derivation reads. An entry without them cannot be classified, and a
3722
+ // guess would be exactly the "live" or "missing" claim the deploy lines must not make.
3723
+ function isCliDomainShape(value) {
3724
+ if (!value || typeof value !== "object")
3725
+ return false;
3726
+ const domain = value;
3727
+ return (typeof domain.hostname === "string" &&
3728
+ domain.hostname.length > 0 &&
3729
+ typeof domain.routingStatus === "string" &&
3730
+ typeof domain.tlsStatus === "string" &&
3731
+ Array.isArray(domain.dnsInstructions) &&
3732
+ (domain.provisioningError === null || domain.provisioningError === undefined || typeof domain.provisioningError === "string"));
3733
+ }
3734
+ // `state` is the shared definition's (src/shared/domain-state.ts), sent by the server or derived
3735
+ // from it by domainStatusJson: `live` needs routing and TLS both active. Every other state,
3736
+ // including one newer than this CLI, is named as it is, never rounded up to live or down to
3737
+ // "missing".
3738
+ export function deployCustomDomainLines(statuses, slug, owner, target) {
3739
+ const apiUrl = apiUrlFlagFor(target);
3740
+ if (statuses === null) {
3741
+ return [
3742
+ `Couldn't check this app's custom domains just now. List them: 9pm domains list ${quoteSiteArg(slug)}${owner ? ` --owner ${quoteSiteArg(owner)}` : ""}${apiUrl}`,
3743
+ ];
3744
+ }
3745
+ return statuses.map((status) => {
3746
+ if (status.state === "live")
3747
+ return `Also serving at https://${status.hostname}`;
3748
+ const notLive = status.state === "failed" || status.state === "expired" ? "is not live" : "is not live yet";
3749
+ return `Custom domain ${status.hostname} ${notLive} (${domainStateLabel(status.state)}). Check it: ${domainCommand("status", slug, status.hostname, { ...(owner ? { owner } : {}), ...(target ? { target } : {}) })}`;
3750
+ });
3751
+ }
3516
3752
  // Polls the read-only list, which reconciles pending domains on every read, so waiting needs
3517
3753
  // only access to the app. A failed read that is not the caller's fault (network, 5xx) is
3518
3754
  // retried until the deadline; a refusal (auth, not found) ends the wait at once.
@@ -3530,6 +3766,9 @@ export async function waitForDomain(options) {
3530
3766
  return { status, exitCode: DOMAIN_WAIT_EXIT.live, timedOut: false };
3531
3767
  if (status.state === "failed" || status.state === "expired")
3532
3768
  return { status, exitCode: DOMAIN_WAIT_EXIT.failed, timedOut: false };
3769
+ // Nothing the owner can publish finishes this hostname, so polling until the timeout would only delay saying so.
3770
+ if (status.next?.action === "use_www")
3771
+ return { status, exitCode: DOMAIN_WAIT_EXIT.ownerAction, timedOut: false };
3533
3772
  }
3534
3773
  catch (error) {
3535
3774
  const transient = !(error instanceof DomainsApiError) || error.status >= 500;
@@ -3541,10 +3780,9 @@ export async function waitForDomain(options) {
3541
3780
  if (remaining <= 0) {
3542
3781
  if (!last)
3543
3782
  throw new Error(`Timed out before ${options.hostname} could be read.`);
3544
- // Only a proven issuing_certificate is the platform's to finish; records that could not be
3545
- // checked are the owner's to confirm, so they share the owner-action code.
3546
- const exitCode = last.state === "issuing_certificate" ? DOMAIN_WAIT_EXIT.platform : DOMAIN_WAIT_EXIT.ownerAction;
3547
- return { status: last, exitCode, timedOut: true };
3783
+ // Only a proven issuing_certificate with nothing left for the owner is the platform's to
3784
+ // finish; records that could not be checked are the owner's to confirm.
3785
+ return { status: last, exitCode: domainWaitExitCode(last), timedOut: true };
3548
3786
  }
3549
3787
  await sleepFn(Math.min(options.intervalMs, remaining));
3550
3788
  }
@@ -3574,7 +3812,7 @@ const DOMAINS_USAGE = {
3574
3812
  status: "9pm domains status <app> <hostname> [--owner <organisation>] [--json]",
3575
3813
  check: "9pm domains check <app> <hostname> [--owner <organisation>] [--json]",
3576
3814
  wait: "9pm domains wait <app> <hostname> [--timeout 30m] [--interval 20s] [--owner <organisation>] [--json]",
3577
- restart: "9pm domains restart <app> <hostname> [--owner <organisation>]",
3815
+ restart: "9pm domains restart <app> <hostname> [--owner <organisation>] (for a failed certificate; the record values may change)",
3578
3816
  remove: "9pm domains remove <app> <hostname> --confirm <hostname> [--owner <organisation>]",
3579
3817
  };
3580
3818
  function parseDomainsArgs(argv) {
@@ -3593,6 +3831,7 @@ function parseDomainsArgs(argv) {
3593
3831
  const withBase = (action) => ({
3594
3832
  kind: "domains",
3595
3833
  ...readBaseApiOptions(flags),
3834
+ apiUrlFrom: apiUrlSource(flags),
3596
3835
  ...(owner ? { owner } : {}),
3597
3836
  action,
3598
3837
  });
@@ -3627,14 +3866,14 @@ function parseDomainsArgs(argv) {
3627
3866
  }
3628
3867
  return withBase({ kind: "remove", site, hostname });
3629
3868
  }
3630
- function printDomainStatus(status, json, owner, heading) {
3869
+ function printDomainStatus(status, json, options, heading) {
3631
3870
  if (json) {
3632
3871
  console.log(JSON.stringify(status, null, 2));
3633
3872
  return;
3634
3873
  }
3635
3874
  if (heading)
3636
3875
  console.log(heading);
3637
- for (const line of formatDomainStatus(status, owner ? { owner } : {}))
3876
+ for (const line of formatDomainStatus(status, options))
3638
3877
  console.log(line);
3639
3878
  }
3640
3879
  function instructionLines(instructions, hostname) {
@@ -3644,6 +3883,10 @@ function instructionLines(instructions, hostname) {
3644
3883
  async function runDomainsCommand(command) {
3645
3884
  const action = command.action;
3646
3885
  const base = { apiUrl: command.apiUrl, token: command.token, ...(command.owner ? { owner: command.owner } : {}) };
3886
+ const format = {
3887
+ ...(command.owner ? { owner: command.owner } : {}),
3888
+ target: { apiUrl: command.apiUrl, apiUrlFrom: command.apiUrlFrom },
3889
+ };
3647
3890
  if (action.kind === "list") {
3648
3891
  const statuses = await listDomainStatuses({ ...base, ...(action.site ? { site: action.site } : {}) });
3649
3892
  if (action.json) {
@@ -3662,19 +3905,19 @@ async function runDomainsCommand(command) {
3662
3905
  // says whether a record the owner already has (a wildcard, an old CNAME) is in the way.
3663
3906
  const status = await getDomainStatus({ ...target, hostname }).catch(() => added ? domainStatusJson(added, action.site, new Date().toISOString()) : null);
3664
3907
  if (!status) {
3665
- console.log(`Added ${hostname} to ${action.site}. See its records: ${domainCommand("status", action.site, hostname, command.owner)}`);
3908
+ console.log(`Added ${hostname} to ${action.site}. See its records: ${domainCommand("status", action.site, hostname, format)}`);
3666
3909
  return;
3667
3910
  }
3668
- printDomainStatus(status, action.json, command.owner, action.json ? undefined : `Added ${hostname} to ${action.site}.`);
3911
+ printDomainStatus(status, action.json, format, action.json ? undefined : `Added ${hostname} to ${action.site}.`);
3669
3912
  return;
3670
3913
  }
3671
3914
  if (action.kind === "status") {
3672
- printDomainStatus(await getDomainStatus(target), action.json, command.owner);
3915
+ printDomainStatus(await getDomainStatus(target), action.json, format);
3673
3916
  return;
3674
3917
  }
3675
3918
  if (action.kind === "check") {
3676
3919
  await recheckDomain(target);
3677
- printDomainStatus(await getDomainStatus(target), action.json, command.owner);
3920
+ printDomainStatus(await getDomainStatus(target), action.json, format);
3678
3921
  return;
3679
3922
  }
3680
3923
  if (action.kind === "wait") {
@@ -3695,9 +3938,9 @@ async function runDomainsCommand(command) {
3695
3938
  console.log(JSON.stringify(result.status, null, 2));
3696
3939
  }
3697
3940
  else {
3698
- for (const line of formatDomainStatus(result.status, command.owner ? { owner: command.owner } : {}))
3941
+ for (const line of formatDomainStatus(result.status, format))
3699
3942
  console.log(line);
3700
- if (result.timedOut)
3943
+ if (result.timedOut || result.status.next?.action === "use_www")
3701
3944
  console.log(domainWaitTimeoutMessage(result.status));
3702
3945
  }
3703
3946
  process.exitCode = result.exitCode;
@@ -3715,7 +3958,7 @@ async function runDomainsCommand(command) {
3715
3958
  console.log("No record changed.");
3716
3959
  }
3717
3960
  const status = await getDomainStatus({ ...target, hostname: restarted.domain.hostname }).catch(() => domainStatusJson(restarted.domain, action.site, new Date().toISOString()));
3718
- for (const line of formatDomainStatus(status, command.owner ? { owner: command.owner } : {}))
3961
+ for (const line of formatDomainStatus(status, format))
3719
3962
  console.log(line);
3720
3963
  return;
3721
3964
  }
@@ -5118,7 +5361,17 @@ async function main() {
5118
5361
  }
5119
5362
  const databaseClause = deployDatabaseClause(result.databases);
5120
5363
  const queued = result.deployment.status === "queued" || result.deployment.status === "publishing";
5364
+ const domainStatuses = deployCustomDomainStatuses({
5365
+ apiUrl: command.options.apiUrl,
5366
+ token: command.options.token,
5367
+ projectId: result.project.id,
5368
+ slug: result.project.slug,
5369
+ });
5121
5370
  console.log(`${queued ? "Queued" : "Deployed"} ${result.project.slug} -> ${result.url}`);
5371
+ const target = { apiUrl: command.options.apiUrl, apiUrlFrom: command.apiUrlFrom };
5372
+ for (const line of deployCustomDomainLines(await domainStatuses, result.project.slug, command.options.owner, target)) {
5373
+ console.log(line);
5374
+ }
5122
5375
  console.log(`Deployment ${result.deployment.id} ${result.containerImageBytes ? `uploaded ${formatBytesForCli(result.containerImageBytes)} image` : `uploaded ${result.uploaded} file(s)`}${databaseClause ? `, ${databaseClause}` : ""}.`);
5123
5376
  if (queued) {
5124
5377
  console.log(`Status: ${result.deployment.status}. Run 9pm apps to check when it becomes active.`);
@@ -5330,6 +5583,9 @@ async function main() {
5330
5583
  });
5331
5584
  console.log(`Deleted ${deleted.slug}.`);
5332
5585
  console.log(`Removed ${deleted.workers} Worker(s), ${deleted.databases} database(s), ${deleted.files} file(s), ${deleted.customDomains} custom domain(s), ${deleted.containerImages ?? 0} container image(s), ${deleted.accessApps} access app(s), and ${deleted.serviceTokens} service token(s). Queued ${deleted.containerApplications ?? 0} container app(s) for teardown.`);
5586
+ if (deleted.customDomainNames && deleted.customDomainNames.length > 0) {
5587
+ console.log(`Removed custom domain(s): ${deleted.customDomainNames.join(", ")}.`);
5588
+ }
5333
5589
  // #722: a partial external-cleanup failure never fails the delete, and used to be
5334
5590
  // silently discarded here — visible only in the raw JSON, if a caller happened to
5335
5591
  // read it. Surfacing it is the whole reason a human running this command manually
@@ -5460,6 +5716,16 @@ async function runSupportCommand(command) {
5460
5716
  });
5461
5717
  console.log("Support request sent. A human will reply to your account email.");
5462
5718
  }
5719
+ // ` --api-url <url>` for a printed command whenever the destination is not the built-in
5720
+ // default, so a line copied from a staging session cannot act on production; "" otherwise.
5721
+ export function apiUrlFlagFor(target) {
5722
+ if (!target || target.apiUrlFrom === "default")
5723
+ return "";
5724
+ // A plain URL (scheme, host, port, path) has no shell-significant characters; anything
5725
+ // else falls back to single quotes.
5726
+ const url = /^[A-Za-z0-9._:/-]+$/.test(target.apiUrl) ? target.apiUrl : quoteSiteArg(target.apiUrl);
5727
+ return ` --api-url ${url}`;
5728
+ }
5463
5729
  // The command line that repeats this one with --switch-mode. Rebuilt from the parsed
5464
5730
  // action rather than echoed from argv so a --token on the original line is never printed.
5465
5731
  // The destination is carried explicitly whenever it is not the built-in default: a line
@@ -5468,10 +5734,7 @@ async function runSupportCommand(command) {
5468
5734
  // no --owner (a site id or slug already names the app), so there is none to carry.
5469
5735
  export function accessSwitchModeCommand(action, target) {
5470
5736
  const site = quoteSiteArg(action.site);
5471
- // A plain URL (scheme, host, port, path) has no shell-significant characters; anything
5472
- // else falls back to single quotes.
5473
- const url = target && /^[A-Za-z0-9._:/-]+$/.test(target.apiUrl) ? target.apiUrl : target ? quoteSiteArg(target.apiUrl) : "";
5474
- const apiUrl = target && target.apiUrlFrom !== "default" ? ` --api-url ${url}` : "";
5737
+ const apiUrl = apiUrlFlagFor(target);
5475
5738
  if (action.kind === "service-create") {
5476
5739
  const name = action.name ? ` --name ${quoteSiteArg(action.name)}` : "";
5477
5740
  const duration = action.duration ? ` --duration ${quoteSiteArg(action.duration)}` : "";
@@ -5721,7 +5984,7 @@ function printUsage() {
5721
5984
  9pm domains status <app> <hostname> [--json] state and, per record, what DNS shows now
5722
5985
  9pm domains check <app> <hostname> [--json] re-check now, then print the status
5723
5986
  9pm domains wait <app> <hostname> [--timeout 30m] [--interval 20s] [--json]
5724
- 9pm domains restart <app> <hostname> start validation over; record values may change
5987
+ 9pm domains restart <app> <hostname> for a failed certificate only: start validation over; record values may change
5725
5988
  9pm domains remove <app> <hostname> --confirm <hostname>
5726
5989
  9pm access show <site>
5727
5990
  9pm access public <site> [--switch-mode]
@@ -5776,16 +6039,19 @@ Missing pages (static sites and --entry worker apps):
5776
6039
  Custom domains:
5777
6040
  Every domains command takes --owner <organisation> for an organisation's app. list, status and
5778
6041
  wait only read; add, check, restart and remove need the configure role on the app.
5779
- --json prints {hostname, app, state, routing, tls, provider, nameservers, records[{type, name,
5780
- fqdn, expected, current, ok, check, apex, optional}], error, expiresAt, checkedAt}. state is one
5781
- of live, waiting_for_dns, dns_unverified (routing is active but the records could not be
5782
- checked), issuing_certificate (every required record checked and found), failed, expired.
6042
+ --json prints {hostname, app, state, reason, next, routing, tls, provider, nameservers,
6043
+ records[{type, name, fqdn, expected, current, ok, check, apex, optional}], error, expiresAt,
6044
+ checkedAt}. state is one of live, waiting_for_dns, dns_unverified (routing is active but the
6045
+ records could not be checked), issuing_certificate (every required record checked and found),
6046
+ failed, expired. reason says why (reason.providerMessage is the provider's own text, when it
6047
+ reported any, and its source: certificate_authority or hostname); next says who acts next (owner or platform) and what to do.
5783
6048
  A record's name is relative to the zone (www, _acme-challenge.www, @). ok is null when the
5784
6049
  check cannot say: check is not_checked, or apex_addresses for a root record answering with
5785
6050
  addresses (a correct ALIAS does), which defers to the routing status.
5786
- wait exits 0 when live, 10 on timeout with records missing or unverified (the owner must act or
5787
- confirm), 11 on timeout with every record found (the platform is still finishing), 20 when the
5788
- domain failed or expired, and 1 on a usage, auth or API error.
6051
+ wait exits 0 when live, 10 on timeout with something for the owner to do (records missing or
6052
+ unverified, or a CAA record blocking the certificate), 11 on timeout with every record found and
6053
+ nothing for the owner to do (the platform is still finishing), 20 when the domain failed or
6054
+ expired, and 1 on a usage, auth or API error.
5789
6055
 
5790
6056
  Container deploys:
5791
6057
  Requires Docker locally. The CLI builds linux/amd64, exports the image, uploads it through a short-lived session, then finalizes a queued deployment.