From a3240a4ae52b24029401dcb47e04b106323691c5 Mon Sep 17 00:00:00 2001 From: Gonzalo Riestra Date: Mon, 28 Sep 2026 11:51:42 +0200 Subject: [PATCH 1/7] Add typed JSON output to theme metafields pull --- .changeset/theme-commands-json.md | 5 + packages/cli/oclif.manifest.json | 11 +- .../rules/json-output-command-exceptions.js | 1 - .../commands/theme/metafields/pull.test.ts | 169 ++++++++++++++++++ .../src/cli/commands/theme/metafields/pull.ts | 14 +- .../src/cli/services/metafields-pull.test.ts | 17 ++ .../theme/src/cli/services/metafields-pull.ts | 78 +++----- .../cli/services/metafields-pull/result.ts | 43 +++++ .../src/cli/services/metafields-pull/types.ts | 58 ++++++ 9 files changed, 336 insertions(+), 60 deletions(-) create mode 100644 .changeset/theme-commands-json.md create mode 100644 packages/theme/src/cli/commands/theme/metafields/pull.test.ts create mode 100644 packages/theme/src/cli/services/metafields-pull/result.ts create mode 100644 packages/theme/src/cli/services/metafields-pull/types.ts diff --git a/.changeset/theme-commands-json.md b/.changeset/theme-commands-json.md new file mode 100644 index 00000000000..79486896927 --- /dev/null +++ b/.changeset/theme-commands-json.md @@ -0,0 +1,5 @@ +--- +'@shopify/cli': minor +--- + +Add json flag and output schema for all theme commands diff --git a/packages/cli/oclif.manifest.json b/packages/cli/oclif.manifest.json index d8c9f752173..43ad58b788b 100644 --- a/packages/cli/oclif.manifest.json +++ b/packages/cli/oclif.manifest.json @@ -11609,7 +11609,7 @@ "args": { }, "customPluginName": "@shopify/theme", - "description": "Retrieves metafields from Shopify Admin.\n\nIf the metafields file already exists, it will be overwritten.", + "description": "Retrieves metafields from Shopify Admin.\n\nIf the metafields file already exists, it will be overwritten.\n\nUse `--json-schema` to print the result, error, and event schemas.\n\nOutput from `--json` conforms to the `ThemeMetafieldsPullResult` schema.\n\n```json\n{\n \"anyOf\": [\n {\n \"type\": \"object\",\n \"properties\": {\n \"status\": {\n \"type\": \"string\",\n \"const\": \"downloaded\"\n },\n \"path\": {\n \"type\": \"string\"\n },\n \"definitions\": {\n \"$ref\": \"#/definitions/MetafieldDefinitions\"\n },\n \"failedOwnerTypes\": {\n \"type\": \"array\",\n \"items\": {\n \"$ref\": \"#/definitions/MetafieldOwnerType\"\n }\n }\n },\n \"required\": [\n \"status\",\n \"path\",\n \"definitions\",\n \"failedOwnerTypes\"\n ],\n \"additionalProperties\": false\n },\n {\n \"type\": \"object\",\n \"properties\": {\n \"status\": {\n \"type\": \"string\",\n \"const\": \"failed\"\n },\n \"failedOwnerTypes\": {\n \"type\": \"array\",\n \"items\": {\n \"$ref\": \"#/definitions/MetafieldOwnerType\"\n }\n }\n },\n \"required\": [\n \"status\",\n \"failedOwnerTypes\"\n ],\n \"additionalProperties\": false\n },\n {\n \"type\": \"object\",\n \"properties\": {\n \"status\": {\n \"type\": \"string\",\n \"const\": \"skipped\"\n },\n \"reason\": {\n \"type\": \"string\",\n \"enum\": [\n \"not-a-theme\",\n \"cancelled\"\n ]\n }\n },\n \"required\": [\n \"status\",\n \"reason\"\n ],\n \"additionalProperties\": false\n }\n ],\n \"title\": \"ThemeMetafieldsPullResult\",\n \"definitions\": {\n \"MetafieldOwnerType\": {\n \"type\": \"string\",\n \"enum\": [\n \"ARTICLE\",\n \"BLOG\",\n \"COLLECTION\",\n \"COMPANY\",\n \"COMPANY_LOCATION\",\n \"LOCATION\",\n \"MARKET\",\n \"ORDER\",\n \"PAGE\",\n \"PRODUCT\",\n \"PRODUCTVARIANT\",\n \"SHOP\"\n ]\n },\n \"MetafieldType\": {\n \"type\": \"object\",\n \"properties\": {\n \"name\": {\n \"type\": \"string\"\n },\n \"category\": {\n \"type\": \"string\"\n }\n },\n \"required\": [\n \"name\",\n \"category\"\n ],\n \"additionalProperties\": false\n },\n \"MetafieldDefinition\": {\n \"type\": \"object\",\n \"properties\": {\n \"key\": {\n \"type\": \"string\"\n },\n \"namespace\": {\n \"type\": \"string\"\n },\n \"name\": {\n \"type\": \"string\"\n },\n \"description\": {\n \"type\": [\n \"string\",\n \"null\"\n ]\n },\n \"type\": {\n \"$ref\": \"#/definitions/MetafieldType\"\n }\n },\n \"required\": [\n \"key\",\n \"namespace\",\n \"name\",\n \"type\"\n ],\n \"additionalProperties\": false\n },\n \"MetafieldDefinitions\": {\n \"type\": \"object\",\n \"properties\": {\n \"article\": {\n \"type\": \"array\",\n \"items\": {\n \"$ref\": \"#/definitions/MetafieldDefinition\"\n }\n },\n \"blog\": {\n \"type\": \"array\",\n \"items\": {\n \"$ref\": \"#/definitions/MetafieldDefinition\"\n }\n },\n \"collection\": {\n \"type\": \"array\",\n \"items\": {\n \"$ref\": \"#/definitions/MetafieldDefinition\"\n }\n },\n \"company\": {\n \"type\": \"array\",\n \"items\": {\n \"$ref\": \"#/definitions/MetafieldDefinition\"\n }\n },\n \"company_location\": {\n \"type\": \"array\",\n \"items\": {\n \"$ref\": \"#/definitions/MetafieldDefinition\"\n }\n },\n \"location\": {\n \"type\": \"array\",\n \"items\": {\n \"$ref\": \"#/definitions/MetafieldDefinition\"\n }\n },\n \"market\": {\n \"type\": \"array\",\n \"items\": {\n \"$ref\": \"#/definitions/MetafieldDefinition\"\n }\n },\n \"order\": {\n \"type\": \"array\",\n \"items\": {\n \"$ref\": \"#/definitions/MetafieldDefinition\"\n }\n },\n \"page\": {\n \"type\": \"array\",\n \"items\": {\n \"$ref\": \"#/definitions/MetafieldDefinition\"\n }\n },\n \"product\": {\n \"type\": \"array\",\n \"items\": {\n \"$ref\": \"#/definitions/MetafieldDefinition\"\n }\n },\n \"variant\": {\n \"type\": \"array\",\n \"items\": {\n \"$ref\": \"#/definitions/MetafieldDefinition\"\n }\n },\n \"shop\": {\n \"type\": \"array\",\n \"items\": {\n \"$ref\": \"#/definitions/MetafieldDefinition\"\n }\n }\n },\n \"required\": [\n \"article\",\n \"blog\",\n \"collection\",\n \"company\",\n \"company_location\",\n \"location\",\n \"market\",\n \"order\",\n \"page\",\n \"product\",\n \"variant\",\n \"shop\"\n ],\n \"additionalProperties\": false\n }\n },\n \"$schema\": \"http://json-schema.org/draft-07/schema#\"\n}\n```", "descriptionWithMarkdown": "Retrieves metafields from Shopify Admin.\n\nIf the metafields file already exists, it will be overwritten.", "enableJsonFlag": false, "flags": { @@ -11639,6 +11639,15 @@ "name": "force", "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 c5f07408071..d6a8650d9d1 100644 --- a/packages/eslint-plugin-cli/rules/json-output-command-exceptions.js +++ b/packages/eslint-plugin-cli/rules/json-output-command-exceptions.js @@ -25,7 +25,6 @@ const commandExceptions = [ 'packages/plugin-did-you-mean/src/commands/config/autocorrect/off.ts', 'packages/plugin-did-you-mean/src/commands/config/autocorrect/on.ts', 'packages/plugin-did-you-mean/src/commands/config/autocorrect/status.ts', - 'packages/theme/src/cli/commands/theme/metafields/pull.ts', // App Security commands, launched before adopting typed JSON output. Remove entries as they adopt it. 'packages/app/src/cli/commands/app/security/check.ts', diff --git a/packages/theme/src/cli/commands/theme/metafields/pull.test.ts b/packages/theme/src/cli/commands/theme/metafields/pull.test.ts new file mode 100644 index 00000000000..e46e222a65f --- /dev/null +++ b/packages/theme/src/cli/commands/theme/metafields/pull.test.ts @@ -0,0 +1,169 @@ +import MetafieldsPull from './pull.js' +import {themeMetafieldsPullJsonOutputSchema} from '../../../services/metafields-pull/types.js' +import {ensureDirectoryConfirmed} from '../../../utilities/theme-ui.js' +import {ensureThemeStore} from '../../../utilities/theme-store.js' +import {Config} from '@oclif/core' +import {runWithCommandEventsForCommand} from '@shopify/cli-kit/node/command-events' +import {inTemporaryDirectory, mkdir, readFile, fileExists, writeFile} from '@shopify/cli-kit/node/fs' +import {joinPath} from '@shopify/cli-kit/node/path' +import {ensureAuthenticatedThemes} from '@shopify/cli-kit/node/session' +import {metafieldDefinitionsByOwnerType} from '@shopify/cli-kit/node/themes/api' +import {withCapturedStandardStreams} from '@shopify/cli-kit/node/testing/output' +import {expect, test, vi, beforeEach} from 'vitest' + +vi.mock('@shopify/cli-kit/node/session') +vi.mock('@shopify/cli-kit/node/themes/api') +vi.mock('../../../utilities/theme-store.js') +vi.mock('../../../utilities/theme-ui.js') +vi.mock('@shopify/cli-kit/node/analytics', () => ({ + recordEvent: vi.fn(), + compileData: vi.fn().mockReturnValue({timings: {}, errors: {}, retries: {}, events: {}}), +})) +vi.mock('@shopify/cli-kit/node/metadata') +vi.mock('@shopify/cli-kit/node/environments') + +beforeEach(() => { + vi.mocked(ensureThemeStore).mockReturnValue('example.myshopify.com') + vi.mocked(ensureAuthenticatedThemes).mockResolvedValue({storeFqdn: 'example.myshopify.com', token: 'token'}) + vi.mocked(ensureDirectoryConfirmed).mockResolvedValue(true) +}) + +async function run(path: string) { + const config = new Config({root: __dirname}) + await config.load() + await runWithCommandEventsForCommand(['--json'], () => + new MetafieldsPull(['--path', path, '--store', 'example.myshopify.com', '--json'], config).run(), + ) +} + +test.each([undefined, null, 'Definition description'])( + 'encodes downloaded definitions with description=%s', + async (description) => { + const definition = { + key: 'subtitle', + namespace: 'custom', + name: 'Subtitle', + description, + type: {name: 'single_line_text_field', category: 'TEXT'}, + } + vi.mocked(metafieldDefinitionsByOwnerType).mockImplementation(async (owner) => + owner === 'PRODUCT' ? [definition] : [], + ) + await inTemporaryDirectory(async (directory) => { + await Promise.all(['config', 'layout', 'templates'].map((name) => mkdir(joinPath(directory, name)))) + await withCapturedStandardStreams(async ({stdout, stderr}) => { + await run(directory) + const result = JSON.parse(stdout()) + expect(result).toMatchObject({ + status: 'downloaded', + path: joinPath(directory, '.shopify/metafields.json'), + failedOwnerTypes: [], + }) + expect(result.definitions.product).toEqual([JSON.parse(JSON.stringify(definition))]) + expect(Object.keys(result.definitions)).toHaveLength(12) + expect(result.definitions.shop).toEqual([]) + expect(JSON.parse(await readFile(result.path))).toEqual(result.definitions) + expect(themeMetafieldsPullJsonOutputSchema.validate(result)).toEqual(result) + // The output must include every supported owner, even when it has no definitions. + const {variant: _variant, ...incompleteDefinitions} = result.definitions + expect(() => + themeMetafieldsPullJsonOutputSchema.validate({...result, definitions: incompleteDefinitions}), + ).toThrow() + expect(stderr()).toBe('') + }) + }) + }, +) + +test('distinguishes an empty successful download from total fetch failure', async () => { + vi.mocked(metafieldDefinitionsByOwnerType).mockResolvedValue([]) + await inTemporaryDirectory(async (directory) => { + await withCapturedStandardStreams(async ({stdout}) => { + await run(directory) + expect(JSON.parse(stdout())).toMatchObject({status: 'downloaded', failedOwnerTypes: []}) + await expect(fileExists(joinPath(directory, '.shopify/metafields.json'))).resolves.toBe(true) + }) + }) +}) + +test('reports partial results without showing debug diagnostics by default', async () => { + vi.mocked(metafieldDefinitionsByOwnerType).mockImplementation(async (owner) => { + if (owner === 'PRODUCT') throw new Error('Unavailable') + return [] + }) + await inTemporaryDirectory(async (directory) => { + await withCapturedStandardStreams(async ({stdout, stderr}) => { + await run(directory) + const result = JSON.parse(stdout()) + expect(result).toMatchObject({status: 'downloaded', failedOwnerTypes: ['PRODUCT'], definitions: {product: []}}) + expect(JSON.parse(await readFile(result.path))).toEqual(result.definitions) + expect(stderr()).toBe('') + }) + }) +}) + +test('reports total failure without writing a file or changing the existing nonfatal behavior', async () => { + vi.mocked(metafieldDefinitionsByOwnerType).mockRejectedValue(new Error('Unavailable')) + await inTemporaryDirectory(async (directory) => { + await withCapturedStandardStreams(async ({stdout, stderr}) => { + await expect(run(directory)).resolves.toBeUndefined() + const result = JSON.parse(stdout()) + expect(result.status).toBe('failed') + expect(result.failedOwnerTypes).toHaveLength(12) + expect(result).not.toHaveProperty('path') + expect(JSON.parse(stderr())).toMatchObject({ + type: 'diagnostic', + level: 'error', + message: 'Failed to fetch metafield definitions.', + }) + await expect(fileExists(joinPath(directory, '.shopify/metafields.json'))).resolves.toBe(false) + }) + }) +}) + +test('reports cancellation without fetching or writing definitions', async () => { + vi.mocked(ensureDirectoryConfirmed).mockResolvedValue(false) + await inTemporaryDirectory(async (directory) => { + await withCapturedStandardStreams(async ({stdout, stderr}) => { + await run(directory) + expect(JSON.parse(stdout())).toEqual({status: 'skipped', reason: 'cancelled'}) + expect(stderr()).toBe('') + expect(metafieldDefinitionsByOwnerType).not.toHaveBeenCalled() + }) + }) +}) + +test('does not emit a result when writing the downloaded file fails', async () => { + vi.mocked(metafieldDefinitionsByOwnerType).mockResolvedValue([]) + await inTemporaryDirectory(async (directory) => { + await writeFile(joinPath(directory, '.shopify'), 'This file prevents directory creation') + await withCapturedStandardStreams(async ({stdout}) => { + await expect(run(directory)).rejects.toThrow() + expect(stdout()).toBe('') + }) + }) +}) + +test('exposes the schema and rejects incomplete definitions and unknown outcomes', () => { + expect(MetafieldsPull.jsonOutputSchema).toBe(themeMetafieldsPullJsonOutputSchema) + expect(MetafieldsPull.flags.json).toBeDefined() + expect(MetafieldsPull.description).toContain('--json-schema') + expect(() => themeMetafieldsPullJsonOutputSchema.validate({status: 'skipped', reason: 'unknown'})).toThrow() + expect(() => + themeMetafieldsPullJsonOutputSchema.validate({status: 'failed', failedOwnerTypes: ['UNKNOWN']}), + ).toThrow() +}) + +test('exports a JSON Schema with resolvable local references', () => { + const schema = themeMetafieldsPullJsonOutputSchema.jsonSchema + const serialized = JSON.stringify(schema) + const references = [...serialized.matchAll(/"\$ref":"#\/([^"]+)"/g)].map((match) => match[1]!) + expect(references.length).toBeGreaterThan(0) + for (const reference of references) { + let target: unknown = schema + for (const segment of reference.split('/')) { + target = (target as Record | undefined)?.[segment.replace(/~1/g, '/').replace(/~0/g, '~')] + } + expect(target, reference).toBeDefined() + } +}) diff --git a/packages/theme/src/cli/commands/theme/metafields/pull.ts b/packages/theme/src/cli/commands/theme/metafields/pull.ts index ae999118b61..80d1c165f27 100644 --- a/packages/theme/src/cli/commands/theme/metafields/pull.ts +++ b/packages/theme/src/cli/commands/theme/metafields/pull.ts @@ -1,13 +1,19 @@ import {themeFlags} from '../../../flags.js' -import {metafieldsPull, MetafieldsPullFlags} from '../../../services/metafields-pull.js' +import {downloadMetafieldDefinitions, MetafieldsPullFlags} from '../../../services/metafields-pull.js' import ThemeCommand, {RequiredFlags} from '../../../utilities/theme-command.js' -import {globalFlags} from '@shopify/cli-kit/node/cli' +import {themeMetafieldsPullJsonOutputSchema} from '../../../services/metafields-pull/types.js' +import {renderThemeMetafieldsPullResult} from '../../../services/metafields-pull/result.js' +import {globalFlags, jsonFlag} from '@shopify/cli-kit/node/cli' import {Flags} from '@oclif/core' import {InferredFlags} from '@oclif/core/interfaces' type MetafieldsFlags = InferredFlags export default class MetafieldsPull extends ThemeCommand { + static get jsonOutputSchema() { + return themeMetafieldsPullJsonOutputSchema + } + static summary = 'Download metafields definitions from your shop into a local file.' static descriptionWithMarkdown = `Retrieves metafields from Shopify Admin. @@ -18,6 +24,7 @@ If the metafields file already exists, it will be overwritten.` static flags = { ...globalFlags, + ...jsonFlag, ...themeFlags, force: Flags.boolean({ hidden: true, @@ -39,6 +46,7 @@ If the metafields file already exists, it will be overwritten.` noColor: flags['no-color'], } - await metafieldsPull(args) + const result = await downloadMetafieldDefinitions(args) + renderThemeMetafieldsPullResult(result, flags.json ? 'json' : 'text') } } diff --git a/packages/theme/src/cli/services/metafields-pull.test.ts b/packages/theme/src/cli/services/metafields-pull.test.ts index ad449c083b2..c01f85c64c6 100644 --- a/packages/theme/src/cli/services/metafields-pull.test.ts +++ b/packages/theme/src/cli/services/metafields-pull.test.ts @@ -171,3 +171,20 @@ describe('metafields-pull', () => { }) }) }) + +test.each([true, false])('keeps the theme dev caller silent when all requests fail=%s', async (fail) => { + vi.mocked(ensureThemeStore).mockReturnValue('example.myshopify.com') + vi.mocked(ensureAuthenticatedThemes).mockResolvedValue({token: '', storeFqdn: 'example.myshopify.com'}) + vi.mocked(hasRequiredThemeDirectories).mockResolvedValue(true) + vi.mocked(metafieldDefinitionsByOwnerType).mockImplementation(async () => { + if (fail) throw new Error('Unavailable') + return [] + }) + const output = mockAndCaptureOutput() + await inTemporaryDirectory(async (path) => { + await metafieldsPull({path, silent: true}) + await expect(fileExists(metafieldDefinitionPath(path))).resolves.toBe(!fail) + expect(output.info()).toBe('') + expect(output.error()).toBe('') + }) +}) diff --git a/packages/theme/src/cli/services/metafields-pull.ts b/packages/theme/src/cli/services/metafields-pull.ts index f0bdf77feae..8552931cb6c 100644 --- a/packages/theme/src/cli/services/metafields-pull.ts +++ b/packages/theme/src/cli/services/metafields-pull.ts @@ -1,3 +1,5 @@ +import {handleToOwnerType, type ThemeMetafieldsPullResult, type MetafieldDefinitions} from './metafields-pull/types.js' +import {renderThemeMetafieldsPullResult} from './metafields-pull/result.js' import {configureCLIEnvironment} from '../utilities/cli-config.js' import {ensureThemeStore} from '../utilities/theme-store.js' import {ensureDirectoryConfirmed} from '../utilities/theme-ui.js' @@ -5,15 +7,12 @@ import {hasRequiredThemeDirectories} from '../utilities/theme-fs.js' import {AdminSession, ensureAuthenticatedThemes} from '@shopify/cli-kit/node/session' import {cwd, joinPath} from '@shopify/cli-kit/node/path' import {metafieldDefinitionsByOwnerType} from '@shopify/cli-kit/node/themes/api' -import {renderError, renderSuccess} from '@shopify/cli-kit/node/ui' import {writeFileSync} from '@shopify/cli-kit/node/fs' -import {outputDebug} from '@shopify/cli-kit/node/output' import {getOrCreateHiddenShopifyFolder} from '@shopify/cli-kit/node/hidden-folder' interface MetafieldsPullOptions { path: string force: boolean - silent: boolean } export interface MetafieldsPullFlags { @@ -59,56 +58,49 @@ export interface MetafieldsPullFlags { * @param flags - All flags are optional. */ export async function metafieldsPull(flags: MetafieldsPullFlags): Promise { + // Compatibility adapter for theme dev and callers that request silent downloads. + const result = await downloadMetafieldDefinitions(flags) + renderThemeMetafieldsPullResult(result, 'text', flags.silent) +} + +export async function downloadMetafieldDefinitions(flags: MetafieldsPullFlags): Promise { configureCLIEnvironment({verbose: flags.verbose, noColor: flags.noColor}) const store = ensureThemeStore({store: flags.store}) const adminSession = await ensureAuthenticatedThemes(store, flags.password) - await executeMetafieldsPull(adminSession, { + return executeMetafieldsPull(adminSession, { path: flags.path ?? cwd(), force: flags.force ?? false, - silent: flags.silent ?? false, }) } -const handleToOwnerType = { - article: 'ARTICLE', - blog: 'BLOG', - collection: 'COLLECTION', - company: 'COMPANY', - company_location: 'COMPANY_LOCATION', - location: 'LOCATION', - market: 'MARKET', - order: 'ORDER', - page: 'PAGE', - product: 'PRODUCT', - variant: 'PRODUCTVARIANT', - shop: 'SHOP', -} as const - /** * Executes the pullMetafields operation for the shop. * * @param session - the admin session to access the API and download the metafield definitions * @param options - the options that modify where the file gets created */ -async function executeMetafieldsPull(session: AdminSession, options: MetafieldsPullOptions) { - const {force, path, silent} = options +async function executeMetafieldsPull( + session: AdminSession, + options: MetafieldsPullOptions, +): Promise { + const {force, path} = options if (!(await hasRequiredThemeDirectories(path))) { // If this is not a theme, and the CLI is run by the language server, quick return if (process.env.SHOPIFY_LANGUAGE_SERVER === '1') { - return + return {status: 'skipped', reason: 'not-a-theme'} } // Ensure the user is okay with running this command outside a theme if (!(await ensureDirectoryConfirmed(force))) { - return + return {status: 'skipped', reason: 'cancelled'} } } const promises = [] - const failedFetchByOwnerType: string[] = [] + const failedFetchByOwnerType: (typeof handleToOwnerType)[keyof typeof handleToOwnerType][] = [] for (const [handle, ownerType] of Object.entries(handleToOwnerType)) { promises.push( @@ -130,38 +122,13 @@ async function executeMetafieldsPull(session: AdminSession, options: MetafieldsP ...metafieldDefinitionByOwnerType, })) - if (failedFetchByOwnerType.length === Object.values(handleToOwnerType).length) { - if (!silent) { - renderError({ - body: `Failed to fetch metafield definitions.`, - nextSteps: [ - 'Check your network connection and try again.', - 'Ensure you have the permission to fetch metafield definitions.', - ], - reference: [ - { - link: { - label: 'Metafield Definition API', - url: 'https://shopify.dev/docs/api/admin-graphql/latest/queries/metafieldDefinition', - }, - }, - ], - }) - } - return + const failedOwnerTypes = Object.values(handleToOwnerType).filter((type) => failedFetchByOwnerType.includes(type)) + if (failedOwnerTypes.length === Object.values(handleToOwnerType).length) { + return {status: 'failed', failedOwnerTypes} } - await writeMetafieldDefinitionsToFile(path, result) - - if (failedFetchByOwnerType.length > 0) { - outputDebug( - `Failed to fetch metafield definitions for the following owner types: ${failedFetchByOwnerType.join(', ')}`, - ) - } - - if (!silent) { - renderSuccess({body: 'Metafield definitions have been successfully downloaded.'}) - } + const filePath = await writeMetafieldDefinitionsToFile(path, result) + return {status: 'downloaded', path: filePath, definitions: result as MetafieldDefinitions, failedOwnerTypes} } async function writeMetafieldDefinitionsToFile(path: string, content: unknown) { @@ -171,4 +138,5 @@ async function writeMetafieldDefinitionsToFile(path: string, content: unknown) { const fileContent = JSON.stringify(content, null, 2) writeFileSync(filePath, fileContent) + return filePath } diff --git a/packages/theme/src/cli/services/metafields-pull/result.ts b/packages/theme/src/cli/services/metafields-pull/result.ts new file mode 100644 index 00000000000..ac0dbf304d9 --- /dev/null +++ b/packages/theme/src/cli/services/metafields-pull/result.ts @@ -0,0 +1,43 @@ +import {themeMetafieldsPullJsonOutputSchema, type ThemeMetafieldsPullResult} from './types.js' +import {outputDebug, outputResult} from '@shopify/cli-kit/node/output' +import {emitCommandEvent} from '@shopify/cli-kit/node/command-events' +import {renderError, renderSuccess} from '@shopify/cli-kit/node/ui' + +export function renderThemeMetafieldsPullResult( + result: ThemeMetafieldsPullResult, + format: 'text' | 'json', + silent = false, +): void { + if (result.status === 'downloaded' && result.failedOwnerTypes.length > 0) { + outputDebug( + `Failed to fetch metafield definitions for the following owner types: ${result.failedOwnerTypes.join(', ')}`, + ) + } + + if (silent) return + + if (format === 'json') { + if (result.status === 'failed') { + emitCommandEvent({type: 'diagnostic', level: 'error', message: 'Failed to fetch metafield definitions.'}) + } + outputResult(themeMetafieldsPullJsonOutputSchema.encode(result)) + } else if (result.status === 'downloaded') { + renderSuccess({body: 'Metafield definitions have been successfully downloaded.'}) + } else if (result.status === 'failed') { + renderError({ + body: 'Failed to fetch metafield definitions.', + nextSteps: [ + 'Check your network connection and try again.', + 'Ensure you have the permission to fetch metafield definitions.', + ], + reference: [ + { + link: { + label: 'Metafield Definition API', + url: 'https://shopify.dev/docs/api/admin-graphql/latest/queries/metafieldDefinition', + }, + }, + ], + }) + } +} diff --git a/packages/theme/src/cli/services/metafields-pull/types.ts b/packages/theme/src/cli/services/metafields-pull/types.ts new file mode 100644 index 00000000000..2b9fdc49eab --- /dev/null +++ b/packages/theme/src/cli/services/metafields-pull/types.ts @@ -0,0 +1,58 @@ +import {defineJsonOutputSchema, type InferJsonOutputSchema} from '@shopify/cli-kit/node/json-output-schema' +import {zod} from '@shopify/cli-kit/node/schema' + +const MetafieldTypeSchema = zod.object({name: zod.string(), category: zod.string()}) +const MetafieldDefinitionSchema = zod.object({ + key: zod.string(), + namespace: zod.string(), + name: zod.string(), + description: zod.string().nullish(), + type: MetafieldTypeSchema, +}) +export const handleToOwnerType = { + article: 'ARTICLE', + blog: 'BLOG', + collection: 'COLLECTION', + company: 'COMPANY', + company_location: 'COMPANY_LOCATION', + location: 'LOCATION', + market: 'MARKET', + order: 'ORDER', + page: 'PAGE', + product: 'PRODUCT', + variant: 'PRODUCTVARIANT', + shop: 'SHOP', +} as const + +// Object.fromEntries widens the keys, but every supported handle is required in the output. +const DefinitionsSchema = zod.object( + Object.fromEntries( + Object.keys(handleToOwnerType).map((handle) => [handle, zod.array(MetafieldDefinitionSchema)]), + ) as { + [Handle in keyof typeof handleToOwnerType]: zod.ZodArray + }, +) +const OwnerTypeSchema = zod.nativeEnum(handleToOwnerType) + +export const themeMetafieldsPullJsonOutputSchema = defineJsonOutputSchema({ + name: 'ThemeMetafieldsPullResult', + schema: zod.discriminatedUnion('status', [ + zod.object({ + status: zod.literal('downloaded'), + path: zod.string(), + definitions: DefinitionsSchema, + failedOwnerTypes: zod.array(OwnerTypeSchema), + }), + zod.object({status: zod.literal('failed'), failedOwnerTypes: zod.array(OwnerTypeSchema)}), + zod.object({status: zod.literal('skipped'), reason: zod.enum(['not-a-theme', 'cancelled'])}), + ]), + definitions: { + MetafieldOwnerType: OwnerTypeSchema, + MetafieldType: MetafieldTypeSchema, + MetafieldDefinition: MetafieldDefinitionSchema, + MetafieldDefinitions: DefinitionsSchema, + }, +}) + +export type ThemeMetafieldsPullResult = InferJsonOutputSchema +export type MetafieldDefinitions = zod.infer From 22d123232ed5598f5e0aca034fb8f4ab8cdfccb3 Mon Sep 17 00:00:00 2001 From: Gonzalo Riestra Date: Tue, 6 Oct 2026 14:10:50 +0200 Subject: [PATCH 2/7] Normalize metafields pull outcomes and public definitions --- .../commands/theme/metafields/pull.test.ts | 144 ++++++++++++++---- .../src/cli/commands/theme/metafields/pull.ts | 33 +++- .../theme/src/cli/services/metafields-pull.ts | 4 +- .../src/cli/services/metafields-pull/types.ts | 88 ++++++++--- 4 files changed, 211 insertions(+), 58 deletions(-) diff --git a/packages/theme/src/cli/commands/theme/metafields/pull.test.ts b/packages/theme/src/cli/commands/theme/metafields/pull.test.ts index e46e222a65f..97be1c9c2d9 100644 --- a/packages/theme/src/cli/commands/theme/metafields/pull.test.ts +++ b/packages/theme/src/cli/commands/theme/metafields/pull.test.ts @@ -17,6 +17,7 @@ vi.mock('../../../utilities/theme-store.js') vi.mock('../../../utilities/theme-ui.js') vi.mock('@shopify/cli-kit/node/analytics', () => ({ recordEvent: vi.fn(), + reportAnalyticsEvent: vi.fn(), compileData: vi.fn().mockReturnValue({timings: {}, errors: {}, retries: {}, events: {}}), })) vi.mock('@shopify/cli-kit/node/metadata') @@ -28,12 +29,27 @@ beforeEach(() => { vi.mocked(ensureDirectoryConfirmed).mockResolvedValue(true) }) -async function run(path: string) { +async function run(path: string, environment?: string) { const config = new Config({root: __dirname}) await config.load() - await runWithCommandEventsForCommand(['--json'], () => - new MetafieldsPull(['--path', path, '--store', 'example.myshopify.com', '--json'], config).run(), - ) + const previousExitCode = process.exitCode + process.exitCode = 0 + const argv = [ + '--path', + path, + '--store', + 'example.myshopify.com', + '--json', + ...(environment ? ['--environment', environment, '--force'] : []), + ] + try { + await runWithCommandEventsForCommand(argv, () => new MetafieldsPull(argv, config).run()) + return process.exitCode + } finally { + // Restore the process state after the command completes. + + process.exitCode = previousExitCode + } } test.each([undefined, null, 'Definition description'])( @@ -55,20 +71,19 @@ test.each([undefined, null, 'Definition description'])( await run(directory) const result = JSON.parse(stdout()) expect(result).toMatchObject({ - status: 'downloaded', + status: 'success', path: joinPath(directory, '.shopify/metafields.json'), failedOwnerTypes: [], }) - expect(result.definitions.product).toEqual([JSON.parse(JSON.stringify(definition))]) - expect(Object.keys(result.definitions)).toHaveLength(12) - expect(result.definitions.shop).toEqual([]) - expect(JSON.parse(await readFile(result.path))).toEqual(result.definitions) + expect(result.definitions).toEqual([{...definition, description: description ?? null, ownerType: 'PRODUCT'}]) + const artifact = JSON.parse(await readFile(result.path)) + expect(artifact.product).toEqual([JSON.parse(JSON.stringify(definition))]) + expect(Object.keys(artifact)).toHaveLength(12) + expect(artifact.company_location).toEqual([]) + expect(artifact.shop).toEqual([]) expect(themeMetafieldsPullJsonOutputSchema.validate(result)).toEqual(result) - // The output must include every supported owner, even when it has no definitions. - const {variant: _variant, ...incompleteDefinitions} = result.definitions - expect(() => - themeMetafieldsPullJsonOutputSchema.validate({...result, definitions: incompleteDefinitions}), - ).toThrow() + expect(() => themeMetafieldsPullJsonOutputSchema.validate({...result, unknown: true})).toThrow() + expect(() => themeMetafieldsPullJsonOutputSchema.validate({...result, path: 'metafields.json'})).toThrow() expect(stderr()).toBe('') }) }) @@ -80,42 +95,38 @@ test('distinguishes an empty successful download from total fetch failure', asyn await inTemporaryDirectory(async (directory) => { await withCapturedStandardStreams(async ({stdout}) => { await run(directory) - expect(JSON.parse(stdout())).toMatchObject({status: 'downloaded', failedOwnerTypes: []}) + expect(JSON.parse(stdout())).toMatchObject({status: 'success', failedOwnerTypes: []}) await expect(fileExists(joinPath(directory, '.shopify/metafields.json'))).resolves.toBe(true) }) }) }) -test('reports partial results without showing debug diagnostics by default', async () => { +test('retains the native artifact and reports partial failure with a nonzero exit', async () => { vi.mocked(metafieldDefinitionsByOwnerType).mockImplementation(async (owner) => { if (owner === 'PRODUCT') throw new Error('Unavailable') return [] }) await inTemporaryDirectory(async (directory) => { await withCapturedStandardStreams(async ({stdout, stderr}) => { - await run(directory) + await expect(run(directory)).resolves.toBe(1) const result = JSON.parse(stdout()) - expect(result).toMatchObject({status: 'downloaded', failedOwnerTypes: ['PRODUCT'], definitions: {product: []}}) - expect(JSON.parse(await readFile(result.path))).toEqual(result.definitions) - expect(stderr()).toBe('') + expect(result).toMatchObject({status: 'partial', changed: true, failedOwnerTypes: ['PRODUCT'], definitions: []}) + expect(JSON.parse(await readFile(result.path)).product).toEqual([]) + expect(JSON.parse(stderr())).toMatchObject({type: 'diagnostic', level: 'error'}) }) }) }) -test('reports total failure without writing a file or changing the existing nonfatal behavior', async () => { +test('throws a fatal error with failed owner types without writing an artifact', async () => { vi.mocked(metafieldDefinitionsByOwnerType).mockRejectedValue(new Error('Unavailable')) await inTemporaryDirectory(async (directory) => { await withCapturedStandardStreams(async ({stdout, stderr}) => { - await expect(run(directory)).resolves.toBeUndefined() - const result = JSON.parse(stdout()) - expect(result.status).toBe('failed') - expect(result.failedOwnerTypes).toHaveLength(12) - expect(result).not.toHaveProperty('path') - expect(JSON.parse(stderr())).toMatchObject({ - type: 'diagnostic', - level: 'error', + await expect(run(directory)).rejects.toMatchObject({ message: 'Failed to fetch metafield definitions.', + details: {failedOwnerTypes: expect.arrayContaining(['PRODUCT', 'SHOP'])}, }) + expect(stdout()).toBe('') + expect(stderr()).toBe('') await expect(fileExists(joinPath(directory, '.shopify/metafields.json'))).resolves.toBe(false) }) }) @@ -126,7 +137,7 @@ test('reports cancellation without fetching or writing definitions', async () => await inTemporaryDirectory(async (directory) => { await withCapturedStandardStreams(async ({stdout, stderr}) => { await run(directory) - expect(JSON.parse(stdout())).toEqual({status: 'skipped', reason: 'cancelled'}) + expect(JSON.parse(stdout())).toEqual({status: 'cancelled'}) expect(stderr()).toBe('') expect(metafieldDefinitionsByOwnerType).not.toHaveBeenCalled() }) @@ -149,9 +160,7 @@ test('exposes the schema and rejects incomplete definitions and unknown outcomes expect(MetafieldsPull.flags.json).toBeDefined() expect(MetafieldsPull.description).toContain('--json-schema') expect(() => themeMetafieldsPullJsonOutputSchema.validate({status: 'skipped', reason: 'unknown'})).toThrow() - expect(() => - themeMetafieldsPullJsonOutputSchema.validate({status: 'failed', failedOwnerTypes: ['UNKNOWN']}), - ).toThrow() + expect(() => themeMetafieldsPullJsonOutputSchema.validate({status: 'failed', failedOwnerTypes: []})).toThrow() }) test('exports a JSON Schema with resolvable local references', () => { @@ -167,3 +176,72 @@ test('exports a JSON Schema with resolvable local references', () => { expect(target, reference).toBeDefined() } }) + +test.each(['success', 'total failure'])('returns an explicit single environment wrapper on %s', async (mode) => { + vi.mocked(metafieldDefinitionsByOwnerType).mockImplementation(async () => { + if (mode === 'total failure') throw new Error('Unavailable') + return [] + }) + const {loadEnvironment} = await import('@shopify/cli-kit/node/environments') + await inTemporaryDirectory(async (directory) => { + vi.mocked(loadEnvironment).mockResolvedValue({path: directory}) + await withCapturedStandardStreams(async ({stdout}) => { + await expect(run(directory, 'staging')).resolves.toBe(mode === 'total failure' ? 1 : 0) + expect(JSON.parse(stdout())).toEqual({ + environments: [ + { + environment: 'staging', + ...(mode === 'total failure' + ? { + error: expect.objectContaining({ + type: 'abort', + details: {failedOwnerTypes: expect.arrayContaining(['PRODUCT'])}, + }), + } + : { + result: { + status: 'success', + changed: true, + path: joinPath(directory, '.shopify/metafields.json'), + definitions: [], + failedOwnerTypes: [], + }, + }), + }, + ], + }) + }) + }) +}) + +class LifecycleMetafieldsPull extends MetafieldsPull { + execute(): Promise { + return this._run() + } +} + +test('renders a single fatal JSON envelope for a failed download', async () => { + vi.mocked(metafieldDefinitionsByOwnerType).mockRejectedValue(new Error('Unavailable')) + vi.spyOn(MetafieldsPull.prototype as unknown as {init(): Promise}, 'init').mockResolvedValue(undefined) + vi.spyOn(process, 'exit').mockImplementation(() => undefined as never) + const config = new Config({root: __dirname}) + await config.load() + await inTemporaryDirectory(async (directory) => { + await withCapturedStandardStreams(async ({stdout, stderr}) => { + await new LifecycleMetafieldsPull( + ['--path', directory, '--store', 'example.myshopify.com', '--json'], + config, + ).execute() + expect(JSON.parse(stdout())).toEqual({ + error: { + type: 'abort', + message: 'Failed to fetch metafield definitions.', + tryMessage: expect.any(String), + details: {failedOwnerTypes: expect.arrayContaining(['PRODUCT', 'SHOP'])}, + }, + }) + expect(stderr()).toBe('') + }) + }) + expect(process.exit).toHaveBeenCalledWith(1) +}) diff --git a/packages/theme/src/cli/commands/theme/metafields/pull.ts b/packages/theme/src/cli/commands/theme/metafields/pull.ts index 80d1c165f27..2b5aeab5051 100644 --- a/packages/theme/src/cli/commands/theme/metafields/pull.ts +++ b/packages/theme/src/cli/commands/theme/metafields/pull.ts @@ -3,9 +3,13 @@ import {downloadMetafieldDefinitions, MetafieldsPullFlags} from '../../../servic import ThemeCommand, {RequiredFlags} from '../../../utilities/theme-command.js' import {themeMetafieldsPullJsonOutputSchema} from '../../../services/metafields-pull/types.js' import {renderThemeMetafieldsPullResult} from '../../../services/metafields-pull/result.js' +import {AbortError} from '@shopify/cli-kit/node/error' +import {outputResult} from '@shopify/cli-kit/node/output' +import {emitCommandEvent} from '@shopify/cli-kit/node/command-events' import {globalFlags, jsonFlag} from '@shopify/cli-kit/node/cli' import {Flags} from '@oclif/core' import {InferredFlags} from '@oclif/core/interfaces' +import type {ThemeEnvironmentResult} from '../../../services/json-output/schema.js' type MetafieldsFlags = InferredFlags @@ -36,7 +40,7 @@ If the metafields file already exists, it will be overwritten.` static multiEnvironmentsFlags: RequiredFlags = null - async command(flags: MetafieldsFlags) { + async command(flags: MetafieldsFlags, _session?: unknown, multiEnvironment = false) { const args: MetafieldsPullFlags = { path: flags.path, password: flags.password, @@ -47,6 +51,31 @@ If the metafields file already exists, it will be overwritten.` } const result = await downloadMetafieldDefinitions(args) - renderThemeMetafieldsPullResult(result, flags.json ? 'json' : 'text') + if (result.status === 'failed') { + const error = new AbortError( + 'Failed to fetch metafield definitions.', + 'Check your connection and permissions, then try again.', + ) + error.details = {failedOwnerTypes: result.failedOwnerTypes} + throw error + } + if (result.status === 'downloaded' && result.failedOwnerTypes.length > 0) { + process.exitCode = 1 + emitCommandEvent({ + type: 'diagnostic', + level: 'error', + message: `Failed to fetch metafield definitions for: ${result.failedOwnerTypes.join(', ')}.`, + }) + } + if (!multiEnvironment || !flags.json) renderThemeMetafieldsPullResult(result, flags.json ? 'json' : 'text') + return result + } + + protected collectsEnvironmentResults(flags: Partial): boolean { + return Boolean(flags.json) + } + + protected renderEnvironmentResults(environments: ThemeEnvironmentResult[]): void { + outputResult(themeMetafieldsPullJsonOutputSchema.encode({environments})) } } diff --git a/packages/theme/src/cli/services/metafields-pull.ts b/packages/theme/src/cli/services/metafields-pull.ts index 8552931cb6c..38e756bab4c 100644 --- a/packages/theme/src/cli/services/metafields-pull.ts +++ b/packages/theme/src/cli/services/metafields-pull.ts @@ -5,7 +5,7 @@ import {ensureThemeStore} from '../utilities/theme-store.js' import {ensureDirectoryConfirmed} from '../utilities/theme-ui.js' import {hasRequiredThemeDirectories} from '../utilities/theme-fs.js' import {AdminSession, ensureAuthenticatedThemes} from '@shopify/cli-kit/node/session' -import {cwd, joinPath} from '@shopify/cli-kit/node/path' +import {cwd, joinPath, resolvePath} from '@shopify/cli-kit/node/path' import {metafieldDefinitionsByOwnerType} from '@shopify/cli-kit/node/themes/api' import {writeFileSync} from '@shopify/cli-kit/node/fs' import {getOrCreateHiddenShopifyFolder} from '@shopify/cli-kit/node/hidden-folder' @@ -70,7 +70,7 @@ export async function downloadMetafieldDefinitions(flags: MetafieldsPullFlags): const adminSession = await ensureAuthenticatedThemes(store, flags.password) return executeMetafieldsPull(adminSession, { - path: flags.path ?? cwd(), + path: resolvePath(flags.path ?? cwd()), force: flags.force ?? false, }) } diff --git a/packages/theme/src/cli/services/metafields-pull/types.ts b/packages/theme/src/cli/services/metafields-pull/types.ts index 2b9fdc49eab..9b1e8ed5dc7 100644 --- a/packages/theme/src/cli/services/metafields-pull/types.ts +++ b/packages/theme/src/cli/services/metafields-pull/types.ts @@ -1,4 +1,5 @@ -import {defineJsonOutputSchema, type InferJsonOutputSchema} from '@shopify/cli-kit/node/json-output-schema' +import {defineThemeJsonOutputSchema} from '../json-output/schema.js' +import {isAbsolutePath} from '@shopify/cli-kit/node/path' import {zod} from '@shopify/cli-kit/node/schema' const MetafieldTypeSchema = zod.object({name: zod.string(), category: zod.string()}) @@ -25,34 +26,79 @@ export const handleToOwnerType = { } as const // Object.fromEntries widens the keys, but every supported handle is required in the output. -const DefinitionsSchema = zod.object( +const _DefinitionsSchema = zod.object( Object.fromEntries( Object.keys(handleToOwnerType).map((handle) => [handle, zod.array(MetafieldDefinitionSchema)]), ) as { [Handle in keyof typeof handleToOwnerType]: zod.ZodArray }, ) -const OwnerTypeSchema = zod.nativeEnum(handleToOwnerType) -export const themeMetafieldsPullJsonOutputSchema = defineJsonOutputSchema({ +// The language server consumes the existing owner-keyed .shopify/metafields.json artifact. +// Its native keys and upstream type names are preserved in the file, not in the CLI wrapper. +export type MetafieldDefinitions = zod.infer +export type ThemeMetafieldsPullResult = + | {status: 'downloaded'; path: string; definitions: MetafieldDefinitions; failedOwnerTypes: string[]} + | {status: 'failed'; failedOwnerTypes: string[]} + | {status: 'skipped'; reason: 'not-a-theme' | 'cancelled'} + +const PublicDefinitionSchema = zod + .object({ + ownerType: zod.string().describe('The upstream owner type, such as PRODUCT or COMPANY_LOCATION.'), + key: zod.string(), + namespace: zod.string(), + name: zod.string(), + description: zod.string().nullable(), + type: zod + .object({ + name: zod.string().describe('The upstream metafield type, such as single_line_text_field.'), + category: zod.string().describe('The upstream metafield category.'), + }) + .strict(), + }) + .strict() + +export const themeMetafieldsPullJsonOutputSchema = defineThemeJsonOutputSchema({ name: 'ThemeMetafieldsPullResult', - schema: zod.discriminatedUnion('status', [ - zod.object({ - status: zod.literal('downloaded'), - path: zod.string(), - definitions: DefinitionsSchema, - failedOwnerTypes: zod.array(OwnerTypeSchema), - }), - zod.object({status: zod.literal('failed'), failedOwnerTypes: zod.array(OwnerTypeSchema)}), - zod.object({status: zod.literal('skipped'), reason: zod.enum(['not-a-theme', 'cancelled'])}), + schema: zod.union([ + zod + .object({ + status: zod.enum(['success', 'partial']), + changed: zod.literal(true), + path: zod + .string() + .refine(isAbsolutePath) + .describe('The absolute path of the native language-server metafields JSON artifact.'), + definitions: zod + .array(PublicDefinitionSchema) + .describe('All fetched definitions, flattened across owner types.'), + failedOwnerTypes: zod.array(zod.string()).describe('Upstream owner types whose fetch failed.'), + }) + .strict(), + zod.object({status: zod.literal('skipped'), reason: zod.literal('not-a-theme')}).strict(), ]), - definitions: { - MetafieldOwnerType: OwnerTypeSchema, - MetafieldType: MetafieldTypeSchema, - MetafieldDefinition: MetafieldDefinitionSchema, - MetafieldDefinitions: DefinitionsSchema, + definitions: {MetafieldDefinition: PublicDefinitionSchema}, + project: (value) => { + const result = value as ThemeMetafieldsPullResult + if (result.status === 'failed') throw new Error('Failed downloads must use the fatal error envelope.') + if (result.status === 'skipped') { + return result.reason === 'cancelled' ? {status: 'cancelled'} : {status: 'skipped', reason: result.reason} + } + return { + status: result.failedOwnerTypes.length ? 'partial' : 'success', + changed: true, + path: result.path, + definitions: Object.entries(result.definitions).flatMap(([handle, definitions]) => + definitions.map((definition) => ({ + ownerType: handleToOwnerType[handle as keyof typeof handleToOwnerType], + key: definition.key, + namespace: definition.namespace, + name: definition.name, + description: definition.description ?? null, + type: {name: definition.type.name, category: definition.type.category}, + })), + ), + failedOwnerTypes: result.failedOwnerTypes, + } }, }) - -export type ThemeMetafieldsPullResult = InferJsonOutputSchema -export type MetafieldDefinitions = zod.infer From f120d62665447bc6c57488e254d229f8a379d79c Mon Sep 17 00:00:00 2001 From: Gonzalo Riestra Date: Tue, 6 Oct 2026 14:16:44 +0200 Subject: [PATCH 3/7] Refresh theme metafields pull JSON schema documentation --- packages/cli/README.md | 335 ++++++++++++++++++++++++++++++- packages/cli/oclif.manifest.json | 2 +- 2 files changed, 335 insertions(+), 2 deletions(-) diff --git a/packages/cli/README.md b/packages/cli/README.md index 1946a3cdc81..ca9c05fb5eb 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -12697,7 +12697,7 @@ Download metafields definitions from your shop into a local file. ``` USAGE - $ shopify theme metafields pull [--auth-alias ] [-e ...] [--json-schema] [--no-color] [--no-input] + $ shopify theme metafields pull [--auth-alias ] [-e ...] [-j] [--json-schema] [--no-color] [--no-input] [--password ] [--path ] [-s ] [--verbose] FLAGS @@ -12705,6 +12705,10 @@ FLAGS The environment to apply to the current command. [env: SHOPIFY_FLAG_ENVIRONMENT] + -j, --json + Output the result as JSON. Automatically disables color output. + [env: SHOPIFY_FLAG_JSON] + -s, --store= Store URL. It can be the store prefix (example) or the full myshopify.com URL (example.myshopify.com, https://example.myshopify.com). @@ -12744,6 +12748,335 @@ DESCRIPTION Retrieves metafields from Shopify Admin. If the metafields file already exists, it will be overwritten. + + Use `--json-schema` to print the result, error, and event schemas. + + Output from `--json` conforms to the `ThemeMetafieldsPullResult` schema. + + ```json + { + "anyOf": [ + { + "$ref": "#/definitions/ThemeEnvironment/anyOf/0/properties/result" + }, + { + "type": "object", + "properties": { + "environments": { + "type": "array", + "items": { + "$ref": "#/definitions/ThemeEnvironment" + } + } + }, + "required": [ + "environments" + ], + "additionalProperties": false + } + ], + "title": "ThemeMetafieldsPullResult", + "definitions": { + "MetafieldDefinition": { + "type": "object", + "properties": { + "ownerType": { + "type": "string", + "description": "The upstream owner type, such as PRODUCT or COMPANY_LOCATION." + }, + "key": { + "type": "string" + }, + "namespace": { + "type": "string" + }, + "name": { + "type": "string" + }, + "description": { + "type": [ + "string", + "null" + ] + }, + "type": { + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "The upstream metafield type, such as single_line_text_field." + }, + "category": { + "type": "string", + "description": "The upstream metafield category." + } + }, + "required": [ + "name", + "category" + ], + "additionalProperties": false + } + }, + "required": [ + "ownerType", + "key", + "namespace", + "name", + "description", + "type" + ], + "additionalProperties": false + }, + "ThemeEnvironment": { + "anyOf": [ + { + "type": "object", + "properties": { + "environment": { + "type": "string" + }, + "result": { + "anyOf": [ + { + "anyOf": [ + { + "type": "object", + "properties": { + "status": { + "type": "string", + "enum": [ + "success", + "partial" + ] + }, + "changed": { + "type": "boolean", + "const": true + }, + "path": { + "type": "string", + "description": "The absolute path of the native language-server metafields JSON artifact." + }, + "definitions": { + "type": "array", + "items": { + "$ref": "#/definitions/MetafieldDefinition" + }, + "description": "All fetched definitions, flattened across owner types." + }, + "failedOwnerTypes": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Upstream owner types whose fetch failed." + } + }, + "required": [ + "status", + "changed", + "path", + "definitions", + "failedOwnerTypes" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "status": { + "type": "string", + "const": "skipped" + }, + "reason": { + "type": "string", + "const": "not-a-theme" + } + }, + "required": [ + "status", + "reason" + ], + "additionalProperties": false + } + ] + }, + { + "type": "object", + "properties": { + "status": { + "type": "string", + "const": "cancelled" + } + }, + "required": [ + "status" + ], + "additionalProperties": false + } + ] + } + }, + "required": [ + "environment", + "result" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "environment": { + "type": "string" + }, + "error": { + "anyOf": [ + { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "abort" + }, + "message": { + "type": "string" + }, + "tryMessage": { + "type": "string" + }, + "nextSteps": { + "type": "array", + "items": { + "type": "string" + } + }, + "customSections": { + "type": "array", + "items": { + "type": "object", + "properties": { + "title": { + "type": "string" + }, + "body": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "array", + "items": { + "type": "string" + } + } + } + ] + } + }, + "required": [ + "body" + ], + "additionalProperties": false + } + }, + "details": {} + }, + "required": [ + "type", + "message" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "bug" + }, + "message": { + "$ref": "#/definitions/ThemeEnvironment/anyOf/1/properties/error/anyOf/0/properties/message" + }, + "tryMessage": { + "$ref": "#/definitions/ThemeEnvironment/anyOf/1/properties/error/anyOf/0/properties/tryMessage" + }, + "nextSteps": { + "$ref": "#/definitions/ThemeEnvironment/anyOf/1/properties/error/anyOf/0/properties/nextSteps" + }, + "customSections": { + "$ref": "#/definitions/ThemeEnvironment/anyOf/1/properties/error/anyOf/0/properties/customSections" + }, + "details": { + "$ref": "#/definitions/ThemeEnvironment/anyOf/1/properties/error/anyOf/0/properties/details" + }, + "stack": { + "type": "string" + } + }, + "required": [ + "type", + "message" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "external" + }, + "message": { + "$ref": "#/definitions/ThemeEnvironment/anyOf/1/properties/error/anyOf/0/properties/message" + }, + "tryMessage": { + "$ref": "#/definitions/ThemeEnvironment/anyOf/1/properties/error/anyOf/0/properties/tryMessage" + }, + "nextSteps": { + "$ref": "#/definitions/ThemeEnvironment/anyOf/1/properties/error/anyOf/0/properties/nextSteps" + }, + "customSections": { + "$ref": "#/definitions/ThemeEnvironment/anyOf/1/properties/error/anyOf/0/properties/customSections" + }, + "details": { + "$ref": "#/definitions/ThemeEnvironment/anyOf/1/properties/error/anyOf/0/properties/details" + }, + "command": { + "type": "string" + }, + "args": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": [ + "type", + "message", + "command", + "args" + ], + "additionalProperties": false + } + ] + } + }, + "required": [ + "environment", + "error" + ], + "additionalProperties": false + } + ] + } + }, + "$schema": "http://json-schema.org/draft-07/schema#" + } + ``` ``` ## `shopify theme open` diff --git a/packages/cli/oclif.manifest.json b/packages/cli/oclif.manifest.json index 43ad58b788b..d4f21866596 100644 --- a/packages/cli/oclif.manifest.json +++ b/packages/cli/oclif.manifest.json @@ -11609,7 +11609,7 @@ "args": { }, "customPluginName": "@shopify/theme", - "description": "Retrieves metafields from Shopify Admin.\n\nIf the metafields file already exists, it will be overwritten.\n\nUse `--json-schema` to print the result, error, and event schemas.\n\nOutput from `--json` conforms to the `ThemeMetafieldsPullResult` schema.\n\n```json\n{\n \"anyOf\": [\n {\n \"type\": \"object\",\n \"properties\": {\n \"status\": {\n \"type\": \"string\",\n \"const\": \"downloaded\"\n },\n \"path\": {\n \"type\": \"string\"\n },\n \"definitions\": {\n \"$ref\": \"#/definitions/MetafieldDefinitions\"\n },\n \"failedOwnerTypes\": {\n \"type\": \"array\",\n \"items\": {\n \"$ref\": \"#/definitions/MetafieldOwnerType\"\n }\n }\n },\n \"required\": [\n \"status\",\n \"path\",\n \"definitions\",\n \"failedOwnerTypes\"\n ],\n \"additionalProperties\": false\n },\n {\n \"type\": \"object\",\n \"properties\": {\n \"status\": {\n \"type\": \"string\",\n \"const\": \"failed\"\n },\n \"failedOwnerTypes\": {\n \"type\": \"array\",\n \"items\": {\n \"$ref\": \"#/definitions/MetafieldOwnerType\"\n }\n }\n },\n \"required\": [\n \"status\",\n \"failedOwnerTypes\"\n ],\n \"additionalProperties\": false\n },\n {\n \"type\": \"object\",\n \"properties\": {\n \"status\": {\n \"type\": \"string\",\n \"const\": \"skipped\"\n },\n \"reason\": {\n \"type\": \"string\",\n \"enum\": [\n \"not-a-theme\",\n \"cancelled\"\n ]\n }\n },\n \"required\": [\n \"status\",\n \"reason\"\n ],\n \"additionalProperties\": false\n }\n ],\n \"title\": \"ThemeMetafieldsPullResult\",\n \"definitions\": {\n \"MetafieldOwnerType\": {\n \"type\": \"string\",\n \"enum\": [\n \"ARTICLE\",\n \"BLOG\",\n \"COLLECTION\",\n \"COMPANY\",\n \"COMPANY_LOCATION\",\n \"LOCATION\",\n \"MARKET\",\n \"ORDER\",\n \"PAGE\",\n \"PRODUCT\",\n \"PRODUCTVARIANT\",\n \"SHOP\"\n ]\n },\n \"MetafieldType\": {\n \"type\": \"object\",\n \"properties\": {\n \"name\": {\n \"type\": \"string\"\n },\n \"category\": {\n \"type\": \"string\"\n }\n },\n \"required\": [\n \"name\",\n \"category\"\n ],\n \"additionalProperties\": false\n },\n \"MetafieldDefinition\": {\n \"type\": \"object\",\n \"properties\": {\n \"key\": {\n \"type\": \"string\"\n },\n \"namespace\": {\n \"type\": \"string\"\n },\n \"name\": {\n \"type\": \"string\"\n },\n \"description\": {\n \"type\": [\n \"string\",\n \"null\"\n ]\n },\n \"type\": {\n \"$ref\": \"#/definitions/MetafieldType\"\n }\n },\n \"required\": [\n \"key\",\n \"namespace\",\n \"name\",\n \"type\"\n ],\n \"additionalProperties\": false\n },\n \"MetafieldDefinitions\": {\n \"type\": \"object\",\n \"properties\": {\n \"article\": {\n \"type\": \"array\",\n \"items\": {\n \"$ref\": \"#/definitions/MetafieldDefinition\"\n }\n },\n \"blog\": {\n \"type\": \"array\",\n \"items\": {\n \"$ref\": \"#/definitions/MetafieldDefinition\"\n }\n },\n \"collection\": {\n \"type\": \"array\",\n \"items\": {\n \"$ref\": \"#/definitions/MetafieldDefinition\"\n }\n },\n \"company\": {\n \"type\": \"array\",\n \"items\": {\n \"$ref\": \"#/definitions/MetafieldDefinition\"\n }\n },\n \"company_location\": {\n \"type\": \"array\",\n \"items\": {\n \"$ref\": \"#/definitions/MetafieldDefinition\"\n }\n },\n \"location\": {\n \"type\": \"array\",\n \"items\": {\n \"$ref\": \"#/definitions/MetafieldDefinition\"\n }\n },\n \"market\": {\n \"type\": \"array\",\n \"items\": {\n \"$ref\": \"#/definitions/MetafieldDefinition\"\n }\n },\n \"order\": {\n \"type\": \"array\",\n \"items\": {\n \"$ref\": \"#/definitions/MetafieldDefinition\"\n }\n },\n \"page\": {\n \"type\": \"array\",\n \"items\": {\n \"$ref\": \"#/definitions/MetafieldDefinition\"\n }\n },\n \"product\": {\n \"type\": \"array\",\n \"items\": {\n \"$ref\": \"#/definitions/MetafieldDefinition\"\n }\n },\n \"variant\": {\n \"type\": \"array\",\n \"items\": {\n \"$ref\": \"#/definitions/MetafieldDefinition\"\n }\n },\n \"shop\": {\n \"type\": \"array\",\n \"items\": {\n \"$ref\": \"#/definitions/MetafieldDefinition\"\n }\n }\n },\n \"required\": [\n \"article\",\n \"blog\",\n \"collection\",\n \"company\",\n \"company_location\",\n \"location\",\n \"market\",\n \"order\",\n \"page\",\n \"product\",\n \"variant\",\n \"shop\"\n ],\n \"additionalProperties\": false\n }\n },\n \"$schema\": \"http://json-schema.org/draft-07/schema#\"\n}\n```", + "description": "Retrieves metafields from Shopify Admin.\n\nIf the metafields file already exists, it will be overwritten.\n\nUse `--json-schema` to print the result, error, and event schemas.\n\nOutput from `--json` conforms to the `ThemeMetafieldsPullResult` schema.\n\n```json\n{\n \"anyOf\": [\n {\n \"$ref\": \"#/definitions/ThemeEnvironment/anyOf/0/properties/result\"\n },\n {\n \"type\": \"object\",\n \"properties\": {\n \"environments\": {\n \"type\": \"array\",\n \"items\": {\n \"$ref\": \"#/definitions/ThemeEnvironment\"\n }\n }\n },\n \"required\": [\n \"environments\"\n ],\n \"additionalProperties\": false\n }\n ],\n \"title\": \"ThemeMetafieldsPullResult\",\n \"definitions\": {\n \"MetafieldDefinition\": {\n \"type\": \"object\",\n \"properties\": {\n \"ownerType\": {\n \"type\": \"string\",\n \"description\": \"The upstream owner type, such as PRODUCT or COMPANY_LOCATION.\"\n },\n \"key\": {\n \"type\": \"string\"\n },\n \"namespace\": {\n \"type\": \"string\"\n },\n \"name\": {\n \"type\": \"string\"\n },\n \"description\": {\n \"type\": [\n \"string\",\n \"null\"\n ]\n },\n \"type\": {\n \"type\": \"object\",\n \"properties\": {\n \"name\": {\n \"type\": \"string\",\n \"description\": \"The upstream metafield type, such as single_line_text_field.\"\n },\n \"category\": {\n \"type\": \"string\",\n \"description\": \"The upstream metafield category.\"\n }\n },\n \"required\": [\n \"name\",\n \"category\"\n ],\n \"additionalProperties\": false\n }\n },\n \"required\": [\n \"ownerType\",\n \"key\",\n \"namespace\",\n \"name\",\n \"description\",\n \"type\"\n ],\n \"additionalProperties\": false\n },\n \"ThemeEnvironment\": {\n \"anyOf\": [\n {\n \"type\": \"object\",\n \"properties\": {\n \"environment\": {\n \"type\": \"string\"\n },\n \"result\": {\n \"anyOf\": [\n {\n \"anyOf\": [\n {\n \"type\": \"object\",\n \"properties\": {\n \"status\": {\n \"type\": \"string\",\n \"enum\": [\n \"success\",\n \"partial\"\n ]\n },\n \"changed\": {\n \"type\": \"boolean\",\n \"const\": true\n },\n \"path\": {\n \"type\": \"string\",\n \"description\": \"The absolute path of the native language-server metafields JSON artifact.\"\n },\n \"definitions\": {\n \"type\": \"array\",\n \"items\": {\n \"$ref\": \"#/definitions/MetafieldDefinition\"\n },\n \"description\": \"All fetched definitions, flattened across owner types.\"\n },\n \"failedOwnerTypes\": {\n \"type\": \"array\",\n \"items\": {\n \"type\": \"string\"\n },\n \"description\": \"Upstream owner types whose fetch failed.\"\n }\n },\n \"required\": [\n \"status\",\n \"changed\",\n \"path\",\n \"definitions\",\n \"failedOwnerTypes\"\n ],\n \"additionalProperties\": false\n },\n {\n \"type\": \"object\",\n \"properties\": {\n \"status\": {\n \"type\": \"string\",\n \"const\": \"skipped\"\n },\n \"reason\": {\n \"type\": \"string\",\n \"const\": \"not-a-theme\"\n }\n },\n \"required\": [\n \"status\",\n \"reason\"\n ],\n \"additionalProperties\": false\n }\n ]\n },\n {\n \"type\": \"object\",\n \"properties\": {\n \"status\": {\n \"type\": \"string\",\n \"const\": \"cancelled\"\n }\n },\n \"required\": [\n \"status\"\n ],\n \"additionalProperties\": false\n }\n ]\n }\n },\n \"required\": [\n \"environment\",\n \"result\"\n ],\n \"additionalProperties\": false\n },\n {\n \"type\": \"object\",\n \"properties\": {\n \"environment\": {\n \"type\": \"string\"\n },\n \"error\": {\n \"anyOf\": [\n {\n \"type\": \"object\",\n \"properties\": {\n \"type\": {\n \"type\": \"string\",\n \"const\": \"abort\"\n },\n \"message\": {\n \"type\": \"string\"\n },\n \"tryMessage\": {\n \"type\": \"string\"\n },\n \"nextSteps\": {\n \"type\": \"array\",\n \"items\": {\n \"type\": \"string\"\n }\n },\n \"customSections\": {\n \"type\": \"array\",\n \"items\": {\n \"type\": \"object\",\n \"properties\": {\n \"title\": {\n \"type\": \"string\"\n },\n \"body\": {\n \"anyOf\": [\n {\n \"type\": \"string\"\n },\n {\n \"type\": \"array\",\n \"items\": {\n \"type\": \"array\",\n \"items\": {\n \"type\": \"string\"\n }\n }\n }\n ]\n }\n },\n \"required\": [\n \"body\"\n ],\n \"additionalProperties\": false\n }\n },\n \"details\": {}\n },\n \"required\": [\n \"type\",\n \"message\"\n ],\n \"additionalProperties\": false\n },\n {\n \"type\": \"object\",\n \"properties\": {\n \"type\": {\n \"type\": \"string\",\n \"const\": \"bug\"\n },\n \"message\": {\n \"$ref\": \"#/definitions/ThemeEnvironment/anyOf/1/properties/error/anyOf/0/properties/message\"\n },\n \"tryMessage\": {\n \"$ref\": \"#/definitions/ThemeEnvironment/anyOf/1/properties/error/anyOf/0/properties/tryMessage\"\n },\n \"nextSteps\": {\n \"$ref\": \"#/definitions/ThemeEnvironment/anyOf/1/properties/error/anyOf/0/properties/nextSteps\"\n },\n \"customSections\": {\n \"$ref\": \"#/definitions/ThemeEnvironment/anyOf/1/properties/error/anyOf/0/properties/customSections\"\n },\n \"details\": {\n \"$ref\": \"#/definitions/ThemeEnvironment/anyOf/1/properties/error/anyOf/0/properties/details\"\n },\n \"stack\": {\n \"type\": \"string\"\n }\n },\n \"required\": [\n \"type\",\n \"message\"\n ],\n \"additionalProperties\": false\n },\n {\n \"type\": \"object\",\n \"properties\": {\n \"type\": {\n \"type\": \"string\",\n \"const\": \"external\"\n },\n \"message\": {\n \"$ref\": \"#/definitions/ThemeEnvironment/anyOf/1/properties/error/anyOf/0/properties/message\"\n },\n \"tryMessage\": {\n \"$ref\": \"#/definitions/ThemeEnvironment/anyOf/1/properties/error/anyOf/0/properties/tryMessage\"\n },\n \"nextSteps\": {\n \"$ref\": \"#/definitions/ThemeEnvironment/anyOf/1/properties/error/anyOf/0/properties/nextSteps\"\n },\n \"customSections\": {\n \"$ref\": \"#/definitions/ThemeEnvironment/anyOf/1/properties/error/anyOf/0/properties/customSections\"\n },\n \"details\": {\n \"$ref\": \"#/definitions/ThemeEnvironment/anyOf/1/properties/error/anyOf/0/properties/details\"\n },\n \"command\": {\n \"type\": \"string\"\n },\n \"args\": {\n \"type\": \"array\",\n \"items\": {\n \"type\": \"string\"\n }\n }\n },\n \"required\": [\n \"type\",\n \"message\",\n \"command\",\n \"args\"\n ],\n \"additionalProperties\": false\n }\n ]\n }\n },\n \"required\": [\n \"environment\",\n \"error\"\n ],\n \"additionalProperties\": false\n }\n ]\n }\n },\n \"$schema\": \"http://json-schema.org/draft-07/schema#\"\n}\n```", "descriptionWithMarkdown": "Retrieves metafields from Shopify Admin.\n\nIf the metafields file already exists, it will be overwritten.", "enableJsonFlag": false, "flags": { From 2e3195ce65aabf3c98af173671760f6428dd069b Mon Sep 17 00:00:00 2001 From: Gonzalo Riestra Date: Tue, 6 Oct 2026 14:17:16 +0200 Subject: [PATCH 4/7] Refresh generated theme metafields pull command docs --- .../generated/generated_docs_data_v2.json | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/docs-shopify.dev/generated/generated_docs_data_v2.json b/docs-shopify.dev/generated/generated_docs_data_v2.json index a196265b8a7..701fae9d6a8 100644 --- a/docs-shopify.dev/generated/generated_docs_data_v2.json +++ b/docs-shopify.dev/generated/generated_docs_data_v2.json @@ -9417,6 +9417,15 @@ "isOptional": true, "environmentValue": "SHOPIFY_FLAG_ENVIRONMENT" }, + { + "filePath": "docs-shopify.dev/commands/interfaces/theme-metafields-pull.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/theme-metafields-pull.interface.ts", "syntaxKind": "PropertySignature", @@ -9427,7 +9436,7 @@ "environmentValue": "SHOPIFY_FLAG_STORE" } ], - "value": "export interface thememetafieldspull {\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 environment to apply to the current command.\n * @environment SHOPIFY_FLAG_ENVIRONMENT\n */\n '-e, --environment '?: string\n\n /**\n * Print the command's JSON schemas.\n * @environment SHOPIFY_FLAG_JSON_SCHEMA\n */\n '--json-schema'?: ''\n\n /**\n * Disable color output.\n * @environment SHOPIFY_FLAG_NO_COLOR\n */\n '--no-color'?: ''\n\n /**\n * Disable interactive prompts and browser authentication.\n * @environment SHOPIFY_FLAG_NO_INPUT\n */\n '--no-input'?: ''\n\n /**\n * Password generated from the Theme Access app or an Admin API token.\n * @environment SHOPIFY_CLI_THEME_TOKEN\n */\n '--password '?: string\n\n /**\n * The path where you want to run the command. Defaults to the current working directory.\n * @environment SHOPIFY_FLAG_PATH\n */\n '--path '?: string\n\n /**\n * Store URL. It can be the store prefix (example) or the full myshopify.com URL (example.myshopify.com, https://example.myshopify.com).\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 thememetafieldspull {\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 environment to apply to the current command.\n * @environment SHOPIFY_FLAG_ENVIRONMENT\n */\n '-e, --environment '?: string\n\n /**\n * Output the result as JSON. Automatically disables color output.\n * @environment SHOPIFY_FLAG_JSON\n */\n '-j, --json'?: ''\n\n /**\n * Print the command's JSON schemas.\n * @environment SHOPIFY_FLAG_JSON_SCHEMA\n */\n '--json-schema'?: ''\n\n /**\n * Disable color output.\n * @environment SHOPIFY_FLAG_NO_COLOR\n */\n '--no-color'?: ''\n\n /**\n * Disable interactive prompts and browser authentication.\n * @environment SHOPIFY_FLAG_NO_INPUT\n */\n '--no-input'?: ''\n\n /**\n * Password generated from the Theme Access app or an Admin API token.\n * @environment SHOPIFY_CLI_THEME_TOKEN\n */\n '--password '?: string\n\n /**\n * The path where you want to run the command. Defaults to the current working directory.\n * @environment SHOPIFY_FLAG_PATH\n */\n '--path '?: string\n\n /**\n * Store URL. It can be the store prefix (example) or the full myshopify.com URL (example.myshopify.com, https://example.myshopify.com).\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}" } }, "themeopen": { From 1ddcc0757727bd81a955851f088d078c721e2cd4 Mon Sep 17 00:00:00 2001 From: Gonzalo Riestra Date: Tue, 6 Oct 2026 14:28:42 +0200 Subject: [PATCH 5/7] Describe new finite theme JSON support in release notes --- .changeset/theme-commands-json.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/theme-commands-json.md b/.changeset/theme-commands-json.md index 79486896927..f672c372ab6 100644 --- a/.changeset/theme-commands-json.md +++ b/.changeset/theme-commands-json.md @@ -2,4 +2,4 @@ '@shopify/cli': minor --- -Add json flag and output schema for all theme commands +Add structured JSON output to finite theme operations, including metafield downloads. From 0c336b8f7c4e0545f4f0c247e8d3b30c87d9ab64 Mon Sep 17 00:00:00 2001 From: Gonzalo Riestra Date: Tue, 6 Oct 2026 15:55:30 +0200 Subject: [PATCH 6/7] Emit native absolute paths at JSON output boundaries --- .../theme/src/cli/commands/theme/metafields/pull.test.ts | 7 +++++-- packages/theme/src/cli/services/metafields-pull/types.ts | 5 ++++- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/packages/theme/src/cli/commands/theme/metafields/pull.test.ts b/packages/theme/src/cli/commands/theme/metafields/pull.test.ts index 97be1c9c2d9..de895ea33d5 100644 --- a/packages/theme/src/cli/commands/theme/metafields/pull.test.ts +++ b/packages/theme/src/cli/commands/theme/metafields/pull.test.ts @@ -10,6 +10,9 @@ import {ensureAuthenticatedThemes} from '@shopify/cli-kit/node/session' import {metafieldDefinitionsByOwnerType} from '@shopify/cli-kit/node/themes/api' import {withCapturedStandardStreams} from '@shopify/cli-kit/node/testing/output' import {expect, test, vi, beforeEach} from 'vitest' +// Native JSON paths must preserve Windows separators instead of pathe normalization. +// eslint-disable-next-line no-restricted-imports +import {resolve as nativePath} from 'node:path' vi.mock('@shopify/cli-kit/node/session') vi.mock('@shopify/cli-kit/node/themes/api') @@ -72,7 +75,7 @@ test.each([undefined, null, 'Definition description'])( const result = JSON.parse(stdout()) expect(result).toMatchObject({ status: 'success', - path: joinPath(directory, '.shopify/metafields.json'), + path: nativePath(directory, '.shopify/metafields.json'), failedOwnerTypes: [], }) expect(result.definitions).toEqual([{...definition, description: description ?? null, ownerType: 'PRODUCT'}]) @@ -202,7 +205,7 @@ test.each(['success', 'total failure'])('returns an explicit single environment result: { status: 'success', changed: true, - path: joinPath(directory, '.shopify/metafields.json'), + path: nativePath(directory, '.shopify/metafields.json'), definitions: [], failedOwnerTypes: [], }, diff --git a/packages/theme/src/cli/services/metafields-pull/types.ts b/packages/theme/src/cli/services/metafields-pull/types.ts index 9b1e8ed5dc7..b01bad36849 100644 --- a/packages/theme/src/cli/services/metafields-pull/types.ts +++ b/packages/theme/src/cli/services/metafields-pull/types.ts @@ -1,6 +1,9 @@ import {defineThemeJsonOutputSchema} from '../json-output/schema.js' import {isAbsolutePath} from '@shopify/cli-kit/node/path' import {zod} from '@shopify/cli-kit/node/schema' +// Native JSON paths must preserve Windows separators instead of pathe normalization. +// eslint-disable-next-line no-restricted-imports +import {resolve as resolvePath} from 'node:path' const MetafieldTypeSchema = zod.object({name: zod.string(), category: zod.string()}) const MetafieldDefinitionSchema = zod.object({ @@ -87,7 +90,7 @@ export const themeMetafieldsPullJsonOutputSchema = defineThemeJsonOutputSchema({ return { status: result.failedOwnerTypes.length ? 'partial' : 'success', changed: true, - path: result.path, + path: resolvePath(result.path), definitions: Object.entries(result.definitions).flatMap(([handle, definitions]) => definitions.map((definition) => ({ ownerType: handleToOwnerType[handle as keyof typeof handleToOwnerType], From 4a636ee594f5b3f6b913dcd31a332cda922318fa Mon Sep 17 00:00:00 2001 From: Gonzalo Riestra Date: Thu, 8 Oct 2026 10:40:05 +0200 Subject: [PATCH 7/7] Refresh JSON schema documentation after rebase --- packages/cli/README.md | 15 ++++++++++++++- packages/cli/oclif.manifest.json | 2 +- 2 files changed, 15 insertions(+), 2 deletions(-) diff --git a/packages/cli/README.md b/packages/cli/README.md index ca9c05fb5eb..cdded0ff6d1 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -12942,6 +12942,11 @@ DESCRIPTION "message": { "type": "string" }, + "code": { + "type": "string", + "minLength": 1, + "description": "A stable error code, included only when known." + }, "tryMessage": { "type": "string" }, @@ -12982,7 +12987,9 @@ DESCRIPTION "additionalProperties": false } }, - "details": {} + "details": { + "description": "Selected domain details, preserving native API payloads such as GraphQL errors, extensions, and data." + } }, "required": [ "type", @@ -13000,6 +13007,9 @@ DESCRIPTION "message": { "$ref": "#/definitions/ThemeEnvironment/anyOf/1/properties/error/anyOf/0/properties/message" }, + "code": { + "$ref": "#/definitions/ThemeEnvironment/anyOf/1/properties/error/anyOf/0/properties/code" + }, "tryMessage": { "$ref": "#/definitions/ThemeEnvironment/anyOf/1/properties/error/anyOf/0/properties/tryMessage" }, @@ -13032,6 +13042,9 @@ DESCRIPTION "message": { "$ref": "#/definitions/ThemeEnvironment/anyOf/1/properties/error/anyOf/0/properties/message" }, + "code": { + "$ref": "#/definitions/ThemeEnvironment/anyOf/1/properties/error/anyOf/0/properties/code" + }, "tryMessage": { "$ref": "#/definitions/ThemeEnvironment/anyOf/1/properties/error/anyOf/0/properties/tryMessage" }, diff --git a/packages/cli/oclif.manifest.json b/packages/cli/oclif.manifest.json index d4f21866596..a742fcf385b 100644 --- a/packages/cli/oclif.manifest.json +++ b/packages/cli/oclif.manifest.json @@ -11609,7 +11609,7 @@ "args": { }, "customPluginName": "@shopify/theme", - "description": "Retrieves metafields from Shopify Admin.\n\nIf the metafields file already exists, it will be overwritten.\n\nUse `--json-schema` to print the result, error, and event schemas.\n\nOutput from `--json` conforms to the `ThemeMetafieldsPullResult` schema.\n\n```json\n{\n \"anyOf\": [\n {\n \"$ref\": \"#/definitions/ThemeEnvironment/anyOf/0/properties/result\"\n },\n {\n \"type\": \"object\",\n \"properties\": {\n \"environments\": {\n \"type\": \"array\",\n \"items\": {\n \"$ref\": \"#/definitions/ThemeEnvironment\"\n }\n }\n },\n \"required\": [\n \"environments\"\n ],\n \"additionalProperties\": false\n }\n ],\n \"title\": \"ThemeMetafieldsPullResult\",\n \"definitions\": {\n \"MetafieldDefinition\": {\n \"type\": \"object\",\n \"properties\": {\n \"ownerType\": {\n \"type\": \"string\",\n \"description\": \"The upstream owner type, such as PRODUCT or COMPANY_LOCATION.\"\n },\n \"key\": {\n \"type\": \"string\"\n },\n \"namespace\": {\n \"type\": \"string\"\n },\n \"name\": {\n \"type\": \"string\"\n },\n \"description\": {\n \"type\": [\n \"string\",\n \"null\"\n ]\n },\n \"type\": {\n \"type\": \"object\",\n \"properties\": {\n \"name\": {\n \"type\": \"string\",\n \"description\": \"The upstream metafield type, such as single_line_text_field.\"\n },\n \"category\": {\n \"type\": \"string\",\n \"description\": \"The upstream metafield category.\"\n }\n },\n \"required\": [\n \"name\",\n \"category\"\n ],\n \"additionalProperties\": false\n }\n },\n \"required\": [\n \"ownerType\",\n \"key\",\n \"namespace\",\n \"name\",\n \"description\",\n \"type\"\n ],\n \"additionalProperties\": false\n },\n \"ThemeEnvironment\": {\n \"anyOf\": [\n {\n \"type\": \"object\",\n \"properties\": {\n \"environment\": {\n \"type\": \"string\"\n },\n \"result\": {\n \"anyOf\": [\n {\n \"anyOf\": [\n {\n \"type\": \"object\",\n \"properties\": {\n \"status\": {\n \"type\": \"string\",\n \"enum\": [\n \"success\",\n \"partial\"\n ]\n },\n \"changed\": {\n \"type\": \"boolean\",\n \"const\": true\n },\n \"path\": {\n \"type\": \"string\",\n \"description\": \"The absolute path of the native language-server metafields JSON artifact.\"\n },\n \"definitions\": {\n \"type\": \"array\",\n \"items\": {\n \"$ref\": \"#/definitions/MetafieldDefinition\"\n },\n \"description\": \"All fetched definitions, flattened across owner types.\"\n },\n \"failedOwnerTypes\": {\n \"type\": \"array\",\n \"items\": {\n \"type\": \"string\"\n },\n \"description\": \"Upstream owner types whose fetch failed.\"\n }\n },\n \"required\": [\n \"status\",\n \"changed\",\n \"path\",\n \"definitions\",\n \"failedOwnerTypes\"\n ],\n \"additionalProperties\": false\n },\n {\n \"type\": \"object\",\n \"properties\": {\n \"status\": {\n \"type\": \"string\",\n \"const\": \"skipped\"\n },\n \"reason\": {\n \"type\": \"string\",\n \"const\": \"not-a-theme\"\n }\n },\n \"required\": [\n \"status\",\n \"reason\"\n ],\n \"additionalProperties\": false\n }\n ]\n },\n {\n \"type\": \"object\",\n \"properties\": {\n \"status\": {\n \"type\": \"string\",\n \"const\": \"cancelled\"\n }\n },\n \"required\": [\n \"status\"\n ],\n \"additionalProperties\": false\n }\n ]\n }\n },\n \"required\": [\n \"environment\",\n \"result\"\n ],\n \"additionalProperties\": false\n },\n {\n \"type\": \"object\",\n \"properties\": {\n \"environment\": {\n \"type\": \"string\"\n },\n \"error\": {\n \"anyOf\": [\n {\n \"type\": \"object\",\n \"properties\": {\n \"type\": {\n \"type\": \"string\",\n \"const\": \"abort\"\n },\n \"message\": {\n \"type\": \"string\"\n },\n \"tryMessage\": {\n \"type\": \"string\"\n },\n \"nextSteps\": {\n \"type\": \"array\",\n \"items\": {\n \"type\": \"string\"\n }\n },\n \"customSections\": {\n \"type\": \"array\",\n \"items\": {\n \"type\": \"object\",\n \"properties\": {\n \"title\": {\n \"type\": \"string\"\n },\n \"body\": {\n \"anyOf\": [\n {\n \"type\": \"string\"\n },\n {\n \"type\": \"array\",\n \"items\": {\n \"type\": \"array\",\n \"items\": {\n \"type\": \"string\"\n }\n }\n }\n ]\n }\n },\n \"required\": [\n \"body\"\n ],\n \"additionalProperties\": false\n }\n },\n \"details\": {}\n },\n \"required\": [\n \"type\",\n \"message\"\n ],\n \"additionalProperties\": false\n },\n {\n \"type\": \"object\",\n \"properties\": {\n \"type\": {\n \"type\": \"string\",\n \"const\": \"bug\"\n },\n \"message\": {\n \"$ref\": \"#/definitions/ThemeEnvironment/anyOf/1/properties/error/anyOf/0/properties/message\"\n },\n \"tryMessage\": {\n \"$ref\": \"#/definitions/ThemeEnvironment/anyOf/1/properties/error/anyOf/0/properties/tryMessage\"\n },\n \"nextSteps\": {\n \"$ref\": \"#/definitions/ThemeEnvironment/anyOf/1/properties/error/anyOf/0/properties/nextSteps\"\n },\n \"customSections\": {\n \"$ref\": \"#/definitions/ThemeEnvironment/anyOf/1/properties/error/anyOf/0/properties/customSections\"\n },\n \"details\": {\n \"$ref\": \"#/definitions/ThemeEnvironment/anyOf/1/properties/error/anyOf/0/properties/details\"\n },\n \"stack\": {\n \"type\": \"string\"\n }\n },\n \"required\": [\n \"type\",\n \"message\"\n ],\n \"additionalProperties\": false\n },\n {\n \"type\": \"object\",\n \"properties\": {\n \"type\": {\n \"type\": \"string\",\n \"const\": \"external\"\n },\n \"message\": {\n \"$ref\": \"#/definitions/ThemeEnvironment/anyOf/1/properties/error/anyOf/0/properties/message\"\n },\n \"tryMessage\": {\n \"$ref\": \"#/definitions/ThemeEnvironment/anyOf/1/properties/error/anyOf/0/properties/tryMessage\"\n },\n \"nextSteps\": {\n \"$ref\": \"#/definitions/ThemeEnvironment/anyOf/1/properties/error/anyOf/0/properties/nextSteps\"\n },\n \"customSections\": {\n \"$ref\": \"#/definitions/ThemeEnvironment/anyOf/1/properties/error/anyOf/0/properties/customSections\"\n },\n \"details\": {\n \"$ref\": \"#/definitions/ThemeEnvironment/anyOf/1/properties/error/anyOf/0/properties/details\"\n },\n \"command\": {\n \"type\": \"string\"\n },\n \"args\": {\n \"type\": \"array\",\n \"items\": {\n \"type\": \"string\"\n }\n }\n },\n \"required\": [\n \"type\",\n \"message\",\n \"command\",\n \"args\"\n ],\n \"additionalProperties\": false\n }\n ]\n }\n },\n \"required\": [\n \"environment\",\n \"error\"\n ],\n \"additionalProperties\": false\n }\n ]\n }\n },\n \"$schema\": \"http://json-schema.org/draft-07/schema#\"\n}\n```", + "description": "Retrieves metafields from Shopify Admin.\n\nIf the metafields file already exists, it will be overwritten.\n\nUse `--json-schema` to print the result, error, and event schemas.\n\nOutput from `--json` conforms to the `ThemeMetafieldsPullResult` schema.\n\n```json\n{\n \"anyOf\": [\n {\n \"$ref\": \"#/definitions/ThemeEnvironment/anyOf/0/properties/result\"\n },\n {\n \"type\": \"object\",\n \"properties\": {\n \"environments\": {\n \"type\": \"array\",\n \"items\": {\n \"$ref\": \"#/definitions/ThemeEnvironment\"\n }\n }\n },\n \"required\": [\n \"environments\"\n ],\n \"additionalProperties\": false\n }\n ],\n \"title\": \"ThemeMetafieldsPullResult\",\n \"definitions\": {\n \"MetafieldDefinition\": {\n \"type\": \"object\",\n \"properties\": {\n \"ownerType\": {\n \"type\": \"string\",\n \"description\": \"The upstream owner type, such as PRODUCT or COMPANY_LOCATION.\"\n },\n \"key\": {\n \"type\": \"string\"\n },\n \"namespace\": {\n \"type\": \"string\"\n },\n \"name\": {\n \"type\": \"string\"\n },\n \"description\": {\n \"type\": [\n \"string\",\n \"null\"\n ]\n },\n \"type\": {\n \"type\": \"object\",\n \"properties\": {\n \"name\": {\n \"type\": \"string\",\n \"description\": \"The upstream metafield type, such as single_line_text_field.\"\n },\n \"category\": {\n \"type\": \"string\",\n \"description\": \"The upstream metafield category.\"\n }\n },\n \"required\": [\n \"name\",\n \"category\"\n ],\n \"additionalProperties\": false\n }\n },\n \"required\": [\n \"ownerType\",\n \"key\",\n \"namespace\",\n \"name\",\n \"description\",\n \"type\"\n ],\n \"additionalProperties\": false\n },\n \"ThemeEnvironment\": {\n \"anyOf\": [\n {\n \"type\": \"object\",\n \"properties\": {\n \"environment\": {\n \"type\": \"string\"\n },\n \"result\": {\n \"anyOf\": [\n {\n \"anyOf\": [\n {\n \"type\": \"object\",\n \"properties\": {\n \"status\": {\n \"type\": \"string\",\n \"enum\": [\n \"success\",\n \"partial\"\n ]\n },\n \"changed\": {\n \"type\": \"boolean\",\n \"const\": true\n },\n \"path\": {\n \"type\": \"string\",\n \"description\": \"The absolute path of the native language-server metafields JSON artifact.\"\n },\n \"definitions\": {\n \"type\": \"array\",\n \"items\": {\n \"$ref\": \"#/definitions/MetafieldDefinition\"\n },\n \"description\": \"All fetched definitions, flattened across owner types.\"\n },\n \"failedOwnerTypes\": {\n \"type\": \"array\",\n \"items\": {\n \"type\": \"string\"\n },\n \"description\": \"Upstream owner types whose fetch failed.\"\n }\n },\n \"required\": [\n \"status\",\n \"changed\",\n \"path\",\n \"definitions\",\n \"failedOwnerTypes\"\n ],\n \"additionalProperties\": false\n },\n {\n \"type\": \"object\",\n \"properties\": {\n \"status\": {\n \"type\": \"string\",\n \"const\": \"skipped\"\n },\n \"reason\": {\n \"type\": \"string\",\n \"const\": \"not-a-theme\"\n }\n },\n \"required\": [\n \"status\",\n \"reason\"\n ],\n \"additionalProperties\": false\n }\n ]\n },\n {\n \"type\": \"object\",\n \"properties\": {\n \"status\": {\n \"type\": \"string\",\n \"const\": \"cancelled\"\n }\n },\n \"required\": [\n \"status\"\n ],\n \"additionalProperties\": false\n }\n ]\n }\n },\n \"required\": [\n \"environment\",\n \"result\"\n ],\n \"additionalProperties\": false\n },\n {\n \"type\": \"object\",\n \"properties\": {\n \"environment\": {\n \"type\": \"string\"\n },\n \"error\": {\n \"anyOf\": [\n {\n \"type\": \"object\",\n \"properties\": {\n \"type\": {\n \"type\": \"string\",\n \"const\": \"abort\"\n },\n \"message\": {\n \"type\": \"string\"\n },\n \"code\": {\n \"type\": \"string\",\n \"minLength\": 1,\n \"description\": \"A stable error code, included only when known.\"\n },\n \"tryMessage\": {\n \"type\": \"string\"\n },\n \"nextSteps\": {\n \"type\": \"array\",\n \"items\": {\n \"type\": \"string\"\n }\n },\n \"customSections\": {\n \"type\": \"array\",\n \"items\": {\n \"type\": \"object\",\n \"properties\": {\n \"title\": {\n \"type\": \"string\"\n },\n \"body\": {\n \"anyOf\": [\n {\n \"type\": \"string\"\n },\n {\n \"type\": \"array\",\n \"items\": {\n \"type\": \"array\",\n \"items\": {\n \"type\": \"string\"\n }\n }\n }\n ]\n }\n },\n \"required\": [\n \"body\"\n ],\n \"additionalProperties\": false\n }\n },\n \"details\": {\n \"description\": \"Selected domain details, preserving native API payloads such as GraphQL errors, extensions, and data.\"\n }\n },\n \"required\": [\n \"type\",\n \"message\"\n ],\n \"additionalProperties\": false\n },\n {\n \"type\": \"object\",\n \"properties\": {\n \"type\": {\n \"type\": \"string\",\n \"const\": \"bug\"\n },\n \"message\": {\n \"$ref\": \"#/definitions/ThemeEnvironment/anyOf/1/properties/error/anyOf/0/properties/message\"\n },\n \"code\": {\n \"$ref\": \"#/definitions/ThemeEnvironment/anyOf/1/properties/error/anyOf/0/properties/code\"\n },\n \"tryMessage\": {\n \"$ref\": \"#/definitions/ThemeEnvironment/anyOf/1/properties/error/anyOf/0/properties/tryMessage\"\n },\n \"nextSteps\": {\n \"$ref\": \"#/definitions/ThemeEnvironment/anyOf/1/properties/error/anyOf/0/properties/nextSteps\"\n },\n \"customSections\": {\n \"$ref\": \"#/definitions/ThemeEnvironment/anyOf/1/properties/error/anyOf/0/properties/customSections\"\n },\n \"details\": {\n \"$ref\": \"#/definitions/ThemeEnvironment/anyOf/1/properties/error/anyOf/0/properties/details\"\n },\n \"stack\": {\n \"type\": \"string\"\n }\n },\n \"required\": [\n \"type\",\n \"message\"\n ],\n \"additionalProperties\": false\n },\n {\n \"type\": \"object\",\n \"properties\": {\n \"type\": {\n \"type\": \"string\",\n \"const\": \"external\"\n },\n \"message\": {\n \"$ref\": \"#/definitions/ThemeEnvironment/anyOf/1/properties/error/anyOf/0/properties/message\"\n },\n \"code\": {\n \"$ref\": \"#/definitions/ThemeEnvironment/anyOf/1/properties/error/anyOf/0/properties/code\"\n },\n \"tryMessage\": {\n \"$ref\": \"#/definitions/ThemeEnvironment/anyOf/1/properties/error/anyOf/0/properties/tryMessage\"\n },\n \"nextSteps\": {\n \"$ref\": \"#/definitions/ThemeEnvironment/anyOf/1/properties/error/anyOf/0/properties/nextSteps\"\n },\n \"customSections\": {\n \"$ref\": \"#/definitions/ThemeEnvironment/anyOf/1/properties/error/anyOf/0/properties/customSections\"\n },\n \"details\": {\n \"$ref\": \"#/definitions/ThemeEnvironment/anyOf/1/properties/error/anyOf/0/properties/details\"\n },\n \"command\": {\n \"type\": \"string\"\n },\n \"args\": {\n \"type\": \"array\",\n \"items\": {\n \"type\": \"string\"\n }\n }\n },\n \"required\": [\n \"type\",\n \"message\",\n \"command\",\n \"args\"\n ],\n \"additionalProperties\": false\n }\n ]\n }\n },\n \"required\": [\n \"environment\",\n \"error\"\n ],\n \"additionalProperties\": false\n }\n ]\n }\n },\n \"$schema\": \"http://json-schema.org/draft-07/schema#\"\n}\n```", "descriptionWithMarkdown": "Retrieves metafields from Shopify Admin.\n\nIf the metafields file already exists, it will be overwritten.", "enableJsonFlag": false, "flags": {