@ryuhq/sdk 0.0.5

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.
Files changed (61) hide show
  1. package/LICENSE +179 -0
  2. package/README.md +31 -0
  3. package/dist/agent.cjs +761 -0
  4. package/dist/agent.d.cts +3 -0
  5. package/dist/agent.d.ts +3 -0
  6. package/dist/agent.js +23 -0
  7. package/dist/chunk-GXHL5CO7.js +353 -0
  8. package/dist/chunk-KPKMMGVC.js +671 -0
  9. package/dist/chunk-ODFEUVPW.js +100 -0
  10. package/dist/cli.cjs +858 -0
  11. package/dist/cli.d.cts +1 -0
  12. package/dist/cli.d.ts +1 -0
  13. package/dist/cli.js +454 -0
  14. package/dist/index-CEbS1SlS.d.cts +988 -0
  15. package/dist/index-DAxq7Y0R.d.ts +988 -0
  16. package/dist/index.cjs +1900 -0
  17. package/dist/index.d.cts +759 -0
  18. package/dist/index.d.ts +759 -0
  19. package/dist/index.js +771 -0
  20. package/dist/manifest.cjs +399 -0
  21. package/dist/manifest.d.cts +355 -0
  22. package/dist/manifest.d.ts +355 -0
  23. package/dist/manifest.js +38 -0
  24. package/package.json +56 -0
  25. package/src/agent/agent.ts +208 -0
  26. package/src/agent/index.ts +51 -0
  27. package/src/agent/loop.test.ts +261 -0
  28. package/src/agent/loop.ts +259 -0
  29. package/src/agent/model-call.ts +190 -0
  30. package/src/agent/query.ts +40 -0
  31. package/src/agent/tools.ts +295 -0
  32. package/src/builder.ts +473 -0
  33. package/src/cli/dev.test.ts +178 -0
  34. package/src/cli/dev.ts +425 -0
  35. package/src/cli.ts +390 -0
  36. package/src/contracts-lockstep.test.ts +77 -0
  37. package/src/generated/plugin-manifest.ts +1121 -0
  38. package/src/index.ts +141 -0
  39. package/src/manifest.test.ts +610 -0
  40. package/src/manifest.ts +589 -0
  41. package/src/mcp/bridge.test.ts +196 -0
  42. package/src/mcp/client.ts +253 -0
  43. package/src/mcp/fixture-server.ts +23 -0
  44. package/src/mcp/server.ts +351 -0
  45. package/src/model/client.test.ts +107 -0
  46. package/src/model/client.ts +179 -0
  47. package/src/model/gateway.ts +41 -0
  48. package/src/plugin/ryu-plugin.ts +191 -0
  49. package/src/runnable/agent.ts +338 -0
  50. package/src/runnable/app.ts +233 -0
  51. package/src/runnable/index.ts +61 -0
  52. package/src/runnable/primitives-hostapi.test.ts +73 -0
  53. package/src/runnable/primitives.test.ts +286 -0
  54. package/src/runnable/primitives.ts +610 -0
  55. package/src/runnable/runnable-types.ts +113 -0
  56. package/src/runnable/runnable.test.ts +397 -0
  57. package/src/runnable/skill.ts +60 -0
  58. package/src/runnable/tool.ts +260 -0
  59. package/src/runnable/turn-hook.test.ts +81 -0
  60. package/src/runnable/turn-hook.ts +191 -0
  61. package/src/runnable/workflow.ts +76 -0
