ninepm 0.69.0 → 0.72.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
@@ -71,6 +71,19 @@ Check this before printing a link or QR code. The live URL is `<slug>-<suffix>.9
71
71
  - There is no rename: a different address means a different app.
72
72
  - A custom domain you own, added on the dashboard's Domains page, gives the app an address of your own. Not every plan includes custom domains.
73
73
 
74
+ ### Pages that do not exist
75
+
76
+ A static site or `--entry` worker app answers a path that has no file in one of two ways. Every deploy prints which, as a `Missing pages:` line.
77
+
78
+ - `--no-spa` (the default for new apps): the path returns status 404, with the deploy's own `404.html` when it has one. `/` still serves `index.html`, and extensionless links work: `/menu` serves `menu.html`, or redirects to `/menu/` when only `menu/index.html` exists, and `/menu/` serves `menu/index.html`. Right for a multi-page site, where a removed or mistyped page should say "not found" rather than show the homepage.
79
+ - `--spa`: every path with no file serves `index.html`, so a single-page app that routes in the browser loads on a deep link or a refresh. That includes dotted paths such as `/users/jane.smith` and missing files such as `/missing.json`, which get the app's shell rather than a 404.
80
+
81
+ ```sh
82
+ 9pm deploy ./dist --name my-spa --spa
83
+ ```
84
+
85
+ The app remembers the choice: a deploy without either flag keeps it, and a deploy that fails to publish leaves the previous choice in place. Apps created before this choice existed keep serving `index.html` for every unmatched path until a deploy passes one of the flags. A worker app's own `Response`, a 404 included, is always returned as it is. The flags do not apply to `--bundle` or container apps.
86
+
74
87
  Deploy a built framework output directory:
75
88
 
76
89
  ```sh
@@ -152,12 +165,14 @@ The app's address goes with it: creating an app again under the same slug gives
152
165
 
153
166
  Apps are public by default.
154
167
 
168
+ Changing an app's access mode needs `--switch-mode`. Without it the command changes nothing and prints what the change would do (who loses access, what happens to the app's custom domains) and the command to re-run. Service-token mode locks every person out and removes the app's custom domains; leaving it sets them up again with new DNS records to publish. Creating another service token on an app already in service-token mode needs no flag.
169
+
155
170
  One-time PIN with email/domain allowlist:
156
171
 
157
172
  ```sh
158
173
  9pm access allow email my-app dev@example.com
159
174
  9pm access allow domain my-app example.com
160
- 9pm access otp my-app
175
+ 9pm access otp my-app --switch-mode
161
176
  9pm access revoke email my-app dev@example.com
162
177
  9pm access revoke domain my-app example.com
163
178
  ```
@@ -165,19 +180,19 @@ One-time PIN with email/domain allowlist:
165
180
  Identity provider access:
166
181
 
167
182
  ```sh
168
- 9pm access idp my-app --provider google
183
+ 9pm access idp my-app --provider google --switch-mode
169
184
  ```
170
185
 
171
186
  Machine access:
172
187
 
173
188
  ```sh
174
- 9pm access service create my-app --name backend
189
+ 9pm access service create my-app --name backend --switch-mode
175
190
  ```
176
191
 
177
192
  Return to public:
178
193
 
179
194
  ```sh
180
- 9pm access public my-app
195
+ 9pm access public my-app --switch-mode
181
196
  ```
182
197
 
183
198
  Create an app-scoped runtime token for server-side allowlist management:
@@ -189,11 +204,40 @@ Create an app-scoped runtime token for server-side allowlist management:
189
204
  Use `9pm_app_...` runtime tokens only from server-side code. They can list, add, and remove email/domain grants for one app. They cannot deploy, create databases, create API keys, or manage another app.
190
205
  Service token secrets returned by `access service create` are also server-side secrets.
191
206
 
