ninepm 0.69.0 → 0.78.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 +86 -10
- package/dist/cli-types.d.ts +2 -0
- package/dist/dns-host.d.ts +15 -0
- package/dist/dns-host.js +43 -0
- package/dist/dns-providers.d.ts +91 -0
- package/dist/dns-providers.js +194 -0
- package/dist/domain-state.d.ts +147 -0
- package/dist/domain-state.js +248 -0
- package/dist/index.d.ts +185 -7
- package/dist/index.js +906 -24
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -62,6 +62,8 @@ Deploy a static directory:
|
|
|
62
62
|
9pm deploy . --name my-app --slug my-app
|
|
63
63
|
```
|
|
64
64
|
|
|
65
|
+
Deploying again under the same slug replaces the live app. To try a change without touching it, deploy under a different slug, which creates a separate app at its own address. On Free, your one app slot can't also hold a separate preview app. Deploying to your existing app updates its live version. Practice mode rehearses the current app's workflow. Testing a changed version separately requires another app slot: a plan with more apps (Hobby allows 3).
|
|
66
|
+
|
|
65
67
|
### How long an app's address stays the same
|
|
66
68
|
|
|
67
69
|
Check this before printing a link or QR code. The live URL is `<slug>-<suffix>.9pm.ai`, and the suffix comes from the app's internal id, not from the slug:
|
|
@@ -71,6 +73,19 @@ Check this before printing a link or QR code. The live URL is `<slug>-<suffix>.9
|
|
|
71
73
|
- There is no rename: a different address means a different app.
|
|
72
74
|
- A custom domain you own, added on the dashboard's Domains page, gives the app an address of your own. Not every plan includes custom domains.
|
|
73
75
|
|
|
76
|
+
### Pages that do not exist
|
|
77
|
+
|
|
78
|
+
A static site or `--entry` worker app answers a path that has no file in one of two ways. Every deploy prints which, as a `Missing pages:` line.
|
|
79
|
+
|
|
80
|
+
- `--no-spa` (the default for new apps): the path returns status 404, with the deploy's own `404.html` when it has one. `/` still serves `index.html`, and extensionless links work: `/menu` serves `menu.html`, or redirects to `/menu/` when only `menu/index.html` exists, and `/menu/` serves `menu/index.html`. Right for a multi-page site, where a removed or mistyped page should say "not found" rather than show the homepage.
|
|
81
|
+
- `--spa`: every path with no file serves `index.html`, so a single-page app that routes in the browser loads on a deep link or a refresh. That includes dotted paths such as `/users/jane.smith` and missing files such as `/missing.json`, which get the app's shell rather than a 404.
|
|
82
|
+
|
|
83
|
+
```sh
|
|
84
|
+
9pm deploy ./dist --name my-spa --spa
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
The app remembers the choice: a deploy without either flag keeps it, and a deploy that fails to publish leaves the previous choice in place. Apps created before this choice existed keep serving `index.html` for every unmatched path until a deploy passes one of the flags. A worker app's own `Response`, a 404 included, is always returned as it is. The flags do not apply to `--bundle` or container apps.
|
|
88
|
+
|
|
74
89
|
Deploy a built framework output directory:
|
|
75
90
|
|
|
76
91
|
```sh
|
|
@@ -152,12 +167,14 @@ The app's address goes with it: creating an app again under the same slug gives
|
|
|
152
167
|
|
|
153
168
|
Apps are public by default.
|
|
154
169
|
|
|
170
|
+
Changing an app's access mode needs `--switch-mode`. Without it the command changes nothing and prints what the change would do (who loses access, what happens to the app's custom domains) and the command to re-run. Service-token mode locks every person out and removes the app's custom domains; leaving it sets them up again with new DNS records to publish. Creating another service token on an app already in service-token mode needs no flag.
|
|
171
|
+
|
|
155
172
|
One-time PIN with email/domain allowlist:
|
|
156
173
|
|
|
157
174
|
```sh
|
|
158
175
|
9pm access allow email my-app dev@example.com
|
|
159
176
|
9pm access allow domain my-app example.com
|
|
160
|
-
9pm access otp my-app
|
|
177
|
+
9pm access otp my-app --switch-mode
|
|
161
178
|
9pm access revoke email my-app dev@example.com
|
|
162
179
|
9pm access revoke domain my-app example.com
|
|
163
180
|
```
|
|
@@ -165,19 +182,19 @@ One-time PIN with email/domain allowlist:
|
|
|
165
182
|
Identity provider access:
|
|
166
183
|
|
|
167
184
|
```sh
|
|
168
|
-
9pm access idp my-app --provider google
|
|
185
|
+
9pm access idp my-app --provider google --switch-mode
|
|
169
186
|
```
|
|
170
187
|
|
|
171
188
|
Machine access:
|
|
172
189
|
|
|
173
190
|
```sh
|
|
174
|
-
9pm access service create my-app --name backend
|
|
191
|
+
9pm access service create my-app --name backend --switch-mode
|
|
175
192
|
```
|
|
176
193
|
|
|
177
194
|
Return to public:
|
|
178
195
|
|
|
179
196
|
```sh
|
|
180
|
-
9pm access public my-app
|
|
197
|
+
9pm access public my-app --switch-mode
|
|
181
198
|
```
|
|
182
199
|
|
|
183
200
|
Create an app-scoped runtime token for server-side allowlist management:
|
|
@@ -189,11 +206,61 @@ Create an app-scoped runtime token for server-side allowlist management:
|
|
|
189
206
|
Use `9pm_app_...` runtime tokens only from server-side code. They can list, add, and remove email/domain grants for one app. They cannot deploy, create databases, create API keys, or manage another app.
|
|
190
207
|
Service token secrets returned by `access service create` are also server-side secrets.
|
|
191
208
|
|
|
209
|
+
## Custom Domains
|
|
210
|
+
|
|
211
|
+
Connect a domain you own to an app:
|
|
212
|
+
|
|
213
|
+
```sh
|
|
214
|
+
9pm domains add my-app www.example.com
|
|
215
|
+
```
|
|
216
|
+
|
|
217
|
+
This prints the DNS records to publish at your DNS provider, with names relative to your
|
|
218
|
+
domain (`www`, `_acme-challenge.www`) and what DNS shows for each right now. When a TXT value
|
|
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
|
|
221
|
+
ALIAS or ANAME (or a CNAME your provider flattens). A correct ALIAS answers with addresses, which
|
|
222
|
+
cannot be compared from outside, so that row follows the platform's own routing check. Then:
|
|
223
|
+
|
|
224
|
+
```sh
|
|
225
|
+
9pm domains wait my-app www.example.com
|
|
226
|
+
```
|
|
227
|
+
|
|
228
|
+
`wait` exits `0` once the domain is live, `10` if it timed out while you have something to do (a
|
|
229
|
+
record is missing or could not be checked, or a CAA record blocks the certificate even though every
|
|
230
|
+
record is found), `11` if it timed out with every record checked and found and nothing left for you
|
|
231
|
+
(the platform is still finishing; wait again), `20` if the domain failed or expired, and `1` on a
|
|
232
|
+
usage, sign-in or API error. Since 0.74.0 a CAA block exits `10`; earlier versions exited `11`. Since 0.77.0 a bare domain whose DNS host cannot point a root
|
|
233
|
+
domain at a hostname (GoDaddy, Route 53, IONOS, Wix) makes `wait` stop at once with `10`: its `next`
|
|
234
|
+
is `use_www` with the `www` hostname to add instead, since no record at the root can finish it.
|
|
235
|
+
`9pm domains status <app> <hostname> --json` prints `{hostname, app, state, reason, next, routing,
|
|
236
|
+
tls, provider, providerId, nameservers, zone, records, advisories, error, expiresAt, checkedAt}`, where `provider` is
|
|
237
|
+
the DNS host (where the nameservers point, not necessarily where the domain was bought), `reason` says why
|
|
238
|
+
(`reason.providerMessage` carries the provider's own words when it reported any, with `source`
|
|
239
|
+
saying whether they are the certificate authority's),
|
|
240
|
+
`next` says who acts next and what to do, and `state` is `live`,
|
|
241
|
+
`waiting_for_dns`, `dns_unverified` (routing is active but the records could not be checked),
|
|
242
|
+
`issuing_certificate`, `failed` or `expired`. Each record's `ok` is `null` when the check cannot
|
|
243
|
+
say, and its `check` says why (`not_checked`, or `apex_addresses`). A record whose value was found
|
|
244
|
+
at the name with the domain typed twice (`_acme-challenge.www.example.com.example.com`) has
|
|
245
|
+
`check: "doubled"` and `doubledAt`: enter only the short name shown. `advisories` lists hints that
|
|
246
|
+
the hostname may be a typo, such as a domain that is not in DNS; the domain is added either way.
|
|
247
|
+
Since 0.78.0 each record also carries `typeAtProvider`, `nameAtProvider` (`""` means leave the Host
|
|
248
|
+
field empty) and `valueAtProvider`: the record as a recognised DNS host's editor wants it.
|
|
249
|
+
`typeAtProvider` is `null` for a root record the host cannot hold (GoDaddy, Route 53, IONOS, Wix);
|
|
250
|
+
the text output then suggests connecting `www` instead. `list`, `status` and `wait`
|
|
251
|
+
only read; `add`, `check`, `restart` and `remove` need permission to configure the app.
|
|
252
|
+
|
|
253
|
+
Every `9pm deploy` lists the app's custom domains under its `Deployed` line: `Also serving at
|
|
254
|
+
https://<domain>` for a live one, and for any other its state and the `9pm domains status`
|
|
255
|
+
command to run. If the domains cannot be read within a few seconds, the deploy says so in one
|
|
256
|
+
line instead; the deploy itself is never held back by it. `9pm delete` names the custom domains
|
|
257
|
+
it removed.
|
|
258
|
+
|
|
192
259
|
## Commands
|
|
193
260
|
|
|
194
261
|
```sh
|
|
195
262
|
9pm --version
|
|
196
|
-
9pm deploy [directory] --name <name> [--slug <slug>] [--with-db]
|
|
263
|
+
9pm deploy [directory] --name <name> [--slug <slug>] [--spa | --no-spa] [--with-db]
|
|
197
264
|
9pm deploy [directory] --check [--runtime worker --entry <path> (--assets <dir> | --no-assets)]
|
|
198
265
|
9pm deploy [directory] --runtime container --port 8080 [--health-path /health] [--region auto] [--with-db]
|
|
199
266
|
9pm signup
|
|
@@ -208,17 +275,24 @@ Service token secrets returned by `access service create` are also server-side s
|
|
|
208
275
|
9pm files get <file-id> [--output <path>] [--project <site>]
|
|
209
276
|
9pm files share <file-id> [--expires 24h] [--public] [--project <site>]
|
|
210
277
|
9pm files delete <file-id> [--project <site>]
|
|
211
|
-
9pm deploy [directory] --runtime worker --entry <path> (--assets <dir> | --no-assets) [--with-db]
|
|
278
|
+
9pm deploy [directory] --runtime worker --entry <path> (--assets <dir> | --no-assets) [--spa | --no-spa] [--with-db]
|
|
212
279
|
9pm delete <site> --confirm <slug>
|
|
280
|
+
9pm domains list [<app>] [--owner <organisation>] [--json]
|
|
281
|
+
9pm domains add <app> <hostname> [--json]
|
|
282
|
+
9pm domains status <app> <hostname> [--json]
|
|
283
|
+
9pm domains check <app> <hostname> [--json]
|
|
284
|
+
9pm domains wait <app> <hostname> [--timeout 30m] [--interval 20s] [--json]
|
|
285
|
+
9pm domains restart <app> <hostname> # only after a certificate failed; record values may change
|
|
286
|
+
9pm domains remove <app> <hostname> --confirm <hostname>
|
|
213
287
|
9pm access show <site>
|
|
214
|
-
9pm access public <site>
|
|
215
|
-
9pm access otp <site>
|
|
216
|
-
9pm access idp <site> [--provider google|github|entra]
|
|
288
|
+
9pm access public <site> [--switch-mode]
|
|
289
|
+
9pm access otp <site> [--switch-mode]
|
|
290
|
+
9pm access idp <site> [--provider google|github|entra] [--switch-mode]
|
|
217
291
|
9pm access allow email <site> <email>
|
|
218
292
|
9pm access allow domain <site> <domain>
|
|
219
293
|
9pm access revoke email <site> <email>
|
|
220
294
|
9pm access revoke domain <site> <domain>
|
|
221
|
-
9pm access service create <site> [--name <name>] [--duration 8760h]
|
|
295
|
+
9pm access service create <site> [--name <name>] [--duration 8760h] [--switch-mode]
|
|
222
296
|
9pm access service revoke <site> <service-token-id>
|
|
223
297
|
9pm access token create <site> [--name <name>]
|
|
224
298
|
9pm access token revoke <site> <token-id>
|
|
@@ -240,6 +314,8 @@ Options:
|
|
|
240
314
|
- `--entry <path>` selects the Worker runtime server entry file
|
|
241
315
|
- `--assets <dir>` makes only that directory of a worker deploy public (remembered for later deploys)
|
|
242
316
|
- `--no-assets` makes no file of a worker deploy public (remembered for later deploys)
|
|
317
|
+
- `--spa` serves `index.html` for every path that has no file, for single-page apps (remembered for later deploys)
|
|
318
|
+
- `--no-spa` returns 404 for a path that has no file, with the deploy's `404.html` when present; the default for new apps (remembered for later deploys)
|
|
243
319
|
- `--port <port>` selects the HTTP port exposed by a container app
|
|
244
320
|
- `--health-path <path>` optionally lets 9pm.ai check container health before marking it healthy
|
|
245
321
|
- `--region <region>` optionally selects provider-neutral container placement
|
package/dist/cli-types.d.ts
CHANGED
|
@@ -8,6 +8,7 @@ export type AppRuntime = "static" | "worker" | "container";
|
|
|
8
8
|
export type ContainerRegion = "auto" | "us-east" | "us-west" | "europe" | "asia" | "south-america" | "middle-east" | "oceania" | "africa";
|
|
9
9
|
export type ManagedFileScope = "user" | "project";
|
|
10
10
|
export type ManagedFileShareMode = "public" | "login_required";
|
|
11
|
+
export type MissingPagesMode = "legacy" | "spa" | "404";
|
|
11
12
|
export interface Project {
|
|
12
13
|
id: string;
|
|
13
14
|
name: string;
|
|
@@ -16,6 +17,7 @@ export interface Project {
|
|
|
16
17
|
createdAt: string;
|
|
17
18
|
updatedAt: string;
|
|
18
19
|
workerPublicAssets?: string | null;
|
|
20
|
+
missingPages?: MissingPagesMode;
|
|
19
21
|
}
|
|
20
22
|
export interface AppUserFilesUsage {
|
|
21
23
|
usedBytes: number;
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/** A best guess at the zone a custom hostname lives in: `www.tmrwtitans.com` -> `tmrwtitans.com`. */
|
|
2
|
+
export declare function guessZoneApex(hostname: string): string;
|
|
3
|
+
/**
|
|
4
|
+
* The record name the way most registrars' Host field wants it, relative to the zone:
|
|
5
|
+
* `www.tmrwtitans.com` -> `www`, the apex itself -> `@`. A name outside the zone stays whole.
|
|
6
|
+
*/
|
|
7
|
+
export declare function relativeRecordName(recordHost: string, apex: string): string;
|
|
8
|
+
/**
|
|
9
|
+
* The zone a hostname's records go in: the zone the DNS check found by walking up to the first
|
|
10
|
+
* name that answers NS, when it has one, otherwise the guess. The found zone is used only when
|
|
11
|
+
* the hostname is inside it and it is no shorter than the guess: a shorter one (`co.uk`) means
|
|
12
|
+
* the walk climbed past a domain that does not exist, which says nothing about where its
|
|
13
|
+
* records would go.
|
|
14
|
+
*/
|
|
15
|
+
export declare function zoneApexFor(hostname: string, checkedZone?: unknown): string;
|
package/dist/dns-host.js
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
// AUTO-GENERATED by scripts/generate-cli-package.mjs from the in-repo CLI source -- do not edit.
|
|
2
|
+
// Edit src/cli or src/shared instead; this is regenerated on typecheck/test/build/pack.
|
|
3
|
+
// Record names for a custom domain's DNS instructions, written the way a registrar's Host
|
|
4
|
+
// field wants them. Read by the dashboard's DNS table and by `9pm domains`, and copied into
|
|
5
|
+
// the CLI tarball by scripts/generate-cli-package.mjs, so it must import nothing.
|
|
6
|
+
// Second-level labels that sit under a two-letter country code as part of the public suffix
|
|
7
|
+
// (example.co.uk, example.com.au). Not a public suffix list: it covers the common shapes, and
|
|
8
|
+
// the full name is always shown beside the short one for the cases it misses.
|
|
9
|
+
const SECOND_LEVEL = new Set(["co", "com", "net", "org", "gov", "ac", "edu", "ne", "or", "go"]);
|
|
10
|
+
/** A best guess at the zone a custom hostname lives in: `www.tmrwtitans.com` -> `tmrwtitans.com`. */
|
|
11
|
+
export function guessZoneApex(hostname) {
|
|
12
|
+
const labels = hostname.toLowerCase().replace(/\.$/, "").split(".");
|
|
13
|
+
if (labels.length >= 3 && SECOND_LEVEL.has(labels[labels.length - 2]) && labels[labels.length - 1].length === 2) {
|
|
14
|
+
return labels.slice(-3).join(".");
|
|
15
|
+
}
|
|
16
|
+
return labels.slice(-2).join(".");
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* The record name the way most registrars' Host field wants it, relative to the zone:
|
|
20
|
+
* `www.tmrwtitans.com` -> `www`, the apex itself -> `@`. A name outside the zone stays whole.
|
|
21
|
+
*/
|
|
22
|
+
export function relativeRecordName(recordHost, apex) {
|
|
23
|
+
const host = recordHost.toLowerCase().replace(/\.$/, "");
|
|
24
|
+
if (host === apex)
|
|
25
|
+
return "@";
|
|
26
|
+
return host.endsWith(`.${apex}`) ? host.slice(0, -(apex.length + 1)) : host;
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* The zone a hostname's records go in: the zone the DNS check found by walking up to the first
|
|
30
|
+
* name that answers NS, when it has one, otherwise the guess. The found zone is used only when
|
|
31
|
+
* the hostname is inside it and it is no shorter than the guess: a shorter one (`co.uk`) means
|
|
32
|
+
* the walk climbed past a domain that does not exist, which says nothing about where its
|
|
33
|
+
* records would go.
|
|
34
|
+
*/
|
|
35
|
+
export function zoneApexFor(hostname, checkedZone) {
|
|
36
|
+
const guess = guessZoneApex(hostname);
|
|
37
|
+
if (typeof checkedZone !== "string" || checkedZone === "")
|
|
38
|
+
return guess;
|
|
39
|
+
const host = hostname.toLowerCase().replace(/\.$/, "");
|
|
40
|
+
const zone = checkedZone.toLowerCase().replace(/\.$/, "");
|
|
41
|
+
const inside = host === zone || host.endsWith(`.${zone}`);
|
|
42
|
+
return inside && zone.split(".").length >= guess.split(".").length ? zone : guess;
|
|
43
|
+
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
export declare const DNS_PROVIDER_SOURCE = "registrars-and-dns-automation.md, 2026-10-09 (Route 53 rechecked 2026-10-13)";
|
|
2
|
+
/** What can stand in for a root-domain record when the host cannot point the root at a hostname. */
|
|
3
|
+
export type ApexFallback =
|
|
4
|
+
/** Connect www.<zone> to the app instead: an ordinary CNAME, always possible. */
|
|
5
|
+
{
|
|
6
|
+
kind: "use_www";
|
|
7
|
+
}
|
|
8
|
+
/** The host's own forwarding of the root to www. Unverified: mentioned as a possibility, never as a step. */
|
|
9
|
+
| {
|
|
10
|
+
kind: "provider_forwarding";
|
|
11
|
+
verified: boolean;
|
|
12
|
+
https: "yes" | "unverified";
|
|
13
|
+
}
|
|
14
|
+
/** Route 53 only: an S3 redirect bucket behind CloudFront, aliased at the root. AWS-documented, not tested by us. */
|
|
15
|
+
| {
|
|
16
|
+
kind: "aws_redirect";
|
|
17
|
+
}
|
|
18
|
+
/** Move DNS hosting to a host that offers ALIAS or flattening, copying every existing record first. */
|
|
19
|
+
| {
|
|
20
|
+
kind: "move_dns_host";
|
|
21
|
+
};
|
|
22
|
+
export type ApexSupport = {
|
|
23
|
+
support: "alias";
|
|
24
|
+
typeLabel: "ALIAS" | "ANAME";
|
|
25
|
+
hostField: "blank" | "@";
|
|
26
|
+
} | {
|
|
27
|
+
support: "cname_flattening";
|
|
28
|
+
typeLabel: "CNAME";
|
|
29
|
+
hostField: "@";
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* `fallbacks` is ordered and always starts with use_www. `hostField` is the root's Host
|
|
33
|
+
* convention where the source states one, used only when routing is already active and the
|
|
34
|
+
* root row is shown after all; absent means `@`.
|
|
35
|
+
*/
|
|
36
|
+
| {
|
|
37
|
+
support: "none";
|
|
38
|
+
fallbacks: ApexFallback[];
|
|
39
|
+
hostField?: "blank";
|
|
40
|
+
};
|
|
41
|
+
/** A note shown under the "your DNS is at" line. Only verified notes are rendered. */
|
|
42
|
+
export interface DnsProviderNote {
|
|
43
|
+
id: string;
|
|
44
|
+
verified: boolean;
|
|
45
|
+
}
|
|
46
|
+
export interface DnsProviderProfile {
|
|
47
|
+
apex: ApexSupport;
|
|
48
|
+
/**
|
|
49
|
+
* Whether the editor appends the zone to the name typed. True: the full name is never
|
|
50
|
+
* offered, since typing it creates `www.example.com.example.com`. "unknown" keeps showing it.
|
|
51
|
+
*/
|
|
52
|
+
appendsZone: true | false | "unknown";
|
|
53
|
+
/** The editor makes a hostname value relative unless it ends in a dot (Gandi). */
|
|
54
|
+
valueNeedsTrailingDot?: true;
|
|
55
|
+
notes: DnsProviderNote[];
|
|
56
|
+
source: string;
|
|
57
|
+
}
|
|
58
|
+
export interface DnsProvider {
|
|
59
|
+
/** Stable key, sent as `dnsCheck.providerId`; the display name may change. */
|
|
60
|
+
id: string;
|
|
61
|
+
/** What `dnsCheck.provider` says. */
|
|
62
|
+
name: string;
|
|
63
|
+
nameservers: RegExp[];
|
|
64
|
+
/** Absent for a host nobody has researched: it is named, and gets the generic instructions. */
|
|
65
|
+
profile?: DnsProviderProfile;
|
|
66
|
+
}
|
|
67
|
+
export declare const DNS_PROVIDERS: readonly DnsProvider[];
|
|
68
|
+
/** The DNS host a zone's nameservers belong to, or null for one not in the table. Entries that are not names are skipped. */
|
|
69
|
+
export declare function dnsProviderForNameservers(nameservers: readonly unknown[]): DnsProvider | null;
|
|
70
|
+
/** The profile for a `dnsCheck.providerId`; undefined for an unknown id or a host with no profile. */
|
|
71
|
+
export declare function dnsProviderProfile(providerId: unknown): DnsProviderProfile | undefined;
|
|
72
|
+
/** How one routing or TXT record is entered at a given host. */
|
|
73
|
+
export interface RecordAtProvider {
|
|
74
|
+
/** The record type the host's editor calls it; null when the host cannot hold it at all (a root record at a no-ALIAS host). */
|
|
75
|
+
type: string | null;
|
|
76
|
+
/** The name to type; "" means leave the Host field empty. */
|
|
77
|
+
name: string;
|
|
78
|
+
/** The value to type, with a trailing dot where the editor needs one. */
|
|
79
|
+
value: string;
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* The record as the host's editor wants it. `apex` is whether this is the routing record at
|
|
83
|
+
* the zone root; `name` is the relative name (`@` at the root). Without a profile this is the
|
|
84
|
+
* record unchanged, with the root's type named the way every host that can hold it does.
|
|
85
|
+
*/
|
|
86
|
+
export declare function recordAtProvider(record: {
|
|
87
|
+
type: string;
|
|
88
|
+
name: string;
|
|
89
|
+
value: string;
|
|
90
|
+
apex: boolean;
|
|
91
|
+
}, profile: DnsProviderProfile | undefined): RecordAtProvider;
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
// AUTO-GENERATED by scripts/generate-cli-package.mjs from the in-repo CLI source -- do not edit.
|
|
2
|
+
// Edit src/cli or src/shared instead; this is regenerated on typecheck/test/build/pack.
|
|
3
|
+
// DNS hosts: how each is recognised from the nameservers a custom domain's zone answers with,
|
|
4
|
+
// and, for the ones researched, how its record editor wants the records we ask for. Detection
|
|
5
|
+
// and profiles live in one table so a host can never be named without the rules that follow
|
|
6
|
+
// from it. Read by the control plane's DNS check, the dashboard and `9pm domains`, and copied
|
|
7
|
+
// into the CLI tarball by scripts/generate-cli-package.mjs, so it must import nothing.
|
|
8
|
+
//
|
|
9
|
+
// Every rule is a property of the DNS HOST (where the nameservers point), never the registrar:
|
|
10
|
+
// records only take effect at the host, whoever sold the domain.
|
|
11
|
+
//
|
|
12
|
+
// Facts come from the BYOD walkthrough's research note (registrars-and-dns-automation.md,
|
|
13
|
+
// researched 2026-10-09 from each provider's help center; the Route 53 row rechecked against
|
|
14
|
+
// AWS's docs on 2026-10-13). That note is not in this repository, so each row cites the
|
|
15
|
+
// provider's own page it relied on, and keeps the note's verified/unverified marks. A fact the note marks unverified or
|
|
16
|
+
// inferred is used only where being wrong is harmless: it never removes information (an
|
|
17
|
+
// "unknown" appendsZone keeps the full-name line) and never becomes a button or a step.
|
|
18
|
+
// Re-check the source before widening any row.
|
|
19
|
+
export const DNS_PROVIDER_SOURCE = "registrars-and-dns-automation.md, 2026-10-09 (Route 53 rechecked 2026-10-13)";
|
|
20
|
+
// Order matters only where two patterns could match one nameserver; none do today.
|
|
21
|
+
export const DNS_PROVIDERS = [
|
|
22
|
+
{
|
|
23
|
+
id: "namecheap",
|
|
24
|
+
name: "Namecheap",
|
|
25
|
+
nameservers: [/(^|\.)registrar-servers\.com$/],
|
|
26
|
+
// Documented: "ALIAS Record", host @; "enter your subdomain just like mysubdomain and not like mysubdomain.domain.tld".
|
|
27
|
+
// https://www.namecheap.com/support/knowledgebase/article.aspx/10128/2237/how-to-create-an-alias-record
|
|
28
|
+
// https://www.namecheap.com/support/knowledgebase/article.aspx/9646/2237/how-to-create-a-cname-record-for-your-domain
|
|
29
|
+
profile: { apex: { support: "alias", typeLabel: "ALIAS", hostField: "@" }, appendsZone: true, notes: [], source: DNS_PROVIDER_SOURCE },
|
|
30
|
+
},
|
|
31
|
+
{
|
|
32
|
+
id: "godaddy",
|
|
33
|
+
name: "GoDaddy",
|
|
34
|
+
nameservers: [/(^|\.)domaincontrol\.com$/],
|
|
35
|
+
// No ALIAS/ANAME; a CNAME Name "cannot be the @ symbol". Forwarding exists (GoDaddy's own
|
|
36
|
+
// pages); whether it answers over HTTPS is unverified. appendsZone is inferred from the
|
|
37
|
+
// Name rules, so true is used only to hide the full-name line, which GoDaddy would double.
|
|
38
|
+
// https://www.godaddy.com/en/help/add-a-cname-record-19236
|
|
39
|
+
// https://www.godaddy.com/resources/news/beyond-cname-flattening
|
|
40
|
+
profile: {
|
|
41
|
+
apex: { support: "none", fallbacks: [{ kind: "use_www" }, { kind: "provider_forwarding", verified: true, https: "unverified" }] },
|
|
42
|
+
appendsZone: true,
|
|
43
|
+
notes: [],
|
|
44
|
+
source: DNS_PROVIDER_SOURCE,
|
|
45
|
+
},
|
|
46
|
+
},
|
|
47
|
+
{
|
|
48
|
+
id: "cloudflare",
|
|
49
|
+
name: "Cloudflare",
|
|
50
|
+
nameservers: [/(^|\.)ns\.cloudflare\.com$/],
|
|
51
|
+
// A CNAME at the root is flattened. Both name forms are accepted (inferred), so the full
|
|
52
|
+
// name stays visible. No DCV advice until #1990 settles the _acme-challenge question.
|
|
53
|
+
// https://developers.cloudflare.com/dns/cname-flattening/
|
|
54
|
+
// https://developers.cloudflare.com/dns/manage-dns-records/how-to/create-zone-apex/
|
|
55
|
+
profile: { apex: { support: "cname_flattening", typeLabel: "CNAME", hostField: "@" }, appendsZone: "unknown", notes: [], source: DNS_PROVIDER_SOURCE },
|
|
56
|
+
},
|
|
57
|
+
{
|
|
58
|
+
id: "route53",
|
|
59
|
+
name: "Amazon Route 53",
|
|
60
|
+
nameservers: [/awsdns-\d+\./],
|
|
61
|
+
// ALIAS targets only AWS resources or same-zone records, and Route 53 has no forwarding
|
|
62
|
+
// service: so www, a redirect the owner runs inside AWS, or another DNS host. The root's
|
|
63
|
+
// record name is left blank; "@" outside a zone-file import creates a literal label.
|
|
64
|
+
// https://docs.aws.amazon.com/Route53/latest/DeveloperGuide/resource-record-sets-choosing-alias-non-alias.html
|
|
65
|
+
// https://repost.aws/questions/QUt4eylJxXQ5On1SEzH09USQ/route-53-records
|
|
66
|
+
profile: {
|
|
67
|
+
apex: { support: "none", fallbacks: [{ kind: "use_www" }, { kind: "aws_redirect" }, { kind: "move_dns_host" }], hostField: "blank" },
|
|
68
|
+
appendsZone: "unknown",
|
|
69
|
+
notes: [{ id: "route53TxtQuotes", verified: false }],
|
|
70
|
+
source: DNS_PROVIDER_SOURCE,
|
|
71
|
+
},
|
|
72
|
+
},
|
|
73
|
+
// `googledomains.com` may be Squarespace Domains rather than Google Cloud DNS (unverified), so no profile.
|
|
74
|
+
{ id: "google-cloud-dns", name: "Google Cloud DNS", nameservers: [/(^|\.)googledomains\.com$/] },
|
|
75
|
+
{ id: "squarespace", name: "Squarespace", nameservers: [/(^|\.)squarespacedns\.com$/] },
|
|
76
|
+
{
|
|
77
|
+
id: "porkbun",
|
|
78
|
+
name: "Porkbun",
|
|
79
|
+
nameservers: [/(^|\.)porkbun\.com$/],
|
|
80
|
+
// Documented: host blank for the root, "enter only the subdomain".
|
|
81
|
+
// 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 },
|
|
83
|
+
},
|
|
84
|
+
{
|
|
85
|
+
id: "namecom",
|
|
86
|
+
name: "Name.com",
|
|
87
|
+
nameservers: [/(^|\.)name\.com$/],
|
|
88
|
+
// https://www.name.com/support/articles/115010493967-adding-an-aname-alias-record
|
|
89
|
+
profile: { apex: { support: "alias", typeLabel: "ANAME", hostField: "blank" }, appendsZone: true, notes: [], source: DNS_PROVIDER_SOURCE },
|
|
90
|
+
},
|
|
91
|
+
{
|
|
92
|
+
id: "gandi",
|
|
93
|
+
name: "Gandi",
|
|
94
|
+
nameservers: [/(^|\.)gandi\.net$/],
|
|
95
|
+
// LiveDNS only; the value needs a trailing dot or the zone is appended to it; no A/AAAA may
|
|
96
|
+
// stay at @ beside the ALIAS.
|
|
97
|
+
// https://docs.gandi.net/en/domain_names/faq/record_types/alias_record.html
|
|
98
|
+
profile: {
|
|
99
|
+
apex: { support: "alias", typeLabel: "ALIAS", hostField: "@" },
|
|
100
|
+
appendsZone: true,
|
|
101
|
+
valueNeedsTrailingDot: true,
|
|
102
|
+
notes: [{ id: "gandiLiveDns", verified: true }],
|
|
103
|
+
source: DNS_PROVIDER_SOURCE,
|
|
104
|
+
},
|
|
105
|
+
},
|
|
106
|
+
{ id: "ovhcloud", name: "OVHcloud", nameservers: [/(^|\.)ovh\.net$/] },
|
|
107
|
+
{
|
|
108
|
+
id: "ionos",
|
|
109
|
+
name: "IONOS",
|
|
110
|
+
nameservers: [/(^|\.)ui-dns\.(com|de|org|biz)$/],
|
|
111
|
+
// "CNAME only on subdomains" is IONOS's own; "only HTTP forwarding" for the root is one
|
|
112
|
+
// integrator's report, so forwarding is unverified and rendered as a possibility only.
|
|
113
|
+
// https://www.ionos.com/help/domains/configuring-cname-records-for-subdomains/configuring-a-cname-record-for-a-subdomain/
|
|
114
|
+
profile: {
|
|
115
|
+
apex: { support: "none", fallbacks: [{ kind: "use_www" }, { kind: "provider_forwarding", verified: false, https: "unverified" }] },
|
|
116
|
+
appendsZone: true,
|
|
117
|
+
notes: [],
|
|
118
|
+
source: DNS_PROVIDER_SOURCE,
|
|
119
|
+
},
|
|
120
|
+
},
|
|
121
|
+
{
|
|
122
|
+
id: "hostinger",
|
|
123
|
+
name: "Hostinger",
|
|
124
|
+
nameservers: [/(^|\.)dns-parking\.com$/],
|
|
125
|
+
// A CNAME at @ is shown as ALIAS, one per zone; "@ is appended by default".
|
|
126
|
+
// https://www.hostinger.com/support/10085192-cname-vs-alias-records-at-hostinger/
|
|
127
|
+
profile: {
|
|
128
|
+
apex: { support: "alias", typeLabel: "ALIAS", hostField: "@" },
|
|
129
|
+
appendsZone: true,
|
|
130
|
+
notes: [{ id: "hostingerCnameAtRoot", verified: true }],
|
|
131
|
+
source: DNS_PROVIDER_SOURCE,
|
|
132
|
+
},
|
|
133
|
+
},
|
|
134
|
+
{ id: "hover", name: "Hover", nameservers: [/(^|\.)hover\.com$/] },
|
|
135
|
+
{
|
|
136
|
+
id: "wix",
|
|
137
|
+
name: "Wix",
|
|
138
|
+
nameservers: [/(^|\.)wixdns\.net$/],
|
|
139
|
+
// No ALIAS found and no forwarding researched, so no forwarding is offered. Wix's own
|
|
140
|
+
// instruction is to leave the Host blank wherever another one says "@".
|
|
141
|
+
// https://support.wix.com/en/article/adding-or-updating-cname-records-in-your-wix-account
|
|
142
|
+
profile: {
|
|
143
|
+
apex: { support: "none", fallbacks: [{ kind: "use_www" }, { kind: "move_dns_host" }], hostField: "blank" },
|
|
144
|
+
appendsZone: true,
|
|
145
|
+
notes: [],
|
|
146
|
+
source: DNS_PROVIDER_SOURCE,
|
|
147
|
+
},
|
|
148
|
+
},
|
|
149
|
+
{ id: "vercel", name: "Vercel", nameservers: [/(^|\.)vercel-dns\.com$/] },
|
|
150
|
+
{ id: "ns1", name: "NS1", nameservers: [/(^|\.)nsone\.net$/] },
|
|
151
|
+
{ id: "digitalocean", name: "DigitalOcean", nameservers: [/(^|\.)digitalocean\.com$/] },
|
|
152
|
+
{ id: "azure", name: "Azure DNS", nameservers: [/azure-dns\./] },
|
|
153
|
+
{ id: "hetzner", name: "Hetzner", nameservers: [/(^|\.)hetzner\.(com|de)$/] },
|
|
154
|
+
// ALIAS support is second-hand and the host convention unknown, so no profile yet.
|
|
155
|
+
{ id: "dnsimple", name: "DNSimple", nameservers: [/(^|\.)dnsimple\.com$/] },
|
|
156
|
+
{ id: "netlify", name: "Netlify", nameservers: [/(^|\.)netlify\.com$/] },
|
|
157
|
+
];
|
|
158
|
+
/** The DNS host a zone's nameservers belong to, or null for one not in the table. Entries that are not names are skipped. */
|
|
159
|
+
export function dnsProviderForNameservers(nameservers) {
|
|
160
|
+
if (!Array.isArray(nameservers))
|
|
161
|
+
return null;
|
|
162
|
+
for (const ns of nameservers) {
|
|
163
|
+
if (typeof ns !== "string")
|
|
164
|
+
continue;
|
|
165
|
+
const name = ns.toLowerCase().replace(/\.$/, "");
|
|
166
|
+
const hit = DNS_PROVIDERS.find((provider) => provider.nameservers.some((pattern) => pattern.test(name)));
|
|
167
|
+
if (hit)
|
|
168
|
+
return hit;
|
|
169
|
+
}
|
|
170
|
+
return null;
|
|
171
|
+
}
|
|
172
|
+
/** The profile for a `dnsCheck.providerId`; undefined for an unknown id or a host with no profile. */
|
|
173
|
+
export function dnsProviderProfile(providerId) {
|
|
174
|
+
if (typeof providerId !== "string" || providerId === "")
|
|
175
|
+
return undefined;
|
|
176
|
+
return DNS_PROVIDERS.find((provider) => provider.id === providerId)?.profile;
|
|
177
|
+
}
|
|
178
|
+
/**
|
|
179
|
+
* The record as the host's editor wants it. `apex` is whether this is the routing record at
|
|
180
|
+
* the zone root; `name` is the relative name (`@` at the root). Without a profile this is the
|
|
181
|
+
* record unchanged, with the root's type named the way every host that can hold it does.
|
|
182
|
+
*/
|
|
183
|
+
export function recordAtProvider(record, profile) {
|
|
184
|
+
const type = String(record.type).toUpperCase();
|
|
185
|
+
const value = profile?.valueNeedsTrailingDot && typeof record.value === "string" && type === "CNAME" && !record.value.endsWith(".") ? `${record.value}.` : record.value;
|
|
186
|
+
if (!record.apex)
|
|
187
|
+
return { type, name: record.name, value };
|
|
188
|
+
if (!profile)
|
|
189
|
+
return { type: "ALIAS", name: record.name, value };
|
|
190
|
+
const apex = profile.apex;
|
|
191
|
+
if (apex.support === "none")
|
|
192
|
+
return { type: null, name: apex.hostField === "blank" ? "" : "@", value };
|
|
193
|
+
return { type: apex.typeLabel, name: apex.hostField === "blank" ? "" : "@", value };
|
|
194
|
+
}
|