ninepm 0.69.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,6 +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, zoneApexFor } from "./dns-host.js";
17
+ import { dnsProviderProfile, recordAtProvider } from "./dns-providers.js";
18
+ import { deriveDomainStatus, domainRecordStatuses, showsUseWww, } from "./domain-state.js";
16
19
  import { buildFileManifest, readManifestFile } from "./manifest.js";
17
20
  import { NO_PUBLIC_ASSETS, normalizeAssetsDirectory, privateLookingPaths, publicAssetPath } from "./public-assets.js";
18
21
  import { API_KEY_HELP, API_URL_HELP, CLI_CLIENT_ID, DEFAULT_API_URL, reportCliError } from "./cli-config.js";
@@ -49,7 +52,7 @@ export function deployDatabaseClause(databases) {
49
52
  const listed = databases.map((database) => (database.name ? `${database.bindingName}:${database.name}` : database.bindingName)).join(", ");
50
53
  return `${databases.length === 1 ? "database" : "databases"} ${listed}`;
51
54
  }
52
- export const CLI_VERSION = "0.69.0";
55
+ export const CLI_VERSION = "0.78.0";
53
56
  const PUBLIC_DASHBOARD_URL = "https://9pm.ai/dashboard";
54
57
  const TOKEN_SERVICE = "9pm";
55
58
  const TOKEN_ACCOUNT = "default";
