@civitai/sdk 0.12.0 → 0.13.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
@@ -492,15 +492,15 @@ Do **one of two things, and never mint a new key for that press**:
492
492
  🔴 **For a `kind: 'training'` body and a step body carrying a `quoteId`, the
493
493
  key is not the run's identity.** The host does not use the client key on those
494
494
  two; the run is identified by the confirmed quote (a step) or by the training
495
- run itself. A submit that names a **new** quote is a new run, whatever key it
496
- carries. So after an unknown outcome on one of these, check
495
+ run itself. So after an unknown outcome on one of these, check
497
496
  `app.workflows.list()` **before** asking the viewer to confirm another quote.
498
497
 
499
498
  What this client can promise stops there. Matching a repeated key to an
500
499
  existing run is done by the host and the orchestrator, not here; it is not
501
- guaranteed to match, and the host remembers a key only for a limited time. So
502
- `list()` is the check that does not depend on it: when a retry is late, or
503
- fails again, look there before telling the viewer nothing ran.
500
+ guaranteed to match, and the host remembers a key only for a limited time.
501
+ `list()` does not settle it either: a listed workflow carries no idempotency
502
+ key, so it cannot be tied to a press for certain, and a run missing from it is
503
+ not proof that nothing ran.
504
504
 
505
505
  ```ts
506
506
  import { ApiError, SubmissionUnconfirmedError } from '@civitai/sdk';
@@ -565,7 +565,7 @@ server text — log it and show your own copy.
565
565
  without one. The key is what lets the host recognise a submit that was sent
566
566
  twice — a retry after a dropped connection, a double press — as one request
567
567
  (for a `kind: 'training'` body or a step with a `quoteId`, see the note above:
568
- there the quote, not the key, identifies the run).
568
+ there the key is not the run's identity).
569
569
 
570
570
  - Mint it **once per thing the viewer asked for**, keep it until that press has
571
571
  a known outcome, and reuse it on every retry. A key minted inside the retry
@@ -9,7 +9,10 @@ export interface BlockSnapshot {
9
9
  context: BlockContext;
10
10
  token: BlockToken;
11
11
  settings: BlockSettings;
12
- /** `null` for anonymous viewers. For identity, read `/me` from the API rather than this. */
12
+ /**
13
+ * `null` for anonymous viewers. For identity, read `app.site.get('blocks/me')`
14
+ * rather than this — not `site.get('me')`, which does not accept a block token.
15
+ */
13
16
  viewer: ViewerInfo | null;
14
17
  theme: Theme;
15
18
  blockInstanceId: string;
@@ -0,0 +1,18 @@
1
+ export interface HostReply {
2
+ /** The message type the host answers with. */
3
+ readonly reply: string;
4
+ /** `legacy`: fields straight in the payload, failure as a string. */
5
+ readonly framing: 'envelope' | 'legacy';
6
+ /** The keys a success carries besides `requestId`; `null` where the host's literal could not be read. */
7
+ readonly keys: readonly string[] | null;
8
+ }
9
+ /** The civitai commit this ledger was read from. A request the host added after it is refused too. */
10
+ export declare const HOST_SNAPSHOT = "464b2023e8";
11
+ /**
12
+ * Every request the host answered at {@link HOST_SNAPSHOT}. A type absent here is refused.
13
+ * Reply names and framing are fixed at publish time: if the host renames a reply or moves a
14
+ * type to envelope framing, builds on this version stop receiving it until they upgrade.
15
+ */
16
+ export declare const HOST_REPLIES: Readonly<Record<string, HostReply>>;
17
+ /** The host's fire-and-forget messages: it handles them and never replies. */
18
+ export declare const HOST_NOTIFICATIONS: ReadonlySet<string>;
@@ -0,0 +1,66 @@
1
+ // Generated by scripts/gen-host-replies.mjs from snapshots/host-messages.json
2
+ // (civitai/civitai 464b2023e8, 2026-10-14). Do not edit.
3
+ /** The civitai commit this ledger was read from. A request the host added after it is refused too. */
4
+ export const HOST_SNAPSHOT = "464b2023e8";
5
+ /**
6
+ * Every request the host answered at {@link HOST_SNAPSHOT}. A type absent here is refused.
7
+ * Reply names and framing are fixed at publish time: if the host renames a reply or moves a
8
+ * type to envelope framing, builds on this version stop receiving it until they upgrade.
9
+ */
10
+ export const HOST_REPLIES = {
11
+ "APP_STORAGE_DELETE": { "reply": "APP_STORAGE_DELETE_RESULT", "framing": "legacy", "keys": ["deleted", "ok"] },
12
+ "APP_STORAGE_GET": { "reply": "APP_STORAGE_GET_RESULT", "framing": "legacy", "keys": ["value"] },
13
+ "APP_STORAGE_LIST": { "reply": "APP_STORAGE_LIST_RESULT", "framing": "legacy", "keys": ["keys", "nextCursor"] },
14
+ "APP_STORAGE_QUOTA": { "reply": "APP_STORAGE_QUOTA_RESULT", "framing": "legacy", "keys": ["limitBytes", "limitRows", "rowCount", "usedBytes"] },
15
+ "APP_STORAGE_SET": { "reply": "APP_STORAGE_SET_RESULT", "framing": "legacy", "keys": ["ok", "sizeBytes"] },
16
+ "CANCEL_APP_WORKFLOW": { "reply": "CANCEL_APP_WORKFLOW_RESULT", "framing": "legacy", "keys": ["result"] },
17
+ "CANCEL_WORKFLOW": { "reply": "WORKFLOW_CANCELED", "framing": "legacy", "keys": ["snapshot"] },
18
+ "CREATE_POST_FROM_APP": { "reply": "CREATE_POST_RESULT", "framing": "legacy", "keys": ["result"] },
19
+ "ESTIMATE_WORKFLOW": { "reply": "ESTIMATE_RESULT", "framing": "legacy", "keys": ["snapshot"] },
20
+ "ESTIMATE_WORKFLOW_BATCH": { "reply": "ESTIMATE_BATCH_RESULT", "framing": "legacy", "keys": ["aggregate", "snapshots"] },
21
+ "GET_BUZZ_ACCOUNTS": { "reply": "BUZZ_ACCOUNTS_RESULT", "framing": "legacy", "keys": ["result"] },
22
+ "GET_BUZZ_BALANCE": { "reply": "BUZZ_BALANCE_RESULT", "framing": "legacy", "keys": ["balance"] },
23
+ "GET_BUZZ_TRANSACTIONS": { "reply": "BUZZ_TRANSACTIONS_RESULT", "framing": "legacy", "keys": ["result"] },
24
+ "GET_DAILY_COMPENSATION": { "reply": "DAILY_COMPENSATION_RESULT", "framing": "legacy", "keys": ["result"] },
25
+ "GET_IMAGES_BY_IDS": { "reply": "IMAGES_RESULT", "framing": "legacy", "keys": ["result"] },
26
+ "GET_VIEWER": { "reply": "VIEWER_RESULT", "framing": "legacy", "keys": ["viewer"] },
27
+ "GET_WILDCARD_PACK": { "reply": "WILDCARD_PACK_RESULT", "framing": "legacy", "keys": ["pack"] },
28
+ "OPEN_BUZZ_PURCHASE": { "reply": "BUZZ_PURCHASE_RESULT", "framing": "legacy", "keys": ["purchased"] },
29
+ "OPEN_CHECKPOINT_PICKER": { "reply": "CHECKPOINT_PICKER_RESULT", "framing": "legacy", "keys": ["selected"] },
30
+ "OPEN_IMAGE_PICKER": { "reply": "IMAGE_PICKER_RESULT", "framing": "legacy", "keys": ["selected", "selectedImages"] },
31
+ "OPEN_IMAGE_UPLOAD": { "reply": "IMAGE_UPLOAD_RESULT", "framing": "legacy", "keys": ["selected"] },
32
+ "OPEN_RESOURCE_PICKER": { "reply": "RESOURCE_PICKER_RESULT", "framing": "legacy", "keys": ["selected", "selectedResources"] },
33
+ "POLL_WORKFLOW": { "reply": "WORKFLOW_STATUS", "framing": "legacy", "keys": ["snapshot"] },
34
+ "PREPARE_TRAINING_DATASET": { "reply": "TRAINING_DATASET_RESULT", "framing": "legacy", "keys": ["result"] },
35
+ "PUBLISH_GENERATION_OUTPUTS": { "reply": "PUBLISH_RESULT", "framing": "legacy", "keys": ["result"] },
36
+ "QUERY_APP_WORKFLOWS": { "reply": "APP_WORKFLOWS_RESULT", "framing": "legacy", "keys": ["result"] },
37
+ "REQUEST_TOKEN": { "reply": "TOKEN_REFRESH_RESPONSE", "framing": "legacy", "keys": ["token"] },
38
+ "RUN_CONFIRMED_STEP": { "reply": "CONFIRMED_STEP_RESULT", "framing": "legacy", "keys": ["snapshot"] },
39
+ "RUN_TRAINING": { "reply": "TRAINING_RESULT", "framing": "legacy", "keys": ["snapshot"] },
40
+ "SAVE_IMAGE": { "reply": "SAVE_IMAGE_RESULT", "framing": "legacy", "keys": ["ok"] },
41
+ "SET_COLLECTION_FOLLOW": { "reply": "COLLECTION_FOLLOW_RESULT", "framing": "legacy", "keys": ["result"] },
42
+ "SET_USER_CHECKPOINT": { "reply": "USER_CHECKPOINT_SET", "framing": "legacy", "keys": ["ok"] },
43
+ "SHARED_APPEND": { "reply": "SHARED_APPEND_RESULT", "framing": "legacy", "keys": ["key"] },
44
+ "SHARED_GET": { "reply": "SHARED_GET_RESULT", "framing": "legacy", "keys": ["item"] },
45
+ "SHARED_GET_COUNT": { "reply": "SHARED_GET_COUNT_RESULT", "framing": "legacy", "keys": ["count"] },
46
+ "SHARED_GET_COUNTS": { "reply": "SHARED_GET_COUNTS_RESULT", "framing": "legacy", "keys": ["counts"] },
47
+ "SHARED_LIST": { "reply": "SHARED_LIST_RESULT", "framing": "legacy", "keys": ["items", "nextCursor"] },
48
+ "SHARED_REPORT": { "reply": "SHARED_REPORT_RESULT", "framing": "legacy", "keys": ["ok"] },
49
+ "SHARED_UNVOTE": { "reply": "SHARED_UNVOTE_RESULT", "framing": "legacy", "keys": ["count"] },
50
+ "SHARED_UPDATE": { "reply": "SHARED_UPDATE_RESULT", "framing": "legacy", "keys": ["ok"] },
51
+ "SHARED_VOTE": { "reply": "SHARED_VOTE_RESULT", "framing": "legacy", "keys": ["count"] },
52
+ "SHARED_WITHDRAW": { "reply": "SHARED_WITHDRAW_RESULT", "framing": "legacy", "keys": ["deleted", "ok"] },
53
+ "SUBMIT_WORKFLOW": { "reply": "WORKFLOW_SUBMITTED", "framing": "legacy", "keys": ["snapshot"] },
54
+ };
55
+ /** The host's fire-and-forget messages: it handles them and never replies. */
56
+ export const HOST_NOTIFICATIONS = new Set([
57
+ "BLOCK_ERROR",
58
+ "BLOCK_HELLO",
59
+ "BLOCK_MESSAGE_REJECTED",
60
+ "BLOCK_READY",
61
+ "NAVIGATE",
62
+ "REQUEST_CONSENT",
63
+ "REQUEST_SIGN_IN",
64
+ "RESIZE_IFRAME",
65
+ "TRACK_EVENT",
66
+ ]);
@@ -1,4 +1,10 @@
1
1
  import { type BlockSnapshot, type BlockTransport, type RequestOptions } from '../transport.js';
2
+ import { BridgeError } from '../errors.js';
3
+ import { type HostReply } from './host-replies.generated.js';
4
+ /** Why `request(type)` cannot settle, and what to call instead. */
5
+ export declare function unanswerable(type: string): BridgeError;
6
+ /** Turns a reply into the value it carries, or the failure it reports. */
7
+ export declare function unwrap(type: string, reply: unknown, expected: HostReply): unknown;
2
8
  export interface IframeTransportOptions {
3
9
  /** At least one entry. Other origins — including the local one — drop silently. */
4
10
  allowedParentOrigins: string[];
@@ -2,48 +2,63 @@ import { isHostMessage, parseBlockInitFragment, stripBlockInitFragment, } from '
2
2
  import { EMPTY_SNAPSHOT, snapshotFromInit, tokenFromWrapped, } from '../transport.js';
3
3
  import { BridgeError, classifyHostError } from '../errors.js';
4
4
  import { OriginMatcher } from '../origin-matcher.js';
5
+ import { HOST_NOTIFICATIONS, HOST_REPLIES, HOST_SNAPSHOT } from './host-replies.generated.js';
5
6
  import { VERSION } from '../../version.generated.js';
6
7
  /**
7
8
  * The framing the host had before this package owned the protocol: fields
8
9
  * straight in the payload, and failure as the server's own sentence. Converting
9
10
  * it here is what keeps the rest of this file — and every domain — speaking one
10
- * shape; when the host modernises a message, its domain drops the declaration
11
- * and this stops being reached.
11
+ * shape; when the host modernises a message, the snapshot reports it as
12
+ * `envelope` and this stops being reached for it.
12
13
  */
13
- function fromLegacyReply(reply) {
14
+ function fromLegacyReply(reply, keys) {
14
15
  const { requestId, error, ...fields } = reply;
15
16
  // An empty string is how the host spells "no failure" on these replies.
16
17
  if (typeof error === 'string' && error !== '') {
17
18
  return { requestId, error: { code: classifyHostError(error), message: error } };
18
19
  }
19
20
  // Some of these replies nest their value under `result` and others put its
20
- // fields straight in the payload; the host was never consistent about it.
21
- return { requestId, result: 'result' in fields ? fields.result : fields };
21
+ // fields straight in the payload. The snapshot says which; where the host's
22
+ // reply could not be read (`keys` null), the payload itself decides.
23
+ const nested = keys === null ? 'result' in fields : keys.length === 1 && keys[0] === 'result';
24
+ return { requestId, result: nested ? fields.result : fields };
22
25
  }
23
26
  /**
24
- * The host's pre-protocol messages, each with the reply it answers with —
25
- * `<REQUEST>_RESULT` holds across most of them but not all. It is the migration
26
- * ledger for this protocol: an entry leaves the day the host modernises that
27
- * reply, and when it empties `fromLegacyReply` goes with it.
27
+ * Where a caller should go instead of a bridge message, from BREAKING.md. Only
28
+ * read when the host does not answer the type, so it is what a request the
29
+ * host dropped (or never had) says instead of hanging.
28
30
  */
29
- const LEGACY_REPLIES = {
30
- OPEN_BUZZ_PURCHASE: 'BUZZ_PURCHASE_RESULT',
31
- OPEN_IMAGE_UPLOAD: 'IMAGE_UPLOAD_RESULT',
32
- OPEN_RESOURCE_PICKER: 'RESOURCE_PICKER_RESULT',
33
- PUBLISH_GENERATION_OUTPUTS: 'PUBLISH_RESULT',
34
- REQUEST_TOKEN: 'TOKEN_REFRESH_RESPONSE',
35
- SAVE_IMAGE: 'SAVE_IMAGE_RESULT',
36
- };
37
- function replyTypeFor(type) {
38
- return LEGACY_REPLIES[type] ?? `${type}_RESULT`;
31
+ const REPLACED_BY = [
32
+ [/^GET_VIEWER$/, "app.site.get('blocks/me')"],
33
+ [/^(SUBMIT|ESTIMATE|POLL|CANCEL)_WORKFLOW$|^QUERY_APP_WORKFLOWS$|^CANCEL_APP_WORKFLOW$/, 'app.workflows'],
34
+ [/^GET_IMAGES_BY_IDS$/, 'GET /api/v1/blocks/images?ids=… (app.site)'],
35
+ [/^APP_STORAGE_/, 'POST /api/v1/blocks/app-storage/* (app.site)'],
36
+ [/^SHARED_/, 'app.sharedStorage'],
37
+ [/^GET_BUZZ_BALANCE$/, "app.site.get('blocks/buzz')"],
38
+ [/^SET_COLLECTION_FOLLOW$/, 'POST /api/v1/blocks/collections/{id}/follow (app.site)'],
39
+ [/^TRACK_EVENT$/, 'app.host.track(eventName, properties)'],
40
+ [/^PUBLISH_GENERATION_OUTPUTS$/, 'app.host.publishGenerationOutputs'],
41
+ ];
42
+ /** Why `request(type)` cannot settle, and what to call instead. */
43
+ export function unanswerable(type) {
44
+ const instead = REPLACED_BY.find(([pattern]) => pattern.test(type))?.[1];
45
+ const message = HOST_NOTIFICATIONS.has(type)
46
+ ? `${type} is fire-and-forget: the host never answers it, so request() would wait forever. ` +
47
+ `Send it with notify()${instead ? `, or use ${instead}` : ''}.`
48
+ : `${type} is not a request the host answered at civitai ${HOST_SNAPSHOT}, ` +
49
+ `the host snapshot @civitai/sdk ${VERSION} was built from, so request() would wait forever. ` +
50
+ (instead
51
+ ? `Use ${instead} instead.`
52
+ : 'If the host has added it since, upgrade @civitai/sdk; otherwise see BREAKING.md for where bridge messages moved.');
53
+ return new BridgeError('invalid', type, message);
39
54
  }
40
55
  /** Turns a reply into the value it carries, or the failure it reports. */
41
- function unwrap(type, reply, framing) {
56
+ export function unwrap(type, reply, expected) {
42
57
  if (reply == null || typeof reply !== 'object') {
43
58
  throw new BridgeError('malformed', type, `${type} replied with no payload`);
44
59
  }
45
- const envelope = framing === 'legacy'
46
- ? fromLegacyReply(reply)
60
+ const envelope = expected.framing === 'legacy'
61
+ ? fromLegacyReply(reply, expected.keys)
47
62
  : reply;
48
63
  if (envelope.error !== undefined) {
49
64
  throw new BridgeError(envelope.error.code, type, envelope.error.message || `${type} failed`);
@@ -187,11 +202,14 @@ export class IframeTransport {
187
202
  this.#dispatch(message.type, message.payload);
188
203
  }
189
204
  async request(type, params, opts = {}) {
190
- const framing = type in LEGACY_REPLIES ? 'legacy' : 'envelope';
191
- return unwrap(type, await this.#exchange(type, params, opts), framing);
205
+ // Own keys only: `HOST_REPLIES['toString']` must not read as an answer.
206
+ const expected = Object.hasOwn(HOST_REPLIES, type) ? HOST_REPLIES[type] : undefined;
207
+ // Nothing would ever answer it, so say so now rather than wait forever.
208
+ if (!expected)
209
+ throw unanswerable(type);
210
+ return unwrap(type, await this.#exchange(type, expected.reply, params, opts), expected);
192
211
  }
193
- #exchange(type, params, opts) {
194
- const responseType = replyTypeFor(type);
212
+ #exchange(type, responseType, params, opts) {
195
213
  const { signal } = opts;
196
214
  if (signal?.aborted)
197
215
  return Promise.reject(signal.reason);
@@ -60,10 +60,12 @@ export function createHost(transport) {
60
60
  transport,
61
61
  });
62
62
  // The host spells "no failure" as `error: ''`, which the transport reads
63
- // as a success — so a reply carrying neither the ids nor a message gets
64
- // this far, and returning it would resolve `undefined` from a promise
65
- // that says `number[]`. A caller reading that as "nothing published"
66
- // would be guessing: images may well have been.
63
+ // as a success. A reply with no `result` at all is refused by the
64
+ // transport (the ledger says this reply nests under `result`), but one
65
+ // whose `result` lacks the ids gets this far, and returning it would
66
+ // resolve `undefined` from a promise that says `number[]`. A caller
67
+ // reading that as "nothing published" would be guessing: images may well
68
+ // have been.
67
69
  if (!Array.isArray(imageIds) || !imageIds.every((id) => typeof id === 'number')) {
68
70
  throw new BridgeError('malformed', 'PUBLISH_GENERATION_OUTPUTS', 'PUBLISH_GENERATION_OUTPUTS reply carried no image ids. Whether anything ' +
69
71
  'was published is unknown; read the workflow back rather than assuming.');
@@ -1,5 +1,5 @@
1
1
  /** Every scope an app can hold, as the site names them. */
2
- export declare const SCOPES: readonly ['ai:write:budgeted', 'apps:storage:read', 'apps:storage:shared:read', 'apps:storage:shared:write', 'apps:storage:write', 'buzz:read:self', 'collections:read:private', 'collections:read:self', 'collections:write:self', 'models:read:self', 'posts:write:self', 'social:tip:self', 'user:read:self'];
2
+ export declare const SCOPES: readonly ['ai:write:budgeted', 'apps:storage', 'apps:storage:publish', 'apps:storage:read', 'apps:storage:shared:read', 'apps:storage:shared:write', 'apps:storage:write', 'apps:store:items:write', 'buzz:read:self', 'collections:read:private', 'collections:read:self', 'collections:write:self', 'goods:purchase:self', 'goods:read:self', 'models:read:self', 'posts:write:self', 'social:tip:self', 'user:read:self'];
3
3
  export type Scope = (typeof SCOPES)[number];
4
4
  export interface TokenOptions {
5
5
  /** Skip the held token and get a new one, e.g. after the API refused it. */
@@ -1,14 +1,19 @@
1
1
  /** Every scope an app can hold, as the site names them. */
2
2
  export const SCOPES = [
3
3
  'ai:write:budgeted',
4
+ 'apps:storage',
5
+ 'apps:storage:publish',
4
6
  'apps:storage:read',
5
7
  'apps:storage:shared:read',
6
8
  'apps:storage:shared:write',
7
9
  'apps:storage:write',
10
+ 'apps:store:items:write',
8
11
  'buzz:read:self',
9
12
  'collections:read:private',
10
13
  'collections:read:self',
11
14
  'collections:write:self',
15
+ 'goods:purchase:self',
16
+ 'goods:read:self',
12
17
  'models:read:self',
13
18
  'posts:write:self',
14
19
  'social:tip:self',
@@ -1,2 +1,2 @@
1
1
  /** This package's version, reported to the host as `sdkVersion` in `BLOCK_READY`. */
2
- export declare const VERSION = "0.12.0";
2
+ export declare const VERSION = "0.13.0";
@@ -1,3 +1,3 @@
1
1
  /* AUTOGENERATED from package.json by scripts/gen-version.mjs. DO NOT EDIT. */
2
2
  /** This package's version, reported to the host as `sdkVersion` in `BLOCK_READY`. */
3
- export const VERSION = "0.12.0";
3
+ export const VERSION = "0.13.0";
@@ -138,13 +138,15 @@ export interface WorkflowsClient {
138
138
  * {@link WorkflowsClient.list}. Never mint a new key for the same viewer
139
139
  * action: to the host a new key is a new generation. That matching is the
140
140
  * host's and the orchestrator's, is not guaranteed, and is not something
141
- * this client can verify; `list` is the check that does not depend on it.
141
+ * this client can verify. Nor does `list` settle it: a listed workflow
142
+ * carries no idempotency key, and a run missing from it is not proof that
143
+ * nothing ran.
142
144
  *
143
145
  * 🔴 For a `kind: 'training'` body and a step body carrying a `quoteId` the
144
146
  * key is NOT the run's identity: the host does not use it there, and
145
- * identifies the run by the confirmed quote or the training run. A submit
146
- * naming a new quote is a new run. After an unknown outcome on one of
147
- * these, check `list` before asking the viewer to confirm another quote.
147
+ * identifies the run by the confirmed quote or the training run. After an
148
+ * unknown outcome on one of these, check `list` before asking the viewer to
149
+ * confirm another quote.
148
150
  *
149
151
  * This client does not retry a failed submit itself; it only re-sends once
150
152
  * after refreshing an expired token, as every call does.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@civitai/sdk",
3
- "version": "0.12.0",
3
+ "version": "0.13.0",
4
4
  "description": "The Civitai SDK: an app holds a token and calls the Civitai API and the orchestrator; a block asks its host only to show host UI.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -69,6 +69,8 @@
69
69
  "gen:steps": "node scripts/gen-step-types.mjs",
70
70
  "check:steps": "node scripts/gen-step-types.mjs --check",
71
71
  "gen:workflow-types": "node scripts/gen-workflow-types.mjs",
72
+ "gen:host-replies": "node scripts/gen-host-replies.mjs",
73
+ "check:host-replies": "node scripts/gen-host-replies.mjs --check",
72
74
  "snapshot:host": "node scripts/snapshot-host-messages.mjs",
73
75
  "typecheck": "tsc -p tsconfig.json --noEmit && tsc -p tsconfig.test.json && tsc -p tsconfig.examples.json",
74
76
  "test": "vitest run",