ninepm 0.72.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.
@@ -0,0 +1,248 @@
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
+ // One plain-language status for a custom domain (#1994): which of six states it is in, why,
4
+ // and the one thing that happens next. The control plane computes it after its DNS check and
5
+ // sends it on every domain; the CLI and the dashboard derive it from this same file when they
6
+ // meet a server that predates the field, so the two paths cannot disagree. Copied into the CLI
7
+ // tarball by scripts/generate-cli-package.mjs, so it imports nothing but dns-host and
8
+ // dns-providers, which are copied beside it.
9
+ import { relativeRecordName, zoneApexFor } from "./dns-host.js";
10
+ import { dnsProviderProfile } from "./dns-providers.js";
11
+ // The markers the control plane writes into provisioningError. Matched as exact strings, so a
12
+ // wording change here changes what every reader recognises at once; the server's reconcilers
13
+ // import them from this file for the same reason.
14
+ // #751: the expired marker. Deliberately static - no configured day-count inside it - because
15
+ // the reconcilers, claim checks and quota carve-outs key on this exact string.
16
+ export const CUSTOM_DOMAIN_EXPIRED_MESSAGE = "This domain was removed because its DNS verification was never completed. Add the domain again to retry.";
17
+ /** The name the CLI has always exported the expired marker under. */
18
+ export const CUSTOM_DOMAIN_EXPIRED_TEXT = CUSTOM_DOMAIN_EXPIRED_MESSAGE;
19
+ // Written onto a domain row when its app is in private_service mode, the only mode whose
20
+ // domains are torn down (#750 kept them for otp/idp).
21
+ export const CUSTOM_DOMAINS_SERVICE_MODE_MESSAGE = "Custom domains are not available for service-token apps, so this domain's hostname was removed. Switching the app to another access mode sets it up again, with new DNS verification values to publish.";
22
+ // The wording rows torn down by earlier releases still carry. It says custom domains are
23
+ // public-only, which is false (otp/idp apps keep theirs), so it is recognised and replaced.
24
+ export const LEGACY_SERVICE_MODE_MESSAGE = "Custom domains are available only for public apps.";
25
+ /** Whether a stored error is the service-mode teardown marker, in either wording. */
26
+ export function isServiceModeProvisioningError(error) {
27
+ return error === CUSTOM_DOMAINS_SERVICE_MODE_MESSAGE || error === LEGACY_SERVICE_MODE_MESSAGE;
28
+ }
29
+ // Written when the platform has no fallback script, so nothing can create, re-adopt or tear
30
+ // down this domain's route.
31
+ export const CUSTOM_DOMAINS_NOT_CONFIGURED_MESSAGE = "Custom domains are not fully configured on this platform.";
32
+ // Written when another script's route already holds the hostname.
33
+ export const CUSTOM_DOMAIN_ROUTE_CONFLICT_MESSAGE = "This domain is already claimed elsewhere on the platform. Contact support to release it.";
34
+ function normalizeDnsName(value) {
35
+ return value.trim().toLowerCase().replace(/\.$/, "");
36
+ }
37
+ // The value the server's DNS check compares against: TXT verbatim, names lowercased without a root dot.
38
+ function checkedRecordValue(type, value) {
39
+ return type === "TXT" ? value : normalizeDnsName(value);
40
+ }
41
+ export function domainRecordStatuses(domain) {
42
+ const apex = zoneApexFor(domain.hostname, domain.dnsCheck?.zone);
43
+ return domain.dnsInstructions.map((instruction) => {
44
+ const type = instruction.type.toUpperCase();
45
+ const fqdn = normalizeDnsName(instruction.host);
46
+ const expected = checkedRecordValue(type, instruction.value);
47
+ // Matched on the expected value as well as host and type: the two TXT values of one name
48
+ // are separate rows, and keying on host and type alone gives both rows one verdict (#1986).
49
+ const seen = domain.dnsCheck?.records.find((entry) => entry.type.toUpperCase() === type && normalizeDnsName(entry.host) === fqdn && entry.expected === expected);
50
+ const atApex = type === "CNAME" && fqdn === apex;
51
+ const check = domainRecordCheck(seen, atApex);
52
+ return {
53
+ type,
54
+ name: relativeRecordName(fqdn, apex),
55
+ fqdn,
56
+ expected: instruction.value,
57
+ current: seen ? seen.current : null,
58
+ ok: check === "found" ? true : check === "missing" || check === "different" || check === "doubled" ? false : null,
59
+ check,
60
+ ...(check === "doubled" && seen?.doubledAt ? { doubledAt: seen.doubledAt } : {}),
61
+ apex: atApex,
62
+ optional: instruction.optional === true,
63
+ };
64
+ });
65
+ }
66
+ const IP_ADDRESS = /^(\d{1,3}(\.\d{1,3}){3}|[0-9a-f:]*:[0-9a-f:.]+)$/i;
67
+ // The server looks a CNAME up and, finding none, falls back to the name's A records. At the
68
+ // zone root that is exactly what a correct ALIAS/ANAME or flattened CNAME answers: addresses,
69
+ // never the target name. So addresses at the apex are not evidence of a wrong record; they
70
+ // cannot be compared from outside at all.
71
+ function domainRecordCheck(seen, atApex) {
72
+ if (!seen)
73
+ return "not_checked";
74
+ if (seen.matches)
75
+ return "found";
76
+ if (seen.doubledAt)
77
+ return "doubled";
78
+ if (seen.current.length === 0)
79
+ return "missing";
80
+ if (atApex && seen.current.every((value) => IP_ADDRESS.test(value.trim())))
81
+ return "apex_addresses";
82
+ return "different";
83
+ }
84
+ // Unproven is decided by `ok`, not by listing check values: a check this code does not know yet
85
+ // carries ok null and so reads as unknown, never as found. The one exception is an apex
86
+ // answering with addresses, which the platform's own active routing vouches for.
87
+ function isUnproven(record, routingActive) {
88
+ if (record.ok !== null)
89
+ return false;
90
+ return !(record.check === "apex_addresses" && routingActive);
91
+ }
92
+ /**
93
+ * The `www.<domain>` to connect instead, when this hostname is a bare domain whose DNS host
94
+ * cannot point a root at a hostname at all (#1996): GoDaddy, Route 53, IONOS, Wix. Read only
95
+ * from a host the check both named and identified, so an unknown host, an older server and a
96
+ * domain with no check keep the ordinary records. Routing that is already active is the
97
+ * platform's own evidence that the root record exists, and wins over the profile.
98
+ */
99
+ export function rootUnsupportedWwwHostname(domain) {
100
+ const check = domain.dnsCheck;
101
+ if (!check?.provider || domain.routingStatus === "active")
102
+ return null;
103
+ if (dnsProviderProfile(check.providerId)?.apex.support !== "none")
104
+ return null;
105
+ const host = domain.hostname.toLowerCase().replace(/\.$/, "");
106
+ return host === zoneApexFor(host, check.zone) ? `www.${host}` : null;
107
+ }
108
+ /**
109
+ * Whether a domain's instructions show "connect www instead" in place of the root record. It
110
+ * reads the status's own next step, so the display and the status cannot disagree: routing
111
+ * that is already active, an unknown host or an older server never show it, and a server's
112
+ * verdict is honoured even for a host this copy's table does not know.
113
+ */
114
+ export function showsUseWww(next) {
115
+ return next?.action === "use_www";
116
+ }
117
+ export function deriveDomainState(domain, records = domainRecordStatuses(domain)) {
118
+ if (domain.provisioningError === CUSTOM_DOMAIN_EXPIRED_MESSAGE)
119
+ return "expired";
120
+ if (domain.routingStatus === "failed" || domain.tlsStatus === "failed" || domain.provisioningError)
121
+ return "failed";
122
+ if (domain.routingStatus === "active" && domain.tlsStatus === "active")
123
+ return "live";
124
+ // No record the owner can publish will finish this domain: it waits on the owner choosing www.
125
+ if (rootUnsupportedWwwHostname(domain))
126
+ return "waiting_for_dns";
127
+ const required = records.filter((record) => !record.optional);
128
+ if (required.some((record) => record.ok === false))
129
+ return "waiting_for_dns";
130
+ const routingActive = domain.routingStatus === "active";
131
+ // An apex answering with addresses defers to the platform's own routing check: active means
132
+ // it found the record, pending means it has not yet.
133
+ const unproven = required.some((record) => isUnproven(record, routingActive));
134
+ if (unproven || (!domain.dnsCheck && required.length === 0)) {
135
+ // Routing still pending is the platform's own evidence that it has not seen the records.
136
+ return routingActive ? "dns_unverified" : "waiting_for_dns";
137
+ }
138
+ return "issuing_certificate";
139
+ }
140
+ function nextRecord(record) {
141
+ const action = record.ok === false ? (record.check === "different" && record.type !== "TXT" ? "replace" : "add") : "confirm";
142
+ return { type: record.type, host: record.fqdn, value: record.expected, action, check: record.check };
143
+ }
144
+ // Who controls the CAA record that decides, which decides the advice: in the owner's own zone
145
+ // they can add an entry; at a CNAME target only whoever runs that name can; and when the
146
+ // resolver did not say, nothing is handed over to edit at a name the owner may not control.
147
+ function caaNext(caa) {
148
+ const summary = {
149
+ recordName: caa.recordName,
150
+ ...(caa.via ? { via: caa.via } : {}),
151
+ ...(caa.ownerUnknown ? { ownerUnknown: true } : {}),
152
+ platformIssuers: caa.platformIssuers,
153
+ };
154
+ if (caa.via)
155
+ return { who: "owner", action: "ask_caa_controller", caa: summary };
156
+ if (caa.ownerUnknown)
157
+ return { who: "owner", action: "check_caa", caa: summary };
158
+ const issuer = caa.platformIssuers[0];
159
+ return {
160
+ who: "owner",
161
+ action: "allow_ca_in_your_zone",
162
+ caa: summary,
163
+ ...(issuer ? { records: [{ type: "CAA", host: caa.recordName, value: `0 issue "${issuer}"`, action: "add", check: "missing" }] } : {}),
164
+ };
165
+ }
166
+ /**
167
+ * The state, why, and the one next step. `state` is exactly deriveDomainState's; `reason` and
168
+ * `next` only refine it. Two rules hold throughout: a record nobody looked at is never called
169
+ * missing (it gets confirm_records, never add), and nothing here restarts validation or
170
+ * suggests that a restart speeds things up - Start over is named only for a failed certificate.
171
+ */
172
+ export function deriveDomainStatus(domain, options = {}) {
173
+ const records = domainRecordStatuses(domain);
174
+ const state = deriveDomainState(domain, records);
175
+ const source = domain.providerMessageSource === "certificate_authority" ? "certificate_authority" : "hostname";
176
+ // A hostname's own note while the owner is still publishing records is the expected "does not
177
+ // CNAME to this zone": it says nothing the records table does not, so it is not surfaced there.
178
+ const showMessage = Boolean(domain.providerMessage && domain.providerMessageAt) && state !== "live" && !(source === "hostname" && state === "waiting_for_dns");
179
+ const withMessage = (code) => showMessage ? { code, providerMessage: { text: domain.providerMessage, seenAt: domain.providerMessageAt, source } } : { code };
180
+ const nextCheckAt = options.nextCheckAt ? { nextCheckAt: options.nextCheckAt } : {};
181
+ const required = records.filter((record) => !record.optional);
182
+ if (state === "expired")
183
+ return { state, reason: withMessage("expired"), next: { who: "owner", action: "add_again" } };
184
+ if (state === "live")
185
+ return { state, reason: null, next: { action: "none" } };
186
+ if (state === "failed") {
187
+ const error = domain.provisioningError;
188
+ if (isServiceModeProvisioningError(error)) {
189
+ return { state, reason: withMessage("service_mode"), next: { who: "owner", action: "change_access_mode" } };
190
+ }
191
+ if (error === CUSTOM_DOMAINS_NOT_CONFIGURED_MESSAGE) {
192
+ return { state, reason: withMessage("platform_not_configured"), next: { who: "platform", action: "contact_support" } };
193
+ }
194
+ if (error === CUSTOM_DOMAIN_ROUTE_CONFLICT_MESSAGE) {
195
+ return { state, reason: withMessage("claimed_elsewhere"), next: { who: "platform", action: "contact_support" } };
196
+ }
197
+ if (!error && domain.tlsStatus === "failed") {
198
+ const fix = required.filter((record) => record.ok === false).map(nextRecord);
199
+ return {
200
+ state,
201
+ reason: withMessage("certificate_failed"),
202
+ next: { who: "owner", action: "start_over", ...(fix.length > 0 ? { records: fix } : {}) },
203
+ };
204
+ }
205
+ if (!error) {
206
+ // Routing failed with nothing recorded to say why. Nothing measured says the owner can
207
+ // fix it, so DNS edits are not prescribed: support is, and Re-check stays available.
208
+ return { state, reason: withMessage("hostname_failed"), next: { who: "platform", action: "contact_support" } };
209
+ }
210
+ // An upstream call threw; the background reconcile retries it, and Re-check retries now.
211
+ return { state, reason: withMessage("provider_error"), next: { who: "platform", action: "recheck" } };
212
+ }
213
+ const www = rootUnsupportedWwwHostname(domain);
214
+ if (www) {
215
+ return { state, reason: withMessage("dns_host_cannot_point_root"), next: { who: "owner", action: "use_www", hostname: www } };
216
+ }
217
+ if (state === "waiting_for_dns" || state === "dns_unverified") {
218
+ const wrong = required.filter((record) => record.ok === false);
219
+ if (wrong.length > 0) {
220
+ // TXT is never "replace": a name may hold several TXT values, so a missing one is added beside them.
221
+ const pointsElsewhere = wrong.some((record) => record.type !== "TXT" && record.check === "different");
222
+ const code = domain.activatedAt
223
+ ? "dns_changed_after_live"
224
+ : pointsElsewhere
225
+ ? "record_points_elsewhere"
226
+ : "records_missing";
227
+ return {
228
+ state,
229
+ reason: withMessage(code),
230
+ next: { who: "owner", action: pointsElsewhere ? "replace_record" : "add_records", records: wrong.map(nextRecord), ...nextCheckAt },
231
+ };
232
+ }
233
+ const routingActive = domain.routingStatus === "active";
234
+ const unproven = required.filter((record) => isUnproven(record, routingActive));
235
+ const code = unproven.length > 0 && unproven.every((record) => record.check === "apex_addresses") ? "apex_unverifiable" : "dns_not_checked";
236
+ return {
237
+ state,
238
+ reason: withMessage(code),
239
+ next: { who: "owner", action: "confirm_records", ...(unproven.length > 0 ? { records: unproven.map(nextRecord) } : {}), ...nextCheckAt },
240
+ };
241
+ }
242
+ // issuing_certificate: every required record was checked and found.
243
+ const caa = domain.dnsCheck?.caa;
244
+ if (caa?.status === "blocks" && caa.recordName) {
245
+ return { state, reason: withMessage("caa_blocks"), next: caaNext({ ...caa, recordName: caa.recordName }) };
246
+ }
247
+ return { state, reason: withMessage("certificate_pending"), next: { who: "platform", action: "wait", ...nextCheckAt } };
248
+ }
package/dist/index.d.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  #!/usr/bin/env node
2
2
  import { Agent } from "undici";
