@shipstatic/types 2.5.0-beta.0 → 2.5.0-beta.10

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/dist/index.d.ts CHANGED
@@ -49,16 +49,30 @@ export interface DeploymentCreateResponse extends Deployment {
49
49
  /** Claim URL for public deployments. Present when deployed without credentials. */
50
50
  readonly claim?: string;
51
51
  }
52
+ /**
53
+ * The half of a list response that is identical on every list.
54
+ *
55
+ * `GET /<collection>` answers exactly two fields — the collection under its
56
+ * own plural noun, and this cursor — so the cursor is declared once here and
57
+ * each response below adds only its noun. `cursor: null` means last page and
58
+ * is the ENTIRE has-more signal, which is why there is no `has_more`.
59
+ *
60
+ * There is deliberately no `total`. A count is an aggregate over a
61
+ * collection, not a property of a page; producing one would cost a COUNT
62
+ * beside every page read, which is precisely what keyset pagination exists
63
+ * to avoid. Counts live on the resource that summarises the collection —
64
+ * `GET /account`'s `usage` for one caller, `GET /admin/stats` platform-wide.
65
+ */
66
+ export interface ListResponse {
67
+ /** Opaque cursor from this page; `null` on the last page. */
68
+ cursor: string | null;
69
+ }
52
70
  /**
53
71
  * Response for listing deployments
54
72
  */
