ninepm 0.83.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 CHANGED
@@ -269,9 +269,21 @@ another service's certificate check uses `_acme-challenge`) is not ours, so leav
269
269
  that sends no such list it prints no deletion advice at all. When the removal is still finishing
270
270
  upstream (the API answers `202` with `releasing: true`), it says so, names no record to delete, and
271
271
  prints the `9pm domains list` command: once the domain is no longer listed, it has stopped serving
272
- from 9pm. A removal that finishes in the background returns no record list, so the CLI cannot say
273
- which DNS records to delete for it. Such a domain shows as `removing`
274
- in `list` and `status` (`"removing": true` in `--json`), and `wait` on it exits `20`. Since 0.80.0.
272
+ from 9pm. Such a domain shows as `removing` in `list` and `status` (`"removing": true` in `--json`),
273
+ and `wait` on it exits `20`. Since 0.80.0.
274
+
275
+ Since 0.84.0, `9pm domains removed [--owner <organisation>]` lists the DNS records you may delete
276
+ after a removal that finished in the background: a `remove` that answered `releasing`, or a domain of
277
+ a deleted app, finished later by the platform. It is the same list `remove` prints when it finishes
278
+ itself, under the same rule (delete a record only while it still has the value shown), and the
279
+ printed hint after a still-finishing removal or `9pm delete` points at it, with `--owner` and
280
+ `--api-url` kept. A removal is listed for 30 days after it finishes, only to the account that owned
281
+ the domain, and not at all once the hostname has been added again anywhere: the server checks the
282
+ certificate provider for it when you run the command, and a domain it couldn't check is named without
283
+ its records, with the command to ask about it again. The server checks ten domains per page; the
284
+ command reads up to 20 pages and says so if more remain. `--hostname <hostname>` asks about one
285
+ domain only. `--json` prints the API's answer with its pages merged (`next` is null once every page
286
+ was read). Against a server without the route it says it can't list any, prints no record and exits `1`.
275
287
 
276
288
  Since 0.83.0, `9pm domains cleanup <app> <hostname>` lists the setup TXT records 9pm issued for a
277
289
  live domain and no longer needs, such as an earlier `_acme-challenge` value or the
@@ -332,6 +344,7 @@ and none can be listed for it later.
332
344
  9pm domains restart <app> <hostname> # only after a certificate failed; record values may change
333
345
  9pm domains remove <app> <hostname> --confirm <hostname> [--json]
334
346
  9pm domains move [<app>] <hostname> --to <app> [--json]
347
+ 9pm domains removed [--hostname <hostname>] [--owner <organisation>] [--json] # records to delete after a removal that finished in the background
335
348
  9pm access show <site>
336
349
  9pm access public <site> [--switch-mode]
337
350
  9pm access otp <site> [--switch-mode]
package/dist/index.d.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  #!/usr/bin/env node
2
2
  import { Agent } from "undici";
3
+ import { type ProjectQrAddress } from "./qr-code.js";
3
4
  import { type DomainNext, type DomainReason, type DomainRecordStatus, type DomainState, type DomainStateDnsCheck, type DomainStateDnsInstruction, type DomainStateInput } from "./domain-state.js";
4
5
  import type { AccessGrant, AccessGrantType, AccessProviderKey, AppAccessSettings, AppAccessServiceToken, AppAccessToken, AppDatabase, AppRuntime, AppUserFilesUsage, ContainerImageUploadInstruction, ContainerRegion, Deployment, ManagedFileObject, ManagedFileShare, ManagedFileUsage, Project } from "./cli-types.js";
