ninepm 0.69.0 → 0.72.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 +63 -10
- package/dist/cli-types.d.ts +2 -0
- package/dist/dns-host.d.ts +7 -0
- package/dist/dns-host.js +27 -0
- package/dist/index.d.ts +182 -6
- package/dist/index.js +686 -23
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -13,6 +13,7 @@ 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 } from "./dns-host.js";
|
|
16
17
|
import { buildFileManifest, readManifestFile } from "./manifest.js";
|
|
17
18
|
import { NO_PUBLIC_ASSETS, normalizeAssetsDirectory, privateLookingPaths, publicAssetPath } from "./public-assets.js";
|
|
18
19
|
import { API_KEY_HELP, API_URL_HELP, CLI_CLIENT_ID, DEFAULT_API_URL, reportCliError } from "./cli-config.js";
|
|
@@ -49,7 +50,7 @@ export function deployDatabaseClause(databases) {
|
|
|
49
50
|
const listed = databases.map((database) => (database.name ? `${database.bindingName}:${database.name}` : database.bindingName)).join(", ");
|
|
50
51
|
return `${databases.length === 1 ? "database" : "databases"} ${listed}`;
|
|
51
52
|
}
|
|
52
|
-
export const CLI_VERSION = "0.
|
|
53
|
+
export const CLI_VERSION = "0.72.0";
|
|
53
54
|
const PUBLIC_DASHBOARD_URL = "https://9pm.ai/dashboard";
|
|
54
55
|
const TOKEN_SERVICE = "9pm";
|
|
55
56
|
const TOKEN_ACCOUNT = "default";
|
|
@@ -243,9 +244,13 @@ export class DeployError extends Error {
|
|
|
243
244
|
* prevent (#1061).
|
|
244
245
|
*/
|
|
245
246
|
export class DeliberateRefusalError extends Error {
|
|
246
|
-
|
|
247
|
+
// The server's error code, so a command can add what only it knows - the exact
|
|
248
|
+
// command line to re-run - to a refusal it recognises.
|
|
249
|
+
code;
|
|
250
|
+
constructor(message, code) {
|
|
247
251
|
super(message);
|
|
248
252
|
this.name = "DeliberateRefusalError";
|
|
253
|
+
this.code = code;
|
|
249
254
|
}
|
|
250
255
|
}
|
|
251
256
|
/**
|
|
@@ -266,6 +271,7 @@ export class DeliberateRefusalError extends Error {
|
|
|
266
271
|
const DELIBERATE_REFUSAL_CODES = new Set([
|
|
267
272
|
"deploys_frozen",
|
|
268
273
|
"cli_version_outdated",
|
|
274
|
+
"access_mode_change_unconfirmed",
|
|
269
275
|
]);
|
|
270
276
|
export function isDeliberateRefusal(error) {
|
|
271
277
|
if (error instanceof DeliberateRefusalError)
|
|
@@ -495,6 +501,11 @@ async function runDeploy(initialOptions, slugHolder) {
|
|
|
495
501
|
throw new Error(options.bundleDir ? `Bundle directory contains no files: ${contentRoot}` : "Directory contains no deployable files.");
|
|
496
502
|
}
|
|
497
503
|
const bundle = options.bundleDir ? bundleMetadataFromManifest(manifest, contentRoot) : null;
|
|
504
|
+
// Checked here as well as at parse time because a detected framework build becomes a
|
|
505
|
+
// bundle only after parsing, and the platform would refuse it after the app exists.
|
|
506
|
+
if (bundle && options.missingPages) {
|
|
507
|
+
throw new Error("--spa and --no-spa do not apply to a framework bundle: its server answers every path itself.");
|
|
508
|
+
}
|
|
498
509
|
const serverEntry = runtime === "worker" && !bundle ? normalizeServerEntry(options.entry ?? "src/index.js") : undefined;
|
|
499
510
|
// Set by the size preflight below and reused by the upload loop, so the entry is read once.
|
|
500
511
|
let serverEntryBytes;
|
|
@@ -637,6 +648,7 @@ async function runDeploy(initialOptions, slugHolder) {
|
|
|
637
648
|
...(options.description ? { description: options.description } : {}),
|
|
638
649
|
runtime,
|
|
639
650
|
...(serverEntry ? { serverEntry } : {}),
|
|
651
|
+
...(options.missingPages ? { missingPages: options.missingPages } : {}),
|
|
640
652
|
metadata: {
|
|
641
653
|
cli: CLI_CLIENT_ID,
|
|
642
654
|
inspiredBy: "here.now",
|
|
@@ -1142,29 +1154,40 @@ export async function getSiteAccess(options) {
|
|
|
1142
1154
|
method: "GET",
|
|
1143
1155
|
});
|
|
1144
1156
|
}
|
|
1157
|
+
// The server refuses an access-mode change unless the request says it means it (#1987):
|
|
1158
|
+
// the change can lock people out and take custom domains down. `--switch-mode` sends
|
|
1159
|
+
// `switchMode: true`; without it the refusal is printed with the command to re-run.
|
|
1160
|
+
export const ACCESS_MODE_CHANGE_UNCONFIRMED = "access_mode_change_unconfirmed";
|
|
1161
|
+
function switchModeField(options) {
|
|
1162
|
+
return options.switchMode ? { switchMode: true } : {};
|
|
1163
|
+
}
|
|
1145
1164
|
export async function setSiteAccessPublic(options) {
|
|
1146
1165
|
const payload = await apiRequest(options, `/v1/sites/${encodeURIComponent(options.site)}/access`, {
|
|
1147
1166
|
method: "PUT",
|
|
1148
|
-
body: JSON.stringify({ mode: "public" }),
|
|
1167
|
+
body: JSON.stringify({ mode: "public", ...switchModeField(options) }),
|
|
1149
1168
|
});
|
|
1150
1169
|
return payload.access;
|
|
1151
1170
|
}
|
|
1152
1171
|
export async function setSiteAccessOtp(options) {
|
|
1153
1172
|
const payload = await apiRequest(options, `/v1/sites/${encodeURIComponent(options.site)}/access`, {
|
|
1154
1173
|
method: "PUT",
|
|
1155
|
-
body: JSON.stringify({ mode: "private_otp" }),
|
|
1174
|
+
body: JSON.stringify({ mode: "private_otp", ...switchModeField(options) }),
|
|
1156
1175
|
});
|
|
1157
1176
|
return payload.access;
|
|
1158
1177
|
}
|
|
1159
1178
|
export async function setSiteAccessIdp(options) {
|
|
1160
1179
|
const payload = await apiRequest(options, `/v1/sites/${encodeURIComponent(options.site)}/access`, {
|
|
1161
1180
|
method: "PUT",
|
|
1162
|
-
body: JSON.stringify({
|
|
1181
|
+
body: JSON.stringify({
|
|
1182
|
+
mode: "private_idp",
|
|
1183
|
+
...(options.providers?.length ? { providers: options.providers } : {}),
|
|
1184
|
+
...switchModeField(options),
|
|
1185
|
+
}),
|
|
1163
1186
|
});
|
|
1164
1187
|
return payload.access;
|
|
1165
1188
|
}
|
|
1166
1189
|
export async function createAccessServiceToken(options) {
|
|
1167
|
-
const body = {};
|
|
1190
|
+
const body = { ...switchModeField(options) };
|
|
1168
1191
|
if (options.name) {
|
|
1169
1192
|
body.name = options.name;
|
|
1170
1193
|
}
|
|
@@ -2435,7 +2458,7 @@ async function expectOk(response, action) {
|
|
|
2435
2458
|
const code = typeof envelope?.code === "string" ? envelope.code : "";
|
|
2436
2459
|
const message = typeof envelope?.message === "string" ? envelope.message.trim() : "";
|
|
2437
2460
|
if (code && message && DELIBERATE_REFUSAL_CODES.has(code)) {
|
|
2438
|
-
throw new DeliberateRefusalError(message);
|
|
2461
|
+
throw new DeliberateRefusalError(message, code);
|
|
2439
2462
|
}
|
|
2440
2463
|
throw new Error(`Failed to ${action}: ${response.status} ${text}`);
|
|
2441
2464
|
}
|
|
@@ -2610,6 +2633,8 @@ const VALID_DEPLOY_FLAGS = new Set([
|
|
|
2610
2633
|
"bundle",
|
|
2611
2634
|
"assets",
|
|
2612
2635
|
"no-assets",
|
|
2636
|
+
"spa",
|
|
2637
|
+
"no-spa",
|
|
2613
2638
|
]);
|
|
2614
2639
|
export function parseArgs(argv) {
|
|
2615
2640
|
const command = argv[0];
|
|
@@ -2628,6 +2653,9 @@ export function parseArgs(argv) {
|
|
|
2628
2653
|
if (command === "deployments") {
|
|
2629
2654
|
return parseDeploymentsArgs(argv.slice(1));
|
|
2630
2655
|
}
|
|
2656
|
+
if (command === "domains") {
|
|
2657
|
+
return parseDomainsArgs(argv.slice(1));
|
|
2658
|
+
}
|
|
2631
2659
|
if (command === "logs") {
|
|
2632
2660
|
return parseLogsArgs(argv.slice(1));
|
|
2633
2661
|
}
|
|
@@ -2860,6 +2888,23 @@ export function parseArgs(argv) {
|
|
|
2860
2888
|
}
|
|
2861
2889
|
options.publicAssets = typeof assetsFlag === "string" ? normalizeAssetsDirectory(assetsFlag) : NO_PUBLIC_ASSETS;
|
|
2862
2890
|
}
|
|
2891
|
+
if (flags.has("spa") || flags.has("no-spa")) {
|
|
2892
|
+
if (flags.has("spa") && flags.has("no-spa")) {
|
|
2893
|
+
throw new Error("--spa and --no-spa cannot be combined.");
|
|
2894
|
+
}
|
|
2895
|
+
// The flag parser hands the next word to a flag as its value, so `--spa dist` would
|
|
2896
|
+
// otherwise deploy "." and drop "dist" silently.
|
|
2897
|
+
for (const name of ["spa", "no-spa"]) {
|
|
2898
|
+
const value = flags.get(name);
|
|
2899
|
+
if (typeof value === "string") {
|
|
2900
|
+
throw new Error(`--${name} takes no value (got "${value}"). Put the directory first: 9pm deploy ${value} --${name}`);
|
|
2901
|
+
}
|
|
2902
|
+
}
|
|
2903
|
+
if (options.runtime === "container" || options.bundleDir) {
|
|
2904
|
+
throw new Error("--spa and --no-spa apply to static sites and --entry worker apps; a container or --bundle app answers every path itself.");
|
|
2905
|
+
}
|
|
2906
|
+
options.missingPages = flags.has("spa") ? "spa" : "404";
|
|
2907
|
+
}
|
|
2863
2908
|
if (options.runtime === "container" && !options.port) {
|
|
2864
2909
|
throw new Error("Container deploys require --port <port>.");
|
|
2865
2910
|
}
|
|
@@ -3173,6 +3218,516 @@ async function runDeploymentsCommand(command) {
|
|
|
3173
3218
|
});
|
|
3174
3219
|
console.log(`Deleted deployment ${result.deleted}.`);
|
|
3175
3220
|
}
|
|
3221
|
+
// The server has no field for an expired trace: it marks one by this exact provisioningError
|
|
3222
|
+
// (CUSTOM_DOMAIN_EXPIRED_MESSAGE in src/server/routes/shared.ts; a test holds the two equal).
|
|
3223
|
+
export const CUSTOM_DOMAIN_EXPIRED_TEXT = "This domain was removed because its DNS verification was never completed. Add the domain again to retry.";
|
|
3224
|
+
export const DOMAIN_WAIT_EXIT = { live: 0, ownerAction: 10, platform: 11, failed: 20 };
|
|
3225
|
+
function normalizeDnsName(value) {
|
|
3226
|
+
return value.trim().toLowerCase().replace(/\.$/, "");
|
|
3227
|
+
}
|
|
3228
|
+
// The value the server's DNS check compares against: TXT verbatim, names lowercased without a root dot.
|
|
3229
|
+
function checkedRecordValue(type, value) {
|
|
3230
|
+
return type === "TXT" ? value : normalizeDnsName(value);
|
|
3231
|
+
}
|
|
3232
|
+
export function domainRecordStatuses(domain) {
|
|
3233
|
+
const apex = guessZoneApex(domain.hostname);
|
|
3234
|
+
return domain.dnsInstructions.map((instruction) => {
|
|
3235
|
+
const type = instruction.type.toUpperCase();
|
|
3236
|
+
const fqdn = normalizeDnsName(instruction.host);
|
|
3237
|
+
const expected = checkedRecordValue(type, instruction.value);
|
|
3238
|
+
// Matched on the expected value as well as host and type: the two TXT values of one name
|
|
3239
|
+
// are separate rows, and keying on host and type alone gives both rows one verdict (#1986).
|
|
3240
|
+
const seen = domain.dnsCheck?.records.find((entry) => entry.type.toUpperCase() === type && normalizeDnsName(entry.host) === fqdn && entry.expected === expected);
|
|
3241
|
+
const atApex = type === "CNAME" && fqdn === apex;
|
|
3242
|
+
const check = domainRecordCheck(seen, atApex);
|
|
3243
|
+
return {
|
|
3244
|
+
type,
|
|
3245
|
+
name: relativeRecordName(fqdn, apex),
|
|
3246
|
+
fqdn,
|
|
3247
|
+
expected: instruction.value,
|
|
3248
|
+
current: seen ? seen.current : null,
|
|
3249
|
+
ok: check === "found" ? true : check === "missing" || check === "different" ? false : null,
|
|
3250
|
+
check,
|
|
3251
|
+
apex: atApex,
|
|
3252
|
+
optional: instruction.optional === true,
|
|
3253
|
+
};
|
|
3254
|
+
});
|
|
3255
|
+
}
|
|
3256
|
+
const IP_ADDRESS = /^(\d{1,3}(\.\d{1,3}){3}|[0-9a-f:]*:[0-9a-f:.]+)$/i;
|
|
3257
|
+
// The server looks a CNAME up and, finding none, falls back to the name's A records. At the
|
|
3258
|
+
// zone root that is exactly what a correct ALIAS/ANAME or flattened CNAME answers: addresses,
|
|
3259
|
+
// never the target name. So addresses at the apex are not evidence of a wrong record; they
|
|
3260
|
+
// cannot be compared from outside at all.
|
|
3261
|
+
function domainRecordCheck(seen, atApex) {
|
|
3262
|
+
if (!seen)
|
|
3263
|
+
return "not_checked";
|
|
3264
|
+
if (seen.matches)
|
|
3265
|
+
return "found";
|
|
3266
|
+
if (seen.current.length === 0)
|
|
3267
|
+
return "missing";
|
|
3268
|
+
if (atApex && seen.current.every((value) => IP_ADDRESS.test(value.trim())))
|
|
3269
|
+
return "apex_addresses";
|
|
3270
|
+
return "different";
|
|
3271
|
+
}
|
|
3272
|
+
export function deriveDomainState(domain, records = domainRecordStatuses(domain)) {
|
|
3273
|
+
if (domain.provisioningError === CUSTOM_DOMAIN_EXPIRED_TEXT)
|
|
3274
|
+
return "expired";
|
|
3275
|
+
if (domain.routingStatus === "failed" || domain.tlsStatus === "failed" || domain.provisioningError)
|
|
3276
|
+
return "failed";
|
|
3277
|
+
if (domain.routingStatus === "active" && domain.tlsStatus === "active")
|
|
3278
|
+
return "live";
|
|
3279
|
+
const required = records.filter((record) => !record.optional);
|
|
3280
|
+
if (required.some((record) => record.ok === false))
|
|
3281
|
+
return "waiting_for_dns";
|
|
3282
|
+
const routingActive = domain.routingStatus === "active";
|
|
3283
|
+
// An apex answering with addresses defers to the platform's own routing check: active means
|
|
3284
|
+
// it found the record, pending means it has not yet.
|
|
3285
|
+
const unproven = required.some((record) => record.check === "not_checked" || (record.check === "apex_addresses" && !routingActive));
|
|
3286
|
+
if (unproven || (!domain.dnsCheck && required.length === 0)) {
|
|
3287
|
+
// Routing still pending is the platform's own evidence that it has not seen the records.
|
|
3288
|
+
return routingActive ? "dns_unverified" : "waiting_for_dns";
|
|
3289
|
+
}
|
|
3290
|
+
return "issuing_certificate";
|
|
3291
|
+
}
|
|
3292
|
+
export function domainStatusJson(domain, app, checkedAt) {
|
|
3293
|
+
const records = domainRecordStatuses(domain);
|
|
3294
|
+
return {
|
|
3295
|
+
hostname: domain.hostname,
|
|
3296
|
+
app: domain.app?.slug ?? app,
|
|
3297
|
+
state: deriveDomainState(domain, records),
|
|
3298
|
+
routing: domain.routingStatus,
|
|
3299
|
+
tls: domain.tlsStatus,
|
|
3300
|
+
provider: domain.dnsCheck?.provider ?? null,
|
|
3301
|
+
nameservers: domain.dnsCheck?.nameservers ?? [],
|
|
3302
|
+
records,
|
|
3303
|
+
error: domain.provisioningError,
|
|
3304
|
+
expiresAt: domain.expiresAt ?? domain.expireAfter ?? null,
|
|
3305
|
+
checkedAt,
|
|
3306
|
+
};
|
|
3307
|
+
}
|
|
3308
|
+
const DOMAIN_STATE_LABELS = {
|
|
3309
|
+
live: "live",
|
|
3310
|
+
waiting_for_dns: "waiting for DNS records",
|
|
3311
|
+
dns_unverified: "couldn't check the DNS records just now",
|
|
3312
|
+
issuing_certificate: "DNS records found, waiting for the platform to issue the certificate",
|
|
3313
|
+
failed: "failed",
|
|
3314
|
+
expired: "expired",
|
|
3315
|
+
};
|
|
3316
|
+
export function domainStateLabel(state) {
|
|
3317
|
+
return DOMAIN_STATE_LABELS[state];
|
|
3318
|
+
}
|
|
3319
|
+
const TXT_KEEP_OTHERS = "add this value as its own TXT record; other TXT values can stay";
|
|
3320
|
+
function recordStateText(record, routing) {
|
|
3321
|
+
if (record.ok === true)
|
|
3322
|
+
return "found";
|
|
3323
|
+
// An optional record never holds the domain back, so its state would only be noise.
|
|
3324
|
+
if (record.optional)
|
|
3325
|
+
return "optional, only needed if the CNAME cannot be pointed first";
|
|
3326
|
+
if (record.check === "apex_addresses") {
|
|
3327
|
+
return routing === "active"
|
|
3328
|
+
? "looks connected: the root answers with addresses, as an ALIAS does, and routing is active"
|
|
3329
|
+
: `answers with addresses (${(record.current ?? []).join(", ")}); an ALIAS can't be verified from outside, so this waits on the platform's routing check`;
|
|
3330
|
+
}
|
|
3331
|
+
if (record.ok === null || record.current === null)
|
|
3332
|
+
return "not checked: couldn't read your DNS just now";
|
|
3333
|
+
if (record.type === "TXT") {
|
|
3334
|
+
const now = record.current.length === 0 ? "not published yet" : `currently ${record.current.join(", ")}`;
|
|
3335
|
+
return `${now}: ${TXT_KEEP_OTHERS}`;
|
|
3336
|
+
}
|
|
3337
|
+
if (record.current.length === 0)
|
|
3338
|
+
return "not published yet";
|
|
3339
|
+
return `currently ${record.current.join(", ")}: point it at the value shown`;
|
|
3340
|
+
}
|
|
3341
|
+
function domainCommand(subcommand, app, hostname, owner) {
|
|
3342
|
+
return `9pm domains ${subcommand} ${quoteSiteArg(app)} ${hostname}${owner ? ` --owner ${quoteSiteArg(owner)}` : ""}`;
|
|
3343
|
+
}
|
|
3344
|
+
function renderColumns(rows) {
|
|
3345
|
+
const widths = (rows[0] ?? []).map((_, column) => Math.max(...rows.map((row) => (row[column] ?? "").length)));
|
|
3346
|
+
return rows.map((cells) => cells
|
|
3347
|
+
.map((cell, column) => (column === cells.length - 1 ? cell : cell.padEnd(widths[column] ?? 0)))
|
|
3348
|
+
.join(" ")
|
|
3349
|
+
.trimEnd());
|
|
3350
|
+
}
|
|
3351
|
+
export function formatDomainStatus(status, options = {}) {
|
|
3352
|
+
const lines = [`${status.hostname} (app ${status.app}): ${domainStateLabel(status.state)}`];
|
|
3353
|
+
if (status.state === "live") {
|
|
3354
|
+
lines.push(`Serving at https://${status.hostname}`);
|
|
3355
|
+
return lines;
|
|
3356
|
+
}
|
|
3357
|
+
if (status.state === "expired") {
|
|
3358
|
+
lines.push(status.error ?? "This domain expired before its DNS records were published.");
|
|
3359
|
+
lines.push(`Add it again: ${domainCommand("add", status.app, status.hostname, options.owner)}`);
|
|
3360
|
+
return lines;
|
|
3361
|
+
}
|
|
3362
|
+
if (status.state === "failed" && status.error) {
|
|
3363
|
+
lines.push(status.error);
|
|
3364
|
+
}
|
|
3365
|
+
const apex = guessZoneApex(status.hostname);
|
|
3366
|
+
if (status.records.length > 0) {
|
|
3367
|
+
lines.push(status.provider
|
|
3368
|
+
? `DNS for ${apex} is managed at ${status.provider}${status.nameservers.length > 0 ? ` (${status.nameservers.join(", ")})` : ""}. Add these records there:`
|
|
3369
|
+
: status.nameservers.length > 0
|
|
3370
|
+
? `DNS for ${apex} is served by ${status.nameservers.join(", ")}. Add these records there:`
|
|
3371
|
+
: `Add these records where the DNS for ${apex} is managed:`);
|
|
3372
|
+
lines.push("");
|
|
3373
|
+
const rows = [
|
|
3374
|
+
["TYPE", "NAME", "VALUE", "STATE"],
|
|
3375
|
+
...status.records.map((record) => [record.apex ? "ALIAS" : record.type, record.name, record.expected, recordStateText(record, status.routing)]),
|
|
3376
|
+
];
|
|
3377
|
+
lines.push(...renderColumns(rows).map((line) => ` ${line}`));
|
|
3378
|
+
lines.push("");
|
|
3379
|
+
if (status.records.some((record) => record.apex)) {
|
|
3380
|
+
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.`);
|
|
3381
|
+
}
|
|
3382
|
+
if (status.records.some((record) => !record.optional && record.check === "not_checked")) {
|
|
3383
|
+
lines.push("Couldn't check your DNS records just now, so their state is unknown. Make sure they are published as shown.");
|
|
3384
|
+
}
|
|
3385
|
+
if (status.records.some((record) => record.name !== record.fqdn)) {
|
|
3386
|
+
lines.push(`Enter the name as shown: most providers add ".${apex}" themselves. If yours asks for the full name, append ".${apex}".`);
|
|
3387
|
+
}
|
|
3388
|
+
}
|
|
3389
|
+
if (status.state === "failed") {
|
|
3390
|
+
lines.push(`Re-check: ${domainCommand("check", status.app, status.hostname, options.owner)}`);
|
|
3391
|
+
lines.push(`Start over (the records may change): ${domainCommand("restart", status.app, status.hostname, options.owner)}`);
|
|
3392
|
+
return lines;
|
|
3393
|
+
}
|
|
3394
|
+
if (status.state === "issuing_certificate") {
|
|
3395
|
+
lines.push("Every required record is published. The platform finishes on its own, usually within minutes.");
|
|
3396
|
+
}
|
|
3397
|
+
lines.push(`Then run: ${domainCommand("wait", status.app, status.hostname, options.owner)}`);
|
|
3398
|
+
return lines;
|
|
3399
|
+
}
|
|
3400
|
+
// What a timed-out wait says. "Missing" is claimed only for a record the check actually saw
|
|
3401
|
+
// absent or different; a record nobody could read is reported as unchecked, whatever the
|
|
3402
|
+
// routing status, because telling the owner to publish what may already be there misleads.
|
|
3403
|
+
export function domainWaitTimeoutMessage(status) {
|
|
3404
|
+
if (status.state === "issuing_certificate") {
|
|
3405
|
+
return "Timed out: the records are published and the platform is still finishing. Wait again; nothing needs changing.";
|
|
3406
|
+
}
|
|
3407
|
+
const required = status.records.filter((record) => !record.optional);
|
|
3408
|
+
if (required.some((record) => record.ok === false)) {
|
|
3409
|
+
return "Timed out: records above are still missing. Publish them, then wait again.";
|
|
3410
|
+
}
|
|
3411
|
+
return "Timed out: the DNS records could not be checked. Make sure they are published as shown, then wait again.";
|
|
3412
|
+
}
|
|
3413
|
+
export function formatDomainsList(statuses) {
|
|
3414
|
+
if (statuses.length === 0) {
|
|
3415
|
+
return ["No custom domains. Add one: 9pm domains add <app> <hostname>"];
|
|
3416
|
+
}
|
|
3417
|
+
return renderColumns([
|
|
3418
|
+
["HOSTNAME", "APP", "STATE", "ROUTING", "TLS"],
|
|
3419
|
+
...statuses.map((status) => [status.hostname, status.app, status.state, status.routing, status.tls]),
|
|
3420
|
+
]);
|
|
3421
|
+
}
|
|
3422
|
+
export class DomainsApiError extends Error {
|
|
3423
|
+
status;
|
|
3424
|
+
code;
|
|
3425
|
+
constructor(message, status, code) {
|
|
3426
|
+
super(message);
|
|
3427
|
+
this.name = "DomainsApiError";
|
|
3428
|
+
this.status = status;
|
|
3429
|
+
this.code = code;
|
|
3430
|
+
}
|
|
3431
|
+
}
|
|
3432
|
+
// The API's own message and code, plus `details.reason` where the refusal carries one
|
|
3433
|
+
// (invalid_custom_domain), rather than the raw envelope the generic path prints.
|
|
3434
|
+
export function domainsApiError(status, text) {
|
|
3435
|
+
let envelope = null;
|
|
3436
|
+
try {
|
|
3437
|
+
const parsed = JSON.parse(text);
|
|
3438
|
+
envelope = parsed.error && typeof parsed.error === "object" ? parsed.error : null;
|
|
3439
|
+
}
|
|
3440
|
+
catch {
|
|
3441
|
+
envelope = null;
|
|
3442
|
+
}
|
|
3443
|
+
const code = typeof envelope?.code === "string" ? envelope.code : null;
|
|
3444
|
+
const message = typeof envelope?.message === "string" ? envelope.message.trim() : "";
|
|
3445
|
+
if (!code || !message) {
|
|
3446
|
+
return new DomainsApiError(`Request failed: ${status} ${text}`.trim(), status, code);
|
|
3447
|
+
}
|
|
3448
|
+
const details = envelope?.details;
|
|
3449
|
+
const reason = details && typeof details === "object" && typeof details.reason === "string"
|
|
3450
|
+
? details.reason
|
|
3451
|
+
: null;
|
|
3452
|
+
return new DomainsApiError(`${message} (${code}${reason ? `, reason: ${reason}` : ""})`, status, code);
|
|
3453
|
+
}
|
|
3454
|
+
async function domainsRequest(options, route, init = { method: "GET" }) {
|
|
3455
|
+
const fetchFn = options.fetchFn ?? fetch;
|
|
3456
|
+
const headers = new Headers(init.headers);
|
|
3457
|
+
headers.set("authorization", `Bearer ${options.token}`);
|
|
3458
|
+
headers.set("x-9pm-cli-version", CLI_VERSION);
|
|
3459
|
+
if (init.body)
|
|
3460
|
+
headers.set("content-type", "application/json");
|
|
3461
|
+
const response = await fetchFn(`${normalizeApiUrl(options.apiUrl)}${route}`, { ...init, headers });
|
|
3462
|
+
noteCliOutdatedAdvisory(response);
|
|
3463
|
+
if (!response.ok) {
|
|
3464
|
+
throw domainsApiError(response.status, await response.text());
|
|
3465
|
+
}
|
|
3466
|
+
return (await response.json());
|
|
3467
|
+
}
|
|
3468
|
+
function ownerQuery(owner) {
|
|
3469
|
+
return owner ? `?owner=${encodeURIComponent(owner)}` : "";
|
|
3470
|
+
}
|
|
3471
|
+
function siteDomainsRoute(options, hostname, action) {
|
|
3472
|
+
const base = `/v1/sites/${encodeURIComponent(options.site)}/domains`;
|
|
3473
|
+
const path = hostname ? `${base}/${encodeURIComponent(hostname)}${action ? `/${action}` : ""}` : base;
|
|
3474
|
+
return `${path}${ownerQuery(options.owner)}`;
|
|
3475
|
+
}
|
|
3476
|
+
export function normalizeDomainArg(value) {
|
|
3477
|
+
return normalizeDnsName(value);
|
|
3478
|
+
}
|
|
3479
|
+
export async function listDomainStatuses(options) {
|
|
3480
|
+
const checkedAt = (options.now ?? (() => new Date()))().toISOString();
|
|
3481
|
+
if (options.site) {
|
|
3482
|
+
const result = await domainsRequest(options, siteDomainsRoute({ ...options, site: options.site }));
|
|
3483
|
+
return result.domains.map((domain) => domainStatusJson(domain, options.site, checkedAt));
|
|
3484
|
+
}
|
|
3485
|
+
const result = await domainsRequest(options, `/v1/domains${ownerQuery(options.owner)}`);
|
|
3486
|
+
return result.domains.map((domain) => domainStatusJson(domain, domain.app?.slug ?? "", checkedAt));
|
|
3487
|
+
}
|
|
3488
|
+
export async function getDomainStatus(options) {
|
|
3489
|
+
const hostname = normalizeDomainArg(options.hostname);
|
|
3490
|
+
const statuses = await listDomainStatuses(options);
|
|
3491
|
+
const status = statuses.find((candidate) => candidate.hostname === hostname);
|
|
3492
|
+
if (!status) {
|
|
3493
|
+
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");
|
|
3494
|
+
}
|
|
3495
|
+
return status;
|
|
3496
|
+
}
|
|
3497
|
+
export async function addDomain(options) {
|
|
3498
|
+
const result = await domainsRequest(options, siteDomainsRoute(options), {
|
|
3499
|
+
method: "POST",
|
|
3500
|
+
body: JSON.stringify({ hostname: options.hostname.trim() }),
|
|
3501
|
+
});
|
|
3502
|
+
return result.domain;
|
|
3503
|
+
}
|
|
3504
|
+
export async function recheckDomain(options) {
|
|
3505
|
+
await domainsRequest(options, siteDomainsRoute(options, normalizeDomainArg(options.hostname), "refresh"), { method: "POST" });
|
|
3506
|
+
}
|
|
3507
|
+
export async function restartDomain(options) {
|
|
3508
|
+
return domainsRequest(options, siteDomainsRoute(options, normalizeDomainArg(options.hostname), "restart"), { method: "POST" });
|
|
3509
|
+
}
|
|
3510
|
+
export async function removeDomain(options) {
|
|
3511
|
+
const result = await domainsRequest(options, siteDomainsRoute(options, normalizeDomainArg(options.hostname)), {
|
|
3512
|
+
method: "DELETE",
|
|
3513
|
+
});
|
|
3514
|
+
return result.domain;
|
|
3515
|
+
}
|
|
3516
|
+
// Polls the read-only list, which reconciles pending domains on every read, so waiting needs
|
|
3517
|
+
// only access to the app. A failed read that is not the caller's fault (network, 5xx) is
|
|
3518
|
+
// retried until the deadline; a refusal (auth, not found) ends the wait at once.
|
|
3519
|
+
export async function waitForDomain(options) {
|
|
3520
|
+
const nowMs = options.nowMs ?? (() => Date.now());
|
|
3521
|
+
const sleepFn = options.sleepFn ?? sleep;
|
|
3522
|
+
const deadline = nowMs() + options.timeoutMs;
|
|
3523
|
+
let last = null;
|
|
3524
|
+
for (;;) {
|
|
3525
|
+
try {
|
|
3526
|
+
const status = await getDomainStatus(options);
|
|
3527
|
+
last = status;
|
|
3528
|
+
options.onStatus?.(status);
|
|
3529
|
+
if (status.state === "live")
|
|
3530
|
+
return { status, exitCode: DOMAIN_WAIT_EXIT.live, timedOut: false };
|
|
3531
|
+
if (status.state === "failed" || status.state === "expired")
|
|
3532
|
+
return { status, exitCode: DOMAIN_WAIT_EXIT.failed, timedOut: false };
|
|
3533
|
+
}
|
|
3534
|
+
catch (error) {
|
|
3535
|
+
const transient = !(error instanceof DomainsApiError) || error.status >= 500;
|
|
3536
|
+
if (!transient || (nowMs() >= deadline && !last))
|
|
3537
|
+
throw error;
|
|
3538
|
+
options.onRetry?.(error);
|
|
3539
|
+
}
|
|
3540
|
+
const remaining = deadline - nowMs();
|
|
3541
|
+
if (remaining <= 0) {
|
|
3542
|
+
if (!last)
|
|
3543
|
+
throw new Error(`Timed out before ${options.hostname} could be read.`);
|
|
3544
|
+
// Only a proven issuing_certificate is the platform's to finish; records that could not be
|
|
3545
|
+
// checked are the owner's to confirm, so they share the owner-action code.
|
|
3546
|
+
const exitCode = last.state === "issuing_certificate" ? DOMAIN_WAIT_EXIT.platform : DOMAIN_WAIT_EXIT.ownerAction;
|
|
3547
|
+
return { status: last, exitCode, timedOut: true };
|
|
3548
|
+
}
|
|
3549
|
+
await sleepFn(Math.min(options.intervalMs, remaining));
|
|
3550
|
+
}
|
|
3551
|
+
}
|
|
3552
|
+
const DOMAINS_VALUE_FLAGS = new Set(["token", "api-url", "owner", "timeout", "interval", "confirm"]);
|
|
3553
|
+
const DOMAINS_BOOLEAN_FLAGS = new Set(["json"]);
|
|
3554
|
+
function domainsPositionals(argv) {
|
|
3555
|
+
const positionals = [];
|
|
3556
|
+
for (let index = 0; index < argv.length; index += 1) {
|
|
3557
|
+
const arg = argv[index] ?? "";
|
|
3558
|
+
if (!arg.startsWith("--")) {
|
|
3559
|
+
positionals.push(arg);
|
|
3560
|
+
continue;
|
|
3561
|
+
}
|
|
3562
|
+
const name = arg.slice(2);
|
|
3563
|
+
if (!DOMAINS_VALUE_FLAGS.has(name) && !DOMAINS_BOOLEAN_FLAGS.has(name)) {
|
|
3564
|
+
throw new Error(`Unknown flag for 9pm domains: --${name}`);
|
|
3565
|
+
}
|
|
3566
|
+
if (DOMAINS_VALUE_FLAGS.has(name))
|
|
3567
|
+
index += 1;
|
|
3568
|
+
}
|
|
3569
|
+
return positionals;
|
|
3570
|
+
}
|
|
3571
|
+
const DOMAINS_USAGE = {
|
|
3572
|
+
list: "9pm domains list [<app>] [--owner <organisation>] [--json]",
|
|
3573
|
+
add: "9pm domains add <app> <hostname> [--owner <organisation>] [--json]",
|
|
3574
|
+
status: "9pm domains status <app> <hostname> [--owner <organisation>] [--json]",
|
|
3575
|
+
check: "9pm domains check <app> <hostname> [--owner <organisation>] [--json]",
|
|
3576
|
+
wait: "9pm domains wait <app> <hostname> [--timeout 30m] [--interval 20s] [--owner <organisation>] [--json]",
|
|
3577
|
+
restart: "9pm domains restart <app> <hostname> [--owner <organisation>]",
|
|
3578
|
+
remove: "9pm domains remove <app> <hostname> --confirm <hostname> [--owner <organisation>]",
|
|
3579
|
+
};
|
|
3580
|
+
function parseDomainsArgs(argv) {
|
|
3581
|
+
const subcommand = argv[0] === "ls" ? "list" : argv[0] === "rm" ? "remove" : argv[0];
|
|
3582
|
+
if (!subcommand || subcommand === "help" || subcommand === "--help") {
|
|
3583
|
+
throw new UsageError();
|
|
3584
|
+
}
|
|
3585
|
+
const usage = DOMAINS_USAGE[subcommand];
|
|
3586
|
+
if (!usage) {
|
|
3587
|
+
throw new Error(`Unknown domains subcommand: ${subcommand}. Expected one of: ${Object.keys(DOMAINS_USAGE).join(", ")}.`);
|
|
3588
|
+
}
|
|
3589
|
+
const flags = parseFlags(argv);
|
|
3590
|
+
const positionals = domainsPositionals(argv.slice(1));
|
|
3591
|
+
const json = flags.has("json");
|
|
3592
|
+
const owner = readStringFlag(flags, "owner");
|
|
3593
|
+
const withBase = (action) => ({
|
|
3594
|
+
kind: "domains",
|
|
3595
|
+
...readBaseApiOptions(flags),
|
|
3596
|
+
...(owner ? { owner } : {}),
|
|
3597
|
+
action,
|
|
3598
|
+
});
|
|
3599
|
+
if (subcommand === "list") {
|
|
3600
|
+
if (positionals.length > 1)
|
|
3601
|
+
throw new Error(`Usage: ${usage}`);
|
|
3602
|
+
const site = positionals[0];
|
|
3603
|
+
return withBase({ kind: "list", json, ...(site ? { site } : {}) });
|
|
3604
|
+
}
|
|
3605
|
+
const [site, hostname] = positionals;
|
|
3606
|
+
if (!site || !hostname || positionals.length > 2) {
|
|
3607
|
+
throw new Error(`Usage: ${usage}`);
|
|
3608
|
+
}
|
|
3609
|
+
if (subcommand === "add" || subcommand === "status" || subcommand === "check") {
|
|
3610
|
+
return withBase({ kind: subcommand, site, hostname, json });
|
|
3611
|
+
}
|
|
3612
|
+
if (subcommand === "wait") {
|
|
3613
|
+
const timeout = readStringFlag(flags, "timeout") ?? "30m";
|
|
3614
|
+
const interval = readStringFlag(flags, "interval") ?? "20s";
|
|
3615
|
+
const timeoutSeconds = parseDurationSeconds(timeout);
|
|
3616
|
+
const intervalSeconds = parseDurationSeconds(interval);
|
|
3617
|
+
if (intervalSeconds < 1)
|
|
3618
|
+
throw new Error("--interval must be at least 1s.");
|
|
3619
|
+
return withBase({ kind: "wait", site, hostname, json, timeoutSeconds, intervalSeconds });
|
|
3620
|
+
}
|
|
3621
|
+
if (subcommand === "restart") {
|
|
3622
|
+
return withBase({ kind: "restart", site, hostname });
|
|
3623
|
+
}
|
|
3624
|
+
const confirm = readStringFlag(flags, "confirm");
|
|
3625
|
+
if (!confirm || normalizeDomainArg(confirm) !== normalizeDomainArg(hostname)) {
|
|
3626
|
+
throw new Error(`Removing a domain stops it serving at once. Pass --confirm ${normalizeDomainArg(hostname)} to remove it.`);
|
|
3627
|
+
}
|
|
3628
|
+
return withBase({ kind: "remove", site, hostname });
|
|
3629
|
+
}
|
|
3630
|
+
function printDomainStatus(status, json, owner, heading) {
|
|
3631
|
+
if (json) {
|
|
3632
|
+
console.log(JSON.stringify(status, null, 2));
|
|
3633
|
+
return;
|
|
3634
|
+
}
|
|
3635
|
+
if (heading)
|
|
3636
|
+
console.log(heading);
|
|
3637
|
+
for (const line of formatDomainStatus(status, owner ? { owner } : {}))
|
|
3638
|
+
console.log(line);
|
|
3639
|
+
}
|
|
3640
|
+
function instructionLines(instructions, hostname) {
|
|
3641
|
+
const apex = guessZoneApex(hostname);
|
|
3642
|
+
return renderColumns(instructions.map((instruction) => [instruction.type.toUpperCase(), relativeRecordName(instruction.host, apex), instruction.value])).map((line) => ` ${line}`);
|
|
3643
|
+
}
|
|
3644
|
+
async function runDomainsCommand(command) {
|
|
3645
|
+
const action = command.action;
|
|
3646
|
+
const base = { apiUrl: command.apiUrl, token: command.token, ...(command.owner ? { owner: command.owner } : {}) };
|
|
3647
|
+
if (action.kind === "list") {
|
|
3648
|
+
const statuses = await listDomainStatuses({ ...base, ...(action.site ? { site: action.site } : {}) });
|
|
3649
|
+
if (action.json) {
|
|
3650
|
+
console.log(JSON.stringify({ domains: statuses }, null, 2));
|
|
3651
|
+
return;
|
|
3652
|
+
}
|
|
3653
|
+
for (const line of formatDomainsList(statuses))
|
|
3654
|
+
console.log(line);
|
|
3655
|
+
return;
|
|
3656
|
+
}
|
|
3657
|
+
const target = { ...base, site: action.site, hostname: action.hostname };
|
|
3658
|
+
if (action.kind === "add") {
|
|
3659
|
+
const added = await addDomain(target);
|
|
3660
|
+
const hostname = added?.hostname ?? normalizeDomainArg(action.hostname);
|
|
3661
|
+
// The add response carries no DNS check; the list read that follows does, and it is what
|
|
3662
|
+
// says whether a record the owner already has (a wildcard, an old CNAME) is in the way.
|
|
3663
|
+
const status = await getDomainStatus({ ...target, hostname }).catch(() => added ? domainStatusJson(added, action.site, new Date().toISOString()) : null);
|
|
3664
|
+
if (!status) {
|
|
3665
|
+
console.log(`Added ${hostname} to ${action.site}. See its records: ${domainCommand("status", action.site, hostname, command.owner)}`);
|
|
3666
|
+
return;
|
|
3667
|
+
}
|
|
3668
|
+
printDomainStatus(status, action.json, command.owner, action.json ? undefined : `Added ${hostname} to ${action.site}.`);
|
|
3669
|
+
return;
|
|
3670
|
+
}
|
|
3671
|
+
if (action.kind === "status") {
|
|
3672
|
+
printDomainStatus(await getDomainStatus(target), action.json, command.owner);
|
|
3673
|
+
return;
|
|
3674
|
+
}
|
|
3675
|
+
if (action.kind === "check") {
|
|
3676
|
+
await recheckDomain(target);
|
|
3677
|
+
printDomainStatus(await getDomainStatus(target), action.json, command.owner);
|
|
3678
|
+
return;
|
|
3679
|
+
}
|
|
3680
|
+
if (action.kind === "wait") {
|
|
3681
|
+
let lastState = null;
|
|
3682
|
+
const result = await waitForDomain({
|
|
3683
|
+
...target,
|
|
3684
|
+
timeoutMs: action.timeoutSeconds * 1000,
|
|
3685
|
+
intervalMs: action.intervalSeconds * 1000,
|
|
3686
|
+
onStatus: (status) => {
|
|
3687
|
+
if (status.state !== lastState) {
|
|
3688
|
+
lastState = status.state;
|
|
3689
|
+
console.error(`${new Date().toISOString()} ${status.hostname}: ${domainStateLabel(status.state)}`);
|
|
3690
|
+
}
|
|
3691
|
+
},
|
|
3692
|
+
onRetry: (error) => console.error(`Could not read the domain (${error instanceof Error ? error.message : String(error)}); retrying.`),
|
|
3693
|
+
});
|
|
3694
|
+
if (action.json) {
|
|
3695
|
+
console.log(JSON.stringify(result.status, null, 2));
|
|
3696
|
+
}
|
|
3697
|
+
else {
|
|
3698
|
+
for (const line of formatDomainStatus(result.status, command.owner ? { owner: command.owner } : {}))
|
|
3699
|
+
console.log(line);
|
|
3700
|
+
if (result.timedOut)
|
|
3701
|
+
console.log(domainWaitTimeoutMessage(result.status));
|
|
3702
|
+
}
|
|
3703
|
+
process.exitCode = result.exitCode;
|
|
3704
|
+
return;
|
|
3705
|
+
}
|
|
3706
|
+
if (action.kind === "restart") {
|
|
3707
|
+
const restarted = await restartDomain(target);
|
|
3708
|
+
console.log(`Started validation for ${restarted.domain.hostname} over.`);
|
|
3709
|
+
if (restarted.replacedInstructions.length > 0) {
|
|
3710
|
+
console.log("These values no longer count; delete them at your DNS provider:");
|
|
3711
|
+
for (const line of instructionLines(restarted.replacedInstructions, restarted.domain.hostname))
|
|
3712
|
+
console.log(line);
|
|
3713
|
+
}
|
|
3714
|
+
else {
|
|
3715
|
+
console.log("No record changed.");
|
|
3716
|
+
}
|
|
3717
|
+
const status = await getDomainStatus({ ...target, hostname: restarted.domain.hostname }).catch(() => domainStatusJson(restarted.domain, action.site, new Date().toISOString()));
|
|
3718
|
+
for (const line of formatDomainStatus(status, command.owner ? { owner: command.owner } : {}))
|
|
3719
|
+
console.log(line);
|
|
3720
|
+
return;
|
|
3721
|
+
}
|
|
3722
|
+
const removed = await removeDomain(target);
|
|
3723
|
+
const hostname = removed?.hostname ?? normalizeDomainArg(action.hostname);
|
|
3724
|
+
console.log(`Removed ${hostname} from ${action.site}. It stops serving within seconds.`);
|
|
3725
|
+
if (removed && removed.dnsInstructions.length > 0) {
|
|
3726
|
+
console.log("You can now delete these records at your DNS provider, if nothing else uses them:");
|
|
3727
|
+
for (const line of instructionLines(removed.dnsInstructions, hostname))
|
|
3728
|
+
console.log(line);
|
|
3729
|
+
}
|
|
3730
|
+
}
|
|
3176
3731
|
// #807 step 5: the claim behind a health verdict, in words. A bare `unknown` reads
|
|
3177
3732
|
// as "fine"; the reason says which statement the platform is actually making.
|
|
3178
3733
|
export function healthWithReason(status, reason) {
|
|
@@ -3314,7 +3869,7 @@ function parseAccessArgs(argv) {
|
|
|
3314
3869
|
throw new UsageError();
|
|
3315
3870
|
}
|
|
3316
3871
|
const flags = parseFlags(argv);
|
|
3317
|
-
const base = readBaseApiOptions(flags);
|
|
3872
|
+
const base = { ...readBaseApiOptions(flags), apiUrlFrom: apiUrlSource(flags) };
|
|
3318
3873
|
const siteFrom = (index) => {
|
|
3319
3874
|
const site = argv[index];
|
|
3320
3875
|
if (!site || site.startsWith("--")) {
|
|
@@ -3325,16 +3880,17 @@ function parseAccessArgs(argv) {
|
|
|
3325
3880
|
if (subcommand === "show") {
|
|
3326
3881
|
return { kind: "access", ...base, action: { kind: "show", site: siteFrom(1) } };
|
|
3327
3882
|
}
|
|
3883
|
+
const switchMode = flags.has("switch-mode") ? { switchMode: true } : {};
|
|
3328
3884
|
if (subcommand === "public") {
|
|
3329
|
-
return { kind: "access", ...base, action: { kind: "public", site: siteFrom(1) } };
|
|
3885
|
+
return { kind: "access", ...base, action: { kind: "public", site: siteFrom(1), ...switchMode } };
|
|
3330
3886
|
}
|
|
3331
3887
|
if (subcommand === "otp") {
|
|
3332
|
-
return { kind: "access", ...base, action: { kind: "otp", site: siteFrom(1) } };
|
|
3888
|
+
return { kind: "access", ...base, action: { kind: "otp", site: siteFrom(1), ...switchMode } };
|
|
3333
3889
|
}
|
|
3334
3890
|
if (subcommand === "idp") {
|
|
3335
3891
|
const site = siteFrom(1);
|
|
3336
3892
|
const providers = readRepeatedStringFlag(argv, "provider").map(parseProviderKey);
|
|
3337
|
-
return { kind: "access", ...base, action: { kind: "idp", site, ...(providers.length ? { providers } : {}) } };
|
|
3893
|
+
return { kind: "access", ...base, action: { kind: "idp", site, ...(providers.length ? { providers } : {}), ...switchMode } };
|
|
3338
3894
|
}
|
|
3339
3895
|
if (subcommand === "service") {
|
|
3340
3896
|
const verb = argv[1];
|
|
@@ -3342,7 +3898,11 @@ function parseAccessArgs(argv) {
|
|
|
3342
3898
|
if (verb === "create") {
|
|
3343
3899
|
const name = readStringFlag(flags, "name");
|
|
3344
3900
|
const duration = readStringFlag(flags, "duration");
|
|
3345
|
-
return {
|
|
3901
|
+
return {
|
|
3902
|
+
kind: "access",
|
|
3903
|
+
...base,
|
|
3904
|
+
action: { kind: "service-create", site, ...(name ? { name } : {}), ...(duration ? { duration } : {}), ...switchMode },
|
|
3905
|
+
};
|
|
3346
3906
|
}
|
|
3347
3907
|
if (verb === "revoke") {
|
|
3348
3908
|
const tokenId = argv[3];
|
|
@@ -3454,6 +4014,11 @@ function parseSupportArgs(argv) {
|
|
|
3454
4014
|
...readBaseApiOptions(flags),
|
|
3455
4015
|
};
|
|
3456
4016
|
}
|
|
4017
|
+
function apiUrlSource(flags) {
|
|
4018
|
+
if (readStringFlag(flags, "api-url") !== undefined)
|
|
4019
|
+
return "flag";
|
|
4020
|
+
return process.env.NINEPM_API_URL !== undefined ? "env" : "default";
|
|
4021
|
+
}
|
|
3457
4022
|
function readApiUrl(flags, fallback) {
|
|
3458
4023
|
return validateApiUrl(readStringFlag(flags, "api-url") ?? process.env.NINEPM_API_URL ?? fallback);
|
|
3459
4024
|
}
|
|
@@ -4462,6 +5027,22 @@ function formatRemainingForCli(value) {
|
|
|
4462
5027
|
}
|
|
4463
5028
|
export class UsageError extends Error {
|
|
4464
5029
|
}
|
|
5030
|
+
// #1856: which missing-pages mode the deployment was published with, read from the
|
|
5031
|
+
// deployment the platform returned rather than from the flags, because a deploy without a
|
|
5032
|
+
// flag keeps whatever the app chose before. Null when the platform recorded none: a bundle
|
|
5033
|
+
// or container app answers every path itself, and an older control plane does not say.
|
|
5034
|
+
export function missingPagesLine(deployment) {
|
|
5035
|
+
switch (deployment.metadata.missingPages) {
|
|
5036
|
+
case "404":
|
|
5037
|
+
return "Missing pages: 404 (the deploy's own 404.html when it has one). If the app routes in the browser, redeploy with --spa.";
|
|
5038
|
+
case "spa":
|
|
5039
|
+
return "Missing pages: homepage (SPA): a path with no file serves index.html. Redeploy with --no-spa to return 404 instead.";
|
|
5040
|
+
case "legacy":
|
|
5041
|
+
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.";
|
|
5042
|
+
default:
|
|
5043
|
+
return null;
|
|
5044
|
+
}
|
|
5045
|
+
}
|
|
4465
5046
|
async function main() {
|
|
4466
5047
|
try {
|
|
4467
5048
|
const command = parseArgs(process.argv.slice(2));
|
|
@@ -4542,6 +5123,10 @@ async function main() {
|
|
|
4542
5123
|
if (queued) {
|
|
4543
5124
|
console.log(`Status: ${result.deployment.status}. Run 9pm apps to check when it becomes active.`);
|
|
4544
5125
|
}
|
|
5126
|
+
const missingPages = missingPagesLine(result.deployment);
|
|
5127
|
+
if (missingPages) {
|
|
5128
|
+
console.log(missingPages);
|
|
5129
|
+
}
|
|
4545
5130
|
console.log(`Manage: ${dashboardUrlForApiUrl(command.options.apiUrl)}${command.options.owner ? `?owner=${encodeURIComponent(command.options.owner)}` : ""}`);
|
|
4546
5131
|
// Gated the same way as the reachability probe above: a programmatic
|
|
4547
5132
|
// caller injecting fetchFn opted out of the CLI's own network side trips.
|
|
@@ -4789,6 +5374,10 @@ async function main() {
|
|
|
4789
5374
|
await runDeploymentsCommand(command);
|
|
4790
5375
|
return;
|
|
4791
5376
|
}
|
|
5377
|
+
if (command.kind === "domains") {
|
|
5378
|
+
await runDomainsCommand(command);
|
|
5379
|
+
return;
|
|
5380
|
+
}
|
|
4792
5381
|
if (command.kind === "logs") {
|
|
4793
5382
|
await runLogsCommand(command);
|
|
4794
5383
|
return;
|
|
@@ -4871,25 +5460,66 @@ async function runSupportCommand(command) {
|
|
|
4871
5460
|
});
|
|
4872
5461
|
console.log("Support request sent. A human will reply to your account email.");
|
|
4873
5462
|
}
|
|
5463
|
+
// The command line that repeats this one with --switch-mode. Rebuilt from the parsed
|
|
5464
|
+
// action rather than echoed from argv so a --token on the original line is never printed.
|
|
5465
|
+
// The destination is carried explicitly whenever it is not the built-in default: a line
|
|
5466
|
+
// copied from a staging session and pasted into a shell without that flag or env var would
|
|
5467
|
+
// otherwise switch the mode of the same-named app on production. The access commands read
|
|
5468
|
+
// no --owner (a site id or slug already names the app), so there is none to carry.
|
|
5469
|
+
export function accessSwitchModeCommand(action, target) {
|
|
5470
|
+
const site = quoteSiteArg(action.site);
|
|
5471
|
+
// A plain URL (scheme, host, port, path) has no shell-significant characters; anything
|
|
5472
|
+
// else falls back to single quotes.
|
|
5473
|
+
const url = target && /^[A-Za-z0-9._:/-]+$/.test(target.apiUrl) ? target.apiUrl : target ? quoteSiteArg(target.apiUrl) : "";
|
|
5474
|
+
const apiUrl = target && target.apiUrlFrom !== "default" ? ` --api-url ${url}` : "";
|
|
5475
|
+
if (action.kind === "service-create") {
|
|
5476
|
+
const name = action.name ? ` --name ${quoteSiteArg(action.name)}` : "";
|
|
5477
|
+
const duration = action.duration ? ` --duration ${quoteSiteArg(action.duration)}` : "";
|
|
5478
|
+
return `9pm access service create ${site}${name}${duration}${apiUrl} --switch-mode`;
|
|
5479
|
+
}
|
|
5480
|
+
if (action.kind === "idp") {
|
|
5481
|
+
const providers = (action.providers ?? []).map((provider) => ` --provider ${provider}`).join("");
|
|
5482
|
+
return `9pm access idp ${site}${providers}${apiUrl} --switch-mode`;
|
|
5483
|
+
}
|
|
5484
|
+
return `9pm access ${action.kind} ${site}${apiUrl} --switch-mode`;
|
|
5485
|
+
}
|
|
5486
|
+
// Names the API the refusal came from, and where that choice was made, so the reader sees
|
|
5487
|
+
// which environment the re-run will act on before running it.
|
|
5488
|
+
function accessTargetNote(target) {
|
|
5489
|
+
const from = target.apiUrlFrom === "flag" ? "from --api-url" : target.apiUrlFrom === "env" ? "from NINEPM_API_URL" : "the default API";
|
|
5490
|
+
return `against ${target.apiUrl} (${from})`;
|
|
5491
|
+
}
|
|
5492
|
+
async function withSwitchModeRerun(action, target, run) {
|
|
5493
|
+
try {
|
|
5494
|
+
return await run();
|
|
5495
|
+
}
|
|
5496
|
+
catch (error) {
|
|
5497
|
+
if (error instanceof DeliberateRefusalError && error.code === ACCESS_MODE_CHANGE_UNCONFIRMED) {
|
|
5498
|
+
throw new DeliberateRefusalError(`${error.message}\n\nNothing was changed. To go ahead ${accessTargetNote(target)}, re-run:\n ${accessSwitchModeCommand(action, target)}`, error.code);
|
|
5499
|
+
}
|
|
5500
|
+
throw error;
|
|
5501
|
+
}
|
|
5502
|
+
}
|
|
4874
5503
|
async function runAccessCommand(command) {
|
|
4875
5504
|
const base = { apiUrl: command.apiUrl, token: command.token };
|
|
5505
|
+
const target = { apiUrl: command.apiUrl, apiUrlFrom: command.apiUrlFrom };
|
|
4876
5506
|
const action = command.action;
|
|
4877
5507
|
if (action.kind === "show") {
|
|
4878
5508
|
console.log(JSON.stringify(await getSiteAccess({ ...base, site: action.site }), null, 2));
|
|
4879
5509
|
return;
|
|
4880
5510
|
}
|
|
4881
5511
|
if (action.kind === "public") {
|
|
4882
|
-
const access = await setSiteAccessPublic({ ...base, site: action.site });
|
|
5512
|
+
const access = await withSwitchModeRerun(action, target, () => setSiteAccessPublic({ ...base, site: action.site, ...switchModeField(action) }));
|
|
4883
5513
|
console.log(`Access for ${action.site} set to ${access.mode}.`);
|
|
4884
5514
|
return;
|
|
4885
5515
|
}
|
|
4886
5516
|
if (action.kind === "otp") {
|
|
4887
|
-
const access = await setSiteAccessOtp({ ...base, site: action.site });
|
|
5517
|
+
const access = await withSwitchModeRerun(action, target, () => setSiteAccessOtp({ ...base, site: action.site, ...switchModeField(action) }));
|
|
4888
5518
|
console.log(`Access for ${action.site} set to ${access.mode}.`);
|
|
4889
5519
|
return;
|
|
4890
5520
|
}
|
|
4891
5521
|
if (action.kind === "idp") {
|
|
4892
|
-
const access = await setSiteAccessIdp({ ...base, site: action.site, ...(action.providers ? { providers: action.providers } : {}) });
|
|
5522
|
+
const access = await withSwitchModeRerun(action, target, () => setSiteAccessIdp({ ...base, site: action.site, ...(action.providers ? { providers: action.providers } : {}), ...switchModeField(action) }));
|
|
4893
5523
|
console.log(`Access for ${action.site} set to ${access.mode}.`);
|
|
4894
5524
|
return;
|
|
4895
5525
|
}
|
|
@@ -4904,12 +5534,13 @@ async function runAccessCommand(command) {
|
|
|
4904
5534
|
return;
|
|
4905
5535
|
}
|
|
4906
5536
|
if (action.kind === "service-create") {
|
|
4907
|
-
const created = await createAccessServiceToken({
|
|
5537
|
+
const created = await withSwitchModeRerun(action, target, () => createAccessServiceToken({
|
|
4908
5538
|
...base,
|
|
4909
5539
|
site: action.site,
|
|
4910
5540
|
...(action.name ? { name: action.name } : {}),
|
|
4911
5541
|
...(action.duration ? { duration: action.duration } : {}),
|
|
4912
|
-
|
|
5542
|
+
...switchModeField(action),
|
|
5543
|
+
}));
|
|
4913
5544
|
console.log(`Created service token ${created.serviceToken.id} for ${action.site}.`);
|
|
4914
5545
|
console.log(`CF-Access-Client-Id: ${created.serviceToken.clientId}`);
|
|
4915
5546
|
console.log(`CF-Access-Client-Secret: ${created.clientSecret}`);
|
|
@@ -5060,8 +5691,8 @@ async function runFilesCommand(command) {
|
|
|
5060
5691
|
function printUsage() {
|
|
5061
5692
|
console.log(`Usage:
|
|
5062
5693
|
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]
|
|
5694
|
+
9pm deploy [directory] --name <site-name> [--slug <slug>] [--owner <organisation>] [--description "<note>"] [--spa | --no-spa] [--with-db] [--with-auth] [--with-email]
|
|
5695
|
+
9pm deploy [directory] --runtime worker --entry src/index.js (--assets <dir> | --no-assets) [--spa | --no-spa] [--with-db]
|
|
5065
5696
|
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
5697
|
9pm deploy [directory] framework projects (Next.js, Vite) are detected and built automatically; --no-build skips
|
|
5067
5698
|
9pm deploy [directory] --bundle <dir> deploy a pre-built Cloudflare framework bundle (e.g. .open-next)
|
|
@@ -5085,15 +5716,22 @@ function printUsage() {
|
|
|
5085
5716
|
9pm deployments rm <deployment-id>
|
|
5086
5717
|
9pm logs <app> [--lines <n>] [--json]
|
|
5087
5718
|
9pm delete <site> --confirm <slug>
|
|
5719
|
+
9pm domains list [<app>] [--owner <organisation>] [--json]
|
|
5720
|
+
9pm domains add <app> <hostname> [--json] connect a domain you own; prints the DNS records to publish
|
|
5721
|
+
9pm domains status <app> <hostname> [--json] state and, per record, what DNS shows now
|
|
5722
|
+
9pm domains check <app> <hostname> [--json] re-check now, then print the status
|
|
5723
|
+
9pm domains wait <app> <hostname> [--timeout 30m] [--interval 20s] [--json]
|
|
5724
|
+
9pm domains restart <app> <hostname> start validation over; record values may change
|
|
5725
|
+
9pm domains remove <app> <hostname> --confirm <hostname>
|
|
5088
5726
|
9pm access show <site>
|
|
5089
|
-
9pm access public <site>
|
|
5090
|
-
9pm access otp <site>
|
|
5091
|
-
9pm access idp <site> [--provider google|github|entra]
|
|
5727
|
+
9pm access public <site> [--switch-mode]
|
|
5728
|
+
9pm access otp <site> [--switch-mode]
|
|
5729
|
+
9pm access idp <site> [--provider google|github|entra] [--switch-mode]
|
|
5092
5730
|
9pm access allow email <site> <email>
|
|
5093
5731
|
9pm access allow domain <site> <domain>
|
|
5094
5732
|
9pm access revoke email <site> <email>
|
|
5095
5733
|
9pm access revoke domain <site> <domain>
|
|
5096
|
-
9pm access service create <site> [--name <name>] [--duration 8760h]
|
|
5734
|
+
9pm access service create <site> [--name <name>] [--duration 8760h] [--switch-mode]
|
|
5097
5735
|
9pm access service revoke <site> <service-token-id>
|
|
5098
5736
|
9pm access token create <site> [--name <name>]
|
|
5099
5737
|
9pm access token revoke <site> <token-id>
|
|
@@ -5124,6 +5762,31 @@ Worker public files:
|
|
|
5124
5762
|
change it. A worker app that never chose serves EVERY file except its entry publicly, and
|
|
5125
5763
|
each deploy warns about it. --check lists the files a deploy would make public.
|
|
5126
5764
|
|
|
5765
|
+
Missing pages (static sites and --entry worker apps):
|
|
5766
|
+
--no-spa a GET for a path with no file returns 404, with the deploy's own 404.html
|
|
5767
|
+
when it has one. / still serves index.html, /menu serves menu.html (or
|
|
5768
|
+
redirects to /menu/ for menu/index.html). The default for new apps.
|
|
5769
|
+
--spa a path with no file serves index.html instead, for apps that route in the
|
|
5770
|
+
browser. Every unmatched path gets it, /missing.json included.
|
|
5771
|
+
The app remembers the choice; a deploy without either flag keeps it, and a deploy that fails
|
|
5772
|
+
to publish does not change it. Apps created before the choice existed keep serving
|
|
5773
|
+
index.html until one of the flags is passed. A worker app's own Response, 404 included,
|
|
5774
|
+
is always returned as it is. Each deploy prints the mode in effect.
|
|
5775
|
+
|
|
5776
|
+
Custom domains:
|
|
5777
|
+
Every domains command takes --owner <organisation> for an organisation's app. list, status and
|
|
5778
|
+
wait only read; add, check, restart and remove need the configure role on the app.
|
|
5779
|
+
--json prints {hostname, app, state, routing, tls, provider, nameservers, records[{type, name,
|
|
5780
|
+
fqdn, expected, current, ok, check, apex, optional}], error, expiresAt, checkedAt}. state is one
|
|
5781
|
+
of live, waiting_for_dns, dns_unverified (routing is active but the records could not be
|
|
5782
|
+
checked), issuing_certificate (every required record checked and found), failed, expired.
|
|
5783
|
+
A record's name is relative to the zone (www, _acme-challenge.www, @). ok is null when the
|
|
5784
|
+
check cannot say: check is not_checked, or apex_addresses for a root record answering with
|
|
5785
|
+
addresses (a correct ALIAS does), which defers to the routing status.
|
|
5786
|
+
wait exits 0 when live, 10 on timeout with records missing or unverified (the owner must act or
|
|
5787
|
+
confirm), 11 on timeout with every record found (the platform is still finishing), 20 when the
|
|
5788
|
+
domain failed or expired, and 1 on a usage, auth or API error.
|
|
5789
|
+
|
|
5127
5790
|
Container deploys:
|
|
5128
5791
|
Requires Docker locally. The CLI builds linux/amd64, exports the image, uploads it through a short-lived session, then finalizes a queued deployment.
|
|
5129
5792
|
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.
|