ninepm 0.72.0 → 0.78.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 { 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.78.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,13 +3277,65 @@ 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";
3320
3331
  function recordStateText(record, routing) {
3321
3332
  if (record.ok === true)
3322
3333
  return "found";
3334
+ if (record.check === "doubled") {
3335
+ // The same name the NAME column shows, so the hint never contradicts the host's convention.
3336
+ const name = record.nameAtProvider ?? record.name;
3337
+ return `found at ${record.doubledAt}: your DNS provider added the domain twice; ${name === "" ? "leave the Host field empty" : `enter only ${name}`}`;
3338
+ }
3323
3339
  // An optional record never holds the domain back, so its state would only be noise.
3324
3340
  if (record.optional)
3325
3341
  return "optional, only needed if the CNAME cannot be pointed first";
@@ -3338,8 +3354,48 @@ function recordStateText(record, routing) {
3338
3354
  return "not published yet";
3339
3355
  return `currently ${record.current.join(", ")}: point it at the value shown`;
3340
3356
  }
3341
- function domainCommand(subcommand, app, hostname, owner) {
3342
- return `9pm domains ${subcommand} ${quoteSiteArg(app)} ${hostname}${owner ? ` --owner ${quoteSiteArg(owner)}` : ""}`;
3357
+ // A printed command carries --owner and, when the destination is not the built-in default,
3358
+ // --api-url: pasted into a shell without NINEPM_API_URL it would otherwise act on production.
3359
+ // Never a token.
3360
+ function domainCommand(subcommand, app, hostname, options) {
3361
+ const owner = options.owner ? ` --owner ${quoteSiteArg(options.owner)}` : "";
3362
+ return `9pm domains ${subcommand} ${quoteSiteArg(app)} ${hostname}${owner}${apiUrlFlagFor(options.target)}`;
3363
+ }
3364
+ // Notes from src/shared/dns-providers.ts; only verified ones are printed.
3365
+ export const DNS_PROVIDER_NOTES = {
3366
+ gandiLiveDns: "At Gandi, an ALIAS works only with LiveDNS, and no A or AAAA record may stay at @ beside it.",
3367
+ hostingerCnameAtRoot: "At Hostinger, add the root record as a CNAME with name @: Hostinger shows it as ALIAS. A domain can have only one.",
3368
+ // Unverified, so never printed; its text is here so a leak would show.
3369
+ route53TxtQuotes: "In the Route 53 console, a TXT value may need to be entered in quotes.",
3370
+ };
3371
+ // A root domain at a DNS host that cannot point one at a hostname: what can stand in for the
3372
+ // root record, in the profile's order. Forwarding is text only, and unverified forwarding is
3373
+ // a possibility, never a step.
3374
+ function noApexLines(status, provider, fallbacks, options) {
3375
+ const host = status.hostname;
3376
+ const www = status.next?.hostname ?? `www.${host}`;
3377
+ const lines = [`${provider} can't point a root domain like ${host} at another address. Connect ${www} instead: it works with an ordinary CNAME.`];
3378
+ for (const fallback of fallbacks) {
3379
+ if (fallback.kind === "use_www") {
3380
+ lines.push(` Add it: ${domainCommand("add", status.app, www, options)}`);
3381
+ }
3382
+ else if (fallback.kind === "provider_forwarding") {
3383
+ if (!fallback.verified) {
3384
+ lines.push(` ${provider} may offer forwarding that sends ${host} to ${www}; we haven't confirmed it.`);
3385
+ }
3386
+ else {
3387
+ 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.`}`);
3388
+ }
3389
+ }
3390
+ else if (fallback.kind === "aws_redirect") {
3391
+ lines.push(` For ${host} itself, you can set up a redirect to ${www} inside AWS yourself, for example an S3 redirect bucket behind CloudFront.`);
3392
+ }
3393
+ else {
3394
+ 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.");
3395
+ 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.");
3396
+ }
3397
+ }
3398
+ return lines;
3343
3399
  }
3344
3400
  function renderColumns(rows) {
3345
3401
  const widths = (rows[0] ?? []).map((_, column) => Math.max(...rows.map((row) => (row[column] ?? "").length)));
@@ -3356,51 +3412,118 @@ export function formatDomainStatus(status, options = {}) {
3356
3412
  }
3357
3413
  if (status.state === "expired") {
3358
3414
  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)}`);
3415
+ lines.push(`Add it again: ${domainCommand("add", status.app, status.hostname, options)}`);
3360
3416
  return lines;
3361
3417
  }
3362
3418
  if (status.state === "failed" && status.error) {
3363
3419
  lines.push(status.error);
3364
3420
  }
3365
- const apex = guessZoneApex(status.hostname);
3421
+ lines.push(...status.advisories.map((advisory) => advisory.message));
3422
+ const apex = zoneApexFor(status.hostname, status.zone);
3423
+ const atRoot = status.hostname.toLowerCase().replace(/\.$/, "") === apex;
3424
+ // The profile is read only for a host the server named; an id without a name is not trusted.
3425
+ const profile = status.provider ? dnsProviderProfile(status.providerId) : undefined;
3426
+ const provider = status.provider ?? "";
3427
+ // The same decision as the status: never the profile alone, which would say "connect www"
3428
+ // over routing that is already active. The profile only supplies the stand-ins to list.
3429
+ const useWwwFallbacks = atRoot && showsUseWww(status.next) ? (profile?.apex.support === "none" ? profile.apex.fallbacks : [{ kind: "use_www" }]) : undefined;
3366
3430
  if (status.records.length > 0) {
3431
+ const elsewhere = "Changes made where you bought the domain won't take effect if that is another company.";
3367
3432
  lines.push(status.provider
3368
- ? `DNS for ${apex} is managed at ${status.provider}${status.nameservers.length > 0 ? ` (${status.nameservers.join(", ")})` : ""}. Add these records there:`
3433
+ ? `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
3434
  : 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.`);
3435
+ ? `Your DNS is served by ${status.nameservers.join(", ")}: make these changes at the company that runs them. ${elsewhere}`
3436
+ : `Add these records where the DNS for ${apex} is hosted, which is where its nameservers point and may not be where you bought it:`);
3437
+ for (const note of profile?.notes ?? []) {
3438
+ const text = note.verified ? DNS_PROVIDER_NOTES[note.id] : undefined;
3439
+ if (text)
3440
+ lines.push(text);
3441
+ }
3442
+ const shown = useWwwFallbacks ? status.records.filter((record) => !record.apex) : status.records;
3443
+ if (shown.length > 0) {
3444
+ lines.push("");
3445
+ const rows = [
3446
+ ["TYPE", "NAME", "VALUE", "STATE"],
3447
+ ...shown.map((record) => [
3448
+ // null: a root the host has no record type for, shown only once routing is already active.
3449
+ record.typeAtProvider ?? "-",
3450
+ record.nameAtProvider === "" ? "(leave empty)" : record.nameAtProvider,
3451
+ record.valueAtProvider,
3452
+ recordStateText(record, status.routing),
3453
+ ]),
3454
+ ];
3455
+ lines.push(...renderColumns(rows).map((line) => ` ${line}`));
3456
+ lines.push("");
3457
+ }
3458
+ if (useWwwFallbacks) {
3459
+ lines.push(...noApexLines(status, status.provider ?? "Your DNS host", useWwwFallbacks, options));
3460
+ }
3461
+ else if (status.records.some((record) => record.apex)) {
3462
+ const support = profile?.apex;
3463
+ if (support?.support === "alias") {
3464
+ 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.`);
3465
+ }
3466
+ else if (support?.support === "none") {
3467
+ 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.`);
3468
+ }
3469
+ else if (support?.support === "cname_flattening") {
3470
+ lines.push(`CNAME at the root: ${provider} flattens a CNAME at the root automatically, so add it at @ pointing at the value shown.`);
3471
+ }
3472
+ else {
3473
+ 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
+ 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.");
3475
+ }
3476
+ }
3477
+ // "Replace the record at @" would take MX and TXT with it: the root names the website record types and keeps the rest.
3478
+ if (atRoot) {
3479
+ 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
3480
  }
3382
3481
  if (status.records.some((record) => !record.optional && record.check === "not_checked")) {
3383
3482
  lines.push("Couldn't check your DNS records just now, so their state is unknown. Make sure they are published as shown.");
3384
3483
  }
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}".`);
3484
+ if (shown.some((record) => record.nameAtProvider !== "" && record.name !== record.fqdn)) {
3485
+ lines.push(profile?.appendsZone === true
3486
+ ? `Enter the name as shown: ${provider} adds ".${apex}" itself, so typing the full name would double it.`
3487
+ : `Enter the name as shown: most providers add ".${apex}" themselves. If yours asks for the full name, append ".${apex}".`);
3387
3488
  }
