ninepm 0.78.0 → 0.81.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 +38 -5
- package/dist/dns-providers.d.ts +30 -0
- package/dist/dns-providers.js +49 -2
- package/dist/domain-state.d.ts +39 -10
- package/dist/domain-state.js +49 -4
- package/dist/index.d.ts +57 -3
- package/dist/index.js +370 -30
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -217,7 +217,8 @@ Connect a domain you own to an app:
|
|
|
217
217
|
This prints the DNS records to publish at your DNS provider, with names relative to your
|
|
218
218
|
domain (`www`, `_acme-challenge.www`) and what DNS shows for each right now. When a TXT value
|
|
219
219
|
is missing, add it as its own TXT record; other TXT values on that name can stay. The root of
|
|
220
|
-
your domain (`@`) cannot hold a CNAME, so a root domain is shown as an `ALIAS` record
|
|
220
|
+
your domain (`@`) cannot hold a CNAME, so a root domain is shown as an `ALIAS / ANAME` record when
|
|
221
|
+
your DNS host is not one the CLI recognises (a recognised host's own name for it otherwise): add it as
|
|
221
222
|
ALIAS or ANAME (or a CNAME your provider flattens). A correct ALIAS answers with addresses, which
|
|
222
223
|
cannot be compared from outside, so that row follows the platform's own routing check. Then:
|
|
223
224
|
|
|
@@ -242,19 +243,50 @@ saying whether they are the certificate authority's),
|
|
|
242
243
|
`issuing_certificate`, `failed` or `expired`. Each record's `ok` is `null` when the check cannot
|
|
243
244
|
say, and its `check` says why (`not_checked`, or `apex_addresses`). A record whose value was found
|
|
244
245
|
at the name with the domain typed twice (`_acme-challenge.www.example.com.example.com`) has
|
|
245
|
-
`check: "doubled"` and `doubledAt`: enter only the short name shown.
|
|
246
|
+
`check: "doubled"` and `doubledAt`: enter only the short name shown. Since 0.79.0, against a server
|
|
247
|
+
that sends them: a record answered by a wildcard record of the DNS host (`*.example.com`) rather
|
|
248
|
+
than one of its own has `check: "wildcard"` and `wildcard`, and the row says to add a record at that
|
|
249
|
+
name (the wildcard can stay; a TXT value is always added beside the values already there);
|
|
250
|
+
`resolvers` lists what each public resolver answered when two were asked, and when one shows the
|
|
251
|
+
expected value and another does not, the record is `check: "resolvers_disagree"` with `ok: null`:
|
|
252
|
+
neither cache proves which answer is current, so confirm the record at your DNS host and check again.
|
|
253
|
+
A record that does not match carries `ttlSeconds`, how long that answer may stay cached. A root
|
|
254
|
+
answering with addresses while routing is pending asks you to confirm the ALIAS or ANAME target,
|
|
255
|
+
since a parking page answers with addresses too. With every record found, the text names the
|
|
256
|
+
platform's next automatic check from `next.nextCheckAt`. `advisories` lists hints that
|
|
246
257
|
the hostname may be a typo, such as a domain that is not in DNS; the domain is added either way.
|
|
247
258
|
Since 0.78.0 each record also carries `typeAtProvider`, `nameAtProvider` (`""` means leave the Host
|
|
248
259
|
field empty) and `valueAtProvider`: the record as a recognised DNS host's editor wants it.
|
|
249
260
|
`typeAtProvider` is `null` for a root record the host cannot hold (GoDaddy, Route 53, IONOS, Wix);
|
|
250
261
|
the text output then suggests connecting `www` instead. `list`, `status` and `wait`
|
|
251
|
-
only read; `add`, `check`, `restart` and `
|
|
262
|
+
only read; `add`, `check`, `restart`, `remove` and `move` need permission to configure the app.
|
|
263
|
+
|
|
264
|
+
`9pm domains remove` prints only the records the API says the domain used, each to delete only if
|
|
265
|
+
its value is unchanged: a name that now holds a different value (the domain points somewhere new, or
|
|
266
|
+
another service's certificate check uses `_acme-challenge`) is not ours, so leave it. Against a server
|
|
267
|
+
that sends no such list it prints no deletion advice at all. When the removal is still finishing
|
|
268
|
+
upstream (the API answers `202` with `releasing: true`), it says so, names no record to delete, and
|
|
269
|
+
prints the `9pm domains list` command: once the domain is no longer listed, it has stopped serving
|
|
270
|
+
from 9pm. A removal that finishes in the background returns no record list, so the CLI cannot say
|
|
271
|
+
which DNS records to delete for it. Such a domain shows as `removing`
|
|
272
|
+
in `list` and `status` (`"removing": true` in `--json`), and `wait` on it exits `20`. Since 0.80.0.
|
|
273
|
+
|
|
274
|
+
`9pm domains move <hostname> --to <app>` moves a domain to another app of the same account. It keeps
|
|
275
|
+
its certificate and the DNS records already published, so nothing is validated again. Without a
|
|
276
|
+
source app the CLI looks it up in the account; name it (`9pm domains move <app> <hostname> --to
|
|
277
|
+
<app>`) to skip that. Each refusal is printed with what to do: the domain or the app is not found,
|
|
278
|
+
the destination is at its custom-domain cap, a removal of the domain is still finishing, the domain
|
|
279
|
+
was disconnected under a service-token app or was never provisioned (remove it and add it to the
|
|
280
|
+
other app), or the destination is in another account. `remove --json` and `move --json` print the
|
|
281
|
+
API's answer as it came. Since 0.80.0.
|
|
252
282
|
|
|
253
283
|
Every `9pm deploy` lists the app's custom domains under its `Deployed` line: `Also serving at
|
|
254
284
|
https://<domain>` for a live one, and for any other its state and the `9pm domains status`
|
|
255
285
|
command to run. If the domains cannot be read within a few seconds, the deploy says so in one
|
|
256
286
|
line instead; the deploy itself is never held back by it. `9pm delete` names the custom domains
|
|
257
|
-
it removed.
|
|
287
|
+
it removed and, since 0.80.0, the records the API says each one used (under the same
|
|
288
|
+
value-unchanged rule); a domain still being released is reported as such, with no records to delete,
|
|
289
|
+
and none can be listed for it later.
|
|
258
290
|
|
|
259
291
|
## Commands
|
|
260
292
|
|
|
@@ -283,7 +315,8 @@ it removed.
|
|
|
283
315
|
9pm domains check <app> <hostname> [--json]
|
|
284
316
|
9pm domains wait <app> <hostname> [--timeout 30m] [--interval 20s] [--json]
|
|
285
317
|
9pm domains restart <app> <hostname> # only after a certificate failed; record values may change
|
|
286
|
-
9pm domains remove <app> <hostname> --confirm <hostname>
|
|
318
|
+
9pm domains remove <app> <hostname> --confirm <hostname> [--json]
|
|
319
|
+
9pm domains move [<app>] <hostname> --to <app> [--json]
|
|
287
320
|
9pm access show <site>
|
|
288
321
|
9pm access public <site> [--switch-mode]
|
|
289
322
|
9pm access otp <site> [--switch-mode]
|
package/dist/dns-providers.d.ts
CHANGED
|
@@ -53,6 +53,12 @@ export interface DnsProviderProfile {
|
|
|
53
53
|
/** The editor makes a hostname value relative unless it ends in a dot (Gandi). */
|
|
54
54
|
valueNeedsTrailingDot?: true;
|
|
55
55
|
notes: DnsProviderNote[];
|
|
56
|
+
/**
|
|
57
|
+
* The values the host's own help pages name as its default parking records. A routing record
|
|
58
|
+
* whose answer is one of these is the host's parking page, and only then is it named as such:
|
|
59
|
+
* knowing the host does not say what any one record at it is for.
|
|
60
|
+
*/
|
|
61
|
+
parkingTargets?: string[];
|
|
56
62
|
source: string;
|
|
57
63
|
}
|
|
58
64
|
export interface DnsProvider {
|
|
@@ -89,3 +95,27 @@ export declare function recordAtProvider(record: {
|
|
|
89
95
|
value: string;
|
|
90
96
|
apex: boolean;
|
|
91
97
|
}, profile: DnsProviderProfile | undefined): RecordAtProvider;
|
|
98
|
+
/** A DNS host's own parking record, as the live DNS check saw it. */
|
|
99
|
+
export interface ParkingRecord {
|
|
100
|
+
/** The DNS host's display name. */
|
|
101
|
+
provider: string;
|
|
102
|
+
/** The parking value the record answered with. */
|
|
103
|
+
value: string;
|
|
104
|
+
/** Set when the answer came from a wildcard (`*.<parent>`): that wildcard is the parking record, the name has none. */
|
|
105
|
+
wildcard?: string;
|
|
106
|
+
}
|
|
107
|
+
/**
|
|
108
|
+
* Whether a routing record's answer is the DNS host's own default parking record. Every one of
|
|
109
|
+
* these must hold: the host is recognised and its help pages name the value (`parkingTargets`);
|
|
110
|
+
* the record is a CNAME, since a TXT or MX value is never a website record; the check found the
|
|
111
|
+
* record different from ours or answered by a wildcard (`check` as domain-state derives it, so
|
|
112
|
+
* a disagreement between resolvers, a doubled name or a root answering with addresses is never
|
|
113
|
+
* read as parking); and every value the check saw is that parking value. A value the owner set
|
|
114
|
+
* themselves at the same name, or a host the check did not recognise, gives null.
|
|
115
|
+
*/
|
|
116
|
+
export declare function parkingRecordFor(providerId: unknown, record: {
|
|
117
|
+
type: unknown;
|
|
118
|
+
check: unknown;
|
|
119
|
+
current: unknown;
|
|
120
|
+
wildcard?: unknown;
|
|
121
|
+
}): ParkingRecord | null;
|
package/dist/dns-providers.js
CHANGED
|
@@ -77,9 +77,23 @@ export const DNS_PROVIDERS = [
|
|
|
77
77
|
id: "porkbun",
|
|
78
78
|
name: "Porkbun",
|
|
79
79
|
nameservers: [/(^|\.)porkbun\.com$/],
|
|
80
|
-
// Documented: host blank for the root, "enter only the subdomain"
|
|
80
|
+
// Documented: host blank for the root, "enter only the subdomain"; the record editor is
|
|
81
|
+
// reached from ACCOUNT > Domain Management, then the domain's "DNS" button (231) or
|
|
82
|
+
// "Details" > "DNS Records" (68). Rechecked 2026-10-14.
|
|
81
83
|
// https://kb.porkbun.com/article/231-how-to-add-dns-records-on-porkbun
|
|
82
|
-
|
|
84
|
+
// https://kb.porkbun.com/article/68-how-do-i-edit-dns
|
|
85
|
+
// Parking: "These records will have a value of 'pixie.porkbun.com'. They're in control of the
|
|
86
|
+
// default parking page", and Porkbun says to delete them once the site is hosted elsewhere.
|
|
87
|
+
// The page names neither record types nor hosts, so only the value is used. uixie.porkbun.com
|
|
88
|
+
// has been seen answering too, but no Porkbun page names it, so it is not listed.
|
|
89
|
+
// https://kb.porkbun.com/article/239-cname-alias-record-with-that-host-already-exists-error
|
|
90
|
+
profile: {
|
|
91
|
+
apex: { support: "alias", typeLabel: "ALIAS", hostField: "blank" },
|
|
92
|
+
appendsZone: true,
|
|
93
|
+
notes: [{ id: "porkbunDnsScreen", verified: true }],
|
|
94
|
+
parkingTargets: ["pixie.porkbun.com"],
|
|
95
|
+
source: DNS_PROVIDER_SOURCE,
|
|
96
|
+
},
|
|
83
97
|
},
|
|
84
98
|
{
|
|
85
99
|
id: "namecom",
|
|
@@ -192,3 +206,36 @@ export function recordAtProvider(record, profile) {
|
|
|
192
206
|
return { type: null, name: apex.hostField === "blank" ? "" : "@", value };
|
|
193
207
|
return { type: apex.typeLabel, name: apex.hostField === "blank" ? "" : "@", value };
|
|
194
208
|
}
|
|
209
|
+
/**
|
|
210
|
+
* Whether a routing record's answer is the DNS host's own default parking record. Every one of
|
|
211
|
+
* these must hold: the host is recognised and its help pages name the value (`parkingTargets`);
|
|
212
|
+
* the record is a CNAME, since a TXT or MX value is never a website record; the check found the
|
|
213
|
+
* record different from ours or answered by a wildcard (`check` as domain-state derives it, so
|
|
214
|
+
* a disagreement between resolvers, a doubled name or a root answering with addresses is never
|
|
215
|
+
* read as parking); and every value the check saw is that parking value. A value the owner set
|
|
216
|
+
* themselves at the same name, or a host the check did not recognise, gives null.
|
|
217
|
+
*/
|
|
218
|
+
export function parkingRecordFor(providerId, record) {
|
|
219
|
+
if (typeof providerId !== "string" || providerId === "")
|
|
220
|
+
return null;
|
|
221
|
+
const provider = DNS_PROVIDERS.find((entry) => entry.id === providerId);
|
|
222
|
+
const targets = provider?.profile?.parkingTargets;
|
|
223
|
+
if (!provider || !targets || targets.length === 0)
|
|
224
|
+
return null;
|
|
225
|
+
if (typeof record.type !== "string" || record.type.toUpperCase() !== "CNAME")
|
|
226
|
+
return null;
|
|
227
|
+
if (record.check !== "different" && record.check !== "wildcard")
|
|
228
|
+
return null;
|
|
229
|
+
if (!Array.isArray(record.current) || record.current.length === 0)
|
|
230
|
+
return null;
|
|
231
|
+
const seen = record.current.map((value) => (typeof value === "string" ? value.trim().toLowerCase().replace(/\.$/, "") : null));
|
|
232
|
+
const first = seen[0];
|
|
233
|
+
if (typeof first !== "string" || !targets.includes(first) || seen.some((value) => value !== first))
|
|
234
|
+
return null;
|
|
235
|
+
if (record.check === "wildcard") {
|
|
236
|
+
if (typeof record.wildcard !== "string" || record.wildcard === "")
|
|
237
|
+
return null;
|
|
238
|
+
return { provider: provider.name, value: first, wildcard: record.wildcard };
|
|
239
|
+
}
|
|
240
|
+
return { provider: provider.name, value: first };
|
|
241
|
+
}
|
package/dist/domain-state.d.ts
CHANGED
|
@@ -29,16 +29,32 @@ export interface DomainStateDnsCheck {
|
|
|
29
29
|
nameservers: string[];
|
|
30
30
|
/** The zone the records go in, as the check found it. Absent from older servers and when no name answered NS. */
|
|
31
31
|
zone?: string;
|
|
32
|
-
records:
|
|
33
|
-
type: string;
|
|
34
|
-
host: string;
|
|
35
|
-
expected: string;
|
|
36
|
-
current: string[];
|
|
37
|
-
matches: boolean;
|
|
38
|
-
doubledAt?: string;
|
|
39
|
-
}>;
|
|
32
|
+
records: DomainStateDnsRecord[];
|
|
40
33
|
caa?: DomainStateCaaCheck;
|
|
41
34
|
}
|
|
35
|
+
/** What one public resolver answered for a record (#1991). */
|
|
36
|
+
export interface DomainStateResolverAnswer {
|
|
37
|
+
resolver: string;
|
|
38
|
+
current: string[];
|
|
39
|
+
matches: boolean;
|
|
40
|
+
/** Seconds it may go on serving this answer from its cache. */
|
|
41
|
+
ttlSeconds?: number;
|
|
42
|
+
}
|
|
43
|
+
export interface DomainStateDnsRecord {
|
|
44
|
+
type: string;
|
|
45
|
+
host: string;
|
|
46
|
+
expected: string;
|
|
47
|
+
current: string[];
|
|
48
|
+
matches: boolean;
|
|
49
|
+
/** The value is published at `<host>.<zone>` instead, the provider having added the zone twice. */
|
|
50
|
+
doubledAt?: string;
|
|
51
|
+
/** The answer is a wildcard's (`*.<parent>`): the name has no record of its own. */
|
|
52
|
+
wildcard?: string;
|
|
53
|
+
/** Seconds `current` may still be served from a resolver's cache; set only when it does not match. */
|
|
54
|
+
ttlSeconds?: number;
|
|
55
|
+
/** Each public resolver's answer, when a second one was asked. `matches` above is the first resolver's, so a disagreement is never a match. */
|
|
56
|
+
resolvers?: DomainStateResolverAnswer[];
|
|
57
|
+
}
|
|
42
58
|
/** What the derivation reads: a domain as any of the domain endpoints returns it. */
|
|
43
59
|
export interface DomainStateInput {
|
|
44
60
|
hostname: string;
|
|
@@ -53,7 +69,7 @@ export interface DomainStateInput {
|
|
|
53
69
|
providerMessageSource?: string | null;
|
|
54
70
|
}
|
|
55
71
|
export type DomainState = "live" | "waiting_for_dns" | "dns_unverified" | "issuing_certificate" | "failed" | "expired";
|
|
56
|
-
export type DomainRecordCheck = "found" | "missing" | "different" | "doubled" | "not_checked" | "apex_addresses";
|
|
72
|
+
export type DomainRecordCheck = "found" | "missing" | "different" | "doubled" | "wildcard" | "resolvers_disagree" | "not_checked" | "apex_addresses";
|
|
57
73
|
export interface DomainRecordStatus {
|
|
58
74
|
type: string;
|
|
59
75
|
name: string;
|
|
@@ -63,10 +79,23 @@ export interface DomainRecordStatus {
|
|
|
63
79
|
ok: boolean | null;
|
|
64
80
|
check: DomainRecordCheck;
|
|
65
81
|
doubledAt?: string;
|
|
82
|
+
wildcard?: string;
|
|
83
|
+
ttlSeconds?: number;
|
|
84
|
+
resolvers?: DomainStateResolverAnswer[];
|
|
66
85
|
apex: boolean;
|
|
67
86
|
optional: boolean;
|
|
68
87
|
}
|
|
69
88
|
export declare function domainRecordStatuses(domain: DomainStateInput): DomainRecordStatus[];
|
|
89
|
+
export declare function domainRecordCheck(seen: DomainStateDnsRecord | undefined, atApex: boolean): DomainRecordCheck;
|
|
90
|
+
/**
|
|
91
|
+
* Each resolver's answer as the server reported it, with malformed entries dropped, so a bad entry
|
|
92
|
+
* neither breaks a row nor counts towards a disagreement.
|
|
93
|
+
*/
|
|
94
|
+
export declare function resolverAnswers(seen: DomainStateDnsRecord | undefined): DomainStateResolverAnswer[];
|
|
95
|
+
/** Whether one resolver shows the expected value while another shows something else. */
|
|
96
|
+
export declare function resolversDisagree(seen: DomainStateDnsRecord | undefined): boolean;
|
|
97
|
+
/** A cache's time left in whole minutes, at least one: "up to N min". */
|
|
98
|
+
export declare function cacheMinutes(ttlSeconds: number): number;
|
|
70
99
|
/**
|
|
71
100
|
* The `www.<domain>` to connect instead, when this hostname is a bare domain whose DNS host
|
|
72
101
|
* cannot point a root at a hostname at all (#1996): GoDaddy, Route 53, IONOS, Wix. Read only
|
|
@@ -85,7 +114,7 @@ export declare function showsUseWww(next: {
|
|
|
85
114
|
action?: string;
|
|
86
115
|
} | null | undefined): boolean;
|
|
87
116
|
export declare function deriveDomainState(domain: DomainStateInput, records?: DomainRecordStatus[]): DomainState;
|
|
88
|
-
export type DomainReasonCode = "records_missing" | "record_points_elsewhere" | "dns_changed_after_live" | "dns_not_checked" | "apex_unverifiable" | "dns_host_cannot_point_root" | "caa_blocks" | "certificate_pending" | "certificate_failed" | "hostname_failed" | "service_mode" | "claimed_elsewhere" | "platform_not_configured" | "provider_error" | "expired";
|
|
117
|
+
export type DomainReasonCode = "records_missing" | "record_points_elsewhere" | "dns_changed_after_live" | "dns_not_checked" | "apex_unverifiable" | "dns_resolvers_disagree" | "dns_host_cannot_point_root" | "caa_blocks" | "certificate_pending" | "certificate_failed" | "hostname_failed" | "service_mode" | "claimed_elsewhere" | "platform_not_configured" | "provider_error" | "expired";
|
|
89
118
|
export interface DomainReason {
|
|
90
119
|
code: DomainReasonCode;
|
|
91
120
|
/**
|
package/dist/domain-state.js
CHANGED
|
@@ -49,15 +49,20 @@ export function domainRecordStatuses(domain) {
|
|
|
49
49
|
const seen = domain.dnsCheck?.records.find((entry) => entry.type.toUpperCase() === type && normalizeDnsName(entry.host) === fqdn && entry.expected === expected);
|
|
50
50
|
const atApex = type === "CNAME" && fqdn === apex;
|
|
51
51
|
const check = domainRecordCheck(seen, atApex);
|
|
52
|
+
const resolvers = resolverAnswers(seen);
|
|
53
|
+
const ttl = seen?.ttlSeconds;
|
|
52
54
|
return {
|
|
53
55
|
type,
|
|
54
56
|
name: relativeRecordName(fqdn, apex),
|
|
55
57
|
fqdn,
|
|
56
58
|
expected: instruction.value,
|
|
57
59
|
current: seen ? seen.current : null,
|
|
58
|
-
ok: check === "found" ? true : check === "missing" || check === "different" || check === "doubled" ? false : null,
|
|
60
|
+
ok: check === "found" ? true : check === "missing" || check === "different" || check === "doubled" || check === "wildcard" ? false : null,
|
|
59
61
|
check,
|
|
60
62
|
...(check === "doubled" && seen?.doubledAt ? { doubledAt: seen.doubledAt } : {}),
|
|
63
|
+
...(check === "wildcard" && seen?.wildcard ? { wildcard: seen.wildcard } : {}),
|
|
64
|
+
...(check !== "found" && typeof ttl === "number" && Number.isFinite(ttl) && ttl >= 0 ? { ttlSeconds: ttl } : {}),
|
|
65
|
+
...(resolvers.length > 0 ? { resolvers } : {}),
|
|
61
66
|
apex: atApex,
|
|
62
67
|
optional: instruction.optional === true,
|
|
63
68
|
};
|
|
@@ -67,10 +72,14 @@ const IP_ADDRESS = /^(\d{1,3}(\.\d{1,3}){3}|[0-9a-f:]*:[0-9a-f:.]+)$/i;
|
|
|
67
72
|
// The server looks a CNAME up and, finding none, falls back to the name's A records. At the
|
|
68
73
|
// zone root that is exactly what a correct ALIAS/ANAME or flattened CNAME answers: addresses,
|
|
69
74
|
// never the target name. So addresses at the apex are not evidence of a wrong record; they
|
|
70
|
-
// cannot be compared from outside at all.
|
|
71
|
-
|
|
75
|
+
// cannot be compared from outside at all. Exported so a client that renders records without a
|
|
76
|
+
// full status (the dashboard's table) reads the same verdict per row.
|
|
77
|
+
export function domainRecordCheck(seen, atApex) {
|
|
72
78
|
if (!seen)
|
|
73
79
|
return "not_checked";
|
|
80
|
+
// Ahead of `matches`, so no shape of answer turns a disagreement between caches into "found".
|
|
81
|
+
if (resolversDisagree(seen))
|
|
82
|
+
return "resolvers_disagree";
|
|
74
83
|
if (seen.matches)
|
|
75
84
|
return "found";
|
|
76
85
|
if (seen.doubledAt)
|
|
@@ -79,8 +88,37 @@ function domainRecordCheck(seen, atApex) {
|
|
|
79
88
|
return "missing";
|
|
80
89
|
if (atApex && seen.current.every((value) => IP_ADDRESS.test(value.trim())))
|
|
81
90
|
return "apex_addresses";
|
|
91
|
+
if (typeof seen.wildcard === "string" && seen.wildcard !== "")
|
|
92
|
+
return "wildcard";
|
|
82
93
|
return "different";
|
|
83
94
|
}
|
|
95
|
+
/**
|
|
96
|
+
* Each resolver's answer as the server reported it, with malformed entries dropped, so a bad entry
|
|
97
|
+
* neither breaks a row nor counts towards a disagreement.
|
|
98
|
+
*/
|
|
99
|
+
export function resolverAnswers(seen) {
|
|
100
|
+
if (!seen || !Array.isArray(seen.resolvers))
|
|
101
|
+
return [];
|
|
102
|
+
return seen.resolvers.flatMap((answer) => {
|
|
103
|
+
if (answer === null || typeof answer !== "object")
|
|
104
|
+
return [];
|
|
105
|
+
const entry = answer;
|
|
106
|
+
if (typeof entry.resolver !== "string" || typeof entry.matches !== "boolean" || !Array.isArray(entry.current))
|
|
107
|
+
return [];
|
|
108
|
+
const current = entry.current.filter((value) => typeof value === "string");
|
|
109
|
+
const ttl = entry.ttlSeconds;
|
|
110
|
+
return [{ resolver: entry.resolver, current, matches: entry.matches, ...(typeof ttl === "number" && Number.isFinite(ttl) && ttl >= 0 ? { ttlSeconds: ttl } : {}) }];
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
/** Whether one resolver shows the expected value while another shows something else. */
|
|
114
|
+
export function resolversDisagree(seen) {
|
|
115
|
+
const answers = resolverAnswers(seen);
|
|
116
|
+
return answers.some((answer) => answer.matches) && answers.some((answer) => !answer.matches);
|
|
117
|
+
}
|
|
118
|
+
/** A cache's time left in whole minutes, at least one: "up to N min". */
|
|
119
|
+
export function cacheMinutes(ttlSeconds) {
|
|
120
|
+
return Math.max(1, Math.ceil(ttlSeconds / 60));
|
|
121
|
+
}
|
|
84
122
|
// Unproven is decided by `ok`, not by listing check values: a check this code does not know yet
|
|
85
123
|
// carries ok null and so reads as unknown, never as found. The one exception is an apex
|
|
86
124
|
// answering with addresses, which the platform's own active routing vouches for.
|
|
@@ -232,7 +270,14 @@ export function deriveDomainStatus(domain, options = {}) {
|
|
|
232
270
|
}
|
|
233
271
|
const routingActive = domain.routingStatus === "active";
|
|
234
272
|
const unproven = required.filter((record) => isUnproven(record, routingActive));
|
|
235
|
-
|
|
273
|
+
// A disagreement is named only when every unproven record is one, or an apex that cannot be
|
|
274
|
+
// compared from outside: a record nobody could read still makes it "not checked".
|
|
275
|
+
const explained = unproven.length > 0 && unproven.every((record) => record.check === "apex_addresses" || record.check === "resolvers_disagree");
|
|
276
|
+
const code = !explained
|
|
277
|
+
? "dns_not_checked"
|
|
278
|
+
: unproven.some((record) => record.check === "resolvers_disagree")
|
|
279
|
+
? "dns_resolvers_disagree"
|
|
280
|
+
: "apex_unverifiable";
|
|
236
281
|
return {
|
|
237
282
|
state,
|
|
238
283
|
reason: withMessage(code),
|
package/dist/index.d.ts
CHANGED
|
@@ -108,6 +108,8 @@ export interface DeletedSiteResult {
|
|
|
108
108
|
workers: number;
|
|
109
109
|
customDomains: number;
|
|
110
110
|
customDomainNames?: string[];
|
|
111
|
+
customDomainCleanup?: unknown;
|
|
112
|
+
customDomainsReleasing?: unknown;
|
|
111
113
|
containerApplications?: number;
|
|
112
114
|
containerImages?: number;
|
|
113
115
|
accessApps: number;
|
|
@@ -118,6 +120,7 @@ export interface DeletedSiteResult {
|
|
|
118
120
|
metadata: boolean;
|
|
119
121
|
errors?: string[];
|
|
120
122
|
}
|
|
123
|
+
export declare function deletedDomainLines(deleted: Pick<DeletedSiteResult, "customDomainCleanup" | "customDomainsReleasing">): string[];
|
|
121
124
|
export interface CreatedRuntimeToken {
|
|
122
125
|
token: string;
|
|
123
126
|
appAccessToken: AppAccessToken;
|
|
@@ -127,7 +130,7 @@ export interface CreatedServiceToken {
|
|
|
127
130
|
clientSecret: string;
|
|
128
131
|
}
|
|
129
132
|
export type FetchLike = (input: string | URL | Request, init?: RequestInit) => Promise<Response>;
|
|
130
|
-
export declare const CLI_VERSION = "0.
|
|
133
|
+
export declare const CLI_VERSION = "0.81.0";
|
|
131
134
|
type CredentialBackend = "macos-keychain" | "windows-dpapi" | "linux-secret-service" | "file-plaintext" | "none";
|
|
132
135
|
export interface StoredCredentialEntry {
|
|
133
136
|
token: string;
|
|
@@ -649,6 +652,13 @@ type CliCommand = {
|
|
|
649
652
|
kind: "remove";
|
|
650
653
|
site: string;
|
|
651
654
|
hostname: string;
|
|
655
|
+
json: boolean;
|
|
656
|
+
} | {
|
|
657
|
+
kind: "move";
|
|
658
|
+
site?: string;
|
|
659
|
+
hostname: string;
|
|
660
|
+
to: string;
|
|
661
|
+
json: boolean;
|
|
652
662
|
};
|
|
653
663
|
apiUrl: string;
|
|
654
664
|
token: string;
|
|
@@ -753,6 +763,8 @@ export interface CliDomain extends DomainStateInput {
|
|
|
753
763
|
state?: string;
|
|
754
764
|
reason?: DomainReason | null;
|
|
755
765
|
next?: DomainNext;
|
|
766
|
+
releasingAt?: string | null;
|
|
767
|
+
releaseIntent?: string | null;
|
|
756
768
|
}
|
|
757
769
|
export interface DomainRecordStatusJson extends DomainRecordStatus {
|
|
758
770
|
typeAtProvider: string | null;
|
|
@@ -779,6 +791,7 @@ export interface DomainStatusJson {
|
|
|
779
791
|
checkedAt: string;
|
|
780
792
|
reason: DomainReason | null;
|
|
781
793
|
next: DomainNext;
|
|
794
|
+
removing: boolean;
|
|
782
795
|
}
|
|
783
796
|
export declare const DOMAIN_WAIT_EXIT: {
|
|
784
797
|
readonly live: 0;
|
|
@@ -789,6 +802,7 @@ export declare const DOMAIN_WAIT_EXIT: {
|
|
|
789
802
|
export declare function domainStatusJson(domain: CliDomain, app: string, checkedAt: string): DomainStatusJson;
|
|
790
803
|
export declare function domainStateLabel(state: string): string;
|
|
791
804
|
export declare function domainWaitExitCode(status: Pick<DomainStatusJson, "state" | "next">): number;
|
|
805
|
+
export declare function nextCheckText(nextCheckAt: unknown): string | null;
|
|
792
806
|
export interface DomainFormatOptions {
|
|
793
807
|
owner?: string;
|
|
794
808
|
target?: AccessApiTarget;
|
|
@@ -800,7 +814,8 @@ export declare function formatDomainsList(statuses: DomainStatusJson[]): string[
|
|
|
800
814
|
export declare class DomainsApiError extends Error {
|
|
801
815
|
readonly status: number;
|
|
802
816
|
readonly code: string | null;
|
|
803
|
-
|
|
817
|
+
readonly reason: string | null;
|
|
818
|
+
constructor(message: string, status: number, code: string | null, reason?: string | null);
|
|
804
819
|
}
|
|
805
820
|
export declare function domainsApiError(status: number, text: string): DomainsApiError;
|
|
806
821
|
export interface DomainCommandOptions extends ApiOptions {
|
|
@@ -829,9 +844,48 @@ export declare function restartDomain(options: DomainCommandOptions & {
|
|
|
829
844
|
domain: CliDomain;
|
|
830
845
|
replacedInstructions: CliDnsInstruction[];
|
|
831
846
|
}>;
|
|
847
|
+
export interface DomainRemoval {
|
|
848
|
+
body: unknown;
|
|
849
|
+
hostname: string;
|
|
850
|
+
releasing: boolean;
|
|
851
|
+
cleanup: DomainCleanup | null;
|
|
852
|
+
}
|
|
832
853
|
export declare function removeDomain(options: DomainCommandOptions & {
|
|
833
854
|
hostname: string;
|
|
834
|
-
}): Promise<
|
|
855
|
+
}): Promise<DomainRemoval>;
|
|
856
|
+
export declare function removalFromResponse(status: number, body: unknown, requested: string): DomainRemoval;
|
|
857
|
+
export interface DomainCleanupRecord {
|
|
858
|
+
type: string;
|
|
859
|
+
host: string;
|
|
860
|
+
value: string;
|
|
861
|
+
purpose: string;
|
|
862
|
+
}
|
|
863
|
+
export interface DomainCleanup {
|
|
864
|
+
hostname: string;
|
|
865
|
+
records: DomainCleanupRecord[];
|
|
866
|
+
}
|
|
867
|
+
export declare function parseDomainCleanup(value: unknown): DomainCleanup | null;
|
|
868
|
+
export declare function domainCleanupLines(cleanup: DomainCleanup): string[];
|
|
869
|
+
export declare function domainRemovalPendingLines(hostname: string, app: string, options: DomainFormatOptions): string[];
|
|
870
|
+
export interface DomainMove {
|
|
871
|
+
body: unknown;
|
|
872
|
+
hostname: string;
|
|
873
|
+
from: string | null;
|
|
874
|
+
to: string | null;
|
|
875
|
+
}
|
|
876
|
+
export declare function moveDomain(options: DomainCommandOptions & {
|
|
877
|
+
hostname: string;
|
|
878
|
+
to: string;
|
|
879
|
+
}): Promise<DomainMove>;
|
|
880
|
+
export declare function findDomainApp(options: ApiOptions & {
|
|
881
|
+
owner?: string;
|
|
882
|
+
hostname: string;
|
|
883
|
+
}): Promise<string | null>;
|
|
884
|
+
export declare function domainMoveRefusalLines(error: DomainsApiError, move: {
|
|
885
|
+
site: string;
|
|
886
|
+
hostname: string;
|
|
887
|
+
to: string;
|
|
888
|
+
}, options: DomainFormatOptions): string[];
|
|
835
889
|
export declare const DEPLOY_DOMAINS_TIMEOUT_MS = 4500;
|
|
836
890
|
export declare function deployCustomDomainStatuses(options: ApiOptions & {
|
|
837
891
|
projectId: string;
|
package/dist/index.js
CHANGED
|
@@ -14,8 +14,8 @@ import { MAX_DEPLOY_DESCRIPTION_LENGTH, normalizeDeployDescription } from "./dep
|
|
|
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
16
|
import { guessZoneApex, relativeRecordName, zoneApexFor } from "./dns-host.js";
|
|
17
|
-
import { dnsProviderProfile, recordAtProvider } from "./dns-providers.js";
|
|
18
|
-
import { deriveDomainStatus, domainRecordStatuses, showsUseWww, } from "./domain-state.js";
|
|
17
|
+
import { dnsProviderProfile, parkingRecordFor, recordAtProvider } from "./dns-providers.js";
|
|
18
|
+
import { cacheMinutes, deriveDomainStatus, domainRecordStatuses, showsUseWww, } from "./domain-state.js";
|
|
19
19
|
import { buildFileManifest, readManifestFile } from "./manifest.js";
|
|
20
20
|
import { NO_PUBLIC_ASSETS, normalizeAssetsDirectory, privateLookingPaths, publicAssetPath } from "./public-assets.js";
|
|
21
21
|
import { API_KEY_HELP, API_URL_HELP, CLI_CLIENT_ID, DEFAULT_API_URL, reportCliError } from "./cli-config.js";
|
|
@@ -52,7 +52,33 @@ export function deployDatabaseClause(databases) {
|
|
|
52
52
|
const listed = databases.map((database) => (database.name ? `${database.bindingName}:${database.name}` : database.bindingName)).join(", ");
|
|
53
53
|
return `${databases.length === 1 ? "database" : "databases"} ${listed}`;
|
|
54
54
|
}
|
|
55
|
-
|
|
55
|
+
// What `9pm delete` says about the app's custom domains beyond the count: the records the API
|
|
56
|
+
// says each removed one used, under the same value-conditional rule as `domains remove`, and the
|
|
57
|
+
// ones still being released, which get no deletion advice. Never throws: the delete has already
|
|
58
|
+
// happened, so a malformed entry costs that entry's advice, not the command.
|
|
59
|
+
export function deletedDomainLines(deleted) {
|
|
60
|
+
const lines = [];
|
|
61
|
+
try {
|
|
62
|
+
const releasing = Array.isArray(deleted.customDomainsReleasing)
|
|
63
|
+
? deleted.customDomainsReleasing.filter((hostname) => typeof hostname === "string" && hostname !== "")
|
|
64
|
+
: [];
|
|
65
|
+
const cleanups = Array.isArray(deleted.customDomainCleanup) ? deleted.customDomainCleanup.map(parseDomainCleanup) : [];
|
|
66
|
+
for (const cleanup of cleanups) {
|
|
67
|
+
// A domain that is also reported as still releasing keeps its records, whatever else says otherwise.
|
|
68
|
+
if (!cleanup || releasing.includes(cleanup.hostname))
|
|
69
|
+
continue;
|
|
70
|
+
lines.push(...domainCleanupLines(cleanup));
|
|
71
|
+
}
|
|
72
|
+
for (const hostname of releasing) {
|
|
73
|
+
lines.push(`${hostname} has stopped serving, but removing it upstream is still finishing; it finishes on its own. ${DOMAIN_RELEASE_NO_CLEANUP}`);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
catch {
|
|
77
|
+
return lines;
|
|
78
|
+
}
|
|
79
|
+
return lines;
|
|
80
|
+
}
|
|
81
|
+
export const CLI_VERSION = "0.81.0";
|
|
56
82
|
const PUBLIC_DASHBOARD_URL = "https://9pm.ai/dashboard";
|
|
57
83
|
const TOKEN_SERVICE = "9pm";
|
|
58
84
|
const TOKEN_ACCOUNT = "default";
|
|
@@ -3267,8 +3293,12 @@ export function domainStatusJson(domain, app, checkedAt) {
|
|
|
3267
3293
|
checkedAt,
|
|
3268
3294
|
reason: fromServer ? (domain.reason ?? null) : derived.reason,
|
|
3269
3295
|
next: fromServer ? (domain.next ?? derived.next) : derived.next,
|
|
3296
|
+
removing: isRemoving(domain),
|
|
3270
3297
|
};
|
|
3271
3298
|
}
|
|
3299
|
+
function isRemoving(domain) {
|
|
3300
|
+
return typeof domain.releasingAt === "string" && domain.releasingAt !== "" && domain.releaseIntent === "remove";
|
|
3301
|
+
}
|
|
3272
3302
|
const DOMAIN_STATE_LABELS = {
|
|
3273
3303
|
live: "live",
|
|
3274
3304
|
waiting_for_dns: "waiting for DNS records",
|
|
@@ -3328,9 +3358,33 @@ function providerMessageLine(reason) {
|
|
|
3328
3358
|
return `${who} reported (${message.seenAt}): ${message.text}`;
|
|
3329
3359
|
}
|
|
3330
3360
|
const TXT_KEEP_OTHERS = "add this value as its own TXT record; other TXT values can stay";
|
|
3331
|
-
|
|
3361
|
+
// The server's next background check as "HH:MM UTC" (UTC so the line reads the same on any
|
|
3362
|
+
// machine); null when there is none or it is not a date, so no time is promised from it.
|
|
3363
|
+
export function nextCheckText(nextCheckAt) {
|
|
3364
|
+
if (typeof nextCheckAt !== "string" || nextCheckAt === "")
|
|
3365
|
+
return null;
|
|
3366
|
+
const at = new Date(nextCheckAt);
|
|
3367
|
+
return Number.isNaN(at.getTime()) ? null : `${at.toISOString().slice(11, 16)} UTC`;
|
|
3368
|
+
}
|
|
3369
|
+
// "up to N min" for a cache's time left, or nothing when the resolver did not say.
|
|
3370
|
+
function forUpTo(ttlSeconds) {
|
|
3371
|
+
return typeof ttlSeconds === "number" ? ` for up to ${cacheMinutes(ttlSeconds)} min` : "";
|
|
3372
|
+
}
|
|
3373
|
+
function answerText(current) {
|
|
3374
|
+
return current.length > 0 ? current.join(", ") : "nothing";
|
|
3375
|
+
}
|
|
3376
|
+
function recordStateText(record, routing, parking = null) {
|
|
3332
3377
|
if (record.ok === true)
|
|
3333
3378
|
return "found";
|
|
3379
|
+
if (record.check === "resolvers_disagree") {
|
|
3380
|
+
// Two caches that disagree prove neither answer current: no "found", no "replace", and neither answer called old.
|
|
3381
|
+
const answers = record.resolvers ?? [];
|
|
3382
|
+
const seeing = answers.filter((answer) => answer.matches).map((answer) => answer.resolver);
|
|
3383
|
+
const others = answers
|
|
3384
|
+
.filter((answer) => !answer.matches)
|
|
3385
|
+
.map((answer) => `${answer.resolver} sees ${answerText(answer.current)}${typeof answer.ttlSeconds === "number" ? ` (and may keep that answer${forUpTo(answer.ttlSeconds)})` : ""}`);
|
|
3386
|
+
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`;
|
|
3387
|
+
}
|
|
3334
3388
|
if (record.check === "doubled") {
|
|
3335
3389
|
// The same name the NAME column shows, so the hint never contradicts the host's convention.
|
|
3336
3390
|
const name = record.nameAtProvider ?? record.name;
|
|
@@ -3342,17 +3396,41 @@ function recordStateText(record, routing) {
|
|
|
3342
3396
|
if (record.check === "apex_addresses") {
|
|
3343
3397
|
return routing === "active"
|
|
3344
3398
|
? "looks connected: the root answers with addresses, as an ALIAS does, and routing is active"
|
|
3345
|
-
:
|
|
3399
|
+
: // A parking page answers with addresses too, so nothing here says the record is right: the owner confirms it.
|
|
3400
|
+
`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`;
|
|
3346
3401
|
}
|
|
3347
3402
|
if (record.ok === null || record.current === null)
|
|
3348
3403
|
return "not checked: couldn't read your DNS just now";
|
|
3404
|
+
// TXT before wildcard: a name may hold several TXT values another certificate still needs, so a
|
|
3405
|
+
// TXT value is always added beside them, never "changed", whatever answers at the name.
|
|
3349
3406
|
if (record.type === "TXT") {
|
|
3350
|
-
const
|
|
3407
|
+
const from = record.check === "wildcard" && record.wildcard ? ` (from the wildcard ${record.wildcard})` : "";
|
|
3408
|
+
const now = record.current.length === 0 ? "not published yet" : `currently ${record.current.join(", ")}${from}`;
|
|
3351
3409
|
return `${now}: ${TXT_KEEP_OTHERS}`;
|
|
3352
3410
|
}
|
|
3411
|
+
if (record.check === "wildcard") {
|
|
3412
|
+
const name = record.nameAtProvider ?? record.name;
|
|
3413
|
+
// A name holding a CNAME can hold nothing else, so an explicit record equal to the wildcard's answer is changed, not joined.
|
|
3414
|
+
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${parkingText(parking)}`;
|
|
3415
|
+
}
|
|
3353
3416
|
if (record.current.length === 0)
|
|
3354
3417
|
return "not published yet";
|
|
3355
|
-
|
|
3418
|
+
// 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.
|
|
3419
|
+
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)` : "";
|
|
3420
|
+
// Two resolvers can each hold a different old answer; both are shown, as neither is the expected value.
|
|
3421
|
+
const shown = record.current.join(", ");
|
|
3422
|
+
const others = (record.resolvers ?? []).filter((answer) => answer.current.join(", ") !== shown);
|
|
3423
|
+
const also = others.map((answer) => `; ${answer.resolver} answers ${answerText(answer.current)}`).join("");
|
|
3424
|
+
return `currently ${shown}${also}${recent}: point it at the value shown${parkingText(parking)}`;
|
|
3425
|
+
}
|
|
3426
|
+
// Said only when the check saw the host's documented parking value (parkingRecordFor), and
|
|
3427
|
+
// conditionally even then: the record may have been changed since the check.
|
|
3428
|
+
function parkingText(parking) {
|
|
3429
|
+
if (!parking)
|
|
3430
|
+
return "";
|
|
3431
|
+
return parking.wildcard
|
|
3432
|
+
? `. If ${parking.wildcard} still points at ${parking.value}, it is ${parking.provider}'s default parking record and can be removed; the record shown works either way`
|
|
3433
|
+
: `. If this record still points at ${parking.value}, it is ${parking.provider}'s default parking record and can be replaced: that only takes down ${parking.provider}'s parking page`;
|
|
3356
3434
|
}
|
|
3357
3435
|
// A printed command carries --owner and, when the destination is not the built-in default,
|
|
3358
3436
|
// --api-url: pasted into a shell without NINEPM_API_URL it would otherwise act on production.
|
|
@@ -3365,6 +3443,7 @@ function domainCommand(subcommand, app, hostname, options) {
|
|
|
3365
3443
|
export const DNS_PROVIDER_NOTES = {
|
|
3366
3444
|
gandiLiveDns: "At Gandi, an ALIAS works only with LiveDNS, and no A or AAAA record may stay at @ beside it.",
|
|
3367
3445
|
hostingerCnameAtRoot: "At Hostinger, add the root record as a CNAME with name @: Hostinger shows it as ALIAS. A domain can have only one.",
|
|
3446
|
+
porkbunDnsScreen: "At Porkbun, edit these in the domain's own DNS records: ACCOUNT > Domain Management, then DNS next to the domain. Not in a tool that changes several domains at once.",
|
|
3368
3447
|
// Unverified, so never printed; its text is here so a leak would show.
|
|
3369
3448
|
route53TxtQuotes: "In the Route 53 console, a TXT value may need to be entered in quotes.",
|
|
3370
3449
|
};
|
|
@@ -3405,6 +3484,9 @@ function renderColumns(rows) {
|
|
|
3405
3484
|
.trimEnd());
|
|
3406
3485
|
}
|
|
3407
3486
|
export function formatDomainStatus(status, options = {}) {
|
|
3487
|
+
if (status.removing) {
|
|
3488
|
+
return [`${status.hostname} (app ${status.app}): being removed`, ...domainRemovalPendingLines(status.hostname, status.app, options)];
|
|
3489
|
+
}
|
|
3408
3490
|
const lines = [`${status.hostname} (app ${status.app}): ${domainStateLabel(status.state)}`];
|
|
3409
3491
|
if (status.state === "live") {
|
|
3410
3492
|
lines.push(`Serving at https://${status.hostname}`);
|
|
@@ -3446,10 +3528,12 @@ export function formatDomainStatus(status, options = {}) {
|
|
|
3446
3528
|
["TYPE", "NAME", "VALUE", "STATE"],
|
|
3447
3529
|
...shown.map((record) => [
|
|
3448
3530
|
// null: a root the host has no record type for, shown only once routing is already active.
|
|
3449
|
-
record
|
|
3531
|
+
// A host with no profile may call the root record either name, so the table gives both;
|
|
3532
|
+
// --json keeps its single `typeAtProvider` value.
|
|
3533
|
+
record.apex && !profile && record.typeAtProvider === "ALIAS" ? "ALIAS / ANAME" : (record.typeAtProvider ?? "-"),
|
|
3450
3534
|
record.nameAtProvider === "" ? "(leave empty)" : record.nameAtProvider,
|
|
3451
3535
|
record.valueAtProvider,
|
|
3452
|
-
recordStateText(record, status.routing),
|
|
3536
|
+
recordStateText(record, status.routing, profile ? parkingRecordFor(status.providerId, record) : null),
|
|
3453
3537
|
]),
|
|
3454
3538
|
];
|
|
3455
3539
|
lines.push(...renderColumns(rows).map((line) => ` ${line}`));
|
|
@@ -3510,6 +3594,10 @@ export function formatDomainStatus(status, options = {}) {
|
|
|
3510
3594
|
}
|
|
3511
3595
|
else if (status.state === "issuing_certificate") {
|
|
3512
3596
|
lines.push("Every required record is published. The platform finishes on its own, usually within minutes.");
|
|
3597
|
+
const nextCheck = nextCheckText(status.next?.nextCheckAt);
|
|
3598
|
+
// A restart is not offered here: nothing measured says it speeds issuance up.
|
|
3599
|
+
if (nextCheck)
|
|
3600
|
+
lines.push(`The platform checks it again around ${nextCheck} on its own; nothing needs restarting.`);
|
|
3513
3601
|
}
|
|
3514
3602
|
lines.push(`Then run: ${domainCommand("wait", status.app, status.hostname, options)}`);
|
|
3515
3603
|
return lines;
|
|
@@ -3525,12 +3613,16 @@ export function domainWaitTimeoutMessage(status) {
|
|
|
3525
3613
|
return "Timed out: a CAA record stops the certificate from being issued. Change it as described above, then wait again.";
|
|
3526
3614
|
}
|
|
3527
3615
|
if (status.state === "issuing_certificate") {
|
|
3528
|
-
|
|
3616
|
+
const nextCheck = nextCheckText(status.next?.nextCheckAt);
|
|
3617
|
+
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}.` : ""}`;
|
|
3529
3618
|
}
|
|
3530
3619
|
const required = status.records.filter((record) => !record.optional);
|
|
3531
3620
|
if (required.some((record) => record.ok === false)) {
|
|
3532
3621
|
return "Timed out: records above are still missing. Publish them, then wait again.";
|
|
3533
3622
|
}
|
|
3623
|
+
if (required.some((record) => record.check === "resolvers_disagree")) {
|
|
3624
|
+
return "Timed out: public resolvers still disagree about a record above. Make sure your DNS host shows the value shown, then wait again.";
|
|
3625
|
+
}
|
|
3534
3626
|
return "Timed out: the DNS records could not be checked. Make sure they are published as shown, then wait again.";
|
|
3535
3627
|
}
|
|
3536
3628
|
export function formatDomainsList(statuses) {
|
|
@@ -3539,17 +3631,20 @@ export function formatDomainsList(statuses) {
|
|
|
3539
3631
|
}
|
|
3540
3632
|
return renderColumns([
|
|
3541
3633
|
["HOSTNAME", "APP", "STATE", "ROUTING", "TLS"],
|
|
3542
|
-
...statuses.map((status) => [status.hostname, status.app, status.state, status.routing, status.tls]),
|
|
3634
|
+
...statuses.map((status) => [status.hostname, status.app, status.removing ? "removing" : status.state, status.routing, status.tls]),
|
|
3543
3635
|
]);
|
|
3544
3636
|
}
|
|
3545
3637
|
export class DomainsApiError extends Error {
|
|
3546
3638
|
status;
|
|
3547
3639
|
code;
|
|
3548
|
-
|
|
3640
|
+
// The refusal's `details.reason`, where it carries one.
|
|
3641
|
+
reason;
|
|
3642
|
+
constructor(message, status, code, reason = null) {
|
|
3549
3643
|
super(message);
|
|
3550
3644
|
this.name = "DomainsApiError";
|
|
3551
3645
|
this.status = status;
|
|
3552
3646
|
this.code = code;
|
|
3647
|
+
this.reason = reason;
|
|
3553
3648
|
}
|
|
3554
3649
|
}
|
|
3555
3650
|
// The API's own message and code, plus `details.reason` where the refusal carries one
|
|
@@ -3572,9 +3667,14 @@ export function domainsApiError(status, text) {
|
|
|
3572
3667
|
const reason = details && typeof details === "object" && typeof details.reason === "string"
|
|
3573
3668
|
? details.reason
|
|
3574
3669
|
: null;
|
|
3575
|
-
return new DomainsApiError(`${message} (${code}${reason ? `, reason: ${reason}` : ""})`, status, code);
|
|
3670
|
+
return new DomainsApiError(`${message} (${code}${reason ? `, reason: ${reason}` : ""})`, status, code, reason);
|
|
3576
3671
|
}
|
|
3577
3672
|
async function domainsRequest(options, route, init = { method: "GET" }) {
|
|
3673
|
+
return (await domainsResponse(options, route, init)).body;
|
|
3674
|
+
}
|
|
3675
|
+
// The body with the status beside it, for an answer whose status carries meaning of its own
|
|
3676
|
+
// (a removal still finishing answers 202).
|
|
3677
|
+
async function domainsResponse(options, route, init = { method: "GET" }) {
|
|
3578
3678
|
const fetchFn = options.fetchFn ?? fetch;
|
|
3579
3679
|
const headers = new Headers(init.headers);
|
|
3580
3680
|
headers.set("authorization", `Bearer ${options.token}`);
|
|
@@ -3586,7 +3686,7 @@ async function domainsRequest(options, route, init = { method: "GET" }) {
|
|
|
3586
3686
|
if (!response.ok) {
|
|
3587
3687
|
throw domainsApiError(response.status, await response.text());
|
|
3588
3688
|
}
|
|
3589
|
-
return (await response.json());
|
|
3689
|
+
return { status: response.status, body: (await response.json()) };
|
|
3590
3690
|
}
|
|
3591
3691
|
function ownerQuery(owner) {
|
|
3592
3692
|
return owner ? `?owner=${encodeURIComponent(owner)}` : "";
|
|
@@ -3631,10 +3731,177 @@ export async function restartDomain(options) {
|
|
|
3631
3731
|
return domainsRequest(options, siteDomainsRoute(options, normalizeDomainArg(options.hostname), "restart"), { method: "POST" });
|
|
3632
3732
|
}
|
|
3633
3733
|
export async function removeDomain(options) {
|
|
3634
|
-
const
|
|
3635
|
-
|
|
3734
|
+
const requested = normalizeDomainArg(options.hostname);
|
|
3735
|
+
const { status, body } = await domainsResponse(options, siteDomainsRoute(options, requested), { method: "DELETE" });
|
|
3736
|
+
return removalFromResponse(status, body, requested);
|
|
3737
|
+
}
|
|
3738
|
+
// Never throws: the removal has happened by the time this reads the answer, so a malformed body
|
|
3739
|
+
// costs the advice, not the command.
|
|
3740
|
+
export function removalFromResponse(status, body, requested) {
|
|
3741
|
+
try {
|
|
3742
|
+
const fields = body && typeof body === "object" ? body : {};
|
|
3743
|
+
const domain = fields.domain && typeof fields.domain === "object" ? fields.domain : null;
|
|
3744
|
+
const hostname = typeof domain?.hostname === "string" && domain.hostname !== "" ? domain.hostname : requested;
|
|
3745
|
+
const releasing = status === 202 || fields.releasing === true;
|
|
3746
|
+
return { body, hostname, releasing, cleanup: releasing ? null : parseDomainCleanup(fields.cleanup) };
|
|
3747
|
+
}
|
|
3748
|
+
catch {
|
|
3749
|
+
return { body, hostname: requested, releasing: status === 202, cleanup: null };
|
|
3750
|
+
}
|
|
3751
|
+
}
|
|
3752
|
+
// The server's `cleanup` (or one `customDomainCleanup` entry), kept only as far as it is well
|
|
3753
|
+
// formed: a malformed record is dropped, never guessed at, and a malformed whole is null.
|
|
3754
|
+
export function parseDomainCleanup(value) {
|
|
3755
|
+
try {
|
|
3756
|
+
if (!value || typeof value !== "object")
|
|
3757
|
+
return null;
|
|
3758
|
+
const { hostname, records } = value;
|
|
3759
|
+
if (typeof hostname !== "string" || hostname === "" || !Array.isArray(records))
|
|
3760
|
+
return null;
|
|
3761
|
+
const kept = [];
|
|
3762
|
+
for (const record of records) {
|
|
3763
|
+
if (!record || typeof record !== "object")
|
|
3764
|
+
continue;
|
|
3765
|
+
const { type, host, value: recordValue, purpose } = record;
|
|
3766
|
+
if (typeof type !== "string" || typeof host !== "string" || typeof recordValue !== "string" || type === "" || host === "" || recordValue === "")
|
|
3767
|
+
continue;
|
|
3768
|
+
kept.push({ type, host, value: recordValue, purpose: typeof purpose === "string" ? purpose : "" });
|
|
3769
|
+
}
|
|
3770
|
+
return { hostname, records: kept };
|
|
3771
|
+
}
|
|
3772
|
+
catch {
|
|
3773
|
+
return null;
|
|
3774
|
+
}
|
|
3775
|
+
}
|
|
3776
|
+
const CLEANUP_PURPOSES = {
|
|
3777
|
+
routing: "pointed the domain at 9pm",
|
|
3778
|
+
certificate: "validated the certificate",
|
|
3779
|
+
ownership: "proved ownership; only there if you added it",
|
|
3780
|
+
};
|
|
3781
|
+
// The records a finished removal frees, each conditional on its value: a name can hold another
|
|
3782
|
+
// service's record by now (the domain pointed somewhere new, or `_acme-challenge` serving another
|
|
3783
|
+
// certificate), and the value is the only thing that tells ours apart. Nothing here is checked
|
|
3784
|
+
// against live DNS, so nothing is ever called safe to delete outright.
|
|
3785
|
+
export function domainCleanupLines(cleanup) {
|
|
3786
|
+
if (cleanup.records.length === 0) {
|
|
3787
|
+
return [`There are no DNS records of ours to delete for ${cleanup.hostname}.`];
|
|
3788
|
+
}
|
|
3789
|
+
const apex = guessZoneApex(cleanup.hostname);
|
|
3790
|
+
const rows = [
|
|
3791
|
+
["TYPE", "NAME", "VALUE", "WAS FOR"],
|
|
3792
|
+
...cleanup.records.map((record) => [
|
|
3793
|
+
record.type.toUpperCase(),
|
|
3794
|
+
relativeRecordName(record.host, apex),
|
|
3795
|
+
record.value,
|
|
3796
|
+
Object.hasOwn(CLEANUP_PURPOSES, record.purpose) ? (CLEANUP_PURPOSES[record.purpose] ?? "") : "",
|
|
3797
|
+
]),
|
|
3798
|
+
];
|
|
3799
|
+
return [
|
|
3800
|
+
`You can now delete these DNS records for ${cleanup.hostname} where your domain's DNS is managed, each only if its value is unchanged:`,
|
|
3801
|
+
...renderColumns(rows).map((line) => ` ${line}`),
|
|
3802
|
+
"Delete a record only while it still has the value shown. If one of these names now holds a different value, for example because the domain points somewhere new, that record is not ours: leave it.",
|
|
3803
|
+
];
|
|
3804
|
+
}
|
|
3805
|
+
// Said wherever a removal is left to finish in the background: only the API's own cleanup may
|
|
3806
|
+
// name records to delete, and a removal that finishes there produces none.
|
|
3807
|
+
const DOMAIN_RELEASE_NO_CLEANUP = "Leave its DNS records in place for now. The CLI cannot list which DNS records to delete for a removal that finishes in the background.";
|
|
3808
|
+
// A removal that has stopped serving but is still finishing upstream: nothing is called removed
|
|
3809
|
+
// and no record is offered for deletion. A removal the background sweep finishes deletes the row
|
|
3810
|
+
// and returns no cleanup to anyone, so no later command is promised to list the records either.
|
|
3811
|
+
export function domainRemovalPendingLines(hostname, app, options) {
|
|
3812
|
+
const owner = options.owner ? ` --owner ${quoteSiteArg(options.owner)}` : "";
|
|
3813
|
+
return [
|
|
3814
|
+
`${app} no longer serves ${hostname}, but removing it upstream is taking longer than usual. It finishes on its own.`,
|
|
3815
|
+
`Once ${hostname} is no longer in this list, it has stopped serving from 9pm: 9pm domains list ${quoteSiteArg(app)}${owner}${apiUrlFlagFor(options.target)}`,
|
|
3816
|
+
DOMAIN_RELEASE_NO_CLEANUP,
|
|
3817
|
+
];
|
|
3818
|
+
}
|
|
3819
|
+
export async function moveDomain(options) {
|
|
3820
|
+
const hostname = normalizeDomainArg(options.hostname);
|
|
3821
|
+
const body = await domainsRequest(options, siteDomainsRoute(options, hostname, "move"), {
|
|
3822
|
+
method: "POST",
|
|
3823
|
+
body: JSON.stringify({ toSiteId: options.to.trim() }),
|
|
3636
3824
|
});
|
|
3637
|
-
|
|
3825
|
+
const fields = body && typeof body === "object" ? body : {};
|
|
3826
|
+
const slugOf = (side) => {
|
|
3827
|
+
const slug = side && typeof side === "object" ? side.slug : undefined;
|
|
3828
|
+
return typeof slug === "string" && slug !== "" ? slug : null;
|
|
3829
|
+
};
|
|
3830
|
+
return { body, hostname, from: slugOf(fields.from), to: slugOf(fields.to) };
|
|
3831
|
+
}
|
|
3832
|
+
// The app holding a hostname in this account, for `move` without a source app. From the stored
|
|
3833
|
+
// list (`reconcile=0`), so finding it costs no provider call; null when the account has no such
|
|
3834
|
+
// domain. Throws when the list cannot be read, which is not the same answer as "none".
|
|
3835
|
+
export async function findDomainApp(options) {
|
|
3836
|
+
const hostname = normalizeDomainArg(options.hostname);
|
|
3837
|
+
const query = new URLSearchParams({ reconcile: "0", ...(options.owner ? { owner: options.owner } : {}) });
|
|
3838
|
+
const result = await domainsRequest(options, `/v1/domains?${query.toString()}`);
|
|
3839
|
+
if (!Array.isArray(result?.domains))
|
|
3840
|
+
throw new Error("the domains list came back in a shape this CLI does not read");
|
|
3841
|
+
for (const entry of result.domains) {
|
|
3842
|
+
if (!entry || typeof entry !== "object")
|
|
3843
|
+
continue;
|
|
3844
|
+
const domain = entry;
|
|
3845
|
+
if (typeof domain.hostname !== "string" || normalizeDnsName(domain.hostname) !== hostname)
|
|
3846
|
+
continue;
|
|
3847
|
+
const slug = domain.app && typeof domain.app === "object" ? domain.app.slug : undefined;
|
|
3848
|
+
if (typeof slug !== "string" || slug === "")
|
|
3849
|
+
throw new Error(`the domains list names no app for ${hostname}`);
|
|
3850
|
+
return slug;
|
|
3851
|
+
}
|
|
3852
|
+
return null;
|
|
3853
|
+
}
|
|
3854
|
+
// What to do about each move refusal, beside the server's own message. Printed commands carry
|
|
3855
|
+
// --owner and a non-default --api-url, never a credential.
|
|
3856
|
+
export function domainMoveRefusalLines(error, move, options) {
|
|
3857
|
+
const owner = options.owner ? ` --owner ${quoteSiteArg(options.owner)}` : "";
|
|
3858
|
+
const apiUrl = apiUrlFlagFor(options.target);
|
|
3859
|
+
const host = normalizeDomainArg(move.hostname);
|
|
3860
|
+
const list = (app) => `9pm domains list${app ? ` ${quoteSiteArg(app)}` : ""}${owner}${apiUrl}`;
|
|
3861
|
+
const removeHere = `9pm domains remove ${quoteSiteArg(move.site)} ${host} --confirm ${host}${owner}${apiUrl}`;
|
|
3862
|
+
const addThere = domainCommand("add", move.to, host, options);
|
|
3863
|
+
switch (error.code) {
|
|
3864
|
+
case "custom_domain_not_found":
|
|
3865
|
+
return [`${host} is not a custom domain of ${move.site}. See which app holds it: ${list()}`];
|
|
3866
|
+
case "project_not_found":
|
|
3867
|
+
return [`No app ${move.to} that you can open was found. List the apps: 9pm apps${owner}${apiUrl}`];
|
|
3868
|
+
case "custom_domain_quota_exceeded":
|
|
3869
|
+
return [`${move.to} already has as many custom domains as the plan allows. Remove one from it first: ${list(move.to)}`];
|
|
3870
|
+
case "plan_custom_domains_unavailable":
|
|
3871
|
+
return ["The plan this account is on includes no custom domains, so no app in it can take one."];
|
|
3872
|
+
case "custom_domain_release_pending":
|
|
3873
|
+
return [`A removal of ${host} is still finishing, so it can't move until that is done. Try again in a few minutes; see where it stands: ${list(move.site)}`];
|
|
3874
|
+
case "custom_domain_move_not_provisioned":
|
|
3875
|
+
if (error.reason === "source_service_mode") {
|
|
3876
|
+
return [
|
|
3877
|
+
`${move.site} uses service tokens, so ${host} was disconnected and has nothing to move. Remove it there, then add it to ${move.to}; it gets new DNS records to publish:`,
|
|
3878
|
+
` ${removeHere}`,
|
|
3879
|
+
` ${addThere}`,
|
|
3880
|
+
];
|
|
3881
|
+
}
|
|
3882
|
+
return [
|
|
3883
|
+
`${host} isn't provisioned yet, so there is no certificate to move. Remove it here and add it to ${move.to} instead; it gets new DNS records to publish:`,
|
|
3884
|
+
` ${removeHere}`,
|
|
3885
|
+
` ${addThere}`,
|
|
3886
|
+
];
|
|
3887
|
+
case "custom_domain_move_cross_account":
|
|
3888
|
+
return [
|
|
3889
|
+
`${move.to} is in another account, and a domain moves only between apps of one account. Remove it here, then add it from that account; it gets new DNS records to publish:`,
|
|
3890
|
+
` ${removeHere}`,
|
|
3891
|
+
];
|
|
3892
|
+
case "custom_domains_public_only":
|
|
3893
|
+
return [`${move.to} uses service tokens, which can't hold a custom domain. Choose another app, or change its access mode first.`];
|
|
3894
|
+
case "custom_domain_redeploy_required":
|
|
3895
|
+
return [`Redeploy ${move.to} once, then run the move again.`];
|
|
3896
|
+
case "custom_domain_expired":
|
|
3897
|
+
return [`${host} expired before its DNS records were published. Add it to ${move.to} instead: ${addThere}`];
|
|
3898
|
+
case "custom_domain_move_conflict":
|
|
3899
|
+
return [`Something changed while the domain was moving. See where it is now: ${list()}`];
|
|
3900
|
+
case "custom_domain_move_same_app":
|
|
3901
|
+
return [`${host} already belongs to ${move.site}; nothing to move.`];
|
|
3902
|
+
default:
|
|
3903
|
+
return [];
|
|
3904
|
+
}
|
|
3638
3905
|
}
|
|
3639
3906
|
// The domains read is a reconcile plus a DNS lookup per record, so it can be slow; the deploy
|
|
3640
3907
|
// has already succeeded and must not wait on it for longer than this.
|
|
@@ -3696,6 +3963,9 @@ export function deployCustomDomainLines(statuses, slug, owner, target) {
|
|
|
3696
3963
|
];
|
|
3697
3964
|
}
|
|
3698
3965
|
return statuses.map((status) => {
|
|
3966
|
+
// A removal still finishing reads as whatever state the row last held, "live" included.
|
|
3967
|
+
if (status.removing)
|
|
3968
|
+
return `Custom domain ${status.hostname} is being removed.`;
|
|
3699
3969
|
if (status.state === "live")
|
|
3700
3970
|
return `Also serving at https://${status.hostname}`;
|
|
3701
3971
|
const notLive = status.state === "failed" || status.state === "expired" ? "is not live" : "is not live yet";
|
|
@@ -3715,6 +3985,9 @@ export async function waitForDomain(options) {
|
|
|
3715
3985
|
const status = await getDomainStatus(options);
|
|
3716
3986
|
last = status;
|
|
3717
3987
|
options.onStatus?.(status);
|
|
3988
|
+
// Being removed: it will not go live, whatever state the row last held.
|
|
3989
|
+
if (status.removing)
|
|
3990
|
+
return { status, exitCode: DOMAIN_WAIT_EXIT.failed, timedOut: false };
|
|
3718
3991
|
if (status.state === "live")
|
|
3719
3992
|
return { status, exitCode: DOMAIN_WAIT_EXIT.live, timedOut: false };
|
|
3720
3993
|
if (status.state === "failed" || status.state === "expired")
|
|
@@ -3740,7 +4013,7 @@ export async function waitForDomain(options) {
|
|
|
3740
4013
|
await sleepFn(Math.min(options.intervalMs, remaining));
|
|
3741
4014
|
}
|
|
3742
4015
|
}
|
|
3743
|
-
const DOMAINS_VALUE_FLAGS = new Set(["token", "api-url", "owner", "timeout", "interval", "confirm"]);
|
|
4016
|
+
const DOMAINS_VALUE_FLAGS = new Set(["token", "api-url", "owner", "timeout", "interval", "confirm", "to"]);
|
|
3744
4017
|
const DOMAINS_BOOLEAN_FLAGS = new Set(["json"]);
|
|
3745
4018
|
function domainsPositionals(argv) {
|
|
3746
4019
|
const positionals = [];
|
|
@@ -3766,7 +4039,8 @@ const DOMAINS_USAGE = {
|
|
|
3766
4039
|
check: "9pm domains check <app> <hostname> [--owner <organisation>] [--json]",
|
|
3767
4040
|
wait: "9pm domains wait <app> <hostname> [--timeout 30m] [--interval 20s] [--owner <organisation>] [--json]",
|
|
3768
4041
|
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>]",
|
|
4042
|
+
remove: "9pm domains remove <app> <hostname> --confirm <hostname> [--owner <organisation>] [--json]",
|
|
4043
|
+
move: "9pm domains move [<app>] <hostname> --to <app> [--owner <organisation>] [--json]",
|
|
3770
4044
|
};
|
|
3771
4045
|
function parseDomainsArgs(argv) {
|
|
3772
4046
|
const subcommand = argv[0] === "ls" ? "list" : argv[0] === "rm" ? "remove" : argv[0];
|
|
@@ -3794,6 +4068,13 @@ function parseDomainsArgs(argv) {
|
|
|
3794
4068
|
const site = positionals[0];
|
|
3795
4069
|
return withBase({ kind: "list", json, ...(site ? { site } : {}) });
|
|
3796
4070
|
}
|
|
4071
|
+
if (subcommand === "move") {
|
|
4072
|
+
const to = readStringFlag(flags, "to");
|
|
4073
|
+
const [first, second] = positionals;
|
|
4074
|
+
if (!to || !first || positionals.length > 2)
|
|
4075
|
+
throw new Error(`Usage: ${usage}`);
|
|
4076
|
+
return withBase(second === undefined ? { kind: "move", hostname: first, to, json } : { kind: "move", site: first, hostname: second, to, json });
|
|
4077
|
+
}
|
|
3797
4078
|
const [site, hostname] = positionals;
|
|
3798
4079
|
if (!site || !hostname || positionals.length > 2) {
|
|
3799
4080
|
throw new Error(`Usage: ${usage}`);
|
|
@@ -3817,7 +4098,7 @@ function parseDomainsArgs(argv) {
|
|
|
3817
4098
|
if (!confirm || normalizeDomainArg(confirm) !== normalizeDomainArg(hostname)) {
|
|
3818
4099
|
throw new Error(`Removing a domain stops it serving at once. Pass --confirm ${normalizeDomainArg(hostname)} to remove it.`);
|
|
3819
4100
|
}
|
|
3820
|
-
return withBase({ kind: "remove", site, hostname });
|
|
4101
|
+
return withBase({ kind: "remove", site, hostname, json });
|
|
3821
4102
|
}
|
|
3822
4103
|
function printDomainStatus(status, json, options, heading) {
|
|
3823
4104
|
if (json) {
|
|
@@ -3850,6 +4131,10 @@ async function runDomainsCommand(command) {
|
|
|
3850
4131
|
console.log(line);
|
|
3851
4132
|
return;
|
|
3852
4133
|
}
|
|
4134
|
+
if (action.kind === "move") {
|
|
4135
|
+
await runDomainMove(action, base, format);
|
|
4136
|
+
return;
|
|
4137
|
+
}
|
|
3853
4138
|
const target = { ...base, site: action.site, hostname: action.hostname };
|
|
3854
4139
|
if (action.kind === "add") {
|
|
3855
4140
|
const added = await addDomain(target);
|
|
@@ -3915,15 +4200,63 @@ async function runDomainsCommand(command) {
|
|
|
3915
4200
|
console.log(line);
|
|
3916
4201
|
return;
|
|
3917
4202
|
}
|
|
3918
|
-
const
|
|
3919
|
-
|
|
3920
|
-
|
|
3921
|
-
|
|
3922
|
-
|
|
3923
|
-
|
|
4203
|
+
const removal = await removeDomain(target);
|
|
4204
|
+
if (action.json) {
|
|
4205
|
+
console.log(JSON.stringify(removal.body, null, 2));
|
|
4206
|
+
return;
|
|
4207
|
+
}
|
|
4208
|
+
if (removal.releasing) {
|
|
4209
|
+
console.log(`Removing ${removal.hostname} from ${action.site} is still finishing.`);
|
|
4210
|
+
for (const line of domainRemovalPendingLines(removal.hostname, action.site, format))
|
|
4211
|
+
console.log(line);
|
|
4212
|
+
return;
|
|
4213
|
+
}
|
|
4214
|
+
console.log(`Removed ${removal.hostname} from ${action.site}. It stops serving within seconds.`);
|
|
4215
|
+
if (removal.cleanup) {
|
|
4216
|
+
for (const line of domainCleanupLines(removal.cleanup))
|
|
3924
4217
|
console.log(line);
|
|
3925
4218
|
}
|
|
3926
4219
|
}
|
|
4220
|
+
async function runDomainMove(action, base, format) {
|
|
4221
|
+
const hostname = normalizeDomainArg(action.hostname);
|
|
4222
|
+
const owner = format.owner ? ` --owner ${quoteSiteArg(format.owner)}` : "";
|
|
4223
|
+
const apiUrl = apiUrlFlagFor(format.target);
|
|
4224
|
+
let site = action.site;
|
|
4225
|
+
if (!site) {
|
|
4226
|
+
let found;
|
|
4227
|
+
try {
|
|
4228
|
+
found = await findDomainApp({ ...base, hostname });
|
|
4229
|
+
}
|
|
4230
|
+
catch (error) {
|
|
4231
|
+
// Finding the app is a convenience in front of the move, which has not been tried.
|
|
4232
|
+
throw new Error(`Couldn't look up which app holds ${hostname} (${error instanceof Error ? error.message : String(error)}). Nothing was moved. Name the app it is on: 9pm domains move <app> ${hostname} --to ${quoteSiteArg(action.to)}${owner}${apiUrl}`);
|
|
4233
|
+
}
|
|
4234
|
+
if (!found) {
|
|
4235
|
+
throw new DomainsApiError(`${hostname} is not a custom domain in this account (custom_domain_not_found). See the account's domains: 9pm domains list${owner}${apiUrl}`, 404, "custom_domain_not_found");
|
|
4236
|
+
}
|
|
4237
|
+
site = found;
|
|
4238
|
+
}
|
|
4239
|
+
let moved;
|
|
4240
|
+
try {
|
|
4241
|
+
moved = await moveDomain({ ...base, site, hostname, to: action.to });
|
|
4242
|
+
}
|
|
4243
|
+
catch (error) {
|
|
4244
|
+
if (error instanceof DomainsApiError) {
|
|
4245
|
+
const advice = domainMoveRefusalLines(error, { site, hostname, to: action.to }, format);
|
|
4246
|
+
if (advice.length > 0)
|
|
4247
|
+
throw new DomainsApiError([error.message, ...advice].join("\n"), error.status, error.code, error.reason);
|
|
4248
|
+
}
|
|
4249
|
+
throw error;
|
|
4250
|
+
}
|
|
4251
|
+
if (action.json) {
|
|
4252
|
+
console.log(JSON.stringify(moved.body, null, 2));
|
|
4253
|
+
return;
|
|
4254
|
+
}
|
|
4255
|
+
const to = moved.to ?? action.to;
|
|
4256
|
+
console.log(`Moved ${moved.hostname} from ${moved.from ?? site} to ${to}.`);
|
|
4257
|
+
console.log("It keeps its certificate and the DNS records you published, so nothing has to be validated again. Leave those records as they are.");
|
|
4258
|
+
console.log(`Check it: ${domainCommand("status", to, moved.hostname, format)}`);
|
|
4259
|
+
}
|
|
3927
4260
|
// #807 step 5: the claim behind a health verdict, in words. A bare `unknown` reads
|
|
3928
4261
|
// as "fine"; the reason says which statement the platform is actually making.
|
|
3929
4262
|
export function healthWithReason(status, reason) {
|
|
@@ -5539,6 +5872,8 @@ async function main() {
|
|
|
5539
5872
|
if (deleted.customDomainNames && deleted.customDomainNames.length > 0) {
|
|
5540
5873
|
console.log(`Removed custom domain(s): ${deleted.customDomainNames.join(", ")}.`);
|
|
5541
5874
|
}
|
|
5875
|
+
for (const line of deletedDomainLines(deleted))
|
|
5876
|
+
console.log(line);
|
|
5542
5877
|
// #722: a partial external-cleanup failure never fails the delete, and used to be
|
|
5543
5878
|
// silently discarded here — visible only in the raw JSON, if a caller happened to
|
|
5544
5879
|
// read it. Surfacing it is the whole reason a human running this command manually
|
|
@@ -5938,7 +6273,8 @@ function printUsage() {
|
|
|
5938
6273
|
9pm domains check <app> <hostname> [--json] re-check now, then print the status
|
|
5939
6274
|
9pm domains wait <app> <hostname> [--timeout 30m] [--interval 20s] [--json]
|
|
5940
6275
|
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>
|
|
6276
|
+
9pm domains remove <app> <hostname> --confirm <hostname> [--json] prints the DNS records you can then delete
|
|
6277
|
+
9pm domains move [<app>] <hostname> --to <app> [--json] to another app of the same account; keeps its certificate and records
|
|
5942
6278
|
9pm access show <site>
|
|
5943
6279
|
9pm access public <site> [--switch-mode]
|
|
5944
6280
|
9pm access otp <site> [--switch-mode]
|
|
@@ -5991,10 +6327,10 @@ Missing pages (static sites and --entry worker apps):
|
|
|
5991
6327
|
|
|
5992
6328
|
Custom domains:
|
|
5993
6329
|
Every domains command takes --owner <organisation> for an organisation's app. list, status and
|
|
5994
|
-
wait only read; add, check, restart and
|
|
6330
|
+
wait only read; add, check, restart, remove and move need the configure role on the app.
|
|
5995
6331
|
--json prints {hostname, app, state, reason, next, routing, tls, provider, nameservers,
|
|
5996
6332
|
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
|
|
6333
|
+
checkedAt, removing}. state is one of live, waiting_for_dns, dns_unverified (routing is active but the
|
|
5998
6334
|
records could not be checked), issuing_certificate (every required record checked and found),
|
|
5999
6335
|
failed, expired. reason says why (reason.providerMessage is the provider's own text, when it
|
|
6000
6336
|
reported any, and its source: certificate_authority or hostname); next says who acts next (owner or platform) and what to do.
|
|
@@ -6004,7 +6340,11 @@ Custom domains:
|
|
|
6004
6340
|
wait exits 0 when live, 10 on timeout with something for the owner to do (records missing or
|
|
6005
6341
|
unverified, or a CAA record blocking the certificate), 11 on timeout with every record found and
|
|
6006
6342
|
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.
|
|
6343
|
+
expired, and 1 on a usage, auth or API error. A domain whose removal is still finishing shows
|
|
6344
|
+
as removing ("removing": true in --json), and wait on it exits 20.
|
|
6345
|
+
remove prints only the records the API says this domain used, each to delete only if its value
|
|
6346
|
+
is unchanged; while the removal is still finishing (a 202) it prints none. remove and move
|
|
6347
|
+
--json print the API's answer as it came.
|
|
6008
6348
|
|
|
6009
6349
|
Container deploys:
|
|
6010
6350
|
Requires Docker locally. The CLI builds linux/amd64, exports the image, uploads it through a short-lived session, then finalizes a queued deployment.
|