ninepm 0.68.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/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.68.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
- constructor(message) {
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({ mode: "private_idp", ...(options.providers?.length ? { providers: options.providers } : {}) }),
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
  }
@@ -1380,6 +1403,16 @@ export async function listSites(options) {
1380
1403
  const payload = await apiRequest(options, route, { method: "GET" });
1381
1404
  return { projects: payload.projects };
1382
1405
  }
1406
+ // Printed before the delete request, never as a prompt: agents run this non-interactively
1407
+ // and `--confirm` is already the consent. The address part is the one nobody expects:
1408
+ // the hostname's suffix comes from the project id (appHostnameForProject), a new app gets
1409
+ // a new random id, and no route renames a project, so the same slug never gets it back.
1410
+ export function deleteWarningLines(slug) {
1411
+ return [
1412
+ `Warning: deleting ${slug} also gives up its address for good. An app created again under the same slug gets a new address, so links, bookmarks and printed QR codes pointing at the current one will stop working.`,
1413
+ "Its deployments, files and managed database (if it has one) are deleted with it and cannot be recovered.",
1414
+ ];
1415
+ }
1383
1416
  export async function deleteSite(options) {
1384
1417
  const route = `/v1/sites/${encodeURIComponent(options.site)}${options.owner ? `?owner=${encodeURIComponent(options.owner)}` : ""}`;
1385
1418
  const payload = await apiRequest(options, route, {
@@ -2425,7 +2458,7 @@ async function expectOk(response, action) {
2425
2458
  const code = typeof envelope?.code === "string" ? envelope.code : "";
2426
2459
  const message = typeof envelope?.message === "string" ? envelope.message.trim() : "";
2427
2460
  if (code && message && DELIBERATE_REFUSAL_CODES.has(code)) {
2428
- throw new DeliberateRefusalError(message);
2461
+ throw new DeliberateRefusalError(message, code);
2429
2462
  }
2430
2463
  throw new Error(`Failed to ${action}: ${response.status} ${text}`);
2431
2464
  }
@@ -2600,6 +2633,8 @@ const VALID_DEPLOY_FLAGS = new Set([
2600
2633
  "bundle",
2601
2634
  "assets",
2602
2635
  "no-assets",
2636
+ "spa",
2637
+ "no-spa",
2603
2638
  ]);
2604
2639
  export function parseArgs(argv) {
2605
2640
  const command = argv[0];
@@ -2618,6 +2653,9 @@ export function parseArgs(argv) {
2618
2653
  if (command === "deployments") {
2619
2654
  return parseDeploymentsArgs(argv.slice(1));
2620
2655
  }
2656
+ if (command === "domains") {
2657
+ return parseDomainsArgs(argv.slice(1));
2658
+ }
2621
2659
  if (command === "logs") {
2622
2660
  return parseLogsArgs(argv.slice(1));
2623
2661
  }
@@ -2850,6 +2888,23 @@ export function parseArgs(argv) {
2850
2888
  }
2851
2889
  options.publicAssets = typeof assetsFlag === "string" ? normalizeAssetsDirectory(assetsFlag) : NO_PUBLIC_ASSETS;
2852
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
+ }
2853
2908
  if (options.runtime === "container" && !options.port) {
2854
2909
  throw new Error("Container deploys require --port <port>.");
2855
2910
  }
@@ -3116,21 +3171,45 @@ function parseDeploymentsArgs(argv) {
3116
3171
  }
3117
3172
  throw new Error(`Unknown deployments subcommand: ${subcommand}`);
3118
3173
  }
3174
+ export function deploymentStatusLabel(deployment) {
3175
+ if (deployment.active) {
3176
+ return "serving";
3177
+ }
3178
+ return deployment.status === "active" ? "active, not serving" : deployment.status;
3179
+ }
3180
+ export function formatDeploymentsList(deployments) {
3181
+ if (deployments.length === 0) {
3182
+ return ["No deployments."];
3183
+ }
3184
+ const header = ["ID", "VERSION", "STATUS", "CREATED"];
3185
+ const rows = deployments.map((deployment) => [deployment.id, `v${deployment.version}`, deploymentStatusLabel(deployment), deployment.createdAt]);
3186
+ const widths = header.map((title, column) => Math.max(title.length, ...rows.map((row) => (row[column] ?? "").length)));
3187
+ const render = (cells) => cells.map((cell, column) => (column === cells.length - 1 ? cell : cell.padEnd(widths[column] ?? 0))).join(" ");
3188
+ const lines = [render(header), ...rows.map(render), ""];
3189
+ const serving = deployments.find((deployment) => deployment.active);
3190
+ lines.push(serving ? `Serving: v${serving.version} (${serving.id}).` : "No deployment is serving this app.");
3191
+ if (deployments.some((deployment) => !deployment.active && deployment.status === "active")) {
3192
+ lines.push('Rows marked "active, not serving" are earlier deploys that finished; they no longer receive traffic.');
3193
+ }
3194
+ return lines;
3195
+ }
3196
+ export function deploymentsListJson(deployments) {
3197
+ return {
3198
+ servingDeploymentId: deployments.find((deployment) => deployment.active)?.id ?? null,
3199
+ deployments: deployments.map((deployment) => ({ ...deployment, serving: deployment.active })),
3200
+ };
3201
+ }
3119
3202
  async function runDeploymentsCommand(command) {
3120
3203
  const base = { apiUrl: command.apiUrl, token: command.token };
3121
3204
  const action = command.action;
3122
3205
  if (action.kind === "ls") {
3123
3206
  const result = await apiRequest(base, `/v1/sites/${encodeURIComponent(action.site)}/deployments`, { method: "GET" });
3124
3207
  if (action.json) {
3125
- console.log(JSON.stringify(result, null, 2));
3208
+ console.log(JSON.stringify({ ...result, ...deploymentsListJson(result.deployments) }, null, 2));
3126
3209
  return;
3127
3210
  }
3128
- if (result.deployments.length === 0) {
3129
- console.log("No deployments.");
3130
- return;
3131
- }
3132
- for (const deployment of result.deployments) {
3133
- console.log(`${deployment.active ? "*" : " "} ${deployment.id}\tv${deployment.version}\t${deployment.status}\t${deployment.createdAt}`);
3211
+ for (const line of formatDeploymentsList(result.deployments)) {
3212
+ console.log(line);
3134
3213
  }
3135
3214
  return;
3136
3215
  }
@@ -3139,6 +3218,516 @@ async function runDeploymentsCommand(command) {
3139
3218
  });
3140
3219
  console.log(`Deleted deployment ${result.deleted}.`);
3141
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
+ }
3142
3731
  // #807 step 5: the claim behind a health verdict, in words. A bare `unknown` reads
3143
3732
  // as "fine"; the reason says which statement the platform is actually making.
3144
3733
  export function healthWithReason(status, reason) {
@@ -3280,7 +3869,7 @@ function parseAccessArgs(argv) {
3280
3869
  throw new UsageError();
3281
3870
  }
3282
3871
  const flags = parseFlags(argv);
3283
- const base = readBaseApiOptions(flags);
3872
+ const base = { ...readBaseApiOptions(flags), apiUrlFrom: apiUrlSource(flags) };
3284
3873
  const siteFrom = (index) => {
3285
3874
  const site = argv[index];
3286
3875
  if (!site || site.startsWith("--")) {
@@ -3291,16 +3880,17 @@ function parseAccessArgs(argv) {
3291
3880
  if (subcommand === "show") {
3292
3881
  return { kind: "access", ...base, action: { kind: "show", site: siteFrom(1) } };
3293
3882
  }
3883
+ const switchMode = flags.has("switch-mode") ? { switchMode: true } : {};
3294
3884
  if (subcommand === "public") {
3295
- return { kind: "access", ...base, action: { kind: "public", site: siteFrom(1) } };
3885
+ return { kind: "access", ...base, action: { kind: "public", site: siteFrom(1), ...switchMode } };
3296
3886
  }
3297
3887
  if (subcommand === "otp") {
3298
- return { kind: "access", ...base, action: { kind: "otp", site: siteFrom(1) } };
3888
+ return { kind: "access", ...base, action: { kind: "otp", site: siteFrom(1), ...switchMode } };
3299
3889
  }
3300
3890
  if (subcommand === "idp") {
3301
3891
  const site = siteFrom(1);
3302
3892
  const providers = readRepeatedStringFlag(argv, "provider").map(parseProviderKey);
3303
- return { kind: "access", ...base, action: { kind: "idp", site, ...(providers.length ? { providers } : {}) } };
3893
+ return { kind: "access", ...base, action: { kind: "idp", site, ...(providers.length ? { providers } : {}), ...switchMode } };
3304
3894
  }
3305
3895
  if (subcommand === "service") {
3306
3896
  const verb = argv[1];
@@ -3308,7 +3898,11 @@ function parseAccessArgs(argv) {
3308
3898
  if (verb === "create") {
3309
3899
  const name = readStringFlag(flags, "name");
3310
3900
  const duration = readStringFlag(flags, "duration");
3311
- return { kind: "access", ...base, action: { kind: "service-create", site, ...(name ? { name } : {}), ...(duration ? { duration } : {}) } };
3901
+ return {
3902
+ kind: "access",
3903
+ ...base,
3904
+ action: { kind: "service-create", site, ...(name ? { name } : {}), ...(duration ? { duration } : {}), ...switchMode },
3905
+ };
3312
3906
  }
3313
3907
  if (verb === "revoke") {
3314
3908
  const tokenId = argv[3];
@@ -3420,6 +4014,11 @@ function parseSupportArgs(argv) {
3420
4014
  ...readBaseApiOptions(flags),
3421
4015
  };
3422
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
+ }
3423
4022
  function readApiUrl(flags, fallback) {
3424
4023
  return validateApiUrl(readStringFlag(flags, "api-url") ?? process.env.NINEPM_API_URL ?? fallback);
3425
4024
  }
@@ -4428,6 +5027,22 @@ function formatRemainingForCli(value) {
4428
5027
  }
4429
5028
  export class UsageError extends Error {
4430
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
+ }
4431
5046
  async function main() {
4432
5047
  try {
4433
5048
  const command = parseArgs(process.argv.slice(2));
@@ -4508,6 +5123,10 @@ async function main() {
4508
5123
  if (queued) {
4509
5124
  console.log(`Status: ${result.deployment.status}. Run 9pm apps to check when it becomes active.`);
4510
5125
  }
5126
+ const missingPages = missingPagesLine(result.deployment);
5127
+ if (missingPages) {
5128
+ console.log(missingPages);
5129
+ }
4511
5130
  console.log(`Manage: ${dashboardUrlForApiUrl(command.options.apiUrl)}${command.options.owner ? `?owner=${encodeURIComponent(command.options.owner)}` : ""}`);
4512
5131
  // Gated the same way as the reachability probe above: a programmatic
4513
5132
  // caller injecting fetchFn opted out of the CLI's own network side trips.
@@ -4700,6 +5319,9 @@ async function main() {
4700
5319
  return;
4701
5320
  }
4702
5321
  if (command.kind === "delete") {
5322
+ for (const line of deleteWarningLines(command.confirmSlug)) {
5323
+ console.warn(line);
5324
+ }
4703
5325
  const deleted = await deleteSite({
4704
5326
  apiUrl: command.apiUrl,
4705
5327
  token: command.token,
@@ -4752,6 +5374,10 @@ async function main() {
4752
5374
  await runDeploymentsCommand(command);
4753
5375
  return;
4754
5376
  }
5377
+ if (command.kind === "domains") {
5378
+ await runDomainsCommand(command);
5379
+ return;
5380
+ }
4755
5381
  if (command.kind === "logs") {
4756
5382
  await runLogsCommand(command);
4757
5383
  return;
@@ -4834,25 +5460,66 @@ async function runSupportCommand(command) {
4834
5460
  });
4835
5461
  console.log("Support request sent. A human will reply to your account email.");
4836
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
+ }
4837
5503
  async function runAccessCommand(command) {
4838
5504
  const base = { apiUrl: command.apiUrl, token: command.token };
5505
+ const target = { apiUrl: command.apiUrl, apiUrlFrom: command.apiUrlFrom };
4839
5506
  const action = command.action;
4840
5507
  if (action.kind === "show") {
4841
5508
  console.log(JSON.stringify(await getSiteAccess({ ...base, site: action.site }), null, 2));
4842
5509
  return;
4843
5510
  }
4844
5511
  if (action.kind === "public") {
4845
- const access = await setSiteAccessPublic({ ...base, site: action.site });
5512
+ const access = await withSwitchModeRerun(action, target, () => setSiteAccessPublic({ ...base, site: action.site, ...switchModeField(action) }));
4846
5513
  console.log(`Access for ${action.site} set to ${access.mode}.`);
4847
5514
  return;
4848
5515
  }
4849
5516
  if (action.kind === "otp") {
4850
- const access = await setSiteAccessOtp({ ...base, site: action.site });
5517
+ const access = await withSwitchModeRerun(action, target, () => setSiteAccessOtp({ ...base, site: action.site, ...switchModeField(action) }));
4851
5518
  console.log(`Access for ${action.site} set to ${access.mode}.`);
4852
5519
  return;
4853
5520
  }
4854
5521
  if (action.kind === "idp") {
4855
- 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) }));
4856
5523
  console.log(`Access for ${action.site} set to ${access.mode}.`);
4857
5524
  return;
4858
5525
  }
@@ -4867,12 +5534,13 @@ async function runAccessCommand(command) {
4867
5534
  return;
4868
5535
  }
4869
5536
  if (action.kind === "service-create") {
4870
- const created = await createAccessServiceToken({
5537
+ const created = await withSwitchModeRerun(action, target, () => createAccessServiceToken({
4871
5538
  ...base,
4872
5539
  site: action.site,
4873
5540
  ...(action.name ? { name: action.name } : {}),
4874
5541
  ...(action.duration ? { duration: action.duration } : {}),
4875
- });
5542
+ ...switchModeField(action),
5543
+ }));
4876
5544
  console.log(`Created service token ${created.serviceToken.id} for ${action.site}.`);
4877
5545
  console.log(`CF-Access-Client-Id: ${created.serviceToken.clientId}`);
4878
5546
  console.log(`CF-Access-Client-Secret: ${created.clientSecret}`);
@@ -5023,8 +5691,8 @@ async function runFilesCommand(command) {
5023
5691
  function printUsage() {
5024
5692
  console.log(`Usage:
5025
5693
  9pm --version
5026
- 9pm deploy [directory] --name <site-name> [--slug <slug>] [--owner <organisation>] [--description "<note>"] [--with-db] [--with-auth] [--with-email]
5027
- 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]
5028
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>]
5029
5697
  9pm deploy [directory] framework projects (Next.js, Vite) are detected and built automatically; --no-build skips
5030
5698
  9pm deploy [directory] --bundle <dir> deploy a pre-built Cloudflare framework bundle (e.g. .open-next)
@@ -5048,15 +5716,22 @@ function printUsage() {
5048
5716
  9pm deployments rm <deployment-id>
5049
5717
  9pm logs <app> [--lines <n>] [--json]
5050
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>
5051
5726
  9pm access show <site>
5052
- 9pm access public <site>
5053
- 9pm access otp <site>
5054
- 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]
5055
5730
  9pm access allow email <site> <email>
5056
5731
  9pm access allow domain <site> <domain>
5057
5732
  9pm access revoke email <site> <email>
5058
5733
  9pm access revoke domain <site> <domain>
5059
- 9pm access service create <site> [--name <name>] [--duration 8760h]
5734
+ 9pm access service create <site> [--name <name>] [--duration 8760h] [--switch-mode]
5060
5735
  9pm access service revoke <site> <service-token-id>
5061
5736
  9pm access token create <site> [--name <name>]
5062
5737
  9pm access token revoke <site> <token-id>
@@ -5087,6 +5762,31 @@ Worker public files:
5087
5762
  change it. A worker app that never chose serves EVERY file except its entry publicly, and
5088
5763
  each deploy warns about it. --check lists the files a deploy would make public.
5089
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
+
5090
5790
  Container deploys:
5091
5791
  Requires Docker locally. The CLI builds linux/amd64, exports the image, uploads it through a short-lived session, then finalizes a queued deployment.
5092
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.