ninepm 0.79.0 → 0.83.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 +42 -4
- package/dist/dns-providers.d.ts +30 -0
- package/dist/dns-providers.js +49 -2
- package/dist/index.d.ts +70 -3
- package/dist/index.js +420 -28
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -163,6 +163,8 @@ Delete an app, its managed database, managed container app, and retained image:
|
|
|
163
163
|
|
|
164
164
|
The app's address goes with it: creating an app again under the same slug gives it a new address, so printed links and QR codes stop working. `9pm delete` prints this warning before it deletes anything.
|
|
165
165
|
|
|
166
|
+
For a static or Worker app with no custom domain, `9pm delete` also prints `Its address stops serving the app within about a minute.` It prints nothing about timing for a container app or an app with a custom domain: how long those keep serving after a delete has not been measured, so check the address yourself.
|
|
167
|
+
|
|
166
168
|
## Access Modes
|
|
167
169
|
|
|
168
170
|
Apps are public by default.
|
|
@@ -217,7 +219,8 @@ Connect a domain you own to an app:
|
|
|
217
219
|
This prints the DNS records to publish at your DNS provider, with names relative to your
|
|
218
220
|
domain (`www`, `_acme-challenge.www`) and what DNS shows for each right now. When a TXT value
|
|
219
221
|
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
|
|
222
|
+
your domain (`@`) cannot hold a CNAME, so a root domain is shown as an `ALIAS / ANAME` record when
|
|
223
|
+
your DNS host is not one the CLI recognises (a recognised host's own name for it otherwise): add it as
|
|
221
224
|
ALIAS or ANAME (or a CNAME your provider flattens). A correct ALIAS answers with addresses, which
|
|
222
225
|
cannot be compared from outside, so that row follows the platform's own routing check. Then:
|
|
223
226
|
|
|
@@ -258,13 +261,47 @@ Since 0.78.0 each record also carries `typeAtProvider`, `nameAtProvider` (`""` m
|
|
|
258
261
|
field empty) and `valueAtProvider`: the record as a recognised DNS host's editor wants it.
|
|
259
262
|
`typeAtProvider` is `null` for a root record the host cannot hold (GoDaddy, Route 53, IONOS, Wix);
|
|
260
263
|
the text output then suggests connecting `www` instead. `list`, `status` and `wait`
|
|
261
|
-
only read; `add`, `check`, `restart` and `
|
|
264
|
+
only read; `add`, `check`, `restart`, `remove` and `move` need permission to configure the app.
|
|
265
|
+
|
|
266
|
+
`9pm domains remove` prints only the records the API says the domain used, each to delete only if
|
|
267
|
+
its value is unchanged: a name that now holds a different value (the domain points somewhere new, or
|
|
268
|
+
another service's certificate check uses `_acme-challenge`) is not ours, so leave it. Against a server
|
|
269
|
+
that sends no such list it prints no deletion advice at all. When the removal is still finishing
|
|
270
|
+
upstream (the API answers `202` with `releasing: true`), it says so, names no record to delete, and
|
|
271
|
+
prints the `9pm domains list` command: once the domain is no longer listed, it has stopped serving
|
|
272
|
+
from 9pm. A removal that finishes in the background returns no record list, so the CLI cannot say
|
|
273
|
+
which DNS records to delete for it. Such a domain shows as `removing`
|
|
274
|
+
in `list` and `status` (`"removing": true` in `--json`), and `wait` on it exits `20`. Since 0.80.0.
|
|
275
|
+
|
|
276
|
+
Since 0.83.0, `9pm domains cleanup <app> <hostname>` lists the setup TXT records 9pm issued for a
|
|
277
|
+
live domain and no longer needs, such as an earlier `_acme-challenge` value or the
|
|
278
|
+
`_cf-custom-hostname` ownership record (`--json` prints the API's answer). Each may be deleted only
|
|
279
|
+
while it still has the value shown; a different value at that name is not ours. The list is checked
|
|
280
|
+
against the certificate provider when you run the command: a value the provider still asks for is
|
|
281
|
+
never in it, a domain that is not live lists nothing, and if the provider cannot be read, or the
|
|
282
|
+
domain changes while it is being checked, the command fails and lists nothing; run it again. Only values the API issued are candidates, never anything read from public
|
|
283
|
+
DNS. `9pm domains status` on a live domain prints the command. `9pm domains remove` lists these values
|
|
284
|
+
with the rest of the domain's records, since once the domain is removed none of them is needed.
|
|
285
|
+
After `9pm domains restart`, the values it says no longer count are worded the same way. Against a
|
|
286
|
+
server without the route, `cleanup` says it can't list any and exits `1`. Values replaced before the
|
|
287
|
+
server kept them cannot be listed.
|
|
288
|
+
|
|
289
|
+
`9pm domains move <hostname> --to <app>` moves a domain to another app of the same account. It keeps
|
|
290
|
+
its certificate and the DNS records already published, so nothing is validated again. Without a
|
|
291
|
+
source app the CLI looks it up in the account; name it (`9pm domains move <app> <hostname> --to
|
|
292
|
+
<app>`) to skip that. Each refusal is printed with what to do: the domain or the app is not found,
|
|
293
|
+
the destination is at its custom-domain cap, a removal of the domain is still finishing, the domain
|
|
294
|
+
was disconnected under a service-token app or was never provisioned (remove it and add it to the
|
|
295
|
+
other app), or the destination is in another account. `remove --json` and `move --json` print the
|
|
296
|
+
API's answer as it came. Since 0.80.0.
|
|
262
297
|
|
|
263
298
|
Every `9pm deploy` lists the app's custom domains under its `Deployed` line: `Also serving at
|
|
264
299
|
https://<domain>` for a live one, and for any other its state and the `9pm domains status`
|
|
265
300
|
command to run. If the domains cannot be read within a few seconds, the deploy says so in one
|
|
266
301
|
line instead; the deploy itself is never held back by it. `9pm delete` names the custom domains
|
|
267
|
-
it removed.
|
|
302
|
+
it removed and, since 0.80.0, the records the API says each one used (under the same
|
|
303
|
+
value-unchanged rule); a domain still being released is reported as such, with no records to delete,
|
|
304
|
+
and none can be listed for it later.
|
|
268
305
|
|
|
269
306
|
## Commands
|
|
270
307
|
|
|
@@ -293,7 +330,8 @@ it removed.
|
|
|
293
330
|
9pm domains check <app> <hostname> [--json]
|
|
294
331
|
9pm domains wait <app> <hostname> [--timeout 30m] [--interval 20s] [--json]
|
|
295
332
|
9pm domains restart <app> <hostname> # only after a certificate failed; record values may change
|
|
296
|
-
9pm domains remove <app> <hostname> --confirm <hostname>
|
|
333
|
+
9pm domains remove <app> <hostname> --confirm <hostname> [--json]
|
|
334
|
+
9pm domains move [<app>] <hostname> --to <app> [--json]
|
|
297
335
|
9pm access show <site>
|
|
298
336
|
9pm access public <site> [--switch-mode]
|
|
299
337
|
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/index.d.ts
CHANGED
|
@@ -108,8 +108,12 @@ 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;
|
|
115
|
+
flyTeardownsQueued?: number;
|
|
116
|
+
workerScripts?: unknown;
|
|
113
117
|
accessApps: number;
|
|
114
118
|
serviceTokens: number;
|
|
115
119
|
databases: number;
|
|
@@ -118,6 +122,7 @@ export interface DeletedSiteResult {
|
|
|
118
122
|
metadata: boolean;
|
|
119
123
|
errors?: string[];
|
|
120
124
|
}
|
|
125
|
+
export declare function deletedDomainLines(deleted: Pick<DeletedSiteResult, "customDomainCleanup" | "customDomainsReleasing">): string[];
|
|
121
126
|
export interface CreatedRuntimeToken {
|
|
122
127
|
token: string;
|
|
123
128
|
appAccessToken: AppAccessToken;
|
|
@@ -127,7 +132,7 @@ export interface CreatedServiceToken {
|
|
|
127
132
|
clientSecret: string;
|
|
128
133
|
}
|
|
129
134
|
export type FetchLike = (input: string | URL | Request, init?: RequestInit) => Promise<Response>;
|
|
130
|
-
export declare const CLI_VERSION = "0.
|
|
135
|
+
export declare const CLI_VERSION = "0.83.0";
|
|
131
136
|
type CredentialBackend = "macos-keychain" | "windows-dpapi" | "linux-secret-service" | "file-plaintext" | "none";
|
|
132
137
|
export interface StoredCredentialEntry {
|
|
133
138
|
token: string;
|
|
@@ -352,6 +357,8 @@ export declare function listSites(options: ApiOptions & {
|
|
|
352
357
|
owner?: string;
|
|
353
358
|
}): Promise<ListSitesResult>;
|
|
354
359
|
export declare function deleteWarningLines(slug: string): string[];
|
|
360
|
+
export declare const DELETE_SERVING_BOUND_LINE = "Its address stops serving the app within about a minute.";
|
|
361
|
+
export declare function deletedAppStopsServingQuickly(deleted: Pick<DeletedSiteResult, "workerScripts" | "containerApplications" | "flyTeardownsQueued" | "customDomains" | "customDomainsReleasing" | "errors">): boolean;
|
|
355
362
|
export declare function deleteSite(options: DeleteSiteOptions): Promise<DeletedSiteResult>;
|
|
356
363
|
export interface DeviceAuthFeedbackOptions {
|
|
357
364
|
progressStream?: NodeJS.WriteStream;
|
|
@@ -645,10 +652,22 @@ type CliCommand = {
|
|
|
645
652
|
kind: "restart";
|
|
646
653
|
site: string;
|
|
647
654
|
hostname: string;
|
|
655
|
+
} | {
|
|
656
|
+
kind: "cleanup";
|
|
657
|
+
site: string;
|
|
658
|
+
hostname: string;
|
|
659
|
+
json: boolean;
|
|
648
660
|
} | {
|
|
649
661
|
kind: "remove";
|
|
650
662
|
site: string;
|
|
651
663
|
hostname: string;
|
|
664
|
+
json: boolean;
|
|
665
|
+
} | {
|
|
666
|
+
kind: "move";
|
|
667
|
+
site?: string;
|
|
668
|
+
hostname: string;
|
|
669
|
+
to: string;
|
|
670
|
+
json: boolean;
|
|
652
671
|
};
|
|
653
672
|
apiUrl: string;
|
|
654
673
|
token: string;
|
|
@@ -753,6 +772,8 @@ export interface CliDomain extends DomainStateInput {
|
|
|
753
772
|
state?: string;
|
|
754
773
|
reason?: DomainReason | null;
|
|
755
774
|
next?: DomainNext;
|
|
775
|
+
releasingAt?: string | null;
|
|
776
|
+
releaseIntent?: string | null;
|
|
756
777
|
}
|
|
757
778
|
export interface DomainRecordStatusJson extends DomainRecordStatus {
|
|
758
779
|
typeAtProvider: string | null;
|
|
@@ -779,6 +800,7 @@ export interface DomainStatusJson {
|
|
|
779
800
|
checkedAt: string;
|
|
780
801
|
reason: DomainReason | null;
|
|
781
802
|
next: DomainNext;
|
|
803
|
+
removing: boolean;
|
|
782
804
|
}
|
|
783
805
|
export declare const DOMAIN_WAIT_EXIT: {
|
|
784
806
|
readonly live: 0;
|
|
@@ -801,7 +823,8 @@ export declare function formatDomainsList(statuses: DomainStatusJson[]): string[
|
|
|
801
823
|
export declare class DomainsApiError extends Error {
|
|
802
824
|
readonly status: number;
|
|
803
825
|
readonly code: string | null;
|
|
804
|
-
|
|
826
|
+
readonly reason: string | null;
|
|
827
|
+
constructor(message: string, status: number, code: string | null, reason?: string | null);
|
|
805
828
|
}
|
|
806
829
|
export declare function domainsApiError(status: number, text: string): DomainsApiError;
|
|
807
830
|
export interface DomainCommandOptions extends ApiOptions {
|
|
@@ -830,9 +853,53 @@ export declare function restartDomain(options: DomainCommandOptions & {
|
|
|
830
853
|
domain: CliDomain;
|
|
831
854
|
replacedInstructions: CliDnsInstruction[];
|
|
832
855
|
}>;
|
|
856
|
+
export interface DomainRemoval {
|
|
857
|
+
body: unknown;
|
|
858
|
+
hostname: string;
|
|
859
|
+
releasing: boolean;
|
|
860
|
+
cleanup: DomainCleanup | null;
|
|
861
|
+
}
|
|
833
862
|
export declare function removeDomain(options: DomainCommandOptions & {
|
|
834
863
|
hostname: string;
|
|
835
|
-
}): Promise<
|
|
864
|
+
}): Promise<DomainRemoval>;
|
|
865
|
+
export declare function removalFromResponse(status: number, body: unknown, requested: string): DomainRemoval;
|
|
866
|
+
export interface DomainCleanupRecord {
|
|
867
|
+
type: string;
|
|
868
|
+
host: string;
|
|
869
|
+
value: string;
|
|
870
|
+
purpose: string;
|
|
871
|
+
}
|
|
872
|
+
export interface DomainCleanup {
|
|
873
|
+
hostname: string;
|
|
874
|
+
records: DomainCleanupRecord[];
|
|
875
|
+
}
|
|
876
|
+
export declare function parseDomainCleanup(value: unknown): DomainCleanup | null;
|
|
877
|
+
export declare function domainCleanupLines(cleanup: DomainCleanup): string[];
|
|
878
|
+
export declare function getDomainSetupCleanup(options: DomainCommandOptions & {
|
|
879
|
+
hostname: string;
|
|
880
|
+
}): Promise<unknown>;
|
|
881
|
+
export declare function domainSetupCleanupLines(body: unknown, hostname: string): string[];
|
|
882
|
+
export declare function setupCleanupLines(hostname: string, records: DomainCleanupRecord[]): string[];
|
|
883
|
+
export declare function domainRemovalPendingLines(hostname: string, app: string, options: DomainFormatOptions): string[];
|
|
884
|
+
export interface DomainMove {
|
|
885
|
+
body: unknown;
|
|
886
|
+
hostname: string;
|
|
887
|
+
from: string | null;
|
|
888
|
+
to: string | null;
|
|
889
|
+
}
|
|
890
|
+
export declare function moveDomain(options: DomainCommandOptions & {
|
|
891
|
+
hostname: string;
|
|
892
|
+
to: string;
|
|
893
|
+
}): Promise<DomainMove>;
|
|
894
|
+
export declare function findDomainApp(options: ApiOptions & {
|
|
895
|
+
owner?: string;
|
|
896
|
+
hostname: string;
|
|
897
|
+
}): Promise<string | null>;
|
|
898
|
+
export declare function domainMoveRefusalLines(error: DomainsApiError, move: {
|
|
899
|
+
site: string;
|
|
900
|
+
hostname: string;
|
|
901
|
+
to: string;
|
|
902
|
+
}, options: DomainFormatOptions): string[];
|
|
836
903
|
export declare const DEPLOY_DOMAINS_TIMEOUT_MS = 4500;
|
|
837
904
|
export declare function deployCustomDomainStatuses(options: ApiOptions & {
|
|
838
905
|
projectId: string;
|
package/dist/index.js
CHANGED
|
@@ -14,7 +14,7 @@ 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";
|
|
17
|
+
import { dnsProviderProfile, parkingRecordFor, recordAtProvider } from "./dns-providers.js";
|
|
18
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";
|
|
@@ -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.83.0";
|
|
56
82
|
const PUBLIC_DASHBOARD_URL = "https://9pm.ai/dashboard";
|
|
57
83
|
const TOKEN_SERVICE = "9pm";
|
|
58
84
|
const TOKEN_ACCOUNT = "default";
|
|
@@ -1415,6 +1441,29 @@ export function deleteWarningLines(slug) {
|
|
|
1415
1441
|
"Its deployments, files and managed database (if it has one) are deleted with it and cannot be recovered.",
|
|
1416
1442
|
];
|
|
1417
1443
|
}
|
|
1444
|
+
// Printed after a delete of the one shape that was measured (#1969): an app whose namespace
|
|
1445
|
+
// script was replaced with the deleted-app Worker, with no container and no custom domain.
|
|
1446
|
+
// Measured from outside on 2026-10-14 (staging) and 2026-10-15 (production) for a static
|
|
1447
|
+
// app: the old content stopped within ~20 s of starting the delete and did not come back
|
|
1448
|
+
// over ~15 min. "About a minute" leaves margin for a single location's measurement;
|
|
1449
|
+
// re-measure before tightening it, and measure a shape before adding it below.
|
|
1450
|
+
export const DELETE_SERVING_BOUND_LINE = "Its address stops serving the app within about a minute.";
|
|
1451
|
+
// Whether DELETE_SERVING_BOUND_LINE is true of this delete. Read from what the server says it
|
|
1452
|
+
// did, not from what the app was: the tombstone is the mechanism the measurement covers. A
|
|
1453
|
+
// container app's teardown is queued and gets no tombstone, a custom domain stops through the
|
|
1454
|
+
// fallback Worker, and a failed cleanup step may have left something serving - none of those
|
|
1455
|
+
// were measured, so they get no promise. An older server sends no `workerScripts`, so neither.
|
|
1456
|
+
export function deletedAppStopsServingQuickly(deleted) {
|
|
1457
|
+
const scripts = Array.isArray(deleted.workerScripts) ? deleted.workerScripts : [];
|
|
1458
|
+
const tombstoned = scripts.some((script) => typeof script === "object" && script !== null && script.tombstoned === true);
|
|
1459
|
+
const releasing = Array.isArray(deleted.customDomainsReleasing) && deleted.customDomainsReleasing.length > 0;
|
|
1460
|
+
return (tombstoned &&
|
|
1461
|
+
(deleted.containerApplications ?? 0) === 0 &&
|
|
1462
|
+
(deleted.flyTeardownsQueued ?? 0) === 0 &&
|
|
1463
|
+
deleted.customDomains === 0 &&
|
|
1464
|
+
!releasing &&
|
|
1465
|
+
(deleted.errors ?? []).length === 0);
|
|
1466
|
+
}
|
|
1418
1467
|
export async function deleteSite(options) {
|
|
1419
1468
|
const route = `/v1/sites/${encodeURIComponent(options.site)}${options.owner ? `?owner=${encodeURIComponent(options.owner)}` : ""}`;
|
|
1420
1469
|
const payload = await apiRequest(options, route, {
|
|
@@ -3267,8 +3316,12 @@ export function domainStatusJson(domain, app, checkedAt) {
|
|
|
3267
3316
|
checkedAt,
|
|
3268
3317
|
reason: fromServer ? (domain.reason ?? null) : derived.reason,
|
|
3269
3318
|
next: fromServer ? (domain.next ?? derived.next) : derived.next,
|
|
3319
|
+
removing: isRemoving(domain),
|
|
3270
3320
|
};
|
|
3271
3321
|
}
|
|
3322
|
+
function isRemoving(domain) {
|
|
3323
|
+
return typeof domain.releasingAt === "string" && domain.releasingAt !== "" && domain.releaseIntent === "remove";
|
|
3324
|
+
}
|
|
3272
3325
|
const DOMAIN_STATE_LABELS = {
|
|
3273
3326
|
live: "live",
|
|
3274
3327
|
waiting_for_dns: "waiting for DNS records",
|
|
@@ -3343,7 +3396,7 @@ function forUpTo(ttlSeconds) {
|
|
|
3343
3396
|
function answerText(current) {
|
|
3344
3397
|
return current.length > 0 ? current.join(", ") : "nothing";
|
|
3345
3398
|
}
|
|
3346
|
-
function recordStateText(record, routing) {
|
|
3399
|
+
function recordStateText(record, routing, parking = null) {
|
|
3347
3400
|
if (record.ok === true)
|
|
3348
3401
|
return "found";
|
|
3349
3402
|
if (record.check === "resolvers_disagree") {
|
|
@@ -3381,7 +3434,7 @@ function recordStateText(record, routing) {
|
|
|
3381
3434
|
if (record.check === "wildcard") {
|
|
3382
3435
|
const name = record.nameAtProvider ?? record.name;
|
|
3383
3436
|
// 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`;
|
|
3437
|
+
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)}`;
|
|
3385
3438
|
}
|
|
3386
3439
|
if (record.current.length === 0)
|
|
3387
3440
|
return "not published yet";
|
|
@@ -3391,7 +3444,16 @@ function recordStateText(record, routing) {
|
|
|
3391
3444
|
const shown = record.current.join(", ");
|
|
3392
3445
|
const others = (record.resolvers ?? []).filter((answer) => answer.current.join(", ") !== shown);
|
|
3393
3446
|
const also = others.map((answer) => `; ${answer.resolver} answers ${answerText(answer.current)}`).join("");
|
|
3394
|
-
return `currently ${shown}${also}${recent}: point it at the value shown`;
|
|
3447
|
+
return `currently ${shown}${also}${recent}: point it at the value shown${parkingText(parking)}`;
|
|
3448
|
+
}
|
|
3449
|
+
// Said only when the check saw the host's documented parking value (parkingRecordFor), and
|
|
3450
|
+
// conditionally even then: the record may have been changed since the check.
|
|
3451
|
+
function parkingText(parking) {
|
|
3452
|
+
if (!parking)
|
|
3453
|
+
return "";
|
|
3454
|
+
return parking.wildcard
|
|
3455
|
+
? `. 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`
|
|
3456
|
+
: `. 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`;
|
|
3395
3457
|
}
|
|
3396
3458
|
// A printed command carries --owner and, when the destination is not the built-in default,
|
|
3397
3459
|
// --api-url: pasted into a shell without NINEPM_API_URL it would otherwise act on production.
|
|
@@ -3404,6 +3466,7 @@ function domainCommand(subcommand, app, hostname, options) {
|
|
|
3404
3466
|
export const DNS_PROVIDER_NOTES = {
|
|
3405
3467
|
gandiLiveDns: "At Gandi, an ALIAS works only with LiveDNS, and no A or AAAA record may stay at @ beside it.",
|
|
3406
3468
|
hostingerCnameAtRoot: "At Hostinger, add the root record as a CNAME with name @: Hostinger shows it as ALIAS. A domain can have only one.",
|
|
3469
|
+
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.",
|
|
3407
3470
|
// Unverified, so never printed; its text is here so a leak would show.
|
|
3408
3471
|
route53TxtQuotes: "In the Route 53 console, a TXT value may need to be entered in quotes.",
|
|
3409
3472
|
};
|
|
@@ -3444,9 +3507,14 @@ function renderColumns(rows) {
|
|
|
3444
3507
|
.trimEnd());
|
|
3445
3508
|
}
|
|
3446
3509
|
export function formatDomainStatus(status, options = {}) {
|
|
3510
|
+
if (status.removing) {
|
|
3511
|
+
return [`${status.hostname} (app ${status.app}): being removed`, ...domainRemovalPendingLines(status.hostname, status.app, options)];
|
|
3512
|
+
}
|
|
3447
3513
|
const lines = [`${status.hostname} (app ${status.app}): ${domainStateLabel(status.state)}`];
|
|
3448
3514
|
if (status.state === "live") {
|
|
3449
3515
|
lines.push(`Serving at https://${status.hostname}`);
|
|
3516
|
+
// #2089: which setup records can go is asked of the provider when wanted, not inferred here.
|
|
3517
|
+
lines.push(`Setup records you may no longer need: ${domainCommand("cleanup", status.app, status.hostname, options)}`);
|
|
3450
3518
|
return lines;
|
|
3451
3519
|
}
|
|
3452
3520
|
if (status.state === "expired") {
|
|
@@ -3485,10 +3553,12 @@ export function formatDomainStatus(status, options = {}) {
|
|
|
3485
3553
|
["TYPE", "NAME", "VALUE", "STATE"],
|
|
3486
3554
|
...shown.map((record) => [
|
|
3487
3555
|
// null: a root the host has no record type for, shown only once routing is already active.
|
|
3488
|
-
record
|
|
3556
|
+
// A host with no profile may call the root record either name, so the table gives both;
|
|
3557
|
+
// --json keeps its single `typeAtProvider` value.
|
|
3558
|
+
record.apex && !profile && record.typeAtProvider === "ALIAS" ? "ALIAS / ANAME" : (record.typeAtProvider ?? "-"),
|
|
3489
3559
|
record.nameAtProvider === "" ? "(leave empty)" : record.nameAtProvider,
|
|
3490
3560
|
record.valueAtProvider,
|
|
3491
|
-
recordStateText(record, status.routing),
|
|
3561
|
+
recordStateText(record, status.routing, profile ? parkingRecordFor(status.providerId, record) : null),
|
|
3492
3562
|
]),
|
|
3493
3563
|
];
|
|
3494
3564
|
lines.push(...renderColumns(rows).map((line) => ` ${line}`));
|
|
@@ -3586,17 +3656,20 @@ export function formatDomainsList(statuses) {
|
|
|
3586
3656
|
}
|
|
3587
3657
|
return renderColumns([
|
|
3588
3658
|
["HOSTNAME", "APP", "STATE", "ROUTING", "TLS"],
|
|
3589
|
-
...statuses.map((status) => [status.hostname, status.app, status.state, status.routing, status.tls]),
|
|
3659
|
+
...statuses.map((status) => [status.hostname, status.app, status.removing ? "removing" : status.state, status.routing, status.tls]),
|
|
3590
3660
|
]);
|
|
3591
3661
|
}
|
|
3592
3662
|
export class DomainsApiError extends Error {
|
|
3593
3663
|
status;
|
|
3594
3664
|
code;
|
|
3595
|
-
|
|
3665
|
+
// The refusal's `details.reason`, where it carries one.
|
|
3666
|
+
reason;
|
|
3667
|
+
constructor(message, status, code, reason = null) {
|
|
3596
3668
|
super(message);
|
|
3597
3669
|
this.name = "DomainsApiError";
|
|
3598
3670
|
this.status = status;
|
|
3599
3671
|
this.code = code;
|
|
3672
|
+
this.reason = reason;
|
|
3600
3673
|
}
|
|
3601
3674
|
}
|
|
3602
3675
|
// The API's own message and code, plus `details.reason` where the refusal carries one
|
|
@@ -3619,9 +3692,14 @@ export function domainsApiError(status, text) {
|
|
|
3619
3692
|
const reason = details && typeof details === "object" && typeof details.reason === "string"
|
|
3620
3693
|
? details.reason
|
|
3621
3694
|
: null;
|
|
3622
|
-
return new DomainsApiError(`${message} (${code}${reason ? `, reason: ${reason}` : ""})`, status, code);
|
|
3695
|
+
return new DomainsApiError(`${message} (${code}${reason ? `, reason: ${reason}` : ""})`, status, code, reason);
|
|
3623
3696
|
}
|
|
3624
3697
|
async function domainsRequest(options, route, init = { method: "GET" }) {
|
|
3698
|
+
return (await domainsResponse(options, route, init)).body;
|
|
3699
|
+
}
|
|
3700
|
+
// The body with the status beside it, for an answer whose status carries meaning of its own
|
|
3701
|
+
// (a removal still finishing answers 202).
|
|
3702
|
+
async function domainsResponse(options, route, init = { method: "GET" }) {
|
|
3625
3703
|
const fetchFn = options.fetchFn ?? fetch;
|
|
3626
3704
|
const headers = new Headers(init.headers);
|
|
3627
3705
|
headers.set("authorization", `Bearer ${options.token}`);
|
|
@@ -3633,7 +3711,7 @@ async function domainsRequest(options, route, init = { method: "GET" }) {
|
|
|
3633
3711
|
if (!response.ok) {
|
|
3634
3712
|
throw domainsApiError(response.status, await response.text());
|
|
3635
3713
|
}
|
|
3636
|
-
return (await response.json());
|
|
3714
|
+
return { status: response.status, body: (await response.json()) };
|
|
3637
3715
|
}
|
|
3638
3716
|
function ownerQuery(owner) {
|
|
3639
3717
|
return owner ? `?owner=${encodeURIComponent(owner)}` : "";
|
|
@@ -3678,10 +3756,221 @@ export async function restartDomain(options) {
|
|
|
3678
3756
|
return domainsRequest(options, siteDomainsRoute(options, normalizeDomainArg(options.hostname), "restart"), { method: "POST" });
|
|
3679
3757
|
}
|
|
3680
3758
|
export async function removeDomain(options) {
|
|
3681
|
-
const
|
|
3682
|
-
|
|
3759
|
+
const requested = normalizeDomainArg(options.hostname);
|
|
3760
|
+
const { status, body } = await domainsResponse(options, siteDomainsRoute(options, requested), { method: "DELETE" });
|
|
3761
|
+
return removalFromResponse(status, body, requested);
|
|
3762
|
+
}
|
|
3763
|
+
// Never throws: the removal has happened by the time this reads the answer, so a malformed body
|
|
3764
|
+
// costs the advice, not the command.
|
|
3765
|
+
export function removalFromResponse(status, body, requested) {
|
|
3766
|
+
try {
|
|
3767
|
+
const fields = body && typeof body === "object" ? body : {};
|
|
3768
|
+
const domain = fields.domain && typeof fields.domain === "object" ? fields.domain : null;
|
|
3769
|
+
const hostname = typeof domain?.hostname === "string" && domain.hostname !== "" ? domain.hostname : requested;
|
|
3770
|
+
const releasing = status === 202 || fields.releasing === true;
|
|
3771
|
+
return { body, hostname, releasing, cleanup: releasing ? null : parseDomainCleanup(fields.cleanup) };
|
|
3772
|
+
}
|
|
3773
|
+
catch {
|
|
3774
|
+
return { body, hostname: requested, releasing: status === 202, cleanup: null };
|
|
3775
|
+
}
|
|
3776
|
+
}
|
|
3777
|
+
// The server's `cleanup` (or one `customDomainCleanup` entry), kept only as far as it is well
|
|
3778
|
+
// formed: a malformed record is dropped, never guessed at, and a malformed whole is null.
|
|
3779
|
+
export function parseDomainCleanup(value) {
|
|
3780
|
+
try {
|
|
3781
|
+
if (!value || typeof value !== "object")
|
|
3782
|
+
return null;
|
|
3783
|
+
const { hostname, records } = value;
|
|
3784
|
+
if (typeof hostname !== "string" || hostname === "" || !Array.isArray(records))
|
|
3785
|
+
return null;
|
|
3786
|
+
const kept = [];
|
|
3787
|
+
for (const record of records) {
|
|
3788
|
+
if (!record || typeof record !== "object")
|
|
3789
|
+
continue;
|
|
3790
|
+
const { type, host, value: recordValue, purpose } = record;
|
|
3791
|
+
if (typeof type !== "string" || typeof host !== "string" || typeof recordValue !== "string" || type === "" || host === "" || recordValue === "")
|
|
3792
|
+
continue;
|
|
3793
|
+
kept.push({ type, host, value: recordValue, purpose: typeof purpose === "string" ? purpose : "" });
|
|
3794
|
+
}
|
|
3795
|
+
return { hostname, records: kept };
|
|
3796
|
+
}
|
|
3797
|
+
catch {
|
|
3798
|
+
return null;
|
|
3799
|
+
}
|
|
3800
|
+
}
|
|
3801
|
+
const CLEANUP_PURPOSES = {
|
|
3802
|
+
routing: "pointed the domain at 9pm",
|
|
3803
|
+
certificate: "validated the certificate",
|
|
3804
|
+
ownership: "proved ownership; only there if you added it",
|
|
3805
|
+
};
|
|
3806
|
+
// The records a finished removal frees, each conditional on its value: a name can hold another
|
|
3807
|
+
// service's record by now (the domain pointed somewhere new, or `_acme-challenge` serving another
|
|
3808
|
+
// certificate), and the value is the only thing that tells ours apart. Nothing here is checked
|
|
3809
|
+
// against live DNS, so nothing is ever called safe to delete outright.
|
|
3810
|
+
export function domainCleanupLines(cleanup) {
|
|
3811
|
+
if (cleanup.records.length === 0) {
|
|
3812
|
+
return [`There are no DNS records of ours to delete for ${cleanup.hostname}.`];
|
|
3813
|
+
}
|
|
3814
|
+
const apex = guessZoneApex(cleanup.hostname);
|
|
3815
|
+
const rows = [
|
|
3816
|
+
["TYPE", "NAME", "VALUE", "WAS FOR"],
|
|
3817
|
+
...cleanup.records.map((record) => [
|
|
3818
|
+
record.type.toUpperCase(),
|
|
3819
|
+
relativeRecordName(record.host, apex),
|
|
3820
|
+
record.value,
|
|
3821
|
+
Object.hasOwn(CLEANUP_PURPOSES, record.purpose) ? (CLEANUP_PURPOSES[record.purpose] ?? "") : "",
|
|
3822
|
+
]),
|
|
3823
|
+
];
|
|
3824
|
+
return [
|
|
3825
|
+
`You can now delete these DNS records for ${cleanup.hostname} where your domain's DNS is managed, each only if its value is unchanged:`,
|
|
3826
|
+
...renderColumns(rows).map((line) => ` ${line}`),
|
|
3827
|
+
"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.",
|
|
3828
|
+
];
|
|
3829
|
+
}
|
|
3830
|
+
export async function getDomainSetupCleanup(options) {
|
|
3831
|
+
return domainsRequest(options, siteDomainsRoute(options, normalizeDomainArg(options.hostname), "cleanup"));
|
|
3832
|
+
}
|
|
3833
|
+
// What `9pm domains cleanup` prints for the server's answer (#2089). Never throws: anything it
|
|
3834
|
+
// cannot read is reported as nothing listed, never as something to delete.
|
|
3835
|
+
export function domainSetupCleanupLines(body, hostname) {
|
|
3836
|
+
try {
|
|
3837
|
+
const fields = body && typeof body === "object" ? body : {};
|
|
3838
|
+
const name = typeof fields.hostname === "string" && fields.hostname !== "" ? fields.hostname : hostname;
|
|
3839
|
+
if (fields.live !== true) {
|
|
3840
|
+
return [`${name} isn't live with its certificate right now, so no setup record is listed for deletion.`];
|
|
3841
|
+
}
|
|
3842
|
+
const cleanup = parseDomainCleanup(fields.cleanup);
|
|
3843
|
+
if (!cleanup)
|
|
3844
|
+
return [`Couldn't read which setup records ${name} no longer needs, so none is listed for deletion.`];
|
|
3845
|
+
if (cleanup.records.length === 0)
|
|
3846
|
+
return [`9pm lists no setup records to delete for ${name}.`];
|
|
3847
|
+
return setupCleanupLines(name, cleanup.records);
|
|
3848
|
+
}
|
|
3849
|
+
catch {
|
|
3850
|
+
return [`Couldn't read which setup records ${hostname} no longer needs, so none is listed for deletion.`];
|
|
3851
|
+
}
|
|
3852
|
+
}
|
|
3853
|
+
// A live domain's leftovers from setup (#2089): only values the API says it issued and that the
|
|
3854
|
+
// provider, read just now, no longer asks for. Each is conditional on its value for the same
|
|
3855
|
+
// reason as after a removal: a name like `_acme-challenge` can hold another service's token, and
|
|
3856
|
+
// only the value tells ours apart.
|
|
3857
|
+
export function setupCleanupLines(hostname, records) {
|
|
3858
|
+
const apex = guessZoneApex(hostname);
|
|
3859
|
+
const rows = [
|
|
3860
|
+
["TYPE", "NAME", "VALUE", "WAS FOR"],
|
|
3861
|
+
...records.map((record) => [
|
|
3862
|
+
record.type.toUpperCase(),
|
|
3863
|
+
relativeRecordName(record.host, apex),
|
|
3864
|
+
record.value,
|
|
3865
|
+
Object.hasOwn(CLEANUP_PURPOSES, record.purpose) ? (CLEANUP_PURPOSES[record.purpose] ?? "") : "",
|
|
3866
|
+
]),
|
|
3867
|
+
];
|
|
3868
|
+
return [
|
|
3869
|
+
`9pm no longer needs these setup records for ${hostname}, checked just now. You can delete each one where your domain's DNS is managed, only while it still has the value shown:`,
|
|
3870
|
+
...renderColumns(rows).map((line) => ` ${line}`),
|
|
3871
|
+
"If one of these names now holds a different value, that record is not ours: leave it. Keep the record that points the domain at 9pm.",
|
|
3872
|
+
];
|
|
3873
|
+
}
|
|
3874
|
+
// Said wherever a removal is left to finish in the background: only the API's own cleanup may
|
|
3875
|
+
// name records to delete, and a removal that finishes there produces none.
|
|
3876
|
+
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.";
|
|
3877
|
+
// A removal that has stopped serving but is still finishing upstream: nothing is called removed
|
|
3878
|
+
// and no record is offered for deletion. A removal the background sweep finishes deletes the row
|
|
3879
|
+
// and returns no cleanup to anyone, so no later command is promised to list the records either.
|
|
3880
|
+
export function domainRemovalPendingLines(hostname, app, options) {
|
|
3881
|
+
const owner = options.owner ? ` --owner ${quoteSiteArg(options.owner)}` : "";
|
|
3882
|
+
return [
|
|
3883
|
+
`${app} no longer serves ${hostname}, but removing it upstream is taking longer than usual. It finishes on its own.`,
|
|
3884
|
+
`Once ${hostname} is no longer in this list, it has stopped serving from 9pm: 9pm domains list ${quoteSiteArg(app)}${owner}${apiUrlFlagFor(options.target)}`,
|
|
3885
|
+
DOMAIN_RELEASE_NO_CLEANUP,
|
|
3886
|
+
];
|
|
3887
|
+
}
|
|
3888
|
+
export async function moveDomain(options) {
|
|
3889
|
+
const hostname = normalizeDomainArg(options.hostname);
|
|
3890
|
+
const body = await domainsRequest(options, siteDomainsRoute(options, hostname, "move"), {
|
|
3891
|
+
method: "POST",
|
|
3892
|
+
body: JSON.stringify({ toSiteId: options.to.trim() }),
|
|
3683
3893
|
});
|
|
3684
|
-
|
|
3894
|
+
const fields = body && typeof body === "object" ? body : {};
|
|
3895
|
+
const slugOf = (side) => {
|
|
3896
|
+
const slug = side && typeof side === "object" ? side.slug : undefined;
|
|
3897
|
+
return typeof slug === "string" && slug !== "" ? slug : null;
|
|
3898
|
+
};
|
|
3899
|
+
return { body, hostname, from: slugOf(fields.from), to: slugOf(fields.to) };
|
|
3900
|
+
}
|
|
3901
|
+
// The app holding a hostname in this account, for `move` without a source app. From the stored
|
|
3902
|
+
// list (`reconcile=0`), so finding it costs no provider call; null when the account has no such
|
|
3903
|
+
// domain. Throws when the list cannot be read, which is not the same answer as "none".
|
|
3904
|
+
export async function findDomainApp(options) {
|
|
3905
|
+
const hostname = normalizeDomainArg(options.hostname);
|
|
3906
|
+
const query = new URLSearchParams({ reconcile: "0", ...(options.owner ? { owner: options.owner } : {}) });
|
|
3907
|
+
const result = await domainsRequest(options, `/v1/domains?${query.toString()}`);
|
|
3908
|
+
if (!Array.isArray(result?.domains))
|
|
3909
|
+
throw new Error("the domains list came back in a shape this CLI does not read");
|
|
3910
|
+
for (const entry of result.domains) {
|
|
3911
|
+
if (!entry || typeof entry !== "object")
|
|
3912
|
+
continue;
|
|
3913
|
+
const domain = entry;
|
|
3914
|
+
if (typeof domain.hostname !== "string" || normalizeDnsName(domain.hostname) !== hostname)
|
|
3915
|
+
continue;
|
|
3916
|
+
const slug = domain.app && typeof domain.app === "object" ? domain.app.slug : undefined;
|
|
3917
|
+
if (typeof slug !== "string" || slug === "")
|
|
3918
|
+
throw new Error(`the domains list names no app for ${hostname}`);
|
|
3919
|
+
return slug;
|
|
3920
|
+
}
|
|
3921
|
+
return null;
|
|
3922
|
+
}
|
|
3923
|
+
// What to do about each move refusal, beside the server's own message. Printed commands carry
|
|
3924
|
+
// --owner and a non-default --api-url, never a credential.
|
|
3925
|
+
export function domainMoveRefusalLines(error, move, options) {
|
|
3926
|
+
const owner = options.owner ? ` --owner ${quoteSiteArg(options.owner)}` : "";
|
|
3927
|
+
const apiUrl = apiUrlFlagFor(options.target);
|
|
3928
|
+
const host = normalizeDomainArg(move.hostname);
|
|
3929
|
+
const list = (app) => `9pm domains list${app ? ` ${quoteSiteArg(app)}` : ""}${owner}${apiUrl}`;
|
|
3930
|
+
const removeHere = `9pm domains remove ${quoteSiteArg(move.site)} ${host} --confirm ${host}${owner}${apiUrl}`;
|
|
3931
|
+
const addThere = domainCommand("add", move.to, host, options);
|
|
3932
|
+
switch (error.code) {
|
|
3933
|
+
case "custom_domain_not_found":
|
|
3934
|
+
return [`${host} is not a custom domain of ${move.site}. See which app holds it: ${list()}`];
|
|
3935
|
+
case "project_not_found":
|
|
3936
|
+
return [`No app ${move.to} that you can open was found. List the apps: 9pm apps${owner}${apiUrl}`];
|
|
3937
|
+
case "custom_domain_quota_exceeded":
|
|
3938
|
+
return [`${move.to} already has as many custom domains as the plan allows. Remove one from it first: ${list(move.to)}`];
|
|
3939
|
+
case "plan_custom_domains_unavailable":
|
|
3940
|
+
return ["The plan this account is on includes no custom domains, so no app in it can take one."];
|
|
3941
|
+
case "custom_domain_release_pending":
|
|
3942
|
+
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)}`];
|
|
3943
|
+
case "custom_domain_move_not_provisioned":
|
|
3944
|
+
if (error.reason === "source_service_mode") {
|
|
3945
|
+
return [
|
|
3946
|
+
`${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:`,
|
|
3947
|
+
` ${removeHere}`,
|
|
3948
|
+
` ${addThere}`,
|
|
3949
|
+
];
|
|
3950
|
+
}
|
|
3951
|
+
return [
|
|
3952
|
+
`${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:`,
|
|
3953
|
+
` ${removeHere}`,
|
|
3954
|
+
` ${addThere}`,
|
|
3955
|
+
];
|
|
3956
|
+
case "custom_domain_move_cross_account":
|
|
3957
|
+
return [
|
|
3958
|
+
`${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:`,
|
|
3959
|
+
` ${removeHere}`,
|
|
3960
|
+
];
|
|
3961
|
+
case "custom_domains_public_only":
|
|
3962
|
+
return [`${move.to} uses service tokens, which can't hold a custom domain. Choose another app, or change its access mode first.`];
|
|
3963
|
+
case "custom_domain_redeploy_required":
|
|
3964
|
+
return [`Redeploy ${move.to} once, then run the move again.`];
|
|
3965
|
+
case "custom_domain_expired":
|
|
3966
|
+
return [`${host} expired before its DNS records were published. Add it to ${move.to} instead: ${addThere}`];
|
|
3967
|
+
case "custom_domain_move_conflict":
|
|
3968
|
+
return [`Something changed while the domain was moving. See where it is now: ${list()}`];
|
|
3969
|
+
case "custom_domain_move_same_app":
|
|
3970
|
+
return [`${host} already belongs to ${move.site}; nothing to move.`];
|
|
3971
|
+
default:
|
|
3972
|
+
return [];
|
|
3973
|
+
}
|
|
3685
3974
|
}
|
|
3686
3975
|
// The domains read is a reconcile plus a DNS lookup per record, so it can be slow; the deploy
|
|
3687
3976
|
// has already succeeded and must not wait on it for longer than this.
|
|
@@ -3743,6 +4032,9 @@ export function deployCustomDomainLines(statuses, slug, owner, target) {
|
|
|
3743
4032
|
];
|
|
3744
4033
|
}
|
|
3745
4034
|
return statuses.map((status) => {
|
|
4035
|
+
// A removal still finishing reads as whatever state the row last held, "live" included.
|
|
4036
|
+
if (status.removing)
|
|
4037
|
+
return `Custom domain ${status.hostname} is being removed.`;
|
|
3746
4038
|
if (status.state === "live")
|
|
3747
4039
|
return `Also serving at https://${status.hostname}`;
|
|
3748
4040
|
const notLive = status.state === "failed" || status.state === "expired" ? "is not live" : "is not live yet";
|
|
@@ -3762,6 +4054,9 @@ export async function waitForDomain(options) {
|
|
|
3762
4054
|
const status = await getDomainStatus(options);
|
|
3763
4055
|
last = status;
|
|
3764
4056
|
options.onStatus?.(status);
|
|
4057
|
+
// Being removed: it will not go live, whatever state the row last held.
|
|
4058
|
+
if (status.removing)
|
|
4059
|
+
return { status, exitCode: DOMAIN_WAIT_EXIT.failed, timedOut: false };
|
|
3765
4060
|
if (status.state === "live")
|
|
3766
4061
|
return { status, exitCode: DOMAIN_WAIT_EXIT.live, timedOut: false };
|
|
3767
4062
|
if (status.state === "failed" || status.state === "expired")
|
|
@@ -3787,7 +4082,7 @@ export async function waitForDomain(options) {
|
|
|
3787
4082
|
await sleepFn(Math.min(options.intervalMs, remaining));
|
|
3788
4083
|
}
|
|
3789
4084
|
}
|
|
3790
|
-
const DOMAINS_VALUE_FLAGS = new Set(["token", "api-url", "owner", "timeout", "interval", "confirm"]);
|
|
4085
|
+
const DOMAINS_VALUE_FLAGS = new Set(["token", "api-url", "owner", "timeout", "interval", "confirm", "to"]);
|
|
3791
4086
|
const DOMAINS_BOOLEAN_FLAGS = new Set(["json"]);
|
|
3792
4087
|
function domainsPositionals(argv) {
|
|
3793
4088
|
const positionals = [];
|
|
@@ -3813,7 +4108,9 @@ const DOMAINS_USAGE = {
|
|
|
3813
4108
|
check: "9pm domains check <app> <hostname> [--owner <organisation>] [--json]",
|
|
3814
4109
|
wait: "9pm domains wait <app> <hostname> [--timeout 30m] [--interval 20s] [--owner <organisation>] [--json]",
|
|
3815
4110
|
restart: "9pm domains restart <app> <hostname> [--owner <organisation>] (for a failed certificate; the record values may change)",
|
|
3816
|
-
|
|
4111
|
+
cleanup: "9pm domains cleanup <app> <hostname> [--owner <organisation>] [--json] (setup records a live domain no longer needs)",
|
|
4112
|
+
remove: "9pm domains remove <app> <hostname> --confirm <hostname> [--owner <organisation>] [--json]",
|
|
4113
|
+
move: "9pm domains move [<app>] <hostname> --to <app> [--owner <organisation>] [--json]",
|
|
3817
4114
|
};
|
|
3818
4115
|
function parseDomainsArgs(argv) {
|
|
3819
4116
|
const subcommand = argv[0] === "ls" ? "list" : argv[0] === "rm" ? "remove" : argv[0];
|
|
@@ -3841,6 +4138,13 @@ function parseDomainsArgs(argv) {
|
|
|
3841
4138
|
const site = positionals[0];
|
|
3842
4139
|
return withBase({ kind: "list", json, ...(site ? { site } : {}) });
|
|
3843
4140
|
}
|
|
4141
|
+
if (subcommand === "move") {
|
|
4142
|
+
const to = readStringFlag(flags, "to");
|
|
4143
|
+
const [first, second] = positionals;
|
|
4144
|
+
if (!to || !first || positionals.length > 2)
|
|
4145
|
+
throw new Error(`Usage: ${usage}`);
|
|
4146
|
+
return withBase(second === undefined ? { kind: "move", hostname: first, to, json } : { kind: "move", site: first, hostname: second, to, json });
|
|
4147
|
+
}
|
|
3844
4148
|
const [site, hostname] = positionals;
|
|
3845
4149
|
if (!site || !hostname || positionals.length > 2) {
|
|
3846
4150
|
throw new Error(`Usage: ${usage}`);
|
|
@@ -3860,11 +4164,14 @@ function parseDomainsArgs(argv) {
|
|
|
3860
4164
|
if (subcommand === "restart") {
|
|
3861
4165
|
return withBase({ kind: "restart", site, hostname });
|
|
3862
4166
|
}
|
|
4167
|
+
if (subcommand === "cleanup") {
|
|
4168
|
+
return withBase({ kind: "cleanup", site, hostname, json });
|
|
4169
|
+
}
|
|
3863
4170
|
const confirm = readStringFlag(flags, "confirm");
|
|
3864
4171
|
if (!confirm || normalizeDomainArg(confirm) !== normalizeDomainArg(hostname)) {
|
|
3865
4172
|
throw new Error(`Removing a domain stops it serving at once. Pass --confirm ${normalizeDomainArg(hostname)} to remove it.`);
|
|
3866
4173
|
}
|
|
3867
|
-
return withBase({ kind: "remove", site, hostname });
|
|
4174
|
+
return withBase({ kind: "remove", site, hostname, json });
|
|
3868
4175
|
}
|
|
3869
4176
|
function printDomainStatus(status, json, options, heading) {
|
|
3870
4177
|
if (json) {
|
|
@@ -3897,6 +4204,10 @@ async function runDomainsCommand(command) {
|
|
|
3897
4204
|
console.log(line);
|
|
3898
4205
|
return;
|
|
3899
4206
|
}
|
|
4207
|
+
if (action.kind === "move") {
|
|
4208
|
+
await runDomainMove(action, base, format);
|
|
4209
|
+
return;
|
|
4210
|
+
}
|
|
3900
4211
|
const target = { ...base, site: action.site, hostname: action.hostname };
|
|
3901
4212
|
if (action.kind === "add") {
|
|
3902
4213
|
const added = await addDomain(target);
|
|
@@ -3915,6 +4226,28 @@ async function runDomainsCommand(command) {
|
|
|
3915
4226
|
printDomainStatus(await getDomainStatus(target), action.json, format);
|
|
3916
4227
|
return;
|
|
3917
4228
|
}
|
|
4229
|
+
if (action.kind === "cleanup") {
|
|
4230
|
+
let body;
|
|
4231
|
+
try {
|
|
4232
|
+
body = await getDomainSetupCleanup(target);
|
|
4233
|
+
}
|
|
4234
|
+
catch (error) {
|
|
4235
|
+
// A server from before #2089 has no such route; it lists nothing, so neither does this.
|
|
4236
|
+
if (error instanceof DomainsApiError && error.status === 404 && error.code === "not_found") {
|
|
4237
|
+
console.error("This server can't list setup records to delete yet, so none is listed.");
|
|
4238
|
+
process.exitCode = 1;
|
|
4239
|
+
return;
|
|
4240
|
+
}
|
|
4241
|
+
throw error;
|
|
4242
|
+
}
|
|
4243
|
+
if (action.json) {
|
|
4244
|
+
console.log(JSON.stringify(body, null, 2));
|
|
4245
|
+
return;
|
|
4246
|
+
}
|
|
4247
|
+
for (const line of domainSetupCleanupLines(body, normalizeDomainArg(action.hostname)))
|
|
4248
|
+
console.log(line);
|
|
4249
|
+
return;
|
|
4250
|
+
}
|
|
3918
4251
|
if (action.kind === "check") {
|
|
3919
4252
|
await recheckDomain(target);
|
|
3920
4253
|
printDomainStatus(await getDomainStatus(target), action.json, format);
|
|
@@ -3950,7 +4283,7 @@ async function runDomainsCommand(command) {
|
|
|
3950
4283
|
const restarted = await restartDomain(target);
|
|
3951
4284
|
console.log(`Started validation for ${restarted.domain.hostname} over.`);
|
|
3952
4285
|
if (restarted.replacedInstructions.length > 0) {
|
|
3953
|
-
console.log("These values no longer count
|
|
4286
|
+
console.log("These values no longer count. At your DNS provider, delete each one only while it still has the value shown; a different value at that name is not ours:");
|
|
3954
4287
|
for (const line of instructionLines(restarted.replacedInstructions, restarted.domain.hostname))
|
|
3955
4288
|
console.log(line);
|
|
3956
4289
|
}
|
|
@@ -3962,15 +4295,63 @@ async function runDomainsCommand(command) {
|
|
|
3962
4295
|
console.log(line);
|
|
3963
4296
|
return;
|
|
3964
4297
|
}
|
|
3965
|
-
const
|
|
3966
|
-
|
|
3967
|
-
|
|
3968
|
-
|
|
3969
|
-
|
|
3970
|
-
|
|
4298
|
+
const removal = await removeDomain(target);
|
|
4299
|
+
if (action.json) {
|
|
4300
|
+
console.log(JSON.stringify(removal.body, null, 2));
|
|
4301
|
+
return;
|
|
4302
|
+
}
|
|
4303
|
+
if (removal.releasing) {
|
|
4304
|
+
console.log(`Removing ${removal.hostname} from ${action.site} is still finishing.`);
|
|
4305
|
+
for (const line of domainRemovalPendingLines(removal.hostname, action.site, format))
|
|
4306
|
+
console.log(line);
|
|
4307
|
+
return;
|
|
4308
|
+
}
|
|
4309
|
+
console.log(`Removed ${removal.hostname} from ${action.site}. It stops serving within seconds.`);
|
|
4310
|
+
if (removal.cleanup) {
|
|
4311
|
+
for (const line of domainCleanupLines(removal.cleanup))
|
|
3971
4312
|
console.log(line);
|
|
3972
4313
|
}
|
|
3973
4314
|
}
|
|
4315
|
+
async function runDomainMove(action, base, format) {
|
|
4316
|
+
const hostname = normalizeDomainArg(action.hostname);
|
|
4317
|
+
const owner = format.owner ? ` --owner ${quoteSiteArg(format.owner)}` : "";
|
|
4318
|
+
const apiUrl = apiUrlFlagFor(format.target);
|
|
4319
|
+
let site = action.site;
|
|
4320
|
+
if (!site) {
|
|
4321
|
+
let found;
|
|
4322
|
+
try {
|
|
4323
|
+
found = await findDomainApp({ ...base, hostname });
|
|
4324
|
+
}
|
|
4325
|
+
catch (error) {
|
|
4326
|
+
// Finding the app is a convenience in front of the move, which has not been tried.
|
|
4327
|
+
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}`);
|
|
4328
|
+
}
|
|
4329
|
+
if (!found) {
|
|
4330
|
+
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");
|
|
4331
|
+
}
|
|
4332
|
+
site = found;
|
|
4333
|
+
}
|
|
4334
|
+
let moved;
|
|
4335
|
+
try {
|
|
4336
|
+
moved = await moveDomain({ ...base, site, hostname, to: action.to });
|
|
4337
|
+
}
|
|
4338
|
+
catch (error) {
|
|
4339
|
+
if (error instanceof DomainsApiError) {
|
|
4340
|
+
const advice = domainMoveRefusalLines(error, { site, hostname, to: action.to }, format);
|
|
4341
|
+
if (advice.length > 0)
|
|
4342
|
+
throw new DomainsApiError([error.message, ...advice].join("\n"), error.status, error.code, error.reason);
|
|
4343
|
+
}
|
|
4344
|
+
throw error;
|
|
4345
|
+
}
|
|
4346
|
+
if (action.json) {
|
|
4347
|
+
console.log(JSON.stringify(moved.body, null, 2));
|
|
4348
|
+
return;
|
|
4349
|
+
}
|
|
4350
|
+
const to = moved.to ?? action.to;
|
|
4351
|
+
console.log(`Moved ${moved.hostname} from ${moved.from ?? site} to ${to}.`);
|
|
4352
|
+
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.");
|
|
4353
|
+
console.log(`Check it: ${domainCommand("status", to, moved.hostname, format)}`);
|
|
4354
|
+
}
|
|
3974
4355
|
// #807 step 5: the claim behind a health verdict, in words. A bare `unknown` reads
|
|
3975
4356
|
// as "fine"; the reason says which statement the platform is actually making.
|
|
3976
4357
|
export function healthWithReason(status, reason) {
|
|
@@ -5582,10 +5963,15 @@ async function main() {
|
|
|
5582
5963
|
confirmSlug: command.confirmSlug,
|
|
5583
5964
|
});
|
|
5584
5965
|
console.log(`Deleted ${deleted.slug}.`);
|
|
5966
|
+
if (deletedAppStopsServingQuickly(deleted)) {
|
|
5967
|
+
console.log(DELETE_SERVING_BOUND_LINE);
|
|
5968
|
+
}
|
|
5585
5969
|
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
5970
|
if (deleted.customDomainNames && deleted.customDomainNames.length > 0) {
|
|
5587
5971
|
console.log(`Removed custom domain(s): ${deleted.customDomainNames.join(", ")}.`);
|
|
5588
5972
|
}
|
|
5973
|
+
for (const line of deletedDomainLines(deleted))
|
|
5974
|
+
console.log(line);
|
|
5589
5975
|
// #722: a partial external-cleanup failure never fails the delete, and used to be
|
|
5590
5976
|
// silently discarded here — visible only in the raw JSON, if a caller happened to
|
|
5591
5977
|
// read it. Surfacing it is the whole reason a human running this command manually
|
|
@@ -5985,7 +6371,9 @@ function printUsage() {
|
|
|
5985
6371
|
9pm domains check <app> <hostname> [--json] re-check now, then print the status
|
|
5986
6372
|
9pm domains wait <app> <hostname> [--timeout 30m] [--interval 20s] [--json]
|
|
5987
6373
|
9pm domains restart <app> <hostname> for a failed certificate only: start validation over; record values may change
|
|
5988
|
-
9pm domains
|
|
6374
|
+
9pm domains cleanup <app> <hostname> [--json] on a live domain: setup records 9pm no longer needs, checked with the provider now
|
|
6375
|
+
9pm domains remove <app> <hostname> --confirm <hostname> [--json] prints the DNS records you can then delete
|
|
6376
|
+
9pm domains move [<app>] <hostname> --to <app> [--json] to another app of the same account; keeps its certificate and records
|
|
5989
6377
|
9pm access show <site>
|
|
5990
6378
|
9pm access public <site> [--switch-mode]
|
|
5991
6379
|
9pm access otp <site> [--switch-mode]
|
|
@@ -6038,10 +6426,10 @@ Missing pages (static sites and --entry worker apps):
|
|
|
6038
6426
|
|
|
6039
6427
|
Custom domains:
|
|
6040
6428
|
Every domains command takes --owner <organisation> for an organisation's app. list, status and
|
|
6041
|
-
wait only read; add, check, restart and
|
|
6429
|
+
wait only read; add, check, restart, remove and move need the configure role on the app.
|
|
6042
6430
|
--json prints {hostname, app, state, reason, next, routing, tls, provider, nameservers,
|
|
6043
6431
|
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
|
|
6432
|
+
checkedAt, removing}. state is one of live, waiting_for_dns, dns_unverified (routing is active but the
|
|
6045
6433
|
records could not be checked), issuing_certificate (every required record checked and found),
|
|
6046
6434
|
failed, expired. reason says why (reason.providerMessage is the provider's own text, when it
|
|
6047
6435
|
reported any, and its source: certificate_authority or hostname); next says who acts next (owner or platform) and what to do.
|
|
@@ -6051,7 +6439,11 @@ Custom domains:
|
|
|
6051
6439
|
wait exits 0 when live, 10 on timeout with something for the owner to do (records missing or
|
|
6052
6440
|
unverified, or a CAA record blocking the certificate), 11 on timeout with every record found and
|
|
6053
6441
|
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.
|
|
6442
|
+
expired, and 1 on a usage, auth or API error. A domain whose removal is still finishing shows
|
|
6443
|
+
as removing ("removing": true in --json), and wait on it exits 20.
|
|
6444
|
+
remove prints only the records the API says this domain used, each to delete only if its value
|
|
6445
|
+
is unchanged; while the removal is still finishing (a 202) it prints none. remove and move
|
|
6446
|
+
--json print the API's answer as it came.
|
|
6055
6447
|
|
|
6056
6448
|
Container deploys:
|
|
6057
6449
|
Requires Docker locally. The CLI builds linux/amd64, exports the image, uploads it through a short-lived session, then finalizes a queued deployment.
|