ninepm 0.81.0 → 0.85.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 -3
- package/dist/index.d.ts +62 -2
- package/dist/index.js +477 -14
- package/dist/qr-code.d.ts +48 -0
- package/dist/qr-code.js +85 -0
- package/dist/vendor/qrcodegen.d.ts +93 -0
- package/dist/vendor/qrcodegen.js +879 -0
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -8,9 +8,10 @@ import path from "node:path";
|
|
|
8
8
|
import { chmodSync, cpSync, existsSync, mkdirSync, mkdtempSync, openAsBlob, readFileSync, realpathSync, rmSync, statSync, writeFileSync } from "node:fs";
|
|
9
9
|
import { fileURLToPath } from "node:url";
|
|
10
10
|
import { once } from "node:events";
|
|
11
|
-
import { createGzip, gzipSync } from "node:zlib";
|
|
11
|
+
import { createGzip, deflateSync, gzipSync } from "node:zlib";
|
|
12
12
|
import { Agent, fetch as undiciFetch } from "undici";
|
|
13
13
|
import { MAX_DEPLOY_DESCRIPTION_LENGTH, normalizeDeployDescription } from "./deploy-description.js";
|
|
14
|
+
import { qrMatrix, qrMinimumWidthMm, qrSvg, qrTerminal } from "./qr-code.js";
|
|
14
15
|
import { managedHostnamesIn, siblingCallWarning, siblingsOf } from "./sibling-hostnames.js";
|
|
15
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";
|
|
16
17
|
import { guessZoneApex, relativeRecordName, zoneApexFor } from "./dns-host.js";
|
|
@@ -56,7 +57,7 @@ export function deployDatabaseClause(databases) {
|
|
|
56
57
|
// says each removed one used, under the same value-conditional rule as `domains remove`, and the
|
|
57
58
|
// ones still being released, which get no deletion advice. Never throws: the delete has already
|
|
58
59
|
// happened, so a malformed entry costs that entry's advice, not the command.
|
|
59
|
-
export function deletedDomainLines(deleted) {
|
|
60
|
+
export function deletedDomainLines(deleted, options = {}) {
|
|
60
61
|
const lines = [];
|
|
61
62
|
try {
|
|
62
63
|
const releasing = Array.isArray(deleted.customDomainsReleasing)
|
|
@@ -70,7 +71,7 @@ export function deletedDomainLines(deleted) {
|
|
|
70
71
|
lines.push(...domainCleanupLines(cleanup));
|
|
71
72
|
}
|
|
72
73
|
for (const hostname of releasing) {
|
|
73
|
-
lines.push(`${hostname} has stopped serving, but removing it upstream is still finishing; it finishes on its own. ${
|
|
74
|
+
lines.push(`${hostname} has stopped serving, but removing it upstream is still finishing; it finishes on its own. ${releaseNoCleanupLine(options)}`);
|
|
74
75
|
}
|
|
75
76
|
}
|
|
76
77
|
catch {
|
|
@@ -78,7 +79,7 @@ export function deletedDomainLines(deleted) {
|
|
|
78
79
|
}
|
|
79
80
|
return lines;
|
|
80
81
|
}
|
|
81
|
-
export const CLI_VERSION = "0.
|
|
82
|
+
export const CLI_VERSION = "0.85.0";
|
|
82
83
|
const PUBLIC_DASHBOARD_URL = "https://9pm.ai/dashboard";
|
|
83
84
|
const TOKEN_SERVICE = "9pm";
|
|
84
85
|
const TOKEN_ACCOUNT = "default";
|
|
@@ -1441,6 +1442,29 @@ export function deleteWarningLines(slug) {
|
|
|
1441
1442
|
"Its deployments, files and managed database (if it has one) are deleted with it and cannot be recovered.",
|
|
1442
1443
|
];
|
|
1443
1444
|
}
|
|
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.
|
|
1451
|
+
export const DELETE_SERVING_BOUND_LINE = "Its address stops serving the app within about a minute.";
|
|
1452
|
+
// 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.
|
|
1457
|
+
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);
|
|
1460
|
+
const releasing = Array.isArray(deleted.customDomainsReleasing) && deleted.customDomainsReleasing.length > 0;
|
|
1461
|
+
return (tombstoned &&
|
|
1462
|
+
(deleted.containerApplications ?? 0) === 0 &&
|
|
1463
|
+
(deleted.flyTeardownsQueued ?? 0) === 0 &&
|
|
1464
|
+
deleted.customDomains === 0 &&
|
|
1465
|
+
!releasing &&
|
|
1466
|
+
(deleted.errors ?? []).length === 0);
|
|
1467
|
+
}
|
|
1444
1468
|
export async function deleteSite(options) {
|
|
1445
1469
|
const route = `/v1/sites/${encodeURIComponent(options.site)}${options.owner ? `?owner=${encodeURIComponent(options.owner)}` : ""}`;
|
|
1446
1470
|
const payload = await apiRequest(options, route, {
|
|
@@ -2684,6 +2708,9 @@ export function parseArgs(argv) {
|
|
|
2684
2708
|
if (command === "domains") {
|
|
2685
2709
|
return parseDomainsArgs(argv.slice(1));
|
|
2686
2710
|
}
|
|
2711
|
+
if (command === "qr") {
|
|
2712
|
+
return parseQrArgs(argv.slice(1));
|
|
2713
|
+
}
|
|
2687
2714
|
if (command === "logs") {
|
|
2688
2715
|
return parseLogsArgs(argv.slice(1));
|
|
2689
2716
|
}
|
|
@@ -2958,7 +2985,7 @@ function parseDeleteArgs(argv) {
|
|
|
2958
2985
|
if (!confirmSlug) {
|
|
2959
2986
|
throw new Error("Missing delete confirmation. Pass --confirm <slug> matching the app slug.");
|
|
2960
2987
|
}
|
|
2961
|
-
return { kind: "delete", ...base, site, confirmSlug };
|
|
2988
|
+
return { kind: "delete", ...base, apiUrlFrom: apiUrlSource(flags), site, confirmSlug };
|
|
2962
2989
|
}
|
|
2963
2990
|
function parseFilesArgs(argv) {
|
|
2964
2991
|
const subcommand = argv[0];
|
|
@@ -3490,6 +3517,8 @@ export function formatDomainStatus(status, options = {}) {
|
|
|
3490
3517
|
const lines = [`${status.hostname} (app ${status.app}): ${domainStateLabel(status.state)}`];
|
|
3491
3518
|
if (status.state === "live") {
|
|
3492
3519
|
lines.push(`Serving at https://${status.hostname}`);
|
|
3520
|
+
// #2089: which setup records can go is asked of the provider when wanted, not inferred here.
|
|
3521
|
+
lines.push(`Setup records you may no longer need: ${domainCommand("cleanup", status.app, status.hostname, options)}`);
|
|
3493
3522
|
return lines;
|
|
3494
3523
|
}
|
|
3495
3524
|
if (status.state === "expired") {
|
|
@@ -3802,20 +3831,186 @@ export function domainCleanupLines(cleanup) {
|
|
|
3802
3831
|
"Delete a record only while it still has the value shown. If one of these names now holds a different value, for example because the domain points somewhere new, that record is not ours: leave it.",
|
|
3803
3832
|
];
|
|
3804
3833
|
}
|
|
3805
|
-
|
|
3806
|
-
|
|
3807
|
-
|
|
3834
|
+
export async function getDomainSetupCleanup(options) {
|
|
3835
|
+
return domainsRequest(options, siteDomainsRoute(options, normalizeDomainArg(options.hostname), "cleanup"));
|
|
3836
|
+
}
|
|
3837
|
+
// What `9pm domains cleanup` prints for the server's answer (#2089). Never throws: anything it
|
|
3838
|
+
// cannot read is reported as nothing listed, never as something to delete.
|
|
3839
|
+
export function domainSetupCleanupLines(body, hostname) {
|
|
3840
|
+
try {
|
|
3841
|
+
const fields = body && typeof body === "object" ? body : {};
|
|
3842
|
+
const name = typeof fields.hostname === "string" && fields.hostname !== "" ? fields.hostname : hostname;
|
|
3843
|
+
if (fields.live !== true) {
|
|
3844
|
+
return [`${name} isn't live with its certificate right now, so no setup record is listed for deletion.`];
|
|
3845
|
+
}
|
|
3846
|
+
const cleanup = parseDomainCleanup(fields.cleanup);
|
|
3847
|
+
if (!cleanup)
|
|
3848
|
+
return [`Couldn't read which setup records ${name} no longer needs, so none is listed for deletion.`];
|
|
3849
|
+
if (cleanup.records.length === 0)
|
|
3850
|
+
return [`9pm lists no setup records to delete for ${name}.`];
|
|
3851
|
+
return setupCleanupLines(name, cleanup.records);
|
|
3852
|
+
}
|
|
3853
|
+
catch {
|
|
3854
|
+
return [`Couldn't read which setup records ${hostname} no longer needs, so none is listed for deletion.`];
|
|
3855
|
+
}
|
|
3856
|
+
}
|
|
3857
|
+
// A live domain's leftovers from setup (#2089): only values the API says it issued and that the
|
|
3858
|
+
// provider, read just now, no longer asks for. Each is conditional on its value for the same
|
|
3859
|
+
// reason as after a removal: a name like `_acme-challenge` can hold another service's token, and
|
|
3860
|
+
// only the value tells ours apart.
|
|
3861
|
+
export function setupCleanupLines(hostname, records) {
|
|
3862
|
+
const apex = guessZoneApex(hostname);
|
|
3863
|
+
const rows = [
|
|
3864
|
+
["TYPE", "NAME", "VALUE", "WAS FOR"],
|
|
3865
|
+
...records.map((record) => [
|
|
3866
|
+
record.type.toUpperCase(),
|
|
3867
|
+
relativeRecordName(record.host, apex),
|
|
3868
|
+
record.value,
|
|
3869
|
+
Object.hasOwn(CLEANUP_PURPOSES, record.purpose) ? (CLEANUP_PURPOSES[record.purpose] ?? "") : "",
|
|
3870
|
+
]),
|
|
3871
|
+
];
|
|
3872
|
+
return [
|
|
3873
|
+
`9pm no longer needs these setup records for ${hostname}, checked just now. You can delete each one where your domain's DNS is managed, only while it still has the value shown:`,
|
|
3874
|
+
...renderColumns(rows).map((line) => ` ${line}`),
|
|
3875
|
+
"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
|
+
];
|
|
3877
|
+
}
|
|
3878
|
+
// The command that lists what a removal finished in the background frees (#2068), aimed at the
|
|
3879
|
+
// same account and control plane as the command that printed it. Never a token.
|
|
3880
|
+
function domainsRemovedCommand(options, hostname) {
|
|
3881
|
+
const owner = options.owner ? ` --owner ${quoteSiteArg(options.owner)}` : "";
|
|
3882
|
+
return `9pm domains removed${hostname ? ` --hostname ${hostname}` : ""}${owner}${apiUrlFlagFor(options.target)}`;
|
|
3883
|
+
}
|
|
3884
|
+
// Said wherever a removal is left to finish in the background: nothing is deletable until it
|
|
3885
|
+
// has finished, and only the API's own list may name what is then. The background path keeps
|
|
3886
|
+
// that list for the owner (#2068), so the line points at the command that reads it.
|
|
3887
|
+
function releaseNoCleanupLine(options) {
|
|
3888
|
+
return `Leave its DNS records in place until the removal has finished. Once it has, this lists the ones you may delete: ${domainsRemovedCommand(options)}`;
|
|
3889
|
+
}
|
|
3808
3890
|
// A removal that has stopped serving but is still finishing upstream: nothing is called removed
|
|
3809
|
-
// and no record is offered for deletion.
|
|
3810
|
-
// and returns no cleanup to anyone, so no later command is promised to list the records either.
|
|
3891
|
+
// and no record is offered for deletion here.
|
|
3811
3892
|
export function domainRemovalPendingLines(hostname, app, options) {
|
|
3812
3893
|
const owner = options.owner ? ` --owner ${quoteSiteArg(options.owner)}` : "";
|
|
3813
3894
|
return [
|
|
3814
3895
|
`${app} no longer serves ${hostname}, but removing it upstream is taking longer than usual. It finishes on its own.`,
|
|
3815
3896
|
`Once ${hostname} is no longer in this list, it has stopped serving from 9pm: 9pm domains list ${quoteSiteArg(app)}${owner}${apiUrlFlagFor(options.target)}`,
|
|
3816
|
-
|
|
3897
|
+
releaseNoCleanupLine(options),
|
|
3817
3898
|
];
|
|
3818
3899
|
}
|
|
3900
|
+
// The server asks the certificate provider about at most ten removals per page and returns a
|
|
3901
|
+
// `next` cursor while more remain; this reads the pages in turn, up to this many, and merges
|
|
3902
|
+
// them. An older server sends no cursor, so its one page is the whole answer.
|
|
3903
|
+
export const FINISHED_REMOVAL_PAGES_PER_COMMAND = 20;
|
|
3904
|
+
export async function getFinishedDomainRemovals(options) {
|
|
3905
|
+
const hostname = options.hostname ? normalizeDomainArg(options.hostname) : null;
|
|
3906
|
+
let merged = null;
|
|
3907
|
+
let after = null;
|
|
3908
|
+
for (let page = 0; page < FINISHED_REMOVAL_PAGES_PER_COMMAND; page += 1) {
|
|
3909
|
+
const query = new URLSearchParams();
|
|
3910
|
+
if (options.owner)
|
|
3911
|
+
query.set("owner", options.owner);
|
|
3912
|
+
if (hostname)
|
|
3913
|
+
query.set("hostname", hostname);
|
|
3914
|
+
if (after)
|
|
3915
|
+
query.set("after", after);
|
|
3916
|
+
const body = await domainsRequest(options, `/v1/domains/finished-removals${query.size > 0 ? `?${query}` : ""}`);
|
|
3917
|
+
const fields = body && typeof body === "object" ? body : null;
|
|
3918
|
+
// Not a list at all: handed on as it came, so the printer says it couldn't read it.
|
|
3919
|
+
if (!fields || !Array.isArray(fields.removals))
|
|
3920
|
+
return merged ?? body;
|
|
3921
|
+
const unchecked = Array.isArray(fields.unchecked) ? fields.unchecked : [];
|
|
3922
|
+
const previous = merged;
|
|
3923
|
+
merged = previous
|
|
3924
|
+
? { ...previous, removals: [...previous.removals, ...fields.removals], unchecked: [...previous.unchecked, ...unchecked] }
|
|
3925
|
+
: { ...fields, removals: [...fields.removals], unchecked: [...unchecked] };
|
|
3926
|
+
const next = !hostname && typeof fields.next === "string" && fields.next !== "" ? fields.next : null;
|
|
3927
|
+
merged.next = next;
|
|
3928
|
+
if (!next)
|
|
3929
|
+
break;
|
|
3930
|
+
after = next;
|
|
3931
|
+
}
|
|
3932
|
+
// An older server ignores `hostname` and answers with every removal; keep only the one asked.
|
|
3933
|
+
if (merged && hostname) {
|
|
3934
|
+
const named = (entry) => {
|
|
3935
|
+
const fields = entry && typeof entry === "object" ? entry : null;
|
|
3936
|
+
return typeof fields?.hostname === "string" && normalizeDomainArg(fields.hostname) === hostname;
|
|
3937
|
+
};
|
|
3938
|
+
merged = { ...merged, removals: merged.removals.filter(named), unchecked: merged.unchecked.filter(named) };
|
|
3939
|
+
}
|
|
3940
|
+
return merged;
|
|
3941
|
+
}
|
|
3942
|
+
// The server's `removals`, kept only as far as each entry is well formed: an entry without a
|
|
3943
|
+
// readable cleanup list is dropped, never guessed at, and a body without a list is null.
|
|
3944
|
+
export function parseFinishedDomainRemovals(body) {
|
|
3945
|
+
try {
|
|
3946
|
+
const removals = body && typeof body === "object" ? body.removals : null;
|
|
3947
|
+
if (!Array.isArray(removals))
|
|
3948
|
+
return null;
|
|
3949
|
+
const kept = [];
|
|
3950
|
+
for (const entry of removals) {
|
|
3951
|
+
if (!entry || typeof entry !== "object")
|
|
3952
|
+
continue;
|
|
3953
|
+
const fields = entry;
|
|
3954
|
+
const cleanup = parseDomainCleanup(fields.cleanup);
|
|
3955
|
+
if (!cleanup || cleanup.records.length === 0)
|
|
3956
|
+
continue;
|
|
3957
|
+
const finishedAt = typeof fields.finishedAt === "string" && Number.isFinite(Date.parse(fields.finishedAt)) ? fields.finishedAt : null;
|
|
3958
|
+
kept.push({ hostname: cleanup.hostname, finishedAt, cleanup });
|
|
3959
|
+
}
|
|
3960
|
+
return kept;
|
|
3961
|
+
}
|
|
3962
|
+
catch {
|
|
3963
|
+
return null;
|
|
3964
|
+
}
|
|
3965
|
+
}
|
|
3966
|
+
// The hostnames the server withheld because it could not check them; anything malformed is
|
|
3967
|
+
// dropped, since it names nothing to delete either way.
|
|
3968
|
+
function uncheckedFinishedRemovals(value) {
|
|
3969
|
+
if (!Array.isArray(value))
|
|
3970
|
+
return [];
|
|
3971
|
+
const hostnames = [];
|
|
3972
|
+
for (const entry of value) {
|
|
3973
|
+
const hostname = entry && typeof entry === "object" ? entry.hostname : null;
|
|
3974
|
+
if (typeof hostname === "string" && hostname !== "" && !hostnames.includes(hostname))
|
|
3975
|
+
hostnames.push(hostname);
|
|
3976
|
+
}
|
|
3977
|
+
return hostnames;
|
|
3978
|
+
}
|
|
3979
|
+
// What `9pm domains removed` prints. Never throws, and never calls anything deletable that the
|
|
3980
|
+
// server did not list: an unreadable answer says so rather than reading as "nothing to delete".
|
|
3981
|
+
export function finishedDomainRemovalLines(body, options = {}) {
|
|
3982
|
+
try {
|
|
3983
|
+
const removals = parseFinishedDomainRemovals(body);
|
|
3984
|
+
if (removals === null)
|
|
3985
|
+
return ["Couldn't read which DNS records removed domains left behind, so none is listed for deletion."];
|
|
3986
|
+
const fields = body;
|
|
3987
|
+
const days = typeof fields.retentionDays === "number" && Number.isInteger(fields.retentionDays) && fields.retentionDays > 0 ? fields.retentionDays : null;
|
|
3988
|
+
const window = days ? ` in the last ${days} days` : "";
|
|
3989
|
+
const unchecked = uncheckedFinishedRemovals(fields.unchecked);
|
|
3990
|
+
if (removals.length === 0 && unchecked.length === 0) {
|
|
3991
|
+
return [`No domain removal that finished in the background${window} has DNS records to list.`];
|
|
3992
|
+
}
|
|
3993
|
+
const lines = [];
|
|
3994
|
+
for (const removal of removals) {
|
|
3995
|
+
lines.push(removal.finishedAt ? `Removing ${removal.hostname} finished at ${removal.finishedAt}.` : `Removing ${removal.hostname} has finished.`);
|
|
3996
|
+
lines.push(...domainCleanupLines(removal.cleanup));
|
|
3997
|
+
}
|
|
3998
|
+
// The server lists a removal only after checking the shared provider holds no hostname for
|
|
3999
|
+
// it (another environment may have added it again); one it could not check is withheld.
|
|
4000
|
+
for (const hostname of unchecked) {
|
|
4001
|
+
lines.push(`Couldn't check whether ${hostname} has been connected again elsewhere, so its records aren't listed. Leave them in place and run this again in a minute: ${domainsRemovedCommand(options, hostname)}`);
|
|
4002
|
+
}
|
|
4003
|
+
// More pages than one run reads: say so rather than let the list look complete.
|
|
4004
|
+
if (typeof fields.next === "string" && fields.next !== "") {
|
|
4005
|
+
lines.push(`There are more removals than one run lists. Ask about one domain at a time: ${domainsRemovedCommand(options, "<hostname>")}`);
|
|
4006
|
+
}
|
|
4007
|
+
lines.push(`A removal is listed here${days ? ` for ${days} days after it finishes` : " for a limited time after it finishes"}, and not at all once its domain is added again.`);
|
|
4008
|
+
return lines;
|
|
4009
|
+
}
|
|
4010
|
+
catch {
|
|
4011
|
+
return ["Couldn't read which DNS records removed domains left behind, so none is listed for deletion."];
|
|
4012
|
+
}
|
|
4013
|
+
}
|
|
3819
4014
|
export async function moveDomain(options) {
|
|
3820
4015
|
const hostname = normalizeDomainArg(options.hostname);
|
|
3821
4016
|
const body = await domainsRequest(options, siteDomainsRoute(options, hostname, "move"), {
|
|
@@ -4013,7 +4208,7 @@ export async function waitForDomain(options) {
|
|
|
4013
4208
|
await sleepFn(Math.min(options.intervalMs, remaining));
|
|
4014
4209
|
}
|
|
4015
4210
|
}
|
|
4016
|
-
const DOMAINS_VALUE_FLAGS = new Set(["token", "api-url", "owner", "timeout", "interval", "confirm", "to"]);
|
|
4211
|
+
const DOMAINS_VALUE_FLAGS = new Set(["token", "api-url", "owner", "timeout", "interval", "confirm", "to", "hostname"]);
|
|
4017
4212
|
const DOMAINS_BOOLEAN_FLAGS = new Set(["json"]);
|
|
4018
4213
|
function domainsPositionals(argv) {
|
|
4019
4214
|
const positionals = [];
|
|
@@ -4039,8 +4234,10 @@ const DOMAINS_USAGE = {
|
|
|
4039
4234
|
check: "9pm domains check <app> <hostname> [--owner <organisation>] [--json]",
|
|
4040
4235
|
wait: "9pm domains wait <app> <hostname> [--timeout 30m] [--interval 20s] [--owner <organisation>] [--json]",
|
|
4041
4236
|
restart: "9pm domains restart <app> <hostname> [--owner <organisation>] (for a failed certificate; the record values may change)",
|
|
4237
|
+
cleanup: "9pm domains cleanup <app> <hostname> [--owner <organisation>] [--json] (setup records a live domain no longer needs)",
|
|
4042
4238
|
remove: "9pm domains remove <app> <hostname> --confirm <hostname> [--owner <organisation>] [--json]",
|
|
4043
4239
|
move: "9pm domains move [<app>] <hostname> --to <app> [--owner <organisation>] [--json]",
|
|
4240
|
+
removed: "9pm domains removed [--hostname <hostname>] [--owner <organisation>] [--json] (DNS records you may delete after a removal that finished in the background)",
|
|
4044
4241
|
};
|
|
4045
4242
|
function parseDomainsArgs(argv) {
|
|
4046
4243
|
const subcommand = argv[0] === "ls" ? "list" : argv[0] === "rm" ? "remove" : argv[0];
|
|
@@ -4068,6 +4265,12 @@ function parseDomainsArgs(argv) {
|
|
|
4068
4265
|
const site = positionals[0];
|
|
4069
4266
|
return withBase({ kind: "list", json, ...(site ? { site } : {}) });
|
|
4070
4267
|
}
|
|
4268
|
+
if (subcommand === "removed") {
|
|
4269
|
+
if (positionals.length > 0)
|
|
4270
|
+
throw new Error(`Usage: ${usage}`);
|
|
4271
|
+
const hostname = readStringFlag(flags, "hostname");
|
|
4272
|
+
return withBase({ kind: "removed", json, ...(hostname ? { hostname } : {}) });
|
|
4273
|
+
}
|
|
4071
4274
|
if (subcommand === "move") {
|
|
4072
4275
|
const to = readStringFlag(flags, "to");
|
|
4073
4276
|
const [first, second] = positionals;
|
|
@@ -4094,6 +4297,9 @@ function parseDomainsArgs(argv) {
|
|
|
4094
4297
|
if (subcommand === "restart") {
|
|
4095
4298
|
return withBase({ kind: "restart", site, hostname });
|
|
4096
4299
|
}
|
|
4300
|
+
if (subcommand === "cleanup") {
|
|
4301
|
+
return withBase({ kind: "cleanup", site, hostname, json });
|
|
4302
|
+
}
|
|
4097
4303
|
const confirm = readStringFlag(flags, "confirm");
|
|
4098
4304
|
if (!confirm || normalizeDomainArg(confirm) !== normalizeDomainArg(hostname)) {
|
|
4099
4305
|
throw new Error(`Removing a domain stops it serving at once. Pass --confirm ${normalizeDomainArg(hostname)} to remove it.`);
|
|
@@ -4135,6 +4341,28 @@ async function runDomainsCommand(command) {
|
|
|
4135
4341
|
await runDomainMove(action, base, format);
|
|
4136
4342
|
return;
|
|
4137
4343
|
}
|
|
4344
|
+
if (action.kind === "removed") {
|
|
4345
|
+
let body;
|
|
4346
|
+
try {
|
|
4347
|
+
body = await getFinishedDomainRemovals({ ...base, ...(action.hostname ? { hostname: action.hostname } : {}) });
|
|
4348
|
+
}
|
|
4349
|
+
catch (error) {
|
|
4350
|
+
// A server from before #2068 has no such route; it keeps no list, so none is printed.
|
|
4351
|
+
if (error instanceof DomainsApiError && error.status === 404 && error.code === "not_found") {
|
|
4352
|
+
console.error("This server can't list the DNS records of removals that finished in the background yet, so none is listed. Leave the records in place.");
|
|
4353
|
+
process.exitCode = 1;
|
|
4354
|
+
return;
|
|
4355
|
+
}
|
|
4356
|
+
throw error;
|
|
4357
|
+
}
|
|
4358
|
+
if (action.json) {
|
|
4359
|
+
console.log(JSON.stringify(body, null, 2));
|
|
4360
|
+
return;
|
|
4361
|
+
}
|
|
4362
|
+
for (const line of finishedDomainRemovalLines(body, format))
|
|
4363
|
+
console.log(line);
|
|
4364
|
+
return;
|
|
4365
|
+
}
|
|
4138
4366
|
const target = { ...base, site: action.site, hostname: action.hostname };
|
|
4139
4367
|
if (action.kind === "add") {
|
|
4140
4368
|
const added = await addDomain(target);
|
|
@@ -4153,6 +4381,28 @@ async function runDomainsCommand(command) {
|
|
|
4153
4381
|
printDomainStatus(await getDomainStatus(target), action.json, format);
|
|
4154
4382
|
return;
|
|
4155
4383
|
}
|
|
4384
|
+
if (action.kind === "cleanup") {
|
|
4385
|
+
let body;
|
|
4386
|
+
try {
|
|
4387
|
+
body = await getDomainSetupCleanup(target);
|
|
4388
|
+
}
|
|
4389
|
+
catch (error) {
|
|
4390
|
+
// A server from before #2089 has no such route; it lists nothing, so neither does this.
|
|
4391
|
+
if (error instanceof DomainsApiError && error.status === 404 && error.code === "not_found") {
|
|
4392
|
+
console.error("This server can't list setup records to delete yet, so none is listed.");
|
|
4393
|
+
process.exitCode = 1;
|
|
4394
|
+
return;
|
|
4395
|
+
}
|
|
4396
|
+
throw error;
|
|
4397
|
+
}
|
|
4398
|
+
if (action.json) {
|
|
4399
|
+
console.log(JSON.stringify(body, null, 2));
|
|
4400
|
+
return;
|
|
4401
|
+
}
|
|
4402
|
+
for (const line of domainSetupCleanupLines(body, normalizeDomainArg(action.hostname)))
|
|
4403
|
+
console.log(line);
|
|
4404
|
+
return;
|
|
4405
|
+
}
|
|
4156
4406
|
if (action.kind === "check") {
|
|
4157
4407
|
await recheckDomain(target);
|
|
4158
4408
|
printDomainStatus(await getDomainStatus(target), action.json, format);
|
|
@@ -4188,7 +4438,7 @@ async function runDomainsCommand(command) {
|
|
|
4188
4438
|
const restarted = await restartDomain(target);
|
|
4189
4439
|
console.log(`Started validation for ${restarted.domain.hostname} over.`);
|
|
4190
4440
|
if (restarted.replacedInstructions.length > 0) {
|
|
4191
|
-
console.log("These values no longer count
|
|
4441
|
+
console.log("These values no longer count. At your DNS provider, delete each one only while it still has the value shown; a different value at that name is not ours:");
|
|
4192
4442
|
for (const line of instructionLines(restarted.replacedInstructions, restarted.domain.hostname))
|
|
4193
4443
|
console.log(line);
|
|
4194
4444
|
}
|
|
@@ -4375,6 +4625,208 @@ async function runLogsCommand(command) {
|
|
|
4375
4625
|
? "No detailed logs were captured for this failure."
|
|
4376
4626
|
: "No logs recorded. Logs are captured when a deployment fails to come up.");
|
|
4377
4627
|
}
|
|
4628
|
+
// `9pm qr` (#2160, _specs/app-qr-codes.md): a printable QR code for an app's address. The
|
|
4629
|
+
// control plane decides which addresses the code may encode (GET /v1/sites/:id/qr); this
|
|
4630
|
+
// only draws one of them, with the settings the dashboard uses (../shared/qr-code.ts).
|
|
4631
|
+
const QR_USAGE = "9pm qr <app> [--owner <organisation>] [--address <host>] [--format svg|png] [--out <file>] [--terminal] [--json]";
|
|
4632
|
+
const QR_VALUE_FLAGS = new Set(["owner", "address", "format", "out", "api-url", "token"]);
|
|
4633
|
+
const QR_BOOLEAN_FLAGS = new Set(["terminal", "json"]);
|
|
4634
|
+
// Pixels per module in the PNG: 16 keeps the typical code (37 modules with its border) near
|
|
4635
|
+
// 600 dpi at the 25 mm minimum print width. The dashboard's download uses the same figure.
|
|
4636
|
+
export const QR_PNG_PIXELS_PER_MODULE = 16;
|
|
4637
|
+
/**
|
|
4638
|
+
* The minimum width in centimetres, rounded UP to the millimetre: it is a floor, so rounding
|
|
4639
|
+
* to nearest would print a 26.5 mm minimum as "2.6 cm", below what the code needs.
|
|
4640
|
+
*/
|
|
4641
|
+
export function qrMinimumWidthCmText(minimumWidthMm) {
|
|
4642
|
+
return String(Math.ceil(minimumWidthMm) / 10);
|
|
4643
|
+
}
|
|
4644
|
+
export const QR_ACCESS_NOTE = "This app is private. Scanning the code doesn't grant access: people still have to sign in and be allowed in.";
|
|
4645
|
+
export function qrCustomDomainNote(hostname) {
|
|
4646
|
+
return `This code opens ${hostname}. If that domain later moves to another app or is removed, printed codes follow the domain, not this app.`;
|
|
4647
|
+
}
|
|
4648
|
+
const QR_UNAVAILABLE_MESSAGES = {
|
|
4649
|
+
not_published: "There's no QR code yet: one appears after the app's first successful deploy.",
|
|
4650
|
+
disabled: "This app is disabled, so there is no QR code to share.",
|
|
4651
|
+
service_access: "This app only accepts service credentials, which a phone camera can't present, so there is no QR code.",
|
|
4652
|
+
};
|
|
4653
|
+
function parseQrArgs(argv) {
|
|
4654
|
+
if (argv[0] === "help" || argv[0] === "--help")
|
|
4655
|
+
throw new UsageError();
|
|
4656
|
+
const flags = parseFlags(argv);
|
|
4657
|
+
const positionals = [];
|
|
4658
|
+
for (let index = 0; index < argv.length; index += 1) {
|
|
4659
|
+
const arg = argv[index] ?? "";
|
|
4660
|
+
if (!arg.startsWith("--")) {
|
|
4661
|
+
positionals.push(arg);
|
|
4662
|
+
continue;
|
|
4663
|
+
}
|
|
4664
|
+
const name = arg.slice(2);
|
|
4665
|
+
if (!QR_VALUE_FLAGS.has(name) && !QR_BOOLEAN_FLAGS.has(name))
|
|
4666
|
+
throw new Error(`Unknown flag for 9pm qr: --${name}`);
|
|
4667
|
+
if (QR_VALUE_FLAGS.has(name))
|
|
4668
|
+
index += 1;
|
|
4669
|
+
}
|
|
4670
|
+
// A value flag given without a value would otherwise fall back to its default, which for
|
|
4671
|
+
// --owner silently picks the personal account's app instead of the organisation's.
|
|
4672
|
+
for (const name of QR_VALUE_FLAGS) {
|
|
4673
|
+
if (flags.has(name) && !readStringFlag(flags, name))
|
|
4674
|
+
throw new Error(`--${name} needs a value.`);
|
|
4675
|
+
}
|
|
4676
|
+
const [site] = positionals;
|
|
4677
|
+
if (!site || positionals.length > 1)
|
|
4678
|
+
throw new Error(`Usage: ${QR_USAGE}`);
|
|
4679
|
+
const format = readStringFlag(flags, "format") ?? "svg";
|
|
4680
|
+
if (format !== "svg" && format !== "png")
|
|
4681
|
+
throw new Error("--format must be svg or png.");
|
|
4682
|
+
const owner = readStringFlag(flags, "owner");
|
|
4683
|
+
const address = readStringFlag(flags, "address");
|
|
4684
|
+
const out = readStringFlag(flags, "out");
|
|
4685
|
+
return {
|
|
4686
|
+
kind: "qr",
|
|
4687
|
+
...readBaseApiOptions(flags),
|
|
4688
|
+
site,
|
|
4689
|
+
format,
|
|
4690
|
+
terminal: flags.has("terminal"),
|
|
4691
|
+
json: flags.has("json"),
|
|
4692
|
+
...(owner ? { owner } : {}),
|
|
4693
|
+
...(address ? { address } : {}),
|
|
4694
|
+
...(out ? { out } : {}),
|
|
4695
|
+
};
|
|
4696
|
+
}
|
|
4697
|
+
/**
|
|
4698
|
+
* The returned address `--address` names, or an error listing the valid ones. The value is
|
|
4699
|
+
* normalized first - a bare hostname gets `https://`, case is ignored, a trailing `/` is
|
|
4700
|
+
* allowed - and must then match an address the server offered: no other host, path, query,
|
|
4701
|
+
* fragment, credentials, port or scheme. The CLI never encodes a URL the server did not offer.
|
|
4702
|
+
*/
|
|
4703
|
+
export function pickQrAddress(addresses, requested) {
|
|
4704
|
+
const fallback = addresses.find((address) => address.isDefault) ?? addresses[0];
|
|
4705
|
+
if (!fallback)
|
|
4706
|
+
throw new Error("The server offered no address for this app's QR code.");
|
|
4707
|
+
if (requested === undefined)
|
|
4708
|
+
return fallback;
|
|
4709
|
+
const valid = addresses.map((address) => address.hostname).join(", ");
|
|
4710
|
+
const refuse = (why) => {
|
|
4711
|
+
throw new Error(`${why} Use one of: ${valid}.`);
|
|
4712
|
+
};
|
|
4713
|
+
const raw = requested.trim();
|
|
4714
|
+
const withScheme = /^[a-z][a-z0-9+.-]*:/i.test(raw) ? raw : `https://${raw}`;
|
|
4715
|
+
let parsed;
|
|
4716
|
+
try {
|
|
4717
|
+
parsed = new URL(withScheme);
|
|
4718
|
+
}
|
|
4719
|
+
catch {
|
|
4720
|
+
return refuse(`--address ${requested} is not a hostname or URL.`);
|
|
4721
|
+
}
|
|
4722
|
+
if (parsed.protocol !== "https:")
|
|
4723
|
+
return refuse(`--address must be an https address.`);
|
|
4724
|
+
if (parsed.username || parsed.password || parsed.port)
|
|
4725
|
+
return refuse(`--address must be a plain hostname.`);
|
|
4726
|
+
if ((parsed.pathname !== "/" && parsed.pathname !== "") || parsed.search || parsed.hash) {
|
|
4727
|
+
return refuse(`--address must be a hostname, with no path, query or fragment.`);
|
|
4728
|
+
}
|
|
4729
|
+
const match = addresses.find((address) => address.hostname.toLowerCase() === parsed.hostname.toLowerCase());
|
|
4730
|
+
return match ?? refuse(`${parsed.hostname} is not an address of this app.`);
|
|
4731
|
+
}
|
|
4732
|
+
// CRC-32 as PNG chunks use it (ISO 3309 polynomial, reflected).
|
|
4733
|
+
const PNG_CRC_TABLE = (() => {
|
|
4734
|
+
const table = new Uint32Array(256);
|
|
4735
|
+
for (let n = 0; n < 256; n += 1) {
|
|
4736
|
+
let c = n;
|
|
4737
|
+
for (let k = 0; k < 8; k += 1)
|
|
4738
|
+
c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
|
|
4739
|
+
table[n] = c >>> 0;
|
|
4740
|
+
}
|
|
4741
|
+
return table;
|
|
4742
|
+
})();
|
|
4743
|
+
function pngChunk(type, data) {
|
|
4744
|
+
const typed = Buffer.concat([Buffer.from(type, "ascii"), data]);
|
|
4745
|
+
let crc = 0xffffffff;
|
|
4746
|
+
for (const byte of typed)
|
|
4747
|
+
crc = (PNG_CRC_TABLE[(crc ^ byte) & 0xff] ?? 0) ^ (crc >>> 8);
|
|
4748
|
+
const length = Buffer.alloc(4);
|
|
4749
|
+
length.writeUInt32BE(data.length);
|
|
4750
|
+
const checksum = Buffer.alloc(4);
|
|
4751
|
+
checksum.writeUInt32BE((crc ^ 0xffffffff) >>> 0);
|
|
4752
|
+
return Buffer.concat([length, typed, checksum]);
|
|
4753
|
+
}
|
|
4754
|
+
/** An 8-bit greyscale PNG of a module matrix (quiet zone included), black on white. */
|
|
4755
|
+
export function encodeQrPng(modules, scale = QR_PNG_PIXELS_PER_MODULE) {
|
|
4756
|
+
const side = modules.length * scale;
|
|
4757
|
+
const header = Buffer.alloc(13);
|
|
4758
|
+
header.writeUInt32BE(side, 0);
|
|
4759
|
+
header.writeUInt32BE(side, 4);
|
|
4760
|
+
header[8] = 8; // bit depth
|
|
4761
|
+
header[9] = 0; // greyscale
|
|
4762
|
+
const raw = Buffer.alloc(side * (side + 1), 0xff);
|
|
4763
|
+
for (let y = 0; y < side; y += 1) {
|
|
4764
|
+
const rowStart = y * (side + 1);
|
|
4765
|
+
raw[rowStart] = 0; // filter: none
|
|
4766
|
+
const moduleRow = modules[Math.floor(y / scale)] ?? [];
|
|
4767
|
+
for (let x = 0; x < side; x += 1) {
|
|
4768
|
+
if (moduleRow[Math.floor(x / scale)])
|
|
4769
|
+
raw[rowStart + 1 + x] = 0;
|
|
4770
|
+
}
|
|
4771
|
+
}
|
|
4772
|
+
return Buffer.concat([
|
|
4773
|
+
Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]),
|
|
4774
|
+
pngChunk("IHDR", header),
|
|
4775
|
+
pngChunk("IDAT", deflateSync(raw)),
|
|
4776
|
+
pngChunk("IEND", Buffer.alloc(0)),
|
|
4777
|
+
]);
|
|
4778
|
+
}
|
|
4779
|
+
function qrFileStem(site) {
|
|
4780
|
+
return site.toLowerCase().replace(/[^a-z0-9-]+/g, "-").replace(/^-+|-+$/g, "") || "app";
|
|
4781
|
+
}
|
|
4782
|
+
async function runQrCommand(command) {
|
|
4783
|
+
const route = `/v1/sites/${encodeURIComponent(command.site)}/qr${ownerQuery(command.owner)}`;
|
|
4784
|
+
let qr;
|
|
4785
|
+
try {
|
|
4786
|
+
qr = await domainsRequest({ apiUrl: command.apiUrl, token: command.token }, route);
|
|
4787
|
+
}
|
|
4788
|
+
catch (error) {
|
|
4789
|
+
if (error instanceof DomainsApiError && error.status === 404 && error.code === "not_found") {
|
|
4790
|
+
console.error("This server can't make QR codes yet.");
|
|
4791
|
+
process.exitCode = 1;
|
|
4792
|
+
return;
|
|
4793
|
+
}
|
|
4794
|
+
if (error instanceof DomainsApiError && error.status === 404 && (error.code === "project_not_found" || error.code === "organisation_not_found")) {
|
|
4795
|
+
console.error(`App not found: ${command.site}. Check the name, or pass --owner <organisation> for an organisation's app.`);
|
|
4796
|
+
process.exitCode = 1;
|
|
4797
|
+
return;
|
|
4798
|
+
}
|
|
4799
|
+
throw error;
|
|
4800
|
+
}
|
|
4801
|
+
if (!qr.available) {
|
|
4802
|
+
console.error(QR_UNAVAILABLE_MESSAGES[qr.reason] ?? `There is no QR code for this app (${qr.reason}).`);
|
|
4803
|
+
process.exitCode = 1;
|
|
4804
|
+
return;
|
|
4805
|
+
}
|
|
4806
|
+
const address = pickQrAddress(qr.addresses, command.address);
|
|
4807
|
+
const file = command.out ?? `${qrFileStem(command.site)}-qr.${command.format}`;
|
|
4808
|
+
const bytes = command.format === "svg" ? qrSvg(address.url) : encodeQrPng(qrMatrix(address.url).modules);
|
|
4809
|
+
writeFileSync(file, bytes);
|
|
4810
|
+
const minimumWidthMm = qrMinimumWidthMm(address.url);
|
|
4811
|
+
const accessNote = qr.accessMode === "private_otp" || qr.accessMode === "private_idp" ? QR_ACCESS_NOTE : null;
|
|
4812
|
+
const customDomainNote = address.kind === "custom_domain" ? qrCustomDomainNote(address.hostname) : null;
|
|
4813
|
+
if (command.json) {
|
|
4814
|
+
console.log(JSON.stringify({ url: address.url, hostname: address.hostname, kind: address.kind, file, format: command.format, minimumWidthMm, accessMode: qr.accessMode, accessNote, customDomainNote }, null, 2));
|
|
4815
|
+
return;
|
|
4816
|
+
}
|
|
4817
|
+
console.log(`Saved ${file}`);
|
|
4818
|
+
console.log(`It opens ${address.url}`);
|
|
4819
|
+
console.log(`Print it at least ${qrMinimumWidthCmText(minimumWidthMm)} cm wide, and larger if people will scan it from further away.`);
|
|
4820
|
+
if (customDomainNote)
|
|
4821
|
+
console.log(customDomainNote);
|
|
4822
|
+
if (accessNote)
|
|
4823
|
+
console.log(accessNote);
|
|
4824
|
+
if (command.terminal) {
|
|
4825
|
+
console.log("");
|
|
4826
|
+
for (const line of qrTerminal(address.url))
|
|
4827
|
+
console.log(line);
|
|
4828
|
+
}
|
|
4829
|
+
}
|
|
4378
4830
|
function parseLogsArgs(argv) {
|
|
4379
4831
|
const site = argv[0];
|
|
4380
4832
|
if (!site || site.startsWith("--")) {
|
|
@@ -5868,11 +6320,14 @@ async function main() {
|
|
|
5868
6320
|
confirmSlug: command.confirmSlug,
|
|
5869
6321
|
});
|
|
5870
6322
|
console.log(`Deleted ${deleted.slug}.`);
|
|
6323
|
+
if (deletedAppStopsServingQuickly(deleted)) {
|
|
6324
|
+
console.log(DELETE_SERVING_BOUND_LINE);
|
|
6325
|
+
}
|
|
5871
6326
|
console.log(`Removed ${deleted.workers} Worker(s), ${deleted.databases} database(s), ${deleted.files} file(s), ${deleted.customDomains} custom domain(s), ${deleted.containerImages ?? 0} container image(s), ${deleted.accessApps} access app(s), and ${deleted.serviceTokens} service token(s). Queued ${deleted.containerApplications ?? 0} container app(s) for teardown.`);
|
|
5872
6327
|
if (deleted.customDomainNames && deleted.customDomainNames.length > 0) {
|
|
5873
6328
|
console.log(`Removed custom domain(s): ${deleted.customDomainNames.join(", ")}.`);
|
|
5874
6329
|
}
|
|
5875
|
-
for (const line of deletedDomainLines(deleted))
|
|
6330
|
+
for (const line of deletedDomainLines(deleted, { target: { apiUrl: command.apiUrl, apiUrlFrom: command.apiUrlFrom ?? "default" } }))
|
|
5876
6331
|
console.log(line);
|
|
5877
6332
|
// #722: a partial external-cleanup failure never fails the delete, and used to be
|
|
5878
6333
|
// silently discarded here — visible only in the raw JSON, if a caller happened to
|
|
@@ -5922,6 +6377,10 @@ async function main() {
|
|
|
5922
6377
|
await runDomainsCommand(command);
|
|
5923
6378
|
return;
|
|
5924
6379
|
}
|
|
6380
|
+
if (command.kind === "qr") {
|
|
6381
|
+
await runQrCommand(command);
|
|
6382
|
+
return;
|
|
6383
|
+
}
|
|
5925
6384
|
if (command.kind === "logs") {
|
|
5926
6385
|
await runLogsCommand(command);
|
|
5927
6386
|
return;
|
|
@@ -6266,6 +6725,8 @@ function printUsage() {
|
|
|
6266
6725
|
9pm deployments ls <app> [--json]
|
|
6267
6726
|
9pm deployments rm <deployment-id>
|
|
6268
6727
|
9pm logs <app> [--lines <n>] [--json]
|
|
6728
|
+
9pm qr <app> [--owner <organisation>] [--address <host>] [--format svg|png] [--out <file>] [--terminal] [--json]
|
|
6729
|
+
save a printable QR code for the app's address (SVG by default)
|
|
6269
6730
|
9pm delete <site> --confirm <slug>
|
|
6270
6731
|
9pm domains list [<app>] [--owner <organisation>] [--json]
|
|
6271
6732
|
9pm domains add <app> <hostname> [--json] connect a domain you own; prints the DNS records to publish
|
|
@@ -6273,8 +6734,10 @@ function printUsage() {
|
|
|
6273
6734
|
9pm domains check <app> <hostname> [--json] re-check now, then print the status
|
|
6274
6735
|
9pm domains wait <app> <hostname> [--timeout 30m] [--interval 20s] [--json]
|
|
6275
6736
|
9pm domains restart <app> <hostname> for a failed certificate only: start validation over; record values may change
|
|
6737
|
+
9pm domains cleanup <app> <hostname> [--json] on a live domain: setup records 9pm no longer needs, checked with the provider now
|
|
6276
6738
|
9pm domains remove <app> <hostname> --confirm <hostname> [--json] prints the DNS records you can then delete
|
|
6277
6739
|
9pm domains move [<app>] <hostname> --to <app> [--json] to another app of the same account; keeps its certificate and records
|
|
6740
|
+
9pm domains removed [--hostname <h>] [--json] DNS records you may delete after a removal that finished in the background
|
|
6278
6741
|
9pm access show <site>
|
|
6279
6742
|
9pm access public <site> [--switch-mode]
|
|
6280
6743
|
9pm access otp <site> [--switch-mode]
|