3
+ import { type DomainNext, type DomainReason, type DomainRecordStatus, type DomainState, type DomainStateDnsCheck, type DomainStateDnsInstruction, type DomainStateInput } from "./domain-state.js";
3
4
  import type { AccessGrant, AccessGrantType, AccessProviderKey, AppAccessSettings, AppAccessServiceToken, AppAccessToken, AppDatabase, AppRuntime, AppUserFilesUsage, ContainerImageUploadInstruction, ContainerRegion, Deployment, ManagedFileObject, ManagedFileShare, ManagedFileUsage, Project } from "./cli-types.js";
4
5
  export interface DeployOptions {
5
6
  directory: string;
@@ -106,6 +107,7 @@ export interface DeletedSiteResult {
106
107
  slug: string;
107
108
  workers: number;
108
109
  customDomains: number;
110
+ customDomainNames?: string[];
109
111
  containerApplications?: number;
110
112
  containerImages?: number;
111
113
  accessApps: number;
@@ -125,7 +127,7 @@ export interface CreatedServiceToken {
125
127
  clientSecret: string;
126
128
  }
127
129
  export type FetchLike = (input: string | URL | Request, init?: RequestInit) => Promise<Response>;
128
- export declare const CLI_VERSION = "0.72.0";
130
+ export declare const CLI_VERSION = "0.78.0";
129
131
  type CredentialBackend = "macos-keychain" | "windows-dpapi" | "linux-secret-service" | "file-plaintext" | "none";
130
132
  export interface StoredCredentialEntry {
131
133
  token: string;
@@ -491,6 +493,7 @@ export declare function dashboardUrlForApiUrl(apiUrl: string): string;
491
493
  type CliCommand = {
492
494
  kind: "deploy";
493
495
  options: DeployOptions;
496
+ apiUrlFrom: ApiUrlSource;
494
497
  } | {
495
498
  kind: "version";
496
499
  } | {
@@ -621,6 +624,7 @@ type CliCommand = {
621
624
  } | {
622
625
  kind: "domains";
623
626
  owner?: string;
627
+ apiUrlFrom: ApiUrlSource;
624
628
  action: {
625
629
  kind: "list";
626
630
  site?: string;
@@ -729,64 +733,52 @@ export declare function deploymentsListJson<T extends ListedDeployment>(deployme
729
733
  serving: boolean;
730
734
  }>;
731
735
  };
732
- export interface CliDnsInstruction {
733
- type: string;
734
- host: string;
735
- value: string;
736
- optional?: boolean;
737
- }
738
- export interface CliDomainDnsCheck {
739
- provider: string | null;
740
- nameservers: string[];
741
- records: Array<{
742
- type: string;
743
- host: string;
744
- expected: string;
745
- current: string[];
746
- matches: boolean;
736
+ export { CUSTOM_DOMAIN_EXPIRED_TEXT, deriveDomainState, deriveDomainStatus, domainRecordStatuses, type DomainRecordCheck, type DomainRecordStatus, type DomainState, } from "./domain-state.js";
737
+ export type CliDnsInstruction = DomainStateDnsInstruction;
738
+ export type CliDomainDnsCheck = DomainStateDnsCheck & {
739
+ advisories?: Array<{
740
+ code: string;
741
+ message: string;
747
742
  }>;
748
- }
749
- export interface CliDomain {
750
- hostname: string;
751
- routingStatus: string;
752
- tlsStatus: string;
753
- dnsInstructions: CliDnsInstruction[];
754
- provisioningError: string | null;
743
+ };
744
+ export interface CliDomain extends DomainStateInput {
745
+ dnsCheck?: CliDomainDnsCheck;
755
746
  expireAfter?: string | null;
756
747
  expiresAt?: string | null;
757
- dnsCheck?: CliDomainDnsCheck;
758
748
  app?: {
759
749
  id: string;
760
750
  slug: string;
761
751
  name: string;
762
752
  };
753
+ state?: string;
754
+ reason?: DomainReason | null;
755
+ next?: DomainNext;
756
+ }
757
+ export interface DomainRecordStatusJson extends DomainRecordStatus {
758
+ typeAtProvider: string | null;
759
+ nameAtProvider: string;
760
+ valueAtProvider: string;
763
761
  }
764
- export type DomainState = "live" | "waiting_for_dns" | "dns_unverified" | "issuing_certificate" | "failed" | "expired";
765
- export declare const CUSTOM_DOMAIN_EXPIRED_TEXT = "This domain was removed because its DNS verification was never completed. Add the domain again to retry.";
766
- export interface DomainRecordStatus {
767
- type: string;
768
- name: string;
769
- fqdn: string;
770
- expected: string;
771
- current: string[] | null;
772
- ok: boolean | null;
773
- check: DomainRecordCheck;
774
- apex: boolean;
775
- optional: boolean;
776
- }
777
- export type DomainRecordCheck = "found" | "missing" | "different" | "not_checked" | "apex_addresses";
778
762
  export interface DomainStatusJson {
779
763
  hostname: string;
780
764
  app: string;
781
- state: DomainState;
765
+ state: DomainState | (string & {});
782
766
  routing: string;
783
767
  tls: string;
784
768
  provider: string | null;
769
+ providerId: string | null;
785
770
  nameservers: string[];
786
- records: DomainRecordStatus[];
771
+ zone: string | null;
772
+ records: DomainRecordStatusJson[];
773
+ advisories: Array<{
774
+ code: string;
775
+ message: string;
776
+ }>;
787
777
  error: string | null;
788
778
  expiresAt: string | null;
789
779
  checkedAt: string;
780
+ reason: DomainReason | null;
781
+ next: DomainNext;
790
782
  }
791
783
  export declare const DOMAIN_WAIT_EXIT: {
792
784
  readonly live: 0;
@@ -794,13 +786,15 @@ export declare const DOMAIN_WAIT_EXIT: {
794
786
  readonly platform: 11;
795
787
  readonly failed: 20;
796
788
  };
797
- export declare function domainRecordStatuses(domain: CliDomain): DomainRecordStatus[];
798
- export declare function deriveDomainState(domain: CliDomain, records?: DomainRecordStatus[]): DomainState;
799
789
  export declare function domainStatusJson(domain: CliDomain, app: string, checkedAt: string): DomainStatusJson;
800
- export declare function domainStateLabel(state: DomainState): string;
801
- export declare function formatDomainStatus(status: DomainStatusJson, options?: {
790
+ export declare function domainStateLabel(state: string): string;
791
+ export declare function domainWaitExitCode(status: Pick<DomainStatusJson, "state" | "next">): number;
792
+ export interface DomainFormatOptions {
802
793
  owner?: string;
803
- }): string[];
794
+ target?: AccessApiTarget;
795
+ }
796
+ export declare const DNS_PROVIDER_NOTES: Record<string, string>;
797
+ export declare function formatDomainStatus(status: DomainStatusJson, options?: DomainFormatOptions): string[];
804
798
  export declare function domainWaitTimeoutMessage(status: DomainStatusJson): string;
805
799
  export declare function formatDomainsList(statuses: DomainStatusJson[]): string[];
806
800
  export declare class DomainsApiError extends Error {
@@ -838,6 +832,14 @@ export declare function restartDomain(options: DomainCommandOptions & {
838
832
  export declare function removeDomain(options: DomainCommandOptions & {
839
833
  hostname: string;
840
834
  }): Promise<CliDomain | null>;
835
+ export declare const DEPLOY_DOMAINS_TIMEOUT_MS = 4500;
836
+ export declare function deployCustomDomainStatuses(options: ApiOptions & {
837
+ projectId: string;
838
+ slug: string;
839
+ timeoutMs?: number;
840
+ now?: () => Date;
841
+ }): Promise<DomainStatusJson[] | null>;
842
+ export declare function deployCustomDomainLines(statuses: DomainStatusJson[] | null, slug: string, owner?: string, target?: AccessApiTarget): string[];
841
843
  export interface DomainWaitResult {
842
844
  status: DomainStatusJson;
843
845
  exitCode: number;
@@ -898,5 +900,5 @@ export interface AccessApiTarget {
898
900
  apiUrl: string;
899
901
  apiUrlFrom: ApiUrlSource;
900
902
  }
903
+ export declare function apiUrlFlagFor(target?: AccessApiTarget): string;
901
904
  export declare function accessSwitchModeCommand(action: AccessModeChangeAction, target?: AccessApiTarget): string;
902
- export {};