ninepm 0.87.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/README.md +31 -5
- package/dist/cli-config.d.ts +1 -1
- package/dist/cli-config.js +1 -1
- package/dist/domain-state.d.ts +51 -4
- package/dist/domain-state.js +79 -3
- package/dist/index.d.ts +149 -5
- package/dist/index.js +783 -67
- package/package.json +1 -1
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";
|
|
@@ -90,7 +90,7 @@ export function deletedDomainLines(deleted, options = {}) {
|
|
|
90
90
|
}
|
|
91
91
|
return lines;
|
|
92
92
|
}
|
|
93
|
-
export const CLI_VERSION = "0.
|
|
93
|
+
export const CLI_VERSION = "0.92.0";
|
|
94
94
|
const PUBLIC_DASHBOARD_URL = "https://9pm.ai/dashboard";
|
|
95
95
|
const TOKEN_SERVICE = "9pm";
|
|
96
96
|
const TOKEN_ACCOUNT = "default";
|
|
@@ -265,8 +265,8 @@ export class DeployError extends Error {
|
|
|
265
265
|
// DeployError, which would otherwise erase the distinction before the command
|
|
266
266
|
// that needs it ever sees the error.
|
|
267
267
|
deliberate;
|
|
268
|
-
constructor(message, slug, deliberate = false) {
|
|
269
|
-
super(message);
|
|
268
|
+
constructor(message, slug, deliberate = false, cause) {
|
|
269
|
+
super(message, cause === undefined ? undefined : { cause });
|
|
270
270
|
this.name = "DeployError";
|
|
271
271
|
this.slug = slug;
|
|
272
272
|
this.deliberate = deliberate;
|
|
@@ -360,7 +360,7 @@ export async function deployDirectory(initialOptions) {
|
|
|
360
360
|
}
|
|
361
361
|
if (error instanceof DeployError)
|
|
362
362
|
throw error;
|
|
363
|
-
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);
|
|
364
364
|
}
|
|
365
365
|
}
|
|
366
366
|
// Cheap unauthenticated probe of the control-plane API before a deploy. The
|
|
@@ -961,8 +961,19 @@ export async function whoami(options) {
|
|
|
961
961
|
return apiRequest(options, "/v1/me", { method: "GET" });
|
|
962
962
|
}
|
|
963
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
|
+
}
|
|
964
973
|
return {
|
|
965
974
|
user: result.user,
|
|
975
|
+
restricted: false,
|
|
976
|
+
preset: "full",
|
|
966
977
|
account: {
|
|
967
978
|
...(result.account?.org ? { org: result.account.org } : {}),
|
|
968
979
|
...(result.account?.plan ? { plan: result.account.plan } : {}),
|
|
@@ -972,10 +983,31 @@ export function publicWhoamiResult(result) {
|
|
|
972
983
|
...(typeof result.account?.storageQuotaBytes === "number" ? { storageQuotaBytes: result.account.storageQuotaBytes } : {}),
|
|
973
984
|
...(typeof result.account?.fileStorageUsedBytes === "number" ? { fileStorageUsedBytes: result.account.fileStorageUsedBytes } : {}),
|
|
974
985
|
...(typeof result.account?.fileStorageQuotaBytes === "number" ? { fileStorageQuotaBytes: result.account.fileStorageQuotaBytes } : {}),
|
|
975
|
-
credentialExpiresAt: result.account?.tokenExpiresAt ?? result.apiKey
|
|
986
|
+
credentialExpiresAt: result.account?.tokenExpiresAt ?? result.apiKey?.expiresAt ?? null,
|
|
976
987
|
},
|
|
977
988
|
};
|
|
978
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
|
+
}
|
|
979
1011
|
// How long each revocation request may take before logout gives up and reports
|
|
980
1012
|
// the honest failure instead of hanging (a black-holed connection accepts TCP
|
|
981
1013
|
// and never answers). Longer than the 5s reachability probe because this
|
|
@@ -1112,6 +1144,12 @@ async function responseErrorCode(response) {
|
|
|
1112
1144
|
return "";
|
|
1113
1145
|
}
|
|
1114
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
|
+
}
|
|
1115
1153
|
async function doctor(options) {
|
|
1116
1154
|
const credentials = credentialDiagnostics();
|
|
1117
1155
|
const token = process.env.NINEPM_API_KEY ?? readStoredCredential();
|
|
@@ -1153,8 +1191,8 @@ async function doctor(options) {
|
|
|
1153
1191
|
console.log(`Warning: ${warning}`);
|
|
1154
1192
|
}
|
|
1155
1193
|
if (account) {
|
|
1156
|
-
|
|
1157
|
-
|
|
1194
|
+
for (const line of doctorAccountLines(account))
|
|
1195
|
+
console.log(line);
|
|
1158
1196
|
}
|
|
1159
1197
|
else if (accountError) {
|
|
1160
1198
|
console.log(`Account: unavailable (${accountError})`);
|
|
@@ -1453,28 +1491,47 @@ export function deleteWarningLines(slug) {
|
|
|
1453
1491
|
"Its deployments, files and managed database (if it has one) are deleted with it and cannot be recovered.",
|
|
1454
1492
|
];
|
|
1455
1493
|
}
|
|
1456
|
-
// Printed after a delete of
|
|
1457
|
-
// script was replaced with the deleted-app Worker, with no
|
|
1458
|
-
//
|
|
1459
|
-
//
|
|
1460
|
-
//
|
|
1461
|
-
//
|
|
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.
|
|
1462
1505
|
export const DELETE_SERVING_BOUND_LINE = "Its address stops serving the app within about a minute.";
|
|
1463
1506
|
// Whether DELETE_SERVING_BOUND_LINE is true of this delete. Read from what the server says it
|
|
1464
|
-
// did, not from what the app was
|
|
1465
|
-
//
|
|
1466
|
-
//
|
|
1467
|
-
//
|
|
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.
|
|
1468
1515
|
export function deletedAppStopsServingQuickly(deleted) {
|
|
1469
|
-
const scripts = Array.isArray(deleted.workerScripts) ? deleted.workerScripts : []
|
|
1470
|
-
|
|
1516
|
+
const scripts = (Array.isArray(deleted.workerScripts) ? deleted.workerScripts : []).map((script) => typeof script === "object" && script !== null
|
|
1517
|
+
? script
|
|
1518
|
+
: {});
|
|
1471
1519
|
const releasing = Array.isArray(deleted.customDomainsReleasing) && deleted.customDomainsReleasing.length > 0;
|
|
1472
|
-
|
|
1473
|
-
(deleted.containerApplications ?? 0) === 0 &&
|
|
1474
|
-
(deleted.flyTeardownsQueued ?? 0) === 0 &&
|
|
1520
|
+
const nothingUnmeasured = (deleted.flyTeardownsQueued ?? 0) === 0 &&
|
|
1475
1521
|
deleted.customDomains === 0 &&
|
|
1476
1522
|
!releasing &&
|
|
1477
|
-
(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"));
|
|
1478
1535
|
}
|
|
1479
1536
|
export async function deleteSite(options) {
|
|
1480
1537
|
const route = `/v1/sites/${encodeURIComponent(options.site)}${options.owner ? `?owner=${encodeURIComponent(options.owner)}` : ""}`;
|
|
@@ -1812,7 +1869,7 @@ export async function retireReplacedCredentials(before, after, newToken, fetchFn
|
|
|
1812
1869
|
/** The lines a login prints about what it retired, for stderr. Silent when nothing needs saying. */
|
|
1813
1870
|
export function replacedCredentialMessages(result, loginApiUrl) {
|
|
1814
1871
|
const lines = [];
|
|
1815
|
-
const manual = "
|
|
1872
|
+
const manual = "log it out from Settings > CLI logins in the dashboard, or it stays valid until 90 days pass without use.";
|
|
1816
1873
|
for (const outcome of result.replaced) {
|
|
1817
1874
|
if (outcome.kind === "revoked") {
|
|
1818
1875
|
const elsewhere = outcome.apiUrl !== normalizeApiUrl(loginApiUrl) ? ` (issued by ${outcome.apiUrl})` : "";
|
|
@@ -2523,7 +2580,27 @@ async function expectOk(response, action) {
|
|
|
2523
2580
|
if (code && message && DELIBERATE_REFUSAL_CODES.has(code)) {
|
|
2524
2581
|
throw new DeliberateRefusalError(message, code);
|
|
2525
2582
|
}
|
|
2526
|
-
throw new
|
|
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
|
+
}
|
|
2527
2604
|
}
|
|
2528
2605
|
// Takes the already-consumed body text rather than the Response: a body can only
|
|
2529
2606
|
// be read once, and expectOk needs the code and the message from the same read.
|
|
@@ -2781,6 +2858,9 @@ export function parseArgs(argv) {
|
|
|
2781
2858
|
if (command === "api-key" || command === "api-keys") {
|
|
2782
2859
|
return parseApiKeyArgs(argv.slice(1));
|
|
2783
2860
|
}
|
|
2861
|
+
if (command === "automation" || command === "automations") {
|
|
2862
|
+
return parseAutomationArgs(argv.slice(1));
|
|
2863
|
+
}
|
|
2784
2864
|
if (command !== "deploy") {
|
|
2785
2865
|
throw new Error(`Unknown command: ${command}`);
|
|
2786
2866
|
}
|
|
@@ -3339,9 +3419,11 @@ function isRemoving(domain) {
|
|
|
3339
3419
|
}
|
|
3340
3420
|
const DOMAIN_STATE_LABELS = {
|
|
3341
3421
|
live: "live",
|
|
3422
|
+
ready_to_switch: "ready to switch: the platform can serve it, but its DNS still points elsewhere",
|
|
3342
3423
|
waiting_for_dns: "waiting for DNS records",
|
|
3343
3424
|
dns_unverified: "couldn't check the DNS records just now",
|
|
3344
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",
|
|
3345
3427
|
failed: "failed",
|
|
3346
3428
|
expired: "expired",
|
|
3347
3429
|
};
|
|
@@ -3461,6 +3543,24 @@ function recordStateText(record, routing, parking = null) {
|
|
|
3461
3543
|
const also = others.map((answer) => `; ${answer.resolver} answers ${answerText(answer.current)}`).join("");
|
|
3462
3544
|
return `currently ${shown}${also}${recent}: point it at the value shown${parkingText(parking)}`;
|
|
3463
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
|
+
}
|
|
3464
3564
|
// Said only when the check saw the host's documented parking value (parkingRecordFor), and
|
|
3465
3565
|
// conditionally even then: the record may have been changed since the check.
|
|
3466
3566
|
function parkingText(parking) {
|
|
@@ -3537,6 +3637,12 @@ export function formatDomainStatus(status, options = {}) {
|
|
|
3537
3637
|
lines.push(`Add it again: ${domainCommand("add", status.app, status.hostname, options)}`);
|
|
3538
3638
|
return lines;
|
|
3539
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
|
+
}
|
|
3540
3646
|
if (status.state === "failed" && status.error) {
|
|
3541
3647
|
lines.push(status.error);
|
|
3542
3648
|
}
|
|
@@ -3573,7 +3679,7 @@ export function formatDomainStatus(status, options = {}) {
|
|
|
3573
3679
|
record.apex && !profile && record.typeAtProvider === "ALIAS" ? "ALIAS / ANAME" : (record.typeAtProvider ?? "-"),
|
|
3574
3680
|
record.nameAtProvider === "" ? "(leave empty)" : record.nameAtProvider,
|
|
3575
3681
|
record.valueAtProvider,
|
|
3576
|
-
recordStateText(record, status.routing, profile ? parkingRecordFor(status.providerId, record) : null),
|
|
3682
|
+
withAuthoritativeText(record, recordStateText(record, status.routing, profile ? parkingRecordFor(status.providerId, record) : null)),
|
|
3577
3683
|
]),
|
|
3578
3684
|
];
|
|
3579
3685
|
lines.push(...renderColumns(rows).map((line) => ` ${line}`));
|
|
@@ -3642,10 +3748,63 @@ export function formatDomainStatus(status, options = {}) {
|
|
|
3642
3748
|
lines.push(`Then run: ${domainCommand("wait", status.app, status.hostname, options)}`);
|
|
3643
3749
|
return lines;
|
|
3644
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
|
+
}
|
|
3645
3801
|
// What a timed-out wait says. "Missing" is claimed only for a record the check actually saw
|
|
3646
3802
|
// absent or different; a record nobody could read is reported as unchecked, whatever the
|
|
3647
3803
|
// routing status, because telling the owner to publish what may already be there misleads.
|
|
3648
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
|
+
}
|
|
3649
3808
|
if (status.next?.action === "use_www") {
|
|
3650
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.`;
|
|
3651
3810
|
}
|
|
@@ -3902,7 +4061,7 @@ export function domainRemovedConsequenceLines(hostnames, options = {}) {
|
|
|
3902
4061
|
const subject = hostnames.length === 1 ? hostnames[0] : hostnames.join(", ");
|
|
3903
4062
|
return [
|
|
3904
4063
|
`After removal, visitors may see a TLS or routing error while DNS for ${subject} still points at 9pm.`,
|
|
3905
|
-
`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
|
|
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)}`,
|
|
3906
4065
|
];
|
|
3907
4066
|
}
|
|
3908
4067
|
// The command that lists what a removal finished in the background frees (#2068), aimed at the
|
|
@@ -4193,7 +4352,7 @@ export function deployCustomDomainLines(statuses, slug, owner, target) {
|
|
|
4193
4352
|
return `Custom domain ${status.hostname} is being removed.`;
|
|
4194
4353
|
if (status.state === "live")
|
|
4195
4354
|
return `Also serving at https://${status.hostname}`;
|
|
4196
|
-
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";
|
|
4197
4356
|
return `Custom domain ${status.hostname} ${notLive} (${domainStateLabel(status.state)}). Check it: ${domainCommand("status", slug, status.hostname, { ...(owner ? { owner } : {}), ...(target ? { target } : {}) })}`;
|
|
4198
4357
|
});
|
|
4199
4358
|
}
|
|
@@ -4220,6 +4379,9 @@ export async function waitForDomain(options) {
|
|
|
4220
4379
|
// Nothing the owner can publish finishes this hostname, so polling until the timeout would only delay saying so.
|
|
4221
4380
|
if (status.next?.action === "use_www")
|
|
4222
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 };
|
|
4223
4385
|
}
|
|
4224
4386
|
catch (error) {
|
|
4225
4387
|
const transient = !(error instanceof DomainsApiError) || error.status >= 500;
|
|
@@ -4985,29 +5147,173 @@ function readBaseApiOptions(flags) {
|
|
|
4985
5147
|
function readApiToken(flags) {
|
|
4986
5148
|
return readStringFlag(flags, "token") ?? process.env.NINEPM_API_KEY ?? readStoredCredential();
|
|
4987
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
|
+
}
|
|
4988
5229
|
function parseApiKeyArgs(argv) {
|
|
4989
5230
|
const subcommand = argv[0];
|
|
4990
|
-
if (!subcommand || subcommand
|
|
4991
|
-
throw new UsageError();
|
|
5231
|
+
if (!subcommand || isHelpToken(subcommand) || isHelpToken(argv[1])) {
|
|
5232
|
+
throw new UsageError("api-key");
|
|
4992
5233
|
}
|
|
4993
5234
|
const flags = parseFlags(argv);
|
|
4994
|
-
const base = readBaseApiOptions(flags);
|
|
4995
5235
|
const json = flags.has("json");
|
|
4996
5236
|
if (subcommand === "create") {
|
|
4997
|
-
const
|
|
4998
|
-
return { kind: "api-key", ...
|
|
5237
|
+
const action = parseApiKeyCreate(argv, flags);
|
|
5238
|
+
return { kind: "api-key", ...readBaseApiOptions(flags), action };
|
|
4999
5239
|
}
|
|
5000
5240
|
if (subcommand === "ls" || subcommand === "list") {
|
|
5001
|
-
return { kind: "api-key", ...
|
|
5241
|
+
return { kind: "api-key", ...readBaseApiOptions(flags), action: { kind: "ls", json } };
|
|
5002
5242
|
}
|
|
5003
5243
|
if (subcommand === "revoke") {
|
|
5004
5244
|
const keyId = argv[1];
|
|
5005
5245
|
if (!keyId || keyId.startsWith("--")) {
|
|
5006
5246
|
throw new Error("Usage: 9pm api-key revoke <key-id>");
|
|
5007
5247
|
}
|
|
5008
|
-
return { kind: "api-key", ...
|
|
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);
|
|
5009
5266
|
}
|
|
5010
|
-
|
|
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.`);
|
|
5011
5317
|
}
|
|
5012
5318
|
function parseSupportArgs(argv) {
|
|
5013
5319
|
// Normalize the -m short flag so parseFlags (which only handles --flags) sees it.
|
|
@@ -6039,6 +6345,11 @@ function formatRemainingForCli(value) {
|
|
|
6039
6345
|
return `${Math.floor(totalSeconds / 60)}m ${totalSeconds % 60}s`;
|
|
6040
6346
|
}
|
|
6041
6347
|
export class UsageError extends Error {
|
|
6348
|
+
topic;
|
|
6349
|
+
constructor(topic) {
|
|
6350
|
+
super();
|
|
6351
|
+
this.topic = topic;
|
|
6352
|
+
}
|
|
6042
6353
|
}
|
|
6043
6354
|
// #1856: which missing-pages mode the deployment was published with, read from the
|
|
6044
6355
|
// deployment the platform returned rather than from the flags, because a deploy without a
|
|
@@ -6056,9 +6367,23 @@ export function missingPagesLine(deployment) {
|
|
|
6056
6367
|
return null;
|
|
6057
6368
|
}
|
|
6058
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
|
+
}
|
|
6059
6382
|
async function main() {
|
|
6383
|
+
let parsed;
|
|
6060
6384
|
try {
|
|
6061
6385
|
const command = parseArgs(process.argv.slice(2));
|
|
6386
|
+
parsed = command;
|
|
6062
6387
|
if (command.kind === "version") {
|
|
6063
6388
|
console.log(CLI_VERSION);
|
|
6064
6389
|
return;
|
|
@@ -6127,7 +6452,7 @@ async function main() {
|
|
|
6127
6452
|
}
|
|
6128
6453
|
const slug = error instanceof DeployError ? error.slug : command.options.slug;
|
|
6129
6454
|
const siteFlag = slug ? ` --site ${quoteSiteArg(slug)}` : "";
|
|
6130
|
-
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 });
|
|
6131
6456
|
}
|
|
6132
6457
|
const databaseClause = deployDatabaseClause(result.databases);
|
|
6133
6458
|
const queued = result.deployment.status === "queued" || result.deployment.status === "publishing";
|
|
@@ -6284,7 +6609,7 @@ async function main() {
|
|
|
6284
6609
|
// path, where a pre-0.42 credential really was issued by the default
|
|
6285
6610
|
// plane and really is dead — but it cannot be called confirmed.
|
|
6286
6611
|
console.error(`Removed the local credential, but could not confirm it was revoked: ${summary.apiUrl} does not recognise it.`);
|
|
6287
|
-
console.error("An older CLI stored it without recording which control plane issued it. If you used another control plane,
|
|
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.");
|
|
6288
6613
|
if (removalWarning) {
|
|
6289
6614
|
console.warn(removalWarning);
|
|
6290
6615
|
}
|
|
@@ -6312,8 +6637,8 @@ async function main() {
|
|
|
6312
6637
|
// it; a staging or self-hosted key cannot be revoked there, and a
|
|
6313
6638
|
// rejected URL leaves the origin unknown.
|
|
6314
6639
|
const settingsHint = !command.apiUrlError && targets.every((target) => target.apiUrl === DEFAULT_API_URL)
|
|
6315
|
-
? `
|
|
6316
|
-
: "
|
|
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.";
|
|
6317
6642
|
console.error(`It can still authenticate. ${settingsHint}`);
|
|
6318
6643
|
process.exitCode = 1;
|
|
6319
6644
|
}
|
|
@@ -6333,11 +6658,8 @@ async function main() {
|
|
|
6333
6658
|
console.log(JSON.stringify(publicMe, null, 2));
|
|
6334
6659
|
}
|
|
6335
6660
|
else {
|
|
6336
|
-
|
|
6337
|
-
|
|
6338
|
-
console.log(`Sites: ${publicMe.account?.sitesUsed ?? "unknown"} / ${publicMe.account?.sitesQuota ?? "unknown"}`);
|
|
6339
|
-
console.log(`Storage: ${formatBytesForCli(publicMe.account?.storageUsedBytes ?? publicMe.account?.fileStorageUsedBytes ?? 0)} / ${formatBytesForCli(publicMe.account?.storageQuotaBytes ?? publicMe.account?.fileStorageQuotaBytes ?? 0)}`);
|
|
6340
|
-
console.log(`Credential: ${publicMe.account?.credentialExpiresAt ? `expires ${publicMe.account.credentialExpiresAt}` : "active"}`);
|
|
6661
|
+
for (const line of whoamiLines(publicMe))
|
|
6662
|
+
console.log(line);
|
|
6341
6663
|
}
|
|
6342
6664
|
return;
|
|
6343
6665
|
}
|
|
@@ -6425,11 +6747,20 @@ async function main() {
|
|
|
6425
6747
|
await runApiKeyCommand(command);
|
|
6426
6748
|
return;
|
|
6427
6749
|
}
|
|
6750
|
+
if (command.kind === "automation") {
|
|
6751
|
+
await runAutomationCommand(command);
|
|
6752
|
+
return;
|
|
6753
|
+
}
|
|
6428
6754
|
await runAccessCommand(command);
|
|
6429
6755
|
}
|
|
6430
6756
|
catch (error) {
|
|
6431
6757
|
if (error instanceof UsageError) {
|
|
6432
|
-
|
|
6758
|
+
if (error.topic) {
|
|
6759
|
+
console.log(usageForTopic(error.topic));
|
|
6760
|
+
}
|
|
6761
|
+
else {
|
|
6762
|
+
printUsage();
|
|
6763
|
+
}
|
|
6433
6764
|
process.exitCode = 0;
|
|
6434
6765
|
return;
|
|
6435
6766
|
}
|
|
@@ -6441,44 +6772,371 @@ async function main() {
|
|
|
6441
6772
|
return;
|
|
6442
6773
|
}
|
|
6443
6774
|
reportCliError(error);
|
|
6775
|
+
for (const line of scopedKeyGuidance(error, commandCredential(parsed))) {
|
|
6776
|
+
console.error(line);
|
|
6777
|
+
}
|
|
6444
6778
|
process.exitCode = 1;
|
|
6445
6779
|
}
|
|
6446
6780
|
}
|
|
6447
|
-
|
|
6448
|
-
|
|
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 } : {}) };
|
|
6449
6930
|
const action = command.action;
|
|
6450
6931
|
if (action.kind === "create") {
|
|
6451
|
-
const
|
|
6932
|
+
const requested = apiKeyCreateBody(action, await resolveKeyProjectIds(base, action.projects), (io.now ?? (() => new Date()))());
|
|
6933
|
+
const result = await accountRequest(base, "/v1/me/api-keys", {
|
|
6452
6934
|
method: "POST",
|
|
6453
|
-
body: JSON.stringify(
|
|
6935
|
+
body: JSON.stringify(requested),
|
|
6454
6936
|
});
|
|
6937
|
+
await refuseUnscopedKey(base, requested, result.apiKey);
|
|
6455
6938
|
if (action.json) {
|
|
6456
|
-
|
|
6939
|
+
log(JSON.stringify({ apiKey: result.apiKey, token: result.token }));
|
|
6457
6940
|
return;
|
|
6458
6941
|
}
|
|
6459
|
-
|
|
6460
|
-
|
|
6461
|
-
|
|
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
|
+
}
|
|
6462
6960
|
return;
|
|
6463
6961
|
}
|
|
6464
6962
|
if (action.kind === "ls") {
|
|
6465
|
-
const result = await
|
|
6963
|
+
const result = await accountRequest(base, "/v1/me/api-keys", { method: "GET" });
|
|
6466
6964
|
const live = result.apiKeys.filter((key) => !key.revokedAt);
|
|
6467
6965
|
if (action.json) {
|
|
6468
|
-
|
|
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) })) }));
|
|
6469
6969
|
return;
|
|
6470
6970
|
}
|
|
6471
6971
|
if (live.length === 0) {
|
|
6472
|
-
|
|
6972
|
+
log("No active API keys.");
|
|
6473
6973
|
return;
|
|
6474
6974
|
}
|
|
6475
6975
|
for (const key of live) {
|
|
6476
|
-
|
|
6976
|
+
log(apiKeyListLine(key));
|
|
6477
6977
|
}
|
|
6478
6978
|
return;
|
|
6479
6979
|
}
|
|
6480
|
-
const revoked = await
|
|
6481
|
-
|
|
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;
|
|
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.");
|
|
7042
|
+
return;
|
|
7043
|
+
}
|
|
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 [];
|
|
6482
7140
|
}
|
|
6483
7141
|
async function runSupportCommand(command) {
|
|
6484
7142
|
const subject = command.subject ?? (command.message.split("\n")[0] ?? command.message).slice(0, 200);
|
|
@@ -6730,6 +7388,59 @@ async function runFilesCommand(command) {
|
|
|
6730
7388
|
}
|
|
6731
7389
|
}
|
|
6732
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
|
+
}
|
|
6733
7444
|
function printUsage() {
|
|
6734
7445
|
console.log(`Usage:
|
|
6735
7446
|
9pm --version
|
|
@@ -6782,9 +7493,11 @@ function printUsage() {
|
|
|
6782
7493
|
9pm access service revoke <site> <service-token-id>
|
|
6783
7494
|
9pm access token create <site> [--name <name>]
|
|
6784
7495
|
9pm access token revoke <site> <token-id>
|
|
6785
|
-
9pm api-key create [--name <name>] [--
|
|
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)
|
|
6786
7498
|
9pm api-key ls [--json]
|
|
6787
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)
|
|
6788
7501
|
|
|
6789
7502
|
Environment:
|
|
6790
7503
|
NINEPM_API_URL ${API_URL_HELP}
|
|
@@ -6824,16 +7537,19 @@ Custom domains:
|
|
|
6824
7537
|
Every domains command takes --owner <organisation> for an organisation's app. list, status and
|
|
6825
7538
|
wait only read; add, check, restart, remove and move need the configure role on the app.
|
|
6826
7539
|
--json prints {hostname, app, state, reason, next, routing, tls, provider, nameservers,
|
|
6827
|
-
records[{type, name, fqdn, expected, current, ok, check, apex, optional}], error, expiresAt,
|
|
6828
|
-
checkedAt, removing}. state is one of live,
|
|
6829
|
-
|
|
6830
|
-
|
|
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
|
|
6831
7546
|
reported any, and its source: certificate_authority or hostname); next says who acts next (owner or platform) and what to do.
|
|
6832
7547
|
A record's name is relative to the zone (www, _acme-challenge.www, @). ok is null when the
|
|
6833
7548
|
check cannot say: check is not_checked, or apex_addresses for a root record answering with
|
|
6834
7549
|
addresses (a correct ALIAS does), which defers to the routing status.
|
|
6835
7550
|
wait exits 0 when live, 10 on timeout with something for the owner to do (records missing or
|
|
6836
|
-
unverified, or a CAA record blocking the certificate),
|
|
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
|
|
6837
7553
|
nothing for the owner to do (the platform is still finishing), 20 when the domain failed or
|
|
6838
7554
|
expired, and 1 on a usage, auth or API error. A domain whose removal is still finishing shows
|
|
6839
7555
|
as removing ("removing": true in --json), and wait on it exits 20.
|