ninepm 0.85.0 → 0.92.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
@@ -16,7 +16,7 @@ import { managedHostnamesIn, siblingCallWarning, siblingsOf } from "./sibling-ho
16
16
  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";
17
17
  import { guessZoneApex, relativeRecordName, zoneApexFor } from "./dns-host.js";
18
18
  import { dnsProviderProfile, parkingRecordFor, recordAtProvider } from "./dns-providers.js";
19
- import { cacheMinutes, deriveDomainStatus, domainRecordStatuses, showsUseWww, } from "./domain-state.js";
19
+ import { cacheMinutes, deriveDomainStatus, domainRecordStatuses, showsAuthoritativeAnswer, showsUseWww, } from "./domain-state.js";
20
20
  import { buildFileManifest, readManifestFile } from "./manifest.js";
21
21
  import { NO_PUBLIC_ASSETS, normalizeAssetsDirectory, privateLookingPaths, publicAssetPath } from "./public-assets.js";
22
22
  import { API_KEY_HELP, API_URL_HELP, CLI_CLIENT_ID, DEFAULT_API_URL, reportCliError } from "./cli-config.js";
@@ -73,13 +73,24 @@ export function deletedDomainLines(deleted, options = {}) {
73
73
  for (const hostname of releasing) {
74
74
  lines.push(`${hostname} has stopped serving, but removing it upstream is still finishing; it finishes on its own. ${releaseNoCleanupLine(options)}`);
75
75
  }
76
+ // The consequence is said for the domains whose removal finished; one still releasing is
77
+ // told to leave its records alone, which repointing them would contradict.
78
+ const finished = [];
79
+ const names = Array.isArray(deleted.customDomainNames) ? deleted.customDomainNames : [];
80
+ for (const hostname of [...names, ...cleanups.map((cleanup) => cleanup?.hostname)]) {
81
+ if (typeof hostname !== "string" || hostname === "" || releasing.includes(hostname) || finished.includes(hostname))
82
+ continue;
83
+ finished.push(hostname);
84
+ }
85
+ if (finished.length > 0)
86
+ lines.push(...domainRemovedConsequenceLines(finished, options));
76
87
  }
77
88
  catch {
78
89
  return lines;
79
90
  }
80
91
  return lines;
81
92
  }
82
- export const CLI_VERSION = "0.85.0";
93
+ export const CLI_VERSION = "0.92.0";
83
94
  const PUBLIC_DASHBOARD_URL = "https://9pm.ai/dashboard";
84
95
  const TOKEN_SERVICE = "9pm";
85
96
  const TOKEN_ACCOUNT = "default";