package/src/cli.ts ADDED
@@ -0,0 +1,390 @@
1
+ #!/usr/bin/env bun
2
+ /**
3
+ * `ryu` CLI — entry-point for the Ryu developer SDK command-line tool.
4
+ *
5
+ * Usage:
6
+ * bunx ryu pack <dir>
7
+ * bunx ryu publish <dir>
8
+ *
9
+ * Commands:
10
+ * pack <dir> Validate the plugin.json in <dir> and emit a publish-ready
11
+ * Plugin bundle at <dir>/dist/plugin.bundle.json.
12
+ * Exits 0 on success; exits 1 with the failing field on error.
13
+ * publish <dir> Validate the plugin.json and POST it to the Ryu Marketplace
14
+ * publish endpoint with the author's auth token. The item is
15
+ * stored as `pending` until a moderator approves it.
16
+ */
17
+
18
+ import { createHash } from "node:crypto";
19
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
20
+ import { join, resolve } from "node:path";
21
+ import { commandDev } from "./cli/dev.ts";
22
+ import { PluginManifestSchema } from "./manifest.ts";
23
+
24
+ // Lower-case hex `sha256(utf8_bytes(code))`. This is the EXACT encoding Core
25
+ // recomputes on install (`hex::encode(Sha256::digest(utf8))`), so the hash written
26
+ // into the signed manifest verifies byte-for-byte on the Rust side. The `code`
27
+ // passed here MUST be the same UTF-8 string stored/served/fetched, so the two ends
28
+ // hash identical bytes (never re-minify between pack/publish and install).
29
+ function uiCodeSha256(code: string): string {
30
+ return createHash("sha256").update(code, "utf8").digest("hex");
31
+ }
32
+
33
+ // ── helpers ───────────────────────────────────────────────────────────────────
34
+
35
+ function printUsage(): void {
36
+ process.stderr.write(
37
+ [
38
+ "Ryu dev SDK",
39
+ "",
40
+ "Usage:",
41
+ " bunx ryu pack <dir> Validate and bundle a plugin.json Plugin",
42
+ " bunx ryu publish <dir> Validate and publish a plugin.json Plugin to the Ryu Marketplace",
43
+ " bunx ryu dev <entry> Run a Runnable locally with an interactive chat loop",
44
+ "",
45
+ ].join("\n")
46
+ );
47
+ }
48
+
49
+ function exitError(message: string): never {
50
+ process.stderr.write(`error: ${message}\n`);
51
+ process.exit(1);
52
+ }
53
+
54
+ // ── shared manifest loading ─────────────────────────────────────────────────
55
+
56
+ type LoadedManifest = ReturnType<typeof PluginManifestSchema.parse>;
57
+
58
+ // Read + parse + validate the plugin.json in `dir`. Exits with the failing
59
+ // field on any error. Shared by pack and publish so both validate identically.
60
+ function loadManifest(dir: string): LoadedManifest {
61
+ const manifestPath = join(dir, "plugin.json");
62
+ if (!existsSync(manifestPath)) {
63
+ exitError(`plugin.json not found in: ${dir}`);
64
+ }
65
+
66
+ let raw: string;
67
+ try {
68
+ raw = readFileSync(manifestPath, "utf8");
69
+ } catch (err) {
70
+ exitError(`could not read ${manifestPath}: ${String(err)}`);
71
+ }
72
+
73
+ let parsed: unknown;
74
+ try {
75
+ parsed = JSON.parse(raw);
76
+ } catch {
77
+ exitError(`plugin.json is not valid JSON: ${manifestPath}`);
78
+ }
79
+
80
+ const result = PluginManifestSchema.safeParse(parsed);
81
+ if (!result.success) {
82
+ const first = result.error.issues[0];
83
+ const field = first?.path.join(".") ?? "unknown";
84
+ const message = first?.message ?? "validation failed";
85
+ exitError(`plugin.json validation failed at '${field}': ${message}`);
86
+ }
87
+ return result.data;
88
+ }
89
+
90
+ // ── pack command ──────────────────────────────────────────────────────────────
91
+
92
+ // Resolve the plugin's sandboxed-UI entry module — the source `ryu pack` bundles
93
+ // into `ui_code`. Two authoring shapes carry one:
94
+ // 1. A `companion` runnable's `config.ui_entry` (companion surface plugins).
95
+ // 2. A Ryu App's `contributes.widgets[].ui_entry` (widget apps via `defineApp`).
96
+ // Companion runnables take precedence; the first non-empty entry wins. Returns
97
+ // null for a manifest-only plugin (no bundled UI) so packing stays
98
+ // backward-compatible in that case.
99
+ function resolveUiEntry(manifest: LoadedManifest): string | null {
100
+ for (const runnable of manifest.runnables) {
101
+ if (runnable.kind !== "companion") {
102
+ continue;
103
+ }
104
+ const entry = (runnable.config as Record<string, unknown> | undefined)
105
+ ?.ui_entry;
106
+ if (typeof entry === "string" && entry.trim().length > 0) {
107
+ return entry;
108
+ }
109
+ }
110
+ for (const widget of manifest.contributes?.widgets ?? []) {
111
+ const entry = widget.ui_entry;
112
+ if (typeof entry === "string" && entry.trim().length > 0) {
113
+ return entry;
114
+ }
115
+ }
116
+ return null;
117
+ }
118
+
119
+ // Resolve a companion's `ui_format` discriminator. `"html"` (Path B) means the
120
+ // `ui_entry` file is ALREADY a self-contained HTML document (a
121
+ // vite-plugin-singlefile build for a heavy app like the whiteboard) and must be
122
+ // shipped VERBATIM as `ui_code` — NOT run through `Bun.build`, which would try to
123
+ // bundle an HTML file as an ESM entry and fail. Anything else (absent / `"js"`) is
124
+ // the default: `ui_entry` is an ESM module `Bun.build` bundles into `ui_code`.
125
+ function resolveUiFormat(manifest: LoadedManifest): "html" | "js" {
126
+ for (const runnable of manifest.runnables) {
127
+ if (runnable.kind !== "companion") {
128
+ continue;
129
+ }
130
+ const fmt = (runnable.config as Record<string, unknown> | undefined)
131
+ ?.ui_format;
132
+ if (typeof fmt === "string" && fmt.trim().toLowerCase() === "html") {
133
+ return "html";
134
+ }
135
+ }
136
+ return "js";
137
+ }
138
+
139
+ // Read a Path B (`ui_format:"html"`) companion's prebuilt HTML entry verbatim. The
140
+ // file is the finished, self-contained document (CSS/JS/fonts already inlined by
141
+ // the singlefile bundler); `ryu pack` ships it as `ui_code` untouched so its
142
+ // sha256 matches byte-for-byte on install.
143
+ function readUiEntryHtml(dir: string, uiEntry: string): string {
144
+ const entryPath = resolve(dir, uiEntry);
145
+ if (!existsSync(entryPath)) {
146
+ exitError(`companion ui_entry (html) not found: ${entryPath}`);
147
+ }
148
+ return readFileSync(entryPath, "utf8");
149
+ }
150
+
151
+ // Bundle the plugin's UI entry into ONE self-contained browser ESM module string.
152
+ // No external imports are emitted: the `RyuPlugin` API is INJECTED at runtime by
153
+ // the host bootstrap (the plugin calls `activate(context)`), not imported, so the
154
+ // bundle carries only the plugin's own code. Throws on a build error so `pack`
155
+ // fails loudly rather than emitting a half-built bundle.
156
+ async function bundleUiEntry(dir: string, uiEntry: string): Promise<string> {
157
+ const entryPath = resolve(dir, uiEntry);
158
+ if (!existsSync(entryPath)) {
159
+ exitError(`companion ui_entry not found: ${entryPath}`);
160
+ }
161
+ const result = await Bun.build({
162
+ entrypoints: [entryPath],
163
+ target: "browser",
164
+ format: "esm",
165
+ minify: false,
166
+ });
167
+ if (!result.success) {
168
+ const messages = result.logs.map((l) => String(l.message)).join("; ");
169
+ exitError(`failed to bundle ui_entry '${uiEntry}': ${messages}`);
170
+ }
171
+ const output = result.outputs[0];
172
+ if (!output) {
173
+ exitError(`bundling ui_entry '${uiEntry}' produced no output`);
174
+ }
175
+ return await output.text();
176
+ }
177
+
178
+ async function commandPack(rawDir: string): Promise<void> {
179
+ const dir = resolve(rawDir);
180
+ const manifest = loadManifest(dir);
181
+
182
+ // Bundle the companion UI entry, if any. Manifest-only plugins skip this and
183
+ // emit exactly the previous shape (no `ui_code`). A `ui_format:"html"` companion
184
+ // (Path B) ships its prebuilt HTML verbatim; otherwise the ESM entry is bundled.
185
+ const uiEntry = resolveUiEntry(manifest);
186
+ const uiCode = uiEntry
187
+ ? resolveUiFormat(manifest) === "html"
188
+ ? readUiEntryHtml(dir, uiEntry)
189
+ : await bundleUiEntry(dir, uiEntry)
190
+ : null;
191
+
192
+ // Bind the bundled code to the manifest by its sha256. The hash goes INTO the
193
+ // manifest (the surface Core signs on publish, and the corruption self-check on
194
+ // local install-bundle reads); the `ui_code` blob rides alongside as payload.
195
+ const manifestWithHash = uiCode
196
+ ? { ...manifest, ui_code_sha256: uiCodeSha256(uiCode) }
197
+ : manifest;
198
+
199
+ // Emit bundle into <dir>/dist/plugin.bundle.json
200
+ const outDir = join(dir, "dist");
201
+ if (!existsSync(outDir)) {
202
+ mkdirSync(outDir, { recursive: true });
203
+ }
204
+ const outPath = join(outDir, "plugin.bundle.json");
205
+ const bundle = uiCode
206
+ ? { ...manifestWithHash, ui_code: uiCode }
207
+ : manifestWithHash;
208
+ writeFileSync(outPath, JSON.stringify(bundle, null, 2), "utf8");
209
+
210
+ const codeNote = uiCode ? ` (+${uiCode.length}B ui_code)` : "";
211
+ process.stdout.write(
212
+ `packed ${manifest.id}@${manifest.version}${codeNote} → ${outPath}\n`
213
+ );
214
+ }
215
+
216
+ // ── publish command ─────────────────────────────────────────────────────────
217
+
218
+ const TRAILING_SLASHES = /\/+$/;
219
+
220
+ // Resolve the publish base URL: env override, else the dev control-plane server.
221
+ function publishBaseUrl(): string {
222
+ const raw = (process.env.RYU_MARKETPLACE_API_URL ?? "").trim();
223
+ return (raw || "http://localhost:3000").replace(TRAILING_SLASHES, "");
224
+ }
225
+
226
+ // Resolve the author's auth token: env (RYU_AUTH_TOKEN), sent as a Bearer token
227
+ // the control plane's createContext accepts (Better Auth session JWT or OAuth
228
+ // access token). Never read from a committed file.
229
+ function authToken(): string {
230
+ const token = (process.env.RYU_AUTH_TOKEN ?? "").trim();
231
+ if (!token) {
232
+ exitError(
233
+ "publish requires an auth token: set RYU_AUTH_TOKEN to your Ryu access token"
234
+ );
235
+ }
236
+ return token;
237
+ }
238
+
239
+ // An SDK-authored Plugin always publishes as a `plugin` (a plugin.json bundle of
240
+ // runnables). It is deliberately NOT published as `skill`: Core's skill install
241
+ // path needs a `descriptor.raw.install_source` (a from-source owner/repo), which
242
+ // a plugin.json manifest does not carry, so a skill-kind publish would be
243
+ // uninstallable. Model / mcp items are published through their own tools.
244
+ const SDK_PUBLISH_KIND = "plugin" as const;
245
+
246
+ async function commandPublish(rawDir: string): Promise<void> {
247
+ const dir = resolve(rawDir);
248
+ const manifest = loadManifest(dir);
249
+ const token = authToken();
250
+ const kind = SDK_PUBLISH_KIND;
251
+
252
+ // Compute the carriage payload the SAME way `pack` does — bundle the companion
253
+ // UI entry and hash it INLINE (never depend on a possibly-stale dist/). The
254
+ // hash is injected into the manifest object BEFORE it is sent for signing, so
255
+ // the Gateway signs a manifest that already binds the code; the `ui_code` blob
256
+ // is sent as a sibling (unsigned payload, integrity via the signed hash).
257
+ const uiEntry = resolveUiEntry(manifest);
258
+ const uiCode = uiEntry
259
+ ? resolveUiFormat(manifest) === "html"
260
+ ? readUiEntryHtml(dir, uiEntry)
261
+ : await bundleUiEntry(dir, uiEntry)
262
+ : null;
263
+ const manifestWithHash = uiCode
264
+ ? { ...manifest, ui_code_sha256: uiCodeSha256(uiCode) }
265
+ : manifest;
266
+
267
+ // Phase 1.5 rich listing metadata forwarded FLAT into the publish body (not
268
+ // inside the signed manifest blob) so the control plane stores + serves it on
269
+ // detail. Each field is only sent when the author declared it. `developer`
270
+ // resolves the Claude-style `author` (string or `{name}`); `website` maps from
271
+ // the Claude `homepage`; the DISPLAY `runnables` array is derived from the
272
+ // manifest's authored runnables (id/name/kind) with a default enabled state.
273
+ const developer =
274
+ typeof manifest.author === "string"
275
+ ? manifest.author
276
+ : manifest.author?.name;
277
+ const runnablesForDisplay = manifest.runnables.map((r) => ({
278
+ id: r.id,
279
+ kind: r.kind,
280
+ name: r.name,
281
+ enabled: true,
282
+ }));
283
+ const listingMetadata = {
284
+ ...(manifest.description ? { description: manifest.description } : {}),
285
+ ...(manifest.tagline ? { tagline: manifest.tagline } : {}),
286
+ ...(developer ? { developer } : {}),
287
+ ...(manifest.category ? { category: manifest.category } : {}),
288
+ ...(manifest.iconUrl ? { iconUrl: manifest.iconUrl } : {}),
289
+ ...(manifest.screenshots?.length
290
+ ? { screenshots: manifest.screenshots }
291
+ : {}),
292
+ ...(manifest.homepage ? { website: manifest.homepage } : {}),
293
+ ...(manifest.privacyPolicyUrl
294
+ ? { privacyPolicyUrl: manifest.privacyPolicyUrl }
295
+ : {}),
296
+ ...(manifest.termsOfServiceUrl
297
+ ? { termsOfServiceUrl: manifest.termsOfServiceUrl }
298
+ : {}),
299
+ ...(manifest.capabilities?.length
300
+ ? { capabilities: manifest.capabilities }
301
+ : {}),
302
+ ...(manifest.examplePrompts?.length
303
+ ? { examplePrompts: manifest.examplePrompts }
304
+ : {}),
305
+ ...(manifest.setup ? { setup: manifest.setup } : {}),
306
+ ...(runnablesForDisplay.length ? { runnables: runnablesForDisplay } : {}),
307
+ };
308
+
309
+ const url = `${publishBaseUrl()}/api/marketplace/publish`;
310
+ const body = {
311
+ id: manifest.id,
312
+ kind,
313
+ name: manifest.name,
314
+ version: manifest.version,
315
+ manifest: manifestWithHash,
316
+ // The descriptor is the manifest itself for a plugin/skill Plugin; Core maps
317
+ // it on install. Grants are read from the manifest server-side too.
318
+ descriptor: manifestWithHash,
319
+ grants: manifest.permission_grants ?? [],
320
+ // Rich listing metadata (Phase 1.5) forwarded flat; see above.
321
+ ...listingMetadata,
322
+ // Per-item affiliate terms (optional): the commission a referrer earns when
323
+ // a referred user buys this paid item. The server re-validates the rule and
324
+ // stores it as the item's override (else the seller default applies).
325
+ ...(manifest.affiliate?.enabled ? { affiliate: manifest.affiliate } : {}),
326
+ // The bundled UI code rides OUTSIDE the signed manifest as payload; the
327
+ // server stores it and serves it on detail. Omitted for manifest-only.
328
+ ...(uiCode ? { ui_code: uiCode } : {}),
329
+ };
330
+
331
+ let resp: Response;
332
+ try {
333
+ resp = await fetch(url, {
334
+ method: "POST",
335
+ headers: {
336
+ "content-type": "application/json",
337
+ authorization: `Bearer ${token}`,
338
+ },
339
+ body: JSON.stringify(body),
340
+ });
341
+ } catch (err) {
342
+ exitError(`could not reach ${url}: ${String(err)}`);
343
+ }
344
+
345
+ const text = await resp.text();
346
+ if (!resp.ok) {
347
+ exitError(`publish failed (${resp.status}): ${text}`);
348
+ }
349
+ process.stdout.write(
350
+ `published ${manifest.id}@${manifest.version} (${kind}) → pending moderation\n${text}\n`
351
+ );
352
+ }
353
+
354
+ // ── main ──────────────────────────────────────────────────────────────────────
355
+
356
+ const [, , command, ...args] = process.argv;
357
+
358
+ if (!command) {
359
+ printUsage();
360
+ process.exit(1);
361
+ }
362
+
363
+ if (command === "pack") {
364
+ const dir = args[0];
365
+ if (!dir) {
366
+ exitError("pack requires a directory argument: bunx ryu pack <dir>");
367
+ }
368
+ commandPack(dir).catch((err: unknown) => {
369
+ exitError(String(err));
370
+ });
371
+ } else if (command === "publish") {
372
+ const dir = args[0];
373
+ if (!dir) {
374
+ exitError("publish requires a directory argument: bunx ryu publish <dir>");
375
+ }
376
+ commandPublish(dir).catch((err: unknown) => {
377
+ exitError(String(err));
378
+ });
379
+ } else if (command === "dev") {
380
+ const entry = args[0];
381
+ if (!entry) {
382
+ exitError("dev requires an entry argument: bunx ryu dev <entry>");
383
+ }
384
+ commandDev(entry).catch((err: unknown) => {
385
+ exitError(String(err));
386
+ });
387
+ } else {
388
+ printUsage();
389
+ exitError(`unknown command: ${command}`);
390
+ }
@@ -0,0 +1,77 @@
1
+ /**
2
+ * Contracts lockstep guard.
3
+ *
4
+ * The Rust crate `crates/ryu-kernel-contracts` blesses
5
+ * `schemas/plugin-manifest.schema.json` (its snapshot test regenerates it from
6
+ * the Rust types), and `bun run generate:contracts` compiles that schema into
7
+ * `src/generated/plugin-manifest.ts`. This test is the cheap structural guard
8
+ * that the SDK's deliberately simpler zod authoring schema, the blessed JSON
9
+ * Schema, and the generated types stay describing the same manifest:
10
+ *
11
+ * 1. a known-good repo manifest (apps-store/mail/ui/plugin.json — the first
12
+ * fully manifest-driven app) parses with the zod `PluginManifestSchema`;
13
+ * 2. every key the schema marks required exists in the schema, the generated
14
+ * TS, and the fixture;
15
+ * 3. every top-level key the fixture uses is a key the wire model knows.
16
+ *
17
+ * Deterministic, filesystem-only, no network.
18
+ */
19
+
20
+ import { describe, expect, test } from "bun:test";
21
+ import { readFileSync } from "node:fs";
22
+ import { join } from "node:path";
23
+ import { PluginManifestSchema } from "./manifest";
24
+
25
+ const FIXTURE_PATH = join(
26
+ import.meta.dir,
27
+ "../../../apps-store/mail/ui/plugin.json"
28
+ );
29
+ const SCHEMA_PATH = join(
30
+ import.meta.dir,
31
+ "../../../crates/core/kernel-contracts/schemas/plugin-manifest.schema.json"
32
+ );
33
+ const GENERATED_PATH = join(import.meta.dir, "generated/plugin-manifest.ts");
34
+
35
+ const fixture = JSON.parse(readFileSync(FIXTURE_PATH, "utf8")) as Record<
36
+ string,
37
+ unknown
38
+ >;
39
+ const schema = JSON.parse(readFileSync(SCHEMA_PATH, "utf8")) as {
40
+ title: string;
41
+ required: string[];
42
+ properties: Record<string, unknown>;
43
+ };
44
+ const generatedSource = readFileSync(GENERATED_PATH, "utf8");
45
+
46
+ describe("contracts lockstep (zod ↔ blessed JSON Schema ↔ generated TS)", () => {
47
+ test("the known-good mail plugin.json parses with the zod authoring schema", () => {
48
+ const parsed = PluginManifestSchema.parse(fixture);
49
+ expect(parsed.id).toBe("com.ryu.mail");
50
+ expect(parsed.runnables.length).toBeGreaterThan(0);
51
+ });
52
+
53
+ test("blessed schema describes PluginManifest with the required identity keys", () => {
54
+ expect(schema.title).toBe("PluginManifest");
55
+ for (const key of ["id", "name", "version", "runnables"]) {
56
+ expect(schema.required).toContain(key);
57
+ expect(Object.keys(schema.properties)).toContain(key);
58
+ // The fixture (and thus anything zod accepts as known-good) carries them.
59
+ expect(fixture).toHaveProperty(key);
60
+ }
61
+ });
62
+
63
+ test("generated TS declares PluginManifest with the required keys", () => {
64
+ expect(generatedSource).toContain("export interface PluginManifest {");
65
+ for (const key of ["id", "name", "version", "runnables"]) {
66
+ // Required schema keys must appear as non-optional generated fields.
67
+ expect(generatedSource).toMatch(new RegExp(`^\\t${key}[?]?:`, "m"));
68
+ }
69
+ });
70
+
71
+ test("every top-level fixture key is a key the wire model knows", () => {
72
+ const wireKeys = new Set(Object.keys(schema.properties));
73
+ for (const key of Object.keys(fixture)) {
74
+ expect(wireKeys).toContain(key);
75
+ }
76
+ });
77
+ });