ninepm 0.81.0 → 0.83.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
@@ -163,6 +163,8 @@ Delete an app, its managed database, managed container app, and retained image:
163
163
 
164
164
  The app's address goes with it: creating an app again under the same slug gives it a new address, so printed links and QR codes stop working. `9pm delete` prints this warning before it deletes anything.
165
165
 
166
+ For a static or Worker app with no custom domain, `9pm delete` also prints `Its address stops serving the app within about a minute.` It prints nothing about timing for a container app or an app with a custom domain: how long those keep serving after a delete has not been measured, so check the address yourself.
167
+
166
168
  ## Access Modes
167
169
 
168
170
  Apps are public by default.
@@ -271,6 +273,19 @@ from 9pm. A removal that finishes in the background returns no record list, so t
271
273
  which DNS records to delete for it. Such a domain shows as `removing`
272
274
  in `list` and `status` (`"removing": true` in `--json`), and `wait` on it exits `20`. Since 0.80.0.
273
275
 
276
+ Since 0.83.0, `9pm domains cleanup <app> <hostname>` lists the setup TXT records 9pm issued for a
277
+ live domain and no longer needs, such as an earlier `_acme-challenge` value or the
278
+ `_cf-custom-hostname` ownership record (`--json` prints the API's answer). Each may be deleted only
279
+ while it still has the value shown; a different value at that name is not ours. The list is checked
280
+ against the certificate provider when you run the command: a value the provider still asks for is
281
+ never in it, a domain that is not live lists nothing, and if the provider cannot be read, or the
282
+ domain changes while it is being checked, the command fails and lists nothing; run it again. Only values the API issued are candidates, never anything read from public
283
+ DNS. `9pm domains status` on a live domain prints the command. `9pm domains remove` lists these values
284
+ with the rest of the domain's records, since once the domain is removed none of them is needed.
285
+ After `9pm domains restart`, the values it says no longer count are worded the same way. Against a
286
+ server without the route, `cleanup` says it can't list any and exits `1`. Values replaced before the
287
+ server kept them cannot be listed.
288
+
274
289
  `9pm domains move <hostname> --to <app>` moves a domain to another app of the same account. It keeps
275
290
  its certificate and the DNS records already published, so nothing is validated again. Without a
276
291
  source app the CLI looks it up in the account; name it (`9pm domains move <app> <hostname> --to
package/dist/index.d.ts CHANGED
@@ -112,6 +112,8 @@ export interface DeletedSiteResult {
112
112
  customDomainsReleasing?: unknown;
113
113
  containerApplications?: number;
114
114
  containerImages?: number;
115
+ flyTeardownsQueued?: number;
116
+ workerScripts?: unknown;
115
117
  accessApps: number;
116
118
  serviceTokens: number;
117
119
  databases: number;
@@ -130,7 +132,7 @@ export interface CreatedServiceToken {
130
132
  clientSecret: string;
131
133
  }
132
134
  export type FetchLike = (input: string | URL | Request, init?: RequestInit) => Promise<Response>;
133
- export declare const CLI_VERSION = "0.81.0";
135
+ export declare const CLI_VERSION = "0.83.0";
134
136
  type CredentialBackend = "macos-keychain" | "windows-dpapi" | "linux-secret-service" | "file-plaintext" | "none";
135
137
  export interface StoredCredentialEntry {
136
138
  token: string;
@@ -355,6 +357,8 @@ export declare function listSites(options: ApiOptions & {
355
357
  owner?: string;
356
358
  }): Promise<ListSitesResult>;
357
359
  export declare function deleteWarningLines(slug: string): string[];
360
+ export declare const DELETE_SERVING_BOUND_LINE = "Its address stops serving the app within about a minute.";
361
+ export declare function deletedAppStopsServingQuickly(deleted: Pick<DeletedSiteResult, "workerScripts" | "containerApplications" | "flyTeardownsQueued" | "customDomains" | "customDomainsReleasing" | "errors">): boolean;
358
362
  export declare function deleteSite(options: DeleteSiteOptions): Promise<DeletedSiteResult>;
359
363
  export interface DeviceAuthFeedbackOptions {
360
364
  progressStream?: NodeJS.WriteStream;
@@ -648,6 +652,11 @@ type CliCommand = {
648
652
  kind: "restart";
649
653
  site: string;
650
654
  hostname: string;
655
+ } | {
656
+ kind: "cleanup";
657
+ site: string;
658
+ hostname: string;
659
+ json: boolean;
651
660
  } | {
652
661
  kind: "remove";
653
662
  site: string;
@@ -866,6 +875,11 @@ export interface DomainCleanup {
866
875
  }
867
876
  export declare function parseDomainCleanup(value: unknown): DomainCleanup | null;
868
877
  export declare function domainCleanupLines(cleanup: DomainCleanup): string[];
878
+ export declare function getDomainSetupCleanup(options: DomainCommandOptions & {
879
+ hostname: string;
880
+ }): Promise<unknown>;
881
+ export declare function domainSetupCleanupLines(body: unknown, hostname: string): string[];
882
+ export declare function setupCleanupLines(hostname: string, records: DomainCleanupRecord[]): string[];
869
883
  export declare function domainRemovalPendingLines(hostname: string, app: string, options: DomainFormatOptions): string[];
870
884
  export interface DomainMove {
871
885
  body: unknown;
package/dist/index.js CHANGED
@@ -78,7 +78,7 @@ export function deletedDomainLines(deleted) {
78
78
  }
79
79
  return lines;
80
80
  }
81
- export const CLI_VERSION = "0.81.0";
81
+ export const CLI_VERSION = "0.83.0";
82
82
  const PUBLIC_DASHBOARD_URL = "https://9pm.ai/dashboard";
83
83
  const TOKEN_SERVICE = "9pm";
84
84
  const TOKEN_ACCOUNT = "default";
@@ -1441,6 +1441,29 @@ export function deleteWarningLines(slug) {
1441
1441
  "Its deployments, files and managed database (if it has one) are deleted with it and cannot be recovered.",
1442
1442
  ];
1443
1443
  }
1444
+ // Printed after a delete of the one shape that was measured (#1969): an app whose namespace
1445
+ // script was replaced with the deleted-app Worker, with no container and no custom domain.
1446
+ // Measured from outside on 2026-10-14 (staging) and 2026-10-15 (production) for a static
1447
+ // app: the old content stopped within ~20 s of starting the delete and did not come back
1448
+ // over ~15 min. "About a minute" leaves margin for a single location's measurement;
1449
+ // re-measure before tightening it, and measure a shape before adding it below.
1450
+ export const DELETE_SERVING_BOUND_LINE = "Its address stops serving the app within about a minute.";
1451
+ // Whether DELETE_SERVING_BOUND_LINE is true of this delete. Read from what the server says it
1452
+ // did, not from what the app was: the tombstone is the mechanism the measurement covers. A
1453
+ // container app's teardown is queued and gets no tombstone, a custom domain stops through the
1454
+ // fallback Worker, and a failed cleanup step may have left something serving - none of those
1455
+ // were measured, so they get no promise. An older server sends no `workerScripts`, so neither.
1456
+ export function deletedAppStopsServingQuickly(deleted) {
1457
+ const scripts = Array.isArray(deleted.workerScripts) ? deleted.workerScripts : [];
1458
+ const tombstoned = scripts.some((script) => typeof script === "object" && script !== null && script.tombstoned === true);
1459
+ const releasing = Array.isArray(deleted.customDomainsReleasing) && deleted.customDomainsReleasing.length > 0;
1460
+ return (tombstoned &&
1461
+ (deleted.containerApplications ?? 0) === 0 &&
1462
+ (deleted.flyTeardownsQueued ?? 0) === 0 &&
1463
+ deleted.customDomains === 0 &&
1464
+ !releasing &&
1465
+ (deleted.errors ?? []).length === 0);
1466
+ }
1444
1467
  export async function deleteSite(options) {
1445
1468
  const route = `/v1/sites/${encodeURIComponent(options.site)}${options.owner ? `?owner=${encodeURIComponent(options.owner)}` : ""}`;
1446
1469
  const payload = await apiRequest(options, route, {
@@ -3490,6 +3513,8 @@ export function formatDomainStatus(status, options = {}) {
3490
3513
  const lines = [`${status.hostname} (app ${status.app}): ${domainStateLabel(status.state)}`];
3491
3514
  if (status.state === "live") {
3492
3515
  lines.push(`Serving at https://${status.hostname}`);
3516
+ // #2089: which setup records can go is asked of the provider when wanted, not inferred here.
3517
+ lines.push(`Setup records you may no longer need: ${domainCommand("cleanup", status.app, status.hostname, options)}`);
3493
3518
  return lines;
3494
3519
  }
3495
3520
  if (status.state === "expired") {
@@ -3802,6 +3827,50 @@ export function domainCleanupLines(cleanup) {
3802
3827
  "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
3828
  ];
3804
3829
  }
3830
+ export async function getDomainSetupCleanup(options) {
3831
+ return domainsRequest(options, siteDomainsRoute(options, normalizeDomainArg(options.hostname), "cleanup"));
3832
+ }
3833
+ // What `9pm domains cleanup` prints for the server's answer (#2089). Never throws: anything it
3834
+ // cannot read is reported as nothing listed, never as something to delete.
3835
+ export function domainSetupCleanupLines(body, hostname) {
3836
+ try {
3837
+ const fields = body && typeof body === "object" ? body : {};
3838
+ const name = typeof fields.hostname === "string" && fields.hostname !== "" ? fields.hostname : hostname;
3839
+ if (fields.live !== true) {
3840
+ return [`${name} isn't live with its certificate right now, so no setup record is listed for deletion.`];
3841
+ }
3842
+ const cleanup = parseDomainCleanup(fields.cleanup);
3843
+ if (!cleanup)
3844
+ return [`Couldn't read which setup records ${name} no longer needs, so none is listed for deletion.`];
3845
+ if (cleanup.records.length === 0)
3846
+ return [`9pm lists no setup records to delete for ${name}.`];
3847
+ return setupCleanupLines(name, cleanup.records);
3848
+ }
3849
+ catch {
3850
+ return [`Couldn't read which setup records ${hostname} no longer needs, so none is listed for deletion.`];
3851
+ }
3852
+ }
3853
+ // A live domain's leftovers from setup (#2089): only values the API says it issued and that the
3854
+ // provider, read just now, no longer asks for. Each is conditional on its value for the same
3855
+ // reason as after a removal: a name like `_acme-challenge` can hold another service's token, and
3856
+ // only the value tells ours apart.
3857
+ export function setupCleanupLines(hostname, records) {
3858
+ const apex = guessZoneApex(hostname);
3859
+ const rows = [
3860
+ ["TYPE", "NAME", "VALUE", "WAS FOR"],
3861
+ ...records.map((record) => [
3862
+ record.type.toUpperCase(),
3863
+ relativeRecordName(record.host, apex),
3864
+ record.value,
3865
+ Object.hasOwn(CLEANUP_PURPOSES, record.purpose) ? (CLEANUP_PURPOSES[record.purpose] ?? "") : "",
3866
+ ]),
3867
+ ];
3868
+ return [
3869
+ `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:`,
3870
+ ...renderColumns(rows).map((line) => ` ${line}`),
3871
+ "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
+ ];
3873
+ }
3805
3874
  // Said wherever a removal is left to finish in the background: only the API's own cleanup may
3806
3875
  // name records to delete, and a removal that finishes there produces none.
3807
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.";
@@ -4039,6 +4108,7 @@ const DOMAINS_USAGE = {
4039
4108
  check: "9pm domains check <app> <hostname> [--owner <organisation>] [--json]",
4040
4109
  wait: "9pm domains wait <app> <hostname> [--timeout 30m] [--interval 20s] [--owner <organisation>] [--json]",
4041
4110
  restart: "9pm domains restart <app> <hostname> [--owner <organisation>] (for a failed certificate; the record values may change)",
4111
+ cleanup: "9pm domains cleanup <app> <hostname> [--owner <organisation>] [--json] (setup records a live domain no longer needs)",
4042
4112
  remove: "9pm domains remove <app> <hostname> --confirm <hostname> [--owner <organisation>] [--json]",
4043
4113
  move: "9pm domains move [<app>] <hostname> --to <app> [--owner <organisation>] [--json]",
4044
4114
  };
@@ -4094,6 +4164,9 @@ function parseDomainsArgs(argv) {
4094
4164
  if (subcommand === "restart") {
4095
4165
  return withBase({ kind: "restart", site, hostname });
4096
4166
  }
4167
+ if (subcommand === "cleanup") {
4168
+ return withBase({ kind: "cleanup", site, hostname, json });
4169
+ }
4097
4170
  const confirm = readStringFlag(flags, "confirm");
4098
4171
  if (!confirm || normalizeDomainArg(confirm) !== normalizeDomainArg(hostname)) {
4099
4172
  throw new Error(`Removing a domain stops it serving at once. Pass --confirm ${normalizeDomainArg(hostname)} to remove it.`);
@@ -4153,6 +4226,28 @@ async function runDomainsCommand(command) {
4153
4226
  printDomainStatus(await getDomainStatus(target), action.json, format);
4154
4227
  return;
4155
4228
  }
4229
+ if (action.kind === "cleanup") {
4230
+ let body;
4231
+ try {
4232
+ body = await getDomainSetupCleanup(target);
4233
+ }
4234
+ catch (error) {
4235
+ // A server from before #2089 has no such route; it lists nothing, so neither does this.
4236
+ if (error instanceof DomainsApiError && error.status === 404 && error.code === "not_found") {
4237
+ console.error("This server can't list setup records to delete yet, so none is listed.");
4238
+ process.exitCode = 1;
4239
+ return;
4240
+ }
4241
+ throw error;
4242
+ }
4243
+ if (action.json) {
4244
+ console.log(JSON.stringify(body, null, 2));
4245
+ return;
4246
+ }
4247
+ for (const line of domainSetupCleanupLines(body, normalizeDomainArg(action.hostname)))
4248
+ console.log(line);
4249
+ return;
4250
+ }
4156
4251
  if (action.kind === "check") {
4157
4252
  await recheckDomain(target);
4158
4253
  printDomainStatus(await getDomainStatus(target), action.json, format);
@@ -4188,7 +4283,7 @@ async function runDomainsCommand(command) {
4188
4283
  const restarted = await restartDomain(target);
4189
4284
  console.log(`Started validation for ${restarted.domain.hostname} over.`);
4190
4285
  if (restarted.replacedInstructions.length > 0) {
4191
- console.log("These values no longer count; delete them at your DNS provider:");
4286
+ 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
4287
  for (const line of instructionLines(restarted.replacedInstructions, restarted.domain.hostname))
4193
4288
  console.log(line);
4194
4289
  }
@@ -5868,6 +5963,9 @@ async function main() {
5868
5963
  confirmSlug: command.confirmSlug,
5869
5964
  });
5870
5965
  console.log(`Deleted ${deleted.slug}.`);
5966
+ if (deletedAppStopsServingQuickly(deleted)) {
5967
+ console.log(DELETE_SERVING_BOUND_LINE);
5968
+ }
5871
5969
  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
5970
  if (deleted.customDomainNames && deleted.customDomainNames.length > 0) {
5873
5971
  console.log(`Removed custom domain(s): ${deleted.customDomainNames.join(", ")}.`);
@@ -6273,6 +6371,7 @@ function printUsage() {
6273
6371
  9pm domains check <app> <hostname> [--json] re-check now, then print the status
6274
6372
  9pm domains wait <app> <hostname> [--timeout 30m] [--interval 20s] [--json]
6275
6373
  9pm domains restart <app> <hostname> for a failed certificate only: start validation over; record values may change
6374
+ 9pm domains cleanup <app> <hostname> [--json] on a live domain: setup records 9pm no longer needs, checked with the provider now
6276
6375
  9pm domains remove <app> <hostname> --confirm <hostname> [--json] prints the DNS records you can then delete
6277
6376
  9pm domains move [<app>] <hostname> --to <app> [--json] to another app of the same account; keeps its certificate and records
6278
6377
  9pm access show <site>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ninepm",
3
- "version": "0.81.0",
3
+ "version": "0.83.0",
4
4
  "description": "CLI for deploying built apps to 9pm.ai.",
5
5
  "homepage": "https://9pm.ai",
6
6
  "keywords": [