5
6
  export interface DeployOptions {
@@ -122,7 +123,7 @@ export interface DeletedSiteResult {
122
123
  metadata: boolean;
123
124
  errors?: string[];
124
125
  }
125
- export declare function deletedDomainLines(deleted: Pick<DeletedSiteResult, "customDomainCleanup" | "customDomainsReleasing">): string[];
126
+ export declare function deletedDomainLines(deleted: Pick<DeletedSiteResult, "customDomainCleanup" | "customDomainsReleasing">, options?: DomainFormatOptions): string[];
126
127
  export interface CreatedRuntimeToken {
127
128
  token: string;
128
129
  appAccessToken: AppAccessToken;
@@ -132,7 +133,7 @@ export interface CreatedServiceToken {
132
133
  clientSecret: string;
133
134
  }
134
135
  export type FetchLike = (input: string | URL | Request, init?: RequestInit) => Promise<Response>;
135
- export declare const CLI_VERSION = "0.83.0";
136
+ export declare const CLI_VERSION = "0.85.0";
136
137
  type CredentialBackend = "macos-keychain" | "windows-dpapi" | "linux-secret-service" | "file-plaintext" | "none";
137
138
  export interface StoredCredentialEntry {
138
139
  token: string;
@@ -532,6 +533,7 @@ type CliCommand = {
532
533
  confirmSlug: string;
533
534
  apiUrl: string;
534
535
  token: string;
536
+ apiUrlFrom?: ApiUrlSource;
535
537
  } | {
536
538
  kind: "apps";
537
539
  apiUrl: string;
@@ -628,6 +630,17 @@ type CliCommand = {
628
630
  };
629
631
  apiUrl: string;
630
632
  token: string;
633
+ } | {
634
+ kind: "qr";
635
+ site: string;
636
+ owner?: string;
637
+ address?: string;
638
+ format: "svg" | "png";
639
+ out?: string;
640
+ terminal: boolean;
641
+ json: boolean;
642
+ apiUrl: string;
643
+ token: string;
631
644
  } | {
632
645
  kind: "domains";
633
646
  owner?: string;
@@ -668,6 +681,10 @@ type CliCommand = {
668
681
  hostname: string;
669
682
  to: string;
670
683
  json: boolean;
684
+ } | {
685
+ kind: "removed";
686
+ json: boolean;
687
+ hostname?: string;
671
688
  };
672
689
  apiUrl: string;
673
690
  token: string;
@@ -881,6 +898,18 @@ export declare function getDomainSetupCleanup(options: DomainCommandOptions & {
881
898
  export declare function domainSetupCleanupLines(body: unknown, hostname: string): string[];
882
899
  export declare function setupCleanupLines(hostname: string, records: DomainCleanupRecord[]): string[];
883
900
  export declare function domainRemovalPendingLines(hostname: string, app: string, options: DomainFormatOptions): string[];
901
+ export declare const FINISHED_REMOVAL_PAGES_PER_COMMAND = 20;
902
+ export declare function getFinishedDomainRemovals(options: ApiOptions & {
903
+ owner?: string;
904
+ hostname?: string;
905
+ }): Promise<unknown>;
906
+ export interface FinishedDomainRemoval {
907
+ hostname: string;
908
+ finishedAt: string | null;
909
+ cleanup: DomainCleanup;
910
+ }
911
+ export declare function parseFinishedDomainRemovals(body: unknown): FinishedDomainRemoval[] | null;
912
+ export declare function finishedDomainRemovalLines(body: unknown, options?: DomainFormatOptions): string[];
884
913
  export interface DomainMove {
885
914
  body: unknown;
886
915
  hostname: string;
@@ -923,6 +952,23 @@ export declare function waitForDomain(options: DomainCommandOptions & {
923
952
  onRetry?: (error: unknown) => void;
924
953
  }): Promise<DomainWaitResult>;
925
954
  export declare function healthWithReason(status: string | null | undefined, reason: string | null | undefined): string;
955
+ export declare const QR_PNG_PIXELS_PER_MODULE = 16;
956
+ /**
957
+ * The minimum width in centimetres, rounded UP to the millimetre: it is a floor, so rounding
958
+ * to nearest would print a 26.5 mm minimum as "2.6 cm", below what the code needs.
959
+ */
960
+ export declare function qrMinimumWidthCmText(minimumWidthMm: number): string;
961
+ export declare 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.";
962
+ export declare function qrCustomDomainNote(hostname: string): string;
963
+ /**
964
+ * The returned address `--address` names, or an error listing the valid ones. The value is
965
+ * normalized first - a bare hostname gets `https://`, case is ignored, a trailing `/` is
966
+ * allowed - and must then match an address the server offered: no other host, path, query,
967
+ * fragment, credentials, port or scheme. The CLI never encodes a URL the server did not offer.
968
+ */
969
+ export declare function pickQrAddress(addresses: readonly ProjectQrAddress[], requested: string | undefined): ProjectQrAddress;
970
+ /** An 8-bit greyscale PNG of a module matrix (quiet zone included), black on white. */
971
+ export declare function encodeQrPng(modules: readonly (readonly boolean[])[], scale?: number): Buffer;
926
972
  export type ApiUrlSource = "flag" | "env" | "default";
927
973
  export declare function workerPublicFiles(paths: readonly string[], serverEntry: string, selection: string | null): Array<{
928
974
  path: string;
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. ${DOMAIN_RELEASE_NO_CLEANUP}`);
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.83.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";
@@ -2707,6 +2708,9 @@ export function parseArgs(argv) {
2707
2708
  if (command === "domains") {
2708
2709
  return parseDomainsArgs(argv.slice(1));
2709
2710
  }
2711
+ if (command === "qr") {
2712
+ return parseQrArgs(argv.slice(1));
2713
+ }
2710
2714
  if (command === "logs") {
2711
2715
  return parseLogsArgs(argv.slice(1));
2712
2716
  }
@@ -2981,7 +2985,7 @@ function parseDeleteArgs(argv) {
2981
2985
  if (!confirmSlug) {
2982
2986
  throw new Error("Missing delete confirmation. Pass --confirm <slug> matching the app slug.");
2983
2987
  }
2984
- return { kind: "delete", ...base, site, confirmSlug };
2988
+ return { kind: "delete", ...base, apiUrlFrom: apiUrlSource(flags), site, confirmSlug };
2985
2989
  }
2986
2990
  function parseFilesArgs(argv) {
2987
2991
  const subcommand = argv[0];
@@ -3871,20 +3875,142 @@ export function setupCleanupLines(hostname, records) {
3871
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.",
3872
3876
  ];
3873
3877
  }
3874
- // Said wherever a removal is left to finish in the background: only the API's own cleanup may
3875
- // name records to delete, and a removal that finishes there produces none.
3876
- const DOMAIN_RELEASE_NO_CLEANUP = "Leave its DNS records in place for now. The CLI cannot list which DNS records to delete for a removal that finishes in the background.";
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
+ }
3877
3890
  // A removal that has stopped serving but is still finishing upstream: nothing is called removed
3878
- // and no record is offered for deletion. A removal the background sweep finishes deletes the row
3879
- // 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.
3880
3892
  export function domainRemovalPendingLines(hostname, app, options) {
3881
3893
  const owner = options.owner ? ` --owner ${quoteSiteArg(options.owner)}` : "";
3882
3894
  return [
3883
3895
  `${app} no longer serves ${hostname}, but removing it upstream is taking longer than usual. It finishes on its own.`,
3884
3896
  `Once ${hostname} is no longer in this list, it has stopped serving from 9pm: 9pm domains list ${quoteSiteArg(app)}${owner}${apiUrlFlagFor(options.target)}`,
3885
- DOMAIN_RELEASE_NO_CLEANUP,
3897
+ releaseNoCleanupLine(options),
3886
3898
  ];
3887
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
+ }
3888
4014
  export async function moveDomain(options) {
3889
4015
  const hostname = normalizeDomainArg(options.hostname);
3890
4016
  const body = await domainsRequest(options, siteDomainsRoute(options, hostname, "move"), {
@@ -4082,7 +4208,7 @@ export async function waitForDomain(options) {
4082
4208
  await sleepFn(Math.min(options.intervalMs, remaining));
4083
4209
  }
4084
4210
  }
4085
- 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"]);
4086
4212
  const DOMAINS_BOOLEAN_FLAGS = new Set(["json"]);
4087
4213
  function domainsPositionals(argv) {
4088
4214
  const positionals = [];
@@ -4111,6 +4237,7 @@ const DOMAINS_USAGE = {
4111
4237
  cleanup: "9pm domains cleanup <app> <hostname> [--owner <organisation>] [--json] (setup records a live domain no longer needs)",
4112
4238
  remove: "9pm domains remove <app> <hostname> --confirm <hostname> [--owner <organisation>] [--json]",
4113
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)",
4114
4241
  };
4115
4242
  function parseDomainsArgs(argv) {
4116
4243
  const subcommand = argv[0] === "ls" ? "list" : argv[0] === "rm" ? "remove" : argv[0];
@@ -4138,6 +4265,12 @@ function parseDomainsArgs(argv) {
4138
4265
  const site = positionals[0];
4139
4266
  return withBase({ kind: "list", json, ...(site ? { site } : {}) });
4140
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
+ }
4141
4274
  if (subcommand === "move") {
4142
4275
  const to = readStringFlag(flags, "to");
4143
4276
  const [first, second] = positionals;
@@ -4208,6 +4341,28 @@ async function runDomainsCommand(command) {
4208
4341
  await runDomainMove(action, base, format);
4209
4342
  return;
4210
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
+ }
4211
4366
  const target = { ...base, site: action.site, hostname: action.hostname };
4212
4367
  if (action.kind === "add") {
4213
4368
  const added = await addDomain(target);
@@ -4470,6 +4625,208 @@ async function runLogsCommand(command) {
4470
4625
  ? "No detailed logs were captured for this failure."
4471
4626
  : "No logs recorded. Logs are captured when a deployment fails to come up.");
4472
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
+ }
4473
4830
  function parseLogsArgs(argv) {
4474
4831
  const site = argv[0];
4475
4832
  if (!site || site.startsWith("--")) {
@@ -5970,7 +6327,7 @@ async function main() {
5970
6327
  if (deleted.customDomainNames && deleted.customDomainNames.length > 0) {
5971
6328
  console.log(`Removed custom domain(s): ${deleted.customDomainNames.join(", ")}.`);
5972
6329
  }
5973
- for (const line of deletedDomainLines(deleted))
6330
+ for (const line of deletedDomainLines(deleted, { target: { apiUrl: command.apiUrl, apiUrlFrom: command.apiUrlFrom ?? "default" } }))
5974
6331
  console.log(line);
5975
6332
  // #722: a partial external-cleanup failure never fails the delete, and used to be
5976
6333
  // silently discarded here — visible only in the raw JSON, if a caller happened to
@@ -6020,6 +6377,10 @@ async function main() {
6020
6377
  await runDomainsCommand(command);
6021
6378
  return;
6022
6379
  }
6380
+ if (command.kind === "qr") {
6381
+ await runQrCommand(command);
6382
+ return;
6383
+ }
6023
6384
  if (command.kind === "logs") {
6024
6385
  await runLogsCommand(command);
6025
6386
  return;
@@ -6364,6 +6725,8 @@ function printUsage() {
6364
6725
  9pm deployments ls <app> [--json]
6365
6726
  9pm deployments rm <deployment-id>
6366
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)
6367
6730
  9pm delete <site> --confirm <slug>
6368
6731
  9pm domains list [<app>] [--owner <organisation>] [--json]
6369
6732
  9pm domains add <app> <hostname> [--json] connect a domain you own; prints the DNS records to publish
@@ -6374,6 +6737,7 @@ function printUsage() {
6374
6737
  9pm domains cleanup <app> <hostname> [--json] on a live domain: setup records 9pm no longer needs, checked with the provider now
6375
6738
  9pm domains remove <app> <hostname> --confirm <hostname> [--json] prints the DNS records you can then delete
6376
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
6377
6741
  9pm access show <site>
6378
6742
  9pm access public <site> [--switch-mode]
6379
6743
  9pm access otp <site> [--switch-mode]