diff --git a/.changeset/theme-commands-json.md b/.changeset/theme-commands-json.md new file mode 100644 index 00000000000..f672c372ab6 --- /dev/null +++ b/.changeset/theme-commands-json.md @@ -0,0 +1,5 @@ +--- +'@shopify/cli': minor +--- + +Add structured JSON output to finite theme operations, including metafield downloads. diff --git a/docs-shopify.dev/generated/generated_docs_data_v2.json b/docs-shopify.dev/generated/generated_docs_data_v2.json index 157e3becc66..0c84bdd9430 100644 --- a/docs-shopify.dev/generated/generated_docs_data_v2.json +++ b/docs-shopify.dev/generated/generated_docs_data_v2.json @@ -9534,6 +9534,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", @@ -9544,7 +9553,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": { diff --git a/packages/cli/README.md b/packages/cli/README.md index a40a48706a6..84a792ca605 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -13963,7 +13963,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 @@ -13971,6 +13971,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). @@ -14010,6 +14014,348 @@ 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" + }, + "code": { + "type": "string", + "minLength": 1, + "description": "A stable error code, included only when known." + }, + "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": { + "description": "Selected domain details, preserving native API payloads such as GraphQL errors, extensions, and data." + } + }, + "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" + }, + "code": { + "$ref": "#/definitions/ThemeEnvironment/anyOf/1/properties/error/anyOf/0/properties/code" + }, + "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" + }, + "code": { + "$ref": "#/definitions/ThemeEnvironment/anyOf/1/properties/error/anyOf/0/properties/code" + }, + "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 0caaec0113e..224ad47b84d 100644 --- a/packages/cli/oclif.manifest.json +++ b/packages/cli/oclif.manifest.json @@ -11767,7 +11767,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 \"$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": { @@ -11797,6 +11797,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 58f33b7f4f4..4ef121ffce7 100644 --- a/packages/eslint-plugin-cli/rules/json-output-command-exceptions.js +++ b/packages/eslint-plugin-cli/rules/json-output-command-exceptions.js @@ -15,7 +15,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..de895ea33d5 --- /dev/null +++ b/packages/theme/src/cli/commands/theme/metafields/pull.test.ts @@ -0,0 +1,250 @@ +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' +// 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') +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') +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, environment?: string) { + const config = new Config({root: __dirname}) + await config.load() + 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'])( + '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: 'success', + path: nativePath(directory, '.shopify/metafields.json'), + failedOwnerTypes: [], + }) + 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) + expect(() => themeMetafieldsPullJsonOutputSchema.validate({...result, unknown: true})).toThrow() + expect(() => themeMetafieldsPullJsonOutputSchema.validate({...result, path: 'metafields.json'})).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: 'success', failedOwnerTypes: []}) + await expect(fileExists(joinPath(directory, '.shopify/metafields.json'))).resolves.toBe(true) + }) + }) +}) + +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 expect(run(directory)).resolves.toBe(1) + const result = JSON.parse(stdout()) + 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('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)).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) + }) + }) +}) + +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: '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: []})).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() + } +}) + +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: nativePath(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 ae999118b61..229362c8811 100644 --- a/packages/theme/src/cli/commands/theme/metafields/pull.ts +++ b/packages/theme/src/cli/commands/theme/metafields/pull.ts @@ -1,13 +1,23 @@ 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 {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 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 +28,7 @@ If the metafields file already exists, it will be overwritten.` static flags = { ...globalFlags, + ...jsonFlag, ...themeFlags, force: Flags.boolean({ hidden: true, @@ -29,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, @@ -39,6 +50,28 @@ If the metafields file already exists, it will be overwritten.` noColor: flags['no-color'], } - await metafieldsPull(args) + const result = await downloadMetafieldDefinitions(args) + 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 renderEnvironmentResults(environments: ThemeEnvironmentResult[]): void { + outputResult(themeMetafieldsPullJsonOutputSchema.encode({environments})) } } diff --git a/packages/theme/src/cli/services/metafields-pull.test.ts b/packages/theme/src/cli/services/metafields-pull.test.ts index ad449c083b2..19193507e45 100644 --- a/packages/theme/src/cli/services/metafields-pull.test.ts +++ b/packages/theme/src/cli/services/metafields-pull.test.ts @@ -85,7 +85,7 @@ describe('metafields-pull', () => { expect(capturedOutput.error()).toBeFalsy() }) - test('should output to debug if some metafield definitions are not found', async () => { + test('warns about partial downloads and identifies failed owner types', async () => { // Given vi.mocked(metafieldDefinitionsByOwnerType).mockImplementation((type: any, _session: AdminSession) => { if (type === 'PRODUCT') return Promise.reject(new Error(`Failed to fetch metafield definitions for ${type}`)) @@ -120,7 +120,10 @@ describe('metafields-pull', () => { 2, ), ) - expect(capturedOutput.info()).toContain('Metafield definitions have been successfully downloaded.') + expect(capturedOutput.info()).not.toContain('Metafield definitions have been successfully downloaded.') + expect(capturedOutput.warn()).toContain('Metafield definitions were partially downloaded.') + expect(capturedOutput.warn()).toContain('Failed owner types:') + expect(capturedOutput.warn()).toContain('PRODUCT.') expect(capturedOutput.debug()).toContain('Failed to fetch metafield definitions for the following owner types') expect(capturedOutput.error()).toBeFalsy() }) @@ -171,3 +174,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..38e756bab4c 100644 --- a/packages/theme/src/cli/services/metafields-pull.ts +++ b/packages/theme/src/cli/services/metafields-pull.ts @@ -1,19 +1,18 @@ +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' 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 {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, { - path: flags.path ?? cwd(), + return executeMetafieldsPull(adminSession, { + path: resolvePath(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..cb1aa439e46 --- /dev/null +++ b/packages/theme/src/cli/services/metafields-pull/result.ts @@ -0,0 +1,49 @@ +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, renderWarning} 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') { + if (result.failedOwnerTypes.length > 0) { + renderWarning({ + body: `Metafield definitions were partially downloaded. Failed owner types: ${result.failedOwnerTypes.join(', ')}.`, + }) + } else { + 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..b01bad36849 --- /dev/null +++ b/packages/theme/src/cli/services/metafields-pull/types.ts @@ -0,0 +1,107 @@ +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({ + 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 + }, +) + +// 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.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: {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: resolvePath(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, + } + }, +})