3388
3489
  }
3490
+ const reported = providerMessageLine(status.reason);
3491
+ if (reported)
3492
+ lines.push(reported);
3389
3493
  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)}`);
3494
+ if (status.next.action === "contact_support") {
3495
+ lines.push("This needs the platform's support to resolve; nothing in your DNS is known to fix it.");
3496
+ }
3497
+ lines.push(`Re-check: ${domainCommand("check", status.app, status.hostname, options)}`);
3498
+ // Offered only where a new certificate attempt is the remedy; a restart never speeds anything up.
3499
+ if (status.next.action === "start_over") {
3500
+ lines.push(`Start over (the records may change): ${domainCommand("restart", status.app, status.hostname, options)}`);
3501
+ }
3392
3502
  return lines;
3393
3503
  }
3394
- if (status.state === "issuing_certificate") {
3504
+ // Waiting on this domain cannot finish it: the next step is a different hostname, printed above.
3505
+ if (status.next?.action === "use_www")
3506
+ return lines;
3507
+ const caaLines = caaAdviceLines(status.next);
3508
+ if (caaLines.length > 0) {
3509
+ lines.push(...caaLines);
3510
+ }
3511
+ else if (status.state === "issuing_certificate") {
3395
3512
  lines.push("Every required record is published. The platform finishes on its own, usually within minutes.");
3396
3513
  }
3397
- lines.push(`Then run: ${domainCommand("wait", status.app, status.hostname, options.owner)}`);
3514
+ lines.push(`Then run: ${domainCommand("wait", status.app, status.hostname, options)}`);
3398
3515
  return lines;
3399
3516
  }
3400
3517
  // What a timed-out wait says. "Missing" is claimed only for a record the check actually saw
3401
3518
  // absent or different; a record nobody could read is reported as unchecked, whatever the
3402
3519
  // routing status, because telling the owner to publish what may already be there misleads.
3403
3520
  export function domainWaitTimeoutMessage(status) {
3521
+ if (status.next?.action === "use_www") {
3522
+ 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.`;
3523
+ }
3524
+ if (status.next.caa) {
3525
+ return "Timed out: a CAA record stops the certificate from being issued. Change it as described above, then wait again.";
3526
+ }
3404
3527
  if (status.state === "issuing_certificate") {
3405
3528
  return "Timed out: the records are published and the platform is still finishing. Wait again; nothing needs changing.";
3406
3529
  }
