diff --git a/.changeset/nervous-terms-invite.md b/.changeset/nervous-terms-invite.md new file mode 100644 index 00000000000..b3accf4acc2 --- /dev/null +++ b/.changeset/nervous-terms-invite.md @@ -0,0 +1,6 @@ +--- +'@shopify/theme': minor +'@shopify/cli': minor +--- + +Developers can now use the `shopify theme metafields pull` command to download metafields, which can then be used for more refined code completion. 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..6c9977f2382 --- /dev/null +++ b/docs-shopify.dev/commands/interfaces/theme-metafields-pull.interface.ts @@ -0,0 +1,38 @@ +// This is an autogenerated file. Don't edit this file manually. +export interface thememetafieldspull { + /** + * The environment to apply to the current command. + * @environment SHOPIFY_FLAG_ENVIRONMENT + */ + '-e, --environment '?: string + + /** + * 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..fdaaf381aee --- /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 definitions from your shop 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 c725ab97c80..a63ead60ab6 100644 --- a/docs-shopify.dev/generated/generated_docs_data.json +++ b/docs-shopify.dev/generated/generated_docs_data.json @@ -5530,6 +5530,98 @@ "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 definitions from your shop 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": "-e, --environment ", + "value": "string", + "description": "The environment to apply to the current command.", + "isOptional": true, + "environmentValue": "SHOPIFY_FLAG_ENVIRONMENT" + }, + { + "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 * The environment to apply to the current command.\n * @environment SHOPIFY_FLAG_ENVIRONMENT\n */\n '-e, --environment '?: string\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.\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..339337b472e --- /dev/null +++ b/packages/cli-kit/src/cli/api/graphql/admin/generated/metafield_definitions_by_owner_type.ts @@ -0,0 +1,87 @@ +/* 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: { + key: string + 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: 'key'}}, + {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 65f693bfcd2..ab109b34c12 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' + /** The input fields for the theme file body. */ export type OnlineStoreThemeFileBodyInput = { /** The input type of the theme file body. */ 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..f05b01b3d06 --- /dev/null +++ b/packages/cli-kit/src/cli/api/graphql/admin/queries/metafield_definitions_by_owner_type.graphql @@ -0,0 +1,14 @@ +query metafieldDefinitionsByOwnerType($ownerType: MetafieldOwnerType!) { + metafieldDefinitions(ownerType: $ownerType, first: 250) { + nodes { + key + 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 706a9eae093..32fba672c97 100644 --- a/packages/cli-kit/src/public/node/themes/api.ts +++ b/packages/cli-kit/src/public/node/themes/api.ts @@ -13,7 +13,9 @@ import { import { OnlineStoreThemeFileBodyInputType, OnlineStoreThemeFilesUpsertFileInput, + MetafieldOwnerType, } from '../../../cli/api/graphql/admin/generated/types.js' +import {MetafieldDefinitionsByOwnerType} from '../../../cli/api/graphql/admin/generated/metafield_definitions_by_owner_type.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' @@ -288,6 +290,23 @@ export async function themeDelete(id: number, session: AdminSession): Promise ({ + key: definition.key, + namespace: definition.namespace, + name: definition.name, + description: definition.description, + type: { + name: definition.type.name, + category: definition.type.category, + }, + })) +} + async function request( method: string, path: string, diff --git a/packages/cli/README.md b/packages/cli/README.md index ca7521e5ea2..587e511fb83 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) @@ -1998,6 +1999,32 @@ DESCRIPTION Lists the themes in your store, along with their IDs and statuses. ``` +## `shopify theme metafields pull` + +Download metafields definitions from your shop into a local file. + +``` +USAGE + $ shopify theme metafields pull [-e ] [--no-color] [--password ] [--path ] [-s ] + [--verbose] + +FLAGS + -e, --environment= The environment to apply to the current command. + -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 definitions from your shop 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 9fdedb43b58..78bfbc0e701 100644 --- a/packages/cli/oclif.manifest.json +++ b/packages/cli/oclif.manifest.json @@ -5606,6 +5606,86 @@ "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": { + "environment": { + "char": "e", + "description": "The environment to apply to the current command.", + "env": "SHOPIFY_FLAG_ENVIRONMENT", + "hasDynamicHelp": false, + "multiple": false, + "name": "environment", + "type": "option" + }, + "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 definitions from your shop into a local file." + }, "theme:open": { "aliases": [ ], diff --git a/packages/theme/src/cli/commands/theme/dev.ts b/packages/theme/src/cli/commands/theme/dev.ts index c137bf671a7..c0fedeaf24d 100644 --- a/packages/theme/src/cli/commands/theme/dev.ts +++ b/packages/theme/src/cli/commands/theme/dev.ts @@ -4,6 +4,7 @@ import ThemeCommand, {FlagValues} from '../../utilities/theme-command.js' import {dev} from '../../services/dev.js' import {DevelopmentThemeManager} from '../../utilities/development-theme-manager.js' import {findOrSelectTheme} from '../../utilities/theme-selector.js' +import {metafieldsPull} from '../../services/metafields-pull.js' import {Flags} from '@oclif/core' import {globalFlags} from '@shopify/cli-kit/node/cli' import {Theme} from '@shopify/cli-kit/node/themes/types' @@ -154,5 +155,15 @@ You can run this command only in a directory that matches the [default Shopify t only, notify: flags.notify, }) + + await metafieldsPull({ + path: flags.path, + password: flags.password, + store: flags.store, + force: flags.force, + verbose: flags.verbose, + noColor: flags['no-color'], + silent: true, + }) } } 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..8b0922d1283 --- /dev/null +++ b/packages/theme/src/cli/commands/theme/metafields/pull.ts @@ -0,0 +1,40 @@ +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 definitions from your shop 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, + ...themeFlags, + 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/commands/theme/pull.ts b/packages/theme/src/cli/commands/theme/pull.ts index d6a6556c7a5..c7de2dfe2a2 100644 --- a/packages/theme/src/cli/commands/theme/pull.ts +++ b/packages/theme/src/cli/commands/theme/pull.ts @@ -61,7 +61,6 @@ If no theme is specified, then you're prompted to select the theme to pull from const pullFlags: PullFlags = { path: flags.path, password: flags.password, - environment: flags.environment, store: flags.store, theme: flags.theme, development: flags.development, diff --git a/packages/theme/src/cli/commands/theme/push.ts b/packages/theme/src/cli/commands/theme/push.ts index c7c3f55fd47..0d4cbdec097 100644 --- a/packages/theme/src/cli/commands/theme/push.ts +++ b/packages/theme/src/cli/commands/theme/push.ts @@ -110,7 +110,6 @@ export default class Push extends ThemeCommand { path: flags.path, password: flags.password, store: flags.store, - environment: flags.environment, theme: flags.theme, development: flags.development, live: flags.live, 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..ea96eda111f --- /dev/null +++ b/packages/theme/src/cli/services/metafields-pull.test.ts @@ -0,0 +1,145 @@ +import {setThemeStore} from './local-storage.js' +import {metafieldsPull} from './metafields-pull.js' +import {ensureThemeStore} from '../utilities/theme-store.js' +import {ensureDirectoryConfirmed} from '../utilities/theme-ui.js' +import {AdminSession, ensureAuthenticatedThemes} from '@shopify/cli-kit/node/session' +import {mockAndCaptureOutput} from '@shopify/cli-kit/node/testing/output' +import {metafieldDefinitionsByOwnerType} from '@shopify/cli-kit/node/themes/api' +import {describe, test, vi, beforeEach, expect, afterEach} from 'vitest' +import {fileExists, inTemporaryDirectory, readFile} from '@shopify/cli-kit/node/fs' + +vi.mock('../utilities/theme-store.js') +vi.mock('../utilities/theme-ui.js') +vi.mock('@shopify/cli-kit/node/session') +vi.mock('@shopify/cli-kit/node/themes/api') + +const metafieldDefinitionPath = (path: string) => `${path}/.shopify/metafields.json` + +describe('metafields-pull', () => { + const fakeMetafieldDefinition = { + key: 'fakename', + name: 'fakename', + namespace: 'fakespace', + description: 'fake metafield definition is fake', + type: { + category: 'text', + name: 'string', + }, + } + const capturedOutput = mockAndCaptureOutput() + + beforeEach(() => { + vi.mocked(ensureThemeStore).mockImplementation(() => { + const themeStore = 'example.myshopify.com' + setThemeStore(themeStore) + return themeStore + }) + vi.mocked(ensureAuthenticatedThemes).mockResolvedValue({token: '', storeFqdn: ''}) + vi.mocked(ensureDirectoryConfirmed).mockResolvedValue(true) + }) + + afterEach(() => { + capturedOutput.clear() + }) + + test('should download metafields for each ownerType and write to file', async () => { + // Given + vi.mocked(metafieldDefinitionsByOwnerType).mockImplementation((type: any, _session: AdminSession) => { + if (type !== 'PRODUCT') return Promise.resolve([]) + return Promise.resolve([fakeMetafieldDefinition]) + }) + + await inTemporaryDirectory(async (tmpDir) => { + // When + await metafieldsPull({path: tmpDir}) + + // Then + const filePath = metafieldDefinitionPath(tmpDir) + await expect(fileExists(filePath)).resolves.toBe(true) + await expect(readFile(filePath)).resolves.toBe( + JSON.stringify( + { + article: [], + blog: [], + brand: [], + collection: [], + company: [], + company_location: [], + location: [], + market: [], + order: [], + page: [], + product: [fakeMetafieldDefinition], + variant: [], + shop: [], + }, + null, + 2, + ), + ) + }) + + expect(capturedOutput.info()).toContain('Metafield definitions have been successfully downloaded.') + expect(capturedOutput.error()).toBeFalsy() + }) + + test('should output to debug if some metafield definitions are not found', 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}`)) + if (type === 'COLLECTION') return Promise.resolve([fakeMetafieldDefinition]) + return Promise.resolve([]) + }) + + await inTemporaryDirectory(async (tmpDir) => { + // When + await metafieldsPull({path: tmpDir}) + + // Then + const filePath = metafieldDefinitionPath(tmpDir) + await expect(fileExists(filePath)).resolves.toBe(true) + await expect(readFile(filePath)).resolves.toBe( + JSON.stringify( + { + article: [], + blog: [], + brand: [], + collection: [fakeMetafieldDefinition], + company: [], + company_location: [], + location: [], + market: [], + order: [], + page: [], + product: [], + variant: [], + shop: [], + }, + null, + 2, + ), + ) + expect(capturedOutput.info()).toContain('Metafield definitions have been successfully downloaded.') + expect(capturedOutput.debug()).toContain('Failed to fetch metafield definitions for the following owner types') + expect(capturedOutput.error()).toBeFalsy() + }) + }) + + 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}`)) + }) + + await inTemporaryDirectory(async (tmpDir) => { + // When + await metafieldsPull({path: tmpDir}) + + // Then + const filePath = metafieldDefinitionPath(tmpDir) + await expect(fileExists(filePath)).resolves.toBe(false) + expect(capturedOutput.info()).toBeFalsy() + expect(capturedOutput.error()).toContain('Failed to fetch metafield definitions.') + }) + }) +}) 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..f929770e601 --- /dev/null +++ b/packages/theme/src/cli/services/metafields-pull.ts @@ -0,0 +1,165 @@ +import {hasRequiredThemeDirectories} from '../utilities/theme-fs.js' +import {ensureDirectoryConfirmed} from '../utilities/theme-ui.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, renderSuccess} from '@shopify/cli-kit/node/ui' +import {mkdirSync, writeFileSync} from '@shopify/cli-kit/node/fs' +import {outputDebug} from '@shopify/cli-kit/node/output' + +interface MetafieldsPullOptions { + path: string + force: boolean + silent: boolean +} + +export interface MetafieldsPullFlags { + /** + * The directory path of the theme to download the metafield definitions. + */ + 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 + + /** + * Suppress all output. + */ + silent?: 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}) + + const store = ensureThemeStore({store: flags.store}) + const adminSession = await ensureAuthenticatedThemes(store, flags.password) + + await executeMetafieldsPull(adminSession, { + path: flags.path ?? cwd(), + force: flags.force ?? false, + silent: flags.silent ?? 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, silent} = options + + if (!(await hasRequiredThemeDirectories(path)) && !(await ensureDirectoryConfirmed(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, + })) + + 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 + } + + 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.'}) + } +} + +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/cli/services/pull.ts b/packages/theme/src/cli/services/pull.ts index 8905200a4f9..1d0df3a7559 100644 --- a/packages/theme/src/cli/services/pull.ts +++ b/packages/theme/src/cli/services/pull.ts @@ -34,11 +34,6 @@ export interface PullFlags { */ password?: string - /** - * The environment to apply to the current command. - */ - environment?: string - /** * Store URL. It can be the store prefix (example.myshopify.com) or the full myshopify.com URL (https://example.myshopify.com). */ @@ -121,11 +116,11 @@ export async function pull(flags: PullFlags): Promise { }) await executePull(theme, adminSession, { - path: path || cwd(), - nodelete: nodelete || false, - only: only || [], - ignore: ignore || [], - force: force || false, + path: path ?? cwd(), + nodelete: nodelete ?? false, + only: only ?? [], + ignore: ignore ?? [], + force: force ?? false, }) } diff --git a/packages/theme/src/cli/services/push.ts b/packages/theme/src/cli/services/push.ts index 57e9b3899ab..078b9f74825 100644 --- a/packages/theme/src/cli/services/push.ts +++ b/packages/theme/src/cli/services/push.ts @@ -56,9 +56,6 @@ export interface PushFlags { /** Store URL. It can be the store prefix (example) or the full myshopify.com URL (example.myshopify.com, https://example.myshopify.com). */ store?: string - /** The environment to apply to the current command. */ - environment?: string - /** Theme ID or name of the remote theme. */ theme?: string 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,