@@ -243,9 +246,13 @@ export class DeployError extends Error {
243
246
  * prevent (#1061).
244
247
  */
245
248
  export class DeliberateRefusalError extends Error {
246
- constructor(message) {
249
+ // The server's error code, so a command can add what only it knows - the exact
250
+ // command line to re-run - to a refusal it recognises.
251
+ code;
252
+ constructor(message, code) {
247
253
  super(message);
248
254
  this.name = "DeliberateRefusalError";
255
+ this.code = code;
249
256
  }
250
257
  }
251
258
  /**
@@ -266,6 +273,7 @@ export class DeliberateRefusalError extends Error {
266
273
  const DELIBERATE_REFUSAL_CODES = new Set([
267
274
  "deploys_frozen",
268
275
  "cli_version_outdated",
276
+ "access_mode_change_unconfirmed",
269
277
  ]);
270
278
  export function isDeliberateRefusal(error) {
271
279
  if (error instanceof DeliberateRefusalError)
@@ -495,6 +503,11 @@ async function runDeploy(initialOptions, slugHolder) {
495
503
  throw new Error(options.bundleDir ? `Bundle directory contains no files: ${contentRoot}` : "Directory contains no deployable files.");
496
504
  }
497
505
  const bundle = options.bundleDir ? bundleMetadataFromManifest(manifest, contentRoot) : null;
506
+ // Checked here as well as at parse time because a detected framework build becomes a
507
+ // bundle only after parsing, and the platform would refuse it after the app exists.
508
+ if (bundle && options.missingPages) {
509
+ throw new Error("--spa and --no-spa do not apply to a framework bundle: its server answers every path itself.");
510
+ }
498
511
  const serverEntry = runtime === "worker" && !bundle ? normalizeServerEntry(options.entry ?? "src/index.js") : undefined;
499
512
  // Set by the size preflight below and reused by the upload loop, so the entry is read once.
500
513
  let serverEntryBytes;
@@ -637,6 +650,7 @@ async function runDeploy(initialOptions, slugHolder) {
637
650
  ...(options.description ? { description: options.description } : {}),
638
651
  runtime,
639
652
  ...(serverEntry ? { serverEntry } : {}),
653
+ ...(options.missingPages ? { missingPages: options.missingPages } : {}),
640
654
  metadata: {
641
655
  cli: CLI_CLIENT_ID,
642
656
  inspiredBy: "here.now",
@@ -1142,29 +1156,40 @@ export async function getSiteAccess(options) {
1142
1156
  method: "GET",
1143
1157
  });
1144
1158
  }
1159
+ // The server refuses an access-mode change unless the request says it means it (#1987):
1160
+ // the change can lock people out and take custom domains down. `--switch-mode` sends
1161
+ // `switchMode: true`; without it the refusal is printed with the command to re-run.
1162
+ export const ACCESS_MODE_CHANGE_UNCONFIRMED = "access_mode_change_unconfirmed";
1163
+ function switchModeField(options) {
1164
+ return options.switchMode ? { switchMode: true } : {};
1165
+ }
1145
1166
  export async function setSiteAccessPublic(options) {
1146
1167
  const payload = await apiRequest(options, `/v1/sites/${encodeURIComponent(options.site)}/access`, {
1147
1168
  method: "PUT",
1148
- body: JSON.stringify({ mode: "public" }),
1169
+ body: JSON.stringify({ mode: "public", ...switchModeField(options) }),
1149
1170
  });
1150
1171
  return payload.access;
1151
1172
  }
1152
1173
  export async function setSiteAccessOtp(options) {
1153
1174
  const payload = await apiRequest(options, `/v1/sites/${encodeURIComponent(options.site)}/access`, {
1154
1175
  method: "PUT",
1155
- body: JSON.stringify({ mode: "private_otp" }),
1176
+ body: JSON.stringify({ mode: "private_otp", ...switchModeField(options) }),
1156
1177
  });
1157
1178
  return payload.access;
1158
1179
  }
1159
1180
  export async function setSiteAccessIdp(options) {
1160
1181
  const payload = await apiRequest(options, `/v1/sites/${encodeURIComponent(options.site)}/access`, {
1161
1182
  method: "PUT",
1162
- body: JSON.stringify({ mode: "private_idp", ...(options.providers?.length ? { providers: options.providers } : {}) }),
1183
+ body: JSON.stringify({
1184
+ mode: "private_idp",
1185
+ ...(options.providers?.length ? { providers: options.providers } : {}),
1186
+ ...switchModeField(options),
1187
+ }),
1163
1188
  });
1164
1189
  return payload.access;
1165
1190
  }
1166
1191
  export async function createAccessServiceToken(options) {
1167
- const body = {};
1192
+ const body = { ...switchModeField(options) };
1168
1193
  if (options.name) {
1169
1194
  body.name = options.name;
1170
1195
  }
@@ -2435,7 +2460,7 @@ async function expectOk(response, action) {
2435
2460
  const code = typeof envelope?.code === "string" ? envelope.code : "";
2436
2461
  const message = typeof envelope?.message === "string" ? envelope.message.trim() : "";
2437
2462
  if (code && message && DELIBERATE_REFUSAL_CODES.has(code)) {
2438
- throw new DeliberateRefusalError(message);
2463
+ throw new DeliberateRefusalError(message, code);
2439
2464
  }
2440
2465
  throw new Error(`Failed to ${action}: ${response.status} ${text}`);
2441
2466
  }
@@ -2610,6 +2635,8 @@ const VALID_DEPLOY_FLAGS = new Set([
2610
2635
  "bundle",
2611
2636
  "assets",
2612
2637
  "no-assets",
2638
+ "spa",
2639
+ "no-spa",
2613
2640
  ]);
2614
2641
  export function parseArgs(argv) {
2615
2642
  const command = argv[0];
@@ -2628,6 +2655,9 @@ export function parseArgs(argv) {
2628
2655
  if (command === "deployments") {
2629
2656
  return parseDeploymentsArgs(argv.slice(1));
2630
2657
  }
2658
+ if (command === "domains") {
2659
+ return parseDomainsArgs(argv.slice(1));
2660
+ }
2631
2661
  if (command === "logs") {
2632
2662
  return parseLogsArgs(argv.slice(1));
2633
2663
  }
@@ -2860,6 +2890,23 @@ export function parseArgs(argv) {
2860
2890
  }
2861
2891
  options.publicAssets = typeof assetsFlag === "string" ? normalizeAssetsDirectory(assetsFlag) : NO_PUBLIC_ASSETS;
2862
2892
  }
2893
+ if (flags.has("spa") || flags.has("no-spa")) {
2894
+ if (flags.has("spa") && flags.has("no-spa")) {
2895
+ throw new Error("--spa and --no-spa cannot be combined.");
2896
+ }
2897
+ // The flag parser hands the next word to a flag as its value, so `--spa dist` would
2898
+ // otherwise deploy "." and drop "dist" silently.
2899
+ for (const name of ["spa", "no-spa"]) {
2900
+ const value = flags.get(name);
2901
+ if (typeof value === "string") {
2902
+ throw new Error(`--${name} takes no value (got "${value}"). Put the directory first: 9pm deploy ${value} --${name}`);
2903
+ }
2904
+ }
2905
+ if (options.runtime === "container" || options.bundleDir) {
2906
+ throw new Error("--spa and --no-spa apply to static sites and --entry worker apps; a container or --bundle app answers every path itself.");
2907
+ }
2908
+ options.missingPages = flags.has("spa") ? "spa" : "404";
2909
+ }
2863
2910
  if (options.runtime === "container" && !options.port) {
2864
2911
  throw new Error("Container deploys require --port <port>.");
2865
2912
  }
@@ -2872,7 +2919,7 @@ export function parseArgs(argv) {
2872
2919
  options.runtime !== "container") {
2873
2920
  throw new Error("--always-on, --volume, --allow-data-wipe, --allow-namespace-reset, --cpus, and --memory require --runtime container.");
2874
2921
  }
2875
- return { kind: "deploy", options };
2922
+ return { kind: "deploy", options, apiUrlFrom: apiUrlSource(flags) };
2876
2923
  }
2877
2924
  function parseDeleteArgs(argv) {
2878
2925
  const flags = parseFlags(argv);
@@ -3173,6 +3220,710 @@ async function runDeploymentsCommand(command) {
3173
3220
  });
3174
3221
  console.log(`Deleted deployment ${result.deleted}.`);
3175
3222
  }
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";
3227
+ export const DOMAIN_WAIT_EXIT = { live: 0, ownerAction: 10, platform: 11, failed: 20 };
3228
+ function normalizeDnsName(value) {
3229
+ return value.trim().toLowerCase().replace(/\.$/, "");
3230
+ }
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
+ }
3242
+ });
3243
+ }
3244
+ function stringOrNull(value) {
3245
+ return typeof value === "string" && value !== "" ? value : null;
3246
+ }
3247
+ export function domainStatusJson(domain, app, checkedAt) {
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 !== "";
3253
+ return {
3254
+ hostname: domain.hostname,
3255
+ app: domain.app?.slug ?? app,
3256
+ state: fromServer ? domain.state : derived.state,
3257
+ routing: domain.routingStatus,
3258
+ tls: domain.tlsStatus,
3259
+ provider: domain.dnsCheck?.provider ?? null,
3260
+ providerId,
3261
+ nameservers: domain.dnsCheck?.nameservers ?? [],
3262
+ zone: stringOrNull(domain.dnsCheck?.zone),
3263
+ records,
3264
+ advisories: (domain.dnsCheck?.advisories ?? []).map(({ code, message }) => ({ code, message })),
3265
+ error: domain.provisioningError,
3266
+ expiresAt: domain.expiresAt ?? domain.expireAfter ?? null,
3267
+ checkedAt,
3268
+ reason: fromServer ? (domain.reason ?? null) : derived.reason,
3269
+ next: fromServer ? (domain.next ?? derived.next) : derived.next,
3270
+ };
3271
+ }
3272
+ const DOMAIN_STATE_LABELS = {
3273
+ live: "live",
3274
+ waiting_for_dns: "waiting for DNS records",
3275
+ dns_unverified: "couldn't check the DNS records just now",
3276
+ issuing_certificate: "DNS records found, waiting for the platform to issue the certificate",
3277
+ failed: "failed",
3278
+ expired: "expired",
3279
+ };
3280
+ // A state newer than this CLI is printed as the server named it rather than guessed at.
3281
+ export function domainStateLabel(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}`;
3329
+ }
3330
+ const TXT_KEEP_OTHERS = "add this value as its own TXT record; other TXT values can stay";
3331
+ function recordStateText(record, routing) {
3332
+ if (record.ok === true)
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
+ }
3339
+ // An optional record never holds the domain back, so its state would only be noise.
3340
+ if (record.optional)
3341
+ return "optional, only needed if the CNAME cannot be pointed first";
3342
+ if (record.check === "apex_addresses") {
3343
+ return routing === "active"
3344
+ ? "looks connected: the root answers with addresses, as an ALIAS does, and routing is active"
3345
+ : `answers with addresses (${(record.current ?? []).join(", ")}); an ALIAS can't be verified from outside, so this waits on the platform's routing check`;
3346
+ }
3347
+ if (record.ok === null || record.current === null)
3348
+ return "not checked: couldn't read your DNS just now";
3349
+ if (record.type === "TXT") {
3350
+ const now = record.current.length === 0 ? "not published yet" : `currently ${record.current.join(", ")}`;
3351
+ return `${now}: ${TXT_KEEP_OTHERS}`;
3352
+ }
3353
+ if (record.current.length === 0)
3354
+ return "not published yet";
3355
+ return `currently ${record.current.join(", ")}: point it at the value shown`;
3356
+ }
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;
3399
+ }
3400
+ function renderColumns(rows) {
3401
+ const widths = (rows[0] ?? []).map((_, column) => Math.max(...rows.map((row) => (row[column] ?? "").length)));
3402
+ return rows.map((cells) => cells
3403
+ .map((cell, column) => (column === cells.length - 1 ? cell : cell.padEnd(widths[column] ?? 0)))
3404
+ .join(" ")
3405
+ .trimEnd());
3406
+ }
3407
+ export function formatDomainStatus(status, options = {}) {
3408
+ const lines = [`${status.hostname} (app ${status.app}): ${domainStateLabel(status.state)}`];
3409
+ if (status.state === "live") {
3410
+ lines.push(`Serving at https://${status.hostname}`);
3411
+ return lines;
3412
+ }
3413
+ if (status.state === "expired") {
3414
+ lines.push(status.error ?? "This domain expired before its DNS records were published.");
3415
+ lines.push(`Add it again: ${domainCommand("add", status.app, status.hostname, options)}`);
3416
+ return lines;
3417
+ }
3418
+ if (status.state === "failed" && status.error) {
3419
+ lines.push(status.error);
3420
+ }
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;
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.";
3432
+ lines.push(status.provider
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}`
3434
+ : status.nameservers.length > 0
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.`);
3480
+ }
3481
+ if (status.records.some((record) => !record.optional && record.check === "not_checked")) {
3482
+ lines.push("Couldn't check your DNS records just now, so their state is unknown. Make sure they are published as shown.");
3483
+ }
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}".`);
3488
+ }
3489
+ }
3490
+ const reported = providerMessageLine(status.reason);
3491
+ if (reported)
3492
+ lines.push(reported);
3493
+ if (status.state === "failed") {
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
+ }
3502
+ return lines;
3503
+ }
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") {
3512
+ lines.push("Every required record is published. The platform finishes on its own, usually within minutes.");
3513
+ }
3514
+ lines.push(`Then run: ${domainCommand("wait", status.app, status.hostname, options)}`);
3515
+ return lines;
3516
+ }
3517
+ // What a timed-out wait says. "Missing" is claimed only for a record the check actually saw
3518
+ // absent or different; a record nobody could read is reported as unchecked, whatever the
3519
+ // routing status, because telling the owner to publish what may already be there misleads.
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
+ }
3527
+ if (status.state === "issuing_certificate") {
3528
+ return "Timed out: the records are published and the platform is still finishing. Wait again; nothing needs changing.";
3529
+ }
3530
+ const required = status.records.filter((record) => !record.optional);
3531
+ if (required.some((record) => record.ok === false)) {
3532
+ return "Timed out: records above are still missing. Publish them, then wait again.";
3533
+ }
3534
+ return "Timed out: the DNS records could not be checked. Make sure they are published as shown, then wait again.";
3535
+ }
3536
+ export function formatDomainsList(statuses) {
3537
+ if (statuses.length === 0) {
3538
+ return ["No custom domains. Add one: 9pm domains add <app> <hostname>"];
3539
+ }
3540
+ return renderColumns([
3541
+ ["HOSTNAME", "APP", "STATE", "ROUTING", "TLS"],
3542
+ ...statuses.map((status) => [status.hostname, status.app, status.state, status.routing, status.tls]),
3543
+ ]);
3544
+ }
3545
+ export class DomainsApiError extends Error {
3546
+ status;
3547
+ code;
3548
+ constructor(message, status, code) {
3549
+ super(message);
3550
+ this.name = "DomainsApiError";
3551
+ this.status = status;
3552
+ this.code = code;
3553
+ }
3554
+ }
3555
+ // The API's own message and code, plus `details.reason` where the refusal carries one
3556
+ // (invalid_custom_domain), rather than the raw envelope the generic path prints.
3557
+ export function domainsApiError(status, text) {
3558
+ let envelope = null;
3559
+ try {
3560
+ const parsed = JSON.parse(text);
3561
+ envelope = parsed.error && typeof parsed.error === "object" ? parsed.error : null;
3562
+ }
3563
+ catch {
3564
+ envelope = null;
3565
+ }
3566
+ const code = typeof envelope?.code === "string" ? envelope.code : null;
3567
+ const message = typeof envelope?.message === "string" ? envelope.message.trim() : "";
3568
+ if (!code || !message) {
3569
+ return new DomainsApiError(`Request failed: ${status} ${text}`.trim(), status, code);
3570
+ }
3571
+ const details = envelope?.details;
3572
+ const reason = details && typeof details === "object" && typeof details.reason === "string"
3573
+ ? details.reason
3574
+ : null;
3575
+ return new DomainsApiError(`${message} (${code}${reason ? `, reason: ${reason}` : ""})`, status, code);
3576
+ }
3577
+ async function domainsRequest(options, route, init = { method: "GET" }) {
3578
+ const fetchFn = options.fetchFn ?? fetch;
3579
+ const headers = new Headers(init.headers);
3580
+ headers.set("authorization", `Bearer ${options.token}`);
3581
+ headers.set("x-9pm-cli-version", CLI_VERSION);
3582
+ if (init.body)
3583
+ headers.set("content-type", "application/json");
3584
+ const response = await fetchFn(`${normalizeApiUrl(options.apiUrl)}${route}`, { ...init, headers });
3585
+ noteCliOutdatedAdvisory(response);
3586
+ if (!response.ok) {
3587
+ throw domainsApiError(response.status, await response.text());
3588
+ }
3589
+ return (await response.json());
3590
+ }
3591
+ function ownerQuery(owner) {
3592
+ return owner ? `?owner=${encodeURIComponent(owner)}` : "";
3593
+ }
3594
+ function siteDomainsRoute(options, hostname, action) {
3595
+ const base = `/v1/sites/${encodeURIComponent(options.site)}/domains`;
3596
+ const path = hostname ? `${base}/${encodeURIComponent(hostname)}${action ? `/${action}` : ""}` : base;
3597
+ return `${path}${ownerQuery(options.owner)}`;
3598
+ }
3599
+ export function normalizeDomainArg(value) {
3600
+ return normalizeDnsName(value);
3601
+ }
3602
+ export async function listDomainStatuses(options) {
3603
+ const checkedAt = (options.now ?? (() => new Date()))().toISOString();
3604
+ if (options.site) {
3605
+ const result = await domainsRequest(options, siteDomainsRoute({ ...options, site: options.site }));
3606
+ return result.domains.map((domain) => domainStatusJson(domain, options.site, checkedAt));
3607
+ }
3608
+ const result = await domainsRequest(options, `/v1/domains${ownerQuery(options.owner)}`);
3609
+ return result.domains.map((domain) => domainStatusJson(domain, domain.app?.slug ?? "", checkedAt));
3610
+ }
3611
+ export async function getDomainStatus(options) {
3612
+ const hostname = normalizeDomainArg(options.hostname);
3613
+ const statuses = await listDomainStatuses(options);
3614
+ const status = statuses.find((candidate) => candidate.hostname === hostname);
3615
+ if (!status) {
3616
+ throw new DomainsApiError(`${hostname} is not a custom domain of ${options.site} (custom_domain_not_found). List them: 9pm domains list ${quoteSiteArg(options.site)}`, 404, "custom_domain_not_found");
3617
+ }
3618
+ return status;
3619
+ }
3620
+ export async function addDomain(options) {
3621
+ const result = await domainsRequest(options, siteDomainsRoute(options), {
3622
+ method: "POST",
3623
+ body: JSON.stringify({ hostname: options.hostname.trim() }),
3624
+ });
3625
+ return result.domain;
3626
+ }
3627
+ export async function recheckDomain(options) {
3628
+ await domainsRequest(options, siteDomainsRoute(options, normalizeDomainArg(options.hostname), "refresh"), { method: "POST" });
3629
+ }
3630
+ export async function restartDomain(options) {
3631
+ return domainsRequest(options, siteDomainsRoute(options, normalizeDomainArg(options.hostname), "restart"), { method: "POST" });
3632
+ }
3633
+ export async function removeDomain(options) {
3634
+ const result = await domainsRequest(options, siteDomainsRoute(options, normalizeDomainArg(options.hostname)), {
3635
+ method: "DELETE",
3636
+ });
3637
+ return result.domain;
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
+ }
3705
+ // Polls the read-only list, which reconciles pending domains on every read, so waiting needs
3706
+ // only access to the app. A failed read that is not the caller's fault (network, 5xx) is
3707
+ // retried until the deadline; a refusal (auth, not found) ends the wait at once.
3708
+ export async function waitForDomain(options) {
3709
+ const nowMs = options.nowMs ?? (() => Date.now());
3710
+ const sleepFn = options.sleepFn ?? sleep;
3711
+ const deadline = nowMs() + options.timeoutMs;
3712
+ let last = null;
3713
+ for (;;) {
3714
+ try {
3715
+ const status = await getDomainStatus(options);
3716
+ last = status;
3717
+ options.onStatus?.(status);
3718
+ if (status.state === "live")
3719
+ return { status, exitCode: DOMAIN_WAIT_EXIT.live, timedOut: false };
3720
+ if (status.state === "failed" || status.state === "expired")
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 };
3725
+ }
3726
+ catch (error) {
3727
+ const transient = !(error instanceof DomainsApiError) || error.status >= 500;
3728
+ if (!transient || (nowMs() >= deadline && !last))
3729
+ throw error;
3730
+ options.onRetry?.(error);
3731
+ }
3732
+ const remaining = deadline - nowMs();
3733
+ if (remaining <= 0) {
3734
+ if (!last)
3735
+ throw new Error(`Timed out before ${options.hostname} could be read.`);
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 };
3739
+ }
3740
+ await sleepFn(Math.min(options.intervalMs, remaining));
3741
+ }
3742
+ }
3743
+ const DOMAINS_VALUE_FLAGS = new Set(["token", "api-url", "owner", "timeout", "interval", "confirm"]);
3744
+ const DOMAINS_BOOLEAN_FLAGS = new Set(["json"]);
3745
+ function domainsPositionals(argv) {
3746
+ const positionals = [];
3747
+ for (let index = 0; index < argv.length; index += 1) {
3748
+ const arg = argv[index] ?? "";
3749
+ if (!arg.startsWith("--")) {
3750
+ positionals.push(arg);
3751
+ continue;
3752
+ }
3753
+ const name = arg.slice(2);
3754
+ if (!DOMAINS_VALUE_FLAGS.has(name) && !DOMAINS_BOOLEAN_FLAGS.has(name)) {
3755
+ throw new Error(`Unknown flag for 9pm domains: --${name}`);
3756
+ }
3757
+ if (DOMAINS_VALUE_FLAGS.has(name))
3758
+ index += 1;
3759
+ }
3760
+ return positionals;
3761
+ }
3762
+ const DOMAINS_USAGE = {
3763
+ list: "9pm domains list [<app>] [--owner <organisation>] [--json]",
3764
+ add: "9pm domains add <app> <hostname> [--owner <organisation>] [--json]",
3765
+ status: "9pm domains status <app> <hostname> [--owner <organisation>] [--json]",
3766
+ check: "9pm domains check <app> <hostname> [--owner <organisation>] [--json]",
3767
+ wait: "9pm domains wait <app> <hostname> [--timeout 30m] [--interval 20s] [--owner <organisation>] [--json]",
3768
+ restart: "9pm domains restart <app> <hostname> [--owner <organisation>] (for a failed certificate; the record values may change)",
3769
+ remove: "9pm domains remove <app> <hostname> --confirm <hostname> [--owner <organisation>]",
3770
+ };
3771
+ function parseDomainsArgs(argv) {
3772
+ const subcommand = argv[0] === "ls" ? "list" : argv[0] === "rm" ? "remove" : argv[0];
3773
+ if (!subcommand || subcommand === "help" || subcommand === "--help") {
3774
+ throw new UsageError();
3775
+ }
3776
+ const usage = DOMAINS_USAGE[subcommand];
3777
+ if (!usage) {
3778
+ throw new Error(`Unknown domains subcommand: ${subcommand}. Expected one of: ${Object.keys(DOMAINS_USAGE).join(", ")}.`);
3779
+ }
3780
+ const flags = parseFlags(argv);
3781
+ const positionals = domainsPositionals(argv.slice(1));
3782
+ const json = flags.has("json");
3783
+ const owner = readStringFlag(flags, "owner");
3784
+ const withBase = (action) => ({
3785
+ kind: "domains",
3786
+ ...readBaseApiOptions(flags),
3787
+ apiUrlFrom: apiUrlSource(flags),
3788
+ ...(owner ? { owner } : {}),
3789
+ action,
3790
+ });
3791
+ if (subcommand === "list") {
3792
+ if (positionals.length > 1)
3793
+ throw new Error(`Usage: ${usage}`);
3794
+ const site = positionals[0];
3795
+ return withBase({ kind: "list", json, ...(site ? { site } : {}) });
3796
+ }
3797
+ const [site, hostname] = positionals;
3798
+ if (!site || !hostname || positionals.length > 2) {
3799
+ throw new Error(`Usage: ${usage}`);
3800
+ }
3801
+ if (subcommand === "add" || subcommand === "status" || subcommand === "check") {
3802
+ return withBase({ kind: subcommand, site, hostname, json });
3803
+ }
3804
+ if (subcommand === "wait") {
3805
+ const timeout = readStringFlag(flags, "timeout") ?? "30m";
3806
+ const interval = readStringFlag(flags, "interval") ?? "20s";
3807
+ const timeoutSeconds = parseDurationSeconds(timeout);
3808
+ const intervalSeconds = parseDurationSeconds(interval);
3809
+ if (intervalSeconds < 1)
3810
+ throw new Error("--interval must be at least 1s.");
3811
+ return withBase({ kind: "wait", site, hostname, json, timeoutSeconds, intervalSeconds });
3812
+ }
3813
+ if (subcommand === "restart") {
3814
+ return withBase({ kind: "restart", site, hostname });
3815
+ }
3816
+ const confirm = readStringFlag(flags, "confirm");
3817
+ if (!confirm || normalizeDomainArg(confirm) !== normalizeDomainArg(hostname)) {
3818
+ throw new Error(`Removing a domain stops it serving at once. Pass --confirm ${normalizeDomainArg(hostname)} to remove it.`);
3819
+ }
3820
+ return withBase({ kind: "remove", site, hostname });
3821
+ }
3822
+ function printDomainStatus(status, json, options, heading) {
3823
+ if (json) {
3824
+ console.log(JSON.stringify(status, null, 2));
3825
+ return;
3826
+ }
3827
+ if (heading)
3828
+ console.log(heading);
3829
+ for (const line of formatDomainStatus(status, options))
3830
+ console.log(line);
3831
+ }
3832
+ function instructionLines(instructions, hostname) {
3833
+ const apex = guessZoneApex(hostname);
3834
+ return renderColumns(instructions.map((instruction) => [instruction.type.toUpperCase(), relativeRecordName(instruction.host, apex), instruction.value])).map((line) => ` ${line}`);
3835
+ }
3836
+ async function runDomainsCommand(command) {
3837
+ const action = command.action;
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
+ };
3843
+ if (action.kind === "list") {
3844
+ const statuses = await listDomainStatuses({ ...base, ...(action.site ? { site: action.site } : {}) });
3845
+ if (action.json) {
3846
+ console.log(JSON.stringify({ domains: statuses }, null, 2));
3847
+ return;
3848
+ }
3849
+ for (const line of formatDomainsList(statuses))
3850
+ console.log(line);
3851
+ return;
3852
+ }
3853
+ const target = { ...base, site: action.site, hostname: action.hostname };
3854
+ if (action.kind === "add") {
3855
+ const added = await addDomain(target);
3856
+ const hostname = added?.hostname ?? normalizeDomainArg(action.hostname);
3857
+ // The add response carries no DNS check; the list read that follows does, and it is what
3858
+ // says whether a record the owner already has (a wildcard, an old CNAME) is in the way.
3859
+ const status = await getDomainStatus({ ...target, hostname }).catch(() => added ? domainStatusJson(added, action.site, new Date().toISOString()) : null);
3860
+ if (!status) {
3861
+ console.log(`Added ${hostname} to ${action.site}. See its records: ${domainCommand("status", action.site, hostname, format)}`);
3862
+ return;
3863
+ }
3864
+ printDomainStatus(status, action.json, format, action.json ? undefined : `Added ${hostname} to ${action.site}.`);
3865
+ return;
3866
+ }
3867
+ if (action.kind === "status") {
3868
+ printDomainStatus(await getDomainStatus(target), action.json, format);
3869
+ return;
3870
+ }
3871
+ if (action.kind === "check") {
3872
+ await recheckDomain(target);
3873
+ printDomainStatus(await getDomainStatus(target), action.json, format);
3874
+ return;
3875
+ }
3876
+ if (action.kind === "wait") {
3877
+ let lastState = null;
3878
+ const result = await waitForDomain({
3879
+ ...target,
3880
+ timeoutMs: action.timeoutSeconds * 1000,
3881
+ intervalMs: action.intervalSeconds * 1000,
3882
+ onStatus: (status) => {
3883
+ if (status.state !== lastState) {
3884
+ lastState = status.state;
3885
+ console.error(`${new Date().toISOString()} ${status.hostname}: ${domainStateLabel(status.state)}`);
3886
+ }
3887
+ },
3888
+ onRetry: (error) => console.error(`Could not read the domain (${error instanceof Error ? error.message : String(error)}); retrying.`),
3889
+ });
3890
+ if (action.json) {
3891
+ console.log(JSON.stringify(result.status, null, 2));
3892
+ }
3893
+ else {
3894
+ for (const line of formatDomainStatus(result.status, format))
3895
+ console.log(line);
3896
+ if (result.timedOut || result.status.next?.action === "use_www")
3897
+ console.log(domainWaitTimeoutMessage(result.status));
3898
+ }
3899
+ process.exitCode = result.exitCode;
3900
+ return;
3901
+ }
3902
+ if (action.kind === "restart") {
3903
+ const restarted = await restartDomain(target);
3904
+ console.log(`Started validation for ${restarted.domain.hostname} over.`);
3905
+ if (restarted.replacedInstructions.length > 0) {
3906
+ console.log("These values no longer count; delete them at your DNS provider:");
3907
+ for (const line of instructionLines(restarted.replacedInstructions, restarted.domain.hostname))
3908
+ console.log(line);
3909
+ }
3910
+ else {
3911
+ console.log("No record changed.");
3912
+ }
3913
+ const status = await getDomainStatus({ ...target, hostname: restarted.domain.hostname }).catch(() => domainStatusJson(restarted.domain, action.site, new Date().toISOString()));
3914
+ for (const line of formatDomainStatus(status, format))
3915
+ console.log(line);
3916
+ return;
3917
+ }
3918
+ const removed = await removeDomain(target);
3919
+ const hostname = removed?.hostname ?? normalizeDomainArg(action.hostname);
3920
+ console.log(`Removed ${hostname} from ${action.site}. It stops serving within seconds.`);
3921
+ if (removed && removed.dnsInstructions.length > 0) {
3922
+ console.log("You can now delete these records at your DNS provider, if nothing else uses them:");
3923
+ for (const line of instructionLines(removed.dnsInstructions, hostname))
3924
+ console.log(line);
3925
+ }
3926
+ }
3176
3927
  // #807 step 5: the claim behind a health verdict, in words. A bare `unknown` reads
3177
3928
  // as "fine"; the reason says which statement the platform is actually making.
3178
3929
  export function healthWithReason(status, reason) {
@@ -3314,7 +4065,7 @@ function parseAccessArgs(argv) {
3314
4065
  throw new UsageError();
3315
4066
  }
3316
4067
  const flags = parseFlags(argv);
3317
- const base = readBaseApiOptions(flags);
4068
+ const base = { ...readBaseApiOptions(flags), apiUrlFrom: apiUrlSource(flags) };
3318
4069
  const siteFrom = (index) => {
3319
4070
  const site = argv[index];
3320
4071
  if (!site || site.startsWith("--")) {
@@ -3325,16 +4076,17 @@ function parseAccessArgs(argv) {
3325
4076
  if (subcommand === "show") {
3326
4077
  return { kind: "access", ...base, action: { kind: "show", site: siteFrom(1) } };
3327
4078
  }
4079
+ const switchMode = flags.has("switch-mode") ? { switchMode: true } : {};
3328
4080
  if (subcommand === "public") {
3329
- return { kind: "access", ...base, action: { kind: "public", site: siteFrom(1) } };
4081
+ return { kind: "access", ...base, action: { kind: "public", site: siteFrom(1), ...switchMode } };
3330
4082
  }
3331
4083
  if (subcommand === "otp") {
3332
- return { kind: "access", ...base, action: { kind: "otp", site: siteFrom(1) } };
4084
+ return { kind: "access", ...base, action: { kind: "otp", site: siteFrom(1), ...switchMode } };
3333
4085
  }
3334
4086
  if (subcommand === "idp") {
3335
4087
  const site = siteFrom(1);
3336
4088
  const providers = readRepeatedStringFlag(argv, "provider").map(parseProviderKey);
3337
- return { kind: "access", ...base, action: { kind: "idp", site, ...(providers.length ? { providers } : {}) } };
4089
+ return { kind: "access", ...base, action: { kind: "idp", site, ...(providers.length ? { providers } : {}), ...switchMode } };
3338
4090
  }
3339
4091
  if (subcommand === "service") {
3340
4092
  const verb = argv[1];
@@ -3342,7 +4094,11 @@ function parseAccessArgs(argv) {
3342
4094
  if (verb === "create") {
3343
4095
  const name = readStringFlag(flags, "name");
3344
4096
  const duration = readStringFlag(flags, "duration");
3345
- return { kind: "access", ...base, action: { kind: "service-create", site, ...(name ? { name } : {}), ...(duration ? { duration } : {}) } };
4097
+ return {
4098
+ kind: "access",
4099
+ ...base,
4100
+ action: { kind: "service-create", site, ...(name ? { name } : {}), ...(duration ? { duration } : {}), ...switchMode },
4101
+ };
3346
4102
  }
3347
4103
  if (verb === "revoke") {
3348
4104
  const tokenId = argv[3];
@@ -3454,6 +4210,11 @@ function parseSupportArgs(argv) {
3454
4210
  ...readBaseApiOptions(flags),
3455
4211
  };
3456
4212
  }
4213
+ function apiUrlSource(flags) {
4214
+ if (readStringFlag(flags, "api-url") !== undefined)
4215
+ return "flag";
4216
+ return process.env.NINEPM_API_URL !== undefined ? "env" : "default";
4217
+ }
3457
4218
  function readApiUrl(flags, fallback) {
3458
4219
  return validateApiUrl(readStringFlag(flags, "api-url") ?? process.env.NINEPM_API_URL ?? fallback);
3459
4220
  }
@@ -4462,6 +5223,22 @@ function formatRemainingForCli(value) {
4462
5223
  }
4463
5224
  export class UsageError extends Error {
4464
5225
  }
5226
+ // #1856: which missing-pages mode the deployment was published with, read from the
5227
+ // deployment the platform returned rather than from the flags, because a deploy without a
5228
+ // flag keeps whatever the app chose before. Null when the platform recorded none: a bundle
5229
+ // or container app answers every path itself, and an older control plane does not say.
5230
+ export function missingPagesLine(deployment) {
5231
+ switch (deployment.metadata.missingPages) {
5232
+ case "404":
5233
+ return "Missing pages: 404 (the deploy's own 404.html when it has one). If the app routes in the browser, redeploy with --spa.";
5234
+ case "spa":
5235
+ return "Missing pages: homepage (SPA): a path with no file serves index.html. Redeploy with --no-spa to return 404 instead.";
5236
+ case "legacy":
5237
+ return "Missing pages: homepage (kept from before this was a choice): a path with no file serves index.html. Redeploy with --no-spa to return 404, or --spa to keep it on purpose.";
5238
+ default:
5239
+ return null;
5240
+ }
5241
+ }
4465
5242
  async function main() {
4466
5243
  try {
4467
5244
  const command = parseArgs(process.argv.slice(2));
@@ -4537,11 +5314,25 @@ async function main() {
4537
5314
  }
4538
5315
  const databaseClause = deployDatabaseClause(result.databases);
4539
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
+ });
4540
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
+ }
4541
5328
  console.log(`Deployment ${result.deployment.id} ${result.containerImageBytes ? `uploaded ${formatBytesForCli(result.containerImageBytes)} image` : `uploaded ${result.uploaded} file(s)`}${databaseClause ? `, ${databaseClause}` : ""}.`);
4542
5329
  if (queued) {
4543
5330
  console.log(`Status: ${result.deployment.status}. Run 9pm apps to check when it becomes active.`);
4544
5331
  }
5332
+ const missingPages = missingPagesLine(result.deployment);
5333
+ if (missingPages) {
5334
+ console.log(missingPages);
5335
+ }
4545
5336
  console.log(`Manage: ${dashboardUrlForApiUrl(command.options.apiUrl)}${command.options.owner ? `?owner=${encodeURIComponent(command.options.owner)}` : ""}`);
4546
5337
  // Gated the same way as the reachability probe above: a programmatic
4547
5338
  // caller injecting fetchFn opted out of the CLI's own network side trips.
@@ -4745,6 +5536,9 @@ async function main() {
4745
5536
  });
4746
5537
  console.log(`Deleted ${deleted.slug}.`);
4747
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
+ }
4748
5542
  // #722: a partial external-cleanup failure never fails the delete, and used to be
4749
5543
  // silently discarded here — visible only in the raw JSON, if a caller happened to
4750
5544
  // read it. Surfacing it is the whole reason a human running this command manually
@@ -4789,6 +5583,10 @@ async function main() {
4789
5583
  await runDeploymentsCommand(command);
4790
5584
  return;
4791
5585
  }
5586
+ if (command.kind === "domains") {
5587
+ await runDomainsCommand(command);
5588
+ return;
5589
+ }
4792
5590
  if (command.kind === "logs") {
4793
5591
  await runLogsCommand(command);
4794
5592
  return;
@@ -4871,25 +5669,73 @@ async function runSupportCommand(command) {
4871
5669
  });
4872
5670
  console.log("Support request sent. A human will reply to your account email.");
4873
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
+ }
5682
+ // The command line that repeats this one with --switch-mode. Rebuilt from the parsed
5683
+ // action rather than echoed from argv so a --token on the original line is never printed.
5684
+ // The destination is carried explicitly whenever it is not the built-in default: a line
5685
+ // copied from a staging session and pasted into a shell without that flag or env var would
5686
+ // otherwise switch the mode of the same-named app on production. The access commands read
5687
+ // no --owner (a site id or slug already names the app), so there is none to carry.
5688
+ export function accessSwitchModeCommand(action, target) {
5689
+ const site = quoteSiteArg(action.site);
5690
+ const apiUrl = apiUrlFlagFor(target);
5691
+ if (action.kind === "service-create") {
5692
+ const name = action.name ? ` --name ${quoteSiteArg(action.name)}` : "";
5693
+ const duration = action.duration ? ` --duration ${quoteSiteArg(action.duration)}` : "";
5694
+ return `9pm access service create ${site}${name}${duration}${apiUrl} --switch-mode`;
5695
+ }
5696
+ if (action.kind === "idp") {
5697
+ const providers = (action.providers ?? []).map((provider) => ` --provider ${provider}`).join("");
5698
+ return `9pm access idp ${site}${providers}${apiUrl} --switch-mode`;
5699
+ }
5700
+ return `9pm access ${action.kind} ${site}${apiUrl} --switch-mode`;
5701
+ }
5702
+ // Names the API the refusal came from, and where that choice was made, so the reader sees
5703
+ // which environment the re-run will act on before running it.
5704
+ function accessTargetNote(target) {
5705
+ const from = target.apiUrlFrom === "flag" ? "from --api-url" : target.apiUrlFrom === "env" ? "from NINEPM_API_URL" : "the default API";
5706
+ return `against ${target.apiUrl} (${from})`;
5707
+ }
5708
+ async function withSwitchModeRerun(action, target, run) {
5709
+ try {
5710
+ return await run();
5711
+ }
5712
+ catch (error) {
5713
+ if (error instanceof DeliberateRefusalError && error.code === ACCESS_MODE_CHANGE_UNCONFIRMED) {
5714
+ throw new DeliberateRefusalError(`${error.message}\n\nNothing was changed. To go ahead ${accessTargetNote(target)}, re-run:\n ${accessSwitchModeCommand(action, target)}`, error.code);
5715
+ }
5716
+ throw error;
5717
+ }
5718
+ }
4874
5719
  async function runAccessCommand(command) {
4875
5720
  const base = { apiUrl: command.apiUrl, token: command.token };
5721
+ const target = { apiUrl: command.apiUrl, apiUrlFrom: command.apiUrlFrom };
4876
5722
  const action = command.action;
4877
5723
  if (action.kind === "show") {
4878
5724
  console.log(JSON.stringify(await getSiteAccess({ ...base, site: action.site }), null, 2));
4879
5725
  return;
4880
5726
  }
4881
5727
  if (action.kind === "public") {
4882
- const access = await setSiteAccessPublic({ ...base, site: action.site });
5728
+ const access = await withSwitchModeRerun(action, target, () => setSiteAccessPublic({ ...base, site: action.site, ...switchModeField(action) }));
4883
5729
  console.log(`Access for ${action.site} set to ${access.mode}.`);
4884
5730
  return;
4885
5731
  }
4886
5732
  if (action.kind === "otp") {
4887
- const access = await setSiteAccessOtp({ ...base, site: action.site });
5733
+ const access = await withSwitchModeRerun(action, target, () => setSiteAccessOtp({ ...base, site: action.site, ...switchModeField(action) }));
4888
5734
  console.log(`Access for ${action.site} set to ${access.mode}.`);
4889
5735
  return;
4890
5736
  }
4891
5737
  if (action.kind === "idp") {
4892
- const access = await setSiteAccessIdp({ ...base, site: action.site, ...(action.providers ? { providers: action.providers } : {}) });
5738
+ const access = await withSwitchModeRerun(action, target, () => setSiteAccessIdp({ ...base, site: action.site, ...(action.providers ? { providers: action.providers } : {}), ...switchModeField(action) }));
4893
5739
  console.log(`Access for ${action.site} set to ${access.mode}.`);
4894
5740
  return;
4895
5741
  }
@@ -4904,12 +5750,13 @@ async function runAccessCommand(command) {
4904
5750
  return;
4905
5751
  }
4906
5752
  if (action.kind === "service-create") {
4907
- const created = await createAccessServiceToken({
5753
+ const created = await withSwitchModeRerun(action, target, () => createAccessServiceToken({
4908
5754
  ...base,
4909
5755
  site: action.site,
4910
5756
  ...(action.name ? { name: action.name } : {}),
4911
5757
  ...(action.duration ? { duration: action.duration } : {}),
4912
- });
5758
+ ...switchModeField(action),
5759
+ }));
4913
5760
  console.log(`Created service token ${created.serviceToken.id} for ${action.site}.`);
4914
5761
  console.log(`CF-Access-Client-Id: ${created.serviceToken.clientId}`);
4915
5762
  console.log(`CF-Access-Client-Secret: ${created.clientSecret}`);
@@ -5060,8 +5907,8 @@ async function runFilesCommand(command) {
5060
5907
  function printUsage() {
5061
5908
  console.log(`Usage:
5062
5909
  9pm --version
5063
- 9pm deploy [directory] --name <site-name> [--slug <slug>] [--owner <organisation>] [--description "<note>"] [--with-db] [--with-auth] [--with-email]
5064
- 9pm deploy [directory] --runtime worker --entry src/index.js (--assets <dir> | --no-assets) [--with-db]
5910
+ 9pm deploy [directory] --name <site-name> [--slug <slug>] [--owner <organisation>] [--description "<note>"] [--spa | --no-spa] [--with-db] [--with-auth] [--with-email]
5911
+ 9pm deploy [directory] --runtime worker --entry src/index.js (--assets <dir> | --no-assets) [--spa | --no-spa] [--with-db]
5065
5912
  9pm deploy [directory] --runtime container --port 8080 [--health-path /health] [--region auto] [--with-db] [--always-on] [--volume <gb> | --no-volume] [--cpus <n>] [--memory <size>]
5066
5913
  9pm deploy [directory] framework projects (Next.js, Vite) are detected and built automatically; --no-build skips
5067
5914
  9pm deploy [directory] --bundle <dir> deploy a pre-built Cloudflare framework bundle (e.g. .open-next)
@@ -5085,15 +5932,22 @@ function printUsage() {
5085
5932
  9pm deployments rm <deployment-id>
5086
5933
  9pm logs <app> [--lines <n>] [--json]
5087
5934
  9pm delete <site> --confirm <slug>
5935
+ 9pm domains list [<app>] [--owner <organisation>] [--json]
5936
+ 9pm domains add <app> <hostname> [--json] connect a domain you own; prints the DNS records to publish
5937
+ 9pm domains status <app> <hostname> [--json] state and, per record, what DNS shows now
5938
+ 9pm domains check <app> <hostname> [--json] re-check now, then print the status
5939
+ 9pm domains wait <app> <hostname> [--timeout 30m] [--interval 20s] [--json]
5940
+ 9pm domains restart <app> <hostname> for a failed certificate only: start validation over; record values may change
5941
+ 9pm domains remove <app> <hostname> --confirm <hostname>
5088
5942
  9pm access show <site>
5089
- 9pm access public <site>
5090
- 9pm access otp <site>
5091
- 9pm access idp <site> [--provider google|github|entra]
5943
+ 9pm access public <site> [--switch-mode]
5944
+ 9pm access otp <site> [--switch-mode]
5945
+ 9pm access idp <site> [--provider google|github|entra] [--switch-mode]
5092
5946
  9pm access allow email <site> <email>
5093
5947
  9pm access allow domain <site> <domain>
5094
5948
  9pm access revoke email <site> <email>
5095
5949
  9pm access revoke domain <site> <domain>
5096
- 9pm access service create <site> [--name <name>] [--duration 8760h]
5950
+ 9pm access service create <site> [--name <name>] [--duration 8760h] [--switch-mode]
5097
5951
  9pm access service revoke <site> <service-token-id>
5098
5952
  9pm access token create <site> [--name <name>]
5099
5953
  9pm access token revoke <site> <token-id>
@@ -5124,6 +5978,34 @@ Worker public files:
5124
5978
  change it. A worker app that never chose serves EVERY file except its entry publicly, and
5125
5979
  each deploy warns about it. --check lists the files a deploy would make public.
5126
5980
 
5981
+ Missing pages (static sites and --entry worker apps):
5982
+ --no-spa a GET for a path with no file returns 404, with the deploy's own 404.html
5983
+ when it has one. / still serves index.html, /menu serves menu.html (or
5984
+ redirects to /menu/ for menu/index.html). The default for new apps.
5985
+ --spa a path with no file serves index.html instead, for apps that route in the
5986
+ browser. Every unmatched path gets it, /missing.json included.
5987
+ The app remembers the choice; a deploy without either flag keeps it, and a deploy that fails
5988
+ to publish does not change it. Apps created before the choice existed keep serving
5989
+ index.html until one of the flags is passed. A worker app's own Response, 404 included,
5990
+ is always returned as it is. Each deploy prints the mode in effect.
5991
+
5992
+ Custom domains:
5993
+ Every domains command takes --owner <organisation> for an organisation's app. list, status and
5994
+ wait only read; add, check, restart and remove need the configure role on the app.
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.
6001
+ A record's name is relative to the zone (www, _acme-challenge.www, @). ok is null when the
6002
+ check cannot say: check is not_checked, or apex_addresses for a root record answering with
6003
+ addresses (a correct ALIAS does), which defers to the routing status.
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.
6008
+
5127
6009
  Container deploys:
5128
6010
  Requires Docker locally. The CLI builds linux/amd64, exports the image, uploads it through a short-lived session, then finalizes a queued deployment.
5129
6011
  The app must listen on 0.0.0.0:$PORT - always read the PORT env var; on some placements the platform holds the selected --port and hands the app a different one. Use --health-path for a cheap readiness endpoint.