@@ -254,8 +265,8 @@ export class DeployError extends Error {
254
265
  // DeployError, which would otherwise erase the distinction before the command
255
266
  // that needs it ever sees the error.
256
267
  deliberate;
257
- constructor(message, slug, deliberate = false) {
258
- super(message);
268
+ constructor(message, slug, deliberate = false, cause) {
269
+ super(message, cause === undefined ? undefined : { cause });
259
270
  this.name = "DeployError";
260
271
  this.slug = slug;
261
272
  this.deliberate = deliberate;
@@ -349,7 +360,7 @@ export async function deployDirectory(initialOptions) {
349
360
  }
350
361
  if (error instanceof DeployError)
351
362
  throw error;
352
- throw new DeployError(error instanceof Error ? error.message : String(error), slugHolder.slug, isDeliberateRefusal(error));
363
+ throw new DeployError(error instanceof Error ? error.message : String(error), slugHolder.slug, isDeliberateRefusal(error), error);
353
364
  }
354
365
  }
355
366
  // Cheap unauthenticated probe of the control-plane API before a deploy. The
@@ -950,8 +961,19 @@ export async function whoami(options) {
950
961
  return apiRequest(options, "/v1/me", { method: "GET" });
951
962
  }
952
963
  export function publicWhoamiResult(result) {
964
+ if (result.restricted === true) {
965
+ return {
966
+ user: result.user,
967
+ restricted: true,
968
+ preset: result.apiKey?.preset ?? "unknown",
969
+ ...(result.apiKey?.prefix ? { apiKey: { prefix: result.apiKey.prefix } } : {}),
970
+ account: { credentialExpiresAt: result.account?.tokenExpiresAt ?? result.apiKey?.expiresAt ?? null },
971
+ };
972
+ }
953
973
  return {
954
974
  user: result.user,
975
+ restricted: false,
976
+ preset: "full",
955
977
  account: {
956
978
  ...(result.account?.org ? { org: result.account.org } : {}),
957
979
  ...(result.account?.plan ? { plan: result.account.plan } : {}),
@@ -961,10 +983,31 @@ export function publicWhoamiResult(result) {
961
983
  ...(typeof result.account?.storageQuotaBytes === "number" ? { storageQuotaBytes: result.account.storageQuotaBytes } : {}),
962
984
  ...(typeof result.account?.fileStorageUsedBytes === "number" ? { fileStorageUsedBytes: result.account.fileStorageUsedBytes } : {}),
963
985
  ...(typeof result.account?.fileStorageQuotaBytes === "number" ? { fileStorageQuotaBytes: result.account.fileStorageQuotaBytes } : {}),
964
- credentialExpiresAt: result.account?.tokenExpiresAt ?? result.apiKey.expiresAt ?? null,
986
+ credentialExpiresAt: result.account?.tokenExpiresAt ?? result.apiKey?.expiresAt ?? null,
965
987
  },
966
988
  };
967
989
  }
990
+ function credentialExpiryLine(me) {
991
+ return `Credential: ${me.account?.credentialExpiresAt ? `expires ${me.account.credentialExpiresAt}` : "active"}`;
992
+ }
993
+ // A scoped key is not shown the account's plan or usage, so it gets its own identity
994
+ // lines instead of "Plan: unknown" and an empty storage meter.
995
+ function scopedIdentityLine(me) {
996
+ return `Key: scoped API key ${me.apiKey?.prefix ? `${me.apiKey.prefix}... ` : ""}(${me.preset ?? "unknown"} preset)`;
997
+ }
998
+ export const SCOPED_WHOAMI_NOTE = "Plan, apps and storage are shown to a full-access login only.";
999
+ export function whoamiLines(me) {
1000
+ if (me.restricted) {
1001
+ return [`User: ${me.user.email}`, scopedIdentityLine(me), credentialExpiryLine(me), SCOPED_WHOAMI_NOTE];
1002
+ }
1003
+ return [
1004
+ `User: ${me.user.email}`,
1005
+ `Plan: ${me.account?.plan ?? "unknown"}`,
1006
+ `Sites: ${me.account?.sitesUsed ?? "unknown"} / ${me.account?.sitesQuota ?? "unknown"}`,
1007
+ `Storage: ${formatBytesForCli(me.account?.storageUsedBytes ?? me.account?.fileStorageUsedBytes ?? 0)} / ${formatBytesForCli(me.account?.storageQuotaBytes ?? me.account?.fileStorageQuotaBytes ?? 0)}`,
1008
+ credentialExpiryLine(me),
1009
+ ];
1010
+ }
968
1011
  // How long each revocation request may take before logout gives up and reports
969
1012
  // the honest failure instead of hanging (a black-holed connection accepts TCP
970
1013
  // and never answers). Longer than the 5s reachability probe because this
@@ -1101,6 +1144,12 @@ async function responseErrorCode(response) {
1101
1144
  return "";
1102
1145
  }
1103
1146
  }
1147
+ export function doctorAccountLines(account) {
1148
+ if (account.restricted) {
1149
+ return [`Account: ${account.user.email}`, scopedIdentityLine(account), credentialExpiryLine(account)];
1150
+ }
1151
+ return [`Account: ${account.user.email}`, `Plan: ${account.account?.plan ?? "unknown"}`];
1152
+ }
1104
1153
  async function doctor(options) {
1105
1154
  const credentials = credentialDiagnostics();
1106
1155
  const token = process.env.NINEPM_API_KEY ?? readStoredCredential();
@@ -1142,8 +1191,8 @@ async function doctor(options) {
1142
1191
  console.log(`Warning: ${warning}`);
1143
1192
  }
1144
1193
  if (account) {
1145
- console.log(`Account: ${account.user.email}`);
1146
- console.log(`Plan: ${account.account?.plan ?? "unknown"}`);
1194
+ for (const line of doctorAccountLines(account))
1195
+ console.log(line);
1147
1196
  }
1148
1197
  else if (accountError) {
1149
1198
  console.log(`Account: unavailable (${accountError})`);
@@ -1442,28 +1491,47 @@ export function deleteWarningLines(slug) {
1442
1491
  "Its deployments, files and managed database (if it has one) are deleted with it and cannot be recovered.",
1443
1492
  ];
1444
1493
  }
1445
- // Printed after a delete of the one shape that was measured (#1969): an app whose namespace
1446
- // script was replaced with the deleted-app Worker, with no container and no custom domain.
1447
- // Measured from outside on 2026-10-14 (staging) and 2026-10-15 (production) for a static
1448
- // app: the old content stopped within ~20 s of starting the delete and did not come back
1449
- // over ~15 min. "About a minute" leaves margin for a single location's measurement;
1450
- // re-measure before tightening it, and measure a shape before adding it below.
1494
+ // Printed after a delete of a shape that was measured, and only those (#1969, #2120):
1495
+ // - a static app whose namespace script was replaced with the deleted-app Worker, with no
1496
+ // container and no custom domain: old content stopped within ~20 s of starting the delete
1497
+ // on 2026-10-14/15 (staging/production, from MAD), and within ~10 s on 2026-10-21 from
1498
+ // outside Madrid (staging and production, answered by ZRH, CDG, CPH, MRS, DUS, TXL, DUB);
1499
+ // - a Cloudflare-placed container app published into the namespace, whose namespace script
1500
+ // was deleted (no tombstone - its Durable Object class forbids one) and whose container
1501
+ // application teardown was queued: old content stopped within ~13 s and ~12 s on two runs on
1502
+ // 2026-10-22 (staging), and did not come back over 30 and 20 min while the queue ran.
1503
+ // Every run watched for 15 min or more afterwards. "About a minute" leaves margin; re-measure
1504
+ // before tightening it, and measure a shape before adding it below.
1451
1505
  export const DELETE_SERVING_BOUND_LINE = "Its address stops serving the app within about a minute.";
1452
1506
  // Whether DELETE_SERVING_BOUND_LINE is true of this delete. Read from what the server says it
1453
- // did, not from what the app was: the tombstone is the mechanism the measurement covers. A
1454
- // container app's teardown is queued and gets no tombstone, a custom domain stops through the
1455
- // fallback Worker, and a failed cleanup step may have left something serving - none of those
1456
- // were measured, so they get no promise. An older server sends no `workerScripts`, so neither.
1507
+ // did, not from what the app was, because the mechanisms are what the measurements cover: a
1508
+ // tombstoned namespace script for a static app, and a deleted namespace script for a
1509
+ // Cloudflare container. An untombstoned static app is NOT covered - #1969 saw such a script
1510
+ // keep serving for 45 min - which is why the untombstoned branch needs a queued container
1511
+ // teardown. Unmeasured, so promised nothing: a container on the legacy path (an account-level
1512
+ // script that was actually there to remove), a Fly container, a custom domain (it stops through
1513
+ // the fallback Worker), and any failed or skipped cleanup step. An older server sends no
1514
+ // `workerScripts`, so neither.
1457
1515
  export function deletedAppStopsServingQuickly(deleted) {
1458
- const scripts = Array.isArray(deleted.workerScripts) ? deleted.workerScripts : [];
1459
- const tombstoned = scripts.some((script) => typeof script === "object" && script !== null && script.tombstoned === true);
1516
+ const scripts = (Array.isArray(deleted.workerScripts) ? deleted.workerScripts : []).map((script) => typeof script === "object" && script !== null
1517
+ ? script
1518
+ : {});
1460
1519
  const releasing = Array.isArray(deleted.customDomainsReleasing) && deleted.customDomainsReleasing.length > 0;
1461
- return (tombstoned &&
1462
- (deleted.containerApplications ?? 0) === 0 &&
1463
- (deleted.flyTeardownsQueued ?? 0) === 0 &&
1520
+ const nothingUnmeasured = (deleted.flyTeardownsQueued ?? 0) === 0 &&
1464
1521
  deleted.customDomains === 0 &&
1465
1522
  !releasing &&
1466
- (deleted.errors ?? []).length === 0);
1523
+ (deleted.errors ?? []).length === 0;
1524
+ if (!nothingUnmeasured) {
1525
+ return false;
1526
+ }
1527
+ const containers = deleted.containerApplications ?? 0;
1528
+ if (containers === 0) {
1529
+ return scripts.some((script) => script.tombstoned === true);
1530
+ }
1531
+ const inNamespace = (script) => typeof script.dispatchNamespace === "string" && script.dispatchNamespace !== "";
1532
+ return (scripts.some((script) => inNamespace(script) && script.outcome === "removed") &&
1533
+ scripts.every((script) => script.outcome === "removed" || script.outcome === "absent") &&
1534
+ !scripts.some((script) => !inNamespace(script) && script.outcome === "removed"));
1467
1535
  }
1468
1536
  export async function deleteSite(options) {
1469
1537
  const route = `/v1/sites/${encodeURIComponent(options.site)}${options.owner ? `?owner=${encodeURIComponent(options.owner)}` : ""}`;
@@ -1801,7 +1869,7 @@ export async function retireReplacedCredentials(before, after, newToken, fetchFn
1801
1869
  /** The lines a login prints about what it retired, for stderr. Silent when nothing needs saying. */
1802
1870
  export function replacedCredentialMessages(result, loginApiUrl) {
1803
1871
  const lines = [];
1804
- const manual = "revoke it from Settings > API keys in the dashboard, or it stays valid until 90 days pass without use.";
1872
+ const manual = "log it out from Settings > CLI logins in the dashboard, or it stays valid until 90 days pass without use.";
1805
1873
  for (const outcome of result.replaced) {
1806
1874
  if (outcome.kind === "revoked") {
1807
1875
  const elsewhere = outcome.apiUrl !== normalizeApiUrl(loginApiUrl) ? ` (issued by ${outcome.apiUrl})` : "";
@@ -2512,7 +2580,27 @@ async function expectOk(response, action) {
2512
2580
  if (code && message && DELIBERATE_REFUSAL_CODES.has(code)) {
2513
2581
  throw new DeliberateRefusalError(message, code);
2514
2582
  }
2515
- throw new Error(`Failed to ${action}: ${response.status} ${text}`);
2583
+ throw new ApiResponseError(`Failed to ${action}: ${response.status} ${text}`, response.status, code || null, message || null, envelope?.details);
2584
+ }
2585
+ /**
2586
+ * A non-2xx answer from the control plane, with the parts of its envelope a caller may
2587
+ * act on. The message keeps the raw form expectOk always printed; `serverMessage` is the
2588
+ * sentence the server wrote, for commands that print that alone.
2589
+ */
2590
+ export class ApiResponseError extends Error {
2591
+ status;
2592
+ code;
2593
+ serverMessage;
2594
+ details;
2595
+ constructor(message, status, code, serverMessage, details) {
2596
+ // No `name` of its own: anything that renders an error as `String(error)` keeps
2597
+ // reading "Error: Failed to ..." exactly as before this class existed.
2598
+ super(message);
2599
+ this.status = status;
2600
+ this.code = code;
2601
+ this.serverMessage = serverMessage;
2602
+ this.details = details;
2603
+ }
2516
2604
  }
2517
2605
  // Takes the already-consumed body text rather than the Response: a body can only
2518
2606
  // be read once, and expectOk needs the code and the message from the same read.
@@ -2770,6 +2858,9 @@ export function parseArgs(argv) {
2770
2858
  if (command === "api-key" || command === "api-keys") {
2771
2859
  return parseApiKeyArgs(argv.slice(1));
2772
2860
  }
2861
+ if (command === "automation" || command === "automations") {
2862
+ return parseAutomationArgs(argv.slice(1));
2863
+ }
2773
2864
  if (command !== "deploy") {
2774
2865
  throw new Error(`Unknown command: ${command}`);
2775
2866
  }
@@ -3328,9 +3419,11 @@ function isRemoving(domain) {
3328
3419
  }
3329
3420
  const DOMAIN_STATE_LABELS = {
3330
3421
  live: "live",
3422
+ ready_to_switch: "ready to switch: the platform can serve it, but its DNS still points elsewhere",
3331
3423
  waiting_for_dns: "waiting for DNS records",
3332
3424
  dns_unverified: "couldn't check the DNS records just now",
3333
3425
  issuing_certificate: "DNS records found, waiting for the platform to issue the certificate",
3426
+ points_elsewhere: "points elsewhere: its DNS now leads to another host",
3334
3427
  failed: "failed",
3335
3428
  expired: "expired",
3336
3429
  };
@@ -3450,6 +3543,24 @@ function recordStateText(record, routing, parking = null) {
3450
3543
  const also = others.map((answer) => `; ${answer.resolver} answers ${answerText(answer.current)}`).join("");
3451
3544
  return `currently ${shown}${also}${recent}: point it at the value shown${parkingText(parking)}`;
3452
3545
  }
3546
+ // #2241: what the DNS host's own nameserver answered, beside the resolvers' state and never in
3547
+ // place of it: the state text above is unchanged whatever this says. A failed read is "unable to
3548
+ // verify", never "nothing published".
3549
+ export function authoritativeText(record) {
3550
+ if (!showsAuthoritativeAnswer(record))
3551
+ return null;
3552
+ const direct = record.authoritative;
3553
+ if (direct.outcome !== "answered")
3554
+ return "your DNS host: unable to verify directly";
3555
+ const from = ` (read directly from ${direct.nameserver})`;
3556
+ if (direct.matches)
3557
+ return `your DNS host already publishes the value shown${from}; the public resolvers have not caught up yet, so check again later`;
3558
+ return `your DNS host publishes ${answerText(direct.current ?? [])}${from}`;
3559
+ }
3560
+ function withAuthoritativeText(record, state) {
3561
+ const direct = authoritativeText(record);
3562
+ return direct ? `${state}. ${direct.charAt(0).toUpperCase()}${direct.slice(1)}` : state;
3563
+ }
3453
3564
  // Said only when the check saw the host's documented parking value (parkingRecordFor), and
3454
3565
  // conditionally even then: the record may have been changed since the check.
3455
3566
  function parkingText(parking) {
@@ -3526,6 +3637,12 @@ export function formatDomainStatus(status, options = {}) {
3526
3637
  lines.push(`Add it again: ${domainCommand("add", status.app, status.hostname, options)}`);
3527
3638
  return lines;
3528
3639
  }
3640
+ if (status.state === "ready_to_switch" || status.state === "points_elsewhere") {
3641
+ return [...lines, ...domainMoveLines(status, options)];
3642
+ }
3643
+ if (status.state === "failed" && status.reason?.code === "moved_needs_restart") {
3644
+ return [...lines, ...movedRestartLines(status, options)];
3645
+ }
3529
3646
  if (status.state === "failed" && status.error) {
3530
3647
  lines.push(status.error);
3531
3648
  }
@@ -3562,7 +3679,7 @@ export function formatDomainStatus(status, options = {}) {
3562
3679
  record.apex && !profile && record.typeAtProvider === "ALIAS" ? "ALIAS / ANAME" : (record.typeAtProvider ?? "-"),
3563
3680
  record.nameAtProvider === "" ? "(leave empty)" : record.nameAtProvider,
3564
3681
  record.valueAtProvider,
3565
- recordStateText(record, status.routing, profile ? parkingRecordFor(status.providerId, record) : null),
3682
+ withAuthoritativeText(record, recordStateText(record, status.routing, profile ? parkingRecordFor(status.providerId, record) : null)),
3566
3683
  ]),
3567
3684
  ];
3568
3685
  lines.push(...renderColumns(rows).map((line) => ` ${line}`));
@@ -3631,10 +3748,63 @@ export function formatDomainStatus(status, options = {}) {
3631
3748
  lines.push(`Then run: ${domainCommand("wait", status.app, status.hostname, options)}`);
3632
3749
  return lines;
3633
3750
  }
3751
+ // The two ends of a move between hosts (#2238). Only the routing record is named: every other
3752
+ // record was what got routing and the certificate this far, and is not what is in the way.
3753
+ function domainMoveLines(status, options) {
3754
+ const routing = status.records.find((record) => !record.optional && record.type !== "TXT" && record.check === "different");
3755
+ const name = routing ? (routing.nameAtProvider === "" ? "the root" : (routing.nameAtProvider ?? routing.name)) : status.hostname;
3756
+ const type = routing ? (routing.typeAtProvider ?? routing.type) : "CNAME";
3757
+ const value = routing ? routing.valueAtProvider : null;
3758
+ const leadsTo = routing?.elsewhere ?? routing?.current?.join(", ") ?? null;
3759
+ const at = status.provider ? ` at ${status.provider}` : " at your DNS host";
3760
+ if (status.state === "ready_to_switch") {
3761
+ const lines = [
3762
+ `The platform is ready to serve ${status.hostname}: routing and the certificate are active. But visitors still reach another host, because the ${type} record ${name}${leadsTo ? ` leads to ${leadsTo}` : " points elsewhere"}.`,
3763
+ ];
3764
+ if (value)
3765
+ lines.push(`To serve it from here, change ${type} ${name} to ${value}${at}.`);
3766
+ if (typeof routing?.ttlSeconds === "number") {
3767
+ lines.push(`Public caches may keep sending visitors to the other host${forUpTo(routing.ttlSeconds)} after you change it.`);
3768
+ }
3769
+ lines.push(`Then run: ${domainCommand("wait", status.app, status.hostname, options)}`);
3770
+ return lines;
3771
+ }
3772
+ const lines = [
3773
+ `${status.hostname} now points elsewhere${leadsTo ? `: the ${type} record ${name} leads to ${leadsTo}` : ""}, so the platform has stopped serving it here.`,
3774
+ `If you moved it to another host on purpose, remove it here once visitors reach the new host: ${domainCommand("remove", status.app, status.hostname, options)}`,
3775
+ ];
3776
+ // Pointing the record back is not enough on its own: Cloudflare keeps a moved hostname moved
3777
+ // until its validation is restarted, which is what `restart` sends (a PATCH of the hostname).
3778
+ // A Re-check only reads it again, so it is not offered here.
3779
+ if (value) {
3780
+ lines.push(`If you didn't mean to move it: change ${type} ${name} back to ${value}${at}, then start validation over: ${domainCommand("restart", status.app, status.hostname, options)}`);
3781
+ lines.push(`That may print new TXT values: publish them as shown, then run: ${domainCommand("wait", status.app, status.hostname, options)}. The provider deletes a hostname that stays moved for 7 days.`);
3782
+ }
3783
+ return lines;
3784
+ }
3785
+ // A hostname the provider reports moved whose DNS no longer provably leads elsewhere (#2238):
3786
+ // pointed back, or not readable. Cloudflare keeps it moved until a restart's PATCH, so the restart
3787
+ // is the step, and a Re-check (a read) is not offered.
3788
+ function movedRestartLines(status, options) {
3789
+ const routing = status.records.find((record) => !record.optional && record.type !== "TXT");
3790
+ const name = routing ? (routing.nameAtProvider === "" ? "the root" : (routing.nameAtProvider ?? routing.name)) : status.hostname;
3791
+ const type = routing ? (routing.typeAtProvider ?? routing.type) : "CNAME";
3792
+ const first = routing?.ok === true
3793
+ ? `${status.hostname}'s DNS points back at the platform, but the provider still has it disconnected after the move. Restart validation to reconnect it:`
3794
+ : `The provider stopped routing ${status.hostname} because its DNS moved away. Make sure ${type} ${name} points to ${routing?.valueAtProvider ?? "the value shown"}, then restart validation to reconnect it:`;
3795
+ return [
3796
+ first,
3797
+ ` ${domainCommand("restart", status.app, status.hostname, options)}`,
3798
+ `That may print new TXT values: publish them as shown, then run: ${domainCommand("wait", status.app, status.hostname, options)}. The provider deletes a hostname that stays moved for 7 days.`,
3799
+ ];
3800
+ }
3634
3801
  // What a timed-out wait says. "Missing" is claimed only for a record the check actually saw
3635
3802
  // absent or different; a record nobody could read is reported as unchecked, whatever the
3636
3803
  // routing status, because telling the owner to publish what may already be there misleads.
3637
3804
  export function domainWaitTimeoutMessage(status) {
3805
+ if (status.state === "ready_to_switch") {
3806
+ return "Timed out: the platform is ready, but the routing record above still leads to another host. Change it as shown above, then wait again.";
3807
+ }
3638
3808
  if (status.next?.action === "use_www") {
3639
3809
  return `Stopped waiting: no record at the root can finish ${status.hostname} at its DNS host. Connect ${status.next.hostname ?? `www.${status.hostname}`} as shown above, then wait on that instead.`;
3640
3810
  }
@@ -3875,6 +4045,25 @@ export function setupCleanupLines(hostname, records) {
3875
4045
  "If one of these names now holds a different value, that record is not ours: leave it. Keep the record that points the domain at 9pm.",
3876
4046
  ];
3877
4047
  }
4048
+ // The public guide's section on moving a domain off 9pm, on the site beside the control plane
4049
+ // this command talked to, so a staging run links staging's copy of the guide.
4050
+ export function customDomainMoveOffGuideUrl(options = {}) {
4051
+ const dashboard = options.target ? dashboardUrlForApiUrl(options.target.apiUrl) : PUBLIC_DASHBOARD_URL;
4052
+ return `${dashboard.replace(/\/dashboard$/, "")}/docs/custom-domains#move-off`;
4053
+ }
4054
+ // Said once a removal has finished (#2089): 9pm no longer holds the hostname, so while DNS
4055
+ // still points here a visitor reaches nothing that can answer for it. Which error they see is
4056
+ // not ours to promise. It names no record: what may be deleted is only the cleanup list above
4057
+ // it, which carries its own value condition, so this adds no deletion advice of its own. The
4058
+ // order is the one the staging rehearsal demonstrated for a move to another host (#2182,
4059
+ // scenario 2). A move to the owner's own Cloudflare zone was not rehearsed, so it gets no order.
4060
+ export function domainRemovedConsequenceLines(hostnames, options = {}) {
4061
+ const subject = hostnames.length === 1 ? hostnames[0] : hostnames.join(", ");
4062
+ return [
4063
+ `After removal, visitors may see a TLS or routing error while DNS for ${subject} still points at 9pm.`,
4064
+ `Moving a domain to another host? Point its DNS at the new host, wait for the DNS TTL to pass, then remove the domain from 9pm. While caches update, some visitors may still reach 9pm, or briefly see an error from the new host until it is set up for the name. Once DNS points away, 9pm shows the domain as pointing elsewhere (or, before an update, as failed); that is expected, so remove it. Guide: ${customDomainMoveOffGuideUrl(options)}`,
4065
+ ];
4066
+ }
3878
4067
  // The command that lists what a removal finished in the background frees (#2068), aimed at the
3879
4068
  // same account and control plane as the command that printed it. Never a token.
3880
4069
  function domainsRemovedCommand(options, hostname) {
@@ -4163,7 +4352,7 @@ export function deployCustomDomainLines(statuses, slug, owner, target) {
4163
4352
  return `Custom domain ${status.hostname} is being removed.`;
4164
4353
  if (status.state === "live")
4165
4354
  return `Also serving at https://${status.hostname}`;
4166
- const notLive = status.state === "failed" || status.state === "expired" ? "is not live" : "is not live yet";
4355
+ const notLive = status.state === "failed" || status.state === "expired" || status.state === "points_elsewhere" ? "is not live" : "is not live yet";
4167
4356
  return `Custom domain ${status.hostname} ${notLive} (${domainStateLabel(status.state)}). Check it: ${domainCommand("status", slug, status.hostname, { ...(owner ? { owner } : {}), ...(target ? { target } : {}) })}`;
4168
4357
  });
4169
4358
  }
@@ -4190,6 +4379,9 @@ export async function waitForDomain(options) {
4190
4379
  // Nothing the owner can publish finishes this hostname, so polling until the timeout would only delay saying so.
4191
4380
  if (status.next?.action === "use_www")
4192
4381
  return { status, exitCode: DOMAIN_WAIT_EXIT.ownerAction, timedOut: false };
4382
+ // Its DNS leads to another host (#2238): the owner decides whether to remove it or point it back.
4383
+ if (status.state === "points_elsewhere")
4384
+ return { status, exitCode: DOMAIN_WAIT_EXIT.ownerAction, timedOut: false };
4193
4385
  }
4194
4386
  catch (error) {
4195
4387
  const transient = !(error instanceof DomainsApiError) || error.status >= 500;
@@ -4466,6 +4658,8 @@ async function runDomainsCommand(command) {
4466
4658
  for (const line of domainCleanupLines(removal.cleanup))
4467
4659
  console.log(line);
4468
4660
  }
4661
+ for (const line of domainRemovedConsequenceLines([removal.hostname], format))
4662
+ console.log(line);
4469
4663
  }
4470
4664
  async function runDomainMove(action, base, format) {
4471
4665
  const hostname = normalizeDomainArg(action.hostname);
@@ -4953,29 +5147,173 @@ function readBaseApiOptions(flags) {
4953
5147
  function readApiToken(flags) {
4954
5148
  return readStringFlag(flags, "token") ?? process.env.NINEPM_API_KEY ?? readStoredCredential();
4955
5149
  }
5150
+ const API_KEY_PRESETS = ["monitor", "deploy", "provision", "full"];
5151
+ function isHelpToken(value) {
5152
+ return value === "help" || value === "--help" || value === "-h";
5153
+ }
5154
+ // Refuses here only what has one reading whatever the account holds: a preset that does
5155
+ // not exist, a scope flag the chosen preset cannot carry, an expiry that is not a date.
5156
+ // Whether the apps or the automation exist, and whether provision keys are issued yet,
5157
+ // is the server's to answer, and its message is printed as it is.
5158
+ function parseApiKeyCreate(argv, flags) {
5159
+ const json = flags.has("json");
5160
+ const name = readStringFlag(flags, "name");
5161
+ const presetFlag = flags.get("preset");
5162
+ if (presetFlag === true) {
5163
+ throw new Error(`Missing --preset value. Use one of: ${API_KEY_PRESETS.join(", ")}.`);
5164
+ }
5165
+ if (typeof presetFlag === "string" && !API_KEY_PRESETS.includes(presetFlag)) {
5166
+ throw new Error(`Unknown preset "${presetFlag}". Use one of: ${API_KEY_PRESETS.join(", ")}.`);
5167
+ }
5168
+ const preset = presetFlag;
5169
+ if (flags.get("project") === true) {
5170
+ throw new Error("Missing --project value. Name an app by its slug or id.");
5171
+ }
5172
+ if (flags.get("automation") === true) {
5173
+ throw new Error("Missing --automation value. Name the automation by its id (9pm automation ls).");
5174
+ }
5175
+ const projects = readRepeatedStringFlag(argv, "project");
5176
+ const automation = readStringFlag(flags, "automation");
5177
+ if (projects.length > 0 && preset !== "monitor" && preset !== "deploy") {
5178
+ throw new Error(preset === "provision"
5179
+ ? "A provision key reaches the apps of one automation: pass --automation <id>, not --project."
5180
+ : "--project belongs to a scoped key: add --preset monitor or --preset deploy. A full-access key reaches every app.");
5181
+ }
5182
+ if (automation && preset !== "provision") {
5183
+ throw new Error(preset === "monitor" || preset === "deploy"
5184
+ ? `A ${preset} key lists its apps with --project, not an automation. Use --preset provision for an automation.`
5185
+ : "--automation belongs to a provision key: add --preset provision. A full-access key reaches every app.");
5186
+ }
5187
+ const expiresFlag = flags.get("expires");
5188
+ if (expiresFlag === true) {
5189
+ throw new Error(`Missing --expires value. ${API_KEY_EXPIRES_FORMS}`);
5190
+ }
5191
+ if (typeof expiresFlag === "string") {
5192
+ resolveApiKeyExpiry(expiresFlag, new Date());
5193
+ }
5194
+ return {
5195
+ kind: "create",
5196
+ ...(name ? { name } : {}),
5197
+ ...(preset ? { preset } : {}),
5198
+ projects,
5199
+ ...(automation ? { automation } : {}),
5200
+ ...(typeof expiresFlag === "string" ? { expires: expiresFlag } : {}),
5201
+ json,
5202
+ };
5203
+ }
5204
+ // A full date-time with its zone. A bare date or a zoneless time is refused rather than
5205
+ // read in some default zone, so the deadline is the one the person meant.
5206
+ const ISO_DATE_TIME = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(?::\d{2}(?:\.\d+)?)?(?:Z|[+-]\d{2}:?\d{2})$/;
5207
+ export const API_KEY_EXPIRES_FORMS = "Use an ISO 8601 date-time with its zone, such as 2026-12-31T00:00:00Z, or a relative <N>d or <N>h, such as 30d or 12h (N a positive integer).";
5208
+ /**
5209
+ * `--expires` as the ISO instant the server takes: an ISO 8601 date-time as given, or
5210
+ * `<N>d` / `<N>h` counted from `now`. How far ahead is allowed is the server's rule (a
5211
+ * year today), so only the shape is checked here.
5212
+ */
5213
+ export function resolveApiKeyExpiry(value, now) {
5214
+ const relative = /^(\d+)([dh])$/.exec(value);
5215
+ if (relative) {
5216
+ const count = Number(relative[1]);
5217
+ if (!Number.isSafeInteger(count) || count < 1) {
5218
+ throw new Error(`--expires ${value} is not a positive duration. ${API_KEY_EXPIRES_FORMS}`);
5219
+ }
5220
+ const unitMs = relative[2] === "d" ? 24 * 60 * 60 * 1000 : 60 * 60 * 1000;
5221
+ return new Date(now.getTime() + count * unitMs).toISOString();
5222
+ }
5223
+ const at = ISO_DATE_TIME.test(value) ? Date.parse(value) : Number.NaN;
5224
+ if (!Number.isFinite(at)) {
5225
+ throw new Error(`--expires ${value} is not an expiry this CLI reads. ${API_KEY_EXPIRES_FORMS}`);
5226
+ }
5227
+ return new Date(at).toISOString();
5228
+ }
4956
5229
  function parseApiKeyArgs(argv) {
4957
5230
  const subcommand = argv[0];
4958
- if (!subcommand || subcommand === "help" || subcommand === "--help") {
4959
- throw new UsageError();
5231
+ if (!subcommand || isHelpToken(subcommand) || isHelpToken(argv[1])) {
5232
+ throw new UsageError("api-key");
4960
5233
  }
4961
5234
  const flags = parseFlags(argv);
4962
- const base = readBaseApiOptions(flags);
4963
5235
  const json = flags.has("json");
4964
5236
  if (subcommand === "create") {
4965
- const name = readStringFlag(flags, "name");
4966
- return { kind: "api-key", ...base, action: { kind: "create", ...(name ? { name } : {}), json } };
5237
+ const action = parseApiKeyCreate(argv, flags);
5238
+ return { kind: "api-key", ...readBaseApiOptions(flags), action };
4967
5239
  }
4968
5240
  if (subcommand === "ls" || subcommand === "list") {
4969
- return { kind: "api-key", ...base, action: { kind: "ls", json } };
5241
+ return { kind: "api-key", ...readBaseApiOptions(flags), action: { kind: "ls", json } };
4970
5242
  }
4971
5243
  if (subcommand === "revoke") {
4972
5244
  const keyId = argv[1];
4973
5245
  if (!keyId || keyId.startsWith("--")) {
4974
5246
  throw new Error("Usage: 9pm api-key revoke <key-id>");
4975
5247
  }
4976
- return { kind: "api-key", ...base, action: { kind: "revoke", keyId } };
5248
+ return { kind: "api-key", ...readBaseApiOptions(flags), action: { kind: "revoke", keyId } };
5249
+ }
5250
+ throw new Error(`Unknown api-key subcommand: ${subcommand}. Run 9pm api-key --help.`);
5251
+ }
5252
+ // Positional arguments after the subcommand, skipping every --flag and the value it takes.
5253
+ function positionalArgs(argv, booleanFlags) {
5254
+ const values = [];
5255
+ for (let index = 1; index < argv.length; index += 1) {
5256
+ const arg = argv[index];
5257
+ if (arg === undefined)
5258
+ continue;
5259
+ if (arg.startsWith("--")) {
5260
+ const next = argv[index + 1];
5261
+ if (!booleanFlags.includes(arg.slice(2)) && next !== undefined && !next.startsWith("--"))
5262
+ index += 1;
5263
+ continue;
5264
+ }
5265
+ values.push(arg);
4977
5266
  }
4978
- throw new Error(`Unknown api-key subcommand: ${subcommand}`);
5267
+ return values;
5268
+ }
5269
+ export const AUTOMATION_RM_WARNING = "Deleting an automation revokes every API key minted for it at once. Its apps stay in your account as ordinary apps, unchanged.";
5270
+ function parseAutomationArgs(argv) {
5271
+ const subcommand = argv[0];
5272
+ if (!subcommand || isHelpToken(subcommand) || isHelpToken(argv[1])) {
5273
+ throw new UsageError("automation");
5274
+ }
5275
+ const flags = parseFlags(argv);
5276
+ const json = flags.has("json");
5277
+ const args = positionalArgs(argv, ["json", "yes"]);
5278
+ const need = (count, usage) => {
5279
+ if (args.length < count)
5280
+ throw new Error(`Usage: 9pm automation ${usage}`);
5281
+ return args;
5282
+ };
5283
+ const command = (action) => ({
5284
+ kind: "automation",
5285
+ ...readBaseApiOptions(flags),
5286
+ action,
5287
+ });
5288
+ if (subcommand === "create") {
5289
+ const [name] = need(1, "create <name>");
5290
+ return command({ kind: "create", name: name, json });
5291
+ }
5292
+ if (subcommand === "ls" || subcommand === "list") {
5293
+ return command({ kind: "ls", json });
5294
+ }
5295
+ if (subcommand === "rename") {
5296
+ const [automationId, name] = need(2, "rename <automation-id> <new-name>");
5297
+ return command({ kind: "rename", automationId: automationId, name: name, json });
5298
+ }
5299
+ if (subcommand === "rm" || subcommand === "delete") {
5300
+ const [automationId] = need(1, "rm <automation-id> --yes");
5301
+ // Like `9pm delete --confirm`, the consent is a flag rather than a prompt: agents run
5302
+ // this non-interactively, and a prompt they cannot answer would hang them.
5303
+ if (!flags.has("yes")) {
5304
+ throw new Error(`${AUTOMATION_RM_WARNING}\nRe-run with --yes to delete it: 9pm automation rm ${quoteSiteArg(automationId)} --yes`);
5305
+ }
5306
+ return command({ kind: "rm", automationId: automationId, json });
5307
+ }
5308
+ if (subcommand === "attach" || subcommand === "detach") {
5309
+ const [automationId, app] = need(2, `${subcommand} <automation-id> <app>`);
5310
+ return command({ kind: subcommand, automationId: automationId, app: app, json });
5311
+ }
5312
+ if (subcommand === "apps") {
5313
+ const [automationId] = need(1, "apps <automation-id>");
5314
+ return command({ kind: "apps", automationId: automationId, json });
5315
+ }
5316
+ throw new Error(`Unknown automation subcommand: ${subcommand}. Run 9pm automation --help.`);
4979
5317
  }
4980
5318
  function parseSupportArgs(argv) {
4981
5319
  // Normalize the -m short flag so parseFlags (which only handles --flags) sees it.
@@ -6007,6 +6345,11 @@ function formatRemainingForCli(value) {
6007
6345
  return `${Math.floor(totalSeconds / 60)}m ${totalSeconds % 60}s`;
6008
6346
  }
6009
6347
  export class UsageError extends Error {
6348
+ topic;
6349
+ constructor(topic) {
6350
+ super();
6351
+ this.topic = topic;
6352
+ }
6010
6353
  }
6011
6354
  // #1856: which missing-pages mode the deployment was published with, read from the
6012
6355
  // deployment the platform returned rather than from the flags, because a deploy without a
@@ -6024,9 +6367,23 @@ export function missingPagesLine(deployment) {
6024
6367
  return null;
6025
6368
  }
6026
6369
  }
6370
+ // The credential and control plane a parsed command talks to, for the guidance printed
6371
+ // under its error. Undefined for commands that carry no token of their own.
6372
+ function commandCredential(command) {
6373
+ if (!command)
6374
+ return {};
6375
+ if (command.kind === "deploy")
6376
+ return { token: command.options.token, apiUrl: command.options.apiUrl };
6377
+ if ("token" in command && typeof command.token === "string") {
6378
+ return { token: command.token, ...("apiUrl" in command && typeof command.apiUrl === "string" ? { apiUrl: command.apiUrl } : {}) };
6379
+ }
6380
+ return {};
6381
+ }
6027
6382
  async function main() {
6383
+ let parsed;
6028
6384
  try {
6029
6385
  const command = parseArgs(process.argv.slice(2));
6386
+ parsed = command;
6030
6387
  if (command.kind === "version") {
6031
6388
  console.log(CLI_VERSION);
6032
6389
  return;
@@ -6095,7 +6452,7 @@ async function main() {
6095
6452
  }
6096
6453
  const slug = error instanceof DeployError ? error.slug : command.options.slug;
6097
6454
  const siteFlag = slug ? ` --site ${quoteSiteArg(slug)}` : "";
6098
- throw new Error(`${base}\n\nStuck? Report it with context: 9pm support${siteFlag} -m "<what happened>"`);
6455
+ throw new Error(`${base}\n\nStuck? Report it with context: 9pm support${siteFlag} -m "<what happened>"`, { cause: error });
6099
6456
  }
6100
6457
  const databaseClause = deployDatabaseClause(result.databases);
6101
6458
  const queued = result.deployment.status === "queued" || result.deployment.status === "publishing";
@@ -6252,7 +6609,7 @@ async function main() {
6252
6609
  // path, where a pre-0.42 credential really was issued by the default
6253
6610
  // plane and really is dead — but it cannot be called confirmed.
6254
6611
  console.error(`Removed the local credential, but could not confirm it was revoked: ${summary.apiUrl} does not recognise it.`);
6255
- console.error("An older CLI stored it without recording which control plane issued it. If you used another control plane, revoke it under Connected devices there.");
6612
+ console.error("An older CLI stored it without recording which control plane issued it. If you used another control plane, log it out under CLI logins in its dashboard settings.");
6256
6613
  if (removalWarning) {
6257
6614
  console.warn(removalWarning);
6258
6615
  }
@@ -6280,8 +6637,8 @@ async function main() {
6280
6637
  // it; a staging or self-hosted key cannot be revoked there, and a
6281
6638
  // rejected URL leaves the origin unknown.
6282
6639
  const settingsHint = !command.apiUrlError && targets.every((target) => target.apiUrl === DEFAULT_API_URL)
6283
- ? `Revoke it under Connected devices: ${PUBLIC_DASHBOARD_URL}/settings`
6284
- : "Revoke it under Connected devices in its control plane's dashboard settings.";
6640
+ ? `Log it out under CLI logins: ${PUBLIC_DASHBOARD_URL}/settings`
6641
+ : "Log it out under CLI logins in its control plane's dashboard settings.";
6285
6642
  console.error(`It can still authenticate. ${settingsHint}`);
6286
6643
  process.exitCode = 1;
6287
6644
  }
@@ -6301,11 +6658,8 @@ async function main() {
6301
6658
  console.log(JSON.stringify(publicMe, null, 2));
6302
6659
  }
6303
6660
  else {
6304
- console.log(`User: ${publicMe.user.email}`);
6305
- console.log(`Plan: ${publicMe.account?.plan ?? "unknown"}`);
6306
- console.log(`Sites: ${publicMe.account?.sitesUsed ?? "unknown"} / ${publicMe.account?.sitesQuota ?? "unknown"}`);
6307
- console.log(`Storage: ${formatBytesForCli(publicMe.account?.storageUsedBytes ?? publicMe.account?.fileStorageUsedBytes ?? 0)} / ${formatBytesForCli(publicMe.account?.storageQuotaBytes ?? publicMe.account?.fileStorageQuotaBytes ?? 0)}`);
6308
- console.log(`Credential: ${publicMe.account?.credentialExpiresAt ? `expires ${publicMe.account.credentialExpiresAt}` : "active"}`);
6661
+ for (const line of whoamiLines(publicMe))
6662
+ console.log(line);
6309
6663
  }
6310
6664
  return;
6311
6665
  }
@@ -6393,11 +6747,20 @@ async function main() {
6393
6747
  await runApiKeyCommand(command);
6394
6748
  return;
6395
6749
  }
6750
+ if (command.kind === "automation") {
6751
+ await runAutomationCommand(command);
6752
+ return;
6753
+ }
6396
6754
  await runAccessCommand(command);
6397
6755
  }
6398
6756
  catch (error) {
6399
6757
  if (error instanceof UsageError) {
6400
- printUsage();
6758
+ if (error.topic) {
6759
+ console.log(usageForTopic(error.topic));
6760
+ }
6761
+ else {
6762
+ printUsage();
6763
+ }
6401
6764
  process.exitCode = 0;
6402
6765
  return;
6403
6766
  }
@@ -6409,44 +6772,371 @@ async function main() {
6409
6772
  return;
6410
6773
  }
6411
6774
  reportCliError(error);
6775
+ for (const line of scopedKeyGuidance(error, commandCredential(parsed))) {
6776
+ console.error(line);
6777
+ }
6412
6778
  process.exitCode = 1;
6413
6779
  }
6414
6780
  }
6415
- async function runApiKeyCommand(command) {
6416
- const base = { apiUrl: command.apiUrl, token: command.token };
6781
+ // The new account commands print the server's own sentence on a refusal, not the raw
6782
+ // envelope: their refusals (a taken name, an app in another automation, provision keys
6783
+ // not issued yet) are written for the person and are the whole answer.
6784
+ async function accountRequest(options, route, init) {
6785
+ try {
6786
+ return await apiRequest(options, route, init);
6787
+ }
6788
+ catch (error) {
6789
+ if (error instanceof ApiResponseError && error.serverMessage) {
6790
+ throw new ApiResponseError(error.serverMessage, error.status, error.code, error.serverMessage, error.details);
6791
+ }
6792
+ throw error;
6793
+ }
6794
+ }
6795
+ export function apiKeyPresetLabel(key) {
6796
+ return key.scope ? key.scope.preset : "full";
6797
+ }
6798
+ function apiKeyReach(key) {
6799
+ const scope = key.scope;
6800
+ if (!scope)
6801
+ return "all apps";
6802
+ if (scope.automationId)
6803
+ return `automation ${scope.automationId}`;
6804
+ if (scope.projectIds && scope.projectIds.length > 0)
6805
+ return `apps ${scope.projectIds.join(",")}`;
6806
+ // A control plane from before key lists named a key's apps leaves them out; Settings shows them.
6807
+ return "selected apps (see Settings)";
6808
+ }
6809
+ // The sooner of a key's two deadlines: a fixed expiry it was minted with, and the
6810
+ // sliding 90-days-without-use one.
6811
+ function apiKeyExpiryLabel(key) {
6812
+ const fixed = key.expiresAt ? Date.parse(key.expiresAt) : Number.NaN;
6813
+ const idle = key.inactivityExpiresAt ? Date.parse(key.inactivityExpiresAt) : Number.NaN;
6814
+ if (Number.isFinite(fixed) && (!Number.isFinite(idle) || fixed <= idle))
6815
+ return `expires ${key.expiresAt.slice(0, 10)}`;
6816
+ if (Number.isFinite(idle))
6817
+ return `lapses ${key.inactivityExpiresAt.slice(0, 10)} unless used`;
6818
+ return "lapses after 90 days without use";
6819
+ }
6820
+ export function apiKeyListLine(key) {
6821
+ return [
6822
+ key.id,
6823
+ key.name,
6824
+ `${key.prefix}...`,
6825
+ apiKeyPresetLabel(key),
6826
+ apiKeyReach(key),
6827
+ apiKeyExpiryLabel(key),
6828
+ `created ${key.createdAt.slice(0, 10)}`,
6829
+ `last used ${key.lastUsedAt ? key.lastUsedAt.slice(0, 10) : "never"}`,
6830
+ ].join("\t");
6831
+ }
6832
+ export const DEPLOY_KEY_SECRETS_WARNING = "A key that can deploy to an app can replace its code, so it can read that app's secrets and data. Scoping protects your other apps, not the ones it may deploy to.";
6833
+ function apiKeyCreateBody(action, projectIds, now) {
6834
+ return {
6835
+ ...(action.name ? { name: action.name } : {}),
6836
+ ...(action.preset ? { preset: action.preset } : {}),
6837
+ ...(projectIds.length > 0 ? { projectIds } : {}),
6838
+ ...(action.automation ? { automationId: action.automation } : {}),
6839
+ ...(action.expires ? { expiresAt: resolveApiKeyExpiry(action.expires, now) } : {}),
6840
+ };
6841
+ }
6842
+ /**
6843
+ * The ids of the apps named by --project, each matched exactly by id or slug against the
6844
+ * caller's own apps. The server also accepts slugs, but answers with ids, and the minted
6845
+ * key can only be checked against what was asked for when both sides name apps the same
6846
+ * way. A name that matches nothing stops here, before any key exists.
6847
+ */
6848
+ async function resolveKeyProjectIds(base, projects) {
6849
+ if (projects.length === 0)
6850
+ return [];
6851
+ const { projects: own } = await listSites(base);
6852
+ return projects.map((name) => {
6853
+ const match = own.find((project) => project.id === name || project.slug === name);
6854
+ if (!match)
6855
+ throw new Error(`No app of yours is called ${name}. Pass each --project as the slug or id that 9pm ls prints.`);
6856
+ return match.id;
6857
+ });
6858
+ }
6859
+ // What each preset grants, as the control plane defines it. Only used to check a minted key:
6860
+ // if a server ever widens a preset, its keys stop matching this and are refused, which errs
6861
+ // toward handing out nothing.
6862
+ const API_KEY_PRESET_PERMISSIONS = {
6863
+ monitor: ["project.read"],
6864
+ deploy: ["project.read", "deploy", "env.write", "database.create"],
6865
+ provision: ["project.read", "project.create", "deploy", "env.write", "database.create"],
6866
+ };
6867
+ const sameSet = (a, b) => new Set(a).size === new Set(b).size && a.every((value) => b.includes(value));
6868
+ /**
6869
+ * How a minted scoped key differs from the one asked for, or null when it is exactly that
6870
+ * key: its preset, its permissions, its apps or automation, and its expiry. A field the
6871
+ * answer leaves out counts as a difference, so a server that does not report one of them
6872
+ * cannot hand over a key that might be broader than requested.
6873
+ */
6874
+ export function scopedKeyMismatch(requested, created) {
6875
+ const scope = created.scope;
6876
+ if (!scope)
6877
+ return "a full-access key";
6878
+ if (scope.preset !== requested.preset)
6879
+ return `a ${scope.preset} key`;
6880
+ const expected = API_KEY_PRESET_PERMISSIONS[scope.preset];
6881
+ if (!expected || !scope.permissions || !sameSet(scope.permissions, expected)) {
6882
+ return `a ${scope.preset} key with permissions ${scope.permissions ? scope.permissions.join(",") || "(none)" : "(not reported)"}`;
6883
+ }
6884
+ if (requested.preset === "provision") {
6885
+ if (scope.automationId !== requested.automationId)
6886
+ return `a key for automation ${scope.automationId ?? "(none)"}`;
6887
+ if (scope.projectIds && scope.projectIds.length > 0)
6888
+ return `a key that also lists apps ${scope.projectIds.join(",")}`;
6889
+ }
6890
+ else {
6891
+ if (scope.automationId)
6892
+ return `a key for automation ${scope.automationId}`;
6893
+ if (!scope.projectIds)
6894
+ return "a key that does not say which apps it reaches";
6895
+ if (!sameSet(scope.projectIds, requested.projectIds ?? []))
6896
+ return `a key for apps ${scope.projectIds.join(",") || "(none)"}`;
6897
+ }
6898
+ const expiryMatches = requested.expiresAt
6899
+ ? typeof created.expiresAt === "string" && Date.parse(created.expiresAt) === Date.parse(requested.expiresAt)
6900
+ : created.expiresAt === null || created.expiresAt === undefined;
6901
+ if (!expiryMatches)
6902
+ return `a key expiring ${created.expiresAt ?? "never"}`;
6903
+ return null;
6904
+ }
6905
+ /**
6906
+ * A scoped key that is not exactly the one asked for - a full-access key from a control
6907
+ * plane that predates scoped keys, or any field broader or missing - is revoked at once and
6908
+ * its token never printed or emitted. Plain full-key creation is untouched: nothing was
6909
+ * asked for that the answer could lack.
6910
+ */
6911
+ async function refuseUnscopedKey(base, requested, created) {
6912
+ if (!requested.preset || requested.preset === "full")
6913
+ return;
6914
+ const got = scopedKeyMismatch(requested, created);
6915
+ if (!got)
6916
+ return;
6917
+ const unsupported = `This control plane did not create the key asked for: asked for a ${requested.preset} key, it answered with ${got} (${created.id}).`;
6918
+ try {
6919
+ await apiRequest(base, `/v1/me/api-keys/${encodeURIComponent(created.id)}/revoke`, { method: "POST" });
6920
+ }
6921
+ catch (error) {
6922
+ const reason = error instanceof ApiResponseError && error.serverMessage ? error.serverMessage : error instanceof Error ? error.message : String(error);
6923
+ throw new Error(`${unsupported} It could not be revoked (${reason}), and it still works: revoke key ${created.id} by hand with 9pm api-key revoke ${created.id}, or under API keys in Settings. Its token was not printed.`);
6924
+ }
6925
+ throw new Error(`${unsupported} That key was revoked and its token was not printed. No ${requested.preset} key was created.`);
6926
+ }
6927
+ export async function runApiKeyCommand(command, io = {}) {
6928
+ const log = io.log ?? ((line) => console.log(line));
6929
+ const base = { apiUrl: command.apiUrl, token: command.token, ...(io.fetchFn ? { fetchFn: io.fetchFn } : {}) };
6417
6930
  const action = command.action;
6418
6931
  if (action.kind === "create") {
6419
- const result = await apiRequest(base, "/v1/me/api-keys", {
6932
+ const requested = apiKeyCreateBody(action, await resolveKeyProjectIds(base, action.projects), (io.now ?? (() => new Date()))());
6933
+ const result = await accountRequest(base, "/v1/me/api-keys", {
6420
6934
  method: "POST",
6421
- body: JSON.stringify(action.name ? { name: action.name } : {}),
6935
+ body: JSON.stringify(requested),
6422
6936
  });
6937
+ await refuseUnscopedKey(base, requested, result.apiKey);
6423
6938
  if (action.json) {
6424
- console.log(JSON.stringify({ apiKey: result.apiKey, token: result.token }));
6939
+ log(JSON.stringify({ apiKey: result.apiKey, token: result.token }));
6425
6940
  return;
6426
6941
  }
6427
- console.log(`Created API key ${result.apiKey.id} (${result.apiKey.name}). The token is shown once:`);
6428
- console.log(result.token);
6429
- console.log("Store it in your CI or server secret store under any name but NINEPM_*, and pass it as --token or NINEPM_API_KEY.");
6942
+ const preset = action.preset ?? apiKeyPresetLabel(result.apiKey);
6943
+ const reach = preset === "full"
6944
+ ? "full access to every app"
6945
+ : action.automation
6946
+ ? `${preset} preset, apps of automation ${action.automation}`
6947
+ : `${preset} preset, apps ${action.projects.join(", ")}`;
6948
+ log(`Created API key ${result.apiKey.id} (${result.apiKey.name}): ${reach}. The token is shown once:`);
6949
+ log(result.token);
6950
+ log("Store it in your CI or server secret store under any name but NINEPM_*, and pass it as --token or NINEPM_API_KEY.");
6951
+ log(result.apiKey.expiresAt
6952
+ ? `It expires ${result.apiKey.expiresAt}, and sooner after 90 days without use.`
6953
+ : "It lapses after 90 days without use.");
6954
+ if (preset === "deploy" || preset === "provision") {
6955
+ log(DEPLOY_KEY_SECRETS_WARNING);
6956
+ }
6957
+ if (preset !== "full") {
6958
+ log(`If it lapses, mint a replacement here with the same ${action.automation ? "--automation" : "--project flags"}${action.automation ? " (the automation's apps stay attached)" : ""}, then run 9pm api-key revoke <old-key-id>.`);
6959
+ }
6430
6960
  return;
6431
6961
  }
6432
6962
  if (action.kind === "ls") {
6433
- const result = await apiRequest(base, "/v1/me/api-keys", { method: "GET" });
6963
+ const result = await accountRequest(base, "/v1/me/api-keys", { method: "GET" });
6434
6964
  const live = result.apiKeys.filter((key) => !key.revokedAt);
6435
6965
  if (action.json) {
6436
- console.log(JSON.stringify({ apiKeys: live }));
6966
+ // Each key exactly as the server sent it, plus `preset` so a script need not know
6967
+ // that a null scope means full access.
6968
+ log(JSON.stringify({ apiKeys: live.map((key) => ({ ...key, preset: apiKeyPresetLabel(key) })) }));
6437
6969
  return;
6438
6970
  }
6439
6971
  if (live.length === 0) {
6440
- console.log("No active API keys.");
6972
+ log("No active API keys.");
6441
6973
  return;
6442
6974
  }
6443
6975
  for (const key of live) {
6444
- console.log(`${key.id}\t${key.name}\t${key.prefix}...\tcreated ${key.createdAt.slice(0, 10)}\tlast used ${key.lastUsedAt ? key.lastUsedAt.slice(0, 10) : "never"}`);
6976
+ log(apiKeyListLine(key));
6977
+ }
6978
+ return;
6979
+ }
6980
+ const revoked = await accountRequest(base, `/v1/me/api-keys/${encodeURIComponent(action.keyId)}/revoke`, {
6981
+ method: "POST",
6982
+ });
6983
+ log(`Revoked API key ${revoked.apiKey.id} (${revoked.apiKey.name}).`);
6984
+ }
6985
+ export async function runAutomationCommand(command, io = {}) {
6986
+ const log = io.log ?? ((line) => console.log(line));
6987
+ const base = { apiUrl: command.apiUrl, token: command.token, ...(io.fetchFn ? { fetchFn: io.fetchFn } : {}) };
6988
+ const action = command.action;
6989
+ const route = (automationId) => `/v1/me/automations/${encodeURIComponent(automationId)}`;
6990
+ if (action.kind === "create") {
6991
+ const result = await accountRequest(base, "/v1/me/automations", {
6992
+ method: "POST",
6993
+ body: JSON.stringify({ name: action.name }),
6994
+ });
6995
+ if (action.json)
6996
+ return log(JSON.stringify(result));
6997
+ const id = result.automation.id;
6998
+ log(`Created automation ${id} (${result.automation.name}).`);
6999
+ log(`Attach apps: 9pm automation attach ${id} <app>`);
7000
+ log(`Mint its key: 9pm api-key create --preset provision --automation ${id}`);
7001
+ return;
7002
+ }
7003
+ if (action.kind === "ls") {
7004
+ const result = await accountRequest(base, "/v1/me/automations", { method: "GET" });
7005
+ if (action.json)
7006
+ return log(JSON.stringify(result));
7007
+ if (result.automations.length === 0) {
7008
+ log("No automations. Create one: 9pm automation create <name>");
7009
+ return;
6445
7010
  }
7011
+ for (const line of renderColumns([
7012
+ ["ID", "NAME", "APPS", "ACTIVE KEYS"],
7013
+ ...result.automations.map((automation) => [
7014
+ automation.id,
7015
+ automation.name,
7016
+ String(automation.apps?.length ?? 0),
7017
+ String(automation.activeKeys ?? 0),
7018
+ ]),
7019
+ ])) {
7020
+ log(line);
7021
+ }
7022
+ return;
7023
+ }
7024
+ if (action.kind === "rename") {
7025
+ const result = await accountRequest(base, route(action.automationId), {
7026
+ method: "PATCH",
7027
+ body: JSON.stringify({ name: action.name }),
7028
+ });
7029
+ if (action.json)
7030
+ return log(JSON.stringify(result));
7031
+ log(`Renamed automation ${result.automation.id} to "${result.automation.name}".`);
7032
+ return;
7033
+ }
7034
+ if (action.kind === "rm") {
7035
+ const result = await accountRequest(base, route(action.automationId), { method: "DELETE" });
7036
+ if (action.json)
7037
+ return log(JSON.stringify(result));
7038
+ log(`Deleted automation ${action.automationId}. Revoked ${result.revokedKeys} API key(s).`);
7039
+ log(result.releasedApps.length > 0
7040
+ ? `Its ${result.releasedApps.length} app(s) stay in your account as ordinary apps: ${result.releasedApps.map((app) => app.slug).join(", ")}.`
7041
+ : "It had no apps.");
6446
7042
  return;
6447
7043
  }
6448
- const revoked = await apiRequest(base, `/v1/me/api-keys/${encodeURIComponent(action.keyId)}/revoke`, { method: "POST" });
6449
- console.log(`Revoked API key ${revoked.apiKey.id} (${revoked.apiKey.name}).`);
7044
+ if (action.kind === "attach") {
7045
+ const result = await accountRequest(base, `${route(action.automationId)}/apps`, {
7046
+ method: "POST",
7047
+ body: JSON.stringify({ project: action.app }),
7048
+ });
7049
+ if (action.json)
7050
+ return log(JSON.stringify(result));
7051
+ log(`Attached ${result.app.slug} to automation ${action.automationId}. Its provision keys now reach it.`);
7052
+ return;
7053
+ }
7054
+ if (action.kind === "detach") {
7055
+ const result = await accountRequest(base, `${route(action.automationId)}/apps/${encodeURIComponent(action.app)}`, { method: "DELETE" });
7056
+ if (action.json)
7057
+ return log(JSON.stringify(result));
7058
+ log(`Detached ${result.app.slug} from automation ${action.automationId}. Its provision keys no longer reach it; the app stays in your account.`);
7059
+ return;
7060
+ }
7061
+ const result = await accountRequest(base, route(action.automationId), { method: "GET" });
7062
+ const apps = result.automation.apps ?? [];
7063
+ if (action.json)
7064
+ return log(JSON.stringify({ automationId: result.automation.id, apps }));
7065
+ if (apps.length === 0) {
7066
+ log(`Automation ${result.automation.id} (${result.automation.name}) has no apps. Attach one: 9pm automation attach ${result.automation.id} <app>`);
7067
+ return;
7068
+ }
7069
+ for (const line of renderColumns([["SLUG", "NAME", "ID"], ...apps.map((app) => [app.slug, app.name, app.id])])) {
7070
+ log(line);
7071
+ }
7072
+ }
7073
+ // What each preset may do, as the server grants it. Used only to word a refusal: the
7074
+ // server decides, and a permission missing from this table is reported as needing a
7075
+ // full-access login rather than guessed at.
7076
+ const PRESET_PERMISSIONS_FOR_GUIDANCE = [
7077
+ ["monitor", ["project.read"]],
7078
+ ["deploy", ["project.read", "deploy", "env.write", "database.create"]],
7079
+ ["provision", ["project.read", "project.create", "deploy", "env.write", "database.create"]],
7080
+ ];
7081
+ export const SCOPED_KEY_PREFIX = "9pm_sk_";
7082
+ function apiFailureOf(error) {
7083
+ let current = error;
7084
+ for (let depth = 0; depth < 5 && current; depth += 1) {
7085
+ if (current instanceof ApiResponseError)
7086
+ return { status: current.status, code: current.code, details: current.details };
7087
+ if (current instanceof DomainsApiError)
7088
+ return { status: current.status, code: current.code, details: undefined };
7089
+ current = current instanceof Error ? current.cause : undefined;
7090
+ }
7091
+ return null;
7092
+ }
7093
+ /**
7094
+ * What to add under an error when the credential is a scoped key, so the reader learns
7095
+ * the real cause. Never points a scoped key at `9pm api-key ls`: that needs a full-access
7096
+ * login, and the key would be refused again.
7097
+ */
7098
+ export function scopedKeyGuidance(error, context) {
7099
+ const failure = apiFailureOf(error);
7100
+ if (!failure)
7101
+ return [];
7102
+ const scoped = typeof context.token === "string" && context.token.startsWith(SCOPED_KEY_PREFIX);
7103
+ if (failure.code === "permission_denied") {
7104
+ const details = failure.details;
7105
+ const permission = details && typeof details === "object" && typeof details.permission === "string"
7106
+ ? details.permission
7107
+ : null;
7108
+ if (!permission) {
7109
+ return ["This API key's preset does not allow this. It needs a full-access login (9pm login)."];
7110
+ }
7111
+ const presets = PRESET_PERMISSIONS_FOR_GUIDANCE.filter(([, permissions]) => permissions.includes(permission)).map(([preset]) => preset);
7112
+ if (presets.length === 0) {
7113
+ return [`This API key lacks the "${permission}" permission, and no key preset includes it. It needs a full-access login (9pm login).`];
7114
+ }
7115
+ const first = presets[0];
7116
+ const scopeFlag = first === "provision" ? "--automation <id>" : "--project <app>";
7117
+ const named = presets.length === 1 ? `${first} preset includes` : `${presets.slice(0, -1).join(", ")} and ${presets[presets.length - 1]} presets include`;
7118
+ return [
7119
+ `This API key lacks the "${permission}" permission. The ${named} it.`,
7120
+ `From a full-access login, mint a key with it: 9pm api-key create --preset ${first} ${scopeFlag}`,
7121
+ ];
7122
+ }
7123
+ if (!scoped)
7124
+ return [];
7125
+ const settings = `${dashboardUrlForApiUrl(context.apiUrl ?? DEFAULT_API_URL)}/settings`;
7126
+ if (failure.code === "expired_api_key") {
7127
+ return [
7128
+ "This is a scoped API key, so 9pm login does not replace it.",
7129
+ "From a full-access login, mint a replacement that reaches the same apps with the same permissions, give it to the worker, then revoke the old key.",
7130
+ "For a provision key, mint the replacement for the same automation (--automation <id>): the automation's apps are still attached.",
7131
+ ];
7132
+ }
7133
+ if (failure.status === 404) {
7134
+ return [
7135
+ "This is a scoped API key: the app was not found, or this key does not reach it.",
7136
+ `Check which apps the key reaches under API keys in your account Settings (${settings}), or from a CLI signed in with your full-access login.`,
7137
+ ];
7138
+ }
7139
+ return [];
6450
7140
  }
6451
7141
  async function runSupportCommand(command) {
6452
7142
  const subject = command.subject ?? (command.message.split("\n")[0] ?? command.message).slice(0, 200);
@@ -6698,6 +7388,59 @@ async function runFilesCommand(command) {
6698
7388
  }
6699
7389
  }
6700
7390
  }
7391
+ export const API_KEY_USAGE = `Usage:
7392
+ 9pm api-key create [--name <name>] [--preset <preset>] [--project <app>]... [--automation <id>] [--expires <when>] [--json]
7393
+ 9pm api-key ls [--json]
7394
+ 9pm api-key revoke <key-id>
7395
+
7396
+ An API key lets CI or an unattended worker act without a browser login. Pass it as
7397
+ NINEPM_API_KEY or --token. The token is printed once, when the key is created.
7398
+
7399
+ Presets:
7400
+ monitor read app status and deployment history apps: --project <app> (repeat it)
7401
+ deploy monitor, plus deploy, set env vars, add a db apps: --project <app> (repeat it)
7402
+ provision deploy, plus create apps apps: those in --automation <id>
7403
+ full everything, on every app (the default)
7404
+
7405
+ A scoped key (monitor, deploy, provision) cannot delete apps, change access, manage
7406
+ domains or files, mint keys or manage automations; those need a full-access login.
7407
+ Deploying to an app is effectively access to that app's secrets and data: a key that
7408
+ can replace an app's code can read anything the app can.
7409
+
7410
+ Expiry:
7411
+ --expires <when> sets a fixed deadline, at most a year ahead. <when> is an ISO 8601
7412
+ date-time with its zone (2026-12-31T00:00:00Z) or a relative <N>d or <N>h (30d, 12h).
7413
+ An explicit expiry does not replace the 90-day inactivity rule: every key also lapses
7414
+ after 90 days without use, and a key with --expires lapses at whichever comes first.
7415
+ To recover, mint a replacement from a full-access login with the same preset and apps
7416
+ (or the same --automation, whose apps stay attached), then revoke the old key.
7417
+
7418
+ A scoped key needs a control plane that supports it. If the server answers with a
7419
+ full-access key instead, the CLI revokes that key at once and prints no token.
7420
+
7421
+ Creating, listing and revoking keys needs a full-access login.`;
7422
+ export const AUTOMATION_USAGE = `Usage:
7423
+ 9pm automation create <name> [--json]
7424
+ 9pm automation ls [--json]
7425
+ 9pm automation rename <automation-id> <new-name> [--json]
7426
+ 9pm automation rm <automation-id> --yes [--json]
7427
+ 9pm automation attach <automation-id> <app> [--json]
7428
+ 9pm automation detach <automation-id> <app> [--json]
7429
+ 9pm automation apps <automation-id> [--json]
7430
+
7431
+ An automation groups the apps a provision key reaches. The apps stay your account's
7432
+ own; the automation only decides which provision keys reach them. An app belongs to at
7433
+ most one automation: detach it before attaching it elsewhere.
7434
+
7435
+ Mint a key for one: 9pm api-key create --preset provision --automation <automation-id>
7436
+
7437
+ rm revokes every key minted for the automation at once, and keeps its apps as ordinary
7438
+ apps of your account. It needs --yes.
7439
+
7440
+ Every automation command needs a full-access login.`;
7441
+ export function usageForTopic(topic) {
7442
+ return topic === "api-key" ? API_KEY_USAGE : AUTOMATION_USAGE;
7443
+ }
6701
7444
  function printUsage() {
6702
7445
  console.log(`Usage:
6703
7446
  9pm --version
@@ -6750,9 +7493,11 @@ function printUsage() {
6750
7493
  9pm access service revoke <site> <service-token-id>
6751
7494
  9pm access token create <site> [--name <name>]
6752
7495
  9pm access token revoke <site> <token-id>
6753
- 9pm api-key create [--name <name>] [--json] mint a platform API key for CI or a server; the token is shown once
7496
+ 9pm api-key create [--name <name>] [--preset monitor|deploy|provision|full] [--project <app>]... [--automation <id>] [--expires 30d|12h|<iso-date-time>] [--json]
7497
+ mint an API key for CI or a worker; the token is shown once (9pm api-key --help)
6754
7498
  9pm api-key ls [--json]
6755
7499
  9pm api-key revoke <key-id>
7500
+ 9pm automation create|ls|rename|rm|attach|detach|apps group the apps a provision key reaches (9pm automation --help)
6756
7501
 
6757
7502
  Environment:
6758
7503
  NINEPM_API_URL ${API_URL_HELP}
@@ -6792,16 +7537,19 @@ Custom domains:
6792
7537
  Every domains command takes --owner <organisation> for an organisation's app. list, status and
6793
7538
  wait only read; add, check, restart, remove and move need the configure role on the app.
6794
7539
  --json prints {hostname, app, state, reason, next, routing, tls, provider, nameservers,
6795
- records[{type, name, fqdn, expected, current, ok, check, apex, optional}], error, expiresAt,
6796
- checkedAt, removing}. state is one of live, waiting_for_dns, dns_unverified (routing is active but the
6797
- records could not be checked), issuing_certificate (every required record checked and found),
6798
- failed, expired. reason says why (reason.providerMessage is the provider's own text, when it
7540
+ records[{type, name, fqdn, expected, current, ok, check, elsewhere, apex, optional}], error, expiresAt,
7541
+ checkedAt, removing}. state is one of live, ready_to_switch (routing and the certificate are active,
7542
+ but the routing record still leads to another host: change it to the value shown), waiting_for_dns,
7543
+ dns_unverified (routing is active but the records could not be checked), issuing_certificate (every
7544
+ required record checked and found), points_elsewhere (the domain's DNS now leads to another host:
7545
+ remove it here once the move is done, or point it back), failed, expired. reason says why (reason.providerMessage is the provider's own text, when it
6799
7546
  reported any, and its source: certificate_authority or hostname); next says who acts next (owner or platform) and what to do.
6800
7547
  A record's name is relative to the zone (www, _acme-challenge.www, @). ok is null when the
6801
7548
  check cannot say: check is not_checked, or apex_addresses for a root record answering with
6802
7549
  addresses (a correct ALIAS does), which defers to the routing status.
6803
7550
  wait exits 0 when live, 10 on timeout with something for the owner to do (records missing or
6804
- unverified, or a CAA record blocking the certificate), 11 on timeout with every record found and
7551
+ unverified, a routing record still to switch, or a CAA record blocking the certificate), and at
7552
+ once when the domain points elsewhere, 11 on timeout with every record found and
6805
7553
  nothing for the owner to do (the platform is still finishing), 20 when the domain failed or
6806
7554
  expired, and 1 on a usage, auth or API error. A domain whose removal is still finishing shows
6807
7555
  as removing ("removing": true in --json), and wait on it exits 20.