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 +44 -4
- package/dist/cli-config.d.ts +1 -1
- package/dist/cli-config.js +1 -1
- package/dist/domain-state.d.ts +51 -4
- package/dist/domain-state.js +79 -3
- package/dist/index.d.ts +152 -6
- package/dist/index.js +814 -66
- package/package.json +1 -1
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
|
-
|
|
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.
|
|
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
|
package/dist/cli-config.d.ts
CHANGED
|
@@ -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
|
|
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;
|
package/dist/cli-config.js
CHANGED
|
@@ -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
|
|
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);
|
package/dist/domain-state.d.ts
CHANGED
|
@@ -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;
|
package/dist/domain-state.js
CHANGED
|
@@ -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
|
|
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.
|
|
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
|
|
598
|
+
name: string;
|
|
566
599
|
json: boolean;
|
|
567
600
|
} | {
|
|
568
601
|
kind: "ls";
|
|
569
602
|
json: boolean;
|
|
570
603
|
} | {
|
|
571
|
-
kind: "
|
|
572
|
-
|
|
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;
|