diff --git a/packages/sdk/generated/domain-map.json b/packages/sdk/generated/domain-map.json index 1e7b00a..15fb22f 100644 --- a/packages/sdk/generated/domain-map.json +++ b/packages/sdk/generated/domain-map.json @@ -14,9 +14,7 @@ }, "Project": { "description": "A Stitch project containing screens.", - "constructorParams": [ - "projectId" - ], + "constructorParams": ["projectId"], "fieldMapping": { "projectId": { "from": "name", @@ -26,10 +24,7 @@ }, "Screen": { "description": "A generated UI screen. Provides access to HTML and screenshots.", - "constructorParams": [ - "projectId", - "screenId" - ], + "constructorParams": ["projectId", "screenId"], "fieldMapping": { "projectId": { "from": "projectId" @@ -103,7 +98,7 @@ "projection": [ { "prop": "outputComponents", - "index": 0 + "find": "design" }, { "prop": "design" @@ -144,7 +139,7 @@ "projection": [ { "prop": "outputComponents", - "index": 0 + "find": "design" }, { "prop": "design" @@ -320,4 +315,4 @@ } } ] -} \ No newline at end of file +} diff --git a/packages/sdk/generated/src/index.ts b/packages/sdk/generated/src/index.ts index ee67a99..f7a535e 100644 --- a/packages/sdk/generated/src/index.ts +++ b/packages/sdk/generated/src/index.ts @@ -2,9 +2,9 @@ * AUTO-GENERATED by scripts/generate-sdk.ts DO NOT EDIT — changes will be overwritten. -Source: tools-manifest.json (sha256:1f84b31604f9...) - domain-map.json (sha256:99b823ad9306...) -Generated: 2026-03-19T18:56:19.253Z +Source: tools-manifest.json (sha256:30054fa9d9b0...) + domain-map.json (sha256:570af21602ed...) +Generated: 2026-03-22T10:14:00.475Z */ export { Stitch } from "./stitch.js"; export { Project } from "./project.js"; diff --git a/packages/sdk/generated/src/project.ts b/packages/sdk/generated/src/project.ts index 12ef723..98704f4 100644 --- a/packages/sdk/generated/src/project.ts +++ b/packages/sdk/generated/src/project.ts @@ -2,9 +2,9 @@ * AUTO-GENERATED by scripts/generate-sdk.ts DO NOT EDIT — changes will be overwritten. -Source: tools-manifest.json (sha256:1f84b31604f9...) - domain-map.json (sha256:99b823ad9306...) -Generated: 2026-03-19T18:56:19.253Z +Source: tools-manifest.json (sha256:30054fa9d9b0...) + domain-map.json (sha256:570af21602ed...) +Generated: 2026-03-22T10:14:00.475Z */ import { type StitchToolClient } from "../../src/client.js"; import { StitchError } from "../../src/spec/errors.js"; @@ -37,7 +37,7 @@ export class Project { async generate(prompt: string, deviceType?: "DEVICE_TYPE_UNSPECIFIED" | "MOBILE" | "DESKTOP" | "TABLET" | "AGNOSTIC", modelId?: "MODEL_ID_UNSPECIFIED" | "GEMINI_3_PRO" | "GEMINI_3_FLASH"): Promise { try { const raw = await this.client.callTool("generate_screen_from_text", { projectId: this.projectId, prompt, deviceType, modelId }); - return new Screen(this.client, { ...raw.outputComponents[0].design.screens[0], projectId: this.projectId }); + return new Screen(this.client, { ...raw.outputComponents.find((c: any) => c.design).design.screens[0], projectId: this.projectId }); } catch (error) { throw StitchError.fromUnknown(error); } diff --git a/packages/sdk/generated/src/screen.ts b/packages/sdk/generated/src/screen.ts index 5aca015..c7064c5 100644 --- a/packages/sdk/generated/src/screen.ts +++ b/packages/sdk/generated/src/screen.ts @@ -2,9 +2,9 @@ * AUTO-GENERATED by scripts/generate-sdk.ts DO NOT EDIT — changes will be overwritten. -Source: tools-manifest.json (sha256:1f84b31604f9...) - domain-map.json (sha256:99b823ad9306...) -Generated: 2026-03-19T18:56:19.253Z +Source: tools-manifest.json (sha256:30054fa9d9b0...) + domain-map.json (sha256:570af21602ed...) +Generated: 2026-03-22T10:14:00.475Z */ import { type StitchToolClient } from "../../src/client.js"; import { StitchError } from "../../src/spec/errors.js"; @@ -38,7 +38,7 @@ export class Screen { async edit(prompt: string, deviceType?: "DEVICE_TYPE_UNSPECIFIED" | "MOBILE" | "DESKTOP" | "TABLET" | "AGNOSTIC", modelId?: "MODEL_ID_UNSPECIFIED" | "GEMINI_3_PRO" | "GEMINI_3_FLASH"): Promise { try { const raw = await this.client.callTool("edit_screens", { projectId: this.projectId, selectedScreenIds: [this.screenId], prompt, deviceType, modelId }); - return new Screen(this.client, { ...raw.outputComponents[0].design.screens[0], projectId: this.projectId }); + return new Screen(this.client, { ...raw.outputComponents.find((c: any) => c.design).design.screens[0], projectId: this.projectId }); } catch (error) { throw StitchError.fromUnknown(error); } diff --git a/packages/sdk/generated/src/stitch.ts b/packages/sdk/generated/src/stitch.ts index 6aaf22d..51ebd2d 100644 --- a/packages/sdk/generated/src/stitch.ts +++ b/packages/sdk/generated/src/stitch.ts @@ -2,9 +2,9 @@ * AUTO-GENERATED by scripts/generate-sdk.ts DO NOT EDIT — changes will be overwritten. -Source: tools-manifest.json (sha256:1f84b31604f9...) - domain-map.json (sha256:99b823ad9306...) -Generated: 2026-03-19T18:56:19.253Z +Source: tools-manifest.json (sha256:30054fa9d9b0...) + domain-map.json (sha256:570af21602ed...) +Generated: 2026-03-22T10:14:00.475Z */ import { type StitchToolClient } from "../../src/client.js"; import { StitchError } from "../../src/spec/errors.js"; diff --git a/packages/sdk/generated/src/tool-definitions.ts b/packages/sdk/generated/src/tool-definitions.ts index 15d17f3..4bf4be6 100644 --- a/packages/sdk/generated/src/tool-definitions.ts +++ b/packages/sdk/generated/src/tool-definitions.ts @@ -2,9 +2,9 @@ * AUTO-GENERATED by scripts/generate-sdk.ts DO NOT EDIT — changes will be overwritten. -Source: tools-manifest.json (sha256:1f84b31604f9...) - domain-map.json (sha256:99b823ad9306...) -Generated: 2026-03-19T18:56:19.253Z +Source: tools-manifest.json (sha256:30054fa9d9b0...) + domain-map.json (sha256:570af21602ed...) +Generated: 2026-03-22T10:14:00.475Z */ /** JSON Schema property descriptor for a tool parameter. */ export interface ToolPropertySchema { diff --git a/packages/sdk/generated/stitch-sdk.lock b/packages/sdk/generated/stitch-sdk.lock index fcacc2a..a721923 100644 --- a/packages/sdk/generated/stitch-sdk.lock +++ b/packages/sdk/generated/stitch-sdk.lock @@ -7,15 +7,15 @@ "serverUrl": "https://stitch.googleapis.com/mcp" }, "generated": { - "generatedAt": "2026-03-19T18:56:19.351Z", - "sourceHash": "sha256:06c97f633a04942efd348aa1634356c9f5f0fd3e16d33802bffe7e8e9a650905", + "generatedAt": "2026-03-22T10:12:33.217Z", + "sourceHash": "sha256:8cc5d1bc07cb437c5cb8f4d9bdd03c3f23d07fc978ac59a709b6c80b5a31f561", "manifestHash": "sha256:1f84b31604f95580325952f0c150a3e045543fad2596e9a4d8ed15c07e0cbf9b", - "domainMapHash": "sha256:99b823ad930620c571a9443c7b956f90b092d626be9ce2319d7a7bfe3a2e3db4", + "domainMapHash": "sha256:b207e213e3e06c9154884320c78ffc515bb830b3d94403f0c0a54352e3d87145", "fileCount": 5 }, "domainMap": { - "generatedAt": "2026-03-19T18:56:19.351Z", - "sourceHash": "sha256:99b823ad930620c571a9443c7b956f90b092d626be9ce2319d7a7bfe3a2e3db4", + "generatedAt": "2026-03-22T10:12:33.217Z", + "sourceHash": "sha256:b207e213e3e06c9154884320c78ffc515bb830b3d94403f0c0a54352e3d87145", "manifestHash": "sha256:1f84b31604f95580325952f0c150a3e045543fad2596e9a4d8ed15c07e0cbf9b", "classCount": 3, "bindingCount": 9 diff --git a/packages/sdk/test/unit/sdk.test.ts b/packages/sdk/test/unit/sdk.test.ts index 4c50fab..37ac3d3 100644 --- a/packages/sdk/test/unit/sdk.test.ts +++ b/packages/sdk/test/unit/sdk.test.ts @@ -32,7 +32,13 @@ describe("SDK Unit Tests", () => { }); describe("Screen Class", () => { - const screenData = { id: "screen-123", name: "Login", htmlCode: { downloadUrl: "https://cached.example.com/html" }, screenshot: { downloadUrl: "https://cached.example.com/img.png" }, projectId: "proj-123" }; + const screenData = { + id: "screen-123", + name: "Login", + htmlCode: { downloadUrl: "https://cached.example.com/html" }, + screenshot: { downloadUrl: "https://cached.example.com/img.png" }, + projectId: "proj-123", + }; const projectId = "proj-123"; it("getHtml should return cached HTML from data if available", async () => { @@ -45,10 +51,14 @@ describe("SDK Unit Tests", () => { }); it("getHtml should call get_screen if no cached htmlCode", async () => { - const screen = new Screen(mockClient, { id: "screen-123", name: "Login", projectId }); + const screen = new Screen(mockClient, { + id: "screen-123", + name: "Login", + projectId, + }); (mockClient.callTool as Mock).mockResolvedValue({ - htmlCode: { downloadUrl: "https://api.example.com/html" } + htmlCode: { downloadUrl: "https://api.example.com/html" }, }); const result = await screen.getHtml(); @@ -71,10 +81,14 @@ describe("SDK Unit Tests", () => { }); it("getImage should call get_screen if no cached screenshot", async () => { - const screen = new Screen(mockClient, { id: "screen-123", name: "Login", projectId }); + const screen = new Screen(mockClient, { + id: "screen-123", + name: "Login", + projectId, + }); (mockClient.callTool as Mock).mockResolvedValue({ - screenshot: { downloadUrl: "https://api.example.com/image.png" } + screenshot: { downloadUrl: "https://api.example.com/image.png" }, }); const result = await screen.getImage(); @@ -87,13 +101,16 @@ describe("SDK Unit Tests", () => { expect(result).toBe("https://api.example.com/image.png"); }); - it("getHtml should fallback to empty string when raw.htmlCode.downloadUrl is missing", async () => { - const screen = new Screen(mockClient, { id: "screen-123", name: "Login", projectId }); + const screen = new Screen(mockClient, { + id: "screen-123", + name: "Login", + projectId, + }); // Mock missing htmlCode / downloadUrl (mockClient.callTool as Mock).mockResolvedValue({ - htmlCode: {} + htmlCode: {}, }); const result = await screen.getHtml(); @@ -107,8 +124,14 @@ describe("SDK Unit Tests", () => { }); it("getHtml should throw StitchError on failure", async () => { - const screen = new Screen(mockClient, { id: "screen-123", name: "Login", projectId }); - (mockClient.callTool as Mock).mockRejectedValue(new Error("Network failure")); + const screen = new Screen(mockClient, { + id: "screen-123", + name: "Login", + projectId, + }); + (mockClient.callTool as Mock).mockRejectedValue( + new Error("Network failure"), + ); await expect(screen.getHtml()).rejects.toThrow("Network failure"); }); @@ -117,9 +140,20 @@ describe("SDK Unit Tests", () => { const screen = new Screen(mockClient, screenData); (mockClient.callTool as Mock).mockResolvedValue({ - outputComponents: [{ - design: { screens: [{ id: "edited-screen", htmlCode: "
Edited
", projectId }] }, - }], + outputComponents: [ + { designSystem: { name: "ds" } }, + { + design: { + screens: [ + { + id: "edited-screen", + htmlCode: "
Edited
", + projectId, + }, + ], + }, + }, + ], projectId, sessionId: "session-1", }); @@ -139,14 +173,16 @@ describe("SDK Unit Tests", () => { const screen = new Screen(mockClient, screenData); (mockClient.callTool as Mock).mockResolvedValue({ - outputComponents: [{ - design: { - screens: [ - { id: "var-1", htmlCode: "
V1
", projectId }, - { id: "var-2", htmlCode: "
V2
", projectId }, - ], + outputComponents: [ + { + design: { + screens: [ + { id: "var-1", htmlCode: "
V1
", projectId }, + { id: "var-2", htmlCode: "
V2
", projectId }, + ], + }, }, - }], + ], projectId, sessionId: "session-1", }); @@ -205,9 +241,17 @@ describe("SDK Unit Tests", () => { (mockClient.callTool as Mock).mockResolvedValue({ outputComponents: [ + { designSystem: { name: "ds" } }, { design: { - screens: [{ id: "new-screen-1", name: "Generated", htmlCode: "
test
", projectId }], + screens: [ + { + id: "new-screen-1", + name: "Generated", + htmlCode: "
test
", + projectId, + }, + ], }, }, ], @@ -217,25 +261,28 @@ describe("SDK Unit Tests", () => { const result = await project.generate(prompt); - expect(mockClient.callTool).toHaveBeenCalledWith("generate_screen_from_text", { - projectId: projectId, - prompt: prompt, - deviceType: undefined, - modelId: undefined - }); + expect(mockClient.callTool).toHaveBeenCalledWith( + "generate_screen_from_text", + { + projectId: projectId, + prompt: prompt, + deviceType: undefined, + modelId: undefined, + }, + ); expect(result).toBeInstanceOf(Screen); expect(result.id).toBe("new-screen-1"); expect(result.projectId).toBe(projectId); }); - it("generate should handle missing design.screens in outputComponents gracefully", async () => { const project = new Project(mockClient, projectId); // Mock with missing screens array (mockClient.callTool as Mock).mockResolvedValue({ outputComponents: [ + { designSystem: { name: "ds" } }, { design: { // screens is missing @@ -257,8 +304,8 @@ describe("SDK Unit Tests", () => { const mockResponse = { screens: [ { id: "s1", sourceScreen: "S1", projectId }, - { id: "s2", sourceScreen: "S2", projectId } - ] + { id: "s2", sourceScreen: "S2", projectId }, + ], }; (mockClient.callTool as Mock).mockResolvedValue(mockResponse); @@ -266,7 +313,7 @@ describe("SDK Unit Tests", () => { const result = await project.screens(); expect(mockClient.callTool).toHaveBeenCalledWith("list_screens", { - projectId: projectId + projectId: projectId, }); expect(result).toHaveLength(2); @@ -275,7 +322,6 @@ describe("SDK Unit Tests", () => { expect(result[0].id).toBe("s1"); }); - it("screens should return [] when returned data has no screens array", async () => { const project = new Project(mockClient, projectId); @@ -285,7 +331,7 @@ describe("SDK Unit Tests", () => { const result = await project.screens(); expect(mockClient.callTool).toHaveBeenCalledWith("list_screens", { - projectId: projectId + projectId: projectId, }); expect(result).toEqual([]); @@ -294,9 +340,13 @@ describe("SDK Unit Tests", () => { it("generate should throw StitchError on failure", async () => { const project = new Project(mockClient, projectId); - (mockClient.callTool as Mock).mockRejectedValue(new Error("Generation failed")); + (mockClient.callTool as Mock).mockRejectedValue( + new Error("Generation failed"), + ); - await expect(project.generate("test")).rejects.toThrow("Generation failed"); + await expect(project.generate("test")).rejects.toThrow( + "Generation failed", + ); }); }); }); diff --git a/scripts/generate-sdk.ts b/scripts/generate-sdk.ts index 4e0daff..22433e5 100644 --- a/scripts/generate-sdk.ts +++ b/scripts/generate-sdk.ts @@ -29,14 +29,36 @@ import { resolve } from "node:path"; import { createHash } from "node:crypto"; -import { readdirSync, mkdirSync, rmSync, existsSync, readFileSync } from "node:fs"; -import { Project as TsProject, Scope, type SourceFile, type ClassDeclaration } from "ts-morph"; -import { DomainMap, type ProjectionStep, type Binding, type ArgSpec } from "./ir-schema.js"; +import { + readdirSync, + mkdirSync, + rmSync, + existsSync, + readFileSync, +} from "node:fs"; +import { + Project as TsProject, + Scope, + type SourceFile, + type ClassDeclaration, +} from "ts-morph"; +import { + DomainMap, + type ProjectionStep, + type Binding, + type ArgSpec, +} from "./ir-schema.js"; import type { Tool, ToolSchema } from "./tool-schema.js"; const ROOT_DIR = resolve(import.meta.dir, ".."); -const MANIFEST_PATH = resolve(ROOT_DIR, "packages/sdk/generated/tools-manifest.json"); -const DOMAIN_MAP_PATH = resolve(ROOT_DIR, "packages/sdk/generated/domain-map.json"); +const MANIFEST_PATH = resolve( + ROOT_DIR, + "packages/sdk/generated/tools-manifest.json", +); +const DOMAIN_MAP_PATH = resolve( + ROOT_DIR, + "packages/sdk/generated/domain-map.json", +); const GENERATED_DIR = resolve(ROOT_DIR, "packages/sdk/generated/src"); const LOCK_PATH = resolve(ROOT_DIR, "packages/sdk/generated/stitch-sdk.lock"); @@ -73,7 +95,10 @@ function getAllFiles(dir: string): string[] { /** * Resolve a $ref in a JSON Schema, returning the referenced schema. */ -export function resolveRef(schema: ToolSchema, ref: string): ToolSchema | undefined { +export function resolveRef( + schema: ToolSchema, + ref: string, +): ToolSchema | undefined { // $ref format: "#/$defs/Foo" const parts = ref.replace("#/", "").split("/"); let node: any = schema; @@ -124,9 +149,9 @@ export function validateProjection( const available = Object.keys(props).join(", "); throw new Error( `❌ Binding "${bindingLabel}" projection step ${i + 1}: ` + - `property "${step.prop}" not found in outputSchema.\n` + - ` Available properties: ${available}\n` + - ` Fix: check the projection in domain-map.json for this binding.` + `property "${step.prop}" not found in outputSchema.\n` + + ` Available properties: ${available}\n` + + ` Fix: check the projection in domain-map.json for this binding.`, ); } @@ -138,8 +163,12 @@ export function validateProjection( currentSchema = resolveRef(rootSchema, currentSchema.$ref); } - // If accessing array items (index or each), unwrap to items schema - if ((step.index !== undefined || step.each) && currentSchema?.type === "array" && currentSchema?.items) { + // If accessing array items (index, each, or find), unwrap to items schema + if ( + (step.index !== undefined || step.each || step.find !== undefined) && + currentSchema?.type === "array" && + currentSchema?.items + ) { currentSchema = currentSchema.items; if (currentSchema?.$ref) { currentSchema = resolveRef(rootSchema, currentSchema.$ref); @@ -156,11 +185,14 @@ export function validateProjection( * Walks the ProjectionStep[] array and emits property access, * [index], or .flatMap() for each step. */ -export function emitProjection(steps: ProjectionStep[], rawVar: string = "raw"): string { +export function emitProjection( + steps: ProjectionStep[], + rawVar: string = "raw", +): string { if (steps.length === 0) return rawVar; // Check if any step uses 'each' (flatMap pattern) - const hasEach = steps.some(s => s.each); + const hasEach = steps.some((s) => s.each); if (hasEach) { return emitFlatMapProjection(steps, rawVar); @@ -170,7 +202,9 @@ export function emitProjection(steps: ProjectionStep[], rawVar: string = "raw"): let code = rawVar; for (const step of steps) { code += `.${step.prop}`; - if (step.index !== undefined) { + if (step.find !== undefined) { + code += `.find((c: any) => c.${step.find})`; + } else if (step.index !== undefined) { code += `[${step.index}]`; } } @@ -182,7 +216,10 @@ export function emitProjection(steps: ProjectionStep[], rawVar: string = "raw"): * e.g. [each:outputComponents, prop:design, each:screens] → * (raw.outputComponents || []).flatMap((a: any) => a.design.screens || []) */ -function emitFlatMapProjection(steps: ProjectionStep[], rawVar: string): string { +function emitFlatMapProjection( + steps: ProjectionStep[], + rawVar: string, +): string { let code = rawVar; let tempVar = "a"; let i = 0; @@ -250,15 +287,20 @@ export function jsonSchemaToTs(prop: ToolSchema | null | undefined): string { return prop.enum.map((v: string) => `"${v}"`).join(" | "); } switch (prop.type) { - case "string": return "string"; + case "string": + return "string"; case "integer": - case "number": return "number"; - case "boolean": return "boolean"; + case "number": + return "number"; + case "boolean": + return "boolean"; case "array": if (prop.items) return `${jsonSchemaToTs(prop.items)}[]`; return "any[]"; - case "object": return "any"; - default: return "any"; + case "object": + return "any"; + default: + return "any"; } } @@ -294,14 +336,12 @@ export function generateArgsObject(args: Record): string { entries.push(name === paramName ? name : `${name}: ${paramName}`); } else if (spec.from === "computed") { const templateStr = spec.template || ""; - const interpolated = templateStr.replace( - /\{(\w+)\}/g, - (_, key) => { - const argSpec = args[key]; - if (argSpec?.from === "self" || argSpec?.from === "selfArray") return `\${this.${key}}`; - return `\${${argSpec?.from === "param" && argSpec?.rename ? argSpec.rename : key}}`; - } - ); + const interpolated = templateStr.replace(/\{(\w+)\}/g, (_, key) => { + const argSpec = args[key]; + if (argSpec?.from === "self" || argSpec?.from === "selfArray") + return `\${this.${key}}`; + return `\${${argSpec?.from === "param" && argSpec?.rename ? argSpec.rename : key}}`; + }); entries.push(`${name}: \`${interpolated}\``); } } @@ -353,23 +393,37 @@ function buildConstructorBody( if (fm.stripPrefix) { const prefix = fm.stripPrefix; statements.push(`{`); - statements.push(` let _v = typeof data === "string" ? data : data.${fm.from};`); - statements.push(` if (typeof _v === "string" && _v.startsWith("${prefix}")) _v = _v.slice(${prefix.length});`); + statements.push( + ` let _v = typeof data === "string" ? data : data.${fm.from};`, + ); + statements.push( + ` if (typeof _v === "string" && _v.startsWith("${prefix}")) _v = _v.slice(${prefix.length});`, + ); statements.push(` this.${p} = _v;`); statements.push(`}`); } else { - statements.push(`this.${p} = typeof data === "string" ? data : data.${fm.from};`); + statements.push( + `this.${p} = typeof data === "string" ? data : data.${fm.from};`, + ); } if (fm.fallback) { - statements.push(`if (!this.${p} && typeof data === "object" && data.${fm.fallback.field}) {`); - statements.push(` const parts = data.${fm.fallback.field}.split("${fm.fallback.splitOn}");`); + statements.push( + `if (!this.${p} && typeof data === "object" && data.${fm.fallback.field}) {`, + ); + statements.push( + ` const parts = data.${fm.fallback.field}.split("${fm.fallback.splitOn}");`, + ); statements.push(` if (parts.length === 2) this.${p} = parts[1];`); statements.push(`}`); } } else if (config.identifierField && p === ctorParams[0]) { - statements.push(`this.${p} = typeof data === "string" ? data : data.${config.identifierField};`); + statements.push( + `this.${p} = typeof data === "string" ? data : data.${config.identifierField};`, + ); } else { - statements.push(`this.${p} = typeof data === "string" ? data : data.${p};`); + statements.push( + `this.${p} = typeof data === "string" ? data : data.${p};`, + ); } } @@ -395,8 +449,12 @@ function buildMethodBody( } statements.push(`try {`); - statements.push(` const raw = await this.client.callTool("${binding.tool}", ${generateArgsObject(binding.args)});`); - statements.push(` return ${generateReturnExpression(binding, className, domainMap)};`); + statements.push( + ` const raw = await this.client.callTool("${binding.tool}", ${generateArgsObject(binding.args)});`, + ); + statements.push( + ` return ${generateReturnExpression(binding, className, domainMap)};`, + ); statements.push(`} catch (error) {`); statements.push(` throw StitchError.fromUnknown(error);`); statements.push(`}`); @@ -421,7 +479,7 @@ async function main() { // Validate projections against output schemas console.log("🔍 Validating projections against output schemas..."); for (const binding of domainMap.bindings) { - const tool = manifest.find(t => t.name === binding.tool); + const tool = manifest.find((t) => t.name === binding.tool); if (!tool?.outputSchema) continue; validateProjection( @@ -464,7 +522,9 @@ async function main() { // Generate a class file for each domain class for (const [className, config] of Object.entries(domainMap.classes)) { - const classBindings = domainMap.bindings.filter(b => b.class === className); + const classBindings = domainMap.bindings.filter( + (b) => b.class === className, + ); const classFileName = className.toLowerCase(); console.log(` 📄 ${classFileName}.ts (${classBindings.length} methods)`); @@ -516,12 +576,19 @@ async function main() { // Constructor if (config.isRoot) { cls.addConstructor({ - parameters: [{ name: "client", type: "StitchToolClient", scope: Scope.Private }], + parameters: [ + { name: "client", type: "StitchToolClient", scope: Scope.Private }, + ], }); } else { // Declare fields for (const p of config.constructorParams) { - cls.addProperty({ name: p, type: "string", scope: Scope.Public, isReadonly: true }); + cls.addProperty({ + name: p, + type: "string", + scope: Scope.Public, + isReadonly: true, + }); } cls.addProperty({ name: "data", type: "any", scope: Scope.Public }); @@ -547,30 +614,38 @@ async function main() { // Methods from bindings for (const binding of classBindings) { - const tool = manifest.find(t => t.name === binding.tool); + const tool = manifest.find((t) => t.name === binding.tool); if (!tool) { - console.warn(` ⚠️ Tool "${binding.tool}" not found in manifest, skipping.`); + console.warn( + ` ⚠️ Tool "${binding.tool}" not found in manifest, skipping.`, + ); continue; } const paramTypes = generateParamType(tool, binding.args); const returnTypeStr = binding.returns.class - ? (binding.returns.array ? `${binding.returns.class}[]` : binding.returns.class) - : (binding.returns.type || "any"); + ? binding.returns.array + ? `${binding.returns.class}[]` + : binding.returns.class + : binding.returns.type || "any"; cls.addMethod({ name: binding.method, isAsync: true, returnType: `Promise<${returnTypeStr}>`, - docs: [{ - description: `${tool.description?.split("\n")[0].trim() || binding.method}\nTool: ${binding.tool}`, - }], + docs: [ + { + description: `${tool.description?.split("\n")[0].trim() || binding.method}\nTool: ${binding.tool}`, + }, + ], // Parameters as raw string (ts-morph doesn't easily support "prompt: string, opts?: Enum" inline) statements: buildMethodBody(binding, className, domainMap), }); // Add parameters manually (from the paramTypes string) by editing the method - const method = cls.getMethods().find(m => m.getName() === binding.method); + const method = cls + .getMethods() + .find((m) => m.getName() === binding.method); if (method && paramTypes) { // Parse paramTypes string into individual params const paramParts = paramTypes.split(", ").filter(Boolean); @@ -592,7 +667,9 @@ async function main() { for (const factory of config.factories) { const factoryClass = domainMap.classes[factory.returns]; if (!factoryClass) { - console.warn(` ⚠️ Factory returns "${factory.returns}" but class not found, skipping.`); + console.warn( + ` ⚠️ Factory returns "${factory.returns}" but class not found, skipping.`, + ); continue; } @@ -600,7 +677,13 @@ async function main() { name: factory.method, returnType: factory.returns, parameters: [{ name: "id", type: "string" }], - docs: [{ description: factory.description || `Create a ${factory.returns} from an ID.` }], + docs: [ + { + description: + factory.description || + `Create a ${factory.returns} from an ID.`, + }, + ], statements: [`return new ${factory.returns}(this.client, id);`], }); } @@ -621,40 +704,109 @@ async function main() { isExported: true, docs: ["JSON Schema property descriptor for a tool parameter."], properties: [ - { name: "type", type: "string", hasQuestionToken: true, docs: ["JSON Schema type (string, integer, array, etc.)"] }, - { name: "description", type: "string", hasQuestionToken: true, docs: ["Human-readable parameter description"] }, - { name: "enum", type: "string[]", hasQuestionToken: true, docs: ["Allowed values for constrained parameters"] }, - { name: "items", type: "ToolPropertySchema", hasQuestionToken: true, docs: ["Schema for array items"] }, - { name: "deprecated", type: "boolean", hasQuestionToken: true, docs: ["Whether the parameter is deprecated"] }, + { + name: "type", + type: "string", + hasQuestionToken: true, + docs: ["JSON Schema type (string, integer, array, etc.)"], + }, + { + name: "description", + type: "string", + hasQuestionToken: true, + docs: ["Human-readable parameter description"], + }, + { + name: "enum", + type: "string[]", + hasQuestionToken: true, + docs: ["Allowed values for constrained parameters"], + }, + { + name: "items", + type: "ToolPropertySchema", + hasQuestionToken: true, + docs: ["Schema for array items"], + }, + { + name: "deprecated", + type: "boolean", + hasQuestionToken: true, + docs: ["Whether the parameter is deprecated"], + }, + ], + indexSignatures: [ + { + keyName: "key", + keyType: "string", + returnType: "unknown", + docs: ["Additional JSON Schema properties"], + }, ], - indexSignatures: [{ keyName: "key", keyType: "string", returnType: "unknown", docs: ["Additional JSON Schema properties"] }], }); toolDefsFile.addInterface({ name: "ToolInputSchema", isExported: true, docs: ["Typed JSON Schema for a tool's input parameters."], properties: [ - { name: "type", type: '"object"', docs: ["Always 'object' for tool inputs"] }, - { name: "description", type: "string", hasQuestionToken: true, docs: ["Schema-level description"] }, - { name: "properties", type: "Record", docs: ["Map of parameter names to their schemas"] }, - { name: "required", type: "string[]", hasQuestionToken: true, docs: ["Names of required parameters"] }, + { + name: "type", + type: '"object"', + docs: ["Always 'object' for tool inputs"], + }, + { + name: "description", + type: "string", + hasQuestionToken: true, + docs: ["Schema-level description"], + }, + { + name: "properties", + type: "Record", + docs: ["Map of parameter names to their schemas"], + }, + { + name: "required", + type: "string[]", + hasQuestionToken: true, + docs: ["Names of required parameters"], + }, + ], + indexSignatures: [ + { + keyName: "key", + keyType: "string", + returnType: "unknown", + docs: ["Additional JSON Schema properties"], + }, ], - indexSignatures: [{ keyName: "key", keyType: "string", returnType: "unknown", docs: ["Additional JSON Schema properties"] }], }); toolDefsFile.addInterface({ name: "ToolDefinition", isExported: true, docs: ["Static tool definition from the Stitch MCP server manifest."], properties: [ - { name: "name", type: "string", docs: ['MCP tool name, e.g. "create_project"'] }, - { name: "description", type: "string", docs: ["Human-readable description of what the tool does"] }, - { name: "inputSchema", type: "ToolInputSchema", docs: ["Typed JSON Schema for the tool's input parameters"] }, + { + name: "name", + type: "string", + docs: ['MCP tool name, e.g. "create_project"'], + }, + { + name: "description", + type: "string", + docs: ["Human-readable description of what the tool does"], + }, + { + name: "inputSchema", + type: "ToolInputSchema", + docs: ["Typed JSON Schema for the tool's input parameters"], + }, ], }); // Use ts-morph for the declaration, but inject the JSON data directly. // (addStatements chokes on very large JSON literals, so we build the output string.) const toolDefsJson = JSON.stringify( - manifest.map(t => ({ + manifest.map((t) => ({ name: t.name, description: t.description || "", inputSchema: t.inputSchema || {}, @@ -666,7 +818,10 @@ async function main() { toolDefsFile.getFullText() + `\n/** All tools available on the Stitch MCP server, generated from tools-manifest.json. */\n` + `export const toolDefinitions: ToolDefinition[] = ${toolDefsJson};\n`; - await Bun.write(resolve(GENERATED_DIR, "tool-definitions.ts"), toolDefsOutput); + await Bun.write( + resolve(GENERATED_DIR, "tool-definitions.ts"), + toolDefsOutput, + ); fileCount++; // Generate barrel export @@ -690,7 +845,9 @@ async function main() { await Bun.write(resolve(GENERATED_DIR, "index.ts"), indexFile.getFullText()); fileCount++; - console.log(`\n📦 Generated ${fileCount} files in packages/sdk/generated/src/`); + console.log( + `\n📦 Generated ${fileCount} files in packages/sdk/generated/src/`, + ); // Update stitch-sdk.lock const generatedHash = hashDirectory(GENERATED_DIR); diff --git a/scripts/ir-schema.ts b/scripts/ir-schema.ts index 13a2dbc..09d3752 100644 --- a/scripts/ir-schema.ts +++ b/scripts/ir-schema.ts @@ -14,7 +14,7 @@ /** * Binding IR Schema - * + * * Zod schemas defining the structure of domain-map.json. * Used by generate-sdk.ts to validate the IR before codegen, * and as documentation for the Stage 2 domain design process. @@ -29,19 +29,34 @@ import { z } from "zod"; * Replaces string-based extraction paths like ".outputComponents[0].design.screens[0]" * with structured, validatable segments. */ -export const ProjectionStep = z.object({ - /** Property name to access on the current object */ - prop: z.string(), - /** Pick nth item from an array (replaces [0], [1], etc.) */ - index: z.number().int().min(0).optional(), - /** Flatten all items via flatMap (replaces [*] glob) */ - each: z.boolean().optional(), - /** Alternate property name if primary is missing */ - fallback: z.string().optional(), -}).refine( - data => !(data.index !== undefined && data.each), - { message: "Cannot use both 'index' and 'each' on the same step" } -); +export const ProjectionStep = z + .object({ + /** Property name to access on the current object */ + prop: z.string(), + /** Pick nth item from an array (replaces [0], [1], etc.) */ + index: z.number().int().min(0).optional(), + /** Flatten all items via flatMap (replaces [*] glob) */ + each: z.boolean().optional(), + /** + * Find the first array item where the given property exists. + * Emits `.find((c: any) => c.)` — resilient to API response ordering changes. + * Use instead of `index` when the target item's position in the array is not stable. + */ + find: z.string().optional(), + /** Alternate property name if primary is missing */ + fallback: z.string().optional(), + }) + .refine( + (data) => { + const modes = [ + data.index !== undefined, + !!data.each, + data.find !== undefined, + ].filter(Boolean).length; + return modes <= 1; + }, + { message: "Cannot combine 'index', 'each', or 'find' on the same step" }, + ); export type ProjectionStep = z.infer; // ── Field Mapping ───────────────────────────────────────────── @@ -52,10 +67,12 @@ export const FieldMappingSpec = z.object({ /** Strip this prefix from the value (e.g., "projects/" strips resource name prefix) */ stripPrefix: z.string().optional(), /** Fallback: parse from alternate field if primary is missing */ - fallback: z.object({ - field: z.string(), - splitOn: z.string(), - }).optional(), + fallback: z + .object({ + field: z.string(), + splitOn: z.string(), + }) + .optional(), }); export type FieldMappingSpec = z.infer; diff --git a/scripts/test/generate-sdk.test.ts b/scripts/test/generate-sdk.test.ts index 1c182df..a40b341 100644 --- a/scripts/test/generate-sdk.test.ts +++ b/scripts/test/generate-sdk.test.ts @@ -72,6 +72,17 @@ describe("emitProjection", () => { expect(emitProjection(steps)).toBe("raw.a[0].b.c[1]"); }); + test("find step → .find((c: any) => c.property) chain", () => { + const steps: ProjectionStep[] = [ + { prop: "outputComponents", find: "design" }, + { prop: "design" }, + { prop: "screens", index: 0 }, + ]; + expect(emitProjection(steps)).toBe( + "raw.outputComponents.find((c: any) => c.design).design.screens[0]", + ); + }); + test("single each → flatMap pattern", () => { const steps: ProjectionStep[] = [ { prop: "outputComponents", each: true }, @@ -98,7 +109,9 @@ describe("emitCacheProjection", () => { { prop: "screenshot" }, { prop: "downloadUrl" }, ]; - expect(emitCacheProjection(steps)).toBe("this.data?.screenshot?.downloadUrl"); + expect(emitCacheProjection(steps)).toBe( + "this.data?.screenshot?.downloadUrl", + ); }); test("empty steps → this.data", () => { @@ -128,18 +141,26 @@ describe("validateProjection", () => { test("valid path passes without throwing", () => { const steps: ProjectionStep[] = [{ prop: "screens" }]; - expect(() => validateProjection(steps, outputSchema, "Test.method")).not.toThrow(); + expect(() => + validateProjection(steps, outputSchema, "Test.method"), + ).not.toThrow(); }); test("valid deep path passes", () => { const steps: ProjectionStep[] = [{ prop: "title" }]; - expect(() => validateProjection(steps, outputSchema, "Test.method")).not.toThrow(); + expect(() => + validateProjection(steps, outputSchema, "Test.method"), + ).not.toThrow(); }); test("typo throws with available properties", () => { const steps: ProjectionStep[] = [{ prop: "screenz" }]; - expect(() => validateProjection(steps, outputSchema, "Test.method")).toThrow(/screenz/); - expect(() => validateProjection(steps, outputSchema, "Test.method")).toThrow(/screens/); + expect(() => + validateProjection(steps, outputSchema, "Test.method"), + ).toThrow(/screenz/); + expect(() => + validateProjection(steps, outputSchema, "Test.method"), + ).toThrow(/screens/); }); test("null schema skips validation (no throw)", () => { @@ -164,7 +185,9 @@ describe("validateProjection", () => { }, }; const steps: ProjectionStep[] = [{ prop: "screen" }, { prop: "htmlCode" }]; - expect(() => validateProjection(steps, schemaWithRef, "Test.method")).not.toThrow(); + expect(() => + validateProjection(steps, schemaWithRef, "Test.method"), + ).not.toThrow(); }); test("$ref with invalid nested prop throws", () => { @@ -183,7 +206,9 @@ describe("validateProjection", () => { }, }; const steps: ProjectionStep[] = [{ prop: "screen" }, { prop: "bogus" }]; - expect(() => validateProjection(steps, schemaWithRef, "Test.method")).toThrow(/bogus/); + expect(() => + validateProjection(steps, schemaWithRef, "Test.method"), + ).toThrow(/bogus/); }); }); @@ -230,7 +255,9 @@ describe("jsonSchemaToTs", () => { }); test("array of strings → string[]", () => { - expect(jsonSchemaToTs({ type: "array", items: { type: "string" } })).toBe("string[]"); + expect(jsonSchemaToTs({ type: "array", items: { type: "string" } })).toBe( + "string[]", + ); }); test("null/missing prop → any", () => { diff --git a/scripts/test/ir-schema.test.ts b/scripts/test/ir-schema.test.ts index 6b237d7..4e3833d 100644 --- a/scripts/test/ir-schema.test.ts +++ b/scripts/test/ir-schema.test.ts @@ -41,7 +41,10 @@ describe("ProjectionStep", () => { }); test("accepts step with prop + index", () => { - const result = ProjectionStep.safeParse({ prop: "outputComponents", index: 0 }); + const result = ProjectionStep.safeParse({ + prop: "outputComponents", + index: 0, + }); expect(result.success).toBe(true); }); @@ -60,8 +63,38 @@ describe("ProjectionStep", () => { expect(result.success).toBe(false); }); + test("accepts step with prop + find", () => { + const result = ProjectionStep.safeParse({ + prop: "outputComponents", + find: "design", + }); + expect(result.success).toBe(true); + }); + test("rejects step with both index and each", () => { - const result = ProjectionStep.safeParse({ prop: "items", index: 0, each: true }); + const result = ProjectionStep.safeParse({ + prop: "items", + index: 0, + each: true, + }); + expect(result.success).toBe(false); + }); + + test("rejects step with both find and index", () => { + const result = ProjectionStep.safeParse({ + prop: "items", + find: "design", + index: 0, + }); + expect(result.success).toBe(false); + }); + + test("rejects step with both find and each", () => { + const result = ProjectionStep.safeParse({ + prop: "items", + find: "design", + each: true, + }); expect(result.success).toBe(false); }); @@ -80,12 +113,19 @@ describe("ArgSpec", () => { }); test("accepts param arg with rename", () => { - const result = ArgSpec.safeParse({ from: "param", rename: "newName", optional: true }); + const result = ArgSpec.safeParse({ + from: "param", + rename: "newName", + optional: true, + }); expect(result.success).toBe(true); }); test("accepts computed arg with template", () => { - const result = ArgSpec.safeParse({ from: "computed", template: "projects/{projectId}" }); + const result = ArgSpec.safeParse({ + from: "computed", + template: "projects/{projectId}", + }); expect(result.success).toBe(true); }); @@ -170,7 +210,10 @@ describe("FieldMappingSpec", () => { }); test("accepts mapping with stripPrefix", () => { - const result = FieldMappingSpec.safeParse({ from: "name", stripPrefix: "projects/" }); + const result = FieldMappingSpec.safeParse({ + from: "name", + stripPrefix: "projects/", + }); expect(result.success).toBe(true); });