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..5fbb7357a1b --- /dev/null +++ b/packages/app/src/cli/commands/app/import/custom-data-definitions.test.ts @@ -0,0 +1,188 @@ +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' +import {MetafieldDefinitions} from '../../../api/graphql/admin/generated/metafield_definitions.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' +// 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' + +test.each([{metafieldCount: -1}, {storeDomain: 'custom.example.com'}, {skippedSections: [{type: 'unknown'}]}])( + 'rejects invalid public fields: %j', + (fields) => { + expect(() => + importCustomDataDefinitionsJsonOutputSchema.validate({ + 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}) + 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[]) { + const args = ['--path', directory, ...argv] + const command = new ImportCustomDataDefinitions(args, await Config.load()) + return runWithCommandEventsForCommand(args, () => command.run()) +} + +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, + 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') + }) + }) +}) + +test.each([false, true])('distinguishes an empty conversion from denied scope: %s', async (scopeDenied) => { + await withApp(async (directory) => { + 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())).toMatchObject({ + metafieldCount: 0, + metaobjectCount: 0, + toml: '', + skippedSections: scopeDenied ? [{type: 'metafields', ownerType: 'PRODUCT'}] : [], + }) + }) + }) +}) + +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.io') + expect(text).toContain(toml) + }) + }) +}) + +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'})])) + }) + }) +}) 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..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 @@ -4,9 +4,11 @@ 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' @@ -15,11 +17,16 @@ 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 get jsonOutputSchema() { + return importCustomDataDefinitionsJsonOutputSchema + } + static description = this.descriptionForHelp() static flags = { ...globalFlags, ...appFlags, + ...jsonFlag, store: Flags.string({ char: 's', description: 'Store URL. Must be an existing development or Shopify Plus sandbox store.', @@ -34,7 +41,7 @@ export default class ImportCustomDataDefinitions extends AppLinkedCommand { } 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,8 @@ export default class ImportCustomDataDefinitions extends AppLinkedCommand { } }, }) - await importDeclarativeDefinitions(options) + const result = await importDeclarativeDefinitions(options) + renderImportDeclarativeDefinitionsResult(result, json) 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 + section.type === 'metafields' ? {type: section.type, ownerType: section.ownerType} : {type: section.type}, + ), + }), + ) + return + } + + renderInfo({ + headline: 'Conversion to TOML complete.', + body: [ + 'Converted', + { + warn: `${metafieldCount} metafields`, + }, + 'and', + { + warn: `${metaobjectCount} metaobjects`, + }, + 'from', + { + warn: storeDomain, + }, + '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) +} + +export function renderTomlStringWithFormatting(tomlContent: string) { + const lines = tomlContent.split('\n') + for (const line of lines) { + if (line.match(/^\s*\[/)) { + outputInfo(outputContent`${outputToken.green(line)}`) + } else if (line.match(/^\s*#/)) { + outputInfo(outputContent`${outputToken.gray(line)}`) + } else { + outputInfo(outputContent`${line}`) + } + } +} 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..d30faeccfa4 --- /dev/null +++ b/packages/app/src/cli/services/generate/shop-import/declarative-definitions/types.ts @@ -0,0 +1,54 @@ +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$/) + .nullable() + .describe('The full myshopify.com domain of the development store, or null when unavailable.'), + 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 + storeDomain: string +} diff --git a/packages/cli/README.md b/packages/cli/README.md index 95b19a8e8b7..7aa7efd4e17 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,107 @@ 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": { + "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", + "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..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).", + "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": { @@ -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 \"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": { @@ -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',