diff --git a/packages/app/src/cli/models/extensions/specifications/type-generation.test.ts b/packages/app/src/cli/models/extensions/specifications/type-generation.test.ts index b26704b3d7..87a54473b3 100644 --- a/packages/app/src/cli/models/extensions/specifications/type-generation.test.ts +++ b/packages/app/src/cli/models/extensions/specifications/type-generation.test.ts @@ -1,7 +1,137 @@ -import {createToolsTypeDefinition} from './type-generation.js' +import {createIntentsTypeDefinition, createToolsTypeDefinition} from './type-generation.js' import {AbortError} from '@shopify/cli-kit/node/error' import {describe, expect, test} from 'vitest' +describe('createIntentsTypeDefinition', () => { + test('returns empty string when intents array is empty', async () => { + // When + const result = await createIntentsTypeDefinition([]) + + // Then + expect(result).toBe('') + }) + + test('generates request and response types for a single intent schema', async () => { + // Given + const intents = [ + { + action: 'create', + type: 'application/email', + inputSchema: { + type: 'object', + properties: { + recipient: {type: 'string'}, + }, + required: ['recipient'], + }, + outputSchema: { + type: 'object', + properties: { + success: {type: 'boolean'}, + }, + }, + }, + ] + + // When + const result = await createIntentsTypeDefinition(intents) + + // Then + expect(result).toBe(`interface CreateApplicationEmailIntentInput { + recipient: string; + [k: string]: unknown; +} + +type CreateApplicationEmailIntentValue = unknown +interface CreateApplicationEmailIntentOutput { + success?: boolean; + [k: string]: unknown; +} + +interface CreateApplicationEmailIntentRequest { + action: "create"; + type: "application/email"; + data: CreateApplicationEmailIntentInput; + value?: CreateApplicationEmailIntentValue; +} + +type ShopifyGeneratedIntentVariants = + | import('@shopify/ui-extensions/admin').ShopifyGeneratedIntentVariant +`) + }) + + test('supports multiple intents with value schemas', async () => { + // Given + const intents = [ + { + action: 'create', + type: 'application/email', + inputSchema: { + type: 'object', + properties: { + recipient: {type: 'string'}, + }, + }, + }, + { + action: 'edit', + type: 'shopify/Product', + valueSchema: { + type: 'string', + }, + inputSchema: { + type: 'object', + properties: { + title: {type: 'string'}, + }, + }, + outputSchema: { + type: 'object', + properties: { + id: {type: 'string'}, + }, + }, + }, + ] + + // When + const result = await createIntentsTypeDefinition(intents) + + // Then + expect(result).toContain('interface CreateApplicationEmailIntentRequest') + expect(result).toContain('type CreateApplicationEmailIntentOutput = unknown') + expect(result).toContain('interface EditShopifyProductIntentRequest') + expect(result).toContain('type EditShopifyProductIntentValue = string') + expect(result).not.toContain('ShopifyGeneratedIntentsApi') + expect(result).toContain( + "import('@shopify/ui-extensions/admin').ShopifyGeneratedIntentVariant", + ) + }) + + test('throws AbortError when intent action/type pairs are duplicated', async () => { + // Given + const intents = [ + { + action: 'create', + type: 'application/email', + inputSchema: {type: 'object'}, + }, + { + action: 'create', + type: 'application/email', + inputSchema: {type: 'object'}, + }, + ] + + // When & Then + await expect(createIntentsTypeDefinition(intents)).rejects.toThrow( + new AbortError( + 'Intent "create:application/email" is defined multiple times. Intents must be unique within a target.', + ), + ) + }) +}) + describe('createToolsTypeDefinition', () => { test('returns empty string when tools array is empty', async () => { // When @@ -53,7 +183,7 @@ interface ShopifyTools { /** * Gets a product by ID */ - register(name: 'get_product', handler: (input: GetProductInput) => GetProductOutput | Promise); + register(name: 'get_product', handler: (input: GetProductInput) => GetProductOutput | Promise): () => void; } `) }) @@ -87,7 +217,7 @@ interface ShopifyTools { /** * A simple action */ - register(name: 'simple_action', handler: (input: SimpleActionInput) => SimpleActionOutput | Promise); + register(name: 'simple_action', handler: (input: SimpleActionInput) => SimpleActionOutput | Promise): () => void; } `) }) @@ -157,11 +287,11 @@ interface ShopifyTools { /** * First tool */ - register(name: 'tool_one', handler: (input: ToolOneInput) => ToolOneOutput | Promise); + register(name: 'tool_one', handler: (input: ToolOneInput) => ToolOneOutput | Promise): () => void; /** * Second tool */ - register(name: 'tool_two', handler: (input: ToolTwoInput) => ToolTwoOutput | Promise); + register(name: 'tool_two', handler: (input: ToolTwoInput) => ToolTwoOutput | Promise): () => void; } `) }) @@ -212,7 +342,7 @@ interface ShopifyTools { /** * This description contains *\\/ which could break comments */ - register(name: 'tool_with_special_desc', handler: (input: ToolWithSpecialDescInput) => ToolWithSpecialDescOutput | Promise); + register(name: 'tool_with_special_desc', handler: (input: ToolWithSpecialDescInput) => ToolWithSpecialDescOutput | Promise): () => void; } `) }) @@ -242,7 +372,7 @@ interface ShopifyTools { * Line two * Line three */ - register(name: 'documented_tool', handler: (input: DocumentedToolInput) => DocumentedToolOutput | Promise); + register(name: 'documented_tool', handler: (input: DocumentedToolInput) => DocumentedToolOutput | Promise): () => void; } `) }) @@ -274,7 +404,7 @@ interface ShopifyTools { /** * A tool with snake case name */ - register(name: 'my_snake_case_tool', handler: (input: MySnakeCaseToolInput) => MySnakeCaseToolOutput | Promise); + register(name: 'my_snake_case_tool', handler: (input: MySnakeCaseToolInput) => MySnakeCaseToolOutput | Promise): () => void; } `) }) @@ -345,7 +475,7 @@ interface ShopifyTools { /** * A tool with nested schema */ - register(name: 'complex_tool', handler: (input: ComplexToolInput) => ComplexToolOutput | Promise); + register(name: 'complex_tool', handler: (input: ComplexToolInput) => ComplexToolOutput | Promise): () => void; } `) }) @@ -373,7 +503,7 @@ interface ShopifyTools { /** * Gets product info */ - register(name: 'get-product-info', handler: (input: GetProductInfoInput) => GetProductInfoOutput | Promise); + register(name: 'get-product-info', handler: (input: GetProductInfoInput) => GetProductInfoOutput | Promise): () => void; } `) }) diff --git a/packages/app/src/cli/models/extensions/specifications/type-generation.ts b/packages/app/src/cli/models/extensions/specifications/type-generation.ts index 6be61749f4..d1a4b55650 100644 --- a/packages/app/src/cli/models/extensions/specifications/type-generation.ts +++ b/packages/app/src/cli/models/extensions/specifications/type-generation.ts @@ -8,6 +8,7 @@ import {zod} from '@shopify/cli-kit/node/schema' import {createRequire} from 'module' const require = createRequire(import.meta.url) +const generatedTypesHelperImportPath = '@shopify/ui-extensions/admin' export function parseApiVersion(apiVersion: string): {year: number; month: number} | null { const [year, month] = apiVersion.split('-') @@ -163,6 +164,12 @@ interface CreateTypeDefinitionOptions { targets: string[] apiVersion: string toolsTypeDefinition?: string + intentsTypeDefinition?: string +} + +interface ShopifyTypeOptions { + includesTools: boolean + includesIntents: boolean } /** @@ -205,36 +212,61 @@ function targetExportsShopifyGlobal(targetDtsPath: string): boolean { return found } +function buildBaseShopifyType(targets: string[], resolvedTargetPaths: Map): string | null { + const typeForTarget = (target: string): string => { + const base = `import('@shopify/ui-extensions/${target}').Api` + const dtsPath = resolvedTargetPaths.get(target) + if (dtsPath && targetExportsShopifyGlobal(dtsPath)) { + return `${base} & import('@shopify/ui-extensions/${target}').ShopifyGlobal` + } + return base + } + + if (targets.length === 1) { + return typeForTarget(targets[0] ?? '') + } + + if (targets.length > 1) { + return `(${targets.map(typeForTarget).join(' | ')})` + } + + return null +} + /** - * Builds the shopify API type based on targets, their resolved .d.ts paths, - * and optional tools type. + * Builds the shopify API type based on targets and optional generated tool / intent types. * - * If a target re-exports `ShopifyGlobal`, the emitted type is + * If a target re-exports `ShopifyGlobal`, the base emitted type includes * `import('').Api & import('').ShopifyGlobal` so consumers * retain access to both the target's data surface and host-level APIs - * (e.g. `shopify.addEventListener`). Otherwise emits just `.Api`. + * (e.g. `shopify.addEventListener`). Generated tools and intents are then + * layered on top of that base type via wrapper utility types. * * Returns null if no targets are provided. */ function buildShopifyType( targets: string[], resolvedTargetPaths: Map, - toolsTypeDefinition?: string, + {includesTools, includesIntents}: ShopifyTypeOptions, ): string | null { - const toolsSuffix = toolsTypeDefinition ? ' & { tools: ShopifyTools }' : '' + const baseShopifyType = buildBaseShopifyType(targets, resolvedTargetPaths) + if (!baseShopifyType) return null - const typeForTarget = (target: string): string => { - const base = `import('@shopify/ui-extensions/${target}').Api` - const dtsPath = resolvedTargetPaths.get(target) - if (dtsPath && targetExportsShopifyGlobal(dtsPath)) { - return `${base} & import('@shopify/ui-extensions/${target}').ShopifyGlobal` - } - return base + if (!includesTools && !includesIntents) { + return baseShopifyType + } + + let shopifyType = baseShopifyType + + if (includesIntents) { + shopifyType = `import('${generatedTypesHelperImportPath}').WithGeneratedIntents<${shopifyType}, ShopifyGeneratedIntentVariants>` } - if (targets.length === 0) return null - if (targets.length === 1) return `${typeForTarget(targets[0] ?? '')}${toolsSuffix}` - return `(${targets.map(typeForTarget).join(' | ')})${toolsSuffix}` + if (includesTools) { + shopifyType = `import('${generatedTypesHelperImportPath}').WithGeneratedTools<${shopifyType}, ShopifyTools>` + } + + return shopifyType } export function createTypeDefinition({ @@ -243,9 +275,12 @@ export function createTypeDefinition({ targets, apiVersion, toolsTypeDefinition, + intentsTypeDefinition, }: CreateTypeDefinitionOptions): string | null { try { const resolvedTargetPaths = new Map() + const includesTools = Boolean(toolsTypeDefinition) + const includesIntents = Boolean(intentsTypeDefinition) // Validate that all targets can be resolved, and capture the resolved .d.ts // path so buildShopifyType can inspect it for ShopifyGlobal exports. @@ -264,15 +299,27 @@ export function createTypeDefinition({ } } - const relativePath = relativizePath(fullPath, dirname(typeFilePath)) + if (includesTools || includesIntents) { + try { + require.resolve(generatedTypesHelperImportPath, {paths: [fullPath, typeFilePath]}) + } catch (_) { + const {year, month} = parseApiVersion(apiVersion) ?? {year: 2025, month: 10} + throw new AbortError( + `Type reference for ${generatedTypesHelperImportPath} could not be found. You might be using the wrong @shopify/ui-extensions version.`, + `Fix the error by ensuring you have the correct version of @shopify/ui-extensions, for example ~${year}.${month}.0, in your dependencies.`, + ) + } + } - const shopifyType = buildShopifyType(targets, resolvedTargetPaths, toolsTypeDefinition) + const relativePath = relativizePath(fullPath, dirname(typeFilePath)) + const shopifyType = buildShopifyType(targets, resolvedTargetPaths, {includesTools, includesIntents}) if (!shopifyType) return null const lines = [ '//@ts-ignore', `declare module './${relativePath}' {`, - ...(toolsTypeDefinition ? [` ${toolsTypeDefinition}`] : []), + ...(toolsTypeDefinition ? [toolsTypeDefinition] : []), + ...(intentsTypeDefinition ? [intentsTypeDefinition] : []), ` const shopify: ${shopifyType};`, ' const globalThis: { shopify: typeof shopify };', '}', @@ -325,6 +372,87 @@ const ToolDefinitionSchema: zod.ZodType = zod.object({ export const ToolsFileSchema = zod.array(ToolDefinitionSchema) +interface IntentTypeDefinition { + action: string + type: string + inputSchema: object + valueSchema?: object + outputSchema?: object +} + +interface IntentSchemaFile { + value?: object + inputSchema: object + outputSchema?: object +} + +export const IntentSchemaFileSchema: zod.ZodType = zod.object({ + value: zod.object({}).passthrough().optional(), + inputSchema: zod.object({}).passthrough(), + outputSchema: zod.object({}).passthrough().optional(), +}) + +function intentTypeBaseName(intent: Pick): string { + return pascalize(`${intent.action} ${intent.type}`.replace(/[^a-zA-Z0-9]+/g, ' ')) +} + +/** + * Generates TypeScript types for shopify.intents.request and shopify.intents.response.ok + * based on intent schema definitions. + */ +export async function createIntentsTypeDefinition(intents: IntentTypeDefinition[]): Promise { + if (intents.length === 0) return '' + + const intentKeys = new Set() + const typePromises = intents.map(async (intent) => { + const intentKey = `${intent.action}:${intent.type}` + if (intentKeys.has(intentKey)) { + throw new AbortError(`Intent "${intentKey}" is defined multiple times. Intents must be unique within a target.`) + } + intentKeys.add(intentKey) + + const typeBaseName = intentTypeBaseName(intent) + const inputTypeName = `${typeBaseName}IntentInput` + const valueTypeName = `${typeBaseName}IntentValue` + const outputTypeName = `${typeBaseName}IntentOutput` + const requestTypeName = `${typeBaseName}IntentRequest` + + const inputType = await formatJsonSchemaType(inputTypeName, intent.inputSchema) + const valueType = await formatJsonSchemaType(valueTypeName, intent.valueSchema) + const outputType = await formatJsonSchemaType(outputTypeName, intent.outputSchema) + + const requestType = `interface ${requestTypeName} { + action: ${JSON.stringify(intent.action)}; + type: ${JSON.stringify(intent.type)}; + data: ${inputTypeName}; + value?: ${valueTypeName}; +}` + + return { + inputType, + valueType, + outputType, + requestType, + requestTypeName, + outputTypeName, + } + }) + + const types = await Promise.all(typePromises) + + const generatedIntents = types + .map(({requestTypeName, outputTypeName}) => { + return ` | import('${generatedTypesHelperImportPath}').ShopifyGeneratedIntentVariant<${requestTypeName}, ${outputTypeName}>` + }) + .join('\n') + + return `${types + .map( + ({inputType, valueType, outputType, requestType}) => `${inputType}\n${valueType}\n${outputType}\n${requestType}`, + ) + .join('\n\n')}\n\ntype ShopifyGeneratedIntentVariants =\n${generatedIntents}\n` +} + /** * Generates TypeScript types for shopify.tools.register based on tool definitions * @param tools - Array of tool definitions from tools.json @@ -370,7 +498,7 @@ export async function createToolsTypeDefinition(tools: ToolDefinition[]): Promis .split('\n') .map((line) => ` * ${line}`) .join('\n') - return ` /**\n${formattedDescription}\n */\n register(name: '${name}', handler: (input: ${inputTypeName}) => ${outputTypeName} | Promise<${outputTypeName}>);` + return ` /**\n${formattedDescription}\n */\n register(name: '${name}', handler: (input: ${inputTypeName}) => ${outputTypeName} | Promise<${outputTypeName}>): () => void;` }) .join('\n') @@ -379,8 +507,15 @@ export async function createToolsTypeDefinition(tools: ToolDefinition[]): Promis .join('\n')}\ninterface ShopifyTools {\n${toolRegistrations}\n}\n` } +function renameGeneratedType(typeDefinition: string, name: string): string { + return typeDefinition.replace(/^(interface|type|enum)\s+[A-Za-z0-9_]+/, `$1 ${name}`) +} + async function formatJsonSchemaType(name: string, schema?: object): Promise { - const outputType = schema ? await compile(schema, name, {bannerComment: ''}) : `type ${name} = unknown` - // The json-schema-to-typescript library adds an export keyword to the type definition, we need to remove it - return outputType.startsWith('export ') ? outputType.slice(7) : outputType + if (!schema) return `type ${name} = unknown` + + const outputType = await compile(schema, name, {bannerComment: ''}) + const normalizedOutputType = outputType.startsWith('export ') ? outputType.slice(7) : outputType + + return renameGeneratedType(normalizedOutputType, name) } diff --git a/packages/app/src/cli/models/extensions/specifications/ui_extension.test.ts b/packages/app/src/cli/models/extensions/specifications/ui_extension.test.ts index be8441e3d4..86701ee547 100644 --- a/packages/app/src/cli/models/extensions/specifications/ui_extension.test.ts +++ b/packages/app/src/cli/models/extensions/specifications/ui_extension.test.ts @@ -1197,6 +1197,10 @@ Please check the configuration in ${uiExtension.configurationPath}`), const nodeModulesPath = joinPath(tmpDir, 'node_modules', '@shopify', 'ui-extensions') await mkdir(nodeModulesPath) + const generatedTypesHelperPath = joinPath(nodeModulesPath, 'admin') + await mkdir(generatedTypesHelperPath) + await writeFile(joinPath(generatedTypesHelperPath, 'index.js'), '// Mock generated types helper exports') + const targetPath = joinPath(nodeModulesPath, target) await mkdir(targetPath) // `require.resolve('@shopify/ui-extensions/')` resolves to this file, @@ -2197,7 +2201,8 @@ Please check the configuration in ${uiExtension.configurationPath}`), expect(typeDefinition).toContain('interface SearchProductsInput') expect(typeDefinition).toContain('interface SearchProductsOutput') expect(typeDefinition).toContain("name: 'search_products'") - expect(typeDefinition).toContain('tools: ShopifyTools') + expect(typeDefinition).toContain("import('@shopify/ui-extensions/admin').WithGeneratedTools<") + expect(typeDefinition).not.toContain('interface GeneratedToolsConstraint') }) }) @@ -2417,12 +2422,191 @@ Please check the configuration in ${uiExtension.configurationPath}`), // Entry point should have ShopifyTools const entryPointType = types.find((t) => t.includes('./src/index.jsx')) expect(entryPointType).toContain('ShopifyTools') - expect(entryPointType).toContain('tools: ShopifyTools') + expect(entryPointType).toContain("import('@shopify/ui-extensions/admin').WithGeneratedTools<") // Imported file should NOT have ShopifyTools const helperType = types.find((t) => t.includes('./src/utils/helper.js')) expect(helperType).not.toContain('ShopifyTools') }) }) + + test('generates shopify.d.ts with generated intent request and response types when intent schema is present', async () => { + const typeDefinitionsByFile = new Map>() + + await inTemporaryDirectory(async (tmpDir) => { + const {extension} = await setupUIExtensionWithNodeModules({ + tmpDir, + fileContent: '// Extension code', + apiVersion: '2025-10', + target: 'admin.app.intent.render', + }) + + const intentSchemaContent = JSON.stringify({ + inputSchema: { + type: 'object', + properties: { + recipient: {type: 'string'}, + }, + required: ['recipient'], + }, + outputSchema: { + type: 'object', + properties: { + success: {type: 'boolean'}, + }, + }, + }) + await writeFile(joinPath(tmpDir, 'intent-schema.json'), intentSchemaContent) + ;(extension.configuration.extension_points[0] as any).intents = [ + { + action: 'create', + type: 'application/email', + schema: './intent-schema.json', + }, + ] + + await writeFile(joinPath(tmpDir, 'tsconfig.json'), '{}') + + // When + await extension.contributeToSharedTypeFile?.(typeDefinitionsByFile) + + const shopifyDtsPath = joinPath(tmpDir, 'shopify.d.ts') + const types = Array.from(typeDefinitionsByFile.get(shopifyDtsPath) ?? []) + + // Then + expect(types).toHaveLength(1) + const typeDefinition = types[0]! + expect(typeDefinition).toContain('interface CreateApplicationEmailIntentInput') + expect(typeDefinition).toContain('interface CreateApplicationEmailIntentRequest') + expect(typeDefinition).toContain(`action: 'create';`) + expect(typeDefinition).toContain(`type: 'application/email';`) + expect(typeDefinition).not.toContain('interface ShopifyGeneratedIntentResponse') + expect(typeDefinition).not.toContain('interface ShopifyGeneratedIntentsApi<') + expect(typeDefinition).toContain('type ShopifyGeneratedIntentVariants =') + expect(typeDefinition).toContain("import('@shopify/ui-extensions/admin').ShopifyGeneratedIntentVariant<") + expect(typeDefinition).toContain('CreateApplicationEmailIntentRequest') + expect(typeDefinition).toContain('CreateApplicationEmailIntentOutput') + expect(typeDefinition).toContain("import('@shopify/ui-extensions/admin').WithGeneratedIntents<") + }) + }) + + test('generates intent types only for entry point file, not for imported files', async () => { + const typeDefinitionsByFile = new Map>() + + await inTemporaryDirectory(async (tmpDir) => { + const {extension} = await setupUIExtensionWithNodeModules({ + tmpDir, + fileContent: ` + import './utils/helper.js'; + // Main extension code + `, + apiVersion: '2025-10', + target: 'admin.app.intent.render', + }) + + const utilsDir = joinPath(tmpDir, 'src', 'utils') + await mkdir(utilsDir) + await writeFile(joinPath(utilsDir, 'helper.js'), 'export const helper = () => {};') + + const intentSchemaContent = JSON.stringify({ + inputSchema: { + type: 'object', + properties: { + recipient: {type: 'string'}, + }, + }, + }) + await writeFile(joinPath(tmpDir, 'intent-schema.json'), intentSchemaContent) + ;(extension.configuration.extension_points[0] as any).intents = [ + { + action: 'create', + type: 'application/email', + schema: './intent-schema.json', + }, + ] + + await writeFile(joinPath(tmpDir, 'tsconfig.json'), '{}') + + // When + await extension.contributeToSharedTypeFile?.(typeDefinitionsByFile) + + const shopifyDtsPath = joinPath(tmpDir, 'shopify.d.ts') + const types = Array.from(typeDefinitionsByFile.get(shopifyDtsPath) ?? []) + + // Then - should have 2 type definitions (entry point and helper) + expect(types).toHaveLength(2) + + const entryPointType = types.find((t) => t.includes('./src/index.jsx')) + expect(entryPointType).toContain('ShopifyGeneratedIntentVariants') + expect(entryPointType).toContain('CreateApplicationEmailIntentRequest') + expect(entryPointType).toContain("import('@shopify/ui-extensions/admin').WithGeneratedIntents<") + + const helperType = types.find((t) => t.includes('./src/utils/helper.js')) + expect(helperType).not.toContain('ShopifyGeneratedIntentVariants') + expect(helperType).not.toContain('CreateApplicationEmailIntentRequest') + }) + }) + + test('generates intent types from an intent schema file that declares a value schema', async () => { + const typeDefinitionsByFile = new Map>() + + await inTemporaryDirectory(async (tmpDir) => { + const {extension} = await setupUIExtensionWithNodeModules({ + tmpDir, + fileContent: '// Extension code', + apiVersion: '2025-10', + target: 'admin.app.intent.render', + }) + + // Given an intent schema file that declares a root-level `value` schema + const intentSchemaContent = JSON.stringify({ + value: { + type: 'object', + properties: { + productId: {type: 'string'}, + }, + required: ['productId'], + }, + inputSchema: { + type: 'object', + properties: { + title: {type: 'string'}, + }, + }, + outputSchema: { + type: 'object', + properties: { + id: {type: 'string'}, + }, + }, + }) + await writeFile(joinPath(tmpDir, 'intent-schema.json'), intentSchemaContent) + ;(extension.configuration.extension_points[0] as any).intents = [ + { + action: 'edit', + type: 'shopify/Product', + schema: './intent-schema.json', + }, + ] + + await writeFile(joinPath(tmpDir, 'tsconfig.json'), '{}') + + // When + await extension.contributeToSharedTypeFile?.(typeDefinitionsByFile) + + const shopifyDtsPath = joinPath(tmpDir, 'shopify.d.ts') + const types = Array.from(typeDefinitionsByFile.get(shopifyDtsPath) ?? []) + + // Then - the value schema is compiled into EditShopifyProductIntentValue + // and wired through the request type. + expect(types).toHaveLength(1) + const typeDefinition = types[0]! + expect(typeDefinition).toContain('interface EditShopifyProductIntentValue') + expect(typeDefinition).toContain('productId: string;') + expect(typeDefinition).toContain('value?: EditShopifyProductIntentValue;') + // Sanity: the value type is not the `unknown` fallback used when no schema is provided. + expect(typeDefinition).not.toContain('type EditShopifyProductIntentValue = unknown') + }) + }) }) }) diff --git a/packages/app/src/cli/models/extensions/specifications/ui_extension.ts b/packages/app/src/cli/models/extensions/specifications/ui_extension.ts index 815ab53399..93f4b305de 100644 --- a/packages/app/src/cli/models/extensions/specifications/ui_extension.ts +++ b/packages/app/src/cli/models/extensions/specifications/ui_extension.ts @@ -1,9 +1,11 @@ import { findAllImportedFiles, + createIntentsTypeDefinition, createTypeDefinition, + createToolsTypeDefinition, findNearestTsConfigDir, + IntentSchemaFileSchema, parseApiVersion, - createToolsTypeDefinition, ToolsFileSchema, } from './type-generation.js' import {Asset, AssetIdentifier, BuildAsset, ExtensionFeature, createExtensionSpecification} from '../specification.js' @@ -32,6 +34,8 @@ export interface BuildManifest { } } +type GeneratedIntentTypeDefinition = Parameters[0][number] + const missingExtensionPointsMessage = 'No extension targets defined, add a `targeting` field to your configuration' type UIExtensionConfigType = zod.infer @@ -195,6 +199,7 @@ const uiExtensionSpec = createExtensionSpecification({ // Track all files and their associated targets const fileToTargetsMap = new Map() const fileToToolsMap = new Map() + const fileToIntentsMap = new Map>() // First pass: collect all entry point files and their targets for await (const extensionPoint of configuration.extension_points) { @@ -211,6 +216,12 @@ const uiExtensionSpec = createExtensionSpecification({ if (extensionPoint.tools) { fileToToolsMap.set(fullPath, extensionPoint.tools) } + // Add intent schema files if present + if (extensionPoint.intents?.length) { + const currentIntents: NonNullable = fileToIntentsMap.get(fullPath) ?? [] + currentIntents.push(...extensionPoint.intents) + fileToIntentsMap.set(fullPath, currentIntents) + } // Add should render module if present if (extensionPoint.build_manifest.assets[AssetIdentifier.ShouldRender]?.module) { const shouldRenderPath = joinPath( @@ -275,21 +286,11 @@ const uiExtensionSpec = createExtensionSpecification({ let toolsTypeDefinition = '' if (toolsDefinition) { try { - const toolsFilePath = joinPath(extension.directory, toolsDefinition) - if (await fileExists(toolsFilePath)) { - // Read and parse the tools JSON file - const toolsContent = await readFile(toolsFilePath) - const tools = ToolsFileSchema.safeParse(JSON.parse(toolsContent)) - if (tools.success) { - // Generate tools type definition - toolsTypeDefinition = await createToolsTypeDefinition(tools.data) - } else { - outputWarn( - `Invalid tools definition in "${toolsDefinition}": ${tools.error.issues - .map((issue) => issue.message) - .join(', ')}`, - ) - } + const tools = await readAndValidateJsonAsset(extension.directory, toolsDefinition, ToolsFileSchema) + if (tools.status === 'ok') { + toolsTypeDefinition = await createToolsTypeDefinition(tools.data) + } else if (tools.status === 'invalid') { + outputWarn(`Invalid tools definition in "${toolsDefinition}": ${tools.issues}`) } // eslint-disable-next-line no-catch-all/no-catch-all } catch (error) { @@ -300,12 +301,33 @@ const uiExtensionSpec = createExtensionSpecification({ ) } } + + const intentsDefinitions = fileToIntentsMap.get(filePath) + let intentsTypeDefinition = '' + if (intentsDefinitions?.length) { + const parsedIntents = await parseIntentTypeDefinitions(extension.directory, intentsDefinitions) + + if (parsedIntents.length > 0) { + try { + intentsTypeDefinition = await createIntentsTypeDefinition(parsedIntents) + // eslint-disable-next-line no-catch-all/no-catch-all + } catch (error) { + outputWarn( + `Failed to create intent type definition for intent schema files "${intentsDefinitions + .map((intent) => intent.schema) + .join(', ')}": ${error instanceof Error ? error.message : 'Unknown error'}`, + ) + } + } + } + let typeDefinition = createTypeDefinition({ fullPath: filePath, typeFilePath, targets: uniqueTargets, apiVersion: configuration.api_version, toolsTypeDefinition, + intentsTypeDefinition, }) if (typeDefinition) { const currentTypes = typeDefinitionsByFile.get(typeFilePath) ?? new Set() @@ -350,6 +372,73 @@ function addDistPathToAssets(extP: NewExtensionPointSchemaType & {build_manifest } } +type JsonAssetResult = {status: 'ok'; data: T} | {status: 'missing'} | {status: 'invalid'; issues: string} + +async function readAndValidateJsonAsset( + extensionDirectory: string, + relativePath: string, + schema: zod.ZodType, +): Promise> { + const filePath = joinPath(extensionDirectory, relativePath) + const exists = await fileExists(filePath) + if (!exists) return {status: 'missing'} + + const content = await readFile(filePath) + const parsed = schema.safeParse(JSON.parse(content)) + if (!parsed.success) { + return { + status: 'invalid', + issues: parsed.error.issues.map((issue) => issue.message).join(', '), + } + } + + return {status: 'ok', data: parsed.data} +} + +async function parseIntentTypeDefinitions( + extensionDirectory: string, + intents: NonNullable, +): Promise { + const parsedIntentDefinitions = await Promise.all( + intents.map(async (intent) => { + try { + const intentSchema = await readAndValidateJsonAsset(extensionDirectory, intent.schema, IntentSchemaFileSchema) + if (intentSchema.status === 'missing') return null + + if (intentSchema.status === 'invalid') { + outputWarn(`Invalid intent schema in "${intent.schema}": ${intentSchema.issues}`) + return null + } + + return { + action: intent.action, + type: intent.type, + inputSchema: intentSchema.data.inputSchema, + valueSchema: intentSchema.data.value, + outputSchema: intentSchema.data.outputSchema, + } + // eslint-disable-next-line no-catch-all/no-catch-all + } catch (error) { + outputWarn( + `Failed to create intent type definition for intent schema file "${intent.schema}": ${ + error instanceof Error ? error.message : 'Unknown error' + }`, + ) + return null + } + }), + ) + + const parsedIntents: GeneratedIntentTypeDefinition[] = [] + for (const parsedIntentDefinition of parsedIntentDefinitions) { + if (parsedIntentDefinition) { + parsedIntents.push(parsedIntentDefinition) + } + } + + return parsedIntents +} + async function checkForMissingPath( directory: string, assetModule: string | undefined,