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/dist/index.js
CHANGED
|
@@ -13,6 +13,9 @@ import { Agent, fetch as undiciFetch } from "undici";
|
|
|
13
13
|
import { MAX_DEPLOY_DESCRIPTION_LENGTH, normalizeDeployDescription } from "./deploy-description.js";
|
|
14
14
|
import { managedHostnamesIn, siblingCallWarning, siblingsOf } from "./sibling-hostnames.js";
|
|
15
15
|
import { MAX_STATIC_ASSET_FILE_BYTES, MAX_WORKER_ENTRY_COMPRESSED_BYTES, MAX_WORKER_SCRIPT_COMPRESSED_BYTES, WORKER_ENTRY_COMPRESSED_WARN_BYTES, assetFileTooLargeMessage, formatMebibytes, formatMegabytes, oversizedAssets, workerBundleNearLimitMessage, workerBundleTooLargeMessage, workerEntryNearLimitMessage, workerEntryTooLargeMessage, } from "./deploy-limits.js";
|
|
16
|
+
import { guessZoneApex, relativeRecordName, zoneApexFor } from "./dns-host.js";
|
|
17
|
+
import { dnsProviderProfile, recordAtProvider } from "./dns-providers.js";
|
|
18
|
+
import { deriveDomainStatus, domainRecordStatuses, showsUseWww, } from "./domain-state.js";
|
|
16
19
|
import { buildFileManifest, readManifestFile } from "./manifest.js";
|
|
17
20
|
import { NO_PUBLIC_ASSETS, normalizeAssetsDirectory, privateLookingPaths, publicAssetPath } from "./public-assets.js";
|
|
18
21
|
import { API_KEY_HELP, API_URL_HELP, CLI_CLIENT_ID, DEFAULT_API_URL, reportCliError } from "./cli-config.js";
|
|
@@ -49,7 +52,7 @@ export function deployDatabaseClause(databases) {
|
|
|
49
52
|
const listed = databases.map((database) => (database.name ? `${database.bindingName}:${database.name}` : database.bindingName)).join(", ");
|
|
50
53
|
return `${databases.length === 1 ? "database" : "databases"} ${listed}`;
|
|
51
54
|
}
|
|
52
|
-
export const CLI_VERSION = "0.
|
|
55
|
+
export const CLI_VERSION = "0.78.0";
|
|
53
56
|
const PUBLIC_DASHBOARD_URL = "https://9pm.ai/dashboard";
|
|
54
57
|
const TOKEN_SERVICE = "9pm";
|
|
55
58
|
const TOKEN_ACCOUNT = "default";
|
|
@@ -243,9 +246,13 @@ export class DeployError extends Error {
|
|
|
243
246
|
* prevent (#1061).
|
|
244
247
|
*/
|
|
245
248
|
export class DeliberateRefusalError extends Error {
|
|
246
|
-
|
|
249
|
+
// The server's error code, so a command can add what only it knows - the exact
|
|
250
|
+
// command line to re-run - to a refusal it recognises.
|
|
251
|
+
code;
|
|
252
|
+
constructor(message, code) {
|
|
247
253
|
super(message);
|
|
248
254
|
this.name = "DeliberateRefusalError";
|
|
255
|
+
this.code = code;
|
|
249
256
|
}
|
|
250
257
|
}
|
|
251
258
|
/**
|
|
@@ -266,6 +273,7 @@ export class DeliberateRefusalError extends Error {
|
|
|
266
273
|
const DELIBERATE_REFUSAL_CODES = new Set([
|
|
267
274
|
"deploys_frozen",
|
|
268
275
|
"cli_version_outdated",
|
|
276
|
+
"access_mode_change_unconfirmed",
|
|
269
277
|
]);
|
|
270
278
|
export function isDeliberateRefusal(error) {
|
|
271
279
|
if (error instanceof DeliberateRefusalError)
|
|
@@ -495,6 +503,11 @@ async function runDeploy(initialOptions, slugHolder) {
|
|
|
495
503
|
throw new Error(options.bundleDir ? `Bundle directory contains no files: ${contentRoot}` : "Directory contains no deployable files.");
|
|
496
504
|
}
|
|
497
505
|
const bundle = options.bundleDir ? bundleMetadataFromManifest(manifest, contentRoot) : null;
|
|
506
|
+
// Checked here as well as at parse time because a detected framework build becomes a
|
|
507
|
+
// bundle only after parsing, and the platform would refuse it after the app exists.
|
|
508
|
+
if (bundle && options.missingPages) {
|
|
509
|
+
throw new Error("--spa and --no-spa do not apply to a framework bundle: its server answers every path itself.");
|
|
510
|
+
}
|
|
498
511
|
const serverEntry = runtime === "worker" && !bundle ? normalizeServerEntry(options.entry ?? "src/index.js") : undefined;
|
|
499
512
|
// Set by the size preflight below and reused by the upload loop, so the entry is read once.
|
|
500
513
|
let serverEntryBytes;
|
|
@@ -637,6 +650,7 @@ async function runDeploy(initialOptions, slugHolder) {
|
|
|
637
650
|
...(options.description ? { description: options.description } : {}),
|
|
638
651
|
runtime,
|
|
639
652
|
...(serverEntry ? { serverEntry } : {}),
|
|
653
|
+
...(options.missingPages ? { missingPages: options.missingPages } : {}),
|
|
640
654
|
metadata: {
|
|
641
655
|
cli: CLI_CLIENT_ID,
|
|
642
656
|
inspiredBy: "here.now",
|
|
@@ -1142,29 +1156,40 @@ export async function getSiteAccess(options) {
|
|
|
1142
1156
|
method: "GET",
|
|
1143
1157
|
});
|
|
1144
1158
|
}
|
|
1159
|
+
// The server refuses an access-mode change unless the request says it means it (#1987):
|
|
1160
|
+
// the change can lock people out and take custom domains down. `--switch-mode` sends
|
|
1161
|
+
// `switchMode: true`; without it the refusal is printed with the command to re-run.
|
|
1162
|
+
export const ACCESS_MODE_CHANGE_UNCONFIRMED = "access_mode_change_unconfirmed";
|
|
1163
|
+
function switchModeField(options) {
|
|
1164
|
+
return options.switchMode ? { switchMode: true } : {};
|
|
1165
|
+
}
|
|
1145
1166
|
export async function setSiteAccessPublic(options) {
|
|
1146
1167
|
const payload = await apiRequest(options, `/v1/sites/${encodeURIComponent(options.site)}/access`, {
|
|
1147
1168
|
method: "PUT",
|
|
1148
|
-
body: JSON.stringify({ mode: "public" }),
|
|
1169
|
+
body: JSON.stringify({ mode: "public", ...switchModeField(options) }),
|
|
1149
1170
|
});
|
|
1150
1171
|
return payload.access;
|
|
1151
1172
|
}
|
|
1152
1173
|
export async function setSiteAccessOtp(options) {
|
|
1153
1174
|
const payload = await apiRequest(options, `/v1/sites/${encodeURIComponent(options.site)}/access`, {
|
|
1154
1175
|
method: "PUT",
|
|
1155
|
-
body: JSON.stringify({ mode: "private_otp" }),
|
|
1176
|
+
body: JSON.stringify({ mode: "private_otp", ...switchModeField(options) }),
|
|
1156
1177
|
});
|
|
1157
1178
|
return payload.access;
|
|
1158
1179
|
}
|
|
1159
1180
|
export async function setSiteAccessIdp(options) {
|
|
1160
1181
|
const payload = await apiRequest(options, `/v1/sites/${encodeURIComponent(options.site)}/access`, {
|
|
1161
1182
|
method: "PUT",
|
|
1162
|
-
body: JSON.stringify({
|
|
1183
|
+
body: JSON.stringify({
|
|
1184
|
+
mode: "private_idp",
|
|
1185
|
+
...(options.providers?.length ? { providers: options.providers } : {}),
|
|
1186
|
+
...switchModeField(options),
|
|
1187
|
+
}),
|
|
1163
1188
|
});
|
|
1164
1189
|
return payload.access;
|
|
1165
1190
|
}
|
|
1166
1191
|
export async function createAccessServiceToken(options) {
|
|
1167
|
-
const body = {};
|
|
1192
|
+
const body = { ...switchModeField(options) };
|
|
1168
1193
|
if (options.name) {
|
|
1169
1194
|
body.name = options.name;
|
|
1170
1195
|
}
|
|
@@ -2435,7 +2460,7 @@ async function expectOk(response, action) {
|
|
|
2435
2460
|
const code = typeof envelope?.code === "string" ? envelope.code : "";
|
|
2436
2461
|
const message = typeof envelope?.message === "string" ? envelope.message.trim() : "";
|
|
2437
2462
|
if (code && message && DELIBERATE_REFUSAL_CODES.has(code)) {
|
|
2438
|
-
throw new DeliberateRefusalError(message);
|
|
2463
|
+
throw new DeliberateRefusalError(message, code);
|
|
2439
2464
|
}
|
|
2440
2465
|
throw new Error(`Failed to ${action}: ${response.status} ${text}`);
|
|
2441
2466
|
}
|
|
@@ -2610,6 +2635,8 @@ const VALID_DEPLOY_FLAGS = new Set([
|
|
|
2610
2635
|
"bundle",
|
|
2611
2636
|
"assets",
|
|
2612
2637
|
"no-assets",
|
|
2638
|
+
"spa",
|
|
2639
|
+
"no-spa",
|
|
2613
2640
|
]);
|
|
2614
2641
|
export function parseArgs(argv) {
|
|
2615
2642
|
const command = argv[0];
|
|
@@ -2628,6 +2655,9 @@ export function parseArgs(argv) {
|
|
|
2628
2655
|
if (command === "deployments") {
|
|
2629
2656
|
return parseDeploymentsArgs(argv.slice(1));
|
|
2630
2657
|
}
|
|
2658
|
+
if (command === "domains") {
|
|
2659
|
+
return parseDomainsArgs(argv.slice(1));
|
|
2660
|
+
}
|
|
2631
2661
|
if (command === "logs") {
|
|
2632
2662
|
return parseLogsArgs(argv.slice(1));
|
|
2633
2663
|
}
|
|
@@ -2860,6 +2890,23 @@ export function parseArgs(argv) {
|
|
|
2860
2890
|
}
|
|
2861
2891
|
options.publicAssets = typeof assetsFlag === "string" ? normalizeAssetsDirectory(assetsFlag) : NO_PUBLIC_ASSETS;
|
|
2862
2892
|
}
|
|
2893
|
+
if (flags.has("spa") || flags.has("no-spa")) {
|
|
2894
|
+
if (flags.has("spa") && flags.has("no-spa")) {
|
|
2895
|
+
throw new Error("--spa and --no-spa cannot be combined.");
|
|
2896
|
+
}
|
|
2897
|
+
// The flag parser hands the next word to a flag as its value, so `--spa dist` would
|
|
2898
|
+
// otherwise deploy "." and drop "dist" silently.
|
|
2899
|
+
for (const name of ["spa", "no-spa"]) {
|
|
2900
|
+
const value = flags.get(name);
|
|
2901
|
+
if (typeof value === "string") {
|
|
2902
|
+
throw new Error(`--${name} takes no value (got "${value}"). Put the directory first: 9pm deploy ${value} --${name}`);
|
|
2903
|
+
}
|
|
2904
|
+
}
|
|
2905
|
+
if (options.runtime === "container" || options.bundleDir) {
|
|
2906
|
+
throw new Error("--spa and --no-spa apply to static sites and --entry worker apps; a container or --bundle app answers every path itself.");
|
|
2907
|
+
}
|
|
2908
|
+
options.missingPages = flags.has("spa") ? "spa" : "404";
|
|
2909
|
+
}
|
|
2863
2910
|
if (options.runtime === "container" && !options.port) {
|
|
2864
2911
|
throw new Error("Container deploys require --port <port>.");
|
|
2865
2912
|
}
|
|
@@ -2872,7 +2919,7 @@ export function parseArgs(argv) {
|
|
|
2872
2919
|
options.runtime !== "container") {
|
|
2873
2920
|
throw new Error("--always-on, --volume, --allow-data-wipe, --allow-namespace-reset, --cpus, and --memory require --runtime container.");
|
|
2874
2921
|
}
|
|
2875
|
-
return { kind: "deploy", options };
|
|
2922
|
+
return { kind: "deploy", options, apiUrlFrom: apiUrlSource(flags) };
|
|
2876
2923
|
}
|
|
2877
2924
|
function parseDeleteArgs(argv) {
|
|
2878
2925
|
const flags = parseFlags(argv);
|
|
@@ -3173,6 +3220,710 @@ async function runDeploymentsCommand(command) {
|
|
|
3173
3220
|
});
|
|
3174
3221
|
console.log(`Deleted deployment ${result.deleted}.`);
|
|
3175
3222
|
}
|
|
3223
|
+
// `9pm domains` (#2000): custom domains on the existing REST API. The state, reason and next
|
|
3224
|
+
// step come from the server when it sends them (#1994); against an older server they are
|
|
3225
|
+
// derived here from the same shared definition, so both paths agree.
|
|
3226
|
+
export { CUSTOM_DOMAIN_EXPIRED_TEXT, deriveDomainState, deriveDomainStatus, domainRecordStatuses, } from "./domain-state.js";
|
|
3227
|
+
export const DOMAIN_WAIT_EXIT = { live: 0, ownerAction: 10, platform: 11, failed: 20 };
|
|
3228
|
+
function normalizeDnsName(value) {
|
|
3229
|
+
return value.trim().toLowerCase().replace(/\.$/, "");
|
|
3230
|
+
}
|
|
3231
|
+
// The provider fields are an enrichment: anything malformed in them falls back to the record
|
|
3232
|
+
// as the server stored it rather than failing the whole status.
|
|
3233
|
+
function recordsAtProvider(records, profile) {
|
|
3234
|
+
return records.map((record) => {
|
|
3235
|
+
try {
|
|
3236
|
+
const at = recordAtProvider({ type: record.type, name: record.name, value: record.expected, apex: record.apex }, profile);
|
|
3237
|
+
return { ...record, typeAtProvider: at.type, nameAtProvider: at.name, valueAtProvider: at.value };
|
|
3238
|
+
}
|
|
3239
|
+
catch {
|
|
3240
|
+
return { ...record, typeAtProvider: record.type, nameAtProvider: record.name, valueAtProvider: record.expected };
|
|
3241
|
+
}
|
|
3242
|
+
});
|
|
3243
|
+
}
|
|
3244
|
+
function stringOrNull(value) {
|
|
3245
|
+
return typeof value === "string" && value !== "" ? value : null;
|
|
3246
|
+
}
|
|
3247
|
+
export function domainStatusJson(domain, app, checkedAt) {
|
|
3248
|
+
const providerId = domain.dnsCheck?.provider ? stringOrNull(domain.dnsCheck.providerId) : null;
|
|
3249
|
+
const records = recordsAtProvider(domainRecordStatuses(domain), dnsProviderProfile(providerId));
|
|
3250
|
+
const derived = deriveDomainStatus(domain);
|
|
3251
|
+
// The server's own status wins when it sent one; it is computed from the same definition.
|
|
3252
|
+
const fromServer = typeof domain.state === "string" && domain.state !== "";
|
|
3253
|
+
return {
|
|
3254
|
+
hostname: domain.hostname,
|
|
3255
|
+
app: domain.app?.slug ?? app,
|
|
3256
|
+
state: fromServer ? domain.state : derived.state,
|
|
3257
|
+
routing: domain.routingStatus,
|
|
3258
|
+
tls: domain.tlsStatus,
|
|
3259
|
+
provider: domain.dnsCheck?.provider ?? null,
|
|
3260
|
+
providerId,
|
|
3261
|
+
nameservers: domain.dnsCheck?.nameservers ?? [],
|
|
3262
|
+
zone: stringOrNull(domain.dnsCheck?.zone),
|
|
3263
|
+
records,
|
|
3264
|
+
advisories: (domain.dnsCheck?.advisories ?? []).map(({ code, message }) => ({ code, message })),
|
|
3265
|
+
error: domain.provisioningError,
|
|
3266
|
+
expiresAt: domain.expiresAt ?? domain.expireAfter ?? null,
|
|
3267
|
+
checkedAt,
|
|
3268
|
+
reason: fromServer ? (domain.reason ?? null) : derived.reason,
|
|
3269
|
+
next: fromServer ? (domain.next ?? derived.next) : derived.next,
|
|
3270
|
+
};
|
|
3271
|
+
}
|
|
3272
|
+
const DOMAIN_STATE_LABELS = {
|
|
3273
|
+
live: "live",
|
|
3274
|
+
waiting_for_dns: "waiting for DNS records",
|
|
3275
|
+
dns_unverified: "couldn't check the DNS records just now",
|
|
3276
|
+
issuing_certificate: "DNS records found, waiting for the platform to issue the certificate",
|
|
3277
|
+
failed: "failed",
|
|
3278
|
+
expired: "expired",
|
|
3279
|
+
};
|
|
3280
|
+
// A state newer than this CLI is printed as the server named it rather than guessed at.
|
|
3281
|
+
export function domainStateLabel(state) {
|
|
3282
|
+
return Object.hasOwn(DOMAIN_STATE_LABELS, state) ? DOMAIN_STATE_LABELS[state] : state;
|
|
3283
|
+
}
|
|
3284
|
+
// What `wait` exits with once it stops polling. Who acts next decides between 10 and 11, so a
|
|
3285
|
+
// CAA record blocking the certificate (every record found, but the owner must change CAA) exits
|
|
3286
|
+
// 10, and a state this CLI does not know is still mapped by who the server says acts.
|
|
3287
|
+
export function domainWaitExitCode(status) {
|
|
3288
|
+
if (status.state === "live")
|
|
3289
|
+
return DOMAIN_WAIT_EXIT.live;
|
|
3290
|
+
if (status.state === "failed" || status.state === "expired")
|
|
3291
|
+
return DOMAIN_WAIT_EXIT.failed;
|
|
3292
|
+
if (status.state === "waiting_for_dns" || status.state === "dns_unverified")
|
|
3293
|
+
return DOMAIN_WAIT_EXIT.ownerAction;
|
|
3294
|
+
return status.next?.who === "owner" ? DOMAIN_WAIT_EXIT.ownerAction : DOMAIN_WAIT_EXIT.platform;
|
|
3295
|
+
}
|
|
3296
|
+
// The CAA advice follows who controls the deciding record: the owner's own zone, a CNAME
|
|
3297
|
+
// target only its operator can change, or a name the resolver did not attribute.
|
|
3298
|
+
function caaAdviceLines(next) {
|
|
3299
|
+
const caa = next.caa;
|
|
3300
|
+
if (!caa)
|
|
3301
|
+
return [];
|
|
3302
|
+
const issuers = caa.platformIssuers.join(", ");
|
|
3303
|
+
if (next.action === "ask_caa_controller" && caa.via) {
|
|
3304
|
+
return [
|
|
3305
|
+
`A CAA record at ${caa.recordName}, which ${caa.via} points to, stops the certificate from being issued.`,
|
|
3306
|
+
`Whoever runs ${caa.recordName} controls it, not your DNS: ask them to allow one of ${issuers}, or stop pointing ${caa.via} at it.`,
|
|
3307
|
+
];
|
|
3308
|
+
}
|
|
3309
|
+
if (next.action === "check_caa") {
|
|
3310
|
+
return [
|
|
3311
|
+
`A CAA record found when looking up ${caa.recordName} stops the certificate from being issued.`,
|
|
3312
|
+
`If ${caa.recordName} is in your own DNS, add an issue entry there for one of ${issuers}. If it points to another service, that service controls it.`,
|
|
3313
|
+
];
|
|
3314
|
+
}
|
|
3315
|
+
const example = next.records?.[0];
|
|
3316
|
+
return [
|
|
3317
|
+
`A CAA record at ${caa.recordName} stops the certificate from being issued.`,
|
|
3318
|
+
`Add an issue entry there for one of ${issuers}${example ? ` (for example: ${example.type} ${example.value})` : ""}. Your existing entries can stay.`,
|
|
3319
|
+
];
|
|
3320
|
+
}
|
|
3321
|
+
function providerMessageLine(reason) {
|
|
3322
|
+
const message = reason?.providerMessage;
|
|
3323
|
+
if (!message)
|
|
3324
|
+
return null;
|
|
3325
|
+
// Only the certificate's own validation errors are the certificate authority's; anything else
|
|
3326
|
+
// (the hostname's verification note, or a source this CLI does not know) is worded neutrally.
|
|
3327
|
+
const who = message.source === "certificate_authority" ? "The certificate authority" : "Our provider";
|
|
3328
|
+
return `${who} reported (${message.seenAt}): ${message.text}`;
|
|
3329
|
+
}
|
|
3330
|
+
const TXT_KEEP_OTHERS = "add this value as its own TXT record; other TXT values can stay";
|
|
3331
|
+
function recordStateText(record, routing) {
|
|
3332
|
+
if (record.ok === true)
|
|
3333
|
+
return "found";
|
|
3334
|
+
if (record.check === "doubled") {
|
|
3335
|
+
// The same name the NAME column shows, so the hint never contradicts the host's convention.
|
|
3336
|
+
const name = record.nameAtProvider ?? record.name;
|
|
3337
|
+
return `found at ${record.doubledAt}: your DNS provider added the domain twice; ${name === "" ? "leave the Host field empty" : `enter only ${name}`}`;
|
|
3338
|
+
}
|
|
3339
|
+
// An optional record never holds the domain back, so its state would only be noise.
|
|
3340
|
+
if (record.optional)
|
|
3341
|
+
return "optional, only needed if the CNAME cannot be pointed first";
|
|
3342
|
+
if (record.check === "apex_addresses") {
|
|
3343
|
+
return routing === "active"
|
|
3344
|
+
? "looks connected: the root answers with addresses, as an ALIAS does, and routing is active"
|
|
3345
|
+
: `answers with addresses (${(record.current ?? []).join(", ")}); an ALIAS can't be verified from outside, so this waits on the platform's routing check`;
|
|
3346
|
+
}
|
|
3347
|
+
if (record.ok === null || record.current === null)
|
|
3348
|
+
return "not checked: couldn't read your DNS just now";
|
|
3349
|
+
if (record.type === "TXT") {
|
|
3350
|
+
const now = record.current.length === 0 ? "not published yet" : `currently ${record.current.join(", ")}`;
|
|
3351
|
+
return `${now}: ${TXT_KEEP_OTHERS}`;
|
|
3352
|
+
}
|
|
3353
|
+
if (record.current.length === 0)
|
|
3354
|
+
return "not published yet";
|
|
3355
|
+
return `currently ${record.current.join(", ")}: point it at the value shown`;
|
|
3356
|
+
}
|
|
3357
|
+
// A printed command carries --owner and, when the destination is not the built-in default,
|
|
3358
|
+
// --api-url: pasted into a shell without NINEPM_API_URL it would otherwise act on production.
|
|
3359
|
+
// Never a token.
|
|
3360
|
+
function domainCommand(subcommand, app, hostname, options) {
|
|
3361
|
+
const owner = options.owner ? ` --owner ${quoteSiteArg(options.owner)}` : "";
|
|
3362
|
+
return `9pm domains ${subcommand} ${quoteSiteArg(app)} ${hostname}${owner}${apiUrlFlagFor(options.target)}`;
|
|
3363
|
+
}
|
|
3364
|
+
// Notes from src/shared/dns-providers.ts; only verified ones are printed.
|
|
3365
|
+
export const DNS_PROVIDER_NOTES = {
|
|
3366
|
+
gandiLiveDns: "At Gandi, an ALIAS works only with LiveDNS, and no A or AAAA record may stay at @ beside it.",
|
|
3367
|
+
hostingerCnameAtRoot: "At Hostinger, add the root record as a CNAME with name @: Hostinger shows it as ALIAS. A domain can have only one.",
|
|
3368
|
+
// Unverified, so never printed; its text is here so a leak would show.
|
|
3369
|
+
route53TxtQuotes: "In the Route 53 console, a TXT value may need to be entered in quotes.",
|
|
3370
|
+
};
|
|
3371
|
+
// A root domain at a DNS host that cannot point one at a hostname: what can stand in for the
|
|
3372
|
+
// root record, in the profile's order. Forwarding is text only, and unverified forwarding is
|
|
3373
|
+
// a possibility, never a step.
|
|
3374
|
+
function noApexLines(status, provider, fallbacks, options) {
|
|
3375
|
+
const host = status.hostname;
|
|
3376
|
+
const www = status.next?.hostname ?? `www.${host}`;
|
|
3377
|
+
const lines = [`${provider} can't point a root domain like ${host} at another address. Connect ${www} instead: it works with an ordinary CNAME.`];
|
|
3378
|
+
for (const fallback of fallbacks) {
|
|
3379
|
+
if (fallback.kind === "use_www") {
|
|
3380
|
+
lines.push(` Add it: ${domainCommand("add", status.app, www, options)}`);
|
|
3381
|
+
}
|
|
3382
|
+
else if (fallback.kind === "provider_forwarding") {
|
|
3383
|
+
if (!fallback.verified) {
|
|
3384
|
+
lines.push(` ${provider} may offer forwarding that sends ${host} to ${www}; we haven't confirmed it.`);
|
|
3385
|
+
}
|
|
3386
|
+
else {
|
|
3387
|
+
lines.push(` Then use ${provider}'s forwarding to send ${host} to https://${www}.${fallback.https === "yes" ? "" : ` We haven't confirmed that its forwarding works over HTTPS, so open https://${host} once it is set up to check.`}`);
|
|
3388
|
+
}
|
|
3389
|
+
}
|
|
3390
|
+
else if (fallback.kind === "aws_redirect") {
|
|
3391
|
+
lines.push(` For ${host} itself, you can set up a redirect to ${www} inside AWS yourself, for example an S3 redirect bucket behind CloudFront.`);
|
|
3392
|
+
}
|
|
3393
|
+
else {
|
|
3394
|
+
lines.push(" Or move your DNS hosting to a provider that supports ALIAS records at the root. Copy every existing record first, MX and TXT especially, or your email stops working.");
|
|
3395
|
+
lines.push(" If DNSSEC is on at your registrar (it holds a DS record for the domain), turn it off first and wait for that to clear before you change the nameservers: otherwise the whole domain, email included, stops resolving for many visitors.");
|
|
3396
|
+
}
|
|
3397
|
+
}
|
|
3398
|
+
return lines;
|
|
3399
|
+
}
|
|
3400
|
+
function renderColumns(rows) {
|
|
3401
|
+
const widths = (rows[0] ?? []).map((_, column) => Math.max(...rows.map((row) => (row[column] ?? "").length)));
|
|
3402
|
+
return rows.map((cells) => cells
|
|
3403
|
+
.map((cell, column) => (column === cells.length - 1 ? cell : cell.padEnd(widths[column] ?? 0)))
|
|
3404
|
+
.join(" ")
|
|
3405
|
+
.trimEnd());
|
|
3406
|
+
}
|
|
3407
|
+
export function formatDomainStatus(status, options = {}) {
|
|
3408
|
+
const lines = [`${status.hostname} (app ${status.app}): ${domainStateLabel(status.state)}`];
|
|
3409
|
+
if (status.state === "live") {
|
|
3410
|
+
lines.push(`Serving at https://${status.hostname}`);
|
|
3411
|
+
return lines;
|
|
3412
|
+
}
|
|
3413
|
+
if (status.state === "expired") {
|
|
3414
|
+
lines.push(status.error ?? "This domain expired before its DNS records were published.");
|
|
3415
|
+
lines.push(`Add it again: ${domainCommand("add", status.app, status.hostname, options)}`);
|
|
3416
|
+
return lines;
|
|
3417
|
+
}
|
|
3418
|
+
if (status.state === "failed" && status.error) {
|
|
3419
|
+
lines.push(status.error);
|
|
3420
|
+
}
|
|
3421
|
+
lines.push(...status.advisories.map((advisory) => advisory.message));
|
|
3422
|
+
const apex = zoneApexFor(status.hostname, status.zone);
|
|
3423
|
+
const atRoot = status.hostname.toLowerCase().replace(/\.$/, "") === apex;
|
|
3424
|
+
// The profile is read only for a host the server named; an id without a name is not trusted.
|
|
3425
|
+
const profile = status.provider ? dnsProviderProfile(status.providerId) : undefined;
|
|
3426
|
+
const provider = status.provider ?? "";
|
|
3427
|
+
// The same decision as the status: never the profile alone, which would say "connect www"
|
|
3428
|
+
// over routing that is already active. The profile only supplies the stand-ins to list.
|
|
3429
|
+
const useWwwFallbacks = atRoot && showsUseWww(status.next) ? (profile?.apex.support === "none" ? profile.apex.fallbacks : [{ kind: "use_www" }]) : undefined;
|
|
3430
|
+
if (status.records.length > 0) {
|
|
3431
|
+
const elsewhere = "Changes made where you bought the domain won't take effect if that is another company.";
|
|
3432
|
+
lines.push(status.provider
|
|
3433
|
+
? `Your DNS is managed at ${status.provider}${status.nameservers.length > 0 ? ` (${status.nameservers.join(", ")})` : ""}, where your domain's nameservers point: make these changes there. ${elsewhere}`
|
|
3434
|
+
: status.nameservers.length > 0
|
|
3435
|
+
? `Your DNS is served by ${status.nameservers.join(", ")}: make these changes at the company that runs them. ${elsewhere}`
|
|
3436
|
+
: `Add these records where the DNS for ${apex} is hosted, which is where its nameservers point and may not be where you bought it:`);
|
|
3437
|
+
for (const note of profile?.notes ?? []) {
|
|
3438
|
+
const text = note.verified ? DNS_PROVIDER_NOTES[note.id] : undefined;
|
|
3439
|
+
if (text)
|
|
3440
|
+
lines.push(text);
|
|
3441
|
+
}
|
|
3442
|
+
const shown = useWwwFallbacks ? status.records.filter((record) => !record.apex) : status.records;
|
|
3443
|
+
if (shown.length > 0) {
|
|
3444
|
+
lines.push("");
|
|
3445
|
+
const rows = [
|
|
3446
|
+
["TYPE", "NAME", "VALUE", "STATE"],
|
|
3447
|
+
...shown.map((record) => [
|
|
3448
|
+
// null: a root the host has no record type for, shown only once routing is already active.
|
|
3449
|
+
record.typeAtProvider ?? "-",
|
|
3450
|
+
record.nameAtProvider === "" ? "(leave empty)" : record.nameAtProvider,
|
|
3451
|
+
record.valueAtProvider,
|
|
3452
|
+
recordStateText(record, status.routing),
|
|
3453
|
+
]),
|
|
3454
|
+
];
|
|
3455
|
+
lines.push(...renderColumns(rows).map((line) => ` ${line}`));
|
|
3456
|
+
lines.push("");
|
|
3457
|
+
}
|
|
3458
|
+
if (useWwwFallbacks) {
|
|
3459
|
+
lines.push(...noApexLines(status, status.provider ?? "Your DNS host", useWwwFallbacks, options));
|
|
3460
|
+
}
|
|
3461
|
+
else if (status.records.some((record) => record.apex)) {
|
|
3462
|
+
const support = profile?.apex;
|
|
3463
|
+
if (support?.support === "alias") {
|
|
3464
|
+
lines.push(`${support.typeLabel} at the root: ${provider} uses an ${support.typeLabel} record there. ${support.hostField === "blank" ? "Leave the Host field empty" : "Enter @ as the Host"} and point it at the value shown.`);
|
|
3465
|
+
}
|
|
3466
|
+
else if (support?.support === "none") {
|
|
3467
|
+
lines.push(`${provider} has no record type that can point a root domain at this value, but routing for ${status.hostname} is ${status.routing === "active" ? "already active" : "being checked by the platform"}: keep the record that serves it as it is.`);
|
|
3468
|
+
}
|
|
3469
|
+
else if (support?.support === "cname_flattening") {
|
|
3470
|
+
lines.push(`CNAME at the root: ${provider} flattens a CNAME at the root automatically, so add it at @ pointing at the value shown.`);
|
|
3471
|
+
}
|
|
3472
|
+
else {
|
|
3473
|
+
lines.push(`ALIAS at @: the root of ${apex} cannot hold a CNAME, so add an ALIAS or ANAME record there (or a CNAME your provider flattens), pointing at the value shown.`);
|
|
3474
|
+
lines.push("If your DNS host has no ALIAS or ANAME record, don't put a CNAME at the root: it would conflict with your MX and TXT records. Connect www instead.");
|
|
3475
|
+
}
|
|
3476
|
+
}
|
|
3477
|
+
// "Replace the record at @" would take MX and TXT with it: the root names the website record types and keeps the rest.
|
|
3478
|
+
if (atRoot) {
|
|
3479
|
+
lines.push(`Change only the website record for ${status.hostname} (A, AAAA, ALIAS/ANAME or CNAME). Keep every MX and TXT record: they carry your email and other verifications.`);
|
|
3480
|
+
}
|
|
3481
|
+
if (status.records.some((record) => !record.optional && record.check === "not_checked")) {
|
|
3482
|
+
lines.push("Couldn't check your DNS records just now, so their state is unknown. Make sure they are published as shown.");
|
|
3483
|
+
}
|
|
3484
|
+
if (shown.some((record) => record.nameAtProvider !== "" && record.name !== record.fqdn)) {
|
|
3485
|
+
lines.push(profile?.appendsZone === true
|
|
3486
|
+
? `Enter the name as shown: ${provider} adds ".${apex}" itself, so typing the full name would double it.`
|
|
3487
|
+
: `Enter the name as shown: most providers add ".${apex}" themselves. If yours asks for the full name, append ".${apex}".`);
|
|
3488
|
+
}
|
|
3489
|
+
}
|
|
3490
|
+
const reported = providerMessageLine(status.reason);
|
|
3491
|
+
if (reported)
|
|
3492
|
+
lines.push(reported);
|
|
3493
|
+
if (status.state === "failed") {
|
|
3494
|
+
if (status.next.action === "contact_support") {
|
|
3495
|
+
lines.push("This needs the platform's support to resolve; nothing in your DNS is known to fix it.");
|
|
3496
|
+
}
|
|
3497
|
+
lines.push(`Re-check: ${domainCommand("check", status.app, status.hostname, options)}`);
|
|
3498
|
+
// Offered only where a new certificate attempt is the remedy; a restart never speeds anything up.
|
|
3499
|
+
if (status.next.action === "start_over") {
|
|
3500
|
+
lines.push(`Start over (the records may change): ${domainCommand("restart", status.app, status.hostname, options)}`);
|
|
3501
|
+
}
|
|
3502
|
+
return lines;
|
|
3503
|
+
}
|
|
3504
|
+
// Waiting on this domain cannot finish it: the next step is a different hostname, printed above.
|
|
3505
|
+
if (status.next?.action === "use_www")
|
|
3506
|
+
return lines;
|
|
3507
|
+
const caaLines = caaAdviceLines(status.next);
|
|
3508
|
+
if (caaLines.length > 0) {
|
|
3509
|
+
lines.push(...caaLines);
|
|
3510
|
+
}
|
|
3511
|
+
else if (status.state === "issuing_certificate") {
|
|
3512
|
+
lines.push("Every required record is published. The platform finishes on its own, usually within minutes.");
|
|
3513
|
+
}
|
|
3514
|
+
lines.push(`Then run: ${domainCommand("wait", status.app, status.hostname, options)}`);
|
|
3515
|
+
return lines;
|
|
3516
|
+
}
|
|
3517
|
+
// What a timed-out wait says. "Missing" is claimed only for a record the check actually saw
|
|
3518
|
+
// absent or different; a record nobody could read is reported as unchecked, whatever the
|
|
3519
|
+
// routing status, because telling the owner to publish what may already be there misleads.
|
|
3520
|
+
export function domainWaitTimeoutMessage(status) {
|
|
3521
|
+
if (status.next?.action === "use_www") {
|
|
3522
|
+
return `Stopped waiting: no record at the root can finish ${status.hostname} at its DNS host. Connect ${status.next.hostname ?? `www.${status.hostname}`} as shown above, then wait on that instead.`;
|
|
3523
|
+
}
|
|
3524
|
+
if (status.next.caa) {
|
|
3525
|
+
return "Timed out: a CAA record stops the certificate from being issued. Change it as described above, then wait again.";
|
|
3526
|
+
}
|
|
3527
|
+
if (status.state === "issuing_certificate") {
|
|
3528
|
+
return "Timed out: the records are published and the platform is still finishing. Wait again; nothing needs changing.";
|
|
3529
|
+
}
|
|
3530
|
+
const required = status.records.filter((record) => !record.optional);
|
|
3531
|
+
if (required.some((record) => record.ok === false)) {
|
|
3532
|
+
return "Timed out: records above are still missing. Publish them, then wait again.";
|
|
3533
|
+
}
|
|
3534
|
+
return "Timed out: the DNS records could not be checked. Make sure they are published as shown, then wait again.";
|
|
3535
|
+
}
|
|
3536
|
+
export function formatDomainsList(statuses) {
|
|
3537
|
+
if (statuses.length === 0) {
|
|
3538
|
+
return ["No custom domains. Add one: 9pm domains add <app> <hostname>"];
|
|
3539
|
+
}
|
|
3540
|
+
return renderColumns([
|
|
3541
|
+
["HOSTNAME", "APP", "STATE", "ROUTING", "TLS"],
|
|
3542
|
+
...statuses.map((status) => [status.hostname, status.app, status.state, status.routing, status.tls]),
|
|
3543
|
+
]);
|
|
3544
|
+
}
|
|
3545
|
+
export class DomainsApiError extends Error {
|
|
3546
|
+
status;
|
|
3547
|
+
code;
|
|
3548
|
+
constructor(message, status, code) {
|
|
3549
|
+
super(message);
|
|
3550
|
+
this.name = "DomainsApiError";
|
|
3551
|
+
this.status = status;
|
|
3552
|
+
this.code = code;
|
|
3553
|
+
}
|
|
3554
|
+
}
|
|
3555
|
+
// The API's own message and code, plus `details.reason` where the refusal carries one
|
|
3556
|
+
// (invalid_custom_domain), rather than the raw envelope the generic path prints.
|
|
3557
|
+
export function domainsApiError(status, text) {
|
|
3558
|
+
let envelope = null;
|
|
3559
|
+
try {
|
|
3560
|
+
const parsed = JSON.parse(text);
|
|
3561
|
+
envelope = parsed.error && typeof parsed.error === "object" ? parsed.error : null;
|
|
3562
|
+
}
|
|
3563
|
+
catch {
|
|
3564
|
+
envelope = null;
|
|
3565
|
+
}
|
|
3566
|
+
const code = typeof envelope?.code === "string" ? envelope.code : null;
|
|
3567
|
+
const message = typeof envelope?.message === "string" ? envelope.message.trim() : "";
|
|
3568
|
+
if (!code || !message) {
|
|
3569
|
+
return new DomainsApiError(`Request failed: ${status} ${text}`.trim(), status, code);
|
|
3570
|
+
}
|
|
3571
|
+
const details = envelope?.details;
|
|
3572
|
+
const reason = details && typeof details === "object" && typeof details.reason === "string"
|
|
3573
|
+
? details.reason
|
|
3574
|
+
: null;
|
|
3575
|
+
return new DomainsApiError(`${message} (${code}${reason ? `, reason: ${reason}` : ""})`, status, code);
|
|
3576
|
+
}
|
|
3577
|
+
async function domainsRequest(options, route, init = { method: "GET" }) {
|
|
3578
|
+
const fetchFn = options.fetchFn ?? fetch;
|
|
3579
|
+
const headers = new Headers(init.headers);
|
|
3580
|
+
headers.set("authorization", `Bearer ${options.token}`);
|
|
3581
|
+
headers.set("x-9pm-cli-version", CLI_VERSION);
|
|
3582
|
+
if (init.body)
|
|
3583
|
+
headers.set("content-type", "application/json");
|
|
3584
|
+
const response = await fetchFn(`${normalizeApiUrl(options.apiUrl)}${route}`, { ...init, headers });
|
|
3585
|
+
noteCliOutdatedAdvisory(response);
|
|
3586
|
+
if (!response.ok) {
|
|
3587
|
+
throw domainsApiError(response.status, await response.text());
|
|
3588
|
+
}
|
|
3589
|
+
return (await response.json());
|
|
3590
|
+
}
|
|
3591
|
+
function ownerQuery(owner) {
|
|
3592
|
+
return owner ? `?owner=${encodeURIComponent(owner)}` : "";
|
|
3593
|
+
}
|
|
3594
|
+
function siteDomainsRoute(options, hostname, action) {
|
|
3595
|
+
const base = `/v1/sites/${encodeURIComponent(options.site)}/domains`;
|
|
3596
|
+
const path = hostname ? `${base}/${encodeURIComponent(hostname)}${action ? `/${action}` : ""}` : base;
|
|
3597
|
+
return `${path}${ownerQuery(options.owner)}`;
|
|
3598
|
+
}
|
|
3599
|
+
export function normalizeDomainArg(value) {
|
|
3600
|
+
return normalizeDnsName(value);
|
|
3601
|
+
}
|
|
3602
|
+
export async function listDomainStatuses(options) {
|
|
3603
|
+
const checkedAt = (options.now ?? (() => new Date()))().toISOString();
|
|
3604
|
+
if (options.site) {
|
|
3605
|
+
const result = await domainsRequest(options, siteDomainsRoute({ ...options, site: options.site }));
|
|
3606
|
+
return result.domains.map((domain) => domainStatusJson(domain, options.site, checkedAt));
|
|
3607
|
+
}
|
|
3608
|
+
const result = await domainsRequest(options, `/v1/domains${ownerQuery(options.owner)}`);
|
|
3609
|
+
return result.domains.map((domain) => domainStatusJson(domain, domain.app?.slug ?? "", checkedAt));
|
|
3610
|
+
}
|
|
3611
|
+
export async function getDomainStatus(options) {
|
|
3612
|
+
const hostname = normalizeDomainArg(options.hostname);
|
|
3613
|
+
const statuses = await listDomainStatuses(options);
|
|
3614
|
+
const status = statuses.find((candidate) => candidate.hostname === hostname);
|
|
3615
|
+
if (!status) {
|
|
3616
|
+
throw new DomainsApiError(`${hostname} is not a custom domain of ${options.site} (custom_domain_not_found). List them: 9pm domains list ${quoteSiteArg(options.site)}`, 404, "custom_domain_not_found");
|
|
3617
|
+
}
|
|
3618
|
+
return status;
|
|
3619
|
+
}
|
|
3620
|
+
export async function addDomain(options) {
|
|
3621
|
+
const result = await domainsRequest(options, siteDomainsRoute(options), {
|
|
3622
|
+
method: "POST",
|
|
3623
|
+
body: JSON.stringify({ hostname: options.hostname.trim() }),
|
|
3624
|
+
});
|
|
3625
|
+
return result.domain;
|
|
3626
|
+
}
|
|
3627
|
+
export async function recheckDomain(options) {
|
|
3628
|
+
await domainsRequest(options, siteDomainsRoute(options, normalizeDomainArg(options.hostname), "refresh"), { method: "POST" });
|
|
3629
|
+
}
|
|
3630
|
+
export async function restartDomain(options) {
|
|
3631
|
+
return domainsRequest(options, siteDomainsRoute(options, normalizeDomainArg(options.hostname), "restart"), { method: "POST" });
|
|
3632
|
+
}
|
|
3633
|
+
export async function removeDomain(options) {
|
|
3634
|
+
const result = await domainsRequest(options, siteDomainsRoute(options, normalizeDomainArg(options.hostname)), {
|
|
3635
|
+
method: "DELETE",
|
|
3636
|
+
});
|
|
3637
|
+
return result.domain;
|
|
3638
|
+
}
|
|
3639
|
+
// The domains read is a reconcile plus a DNS lookup per record, so it can be slow; the deploy
|
|
3640
|
+
// has already succeeded and must not wait on it for longer than this.
|
|
3641
|
+
export const DEPLOY_DOMAINS_TIMEOUT_MS = 4500;
|
|
3642
|
+
// `null` when the read failed or timed out: unknown, which the caller must not print as
|
|
3643
|
+
// "this app has no custom domains".
|
|
3644
|
+
export async function deployCustomDomainStatuses(options) {
|
|
3645
|
+
const controller = new AbortController();
|
|
3646
|
+
let timer;
|
|
3647
|
+
const timeout = new Promise((resolve) => {
|
|
3648
|
+
timer = setTimeout(() => {
|
|
3649
|
+
controller.abort();
|
|
3650
|
+
resolve(null);
|
|
3651
|
+
}, options.timeoutMs ?? DEPLOY_DOMAINS_TIMEOUT_MS);
|
|
3652
|
+
});
|
|
3653
|
+
// By id, not slug: the id names this deploy's app whatever owner it belongs to, so no
|
|
3654
|
+
// `?owner=` is needed to disambiguate. One chain, one catch: the request, the body and the
|
|
3655
|
+
// conversion of every entry. Anything that throws - a malformed entry included - is an
|
|
3656
|
+
// unknown answer, never a failed deploy.
|
|
3657
|
+
const read = (async () => {
|
|
3658
|
+
const result = await domainsRequest(options, `/v1/sites/${encodeURIComponent(options.projectId)}/domains`, {
|
|
3659
|
+
method: "GET",
|
|
3660
|
+
signal: controller.signal,
|
|
3661
|
+
});
|
|
3662
|
+
if (!Array.isArray(result?.domains) || !result.domains.every(isCliDomainShape))
|
|
3663
|
+
return null;
|
|
3664
|
+
const checkedAt = (options.now ?? (() => new Date()))().toISOString();
|
|
3665
|
+
return result.domains.map((domain) => domainStatusJson(domain, options.slug, checkedAt));
|
|
3666
|
+
})().catch(() => null);
|
|
3667
|
+
try {
|
|
3668
|
+
return await Promise.race([read, timeout]);
|
|
3669
|
+
}
|
|
3670
|
+
finally {
|
|
3671
|
+
clearTimeout(timer);
|
|
3672
|
+
}
|
|
3673
|
+
}
|
|
3674
|
+
// The fields the state derivation reads. An entry without them cannot be classified, and a
|
|
3675
|
+
// guess would be exactly the "live" or "missing" claim the deploy lines must not make.
|
|
3676
|
+
function isCliDomainShape(value) {
|
|
3677
|
+
if (!value || typeof value !== "object")
|
|
3678
|
+
return false;
|
|
3679
|
+
const domain = value;
|
|
3680
|
+
return (typeof domain.hostname === "string" &&
|
|
3681
|
+
domain.hostname.length > 0 &&
|
|
3682
|
+
typeof domain.routingStatus === "string" &&
|
|
3683
|
+
typeof domain.tlsStatus === "string" &&
|
|
3684
|
+
Array.isArray(domain.dnsInstructions) &&
|
|
3685
|
+
(domain.provisioningError === null || domain.provisioningError === undefined || typeof domain.provisioningError === "string"));
|
|
3686
|
+
}
|
|
3687
|
+
// `state` is the shared definition's (src/shared/domain-state.ts), sent by the server or derived
|
|
3688
|
+
// from it by domainStatusJson: `live` needs routing and TLS both active. Every other state,
|
|
3689
|
+
// including one newer than this CLI, is named as it is, never rounded up to live or down to
|
|
3690
|
+
// "missing".
|
|
3691
|
+
export function deployCustomDomainLines(statuses, slug, owner, target) {
|
|
3692
|
+
const apiUrl = apiUrlFlagFor(target);
|
|
3693
|
+
if (statuses === null) {
|
|
3694
|
+
return [
|
|
3695
|
+
`Couldn't check this app's custom domains just now. List them: 9pm domains list ${quoteSiteArg(slug)}${owner ? ` --owner ${quoteSiteArg(owner)}` : ""}${apiUrl}`,
|
|
3696
|
+
];
|
|
3697
|
+
}
|
|
3698
|
+
return statuses.map((status) => {
|
|
3699
|
+
if (status.state === "live")
|
|
3700
|
+
return `Also serving at https://${status.hostname}`;
|
|
3701
|
+
const notLive = status.state === "failed" || status.state === "expired" ? "is not live" : "is not live yet";
|
|
3702
|
+
return `Custom domain ${status.hostname} ${notLive} (${domainStateLabel(status.state)}). Check it: ${domainCommand("status", slug, status.hostname, { ...(owner ? { owner } : {}), ...(target ? { target } : {}) })}`;
|
|
3703
|
+
});
|
|
3704
|
+
}
|
|
3705
|
+
// Polls the read-only list, which reconciles pending domains on every read, so waiting needs
|
|
3706
|
+
// only access to the app. A failed read that is not the caller's fault (network, 5xx) is
|
|
3707
|
+
// retried until the deadline; a refusal (auth, not found) ends the wait at once.
|
|
3708
|
+
export async function waitForDomain(options) {
|
|
3709
|
+
const nowMs = options.nowMs ?? (() => Date.now());
|
|
3710
|
+
const sleepFn = options.sleepFn ?? sleep;
|
|
3711
|
+
const deadline = nowMs() + options.timeoutMs;
|
|
3712
|
+
let last = null;
|
|
3713
|
+
for (;;) {
|
|
3714
|
+
try {
|
|
3715
|
+
const status = await getDomainStatus(options);
|
|
3716
|
+
last = status;
|
|
3717
|
+
options.onStatus?.(status);
|
|
3718
|
+
if (status.state === "live")
|
|
3719
|
+
return { status, exitCode: DOMAIN_WAIT_EXIT.live, timedOut: false };
|
|
3720
|
+
if (status.state === "failed" || status.state === "expired")
|
|
3721
|
+
return { status, exitCode: DOMAIN_WAIT_EXIT.failed, timedOut: false };
|
|
3722
|
+
// Nothing the owner can publish finishes this hostname, so polling until the timeout would only delay saying so.
|
|
3723
|
+
if (status.next?.action === "use_www")
|
|
3724
|
+
return { status, exitCode: DOMAIN_WAIT_EXIT.ownerAction, timedOut: false };
|
|
3725
|
+
}
|
|
3726
|
+
catch (error) {
|
|
3727
|
+
const transient = !(error instanceof DomainsApiError) || error.status >= 500;
|
|
3728
|
+
if (!transient || (nowMs() >= deadline && !last))
|
|
3729
|
+
throw error;
|
|
3730
|
+
options.onRetry?.(error);
|
|
3731
|
+
}
|
|
3732
|
+
const remaining = deadline - nowMs();
|
|
3733
|
+
if (remaining <= 0) {
|
|
3734
|
+
if (!last)
|
|
3735
|
+
throw new Error(`Timed out before ${options.hostname} could be read.`);
|
|
3736
|
+
// Only a proven issuing_certificate with nothing left for the owner is the platform's to
|
|
3737
|
+
// finish; records that could not be checked are the owner's to confirm.
|
|
3738
|
+
return { status: last, exitCode: domainWaitExitCode(last), timedOut: true };
|
|
3739
|
+
}
|
|
3740
|
+
await sleepFn(Math.min(options.intervalMs, remaining));
|
|
3741
|
+
}
|
|
3742
|
+
}
|
|
3743
|
+
const DOMAINS_VALUE_FLAGS = new Set(["token", "api-url", "owner", "timeout", "interval", "confirm"]);
|
|
3744
|
+
const DOMAINS_BOOLEAN_FLAGS = new Set(["json"]);
|
|
3745
|
+
function domainsPositionals(argv) {
|
|
3746
|
+
const positionals = [];
|
|
3747
|
+
for (let index = 0; index < argv.length; index += 1) {
|
|
3748
|
+
const arg = argv[index] ?? "";
|
|
3749
|
+
if (!arg.startsWith("--")) {
|
|
3750
|
+
positionals.push(arg);
|
|
3751
|
+
continue;
|
|
3752
|
+
}
|
|
3753
|
+
const name = arg.slice(2);
|
|
3754
|
+
if (!DOMAINS_VALUE_FLAGS.has(name) && !DOMAINS_BOOLEAN_FLAGS.has(name)) {
|
|
3755
|
+
throw new Error(`Unknown flag for 9pm domains: --${name}`);
|
|
3756
|
+
}
|
|
3757
|
+
if (DOMAINS_VALUE_FLAGS.has(name))
|
|
3758
|
+
index += 1;
|
|
3759
|
+
}
|
|
3760
|
+
return positionals;
|
|
3761
|
+
}
|
|
3762
|
+
const DOMAINS_USAGE = {
|
|
3763
|
+
list: "9pm domains list [<app>] [--owner <organisation>] [--json]",
|
|
3764
|
+
add: "9pm domains add <app> <hostname> [--owner <organisation>] [--json]",
|
|
3765
|
+
status: "9pm domains status <app> <hostname> [--owner <organisation>] [--json]",
|
|
3766
|
+
check: "9pm domains check <app> <hostname> [--owner <organisation>] [--json]",
|
|
3767
|
+
wait: "9pm domains wait <app> <hostname> [--timeout 30m] [--interval 20s] [--owner <organisation>] [--json]",
|
|
3768
|
+
restart: "9pm domains restart <app> <hostname> [--owner <organisation>] (for a failed certificate; the record values may change)",
|
|
3769
|
+
remove: "9pm domains remove <app> <hostname> --confirm <hostname> [--owner <organisation>]",
|
|
3770
|
+
};
|
|
3771
|
+
function parseDomainsArgs(argv) {
|
|
3772
|
+
const subcommand = argv[0] === "ls" ? "list" : argv[0] === "rm" ? "remove" : argv[0];
|
|
3773
|
+
if (!subcommand || subcommand === "help" || subcommand === "--help") {
|
|
3774
|
+
throw new UsageError();
|
|
3775
|
+
}
|
|
3776
|
+
const usage = DOMAINS_USAGE[subcommand];
|
|
3777
|
+
if (!usage) {
|
|
3778
|
+
throw new Error(`Unknown domains subcommand: ${subcommand}. Expected one of: ${Object.keys(DOMAINS_USAGE).join(", ")}.`);
|
|
3779
|
+
}
|
|
3780
|
+
const flags = parseFlags(argv);
|
|
3781
|
+
const positionals = domainsPositionals(argv.slice(1));
|
|
3782
|
+
const json = flags.has("json");
|
|
3783
|
+
const owner = readStringFlag(flags, "owner");
|
|
3784
|
+
const withBase = (action) => ({
|
|
3785
|
+
kind: "domains",
|
|
3786
|
+
...readBaseApiOptions(flags),
|
|
3787
|
+
apiUrlFrom: apiUrlSource(flags),
|
|
3788
|
+
...(owner ? { owner } : {}),
|
|
3789
|
+
action,
|
|
3790
|
+
});
|
|
3791
|
+
if (subcommand === "list") {
|
|
3792
|
+
if (positionals.length > 1)
|
|
3793
|
+
throw new Error(`Usage: ${usage}`);
|
|
3794
|
+
const site = positionals[0];
|
|
3795
|
+
return withBase({ kind: "list", json, ...(site ? { site } : {}) });
|
|
3796
|
+
}
|
|
3797
|
+
const [site, hostname] = positionals;
|
|
3798
|
+
if (!site || !hostname || positionals.length > 2) {
|
|
3799
|
+
throw new Error(`Usage: ${usage}`);
|
|
3800
|
+
}
|
|
3801
|
+
if (subcommand === "add" || subcommand === "status" || subcommand === "check") {
|
|
3802
|
+
return withBase({ kind: subcommand, site, hostname, json });
|
|
3803
|
+
}
|
|
3804
|
+
if (subcommand === "wait") {
|
|
3805
|
+
const timeout = readStringFlag(flags, "timeout") ?? "30m";
|
|
3806
|
+
const interval = readStringFlag(flags, "interval") ?? "20s";
|
|
3807
|
+
const timeoutSeconds = parseDurationSeconds(timeout);
|
|
3808
|
+
const intervalSeconds = parseDurationSeconds(interval);
|
|
3809
|
+
if (intervalSeconds < 1)
|
|
3810
|
+
throw new Error("--interval must be at least 1s.");
|
|
3811
|
+
return withBase({ kind: "wait", site, hostname, json, timeoutSeconds, intervalSeconds });
|
|
3812
|
+
}
|
|
3813
|
+
if (subcommand === "restart") {
|
|
3814
|
+
return withBase({ kind: "restart", site, hostname });
|
|
3815
|
+
}
|
|
3816
|
+
const confirm = readStringFlag(flags, "confirm");
|
|
3817
|
+
if (!confirm || normalizeDomainArg(confirm) !== normalizeDomainArg(hostname)) {
|
|
3818
|
+
throw new Error(`Removing a domain stops it serving at once. Pass --confirm ${normalizeDomainArg(hostname)} to remove it.`);
|
|
3819
|
+
}
|
|
3820
|
+
return withBase({ kind: "remove", site, hostname });
|
|
3821
|
+
}
|
|
3822
|
+
function printDomainStatus(status, json, options, heading) {
|
|
3823
|
+
if (json) {
|
|
3824
|
+
console.log(JSON.stringify(status, null, 2));
|
|
3825
|
+
return;
|
|
3826
|
+
}
|
|
3827
|
+
if (heading)
|
|
3828
|
+
console.log(heading);
|
|
3829
|
+
for (const line of formatDomainStatus(status, options))
|
|
3830
|
+
console.log(line);
|
|
3831
|
+
}
|
|
3832
|
+
function instructionLines(instructions, hostname) {
|
|
3833
|
+
const apex = guessZoneApex(hostname);
|
|
3834
|
+
return renderColumns(instructions.map((instruction) => [instruction.type.toUpperCase(), relativeRecordName(instruction.host, apex), instruction.value])).map((line) => ` ${line}`);
|
|
3835
|
+
}
|
|
3836
|
+
async function runDomainsCommand(command) {
|
|
3837
|
+
const action = command.action;
|
|
3838
|
+
const base = { apiUrl: command.apiUrl, token: command.token, ...(command.owner ? { owner: command.owner } : {}) };
|
|
3839
|
+
const format = {
|
|
3840
|
+
...(command.owner ? { owner: command.owner } : {}),
|
|
3841
|
+
target: { apiUrl: command.apiUrl, apiUrlFrom: command.apiUrlFrom },
|
|
3842
|
+
};
|
|
3843
|
+
if (action.kind === "list") {
|
|
3844
|
+
const statuses = await listDomainStatuses({ ...base, ...(action.site ? { site: action.site } : {}) });
|
|
3845
|
+
if (action.json) {
|
|
3846
|
+
console.log(JSON.stringify({ domains: statuses }, null, 2));
|
|
3847
|
+
return;
|
|
3848
|
+
}
|
|
3849
|
+
for (const line of formatDomainsList(statuses))
|
|
3850
|
+
console.log(line);
|
|
3851
|
+
return;
|
|
3852
|
+
}
|
|
3853
|
+
const target = { ...base, site: action.site, hostname: action.hostname };
|
|
3854
|
+
if (action.kind === "add") {
|
|
3855
|
+
const added = await addDomain(target);
|
|
3856
|
+
const hostname = added?.hostname ?? normalizeDomainArg(action.hostname);
|
|
3857
|
+
// The add response carries no DNS check; the list read that follows does, and it is what
|
|
3858
|
+
// says whether a record the owner already has (a wildcard, an old CNAME) is in the way.
|
|
3859
|
+
const status = await getDomainStatus({ ...target, hostname }).catch(() => added ? domainStatusJson(added, action.site, new Date().toISOString()) : null);
|
|
3860
|
+
if (!status) {
|
|
3861
|
+
console.log(`Added ${hostname} to ${action.site}. See its records: ${domainCommand("status", action.site, hostname, format)}`);
|
|
3862
|
+
return;
|
|
3863
|
+
}
|
|
3864
|
+
printDomainStatus(status, action.json, format, action.json ? undefined : `Added ${hostname} to ${action.site}.`);
|
|
3865
|
+
return;
|
|
3866
|
+
}
|
|
3867
|
+
if (action.kind === "status") {
|
|
3868
|
+
printDomainStatus(await getDomainStatus(target), action.json, format);
|
|
3869
|
+
return;
|
|
3870
|
+
}
|
|
3871
|
+
if (action.kind === "check") {
|
|
3872
|
+
await recheckDomain(target);
|
|
3873
|
+
printDomainStatus(await getDomainStatus(target), action.json, format);
|
|
3874
|
+
return;
|
|
3875
|
+
}
|
|
3876
|
+
if (action.kind === "wait") {
|
|
3877
|
+
let lastState = null;
|
|
3878
|
+
const result = await waitForDomain({
|
|
3879
|
+
...target,
|
|
3880
|
+
timeoutMs: action.timeoutSeconds * 1000,
|
|
3881
|
+
intervalMs: action.intervalSeconds * 1000,
|
|
3882
|
+
onStatus: (status) => {
|
|
3883
|
+
if (status.state !== lastState) {
|
|
3884
|
+
lastState = status.state;
|
|
3885
|
+
console.error(`${new Date().toISOString()} ${status.hostname}: ${domainStateLabel(status.state)}`);
|
|
3886
|
+
}
|
|
3887
|
+
},
|
|
3888
|
+
onRetry: (error) => console.error(`Could not read the domain (${error instanceof Error ? error.message : String(error)}); retrying.`),
|
|
3889
|
+
});
|
|
3890
|
+
if (action.json) {
|
|
3891
|
+
console.log(JSON.stringify(result.status, null, 2));
|
|
3892
|
+
}
|
|
3893
|
+
else {
|
|
3894
|
+
for (const line of formatDomainStatus(result.status, format))
|
|
3895
|
+
console.log(line);
|
|
3896
|
+
if (result.timedOut || result.status.next?.action === "use_www")
|
|
3897
|
+
console.log(domainWaitTimeoutMessage(result.status));
|
|
3898
|
+
}
|
|
3899
|
+
process.exitCode = result.exitCode;
|
|
3900
|
+
return;
|
|
3901
|
+
}
|
|
3902
|
+
if (action.kind === "restart") {
|
|
3903
|
+
const restarted = await restartDomain(target);
|
|
3904
|
+
console.log(`Started validation for ${restarted.domain.hostname} over.`);
|
|
3905
|
+
if (restarted.replacedInstructions.length > 0) {
|
|
3906
|
+
console.log("These values no longer count; delete them at your DNS provider:");
|
|
3907
|
+
for (const line of instructionLines(restarted.replacedInstructions, restarted.domain.hostname))
|
|
3908
|
+
console.log(line);
|
|
3909
|
+
}
|
|
3910
|
+
else {
|
|
3911
|
+
console.log("No record changed.");
|
|
3912
|
+
}
|
|
3913
|
+
const status = await getDomainStatus({ ...target, hostname: restarted.domain.hostname }).catch(() => domainStatusJson(restarted.domain, action.site, new Date().toISOString()));
|
|
3914
|
+
for (const line of formatDomainStatus(status, format))
|
|
3915
|
+
console.log(line);
|
|
3916
|
+
return;
|
|
3917
|
+
}
|
|
3918
|
+
const removed = await removeDomain(target);
|
|
3919
|
+
const hostname = removed?.hostname ?? normalizeDomainArg(action.hostname);
|
|
3920
|
+
console.log(`Removed ${hostname} from ${action.site}. It stops serving within seconds.`);
|
|
3921
|
+
if (removed && removed.dnsInstructions.length > 0) {
|
|
3922
|
+
console.log("You can now delete these records at your DNS provider, if nothing else uses them:");
|
|
3923
|
+
for (const line of instructionLines(removed.dnsInstructions, hostname))
|
|
3924
|
+
console.log(line);
|
|
3925
|
+
}
|
|
3926
|
+
}
|
|
3176
3927
|
// #807 step 5: the claim behind a health verdict, in words. A bare `unknown` reads
|
|
3177
3928
|
// as "fine"; the reason says which statement the platform is actually making.
|
|
3178
3929
|
export function healthWithReason(status, reason) {
|
|
@@ -3314,7 +4065,7 @@ function parseAccessArgs(argv) {
|
|
|
3314
4065
|
throw new UsageError();
|
|
3315
4066
|
}
|
|
3316
4067
|
const flags = parseFlags(argv);
|
|
3317
|
-
const base = readBaseApiOptions(flags);
|
|
4068
|
+
const base = { ...readBaseApiOptions(flags), apiUrlFrom: apiUrlSource(flags) };
|
|
3318
4069
|
const siteFrom = (index) => {
|
|
3319
4070
|
const site = argv[index];
|
|
3320
4071
|
if (!site || site.startsWith("--")) {
|
|
@@ -3325,16 +4076,17 @@ function parseAccessArgs(argv) {
|
|
|
3325
4076
|
if (subcommand === "show") {
|
|
3326
4077
|
return { kind: "access", ...base, action: { kind: "show", site: siteFrom(1) } };
|
|
3327
4078
|
}
|
|
4079
|
+
const switchMode = flags.has("switch-mode") ? { switchMode: true } : {};
|
|
3328
4080
|
if (subcommand === "public") {
|
|
3329
|
-
return { kind: "access", ...base, action: { kind: "public", site: siteFrom(1) } };
|
|
4081
|
+
return { kind: "access", ...base, action: { kind: "public", site: siteFrom(1), ...switchMode } };
|
|
3330
4082
|
}
|
|
3331
4083
|
if (subcommand === "otp") {
|
|
3332
|
-
return { kind: "access", ...base, action: { kind: "otp", site: siteFrom(1) } };
|
|
4084
|
+
return { kind: "access", ...base, action: { kind: "otp", site: siteFrom(1), ...switchMode } };
|
|
3333
4085
|
}
|
|
3334
4086
|
if (subcommand === "idp") {
|
|
3335
4087
|
const site = siteFrom(1);
|
|
3336
4088
|
const providers = readRepeatedStringFlag(argv, "provider").map(parseProviderKey);
|
|
3337
|
-
return { kind: "access", ...base, action: { kind: "idp", site, ...(providers.length ? { providers } : {}) } };
|
|
4089
|
+
return { kind: "access", ...base, action: { kind: "idp", site, ...(providers.length ? { providers } : {}), ...switchMode } };
|
|
3338
4090
|
}
|
|
3339
4091
|
if (subcommand === "service") {
|
|
3340
4092
|
const verb = argv[1];
|
|
@@ -3342,7 +4094,11 @@ function parseAccessArgs(argv) {
|
|
|
3342
4094
|
if (verb === "create") {
|
|
3343
4095
|
const name = readStringFlag(flags, "name");
|
|
3344
4096
|
const duration = readStringFlag(flags, "duration");
|
|
3345
|
-
return {
|
|
4097
|
+
return {
|
|
4098
|
+
kind: "access",
|
|
4099
|
+
...base,
|
|
4100
|
+
action: { kind: "service-create", site, ...(name ? { name } : {}), ...(duration ? { duration } : {}), ...switchMode },
|
|
4101
|
+
};
|
|
3346
4102
|
}
|
|
3347
4103
|
if (verb === "revoke") {
|
|
3348
4104
|
const tokenId = argv[3];
|
|
@@ -3454,6 +4210,11 @@ function parseSupportArgs(argv) {
|
|
|
3454
4210
|
...readBaseApiOptions(flags),
|
|
3455
4211
|
};
|
|
3456
4212
|
}
|
|
4213
|
+
function apiUrlSource(flags) {
|
|
4214
|
+
if (readStringFlag(flags, "api-url") !== undefined)
|
|
4215
|
+
return "flag";
|
|
4216
|
+
return process.env.NINEPM_API_URL !== undefined ? "env" : "default";
|
|
4217
|
+
}
|
|
3457
4218
|
function readApiUrl(flags, fallback) {
|
|
3458
4219
|
return validateApiUrl(readStringFlag(flags, "api-url") ?? process.env.NINEPM_API_URL ?? fallback);
|
|
3459
4220
|
}
|
|
@@ -4462,6 +5223,22 @@ function formatRemainingForCli(value) {
|
|
|
4462
5223
|
}
|
|
4463
5224
|
export class UsageError extends Error {
|
|
4464
5225
|
}
|
|
5226
|
+
// #1856: which missing-pages mode the deployment was published with, read from the
|
|
5227
|
+
// deployment the platform returned rather than from the flags, because a deploy without a
|
|
5228
|
+
// flag keeps whatever the app chose before. Null when the platform recorded none: a bundle
|
|
5229
|
+
// or container app answers every path itself, and an older control plane does not say.
|
|
5230
|
+
export function missingPagesLine(deployment) {
|
|
5231
|
+
switch (deployment.metadata.missingPages) {
|
|
5232
|
+
case "404":
|
|
5233
|
+
return "Missing pages: 404 (the deploy's own 404.html when it has one). If the app routes in the browser, redeploy with --spa.";
|
|
5234
|
+
case "spa":
|
|
5235
|
+
return "Missing pages: homepage (SPA): a path with no file serves index.html. Redeploy with --no-spa to return 404 instead.";
|
|
5236
|
+
case "legacy":
|
|
5237
|
+
return "Missing pages: homepage (kept from before this was a choice): a path with no file serves index.html. Redeploy with --no-spa to return 404, or --spa to keep it on purpose.";
|
|
5238
|
+
default:
|
|
5239
|
+
return null;
|
|
5240
|
+
}
|
|
5241
|
+
}
|
|
4465
5242
|
async function main() {
|
|
4466
5243
|
try {
|
|
4467
5244
|
const command = parseArgs(process.argv.slice(2));
|
|
@@ -4537,11 +5314,25 @@ async function main() {
|
|
|
4537
5314
|
}
|
|
4538
5315
|
const databaseClause = deployDatabaseClause(result.databases);
|
|
4539
5316
|
const queued = result.deployment.status === "queued" || result.deployment.status === "publishing";
|
|
5317
|
+
const domainStatuses = deployCustomDomainStatuses({
|
|
5318
|
+
apiUrl: command.options.apiUrl,
|
|
5319
|
+
token: command.options.token,
|
|
5320
|
+
projectId: result.project.id,
|
|
5321
|
+
slug: result.project.slug,
|
|
5322
|
+
});
|
|
4540
5323
|
console.log(`${queued ? "Queued" : "Deployed"} ${result.project.slug} -> ${result.url}`);
|
|
5324
|
+
const target = { apiUrl: command.options.apiUrl, apiUrlFrom: command.apiUrlFrom };
|
|
5325
|
+
for (const line of deployCustomDomainLines(await domainStatuses, result.project.slug, command.options.owner, target)) {
|
|
5326
|
+
console.log(line);
|
|
5327
|
+
}
|
|
4541
5328
|
console.log(`Deployment ${result.deployment.id} ${result.containerImageBytes ? `uploaded ${formatBytesForCli(result.containerImageBytes)} image` : `uploaded ${result.uploaded} file(s)`}${databaseClause ? `, ${databaseClause}` : ""}.`);
|
|
4542
5329
|
if (queued) {
|
|
4543
5330
|
console.log(`Status: ${result.deployment.status}. Run 9pm apps to check when it becomes active.`);
|
|
4544
5331
|
}
|
|
5332
|
+
const missingPages = missingPagesLine(result.deployment);
|
|
5333
|
+
if (missingPages) {
|
|
5334
|
+
console.log(missingPages);
|
|
5335
|
+
}
|
|
4545
5336
|
console.log(`Manage: ${dashboardUrlForApiUrl(command.options.apiUrl)}${command.options.owner ? `?owner=${encodeURIComponent(command.options.owner)}` : ""}`);
|
|
4546
5337
|
// Gated the same way as the reachability probe above: a programmatic
|
|
4547
5338
|
// caller injecting fetchFn opted out of the CLI's own network side trips.
|
|
@@ -4745,6 +5536,9 @@ async function main() {
|
|
|
4745
5536
|
});
|
|
4746
5537
|
console.log(`Deleted ${deleted.slug}.`);
|
|
4747
5538
|
console.log(`Removed ${deleted.workers} Worker(s), ${deleted.databases} database(s), ${deleted.files} file(s), ${deleted.customDomains} custom domain(s), ${deleted.containerImages ?? 0} container image(s), ${deleted.accessApps} access app(s), and ${deleted.serviceTokens} service token(s). Queued ${deleted.containerApplications ?? 0} container app(s) for teardown.`);
|
|
5539
|
+
if (deleted.customDomainNames && deleted.customDomainNames.length > 0) {
|
|
5540
|
+
console.log(`Removed custom domain(s): ${deleted.customDomainNames.join(", ")}.`);
|
|
5541
|
+
}
|
|
4748
5542
|
// #722: a partial external-cleanup failure never fails the delete, and used to be
|
|
4749
5543
|
// silently discarded here — visible only in the raw JSON, if a caller happened to
|
|
4750
5544
|
// read it. Surfacing it is the whole reason a human running this command manually
|
|
@@ -4789,6 +5583,10 @@ async function main() {
|
|
|
4789
5583
|
await runDeploymentsCommand(command);
|
|
4790
5584
|
return;
|
|
4791
5585
|
}
|
|
5586
|
+
if (command.kind === "domains") {
|
|
5587
|
+
await runDomainsCommand(command);
|
|
5588
|
+
return;
|
|
5589
|
+
}
|
|
4792
5590
|
if (command.kind === "logs") {
|
|
4793
5591
|
await runLogsCommand(command);
|
|
4794
5592
|
return;
|
|
@@ -4871,25 +5669,73 @@ async function runSupportCommand(command) {
|
|
|
4871
5669
|
});
|
|
4872
5670
|
console.log("Support request sent. A human will reply to your account email.");
|
|
4873
5671
|
}
|
|
5672
|
+
// ` --api-url <url>` for a printed command whenever the destination is not the built-in
|
|
5673
|
+
// default, so a line copied from a staging session cannot act on production; "" otherwise.
|
|
5674
|
+
export function apiUrlFlagFor(target) {
|
|
5675
|
+
if (!target || target.apiUrlFrom === "default")
|
|
5676
|
+
return "";
|
|
5677
|
+
// A plain URL (scheme, host, port, path) has no shell-significant characters; anything
|
|
5678
|
+
// else falls back to single quotes.
|
|
5679
|
+
const url = /^[A-Za-z0-9._:/-]+$/.test(target.apiUrl) ? target.apiUrl : quoteSiteArg(target.apiUrl);
|
|
5680
|
+
return ` --api-url ${url}`;
|
|
5681
|
+
}
|
|
5682
|
+
// The command line that repeats this one with --switch-mode. Rebuilt from the parsed
|
|
5683
|
+
// action rather than echoed from argv so a --token on the original line is never printed.
|
|
5684
|
+
// The destination is carried explicitly whenever it is not the built-in default: a line
|
|
5685
|
+
// copied from a staging session and pasted into a shell without that flag or env var would
|
|
5686
|
+
// otherwise switch the mode of the same-named app on production. The access commands read
|
|
5687
|
+
// no --owner (a site id or slug already names the app), so there is none to carry.
|
|
5688
|
+
export function accessSwitchModeCommand(action, target) {
|
|
5689
|
+
const site = quoteSiteArg(action.site);
|
|
5690
|
+
const apiUrl = apiUrlFlagFor(target);
|
|
5691
|
+
if (action.kind === "service-create") {
|
|
5692
|
+
const name = action.name ? ` --name ${quoteSiteArg(action.name)}` : "";
|
|
5693
|
+
const duration = action.duration ? ` --duration ${quoteSiteArg(action.duration)}` : "";
|
|
5694
|
+
return `9pm access service create ${site}${name}${duration}${apiUrl} --switch-mode`;
|
|
5695
|
+
}
|
|
5696
|
+
if (action.kind === "idp") {
|
|
5697
|
+
const providers = (action.providers ?? []).map((provider) => ` --provider ${provider}`).join("");
|
|
5698
|
+
return `9pm access idp ${site}${providers}${apiUrl} --switch-mode`;
|
|
5699
|
+
}
|
|
5700
|
+
return `9pm access ${action.kind} ${site}${apiUrl} --switch-mode`;
|
|
5701
|
+
}
|
|
5702
|
+
// Names the API the refusal came from, and where that choice was made, so the reader sees
|
|
5703
|
+
// which environment the re-run will act on before running it.
|
|
5704
|
+
function accessTargetNote(target) {
|
|
5705
|
+
const from = target.apiUrlFrom === "flag" ? "from --api-url" : target.apiUrlFrom === "env" ? "from NINEPM_API_URL" : "the default API";
|
|
5706
|
+
return `against ${target.apiUrl} (${from})`;
|
|
5707
|
+
}
|
|
5708
|
+
async function withSwitchModeRerun(action, target, run) {
|
|
5709
|
+
try {
|
|
5710
|
+
return await run();
|
|
5711
|
+
}
|
|
5712
|
+
catch (error) {
|
|
5713
|
+
if (error instanceof DeliberateRefusalError && error.code === ACCESS_MODE_CHANGE_UNCONFIRMED) {
|
|
5714
|
+
throw new DeliberateRefusalError(`${error.message}\n\nNothing was changed. To go ahead ${accessTargetNote(target)}, re-run:\n ${accessSwitchModeCommand(action, target)}`, error.code);
|
|
5715
|
+
}
|
|
5716
|
+
throw error;
|
|
5717
|
+
}
|
|
5718
|
+
}
|
|
4874
5719
|
async function runAccessCommand(command) {
|
|
4875
5720
|
const base = { apiUrl: command.apiUrl, token: command.token };
|
|
5721
|
+
const target = { apiUrl: command.apiUrl, apiUrlFrom: command.apiUrlFrom };
|
|
4876
5722
|
const action = command.action;
|
|
4877
5723
|
if (action.kind === "show") {
|
|
4878
5724
|
console.log(JSON.stringify(await getSiteAccess({ ...base, site: action.site }), null, 2));
|
|
4879
5725
|
return;
|
|
4880
5726
|
}
|
|
4881
5727
|
if (action.kind === "public") {
|
|
4882
|
-
const access = await setSiteAccessPublic({ ...base, site: action.site });
|
|
5728
|
+
const access = await withSwitchModeRerun(action, target, () => setSiteAccessPublic({ ...base, site: action.site, ...switchModeField(action) }));
|
|
4883
5729
|
console.log(`Access for ${action.site} set to ${access.mode}.`);
|
|
4884
5730
|
return;
|
|
4885
5731
|
}
|
|
4886
5732
|
if (action.kind === "otp") {
|
|
4887
|
-
const access = await setSiteAccessOtp({ ...base, site: action.site });
|
|
5733
|
+
const access = await withSwitchModeRerun(action, target, () => setSiteAccessOtp({ ...base, site: action.site, ...switchModeField(action) }));
|
|
4888
5734
|
console.log(`Access for ${action.site} set to ${access.mode}.`);
|
|
4889
5735
|
return;
|
|
4890
5736
|
}
|
|
4891
5737
|
if (action.kind === "idp") {
|
|
4892
|
-
const access = await setSiteAccessIdp({ ...base, site: action.site, ...(action.providers ? { providers: action.providers } : {}) });
|
|
5738
|
+
const access = await withSwitchModeRerun(action, target, () => setSiteAccessIdp({ ...base, site: action.site, ...(action.providers ? { providers: action.providers } : {}), ...switchModeField(action) }));
|
|
4893
5739
|
console.log(`Access for ${action.site} set to ${access.mode}.`);
|
|
4894
5740
|
return;
|
|
4895
5741
|
}
|
|
@@ -4904,12 +5750,13 @@ async function runAccessCommand(command) {
|
|
|
4904
5750
|
return;
|
|
4905
5751
|
}
|
|
4906
5752
|
if (action.kind === "service-create") {
|
|
4907
|
-
const created = await createAccessServiceToken({
|
|
5753
|
+
const created = await withSwitchModeRerun(action, target, () => createAccessServiceToken({
|
|
4908
5754
|
...base,
|
|
4909
5755
|
site: action.site,
|
|
4910
5756
|
...(action.name ? { name: action.name } : {}),
|
|
4911
5757
|
...(action.duration ? { duration: action.duration } : {}),
|
|
4912
|
-
|
|
5758
|
+
...switchModeField(action),
|
|
5759
|
+
}));
|
|
4913
5760
|
console.log(`Created service token ${created.serviceToken.id} for ${action.site}.`);
|
|
4914
5761
|
console.log(`CF-Access-Client-Id: ${created.serviceToken.clientId}`);
|
|
4915
5762
|
console.log(`CF-Access-Client-Secret: ${created.clientSecret}`);
|
|
@@ -5060,8 +5907,8 @@ async function runFilesCommand(command) {
|
|
|
5060
5907
|
function printUsage() {
|
|
5061
5908
|
console.log(`Usage:
|
|
5062
5909
|
9pm --version
|
|
5063
|
-
9pm deploy [directory] --name <site-name> [--slug <slug>] [--owner <organisation>] [--description "<note>"] [--with-db] [--with-auth] [--with-email]
|
|
5064
|
-
9pm deploy [directory] --runtime worker --entry src/index.js (--assets <dir> | --no-assets) [--with-db]
|
|
5910
|
+
9pm deploy [directory] --name <site-name> [--slug <slug>] [--owner <organisation>] [--description "<note>"] [--spa | --no-spa] [--with-db] [--with-auth] [--with-email]
|
|
5911
|
+
9pm deploy [directory] --runtime worker --entry src/index.js (--assets <dir> | --no-assets) [--spa | --no-spa] [--with-db]
|
|
5065
5912
|
9pm deploy [directory] --runtime container --port 8080 [--health-path /health] [--region auto] [--with-db] [--always-on] [--volume <gb> | --no-volume] [--cpus <n>] [--memory <size>]
|
|
5066
5913
|
9pm deploy [directory] framework projects (Next.js, Vite) are detected and built automatically; --no-build skips
|
|
5067
5914
|
9pm deploy [directory] --bundle <dir> deploy a pre-built Cloudflare framework bundle (e.g. .open-next)
|
|
@@ -5085,15 +5932,22 @@ function printUsage() {
|
|
|
5085
5932
|
9pm deployments rm <deployment-id>
|
|
5086
5933
|
9pm logs <app> [--lines <n>] [--json]
|
|
5087
5934
|
9pm delete <site> --confirm <slug>
|
|
5935
|
+
9pm domains list [<app>] [--owner <organisation>] [--json]
|
|
5936
|
+
9pm domains add <app> <hostname> [--json] connect a domain you own; prints the DNS records to publish
|
|
5937
|
+
9pm domains status <app> <hostname> [--json] state and, per record, what DNS shows now
|
|
5938
|
+
9pm domains check <app> <hostname> [--json] re-check now, then print the status
|
|
5939
|
+
9pm domains wait <app> <hostname> [--timeout 30m] [--interval 20s] [--json]
|
|
5940
|
+
9pm domains restart <app> <hostname> for a failed certificate only: start validation over; record values may change
|
|
5941
|
+
9pm domains remove <app> <hostname> --confirm <hostname>
|
|
5088
5942
|
9pm access show <site>
|
|
5089
|
-
9pm access public <site>
|
|
5090
|
-
9pm access otp <site>
|
|
5091
|
-
9pm access idp <site> [--provider google|github|entra]
|
|
5943
|
+
9pm access public <site> [--switch-mode]
|
|
5944
|
+
9pm access otp <site> [--switch-mode]
|
|
5945
|
+
9pm access idp <site> [--provider google|github|entra] [--switch-mode]
|
|
5092
5946
|
9pm access allow email <site> <email>
|
|
5093
5947
|
9pm access allow domain <site> <domain>
|
|
5094
5948
|
9pm access revoke email <site> <email>
|
|
5095
5949
|
9pm access revoke domain <site> <domain>
|
|
5096
|
-
9pm access service create <site> [--name <name>] [--duration 8760h]
|
|
5950
|
+
9pm access service create <site> [--name <name>] [--duration 8760h] [--switch-mode]
|
|
5097
5951
|
9pm access service revoke <site> <service-token-id>
|
|
5098
5952
|
9pm access token create <site> [--name <name>]
|
|
5099
5953
|
9pm access token revoke <site> <token-id>
|
|
@@ -5124,6 +5978,34 @@ Worker public files:
|
|
|
5124
5978
|
change it. A worker app that never chose serves EVERY file except its entry publicly, and
|
|
5125
5979
|
each deploy warns about it. --check lists the files a deploy would make public.
|
|
5126
5980
|
|
|
5981
|
+
Missing pages (static sites and --entry worker apps):
|
|
5982
|
+
--no-spa a GET for a path with no file returns 404, with the deploy's own 404.html
|
|
5983
|
+
when it has one. / still serves index.html, /menu serves menu.html (or
|
|
5984
|
+
redirects to /menu/ for menu/index.html). The default for new apps.
|
|
5985
|
+
--spa a path with no file serves index.html instead, for apps that route in the
|
|
5986
|
+
browser. Every unmatched path gets it, /missing.json included.
|
|
5987
|
+
The app remembers the choice; a deploy without either flag keeps it, and a deploy that fails
|
|
5988
|
+
to publish does not change it. Apps created before the choice existed keep serving
|
|
5989
|
+
index.html until one of the flags is passed. A worker app's own Response, 404 included,
|
|
5990
|
+
is always returned as it is. Each deploy prints the mode in effect.
|
|
5991
|
+
|
|
5992
|
+
Custom domains:
|
|
5993
|
+
Every domains command takes --owner <organisation> for an organisation's app. list, status and
|
|
5994
|
+
wait only read; add, check, restart and remove need the configure role on the app.
|
|
5995
|
+
--json prints {hostname, app, state, reason, next, routing, tls, provider, nameservers,
|
|
5996
|
+
records[{type, name, fqdn, expected, current, ok, check, apex, optional}], error, expiresAt,
|
|
5997
|
+
checkedAt}. state is one of live, waiting_for_dns, dns_unverified (routing is active but the
|
|
5998
|
+
records could not be checked), issuing_certificate (every required record checked and found),
|
|
5999
|
+
failed, expired. reason says why (reason.providerMessage is the provider's own text, when it
|
|
6000
|
+
reported any, and its source: certificate_authority or hostname); next says who acts next (owner or platform) and what to do.
|
|
6001
|
+
A record's name is relative to the zone (www, _acme-challenge.www, @). ok is null when the
|
|
6002
|
+
check cannot say: check is not_checked, or apex_addresses for a root record answering with
|
|
6003
|
+
addresses (a correct ALIAS does), which defers to the routing status.
|
|
6004
|
+
wait exits 0 when live, 10 on timeout with something for the owner to do (records missing or
|
|
6005
|
+
unverified, or a CAA record blocking the certificate), 11 on timeout with every record found and
|
|
6006
|
+
nothing for the owner to do (the platform is still finishing), 20 when the domain failed or
|
|
6007
|
+
expired, and 1 on a usage, auth or API error.
|
|
6008
|
+
|
|
5127
6009
|
Container deploys:
|
|
5128
6010
|
Requires Docker locally. The CLI builds linux/amd64, exports the image, uploads it through a short-lived session, then finalizes a queued deployment.
|
|
5129
6011
|
The app must listen on 0.0.0.0:$PORT - always read the PORT env var; on some placements the platform holds the selected --port and hands the app a different one. Use --health-path for a cheap readiness endpoint.
|