@@ -3513,6 +3636,72 @@ export async function removeDomain(options) {
3513
3636
  });
3514
3637
  return result.domain;
3515
3638
  }
3639
+ // The domains read is a reconcile plus a DNS lookup per record, so it can be slow; the deploy
3640
+ // has already succeeded and must not wait on it for longer than this.
3641
+ export const DEPLOY_DOMAINS_TIMEOUT_MS = 4500;
3642
+ // `null` when the read failed or timed out: unknown, which the caller must not print as
3643
+ // "this app has no custom domains".
3644
+ export async function deployCustomDomainStatuses(options) {
3645
+ const controller = new AbortController();
3646
+ let timer;
3647
+ const timeout = new Promise((resolve) => {
3648
+ timer = setTimeout(() => {
3649
+ controller.abort();
3650
+ resolve(null);
3651
+ }, options.timeoutMs ?? DEPLOY_DOMAINS_TIMEOUT_MS);
3652
+ });
3653
+ // By id, not slug: the id names this deploy's app whatever owner it belongs to, so no
3654
+ // `?owner=` is needed to disambiguate. One chain, one catch: the request, the body and the
3655
+ // conversion of every entry. Anything that throws - a malformed entry included - is an
3656
+ // unknown answer, never a failed deploy.
3657
+ const read = (async () => {
3658
+ const result = await domainsRequest(options, `/v1/sites/${encodeURIComponent(options.projectId)}/domains`, {
3659
+ method: "GET",
3660
+ signal: controller.signal,
3661
+ });
3662
+ if (!Array.isArray(result?.domains) || !result.domains.every(isCliDomainShape))
3663
+ return null;
3664
+ const checkedAt = (options.now ?? (() => new Date()))().toISOString();
3665
+ return result.domains.map((domain) => domainStatusJson(domain, options.slug, checkedAt));
3666
+ })().catch(() => null);
3667
+ try {
3668
+ return await Promise.race([read, timeout]);
3669
+ }
3670
+ finally {
3671
+ clearTimeout(timer);
3672
+ }
3673
+ }
3674
+ // The fields the state derivation reads. An entry without them cannot be classified, and a
3675
+ // guess would be exactly the "live" or "missing" claim the deploy lines must not make.
3676
+ function isCliDomainShape(value) {
3677
+ if (!value || typeof value !== "object")
3678
+ return false;
3679
+ const domain = value;
3680
+ return (typeof domain.hostname === "string" &&
3681
+ domain.hostname.length > 0 &&
3682
+ typeof domain.routingStatus === "string" &&
3683
+ typeof domain.tlsStatus === "string" &&
3684
+ Array.isArray(domain.dnsInstructions) &&
3685
+ (domain.provisioningError === null || domain.provisioningError === undefined || typeof domain.provisioningError === "string"));
3686
+ }
3687
+ // `state` is the shared definition's (src/shared/domain-state.ts), sent by the server or derived
3688
+ // from it by domainStatusJson: `live` needs routing and TLS both active. Every other state,
3689
+ // including one newer than this CLI, is named as it is, never rounded up to live or down to
3690
+ // "missing".
3691
+ export function deployCustomDomainLines(statuses, slug, owner, target) {
3692
+ const apiUrl = apiUrlFlagFor(target);
3693
+ if (statuses === null) {
3694
+ return [
3695
+ `Couldn't check this app's custom domains just now. List them: 9pm domains list ${quoteSiteArg(slug)}${owner ? ` --owner ${quoteSiteArg(owner)}` : ""}${apiUrl}`,
3696
+ ];
3697
+ }
3698
+ return statuses.map((status) => {
3699
+ if (status.state === "live")
3700
+ return `Also serving at https://${status.hostname}`;
3701
+ const notLive = status.state === "failed" || status.state === "expired" ? "is not live" : "is not live yet";
3702
+ return `Custom domain ${status.hostname} ${notLive} (${domainStateLabel(status.state)}). Check it: ${domainCommand("status", slug, status.hostname, { ...(owner ? { owner } : {}), ...(target ? { target } : {}) })}`;
3703
+ });
3704
+ }
3516
3705
  // Polls the read-only list, which reconciles pending domains on every read, so waiting needs
