diff --git a/.changeset/nervous-terms-invite.md b/.changeset/nervous-terms-invite.md new file mode 100644 index 00000000000..3c3e2fbdf99 --- /dev/null +++ b/.changeset/nervous-terms-invite.md @@ -0,0 +1,8 @@ +--- +'@shopify/theme': minor +'@shopify/cli': minor +--- + +New CLI command under `shopify theme` to pull metafield definitions from the shop + +Run command by calling `shopify theme metafields pull` diff --git a/.changeset/quick-eggs-end.md b/.changeset/quick-eggs-end.md new file mode 100644 index 00000000000..02aaa148b7e --- /dev/null +++ b/.changeset/quick-eggs-end.md @@ -0,0 +1,5 @@ +--- +'@shopify/cli-kit': patch +--- + +Introduce method to fetch metafield definitions by ownerType from Admin API diff --git a/docs-shopify.dev/commands/examples/theme-metafields-pull.example.sh b/docs-shopify.dev/commands/examples/theme-metafields-pull.example.sh new file mode 100644 index 00000000000..808efb44fc4 --- /dev/null +++ b/docs-shopify.dev/commands/examples/theme-metafields-pull.example.sh @@ -0,0 +1 @@ +shopify theme metafields pull [flags] \ No newline at end of file diff --git a/docs-shopify.dev/commands/interfaces/theme-metafields-pull.interface.ts b/docs-shopify.dev/commands/interfaces/theme-metafields-pull.interface.ts new file mode 100644 index 00000000000..de03cfc7819 --- /dev/null +++ b/docs-shopify.dev/commands/interfaces/theme-metafields-pull.interface.ts @@ -0,0 +1,32 @@ +// This is an autogenerated file. Don't edit this file manually. +export interface thememetafieldspull { + /** + * Disable color output. + * @environment SHOPIFY_FLAG_NO_COLOR + */ + '--no-color'?: '' + + /** + * Password generated from the Theme Access app. + * @environment SHOPIFY_CLI_THEME_TOKEN + */ + '--password '?: string + + /** + * The path to your theme directory. + * @environment SHOPIFY_FLAG_PATH + */ + '--path '?: string + + /** + * Store URL. It can be the store prefix (example) or the full myshopify.com URL (example.myshopify.com, https://example.myshopify.com). + * @environment SHOPIFY_FLAG_STORE + */ + '-s, --store '?: string + + /** + * Increase the verbosity of the output. + * @environment SHOPIFY_FLAG_VERBOSE + */ + '--verbose'?: '' +} diff --git a/docs-shopify.dev/commands/theme-metafields-pull.doc.ts b/docs-shopify.dev/commands/theme-metafields-pull.doc.ts new file mode 100644 index 00000000000..cbd78062141 --- /dev/null +++ b/docs-shopify.dev/commands/theme-metafields-pull.doc.ts @@ -0,0 +1,36 @@ +// This is an autogenerated file. Don't edit this file manually. +import {ReferenceEntityTemplateSchema} from '@shopify/generate-docs' + +const data: ReferenceEntityTemplateSchema = { + name: 'theme metafields pull', + description: `Retrieves metafields from Shopify Admin. + +If the metafields file already exists, it will be overwritten.`, + overviewPreviewDescription: `Download metafields defined on Shopify Admin into a local file.`, + type: 'command', + isVisualComponent: false, + defaultExample: { + codeblock: { + tabs: [ + { + title: 'theme metafields pull', + code: './examples/theme-metafields-pull.example.sh', + language: 'bash', + }, + ], + title: 'theme metafields pull', + }, + }, + definitions: [ + { + title: 'Flags', + description: 'The following flags are available for the `theme metafields pull` command:', + type: 'thememetafieldspull', + }, + ], + category: 'theme', + related: [ + ], +} + +export default data \ No newline at end of file diff --git a/docs-shopify.dev/generated/generated_docs_data.json b/docs-shopify.dev/generated/generated_docs_data.json index f4877371d74..dcb101fcd58 100644 --- a/docs-shopify.dev/generated/generated_docs_data.json +++ b/docs-shopify.dev/generated/generated_docs_data.json @@ -5332,6 +5332,89 @@ "category": "theme", "related": [] }, + { + "name": "theme metafields pull", + "description": "Retrieves metafields from Shopify Admin.\n\nIf the metafields file already exists, it will be overwritten.", + "overviewPreviewDescription": "Download metafields defined on Shopify Admin into a local file.", + "type": "command", + "isVisualComponent": false, + "defaultExample": { + "codeblock": { + "tabs": [ + { + "title": "theme metafields pull", + "code": "shopify theme metafields pull [flags]", + "language": "bash" + } + ], + "title": "theme metafields pull" + } + }, + "definitions": [ + { + "title": "Flags", + "description": "The following flags are available for the `theme metafields pull` command:", + "type": "thememetafieldspull", + "typeDefinitions": { + "thememetafieldspull": { + "filePath": "docs-shopify.dev/commands/interfaces/theme-metafields-pull.interface.ts", + "name": "thememetafieldspull", + "description": "", + "members": [ + { + "filePath": "docs-shopify.dev/commands/interfaces/theme-metafields-pull.interface.ts", + "syntaxKind": "PropertySignature", + "name": "--no-color", + "value": "\"\"", + "description": "Disable color output.", + "isOptional": true, + "environmentValue": "SHOPIFY_FLAG_NO_COLOR" + }, + { + "filePath": "docs-shopify.dev/commands/interfaces/theme-metafields-pull.interface.ts", + "syntaxKind": "PropertySignature", + "name": "--password ", + "value": "string", + "description": "Password generated from the Theme Access app.", + "isOptional": true, + "environmentValue": "SHOPIFY_CLI_THEME_TOKEN" + }, + { + "filePath": "docs-shopify.dev/commands/interfaces/theme-metafields-pull.interface.ts", + "syntaxKind": "PropertySignature", + "name": "--path ", + "value": "string", + "description": "The path to your theme directory.", + "isOptional": true, + "environmentValue": "SHOPIFY_FLAG_PATH" + }, + { + "filePath": "docs-shopify.dev/commands/interfaces/theme-metafields-pull.interface.ts", + "syntaxKind": "PropertySignature", + "name": "--verbose", + "value": "\"\"", + "description": "Increase the verbosity of the output.", + "isOptional": true, + "environmentValue": "SHOPIFY_FLAG_VERBOSE" + }, + { + "filePath": "docs-shopify.dev/commands/interfaces/theme-metafields-pull.interface.ts", + "syntaxKind": "PropertySignature", + "name": "-s, --store ", + "value": "string", + "description": "Store URL. It can be the store prefix (example) or the full myshopify.com URL (example.myshopify.com, https://example.myshopify.com).", + "isOptional": true, + "environmentValue": "SHOPIFY_FLAG_STORE" + } + ], + "value": "export interface thememetafieldspull {\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.\n * @environment SHOPIFY_CLI_THEME_TOKEN\n */\n '--password '?: string\n\n /**\n * The path to your theme 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.\n * @environment SHOPIFY_FLAG_VERBOSE\n */\n '--verbose'?: ''\n}" + } + } + } + ], + "category": "theme", + "related": [] + }, { "name": "theme open", "description": "Returns links that let you preview the specified theme. The following links are returned:\n\n - A link to the [editor](/docs/themes/tools/online-editor) for the theme in the Shopify admin.\n - A [preview link](https://help.shopify.com/manual/online-store/themes/adding-themes#share-a-theme-preview-with-others) that you can share with other developers.\n\n If you don't specify a theme, then you're prompted to select the theme to open from the list of the themes in your store.", diff --git a/packages/cli-kit/src/cli/api/graphql/admin/generated/metafield_definitions_by_owner_type.ts b/packages/cli-kit/src/cli/api/graphql/admin/generated/metafield_definitions_by_owner_type.ts new file mode 100644 index 00000000000..f442c494f4b --- /dev/null +++ b/packages/cli-kit/src/cli/api/graphql/admin/generated/metafield_definitions_by_owner_type.ts @@ -0,0 +1,80 @@ +/* eslint-disable @typescript-eslint/consistent-type-definitions */ +import * as Types from './types.js' + +import {TypedDocumentNode as DocumentNode} from '@graphql-typed-document-node/core' + +export type MetafieldDefinitionsByOwnerTypeQueryVariables = Types.Exact<{ + ownerType: Types.MetafieldOwnerType +}> + +export type MetafieldDefinitionsByOwnerTypeQuery = { + metafieldDefinitions: { + nodes: {name: string; namespace: string; description?: string | null; type: {category: string; name: string}}[] + } +} + +export const MetafieldDefinitionsByOwnerType = { + kind: 'Document', + definitions: [ + { + kind: 'OperationDefinition', + operation: 'query', + name: {kind: 'Name', value: 'metafieldDefinitionsByOwnerType'}, + variableDefinitions: [ + { + kind: 'VariableDefinition', + variable: {kind: 'Variable', name: {kind: 'Name', value: 'ownerType'}}, + type: {kind: 'NonNullType', type: {kind: 'NamedType', name: {kind: 'Name', value: 'MetafieldOwnerType'}}}, + }, + ], + selectionSet: { + kind: 'SelectionSet', + selections: [ + { + kind: 'Field', + name: {kind: 'Name', value: 'metafieldDefinitions'}, + arguments: [ + { + kind: 'Argument', + name: {kind: 'Name', value: 'ownerType'}, + value: {kind: 'Variable', name: {kind: 'Name', value: 'ownerType'}}, + }, + {kind: 'Argument', name: {kind: 'Name', value: 'first'}, value: {kind: 'IntValue', value: '250'}}, + ], + selectionSet: { + kind: 'SelectionSet', + selections: [ + { + kind: 'Field', + name: {kind: 'Name', value: 'nodes'}, + selectionSet: { + kind: 'SelectionSet', + selections: [ + {kind: 'Field', name: {kind: 'Name', value: 'name'}}, + {kind: 'Field', name: {kind: 'Name', value: 'namespace'}}, + {kind: 'Field', name: {kind: 'Name', value: 'description'}}, + { + kind: 'Field', + name: {kind: 'Name', value: 'type'}, + selectionSet: { + kind: 'SelectionSet', + selections: [ + {kind: 'Field', name: {kind: 'Name', value: 'category'}}, + {kind: 'Field', name: {kind: 'Name', value: 'name'}}, + {kind: 'Field', name: {kind: 'Name', value: '__typename'}}, + ], + }, + }, + {kind: 'Field', name: {kind: 'Name', value: '__typename'}}, + ], + }, + }, + {kind: 'Field', name: {kind: 'Name', value: '__typename'}}, + ], + }, + }, + ], + }, + }, + ], +} as unknown as DocumentNode diff --git a/packages/cli-kit/src/cli/api/graphql/admin/generated/types.d.ts b/packages/cli-kit/src/cli/api/graphql/admin/generated/types.d.ts index 910af7f7831..f474e13bf0d 100644 --- a/packages/cli-kit/src/cli/api/graphql/admin/generated/types.d.ts +++ b/packages/cli-kit/src/cli/api/graphql/admin/generated/types.d.ts @@ -120,6 +120,67 @@ export type Scalars = { UtcOffset: {input: any; output: any} } +/** Possible types of a metafield's owner resource. */ +export type MetafieldOwnerType = + /** The Api Permission metafield owner type. */ + | 'API_PERMISSION' + /** The Article metafield owner type. */ + | 'ARTICLE' + /** The Blog metafield owner type. */ + | 'BLOG' + /** The Brand metafield owner type. */ + | 'BRAND' + /** The Cart Transform metafield owner type. */ + | 'CARTTRANSFORM' + /** The Collection metafield owner type. */ + | 'COLLECTION' + /** The Company metafield owner type. */ + | 'COMPANY' + /** The Company Location metafield owner type. */ + | 'COMPANY_LOCATION' + /** The Customer metafield owner type. */ + | 'CUSTOMER' + /** The Delivery Customization metafield owner type. */ + | 'DELIVERY_CUSTOMIZATION' + /** The Delivery Method metafield owner type. */ + | 'DELIVERY_METHOD' + /** The Delivery Option Generator metafield owner type. */ + | 'DELIVERY_OPTION_GENERATOR' + /** The Discount metafield owner type. */ + | 'DISCOUNT' + /** The draft order metafield owner type. */ + | 'DRAFTORDER' + /** The Fulfillment Constraint Rule metafield owner type. */ + | 'FULFILLMENT_CONSTRAINT_RULE' + /** The Gate Configuration metafield owner type. */ + | 'GATE_CONFIGURATION' + /** The GiftCardTransaction metafield owner type. */ + | 'GIFT_CARD_TRANSACTION' + /** The Location metafield owner type. */ + | 'LOCATION' + /** The Market metafield owner type. */ + | 'MARKET' + /** The Media Image metafield owner type. */ + | 'MEDIA_IMAGE' + /** The Order metafield owner type. */ + | 'ORDER' + /** The Order Routing Location Rule metafield owner type. */ + | 'ORDER_ROUTING_LOCATION_RULE' + /** The Page metafield owner type. */ + | 'PAGE' + /** The Payment Customization metafield owner type. */ + | 'PAYMENT_CUSTOMIZATION' + /** The Product metafield owner type. */ + | 'PRODUCT' + /** The Product Variant metafield owner type. */ + | 'PRODUCTVARIANT' + /** The Selling Plan metafield owner type. */ + | 'SELLING_PLAN' + /** The Shop metafield owner type. */ + | 'SHOP' + /** The Validation metafield owner type. */ + | 'VALIDATION' + /** Type of a theme file operation result. */ export type OnlineStoreThemeFileResultType = /** Operation was malformed or invalid. */ diff --git a/packages/cli-kit/src/cli/api/graphql/admin/queries/metafield_definitions_by_owner_type.graphql b/packages/cli-kit/src/cli/api/graphql/admin/queries/metafield_definitions_by_owner_type.graphql new file mode 100644 index 00000000000..1e457a9957e --- /dev/null +++ b/packages/cli-kit/src/cli/api/graphql/admin/queries/metafield_definitions_by_owner_type.graphql @@ -0,0 +1,13 @@ +query metafieldDefinitionsByOwnerType($ownerType: MetafieldOwnerType!) { + metafieldDefinitions(ownerType: $ownerType, first: 250) { + nodes { + name + namespace + description + type { + category + name + } + } + } +} diff --git a/packages/cli-kit/src/public/node/themes/api.ts b/packages/cli-kit/src/public/node/themes/api.ts index eb445567be7..2b30cc0259c 100644 --- a/packages/cli-kit/src/public/node/themes/api.ts +++ b/packages/cli-kit/src/public/node/themes/api.ts @@ -6,6 +6,8 @@ import {ThemeDelete} from '../../../cli/api/graphql/admin/generated/theme_delete import {ThemePublish} from '../../../cli/api/graphql/admin/generated/theme_publish.js' import {GetThemeFileBodies} from '../../../cli/api/graphql/admin/generated/get_theme_file_bodies.js' import {GetThemeFileChecksums} from '../../../cli/api/graphql/admin/generated/get_theme_file_checksums.js' +import {MetafieldDefinitionsByOwnerType} from '../../../cli/api/graphql/admin/generated/metafield_definitions_by_owner_type.js' +import {MetafieldOwnerType} from '../../../cli/api/graphql/admin/generated/types.js' import {restRequest, RestResponse, adminRequestDoc} from '@shopify/cli-kit/node/api/admin' import {AdminSession} from '@shopify/cli-kit/node/session' import {AbortError} from '@shopify/cli-kit/node/error' @@ -209,6 +211,23 @@ export async function themeDelete(id: number, session: AdminSession): Promise + // stripping away the __typename field + result.metafieldDefinitions.nodes.map((node) => ({ + name: node.name, + namespace: node.namespace, + description: node.description, + type: { + category: node.type.category, + name: node.type.name, + }, + })), + ) +} + async function request( method: string, path: string, diff --git a/packages/cli/README.md b/packages/cli/README.md index 9d23246f12f..f8cb0a3bfe0 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -70,6 +70,7 @@ * [`shopify theme init [name]`](#shopify-theme-init-name) * [`shopify theme language-server`](#shopify-theme-language-server) * [`shopify theme list`](#shopify-theme-list) +* [`shopify theme metafields pull`](#shopify-theme-metafields-pull) * [`shopify theme open`](#shopify-theme-open) * [`shopify theme package`](#shopify-theme-package) * [`shopify theme publish`](#shopify-theme-publish) @@ -1970,6 +1971,30 @@ DESCRIPTION Lists the themes in your store, along with their IDs and statuses. ``` +## `shopify theme metafields pull` + +Download metafields defined on Shopify Admin into a local file. + +``` +USAGE + $ shopify theme metafields pull [--no-color] [--password ] [--path ] [-s ] [--verbose] + +FLAGS + -s, --store= Store URL. It can be the store prefix (example) or the full myshopify.com URL + (example.myshopify.com, https://example.myshopify.com). + --no-color Disable color output. + --password= Password generated from the Theme Access app. + --path= The path to your theme directory. + --verbose Increase the verbosity of the output. + +DESCRIPTION + Download metafields defined on Shopify Admin into a local file. + + Retrieves metafields from Shopify Admin. + + If the metafields file already exists, it will be overwritten. +``` + ## `shopify theme open` Opens the preview of your remote theme. diff --git a/packages/cli/oclif.manifest.json b/packages/cli/oclif.manifest.json index 5d47583ba10..69ceca1056d 100644 --- a/packages/cli/oclif.manifest.json +++ b/packages/cli/oclif.manifest.json @@ -5365,6 +5365,77 @@ "pluginType": "core", "strict": true }, + "theme:metafields:pull": { + "aliases": [ + ], + "args": { + }, + "customPluginName": "@shopify/theme", + "description": "Retrieves metafields from Shopify Admin.\n\nIf the metafields file already exists, it will be overwritten.", + "descriptionWithMarkdown": "Retrieves metafields from Shopify Admin.\n\nIf the metafields file already exists, it will be overwritten.", + "flags": { + "force": { + "allowNo": false, + "char": "f", + "description": "Proceed without confirmation, if current directory does not seem to be theme directory.", + "env": "SHOPIFY_FLAG_FORCE", + "hidden": true, + "name": "force", + "type": "boolean" + }, + "no-color": { + "allowNo": false, + "description": "Disable color output.", + "env": "SHOPIFY_FLAG_NO_COLOR", + "hidden": false, + "name": "no-color", + "type": "boolean" + }, + "password": { + "description": "Password generated from the Theme Access app.", + "env": "SHOPIFY_CLI_THEME_TOKEN", + "hasDynamicHelp": false, + "multiple": false, + "name": "password", + "type": "option" + }, + "path": { + "description": "The path to your theme directory.", + "env": "SHOPIFY_FLAG_PATH", + "hasDynamicHelp": false, + "multiple": false, + "name": "path", + "noCacheDefault": true, + "type": "option" + }, + "store": { + "char": "s", + "description": "Store URL. It can be the store prefix (example) or the full myshopify.com URL (example.myshopify.com, https://example.myshopify.com).", + "env": "SHOPIFY_FLAG_STORE", + "hasDynamicHelp": false, + "multiple": false, + "name": "store", + "type": "option" + }, + "verbose": { + "allowNo": false, + "description": "Increase the verbosity of the output.", + "env": "SHOPIFY_FLAG_VERBOSE", + "hidden": false, + "name": "verbose", + "type": "boolean" + } + }, + "hasDynamicHelp": false, + "hiddenAliases": [ + ], + "id": "theme:metafields:pull", + "pluginAlias": "@shopify/cli", + "pluginName": "@shopify/cli", + "pluginType": "core", + "strict": true, + "summary": "Download metafields defined on Shopify Admin into a local file." + }, "theme:open": { "aliases": [ ], diff --git a/packages/theme/src/cli/commands/theme/metafields/pull.ts b/packages/theme/src/cli/commands/theme/metafields/pull.ts new file mode 100644 index 00000000000..1a962540c0b --- /dev/null +++ b/packages/theme/src/cli/commands/theme/metafields/pull.ts @@ -0,0 +1,44 @@ +import {themeFlags} from '../../../flags.js' +import {metafieldsPull, MetafieldsPullFlags} from '../../../services/metafields-pull.js' +import ThemeCommand from '../../../utilities/theme-command.js' +import {globalFlags} from '@shopify/cli-kit/node/cli' +import {Flags} from '@oclif/core' + +export default class MetafieldsPull extends ThemeCommand { + static summary = 'Download metafields defined on Shopify Admin into a local file.' + + static descriptionWithMarkdown = `Retrieves metafields from Shopify Admin. + +If the metafields file already exists, it will be overwritten.` + + static description = this.descriptionWithoutMarkdown() + + static flags = { + ...globalFlags, + ...{ + path: themeFlags.path, + password: themeFlags.password, + store: themeFlags.store, + }, + force: Flags.boolean({ + hidden: true, + char: 'f', + description: 'Proceed without confirmation, if current directory does not seem to be theme directory.', + env: 'SHOPIFY_FLAG_FORCE', + }), + } + + async run(): Promise { + const {flags} = await this.parse(MetafieldsPull) + const args: MetafieldsPullFlags = { + path: flags.path, + password: flags.password, + store: flags.store, + force: flags.force, + verbose: flags.verbose, + noColor: flags['no-color'], + } + + await metafieldsPull(args) + } +} diff --git a/packages/theme/src/cli/services/metafields-pull.test.ts b/packages/theme/src/cli/services/metafields-pull.test.ts new file mode 100644 index 00000000000..2d24858790f --- /dev/null +++ b/packages/theme/src/cli/services/metafields-pull.test.ts @@ -0,0 +1,168 @@ +import {setThemeStore} from './local-storage.js' +import {MetafieldsPullFlags, metafieldsPull} from './metafields-pull.js' +import {ensureThemeStore} from '../utilities/theme-store.js' +import {currentDirectoryConfirmed} from '../utilities/theme-ui.js' +import {AdminSession, ensureAuthenticatedThemes} from '@shopify/cli-kit/node/session' +import {metafieldDefinitionsByOwnerType} from '@shopify/cli-kit/node/themes/api' +import {describe, test, vi, beforeEach, expect, Mock} from 'vitest' +import {mkdirSync, writeFileSync} from '@shopify/cli-kit/node/fs' +import {renderError, renderInfo, renderSuccess} from '@shopify/cli-kit/node/ui' + +vi.mock('../utilities/theme-store.js') +vi.mock('../utilities/theme-ui.js') +vi.mock('@shopify/cli-kit/node') +vi.mock('@shopify/cli-kit/node/fs') +vi.mock('@shopify/cli-kit/node/session') +vi.mock('@shopify/cli-kit/node/themes/api') +vi.mock('@shopify/cli-kit/node/ui') + +const adminSession = {token: '', storeFqdn: ''} +const path = '/my-theme' +const metafieldDefinitionPath = `${path}/.shopify/metafields.json` +const defaultFlags: MetafieldsPullFlags = { + path, + force: false, +} + +describe('metafields-pull', () => { + let spiedMetafieldsFileWrite: Mock + const fakeMetafieldDefinition = { + name: 'fakename', + namespace: 'fakespace', + description: 'fake metafield definition is fake', + type: { + category: 'text', + name: 'string', + }, + } + const reference = [ + { + link: { + label: 'Metafield Definition API', + url: 'https://shopify.dev/docs/api/admin-graphql/2024-10/queries/metafieldDefinition', + }, + }, + ] + + beforeEach(() => { + vi.mocked(ensureThemeStore).mockImplementation(() => { + const themeStore = 'example.myshopify.com' + setThemeStore(themeStore) + return themeStore + }) + vi.mocked(ensureAuthenticatedThemes).mockResolvedValue(adminSession) + vi.mocked(mkdirSync).mockReturnValue() + vi.mocked(currentDirectoryConfirmed).mockResolvedValue(true) + + spiedMetafieldsFileWrite = vi.fn() + vi.mocked(writeFileSync).mockImplementation(spiedMetafieldsFileWrite) + }) + + test('should download metafields for each ownerType and write to file', async () => { + // Given + vi.mocked(metafieldDefinitionsByOwnerType).mockImplementation((type: string, _session: AdminSession) => { + if (type !== 'PRODUCT') return Promise.resolve([]) + return Promise.resolve([fakeMetafieldDefinition]) + }) + + // When + await metafieldsPull({...defaultFlags}) + + // Then + expect(spiedMetafieldsFileWrite).toHaveBeenCalledWith( + `${path}/.shopify/metafields.json`, + JSON.stringify( + { + article: [], + blog: [], + brand: [], + collection: [], + company: [], + company_location: [], + location: [], + market: [], + order: [], + page: [], + product: [fakeMetafieldDefinition], + variant: [], + shop: [], + }, + null, + 2, + ), + ) + expect(renderInfo).not.toBeCalled() + expect(renderError).not.toBeCalled() + expect(renderSuccess).toHaveBeenCalledWith({ + body: 'Metafield definitions have been successfully downloaded.', + }) + }) + + test('should render warning if some metafield definitions are not found', async () => { + // Given + vi.mocked(metafieldDefinitionsByOwnerType).mockImplementation((type: string, _session: AdminSession) => { + if (type === 'PRODUCT') return Promise.reject(new Error(`Failed to fetch metafield definitions for ${type}`)) + if (type === 'COLLECTION') return Promise.resolve([fakeMetafieldDefinition]) + return Promise.resolve([]) + }) + + // When + await metafieldsPull({...defaultFlags}) + + // Then + expect(spiedMetafieldsFileWrite).toHaveBeenCalledWith( + `${path}/.shopify/metafields.json`, + JSON.stringify( + { + article: [], + blog: [], + brand: [], + collection: [fakeMetafieldDefinition], + company: [], + company_location: [], + location: [], + market: [], + order: [], + page: [], + product: [], + variant: [], + shop: [], + }, + null, + 2, + ), + ) + expect(renderInfo).toBeCalledWith({ + body: 'Failed to fetch metafield definitions for the following owner types: PRODUCT', + nextSteps: ['Ensure you have the permission for each owner type to fetch its metafield definitions.'], + reference, + }) + expect(renderError).not.toBeCalled() + expect(renderSuccess).toHaveBeenCalledWith({ + body: 'Metafield definitions have been successfully downloaded.', + }) + }) + + test('should render error if no metafield definitions are found', async () => { + // Given + vi.mocked(metafieldDefinitionsByOwnerType).mockImplementation((type: string, _session: AdminSession) => { + return Promise.reject(new Error(`Failed to fetch metafield definitions for ${type}`)) + }) + + // When + await metafieldsPull({...defaultFlags}) + + // Then + expect(spiedMetafieldsFileWrite).not.toBeCalled() + expect(renderInfo).not.toBeCalled() + expect(renderError).toBeCalledWith({ + body: 'Failed to fetch metafield definitions.', + nextSteps: [ + 'Check your network connection and try again.', + 'Ensure you have the permission to fetch metafield definitions.', + ], + reference, + }) + expect(renderSuccess).not.toBeCalled() + }) +}) diff --git a/packages/theme/src/cli/services/metafields-pull.ts b/packages/theme/src/cli/services/metafields-pull.ts new file mode 100644 index 00000000000..c14660ac2d6 --- /dev/null +++ b/packages/theme/src/cli/services/metafields-pull.ts @@ -0,0 +1,157 @@ +import {hasRequiredThemeDirectories} from '../utilities/theme-fs.js' +import {currentDirectoryConfirmed} from '../utilities/theme-ui.js' +import {showEmbeddedCLIWarning} from '../utilities/embedded-cli-warning.js' +import {ensureThemeStore} from '../utilities/theme-store.js' +import {configureCLIEnvironment} from '../utilities/cli-config.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, renderInfo, renderSuccess} from '@shopify/cli-kit/node/ui' +import {mkdirSync, writeFileSync} from '@shopify/cli-kit/node/fs' + +interface MetafieldsPullOptions { + path: string + force: boolean +} + +export interface MetafieldsPullFlags { + /** + * The directory path to download the theme. + */ + path?: string + + /** + * The password for authenticating with the store. + */ + password?: string + + /** + * Store URL. It can be the store prefix (example.myshopify.com) or the full myshopify.com URL (https://example.myshopify.com). + */ + store?: string + + /** Proceed without confirmation, if current directory does not seem to be theme directory. */ + force?: boolean + + /** + * Disable color output. + */ + noColor?: boolean + + /** + * Increase the verbosity of the output. + */ + verbose?: boolean +} + +/** + * Pulls the metafield definitions from an authenticated store. + * + * @param flags - All flags are optional. + */ +export async function metafieldsPull(flags: MetafieldsPullFlags): Promise { + configureCLIEnvironment({verbose: flags.verbose, noColor: flags.noColor}) + showEmbeddedCLIWarning() + + const store = ensureThemeStore({store: flags.store}) + const adminSession = await ensureAuthenticatedThemes(store, flags.password) + + await executeMetafieldsPull(adminSession, { + path: flags.path ?? cwd(), + force: flags.force ?? false, + }) +} + +const handleToOwnerType = { + article: 'ARTICLE', + blog: 'BLOG', + brand: 'BRAND', + 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} = options + + if (!(await hasRequiredThemeDirectories(path)) && !(await currentDirectoryConfirmed(force))) { + return + } + + const promises = [] + const failedFetchByOwnerType: string[] = [] + + for (const [handle, ownerType] of Object.entries(handleToOwnerType)) { + promises.push( + metafieldDefinitionsByOwnerType(ownerType, session) + .catch((_) => { + failedFetchByOwnerType.push(ownerType) + return [] + }) + .then((definitions) => { + return { + [handle]: definitions, + } + }), + ) + } + + const result = (await Promise.all(promises)).reduce((acc, metafieldDefinitionByOwnerType) => ({ + ...acc, + ...metafieldDefinitionByOwnerType, + })) + + const reference = [ + { + link: { + label: 'Metafield Definition API', + url: 'https://shopify.dev/docs/api/admin-graphql/2024-10/queries/metafieldDefinition', + }, + }, + ] + + if (failedFetchByOwnerType.length === Object.values(handleToOwnerType).length) { + 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, + }) + return + } + + writeMetafieldDefinitionsToFile(path, result) + + if (failedFetchByOwnerType.length > 0) { + renderInfo({ + body: `Failed to fetch metafield definitions for the following owner types: ${failedFetchByOwnerType.join(', ')}`, + nextSteps: ['Ensure you have the permission for each owner type to fetch its metafield definitions.'], + reference, + }) + } + + renderSuccess({body: 'Metafield definitions have been successfully downloaded.'}) +} + +function writeMetafieldDefinitionsToFile(path: string, content: unknown) { + const shopifyDirectory = joinPath(path, '.shopify') + mkdirSync(shopifyDirectory) + + const filePath = joinPath(shopifyDirectory, 'metafields.json') + writeFileSync(filePath, JSON.stringify(content, null, 2)) +} diff --git a/packages/theme/src/index.ts b/packages/theme/src/index.ts index 387acb05db8..8a21dbfda58 100644 --- a/packages/theme/src/index.ts +++ b/packages/theme/src/index.ts @@ -9,6 +9,7 @@ import ListCommnd from './cli/commands/theme/list.js' import Open from './cli/commands/theme/open.js' import Package from './cli/commands/theme/package.js' import Publish from './cli/commands/theme/publish.js' +import MetafieldsPull from './cli/commands/theme/metafields/pull.js' import Pull from './cli/commands/theme/pull.js' import Push from './cli/commands/theme/push.js' import Rename from './cli/commands/theme/rename.js' @@ -24,6 +25,7 @@ const COMMANDS = { 'theme:info': ThemeInfo, 'theme:language-server': LanguageServer, 'theme:list': ListCommnd, + 'theme:metafields:pull': MetafieldsPull, 'theme:open': Open, 'theme:package': Package, 'theme:publish': Publish,