From 07395a8afe34c7c683c216bbc8a3d77385adbd19 Mon Sep 17 00:00:00 2001 From: Gonzalo Riestra Date: Mon, 28 Sep 2026 11:51:42 +0200 Subject: [PATCH] Add typed JSON output to theme metafields pull --- .changeset/theme-commands-json.md | 5 + .../generated/generated_docs_data_v2.json | 11 +- packages/cli/README.md | 247 +++++++++++++++++- 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 ++++ 11 files changed, 591 insertions(+), 63 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/docs-shopify.dev/generated/generated_docs_data_v2.json b/docs-shopify.dev/generated/generated_docs_data_v2.json index f911b753420..ff6f3429286 100644 --- a/docs-shopify.dev/generated/generated_docs_data_v2.json +++ b/docs-shopify.dev/generated/generated_docs_data_v2.json @@ -8096,6 +8096,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", @@ -8106,7 +8115,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 * 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 * 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": { diff --git a/packages/cli/README.md b/packages/cli/README.md index 065e4528d8c..2c2d1e93874 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -8765,14 +8765,18 @@ Download metafields definitions from your shop into a local file. ``` USAGE - $ shopify theme metafields pull [--auth-alias ] [-e ...] [--json-schema] [--no-color] [--password ] - [--path ] [-s ] [--verbose] + $ shopify theme metafields pull [--auth-alias ] [-e ...] [-j] [--json-schema] [--no-color] [--password + ] [--path ] [-s ] [--verbose] FLAGS -e, --environment=... 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). @@ -8808,6 +8812,245 @@ 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": [ + { + "type": "object", + "properties": { + "status": { + "type": "string", + "const": "downloaded" + }, + "path": { + "type": "string" + }, + "definitions": { + "$ref": "#/definitions/MetafieldDefinitions" + }, + "failedOwnerTypes": { + "type": "array", + "items": { + "$ref": "#/definitions/MetafieldOwnerType" + } + } + }, + "required": [ + "status", + "path", + "definitions", + "failedOwnerTypes" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "status": { + "type": "string", + "const": "failed" + }, + "failedOwnerTypes": { + "type": "array", + "items": { + "$ref": "#/definitions/MetafieldOwnerType" + } + } + }, + "required": [ + "status", + "failedOwnerTypes" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "status": { + "type": "string", + "const": "skipped" + }, + "reason": { + "type": "string", + "enum": [ + "not-a-theme", + "cancelled" + ] + } + }, + "required": [ + "status", + "reason" + ], + "additionalProperties": false + } + ], + "title": "ThemeMetafieldsPullResult", + "definitions": { + "MetafieldOwnerType": { + "type": "string", + "enum": [ + "ARTICLE", + "BLOG", + "COLLECTION", + "COMPANY", + "COMPANY_LOCATION", + "LOCATION", + "MARKET", + "ORDER", + "PAGE", + "PRODUCT", + "PRODUCTVARIANT", + "SHOP" + ] + }, + "MetafieldType": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "category": { + "type": "string" + } + }, + "required": [ + "name", + "category" + ], + "additionalProperties": false + }, + "MetafieldDefinition": { + "type": "object", + "properties": { + "key": { + "type": "string" + }, + "namespace": { + "type": "string" + }, + "name": { + "type": "string" + }, + "description": { + "type": [ + "string", + "null" + ] + }, + "type": { + "$ref": "#/definitions/MetafieldType" + } + }, + "required": [ + "key", + "namespace", + "name", + "type" + ], + "additionalProperties": false + }, + "MetafieldDefinitions": { + "type": "object", + "properties": { + "article": { + "type": "array", + "items": { + "$ref": "#/definitions/MetafieldDefinition" + } + }, + "blog": { + "type": "array", + "items": { + "$ref": "#/definitions/MetafieldDefinition" + } + }, + "collection": { + "type": "array", + "items": { + "$ref": "#/definitions/MetafieldDefinition" + } + }, + "company": { + "type": "array", + "items": { + "$ref": "#/definitions/MetafieldDefinition" + } + }, + "company_location": { + "type": "array", + "items": { + "$ref": "#/definitions/MetafieldDefinition" + } + }, + "location": { + "type": "array", + "items": { + "$ref": "#/definitions/MetafieldDefinition" + } + }, + "market": { + "type": "array", + "items": { + "$ref": "#/definitions/MetafieldDefinition" + } + }, + "order": { + "type": "array", + "items": { + "$ref": "#/definitions/MetafieldDefinition" + } + }, + "page": { + "type": "array", + "items": { + "$ref": "#/definitions/MetafieldDefinition" + } + }, + "product": { + "type": "array", + "items": { + "$ref": "#/definitions/MetafieldDefinition" + } + }, + "variant": { + "type": "array", + "items": { + "$ref": "#/definitions/MetafieldDefinition" + } + }, + "shop": { + "type": "array", + "items": { + "$ref": "#/definitions/MetafieldDefinition" + } + } + }, + "required": [ + "article", + "blog", + "collection", + "company", + "company_location", + "location", + "market", + "order", + "page", + "product", + "variant", + "shop" + ], + "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 7d50330e941..0b8ab50d8ea 100644 --- a/packages/cli/oclif.manifest.json +++ b/packages/cli/oclif.manifest.json @@ -10540,7 +10540,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": { @@ -10570,6 +10570,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 daa1870c2f1..284932f7f2f 100644 --- a/packages/eslint-plugin-cli/rules/json-output-command-exceptions.js +++ b/packages/eslint-plugin-cli/rules/json-output-command-exceptions.js @@ -43,7 +43,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', // Streaming commands without a single finite result. 'packages/app/src/cli/commands/app/dev.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