207
+ ## Custom Domains
208
+
209
+ Connect a domain you own to an app:
210
+
211
+ ```sh
212
+ 9pm domains add my-app www.example.com
213
+ ```
214
+
215
+ This prints the DNS records to publish at your DNS provider, with names relative to your
216
+ domain (`www`, `_acme-challenge.www`) and what DNS shows for each right now. When a TXT value
217
+ is missing, add it as its own TXT record; other TXT values on that name can stay. The root of
218
+ your domain (`@`) cannot hold a CNAME, so a root domain is shown as an `ALIAS` record: add it as
219
+ ALIAS or ANAME (or a CNAME your provider flattens). A correct ALIAS answers with addresses, which
220
+ cannot be compared from outside, so that row follows the platform's own routing check. Then:
221
+
222
+ ```sh
223
+ 9pm domains wait my-app www.example.com
224
+ ```
225
+
226
+ `wait` exits `0` once the domain is live, `10` if it timed out while a record is missing or could
227
+ not be checked, `11` if it timed out with every record checked and found (the platform is still
228
+ finishing; wait again), `20` if the domain failed or expired, and `1` on a usage, sign-in or API
229
+ error. `9pm domains status <app> <hostname> --json` prints `{hostname, app, state, routing, tls,
230
+ provider, nameservers, records, error, expiresAt, checkedAt}`, where `state` is `live`,
231
+ `waiting_for_dns`, `dns_unverified` (routing is active but the records could not be checked),
232
+ `issuing_certificate`, `failed` or `expired`. Each record's `ok` is `null` when the check cannot
233
+ say, and its `check` says why (`not_checked`, or `apex_addresses`). `list`, `status` and `wait`
234
+ only read; `add`, `check`, `restart` and `remove` need permission to configure the app.
235
+
192
236
  ## Commands
193
237
 