55
- export interface DeploymentListResponse {
73
+ export interface DeploymentListResponse extends ListResponse {
56
74
  /** Array of deployments */
57
75
  deployments: Deployment[];
58
- /** Cursor for pagination, null if no more pages */
59
- cursor: string | null;
60
- /** Total number of deployments */
61
- total: number;
62
76
  }
63
77
  /**
64
78
  * Domain status constants
@@ -91,7 +105,7 @@ export interface Domain {
91
105
  labels: string[];
92
106
  /** Unix timestamp (seconds) when domain was created */
93
107
  readonly created: number;
94
- /** When deployment was last linked (Unix timestamp), null if never linked */
108
+ /** Unix timestamp (seconds) when deployment was last linked, null if never linked */
95
109
  linked: number | null;
96
110
  /** Total deployment links */
97
111
  links: number;
@@ -113,13 +127,9 @@ export interface DomainSetResult extends Domain {
113
127
  /**
114
128
  * Response for listing domains
115
129
  */
116
- export interface DomainListResponse {
130
+ export interface DomainListResponse extends ListResponse {
117
131
  /** Array of domains */
118
132
  domains: Domain[];
119
- /** Cursor for pagination, null if no more pages */
120
- cursor: string | null;
121
- /** Total number of domains */
122
- total: number;
123
133
  }
124
134
  /**
125
135
  * DNS record types supported for domain configuration
@@ -179,11 +189,13 @@ export interface DomainValidateResponse {
179
189
  error: string | null;
180
190
  }
181
191
  /**
182
- * Token as returned by the list endpoint.
183
- * The secret is shown once at creation and never again — listings carry
184
- * only the management identifier and lifecycle metadata.
192
+ * Core deploy token object - used in both API responses and SDK.
193
+ *
194
+ * The secret is never here: it is shown once at creation
195
+ * ({@link TokenCreateResponse.secret}) and never again, so an entity read
196
+ * carries only the management identifier and lifecycle metadata.
185
197
  */
186
- export interface TokenListItem {
198
+ export interface Token {
187
199
  /** 7-char management identifier (e.g., "a1b2c3d") */
188
200
  readonly token: string;
189
201
  /** Labels for categorization and filtering. Always present, empty array when none. */
@@ -198,24 +210,19 @@ export interface TokenListItem {
198
210
  /**
199
211
  * Response for listing tokens
200
212
  */
201
- export interface TokenListResponse {
202
- /** Array of tokens (security-redacted for list display) */
203
- tokens: TokenListItem[];
204
- /** Total number of tokens */
205
- total: number;
213
+ export interface TokenListResponse extends ListResponse {
214
+ /** Array of tokens (the secret is never among them) */
215
+ tokens: Token[];
206
216
  }
207
217
  /**
208
- * Response for token creation
218
+ * Response from token creation. Extends Token with the one field that
219
+ * exists only on creation — the same shape as
220
+ * {@link DeploymentCreateResponse}, because a 201 returns the resource it
221
+ * created plus whatever is knowable only once.
209
222
  */
210
- export interface TokenCreateResponse {
211
- /** 7-char management identifier */
212
- token: string;
223
+ export interface TokenCreateResponse extends Token {
213
224
  /** The raw credential value (shown once at creation, then never again) */
214
- secret: string;
215
- /** Labels for categorization and filtering. Always present, empty array when none. */
216
- labels: string[];
217
- /** Unix timestamp (seconds) when token expires, null for never */
218
- expires: number | null;
225
+ readonly secret: string;
219
226
  }
220
227
  /**
221
228
  * Account plan constants
@@ -232,10 +239,34 @@ export declare const AccountPlan: {
232
239
  export type AccountPlanType = (typeof AccountPlan)[keyof typeof AccountPlan];
233
240
  /**
234
241
  * Account usage metrics — always available regardless of billing provider.
242
+ *
243
+ * This is where a caller's own totals live. Lists answer pages and carry no
244
+ * `total` (see {@link ListOptions}); a count is an aggregate over a
245
+ * collection, so it belongs to the summary resource that owns the
246
+ * collection. `GET /account` is that resource for one caller, `GET
247
+ * /admin/stats` for the platform.
248
+ *
249
+ * The counted dimensions are the ones the plan caps — deployments and
250
+ * domains (`PlatformLimits`) — plus the billable custom-domain subset, so a
251
+ * surface can render "3 of 10" without a second request.
235
252
  */
236
253
  export interface AccountUsage {
237
254
  /** Number of active custom domains (excludes paused) */
238
255
  customDomains: number;
256
+ /**
257
+ * Deployments counted against the plan's deployment cap — every row
258
+ * whatever its status, because that is what the cap counts, so a surface
259
+ * renders "3 of 10" against the denominator the 403 divides by. (`GET
260
+ * /deployments` lists successful ones only; that is a different question
261
+ * asked of a different resource.) Optional by the additive-evolution law:
262
+ * an API predating this field omits it.
263
+ */
264
+ deployments?: number;
265
+ /**
266
+ * Domains counted against the plan's domain cap — every domain, platform
267
+ * and custom alike, unlike `customDomains`. Optional for the same reason.
268
+ */
269
+ domains?: number;
239
270
  }
240
271
  /**
241
272
  * Core account object - used in both API responses and SDK
@@ -421,6 +452,18 @@ export declare class ShipError extends Error {
421
452
  static file(message: string, details?: unknown): ShipError;
422
453
  static config(message: string, details?: unknown): ShipError;
423
454
  static api(message: string, status?: number, details?: unknown): ShipError;
455
+ /**
456
+ * The caller is at fault — by HTTP's own definition of a 4xx, or by a type
457
+ * that is client-attributable without ever having a status (`Config`,
458
+ * `File`, raised locally by the SDK).
459
+ *
460
+ * Both arms are load-bearing, because type and status are independent
461
+ * axes. `fromHttpResponse` trusts `body.error` only when it names a
462
+ * server-producible type; a non-OK response without one is status-derived,
463
+ * so a CDN 404 or any intermediary error arrives as `Api` — a server-fault
464
+ * *type* carrying a client *status*. Judging by type alone would report it
465
+ * as a platform failure and bury the server's own message.
466
+ */
424
467
  isClientError(): boolean;
425
468
  isNetworkError(): boolean;
426
469
  isAuthError(): boolean;
@@ -522,7 +565,7 @@ export declare function hasUnbuiltMarker(filePath: string): boolean;
522
565
  export interface PingResponse {
523
566
  /** Always true if service is healthy */
524
567
  success: boolean;
525
- /** Optional timestamp */
568
+ /** Server time in unix seconds — the one wire unit for timestamps. */
526
569
  timestamp?: number;
527
570
  }
528
571
  /**
@@ -645,6 +688,36 @@ export declare const SPA_DEFAULT_CONFIG: {
645
688
  readonly destination: "/index.html";
646
689
  }];
647
690
  };
691
+ /**
692
+ * Assert that a ship.json file is *syntactically* loadable. Syntax only —
693
+ * never schema.
694
+ *
695
+ * ship.json is validated and compiled on the server, deliberately: the schema
696
+ * and the compiler evolve, and a client that judged them would reject configs
697
+ * a newer platform accepts. That reasoning bounds what a client may check to
698
+ * the properties which are true of *every* past and future schema:
699
+ *
700
+ * 1. it parses as JSON — JSON syntax is frozen (RFC 8259), so text that
701
+ * does not parse can never be a valid config;
702
+ * 2. its top level is an object — ship.json is `{ ... }` in every version.
703
+ *
704
+ * Both are monotonic: neither can ever reject something the server would
705
+ * accept. Everything beyond them (field names, types, rule semantics, which
706
+ * keys are permitted) stays server-side, where it can change.
707
+ *
708
+ * The payoff is the common case. Hand-edited JSON fails on a trailing comma,
709
+ * a `//` comment, single quotes, unquoted keys, or smart quotes pasted from
710
+ * documentation — mistakes that otherwise cost a full upload round-trip to
711
+ * discover. A UTF-8 BOM (Windows editors, PowerShell redirects) is stripped
712
+ * before parsing rather than rejected, because the server accepts it too;
713
+ * diverging there would reintroduce exactly the false rejection this
714
+ * function exists to avoid.
715
+ *
716
+ * @throws {ShipError} `ErrorType.Config` — the same type the server's own
717
+ * config rejection carries, so the error contract is identical wherever the
718
+ * failure is detected.
719
+ */
720
+ export declare function assertShipJsonSyntax(text: string): void;
648
721
  /**
649
722
  * Validate API key format
650
723
  */
@@ -727,20 +800,6 @@ export interface StaticFile {
727
800
  /** The size of the file in bytes. */
728
801
  size: number;
729
802
  }
730
- /**
731
- * Progress information for deploy/upload operations.
732
- * Provides consistent percentage-based progress with byte-level details.
733
- */
734
- export interface ProgressInfo {
735
- /** Progress percentage (0-100) */
736
- percent: number;
737
- /** Number of bytes loaded so far */
738
- loaded: number;
739
- /** Total number of bytes to load. May be 0 if unknown initially */
740
- total: number;
741
- /** Current file being processed (optional) */
742
- file?: string;
743
- }
744
803
  /** Default API URL if not otherwise configured. */
745
804
  export declare const DEFAULT_API = "https://api.shipstatic.com";
746
805
  /**
@@ -784,10 +843,20 @@ export interface DeploymentUploadOptions {
784
843
  captcha?: string;
785
844
  }
786
845
  /**
787
- * Pagination options for the paginated list endpoints (`GET /deployments`,
788
- * `GET /domains`). The response's `cursor` feeds the next request; a `null`
789
- * cursor on the response means the last page. Omitting both returns the
790
- * server's default first page.
846
+ * Pagination options for every list endpoint. The response's `cursor` feeds
847
+ * the next request; a `null` cursor means the last page. Omitting both
848
+ * returns the server's default first page.
849
+ *
850
+ * A list answers `{ <collection>, cursor }` and nothing else — `cursor`
851
+ * carries the entire has-more signal, so no redundant boolean, and no
852
+ * `total`. **A count is an aggregate over a collection, not a property of a
853
+ * page:** including one makes every read pay for a full scan it did not ask
854
+ * for, which is precisely the cost keyset pagination exists to avoid.
855
+ *
856
+ * Counts therefore live on the summary resource that owns them —
857
+ * `GET /account` (`usage`) for a caller's own totals, `GET /admin/stats` for
858
+ * platform-wide ones. Ask for a count when you want a count; ask for a page
859
+ * when you want a page.
791
860
  */
792
861
  export interface ListOptions {
793
862
  /** Maximum number of items to return in one page. */
@@ -848,7 +917,7 @@ export interface TokenResource {
848
917
  ttl?: number;
849
918
  labels?: string[];
850
919
  }) => Promise<TokenCreateResponse>;
851
- list: () => Promise<TokenListResponse>;
920
+ list: (options?: ListOptions) => Promise<TokenListResponse>;
852
921
  remove: (token: string) => Promise<void>;
853
922
  }
854
923
  /**
@@ -903,6 +972,12 @@ export interface Activity {
903
972
  /**
904
973
  * Parsed activity metadata.
905
974
  * Different events populate different fields.
975
+ *
976
+ * Naming convention: meta booleans are event-scoped predicates and carry
977
+ * their prefix (`isUpdate`, `wasVerified`, `hasConfig`, `hasPassword`),
978
+ * while entity booleans are bare nouns (`Deployment.config`,
979
+ * `Deployment.password`). Two vocabularies, each internally consistent —
980
+ * deliberate, not drift.
906
981
  */
907
982
  export interface ActivityMeta {
908
983
  /** Number of files in deployment */
@@ -937,7 +1012,7 @@ export interface ActivityMeta {
937
1012
  /**
938
1013
  * Response from GET /activities endpoint
939
1014
  */
940
- export interface ActivityListResponse {
1015
+ export interface ActivityListResponse extends ListResponse {
941
1016
  /** Array of activities */
942
1017
  activities: Activity[];
943
1018
  }
package/dist/index.js CHANGED
@@ -100,11 +100,19 @@ const CLIENT_ONLY_ERROR_TYPES = new Set([
100
100
  * union so `.has(error.type)` accepts any value from the union.
101
101
  */
102
102
  const ERROR_CATEGORIES = {
103
+ /**
104
+ * Client-attributable types. Exhaustive over the 4xx-carrying types, and
105
+ * it must include the statusless ones (`Config`, `File`) — those are
106
+ * raised locally by the SDK and have no status for `isClientError`'s
107
+ * second arm to read.
108
+ */
103
109
  client: new Set([
104
110
  ErrorType.Business,
105
111
  ErrorType.Config,
106
112
  ErrorType.File,
107
113
  ErrorType.Forbidden,
114
+ ErrorType.NotFound,
115
+ ErrorType.RateLimit,
108
116
  ErrorType.Validation,
109
117
  ]),
110
118
  network: new Set([ErrorType.Network]),
@@ -309,10 +317,24 @@ export class ShipError extends Error {
309
317
  static api(message, status = 500, details) {
310
318
  return new ShipError(ErrorType.Api, message, status, details);
311
319
  }
312
- // Semantic-category type guards. For specific-type checks, use
320
+ // Semantic-category guards. For specific-type checks, use
313
321
  // `error.type === ErrorType.X` directly or the generic `isType(t)`.
322
+ /**
323
+ * The caller is at fault — by HTTP's own definition of a 4xx, or by a type
324
+ * that is client-attributable without ever having a status (`Config`,
325
+ * `File`, raised locally by the SDK).
326
+ *
327
+ * Both arms are load-bearing, because type and status are independent
328
+ * axes. `fromHttpResponse` trusts `body.error` only when it names a
329
+ * server-producible type; a non-OK response without one is status-derived,
330
+ * so a CDN 404 or any intermediary error arrives as `Api` — a server-fault
331
+ * *type* carrying a client *status*. Judging by type alone would report it
332
+ * as a platform failure and bury the server's own message.
333
+ */
314
334
  isClientError() {
315
- return ERROR_CATEGORIES.client.has(this.type);
335
+ if (ERROR_CATEGORIES.client.has(this.type))
336
+ return true;
337
+ return this.status !== undefined && this.status >= 400 && this.status < 500;
316
338
  }
317
339
  isNetworkError() {
318
340
  return ERROR_CATEGORIES.network.has(this.type);
@@ -600,6 +622,52 @@ export const DEPLOYMENT_CONFIG_FILENAME = 'ship.json';
600
622
  export const SPA_DEFAULT_CONFIG = {
601
623
  rewrites: [{ source: '/(.*)', destination: '/index.html' }],
602
624
  };
625
+ /**
626
+ * Assert that a ship.json file is *syntactically* loadable. Syntax only —
627
+ * never schema.
628
+ *
629
+ * ship.json is validated and compiled on the server, deliberately: the schema
630
+ * and the compiler evolve, and a client that judged them would reject configs
631
+ * a newer platform accepts. That reasoning bounds what a client may check to
632
+ * the properties which are true of *every* past and future schema:
633
+ *
634
+ * 1. it parses as JSON — JSON syntax is frozen (RFC 8259), so text that
635
+ * does not parse can never be a valid config;
636
+ * 2. its top level is an object — ship.json is `{ ... }` in every version.
637
+ *
638
+ * Both are monotonic: neither can ever reject something the server would
639
+ * accept. Everything beyond them (field names, types, rule semantics, which
640
+ * keys are permitted) stays server-side, where it can change.
641
+ *
642
+ * The payoff is the common case. Hand-edited JSON fails on a trailing comma,
643
+ * a `//` comment, single quotes, unquoted keys, or smart quotes pasted from
644
+ * documentation — mistakes that otherwise cost a full upload round-trip to
645
+ * discover. A UTF-8 BOM (Windows editors, PowerShell redirects) is stripped
646
+ * before parsing rather than rejected, because the server accepts it too;
647
+ * diverging there would reintroduce exactly the false rejection this
648
+ * function exists to avoid.
649
+ *
650
+ * @throws {ShipError} `ErrorType.Config` — the same type the server's own
651
+ * config rejection carries, so the error contract is identical wherever the
652
+ * failure is detected.
653
+ */
654
+ export function assertShipJsonSyntax(text) {
655
+ const withoutBom = text.charCodeAt(0) === 0xfeff ? text.slice(1) : text;
656
+ let parsed;
657
+ try {
658
+ parsed = JSON.parse(withoutBom);
659
+ }
660
+ catch (error) {
661
+ throw ShipError.config(`invalid JSON format in config: ${error.message}`, {
662
+ filePath: DEPLOYMENT_CONFIG_FILENAME,
663
+ });
664
+ }
665
+ if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
666
+ throw ShipError.config(`${DEPLOYMENT_CONFIG_FILENAME} must contain a JSON object`, {
667
+ filePath: DEPLOYMENT_CONFIG_FILENAME,
668
+ });
669
+ }
670
+ }
603
671
  // =============================================================================
604
672
  // VALIDATION UTILITIES
605
673
  // =============================================================================
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@shipstatic/types",
3
- "version": "2.5.0-beta.0",
3
+ "version": "2.5.0-beta.10",
4
4
  "description": "Shared types for ShipStatic platform",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -11,6 +11,16 @@
11
11
  "default": "./dist/index.js"
12
12
  }
13
13
  },
14
+ "scripts": {
15
+ "build": "tsc",
16
+ "clean": "rm -rf dist",
17
+ "test": "vitest",
18
+ "lint": "biome check .",
19
+ "format": "biome format --write .",
20
+ "prepare": "git config core.hooksPath scripts/githooks",
21
+ "typecheck": "tsc -p tsconfig.check.json --noEmit"
22
+ },
23
+ "packageManager": "pnpm@10.12.4",
14
24
  "files": [
15
25
  "dist",
16
26
  "src"
@@ -34,16 +44,8 @@
34
44
  },
35
45
  "devDependencies": {
36
46
  "@biomejs/biome": "2.5.5",
37
- "@types/node": "^24.10.9",
47
+ "@types/node": "^24.13.3",
38
48
  "typescript": "^5.9.3",
39
- "vitest": "^2.1.8"
40
- },
41
- "scripts": {
42
- "build": "tsc",
43
- "clean": "rm -rf dist",
44
- "test": "vitest",
45
- "lint": "biome check .",
46
- "format": "biome format --write .",
47
- "typecheck": "tsc --noEmit"
49
+ "vitest": "^2.1.9"
48
50
  }
49
- }
51
+ }
package/src/index.ts CHANGED
@@ -58,16 +58,31 @@ export interface DeploymentCreateResponse extends Deployment {
58
58
  readonly claim?: string;
59
59
  }
60
60
 
61
+ /**
62
+ * The half of a list response that is identical on every list.
63
+ *
64
+ * `GET /<collection>` answers exactly two fields — the collection under its
65
+ * own plural noun, and this cursor — so the cursor is declared once here and
66
+ * each response below adds only its noun. `cursor: null` means last page and
67
+ * is the ENTIRE has-more signal, which is why there is no `has_more`.
68
+ *
69
+ * There is deliberately no `total`. A count is an aggregate over a
70
+ * collection, not a property of a page; producing one would cost a COUNT
71
+ * beside every page read, which is precisely what keyset pagination exists
72
+ * to avoid. Counts live on the resource that summarises the collection —
73
+ * `GET /account`'s `usage` for one caller, `GET /admin/stats` platform-wide.
74
+ */
75
+ export interface ListResponse {
76
+ /** Opaque cursor from this page; `null` on the last page. */
77
+ cursor: string | null;
78
+ }
79
+
61
80
  /**
62
81
  * Response for listing deployments
63
82
  */
64
- export interface DeploymentListResponse {
83
+ export interface DeploymentListResponse extends ListResponse {
65
84
  /** Array of deployments */
66
85
  deployments: Deployment[];
67
- /** Cursor for pagination, null if no more pages */
68
- cursor: string | null;
69
- /** Total number of deployments */
70
- total: number;
71
86
  }
72
87
 
73
88
  // =============================================================================
@@ -107,7 +122,7 @@ export interface Domain {
107
122
  labels: string[];
108
123
  /** Unix timestamp (seconds) when domain was created */
109
124
  readonly created: number;
110
- /** When deployment was last linked (Unix timestamp), null if never linked */
125
+ /** Unix timestamp (seconds) when deployment was last linked, null if never linked */
111
126
  linked: number | null;
112
127
  /** Total deployment links */
113
128
  links: number;
@@ -131,13 +146,9 @@ export interface DomainSetResult extends Domain {
131
146
  /**
132
147
  * Response for listing domains
133
148
  */
134
- export interface DomainListResponse {
149
+ export interface DomainListResponse extends ListResponse {
135
150
  /** Array of domains */
136
151
  domains: Domain[];
137
- /** Cursor for pagination, null if no more pages */
138
- cursor: string | null;
139
- /** Total number of domains */
140
- total: number;
141
152
  }
142
153
 
143
154
  /**
@@ -206,11 +217,13 @@ export interface DomainValidateResponse {
206
217
  // =============================================================================
207
218
 
208
219
  /**
209
- * Token as returned by the list endpoint.
210
- * The secret is shown once at creation and never again — listings carry
211
- * only the management identifier and lifecycle metadata.
220
+ * Core deploy token object - used in both API responses and SDK.
221
+ *
222
+ * The secret is never here: it is shown once at creation
223
+ * ({@link TokenCreateResponse.secret}) and never again, so an entity read
224
+ * carries only the management identifier and lifecycle metadata.
212
225
  */
213
- export interface TokenListItem {
226
+ export interface Token {
214
227
  /** 7-char management identifier (e.g., "a1b2c3d") */
215
228
  readonly token: string;
216
229
  /** Labels for categorization and filtering. Always present, empty array when none. */
@@ -226,25 +239,20 @@ export interface TokenListItem {
226
239
  /**
227
240
  * Response for listing tokens
228
241
  */
229
- export interface TokenListResponse {
230
- /** Array of tokens (security-redacted for list display) */
231
- tokens: TokenListItem[];
232
- /** Total number of tokens */
233
- total: number;
242
+ export interface TokenListResponse extends ListResponse {
243
+ /** Array of tokens (the secret is never among them) */
244
+ tokens: Token[];
234
245
  }
235
246
 
236
247
  /**
237
- * Response for token creation
248
+ * Response from token creation. Extends Token with the one field that
249
+ * exists only on creation — the same shape as
250
+ * {@link DeploymentCreateResponse}, because a 201 returns the resource it
251
+ * created plus whatever is knowable only once.
238
252
  */
239
- export interface TokenCreateResponse {
240
- /** 7-char management identifier */
241
- token: string;
253
+ export interface TokenCreateResponse extends Token {
242
254
  /** The raw credential value (shown once at creation, then never again) */
243
- secret: string;
244
- /** Labels for categorization and filtering. Always present, empty array when none. */
245
- labels: string[];
246
- /** Unix timestamp (seconds) when token expires, null for never */
247
- expires: number | null;
255
+ readonly secret: string;
248
256
  }
249
257
 
250
258
  // =============================================================================
@@ -268,10 +276,34 @@ export type AccountPlanType = (typeof AccountPlan)[keyof typeof AccountPlan];
268
276
 
269
277
  /**
270
278
  * Account usage metrics — always available regardless of billing provider.
279
+ *
280
+ * This is where a caller's own totals live. Lists answer pages and carry no
281
+ * `total` (see {@link ListOptions}); a count is an aggregate over a
282
+ * collection, so it belongs to the summary resource that owns the
283
+ * collection. `GET /account` is that resource for one caller, `GET
284
+ * /admin/stats` for the platform.
285
+ *
286
+ * The counted dimensions are the ones the plan caps — deployments and
287
+ * domains (`PlatformLimits`) — plus the billable custom-domain subset, so a
288
+ * surface can render "3 of 10" without a second request.
271
289
  */
272
290
  export interface AccountUsage {
273
291
  /** Number of active custom domains (excludes paused) */
274
292
  customDomains: number;
293
+ /**
294
+ * Deployments counted against the plan's deployment cap — every row
295
+ * whatever its status, because that is what the cap counts, so a surface
296
+ * renders "3 of 10" against the denominator the 403 divides by. (`GET
297
+ * /deployments` lists successful ones only; that is a different question
298
+ * asked of a different resource.) Optional by the additive-evolution law:
299
+ * an API predating this field omits it.
300
+ */
301
+ deployments?: number;
302
+ /**
303
+ * Domains counted against the plan's domain cap — every domain, platform
304
+ * and custom alike, unlike `customDomains`. Optional for the same reason.
305
+ */
306
+ domains?: number;
275
307
  }
276
308
 
277
309
  /**
@@ -397,11 +429,19 @@ const CLIENT_ONLY_ERROR_TYPES = new Set<string>([
397
429
  * union so `.has(error.type)` accepts any value from the union.
398
430
  */
399
431
  const ERROR_CATEGORIES = {
432
+ /**
433
+ * Client-attributable types. Exhaustive over the 4xx-carrying types, and
434
+ * it must include the statusless ones (`Config`, `File`) — those are
435
+ * raised locally by the SDK and have no status for `isClientError`'s
436
+ * second arm to read.
437
+ */
400
438
  client: new Set<ErrorType>([
401
439
  ErrorType.Business,
402
440
  ErrorType.Config,
403
441
  ErrorType.File,
404
442
  ErrorType.Forbidden,
443
+ ErrorType.NotFound,
444
+ ErrorType.RateLimit,
405
445
  ErrorType.Validation,
406
446
  ]),
407
447
  network: new Set<ErrorType>([ErrorType.Network]),
@@ -645,10 +685,24 @@ export class ShipError extends Error {
645
685
  return new ShipError(ErrorType.Api, message, status, details);
646
686
  }
647
687
 
648
- // Semantic-category type guards. For specific-type checks, use
688
+ // Semantic-category guards. For specific-type checks, use
649
689
  // `error.type === ErrorType.X` directly or the generic `isType(t)`.
690
+
691
+ /**
692
+ * The caller is at fault — by HTTP's own definition of a 4xx, or by a type
693
+ * that is client-attributable without ever having a status (`Config`,
694
+ * `File`, raised locally by the SDK).
695
+ *
696
+ * Both arms are load-bearing, because type and status are independent
697
+ * axes. `fromHttpResponse` trusts `body.error` only when it names a
698
+ * server-producible type; a non-OK response without one is status-derived,
699
+ * so a CDN 404 or any intermediary error arrives as `Api` — a server-fault
700
+ * *type* carrying a client *status*. Judging by type alone would report it
701
+ * as a platform failure and bury the server's own message.
702
+ */
650
703
  isClientError(): boolean {
651
- return ERROR_CATEGORIES.client.has(this.type);
704
+ if (ERROR_CATEGORIES.client.has(this.type)) return true;
705
+ return this.status !== undefined && this.status >= 400 && this.status < 500;
652
706
  }
653
707
 
654
708
  isNetworkError(): boolean {
@@ -853,7 +907,7 @@ export function hasUnbuiltMarker(filePath: string): boolean {
853
907
  export interface PingResponse {
854
908
  /** Always true if service is healthy */
855
909
  success: boolean;
856
- /** Optional timestamp */
910
+ /** Server time in unix seconds — the one wire unit for timestamps. */
857
911
  timestamp?: number;
858
912
  }
859
913
 
@@ -1004,6 +1058,54 @@ export const SPA_DEFAULT_CONFIG = {
1004
1058
  rewrites: [{ source: '/(.*)', destination: '/index.html' }],
1005
1059
  } as const;
1006
1060
 
1061
+ /**
1062
+ * Assert that a ship.json file is *syntactically* loadable. Syntax only —
1063
+ * never schema.
1064
+ *
1065
+ * ship.json is validated and compiled on the server, deliberately: the schema
1066
+ * and the compiler evolve, and a client that judged them would reject configs
1067
+ * a newer platform accepts. That reasoning bounds what a client may check to
1068
+ * the properties which are true of *every* past and future schema:
1069
+ *
1070
+ * 1. it parses as JSON — JSON syntax is frozen (RFC 8259), so text that
1071
+ * does not parse can never be a valid config;
1072
+ * 2. its top level is an object — ship.json is `{ ... }` in every version.
1073
+ *
1074
+ * Both are monotonic: neither can ever reject something the server would
1075
+ * accept. Everything beyond them (field names, types, rule semantics, which
1076
+ * keys are permitted) stays server-side, where it can change.
1077
+ *
1078
+ * The payoff is the common case. Hand-edited JSON fails on a trailing comma,
1079
+ * a `//` comment, single quotes, unquoted keys, or smart quotes pasted from
1080
+ * documentation — mistakes that otherwise cost a full upload round-trip to
1081
+ * discover. A UTF-8 BOM (Windows editors, PowerShell redirects) is stripped
1082
+ * before parsing rather than rejected, because the server accepts it too;
1083
+ * diverging there would reintroduce exactly the false rejection this
1084
+ * function exists to avoid.
1085
+ *
1086
+ * @throws {ShipError} `ErrorType.Config` — the same type the server's own
1087
+ * config rejection carries, so the error contract is identical wherever the
1088
+ * failure is detected.
1089
+ */
1090
+ export function assertShipJsonSyntax(text: string): void {
1091
+ const withoutBom = text.charCodeAt(0) === 0xfeff ? text.slice(1) : text;
1092
+
1093
+ let parsed: unknown;
1094
+ try {
1095
+ parsed = JSON.parse(withoutBom);
1096
+ } catch (error) {
1097
+ throw ShipError.config(`invalid JSON format in config: ${(error as Error).message}`, {
1098
+ filePath: DEPLOYMENT_CONFIG_FILENAME,
1099
+ });
1100
+ }
1101
+
1102
+ if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
1103
+ throw ShipError.config(`${DEPLOYMENT_CONFIG_FILENAME} must contain a JSON object`, {
1104
+ filePath: DEPLOYMENT_CONFIG_FILENAME,
1105
+ });
1106
+ }
1107
+ }
1108
+
1007
1109
  // =============================================================================
1008
1110
  // VALIDATION UTILITIES
1009
1111
  // =============================================================================
@@ -1179,25 +1281,6 @@ export interface StaticFile {
1179
1281
  size: number;
1180
1282
  }
1181
1283
 
1182
- // =============================================================================
1183
- // PROGRESS TRACKING
1184
- // =============================================================================
1185
-
1186
- /**
1187
- * Progress information for deploy/upload operations.
1188
- * Provides consistent percentage-based progress with byte-level details.
1189
- */
1190
- export interface ProgressInfo {
1191
- /** Progress percentage (0-100) */
1192
- percent: number;
1193
- /** Number of bytes loaded so far */
1194
- loaded: number;
1195
- /** Total number of bytes to load. May be 0 if unknown initially */
1196
- total: number;
1197
- /** Current file being processed (optional) */
1198
- file?: string;
1199
- }
1200
-
1201
1284
  // =============================================================================
1202
1285
  // PLATFORM CONSTANTS
1203
1286
  // =============================================================================
@@ -1252,10 +1335,20 @@ export interface DeploymentUploadOptions {
1252
1335
  }
1253
1336
 
1254
1337
  /**
1255
- * Pagination options for the paginated list endpoints (`GET /deployments`,
1256
- * `GET /domains`). The response's `cursor` feeds the next request; a `null`
1257
- * cursor on the response means the last page. Omitting both returns the
1258
- * server's default first page.
1338
+ * Pagination options for every list endpoint. The response's `cursor` feeds
1339
+ * the next request; a `null` cursor means the last page. Omitting both
1340
+ * returns the server's default first page.
1341
+ *
1342
+ * A list answers `{ <collection>, cursor }` and nothing else — `cursor`
1343
+ * carries the entire has-more signal, so no redundant boolean, and no
1344
+ * `total`. **A count is an aggregate over a collection, not a property of a
1345
+ * page:** including one makes every read pay for a full scan it did not ask
1346
+ * for, which is precisely the cost keyset pagination exists to avoid.
1347
+ *
1348
+ * Counts therefore live on the summary resource that owns them —
1349
+ * `GET /account` (`usage`) for a caller's own totals, `GET /admin/stats` for
1350
+ * platform-wide ones. Ask for a count when you want a count; ask for a page
1351
+ * when you want a page.
1259
1352
  */
1260
1353
  export interface ListOptions {
1261
1354
  /** Maximum number of items to return in one page. */
@@ -1312,7 +1405,7 @@ export interface AccountResource {
1312
1405
  */
1313
1406
  export interface TokenResource {
1314
1407
  create: (options?: { ttl?: number; labels?: string[] }) => Promise<TokenCreateResponse>;
1315
- list: () => Promise<TokenListResponse>;
1408
+ list: (options?: ListOptions) => Promise<TokenListResponse>;
1316
1409
  remove: (token: string) => Promise<void>;
1317
1410
  }
1318
1411
 
@@ -1447,6 +1540,12 @@ export interface Activity {
1447
1540
  /**
1448
1541
  * Parsed activity metadata.
1449
1542
  * Different events populate different fields.
1543
+ *
1544
+ * Naming convention: meta booleans are event-scoped predicates and carry
1545
+ * their prefix (`isUpdate`, `wasVerified`, `hasConfig`, `hasPassword`),
1546
+ * while entity booleans are bare nouns (`Deployment.config`,
1547
+ * `Deployment.password`). Two vocabularies, each internally consistent —
1548
+ * deliberate, not drift.
1450
1549
  */
1451
1550
  export interface ActivityMeta {
1452
1551
  // Deployment events
@@ -1490,7 +1589,7 @@ export interface ActivityMeta {
1490
1589
  /**
1491
1590
  * Response from GET /activities endpoint
1492
1591
  */
1493
- export interface ActivityListResponse {
1592
+ export interface ActivityListResponse extends ListResponse {
1494
1593
  /** Array of activities */
1495
1594
  activities: Activity[];
1496
1595
  }