3517
3706
  // only access to the app. A failed read that is not the caller's fault (network, 5xx) is
3518
3707
  // retried until the deadline; a refusal (auth, not found) ends the wait at once.
@@ -3530,6 +3719,9 @@ export async function waitForDomain(options) {
3530
3719
  return { status, exitCode: DOMAIN_WAIT_EXIT.live, timedOut: false };
3531
3720
  if (status.state === "failed" || status.state === "expired")
3532
3721
  return { status, exitCode: DOMAIN_WAIT_EXIT.failed, timedOut: false };
3722
+ // Nothing the owner can publish finishes this hostname, so polling until the timeout would only delay saying so.
3723
+ if (status.next?.action === "use_www")
3724
+ return { status, exitCode: DOMAIN_WAIT_EXIT.ownerAction, timedOut: false };
3533
3725
  }
3534
3726
  catch (error) {
3535
3727
  const transient = !(error instanceof DomainsApiError) || error.status >= 500;
@@ -3541,10 +3733,9 @@ export async function waitForDomain(options) {
3541
3733
  if (remaining <= 0) {
3542
3734
  if (!last)
3543
3735
  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 };
3736
+ // Only a proven issuing_certificate with nothing left for the owner is the platform's to
3737
+ // finish; records that could not be checked are the owner's to confirm.
3738
+ return { status: last, exitCode: domainWaitExitCode(last), timedOut: true };
3548
3739
  }
3549
3740
  await sleepFn(Math.min(options.intervalMs, remaining));
3550
3741
  }