194
238
  ```sh
195
239
  9pm --version
196
- 9pm deploy [directory] --name <name> [--slug <slug>] [--with-db]
240
+ 9pm deploy [directory] --name <name> [--slug <slug>] [--spa | --no-spa] [--with-db]
197
241
  9pm deploy [directory] --check [--runtime worker --entry <path> (--assets <dir> | --no-assets)]
198
242
  9pm deploy [directory] --runtime container --port 8080 [--health-path /health] [--region auto] [--with-db]
199
243
  9pm signup
@@ -208,17 +252,24 @@ Service token secrets returned by `access service create` are also server-side s
208
252
  9pm files get <file-id> [--output <path>] [--project <site>]
209
253
  9pm files share <file-id> [--expires 24h] [--public] [--project <site>]
210
254
  9pm files delete <file-id> [--project <site>]
211
- 9pm deploy [directory] --runtime worker --entry <path> (--assets <dir> | --no-assets) [--with-db]
255
+ 9pm deploy [directory] --runtime worker --entry <path> (--assets <dir> | --no-assets) [--spa | --no-spa] [--with-db]
212
256
  9pm delete <site> --confirm <slug>
257
+ 9pm domains list [<app>] [--owner <organisation>] [--json]
258
+ 9pm domains add <app> <hostname> [--json]
259
+ 9pm domains status <app> <hostname> [--json]
260
+ 9pm domains check <app> <hostname> [--json]
261
+ 9pm domains wait <app> <hostname> [--timeout 30m] [--interval 20s] [--json]
262
+ 9pm domains restart <app> <hostname>
263
+ 9pm domains remove <app> <hostname> --confirm <hostname>
213
264
  9pm access show <site>
214
- 9pm access public <site>
215
- 9pm access otp <site>
216
- 9pm access idp <site> [--provider google|github|entra]
265
+ 9pm access public <site> [--switch-mode]
266
+ 9pm access otp <site> [--switch-mode]
267
+ 9pm access idp <site> [--provider google|github|entra] [--switch-mode]
217
268
  9pm access allow email <site> <email>
218
269
  9pm access allow domain <site> <domain>
219
270
  9pm access revoke email <site> <email>
220
271
  9pm access revoke domain <site> <domain>
221
- 9pm access service create <site> [--name <name>] [--duration 8760h]
272
+ 9pm access service create <site> [--name <name>] [--duration 8760h] [--switch-mode]
222
273
  9pm access service revoke <site> <service-token-id>
223
274
  9pm access token create <site> [--name <name>]
224
275
  9pm access token revoke <site> <token-id>
@@ -240,6 +291,8 @@ Options:
240
291
  - `--entry <path>` selects the Worker runtime server entry file
241
292
  - `--assets <dir>` makes only that directory of a worker deploy public (remembered for later deploys)
242
293
  - `--no-assets` makes no file of a worker deploy public (remembered for later deploys)
294
+ - `--spa` serves `index.html` for every path that has no file, for single-page apps (remembered for later deploys)
295
+ - `--no-spa` returns 404 for a path that has no file, with the deploy's `404.html` when present; the default for new apps (remembered for later deploys)
243
296
  - `--port <port>` selects the HTTP port exposed by a container app
244
297
  - `--health-path <path>` optionally lets 9pm.ai check container health before marking it healthy
245
298
  - `--region <region>` optionally selects provider-neutral container placement
@@ -8,6 +8,7 @@ export type AppRuntime = "static" | "worker" | "container";
8
8
  export type ContainerRegion = "auto" | "us-east" | "us-west" | "europe" | "asia" | "south-america" | "middle-east" | "oceania" | "africa";
9
9
  export type ManagedFileScope = "user" | "project";
10
10
  export type ManagedFileShareMode = "public" | "login_required";
11
+ export type MissingPagesMode = "legacy" | "spa" | "404";
11
12
  export interface Project {
12
13
  id: string;
13
14
  name: string;
@@ -16,6 +17,7 @@ export interface Project {
16
17
  createdAt: string;
17
18
  updatedAt: string;
18
19
  workerPublicAssets?: string | null;
20
+ missingPages?: MissingPagesMode;
19
21
  }
20
22
  export interface AppUserFilesUsage {
21
23
  usedBytes: number;
@@ -0,0 +1,7 @@
1
+ /** A best guess at the zone a custom hostname lives in: `www.tmrwtitans.com` -> `tmrwtitans.com`. */
2
+ export declare function guessZoneApex(hostname: string): string;
3
+ /**
4
+ * The record name the way most registrars' Host field wants it, relative to the zone:
5
+ * `www.tmrwtitans.com` -> `www`, the apex itself -> `@`. A name outside the zone stays whole.
6
+ */
7
+ export declare function relativeRecordName(recordHost: string, apex: string): string;
@@ -0,0 +1,27 @@
1
+ // AUTO-GENERATED by scripts/generate-cli-package.mjs from the in-repo CLI source -- do not edit.
2
+ // Edit src/cli or src/shared instead; this is regenerated on typecheck/test/build/pack.
3
+ // Record names for a custom domain's DNS instructions, written the way a registrar's Host
4
+ // field wants them. Read by the dashboard's DNS table and by `9pm domains`, and copied into
5
+ // the CLI tarball by scripts/generate-cli-package.mjs, so it must import nothing.
6
+ // Second-level labels that sit under a two-letter country code as part of the public suffix
7
+ // (example.co.uk, example.com.au). Not a public suffix list: it covers the common shapes, and
8
+ // the full name is always shown beside the short one for the cases it misses.
9
+ const SECOND_LEVEL = new Set(["co", "com", "net", "org", "gov", "ac", "edu", "ne", "or", "go"]);
10
+ /** A best guess at the zone a custom hostname lives in: `www.tmrwtitans.com` -> `tmrwtitans.com`. */
11
+ export function guessZoneApex(hostname) {
12
+ const labels = hostname.toLowerCase().replace(/\.$/, "").split(".");
13
+ if (labels.length >= 3 && SECOND_LEVEL.has(labels[labels.length - 2]) && labels[labels.length - 1].length === 2) {
14
+ return labels.slice(-3).join(".");
15
+ }
16
+ return labels.slice(-2).join(".");
17
+ }
18
+ /**
19
+ * The record name the way most registrars' Host field wants it, relative to the zone:
20
+ * `www.tmrwtitans.com` -> `www`, the apex itself -> `@`. A name outside the zone stays whole.
21
+ */
22
+ export function relativeRecordName(recordHost, apex) {
23
+ const host = recordHost.toLowerCase().replace(/\.$/, "");
24
+ if (host === apex)
25
+ return "@";
26
+ return host.endsWith(`.${apex}`) ? host.slice(0, -(apex.length + 1)) : host;
27
+ }
package/dist/index.d.ts CHANGED
@@ -18,6 +18,7 @@ export interface DeployOptions {
18
18
  runtime?: AppRuntime;
19
19
  entry?: string;
20
20
  publicAssets?: string;
21
+ missingPages?: "spa" | "404";
21
22
  port?: number;
22
23
  healthPath?: string;
23
24
  region?: ContainerRegion;
@@ -124,7 +125,7 @@ export interface CreatedServiceToken {
124
125
  clientSecret: string;
125
126
  }
126
127
  export type FetchLike = (input: string | URL | Request, init?: RequestInit) => Promise<Response>;
127
- export declare const CLI_VERSION = "0.69.0";
128
+ export declare const CLI_VERSION = "0.72.0";
128
129
  type CredentialBackend = "macos-keychain" | "windows-dpapi" | "linux-secret-service" | "file-plaintext" | "none";
129
130
  export interface StoredCredentialEntry {
130
131
  token: string;
@@ -225,7 +226,8 @@ export declare class DeployError extends Error {
225
226
  * prevent (#1061).
226
227
  */
227
228
  export declare class DeliberateRefusalError extends Error {
228
- constructor(message: string);
229
+ readonly code: string | undefined;
230
+ constructor(message: string, code?: string);
229
231
  }
230
232
  export declare function isDeliberateRefusal(error: unknown): boolean;
231
233
  export declare function deployDirectory(initialOptions: DeployOptions): Promise<DeployResult>;
@@ -271,12 +273,16 @@ export type LogoutSummary = {
271
273
  };
272
274
  export declare function summarizeRevocations(attempts: RevocationAttempt[]): LogoutSummary;
273
275
  export declare function getSiteAccess(options: SiteAccessOptions): Promise<AccessState>;
274
- export declare function setSiteAccessPublic(options: SiteAccessOptions): Promise<AccessState["access"]>;
275
- export declare function setSiteAccessOtp(options: SiteAccessOptions): Promise<AccessState["access"]>;
276
- export declare function setSiteAccessIdp(options: SiteAccessOptions & {
276
+ export declare const ACCESS_MODE_CHANGE_UNCONFIRMED = "access_mode_change_unconfirmed";
277
+ export interface AccessModeChangeOptions extends SiteAccessOptions {
278
+ switchMode?: boolean;
279
+ }
280
+ export declare function setSiteAccessPublic(options: AccessModeChangeOptions): Promise<AccessState["access"]>;
281
+ export declare function setSiteAccessOtp(options: AccessModeChangeOptions): Promise<AccessState["access"]>;
282
+ export declare function setSiteAccessIdp(options: AccessModeChangeOptions & {
277
283
  providers?: AccessProviderKey[];
278
284
  }): Promise<AccessState["access"]>;
279
- export declare function createAccessServiceToken(options: SiteAccessOptions & {
285
+ export declare function createAccessServiceToken(options: AccessModeChangeOptions & {
280
286
  name?: string;
281
287
  duration?: string;
282
288
  }): Promise<CreatedServiceToken>;
@@ -612,6 +618,36 @@ type CliCommand = {
612
618
  };
613
619
  apiUrl: string;
614
620
  token: string;
621
+ } | {
622
+ kind: "domains";
623
+ owner?: string;
624
+ action: {
625
+ kind: "list";
626
+ site?: string;
627
+ json: boolean;
628
+ } | {
629
+ kind: "add" | "status" | "check";
630
+ site: string;
631
+ hostname: string;
632
+ json: boolean;
633
+ } | {
634
+ kind: "wait";
635
+ site: string;
636
+ hostname: string;
637
+ json: boolean;
638
+ timeoutSeconds: number;
639
+ intervalSeconds: number;
640
+ } | {
641
+ kind: "restart";
642
+ site: string;
643
+ hostname: string;
644
+ } | {
645
+ kind: "remove";
646
+ site: string;
647
+ hostname: string;
648
+ };
649
+ apiUrl: string;
650
+ token: string;
615
651
  } | {
616
652
  kind: "deployments";
617
653
  action: {
@@ -632,13 +668,16 @@ type CliCommand = {
632
668
  } | {
633
669
  kind: "public";
634
670
  site: string;
671
+ switchMode?: true;
635
672
  } | {
636
673
  kind: "otp";
637
674
  site: string;
675
+ switchMode?: true;
638
676
  } | {
639
677
  kind: "idp";
640
678
  site: string;
641
679
  providers?: AccessProviderKey[];
680
+ switchMode?: true;
642
681
  } | {
643
682
  kind: "allow";
644
683
  site: string;
@@ -654,6 +693,7 @@ type CliCommand = {
654
693
  site: string;
655
694
  name?: string;
656
695
  duration?: string;
696
+ switchMode?: true;
657
697
  } | {
658
698
  kind: "service-revoke";
659
699
  site: string;
@@ -669,6 +709,7 @@ type CliCommand = {
669
709
  tokenId: string;
670
710
  };
671
711
  apiUrl: string;
712
+ apiUrlFrom: ApiUrlSource;
672
713
  token: string;
673
714
  };
674
715
  export declare function parseArgs(argv: string[]): CliCommand;
@@ -688,7 +729,131 @@ export declare function deploymentsListJson<T extends ListedDeployment>(deployme
688
729
  serving: boolean;
689
730
  }>;
690
731
  };
732
+ export interface CliDnsInstruction {
733
+ type: string;
734
+ host: string;
735
+ value: string;
736
+ optional?: boolean;
737
+ }
738
+ export interface CliDomainDnsCheck {
739
+ provider: string | null;
740
+ nameservers: string[];
741
+ records: Array<{
742
+ type: string;
743
+ host: string;
744
+ expected: string;
745
+ current: string[];
746
+ matches: boolean;
747
+ }>;
748
+ }
749
+ export interface CliDomain {
750
+ hostname: string;
751
+ routingStatus: string;
752
+ tlsStatus: string;
753
+ dnsInstructions: CliDnsInstruction[];
754
+ provisioningError: string | null;
755
+ expireAfter?: string | null;
756
+ expiresAt?: string | null;
757
+ dnsCheck?: CliDomainDnsCheck;
758
+ app?: {
759
+ id: string;
760
+ slug: string;
761
+ name: string;
762
+ };
763
+ }
764
+ export type DomainState = "live" | "waiting_for_dns" | "dns_unverified" | "issuing_certificate" | "failed" | "expired";
765
+ export declare const CUSTOM_DOMAIN_EXPIRED_TEXT = "This domain was removed because its DNS verification was never completed. Add the domain again to retry.";
766
+ export interface DomainRecordStatus {
767
+ type: string;
768
+ name: string;
769
+ fqdn: string;
770
+ expected: string;
771
+ current: string[] | null;
772
+ ok: boolean | null;
773
+ check: DomainRecordCheck;
774
+ apex: boolean;
775
+ optional: boolean;
776
+ }
777
+ export type DomainRecordCheck = "found" | "missing" | "different" | "not_checked" | "apex_addresses";
778
+ export interface DomainStatusJson {
779
+ hostname: string;
780
+ app: string;
781
+ state: DomainState;
782
+ routing: string;
783
+ tls: string;
784
+ provider: string | null;
785
+ nameservers: string[];
786
+ records: DomainRecordStatus[];
787
+ error: string | null;
788
+ expiresAt: string | null;
789
+ checkedAt: string;
790
+ }
791
+ export declare const DOMAIN_WAIT_EXIT: {
792
+ readonly live: 0;
793
+ readonly ownerAction: 10;
794
+ readonly platform: 11;
795
+ readonly failed: 20;
796
+ };
797
+ export declare function domainRecordStatuses(domain: CliDomain): DomainRecordStatus[];
798
+ export declare function deriveDomainState(domain: CliDomain, records?: DomainRecordStatus[]): DomainState;
799
+ export declare function domainStatusJson(domain: CliDomain, app: string, checkedAt: string): DomainStatusJson;
800
+ export declare function domainStateLabel(state: DomainState): string;
801
+ export declare function formatDomainStatus(status: DomainStatusJson, options?: {
802
+ owner?: string;
803
+ }): string[];
804
+ export declare function domainWaitTimeoutMessage(status: DomainStatusJson): string;
805
+ export declare function formatDomainsList(statuses: DomainStatusJson[]): string[];
806
+ export declare class DomainsApiError extends Error {
807
+ readonly status: number;
808
+ readonly code: string | null;
809
+ constructor(message: string, status: number, code: string | null);
810
+ }
811
+ export declare function domainsApiError(status: number, text: string): DomainsApiError;
812
+ export interface DomainCommandOptions extends ApiOptions {
813
+ site: string;
814
+ owner?: string;
815
+ }
816
+ export declare function normalizeDomainArg(value: string): string;
817
+ export declare function listDomainStatuses(options: ApiOptions & {
818
+ site?: string;
819
+ owner?: string;
820
+ now?: () => Date;
821
+ }): Promise<DomainStatusJson[]>;
822
+ export declare function getDomainStatus(options: DomainCommandOptions & {
823
+ hostname: string;
824
+ now?: () => Date;
825
+ }): Promise<DomainStatusJson>;
826
+ export declare function addDomain(options: DomainCommandOptions & {
827
+ hostname: string;
828
+ }): Promise<CliDomain | null>;
829
+ export declare function recheckDomain(options: DomainCommandOptions & {
830
+ hostname: string;
831
+ }): Promise<void>;
832
+ export declare function restartDomain(options: DomainCommandOptions & {
833
+ hostname: string;
834
+ }): Promise<{
835
+ domain: CliDomain;
836
+ replacedInstructions: CliDnsInstruction[];
837
+ }>;
838
+ export declare function removeDomain(options: DomainCommandOptions & {
839
+ hostname: string;
840
+ }): Promise<CliDomain | null>;
841
+ export interface DomainWaitResult {
842
+ status: DomainStatusJson;
843
+ exitCode: number;
844
+ timedOut: boolean;
845
+ }
846
+ export declare function waitForDomain(options: DomainCommandOptions & {
847
+ hostname: string;
848
+ timeoutMs: number;
849
+ intervalMs: number;
850
+ nowMs?: () => number;
851
+ sleepFn?: (ms: number) => Promise<void>;
852
+ onStatus?: (status: DomainStatusJson) => void;
853
+ onRetry?: (error: unknown) => void;
854
+ }): Promise<DomainWaitResult>;
691
855
  export declare function healthWithReason(status: string | null | undefined, reason: string | null | undefined): string;
856
+ export type ApiUrlSource = "flag" | "env" | "default";
692
857
  export declare function workerPublicFiles(paths: readonly string[], serverEntry: string, selection: string | null): Array<{
693
858
  path: string;
694
859
  url: string;
@@ -723,4 +888,15 @@ export declare function hasNoGraphicalDisplay(platform: string, env: Record<stri
723
888
  export declare function formatAppUserFilesUsageLine(slug: string, usage: AppUserFilesUsage): string;
724
889
  export declare class UsageError extends Error {
725
890
  }
891
+ export declare function missingPagesLine(deployment: Deployment): string | null;
892
+ type AccessModeChangeAction = Extract<Extract<CliCommand, {
893
+ kind: "access";
894
+ }>["action"], {
895
+ kind: "public" | "otp" | "idp" | "service-create";
896
+ }>;
897
+ export interface AccessApiTarget {
898
+ apiUrl: string;
899
+ apiUrlFrom: ApiUrlSource;
900
+ }
901
+ export declare function accessSwitchModeCommand(action: AccessModeChangeAction, target?: AccessApiTarget): string;
726
902
  export {};