ninepm 0.79.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 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: add it as
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
 
@@ -258,13 +259,34 @@ Since 0.78.0 each record also carries `typeAtProvider`, `nameAtProvider` (`""` m
258
259
  field empty) and `valueAtProvider`: the record as a recognised DNS host's editor wants it.
259
260
  `typeAtProvider` is `null` for a root record the host cannot hold (GoDaddy, Route 53, IONOS, Wix);
260
261
  the text output then suggests connecting `www` instead. `list`, `status` and `wait`
261
- only read; `add`, `check`, `restart` and `remove` need permission to configure the app.
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.
262
282
 
263
283
  Every `9pm deploy` lists the app's custom domains under its `Deployed` line: `Also serving at
264
284
  https://<domain>` for a live one, and for any other its state and the `9pm domains status`
265
285
  command to run. If the domains cannot be read within a few seconds, the deploy says so in one
266
286
  line instead; the deploy itself is never held back by it. `9pm delete` names the custom domains
267
- 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.
268
290
 
269
291
  ## Commands
270
292
 
@@ -293,7 +315,8 @@ it removed.
293
315
  9pm domains check <app> <hostname> [--json]
294
316
  9pm domains wait <app> <hostname> [--timeout 30m] [--interval 20s] [--json]
295
317
  9pm domains restart <app> <hostname> # only after a certificate failed; record values may change
296
- 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]
297
320
  9pm access show <site>
298
321
  9pm access public <site> [--switch-mode]
299
322
  9pm access otp <site> [--switch-mode]