@@ -3574,7 +3765,7 @@ const DOMAINS_USAGE = {
3574
3765
  status: "9pm domains status <app> <hostname> [--owner <organisation>] [--json]",
3575
3766
  check: "9pm domains check <app> <hostname> [--owner <organisation>] [--json]",
3576
3767
  wait: "9pm domains wait <app> <hostname> [--timeout 30m] [--interval 20s] [--owner <organisation>] [--json]",
3577
- restart: "9pm domains restart <app> <hostname> [--owner <organisation>]",
3768
+ restart: "9pm domains restart <app> <hostname> [--owner <organisation>] (for a failed certificate; the record values may change)",
3578
3769
  remove: "9pm domains remove <app> <hostname> --confirm <hostname> [--owner <organisation>]",
3579
3770
  };
3580
3771
  function parseDomainsArgs(argv) {
@@ -3593,6 +3784,7 @@ function parseDomainsArgs(argv) {
3593
3784
  const withBase = (action) => ({
3594
3785
  kind: "domains",
3595
3786
  ...readBaseApiOptions(flags),
3787
+ apiUrlFrom: apiUrlSource(flags),
3596
3788
  ...(owner ? { owner } : {}),
3597
3789
  action,
3598
3790
  });
@@ -3627,14 +3819,14 @@ function parseDomainsArgs(argv) {
3627
3819
  }
3628
3820
  return withBase({ kind: "remove", site, hostname });
3629
3821
  }
3630
- function printDomainStatus(status, json, owner, heading) {
3822
+ function printDomainStatus(status, json, options, heading) {
3631
3823
  if (json) {
3632
3824
  console.log(JSON.stringify(status, null, 2));
3633
3825
  return;
3634
3826
  }
3635
3827
  if (heading)
3636
3828
  console.log(heading);
3637
- for (const line of formatDomainStatus(status, owner ? { owner } : {}))
3829
+ for (const line of formatDomainStatus(status, options))
3638
3830
  console.log(line);
3639
3831
  }
3640
3832
  function instructionLines(instructions, hostname) {
@@ -3644,6 +3836,10 @@ function instructionLines(instructions, hostname) {
3644
3836
  async function runDomainsCommand(command) {
3645
3837
  const action = command.action;
3646
3838
  const base = { apiUrl: command.apiUrl, token: command.token, ...(command.owner ? { owner: command.owner } : {}) };
3839
+ const format = {
3840
+ ...(command.owner ? { owner: command.owner } : {}),
3841
+ target: { apiUrl: command.apiUrl, apiUrlFrom: command.apiUrlFrom },
3842
+ };
3647
3843
  if (action.kind === "list") {
3648
3844
  const statuses = await listDomainStatuses({ ...base, ...(action.site ? { site: action.site } : {}) });
3649
3845
  if (action.json) {
@@ -3662,19 +3858,19 @@ async function runDomainsCommand(command) {
3662
3858
  // says whether a record the owner already has (a wildcard, an old CNAME) is in the way.
3663
3859
  const status = await getDomainStatus({ ...target, hostname }).catch(() => added ? domainStatusJson(added, action.site, new Date().toISOString()) : null);
3664
3860
  if (!status) {
3665
- console.log(`Added ${hostname} to ${action.site}. See its records: ${domainCommand("status", action.site, hostname, command.owner)}`);
3861
+ console.log(`Added ${hostname} to ${action.site}. See its records: ${domainCommand("status", action.site, hostname, format)}`);
3666
3862
  return;
3667
3863
  }
3668
- printDomainStatus(status, action.json, command.owner, action.json ? undefined : `Added ${hostname} to ${action.site}.`);
3864
+ printDomainStatus(status, action.json, format, action.json ? undefined : `Added ${hostname} to ${action.site}.`);
3669
3865
  return;
3670
3866
  }
3671
3867
  if (action.kind === "status") {
3672
- printDomainStatus(await getDomainStatus(target), action.json, command.owner);
3868
+ printDomainStatus(await getDomainStatus(target), action.json, format);
3673
3869
  return;
3674
3870
  }
3675
3871
  if (action.kind === "check") {
3676
3872
  await recheckDomain(target);
3677
- printDomainStatus(await getDomainStatus(target), action.json, command.owner);
3873
+ printDomainStatus(await getDomainStatus(target), action.json, format);
3678
3874
  return;
3679
3875
  }
3680
3876
  if (action.kind === "wait") {
@@ -3695,9 +3891,9 @@ async function runDomainsCommand(command) {
3695
3891
  console.log(JSON.stringify(result.status, null, 2));
3696
3892
  }
3697
3893
  else {
3698
- for (const line of formatDomainStatus(result.status, command.owner ? { owner: command.owner } : {}))
3894
+ for (const line of formatDomainStatus(result.status, format))
3699
3895
  console.log(line);
3700
- if (result.timedOut)
3896
+ if (result.timedOut || result.status.next?.action === "use_www")
3701
3897
  console.log(domainWaitTimeoutMessage(result.status));
3702
3898
  }
3703
3899
  process.exitCode = result.exitCode;
@@ -3715,7 +3911,7 @@ async function runDomainsCommand(command) {
3715
3911
  console.log("No record changed.");
3716
3912
  }
3717
3913
  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 } : {}))
3914
+ for (const line of formatDomainStatus(status, format))
3719
3915
  console.log(line);
3720
3916
  return;
3721
3917
  }
@@ -5118,7 +5314,17 @@ async function main() {
5118
5314
  }
5119
5315
  const databaseClause = deployDatabaseClause(result.databases);
5120
5316
  const queued = result.deployment.status === "queued" || result.deployment.status === "publishing";
5317
+ const domainStatuses = deployCustomDomainStatuses({
5318
+ apiUrl: command.options.apiUrl,
5319
+ token: command.options.token,
5320
+ projectId: result.project.id,
5321
+ slug: result.project.slug,
5322
+ });
5121
5323
  console.log(`${queued ? "Queued" : "Deployed"} ${result.project.slug} -> ${result.url}`);
5324
+ const target = { apiUrl: command.options.apiUrl, apiUrlFrom: command.apiUrlFrom };
5325
+ for (const line of deployCustomDomainLines(await domainStatuses, result.project.slug, command.options.owner, target)) {
5326
+ console.log(line);
5327
+ }
5122
5328
  console.log(`Deployment ${result.deployment.id} ${result.containerImageBytes ? `uploaded ${formatBytesForCli(result.containerImageBytes)} image` : `uploaded ${result.uploaded} file(s)`}${databaseClause ? `, ${databaseClause}` : ""}.`);
5123
5329
  if (queued) {
5124
5330
  console.log(`Status: ${result.deployment.status}. Run 9pm apps to check when it becomes active.`);
@@ -5330,6 +5536,9 @@ async function main() {
5330
5536
  });
5331
5537
  console.log(`Deleted ${deleted.slug}.`);
5332
5538
  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.`);
5539
+ if (deleted.customDomainNames && deleted.customDomainNames.length > 0) {
5540
+ console.log(`Removed custom domain(s): ${deleted.customDomainNames.join(", ")}.`);
5541
+ }
5333
5542
  // #722: a partial external-cleanup failure never fails the delete, and used to be
5334
5543
  // silently discarded here — visible only in the raw JSON, if a caller happened to
5335
5544
  // read it. Surfacing it is the whole reason a human running this command manually
@@ -5460,6 +5669,16 @@ async function runSupportCommand(command) {
5460
5669
  });
5461
5670
  console.log("Support request sent. A human will reply to your account email.");
5462
5671
  }
5672
+ // ` --api-url <url>` for a printed command whenever the destination is not the built-in
5673
+ // default, so a line copied from a staging session cannot act on production; "" otherwise.
5674
+ export function apiUrlFlagFor(target) {
5675
+ if (!target || target.apiUrlFrom === "default")
5676
+ return "";
5677
+ // A plain URL (scheme, host, port, path) has no shell-significant characters; anything
5678
+ // else falls back to single quotes.
5679
+ const url = /^[A-Za-z0-9._:/-]+$/.test(target.apiUrl) ? target.apiUrl : quoteSiteArg(target.apiUrl);
5680
+ return ` --api-url ${url}`;
5681
+ }
5463
5682
  // The command line that repeats this one with --switch-mode. Rebuilt from the parsed
5464
5683
  // action rather than echoed from argv so a --token on the original line is never printed.
5465
5684
  // The destination is carried explicitly whenever it is not the built-in default: a line
@@ -5468,10 +5687,7 @@ async function runSupportCommand(command) {
5468
5687
  // no --owner (a site id or slug already names the app), so there is none to carry.
5469
5688
  export function accessSwitchModeCommand(action, target) {
5470
5689
  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}` : "";
5690
+ const apiUrl = apiUrlFlagFor(target);
5475
5691
  if (action.kind === "service-create") {
5476
5692
  const name = action.name ? ` --name ${quoteSiteArg(action.name)}` : "";
5477
5693
  const duration = action.duration ? ` --duration ${quoteSiteArg(action.duration)}` : "";
@@ -5721,7 +5937,7 @@ function printUsage() {
5721
5937
  9pm domains status <app> <hostname> [--json] state and, per record, what DNS shows now
5722
5938
  9pm domains check <app> <hostname> [--json] re-check now, then print the status
5723
5939
  9pm domains wait <app> <hostname> [--timeout 30m] [--interval 20s] [--json]
5724
- 9pm domains restart <app> <hostname> start validation over; record values may change
5940
+ 9pm domains restart <app> <hostname> for a failed certificate only: start validation over; record values may change
5725
5941
  9pm domains remove <app> <hostname> --confirm <hostname>
5726
5942
  9pm access show <site>
5727
5943
  9pm access public <site> [--switch-mode]
@@ -5776,16 +5992,19 @@ Missing pages (static sites and --entry worker apps):
5776
5992
  Custom domains:
5777
5993
  Every domains command takes --owner <organisation> for an organisation's app. list, status and
5778
5994
  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.
5995
+ --json prints {hostname, app, state, reason, next, routing, tls, provider, nameservers,
5996
+ records[{type, name, fqdn, expected, current, ok, check, apex, optional}], error, expiresAt,
5997
+ checkedAt}. state is one of live, waiting_for_dns, dns_unverified (routing is active but the
5998
+ records could not be checked), issuing_certificate (every required record checked and found),
5999
+ failed, expired. reason says why (reason.providerMessage is the provider's own text, when it
6000
+ reported any, and its source: certificate_authority or hostname); next says who acts next (owner or platform) and what to do.
5783
6001
  A record's name is relative to the zone (www, _acme-challenge.www, @). ok is null when the
5784
6002
  check cannot say: check is not_checked, or apex_addresses for a root record answering with
5785
6003
  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.
6004
+ wait exits 0 when live, 10 on timeout with something for the owner to do (records missing or
6005
+ unverified, or a CAA record blocking the certificate), 11 on timeout with every record found and
6006
+ nothing for the owner to do (the platform is still finishing), 20 when the domain failed or
6007
+ expired, and 1 on a usage, auth or API error.
5789
6008
 
5790
6009
  Container deploys:
5791
6010
  Requires Docker locally. The CLI builds linux/amd64, exports the image, uploads it through a short-lived session, then finalizes a queued deployment.