From e784c259d8ddccdc3ed373edfec381442fa193eb Mon Sep 17 00:00:00 2001 From: Gonzalo Riestra Date: Wed, 14 Oct 2026 16:22:07 +0200 Subject: [PATCH 1/5] Add typed JSON output to app function typegen --- .changeset/function-typegen-json.md | 5 + .../cli/commands/app/function/typegen.test.ts | 139 ++++++++++++++++++ .../src/cli/commands/app/function/typegen.ts | 28 +++- .../src/cli/services/function/typegen.test.ts | 23 +++ .../app/src/cli/services/function/typegen.ts | 14 ++ .../services/function/typegen/result.test.ts | 21 +++ .../cli/services/function/typegen/result.ts | 8 + .../cli/services/function/typegen/types.ts | 16 ++ packages/cli/README.md | 62 +++++++- packages/cli/oclif.manifest.json | 11 +- .../rules/json-output-command-exceptions.js | 1 - 11 files changed, 319 insertions(+), 9 deletions(-) create mode 100644 .changeset/function-typegen-json.md create mode 100644 packages/app/src/cli/commands/app/function/typegen.test.ts create mode 100644 packages/app/src/cli/services/function/typegen.test.ts create mode 100644 packages/app/src/cli/services/function/typegen.ts create mode 100644 packages/app/src/cli/services/function/typegen/result.test.ts create mode 100644 packages/app/src/cli/services/function/typegen/result.ts create mode 100644 packages/app/src/cli/services/function/typegen/types.ts diff --git a/.changeset/function-typegen-json.md b/.changeset/function-typegen-json.md new file mode 100644 index 00000000000..8445c4a54d8 --- /dev/null +++ b/.changeset/function-typegen-json.md @@ -0,0 +1,5 @@ +--- +'@shopify/cli': minor +--- + +Add typed JSON output and schema discovery to `app function typegen`. diff --git a/packages/app/src/cli/commands/app/function/typegen.test.ts b/packages/app/src/cli/commands/app/function/typegen.test.ts new file mode 100644 index 00000000000..cc899e8f068 --- /dev/null +++ b/packages/app/src/cli/commands/app/function/typegen.test.ts @@ -0,0 +1,139 @@ +import FunctionTypegen from './typegen.js' +import {chooseFunction} from '../../../services/function/common.js' +import {localAppContext} from '../../../services/app-context.js' +import {testApp, testFunctionExtension, testProject} from '../../../models/app/app.test-data.js' +import {functionTypegenJsonOutputSchema} from '../../../services/function/typegen/types.js' +import {Config} from '@oclif/core' +import {afterEach, expect, test, vi} from 'vitest' +import {inTemporaryDirectory, readFile, writeFile} from '@shopify/cli-kit/node/fs' +import {joinPath} from '@shopify/cli-kit/node/path' +import {withCapturedStandardStreams} from '@shopify/cli-kit/node/testing/output' +import {runWithCommandEventsForCommand} from '@shopify/cli-kit/node/command-events' +import {ExternalError, AbortError, handler} from '@shopify/cli-kit/node/error' +import {exec} from '@shopify/cli-kit/node/system' +import {packageManagerBinaryCommandForDirectory} from '@shopify/cli-kit/node/node-package-manager' +import {unstyled} from '@shopify/cli-kit/node/output' + +vi.mock('../../../services/app-context.js') +vi.mock('@shopify/cli-kit/node/system', async (importOriginal) => ({ + ...(await importOriginal()), + exec: vi.fn(), +})) +vi.mock('@shopify/cli-kit/node/node-package-manager', async (importOriginal) => ({ + ...(await importOriginal()), + packageManagerBinaryCommandForDirectory: vi.fn(), +})) +vi.mock('../../../services/function/common.js', async (importOriginal) => ({ + ...(await importOriginal()), + chooseFunction: vi.fn(), +})) +afterEach(() => vi.unstubAllEnvs()) + +async function command(argv: string[], directory: string, custom = false) { + const app = testApp({directory}) + const extension = await testFunctionExtension({dir: directory, entryPath: 'src/index.js'}) + if (custom) extension.configuration.build!.typegen_command = 'custom-codegen --target test' + vi.mocked(localAppContext).mockResolvedValue({app, project: testProject(), activeConfig: {} as never}) + vi.mocked(chooseFunction).mockResolvedValue(extension) + vi.mocked(packageManagerBinaryCommandForDirectory).mockResolvedValue({ + command: 'codegen', + args: ['--config', 'package.json'], + }) + return {instance: new FunctionTypegen(argv, await Config.load()), extension, app} +} + +test.each([ + {custom: true, noInput: true}, + {custom: true, noInput: false}, + {custom: false, noInput: true}, + {custom: false, noInput: false}, +])('emits one result and codegen side events: %j', async ({custom, noInput}) => { + await inTemporaryDirectory(async (directory) => { + const argv = ['--json', ...(noInput ? ['--no-input'] : [])] + const {instance, extension, app} = await command(argv, directory, custom) + vi.mocked(exec).mockImplementation(async (_command, _args, options) => { + if (options?.stdout && options.stdout !== 'inherit') options.stdout.write('Generating types\n') + if (options?.stderr && options.stderr !== 'inherit') options.stderr.write('Codegen warning\n') + await writeFile(joinPath(directory, 'generated.ts'), 'export type Input = {}\n') + }) + await withCapturedStandardStreams(async ({stdout, stderr}) => { + await expect(runWithCommandEventsForCommand(argv, () => instance.run())).resolves.toEqual({app}) + expect(JSON.parse(stdout())).toEqual({ + status: 'success', + function: {handle: extension.configuration.handle ?? null, name: extension.name, directory}, + }) + const events = stderr() + .trim() + .split('\n') + .map((line) => JSON.parse(line)) + expect(events).toContainEqual( + expect.objectContaining({type: 'diagnostic', message: expect.stringContaining('Codegen warning')}), + ) + if (custom) + expect(events).toContainEqual( + expect.objectContaining({type: 'diagnostic', message: expect.stringContaining('Generating types')}), + ) + expect(stderr()).not.toContain('GraphQL types generated successfully.') + }) + await expect(readFile(joinPath(directory, 'generated.ts'))).resolves.toBe('export type Input = {}\n') + expect(exec).toHaveBeenCalledWith( + custom ? 'custom-codegen' : 'codegen', + custom ? ['--target', 'test'] : ['--config', 'package.json'], + expect.objectContaining({cwd: directory}), + ) + }) +}) + +test.each([[], ['--no-input']])('keeps custom codegen text output and the success banner: %j', async (...argv) => { + await inTemporaryDirectory(async (directory) => { + const {instance} = await command(argv, directory, true) + vi.mocked(exec).mockImplementation(async (_command, _args, options) => { + if (options?.stdout && options.stdout !== 'inherit') options.stdout.write('Native codegen output\n') + }) + await withCapturedStandardStreams(async ({stdout, stderr}) => { + await instance.run() + expect(stdout()).toBe('Native codegen output\n') + expect(unstyled(stderr())).toContain('GraphQL types generated successfully.') + }) + }) +}) + +test('emits one fatal document when the custom generator fails', async () => { + vi.stubEnv('SHOPIFY_FLAG_JSON', '1') + await inTemporaryDirectory(async (directory) => { + const {instance} = await command(['--json'], directory, true) + vi.mocked(exec).mockRejectedValue(new ExternalError('Generator failed', 'custom-codegen', [])) + await withCapturedStandardStreams(async ({stdout}) => { + await runWithCommandEventsForCommand(instance.argv, async () => { + try { + await instance.run() + expect.unreachable('Expected typegen to fail') + } catch (error) { + if (!(error instanceof ExternalError)) throw error + await handler(error) + } + }) + expect(JSON.parse(stdout())).toMatchObject({error: {type: 'external', message: 'Generator failed'}}) + expect(stdout()).not.toContain('"status":"success"') + }) + }) +}) + +test('keeps the missing custom generator error for non-JavaScript functions', async () => { + await inTemporaryDirectory(async (directory) => { + const {instance, extension} = await command(['--json'], directory) + extension.entrySourceFilePath = 'src/main.rs' + await withCapturedStandardStreams(async ({stdout}) => { + await expect(runWithCommandEventsForCommand(instance.argv, () => instance.run())).rejects.toThrow(AbortError) + expect(stdout()).toBe('') + }) + expect(exec).not.toHaveBeenCalled() + }) +}) + +test('exposes the result schema and retains inherited flags', () => { + expect(FunctionTypegen.flags.json).toBeDefined() + expect(FunctionTypegen.flags['no-input']).toBeDefined() + expect(FunctionTypegen.jsonOutputSchema).toBe(functionTypegenJsonOutputSchema) + expect(FunctionTypegen.descriptionForHelp()).toContain('FunctionTypegenResult') +}) diff --git a/packages/app/src/cli/commands/app/function/typegen.ts b/packages/app/src/cli/commands/app/function/typegen.ts index 9c81f39d7ee..8794de7bae2 100644 --- a/packages/app/src/cli/commands/app/function/typegen.ts +++ b/packages/app/src/cli/commands/app/function/typegen.ts @@ -1,12 +1,19 @@ import {chooseFunction, functionFlags} from '../../../services/function/common.js' -import {buildGraphqlTypes} from '../../../services/function/build.js' +import {generateFunctionTypes} from '../../../services/function/typegen.js' +import {functionTypegenJsonOutputSchema} from '../../../services/function/typegen/types.js' +import {renderFunctionTypegenResult} from '../../../services/function/typegen/result.js' import {appFlags} from '../../../flags.js' import AppUnlinkedCommand, {AppUnlinkedCommandOutput} from '../../../utilities/app-unlinked-command.js' import {localAppContext} from '../../../services/app-context.js' -import {globalFlags} from '@shopify/cli-kit/node/cli' -import {renderSuccess} from '@shopify/cli-kit/node/ui' +import {globalFlags, jsonFlag} from '@shopify/cli-kit/node/cli' +import {renderConcurrent} from '@shopify/cli-kit/node/ui' +import {Writable} from 'stream' export default class FunctionTypegen extends AppUnlinkedCommand { + static get jsonOutputSchema() { + return functionTypegenJsonOutputSchema + } + 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. Supports JavaScript functions out of the box, or any language via the \`build.typegen_command\` configuration.` @@ -17,6 +24,7 @@ export default class FunctionTypegen extends AppUnlinkedCommand { ...globalFlags, ...appFlags, ...functionFlags, + ...jsonFlag, } public async run(): Promise { @@ -29,8 +37,18 @@ export default class FunctionTypegen extends AppUnlinkedCommand { const ourFunction = await chooseFunction(app, flags.path) - await buildGraphqlTypes(ourFunction, {stdout: process.stdout, stderr: process.stderr, app}) - renderSuccess({headline: 'GraphQL types generated successfully.'}) + const execute = async (stdout: Writable, stderr: Writable) => { + const result = await generateFunctionTypes(ourFunction, {stdout, stderr, app}) + renderFunctionTypegenResult(result, flags.json) + } + if (flags.json) { + await renderConcurrent({ + processes: [{prefix: ourFunction.localIdentifier, action: execute}], + showTimestamps: false, + }) + } else { + await execute(process.stdout, process.stderr) + } return {app} } diff --git a/packages/app/src/cli/services/function/typegen.test.ts b/packages/app/src/cli/services/function/typegen.test.ts new file mode 100644 index 00000000000..257ad854d16 --- /dev/null +++ b/packages/app/src/cli/services/function/typegen.test.ts @@ -0,0 +1,23 @@ +import {generateFunctionTypes} from './typegen.js' +import {buildGraphqlTypes} from './build.js' +import {testApp, testFunctionExtension} from '../../models/app/app.test-data.js' +import {expect, test, vi} from 'vitest' + +// eslint-disable-next-line no-restricted-imports +import {resolve} from 'node:path' + +vi.mock('./build.js') + +test('returns a typed Function identity after generation', async () => { + const extension = await testFunctionExtension() + const options = {app: testApp(), stdout: process.stdout, stderr: process.stderr} + await expect(generateFunctionTypes(extension, options)).resolves.toEqual({ + status: 'success', + function: { + handle: extension.configuration.handle ?? null, + name: extension.name, + directory: resolve(extension.directory), + }, + }) + expect(buildGraphqlTypes).toHaveBeenCalledWith(extension, options) +}) diff --git a/packages/app/src/cli/services/function/typegen.ts b/packages/app/src/cli/services/function/typegen.ts new file mode 100644 index 00000000000..27526c220a3 --- /dev/null +++ b/packages/app/src/cli/services/function/typegen.ts @@ -0,0 +1,14 @@ +import {buildGraphqlTypes} from './build.js' +import {functionIdentity} from './identity.js' +import {type FunctionTypegenResult} from './typegen/types.js' +import {type ExtensionBuildOptions} from '../build/extension.js' +import {ExtensionInstance} from '../../models/extensions/extension-instance.js' +import {FunctionConfigType} from '../../models/extensions/specifications/function.js' + +export async function generateFunctionTypes( + extension: ExtensionInstance, + options: Pick, +): Promise { + await buildGraphqlTypes(extension, options) + return {status: 'success', function: functionIdentity(extension)} +} diff --git a/packages/app/src/cli/services/function/typegen/result.test.ts b/packages/app/src/cli/services/function/typegen/result.test.ts new file mode 100644 index 00000000000..84fbf1de913 --- /dev/null +++ b/packages/app/src/cli/services/function/typegen/result.test.ts @@ -0,0 +1,21 @@ +import {functionTypegenJsonOutputSchema} from './types.js' +import {expect, test} from 'vitest' + +function fixture() { + return {status: 'success' as const, function: {handle: null, name: 'My Function', directory: '/function'}} +} + +test.each([ + {field: 'directory', value: 'relative/path'}, + {field: 'handle', value: 123}, + {field: 'unexpected', value: true}, +])('rejects invalid Function $field values', ({field, value}) => { + const result = fixture() + expect(() => + functionTypegenJsonOutputSchema.validate({...result, function: {...result.function, [field]: value}}), + ).toThrow() +}) + +test('rejects unknown root fields', () => { + expect(() => functionTypegenJsonOutputSchema.validate({...fixture(), unexpected: true})).toThrow() +}) diff --git a/packages/app/src/cli/services/function/typegen/result.ts b/packages/app/src/cli/services/function/typegen/result.ts new file mode 100644 index 00000000000..a8a66810a13 --- /dev/null +++ b/packages/app/src/cli/services/function/typegen/result.ts @@ -0,0 +1,8 @@ +import {functionTypegenJsonOutputSchema, type FunctionTypegenResult} from './types.js' +import {outputResult} from '@shopify/cli-kit/node/output' +import {renderSuccess} from '@shopify/cli-kit/node/ui' + +export function renderFunctionTypegenResult(result: FunctionTypegenResult, json: boolean): void { + if (json) outputResult(functionTypegenJsonOutputSchema.encode(result)) + else renderSuccess({headline: 'GraphQL types generated successfully.'}) +} diff --git a/packages/app/src/cli/services/function/typegen/types.ts b/packages/app/src/cli/services/function/typegen/types.ts new file mode 100644 index 00000000000..8be316d61aa --- /dev/null +++ b/packages/app/src/cli/services/function/typegen/types.ts @@ -0,0 +1,16 @@ +import {functionIdentitySchema} from '../types.js' +import {defineJsonOutputSchema, type InferJsonOutputSchema} from '@shopify/cli-kit/node/json-output-schema' +import {zod} from '@shopify/cli-kit/node/schema' + +export const functionTypegenJsonOutputSchema = defineJsonOutputSchema({ + name: 'FunctionTypegenResult', + schema: zod + .object({ + status: zod.literal('success'), + function: functionIdentitySchema, + }) + .strict(), + definitions: {Function: functionIdentitySchema}, +}) + +export type FunctionTypegenResult = InferJsonOutputSchema diff --git a/packages/cli/README.md b/packages/cli/README.md index c30fe826dab..ac2f43d35c7 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -3206,14 +3206,18 @@ Generate GraphQL types for a function. ``` USAGE - $ shopify app function typegen [--auth-alias ] [--client-id | -c ] [--json-schema] [--no-color] - [--no-input] [--path ] [--reset | ] [--verbose] + $ shopify app function typegen [--auth-alias ] [--client-id | -c ] [-j] [--json-schema] + [--no-color] [--no-input] [--path ] [--reset | ] [--verbose] FLAGS -c, --config= The name of the app configuration. [env: SHOPIFY_FLAG_APP_CONFIG] + -j, --json + Output the result as JSON. Automatically disables color output. + [env: SHOPIFY_FLAG_JSON] + --auth-alias= Alias of the Shopify account to use for authentication. [env: SHOPIFY_FLAG_AUTH_ALIAS] @@ -3251,6 +3255,60 @@ 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. + + Use `--json-schema` to print the result, error, and event schemas. + + Output from `--json` conforms to the `FunctionTypegenResult` schema. + + ```json + { + "type": "object", + "properties": { + "status": { + "type": "string", + "const": "success" + }, + "function": { + "$ref": "#/definitions/Function" + } + }, + "required": [ + "status", + "function" + ], + "additionalProperties": false, + "title": "FunctionTypegenResult", + "definitions": { + "Function": { + "type": "object", + "properties": { + "handle": { + "type": [ + "string", + "null" + ], + "description": "Null when the extension does not configure a handle." + }, + "name": { + "type": "string" + }, + "directory": { + "type": "string", + "pattern": "^(?:\\/|[a-zA-Z]:[\\\\/]|\\\\\\\\)", + "description": "An absolute native filesystem path." + } + }, + "required": [ + "handle", + "name", + "directory" + ], + "additionalProperties": false + } + }, + "$schema": "http://json-schema.org/draft-07/schema#" + } + ``` ``` ## `shopify app generate extension` diff --git a/packages/cli/oclif.manifest.json b/packages/cli/oclif.manifest.json index 50b405613c2..3929a5bc852 100644 --- a/packages/cli/oclif.manifest.json +++ b/packages/cli/oclif.manifest.json @@ -2544,7 +2544,7 @@ "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. Supports JavaScript functions out of the box, or any language via the `build.typegen_command` configuration.", + "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.\n\nUse `--json-schema` to print the result, error, and event schemas.\n\nOutput from `--json` conforms to the `FunctionTypegenResult` schema.\n\n```json\n{\n \"type\": \"object\",\n \"properties\": {\n \"status\": {\n \"type\": \"string\",\n \"const\": \"success\"\n },\n \"function\": {\n \"$ref\": \"#/definitions/Function\"\n }\n },\n \"required\": [\n \"status\",\n \"function\"\n ],\n \"additionalProperties\": false,\n \"title\": \"FunctionTypegenResult\",\n \"definitions\": {\n \"Function\": {\n \"type\": \"object\",\n \"properties\": {\n \"handle\": {\n \"type\": [\n \"string\",\n \"null\"\n ],\n \"description\": \"Null when the extension does not configure a handle.\"\n },\n \"name\": {\n \"type\": \"string\"\n },\n \"directory\": {\n \"type\": \"string\",\n \"pattern\": \"^(?:\\\\/|[a-zA-Z]:[\\\\\\\\/]|\\\\\\\\\\\\\\\\)\",\n \"description\": \"An absolute native filesystem path.\"\n }\n },\n \"required\": [\n \"handle\",\n \"name\",\n \"directory\"\n ],\n \"additionalProperties\": false\n }\n },\n \"$schema\": \"http://json-schema.org/draft-07/schema#\"\n}\n```", "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": { "auth-alias": { @@ -2577,6 +2577,15 @@ "name": "config", "type": "option" }, + "json": { + "allowNo": false, + "char": "j", + "description": "Output the result as JSON. Automatically disables color output.", + "env": "SHOPIFY_FLAG_JSON", + "hidden": false, + "name": "json", + "type": "boolean" + }, "json-schema": { "allowNo": false, "description": "Print the command's JSON schemas.", diff --git a/packages/eslint-plugin-cli/rules/json-output-command-exceptions.js b/packages/eslint-plugin-cli/rules/json-output-command-exceptions.js index dc6375a3b6f..9d9f0e1e5a1 100644 --- a/packages/eslint-plugin-cli/rules/json-output-command-exceptions.js +++ b/packages/eslint-plugin-cli/rules/json-output-command-exceptions.js @@ -2,7 +2,6 @@ const commandExceptions = [ // Existing finite commands awaiting migration. Remove entries as they adopt typed JSON output. // Do not add new finite commands to this section. - 'packages/app/src/cli/commands/app/function/typegen.ts', 'packages/plugin-did-you-mean/src/commands/config/autocorrect/off.ts', 'packages/plugin-did-you-mean/src/commands/config/autocorrect/on.ts', 'packages/plugin-did-you-mean/src/commands/config/autocorrect/status.ts', From 2a79a32cb0a9d5d6f359f8f243715e64d0241a6d Mon Sep 17 00:00:00 2001 From: Gonzalo Riestra Date: Thu, 15 Oct 2026 11:44:30 +0200 Subject: [PATCH 2/5] Refresh Function typegen docs and native path expectations --- .../generated/generated_docs_data_v2.json | 11 ++++++++++- .../app/src/cli/commands/app/function/typegen.test.ts | 5 ++++- 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/docs-shopify.dev/generated/generated_docs_data_v2.json b/docs-shopify.dev/generated/generated_docs_data_v2.json index 19f77590f27..82d72ceba0b 100644 --- a/docs-shopify.dev/generated/generated_docs_data_v2.json +++ b/docs-shopify.dev/generated/generated_docs_data_v2.json @@ -2445,9 +2445,18 @@ "description": "The name of the app configuration.", "isOptional": true, "environmentValue": "SHOPIFY_FLAG_APP_CONFIG" + }, + { + "filePath": "docs-shopify.dev/commands/interfaces/app-function-typegen.interface.ts", + "syntaxKind": "PropertySignature", + "name": "-j, --json", + "value": "''", + "description": "Output the result as JSON. Automatically disables color output.", + "isOptional": true, + "environmentValue": "SHOPIFY_FLAG_JSON" } ], - "value": "export interface appfunctiontypegen {\n /**\n * Alias of the Shopify account to use for authentication.\n * @environment SHOPIFY_FLAG_AUTH_ALIAS\n */\n '--auth-alias '?: string\n\n /**\n * The Client ID of your app.\n * @environment SHOPIFY_FLAG_CLIENT_ID\n */\n '--client-id '?: string\n\n /**\n * The name of the app configuration.\n * @environment SHOPIFY_FLAG_APP_CONFIG\n */\n '-c, --config '?: string\n\n /**\n * Print the command's JSON schemas.\n * @environment SHOPIFY_FLAG_JSON_SCHEMA\n */\n '--json-schema'?: ''\n\n /**\n * Disable color output.\n * @environment SHOPIFY_FLAG_NO_COLOR\n */\n '--no-color'?: ''\n\n /**\n * Disable interactive prompts and browser authentication.\n * @environment SHOPIFY_FLAG_NO_INPUT\n */\n '--no-input'?: ''\n\n /**\n * The path to your function directory.\n * @environment SHOPIFY_FLAG_PATH\n */\n '--path '?: string\n\n /**\n * Reset all your settings.\n * @environment SHOPIFY_FLAG_RESET\n */\n '--reset'?: ''\n\n /**\n * Increase the verbosity of the output. May include sensitive data.\n * @environment SHOPIFY_FLAG_VERBOSE\n */\n '--verbose'?: ''\n}" + "value": "export interface appfunctiontypegen {\n /**\n * Alias of the Shopify account to use for authentication.\n * @environment SHOPIFY_FLAG_AUTH_ALIAS\n */\n '--auth-alias '?: string\n\n /**\n * The Client ID of your app.\n * @environment SHOPIFY_FLAG_CLIENT_ID\n */\n '--client-id '?: string\n\n /**\n * The name of the app configuration.\n * @environment SHOPIFY_FLAG_APP_CONFIG\n */\n '-c, --config '?: string\n\n /**\n * Output the result as JSON. Automatically disables color output.\n * @environment SHOPIFY_FLAG_JSON\n */\n '-j, --json'?: ''\n\n /**\n * Print the command's JSON schemas.\n * @environment SHOPIFY_FLAG_JSON_SCHEMA\n */\n '--json-schema'?: ''\n\n /**\n * Disable color output.\n * @environment SHOPIFY_FLAG_NO_COLOR\n */\n '--no-color'?: ''\n\n /**\n * Disable interactive prompts and browser authentication.\n * @environment SHOPIFY_FLAG_NO_INPUT\n */\n '--no-input'?: ''\n\n /**\n * The path to your function directory.\n * @environment SHOPIFY_FLAG_PATH\n */\n '--path '?: string\n\n /**\n * Reset all your settings.\n * @environment SHOPIFY_FLAG_RESET\n */\n '--reset'?: ''\n\n /**\n * Increase the verbosity of the output. May include sensitive data.\n * @environment SHOPIFY_FLAG_VERBOSE\n */\n '--verbose'?: ''\n}" } }, "appgenerateextension": { diff --git a/packages/app/src/cli/commands/app/function/typegen.test.ts b/packages/app/src/cli/commands/app/function/typegen.test.ts index cc899e8f068..a645d529399 100644 --- a/packages/app/src/cli/commands/app/function/typegen.test.ts +++ b/packages/app/src/cli/commands/app/function/typegen.test.ts @@ -14,6 +14,9 @@ import {exec} from '@shopify/cli-kit/node/system' import {packageManagerBinaryCommandForDirectory} from '@shopify/cli-kit/node/node-package-manager' import {unstyled} from '@shopify/cli-kit/node/output' +// eslint-disable-next-line no-restricted-imports +import {resolve} from 'node:path' + vi.mock('../../../services/app-context.js') vi.mock('@shopify/cli-kit/node/system', async (importOriginal) => ({ ...(await importOriginal()), @@ -60,7 +63,7 @@ test.each([ await expect(runWithCommandEventsForCommand(argv, () => instance.run())).resolves.toEqual({app}) expect(JSON.parse(stdout())).toEqual({ status: 'success', - function: {handle: extension.configuration.handle ?? null, name: extension.name, directory}, + function: {handle: extension.configuration.handle ?? null, name: extension.name, directory: resolve(directory)}, }) const events = stderr() .trim() From a6f00e2d4e48d7f979947f60f687c1e4100ddca8 Mon Sep 17 00:00:00 2001 From: Gonzalo Riestra Date: Thu, 15 Oct 2026 13:07:26 +0200 Subject: [PATCH 3/5] Add one changeset for the Function JSON commands --- .changeset/function-json.md | 5 +++++ .changeset/function-typegen-json.md | 5 ----- 2 files changed, 5 insertions(+), 5 deletions(-) create mode 100644 .changeset/function-json.md delete mode 100644 .changeset/function-typegen-json.md diff --git a/.changeset/function-json.md b/.changeset/function-json.md new file mode 100644 index 00000000000..b9ff8963dba --- /dev/null +++ b/.changeset/function-json.md @@ -0,0 +1,5 @@ +--- +'@shopify/cli': minor +--- + +Add typed JSON output to `app function` commands diff --git a/.changeset/function-typegen-json.md b/.changeset/function-typegen-json.md deleted file mode 100644 index 8445c4a54d8..00000000000 --- a/.changeset/function-typegen-json.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@shopify/cli': minor ---- - -Add typed JSON output and schema discovery to `app function typegen`. From 31c08a32ac99eb1c4fe542dabef837a9c333c7c9 Mon Sep 17 00:00:00 2001 From: Gonzalo Riestra Date: Fri, 16 Oct 2026 11:11:56 +0200 Subject: [PATCH 4/5] Inline Function identity in the typegen result --- packages/app/src/cli/services/function/typegen.ts | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/packages/app/src/cli/services/function/typegen.ts b/packages/app/src/cli/services/function/typegen.ts index 27526c220a3..0d837dfed24 100644 --- a/packages/app/src/cli/services/function/typegen.ts +++ b/packages/app/src/cli/services/function/typegen.ts @@ -1,14 +1,22 @@ import {buildGraphqlTypes} from './build.js' -import {functionIdentity} from './identity.js' import {type FunctionTypegenResult} from './typegen/types.js' import {type ExtensionBuildOptions} from '../build/extension.js' import {ExtensionInstance} from '../../models/extensions/extension-instance.js' import {FunctionConfigType} from '../../models/extensions/specifications/function.js' +// eslint-disable-next-line no-restricted-imports +import {resolve} from 'node:path' export async function generateFunctionTypes( extension: ExtensionInstance, options: Pick, ): Promise { await buildGraphqlTypes(extension, options) - return {status: 'success', function: functionIdentity(extension)} + return { + status: 'success', + function: { + handle: extension.configuration.handle ?? null, + name: extension.name, + directory: resolve(extension.directory), + }, + } } From 7d1a5bc0a864d9c05a1879601528fa3110a1ac3d Mon Sep 17 00:00:00 2001 From: Gonzalo Riestra Date: Fri, 16 Oct 2026 12:16:41 +0200 Subject: [PATCH 5/5] Select JSON typegen events from parsed flags --- .../src/cli/commands/app/function/typegen.test.ts | 15 +++++++++------ .../app/src/cli/commands/app/function/typegen.ts | 11 +++++++---- 2 files changed, 16 insertions(+), 10 deletions(-) diff --git a/packages/app/src/cli/commands/app/function/typegen.test.ts b/packages/app/src/cli/commands/app/function/typegen.test.ts index a645d529399..fe23632e3d2 100644 --- a/packages/app/src/cli/commands/app/function/typegen.test.ts +++ b/packages/app/src/cli/commands/app/function/typegen.test.ts @@ -46,13 +46,16 @@ async function command(argv: string[], directory: string, custom = false) { } test.each([ - {custom: true, noInput: true}, - {custom: true, noInput: false}, - {custom: false, noInput: true}, - {custom: false, noInput: false}, -])('emits one result and codegen side events: %j', async ({custom, noInput}) => { + {custom: true, argv: ['--json', '--no-input']}, + {custom: true, argv: ['--json']}, + {custom: false, argv: ['--json', '--no-input']}, + {custom: false, argv: ['--json']}, + {custom: true, argv: ['-jcstaging']}, + {custom: false, argv: ['-jcstaging']}, + {custom: true, argv: ['-j']}, + {custom: false, argv: ['-j']}, +])('emits one result and codegen side events: %j', async ({custom, argv}) => { await inTemporaryDirectory(async (directory) => { - const argv = ['--json', ...(noInput ? ['--no-input'] : [])] const {instance, extension, app} = await command(argv, directory, custom) vi.mocked(exec).mockImplementation(async (_command, _args, options) => { if (options?.stdout && options.stdout !== 'inherit') options.stdout.write('Generating types\n') diff --git a/packages/app/src/cli/commands/app/function/typegen.ts b/packages/app/src/cli/commands/app/function/typegen.ts index 8794de7bae2..c76126698dc 100644 --- a/packages/app/src/cli/commands/app/function/typegen.ts +++ b/packages/app/src/cli/commands/app/function/typegen.ts @@ -7,6 +7,7 @@ import AppUnlinkedCommand, {AppUnlinkedCommandOutput} from '../../../utilities/a import {localAppContext} from '../../../services/app-context.js' import {globalFlags, jsonFlag} from '@shopify/cli-kit/node/cli' import {renderConcurrent} from '@shopify/cli-kit/node/ui' +import {renderCommandEventAsJson, runWithCommandEvents} from '@shopify/cli-kit/node/command-events' import {Writable} from 'stream' export default class FunctionTypegen extends AppUnlinkedCommand { @@ -42,10 +43,12 @@ export default class FunctionTypegen extends AppUnlinkedCommand { renderFunctionTypegenResult(result, flags.json) } if (flags.json) { - await renderConcurrent({ - processes: [{prefix: ourFunction.localIdentifier, action: execute}], - showTimestamps: false, - }) + await runWithCommandEvents({outputMode: 'json', sink: renderCommandEventAsJson}, () => + renderConcurrent({ + processes: [{prefix: ourFunction.localIdentifier, action: execute}], + showTimestamps: false, + }), + ) } else { await execute(process.stdout, process.stderr) }