ninepm 0.83.0 → 0.87.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 +30 -3
- package/dist/index.d.ts +50 -2
- package/dist/index.js +409 -13
- 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,15 +71,26 @@ 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)}`);
|
|
75
|
+
}
|
|
76
|
+
// The consequence is said for the domains whose removal finished; one still releasing is
|
|
77
|
+
// told to leave its records alone, which repointing them would contradict.
|
|
78
|
+
const finished = [];
|
|
79
|
+
const names = Array.isArray(deleted.customDomainNames) ? deleted.customDomainNames : [];
|
|
80
|
+
for (const hostname of [...names, ...cleanups.map((cleanup) => cleanup?.hostname)]) {
|
|
81
|
+
if (typeof hostname !== "string" || hostname === "" || releasing.includes(hostname) || finished.includes(hostname))
|
|
82
|
+
continue;
|
|
83
|
+
finished.push(hostname);
|
|
74
84
|
}
|
|
85
|
+
if (finished.length > 0)
|
|
86
|
+
lines.push(...domainRemovedConsequenceLines(finished, options));
|
|
75
87
|
}
|
|
76
88
|
catch {
|
|
77
89
|
return lines;
|
|
78
90
|
}
|
|
79
91
|
return lines;
|
|
80
92
|
}
|
|
81
|
-
export const CLI_VERSION = "0.
|
|
93
|
+
export const CLI_VERSION = "0.87.0";
|
|
82
94
|
const PUBLIC_DASHBOARD_URL = "https://9pm.ai/dashboard";
|
|
83
95
|
const TOKEN_SERVICE = "9pm";
|
|
84
96
|
const TOKEN_ACCOUNT = "default";
|
|
@@ -2707,6 +2719,9 @@ export function parseArgs(argv) {
|
|
|
2707
2719
|
if (command === "domains") {
|
|
2708
2720
|
return parseDomainsArgs(argv.slice(1));
|
|
2709
2721
|
}
|
|
2722
|
+
if (command === "qr") {
|
|
2723
|
+
return parseQrArgs(argv.slice(1));
|
|
2724
|
+
}
|
|
2710
2725
|
if (command === "logs") {
|
|
2711
2726
|
return parseLogsArgs(argv.slice(1));
|
|
2712
2727
|
}
|
|
@@ -2981,7 +2996,7 @@ function parseDeleteArgs(argv) {
|
|
|
2981
2996
|
if (!confirmSlug) {
|
|
2982
2997
|
throw new Error("Missing delete confirmation. Pass --confirm <slug> matching the app slug.");
|
|
2983
2998
|
}
|
|
2984
|
-
return { kind: "delete", ...base, site, confirmSlug };
|
|
2999
|
+
return { kind: "delete", ...base, apiUrlFrom: apiUrlSource(flags), site, confirmSlug };
|
|
2985
3000
|
}
|
|
2986
3001
|
function parseFilesArgs(argv) {
|
|
2987
3002
|
const subcommand = argv[0];
|
|
@@ -3871,20 +3886,161 @@ export function setupCleanupLines(hostname, records) {
|
|
|
3871
3886
|
"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.",
|
|
3872
3887
|
];
|
|
3873
3888
|
}
|
|
3874
|
-
//
|
|
3875
|
-
//
|
|
3876
|
-
|
|
3889
|
+
// The public guide's section on moving a domain off 9pm, on the site beside the control plane
|
|
3890
|
+
// this command talked to, so a staging run links staging's copy of the guide.
|
|
3891
|
+
export function customDomainMoveOffGuideUrl(options = {}) {
|
|
3892
|
+
const dashboard = options.target ? dashboardUrlForApiUrl(options.target.apiUrl) : PUBLIC_DASHBOARD_URL;
|
|
3893
|
+
return `${dashboard.replace(/\/dashboard$/, "")}/docs/custom-domains#move-off`;
|
|
3894
|
+
}
|
|
3895
|
+
// Said once a removal has finished (#2089): 9pm no longer holds the hostname, so while DNS
|
|
3896
|
+
// still points here a visitor reaches nothing that can answer for it. Which error they see is
|
|
3897
|
+
// not ours to promise. It names no record: what may be deleted is only the cleanup list above
|
|
3898
|
+
// it, which carries its own value condition, so this adds no deletion advice of its own. The
|
|
3899
|
+
// order is the one the staging rehearsal demonstrated for a move to another host (#2182,
|
|
3900
|
+
// scenario 2). A move to the owner's own Cloudflare zone was not rehearsed, so it gets no order.
|
|
3901
|
+
export function domainRemovedConsequenceLines(hostnames, options = {}) {
|
|
3902
|
+
const subject = hostnames.length === 1 ? hostnames[0] : hostnames.join(", ");
|
|
3903
|
+
return [
|
|
3904
|
+
`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 may show the domain as failed; that is expected, so remove it. Guide: ${customDomainMoveOffGuideUrl(options)}`,
|
|
3906
|
+
];
|
|
3907
|
+
}
|
|
3908
|
+
// The command that lists what a removal finished in the background frees (#2068), aimed at the
|
|
3909
|
+
// same account and control plane as the command that printed it. Never a token.
|
|
3910
|
+
function domainsRemovedCommand(options, hostname) {
|
|
3911
|
+
const owner = options.owner ? ` --owner ${quoteSiteArg(options.owner)}` : "";
|
|
3912
|
+
return `9pm domains removed${hostname ? ` --hostname ${hostname}` : ""}${owner}${apiUrlFlagFor(options.target)}`;
|
|
3913
|
+
}
|
|
3914
|
+
// Said wherever a removal is left to finish in the background: nothing is deletable until it
|
|
3915
|
+
// has finished, and only the API's own list may name what is then. The background path keeps
|
|
3916
|
+
// that list for the owner (#2068), so the line points at the command that reads it.
|
|
3917
|
+
function releaseNoCleanupLine(options) {
|
|
3918
|
+
return `Leave its DNS records in place until the removal has finished. Once it has, this lists the ones you may delete: ${domainsRemovedCommand(options)}`;
|
|
3919
|
+
}
|
|
3877
3920
|
// A removal that has stopped serving but is still finishing upstream: nothing is called removed
|
|
3878
|
-
// and no record is offered for deletion.
|
|
3879
|
-
// and returns no cleanup to anyone, so no later command is promised to list the records either.
|
|
3921
|
+
// and no record is offered for deletion here.
|
|
3880
3922
|
export function domainRemovalPendingLines(hostname, app, options) {
|
|
3881
3923
|
const owner = options.owner ? ` --owner ${quoteSiteArg(options.owner)}` : "";
|
|
3882
3924
|
return [
|
|
3883
3925
|
`${app} no longer serves ${hostname}, but removing it upstream is taking longer than usual. It finishes on its own.`,
|
|
3884
3926
|
`Once ${hostname} is no longer in this list, it has stopped serving from 9pm: 9pm domains list ${quoteSiteArg(app)}${owner}${apiUrlFlagFor(options.target)}`,
|
|
3885
|
-
|
|
3927
|
+
releaseNoCleanupLine(options),
|
|
3886
3928
|
];
|
|
3887
3929
|
}
|
|
3930
|
+
// The server asks the certificate provider about at most ten removals per page and returns a
|
|
3931
|
+
// `next` cursor while more remain; this reads the pages in turn, up to this many, and merges
|
|
3932
|
+
// them. An older server sends no cursor, so its one page is the whole answer.
|
|
3933
|
+
export const FINISHED_REMOVAL_PAGES_PER_COMMAND = 20;
|
|
3934
|
+
export async function getFinishedDomainRemovals(options) {
|
|
3935
|
+
const hostname = options.hostname ? normalizeDomainArg(options.hostname) : null;
|
|
3936
|
+
let merged = null;
|
|
3937
|
+
let after = null;
|
|
3938
|
+
for (let page = 0; page < FINISHED_REMOVAL_PAGES_PER_COMMAND; page += 1) {
|
|
3939
|
+
const query = new URLSearchParams();
|
|
3940
|
+
if (options.owner)
|
|
3941
|
+
query.set("owner", options.owner);
|
|
3942
|
+
if (hostname)
|
|
3943
|
+
query.set("hostname", hostname);
|
|
3944
|
+
if (after)
|
|
3945
|
+
query.set("after", after);
|
|
3946
|
+
const body = await domainsRequest(options, `/v1/domains/finished-removals${query.size > 0 ? `?${query}` : ""}`);
|
|
3947
|
+
const fields = body && typeof body === "object" ? body : null;
|
|
3948
|
+
// Not a list at all: handed on as it came, so the printer says it couldn't read it.
|
|
3949
|
+
if (!fields || !Array.isArray(fields.removals))
|
|
3950
|
+
return merged ?? body;
|
|
3951
|
+
const unchecked = Array.isArray(fields.unchecked) ? fields.unchecked : [];
|
|
3952
|
+
const previous = merged;
|
|
3953
|
+
merged = previous
|
|
3954
|
+
? { ...previous, removals: [...previous.removals, ...fields.removals], unchecked: [...previous.unchecked, ...unchecked] }
|
|
3955
|
+
: { ...fields, removals: [...fields.removals], unchecked: [...unchecked] };
|
|
3956
|
+
const next = !hostname && typeof fields.next === "string" && fields.next !== "" ? fields.next : null;
|
|
3957
|
+
merged.next = next;
|
|
3958
|
+
if (!next)
|
|
3959
|
+
break;
|
|
3960
|
+
after = next;
|
|
3961
|
+
}
|
|
3962
|
+
// An older server ignores `hostname` and answers with every removal; keep only the one asked.
|
|
3963
|
+
if (merged && hostname) {
|
|
3964
|
+
const named = (entry) => {
|
|
3965
|
+
const fields = entry && typeof entry === "object" ? entry : null;
|
|
3966
|
+
return typeof fields?.hostname === "string" && normalizeDomainArg(fields.hostname) === hostname;
|
|
3967
|
+
};
|
|
3968
|
+
merged = { ...merged, removals: merged.removals.filter(named), unchecked: merged.unchecked.filter(named) };
|
|
3969
|
+
}
|
|
3970
|
+
return merged;
|
|
3971
|
+
}
|
|
3972
|
+
// The server's `removals`, kept only as far as each entry is well formed: an entry without a
|
|
3973
|
+
// readable cleanup list is dropped, never guessed at, and a body without a list is null.
|
|
3974
|
+
export function parseFinishedDomainRemovals(body) {
|
|
3975
|
+
try {
|
|
3976
|
+
const removals = body && typeof body === "object" ? body.removals : null;
|
|
3977
|
+
if (!Array.isArray(removals))
|
|
3978
|
+
return null;
|
|
3979
|
+
const kept = [];
|
|
3980
|
+
for (const entry of removals) {
|
|
3981
|
+
if (!entry || typeof entry !== "object")
|
|
3982
|
+
continue;
|
|
3983
|
+
const fields = entry;
|
|
3984
|
+
const cleanup = parseDomainCleanup(fields.cleanup);
|
|
3985
|
+
if (!cleanup || cleanup.records.length === 0)
|
|
3986
|
+
continue;
|
|
3987
|
+
const finishedAt = typeof fields.finishedAt === "string" && Number.isFinite(Date.parse(fields.finishedAt)) ? fields.finishedAt : null;
|
|
3988
|
+
kept.push({ hostname: cleanup.hostname, finishedAt, cleanup });
|
|
3989
|
+
}
|
|
3990
|
+
return kept;
|
|
3991
|
+
}
|
|
3992
|
+
catch {
|
|
3993
|
+
return null;
|
|
3994
|
+
}
|
|
3995
|
+
}
|
|
3996
|
+
// The hostnames the server withheld because it could not check them; anything malformed is
|
|
3997
|
+
// dropped, since it names nothing to delete either way.
|
|
3998
|
+
function uncheckedFinishedRemovals(value) {
|
|
3999
|
+
if (!Array.isArray(value))
|
|
4000
|
+
return [];
|
|
4001
|
+
const hostnames = [];
|
|
4002
|
+
for (const entry of value) {
|
|
4003
|
+
const hostname = entry && typeof entry === "object" ? entry.hostname : null;
|
|
4004
|
+
if (typeof hostname === "string" && hostname !== "" && !hostnames.includes(hostname))
|
|
4005
|
+
hostnames.push(hostname);
|
|
4006
|
+
}
|
|
4007
|
+
return hostnames;
|
|
4008
|
+
}
|
|
4009
|
+
// What `9pm domains removed` prints. Never throws, and never calls anything deletable that the
|
|
4010
|
+
// server did not list: an unreadable answer says so rather than reading as "nothing to delete".
|
|
4011
|
+
export function finishedDomainRemovalLines(body, options = {}) {
|
|
4012
|
+
try {
|
|
4013
|
+
const removals = parseFinishedDomainRemovals(body);
|
|
4014
|
+
if (removals === null)
|
|
4015
|
+
return ["Couldn't read which DNS records removed domains left behind, so none is listed for deletion."];
|
|
4016
|
+
const fields = body;
|
|
4017
|
+
const days = typeof fields.retentionDays === "number" && Number.isInteger(fields.retentionDays) && fields.retentionDays > 0 ? fields.retentionDays : null;
|
|
4018
|
+
const window = days ? ` in the last ${days} days` : "";
|
|
4019
|
+
const unchecked = uncheckedFinishedRemovals(fields.unchecked);
|
|
4020
|
+
if (removals.length === 0 && unchecked.length === 0) {
|
|
4021
|
+
return [`No domain removal that finished in the background${window} has DNS records to list.`];
|
|
4022
|
+
}
|
|
4023
|
+
const lines = [];
|
|
4024
|
+
for (const removal of removals) {
|
|
4025
|
+
lines.push(removal.finishedAt ? `Removing ${removal.hostname} finished at ${removal.finishedAt}.` : `Removing ${removal.hostname} has finished.`);
|
|
4026
|
+
lines.push(...domainCleanupLines(removal.cleanup));
|
|
4027
|
+
}
|
|
4028
|
+
// The server lists a removal only after checking the shared provider holds no hostname for
|
|
4029
|
+
// it (another environment may have added it again); one it could not check is withheld.
|
|
4030
|
+
for (const hostname of unchecked) {
|
|
4031
|
+
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)}`);
|
|
4032
|
+
}
|
|
4033
|
+
// More pages than one run reads: say so rather than let the list look complete.
|
|
4034
|
+
if (typeof fields.next === "string" && fields.next !== "") {
|
|
4035
|
+
lines.push(`There are more removals than one run lists. Ask about one domain at a time: ${domainsRemovedCommand(options, "<hostname>")}`);
|
|
4036
|
+
}
|
|
4037
|
+
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.`);
|
|
4038
|
+
return lines;
|
|
4039
|
+
}
|
|
4040
|
+
catch {
|
|
4041
|
+
return ["Couldn't read which DNS records removed domains left behind, so none is listed for deletion."];
|
|
4042
|
+
}
|
|
4043
|
+
}
|
|
3888
4044
|
export async function moveDomain(options) {
|
|
3889
4045
|
const hostname = normalizeDomainArg(options.hostname);
|
|
3890
4046
|
const body = await domainsRequest(options, siteDomainsRoute(options, hostname, "move"), {
|
|
@@ -4082,7 +4238,7 @@ export async function waitForDomain(options) {
|
|
|
4082
4238
|
await sleepFn(Math.min(options.intervalMs, remaining));
|
|
4083
4239
|
}
|
|
4084
4240
|
}
|
|
4085
|
-
const DOMAINS_VALUE_FLAGS = new Set(["token", "api-url", "owner", "timeout", "interval", "confirm", "to"]);
|
|
4241
|
+
const DOMAINS_VALUE_FLAGS = new Set(["token", "api-url", "owner", "timeout", "interval", "confirm", "to", "hostname"]);
|
|
4086
4242
|
const DOMAINS_BOOLEAN_FLAGS = new Set(["json"]);
|
|
4087
4243
|
function domainsPositionals(argv) {
|
|
4088
4244
|
const positionals = [];
|
|
@@ -4111,6 +4267,7 @@ const DOMAINS_USAGE = {
|
|
|
4111
4267
|
cleanup: "9pm domains cleanup <app> <hostname> [--owner <organisation>] [--json] (setup records a live domain no longer needs)",
|
|
4112
4268
|
remove: "9pm domains remove <app> <hostname> --confirm <hostname> [--owner <organisation>] [--json]",
|
|
4113
4269
|
move: "9pm domains move [<app>] <hostname> --to <app> [--owner <organisation>] [--json]",
|
|
4270
|
+
removed: "9pm domains removed [--hostname <hostname>] [--owner <organisation>] [--json] (DNS records you may delete after a removal that finished in the background)",
|
|
4114
4271
|
};
|
|
4115
4272
|
function parseDomainsArgs(argv) {
|
|
4116
4273
|
const subcommand = argv[0] === "ls" ? "list" : argv[0] === "rm" ? "remove" : argv[0];
|
|
@@ -4138,6 +4295,12 @@ function parseDomainsArgs(argv) {
|
|
|
4138
4295
|
const site = positionals[0];
|
|
4139
4296
|
return withBase({ kind: "list", json, ...(site ? { site } : {}) });
|
|
4140
4297
|
}
|
|
4298
|
+
if (subcommand === "removed") {
|
|
4299
|
+
if (positionals.length > 0)
|
|
4300
|
+
throw new Error(`Usage: ${usage}`);
|
|
4301
|
+
const hostname = readStringFlag(flags, "hostname");
|
|
4302
|
+
return withBase({ kind: "removed", json, ...(hostname ? { hostname } : {}) });
|
|
4303
|
+
}
|
|
4141
4304
|
if (subcommand === "move") {
|
|
4142
4305
|
const to = readStringFlag(flags, "to");
|
|
4143
4306
|
const [first, second] = positionals;
|
|
@@ -4208,6 +4371,28 @@ async function runDomainsCommand(command) {
|
|
|
4208
4371
|
await runDomainMove(action, base, format);
|
|
4209
4372
|
return;
|
|
4210
4373
|
}
|
|
4374
|
+
if (action.kind === "removed") {
|
|
4375
|
+
let body;
|
|
4376
|
+
try {
|
|
4377
|
+
body = await getFinishedDomainRemovals({ ...base, ...(action.hostname ? { hostname: action.hostname } : {}) });
|
|
4378
|
+
}
|
|
4379
|
+
catch (error) {
|
|
4380
|
+
// A server from before #2068 has no such route; it keeps no list, so none is printed.
|
|
4381
|
+
if (error instanceof DomainsApiError && error.status === 404 && error.code === "not_found") {
|
|
4382
|
+
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.");
|
|
4383
|
+
process.exitCode = 1;
|
|
4384
|
+
return;
|
|
4385
|
+
}
|
|
4386
|
+
throw error;
|
|
4387
|
+
}
|
|
4388
|
+
if (action.json) {
|
|
4389
|
+
console.log(JSON.stringify(body, null, 2));
|
|
4390
|
+
return;
|
|
4391
|
+
}
|
|
4392
|
+
for (const line of finishedDomainRemovalLines(body, format))
|
|
4393
|
+
console.log(line);
|
|
4394
|
+
return;
|
|
4395
|
+
}
|
|
4211
4396
|
const target = { ...base, site: action.site, hostname: action.hostname };
|
|
4212
4397
|
if (action.kind === "add") {
|
|
4213
4398
|
const added = await addDomain(target);
|
|
@@ -4311,6 +4496,8 @@ async function runDomainsCommand(command) {
|
|
|
4311
4496
|
for (const line of domainCleanupLines(removal.cleanup))
|
|
4312
4497
|
console.log(line);
|
|
4313
4498
|
}
|
|
4499
|
+
for (const line of domainRemovedConsequenceLines([removal.hostname], format))
|
|
4500
|
+
console.log(line);
|
|
4314
4501
|
}
|
|
4315
4502
|
async function runDomainMove(action, base, format) {
|
|
4316
4503
|
const hostname = normalizeDomainArg(action.hostname);
|
|
@@ -4470,6 +4657,208 @@ async function runLogsCommand(command) {
|
|
|
4470
4657
|
? "No detailed logs were captured for this failure."
|
|
4471
4658
|
: "No logs recorded. Logs are captured when a deployment fails to come up.");
|
|
4472
4659
|
}
|
|
4660
|
+
// `9pm qr` (#2160, _specs/app-qr-codes.md): a printable QR code for an app's address. The
|
|
4661
|
+
// control plane decides which addresses the code may encode (GET /v1/sites/:id/qr); this
|
|
4662
|
+
// only draws one of them, with the settings the dashboard uses (../shared/qr-code.ts).
|
|
4663
|
+
const QR_USAGE = "9pm qr <app> [--owner <organisation>] [--address <host>] [--format svg|png] [--out <file>] [--terminal] [--json]";
|
|
4664
|
+
const QR_VALUE_FLAGS = new Set(["owner", "address", "format", "out", "api-url", "token"]);
|
|
4665
|
+
const QR_BOOLEAN_FLAGS = new Set(["terminal", "json"]);
|
|
4666
|
+
// Pixels per module in the PNG: 16 keeps the typical code (37 modules with its border) near
|
|
4667
|
+
// 600 dpi at the 25 mm minimum print width. The dashboard's download uses the same figure.
|
|
4668
|
+
export const QR_PNG_PIXELS_PER_MODULE = 16;
|
|
4669
|
+
/**
|
|
4670
|
+
* The minimum width in centimetres, rounded UP to the millimetre: it is a floor, so rounding
|
|
4671
|
+
* to nearest would print a 26.5 mm minimum as "2.6 cm", below what the code needs.
|
|
4672
|
+
*/
|
|
4673
|
+
export function qrMinimumWidthCmText(minimumWidthMm) {
|
|
4674
|
+
return String(Math.ceil(minimumWidthMm) / 10);
|
|
4675
|
+
}
|
|
4676
|
+
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.";
|
|
4677
|
+
export function qrCustomDomainNote(hostname) {
|
|
4678
|
+
return `This code opens ${hostname}. If that domain later moves to another app or is removed, printed codes follow the domain, not this app.`;
|
|
4679
|
+
}
|
|
4680
|
+
const QR_UNAVAILABLE_MESSAGES = {
|
|
4681
|
+
not_published: "There's no QR code yet: one appears after the app's first successful deploy.",
|
|
4682
|
+
disabled: "This app is disabled, so there is no QR code to share.",
|
|
4683
|
+
service_access: "This app only accepts service credentials, which a phone camera can't present, so there is no QR code.",
|
|
4684
|
+
};
|
|
4685
|
+
function parseQrArgs(argv) {
|
|
4686
|
+
if (argv[0] === "help" || argv[0] === "--help")
|
|
4687
|
+
throw new UsageError();
|
|
4688
|
+
const flags = parseFlags(argv);
|
|
4689
|
+
const positionals = [];
|
|
4690
|
+
for (let index = 0; index < argv.length; index += 1) {
|
|
4691
|
+
const arg = argv[index] ?? "";
|
|
4692
|
+
if (!arg.startsWith("--")) {
|
|
4693
|
+
positionals.push(arg);
|
|
4694
|
+
continue;
|
|
4695
|
+
}
|
|
4696
|
+
const name = arg.slice(2);
|
|
4697
|
+
if (!QR_VALUE_FLAGS.has(name) && !QR_BOOLEAN_FLAGS.has(name))
|
|
4698
|
+
throw new Error(`Unknown flag for 9pm qr: --${name}`);
|
|
4699
|
+
if (QR_VALUE_FLAGS.has(name))
|
|
4700
|
+
index += 1;
|
|
4701
|
+
}
|
|
4702
|
+
// A value flag given without a value would otherwise fall back to its default, which for
|
|
4703
|
+
// --owner silently picks the personal account's app instead of the organisation's.
|
|
4704
|
+
for (const name of QR_VALUE_FLAGS) {
|
|
4705
|
+
if (flags.has(name) && !readStringFlag(flags, name))
|
|
4706
|
+
throw new Error(`--${name} needs a value.`);
|
|
4707
|
+
}
|
|
4708
|
+
const [site] = positionals;
|
|
4709
|
+
if (!site || positionals.length > 1)
|
|
4710
|
+
throw new Error(`Usage: ${QR_USAGE}`);
|
|
4711
|
+
const format = readStringFlag(flags, "format") ?? "svg";
|
|
4712
|
+
if (format !== "svg" && format !== "png")
|
|
4713
|
+
throw new Error("--format must be svg or png.");
|
|
4714
|
+
const owner = readStringFlag(flags, "owner");
|
|
4715
|
+
const address = readStringFlag(flags, "address");
|
|
4716
|
+
const out = readStringFlag(flags, "out");
|
|
4717
|
+
return {
|
|
4718
|
+
kind: "qr",
|
|
4719
|
+
...readBaseApiOptions(flags),
|
|
4720
|
+
site,
|
|
4721
|
+
format,
|
|
4722
|
+
terminal: flags.has("terminal"),
|
|
4723
|
+
json: flags.has("json"),
|
|
4724
|
+
...(owner ? { owner } : {}),
|
|
4725
|
+
...(address ? { address } : {}),
|
|
4726
|
+
...(out ? { out } : {}),
|
|
4727
|
+
};
|
|
4728
|
+
}
|
|
4729
|
+
/**
|
|
4730
|
+
* The returned address `--address` names, or an error listing the valid ones. The value is
|
|
4731
|
+
* normalized first - a bare hostname gets `https://`, case is ignored, a trailing `/` is
|
|
4732
|
+
* allowed - and must then match an address the server offered: no other host, path, query,
|
|
4733
|
+
* fragment, credentials, port or scheme. The CLI never encodes a URL the server did not offer.
|
|
4734
|
+
*/
|
|
4735
|
+
export function pickQrAddress(addresses, requested) {
|
|
4736
|
+
const fallback = addresses.find((address) => address.isDefault) ?? addresses[0];
|
|
4737
|
+
if (!fallback)
|
|
4738
|
+
throw new Error("The server offered no address for this app's QR code.");
|
|
4739
|
+
if (requested === undefined)
|
|
4740
|
+
return fallback;
|
|
4741
|
+
const valid = addresses.map((address) => address.hostname).join(", ");
|
|
4742
|
+
const refuse = (why) => {
|
|
4743
|
+
throw new Error(`${why} Use one of: ${valid}.`);
|
|
4744
|
+
};
|
|
4745
|
+
const raw = requested.trim();
|
|
4746
|
+
const withScheme = /^[a-z][a-z0-9+.-]*:/i.test(raw) ? raw : `https://${raw}`;
|
|
4747
|
+
let parsed;
|
|
4748
|
+
try {
|
|
4749
|
+
parsed = new URL(withScheme);
|
|
4750
|
+
}
|
|
4751
|
+
catch {
|
|
4752
|
+
return refuse(`--address ${requested} is not a hostname or URL.`);
|
|
4753
|
+
}
|
|
4754
|
+
if (parsed.protocol !== "https:")
|
|
4755
|
+
return refuse(`--address must be an https address.`);
|
|
4756
|
+
if (parsed.username || parsed.password || parsed.port)
|
|
4757
|
+
return refuse(`--address must be a plain hostname.`);
|
|
4758
|
+
if ((parsed.pathname !== "/" && parsed.pathname !== "") || parsed.search || parsed.hash) {
|
|
4759
|
+
return refuse(`--address must be a hostname, with no path, query or fragment.`);
|
|
4760
|
+
}
|
|
4761
|
+
const match = addresses.find((address) => address.hostname.toLowerCase() === parsed.hostname.toLowerCase());
|
|
4762
|
+
return match ?? refuse(`${parsed.hostname} is not an address of this app.`);
|
|
4763
|
+
}
|
|
4764
|
+
// CRC-32 as PNG chunks use it (ISO 3309 polynomial, reflected).
|
|
4765
|
+
const PNG_CRC_TABLE = (() => {
|
|
4766
|
+
const table = new Uint32Array(256);
|
|
4767
|
+
for (let n = 0; n < 256; n += 1) {
|
|
4768
|
+
let c = n;
|
|
4769
|
+
for (let k = 0; k < 8; k += 1)
|
|
4770
|
+
c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
|
|
4771
|
+
table[n] = c >>> 0;
|
|
4772
|
+
}
|
|
4773
|
+
return table;
|
|
4774
|
+
})();
|
|
4775
|
+
function pngChunk(type, data) {
|
|
4776
|
+
const typed = Buffer.concat([Buffer.from(type, "ascii"), data]);
|
|
4777
|
+
let crc = 0xffffffff;
|
|
4778
|
+
for (const byte of typed)
|
|
4779
|
+
crc = (PNG_CRC_TABLE[(crc ^ byte) & 0xff] ?? 0) ^ (crc >>> 8);
|
|
4780
|
+
const length = Buffer.alloc(4);
|
|
4781
|
+
length.writeUInt32BE(data.length);
|
|
4782
|
+
const checksum = Buffer.alloc(4);
|
|
4783
|
+
checksum.writeUInt32BE((crc ^ 0xffffffff) >>> 0);
|
|
4784
|
+
return Buffer.concat([length, typed, checksum]);
|
|
4785
|
+
}
|
|
4786
|
+
/** An 8-bit greyscale PNG of a module matrix (quiet zone included), black on white. */
|
|
4787
|
+
export function encodeQrPng(modules, scale = QR_PNG_PIXELS_PER_MODULE) {
|
|
4788
|
+
const side = modules.length * scale;
|
|
4789
|
+
const header = Buffer.alloc(13);
|
|
4790
|
+
header.writeUInt32BE(side, 0);
|
|
4791
|
+
header.writeUInt32BE(side, 4);
|
|
4792
|
+
header[8] = 8; // bit depth
|
|
4793
|
+
header[9] = 0; // greyscale
|
|
4794
|
+
const raw = Buffer.alloc(side * (side + 1), 0xff);
|
|
4795
|
+
for (let y = 0; y < side; y += 1) {
|
|
4796
|
+
const rowStart = y * (side + 1);
|
|
4797
|
+
raw[rowStart] = 0; // filter: none
|
|
4798
|
+
const moduleRow = modules[Math.floor(y / scale)] ?? [];
|
|
4799
|
+
for (let x = 0; x < side; x += 1) {
|
|
4800
|
+
if (moduleRow[Math.floor(x / scale)])
|
|
4801
|
+
raw[rowStart + 1 + x] = 0;
|
|
4802
|
+
}
|
|
4803
|
+
}
|
|
4804
|
+
return Buffer.concat([
|
|
4805
|
+
Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]),
|
|
4806
|
+
pngChunk("IHDR", header),
|
|
4807
|
+
pngChunk("IDAT", deflateSync(raw)),
|
|
4808
|
+
pngChunk("IEND", Buffer.alloc(0)),
|
|
4809
|
+
]);
|
|
4810
|
+
}
|
|
4811
|
+
function qrFileStem(site) {
|
|
4812
|
+
return site.toLowerCase().replace(/[^a-z0-9-]+/g, "-").replace(/^-+|-+$/g, "") || "app";
|
|
4813
|
+
}
|
|
4814
|
+
async function runQrCommand(command) {
|
|
4815
|
+
const route = `/v1/sites/${encodeURIComponent(command.site)}/qr${ownerQuery(command.owner)}`;
|
|
4816
|
+
let qr;
|
|
4817
|
+
try {
|
|
4818
|
+
qr = await domainsRequest({ apiUrl: command.apiUrl, token: command.token }, route);
|
|
4819
|
+
}
|
|
4820
|
+
catch (error) {
|
|
4821
|
+
if (error instanceof DomainsApiError && error.status === 404 && error.code === "not_found") {
|
|
4822
|
+
console.error("This server can't make QR codes yet.");
|
|
4823
|
+
process.exitCode = 1;
|
|
4824
|
+
return;
|
|
4825
|
+
}
|
|
4826
|
+
if (error instanceof DomainsApiError && error.status === 404 && (error.code === "project_not_found" || error.code === "organisation_not_found")) {
|
|
4827
|
+
console.error(`App not found: ${command.site}. Check the name, or pass --owner <organisation> for an organisation's app.`);
|
|
4828
|
+
process.exitCode = 1;
|
|
4829
|
+
return;
|
|
4830
|
+
}
|
|
4831
|
+
throw error;
|
|
4832
|
+
}
|
|
4833
|
+
if (!qr.available) {
|
|
4834
|
+
console.error(QR_UNAVAILABLE_MESSAGES[qr.reason] ?? `There is no QR code for this app (${qr.reason}).`);
|
|
4835
|
+
process.exitCode = 1;
|
|
4836
|
+
return;
|
|
4837
|
+
}
|
|
4838
|
+
const address = pickQrAddress(qr.addresses, command.address);
|
|
4839
|
+
const file = command.out ?? `${qrFileStem(command.site)}-qr.${command.format}`;
|
|
4840
|
+
const bytes = command.format === "svg" ? qrSvg(address.url) : encodeQrPng(qrMatrix(address.url).modules);
|
|
4841
|
+
writeFileSync(file, bytes);
|
|
4842
|
+
const minimumWidthMm = qrMinimumWidthMm(address.url);
|
|
4843
|
+
const accessNote = qr.accessMode === "private_otp" || qr.accessMode === "private_idp" ? QR_ACCESS_NOTE : null;
|
|
4844
|
+
const customDomainNote = address.kind === "custom_domain" ? qrCustomDomainNote(address.hostname) : null;
|
|
4845
|
+
if (command.json) {
|
|
4846
|
+
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));
|
|
4847
|
+
return;
|
|
4848
|
+
}
|
|
4849
|
+
console.log(`Saved ${file}`);
|
|
4850
|
+
console.log(`It opens ${address.url}`);
|
|
4851
|
+
console.log(`Print it at least ${qrMinimumWidthCmText(minimumWidthMm)} cm wide, and larger if people will scan it from further away.`);
|
|
4852
|
+
if (customDomainNote)
|
|
4853
|
+
console.log(customDomainNote);
|
|
4854
|
+
if (accessNote)
|
|
4855
|
+
console.log(accessNote);
|
|
4856
|
+
if (command.terminal) {
|
|
4857
|
+
console.log("");
|
|
4858
|
+
for (const line of qrTerminal(address.url))
|
|
4859
|
+
console.log(line);
|
|
4860
|
+
}
|
|
4861
|
+
}
|
|
4473
4862
|
function parseLogsArgs(argv) {
|
|
4474
4863
|
const site = argv[0];
|
|
4475
4864
|
if (!site || site.startsWith("--")) {
|
|
@@ -5970,7 +6359,7 @@ async function main() {
|
|
|
5970
6359
|
if (deleted.customDomainNames && deleted.customDomainNames.length > 0) {
|
|
5971
6360
|
console.log(`Removed custom domain(s): ${deleted.customDomainNames.join(", ")}.`);
|
|
5972
6361
|
}
|
|
5973
|
-
for (const line of deletedDomainLines(deleted))
|
|
6362
|
+
for (const line of deletedDomainLines(deleted, { target: { apiUrl: command.apiUrl, apiUrlFrom: command.apiUrlFrom ?? "default" } }))
|
|
5974
6363
|
console.log(line);
|
|
5975
6364
|
// #722: a partial external-cleanup failure never fails the delete, and used to be
|
|
5976
6365
|
// silently discarded here — visible only in the raw JSON, if a caller happened to
|
|
@@ -6020,6 +6409,10 @@ async function main() {
|
|
|
6020
6409
|
await runDomainsCommand(command);
|
|
6021
6410
|
return;
|
|
6022
6411
|
}
|
|
6412
|
+
if (command.kind === "qr") {
|
|
6413
|
+
await runQrCommand(command);
|
|
6414
|
+
return;
|
|
6415
|
+
}
|
|
6023
6416
|
if (command.kind === "logs") {
|
|
6024
6417
|
await runLogsCommand(command);
|
|
6025
6418
|
return;
|
|
@@ -6364,6 +6757,8 @@ function printUsage() {
|
|
|
6364
6757
|
9pm deployments ls <app> [--json]
|
|
6365
6758
|
9pm deployments rm <deployment-id>
|
|
6366
6759
|
9pm logs <app> [--lines <n>] [--json]
|
|
6760
|
+
9pm qr <app> [--owner <organisation>] [--address <host>] [--format svg|png] [--out <file>] [--terminal] [--json]
|
|
6761
|
+
save a printable QR code for the app's address (SVG by default)
|
|
6367
6762
|
9pm delete <site> --confirm <slug>
|
|
6368
6763
|
9pm domains list [<app>] [--owner <organisation>] [--json]
|
|
6369
6764
|
9pm domains add <app> <hostname> [--json] connect a domain you own; prints the DNS records to publish
|
|
@@ -6374,6 +6769,7 @@ function printUsage() {
|
|
|
6374
6769
|
9pm domains cleanup <app> <hostname> [--json] on a live domain: setup records 9pm no longer needs, checked with the provider now
|
|
6375
6770
|
9pm domains remove <app> <hostname> --confirm <hostname> [--json] prints the DNS records you can then delete
|
|
6376
6771
|
9pm domains move [<app>] <hostname> --to <app> [--json] to another app of the same account; keeps its certificate and records
|
|
6772
|
+
9pm domains removed [--hostname <h>] [--json] DNS records you may delete after a removal that finished in the background
|
|
6377
6773
|
9pm access show <site>
|
|
6378
6774
|
9pm access public <site> [--switch-mode]
|
|
6379
6775
|
9pm access otp <site> [--switch-mode]
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/** White border on every side, in modules: the QR standard's required quiet zone. */
|
|
2
|
+
export declare const QR_QUIET_ZONE_MODULES = 4;
|
|
3
|
+
/** Smallest printed width we promise scans, border included (spec: release-gated by print tests). */
|
|
4
|
+
export declare const QR_MIN_WIDTH_MM = 25;
|
|
5
|
+
/**
|
|
6
|
+
* Our chosen product floor for one module, in millimetres - not a universal scanning
|
|
7
|
+
* threshold. The printed scan fixtures are what justify it; re-measure before changing it.
|
|
8
|
+
*/
|
|
9
|
+
export declare const QR_MIN_MODULE_MM = 0.5;
|
|
10
|
+
export interface QrMatrix {
|
|
11
|
+
/** Modules per side INCLUDING the quiet zone. */
|
|
12
|
+
size: number;
|
|
13
|
+
/** Modules per side of the symbol alone. */
|
|
14
|
+
symbolSize: number;
|
|
15
|
+
/** `modules[y][x]`, true = dark, quiet zone included. */
|
|
16
|
+
modules: boolean[][];
|
|
17
|
+
}
|
|
18
|
+
export type ProjectQrUnavailableReason = "not_published" | "disabled" | "service_access";
|
|
19
|
+
export interface ProjectQrAddress {
|
|
20
|
+
url: string;
|
|
21
|
+
hostname: string;
|
|
22
|
+
kind: "custom_domain" | "platform";
|
|
23
|
+
isDefault: boolean;
|
|
24
|
+
}
|
|
25
|
+
export type ProjectQrResponse = {
|
|
26
|
+
available: true;
|
|
27
|
+
accessMode: string;
|
|
28
|
+
addresses: ProjectQrAddress[];
|
|
29
|
+
} | {
|
|
30
|
+
available: false;
|
|
31
|
+
accessMode: string;
|
|
32
|
+
reason: ProjectQrUnavailableReason;
|
|
33
|
+
};
|
|
34
|
+
export declare function qrMatrix(url: string): QrMatrix;
|
|
35
|
+
/** max(25 mm, 0.5 mm × (symbol modules per side + 8)): the 8 is the two four-module borders. */
|
|
36
|
+
export declare function qrMinimumWidthMm(url: string): number;
|
|
37
|
+
/**
|
|
38
|
+
* Black on white, sized only by its viewBox (one unit per module), so it prints at
|
|
39
|
+
* whatever physical width the owner chooses without resampling.
|
|
40
|
+
*/
|
|
41
|
+
export declare function qrSvg(url: string): string;
|
|
42
|
+
/**
|
|
43
|
+
* Two module rows per text line using half blocks. LIGHT modules are drawn and dark ones
|
|
44
|
+
* left blank, so on the usual dark-background terminal the code reads dark-on-light, the
|
|
45
|
+
* polarity phone cameras expect. The size is always odd, so the last line's lower half lies
|
|
46
|
+
* past the matrix and is drawn light: a little more quiet zone, never a missing module.
|
|
47
|
+
*/
|
|
48
|
+
export declare function qrTerminal(url: string): string[];
|