ninepm 0.85.0 → 0.92.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
@@ -163,7 +163,7 @@ 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.
166
+ `9pm delete` also prints `Its address stops serving the app within about a minute.` when the delete went the way whose timing has been measured: a static or Worker app, or a container app on the default (non-persistent) placement, with no custom domain. Not every such container app qualifies, so go by the line, not the app type. When the line is missing (an app with a custom domain, a container app with a volume, always-on or custom CPU/memory, some other container apps, or a cleanup step that reported an error), how long the address keeps serving has not been measured, so check the address yourself.
167
167
 
168
168
  ## Access Modes
169
169
 
@@ -240,9 +240,9 @@ tls, provider, providerId, nameservers, zone, records, advisories, error, expire
240
240
  the DNS host (where the nameservers point, not necessarily where the domain was bought), `reason` says why
241
241
  (`reason.providerMessage` carries the provider's own words when it reported any, with `source`
242
242
  saying whether they are the certificate authority's),
243
- `next` says who acts next and what to do, and `state` is `live`,
243
+ `next` says who acts next and what to do, and `state` is `live`, `ready_to_switch`,
244
244
  `waiting_for_dns`, `dns_unverified` (routing is active but the records could not be checked),
245
- `issuing_certificate`, `failed` or `expired`. Each record's `ok` is `null` when the check cannot
245
+ `issuing_certificate`, `points_elsewhere`, `failed` or `expired`. Each record's `ok` is `null` when the check cannot
246
246
  say, and its `check` says why (`not_checked`, or `apex_addresses`). A record whose value was found
247
247
  at the name with the domain typed twice (`_acme-challenge.www.example.com.example.com`) has
248
248
  `check: "doubled"` and `doubledAt`: enter only the short name shown. Since 0.79.0, against a server
@@ -252,7 +252,14 @@ name (the wildcard can stay; a TXT value is always added beside the values alrea
252
252
  `resolvers` lists what each public resolver answered when two were asked, and when one shows the
253
253
  expected value and another does not, the record is `check: "resolvers_disagree"` with `ok: null`:
254
254
  neither cache proves which answer is current, so confirm the record at your DNS host and check again.
255
- A record that does not match carries `ttlSeconds`, how long that answer may stay cached. A root
255
+ A record that does not match carries `ttlSeconds`, how long that answer may stay cached. Since
256
+ 0.92.0, against a server that sends it, a record whose DNS host is GoDaddy, Namecheap, Google Cloud
257
+ DNS or NS1 can carry `authoritative`: what the host's own nameserver answered when asked directly
258
+ (`outcome: "answered"` with `nameserver`, `current` and `matches`, or `outcome: "unverified"` when
259
+ that read failed). The row then says "your DNS host publishes …", or "your DNS host: unable to
260
+ verify directly"; a failed read never means the record is missing. It only explains the row: `ok`,
261
+ `check` and the state stay the public resolvers' verdict either way. Other hosts get no such
262
+ field. A root
256
263
  answering with addresses while routing is pending asks you to confirm the ALIAS or ANAME target,
257
264
  since a parking page answers with addresses too. With every record found, the text names the
258
265
  platform's next automatic check from `next.nextCheckAt`. `advisories` lists hints that
@@ -263,6 +270,24 @@ field empty) and `valueAtProvider`: the record as a recognised DNS host's editor
263
270
  the text output then suggests connecting `www` instead. `list`, `status` and `wait`
264
271
  only read; `add`, `check`, `restart`, `remove` and `move` need permission to configure the app.
265
272
 
273
+ Since 0.89.0, against a server that sends them, two states cover moving a domain between hosts.
274
+ `ready_to_switch`: routing and the certificate are active, so the platform could serve the domain,
275
+ but its routing record still leads to another host and visitors still reach that host. The text
276
+ names the record and the value to change it to, `next` is `replace_record`, and `wait` keeps
277
+ waiting until it is `live`. `points_elsewhere`: the domain was live and its DNS now leads to another
278
+ host, so the provider stopped routing it. `next` is `remove`: remove the domain here once visitors
279
+ reach the new host. If the move was a mistake, change the record back and run `9pm domains restart`:
280
+ the provider keeps a moved hostname moved until its validation is restarted, so `check` alone does
281
+ not recover it. Once the record points back, the state is `failed` with `reason.code`
282
+ `moved_needs_restart` and `next.action` `start_over`, and the text prints the `restart` and `wait`
283
+ commands; a blocked or deleted hostname never gets this step. Publish any new TXT values `restart` prints, then `wait`. The provider deletes a
284
+ hostname left moved for 7 days. `wait` stops at once with `10` on this state. Either state's routing record carries `elsewhere`, the host it leads to. The platform reports
285
+ these only on strong evidence: two public resolvers agree on a record of the name's own that leads,
286
+ through its whole CNAME chain, to a host outside both your zone and the platform. A root answering
287
+ with addresses, a wildcard's answer, resolvers that disagree, or a check that could not run leave
288
+ the domain in the state it had (`live`, or `failed` for one the provider stopped routing). An older
289
+ CLI prints these state names as they are.
290
+
266
291
  `9pm domains remove` prints only the records the API says the domain used, each to delete only if
267
292
  its value is unchanged: a name that now holds a different value (the domain points somewhere new, or
268
293
  another service's certificate check uses `_acme-challenge`) is not ours, so leave it. Against a server
@@ -272,6 +297,21 @@ prints the `9pm domains list` command: once the domain is no longer listed, it h
272
297
  from 9pm. Such a domain shows as `removing` in `list` and `status` (`"removing": true` in `--json`),
273
298
  and `wait` on it exits `20`. Since 0.80.0.
274
299
 
300
+ Since 0.86.0, a removal that finished (from `remove` or `9pm delete`) also says that after removal,
301
+ visitors may see a TLS or routing error while the domain's DNS still points at 9pm. It names no
302
+ record and adds no deletion advice of its own: what may be deleted is only the cleanup list printed
303
+ above it, each record only if its value still matches. A removal still finishing gets no such line,
304
+ since it is told to leave its records in place. A server that sends no cleanup list still gets it.
305
+
306
+ Since 0.87.0, that line is followed by the order the staging rehearsal (#2182) demonstrated for a
307
+ move to another host: point DNS at the new host, wait for the DNS TTL to pass, then remove the domain
308
+ from 9pm. While caches update, some visitors may still reach 9pm or briefly see an error from the new
309
+ host, and once DNS points away 9pm shows the domain as pointing elsewhere (before 0.89.0, or
310
+ against an older server, as failed), which is expected. It links the
311
+ custom-domain guide's "Moving a domain off 9pm" section on the site beside the control plane it
312
+ talked to (staging's guide for `--api-url https://api-staging.9pm.ai`). A move to your own Cloudflare
313
+ DNS zone was not rehearsed, so no order is printed for it; the guide says to contact support first.
314
+
275
315
  Since 0.84.0, `9pm domains removed [--owner <organisation>]` lists the DNS records you may delete
276
316
  after a removal that finished in the background: a `remove` that answered `releasing`, or a domain of
277
317
  a deleted app, finished later by the platform. It is the same list `remove` prints when it finishes
@@ -1,5 +1,5 @@
1
1
  export declare const DEFAULT_API_URL = "https://api.9pm.ai";
2
2
  export declare const CLI_CLIENT_ID = "ninepm";
3
3
  export declare const API_URL_HELP = "Control plane API URL";
4
- export declare const API_KEY_HELP = "API key from 9pm.ai; overrides stored login";
4
+ export declare const API_KEY_HELP = "API key from 9pm api-key create (or Settings > API keys); overrides stored login";
5
5
  export declare function reportCliError(error: unknown): void;
@@ -10,7 +10,7 @@ export const DEFAULT_API_URL = "https://api.9pm.ai";
10
10
  export const CLI_CLIENT_ID = "ninepm";
11
11
  // Help text for the NINEPM_API_URL / NINEPM_API_KEY environment variables.
12
12
  export const API_URL_HELP = "Control plane API URL";
13
- export const API_KEY_HELP = "API key from 9pm.ai; overrides stored login";
13
+ export const API_KEY_HELP = "API key from 9pm api-key create (or Settings > API keys); overrides stored login";
14
14
  // Render an error to stderr.
15
15
  export function reportCliError(error) {
16
16
  console.error(error instanceof Error ? error.message : error);
@@ -54,6 +54,28 @@ export interface DomainStateDnsRecord {
54
54
  ttlSeconds?: number;
55
55
  /** Each public resolver's answer, when a second one was asked. `matches` above is the first resolver's, so a disagreement is never a match. */
56
56
  resolvers?: DomainStateResolverAnswer[];
57
+ /**
58
+ * #2238: where a routing record leads instead of the platform - the last name of its CNAME
59
+ * chain, outside both the owner's zone and the platform's. The server sets it only on strong
60
+ * evidence: the answer is a name of the record's own (not addresses, not a wildcard's), two
61
+ * public resolvers both answered without the expected value, and every link of the chain was
62
+ * looked up. Absent from older servers, and whenever any of that is not so.
63
+ */
64
+ elsewhere?: string;
65
+ /** #2241: what the DNS host's own nameserver answered when asked directly. Absent from older servers and for hosts not read directly. */
66
+ authoritative?: DomainStateAuthoritativeAnswer;
67
+ }
68
+ /**
69
+ * A direct read from the DNS host's own nameserver (#2241), done only for the hosts it was
70
+ * measured on. It never decides anything: every verdict here (check, ok, state, reason, next) is
71
+ * the public resolvers', and this is shown beside it as "your DNS host publishes X". "unverified"
72
+ * means the read failed, and reads "unable to verify", never "nothing published".
73
+ */
74
+ export interface DomainStateAuthoritativeAnswer {
75
+ outcome: "answered" | "unverified";
76
+ nameserver?: string;
77
+ current?: string[];
78
+ matches?: boolean;
57
79
  }
58
80
  /** What the derivation reads: a domain as any of the domain endpoints returns it. */
59
81
  export interface DomainStateInput {
@@ -67,8 +89,9 @@ export interface DomainStateInput {
67
89
  providerMessage?: string | null;
68
90
  providerMessageAt?: string | null;
69
91
  providerMessageSource?: string | null;
92
+ routingFailure?: string | null;
70
93
  }
71
- export type DomainState = "live" | "waiting_for_dns" | "dns_unverified" | "issuing_certificate" | "failed" | "expired";
94
+ export type DomainState = "live" | "ready_to_switch" | "waiting_for_dns" | "dns_unverified" | "issuing_certificate" | "points_elsewhere" | "failed" | "expired";
72
95
  export type DomainRecordCheck = "found" | "missing" | "different" | "doubled" | "wildcard" | "resolvers_disagree" | "not_checked" | "apex_addresses";
73
96
  export interface DomainRecordStatus {
74
97
  type: string;
@@ -82,6 +105,8 @@ export interface DomainRecordStatus {
82
105
  wildcard?: string;
83
106
  ttlSeconds?: number;
84
107
  resolvers?: DomainStateResolverAnswer[];
108
+ elsewhere?: string;
109
+ authoritative?: DomainStateAuthoritativeAnswer;
85
110
  apex: boolean;
86
111
  optional: boolean;
87
112
  }
@@ -92,6 +117,20 @@ export declare function domainRecordCheck(seen: DomainStateDnsRecord | undefined
92
117
  * neither breaks a row nor counts towards a disagreement.
93
118
  */
94
119
  export declare function resolverAnswers(seen: DomainStateDnsRecord | undefined): DomainStateResolverAnswer[];
120
+ /**
121
+ * The direct read as the server reported it, or null when there was none. Only a complete
122
+ * answer (an "answered" outcome with its nameserver, values and match) reads as one; anything
123
+ * else that is present, an outcome this copy does not know included, reads as "unverified", so a
124
+ * malformed or newer entry can only ever say "unable to verify", never "nothing published".
125
+ */
126
+ export declare function authoritativeAnswer(seen: DomainStateDnsRecord | undefined): DomainStateAuthoritativeAnswer | null;
127
+ /**
128
+ * Whether a row shows its direct read. Shown wherever the resolvers did not find the record, so
129
+ * the owner sees what their host publishes beside what the caches still say; on a found record
130
+ * only when the host now publishes something else, since agreement and a failed read there would
131
+ * add nothing to "found".
132
+ */
133
+ export declare function showsAuthoritativeAnswer(record: Pick<DomainRecordStatus, "check" | "authoritative">): boolean;
95
134
  /** Whether one resolver shows the expected value while another shows something else. */
96
135
  export declare function resolversDisagree(seen: DomainStateDnsRecord | undefined): boolean;
97
136
  /** A cache's time left in whole minutes, at least one: "up to N min". */
@@ -113,8 +152,14 @@ export declare function rootUnsupportedWwwHostname(domain: DomainStateInput): st
113
152
  export declare function showsUseWww(next: {
114
153
  action?: string;
115
154
  } | null | undefined): boolean;
155
+ /**
156
+ * Where the routing record leads instead of the platform (#2238), or null. Every required
157
+ * routing record has to say so, so one record that is found, unread or ambiguous keeps the
158
+ * domain where it was. TXT records never route, so they are not asked.
159
+ */
160
+ export declare function routingLeadsElsewhere(records: DomainRecordStatus[]): string | null;
116
161
  export declare function deriveDomainState(domain: DomainStateInput, records?: DomainRecordStatus[]): DomainState;
117
- export type DomainReasonCode = "records_missing" | "record_points_elsewhere" | "dns_changed_after_live" | "dns_not_checked" | "apex_unverifiable" | "dns_resolvers_disagree" | "dns_host_cannot_point_root" | "caa_blocks" | "certificate_pending" | "certificate_failed" | "hostname_failed" | "service_mode" | "claimed_elsewhere" | "platform_not_configured" | "provider_error" | "expired";
162
+ export type DomainReasonCode = "records_missing" | "record_points_elsewhere" | "dns_changed_after_live" | "dns_not_checked" | "apex_unverifiable" | "dns_resolvers_disagree" | "dns_host_cannot_point_root" | "caa_blocks" | "certificate_pending" | "certificate_failed" | "hostname_failed" | "service_mode" | "claimed_elsewhere" | "platform_not_configured" | "provider_error" | "moved_needs_restart" | "domain_points_elsewhere" | "expired";
118
163
  export interface DomainReason {
119
164
  code: DomainReasonCode;
120
165
  /**
@@ -128,7 +173,7 @@ export interface DomainReason {
128
173
  source: "certificate_authority" | "hostname";
129
174
  };
130
175
  }
131
- export type DomainNextAction = "none" | "add_records" | "replace_record" | "confirm_records" | "use_www" | "allow_ca_in_your_zone" | "ask_caa_controller" | "check_caa" | "wait" | "start_over" | "change_access_mode" | "add_again" | "recheck" | "contact_support";
176
+ export type DomainNextAction = "none" | "add_records" | "replace_record" | "confirm_records" | "use_www" | "allow_ca_in_your_zone" | "ask_caa_controller" | "check_caa" | "wait" | "start_over" | "change_access_mode" | "add_again" | "recheck" | "remove" | "contact_support";
132
177
  export interface DomainNextRecord {
133
178
  type: string;
134
179
  host: string;
@@ -169,7 +214,9 @@ export interface DomainStatus {
169
214
  * The state, why, and the one next step. `state` is exactly deriveDomainState's; `reason` and
170
215
  * `next` only refine it. Two rules hold throughout: a record nobody looked at is never called
171
216
  * missing (it gets confirm_records, never add), and nothing here restarts validation or
172
- * suggests that a restart speeds things up - Start over is named only for a failed certificate.
217
+ * suggests that a restart speeds things up - Start over is named only for a failed certificate,
218
+ * and for a hostname the provider reports moved (points_elsewhere, or moved_needs_restart once
219
+ * its DNS no longer leads elsewhere), which stays moved until its validation is restarted (#2238).
173
220
  */
174
221
  export declare function deriveDomainStatus(domain: DomainStateInput, options?: {
175
222
  nextCheckAt?: string | null;
@@ -1,6 +1,6 @@
1
1
  // AUTO-GENERATED by scripts/generate-cli-package.mjs from the in-repo CLI source -- do not edit.
2
2
  // Edit src/cli or src/shared instead; this is regenerated on typecheck/test/build/pack.
3
- // One plain-language status for a custom domain (#1994): which of six states it is in, why,
3
+ // One plain-language status for a custom domain (#1994): which of eight states it is in, why,
4
4
  // and the one thing that happens next. The control plane computes it after its DNS check and
5
5
  // sends it on every domain; the CLI and the dashboard derive it from this same file when they
6
6
  // meet a server that predates the field, so the two paths cannot disagree. Copied into the CLI
@@ -50,6 +50,7 @@ export function domainRecordStatuses(domain) {
50
50
  const atApex = type === "CNAME" && fqdn === apex;
51
51
  const check = domainRecordCheck(seen, atApex);
52
52
  const resolvers = resolverAnswers(seen);
53
+ const direct = authoritativeAnswer(seen);
53
54
  const ttl = seen?.ttlSeconds;
54
55
  return {
55
56
  type,
@@ -61,8 +62,10 @@ export function domainRecordStatuses(domain) {
61
62
  check,
62
63
  ...(check === "doubled" && seen?.doubledAt ? { doubledAt: seen.doubledAt } : {}),
63
64
  ...(check === "wildcard" && seen?.wildcard ? { wildcard: seen.wildcard } : {}),
65
+ ...(check === "different" && type !== "TXT" && typeof seen?.elsewhere === "string" && seen.elsewhere !== "" ? { elsewhere: seen.elsewhere } : {}),
64
66
  ...(check !== "found" && typeof ttl === "number" && Number.isFinite(ttl) && ttl >= 0 ? { ttlSeconds: ttl } : {}),
65
67
  ...(resolvers.length > 0 ? { resolvers } : {}),
68
+ ...(direct ? { authoritative: direct } : {}),
66
69
  apex: atApex,
67
70
  optional: instruction.optional === true,
68
71
  };
@@ -110,6 +113,43 @@ export function resolverAnswers(seen) {
110
113
  return [{ resolver: entry.resolver, current, matches: entry.matches, ...(typeof ttl === "number" && Number.isFinite(ttl) && ttl >= 0 ? { ttlSeconds: ttl } : {}) }];
111
114
  });
112
115
  }
116
+ /**
117
+ * The direct read as the server reported it, or null when there was none. Only a complete
118
+ * answer (an "answered" outcome with its nameserver, values and match) reads as one; anything
119
+ * else that is present, an outcome this copy does not know included, reads as "unverified", so a
120
+ * malformed or newer entry can only ever say "unable to verify", never "nothing published".
121
+ */
122
+ export function authoritativeAnswer(seen) {
123
+ const raw = seen?.authoritative;
124
+ if (raw === undefined || raw === null)
125
+ return null;
126
+ if (typeof raw !== "object")
127
+ return { outcome: "unverified" };
128
+ const entry = raw;
129
+ if (entry.outcome === "answered" &&
130
+ typeof entry.nameserver === "string" &&
131
+ entry.nameserver !== "" &&
132
+ Array.isArray(entry.current) &&
133
+ entry.current.every((value) => typeof value === "string") &&
134
+ typeof entry.matches === "boolean") {
135
+ return { outcome: "answered", nameserver: entry.nameserver, current: [...entry.current], matches: entry.matches };
136
+ }
137
+ return { outcome: "unverified" };
138
+ }
139
+ /**
140
+ * Whether a row shows its direct read. Shown wherever the resolvers did not find the record, so
141
+ * the owner sees what their host publishes beside what the caches still say; on a found record
142
+ * only when the host now publishes something else, since agreement and a failed read there would
143
+ * add nothing to "found".
144
+ */
145
+ export function showsAuthoritativeAnswer(record) {
146
+ const direct = record.authoritative;
147
+ if (!direct)
148
+ return false;
149
+ if (record.check !== "found")
150
+ return true;
151
+ return direct.outcome === "answered" && direct.matches === false;
152
+ }
113
153
  /** Whether one resolver shows the expected value while another shows something else. */
114
154
  export function resolversDisagree(seen) {
115
155
  const answers = resolverAnswers(seen);
@@ -152,13 +192,33 @@ export function rootUnsupportedWwwHostname(domain) {
152
192
  export function showsUseWww(next) {
153
193
  return next?.action === "use_www";
154
194
  }
195
+ /**
196
+ * Where the routing record leads instead of the platform (#2238), or null. Every required
197
+ * routing record has to say so, so one record that is found, unread or ambiguous keeps the
198
+ * domain where it was. TXT records never route, so they are not asked.
199
+ */
200
+ export function routingLeadsElsewhere(records) {
201
+ const routing = records.filter((record) => !record.optional && record.type !== "TXT");
202
+ if (routing.length === 0)
203
+ return null;
204
+ // `elsewhere` is set only on a record checked `different` (domainRecordStatuses), so a found,
205
+ // wildcard, doubled or disagreeing record never carries it.
206
+ if (!routing.every((record) => typeof record.elsewhere === "string" && record.elsewhere !== ""))
207
+ return null;
208
+ return routing[0].elsewhere ?? null;
209
+ }
155
210
  export function deriveDomainState(domain, records = domainRecordStatuses(domain)) {
156
211
  if (domain.provisioningError === CUSTOM_DOMAIN_EXPIRED_MESSAGE)
157
212
  return "expired";
213
+ const elsewhere = routingLeadsElsewhere(records);
214
+ // Keyed on the provider's own "moved", not on routing failed alone: a blocked or deleted
215
+ // hostname, or routing failed while the record still leads here, is a real failure.
216
+ if (elsewhere && domain.routingStatus === "failed" && domain.routingFailure === "moved" && !domain.provisioningError)
217
+ return "points_elsewhere";
158
218
  if (domain.routingStatus === "failed" || domain.tlsStatus === "failed" || domain.provisioningError)
159
219
  return "failed";
160
220
  if (domain.routingStatus === "active" && domain.tlsStatus === "active")
161
- return "live";
221
+ return elsewhere ? "ready_to_switch" : "live";
162
222
  // No record the owner can publish will finish this domain: it waits on the owner choosing www.
163
223
  if (rootUnsupportedWwwHostname(domain))
164
224
  return "waiting_for_dns";
@@ -205,7 +265,9 @@ function caaNext(caa) {
205
265
  * The state, why, and the one next step. `state` is exactly deriveDomainState's; `reason` and
206
266
  * `next` only refine it. Two rules hold throughout: a record nobody looked at is never called
207
267
  * missing (it gets confirm_records, never add), and nothing here restarts validation or
208
- * suggests that a restart speeds things up - Start over is named only for a failed certificate.
268
+ * suggests that a restart speeds things up - Start over is named only for a failed certificate,
269
+ * and for a hostname the provider reports moved (points_elsewhere, or moved_needs_restart once
270
+ * its DNS no longer leads elsewhere), which stays moved until its validation is restarted (#2238).
209
271
  */
210
272
  export function deriveDomainStatus(domain, options = {}) {
211
273
  const records = domainRecordStatuses(domain);
@@ -221,6 +283,14 @@ export function deriveDomainStatus(domain, options = {}) {
221
283
  return { state, reason: withMessage("expired"), next: { who: "owner", action: "add_again" } };
222
284
  if (state === "live")
223
285
  return { state, reason: null, next: { action: "none" } };
286
+ if (state === "points_elsewhere")
287
+ return { state, reason: withMessage("domain_points_elsewhere"), next: { who: "owner", action: "remove" } };
288
+ if (state === "ready_to_switch") {
289
+ // The same reason and action a pending domain with a record pointing elsewhere gets, so a
290
+ // client that does not know this state still says which record to change, and to what.
291
+ const routing = required.filter((record) => record.type !== "TXT" && record.check === "different").map(nextRecord);
292
+ return { state, reason: withMessage("record_points_elsewhere"), next: { who: "owner", action: "replace_record", records: routing } };
293
+ }
224
294
  if (state === "failed") {
225
295
  const error = domain.provisioningError;
226
296
  if (isServiceModeProvisioningError(error)) {
@@ -232,6 +302,12 @@ export function deriveDomainStatus(domain, options = {}) {
232
302
  if (error === CUSTOM_DOMAIN_ROUTE_CONFLICT_MESSAGE) {
233
303
  return { state, reason: withMessage("claimed_elsewhere"), next: { who: "platform", action: "contact_support" } };
234
304
  }
305
+ // Cloudflare keeps a moved hostname moved, whatever its DNS says now, until a PATCH restarts
306
+ // its validation; that PATCH is what Start over sends. Keyed on the provider's own "moved",
307
+ // so blocked, deleted and platform failures never get it.
308
+ if (!error && domain.routingFailure === "moved") {
309
+ return { state, reason: withMessage("moved_needs_restart"), next: { who: "owner", action: "start_over" } };
310
+ }
235
311
  if (!error && domain.tlsStatus === "failed") {
236
312
  const fix = required.filter((record) => record.ok === false).map(nextRecord);
237
313
  return {
package/dist/index.d.ts CHANGED
@@ -123,7 +123,7 @@ export interface DeletedSiteResult {
123
123
  metadata: boolean;
124
124
  errors?: string[];
125
125
  }
126
- export declare function deletedDomainLines(deleted: Pick<DeletedSiteResult, "customDomainCleanup" | "customDomainsReleasing">, options?: DomainFormatOptions): string[];
126
+ export declare function deletedDomainLines(deleted: Pick<DeletedSiteResult, "customDomainCleanup" | "customDomainsReleasing" | "customDomainNames">, options?: DomainFormatOptions): string[];
127
127
  export interface CreatedRuntimeToken {
128
128
  token: string;
129
129
  appAccessToken: AppAccessToken;
@@ -133,7 +133,7 @@ export interface CreatedServiceToken {
133
133
  clientSecret: string;
134
134
  }
135
135
  export type FetchLike = (input: string | URL | Request, init?: RequestInit) => Promise<Response>;
136
- export declare const CLI_VERSION = "0.85.0";
136
+ export declare const CLI_VERSION = "0.92.0";
137
137
  type CredentialBackend = "macos-keychain" | "windows-dpapi" | "linux-secret-service" | "file-plaintext" | "none";
138
138
  export interface StoredCredentialEntry {
139
139
  token: string;
@@ -162,7 +162,9 @@ export interface WhoamiResult {
162
162
  id?: string;
163
163
  prefix: string;
164
164
  expiresAt: string | null;
165
+ preset?: string | null;
165
166
  };
167
+ restricted?: boolean;
166
168
  account?: {
167
169
  org?: string;
168
170
  plan?: string;
@@ -180,6 +182,11 @@ export interface PublicWhoamiResult {
180
182
  email: string;
181
183
  role?: string;
182
184
  };
185
+ restricted?: boolean;
186
+ preset?: string;
187
+ apiKey?: {
188
+ prefix: string;
189
+ };
183
190
  account?: {
184
191
  org?: string;
185
192
  plan?: string;
@@ -220,7 +227,7 @@ export declare function isOpenNextInstalled(directory: string): boolean;
220
227
  export declare class DeployError extends Error {
221
228
  readonly slug: string | undefined;
222
229
  readonly deliberate: boolean;
223
- constructor(message: string, slug: string | undefined, deliberate?: boolean);
230
+ constructor(message: string, slug: string | undefined, deliberate?: boolean, cause?: unknown);
224
231
  }
225
232
  /**
226
233
  * A refusal the platform MEANT — an operator paused deploys, a queue is full —
@@ -254,6 +261,8 @@ export declare function checkDeployDirectory(options: DeployOptions): Promise<De
254
261
  export declare function deployCheckSummary(check: DeployCheckResult, publicAssets: string | undefined): string;
255
262
  export declare function whoami(options: ApiOptions): Promise<WhoamiResult>;
256
263
  export declare function publicWhoamiResult(result: WhoamiResult): PublicWhoamiResult;
264
+ export declare const SCOPED_WHOAMI_NOTE = "Plan, apps and storage are shown to a full-access login only.";
265
+ export declare function whoamiLines(me: PublicWhoamiResult): string[];
257
266
  export type CredentialRevocation = {
258
267
  kind: "revoked";
259
268
  } | {
@@ -280,6 +289,7 @@ export type LogoutSummary = {
280
289
  kind: "already_invalid";
281
290
  };
282
291
  export declare function summarizeRevocations(attempts: RevocationAttempt[]): LogoutSummary;
292
+ export declare function doctorAccountLines(account: PublicWhoamiResult): string[];
283
293
  export declare function getSiteAccess(options: SiteAccessOptions): Promise<AccessState>;
284
294
  export declare const ACCESS_MODE_CHANGE_UNCONFIRMED = "access_mode_change_unconfirmed";
285
295
  export interface AccessModeChangeOptions extends SiteAccessOptions {
@@ -495,6 +505,18 @@ export interface DeviceAuthProgressReporter {
495
505
  export declare function createDeviceAuthProgressReporter(stream?: NodeJS.WriteStream, options?: {
496
506
  hint?: string;
497
507
  }): DeviceAuthProgressReporter;
508
+ /**
509
+ * A non-2xx answer from the control plane, with the parts of its envelope a caller may
510
+ * act on. The message keeps the raw form expectOk always printed; `serverMessage` is the
511
+ * sentence the server wrote, for commands that print that alone.
512
+ */
513
+ export declare class ApiResponseError extends Error {
514
+ readonly status: number;
515
+ readonly code: string | null;
516
+ readonly serverMessage: string | null;
517
+ readonly details: unknown;
518
+ constructor(message: string, status: number, code: string | null, serverMessage: string | null, details?: unknown);
519
+ }
498
520
  export declare function noteCliOutdatedAdvisory(response: Response, log?: (line: string) => void): void;
499
521
  export declare function resetCliOutdatedAdvisoryForTests(): void;
500
522
  export declare function dashboardUrlForApiUrl(apiUrl: string): string;
@@ -560,16 +582,47 @@ type CliCommand = {
560
582
  token: string;
561
583
  } | {
562
584
  kind: "api-key";
585
+ action: ApiKeyCreateAction | {
586
+ kind: "ls";
587
+ json: boolean;
588
+ } | {
589
+ kind: "revoke";
590
+ keyId: string;
591
+ };
592
+ apiUrl: string;
593
+ token: string;
594
+ } | {
595
+ kind: "automation";
563
596
  action: {
564
597
  kind: "create";
565
- name?: string;
598
+ name: string;
566
599
  json: boolean;
567
600
  } | {
568
601
  kind: "ls";
569
602
  json: boolean;
570
603
  } | {
571
- kind: "revoke";
572
- keyId: string;
604
+ kind: "rename";
605
+ automationId: string;
606
+ name: string;
607
+ json: boolean;
608
+ } | {
609
+ kind: "rm";
610
+ automationId: string;
611
+ json: boolean;
612
+ } | {
613
+ kind: "attach";
614
+ automationId: string;
615
+ app: string;
616
+ json: boolean;
617
+ } | {
618
+ kind: "detach";
619
+ automationId: string;
620
+ app: string;
621
+ json: boolean;
622
+ } | {
623
+ kind: "apps";
624
+ automationId: string;
625
+ json: boolean;
573
626
  };
574
627
  apiUrl: string;
575
628
  token: string;
@@ -829,6 +882,7 @@ export declare function domainStatusJson(domain: CliDomain, app: string, checked
829
882
  export declare function domainStateLabel(state: string): string;
830
883
  export declare function domainWaitExitCode(status: Pick<DomainStatusJson, "state" | "next">): number;
831
884
  export declare function nextCheckText(nextCheckAt: unknown): string | null;
885
+ export declare function authoritativeText(record: Pick<DomainRecordStatus, "check" | "authoritative">): string | null;
832
886
  export interface DomainFormatOptions {
833
887
  owner?: string;
834
888
  target?: AccessApiTarget;
@@ -897,6 +951,8 @@ export declare function getDomainSetupCleanup(options: DomainCommandOptions & {
897
951
  }): Promise<unknown>;
898
952
  export declare function domainSetupCleanupLines(body: unknown, hostname: string): string[];
899
953
  export declare function setupCleanupLines(hostname: string, records: DomainCleanupRecord[]): string[];
954
+ export declare function customDomainMoveOffGuideUrl(options?: DomainFormatOptions): string;
955
+ export declare function domainRemovedConsequenceLines(hostnames: string[], options?: DomainFormatOptions): string[];
900
956
  export declare function domainRemovalPendingLines(hostname: string, app: string, options: DomainFormatOptions): string[];
901
957
  export declare const FINISHED_REMOVAL_PAGES_PER_COMMAND = 20;
902
958
  export declare function getFinishedDomainRemovals(options: ApiOptions & {
@@ -969,6 +1025,24 @@ export declare function qrCustomDomainNote(hostname: string): string;
969
1025
  export declare function pickQrAddress(addresses: readonly ProjectQrAddress[], requested: string | undefined): ProjectQrAddress;
970
1026
  /** An 8-bit greyscale PNG of a module matrix (quiet zone included), black on white. */
971
1027
  export declare function encodeQrPng(modules: readonly (readonly boolean[])[], scale?: number): Buffer;
1028
+ export type ApiKeyPresetName = "monitor" | "deploy" | "provision" | "full";
1029
+ export interface ApiKeyCreateAction {
1030
+ kind: "create";
1031
+ name?: string;
1032
+ preset?: ApiKeyPresetName;
1033
+ projects: string[];
1034
+ automation?: string;
1035
+ expires?: string;
1036
+ json: boolean;
1037
+ }
1038
+ export declare const API_KEY_EXPIRES_FORMS = "Use an ISO 8601 date-time with its zone, such as 2026-12-31T00:00:00Z, or a relative <N>d or <N>h, such as 30d or 12h (N a positive integer).";
1039
+ /**
1040
+ * `--expires` as the ISO instant the server takes: an ISO 8601 date-time as given, or
1041
+ * `<N>d` / `<N>h` counted from `now`. How far ahead is allowed is the server's rule (a
1042
+ * year today), so only the shape is checked here.
1043
+ */
1044
+ export declare function resolveApiKeyExpiry(value: string, now: Date): string;
1045
+ export declare const AUTOMATION_RM_WARNING = "Deleting an automation revokes every API key minted for it at once. Its apps stay in your account as ordinary apps, unchanged.";
972
1046
  export type ApiUrlSource = "flag" | "env" | "default";
973
1047
  export declare function workerPublicFiles(paths: readonly string[], serverEntry: string, selection: string | null): Array<{
974
1048
  path: string;
@@ -1002,9 +1076,78 @@ export declare function classifyBrowserOpen(input: {
1002
1076
  }): BrowserOpenResult;
1003
1077
  export declare function hasNoGraphicalDisplay(platform: string, env: Record<string, string | undefined>): boolean;
1004
1078
  export declare function formatAppUserFilesUsageLine(slug: string, usage: AppUserFilesUsage): string;
1079
+ export type UsageTopic = "api-key" | "automation";
1005
1080
  export declare class UsageError extends Error {
1081
+ readonly topic: UsageTopic | undefined;
1082
+ constructor(topic?: UsageTopic);
1006
1083
  }
1007
1084
  export declare function missingPagesLine(deployment: Deployment): string | null;
1085
+ export interface ApiKeySummary {
1086
+ id: string;
1087
+ name: string;
1088
+ prefix: string;
1089
+ createdAt: string;
1090
+ lastUsedAt: string | null;
1091
+ expiresAt: string | null;
1092
+ revokedAt: string | null;
1093
+ inactivityExpiresAt?: string | null;
1094
+ scope?: {
1095
+ preset: string;
1096
+ permissions: readonly string[] | null;
1097
+ automationId: string | null;
1098
+ projectIds?: string[];
1099
+ } | null;
1100
+ }
1101
+ export interface CommandIo {
1102
+ fetchFn?: FetchLike;
1103
+ log?: (line: string) => void;
1104
+ now?: () => Date;
1105
+ }
1106
+ export declare function apiKeyPresetLabel(key: Pick<ApiKeySummary, "scope">): string;
1107
+ export declare function apiKeyListLine(key: ApiKeySummary): string;
1108
+ export declare const DEPLOY_KEY_SECRETS_WARNING = "A key that can deploy to an app can replace its code, so it can read that app's secrets and data. Scoping protects your other apps, not the ones it may deploy to.";
1109
+ export interface ApiKeyCreateRequest {
1110
+ name?: string;
1111
+ preset?: string;
1112
+ projectIds?: string[];
1113
+ automationId?: string;
1114
+ expiresAt?: string;
1115
+ }
1116
+ /**
1117
+ * How a minted scoped key differs from the one asked for, or null when it is exactly that
1118
+ * key: its preset, its permissions, its apps or automation, and its expiry. A field the
1119
+ * answer leaves out counts as a difference, so a server that does not report one of them
1120
+ * cannot hand over a key that might be broader than requested.
1121
+ */
1122
+ export declare function scopedKeyMismatch(requested: ApiKeyCreateRequest, created: ApiKeySummary): string | null;
1123
+ export declare function runApiKeyCommand(command: Extract<CliCommand, {
1124
+ kind: "api-key";
1125
+ }>, io?: CommandIo): Promise<void>;
1126
+ export interface AutomationSummary {
1127
+ id: string;
1128
+ name: string;
1129
+ createdAt: string;
1130
+ updatedAt: string;
1131
+ apps?: Array<{
1132
+ id: string;
1133
+ slug: string;
1134
+ name: string;
1135
+ }>;
1136
+ activeKeys?: number;
1137
+ }
1138
+ export declare function runAutomationCommand(command: Extract<CliCommand, {
1139
+ kind: "automation";
1140
+ }>, io?: CommandIo): Promise<void>;
1141
+ export declare const SCOPED_KEY_PREFIX = "9pm_sk_";
1142
+ /**
1143
+ * What to add under an error when the credential is a scoped key, so the reader learns
1144
+ * the real cause. Never points a scoped key at `9pm api-key ls`: that needs a full-access
1145
+ * login, and the key would be refused again.
1146
+ */
1147
+ export declare function scopedKeyGuidance(error: unknown, context: {
1148
+ token?: string | undefined;
1149
+ apiUrl?: string | undefined;
1150
+ }): string[];
1008
1151
  type AccessModeChangeAction = Extract<Extract<CliCommand, {
1009
1152
  kind: "access";
1010
1153
  }>["action"], {
@@ -1016,3 +1159,6 @@ export interface AccessApiTarget {
1016
1159
  }
1017
1160
  export declare function apiUrlFlagFor(target?: AccessApiTarget): string;
1018
1161
  export declare function accessSwitchModeCommand(action: AccessModeChangeAction, target?: AccessApiTarget): string;
1162
+ export declare const API_KEY_USAGE = "Usage:\n 9pm api-key create [--name <name>] [--preset <preset>] [--project <app>]... [--automation <id>] [--expires <when>] [--json]\n 9pm api-key ls [--json]\n 9pm api-key revoke <key-id>\n\nAn API key lets CI or an unattended worker act without a browser login. Pass it as\nNINEPM_API_KEY or --token. The token is printed once, when the key is created.\n\nPresets:\n monitor read app status and deployment history apps: --project <app> (repeat it)\n deploy monitor, plus deploy, set env vars, add a db apps: --project <app> (repeat it)\n provision deploy, plus create apps apps: those in --automation <id>\n full everything, on every app (the default)\n\nA scoped key (monitor, deploy, provision) cannot delete apps, change access, manage\ndomains or files, mint keys or manage automations; those need a full-access login.\nDeploying to an app is effectively access to that app's secrets and data: a key that\ncan replace an app's code can read anything the app can.\n\nExpiry:\n --expires <when> sets a fixed deadline, at most a year ahead. <when> is an ISO 8601\n date-time with its zone (2026-12-31T00:00:00Z) or a relative <N>d or <N>h (30d, 12h).\n An explicit expiry does not replace the 90-day inactivity rule: every key also lapses\n after 90 days without use, and a key with --expires lapses at whichever comes first.\n To recover, mint a replacement from a full-access login with the same preset and apps\n (or the same --automation, whose apps stay attached), then revoke the old key.\n\nA scoped key needs a control plane that supports it. If the server answers with a\nfull-access key instead, the CLI revokes that key at once and prints no token.\n\nCreating, listing and revoking keys needs a full-access login.";
1163
+ export declare const AUTOMATION_USAGE = "Usage:\n 9pm automation create <name> [--json]\n 9pm automation ls [--json]\n 9pm automation rename <automation-id> <new-name> [--json]\n 9pm automation rm <automation-id> --yes [--json]\n 9pm automation attach <automation-id> <app> [--json]\n 9pm automation detach <automation-id> <app> [--json]\n 9pm automation apps <automation-id> [--json]\n\nAn automation groups the apps a provision key reaches. The apps stay your account's\nown; the automation only decides which provision keys reach them. An app belongs to at\nmost one automation: detach it before attaching it elsewhere.\n\nMint a key for one: 9pm api-key create --preset provision --automation <automation-id>\n\nrm revokes every key minted for the automation at once, and keeps its apps as ordinary\napps of your account. It needs --yes.\n\nEvery automation command needs a full-access login.";
1164
+ export declare function usageForTopic(topic: UsageTopic): string;