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 +15 -0
- package/dist/index.d.ts +15 -1
- package/dist/index.js +101 -2
- package/package.json +1 -1
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.
|
|
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
|
+
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
|
|
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>
|