diff --git a/packages/app/package.json b/packages/app/package.json index 58cf9c32c2a..2aaf9a9a3db 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -68,6 +68,8 @@ "h3": "0.7.21", "http-proxy-node16": "1.0.6", "ignore": "6.0.2", + "json-schema-to-typescript": "15.0.4", + "prettier": "2.8.8", "proper-lockfile": "4.1.2", "react": "^18.2.0", "react-dom": "18.3.1", @@ -78,6 +80,7 @@ "@types/body-parser": "^1.19.2", "@types/diff": "^5.0.3", "@types/express": "^4.17.17", + "@types/prettier": "^2.7.3", "@types/proper-lockfile": "4.1.4", "@types/react": "^18.2.0", "@types/react-dom": "^18.2.0", diff --git a/packages/app/src/cli/api/graphql/business-platform-organizations/generated/types.d.ts b/packages/app/src/cli/api/graphql/business-platform-organizations/generated/types.d.ts index af5f0d3a1b7..48f87ac5f97 100644 --- a/packages/app/src/cli/api/graphql/business-platform-organizations/generated/types.d.ts +++ b/packages/app/src/cli/api/graphql/business-platform-organizations/generated/types.d.ts @@ -84,4 +84,4 @@ export type OrganizationUserProvisionShopAccessInput = { shopifyShopId: Scalars['PropertyPublicID']['input'] } -export type Store = 'APP_DEVELOPMENT' | 'DEVELOPMENT' | 'DEVELOPMENT_SUPERSET' | 'PRODUCTION' +export type Store = 'APP_DEVELOPMENT' | 'CLIENT_TRANSFER' | 'DEVELOPMENT' | 'DEVELOPMENT_SUPERSET' | 'PRODUCTION' 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 new file mode 100644 index 00000000000..811a5efe2a9 --- /dev/null +++ b/packages/app/src/cli/models/extensions/specifications/type-generation.test.ts @@ -0,0 +1,298 @@ +import {createToolsTypeDefinition} from './type-generation.js' +import {AbortError} from '@shopify/cli-kit/node/error' +import {describe, expect, test} from 'vitest' + +describe('createToolsTypeDefinition', () => { + test('returns empty string when tools array is empty', async () => { + // When + const result = await createToolsTypeDefinition([]) + + // Then + expect(result).toBe('') + }) + + test('generates type definitions for a single tool with input and output schemas', async () => { + // Given + const tools = [ + { + name: 'get_product', + description: 'Gets a product by ID', + inputSchema: { + type: 'object', + properties: { + productId: {type: 'string'}, + }, + required: ['productId'], + }, + outputSchema: { + type: 'object', + properties: { + title: {type: 'string'}, + price: {type: 'number'}, + }, + }, + }, + ] + + // When + const result = await createToolsTypeDefinition(tools) + + // Then + expect(result).toContain('interface GetProductInput') + expect(result).toContain('productId: string') + expect(result).toContain('interface GetProductOutput') + expect(result).toContain('title?: string') + expect(result).toContain('price?: number') + expect(result).toContain('interface ShopifyTools') + expect(result).toContain("register(name: 'get_product'") + expect(result).toContain('handler: (input: GetProductInput) => GetProductOutput | Promise') + expect(result).toContain('* Gets a product by ID') + }) + + test('generates unknown type when outputSchema is not provided', async () => { + // Given + const tools = [ + { + name: 'simple_action', + description: 'A simple action', + inputSchema: { + type: 'object', + properties: { + data: {type: 'string'}, + }, + }, + }, + ] + + // When + const result = await createToolsTypeDefinition(tools) + + // Then + expect(result).toContain('interface SimpleActionInput') + expect(result).toContain('type SimpleActionOutput = unknown') + }) + + test('generates type definitions for multiple tools', async () => { + // Given + const tools = [ + { + name: 'tool_one', + description: 'First tool', + inputSchema: { + type: 'object', + properties: { + arg1: {type: 'string'}, + }, + }, + outputSchema: { + type: 'object', + properties: { + result1: {type: 'string'}, + }, + }, + }, + { + name: 'tool_two', + description: 'Second tool', + inputSchema: { + type: 'object', + properties: { + arg2: {type: 'number'}, + }, + }, + outputSchema: { + type: 'object', + properties: { + result2: {type: 'boolean'}, + }, + }, + }, + ] + + // When + const result = await createToolsTypeDefinition(tools) + + // Then + expect(result).toContain('interface ToolOneInput') + expect(result).toContain('interface ToolOneOutput') + expect(result).toContain('interface ToolTwoInput') + expect(result).toContain('interface ToolTwoOutput') + expect(result).toContain("register(name: 'tool_one'") + expect(result).toContain("register(name: 'tool_two'") + }) + + test('throws AbortError when tool names are duplicated', async () => { + // Given + const tools = [ + { + name: 'duplicate_tool', + description: 'First instance', + inputSchema: {type: 'object'}, + }, + { + name: 'duplicate_tool', + description: 'Second instance', + inputSchema: {type: 'object'}, + }, + ] + + // When & Then + await expect(createToolsTypeDefinition(tools)).rejects.toThrow( + new AbortError( + 'Tool name "duplicate_tool" is defined multiple times. Tool names must be unique within a tools file.', + ), + ) + }) + + test('escapes closing comment markers in descriptions', async () => { + // Given + const tools = [ + { + name: 'tool_with_special_desc', + description: 'This description contains */ which could break comments', + inputSchema: {type: 'object'}, + }, + ] + + // When + const result = await createToolsTypeDefinition(tools) + + // Then + // The */ sequence should be escaped to *\/ to prevent breaking JSDoc comments + expect(result).toContain('This description contains *\\/ which could break comments') + }) + + test('handles multi-line descriptions', async () => { + // Given + const tools = [ + { + name: 'documented_tool', + description: 'Line one\nLine two\nLine three', + inputSchema: {type: 'object'}, + }, + ] + + // When + const result = await createToolsTypeDefinition(tools) + + // Then + expect(result).toContain('* Line one') + expect(result).toContain('* Line two') + expect(result).toContain('* Line three') + }) + + test('converts multi-segment snake_case tool names to PascalCase type names', async () => { + // Given + const tools = [ + { + name: 'my_snake_case_tool', + description: 'A tool with snake case name', + inputSchema: {type: 'object'}, + outputSchema: {type: 'object'}, + }, + ] + + // When + const result = await createToolsTypeDefinition(tools) + + // Then + expect(result).toContain('MySnakeCaseToolInput') + expect(result).toContain('MySnakeCaseToolOutput') + }) + + test('removes export keyword from generated types', async () => { + // Given + const tools = [ + { + name: 'test_tool', + description: 'Test tool', + inputSchema: { + type: 'object', + properties: { + id: {type: 'string'}, + }, + }, + outputSchema: { + type: 'object', + properties: { + result: {type: 'string'}, + }, + }, + }, + ] + + // When + const result = await createToolsTypeDefinition(tools) + + // Then + // The json-schema-to-typescript library adds "export " prefix, which should be removed + expect(result).not.toMatch(/^export /m) + }) + + test('handles complex nested schemas', async () => { + // Given + const tools = [ + { + name: 'complex_tool', + description: 'A tool with nested schema', + inputSchema: { + type: 'object', + properties: { + user: { + type: 'object', + properties: { + name: {type: 'string'}, + email: {type: 'string'}, + }, + required: ['name'], + }, + tags: { + type: 'array', + items: {type: 'string'}, + }, + }, + required: ['user'], + }, + outputSchema: { + type: 'object', + properties: { + success: {type: 'boolean'}, + data: { + type: 'object', + properties: { + id: {type: 'number'}, + }, + }, + }, + }, + }, + ] + + // When + const result = await createToolsTypeDefinition(tools) + + // Then + expect(result).toContain('interface ComplexToolInput') + expect(result).toContain('interface ComplexToolOutput') + expect(result).toContain('user:') + expect(result).toContain('tags?:') + }) + + test('preserves tool name exactly in register method', async () => { + // Given + const tools = [ + { + name: 'get-product-info', + description: 'Gets product info', + inputSchema: {type: 'object'}, + }, + ] + + // When + const result = await createToolsTypeDefinition(tools) + + // Then + // Tool name should be preserved as-is in the register method + expect(result).toContain("register(name: 'get-product-info'") + }) +}) 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 f75ea940be3..28ca5deb934 100644 --- a/packages/app/src/cli/models/extensions/specifications/type-generation.ts +++ b/packages/app/src/cli/models/extensions/specifications/type-generation.ts @@ -2,6 +2,9 @@ import {fileExists, findPathUp, readFileSync} from '@shopify/cli-kit/node/fs' import {dirname, joinPath, relativizePath, resolvePath} from '@shopify/cli-kit/node/path' import {AbortError} from '@shopify/cli-kit/node/error' import ts from 'typescript' +import {compile} from 'json-schema-to-typescript' +import {pascalize} from '@shopify/cli-kit/common/string' +import {zod} from '@shopify/cli-kit/node/schema' import {createRequire} from 'module' const require = createRequire(import.meta.url) @@ -154,12 +157,41 @@ export async function findAllImportedFiles(filePath: string, visited = new Set 1) { + const unionType = targets.map((target) => `import('@shopify/ui-extensions/${target}').Api`).join(' | ') + return `(${unionType})${toolsSuffix}` + } + + return null +} + +export function createTypeDefinition({ + fullPath, + typeFilePath, + targets, + apiVersion, + toolsTypeDefinition, +}: CreateTypeDefinitionOptions): string | null { try { // Validate that all targets can be resolved for (const target of targets) { @@ -177,15 +209,20 @@ export function createTypeDefinition( const relativePath = relativizePath(fullPath, dirname(typeFilePath)) - if (targets.length === 1) { - const target = targets[0] ?? '' - return `//@ts-ignore\ndeclare module './${relativePath}' {\n const shopify: import('@shopify/ui-extensions/${target}').Api;\n const globalThis: { shopify: typeof shopify };\n}\n` - } else if (targets.length > 1) { - const unionType = targets.map((target) => ` import('@shopify/ui-extensions/${target}').Api`).join(' |\n') - return `//@ts-ignore\ndeclare module './${relativePath}' {\n const shopify: \n${unionType};\n const globalThis: { shopify: typeof shopify };\n}\n` - } + const shopifyType = buildShopifyType(targets, toolsTypeDefinition) + if (!shopifyType) return null - return null + const lines = [ + '//@ts-ignore', + `declare module './${relativePath}' {`, + ...(toolsTypeDefinition ? [` ${toolsTypeDefinition}`] : []), + ` const shopify: ${shopifyType};`, + ' const globalThis: { shopify: typeof shopify };', + '}', + '', + ] + + return lines.join('\n') } catch (error) { // Re-throw AbortError as-is, wrap other errors if (error instanceof AbortError) { @@ -216,3 +253,90 @@ export async function findNearestTsConfigDir( } } } + +interface ToolDefinition { + name: string + description: string + inputSchema: object + outputSchema?: object +} +const ToolDefinitionSchema: zod.ZodType = zod.object({ + name: zod.string(), + description: zod.string(), + inputSchema: zod.object({}).passthrough(), + outputSchema: zod.object({}).passthrough().optional(), +}) + +export const ToolsFileSchema = zod.array(ToolDefinitionSchema) + +/** + * Generates TypeScript types for shopify.tools.register based on tool definitions + * @param tools - Array of tool definitions from tools.json + * @returns TypeScript declaration string + */ +export async function createToolsTypeDefinition(tools: ToolDefinition[]): Promise { + if (tools.length === 0) return '' + + const toolNames = new Set() + const typePromises = tools.map(async (tool) => { + // Tool names must be unique within a tools file + if (toolNames.has(tool.name)) { + throw new AbortError( + `Tool name "${tool.name}" is defined multiple times. Tool names must be unique within a tools file.`, + ) + } + toolNames.add(tool.name) + + // Generate input type definition + const inputTypeName = pascalize(`${tool.name}Input`) + let inputType = tool.inputSchema + ? await compile(tool.inputSchema, inputTypeName, { + bannerComment: '', + unknownAny: true, + }) + : `type ${inputTypeName} = unknown` + // The json-schema-to-typescript library adds an export keyword to the type definition, we need to remove it + if (inputType.startsWith('export ')) { + inputType = inputType.slice(7) + } + + // Generate output type definition + const outputTypeName = pascalize(`${tool.name}Output`) + let outputType = tool.outputSchema + ? await compile(tool.outputSchema, outputTypeName, { + bannerComment: '', + unknownAny: true, + }) + : `type ${outputTypeName} = unknown` + // The json-schema-to-typescript library adds an export keyword to the type definition, we need to remove it + if (outputType.startsWith('export ')) { + outputType = outputType.slice(7) + } + + return { + name: tool.name, + description: tool.description, + inputType, + outputType, + inputTypeName, + outputTypeName, + } + }) + + const types = await Promise.all(typePromises) + + const toolRegistrations = types + .map(({name, description, inputTypeName, outputTypeName}) => { + const formattedDescription = description + .replace(/\*\//g, '*\\/') + .split('\n') + .map((line) => ` * ${line}`) + .join('\n') + return `/**\n${formattedDescription}\n */\nregister(name: '${name}', handler: (input: ${inputTypeName}) => ${outputTypeName} | Promise<${outputTypeName}>);` + }) + .join('\n') + + return `${types + .map(({inputType, outputType}) => `${inputType}\n${outputType}`) + .join('\n')}\ninterface ShopifyTools {\n${toolRegistrations}\n}\n` +} 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 aac1823045e..77f968a273e 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 @@ -148,6 +148,7 @@ describe('ui_extension', async () => { expect(got.extension_points).toStrictEqual([ { target: 'EXTENSION::POINT::A', + tools: undefined, module: './src/ExtensionPointA.js', metafields: [{namespace: 'test', key: 'test'}], default_placement_reference: undefined, @@ -213,6 +214,7 @@ describe('ui_extension', async () => { expect(got.extension_points).toStrictEqual([ { target: 'EXTENSION::POINT::A', + tools: undefined, module: './src/ExtensionPointA.js', metafields: [], default_placement_reference: 'PLACEMENT_REFERENCE1', @@ -274,6 +276,7 @@ describe('ui_extension', async () => { expect(got.extension_points).toStrictEqual([ { target: 'EXTENSION::POINT::A', + tools: undefined, module: './src/ExtensionPointA.js', metafields: [], urls: {}, @@ -335,6 +338,7 @@ describe('ui_extension', async () => { expect(got.extension_points).toStrictEqual([ { target: 'EXTENSION::POINT::A', + tools: undefined, module: './src/ExtensionPointA.js', metafields: [], default_placement_reference: undefined, @@ -399,6 +403,7 @@ describe('ui_extension', async () => { expect(got.extension_points).toStrictEqual([ { target: 'EXTENSION::POINT::A', + tools: undefined, module: './src/ExtensionPointA.js', metafields: [], default_placement_reference: undefined, @@ -465,6 +470,7 @@ describe('ui_extension', async () => { expect(got.extension_points).toStrictEqual([ { target: 'EXTENSION::POINT::A', + tools: undefined, module: './src/ExtensionPointA.js', metafields: [], default_placement_reference: undefined, @@ -1211,7 +1217,7 @@ Please check the configuration in ${uiExtension.configurationPath}`), // Then - should generate union type for shared module expect(Array.from(types ?? [])).toContain( - `//@ts-ignore\ndeclare module './shared/utils.js' {\n const shopify: \n import('@shopify/ui-extensions/admin.product-details.action.render').Api |\n import('@shopify/ui-extensions/admin.orders-details.block.render').Api;\n const globalThis: { shopify: typeof shopify };\n}\n`, + `//@ts-ignore\ndeclare module './shared/utils.js' {\n const shopify:\n | import('@shopify/ui-extensions/admin.product-details.action.render').Api\n | import('@shopify/ui-extensions/admin.orders-details.block.render').Api;\n const globalThis: { shopify: typeof shopify };\n}\n`, ) }) }) @@ -1272,7 +1278,7 @@ Please check the configuration in ${uiExtension.configurationPath}`), // Then - should generate union types for shared files // when targets are from different surfaces (admin vs checkout) expect(types).toContain( - `//@ts-ignore\ndeclare module './src/components/Shared.jsx' {\n const shopify: \n import('@shopify/ui-extensions/admin.product-details.action.render').Api |\n import('@shopify/ui-extensions/purchase.checkout.block.render').Api;\n const globalThis: { shopify: typeof shopify };\n}\n`, + `//@ts-ignore\ndeclare module './src/components/Shared.jsx' {\n const shopify:\n | import('@shopify/ui-extensions/admin.product-details.action.render').Api\n | import('@shopify/ui-extensions/purchase.checkout.block.render').Api;\n const globalThis: { shopify: typeof shopify };\n}\n`, ) }) }) @@ -1687,5 +1693,284 @@ Please check the configuration in ${uiExtension.configurationPath}`), expect(types).toHaveLength(3) }) }) + + test('generates shopify.d.ts with ShopifyTools interface when tools file is present', async () => { + const typeDefinitionsByFile = new Map>() + + await inTemporaryDirectory(async (tmpDir) => { + const {extension} = await setupUIExtensionWithNodeModules({ + tmpDir, + fileContent: '// Extension code', + apiVersion: '2025-10', + }) + + // Create tools.json file + const toolsContent = JSON.stringify([ + { + name: 'search_products', + description: 'Search for products by query', + inputSchema: { + type: 'object', + properties: { + query: {type: 'string'}, + }, + required: ['query'], + }, + outputSchema: { + type: 'object', + properties: { + products: {type: 'array', items: {type: 'string'}}, + }, + }, + }, + ]) + await writeFile(joinPath(tmpDir, 'tools.json'), toolsContent) + + // Update extension configuration to include tools + ;(extension.configuration.extension_points[0] as any).tools = './tools.json' + + // Create tsconfig.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 include ShopifyTools interface and tool type definitions + expect(types).toHaveLength(1) + const typeDefinition = types[0]! + expect(typeDefinition).toContain('interface ShopifyTools') + expect(typeDefinition).toContain('interface SearchProductsInput') + expect(typeDefinition).toContain('interface SearchProductsOutput') + expect(typeDefinition).toContain("name: 'search_products'") + expect(typeDefinition).toContain('tools: ShopifyTools') + }) + }) + + test('generates shopify.d.ts with multiple tools in ShopifyTools interface', async () => { + const typeDefinitionsByFile = new Map>() + + await inTemporaryDirectory(async (tmpDir) => { + const {extension} = await setupUIExtensionWithNodeModules({ + tmpDir, + fileContent: '// Extension code', + apiVersion: '2025-10', + }) + + // Create tools.json file with multiple tools + const toolsContent = JSON.stringify([ + { + name: 'get_product', + description: 'Get product by ID', + inputSchema: { + type: 'object', + properties: { + productId: {type: 'string'}, + }, + required: ['productId'], + }, + outputSchema: { + type: 'object', + properties: { + title: {type: 'string'}, + price: {type: 'number'}, + }, + }, + }, + { + name: 'update_inventory', + description: 'Update inventory count', + inputSchema: { + type: 'object', + properties: { + sku: {type: 'string'}, + quantity: {type: 'number'}, + }, + required: ['sku', 'quantity'], + }, + }, + ]) + await writeFile(joinPath(tmpDir, 'tools.json'), toolsContent) + + // Update extension configuration to include tools + ;(extension.configuration.extension_points[0] as any).tools = './tools.json' + + // Create tsconfig.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 include type definitions for both tools + expect(types).toHaveLength(1) + const typeDefinition = types[0]! + expect(typeDefinition).toContain('interface ShopifyTools') + expect(typeDefinition).toContain('interface GetProductInput') + expect(typeDefinition).toContain('interface GetProductOutput') + expect(typeDefinition).toContain("name: 'get_product'") + expect(typeDefinition).toContain('interface UpdateInventoryInput') + expect(typeDefinition).toContain('type UpdateInventoryOutput = unknown') + expect(typeDefinition).toContain("name: 'update_inventory'") + }) + }) + + test('does not include ShopifyTools when tools file does not exist', async () => { + const typeDefinitionsByFile = new Map>() + + await inTemporaryDirectory(async (tmpDir) => { + const {extension} = await setupUIExtensionWithNodeModules({ + tmpDir, + fileContent: '// Extension code', + apiVersion: '2025-10', + }) + + // Update extension configuration to reference a non-existent tools file + ;(extension.configuration.extension_points[0] as any).tools = './non-existent-tools.json' + + // Create tsconfig.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 generate type definition without ShopifyTools + expect(types).toHaveLength(1) + const typeDefinition = types[0]! + expect(typeDefinition).not.toContain('ShopifyTools') + expect(typeDefinition).toContain("import('@shopify/ui-extensions/admin.product-details.action.render').Api") + }) + }) + + test('does not include ShopifyTools when tools file has invalid JSON', async () => { + const typeDefinitionsByFile = new Map>() + + await inTemporaryDirectory(async (tmpDir) => { + const {extension} = await setupUIExtensionWithNodeModules({ + tmpDir, + fileContent: '// Extension code', + apiVersion: '2025-10', + }) + + // Create invalid tools.json file + await writeFile(joinPath(tmpDir, 'tools.json'), 'not valid json {{{') + + // Update extension configuration to include tools + ;(extension.configuration.extension_points[0] as any).tools = './tools.json' + + // Create tsconfig.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 generate type definition without ShopifyTools (graceful fallback) + expect(types).toHaveLength(1) + const typeDefinition = types[0]! + expect(typeDefinition).not.toContain('ShopifyTools') + }) + }) + + test('does not include ShopifyTools when tools file has invalid schema', async () => { + const typeDefinitionsByFile = new Map>() + + await inTemporaryDirectory(async (tmpDir) => { + const {extension} = await setupUIExtensionWithNodeModules({ + tmpDir, + fileContent: '// Extension code', + apiVersion: '2025-10', + }) + + // Create tools.json with invalid schema (missing required fields) + const invalidToolsContent = JSON.stringify([ + { + name: 'incomplete_tool', + // missing description and inputSchema + }, + ]) + await writeFile(joinPath(tmpDir, 'tools.json'), invalidToolsContent) + + // Update extension configuration to include tools + ;(extension.configuration.extension_points[0] as any).tools = './tools.json' + + // Create tsconfig.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 generate type definition without ShopifyTools (graceful fallback) + expect(types).toHaveLength(1) + const typeDefinition = types[0]! + expect(typeDefinition).not.toContain('ShopifyTools') + }) + }) + + test('generates ShopifyTools 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', + }) + + // Create imported file + const utilsDir = joinPath(tmpDir, 'src', 'utils') + await mkdir(utilsDir) + await writeFile(joinPath(utilsDir, 'helper.js'), 'export const helper = () => {};') + + // Create tools.json file + const toolsContent = JSON.stringify([ + { + name: 'my_tool', + description: 'A tool', + inputSchema: {type: 'object'}, + }, + ]) + await writeFile(joinPath(tmpDir, 'tools.json'), toolsContent) + + // Update extension configuration to include tools + ;(extension.configuration.extension_points[0] as any).tools = './tools.json' + + // Create tsconfig.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) + + // Entry point should have ShopifyTools + const entryPointType = types.find((t) => t.includes('./src/index.jsx')) + expect(entryPointType).toContain('ShopifyTools') + expect(entryPointType).toContain('tools: ShopifyTools') + + // Imported file should NOT have ShopifyTools + const helperType = types.find((t) => t.includes('./src/utils/helper.js')) + expect(helperType).not.toContain('ShopifyTools') + }) + }) }) }) 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 637c2cba1ee..90760e667ef 100644 --- a/packages/app/src/cli/models/extensions/specifications/ui_extension.ts +++ b/packages/app/src/cli/models/extensions/specifications/ui_extension.ts @@ -1,13 +1,21 @@ -import {findAllImportedFiles, createTypeDefinition, findNearestTsConfigDir, parseApiVersion} from './type-generation.js' +import { + findAllImportedFiles, + createTypeDefinition, + findNearestTsConfigDir, + parseApiVersion, + createToolsTypeDefinition, + ToolsFileSchema, +} from './type-generation.js' import {Asset, AssetIdentifier, ExtensionFeature, createExtensionSpecification} from '../specification.js' import {NewExtensionPointSchemaType, NewExtensionPointsSchema, BaseSchema, MetafieldSchema} from '../schemas.js' import {loadLocalesConfig} from '../../../utilities/extensions/locales-configuration.js' import {getExtensionPointTargetSurface} from '../../../services/dev/extension/utilities.js' import {ExtensionInstance} from '../extension-instance.js' +import {formatContent} from '../../../utilities/file-formatter.js' import {err, ok, Result} from '@shopify/cli-kit/node/result' -import {copyFile, fileExists} from '@shopify/cli-kit/node/fs' +import {copyFile, fileExists, readFile} from '@shopify/cli-kit/node/fs' import {joinPath, basename, dirname} from '@shopify/cli-kit/node/path' -import {outputContent, outputToken} from '@shopify/cli-kit/node/output' +import {outputContent, outputToken, outputWarn} from '@shopify/cli-kit/node/output' import {zod} from '@shopify/cli-kit/node/schema' const dependency = '@shopify/checkout-ui-extensions' @@ -71,6 +79,7 @@ export const UIExtensionSchema = BaseSchema.extend({ } return { + tools: targeting.tools, target: targeting.target, module: targeting.module, metafields: targeting.metafields ?? config.metafields ?? [], @@ -194,6 +203,7 @@ const uiExtensionSpec = createExtensionSpecification({ // Track all files and their associated targets const fileToTargetsMap = new Map() + const fileToToolsMap = new Map() // First pass: collect all entry point files and their targets for await (const extensionPoint of configuration.extension_points) { @@ -206,6 +216,10 @@ const uiExtensionSpec = createExtensionSpecification({ currentTargets.push(extensionPoint.target) fileToTargetsMap.set(fullPath, currentTargets) + // Add tools module if present + if (extensionPoint.tools) { + fileToToolsMap.set(fullPath, extensionPoint.tools) + } // Add should render module if present if (extensionPoint.build_manifest.assets[AssetIdentifier.ShouldRender]?.module) { const shouldRenderPath = joinPath( @@ -266,9 +280,47 @@ const uiExtensionSpec = createExtensionSpecification({ const uniqueTargets = [...new Set(targets)] try { - const typeDefinition = createTypeDefinition(filePath, typeFilePath, uniqueTargets, configuration.api_version) + const toolsDefinition = fileToToolsMap.get(filePath) + 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(', ')}`, + ) + toolsTypeDefinition = '' + } + } + // eslint-disable-next-line no-catch-all/no-catch-all + } catch (error) { + outputWarn( + `Failed to create tools type definition for tools file "${toolsDefinition}": ${ + error instanceof Error ? error.message : 'Unknown error' + }`, + ) + toolsTypeDefinition = '' + } + } + let typeDefinition = createTypeDefinition({ + fullPath: filePath, + typeFilePath, + targets: uniqueTargets, + apiVersion: configuration.api_version, + toolsTypeDefinition, + }) if (typeDefinition) { const currentTypes = typeDefinitionsByFile.get(typeFilePath) ?? new Set() + typeDefinition = await formatContent(typeDefinition, {parser: 'typescript', singleQuote: true}) currentTypes.add(typeDefinition) typeDefinitionsByFile.set(typeFilePath, currentTypes) } diff --git a/packages/app/src/cli/utilities/file-formatter.ts b/packages/app/src/cli/utilities/file-formatter.ts new file mode 100644 index 00000000000..1aa88c0ceef --- /dev/null +++ b/packages/app/src/cli/utilities/file-formatter.ts @@ -0,0 +1,12 @@ +import prettier from 'prettier' +import type {Options} from 'prettier' + +/** + * Formats the contents of a file being generated. + * @param content - The content to format. + * @param options - The options to pass to the formatter. + * @returns The formatted content. + */ +export async function formatContent(content: string, options?: Options): Promise { + return prettier.format(content, options) +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9f236364b63..9fc1b206975 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -50,7 +50,7 @@ importers: version: 22.0.0 '@shopify/eslint-plugin-cli': specifier: file:packages/eslint-plugin-cli - version: file:packages/eslint-plugin-cli(eslint@8.57.1)(prettier@2.8.8)(typescript@5.8.3)(vitest@3.2.1(@types/node@18.19.70)(jiti@2.4.2)(jsdom@20.0.3)(msw@2.8.7(@types/node@18.19.70)(typescript@5.8.3))(sass@1.89.1)(yaml@2.8.1)) + version: file:packages/eslint-plugin-cli(eslint@8.57.1)(prettier@3.7.4)(typescript@5.8.3)(vitest@3.2.1(@types/node@18.19.70)(jiti@2.4.2)(jsdom@20.0.3)(msw@2.8.7(@types/node@18.19.70)(typescript@5.8.3))(sass@1.89.1)(yaml@2.8.1)) '@shopify/generate-docs': specifier: 0.15.6 version: 0.15.6 @@ -213,6 +213,12 @@ importers: ignore: specifier: 6.0.2 version: 6.0.2 + json-schema-to-typescript: + specifier: 15.0.4 + version: 15.0.4 + prettier: + specifier: 2.8.8 + version: 2.8.8 proper-lockfile: specifier: 4.1.2 version: 4.1.2 @@ -238,6 +244,9 @@ importers: '@types/express': specifier: ^4.17.17 version: 4.17.22 + '@types/prettier': + specifier: ^2.7.3 + version: 2.7.3 '@types/proper-lockfile': specifier: 4.1.4 version: 4.1.4 @@ -3924,6 +3933,9 @@ packages: '@types/parse-json@4.0.2': resolution: {integrity: sha512-dISoDXWWQwUquiKsyZ4Ng+HX2KsPL7LyHKHQwgGFEA3IaKac4Obd+h2a/a6waisAoepJlBcx9paWqjA8/HVjCw==} + '@types/prettier@2.7.3': + resolution: {integrity: sha512-+68kP9yzs4LMp7VNh8gdzMSPZFL44MLGqiHWvttYJe+6qnuVr4Ek9wSBQoveqY/r+LwjCcU29kNVkidwim+kYA==} + '@types/prop-types@15.7.14': resolution: {integrity: sha512-gNMvNH49DJ7OJYv+KAKn0Xp45p8PLl6zo2YnvDIbTd4J6MER2BmWN49TG7n9LvkyihINxeKW8+3bfS2yDC9dzQ==} @@ -6776,6 +6788,11 @@ packages: json-parse-even-better-errors@2.3.1: resolution: {integrity: sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==} + json-schema-to-typescript@15.0.4: + resolution: {integrity: sha512-Su9oK8DR4xCmDsLlyvadkXzX6+GGXJpbhwoLtOGArAG61dvbW4YQmSEno2y66ahpIdmLMg6YUf/QHLgiwvkrHQ==} + engines: {node: '>=16.0.0'} + hasBin: true + json-schema-traverse@0.4.1: resolution: {integrity: sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==} @@ -7758,6 +7775,11 @@ packages: engines: {node: '>=10.13.0'} hasBin: true + prettier@3.7.4: + resolution: {integrity: sha512-v6UNi1+3hSlVvv8fSaoUbggEM5VErKmmpGA7Pl3HF8V6uKY7rvClBOJlH6yNwQtfTueNkGVpOv/mtWL9L4bgRA==} + engines: {node: '>=14'} + hasBin: true + pretty-format@26.6.2: resolution: {integrity: sha512-7AeGuCYNGmycyQbCqd/3PWH4eOoX/OiCa0uphp57NVTeAGdJGaAliecxwBDHYQCIvrW7aDBZCYeNTP/WX69mkg==} engines: {node: '>= 10'} @@ -13128,10 +13150,10 @@ snapshots: dependencies: '@shopify/function-enhancers': 2.0.8 - '@shopify/eslint-plugin-cli@file:packages/eslint-plugin-cli(eslint@8.57.1)(prettier@2.8.8)(typescript@5.8.3)(vitest@3.2.1(@types/node@18.19.70)(jiti@2.4.2)(jsdom@20.0.3)(msw@2.8.7(@types/node@18.19.70)(typescript@5.8.3))(sass@1.89.1)(yaml@2.8.1))': + '@shopify/eslint-plugin-cli@file:packages/eslint-plugin-cli(eslint@8.57.1)(prettier@3.7.4)(typescript@5.8.3)(vitest@3.2.1(@types/node@18.19.70)(jiti@2.4.2)(jsdom@20.0.3)(msw@2.8.7(@types/node@18.19.70)(typescript@5.8.3))(sass@1.89.1)(yaml@2.8.1))': dependencies: '@babel/core': 7.27.4 - '@shopify/eslint-plugin': 42.1.0(@babel/core@7.27.4)(eslint@8.57.1)(prettier@2.8.8)(typescript@5.8.3) + '@shopify/eslint-plugin': 42.1.0(@babel/core@7.27.4)(eslint@8.57.1)(prettier@3.7.4)(typescript@5.8.3) '@typescript-eslint/eslint-plugin': 7.13.1(@typescript-eslint/parser@7.18.0(eslint@8.57.1)(typescript@5.8.3))(eslint@8.57.1)(typescript@5.8.3) '@typescript-eslint/parser': 7.18.0(eslint@8.57.1)(typescript@5.8.3) debug: 4.4.0(supports-color@8.1.1) @@ -13141,7 +13163,7 @@ snapshots: eslint-plugin-jsdoc: 48.11.0(eslint@8.57.1) eslint-plugin-jsx-a11y: 6.10.2(eslint@8.57.1) eslint-plugin-no-catch-all: 1.1.0(eslint@8.57.1) - eslint-plugin-prettier: 4.2.1(eslint-config-prettier@8.10.0(eslint@8.57.1))(eslint@8.57.1)(prettier@2.8.8) + eslint-plugin-prettier: 4.2.1(eslint-config-prettier@8.10.0(eslint@8.57.1))(eslint@8.57.1)(prettier@3.7.4) eslint-plugin-react: 7.37.3(eslint@8.57.1) eslint-plugin-react-hooks: 4.6.2(eslint@8.57.1) eslint-plugin-tsdoc: 0.4.0 @@ -13194,6 +13216,42 @@ snapshots: - supports-color - typescript + '@shopify/eslint-plugin@42.1.0(@babel/core@7.27.4)(eslint@8.57.1)(prettier@3.7.4)(typescript@5.8.3)': + dependencies: + '@babel/eslint-parser': 7.27.1(@babel/core@7.27.4)(eslint@8.57.1) + '@babel/eslint-plugin': 7.27.1(@babel/eslint-parser@7.27.1(@babel/core@7.27.4)(eslint@8.57.1))(eslint@8.57.1) + '@typescript-eslint/eslint-plugin': 5.62.0(@typescript-eslint/parser@5.62.0(eslint@8.57.1)(typescript@5.8.3))(eslint@8.57.1)(typescript@5.8.3) + '@typescript-eslint/parser': 5.62.0(eslint@8.57.1)(typescript@5.8.3) + change-case: 4.1.2 + common-tags: 1.8.2 + doctrine: 2.1.0 + eslint: 8.57.1 + eslint-config-prettier: 8.10.0(eslint@8.57.1) + eslint-module-utils: 2.12.0(@typescript-eslint/parser@5.62.0(eslint@8.57.1)(typescript@5.8.3))(eslint-import-resolver-node@0.3.9)(eslint@8.57.1) + eslint-plugin-eslint-comments: 3.2.0(eslint@8.57.1) + eslint-plugin-import: 2.31.0(@typescript-eslint/parser@5.62.0(eslint@8.57.1)(typescript@5.8.3))(eslint@8.57.1) + eslint-plugin-jest: 25.7.0(@typescript-eslint/eslint-plugin@5.62.0(@typescript-eslint/parser@5.62.0(eslint@8.57.1)(typescript@5.8.3))(eslint@8.57.1)(typescript@5.8.3))(eslint@8.57.1)(typescript@5.8.3) + eslint-plugin-jest-formatting: 3.1.0(eslint@8.57.1) + eslint-plugin-jsx-a11y: 6.10.2(eslint@8.57.1) + eslint-plugin-node: 11.1.0(eslint@8.57.1) + eslint-plugin-prettier: 4.2.1(eslint-config-prettier@8.10.0(eslint@8.57.1))(eslint@8.57.1)(prettier@3.7.4) + eslint-plugin-promise: 6.6.0(eslint@8.57.1) + eslint-plugin-react: 7.37.3(eslint@8.57.1) + eslint-plugin-react-hooks: 4.6.2(eslint@8.57.1) + eslint-plugin-sort-class-members: 1.21.0(eslint@8.57.1) + jsx-ast-utils: 3.3.5 + pkg-dir: 5.0.0 + pluralize: 8.0.0 + transitivePeerDependencies: + - '@babel/core' + - eslint-import-resolver-node + - eslint-import-resolver-typescript + - eslint-import-resolver-webpack + - jest + - prettier + - supports-color + - typescript + '@shopify/function-enhancers@2.0.8': {} '@shopify/function-runner@4.1.1': @@ -13880,6 +13938,8 @@ snapshots: '@types/parse-json@4.0.2': {} + '@types/prettier@2.7.3': {} + '@types/prop-types@15.7.14': {} '@types/proper-lockfile@4.1.4': @@ -15917,6 +15977,14 @@ snapshots: optionalDependencies: eslint-config-prettier: 8.10.0(eslint@8.57.1) + eslint-plugin-prettier@4.2.1(eslint-config-prettier@8.10.0(eslint@8.57.1))(eslint@8.57.1)(prettier@3.7.4): + dependencies: + eslint: 8.57.1 + prettier: 3.7.4 + prettier-linter-helpers: 1.0.0 + optionalDependencies: + eslint-config-prettier: 8.10.0(eslint@8.57.1) + eslint-plugin-promise@6.6.0(eslint@8.57.1): dependencies: eslint: 8.57.1 @@ -17316,6 +17384,18 @@ snapshots: json-parse-even-better-errors@2.3.1: {} + json-schema-to-typescript@15.0.4: + dependencies: + '@apidevtools/json-schema-ref-parser': 11.7.3 + '@types/json-schema': 7.0.15 + '@types/lodash': 4.17.19 + is-glob: 4.0.3 + js-yaml: 4.1.0 + lodash: 4.17.21 + minimist: 1.2.8 + prettier: 3.7.4 + tinyglobby: 0.2.15 + json-schema-traverse@0.4.1: {} json-schema-traverse@1.0.0: {} @@ -18306,6 +18386,8 @@ snapshots: prettier@2.8.8: {} + prettier@3.7.4: {} + pretty-format@26.6.2: dependencies: '@jest/types': 26.6.2