From 576f2407fcee42b9cbc81464c39bc5c686fabd51 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Isaac=20Rold=C3=A1n?= Date: Wed, 7 Oct 2026 13:39:34 +0200 Subject: [PATCH 1/8] Add typed JSON output to app import custom-data-definitions --- .changeset/app-import-custom-data-json.md | 5 + .../generated/generated_docs_data_v2.json | 11 +- .../import/custom-data-definitions.test.ts | 243 ++++++++++++++++++ .../app/import/custom-data-definitions.ts | 32 ++- .../declarative-definitions.test.ts | 27 +- .../shop-import/declarative-definitions.ts | 133 +++------- .../declarative-definitions/result.ts | 73 ++++++ .../declarative-definitions/types.test.ts | 55 ++++ .../declarative-definitions/types.ts | 50 ++++ packages/cli/README.md | 100 ++++++- packages/cli/oclif.manifest.json | 22 +- .../rules/json-output-command-exceptions.js | 1 - 12 files changed, 645 insertions(+), 107 deletions(-) create mode 100644 .changeset/app-import-custom-data-json.md create mode 100644 packages/app/src/cli/commands/app/import/custom-data-definitions.test.ts create mode 100644 packages/app/src/cli/services/generate/shop-import/declarative-definitions/result.ts create mode 100644 packages/app/src/cli/services/generate/shop-import/declarative-definitions/types.test.ts create mode 100644 packages/app/src/cli/services/generate/shop-import/declarative-definitions/types.ts diff --git a/.changeset/app-import-custom-data-json.md b/.changeset/app-import-custom-data-json.md new file mode 100644 index 00000000000..3438a94d593 --- /dev/null +++ b/.changeset/app-import-custom-data-json.md @@ -0,0 +1,5 @@ +--- +'@shopify/cli': minor +--- + +Add typed JSON output to `app import custom-data-definitions`. diff --git a/docs-shopify.dev/generated/generated_docs_data_v2.json b/docs-shopify.dev/generated/generated_docs_data_v2.json index 79b22e26edf..392724aadda 100644 --- a/docs-shopify.dev/generated/generated_docs_data_v2.json +++ b/docs-shopify.dev/generated/generated_docs_data_v2.json @@ -2695,6 +2695,15 @@ "isOptional": true, "environmentValue": "SHOPIFY_FLAG_APP_CONFIG" }, + { + "filePath": "docs-shopify.dev/commands/interfaces/app-import-custom-data-definitions.interface.ts", + "syntaxKind": "PropertySignature", + "name": "-j, --json", + "value": "''", + "description": "Output the result as JSON. Automatically disables color output.", + "isOptional": true, + "environmentValue": "SHOPIFY_FLAG_JSON" + }, { "filePath": "docs-shopify.dev/commands/interfaces/app-import-custom-data-definitions.interface.ts", "syntaxKind": "PropertySignature", @@ -2705,7 +2714,7 @@ "environmentValue": "SHOPIFY_FLAG_STORE" } ], - "value": "export interface appimportcustomdatadefinitions {\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 * Include existing declared definitions in the output.\n * @environment SHOPIFY_FLAG_INCLUDE_EXISTING\n */\n '--include-existing'?: ''\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 app 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 * Store URL. Must be an existing development or Shopify Plus sandbox store.\n * @environment SHOPIFY_FLAG_STORE\n */\n '-s, --store '?: string\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 appimportcustomdatadefinitions {\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 * Include existing declared definitions in the output.\n * @environment SHOPIFY_FLAG_INCLUDE_EXISTING\n */\n '--include-existing'?: ''\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 app 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 * Store URL. Must be an existing development or Shopify Plus sandbox store.\n * @environment SHOPIFY_FLAG_STORE\n */\n '-s, --store '?: string\n\n /**\n * Increase the verbosity of the output. May include sensitive data.\n * @environment SHOPIFY_FLAG_VERBOSE\n */\n '--verbose'?: ''\n}" } }, "appimportdashboardextensions": { diff --git a/packages/app/src/cli/commands/app/import/custom-data-definitions.test.ts b/packages/app/src/cli/commands/app/import/custom-data-definitions.test.ts new file mode 100644 index 00000000000..6ea35d6ee78 --- /dev/null +++ b/packages/app/src/cli/commands/app/import/custom-data-definitions.test.ts @@ -0,0 +1,243 @@ +import ImportCustomDataDefinitions from './custom-data-definitions.js' +import ImportCustomDataDefinitionsDeprecated from '../import-custom-data-definitions.js' +import {linkedAppContext} from '../../../services/app-context.js' +import {storeContext} from '../../../services/store-context.js' +import {adminAsAppRequestDoc} from '../../../api/admin-as-app.js' +import {MetaobjectDefinitions} from '../../../api/graphql/admin/generated/metaobject_definitions.js' +import {MetafieldDefinitions} from '../../../api/graphql/admin/generated/metafield_definitions.js' +import {importCustomDataDefinitionsJsonOutputSchema} from '../../../services/generate/shop-import/declarative-definitions/types.js' +import {testAppLinked, testOrganizationApp, testOrganizationStore} from '../../../models/app/app.test-data.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 {ensureAuthenticatedAdminAsApp} from '@shopify/cli-kit/node/session' +import {unstyled} from '@shopify/cli-kit/node/output' +import {AbortError, handler} from '@shopify/cli-kit/node/error' +import {isInputDisabled} from '@shopify/cli-kit/node/no-input' +// eslint-disable-next-line n/prefer-global/console +import {Console} from 'node:console' + +vi.mock('../../../services/app-context.js') +vi.mock('../../../services/store-context.js') +vi.mock('../../../api/admin-as-app.js') +vi.mock('@shopify/cli-kit/node/session') + +afterEach(() => { + vi.unstubAllEnvs() + vi.unstubAllGlobals() +}) + +const toml = + '# namespace: $app key: color owner_type: PRODUCT\n[product.metafields.app.color]\ntype = "single_line_text_field"\n' + +async function withApp(run: (directory: string) => Promise) { + // Ink uses the Console constructor, which Vitest's console replacement does not provide. + vi.stubGlobal('console', {...globalThis.console, Console}) + await inTemporaryDirectory(async (directory) => { + const configurationPath = joinPath(directory, 'shopify.app.toml') + const configuration = 'name = "Test app"\nclient_id = "test-client-id"\n' + await writeFile(configurationPath, configuration) + const app = testAppLinked({directory, configPath: configurationPath}) + const remoteApp = testOrganizationApp() + vi.mocked(linkedAppContext).mockResolvedValue({app, remoteApp} as Awaited>) + vi.mocked(storeContext).mockResolvedValue(testOrganizationStore({shopDomain: 'test-shop.myshopify.com'})) + vi.mocked(ensureAuthenticatedAdminAsApp).mockResolvedValue({ + storeFqdn: 'test-shop.myshopify.com', + token: 'test-token', + }) + vi.mocked(adminAsAppRequestDoc).mockImplementation(async ({query, variables}) => { + if (query === MetafieldDefinitions) { + return { + metafieldDefinitions: { + pageInfo: {hasNextPage: false, endCursor: null}, + nodes: + variables?.ownerType === 'PRODUCT' + ? [ + { + key: 'color', + name: 'color', + namespace: 'app--123456', + type: {name: 'single_line_text_field'}, + access: {admin: 'MERCHANT_READ', storefront: 'NONE', customerAccount: 'NONE'}, + capabilities: {adminFilterable: {enabled: false}}, + validations: [], + }, + ] + : [], + }, + } + } + return {metaobjectDefinitions: {pageInfo: {hasNextPage: false, endCursor: null}, nodes: []}} + }) + await run(directory) + await expect(readFile(configurationPath)).resolves.toBe(configuration) + }) +} + +async function runCommand(directory: string, argv: string[], deprecated = false) { + const Command = deprecated ? ImportCustomDataDefinitionsDeprecated : ImportCustomDataDefinitions + const args = ['--path', directory, ...argv] + const command = new Command(args, await Config.load()) + return runWithCommandEventsForCommand(args, () => command.run()) +} + +test('writes one encoded JSON document and progress events without changing the local TOML', async () => { + await withApp(async (directory) => { + await withCapturedStandardStreams(async ({stdout, stderr}) => { + const result = await runCommand(directory, ['--json', '--store', 'test-shop']) + expect(result.app.directory).toBe(directory) + expect(JSON.parse(stdout())).toEqual({ + status: 'success', + storeDomain: 'test-shop.myshopify.com', + metafieldCount: 1, + metaobjectCount: 0, + toml, + skippedSections: [], + }) + const events = stderr() + .trim() + .split('\n') + .map((line) => JSON.parse(line)) + expect(events).toEqual(expect.arrayContaining([expect.objectContaining({type: 'progress', status: 'completed'})])) + expect(events.every((event) => event.type === 'progress')).toBe(true) + expect(stderr()).not.toContain('Conversion to TOML complete') + }) + expect(storeContext).toHaveBeenCalledWith(expect.objectContaining({storeFqdn: 'test-shop.myshopify.com'})) + }) +}) + +test('returns a known empty conversion with no skipped sections', async () => { + await withApp(async (directory) => { + vi.mocked(adminAsAppRequestDoc).mockImplementation(async ({query}) => ({ + [query === MetafieldDefinitions ? 'metafieldDefinitions' : 'metaobjectDefinitions']: { + pageInfo: {hasNextPage: false, endCursor: null}, + nodes: [], + }, + })) + await withCapturedStandardStreams(async ({stdout}) => { + await runCommand(directory, ['--json']) + expect(JSON.parse(stdout())).toEqual({ + status: 'success', + storeDomain: 'test-shop.myshopify.com', + metafieldCount: 0, + metaobjectCount: 0, + toml: '', + skippedSections: [], + }) + }) + }) +}) + +test.each([{type: 'metafields'}, {type: 'metaobjects'}])( + 'reports inaccessible $type separately from available empty definitions', + async ({type}) => { + await withApp(async (directory) => { + const original = vi.mocked(adminAsAppRequestDoc).getMockImplementation()! + vi.mocked(adminAsAppRequestDoc).mockImplementation(async (options) => { + if ( + (type === 'metafields' && + options.query === MetafieldDefinitions && + options.variables?.ownerType === 'PRODUCT') || + (type === 'metaobjects' && options.query === MetaobjectDefinitions) + ) { + throw new Error('ACCESS_DENIED: Missing access scope') + } + return original(options) + }) + await withCapturedStandardStreams(async ({stdout}) => { + await runCommand(directory, ['--json']) + expect(JSON.parse(stdout())).toEqual({ + status: 'success', + storeDomain: 'test-shop.myshopify.com', + metafieldCount: type === 'metafields' ? 0 : 1, + metaobjectCount: 0, + toml: type === 'metafields' ? '' : toml, + skippedSections: type === 'metafields' ? [{type, ownerType: 'PRODUCT'}] : [{type}], + }) + }) + }) + }, +) + +test('keeps the existing conversion summary and native TOML on stderr in text mode', async () => { + await withApp(async (directory) => { + await withCapturedStandardStreams(async ({stdout, stderr}) => { + await runCommand(directory, []) + expect(stdout()).toBe('') + const text = unstyled(stderr()) + expect(text).toContain('Conversion to TOML complete.') + expect(text).toContain('1 metafields and 0 metaobjects') + expect(text).toContain('test-shop.myshopify.com') + expect(text).toContain(toml) + }) + }) +}) + +test('preserves the deprecated alias result and warning on stderr', async () => { + await withApp(async (directory) => { + await withCapturedStandardStreams(async ({stdout, stderr}) => { + await runCommand(directory, ['--json'], true) + expect(JSON.parse(stdout())).toMatchObject({status: 'success', metafieldCount: 1, toml}) + expect(unstyled(stderr())).toContain('`shopify app import-custom-data-definitions` has moved.') + }) + }) +}) + +test('preserves a transport failure without printing a success result', async () => { + await withApp(async (directory) => { + vi.mocked(adminAsAppRequestDoc).mockRejectedValue(new AbortError('Definition request failed')) + await withCapturedStandardStreams(async ({stdout, stderr}) => { + const error = await runCommand(directory, ['--json']).catch((failure: AbortError) => failure) + expect(error).toBeInstanceOf(AbortError) + expect(stdout()).toBe('') + vi.stubEnv('SHOPIFY_FLAG_JSON', '1') + await runWithCommandEventsForCommand(['--json'], () => handler(error as AbortError)) + expect(JSON.parse(stdout())).toMatchObject({error: {type: 'abort', message: 'Definition request failed'}}) + expect( + stderr() + .trim() + .split('\n') + .map((line) => JSON.parse(line)), + ).toEqual(expect.arrayContaining([expect.objectContaining({type: 'progress', status: 'failed'})])) + }) + }) +}) + +test.each([ + {argv: [], disabled: false}, + {argv: ['--json'], disabled: false}, + {argv: ['--no-input'], disabled: true}, + {argv: ['--json', '--no-input'], disabled: true}, +])('keeps the store input policy independent of formatting: $argv', async ({argv, disabled}) => { + await withApp(async (directory) => { + vi.stubEnv('SHOPIFY_FLAG_NO_INPUT', disabled ? '1' : '0') + vi.mocked(storeContext).mockImplementation(async () => { + expect(isInputDisabled()).toBe(disabled) + if (disabled) throw new AbortError('Select a store with --store') + return testOrganizationStore({shopDomain: 'test-shop.myshopify.com'}) + }) + await withCapturedStandardStreams(async ({stdout}) => { + if (disabled) { + await expect(runCommand(directory, argv)).rejects.toThrow('Select a store with --store') + expect(stdout()).toBe('') + expect(adminAsAppRequestDoc).not.toHaveBeenCalled() + } else { + await runCommand(directory, argv) + expect(storeContext).toHaveBeenCalledOnce() + if (argv.includes('--json')) expect(JSON.parse(stdout())).toHaveProperty('status', 'success') + else expect(stdout()).toBe('') + } + }) + }) +}) + +test('exposes the result schema and flag through both command paths', () => { + expect(ImportCustomDataDefinitions.jsonOutputSchema).toBe(importCustomDataDefinitionsJsonOutputSchema) + expect(ImportCustomDataDefinitionsDeprecated.jsonOutputSchema).toBe(importCustomDataDefinitionsJsonOutputSchema) + expect(ImportCustomDataDefinitions.flags.json).toBeDefined() + expect(ImportCustomDataDefinitionsDeprecated.flags.json).toBeDefined() + expect(ImportCustomDataDefinitions.descriptionForHelp()).toContain('`ImportCustomDataDefinitionsResult` schema') +}) diff --git a/packages/app/src/cli/commands/app/import/custom-data-definitions.ts b/packages/app/src/cli/commands/app/import/custom-data-definitions.ts index f7bd3ad150c..6f921cc0241 100644 --- a/packages/app/src/cli/commands/app/import/custom-data-definitions.ts +++ b/packages/app/src/cli/commands/app/import/custom-data-definitions.ts @@ -4,11 +4,13 @@ import AppLinkedCommand, {AppLinkedCommandOutput} from '../../../utilities/app-l import {linkedAppContext} from '../../../services/app-context.js' import {storeContext} from '../../../services/store-context.js' import {importDeclarativeDefinitions} from '../../../services/generate/shop-import/declarative-definitions.js' +import {importCustomDataDefinitionsJsonOutputSchema} from '../../../services/generate/shop-import/declarative-definitions/types.js' +import {renderImportDeclarativeDefinitionsResult} from '../../../services/generate/shop-import/declarative-definitions/result.js' import {Flags} from '@oclif/core' import {normalizeStoreFqdn} from '@shopify/cli-kit/node/context/fqdn' -import {globalFlags} from '@shopify/cli-kit/node/cli' +import {globalFlags, jsonFlag} from '@shopify/cli-kit/node/cli' import {renderSingleTask} from '@shopify/cli-kit/node/ui' -import {outputContent} from '@shopify/cli-kit/node/output' +import {outputContent, outputResult} from '@shopify/cli-kit/node/output' export default class ImportCustomDataDefinitions extends AppLinkedCommand { static summary = 'Import metafield and metaobject definitions.' @@ -20,6 +22,7 @@ export default class ImportCustomDataDefinitions extends AppLinkedCommand { static flags = { ...globalFlags, ...appFlags, + ...jsonFlag, store: Flags.string({ char: 's', description: 'Store URL. Must be an existing development or Shopify Plus sandbox store.', @@ -33,8 +36,12 @@ export default class ImportCustomDataDefinitions extends AppLinkedCommand { }), } + static get jsonOutputSchema() { + return importCustomDataDefinitionsJsonOutputSchema + } + public async run(): Promise { - const {appContextResult, ...options} = await renderSingleTask({ + const {appContextResult, json, ...options} = await renderSingleTask({ title: outputContent`Loading application`, task: async () => { const {flags} = await this.parse(ImportCustomDataDefinitions) @@ -55,6 +62,7 @@ export default class ImportCustomDataDefinitions extends AppLinkedCommand { return { appContextResult, + json: flags.json, appConfiguration: appContextResult.app.configuration, remoteApp: appContextResult.remoteApp, store, @@ -62,7 +70,23 @@ export default class ImportCustomDataDefinitions extends AppLinkedCommand { } }, }) - await importDeclarativeDefinitions(options) + const result = await importDeclarativeDefinitions(options) + if (json) { + outputResult( + importCustomDataDefinitionsJsonOutputSchema.encode({ + status: result.status, + storeDomain: result.storeDomain, + metafieldCount: result.metafieldCount, + metaobjectCount: result.metaobjectCount, + toml: result.tomlContent, + skippedSections: result.skippedSections.map((section) => + section.type === 'metafields' ? {type: section.type, ownerType: section.ownerType} : {type: section.type}, + ), + }), + ) + } else { + renderImportDeclarativeDefinitionsResult(result) + } return {app: appContextResult.app} } diff --git a/packages/app/src/cli/services/generate/shop-import/declarative-definitions.test.ts b/packages/app/src/cli/services/generate/shop-import/declarative-definitions.test.ts index 56478ef6638..6269ed71c3e 100644 --- a/packages/app/src/cli/services/generate/shop-import/declarative-definitions.test.ts +++ b/packages/app/src/cli/services/generate/shop-import/declarative-definitions.test.ts @@ -1,10 +1,13 @@ import { processDeclarativeDefinitionNodes, MetafieldNodesInput, - renderTomlStringWithFormatting, paginatedQuery, importDeclarativeDefinitions, } from './declarative-definitions.js' +import { + renderTomlStringWithFormatting, + renderImportDeclarativeDefinitionsResult, +} from './declarative-definitions/result.js' import { MetaobjectDefinitions, MetaobjectDefinitionsQuery, @@ -1099,7 +1102,7 @@ describe('paginatedQuery', () => { }) describe('importDeclarativeDefinitions', () => { - test('imports metafields and metaobjects from a shop and outputs TOML', async () => { + test('returns a known empty conversion without final terminal output', async () => { const outputMock = mockAndCaptureOutput() vi.mocked(ensureAuthenticatedAdminAsApp).mockResolvedValue({ @@ -1128,7 +1131,7 @@ describe('importDeclarativeDefinitions', () => { return {} }) - await importDeclarativeDefinitions({ + const result = await importDeclarativeDefinitions({ remoteApp: { apiKey: 'test-api-key', apiSecretKeys: [{secret: 'test-secret'}], @@ -1139,7 +1142,15 @@ describe('importDeclarativeDefinitions', () => { appConfiguration: {}, } as any) - expect(outputMock.info()).toMatchInlineSnapshot(`""`) + expect(result).toEqual({ + status: 'success', + storeDomain: 'test-shop.myshopify.com', + tomlContent: '', + metafieldCount: 0, + metaobjectCount: 0, + skippedSections: [], + }) + expect(outputMock.info()).toBe('') outputMock.clear() }) @@ -1316,10 +1327,12 @@ describe('importDeclarativeDefinitions', () => { }, } - await importDeclarativeDefinitions({ + const filteredResult = await importDeclarativeDefinitions({ ...options, includeExistingDeclaredDefinitions: false, } as any) + expect(filteredResult).toMatchObject({metafieldCount: 1, metaobjectCount: 1, skippedSections: []}) + renderImportDeclarativeDefinitionsResult(filteredResult) expect(outputMock.info()).toMatchInlineSnapshot(` "# type: $app:new @@ -1333,10 +1346,12 @@ describe('importDeclarativeDefinitions', () => { `) outputMock.clear() - await importDeclarativeDefinitions({ + const fullResult = await importDeclarativeDefinitions({ ...options, includeExistingDeclaredDefinitions: true, } as any) + expect(fullResult).toMatchObject({metafieldCount: 2, metaobjectCount: 2, skippedSections: []}) + renderImportDeclarativeDefinitionsResult(fullResult) expect(outputMock.info()).toMatchInlineSnapshot(` "# type: $app:existing diff --git a/packages/app/src/cli/services/generate/shop-import/declarative-definitions.ts b/packages/app/src/cli/services/generate/shop-import/declarative-definitions.ts index 0930bccdb2d..1d308df11b4 100644 --- a/packages/app/src/cli/services/generate/shop-import/declarative-definitions.ts +++ b/packages/app/src/cli/services/generate/shop-import/declarative-definitions.ts @@ -1,5 +1,6 @@ /* eslint-disable @typescript-eslint/prefer-nullish-coalescing */ import {FieldObject, Metafield, MetafieldOwners, MetaObject, ValidationRule} from './dcdd.js' +import {ImportDeclarativeDefinitionsResult, ImportCustomDataDefinitionsResult} from './declarative-definitions/types.js' import {OrganizationApp, OrganizationStore} from '../../../models/organization.js' import { MetafieldDefinitions, @@ -21,11 +22,11 @@ import { import {CurrentAppConfiguration} from '../../../models/app/app.js' import {BugError} from '@shopify/cli-kit/node/error' import {AdminSession, ensureAuthenticatedAdminAsApp} from '@shopify/cli-kit/node/session' -import {outputContent, outputInfo, outputToken} from '@shopify/cli-kit/node/output' +import {outputContent, outputToken} from '@shopify/cli-kit/node/output' import {TypedDocumentNode} from '@graphql-typed-document-node/core' import {Variables} from 'graphql-request' import {updateTomlValues} from '@shopify/toml-patch' -import {renderInfo, renderSingleTask, renderTasks} from '@shopify/cli-kit/node/ui' +import {renderSingleTask, renderTasks} from '@shopify/cli-kit/node/ui' import {isEmpty} from '@shopify/cli-kit/common/object' interface ImportDeclarativeDefinitionsOptions { @@ -187,12 +188,14 @@ export function processDeclarativeDefinitionNodes( } } -export async function importDeclarativeDefinitions(options: ImportDeclarativeDefinitionsOptions) { +export async function importDeclarativeDefinitions( + options: ImportDeclarativeDefinitionsOptions, +): Promise { const adminSession = await createAdminApiSessionForShop(options) - const shopName = adminSession.storeFqdn - - let metafieldNodes: MetafieldNodesInput[] = await loadMetafieldNodes(adminSession) - let metaobjectNodes: MetaobjectForImportFragment[] = await loadMetaobjectNodes(adminSession) + const metafields = await loadMetafieldNodes(adminSession) + const metaobjects = await loadMetaobjectNodes(adminSession) + let metafieldNodes = metafields.nodes + let metaobjectNodes = metaobjects.nodes if (!options.includeExistingDeclaredDefinitions) { metafieldNodes = filterOutDeclaredMetafields(metafieldNodes, options.appConfiguration) @@ -204,7 +207,14 @@ export async function importDeclarativeDefinitions(options: ImportDeclarativeDef metaobjectNodes, ) - renderConversionSummary(metafieldCount, metaobjectCount, shopName, tomlContent) + return { + status: 'success', + storeDomain: adminSession.storeFqdn, + metafieldCount, + metaobjectCount, + tomlContent, + skippedSections: [...metafields.skippedSections, ...metaobjects.skippedSections], + } } type ConvertedMetafield = @@ -218,64 +228,10 @@ type ConvertedMetafield = status: 'not_app_reserved' } -function renderConversionSummary( - metafieldCount: number, - metaobjectCount: number, - shopName: string, - tomlContent: string, -) { - renderInfo({ - headline: 'Conversion to TOML complete.', - body: [ - 'Converted', - { - warn: `${metafieldCount} metafields`, - }, - 'and', - { - warn: `${metaobjectCount} metaobjects`, - }, - 'from', - { - warn: shopName, - }, - 'into TOML, ready for you to copy.', - ], - orderedNextSteps: true, - nextSteps: [ - 'Review the suggested TOML carefully before applying.', - [ - 'Missing sections? Make sure your app has the required access scopes to load metafields and metaobjects (e.g.', - { - command: 'read_customers', - }, - 'to load customer metafields,', - { - command: 'read_metaobject_definitions', - }, - 'to load metaobjects.)', - ], - [ - 'Missing definitions? Only metafields and metaobjects that are app-reserved (using', - { - command: '$app', - }, - ') will be converted.', - ], - [ - "When you're ready, add the generated TOML to your app's configuration file and test out changes with the", - { - command: 'shopify app dev', - }, - 'command.', - ], - ], - }) - - renderTomlStringWithFormatting(tomlContent) -} - -async function loadMetafieldNodes(adminSession: AdminSession): Promise { +async function loadMetafieldNodes(adminSession: AdminSession): Promise<{ + nodes: MetafieldNodesInput[] + skippedSections: ImportCustomDataDefinitionsResult['skippedSections'] +}> { const metafieldLoadResults: { metafields: PaginatedQueryResult ownerType: MetafieldOwners @@ -304,21 +260,25 @@ async function loadMetafieldNodes(adminSession: AdminSession): Promise { - if (metafields.status === 'ok') { - return { - ownerType, - items: metafields.items, - graphQLOwner, + return { + nodes: metafieldLoadResults + .map(({metafields, ownerType, graphQLOwner}) => { + if (metafields.status === 'ok') { + return {ownerType, items: metafields.items, graphQLOwner} } - } - return null - }) - .filter((item) => item !== null) + return null + }) + .filter((item) => item !== null), + skippedSections: metafieldLoadResults + .filter(({metafields}) => metafields.status === 'scope_error') + .map(({graphQLOwner}) => ({type: 'metafields', ownerType: graphQLOwner})), + } } -async function loadMetaobjectNodes(adminSession: AdminSession): Promise { +async function loadMetaobjectNodes(adminSession: AdminSession): Promise<{ + nodes: MetaobjectForImportFragment[] + skippedSections: ImportCustomDataDefinitionsResult['skippedSections'] +}> { const metaobjects = await renderSingleTask({ title: outputContent`Loading ${outputToken.green('metaobjects')}`, task: async () => { @@ -332,7 +292,9 @@ async function loadMetaobjectNodes(adminSession: AdminSession): Promise { + const toml = + '# namespace: $app key: café owner_type: PRODUCT\r\n[product.metafields.app.café]\r\ntype = "single_line_text_field"\r\n' + expect( + JSON.parse(importCustomDataDefinitionsJsonOutputSchema.encode({...emptyResult, metafieldCount: 1, toml})), + ).toEqual({ + ...emptyResult, + metafieldCount: 1, + toml, + }) +}) + +test('distinguishes known empty conversions from inaccessible sections', () => { + expect(JSON.parse(importCustomDataDefinitionsJsonOutputSchema.encode(emptyResult))).toEqual(emptyResult) + const skippedSections: ImportCustomDataDefinitionsResult['skippedSections'] = [ + {type: 'metafields', ownerType: 'PRODUCT'}, + {type: 'metaobjects'}, + ] + expect(JSON.parse(importCustomDataDefinitionsJsonOutputSchema.encode({...emptyResult, skippedSections}))).toEqual({ + ...emptyResult, + skippedSections, + }) +}) + +test.each([ + {field: 'status', value: 'failed'}, + {field: 'storeDomain', value: 'example.com'}, + {field: 'storeDomain', value: 'test-shop.myshopify.com.example.com'}, + {field: 'metafieldCount', value: -1}, + {field: 'metafieldCount', value: 0.5}, + {field: 'metaobjectCount', value: null}, + {field: 'toml', value: null}, + {field: 'skippedSections', value: [{type: 'metafields'}]}, + {field: 'skippedSections', value: [{type: 'metafields', ownerType: ''}]}, + {field: 'skippedSections', value: [{type: 'metaobjects', ownerType: 'PRODUCT'}]}, + {field: 'skippedSections', value: [{type: 'unknown'}]}, +])('rejects an invalid $field: $value', ({field, value}) => { + expect(() => importCustomDataDefinitionsJsonOutputSchema.validate({...emptyResult, [field]: value})).toThrow() +}) + +test('rejects accidental internal result fields', () => { + expect(() => importCustomDataDefinitionsJsonOutputSchema.validate({...emptyResult, accessToken: 'secret'})).toThrow() +}) diff --git a/packages/app/src/cli/services/generate/shop-import/declarative-definitions/types.ts b/packages/app/src/cli/services/generate/shop-import/declarative-definitions/types.ts new file mode 100644 index 00000000000..83efe96c4c6 --- /dev/null +++ b/packages/app/src/cli/services/generate/shop-import/declarative-definitions/types.ts @@ -0,0 +1,50 @@ +import {defineJsonOutputSchema, type InferJsonOutputSchema} from '@shopify/cli-kit/node/json-output-schema' +import {zod} from '@shopify/cli-kit/node/schema' + +const skippedMetafieldsSchema = zod + .object({ + type: zod.literal('metafields'), + ownerType: zod.string().min(1).describe('The Admin API metafield owner type, such as PRODUCT.'), + }) + .strict() + +const skippedMetaobjectsSchema = zod.object({type: zod.literal('metaobjects')}).strict() + +export const importCustomDataDefinitionsJsonOutputSchema = defineJsonOutputSchema({ + name: 'ImportCustomDataDefinitionsResult', + schema: zod + .object({ + status: zod.literal('success'), + storeDomain: zod + .string() + .regex(/^[a-z0-9][a-z0-9-]*\.myshopify\.com$/) + .describe('The full myshopify.com domain of the development store.'), + metafieldCount: zod + .number() + .int() + .nonnegative() + .describe('The number of available metafields converted to TOML.'), + metaobjectCount: zod + .number() + .int() + .nonnegative() + .describe('The number of available metaobjects converted to TOML.'), + toml: zod.string().describe('Suggested native TOML for app-reserved definitions. No file is written.'), + skippedSections: zod + .array(zod.discriminatedUnion('type', [skippedMetafieldsSchema, skippedMetaobjectsSchema])) + .describe( + 'Sections skipped because required access scopes are unavailable. An empty array means all requests were authorized.', + ), + }) + .strict(), + definitions: { + SkippedMetafields: skippedMetafieldsSchema, + SkippedMetaobjects: skippedMetaobjectsSchema, + }, +}) + +export type ImportCustomDataDefinitionsResult = InferJsonOutputSchema< + typeof importCustomDataDefinitionsJsonOutputSchema +> + +export type ImportDeclarativeDefinitionsResult = Omit & {tomlContent: string} diff --git a/packages/cli/README.md b/packages/cli/README.md index 95b19a8e8b7..2cfbb14eff9 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -2155,7 +2155,7 @@ Import metafield and metaobject definitions. ``` USAGE - $ shopify app import custom-data-definitions [--auth-alias ] [--client-id | -c ] [--include-existing] + $ shopify app import custom-data-definitions [--auth-alias ] [--client-id | -c ] [--include-existing] [-j] [--json-schema] [--no-color] [--no-input] [--path ] [--reset | ] [-s ] [--verbose] FLAGS @@ -2163,6 +2163,10 @@ FLAGS 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] + -s, --store= Store URL. Must be an existing development or Shopify Plus sandbox store. [env: SHOPIFY_FLAG_STORE] @@ -2208,6 +2212,100 @@ DESCRIPTION Import metafield and metaobject definitions from your development store. "Read more about declarative custom data definitions" (https://shopify.dev/docs/apps/build/custom-data/declarative-custom-data-definitions). + + Use `--json-schema` to print the result, error, and event schemas. + + Output from `--json` conforms to the `ImportCustomDataDefinitionsResult` schema. + + ```json + { + "type": "object", + "properties": { + "status": { + "type": "string", + "const": "success" + }, + "storeDomain": { + "type": "string", + "pattern": "^[a-z0-9][a-z0-9-]*\\.myshopify\\.com$", + "description": "The full myshopify.com domain of the development store." + }, + "metafieldCount": { + "type": "integer", + "minimum": 0, + "description": "The number of available metafields converted to TOML." + }, + "metaobjectCount": { + "type": "integer", + "minimum": 0, + "description": "The number of available metaobjects converted to TOML." + }, + "toml": { + "type": "string", + "description": "Suggested native TOML for app-reserved definitions. No file is written." + }, + "skippedSections": { + "type": "array", + "items": { + "anyOf": [ + { + "$ref": "#/definitions/SkippedMetafields" + }, + { + "$ref": "#/definitions/SkippedMetaobjects" + } + ] + }, + "description": "Sections skipped because required access scopes are unavailable. An empty array means all requests were authorized." + } + }, + "required": [ + "status", + "storeDomain", + "metafieldCount", + "metaobjectCount", + "toml", + "skippedSections" + ], + "additionalProperties": false, + "title": "ImportCustomDataDefinitionsResult", + "definitions": { + "SkippedMetafields": { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "metafields" + }, + "ownerType": { + "type": "string", + "minLength": 1, + "description": "The Admin API metafield owner type, such as PRODUCT." + } + }, + "required": [ + "type", + "ownerType" + ], + "additionalProperties": false + }, + "SkippedMetaobjects": { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "metaobjects" + } + }, + "required": [ + "type" + ], + "additionalProperties": false + } + }, + "$schema": "http://json-schema.org/draft-07/schema#" + } + ``` ``` ## `shopify app import dashboard-extensions` diff --git a/packages/cli/oclif.manifest.json b/packages/cli/oclif.manifest.json index 31e82b9ad49..e77aba43d26 100644 --- a/packages/cli/oclif.manifest.json +++ b/packages/cli/oclif.manifest.json @@ -2831,7 +2831,7 @@ "args": { }, "customPluginName": "@shopify/app", - "description": "Import metafield and metaobject definitions from your development store. \"Read more about declarative custom data definitions\" (https://shopify.dev/docs/apps/build/custom-data/declarative-custom-data-definitions).", + "description": "Import metafield and metaobject definitions from your development store. \"Read more about declarative custom data definitions\" (https://shopify.dev/docs/apps/build/custom-data/declarative-custom-data-definitions).\n\nUse `--json-schema` to print the result, error, and event schemas.\n\nOutput from `--json` conforms to the `ImportCustomDataDefinitionsResult` schema.\n\n```json\n{\n \"type\": \"object\",\n \"properties\": {\n \"status\": {\n \"type\": \"string\",\n \"const\": \"success\"\n },\n \"storeDomain\": {\n \"type\": \"string\",\n \"pattern\": \"^[a-z0-9][a-z0-9-]*\\\\.myshopify\\\\.com$\",\n \"description\": \"The full myshopify.com domain of the development store.\"\n },\n \"metafieldCount\": {\n \"type\": \"integer\",\n \"minimum\": 0,\n \"description\": \"The number of available metafields converted to TOML.\"\n },\n \"metaobjectCount\": {\n \"type\": \"integer\",\n \"minimum\": 0,\n \"description\": \"The number of available metaobjects converted to TOML.\"\n },\n \"toml\": {\n \"type\": \"string\",\n \"description\": \"Suggested native TOML for app-reserved definitions. No file is written.\"\n },\n \"skippedSections\": {\n \"type\": \"array\",\n \"items\": {\n \"anyOf\": [\n {\n \"$ref\": \"#/definitions/SkippedMetafields\"\n },\n {\n \"$ref\": \"#/definitions/SkippedMetaobjects\"\n }\n ]\n },\n \"description\": \"Sections skipped because required access scopes are unavailable. An empty array means all requests were authorized.\"\n }\n },\n \"required\": [\n \"status\",\n \"storeDomain\",\n \"metafieldCount\",\n \"metaobjectCount\",\n \"toml\",\n \"skippedSections\"\n ],\n \"additionalProperties\": false,\n \"title\": \"ImportCustomDataDefinitionsResult\",\n \"definitions\": {\n \"SkippedMetafields\": {\n \"type\": \"object\",\n \"properties\": {\n \"type\": {\n \"type\": \"string\",\n \"const\": \"metafields\"\n },\n \"ownerType\": {\n \"type\": \"string\",\n \"minLength\": 1,\n \"description\": \"The Admin API metafield owner type, such as PRODUCT.\"\n }\n },\n \"required\": [\n \"type\",\n \"ownerType\"\n ],\n \"additionalProperties\": false\n },\n \"SkippedMetaobjects\": {\n \"type\": \"object\",\n \"properties\": {\n \"type\": {\n \"type\": \"string\",\n \"const\": \"metaobjects\"\n }\n },\n \"required\": [\n \"type\"\n ],\n \"additionalProperties\": false\n }\n },\n \"$schema\": \"http://json-schema.org/draft-07/schema#\"\n}\n```", "descriptionWithMarkdown": "Import metafield and metaobject definitions from your development store. [Read more about declarative custom data definitions](https://shopify.dev/docs/apps/build/custom-data/declarative-custom-data-definitions).", "flags": { "auth-alias": { @@ -2871,6 +2871,15 @@ "name": "include-existing", "type": "boolean" }, + "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.", @@ -3162,7 +3171,7 @@ "args": { }, "customPluginName": "@shopify/app", - "description": "Import metafield and metaobject definitions from your development store. \"Read more about declarative custom data definitions\" (https://shopify.dev/docs/apps/build/custom-data/declarative-custom-data-definitions).", + "description": "Import metafield and metaobject definitions from your development store. \"Read more about declarative custom data definitions\" (https://shopify.dev/docs/apps/build/custom-data/declarative-custom-data-definitions).\n\nUse `--json-schema` to print the result, error, and event schemas.\n\nOutput from `--json` conforms to the `ImportCustomDataDefinitionsResult` schema.\n\n```json\n{\n \"type\": \"object\",\n \"properties\": {\n \"status\": {\n \"type\": \"string\",\n \"const\": \"success\"\n },\n \"storeDomain\": {\n \"type\": \"string\",\n \"pattern\": \"^[a-z0-9][a-z0-9-]*\\\\.myshopify\\\\.com$\",\n \"description\": \"The full myshopify.com domain of the development store.\"\n },\n \"metafieldCount\": {\n \"type\": \"integer\",\n \"minimum\": 0,\n \"description\": \"The number of available metafields converted to TOML.\"\n },\n \"metaobjectCount\": {\n \"type\": \"integer\",\n \"minimum\": 0,\n \"description\": \"The number of available metaobjects converted to TOML.\"\n },\n \"toml\": {\n \"type\": \"string\",\n \"description\": \"Suggested native TOML for app-reserved definitions. No file is written.\"\n },\n \"skippedSections\": {\n \"type\": \"array\",\n \"items\": {\n \"anyOf\": [\n {\n \"$ref\": \"#/definitions/SkippedMetafields\"\n },\n {\n \"$ref\": \"#/definitions/SkippedMetaobjects\"\n }\n ]\n },\n \"description\": \"Sections skipped because required access scopes are unavailable. An empty array means all requests were authorized.\"\n }\n },\n \"required\": [\n \"status\",\n \"storeDomain\",\n \"metafieldCount\",\n \"metaobjectCount\",\n \"toml\",\n \"skippedSections\"\n ],\n \"additionalProperties\": false,\n \"title\": \"ImportCustomDataDefinitionsResult\",\n \"definitions\": {\n \"SkippedMetafields\": {\n \"type\": \"object\",\n \"properties\": {\n \"type\": {\n \"type\": \"string\",\n \"const\": \"metafields\"\n },\n \"ownerType\": {\n \"type\": \"string\",\n \"minLength\": 1,\n \"description\": \"The Admin API metafield owner type, such as PRODUCT.\"\n }\n },\n \"required\": [\n \"type\",\n \"ownerType\"\n ],\n \"additionalProperties\": false\n },\n \"SkippedMetaobjects\": {\n \"type\": \"object\",\n \"properties\": {\n \"type\": {\n \"type\": \"string\",\n \"const\": \"metaobjects\"\n }\n },\n \"required\": [\n \"type\"\n ],\n \"additionalProperties\": false\n }\n },\n \"$schema\": \"http://json-schema.org/draft-07/schema#\"\n}\n```", "descriptionWithMarkdown": "Import metafield and metaobject definitions from your development store. [Read more about declarative custom data definitions](https://shopify.dev/docs/apps/build/custom-data/declarative-custom-data-definitions).", "flags": { "auth-alias": { @@ -3202,6 +3211,15 @@ "name": "include-existing", "type": "boolean" }, + "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 0fcd20c5ff1..1bb02024741 100644 --- a/packages/eslint-plugin-cli/rules/json-output-command-exceptions.js +++ b/packages/eslint-plugin-cli/rules/json-output-command-exceptions.js @@ -19,7 +19,6 @@ const commandExceptions = [ 'packages/app/src/cli/commands/app/function/schema.ts', 'packages/app/src/cli/commands/app/function/typegen.ts', 'packages/app/src/cli/commands/app/generate/extension.ts', - 'packages/app/src/cli/commands/app/import/custom-data-definitions.ts', 'packages/app/src/cli/commands/app/import/dashboard-extensions.ts', 'packages/app/src/cli/commands/app/init.ts', 'packages/app/src/cli/commands/app/release.ts', From af5fd2050902f1c9a3ac3e802ae6fc03a8e13b84 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Isaac=20Rold=C3=A1n?= Date: Wed, 7 Oct 2026 13:42:27 +0200 Subject: [PATCH 2/8] Keep alias warning assertion compatible with diagnostic events --- .../src/cli/commands/app/import/custom-data-definitions.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/app/src/cli/commands/app/import/custom-data-definitions.test.ts b/packages/app/src/cli/commands/app/import/custom-data-definitions.test.ts index 6ea35d6ee78..251e4cc33ca 100644 --- a/packages/app/src/cli/commands/app/import/custom-data-definitions.test.ts +++ b/packages/app/src/cli/commands/app/import/custom-data-definitions.test.ts @@ -181,7 +181,7 @@ test('preserves the deprecated alias result and warning on stderr', async () => await withCapturedStandardStreams(async ({stdout, stderr}) => { await runCommand(directory, ['--json'], true) expect(JSON.parse(stdout())).toMatchObject({status: 'success', metafieldCount: 1, toml}) - expect(unstyled(stderr())).toContain('`shopify app import-custom-data-definitions` has moved.') + expect(unstyled(stderr()).replaceAll('`', '')).toContain('shopify app import-custom-data-definitions has moved.') }) }) }) From 6d550a2b09e35adfc54d770debf73ef066079964 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Isaac=20Rold=C3=A1n?= Date: Wed, 7 Oct 2026 14:42:05 +0200 Subject: [PATCH 3/8] Keep custom-data JSON tests focused on changed output --- .../import/custom-data-definitions.test.ts | 115 +++--------------- .../declarative-definitions/types.test.ts | 55 --------- 2 files changed, 17 insertions(+), 153 deletions(-) delete mode 100644 packages/app/src/cli/services/generate/shop-import/declarative-definitions/types.test.ts diff --git a/packages/app/src/cli/commands/app/import/custom-data-definitions.test.ts b/packages/app/src/cli/commands/app/import/custom-data-definitions.test.ts index 251e4cc33ca..a79fa8f7928 100644 --- a/packages/app/src/cli/commands/app/import/custom-data-definitions.test.ts +++ b/packages/app/src/cli/commands/app/import/custom-data-definitions.test.ts @@ -1,11 +1,8 @@ import ImportCustomDataDefinitions from './custom-data-definitions.js' -import ImportCustomDataDefinitionsDeprecated from '../import-custom-data-definitions.js' import {linkedAppContext} from '../../../services/app-context.js' import {storeContext} from '../../../services/store-context.js' import {adminAsAppRequestDoc} from '../../../api/admin-as-app.js' -import {MetaobjectDefinitions} from '../../../api/graphql/admin/generated/metaobject_definitions.js' import {MetafieldDefinitions} from '../../../api/graphql/admin/generated/metafield_definitions.js' -import {importCustomDataDefinitionsJsonOutputSchema} from '../../../services/generate/shop-import/declarative-definitions/types.js' import {testAppLinked, testOrganizationApp, testOrganizationStore} from '../../../models/app/app.test-data.js' import {Config} from '@oclif/core' import {afterEach, expect, test, vi} from 'vitest' @@ -16,7 +13,6 @@ import {runWithCommandEventsForCommand} from '@shopify/cli-kit/node/command-even import {ensureAuthenticatedAdminAsApp} from '@shopify/cli-kit/node/session' import {unstyled} from '@shopify/cli-kit/node/output' import {AbortError, handler} from '@shopify/cli-kit/node/error' -import {isInputDisabled} from '@shopify/cli-kit/node/no-input' // eslint-disable-next-line n/prefer-global/console import {Console} from 'node:console' @@ -77,18 +73,16 @@ async function withApp(run: (directory: string) => Promise) { }) } -async function runCommand(directory: string, argv: string[], deprecated = false) { - const Command = deprecated ? ImportCustomDataDefinitionsDeprecated : ImportCustomDataDefinitions +async function runCommand(directory: string, argv: string[]) { const args = ['--path', directory, ...argv] - const command = new Command(args, await Config.load()) + const command = new ImportCustomDataDefinitions(args, await Config.load()) return runWithCommandEventsForCommand(args, () => command.run()) } test('writes one encoded JSON document and progress events without changing the local TOML', async () => { await withApp(async (directory) => { await withCapturedStandardStreams(async ({stdout, stderr}) => { - const result = await runCommand(directory, ['--json', '--store', 'test-shop']) - expect(result.app.directory).toBe(directory) + await runCommand(directory, ['--json', '--store', 'test-shop']) expect(JSON.parse(stdout())).toEqual({ status: 'success', storeDomain: 'test-shop.myshopify.com', @@ -105,63 +99,34 @@ test('writes one encoded JSON document and progress events without changing the expect(events.every((event) => event.type === 'progress')).toBe(true) expect(stderr()).not.toContain('Conversion to TOML complete') }) - expect(storeContext).toHaveBeenCalledWith(expect.objectContaining({storeFqdn: 'test-shop.myshopify.com'})) }) }) -test('returns a known empty conversion with no skipped sections', async () => { +test.each([false, true])('distinguishes an empty conversion from denied scope: %s', async (scopeDenied) => { await withApp(async (directory) => { - vi.mocked(adminAsAppRequestDoc).mockImplementation(async ({query}) => ({ - [query === MetafieldDefinitions ? 'metafieldDefinitions' : 'metaobjectDefinitions']: { - pageInfo: {hasNextPage: false, endCursor: null}, - nodes: [], - }, - })) + vi.mocked(adminAsAppRequestDoc).mockImplementation(async ({query, variables}) => { + if (scopeDenied && query === MetafieldDefinitions && variables?.ownerType === 'PRODUCT') { + throw new Error('ACCESS_DENIED: Missing access scope') + } + return { + [query === MetafieldDefinitions ? 'metafieldDefinitions' : 'metaobjectDefinitions']: { + pageInfo: {hasNextPage: false, endCursor: null}, + nodes: [], + }, + } + }) await withCapturedStandardStreams(async ({stdout}) => { await runCommand(directory, ['--json']) - expect(JSON.parse(stdout())).toEqual({ - status: 'success', - storeDomain: 'test-shop.myshopify.com', + expect(JSON.parse(stdout())).toMatchObject({ metafieldCount: 0, metaobjectCount: 0, toml: '', - skippedSections: [], + skippedSections: scopeDenied ? [{type: 'metafields', ownerType: 'PRODUCT'}] : [], }) }) }) }) -test.each([{type: 'metafields'}, {type: 'metaobjects'}])( - 'reports inaccessible $type separately from available empty definitions', - async ({type}) => { - await withApp(async (directory) => { - const original = vi.mocked(adminAsAppRequestDoc).getMockImplementation()! - vi.mocked(adminAsAppRequestDoc).mockImplementation(async (options) => { - if ( - (type === 'metafields' && - options.query === MetafieldDefinitions && - options.variables?.ownerType === 'PRODUCT') || - (type === 'metaobjects' && options.query === MetaobjectDefinitions) - ) { - throw new Error('ACCESS_DENIED: Missing access scope') - } - return original(options) - }) - await withCapturedStandardStreams(async ({stdout}) => { - await runCommand(directory, ['--json']) - expect(JSON.parse(stdout())).toEqual({ - status: 'success', - storeDomain: 'test-shop.myshopify.com', - metafieldCount: type === 'metafields' ? 0 : 1, - metaobjectCount: 0, - toml: type === 'metafields' ? '' : toml, - skippedSections: type === 'metafields' ? [{type, ownerType: 'PRODUCT'}] : [{type}], - }) - }) - }) - }, -) - test('keeps the existing conversion summary and native TOML on stderr in text mode', async () => { await withApp(async (directory) => { await withCapturedStandardStreams(async ({stdout, stderr}) => { @@ -176,16 +141,6 @@ test('keeps the existing conversion summary and native TOML on stderr in text mo }) }) -test('preserves the deprecated alias result and warning on stderr', async () => { - await withApp(async (directory) => { - await withCapturedStandardStreams(async ({stdout, stderr}) => { - await runCommand(directory, ['--json'], true) - expect(JSON.parse(stdout())).toMatchObject({status: 'success', metafieldCount: 1, toml}) - expect(unstyled(stderr()).replaceAll('`', '')).toContain('shopify app import-custom-data-definitions has moved.') - }) - }) -}) - test('preserves a transport failure without printing a success result', async () => { await withApp(async (directory) => { vi.mocked(adminAsAppRequestDoc).mockRejectedValue(new AbortError('Definition request failed')) @@ -205,39 +160,3 @@ test('preserves a transport failure without printing a success result', async () }) }) }) - -test.each([ - {argv: [], disabled: false}, - {argv: ['--json'], disabled: false}, - {argv: ['--no-input'], disabled: true}, - {argv: ['--json', '--no-input'], disabled: true}, -])('keeps the store input policy independent of formatting: $argv', async ({argv, disabled}) => { - await withApp(async (directory) => { - vi.stubEnv('SHOPIFY_FLAG_NO_INPUT', disabled ? '1' : '0') - vi.mocked(storeContext).mockImplementation(async () => { - expect(isInputDisabled()).toBe(disabled) - if (disabled) throw new AbortError('Select a store with --store') - return testOrganizationStore({shopDomain: 'test-shop.myshopify.com'}) - }) - await withCapturedStandardStreams(async ({stdout}) => { - if (disabled) { - await expect(runCommand(directory, argv)).rejects.toThrow('Select a store with --store') - expect(stdout()).toBe('') - expect(adminAsAppRequestDoc).not.toHaveBeenCalled() - } else { - await runCommand(directory, argv) - expect(storeContext).toHaveBeenCalledOnce() - if (argv.includes('--json')) expect(JSON.parse(stdout())).toHaveProperty('status', 'success') - else expect(stdout()).toBe('') - } - }) - }) -}) - -test('exposes the result schema and flag through both command paths', () => { - expect(ImportCustomDataDefinitions.jsonOutputSchema).toBe(importCustomDataDefinitionsJsonOutputSchema) - expect(ImportCustomDataDefinitionsDeprecated.jsonOutputSchema).toBe(importCustomDataDefinitionsJsonOutputSchema) - expect(ImportCustomDataDefinitions.flags.json).toBeDefined() - expect(ImportCustomDataDefinitionsDeprecated.flags.json).toBeDefined() - expect(ImportCustomDataDefinitions.descriptionForHelp()).toContain('`ImportCustomDataDefinitionsResult` schema') -}) diff --git a/packages/app/src/cli/services/generate/shop-import/declarative-definitions/types.test.ts b/packages/app/src/cli/services/generate/shop-import/declarative-definitions/types.test.ts deleted file mode 100644 index 3402062169f..00000000000 --- a/packages/app/src/cli/services/generate/shop-import/declarative-definitions/types.test.ts +++ /dev/null @@ -1,55 +0,0 @@ -import {importCustomDataDefinitionsJsonOutputSchema, type ImportCustomDataDefinitionsResult} from './types.js' -import {expect, test} from 'vitest' - -const emptyResult: ImportCustomDataDefinitionsResult = { - status: 'success', - storeDomain: 'test-shop.myshopify.com', - metafieldCount: 0, - metaobjectCount: 0, - toml: '', - skippedSections: [], -} - -test('encodes native TOML without changing its bytes', () => { - const toml = - '# namespace: $app key: café owner_type: PRODUCT\r\n[product.metafields.app.café]\r\ntype = "single_line_text_field"\r\n' - expect( - JSON.parse(importCustomDataDefinitionsJsonOutputSchema.encode({...emptyResult, metafieldCount: 1, toml})), - ).toEqual({ - ...emptyResult, - metafieldCount: 1, - toml, - }) -}) - -test('distinguishes known empty conversions from inaccessible sections', () => { - expect(JSON.parse(importCustomDataDefinitionsJsonOutputSchema.encode(emptyResult))).toEqual(emptyResult) - const skippedSections: ImportCustomDataDefinitionsResult['skippedSections'] = [ - {type: 'metafields', ownerType: 'PRODUCT'}, - {type: 'metaobjects'}, - ] - expect(JSON.parse(importCustomDataDefinitionsJsonOutputSchema.encode({...emptyResult, skippedSections}))).toEqual({ - ...emptyResult, - skippedSections, - }) -}) - -test.each([ - {field: 'status', value: 'failed'}, - {field: 'storeDomain', value: 'example.com'}, - {field: 'storeDomain', value: 'test-shop.myshopify.com.example.com'}, - {field: 'metafieldCount', value: -1}, - {field: 'metafieldCount', value: 0.5}, - {field: 'metaobjectCount', value: null}, - {field: 'toml', value: null}, - {field: 'skippedSections', value: [{type: 'metafields'}]}, - {field: 'skippedSections', value: [{type: 'metafields', ownerType: ''}]}, - {field: 'skippedSections', value: [{type: 'metaobjects', ownerType: 'PRODUCT'}]}, - {field: 'skippedSections', value: [{type: 'unknown'}]}, -])('rejects an invalid $field: $value', ({field, value}) => { - expect(() => importCustomDataDefinitionsJsonOutputSchema.validate({...emptyResult, [field]: value})).toThrow() -}) - -test('rejects accidental internal result fields', () => { - expect(() => importCustomDataDefinitionsJsonOutputSchema.validate({...emptyResult, accessToken: 'secret'})).toThrow() -}) From 82faa07bc9dacc700c4373486ceabd7b52156020 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Isaac=20Rold=C3=A1n?= Date: Wed, 7 Oct 2026 15:52:28 +0200 Subject: [PATCH 4/8] Use a static schema field for custom-data imports --- .../src/cli/commands/app/import/custom-data-definitions.ts | 6 ++---- packages/eslint-plugin-cli/rules/command-json-output.js | 4 +++- .../eslint-plugin-cli/rules/command-json-output.test.js | 4 +--- 3 files changed, 6 insertions(+), 8 deletions(-) diff --git a/packages/app/src/cli/commands/app/import/custom-data-definitions.ts b/packages/app/src/cli/commands/app/import/custom-data-definitions.ts index 6f921cc0241..a5a9b1b7efc 100644 --- a/packages/app/src/cli/commands/app/import/custom-data-definitions.ts +++ b/packages/app/src/cli/commands/app/import/custom-data-definitions.ts @@ -17,6 +17,8 @@ export default class ImportCustomDataDefinitions extends AppLinkedCommand { static descriptionWithMarkdown = `Import metafield and metaobject definitions from your development store. [Read more about declarative custom data definitions](https://shopify.dev/docs/apps/build/custom-data/declarative-custom-data-definitions).` + static jsonOutputSchema = importCustomDataDefinitionsJsonOutputSchema + static description = this.descriptionForHelp() static flags = { @@ -36,10 +38,6 @@ export default class ImportCustomDataDefinitions extends AppLinkedCommand { }), } - static get jsonOutputSchema() { - return importCustomDataDefinitionsJsonOutputSchema - } - public async run(): Promise { const {appContextResult, json, ...options} = await renderSingleTask({ title: outputContent`Loading application`, diff --git a/packages/eslint-plugin-cli/rules/command-json-output.js b/packages/eslint-plugin-cli/rules/command-json-output.js index a6493344283..4d41adab57a 100644 --- a/packages/eslint-plugin-cli/rules/command-json-output.js +++ b/packages/eslint-plugin-cli/rules/command-json-output.js @@ -62,7 +62,9 @@ function repositoryPath(filename) { function hasJsonOutputSchema(classMembers) { return classMembers.some( (member) => - member.type === 'MethodDefinition' && member.kind === 'get' && isStaticMemberNamed(member, 'jsonOutputSchema'), + isStaticMemberNamed(member, 'jsonOutputSchema') && + ((member.type === 'MethodDefinition' && member.kind === 'get') || + (member.type === 'PropertyDefinition' && Boolean(member.value))), ) } diff --git a/packages/eslint-plugin-cli/rules/command-json-output.test.js b/packages/eslint-plugin-cli/rules/command-json-output.test.js index 5eaa3d46144..b095246b579 100644 --- a/packages/eslint-plugin-cli/rules/command-json-output.test.js +++ b/packages/eslint-plugin-cli/rules/command-json-output.test.js @@ -37,9 +37,7 @@ ruleTester.run('command-json-output', rule, { code: ` export default class WidgetDelete extends Command { static flags = {...globalFlags, ...jsonFlag} - static get jsonOutputSchema() { - return widgetDeleteJsonOutputSchema - } + static jsonOutputSchema = widgetDeleteJsonOutputSchema } `, }, From 5f5bfd2eb75f9f8265d7d8a5ad60f31661df8889 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Isaac=20Rold=C3=A1n?= Date: Wed, 7 Oct 2026 15:58:23 +0200 Subject: [PATCH 5/8] Keep runtime JSON schemas out of cached manifests --- bin/prettify-manifests.js | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/bin/prettify-manifests.js b/bin/prettify-manifests.js index 2363b227819..5ce134edb21 100755 --- a/bin/prettify-manifests.js +++ b/bin/prettify-manifests.js @@ -13,6 +13,11 @@ const manifestFiles = glob.sync(`packages/*/oclif.manifest.json`) for (const file of manifestFiles) { console.log(`Prettifying ${file}...`) const content = fs.readFileSync(file) - const prettyContent = stringify(JSON.parse(content), {space: ' '}).replaceAll(root, '.') + const manifest = JSON.parse(content) + for (const command of Object.values(manifest.commands)) { + // Runtime schemas belong to command classes, not cached metadata. + delete command.jsonOutputSchema + } + const prettyContent = stringify(manifest, {space: ' '}).replaceAll(root, '.') fs.writeFileSync(file, prettyContent) } From 4d86055a417b8c3984c855fc60d8d0daff44d67e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Isaac=20Rold=C3=A1n?= Date: Thu, 8 Oct 2026 17:47:51 +0200 Subject: [PATCH 6/8] Keep custom-data JSON support on existing schema conventions --- bin/prettify-manifests.js | 7 +------ .../app/import/custom-data-definitions.test.ts | 18 ++++++++++++++++++ .../app/import/custom-data-definitions.ts | 4 +++- .../rules/command-json-output.js | 4 +--- .../rules/command-json-output.test.js | 4 +++- 5 files changed, 26 insertions(+), 11 deletions(-) diff --git a/bin/prettify-manifests.js b/bin/prettify-manifests.js index 5ce134edb21..2363b227819 100755 --- a/bin/prettify-manifests.js +++ b/bin/prettify-manifests.js @@ -13,11 +13,6 @@ const manifestFiles = glob.sync(`packages/*/oclif.manifest.json`) for (const file of manifestFiles) { console.log(`Prettifying ${file}...`) const content = fs.readFileSync(file) - const manifest = JSON.parse(content) - for (const command of Object.values(manifest.commands)) { - // Runtime schemas belong to command classes, not cached metadata. - delete command.jsonOutputSchema - } - const prettyContent = stringify(manifest, {space: ' '}).replaceAll(root, '.') + const prettyContent = stringify(JSON.parse(content), {space: ' '}).replaceAll(root, '.') fs.writeFileSync(file, prettyContent) } diff --git a/packages/app/src/cli/commands/app/import/custom-data-definitions.test.ts b/packages/app/src/cli/commands/app/import/custom-data-definitions.test.ts index a79fa8f7928..e59669163f4 100644 --- a/packages/app/src/cli/commands/app/import/custom-data-definitions.test.ts +++ b/packages/app/src/cli/commands/app/import/custom-data-definitions.test.ts @@ -1,4 +1,5 @@ import ImportCustomDataDefinitions from './custom-data-definitions.js' +import {importCustomDataDefinitionsJsonOutputSchema} from '../../../services/generate/shop-import/declarative-definitions/types.js' import {linkedAppContext} from '../../../services/app-context.js' import {storeContext} from '../../../services/store-context.js' import {adminAsAppRequestDoc} from '../../../api/admin-as-app.js' @@ -29,6 +30,23 @@ afterEach(() => { const toml = '# namespace: $app key: color owner_type: PRODUCT\n[product.metafields.app.color]\ntype = "single_line_text_field"\n' +test.each([{metafieldCount: -1}, {storeDomain: 'custom.example.com'}, {skippedSections: [{type: 'unknown'}]}])( + 'rejects invalid public fields: %j', + (fields) => { + expect(() => + importCustomDataDefinitionsJsonOutputSchema.encode({ + status: 'success', + storeDomain: 'test-shop.myshopify.com', + metafieldCount: 0, + metaobjectCount: 0, + toml: '', + skippedSections: [], + ...fields, + }), + ).toThrow() + }, +) + async function withApp(run: (directory: string) => Promise) { // Ink uses the Console constructor, which Vitest's console replacement does not provide. vi.stubGlobal('console', {...globalThis.console, Console}) diff --git a/packages/app/src/cli/commands/app/import/custom-data-definitions.ts b/packages/app/src/cli/commands/app/import/custom-data-definitions.ts index a5a9b1b7efc..cdf51926d90 100644 --- a/packages/app/src/cli/commands/app/import/custom-data-definitions.ts +++ b/packages/app/src/cli/commands/app/import/custom-data-definitions.ts @@ -17,7 +17,9 @@ export default class ImportCustomDataDefinitions extends AppLinkedCommand { static descriptionWithMarkdown = `Import metafield and metaobject definitions from your development store. [Read more about declarative custom data definitions](https://shopify.dev/docs/apps/build/custom-data/declarative-custom-data-definitions).` - static jsonOutputSchema = importCustomDataDefinitionsJsonOutputSchema + static get jsonOutputSchema() { + return importCustomDataDefinitionsJsonOutputSchema + } static description = this.descriptionForHelp() diff --git a/packages/eslint-plugin-cli/rules/command-json-output.js b/packages/eslint-plugin-cli/rules/command-json-output.js index 4d41adab57a..a6493344283 100644 --- a/packages/eslint-plugin-cli/rules/command-json-output.js +++ b/packages/eslint-plugin-cli/rules/command-json-output.js @@ -62,9 +62,7 @@ function repositoryPath(filename) { function hasJsonOutputSchema(classMembers) { return classMembers.some( (member) => - isStaticMemberNamed(member, 'jsonOutputSchema') && - ((member.type === 'MethodDefinition' && member.kind === 'get') || - (member.type === 'PropertyDefinition' && Boolean(member.value))), + member.type === 'MethodDefinition' && member.kind === 'get' && isStaticMemberNamed(member, 'jsonOutputSchema'), ) } diff --git a/packages/eslint-plugin-cli/rules/command-json-output.test.js b/packages/eslint-plugin-cli/rules/command-json-output.test.js index b095246b579..5eaa3d46144 100644 --- a/packages/eslint-plugin-cli/rules/command-json-output.test.js +++ b/packages/eslint-plugin-cli/rules/command-json-output.test.js @@ -37,7 +37,9 @@ ruleTester.run('command-json-output', rule, { code: ` export default class WidgetDelete extends Command { static flags = {...globalFlags, ...jsonFlag} - static jsonOutputSchema = widgetDeleteJsonOutputSchema + static get jsonOutputSchema() { + return widgetDeleteJsonOutputSchema + } } `, }, From 49ae50e73bc5ad15db0faeca1f7d905ab4997e06 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Isaac=20Rold=C3=A1n?= Date: Thu, 8 Oct 2026 18:01:20 +0200 Subject: [PATCH 7/8] Preserve custom-data JSON imports for local stores --- .../app/import/custom-data-definitions.test.ts | 16 ++++++++++++---- .../app/import/custom-data-definitions.ts | 4 +++- .../shop-import/declarative-definitions/types.ts | 8 ++++++-- packages/cli/README.md | 13 ++++++++++--- packages/cli/oclif.manifest.json | 4 ++-- 5 files changed, 33 insertions(+), 12 deletions(-) diff --git a/packages/app/src/cli/commands/app/import/custom-data-definitions.test.ts b/packages/app/src/cli/commands/app/import/custom-data-definitions.test.ts index e59669163f4..5fbb7357a1b 100644 --- a/packages/app/src/cli/commands/app/import/custom-data-definitions.test.ts +++ b/packages/app/src/cli/commands/app/import/custom-data-definitions.test.ts @@ -34,7 +34,7 @@ test.each([{metafieldCount: -1}, {storeDomain: 'custom.example.com'}, {skippedSe 'rejects invalid public fields: %j', (fields) => { expect(() => - importCustomDataDefinitionsJsonOutputSchema.encode({ + importCustomDataDefinitionsJsonOutputSchema.validate({ status: 'success', storeDomain: 'test-shop.myshopify.com', metafieldCount: 0, @@ -97,13 +97,17 @@ async function runCommand(directory: string, argv: string[]) { return runWithCommandEventsForCommand(args, () => command.run()) } -test('writes one encoded JSON document and progress events without changing the local TOML', async () => { +test.each([ + {storeFqdn: 'test-shop.myshopify.com', storeDomain: 'test-shop.myshopify.com'}, + {storeFqdn: 'test-shop.myshopify.io', storeDomain: null}, +])('writes one JSON document for $storeFqdn without changing the local TOML', async ({storeFqdn, storeDomain}) => { await withApp(async (directory) => { + vi.mocked(ensureAuthenticatedAdminAsApp).mockResolvedValue({storeFqdn, token: 'test-token'}) await withCapturedStandardStreams(async ({stdout, stderr}) => { await runCommand(directory, ['--json', '--store', 'test-shop']) expect(JSON.parse(stdout())).toEqual({ status: 'success', - storeDomain: 'test-shop.myshopify.com', + storeDomain, metafieldCount: 1, metaobjectCount: 0, toml, @@ -147,13 +151,17 @@ test.each([false, true])('distinguishes an empty conversion from denied scope: % test('keeps the existing conversion summary and native TOML on stderr in text mode', async () => { await withApp(async (directory) => { + vi.mocked(ensureAuthenticatedAdminAsApp).mockResolvedValue({ + storeFqdn: 'test-shop.myshopify.io', + token: 'test-token', + }) await withCapturedStandardStreams(async ({stdout, stderr}) => { await runCommand(directory, []) expect(stdout()).toBe('') const text = unstyled(stderr()) expect(text).toContain('Conversion to TOML complete.') expect(text).toContain('1 metafields and 0 metaobjects') - expect(text).toContain('test-shop.myshopify.com') + expect(text).toContain('test-shop.myshopify.io') expect(text).toContain(toml) }) }) diff --git a/packages/app/src/cli/commands/app/import/custom-data-definitions.ts b/packages/app/src/cli/commands/app/import/custom-data-definitions.ts index cdf51926d90..aec4450f584 100644 --- a/packages/app/src/cli/commands/app/import/custom-data-definitions.ts +++ b/packages/app/src/cli/commands/app/import/custom-data-definitions.ts @@ -8,6 +8,7 @@ import {importCustomDataDefinitionsJsonOutputSchema} from '../../../services/gen import {renderImportDeclarativeDefinitionsResult} from '../../../services/generate/shop-import/declarative-definitions/result.js' import {Flags} from '@oclif/core' import {normalizeStoreFqdn} from '@shopify/cli-kit/node/context/fqdn' +import {extractMyshopifyHandle} from '@shopify/cli-kit/common/url' import {globalFlags, jsonFlag} from '@shopify/cli-kit/node/cli' import {renderSingleTask} from '@shopify/cli-kit/node/ui' import {outputContent, outputResult} from '@shopify/cli-kit/node/output' @@ -72,10 +73,11 @@ export default class ImportCustomDataDefinitions extends AppLinkedCommand { }) const result = await importDeclarativeDefinitions(options) if (json) { + const storeHandle = extractMyshopifyHandle(result.storeDomain) outputResult( importCustomDataDefinitionsJsonOutputSchema.encode({ status: result.status, - storeDomain: result.storeDomain, + storeDomain: storeHandle ? `${storeHandle}.myshopify.com` : null, metafieldCount: result.metafieldCount, metaobjectCount: result.metaobjectCount, toml: result.tomlContent, diff --git a/packages/app/src/cli/services/generate/shop-import/declarative-definitions/types.ts b/packages/app/src/cli/services/generate/shop-import/declarative-definitions/types.ts index 83efe96c4c6..d30faeccfa4 100644 --- a/packages/app/src/cli/services/generate/shop-import/declarative-definitions/types.ts +++ b/packages/app/src/cli/services/generate/shop-import/declarative-definitions/types.ts @@ -18,7 +18,8 @@ export const importCustomDataDefinitionsJsonOutputSchema = defineJsonOutputSchem storeDomain: zod .string() .regex(/^[a-z0-9][a-z0-9-]*\.myshopify\.com$/) - .describe('The full myshopify.com domain of the development store.'), + .nullable() + .describe('The full myshopify.com domain of the development store, or null when unavailable.'), metafieldCount: zod .number() .int() @@ -47,4 +48,7 @@ export type ImportCustomDataDefinitionsResult = InferJsonOutputSchema< typeof importCustomDataDefinitionsJsonOutputSchema > -export type ImportDeclarativeDefinitionsResult = Omit & {tomlContent: string} +export type ImportDeclarativeDefinitionsResult = Omit & { + tomlContent: string + storeDomain: string +} diff --git a/packages/cli/README.md b/packages/cli/README.md index 2cfbb14eff9..7aa7efd4e17 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -2226,9 +2226,16 @@ DESCRIPTION "const": "success" }, "storeDomain": { - "type": "string", - "pattern": "^[a-z0-9][a-z0-9-]*\\.myshopify\\.com$", - "description": "The full myshopify.com domain of the development store." + "anyOf": [ + { + "type": "string", + "pattern": "^[a-z0-9][a-z0-9-]*\\.myshopify\\.com$" + }, + { + "type": "null" + } + ], + "description": "The full myshopify.com domain of the development store, or null when unavailable." }, "metafieldCount": { "type": "integer", diff --git a/packages/cli/oclif.manifest.json b/packages/cli/oclif.manifest.json index e77aba43d26..c9ddf48dd77 100644 --- a/packages/cli/oclif.manifest.json +++ b/packages/cli/oclif.manifest.json @@ -2831,7 +2831,7 @@ "args": { }, "customPluginName": "@shopify/app", - "description": "Import metafield and metaobject definitions from your development store. \"Read more about declarative custom data definitions\" (https://shopify.dev/docs/apps/build/custom-data/declarative-custom-data-definitions).\n\nUse `--json-schema` to print the result, error, and event schemas.\n\nOutput from `--json` conforms to the `ImportCustomDataDefinitionsResult` schema.\n\n```json\n{\n \"type\": \"object\",\n \"properties\": {\n \"status\": {\n \"type\": \"string\",\n \"const\": \"success\"\n },\n \"storeDomain\": {\n \"type\": \"string\",\n \"pattern\": \"^[a-z0-9][a-z0-9-]*\\\\.myshopify\\\\.com$\",\n \"description\": \"The full myshopify.com domain of the development store.\"\n },\n \"metafieldCount\": {\n \"type\": \"integer\",\n \"minimum\": 0,\n \"description\": \"The number of available metafields converted to TOML.\"\n },\n \"metaobjectCount\": {\n \"type\": \"integer\",\n \"minimum\": 0,\n \"description\": \"The number of available metaobjects converted to TOML.\"\n },\n \"toml\": {\n \"type\": \"string\",\n \"description\": \"Suggested native TOML for app-reserved definitions. No file is written.\"\n },\n \"skippedSections\": {\n \"type\": \"array\",\n \"items\": {\n \"anyOf\": [\n {\n \"$ref\": \"#/definitions/SkippedMetafields\"\n },\n {\n \"$ref\": \"#/definitions/SkippedMetaobjects\"\n }\n ]\n },\n \"description\": \"Sections skipped because required access scopes are unavailable. An empty array means all requests were authorized.\"\n }\n },\n \"required\": [\n \"status\",\n \"storeDomain\",\n \"metafieldCount\",\n \"metaobjectCount\",\n \"toml\",\n \"skippedSections\"\n ],\n \"additionalProperties\": false,\n \"title\": \"ImportCustomDataDefinitionsResult\",\n \"definitions\": {\n \"SkippedMetafields\": {\n \"type\": \"object\",\n \"properties\": {\n \"type\": {\n \"type\": \"string\",\n \"const\": \"metafields\"\n },\n \"ownerType\": {\n \"type\": \"string\",\n \"minLength\": 1,\n \"description\": \"The Admin API metafield owner type, such as PRODUCT.\"\n }\n },\n \"required\": [\n \"type\",\n \"ownerType\"\n ],\n \"additionalProperties\": false\n },\n \"SkippedMetaobjects\": {\n \"type\": \"object\",\n \"properties\": {\n \"type\": {\n \"type\": \"string\",\n \"const\": \"metaobjects\"\n }\n },\n \"required\": [\n \"type\"\n ],\n \"additionalProperties\": false\n }\n },\n \"$schema\": \"http://json-schema.org/draft-07/schema#\"\n}\n```", + "description": "Import metafield and metaobject definitions from your development store. \"Read more about declarative custom data definitions\" (https://shopify.dev/docs/apps/build/custom-data/declarative-custom-data-definitions).\n\nUse `--json-schema` to print the result, error, and event schemas.\n\nOutput from `--json` conforms to the `ImportCustomDataDefinitionsResult` schema.\n\n```json\n{\n \"type\": \"object\",\n \"properties\": {\n \"status\": {\n \"type\": \"string\",\n \"const\": \"success\"\n },\n \"storeDomain\": {\n \"anyOf\": [\n {\n \"type\": \"string\",\n \"pattern\": \"^[a-z0-9][a-z0-9-]*\\\\.myshopify\\\\.com$\"\n },\n {\n \"type\": \"null\"\n }\n ],\n \"description\": \"The full myshopify.com domain of the development store, or null when unavailable.\"\n },\n \"metafieldCount\": {\n \"type\": \"integer\",\n \"minimum\": 0,\n \"description\": \"The number of available metafields converted to TOML.\"\n },\n \"metaobjectCount\": {\n \"type\": \"integer\",\n \"minimum\": 0,\n \"description\": \"The number of available metaobjects converted to TOML.\"\n },\n \"toml\": {\n \"type\": \"string\",\n \"description\": \"Suggested native TOML for app-reserved definitions. No file is written.\"\n },\n \"skippedSections\": {\n \"type\": \"array\",\n \"items\": {\n \"anyOf\": [\n {\n \"$ref\": \"#/definitions/SkippedMetafields\"\n },\n {\n \"$ref\": \"#/definitions/SkippedMetaobjects\"\n }\n ]\n },\n \"description\": \"Sections skipped because required access scopes are unavailable. An empty array means all requests were authorized.\"\n }\n },\n \"required\": [\n \"status\",\n \"storeDomain\",\n \"metafieldCount\",\n \"metaobjectCount\",\n \"toml\",\n \"skippedSections\"\n ],\n \"additionalProperties\": false,\n \"title\": \"ImportCustomDataDefinitionsResult\",\n \"definitions\": {\n \"SkippedMetafields\": {\n \"type\": \"object\",\n \"properties\": {\n \"type\": {\n \"type\": \"string\",\n \"const\": \"metafields\"\n },\n \"ownerType\": {\n \"type\": \"string\",\n \"minLength\": 1,\n \"description\": \"The Admin API metafield owner type, such as PRODUCT.\"\n }\n },\n \"required\": [\n \"type\",\n \"ownerType\"\n ],\n \"additionalProperties\": false\n },\n \"SkippedMetaobjects\": {\n \"type\": \"object\",\n \"properties\": {\n \"type\": {\n \"type\": \"string\",\n \"const\": \"metaobjects\"\n }\n },\n \"required\": [\n \"type\"\n ],\n \"additionalProperties\": false\n }\n },\n \"$schema\": \"http://json-schema.org/draft-07/schema#\"\n}\n```", "descriptionWithMarkdown": "Import metafield and metaobject definitions from your development store. [Read more about declarative custom data definitions](https://shopify.dev/docs/apps/build/custom-data/declarative-custom-data-definitions).", "flags": { "auth-alias": { @@ -3171,7 +3171,7 @@ "args": { }, "customPluginName": "@shopify/app", - "description": "Import metafield and metaobject definitions from your development store. \"Read more about declarative custom data definitions\" (https://shopify.dev/docs/apps/build/custom-data/declarative-custom-data-definitions).\n\nUse `--json-schema` to print the result, error, and event schemas.\n\nOutput from `--json` conforms to the `ImportCustomDataDefinitionsResult` schema.\n\n```json\n{\n \"type\": \"object\",\n \"properties\": {\n \"status\": {\n \"type\": \"string\",\n \"const\": \"success\"\n },\n \"storeDomain\": {\n \"type\": \"string\",\n \"pattern\": \"^[a-z0-9][a-z0-9-]*\\\\.myshopify\\\\.com$\",\n \"description\": \"The full myshopify.com domain of the development store.\"\n },\n \"metafieldCount\": {\n \"type\": \"integer\",\n \"minimum\": 0,\n \"description\": \"The number of available metafields converted to TOML.\"\n },\n \"metaobjectCount\": {\n \"type\": \"integer\",\n \"minimum\": 0,\n \"description\": \"The number of available metaobjects converted to TOML.\"\n },\n \"toml\": {\n \"type\": \"string\",\n \"description\": \"Suggested native TOML for app-reserved definitions. No file is written.\"\n },\n \"skippedSections\": {\n \"type\": \"array\",\n \"items\": {\n \"anyOf\": [\n {\n \"$ref\": \"#/definitions/SkippedMetafields\"\n },\n {\n \"$ref\": \"#/definitions/SkippedMetaobjects\"\n }\n ]\n },\n \"description\": \"Sections skipped because required access scopes are unavailable. An empty array means all requests were authorized.\"\n }\n },\n \"required\": [\n \"status\",\n \"storeDomain\",\n \"metafieldCount\",\n \"metaobjectCount\",\n \"toml\",\n \"skippedSections\"\n ],\n \"additionalProperties\": false,\n \"title\": \"ImportCustomDataDefinitionsResult\",\n \"definitions\": {\n \"SkippedMetafields\": {\n \"type\": \"object\",\n \"properties\": {\n \"type\": {\n \"type\": \"string\",\n \"const\": \"metafields\"\n },\n \"ownerType\": {\n \"type\": \"string\",\n \"minLength\": 1,\n \"description\": \"The Admin API metafield owner type, such as PRODUCT.\"\n }\n },\n \"required\": [\n \"type\",\n \"ownerType\"\n ],\n \"additionalProperties\": false\n },\n \"SkippedMetaobjects\": {\n \"type\": \"object\",\n \"properties\": {\n \"type\": {\n \"type\": \"string\",\n \"const\": \"metaobjects\"\n }\n },\n \"required\": [\n \"type\"\n ],\n \"additionalProperties\": false\n }\n },\n \"$schema\": \"http://json-schema.org/draft-07/schema#\"\n}\n```", + "description": "Import metafield and metaobject definitions from your development store. \"Read more about declarative custom data definitions\" (https://shopify.dev/docs/apps/build/custom-data/declarative-custom-data-definitions).\n\nUse `--json-schema` to print the result, error, and event schemas.\n\nOutput from `--json` conforms to the `ImportCustomDataDefinitionsResult` schema.\n\n```json\n{\n \"type\": \"object\",\n \"properties\": {\n \"status\": {\n \"type\": \"string\",\n \"const\": \"success\"\n },\n \"storeDomain\": {\n \"anyOf\": [\n {\n \"type\": \"string\",\n \"pattern\": \"^[a-z0-9][a-z0-9-]*\\\\.myshopify\\\\.com$\"\n },\n {\n \"type\": \"null\"\n }\n ],\n \"description\": \"The full myshopify.com domain of the development store, or null when unavailable.\"\n },\n \"metafieldCount\": {\n \"type\": \"integer\",\n \"minimum\": 0,\n \"description\": \"The number of available metafields converted to TOML.\"\n },\n \"metaobjectCount\": {\n \"type\": \"integer\",\n \"minimum\": 0,\n \"description\": \"The number of available metaobjects converted to TOML.\"\n },\n \"toml\": {\n \"type\": \"string\",\n \"description\": \"Suggested native TOML for app-reserved definitions. No file is written.\"\n },\n \"skippedSections\": {\n \"type\": \"array\",\n \"items\": {\n \"anyOf\": [\n {\n \"$ref\": \"#/definitions/SkippedMetafields\"\n },\n {\n \"$ref\": \"#/definitions/SkippedMetaobjects\"\n }\n ]\n },\n \"description\": \"Sections skipped because required access scopes are unavailable. An empty array means all requests were authorized.\"\n }\n },\n \"required\": [\n \"status\",\n \"storeDomain\",\n \"metafieldCount\",\n \"metaobjectCount\",\n \"toml\",\n \"skippedSections\"\n ],\n \"additionalProperties\": false,\n \"title\": \"ImportCustomDataDefinitionsResult\",\n \"definitions\": {\n \"SkippedMetafields\": {\n \"type\": \"object\",\n \"properties\": {\n \"type\": {\n \"type\": \"string\",\n \"const\": \"metafields\"\n },\n \"ownerType\": {\n \"type\": \"string\",\n \"minLength\": 1,\n \"description\": \"The Admin API metafield owner type, such as PRODUCT.\"\n }\n },\n \"required\": [\n \"type\",\n \"ownerType\"\n ],\n \"additionalProperties\": false\n },\n \"SkippedMetaobjects\": {\n \"type\": \"object\",\n \"properties\": {\n \"type\": {\n \"type\": \"string\",\n \"const\": \"metaobjects\"\n }\n },\n \"required\": [\n \"type\"\n ],\n \"additionalProperties\": false\n }\n },\n \"$schema\": \"http://json-schema.org/draft-07/schema#\"\n}\n```", "descriptionWithMarkdown": "Import metafield and metaobject definitions from your development store. [Read more about declarative custom data definitions](https://shopify.dev/docs/apps/build/custom-data/declarative-custom-data-definitions).", "flags": { "auth-alias": { From 8b847734702c4afb274e4bf49dc722167e12c84f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Isaac=20Rold=C3=A1n?= Date: Fri, 9 Oct 2026 13:45:42 +0200 Subject: [PATCH 8/8] Move custom-data JSON output into its presenter --- .../app/import/custom-data-definitions.ts | 21 +--------- .../declarative-definitions/result.ts | 39 +++++++++++++++---- 2 files changed, 33 insertions(+), 27 deletions(-) diff --git a/packages/app/src/cli/commands/app/import/custom-data-definitions.ts b/packages/app/src/cli/commands/app/import/custom-data-definitions.ts index aec4450f584..ff586cc6024 100644 --- a/packages/app/src/cli/commands/app/import/custom-data-definitions.ts +++ b/packages/app/src/cli/commands/app/import/custom-data-definitions.ts @@ -8,10 +8,9 @@ import {importCustomDataDefinitionsJsonOutputSchema} from '../../../services/gen import {renderImportDeclarativeDefinitionsResult} from '../../../services/generate/shop-import/declarative-definitions/result.js' import {Flags} from '@oclif/core' import {normalizeStoreFqdn} from '@shopify/cli-kit/node/context/fqdn' -import {extractMyshopifyHandle} from '@shopify/cli-kit/common/url' import {globalFlags, jsonFlag} from '@shopify/cli-kit/node/cli' import {renderSingleTask} from '@shopify/cli-kit/node/ui' -import {outputContent, outputResult} from '@shopify/cli-kit/node/output' +import {outputContent} from '@shopify/cli-kit/node/output' export default class ImportCustomDataDefinitions extends AppLinkedCommand { static summary = 'Import metafield and metaobject definitions.' @@ -72,23 +71,7 @@ export default class ImportCustomDataDefinitions extends AppLinkedCommand { }, }) const result = await importDeclarativeDefinitions(options) - if (json) { - const storeHandle = extractMyshopifyHandle(result.storeDomain) - outputResult( - importCustomDataDefinitionsJsonOutputSchema.encode({ - status: result.status, - storeDomain: storeHandle ? `${storeHandle}.myshopify.com` : null, - metafieldCount: result.metafieldCount, - metaobjectCount: result.metaobjectCount, - toml: result.tomlContent, - skippedSections: result.skippedSections.map((section) => - section.type === 'metafields' ? {type: section.type, ownerType: section.ownerType} : {type: section.type}, - ), - }), - ) - } else { - renderImportDeclarativeDefinitionsResult(result) - } + renderImportDeclarativeDefinitionsResult(result, json) return {app: appContextResult.app} } diff --git a/packages/app/src/cli/services/generate/shop-import/declarative-definitions/result.ts b/packages/app/src/cli/services/generate/shop-import/declarative-definitions/result.ts index 67396a95d78..cd00fbfa9b3 100644 --- a/packages/app/src/cli/services/generate/shop-import/declarative-definitions/result.ts +++ b/packages/app/src/cli/services/generate/shop-import/declarative-definitions/result.ts @@ -1,13 +1,36 @@ -import {ImportDeclarativeDefinitionsResult} from './types.js' -import {outputContent, outputInfo, outputToken} from '@shopify/cli-kit/node/output' +import {importCustomDataDefinitionsJsonOutputSchema, type ImportDeclarativeDefinitionsResult} from './types.js' +import {extractMyshopifyHandle} from '@shopify/cli-kit/common/url' +import {outputContent, outputInfo, outputResult, outputToken} from '@shopify/cli-kit/node/output' import {renderInfo} from '@shopify/cli-kit/node/ui' -export function renderImportDeclarativeDefinitionsResult({ - metafieldCount, - metaobjectCount, - storeDomain, - tomlContent, -}: ImportDeclarativeDefinitionsResult) { +export function renderImportDeclarativeDefinitionsResult( + { + status, + metafieldCount, + metaobjectCount, + storeDomain, + tomlContent, + skippedSections, + }: ImportDeclarativeDefinitionsResult, + json = false, +) { + if (json) { + const storeHandle = extractMyshopifyHandle(storeDomain) + outputResult( + importCustomDataDefinitionsJsonOutputSchema.encode({ + status, + storeDomain: storeHandle ? `${storeHandle}.myshopify.com` : null, + metafieldCount, + metaobjectCount, + toml: tomlContent, + skippedSections: skippedSections.map((section) => + section.type === 'metafields' ? {type: section.type, ownerType: section.ownerType} : {type: section.type}, + ), + }), + ) + return + } + renderInfo({ headline: 'Conversion to TOML complete.', body: [