@@ -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;
@@ -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
- profile: { apex: { support: "alias", typeLabel: "ALIAS", hostField: "blank" }, appendsZone: true, notes: [], source: DNS_PROVIDER_SOURCE },
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,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.79.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;
@@ -801,7 +814,8 @@ export declare function formatDomainsList(statuses: DomainStatusJson[]): string[
801
814
  export declare class DomainsApiError extends Error {
802
815
  readonly status: number;
803
816
  readonly code: string | null;
804
- constructor(message: string, status: number, code: string | null);
817
+ readonly reason: string | null;
818
+ constructor(message: string, status: number, code: string | null, reason?: string | null);
805
819
  }
806
820
  export declare function domainsApiError(status: number, text: string): DomainsApiError;
807
821
  export interface DomainCommandOptions extends ApiOptions {
@@ -830,9 +844,48 @@ export declare function restartDomain(options: DomainCommandOptions & {
830
844
  domain: CliDomain;
831
845
  replacedInstructions: CliDnsInstruction[];
832
846
  }>;
847
+ export interface DomainRemoval {
848
+ body: unknown;
849
+ hostname: string;
850
+ releasing: boolean;
851
+ cleanup: DomainCleanup | null;
852
+ }
833
853
  export declare function removeDomain(options: DomainCommandOptions & {
834
854
  hostname: string;
835
- }): Promise<CliDomain | null>;
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[];
836
889
  export declare const DEPLOY_DOMAINS_TIMEOUT_MS = 4500;
837
890
  export declare function deployCustomDomainStatuses(options: ApiOptions & {
838
891
  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
- export const CLI_VERSION = "0.79.0";
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",
@@ -3343,7 +3373,7 @@ function forUpTo(ttlSeconds) {
3343
3373
  function answerText(current) {
3344
3374
  return current.length > 0 ? current.join(", ") : "nothing";
3345
3375
  }
3346
- function recordStateText(record, routing) {
3376
+ function recordStateText(record, routing, parking = null) {
3347
3377
  if (record.ok === true)
3348
3378
  return "found";
3349
3379
  if (record.check === "resolvers_disagree") {
@@ -3381,7 +3411,7 @@ function recordStateText(record, routing) {
3381
3411
  if (record.check === "wildcard") {
3382
3412
  const name = record.nameAtProvider ?? record.name;
3383
3413
  // 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`;
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)}`;
3385
3415
  }
3386
3416
  if (record.current.length === 0)
3387
3417
  return "not published yet";
@@ -3391,7 +3421,16 @@ function recordStateText(record, routing) {
3391
3421
  const shown = record.current.join(", ");
3392
3422
  const others = (record.resolvers ?? []).filter((answer) => answer.current.join(", ") !== shown);
3393
3423
  const also = others.map((answer) => `; ${answer.resolver} answers ${answerText(answer.current)}`).join("");
3394
- return `currently ${shown}${also}${recent}: point it at the value shown`;
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`;
3395
3434
  }
3396
3435
  // A printed command carries --owner and, when the destination is not the built-in default,
3397
3436
  // --api-url: pasted into a shell without NINEPM_API_URL it would otherwise act on production.
@@ -3404,6 +3443,7 @@ function domainCommand(subcommand, app, hostname, options) {
3404
3443
  export const DNS_PROVIDER_NOTES = {
3405
3444
  gandiLiveDns: "At Gandi, an ALIAS works only with LiveDNS, and no A or AAAA record may stay at @ beside it.",
3406
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.",
3407
3447
  // Unverified, so never printed; its text is here so a leak would show.
3408
3448
  route53TxtQuotes: "In the Route 53 console, a TXT value may need to be entered in quotes.",
3409
3449
  };
@@ -3444,6 +3484,9 @@ function renderColumns(rows) {
3444
3484
  .trimEnd());
3445
3485
  }
3446
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
+ }
3447
3490
  const lines = [`${status.hostname} (app ${status.app}): ${domainStateLabel(status.state)}`];
3448
3491
  if (status.state === "live") {
3449
3492
  lines.push(`Serving at https://${status.hostname}`);
@@ -3485,10 +3528,12 @@ export function formatDomainStatus(status, options = {}) {
3485
3528
  ["TYPE", "NAME", "VALUE", "STATE"],
3486
3529
  ...shown.map((record) => [
3487
3530
  // null: a root the host has no record type for, shown only once routing is already active.
3488
- record.typeAtProvider ?? "-",
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 ?? "-"),
3489
3534
  record.nameAtProvider === "" ? "(leave empty)" : record.nameAtProvider,
3490
3535
  record.valueAtProvider,
3491
- recordStateText(record, status.routing),
3536
+ recordStateText(record, status.routing, profile ? parkingRecordFor(status.providerId, record) : null),
3492
3537
  ]),
3493
3538
  ];
3494
3539
  lines.push(...renderColumns(rows).map((line) => ` ${line}`));
@@ -3586,17 +3631,20 @@ export function formatDomainsList(statuses) {
3586
3631
  }
3587
3632
  return renderColumns([
3588
3633
  ["HOSTNAME", "APP", "STATE", "ROUTING", "TLS"],
3589
- ...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]),
3590
3635
  ]);
3591
3636
  }
3592
3637
  export class DomainsApiError extends Error {
3593
3638
  status;
3594
3639
  code;
3595
- constructor(message, status, code) {
3640
+ // The refusal's `details.reason`, where it carries one.
3641
+ reason;
3642
+ constructor(message, status, code, reason = null) {
3596
3643
  super(message);
3597
3644
  this.name = "DomainsApiError";
3598
3645
  this.status = status;
3599
3646
  this.code = code;
3647
+ this.reason = reason;
3600
3648
  }
3601
3649
  }
3602
3650
  // The API's own message and code, plus `details.reason` where the refusal carries one
@@ -3619,9 +3667,14 @@ export function domainsApiError(status, text) {
3619
3667
  const reason = details && typeof details === "object" && typeof details.reason === "string"
3620
3668
  ? details.reason
3621
3669
  : null;
3622
- return new DomainsApiError(`${message} (${code}${reason ? `, reason: ${reason}` : ""})`, status, code);
3670
+ return new DomainsApiError(`${message} (${code}${reason ? `, reason: ${reason}` : ""})`, status, code, reason);
3623
3671
  }
3624
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" }) {
3625
3678
  const fetchFn = options.fetchFn ?? fetch;
3626
3679
  const headers = new Headers(init.headers);
3627
3680
  headers.set("authorization", `Bearer ${options.token}`);
@@ -3633,7 +3686,7 @@ async function domainsRequest(options, route, init = { method: "GET" }) {
3633
3686
  if (!response.ok) {
3634
3687
  throw domainsApiError(response.status, await response.text());
3635
3688
  }
3636
- return (await response.json());
3689
+ return { status: response.status, body: (await response.json()) };
3637
3690
  }
3638
3691
  function ownerQuery(owner) {
3639
3692
  return owner ? `?owner=${encodeURIComponent(owner)}` : "";
@@ -3678,10 +3731,177 @@ export async function restartDomain(options) {
3678
3731
  return domainsRequest(options, siteDomainsRoute(options, normalizeDomainArg(options.hostname), "restart"), { method: "POST" });
3679
3732
  }
3680
3733
  export async function removeDomain(options) {
3681
- const result = await domainsRequest(options, siteDomainsRoute(options, normalizeDomainArg(options.hostname)), {
3682
- method: "DELETE",
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() }),
3683
3824
  });
3684
- return result.domain;
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
+ }
3685
3905
  }
3686
3906
  // The domains read is a reconcile plus a DNS lookup per record, so it can be slow; the deploy
3687
3907
  // has already succeeded and must not wait on it for longer than this.
@@ -3743,6 +3963,9 @@ export function deployCustomDomainLines(statuses, slug, owner, target) {
3743
3963
  ];
3744
3964
  }
3745
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.`;
3746
3969
  if (status.state === "live")
3747
3970
  return `Also serving at https://${status.hostname}`;
3748
3971
  const notLive = status.state === "failed" || status.state === "expired" ? "is not live" : "is not live yet";
@@ -3762,6 +3985,9 @@ export async function waitForDomain(options) {
3762
3985
  const status = await getDomainStatus(options);
3763
3986
  last = status;
3764
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 };
3765
3991
  if (status.state === "live")
3766
3992
  return { status, exitCode: DOMAIN_WAIT_EXIT.live, timedOut: false };
3767
3993
  if (status.state === "failed" || status.state === "expired")
@@ -3787,7 +4013,7 @@ export async function waitForDomain(options) {
3787
4013
  await sleepFn(Math.min(options.intervalMs, remaining));
3788
4014
  }
3789
4015
  }
3790
- 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"]);
3791
4017
  const DOMAINS_BOOLEAN_FLAGS = new Set(["json"]);
3792
4018
  function domainsPositionals(argv) {
3793
4019
  const positionals = [];
@@ -3813,7 +4039,8 @@ const DOMAINS_USAGE = {
3813
4039
  check: "9pm domains check <app> <hostname> [--owner <organisation>] [--json]",
3814
4040
  wait: "9pm domains wait <app> <hostname> [--timeout 30m] [--interval 20s] [--owner <organisation>] [--json]",
3815
4041
  restart: "9pm domains restart <app> <hostname> [--owner <organisation>] (for a failed certificate; the record values may change)",
3816
- 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]",
3817
4044
  };
3818
4045
  function parseDomainsArgs(argv) {
3819
4046
  const subcommand = argv[0] === "ls" ? "list" : argv[0] === "rm" ? "remove" : argv[0];
@@ -3841,6 +4068,13 @@ function parseDomainsArgs(argv) {
3841
4068
  const site = positionals[0];
3842
4069
  return withBase({ kind: "list", json, ...(site ? { site } : {}) });
3843
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
+ }
3844
4078
  const [site, hostname] = positionals;
3845
4079
  if (!site || !hostname || positionals.length > 2) {
3846
4080
  throw new Error(`Usage: ${usage}`);
@@ -3864,7 +4098,7 @@ function parseDomainsArgs(argv) {
3864
4098
  if (!confirm || normalizeDomainArg(confirm) !== normalizeDomainArg(hostname)) {
3865
4099
  throw new Error(`Removing a domain stops it serving at once. Pass --confirm ${normalizeDomainArg(hostname)} to remove it.`);
3866
4100
  }
3867
- return withBase({ kind: "remove", site, hostname });
4101
+ return withBase({ kind: "remove", site, hostname, json });
3868
4102
  }
3869
4103
  function printDomainStatus(status, json, options, heading) {
3870
4104
  if (json) {
@@ -3897,6 +4131,10 @@ async function runDomainsCommand(command) {
3897
4131
  console.log(line);
3898
4132
  return;
3899
4133
  }
4134
+ if (action.kind === "move") {
4135
+ await runDomainMove(action, base, format);
4136
+ return;
4137
+ }
3900
4138
  const target = { ...base, site: action.site, hostname: action.hostname };
3901
4139
  if (action.kind === "add") {
3902
4140
  const added = await addDomain(target);
@@ -3962,15 +4200,63 @@ async function runDomainsCommand(command) {
3962
4200
  console.log(line);
3963
4201
  return;
3964
4202
  }
3965
- const removed = await removeDomain(target);
3966
- const hostname = removed?.hostname ?? normalizeDomainArg(action.hostname);
3967
- console.log(`Removed ${hostname} from ${action.site}. It stops serving within seconds.`);
3968
- if (removed && removed.dnsInstructions.length > 0) {
3969
- console.log("You can now delete these records at your DNS provider, if nothing else uses them:");
3970
- for (const line of instructionLines(removed.dnsInstructions, hostname))
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))
3971
4217
  console.log(line);
3972
4218
  }
3973
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
+ }
3974
4260
  // #807 step 5: the claim behind a health verdict, in words. A bare `unknown` reads
3975
4261
  // as "fine"; the reason says which statement the platform is actually making.
3976
4262
  export function healthWithReason(status, reason) {
@@ -5586,6 +5872,8 @@ async function main() {
5586
5872
  if (deleted.customDomainNames && deleted.customDomainNames.length > 0) {
5587
5873
  console.log(`Removed custom domain(s): ${deleted.customDomainNames.join(", ")}.`);
5588
5874
  }
5875
+ for (const line of deletedDomainLines(deleted))
5876
+ console.log(line);
5589
5877
  // #722: a partial external-cleanup failure never fails the delete, and used to be
5590
5878
  // silently discarded here — visible only in the raw JSON, if a caller happened to
5591
5879
  // read it. Surfacing it is the whole reason a human running this command manually
@@ -5985,7 +6273,8 @@ function printUsage() {
5985
6273
  9pm domains check <app> <hostname> [--json] re-check now, then print the status
5986
6274
  9pm domains wait <app> <hostname> [--timeout 30m] [--interval 20s] [--json]
5987
6275
  9pm domains restart <app> <hostname> for a failed certificate only: start validation over; record values may change
5988
- 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
5989
6278
  9pm access show <site>
5990
6279
  9pm access public <site> [--switch-mode]
5991
6280
  9pm access otp <site> [--switch-mode]
@@ -6038,10 +6327,10 @@ Missing pages (static sites and --entry worker apps):
6038
6327
 
6039
6328
  Custom domains:
6040
6329
  Every domains command takes --owner <organisation> for an organisation's app. list, status and
6041
- wait only read; add, check, restart and remove need the configure role on the app.
6330
+ wait only read; add, check, restart, remove and move need the configure role on the app.
6042
6331
  --json prints {hostname, app, state, reason, next, routing, tls, provider, nameservers,
6043
6332
  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
6333
+ checkedAt, removing}. state is one of live, waiting_for_dns, dns_unverified (routing is active but the
6045
6334
  records could not be checked), issuing_certificate (every required record checked and found),
6046
6335
  failed, expired. reason says why (reason.providerMessage is the provider's own text, when it
6047
6336
  reported any, and its source: certificate_authority or hostname); next says who acts next (owner or platform) and what to do.
@@ -6051,7 +6340,11 @@ Custom domains:
6051
6340
  wait exits 0 when live, 10 on timeout with something for the owner to do (records missing or
6052
6341
  unverified, or a CAA record blocking the certificate), 11 on timeout with every record found and
6053
6342
  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.
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.
6055
6348
 
6056
6349
  Container deploys:
6057
6350
  Requires Docker locally. The CLI builds linux/amd64, exports the image, uploads it through a short-lived session, then finalizes a queued deployment.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ninepm",
3
- "version": "0.79.0",
3
+ "version": "0.81.0",
4
4
  "description": "CLI for deploying built apps to 9pm.ai.",
5
5
  "homepage": "https://9pm.ai",
6
6
  "keywords": [