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