From 40c3b83d1221407e8daafbd3e3b3210f89c7bff0 Mon Sep 17 00:00:00 2001 From: David Cameron Date: Thu, 19 Feb 2026 14:45:32 -0500 Subject: [PATCH 1/2] Add build.typegen_command support for non-JS Shopify Functions --- .../commands/app-function-typegen.doc.ts | 4 +- .../generated/generated_docs_data.json | 4 +- .../src/cli/commands/app/function/typegen.ts | 4 +- .../models/extensions/extension-instance.ts | 5 + .../specifications/function.test.ts | 28 ++++ .../extensions/specifications/function.ts | 4 + .../src/cli/services/build/extension.test.ts | 124 +++++++++++++++++- .../app/src/cli/services/build/extension.ts | 8 +- .../src/cli/services/function/build.test.ts | 60 ++++++++- .../app/src/cli/services/function/build.ts | 13 +- packages/cli/README.md | 6 +- packages/cli/oclif.manifest.json | 6 +- 12 files changed, 250 insertions(+), 16 deletions(-) diff --git a/docs-shopify.dev/commands/app-function-typegen.doc.ts b/docs-shopify.dev/commands/app-function-typegen.doc.ts index ad0be3e5a2a..f5f3770d122 100644 --- a/docs-shopify.dev/commands/app-function-typegen.doc.ts +++ b/docs-shopify.dev/commands/app-function-typegen.doc.ts @@ -3,8 +3,8 @@ import {ReferenceEntityTemplateSchema} from '@shopify/generate-docs' const data: ReferenceEntityTemplateSchema = { name: 'app function typegen', - description: `Creates GraphQL types based on your [input query](/docs/apps/functions/input-output#input) for a function written in JavaScript.`, - overviewPreviewDescription: `Generate GraphQL types for a JavaScript function.`, + description: `Creates GraphQL types based on your [input query](/docs/apps/functions/input-output#input) for a function. Supports JavaScript functions out of the box, or any language via the \`build.typegen_command\` configuration.`, + overviewPreviewDescription: `Generate GraphQL types for a function.`, type: 'command', isVisualComponent: false, defaultExample: { diff --git a/docs-shopify.dev/generated/generated_docs_data.json b/docs-shopify.dev/generated/generated_docs_data.json index 5d51044f3a6..6eba1a1d255 100644 --- a/docs-shopify.dev/generated/generated_docs_data.json +++ b/docs-shopify.dev/generated/generated_docs_data.json @@ -2088,8 +2088,8 @@ }, { "name": "app function typegen", - "description": "Creates GraphQL types based on your [input query](/docs/apps/functions/input-output#input) for a function written in JavaScript.", - "overviewPreviewDescription": "Generate GraphQL types for a JavaScript function.", + "description": "Creates GraphQL types based on your [input query](/docs/apps/functions/input-output#input) for a function. Supports JavaScript functions out of the box, or any language via the `build.typegen_command` configuration.", + "overviewPreviewDescription": "Generate GraphQL types for a function.", "type": "command", "isVisualComponent": false, "defaultExample": { diff --git a/packages/app/src/cli/commands/app/function/typegen.ts b/packages/app/src/cli/commands/app/function/typegen.ts index 658dda0a890..9fd9a738cc4 100644 --- a/packages/app/src/cli/commands/app/function/typegen.ts +++ b/packages/app/src/cli/commands/app/function/typegen.ts @@ -7,9 +7,9 @@ import {globalFlags} from '@shopify/cli-kit/node/cli' import {renderSuccess} from '@shopify/cli-kit/node/ui' export default class FunctionTypegen extends AppUnlinkedCommand { - static summary = 'Generate GraphQL types for a JavaScript function.' + static summary = 'Generate GraphQL types for a function.' - static descriptionWithMarkdown = `Creates GraphQL types based on your [input query](https://shopify.dev/docs/apps/functions/input-output#input) for a function written in JavaScript.` + static descriptionWithMarkdown = `Creates GraphQL types based on your [input query](https://shopify.dev/docs/apps/functions/input-output#input) for a function. Supports JavaScript functions out of the box, or any language via the \`build.typegen_command\` configuration.` static description = this.descriptionWithoutMarkdown() diff --git a/packages/app/src/cli/models/extensions/extension-instance.ts b/packages/app/src/cli/models/extensions/extension-instance.ts index c51ee23bbec..61fda43e01e 100644 --- a/packages/app/src/cli/models/extensions/extension-instance.ts +++ b/packages/app/src/cli/models/extensions/extension-instance.ts @@ -289,6 +289,11 @@ export class ExtensionInstance { }) }) + test('accepts configuration with typegen_command in build section', async () => { + // Given + const configWithTypegen = { + name: 'function', + type: 'function', + metafields: [], + description: 'my function', + build: { + command: 'zig build -Doptimize=ReleaseSmall', + path: 'dist/index.wasm', + wasm_opt: true, + typegen_command: 'npx shopify-function-codegen --schema schema.graphql', + }, + configuration_ui: false, + api_version: '2022-07', + } + + // When + const extension = await testFunctionExtension({ + dir: '/function', + config: configWithTypegen as FunctionConfigType, + }) + + // Then + expect(extension.configuration.build?.typegen_command).toBe('npx shopify-function-codegen --schema schema.graphql') + expect(extension.typegenCommand).toBe('npx shopify-function-codegen --schema schema.graphql') + }) + test('accepts configuration without build section', async () => { // Given const configWithoutBuild = { diff --git a/packages/app/src/cli/models/extensions/specifications/function.ts b/packages/app/src/cli/models/extensions/specifications/function.ts index 4687dc44558..ce6d2ec5a5d 100644 --- a/packages/app/src/cli/models/extensions/specifications/function.ts +++ b/packages/app/src/cli/models/extensions/specifications/function.ts @@ -27,6 +27,10 @@ const FunctionExtensionSchema = BaseSchema.extend({ path: zod.string().optional(), watch: zod.union([zod.string(), zod.string().array()]).optional(), wasm_opt: zod.boolean().optional().default(true), + typegen_command: zod + .string() + .transform((value) => (value.trim() === '' ? undefined : value)) + .optional(), }) .optional(), name: zod.string(), diff --git a/packages/app/src/cli/services/build/extension.test.ts b/packages/app/src/cli/services/build/extension.test.ts index 4889dbd803c..adf92867b84 100644 --- a/packages/app/src/cli/services/build/extension.test.ts +++ b/packages/app/src/cli/services/build/extension.test.ts @@ -1,6 +1,6 @@ import {buildFunctionExtension} from './extension.js' import {testFunctionExtension} from '../../models/app/app.test-data.js' -import {buildJSFunction, runWasmOpt, runTrampoline} from '../function/build.js' +import {buildGraphqlTypes, buildJSFunction, runWasmOpt, runTrampoline} from '../function/build.js' import {ExtensionInstance} from '../../models/extensions/extension-instance.js' import {FunctionConfigType} from '../../models/extensions/specifications/function.js' import {beforeEach, describe, expect, test, vi} from 'vitest' @@ -254,6 +254,128 @@ describe('buildFunctionExtension', () => { expect(runWasmOpt).not.toHaveBeenCalled() }) + test('runs typegen_command before build for non-JS function', async () => { + // Given + const configWithTypegen = { + name: 'MyFunction', + type: 'product_discounts', + description: '', + build: { + command: 'make build', + path: 'dist/index.wasm', + wasm_opt: true, + typegen_command: 'npx shopify-function-codegen --schema schema.graphql', + }, + configuration_ui: true, + api_version: '2022-07', + metafields: [], + } + extension = await testFunctionExtension({config: configWithTypegen}) + + // When + await expect( + buildFunctionExtension(extension, { + stdout, + stderr, + signal, + app, + environment: 'production', + }), + ).resolves.toBeUndefined() + + // Then + expect(buildGraphqlTypes).toHaveBeenCalledWith(extension, { + stdout, + stderr, + signal, + app, + environment: 'production', + }) + expect(exec).toHaveBeenCalledWith('make', ['build'], { + stdout, + stderr, + cwd: extension.directory, + signal, + }) + }) + + test('runs typegen_command before build for JS function with custom build command', async () => { + // Given + const configWithTypegen = { + name: 'MyFunction', + type: 'product_discounts', + description: '', + build: { + command: 'make build', + path: 'dist/index.wasm', + wasm_opt: true, + typegen_command: 'custom-typegen --output types.ts', + }, + configuration_ui: true, + api_version: '2022-07', + metafields: [], + } + extension = await testFunctionExtension({config: configWithTypegen, entryPath: 'src/index.js'}) + + // When + await expect( + buildFunctionExtension(extension, { + stdout, + stderr, + signal, + app, + environment: 'production', + }), + ).resolves.toBeUndefined() + + // Then + expect(buildGraphqlTypes).toHaveBeenCalledWith(extension, { + stdout, + stderr, + signal, + app, + environment: 'production', + }) + expect(exec).toHaveBeenCalledWith('make', ['build'], { + stdout, + stderr, + cwd: extension.directory, + signal, + }) + }) + + test('does not run typegen when typegen_command is not set', async () => { + // Given + const configWithoutTypegen = { + name: 'MyFunction', + type: 'product_discounts', + description: '', + build: { + command: 'make build', + path: 'dist/index.wasm', + wasm_opt: true, + }, + configuration_ui: true, + api_version: '2022-07', + metafields: [], + } + extension = await testFunctionExtension({config: configWithoutTypegen}) + + // When + await expect( + buildFunctionExtension(extension, { + stdout, + stderr, + signal, + app, + environment: 'production', + }), + ).resolves.toBeUndefined() + + // Then + expect(buildGraphqlTypes).not.toHaveBeenCalled() + }) + test('handles function with build config but undefined path', async () => { // Given const configWithoutPath = { diff --git a/packages/app/src/cli/services/build/extension.ts b/packages/app/src/cli/services/build/extension.ts index 0e992326a25..487e439c2dc 100644 --- a/packages/app/src/cli/services/build/extension.ts +++ b/packages/app/src/cli/services/build/extension.ts @@ -1,7 +1,7 @@ import {runThemeCheck} from './theme-check.js' import {AppInterface} from '../../models/app/app.js' import {bundleExtension} from '../extensions/bundle.js' -import {buildJSFunction, runTrampoline, runWasmOpt} from '../function/build.js' +import {buildGraphqlTypes, buildJSFunction, runTrampoline, runWasmOpt} from '../function/build.js' import {ExtensionInstance} from '../../models/extensions/extension-instance.js' import {FunctionConfigType} from '../../models/extensions/specifications/function.js' import {exec} from '@shopify/cli-kit/node/system' @@ -202,6 +202,9 @@ export async function bundleFunctionExtension(wasmPath: string, bundlePath: stri async function runCommandOrBuildJSFunction(extension: ExtensionInstance, options: BuildFunctionExtensionOptions) { if (extension.buildCommand) { + if (extension.typegenCommand) { + await buildGraphqlTypes(extension, options) + } return runCommand(extension.buildCommand, extension, options) } else { return buildJSFunction(extension as ExtensionInstance, options) @@ -223,6 +226,9 @@ async function buildOtherFunction(extension: ExtensionInstance, options: BuildFu `) throw new AbortSilentError() } + if (extension.typegenCommand) { + await buildGraphqlTypes(extension, options) + } return runCommand(extension.buildCommand, extension, options) } diff --git a/packages/app/src/cli/services/function/build.test.ts b/packages/app/src/cli/services/function/build.test.ts index f964ae821ae..bc7ab2dab6d 100644 --- a/packages/app/src/cli/services/function/build.test.ts +++ b/packages/app/src/cli/services/function/build.test.ts @@ -85,7 +85,7 @@ describe('buildGraphqlTypes', () => { }) }) - test('errors if function is not a JS function', async () => { + test('errors if function is not a JS function and no typegen_command', async () => { // Given const ourFunction = await testFunctionExtension() ourFunction.entrySourceFilePath = 'src/main.rs' @@ -96,6 +96,64 @@ describe('buildGraphqlTypes', () => { // Then await expect(got).rejects.toThrow(/GraphQL types can only be built for JavaScript functions/) }) + + test('runs custom typegen_command when provided', async () => { + // Given + const ourFunction = await testFunctionExtension({ + config: { + name: 'test function', + type: 'order_discounts', + build: { + command: 'zig build', + wasm_opt: true, + typegen_command: 'npx shopify-function-codegen --schema schema.graphql', + }, + configuration_ui: true, + api_version: '2024-01', + }, + }) + ourFunction.entrySourceFilePath = 'src/main.rs' + + // When + const got = buildGraphqlTypes(ourFunction, {stdout, stderr, signal, app}) + + // Then + await expect(got).resolves.toBeUndefined() + expect(exec).toHaveBeenCalledWith('npx', ['shopify-function-codegen', '--schema', 'schema.graphql'], { + cwd: ourFunction.directory, + stderr, + signal, + }) + }) + + test('runs custom typegen_command for JS functions when provided', async () => { + // Given + const ourFunction = await testFunctionExtension({ + entryPath: 'src/index.js', + config: { + name: 'test function', + type: 'order_discounts', + build: { + command: 'echo "hello"', + wasm_opt: true, + typegen_command: 'custom-typegen --output types.ts', + }, + configuration_ui: true, + api_version: '2024-01', + }, + }) + + // When + const got = buildGraphqlTypes(ourFunction, {stdout, stderr, signal, app}) + + // Then + await expect(got).resolves.toBeUndefined() + expect(exec).toHaveBeenCalledWith('custom-typegen', ['--output', 'types.ts'], { + cwd: ourFunction.directory, + stderr, + signal, + }) + }) }) async function installShopifyLibrary(tmpDir: string) { diff --git a/packages/app/src/cli/services/function/build.ts b/packages/app/src/cli/services/function/build.ts index d5e0e1bb2da..5b4257bf3b2 100644 --- a/packages/app/src/cli/services/function/build.ts +++ b/packages/app/src/cli/services/function/build.ts @@ -123,9 +123,20 @@ async function buildJSFunctionWithTasks( } export async function buildGraphqlTypes( - fun: {directory: string; isJavaScript: boolean}, + fun: {directory: string; isJavaScript: boolean; typegenCommand?: string}, options: JSFunctionBuildOptions, ) { + if (fun.typegenCommand) { + const commandComponents = fun.typegenCommand.split(' ') + return runWithTimer('cmd_all_timing_network_ms')(async () => { + return exec(commandComponents[0]!, commandComponents.slice(1), { + cwd: fun.directory, + stderr: options.stderr, + signal: options.signal, + }) + }) + } + if (!fun.isJavaScript) { throw new AbortError('GraphQL types can only be built for JavaScript functions') } diff --git a/packages/cli/README.md b/packages/cli/README.md index 82416a8fed7..b00331f5c09 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -662,7 +662,7 @@ DESCRIPTION ## `shopify app function typegen` -Generate GraphQL types for a JavaScript function. +Generate GraphQL types for a function. ``` USAGE @@ -678,10 +678,10 @@ FLAGS --verbose [env: SHOPIFY_FLAG_VERBOSE] Increase the verbosity of the output. DESCRIPTION - Generate GraphQL types for a JavaScript function. + Generate GraphQL types for a function. Creates GraphQL types based on your "input query" (https://shopify.dev/docs/apps/functions/input-output#input) for a - function written in JavaScript. + function. Supports JavaScript functions out of the box, or any language via the `build.typegen_command` configuration. ``` ## `shopify app generate extension` diff --git a/packages/cli/oclif.manifest.json b/packages/cli/oclif.manifest.json index 105fd1e3678..fc0c0ea75da 100644 --- a/packages/cli/oclif.manifest.json +++ b/packages/cli/oclif.manifest.json @@ -1867,8 +1867,8 @@ "args": { }, "customPluginName": "@shopify/app", - "description": "Creates GraphQL types based on your \"input query\" (https://shopify.dev/docs/apps/functions/input-output#input) for a function written in JavaScript.", - "descriptionWithMarkdown": "Creates GraphQL types based on your [input query](https://shopify.dev/docs/apps/functions/input-output#input) for a function written in JavaScript.", + "description": "Creates GraphQL types based on your \"input query\" (https://shopify.dev/docs/apps/functions/input-output#input) for a function. Supports JavaScript functions out of the box, or any language via the `build.typegen_command` configuration.", + "descriptionWithMarkdown": "Creates GraphQL types based on your [input query](https://shopify.dev/docs/apps/functions/input-output#input) for a function. Supports JavaScript functions out of the box, or any language via the `build.typegen_command` configuration.", "flags": { "client-id": { "description": "The Client ID of your app.", @@ -1938,7 +1938,7 @@ "pluginName": "@shopify/cli", "pluginType": "core", "strict": true, - "summary": "Generate GraphQL types for a JavaScript function." + "summary": "Generate GraphQL types for a function." }, "app:generate:extension": { "aliases": [ From 5ccee3c0a1afad5f0ddd4fd5fe3f710ab849fab1 Mon Sep 17 00:00:00 2001 From: David Cameron Date: Fri, 20 Feb 2026 08:40:46 -0500 Subject: [PATCH 2/2] Update typegen error message to suggest build.typegen_command for non-JS functions --- packages/app/src/cli/services/function/build.test.ts | 2 +- packages/app/src/cli/services/function/build.ts | 4 +++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/app/src/cli/services/function/build.test.ts b/packages/app/src/cli/services/function/build.test.ts index bc7ab2dab6d..a0d5345902f 100644 --- a/packages/app/src/cli/services/function/build.test.ts +++ b/packages/app/src/cli/services/function/build.test.ts @@ -94,7 +94,7 @@ describe('buildGraphqlTypes', () => { const got = buildGraphqlTypes(ourFunction, {stdout, stderr, signal, app}) // Then - await expect(got).rejects.toThrow(/GraphQL types can only be built for JavaScript functions/) + await expect(got).rejects.toThrow(/No typegen_command specified/) }) test('runs custom typegen_command when provided', async () => { diff --git a/packages/app/src/cli/services/function/build.ts b/packages/app/src/cli/services/function/build.ts index 5b4257bf3b2..f8fd0df73de 100644 --- a/packages/app/src/cli/services/function/build.ts +++ b/packages/app/src/cli/services/function/build.ts @@ -138,7 +138,9 @@ export async function buildGraphqlTypes( } if (!fun.isJavaScript) { - throw new AbortError('GraphQL types can only be built for JavaScript functions') + throw new AbortError( + 'No typegen_command specified. Set build.typegen_command in your function extension TOML to generate GraphQL types for non-JavaScript functions.', + ) } return runWithTimer('cmd_all_timing_network_ms')(async () => {