diff --git a/.changeset/store-auth-execute.md b/.changeset/store-auth-execute.md new file mode 100644 index 00000000000..1637dbdc087 --- /dev/null +++ b/.changeset/store-auth-execute.md @@ -0,0 +1,9 @@ +--- +'@shopify/cli': minor +--- + +Add `shopify store auth` and `shopify store execute` + +`shopify store auth` authenticates an app against a store using PKCE and stores online per-user auth for later store commands. + +`shopify store execute` runs Admin API GraphQL against that stored auth, supports query and variables input similar to `shopify app execute`, and requires `--allow-mutations` for write operations. diff --git a/docs-shopify.dev/commands/examples/store-auth.example.sh b/docs-shopify.dev/commands/examples/store-auth.example.sh new file mode 100644 index 00000000000..e0e0d4062f6 --- /dev/null +++ b/docs-shopify.dev/commands/examples/store-auth.example.sh @@ -0,0 +1 @@ +shopify store auth [flags] \ No newline at end of file diff --git a/docs-shopify.dev/commands/examples/store-execute.example.sh b/docs-shopify.dev/commands/examples/store-execute.example.sh new file mode 100644 index 00000000000..1bbf7d1bbfc --- /dev/null +++ b/docs-shopify.dev/commands/examples/store-execute.example.sh @@ -0,0 +1 @@ +shopify store execute [flags] \ No newline at end of file diff --git a/docs-shopify.dev/commands/interfaces/store-auth.interface.ts b/docs-shopify.dev/commands/interfaces/store-auth.interface.ts new file mode 100644 index 00000000000..e38a6d147e8 --- /dev/null +++ b/docs-shopify.dev/commands/interfaces/store-auth.interface.ts @@ -0,0 +1,26 @@ +// This is an autogenerated file. Don't edit this file manually. +export interface storeauth { + /** + * Disable color output. + * @environment SHOPIFY_FLAG_NO_COLOR + */ + '--no-color'?: '' + + /** + * Comma-separated Admin API scopes to request for the app. + * @environment SHOPIFY_FLAG_SCOPES + */ + '--scopes ': string + + /** + * The myshopify.com domain of the store to authenticate against. + * @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/interfaces/store-execute.interface.ts b/docs-shopify.dev/commands/interfaces/store-execute.interface.ts new file mode 100644 index 00000000000..3bff68a8f95 --- /dev/null +++ b/docs-shopify.dev/commands/interfaces/store-execute.interface.ts @@ -0,0 +1,62 @@ +// This is an autogenerated file. Don't edit this file manually. +export interface storeexecute { + /** + * Allow GraphQL mutations to run against the target store. + * @environment SHOPIFY_FLAG_ALLOW_MUTATIONS + */ + '--allow-mutations'?: '' + + /** + * Disable color output. + * @environment SHOPIFY_FLAG_NO_COLOR + */ + '--no-color'?: '' + + /** + * The file name where results should be written, instead of STDOUT. + * @environment SHOPIFY_FLAG_OUTPUT_FILE + */ + '--output-file '?: string + + /** + * The GraphQL query or mutation, as a string. + * @environment SHOPIFY_FLAG_QUERY + */ + '-q, --query '?: string + + /** + * Path to a file containing the GraphQL query or mutation. Can't be used with --query. + * @environment SHOPIFY_FLAG_QUERY_FILE + */ + '--query-file '?: string + + /** + * The myshopify.com domain of the store to execute against. + * @environment SHOPIFY_FLAG_STORE + */ + '-s, --store ': string + + /** + * Path to a file containing GraphQL variables in JSON format. Can't be used with --variables. + * @environment SHOPIFY_FLAG_VARIABLE_FILE + */ + '--variable-file '?: string + + /** + * The values for any GraphQL variables in your query or mutation, in JSON format. + * @environment SHOPIFY_FLAG_VARIABLES + */ + '-v, --variables '?: string + + /** + * Increase the verbosity of the output. + * @environment SHOPIFY_FLAG_VERBOSE + */ + '--verbose'?: '' + + /** + * The API version to use for the query or mutation. Defaults to the latest stable version. + * @environment SHOPIFY_FLAG_VERSION + */ + '--version '?: string +} diff --git a/docs-shopify.dev/commands/store-auth.doc.ts b/docs-shopify.dev/commands/store-auth.doc.ts new file mode 100644 index 00000000000..15438784f2d --- /dev/null +++ b/docs-shopify.dev/commands/store-auth.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: 'store auth', + description: `Starts a PKCE OAuth flow against the specified store and stores an online access token for later use by \`shopify store execute\`. + +This flow authenticates the app on behalf of the current user. Re-run this command if the stored token is missing, expires, or no longer has the scopes you need.`, + overviewPreviewDescription: `Authenticate an app against a store for store commands.`, + type: 'command', + isVisualComponent: false, + defaultExample: { + codeblock: { + tabs: [ + { + title: 'store auth', + code: './examples/store-auth.example.sh', + language: 'bash', + }, + ], + title: 'store auth', + }, + }, + definitions: [ + { + title: 'Flags', + description: 'The following flags are available for the `store auth` command:', + type: 'storeauth', + }, + ], + category: 'store', + related: [ + ], +} + +export default data \ No newline at end of file diff --git a/docs-shopify.dev/commands/store-execute.doc.ts b/docs-shopify.dev/commands/store-execute.doc.ts new file mode 100644 index 00000000000..e1707fef3e5 --- /dev/null +++ b/docs-shopify.dev/commands/store-execute.doc.ts @@ -0,0 +1,38 @@ +// This is an autogenerated file. Don't edit this file manually. +import {ReferenceEntityTemplateSchema} from '@shopify/generate-docs' + +const data: ReferenceEntityTemplateSchema = { + name: 'store execute', + description: `Executes an Admin API GraphQL query or mutation on the specified store using previously stored app authentication. + +Run \`shopify store auth\` first to create stored auth for the store. + +Mutations are disabled by default. Re-run with \`--allow-mutations\` if you intend to modify store data.`, + overviewPreviewDescription: `Execute GraphQL queries and mutations on a store.`, + type: 'command', + isVisualComponent: false, + defaultExample: { + codeblock: { + tabs: [ + { + title: 'store execute', + code: './examples/store-execute.example.sh', + language: 'bash', + }, + ], + title: 'store execute', + }, + }, + definitions: [ + { + title: 'Flags', + description: 'The following flags are available for the `store execute` command:', + type: 'storeexecute', + }, + ], + category: 'store', + 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 651bbdf94a8..2209c497d21 100644 --- a/docs-shopify.dev/generated/generated_docs_data.json +++ b/docs-shopify.dev/generated/generated_docs_data.json @@ -5774,6 +5774,205 @@ "category": "general commands", "related": [] }, + { + "name": "store auth", + "description": "Starts a PKCE OAuth flow against the specified store and stores an online access token for later use by `shopify store execute`.\n\nThis flow authenticates the app on behalf of the current user. Re-run this command if the stored token is missing, expires, or no longer has the scopes you need.", + "overviewPreviewDescription": "Authenticate an app against a store for store commands.", + "type": "command", + "isVisualComponent": false, + "defaultExample": { + "codeblock": { + "tabs": [ + { + "title": "store auth", + "code": "shopify store auth [flags]", + "language": "bash" + } + ], + "title": "store auth" + } + }, + "definitions": [ + { + "title": "Flags", + "description": "The following flags are available for the `store auth` command:", + "type": "storeauth", + "typeDefinitions": { + "storeauth": { + "filePath": "docs-shopify.dev/commands/interfaces/store-auth.interface.ts", + "name": "storeauth", + "description": "", + "members": [ + { + "filePath": "docs-shopify.dev/commands/interfaces/store-auth.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/store-auth.interface.ts", + "syntaxKind": "PropertySignature", + "name": "--scopes ", + "value": "string", + "description": "Comma-separated Admin API scopes to request for the app.", + "environmentValue": "SHOPIFY_FLAG_SCOPES" + }, + { + "filePath": "docs-shopify.dev/commands/interfaces/store-auth.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/store-auth.interface.ts", + "syntaxKind": "PropertySignature", + "name": "-s, --store ", + "value": "string", + "description": "The myshopify.com domain of the store to authenticate against.", + "environmentValue": "SHOPIFY_FLAG_STORE" + } + ], + "value": "export interface storeauth {\n /**\n * Disable color output.\n * @environment SHOPIFY_FLAG_NO_COLOR\n */\n '--no-color'?: ''\n\n /**\n * Comma-separated Admin API scopes to request for the app.\n * @environment SHOPIFY_FLAG_SCOPES\n */\n '--scopes ': string\n\n /**\n * The myshopify.com domain of the store to authenticate against.\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": "store", + "related": [] + }, + { + "name": "store execute", + "description": "Executes an Admin API GraphQL query or mutation on the specified store using previously stored app authentication.\n\nRun `shopify store auth` first to create stored auth for the store.\n\nMutations are disabled by default. Re-run with `--allow-mutations` if you intend to modify store data.", + "overviewPreviewDescription": "Execute GraphQL queries and mutations on a store.", + "type": "command", + "isVisualComponent": false, + "defaultExample": { + "codeblock": { + "tabs": [ + { + "title": "store execute", + "code": "shopify store execute [flags]", + "language": "bash" + } + ], + "title": "store execute" + } + }, + "definitions": [ + { + "title": "Flags", + "description": "The following flags are available for the `store execute` command:", + "type": "storeexecute", + "typeDefinitions": { + "storeexecute": { + "filePath": "docs-shopify.dev/commands/interfaces/store-execute.interface.ts", + "name": "storeexecute", + "description": "", + "members": [ + { + "filePath": "docs-shopify.dev/commands/interfaces/store-execute.interface.ts", + "syntaxKind": "PropertySignature", + "name": "--allow-mutations", + "value": "\"\"", + "description": "Allow GraphQL mutations to run against the target store.", + "isOptional": true, + "environmentValue": "SHOPIFY_FLAG_ALLOW_MUTATIONS" + }, + { + "filePath": "docs-shopify.dev/commands/interfaces/store-execute.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/store-execute.interface.ts", + "syntaxKind": "PropertySignature", + "name": "--output-file ", + "value": "string", + "description": "The file name where results should be written, instead of STDOUT.", + "isOptional": true, + "environmentValue": "SHOPIFY_FLAG_OUTPUT_FILE" + }, + { + "filePath": "docs-shopify.dev/commands/interfaces/store-execute.interface.ts", + "syntaxKind": "PropertySignature", + "name": "--query-file ", + "value": "string", + "description": "Path to a file containing the GraphQL query or mutation. Can't be used with --query.", + "isOptional": true, + "environmentValue": "SHOPIFY_FLAG_QUERY_FILE" + }, + { + "filePath": "docs-shopify.dev/commands/interfaces/store-execute.interface.ts", + "syntaxKind": "PropertySignature", + "name": "--variable-file ", + "value": "string", + "description": "Path to a file containing GraphQL variables in JSON format. Can't be used with --variables.", + "isOptional": true, + "environmentValue": "SHOPIFY_FLAG_VARIABLE_FILE" + }, + { + "filePath": "docs-shopify.dev/commands/interfaces/store-execute.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/store-execute.interface.ts", + "syntaxKind": "PropertySignature", + "name": "--version ", + "value": "string", + "description": "The API version to use for the query or mutation. Defaults to the latest stable version.", + "isOptional": true, + "environmentValue": "SHOPIFY_FLAG_VERSION" + }, + { + "filePath": "docs-shopify.dev/commands/interfaces/store-execute.interface.ts", + "syntaxKind": "PropertySignature", + "name": "-q, --query ", + "value": "string", + "description": "The GraphQL query or mutation, as a string.", + "isOptional": true, + "environmentValue": "SHOPIFY_FLAG_QUERY" + }, + { + "filePath": "docs-shopify.dev/commands/interfaces/store-execute.interface.ts", + "syntaxKind": "PropertySignature", + "name": "-s, --store ", + "value": "string", + "description": "The myshopify.com domain of the store to execute against.", + "environmentValue": "SHOPIFY_FLAG_STORE" + }, + { + "filePath": "docs-shopify.dev/commands/interfaces/store-execute.interface.ts", + "syntaxKind": "PropertySignature", + "name": "-v, --variables ", + "value": "string", + "description": "The values for any GraphQL variables in your query or mutation, in JSON format.", + "isOptional": true, + "environmentValue": "SHOPIFY_FLAG_VARIABLES" + } + ], + "value": "export interface storeexecute {\n /**\n * Allow GraphQL mutations to run against the target store.\n * @environment SHOPIFY_FLAG_ALLOW_MUTATIONS\n */\n '--allow-mutations'?: ''\n\n /**\n * Disable color output.\n * @environment SHOPIFY_FLAG_NO_COLOR\n */\n '--no-color'?: ''\n\n /**\n * The file name where results should be written, instead of STDOUT.\n * @environment SHOPIFY_FLAG_OUTPUT_FILE\n */\n '--output-file '?: string\n\n /**\n * The GraphQL query or mutation, as a string.\n * @environment SHOPIFY_FLAG_QUERY\n */\n '-q, --query '?: string\n\n /**\n * Path to a file containing the GraphQL query or mutation. Can't be used with --query.\n * @environment SHOPIFY_FLAG_QUERY_FILE\n */\n '--query-file '?: string\n\n /**\n * The myshopify.com domain of the store to execute against.\n * @environment SHOPIFY_FLAG_STORE\n */\n '-s, --store ': string\n\n /**\n * Path to a file containing GraphQL variables in JSON format. Can't be used with --variables.\n * @environment SHOPIFY_FLAG_VARIABLE_FILE\n */\n '--variable-file '?: string\n\n /**\n * The values for any GraphQL variables in your query or mutation, in JSON format.\n * @environment SHOPIFY_FLAG_VARIABLES\n */\n '-v, --variables '?: string\n\n /**\n * Increase the verbosity of the output.\n * @environment SHOPIFY_FLAG_VERBOSE\n */\n '--verbose'?: ''\n\n /**\n * The API version to use for the query or mutation. Defaults to the latest stable version.\n * @environment SHOPIFY_FLAG_VERSION\n */\n '--version '?: string\n}" + } + } + } + ], + "category": "store", + "related": [] + }, { "name": "theme check", "description": "Calls and runs [Theme Check](/docs/themes/tools/theme-check) to analyze your theme code for errors and to ensure that it follows theme and Liquid best practices. [Learn more about the checks that Theme Check runs.](/docs/themes/tools/theme-check/checks)", diff --git a/packages/cli/README.md b/packages/cli/README.md index d9c40dc4885..85f1446ef4a 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -73,6 +73,8 @@ * [`shopify plugins unlink [PLUGIN]`](#shopify-plugins-unlink-plugin) * [`shopify plugins update`](#shopify-plugins-update) * [`shopify search [query]`](#shopify-search-query) +* [`shopify store auth`](#shopify-store-auth) +* [`shopify store execute`](#shopify-store-execute) * [`shopify theme check`](#shopify-theme-check) * [`shopify theme console`](#shopify-theme-console) * [`shopify theme delete`](#shopify-theme-delete) @@ -2049,6 +2051,79 @@ EXAMPLES shopify search "" ``` +## `shopify store auth` + +Authenticate an app against a store for store commands. + +``` +USAGE + $ shopify store auth --scopes -s [--no-color] [--verbose] + +FLAGS + -s, --store= (required) [env: SHOPIFY_FLAG_STORE] The myshopify.com domain of the store to authenticate + against. + --no-color [env: SHOPIFY_FLAG_NO_COLOR] Disable color output. + --scopes= (required) [env: SHOPIFY_FLAG_SCOPES] Comma-separated Admin API scopes to request for the app. + --verbose [env: SHOPIFY_FLAG_VERBOSE] Increase the verbosity of the output. + +DESCRIPTION + Authenticate an app against a store for store commands. + + Starts a PKCE OAuth flow against the specified store and stores an online access token for later use by `shopify store + execute`. + + This flow authenticates the app on behalf of the current user. Re-run this command if the stored token is missing, + expires, or no longer has the scopes you need. + +EXAMPLES + $ shopify store auth --store shop.myshopify.com --scopes read_products,write_products +``` + +## `shopify store execute` + +Execute GraphQL queries and mutations on a store. + +``` +USAGE + $ shopify store execute -s [--allow-mutations] [--no-color] [--output-file ] [-q ] + [--query-file ] [--variable-file | -v ] [--verbose] [--version ] + +FLAGS + -q, --query= [env: SHOPIFY_FLAG_QUERY] The GraphQL query or mutation, as a string. + -s, --store= (required) [env: SHOPIFY_FLAG_STORE] The myshopify.com domain of the store to execute + against. + -v, --variables= [env: SHOPIFY_FLAG_VARIABLES] The values for any GraphQL variables in your query or + mutation, in JSON format. + --allow-mutations [env: SHOPIFY_FLAG_ALLOW_MUTATIONS] Allow GraphQL mutations to run against the target + store. + --no-color [env: SHOPIFY_FLAG_NO_COLOR] Disable color output. + --output-file= [env: SHOPIFY_FLAG_OUTPUT_FILE] The file name where results should be written, instead of + STDOUT. + --query-file= [env: SHOPIFY_FLAG_QUERY_FILE] Path to a file containing the GraphQL query or mutation. + Can't be used with --query. + --variable-file= [env: SHOPIFY_FLAG_VARIABLE_FILE] Path to a file containing GraphQL variables in JSON + format. Can't be used with --variables. + --verbose [env: SHOPIFY_FLAG_VERBOSE] Increase the verbosity of the output. + --version= [env: SHOPIFY_FLAG_VERSION] The API version to use for the query or mutation. Defaults to + the latest stable version. + +DESCRIPTION + Execute GraphQL queries and mutations on a store. + + Executes an Admin API GraphQL query or mutation on the specified store using previously stored app authentication. + + Run `shopify store auth` first to create stored auth for the store. + + Mutations are disabled by default. Re-run with `--allow-mutations` if you intend to modify store data. + +EXAMPLES + $ shopify store execute --store shop.myshopify.com --query "query { shop { name } }" + + $ shopify store execute --store shop.myshopify.com --query-file ./operation.graphql --variables "{"id":"gid://shopify/Product/1"}" + + $ shopify store execute --store shop.myshopify.com --query "mutation { shop { id } }" --allow-mutations +``` + ## `shopify theme check` Validate the theme. diff --git a/packages/cli/oclif.manifest.json b/packages/cli/oclif.manifest.json index c77e2dc67fd..d014fc51bcd 100644 --- a/packages/cli/oclif.manifest.json +++ b/packages/cli/oclif.manifest.json @@ -5734,6 +5734,179 @@ "strict": true, "usage": "search [query]" }, + "store:auth": { + "aliases": [ + ], + "args": { + }, + "description": "Starts a PKCE OAuth flow against the specified store and stores an online access token for later use by `shopify store execute`.\n\nThis flow authenticates the app on behalf of the current user. Re-run this command if the stored token is missing, expires, or no longer has the scopes you need.", + "descriptionWithMarkdown": "Starts a PKCE OAuth flow against the specified store and stores an online access token for later use by `shopify store execute`.\n\nThis flow authenticates the app on behalf of the current user. Re-run this command if the stored token is missing, expires, or no longer has the scopes you need.", + "enableJsonFlag": false, + "examples": [ + "<%= config.bin %> <%= command.id %> --store shop.myshopify.com --scopes read_products,write_products" + ], + "flags": { + "no-color": { + "allowNo": false, + "description": "Disable color output.", + "env": "SHOPIFY_FLAG_NO_COLOR", + "hidden": false, + "name": "no-color", + "type": "boolean" + }, + "scopes": { + "description": "Comma-separated Admin API scopes to request for the app.", + "env": "SHOPIFY_FLAG_SCOPES", + "hasDynamicHelp": false, + "multiple": false, + "name": "scopes", + "required": true, + "type": "option" + }, + "store": { + "char": "s", + "description": "The myshopify.com domain of the store to authenticate against.", + "env": "SHOPIFY_FLAG_STORE", + "hasDynamicHelp": false, + "multiple": false, + "name": "store", + "required": true, + "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": "store:auth", + "pluginAlias": "@shopify/cli", + "pluginName": "@shopify/cli", + "pluginType": "core", + "strict": true, + "summary": "Authenticate an app against a store for store commands." + }, + "store:execute": { + "aliases": [ + ], + "args": { + }, + "description": "Executes an Admin API GraphQL query or mutation on the specified store using previously stored app authentication.\n\nRun `shopify store auth` first to create stored auth for the store.\n\nMutations are disabled by default. Re-run with `--allow-mutations` if you intend to modify store data.", + "descriptionWithMarkdown": "Executes an Admin API GraphQL query or mutation on the specified store using previously stored app authentication.\n\nRun `shopify store auth` first to create stored auth for the store.\n\nMutations are disabled by default. Re-run with `--allow-mutations` if you intend to modify store data.", + "enableJsonFlag": false, + "examples": [ + "<%= config.bin %> <%= command.id %> --store shop.myshopify.com --query \"query { shop { name } }\"", + "<%= config.bin %> <%= command.id %> --store shop.myshopify.com --query-file ./operation.graphql --variables \"{\"id\":\"gid://shopify/Product/1\"}\"", + "<%= config.bin %> <%= command.id %> --store shop.myshopify.com --query \"mutation { shop { id } }\" --allow-mutations" + ], + "flags": { + "allow-mutations": { + "allowNo": false, + "description": "Allow GraphQL mutations to run against the target store.", + "env": "SHOPIFY_FLAG_ALLOW_MUTATIONS", + "name": "allow-mutations", + "type": "boolean" + }, + "no-color": { + "allowNo": false, + "description": "Disable color output.", + "env": "SHOPIFY_FLAG_NO_COLOR", + "hidden": false, + "name": "no-color", + "type": "boolean" + }, + "output-file": { + "description": "The file name where results should be written, instead of STDOUT.", + "env": "SHOPIFY_FLAG_OUTPUT_FILE", + "hasDynamicHelp": false, + "multiple": false, + "name": "output-file", + "type": "option" + }, + "query": { + "char": "q", + "description": "The GraphQL query or mutation, as a string.", + "env": "SHOPIFY_FLAG_QUERY", + "hasDynamicHelp": false, + "multiple": false, + "name": "query", + "required": false, + "type": "option" + }, + "query-file": { + "description": "Path to a file containing the GraphQL query or mutation. Can't be used with --query.", + "env": "SHOPIFY_FLAG_QUERY_FILE", + "hasDynamicHelp": false, + "multiple": false, + "name": "query-file", + "type": "option" + }, + "store": { + "char": "s", + "description": "The myshopify.com domain of the store to execute against.", + "env": "SHOPIFY_FLAG_STORE", + "hasDynamicHelp": false, + "multiple": false, + "name": "store", + "required": true, + "type": "option" + }, + "variable-file": { + "description": "Path to a file containing GraphQL variables in JSON format. Can't be used with --variables.", + "env": "SHOPIFY_FLAG_VARIABLE_FILE", + "exclusive": [ + "variables" + ], + "hasDynamicHelp": false, + "multiple": false, + "name": "variable-file", + "type": "option" + }, + "variables": { + "char": "v", + "description": "The values for any GraphQL variables in your query or mutation, in JSON format.", + "env": "SHOPIFY_FLAG_VARIABLES", + "exclusive": [ + "variable-file" + ], + "hasDynamicHelp": false, + "multiple": false, + "name": "variables", + "type": "option" + }, + "verbose": { + "allowNo": false, + "description": "Increase the verbosity of the output.", + "env": "SHOPIFY_FLAG_VERBOSE", + "hidden": false, + "name": "verbose", + "type": "boolean" + }, + "version": { + "description": "The API version to use for the query or mutation. Defaults to the latest stable version.", + "env": "SHOPIFY_FLAG_VERSION", + "hasDynamicHelp": false, + "multiple": false, + "name": "version", + "type": "option" + } + }, + "hasDynamicHelp": false, + "hiddenAliases": [ + ], + "id": "store:execute", + "pluginAlias": "@shopify/cli", + "pluginName": "@shopify/cli", + "pluginType": "core", + "strict": true, + "summary": "Execute GraphQL queries and mutations on a store." + }, "theme:check": { "aliases": [ ], diff --git a/packages/cli/package.json b/packages/cli/package.json index bdae9faaf76..aa983a8566f 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -102,6 +102,9 @@ "app": { "description": "Build Shopify apps." }, + "store": { + "description": "Work directly with Shopify stores." + }, "app:config": { "description": "Manage app configuration." }, diff --git a/packages/cli/src/cli/commands/store/auth.test.ts b/packages/cli/src/cli/commands/store/auth.test.ts new file mode 100644 index 00000000000..d2bc2c9a33d --- /dev/null +++ b/packages/cli/src/cli/commands/store/auth.test.ts @@ -0,0 +1,27 @@ +import {describe, test, expect, vi, beforeEach} from 'vitest' +import StoreAuth from './auth.js' +import {authenticateStoreWithApp} from '../../services/store/auth.js' + +vi.mock('../../services/store/auth.js') + +describe('store auth command', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + test('passes parsed flags through to the auth service', async () => { + await StoreAuth.run(['--store', 'shop.myshopify.com', '--scopes', 'read_products,write_products']) + + expect(authenticateStoreWithApp).toHaveBeenCalledWith({ + store: 'shop.myshopify.com', + scopes: 'read_products,write_products', + }) + }) + + test('defines the expected flags', () => { + expect(StoreAuth.flags.store).toBeDefined() + expect(StoreAuth.flags.scopes).toBeDefined() + expect('port' in StoreAuth.flags).toBe(false) + expect('client-secret-file' in StoreAuth.flags).toBe(false) + }) +}) diff --git a/packages/cli/src/cli/commands/store/auth.ts b/packages/cli/src/cli/commands/store/auth.ts new file mode 100644 index 00000000000..b55a405b4eb --- /dev/null +++ b/packages/cli/src/cli/commands/store/auth.ts @@ -0,0 +1,42 @@ +import Command from '@shopify/cli-kit/node/base-command' +import {globalFlags} from '@shopify/cli-kit/node/cli' +import {normalizeStoreFqdn} from '@shopify/cli-kit/node/context/fqdn' +import {Flags} from '@oclif/core' +import {authenticateStoreWithApp} from '../../services/store/auth.js' + +export default class StoreAuth extends Command { + static summary = 'Authenticate an app against a store for store commands.' + + static descriptionWithMarkdown = `Starts a PKCE OAuth flow against the specified store and stores an online access token for later use by \`shopify store execute\`. + +This flow authenticates the app on behalf of the current user. Re-run this command if the stored token is missing, expires, or no longer has the scopes you need.` + + static description = this.descriptionWithoutMarkdown() + + static examples = ['<%= config.bin %> <%= command.id %> --store shop.myshopify.com --scopes read_products,write_products'] + + static flags = { + ...globalFlags, + store: Flags.string({ + char: 's', + description: 'The myshopify.com domain of the store to authenticate against.', + env: 'SHOPIFY_FLAG_STORE', + parse: async (input) => normalizeStoreFqdn(input), + required: true, + }), + scopes: Flags.string({ + description: 'Comma-separated Admin API scopes to request for the app.', + env: 'SHOPIFY_FLAG_SCOPES', + required: true, + }), + } + + async run(): Promise { + const {flags} = await this.parse(StoreAuth) + + await authenticateStoreWithApp({ + store: flags.store, + scopes: flags.scopes, + }) + } +} diff --git a/packages/cli/src/cli/commands/store/execute.test.ts b/packages/cli/src/cli/commands/store/execute.test.ts new file mode 100644 index 00000000000..e890b5332a0 --- /dev/null +++ b/packages/cli/src/cli/commands/store/execute.test.ts @@ -0,0 +1,47 @@ +import {describe, test, expect, vi, beforeEach} from 'vitest' +import StoreExecute from './execute.js' +import {executeStoreOperation} from '../../services/store/execute.js' + +vi.mock('../../services/store/execute.js') + +describe('store execute command', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + test('passes the inline query through to the service', async () => { + await StoreExecute.run(['--store', 'shop.myshopify.com', '--query', 'query { shop { name } }']) + + expect(executeStoreOperation).toHaveBeenCalledWith({ + store: 'shop.myshopify.com', + query: 'query { shop { name } }', + queryFile: undefined, + variables: undefined, + variableFile: undefined, + outputFile: undefined, + version: undefined, + allowMutations: false, + }) + }) + + test('passes the query file through to the service', async () => { + await StoreExecute.run(['--store', 'shop.myshopify.com', '--query-file', './operation.graphql']) + + expect(executeStoreOperation).toHaveBeenCalledWith( + expect.objectContaining({ + store: 'shop.myshopify.com', + query: undefined, + queryFile: expect.stringMatching(/operation\.graphql$/), + }), + ) + }) + + test('defines the expected flags', () => { + expect(StoreExecute.flags.store).toBeDefined() + expect(StoreExecute.flags.query).toBeDefined() + expect(StoreExecute.flags['query-file']).toBeDefined() + expect(StoreExecute.flags.variables).toBeDefined() + expect(StoreExecute.flags['variable-file']).toBeDefined() + expect(StoreExecute.flags['allow-mutations']).toBeDefined() + }) +}) diff --git a/packages/cli/src/cli/commands/store/execute.ts b/packages/cli/src/cli/commands/store/execute.ts new file mode 100644 index 00000000000..de7ec27821f --- /dev/null +++ b/packages/cli/src/cli/commands/store/execute.ts @@ -0,0 +1,89 @@ +import Command from '@shopify/cli-kit/node/base-command' +import {globalFlags} from '@shopify/cli-kit/node/cli' +import {normalizeStoreFqdn} from '@shopify/cli-kit/node/context/fqdn' +import {resolvePath} from '@shopify/cli-kit/node/path' +import {Flags} from '@oclif/core' +import {executeStoreOperation} from '../../services/store/execute.js' + +export default class StoreExecute extends Command { + static summary = 'Execute GraphQL queries and mutations on a store.' + + static descriptionWithMarkdown = `Executes an Admin API GraphQL query or mutation on the specified store using previously stored app authentication. + +Run \`shopify store auth\` first to create stored auth for the store. + +Mutations are disabled by default. Re-run with \`--allow-mutations\` if you intend to modify store data.` + + static description = this.descriptionWithoutMarkdown() + + static examples = [ + '<%= config.bin %> <%= command.id %> --store shop.myshopify.com --query "query { shop { name } }"', + '<%= config.bin %> <%= command.id %> --store shop.myshopify.com --query-file ./operation.graphql --variables "{\"id\":\"gid://shopify/Product/1\"}"', + '<%= config.bin %> <%= command.id %> --store shop.myshopify.com --query "mutation { shop { id } }" --allow-mutations', + ] + + static flags = { + ...globalFlags, + query: Flags.string({ + char: 'q', + description: 'The GraphQL query or mutation, as a string.', + env: 'SHOPIFY_FLAG_QUERY', + required: false, + exactlyOne: ['query', 'query-file'], + }), + 'query-file': Flags.string({ + description: "Path to a file containing the GraphQL query or mutation. Can't be used with --query.", + env: 'SHOPIFY_FLAG_QUERY_FILE', + parse: async (input) => resolvePath(input), + exactlyOne: ['query', 'query-file'], + }), + variables: Flags.string({ + char: 'v', + description: 'The values for any GraphQL variables in your query or mutation, in JSON format.', + env: 'SHOPIFY_FLAG_VARIABLES', + exclusive: ['variable-file'], + }), + 'variable-file': Flags.string({ + description: "Path to a file containing GraphQL variables in JSON format. Can't be used with --variables.", + env: 'SHOPIFY_FLAG_VARIABLE_FILE', + parse: async (input) => resolvePath(input), + exclusive: ['variables'], + }), + store: Flags.string({ + char: 's', + description: 'The myshopify.com domain of the store to execute against.', + env: 'SHOPIFY_FLAG_STORE', + parse: async (input) => normalizeStoreFqdn(input), + required: true, + }), + version: Flags.string({ + description: 'The API version to use for the query or mutation. Defaults to the latest stable version.', + env: 'SHOPIFY_FLAG_VERSION', + }), + 'output-file': Flags.string({ + description: 'The file name where results should be written, instead of STDOUT.', + env: 'SHOPIFY_FLAG_OUTPUT_FILE', + parse: async (input) => resolvePath(input), + }), + 'allow-mutations': Flags.boolean({ + description: 'Allow GraphQL mutations to run against the target store.', + env: 'SHOPIFY_FLAG_ALLOW_MUTATIONS', + default: false, + }), + } + + async run(): Promise { + const {flags} = await this.parse(StoreExecute) + + await executeStoreOperation({ + store: flags.store, + query: flags.query, + queryFile: flags['query-file'], + variables: flags.variables, + variableFile: flags['variable-file'], + outputFile: flags['output-file'], + version: flags.version, + allowMutations: flags['allow-mutations'], + }) + } +} diff --git a/packages/cli/src/cli/services/store/admin-graphql-context.test.ts b/packages/cli/src/cli/services/store/admin-graphql-context.test.ts new file mode 100644 index 00000000000..47cb4f1540f --- /dev/null +++ b/packages/cli/src/cli/services/store/admin-graphql-context.test.ts @@ -0,0 +1,161 @@ +import {beforeEach, describe, expect, test, vi} from 'vitest' +import {fetchApiVersions} from '@shopify/cli-kit/node/api/admin' +import {AbortError} from '@shopify/cli-kit/node/error' +import {fetch} from '@shopify/cli-kit/node/http' +import { + clearStoredStoreAppSession, + getStoredStoreAppSession, + isSessionExpired, + setStoredStoreAppSession, +} from './session.js' +import {prepareAdminStoreGraphQLContext} from './admin-graphql-context.js' + +vi.mock('./session.js') +vi.mock('@shopify/cli-kit/node/http') +vi.mock('@shopify/cli-kit/node/api/admin', async () => { + const actual = await vi.importActual('@shopify/cli-kit/node/api/admin') + return { + ...actual, + fetchApiVersions: vi.fn(), + } +}) + +describe('prepareAdminStoreGraphQLContext', () => { + const store = 'shop.myshopify.com' + const storedSession = { + store, + clientId: 'b16de5d7ba3e2e22279a38c22ef025a0', + userId: '42', + accessToken: 'token', + refreshToken: 'refresh-token', + scopes: ['read_products'], + acquiredAt: '2026-03-27T00:00:00.000Z', + expiresAt: '2026-03-27T01:00:00.000Z', + } + + beforeEach(() => { + vi.clearAllMocks() + vi.mocked(getStoredStoreAppSession).mockReturnValue(storedSession) + vi.mocked(isSessionExpired).mockReturnValue(false) + vi.mocked(fetchApiVersions).mockResolvedValue([ + {handle: '2025-10', supported: true}, + {handle: '2025-07', supported: true}, + {handle: 'unstable', supported: false}, + ] as any) + }) + + test('returns the stored admin session and latest supported version by default', async () => { + const result = await prepareAdminStoreGraphQLContext({store}) + + expect(result).toEqual({ + adminSession: { + token: 'token', + storeFqdn: store, + }, + version: '2025-10', + sessionUserId: '42', + }) + }) + + test('refreshes expired sessions before resolving the API version', async () => { + vi.mocked(isSessionExpired).mockReturnValue(true) + vi.mocked(fetch).mockResolvedValue({ + ok: true, + text: vi.fn().mockResolvedValue( + JSON.stringify({ + access_token: 'fresh-token', + refresh_token: 'fresh-refresh-token', + expires_in: 3600, + refresh_token_expires_in: 7200, + }), + ), + } as any) + + const result = await prepareAdminStoreGraphQLContext({store}) + + expect(result.adminSession.token).toBe('fresh-token') + expect(setStoredStoreAppSession).toHaveBeenCalledWith( + expect.objectContaining({ + store, + accessToken: 'fresh-token', + refreshToken: 'fresh-refresh-token', + }), + ) + }) + + test('returns the requested API version when provided', async () => { + const result = await prepareAdminStoreGraphQLContext({store, userSpecifiedVersion: '2025-07'}) + + expect(result.version).toBe('2025-07') + }) + + test('allows unstable without validating against fetched versions', async () => { + const result = await prepareAdminStoreGraphQLContext({store, userSpecifiedVersion: 'unstable'}) + + expect(result.version).toBe('unstable') + expect(fetchApiVersions).not.toHaveBeenCalled() + }) + + test('throws when no stored auth exists', async () => { + vi.mocked(getStoredStoreAppSession).mockReturnValue(undefined) + + await expect(prepareAdminStoreGraphQLContext({store})).rejects.toThrow('No stored app authentication found') + }) + + test('clears stored auth when token refresh fails', async () => { + vi.mocked(isSessionExpired).mockReturnValue(true) + vi.mocked(fetch).mockResolvedValue({ + ok: false, + status: 401, + text: vi.fn().mockResolvedValue('bad refresh'), + } as any) + + await expect(prepareAdminStoreGraphQLContext({store})).rejects.toThrow('Token refresh failed') + expect(clearStoredStoreAppSession).toHaveBeenCalledWith(store, '42') + }) + + test('throws when an expired session cannot be refreshed because no refresh token is stored', async () => { + vi.mocked(isSessionExpired).mockReturnValue(true) + vi.mocked(getStoredStoreAppSession).mockReturnValue({...storedSession, refreshToken: undefined}) + + await expect(prepareAdminStoreGraphQLContext({store})).rejects.toThrow('No refresh token stored') + expect(clearStoredStoreAppSession).not.toHaveBeenCalled() + }) + + test('clears only the current stored auth when token refresh returns an invalid response body', async () => { + vi.mocked(isSessionExpired).mockReturnValue(true) + vi.mocked(fetch).mockResolvedValue({ + ok: true, + text: vi.fn().mockResolvedValue(JSON.stringify({refresh_token: 'fresh-refresh-token'})), + } as any) + + await expect(prepareAdminStoreGraphQLContext({store})).rejects.toThrow('Token refresh returned an invalid response') + expect(clearStoredStoreAppSession).toHaveBeenCalledWith(store, '42') + }) + + test('clears only the current stored auth when token refresh returns malformed JSON', async () => { + vi.mocked(isSessionExpired).mockReturnValue(true) + vi.mocked(fetch).mockResolvedValue({ + ok: true, + text: vi.fn().mockResolvedValue('not-json'), + } as any) + + await expect(prepareAdminStoreGraphQLContext({store})).rejects.toThrow('Received an invalid refresh response') + expect(clearStoredStoreAppSession).toHaveBeenCalledWith(store, '42') + }) + + test('clears stored auth and prompts re-auth when API version lookup fails with invalid auth', async () => { + vi.mocked(fetchApiVersions).mockRejectedValue( + new AbortError(`Error connecting to your store ${store}: unauthorized 401 {}`), + ) + + await expect(prepareAdminStoreGraphQLContext({store})).rejects.toThrow('Stored app authentication for') + expect(clearStoredStoreAppSession).toHaveBeenCalledWith(store, '42') + }) + + test('throws when the requested API version is invalid', async () => { + await expect(prepareAdminStoreGraphQLContext({store, userSpecifiedVersion: '1999-01'})).rejects.toThrow( + 'Invalid API version', + ) + }) +}) diff --git a/packages/cli/src/cli/services/store/admin-graphql-context.ts b/packages/cli/src/cli/services/store/admin-graphql-context.ts new file mode 100644 index 00000000000..15784617c14 --- /dev/null +++ b/packages/cli/src/cli/services/store/admin-graphql-context.ts @@ -0,0 +1,168 @@ +import {fetchApiVersions} from '@shopify/cli-kit/node/api/admin' +import {AbortError} from '@shopify/cli-kit/node/error' +import {fetch} from '@shopify/cli-kit/node/http' +import {outputContent, outputDebug, outputToken} from '@shopify/cli-kit/node/output' +import {AdminSession} from '@shopify/cli-kit/node/session' +import {maskToken, STORE_AUTH_APP_CLIENT_ID} from './auth-config.js' +import { + clearStoredStoreAppSession, + getStoredStoreAppSession, + isSessionExpired, + setStoredStoreAppSession, + StoredStoreAppSession, +} from './session.js' + +export interface AdminStoreGraphQLContext { + adminSession: AdminSession + version: string + sessionUserId: string +} + +async function refreshStoreToken(session: StoredStoreAppSession): Promise { + if (!session.refreshToken) { + throw new AbortError( + `No refresh token stored for ${session.store}.`, + `Run ${outputToken.genericShellCommand(`shopify store auth --store ${session.store} --scopes ${session.scopes.join(',')}`).value} to re-authenticate.`, + ) + } + + const endpoint = `https://${session.store}/admin/oauth/access_token` + + outputDebug( + outputContent`Refreshing expired token for ${outputToken.raw(session.store)} (expired at ${outputToken.raw(session.expiresAt ?? 'unknown')}, refresh_token=${outputToken.raw(maskToken(session.refreshToken))})`, + ) + + const response = await fetch(endpoint, { + method: 'POST', + headers: {'Content-Type': 'application/json'}, + body: JSON.stringify({ + client_id: STORE_AUTH_APP_CLIENT_ID, + grant_type: 'refresh_token', + refresh_token: session.refreshToken, + }), + }) + + const body = await response.text() + + if (!response.ok) { + outputDebug( + outputContent`Token refresh failed with HTTP ${outputToken.raw(String(response.status))}: ${outputToken.raw(body.slice(0, 300))}`, + ) + clearStoredStoreAppSession(session.store, session.userId) + throw new AbortError( + `Token refresh failed for ${session.store} (HTTP ${response.status}).`, + `Run ${outputToken.genericShellCommand(`shopify store auth --store ${session.store} --scopes ${session.scopes.join(',')}`).value} to re-authenticate.`, + ) + } + + let data: {access_token?: string; refresh_token?: string; expires_in?: number; refresh_token_expires_in?: number} + try { + data = JSON.parse(body) + } catch { + clearStoredStoreAppSession(session.store, session.userId) + throw new AbortError('Received an invalid refresh response from Shopify.') + } + + if (!data.access_token) { + clearStoredStoreAppSession(session.store, session.userId) + throw new AbortError( + `Token refresh returned an invalid response for ${session.store}.`, + `Run ${outputToken.genericShellCommand(`shopify store auth --store ${session.store} --scopes ${session.scopes.join(',')}`).value} to re-authenticate.`, + ) + } + + const now = Date.now() + const expiresAt = data.expires_in ? new Date(now + data.expires_in * 1000).toISOString() : session.expiresAt + + const refreshedSession: StoredStoreAppSession = { + ...session, + accessToken: data.access_token, + refreshToken: data.refresh_token ?? session.refreshToken, + expiresAt, + refreshTokenExpiresAt: data.refresh_token_expires_in + ? new Date(now + data.refresh_token_expires_in * 1000).toISOString() + : session.refreshTokenExpiresAt, + acquiredAt: new Date(now).toISOString(), + } + + outputDebug( + outputContent`Token refresh succeeded for ${outputToken.raw(session.store)}: ${outputToken.raw(maskToken(session.accessToken))} → ${outputToken.raw(maskToken(refreshedSession.accessToken))}, new expiry ${outputToken.raw(expiresAt ?? 'unknown')}`, + ) + + setStoredStoreAppSession(refreshedSession) + return refreshedSession +} + +async function loadStoredStoreSession(store: string): Promise { + let session = getStoredStoreAppSession(store) + + if (!session) { + throw new AbortError( + `No stored app authentication found for ${store}.`, + `Run ${outputToken.genericShellCommand(`shopify store auth --store ${store} --scopes `).value} to create stored auth for this store.`, + ) + } + + outputDebug( + outputContent`Loaded stored session for ${outputToken.raw(store)}: token=${outputToken.raw(maskToken(session.accessToken))}, expires=${outputToken.raw(session.expiresAt ?? 'unknown')}`, + ) + + if (isSessionExpired(session)) { + session = await refreshStoreToken(session) + } + + return session +} + +async function resolveApiVersion(options: { + session: StoredStoreAppSession + adminSession: AdminSession + userSpecifiedVersion?: string +}): Promise { + const {session, adminSession, userSpecifiedVersion} = options + + if (userSpecifiedVersion === 'unstable') return userSpecifiedVersion + + let availableVersions + try { + availableVersions = await fetchApiVersions(adminSession) + } catch (error) { + if ( + error instanceof AbortError && + error.message.includes(`Error connecting to your store ${adminSession.storeFqdn}:`) && + /\b(?:401|404)\b/.test(error.message) + ) { + clearStoredStoreAppSession(session.store, session.userId) + throw new AbortError( + `Stored app authentication for ${session.store} is no longer valid.`, + `Run ${outputToken.genericShellCommand(`shopify store auth --store ${session.store} --scopes ${session.scopes.join(',')}`).value} to re-authenticate.`, + ) + } + + throw error + } + + if (!userSpecifiedVersion) { + const supportedVersions = availableVersions.filter((version) => version.supported).map((version) => version.handle) + return supportedVersions.sort().reverse()[0]! + } + + const versionList = availableVersions.map((version) => version.handle) + if (versionList.includes(userSpecifiedVersion)) return userSpecifiedVersion + + throw new AbortError(`Invalid API version: ${userSpecifiedVersion}`, `Allowed versions: ${versionList.join(', ')}`) +} + +export async function prepareAdminStoreGraphQLContext(input: { + store: string + userSpecifiedVersion?: string +}): Promise { + const session = await loadStoredStoreSession(input.store) + const adminSession = { + token: session.accessToken, + storeFqdn: session.store, + } + const version = await resolveApiVersion({session, adminSession, userSpecifiedVersion: input.userSpecifiedVersion}) + + return {adminSession, version, sessionUserId: session.userId} +} diff --git a/packages/cli/src/cli/services/store/admin-graphql-transport.test.ts b/packages/cli/src/cli/services/store/admin-graphql-transport.test.ts new file mode 100644 index 00000000000..fa196baf79e --- /dev/null +++ b/packages/cli/src/cli/services/store/admin-graphql-transport.test.ts @@ -0,0 +1,80 @@ +import {beforeEach, describe, expect, test, vi} from 'vitest' +import {adminUrl} from '@shopify/cli-kit/node/api/admin' +import {graphqlRequest} from '@shopify/cli-kit/node/api/graphql' +import {renderSingleTask} from '@shopify/cli-kit/node/ui' +import {clearStoredStoreAppSession} from './session.js' +import {prepareStoreExecuteRequest} from './execute-request.js' +import {runAdminStoreGraphQLOperation} from './admin-graphql-transport.js' + +vi.mock('./session.js') +vi.mock('@shopify/cli-kit/node/api/graphql') +vi.mock('@shopify/cli-kit/node/ui') +vi.mock('@shopify/cli-kit/node/api/admin', async () => { + const actual = await vi.importActual('@shopify/cli-kit/node/api/admin') + return { + ...actual, + adminUrl: vi.fn(), + } +}) + +describe('runAdminStoreGraphQLOperation', () => { + const store = 'shop.myshopify.com' + const adminSession = {token: 'token', storeFqdn: store} + + beforeEach(() => { + vi.clearAllMocks() + vi.mocked(adminUrl).mockReturnValue('https://shop.myshopify.com/admin/api/2025-10/graphql.json') + vi.mocked(renderSingleTask).mockImplementation(async ({task}) => task(() => {})) + }) + + test('executes the GraphQL request successfully', async () => { + vi.mocked(graphqlRequest).mockResolvedValue({data: {shop: {name: 'Test shop'}}}) + const request = await prepareStoreExecuteRequest({query: 'query { shop { name } }'}) + + const result = await runAdminStoreGraphQLOperation({ + store, + adminSession, + sessionUserId: '42', + version: '2025-10', + request, + }) + + expect(result).toEqual({data: {shop: {name: 'Test shop'}}}) + expect(graphqlRequest).toHaveBeenCalledWith({ + query: 'query { shop { name } }', + api: 'Admin', + url: 'https://shop.myshopify.com/admin/api/2025-10/graphql.json', + token: 'token', + variables: undefined, + responseOptions: {handleErrors: false}, + }) + }) + + test('clears stored auth and throws a re-auth error on 401', async () => { + vi.mocked(graphqlRequest).mockRejectedValue({response: {status: 401}}) + const request = await prepareStoreExecuteRequest({query: 'query { shop { name } }'}) + + await expect( + runAdminStoreGraphQLOperation({store, adminSession, sessionUserId: '42', version: '2025-10', request}), + ).rejects.toThrow('Stored app authentication for') + expect(clearStoredStoreAppSession).toHaveBeenCalledWith(store, '42') + }) + + test('throws a GraphQL operation error when errors are returned', async () => { + vi.mocked(graphqlRequest).mockRejectedValue({response: {errors: [{message: 'Field does not exist'}]}}) + const request = await prepareStoreExecuteRequest({query: 'query { nope }'}) + + await expect( + runAdminStoreGraphQLOperation({store, adminSession, sessionUserId: '42', version: '2025-10', request}), + ).rejects.toThrow('GraphQL operation failed.') + }) + + test('rethrows non-GraphQL errors', async () => { + vi.mocked(graphqlRequest).mockRejectedValue(new Error('boom')) + const request = await prepareStoreExecuteRequest({query: 'query { shop { name } }'}) + + await expect( + runAdminStoreGraphQLOperation({store, adminSession, sessionUserId: '42', version: '2025-10', request}), + ).rejects.toThrow('boom') + }) +}) diff --git a/packages/cli/src/cli/services/store/admin-graphql-transport.ts b/packages/cli/src/cli/services/store/admin-graphql-transport.ts new file mode 100644 index 00000000000..52ffcf67321 --- /dev/null +++ b/packages/cli/src/cli/services/store/admin-graphql-transport.ts @@ -0,0 +1,53 @@ +import {adminUrl} from '@shopify/cli-kit/node/api/admin' +import {graphqlRequest} from '@shopify/cli-kit/node/api/graphql' +import {AbortError} from '@shopify/cli-kit/node/error' +import {clearStoredStoreAppSession} from './session.js' +import {outputContent, outputToken} from '@shopify/cli-kit/node/output' +import {AdminSession} from '@shopify/cli-kit/node/session' +import {renderSingleTask} from '@shopify/cli-kit/node/ui' +import {PreparedStoreExecuteRequest} from './execute-request.js' + +function isGraphQLClientError(error: unknown): error is {response: {errors?: unknown; status?: number}} { + if (!error || typeof error !== 'object' || !('response' in error)) return false + const response = (error as {response?: unknown}).response + return !!response && typeof response === 'object' +} + +export async function runAdminStoreGraphQLOperation(input: { + store: string + adminSession: AdminSession + sessionUserId: string + version: string + request: PreparedStoreExecuteRequest +}): Promise { + try { + return await renderSingleTask({ + title: outputContent`Executing GraphQL operation`, + task: async () => { + return graphqlRequest({ + query: input.request.query, + api: 'Admin', + url: adminUrl(input.adminSession.storeFqdn, input.version, input.adminSession), + token: input.adminSession.token, + variables: input.request.parsedVariables, + responseOptions: {handleErrors: false}, + }) + }, + renderOptions: {stdout: process.stderr}, + }) + } catch (error) { + if (isGraphQLClientError(error) && error.response.status === 401) { + clearStoredStoreAppSession(input.store, input.sessionUserId) + throw new AbortError( + `Stored app authentication for ${input.store} is no longer valid.`, + `Run ${outputToken.genericShellCommand(`shopify store auth --store ${input.store} --scopes `).value} to re-authenticate.`, + ) + } + + if (isGraphQLClientError(error) && error.response.errors) { + throw new AbortError('GraphQL operation failed.', JSON.stringify({errors: error.response.errors}, null, 2)) + } + + throw error + } +} diff --git a/packages/cli/src/cli/services/store/auth-config.ts b/packages/cli/src/cli/services/store/auth-config.ts new file mode 100644 index 00000000000..843c5ed5a9d --- /dev/null +++ b/packages/cli/src/cli/services/store/auth-config.ts @@ -0,0 +1,16 @@ +export const STORE_AUTH_APP_CLIENT_ID = 'b16de5d7ba3e2e22279a38c22ef025a0' +export const DEFAULT_STORE_AUTH_PORT = 13387 +export const STORE_AUTH_CALLBACK_PATH = '/auth/callback' + +export function storeAuthRedirectUri(port: number): string { + return `http://127.0.0.1:${port}${STORE_AUTH_CALLBACK_PATH}` +} + +export function storeAuthSessionKey(store: string): string { + return `${STORE_AUTH_APP_CLIENT_ID}::${store}` +} + +export function maskToken(token: string): string { + if (token.length <= 10) return '***' + return `${token.slice(0, 10)}***` +} diff --git a/packages/cli/src/cli/services/store/auth.test.ts b/packages/cli/src/cli/services/store/auth.test.ts new file mode 100644 index 00000000000..419721cee59 --- /dev/null +++ b/packages/cli/src/cli/services/store/auth.test.ts @@ -0,0 +1,407 @@ +import {createServer} from 'http' +import {describe, test, expect, vi, beforeEach, afterEach} from 'vitest' +import { + authenticateStoreWithApp, + buildStoreAuthUrl, + parseStoreAuthScopes, + generateCodeVerifier, + computeCodeChallenge, + exchangeStoreAuthCodeForToken, + waitForStoreAuthCode, +} from './auth.js' +import {setStoredStoreAppSession} from './session.js' +import {STORE_AUTH_APP_CLIENT_ID} from './auth-config.js' +import {fetch} from '@shopify/cli-kit/node/http' + +vi.mock('./session.js') +vi.mock('@shopify/cli-kit/node/http') +vi.mock('@shopify/cli-kit/node/system', () => ({openURL: vi.fn().mockResolvedValue(true)})) +vi.mock('@shopify/cli-kit/node/crypto', () => ({randomUUID: vi.fn().mockReturnValue('state-123')})) + +async function getAvailablePort(): Promise { + return await new Promise((resolve, reject) => { + const server = createServer() + + server.on('error', reject) + server.listen(0, '127.0.0.1', () => { + const address = server.address() + if (!address || typeof address === 'string') { + reject(new Error('Expected an ephemeral port.')) + return + } + + server.close((error) => { + if (error) { + reject(error) + return + } + + resolve(address.port) + }) + }) + }) +} + +function callbackParams(options?: { + code?: string + shop?: string + state?: string + error?: string +}): URLSearchParams { + const params = new URLSearchParams() + params.set('shop', options?.shop ?? 'shop.myshopify.com') + params.set('state', options?.state ?? 'state-123') + + if (options?.code) params.set('code', options.code) + if (options?.error) params.set('error', options.error) + if (!options?.code && !options?.error) params.set('code', 'abc123') + + return params +} + +describe('store auth service', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + afterEach(() => { + vi.restoreAllMocks() + }) + + test('generateCodeVerifier produces a base64url string of 43 chars', () => { + const verifier = generateCodeVerifier() + expect(verifier).toMatch(/^[A-Za-z0-9_-]{43}$/) + }) + + test('generateCodeVerifier produces unique values', () => { + const a = generateCodeVerifier() + const b = generateCodeVerifier() + expect(a).not.toBe(b) + }) + + test('computeCodeChallenge produces a deterministic S256 hash', () => { + const verifier = 'dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk' + const expected = 'E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM' + expect(computeCodeChallenge(verifier)).toBe(expected) + }) + + test('parseStoreAuthScopes splits and deduplicates scopes', () => { + expect(parseStoreAuthScopes('read_products, write_products,read_products')).toEqual([ + 'read_products', + 'write_products', + ]) + }) + + test('buildStoreAuthUrl includes PKCE params and response_type=code', () => { + const url = new URL( + buildStoreAuthUrl({ + store: 'shop.myshopify.com', + scopes: ['read_products', 'write_products'], + state: 'state-123', + redirectUri: 'http://127.0.0.1:13387/auth/callback', + codeChallenge: 'test-challenge-value', + }), + ) + + expect(url.hostname).toBe('shop.myshopify.com') + expect(url.pathname).toBe('/admin/oauth/authorize') + expect(url.searchParams.get('client_id')).toBe(STORE_AUTH_APP_CLIENT_ID) + expect(url.searchParams.get('scope')).toBe('read_products,write_products') + expect(url.searchParams.get('state')).toBe('state-123') + expect(url.searchParams.get('redirect_uri')).toBe('http://127.0.0.1:13387/auth/callback') + expect(url.searchParams.get('response_type')).toBe('code') + expect(url.searchParams.get('code_challenge')).toBe('test-challenge-value') + expect(url.searchParams.get('code_challenge_method')).toBe('S256') + expect(url.searchParams.get('grant_options[]')).toBeNull() + }) + + test('waitForStoreAuthCode resolves after a valid callback', async () => { + const port = await getAvailablePort() + const params = callbackParams() + const onListening = vi.fn(async () => { + const response = await globalThis.fetch(`http://127.0.0.1:${port}/auth/callback?${params.toString()}`) + expect(response.status).toBe(200) + await response.text() + }) + + await expect( + waitForStoreAuthCode({ + store: 'shop.myshopify.com', + state: 'state-123', + port, + timeoutMs: 1000, + onListening, + }), + ).resolves.toBe('abc123') + + expect(onListening).toHaveBeenCalledOnce() + }) + + test('waitForStoreAuthCode rejects when callback state does not match', async () => { + const port = await getAvailablePort() + const params = callbackParams({state: 'wrong-state'}) + + await expect( + waitForStoreAuthCode({ + store: 'shop.myshopify.com', + state: 'state-123', + port, + timeoutMs: 1000, + onListening: async () => { + const response = await globalThis.fetch(`http://127.0.0.1:${port}/auth/callback?${params.toString()}`) + expect(response.status).toBe(400) + await response.text() + }, + }), + ).rejects.toThrow('OAuth callback state does not match the original request.') + }) + + test('waitForStoreAuthCode rejects when callback store does not match', async () => { + const port = await getAvailablePort() + const params = callbackParams({shop: 'other-shop.myshopify.com'}) + + await expect( + waitForStoreAuthCode({ + store: 'shop.myshopify.com', + state: 'state-123', + port, + timeoutMs: 1000, + onListening: async () => { + const response = await globalThis.fetch(`http://127.0.0.1:${port}/auth/callback?${params.toString()}`) + expect(response.status).toBe(400) + await response.text() + }, + }), + ).rejects.toThrow('OAuth callback store does not match the requested store.') + }) + + test('waitForStoreAuthCode rejects when Shopify returns an OAuth error', async () => { + const port = await getAvailablePort() + const params = callbackParams({error: 'access_denied'}) + + await expect( + waitForStoreAuthCode({ + store: 'shop.myshopify.com', + state: 'state-123', + port, + timeoutMs: 1000, + onListening: async () => { + const response = await globalThis.fetch(`http://127.0.0.1:${port}/auth/callback?${params.toString()}`) + expect(response.status).toBe(400) + await response.text() + }, + }), + ).rejects.toThrow('Shopify returned an OAuth error: access_denied') + }) + + test('waitForStoreAuthCode rejects when callback does not include an authorization code', async () => { + const port = await getAvailablePort() + const params = callbackParams() + params.delete('code') + + await expect( + waitForStoreAuthCode({ + store: 'shop.myshopify.com', + state: 'state-123', + port, + timeoutMs: 1000, + onListening: async () => { + const response = await globalThis.fetch(`http://127.0.0.1:${port}/auth/callback?${params.toString()}`) + expect(response.status).toBe(400) + await response.text() + }, + }), + ).rejects.toThrow('OAuth callback did not include an authorization code.') + }) + + test('waitForStoreAuthCode rejects when the port is already in use', async () => { + const port = await getAvailablePort() + const server = createServer() + await new Promise((resolve, reject) => { + server.on('error', reject) + server.listen(port, '127.0.0.1', () => resolve()) + }) + + await expect( + waitForStoreAuthCode({ + store: 'shop.myshopify.com', + state: 'state-123', + port, + timeoutMs: 1000, + }), + ).rejects.toThrow(`Port ${port} is already in use.`) + + await new Promise((resolve, reject) => { + server.close((error) => { + if (error) { + reject(error) + return + } + + resolve() + }) + }) + }) + + test('waitForStoreAuthCode rejects on timeout', async () => { + const port = await getAvailablePort() + + await expect( + waitForStoreAuthCode({ + store: 'shop.myshopify.com', + state: 'state-123', + port, + timeoutMs: 25, + }), + ).rejects.toThrow('Timed out waiting for OAuth callback.') + }) + + test('exchangeStoreAuthCodeForToken sends PKCE params and returns token response', async () => { + vi.mocked(fetch).mockResolvedValue({ + ok: true, + text: vi.fn().mockResolvedValue( + JSON.stringify({ + access_token: 'token', + scope: 'read_products', + expires_in: 86400, + refresh_token: 'refresh-token', + associated_user: {id: 42, email: 'test@example.com'}, + }), + ), + } as any) + + const response = await exchangeStoreAuthCodeForToken({ + store: 'shop.myshopify.com', + code: 'abc123', + codeVerifier: 'test-verifier', + redirectUri: 'http://127.0.0.1:13387/auth/callback', + }) + + expect(response.access_token).toBe('token') + expect(response.refresh_token).toBe('refresh-token') + expect(response.expires_in).toBe(86400) + + expect(fetch).toHaveBeenCalledWith( + 'https://shop.myshopify.com/admin/oauth/access_token', + expect.objectContaining({ + method: 'POST', + body: expect.stringContaining('"code_verifier":"test-verifier"'), + }), + ) + + const sentBody = JSON.parse((fetch as any).mock.calls[0][1].body) + expect(sentBody.client_id).toBe(STORE_AUTH_APP_CLIENT_ID) + expect(sentBody.code).toBe('abc123') + expect(sentBody.code_verifier).toBe('test-verifier') + expect(sentBody.redirect_uri).toBe('http://127.0.0.1:13387/auth/callback') + expect(sentBody.client_secret).toBeUndefined() + }) + + test('authenticateStoreWithApp opens the browser and stores the session with refresh token', async () => { + const openURL = vi.fn().mockResolvedValue(true) + const waitForStoreAuthCodeMock = vi.fn().mockImplementation(async (options) => { + await options.onListening?.() + return 'abc123' + }) + + await authenticateStoreWithApp( + { + store: 'shop.myshopify.com', + scopes: 'read_products', + }, + { + openURL, + waitForStoreAuthCode: waitForStoreAuthCodeMock, + exchangeStoreAuthCodeForToken: vi.fn().mockResolvedValue({ + access_token: 'token', + scope: 'read_products', + expires_in: 86400, + refresh_token: 'refresh-token', + associated_user: {id: 42, email: 'test@example.com'}, + }), + renderInfo: vi.fn(), + renderSuccess: vi.fn(), + }, + ) + + expect(openURL).toHaveBeenCalledWith(expect.stringContaining('/admin/oauth/authorize?')) + + const storedSession = vi.mocked(setStoredStoreAppSession).mock.calls[0]![0] + expect(storedSession.store).toBe('shop.myshopify.com') + expect(storedSession.clientId).toBe(STORE_AUTH_APP_CLIENT_ID) + expect(storedSession.userId).toBe('42') + expect(storedSession.accessToken).toBe('token') + expect(storedSession.refreshToken).toBe('refresh-token') + expect(storedSession.scopes).toEqual(['read_products']) + expect(storedSession.expiresAt).toBeDefined() + expect(storedSession.associatedUser).toEqual({ + id: 42, + email: 'test@example.com', + firstName: undefined, + lastName: undefined, + accountOwner: undefined, + }) + }) + + test('authenticateStoreWithApp rejects when Shopify grants fewer scopes than requested', async () => { + const waitForStoreAuthCodeMock = vi.fn().mockImplementation(async (options) => { + await options.onListening?.() + return 'abc123' + }) + + await expect( + authenticateStoreWithApp( + { + store: 'shop.myshopify.com', + scopes: 'read_products,write_products', + }, + { + openURL: vi.fn().mockResolvedValue(true), + waitForStoreAuthCode: waitForStoreAuthCodeMock, + exchangeStoreAuthCodeForToken: vi.fn().mockResolvedValue({ + access_token: 'token', + scope: 'read_products', + expires_in: 86400, + associated_user: {id: 42, email: 'test@example.com'}, + }), + renderInfo: vi.fn(), + renderSuccess: vi.fn(), + }, + ), + ).rejects.toThrow('Shopify granted fewer scopes than were requested.') + + expect(setStoredStoreAppSession).not.toHaveBeenCalled() + }) + + test('authenticateStoreWithApp falls back to requested scopes when Shopify omits granted scopes', async () => { + const waitForStoreAuthCodeMock = vi.fn().mockImplementation(async (options) => { + await options.onListening?.() + return 'abc123' + }) + + await authenticateStoreWithApp( + { + store: 'shop.myshopify.com', + scopes: 'read_products', + }, + { + openURL: vi.fn().mockResolvedValue(true), + waitForStoreAuthCode: waitForStoreAuthCodeMock, + exchangeStoreAuthCodeForToken: vi.fn().mockResolvedValue({ + access_token: 'token', + expires_in: 86400, + associated_user: {id: 42, email: 'test@example.com'}, + }), + renderInfo: vi.fn(), + renderSuccess: vi.fn(), + }, + ) + + expect(setStoredStoreAppSession).toHaveBeenCalledWith( + expect.objectContaining({ + store: 'shop.myshopify.com', + scopes: ['read_products'], + }), + ) + }) +}) diff --git a/packages/cli/src/cli/services/store/auth.ts b/packages/cli/src/cli/services/store/auth.ts new file mode 100644 index 00000000000..7afc5ef7c0f --- /dev/null +++ b/packages/cli/src/cli/services/store/auth.ts @@ -0,0 +1,429 @@ +import {DEFAULT_STORE_AUTH_PORT, STORE_AUTH_APP_CLIENT_ID, STORE_AUTH_CALLBACK_PATH, maskToken, storeAuthRedirectUri} from './auth-config.js' +import {setStoredStoreAppSession} from './session.js' +import {normalizeStoreFqdn} from '@shopify/cli-kit/node/context/fqdn' +import {randomUUID} from '@shopify/cli-kit/node/crypto' +import {AbortError} from '@shopify/cli-kit/node/error' +import {fetch} from '@shopify/cli-kit/node/http' +import {outputContent, outputDebug, outputToken} from '@shopify/cli-kit/node/output' +import {openURL} from '@shopify/cli-kit/node/system' +import {renderInfo, renderSuccess} from '@shopify/cli-kit/node/ui' +import {createHash, randomBytes, timingSafeEqual} from 'crypto' +import {createServer} from 'http' + +interface StoreAuthInput { + store: string + scopes: string +} + +interface StoreTokenResponse { + access_token: string + token_type?: string + scope?: string + expires_in?: number + refresh_token?: string + refresh_token_expires_in?: number + associated_user_scope?: string + associated_user?: { + id: number + first_name?: string + last_name?: string + email?: string + account_owner?: boolean + locale?: string + collaborator?: boolean + email_verified?: boolean + } +} + +interface StoreAuthorizationContext { + store: string + scopes: string[] + state: string + port: number + redirectUri: string + authorizationUrl: string + codeVerifier: string +} + +interface StoreAuthBootstrap { + authorization: StoreAuthorizationContext + waitForAuthCodeOptions: WaitForAuthCodeOptions + exchangeCodeForToken: (code: string) => Promise +} + +interface WaitForAuthCodeOptions { + store: string + state: string + port: number + timeoutMs?: number + onListening?: () => void | Promise +} + +export function generateCodeVerifier(): string { + return randomBytes(32).toString('base64url') +} + +export function computeCodeChallenge(verifier: string): string { + return createHash('sha256').update(verifier).digest('base64url') +} + +export function parseStoreAuthScopes(input: string): string[] { + const scopes = input + .split(',') + .map((scope) => scope.trim()) + .filter(Boolean) + + if (scopes.length === 0) { + throw new AbortError('At least one scope is required.', 'Pass --scopes as a comma-separated list.') + } + + return [...new Set(scopes)] +} + +function resolveGrantedScopes(tokenResponse: StoreTokenResponse, requestedScopes: string[]): string[] { + if (!tokenResponse.scope) { + outputDebug(outputContent`Token response did not include scope; falling back to requested scopes`) + return requestedScopes + } + + const grantedScopes = parseStoreAuthScopes(tokenResponse.scope) + const missingScopes = requestedScopes.filter((scope) => !grantedScopes.includes(scope)) + + if (missingScopes.length > 0) { + throw new AbortError( + 'Shopify granted fewer scopes than were requested.', + `Missing scopes: ${missingScopes.join(', ')}. Update the app or store installation scopes, then re-run shopify store auth.`, + ) + } + + return grantedScopes +} + +export function buildStoreAuthUrl(options: { + store: string + scopes: string[] + state: string + redirectUri: string + codeChallenge: string +}): string { + const params = new URLSearchParams() + params.set('client_id', STORE_AUTH_APP_CLIENT_ID) + params.set('scope', options.scopes.join(',')) + params.set('redirect_uri', options.redirectUri) + params.set('state', options.state) + params.set('response_type', 'code') + params.set('code_challenge', options.codeChallenge) + params.set('code_challenge_method', 'S256') + + return `https://${options.store}/admin/oauth/authorize?${params.toString()}` +} + +export async function waitForStoreAuthCode({ + store, + state, + port, + timeoutMs = 5 * 60 * 1000, + onListening, +}: WaitForAuthCodeOptions): Promise { + const normalizedStore = normalizeStoreFqdn(store) + + return new Promise((resolve, reject) => { + let settled = false + let isListening = false + + const timeout = setTimeout(() => { + settleWithError(new AbortError('Timed out waiting for OAuth callback.')) + }, timeoutMs) + + const server = createServer((req, res) => { + const requestUrl = new URL(req.url ?? '/', `http://127.0.0.1:${port}`) + + if (requestUrl.pathname !== STORE_AUTH_CALLBACK_PATH) { + res.statusCode = 404 + res.end('Not found') + return + } + + const {searchParams} = requestUrl + + const fail = (message: string) => { + res.statusCode = 400 + res.setHeader('Content-Type', 'text/html') + res.setHeader('Connection', 'close') + const safeMessage = message + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + res.once('finish', () => settleWithError(new AbortError(message))) + res.end(`

Authentication failed

${safeMessage}

`) + } + + const returnedStore = searchParams.get('shop') + outputDebug(outputContent`Received OAuth callback for shop ${outputToken.raw(returnedStore ?? 'unknown')}`) + + if (!returnedStore || normalizeStoreFqdn(returnedStore) !== normalizedStore) { + fail('OAuth callback store does not match the requested store.') + return + } + + const returnedState = searchParams.get('state') + if (!returnedState || !constantTimeEqual(returnedState, state)) { + fail('OAuth callback state does not match the original request.') + return + } + + const error = searchParams.get('error') + if (error) { + fail(`Shopify returned an OAuth error: ${error}`) + return + } + + const code = searchParams.get('code') + if (!code) { + fail('OAuth callback did not include an authorization code.') + return + } + + outputDebug(outputContent`Received authorization code ${outputToken.raw(maskToken(code))}`) + + res.statusCode = 200 + res.setHeader('Content-Type', 'text/html') + res.setHeader('Connection', 'close') + res.once('finish', () => settle(() => resolve(code))) + res.end('

Authentication succeeded

You can close this window and return to the terminal.

') + }) + + const settle = (callback: () => void) => { + if (settled) return + settled = true + clearTimeout(timeout) + + const finalize = () => { + callback() + } + + if (!isListening) { + finalize() + return + } + + server.close(() => { + isListening = false + finalize() + }) + server.closeIdleConnections?.() + } + + const settleWithError = (error: Error) => { + settle(() => reject(error)) + } + + server.on('error', (error: NodeJS.ErrnoException) => { + if (error.code === 'EADDRINUSE') { + settleWithError( + new AbortError( + `Port ${port} is already in use.`, + `Free port ${port} and re-run ${outputToken.genericShellCommand(`shopify store auth --store ${store} --scopes `).value}. Ensure that redirect URI is allowed in the app configuration.`, + ), + ) + return + } + + settleWithError(error) + }) + + server.listen(port, '127.0.0.1', async () => { + isListening = true + outputDebug( + outputContent`PKCE callback server listening on http://127.0.0.1:${outputToken.raw(String(port))}${outputToken.raw(STORE_AUTH_CALLBACK_PATH)}`, + ) + + if (!onListening) return + + try { + await onListening() + } catch (error) { + settleWithError(error instanceof Error ? error : new Error(String(error))) + } + }) + }) +} + +function constantTimeEqual(a: string, b: string): boolean { + if (a.length !== b.length) return false + return timingSafeEqual(Buffer.from(a, 'utf8'), Buffer.from(b, 'utf8')) +} + +export async function exchangeStoreAuthCodeForToken(options: { + store: string + code: string + codeVerifier: string + redirectUri: string +}): Promise { + const endpoint = `https://${options.store}/admin/oauth/access_token` + + outputDebug(outputContent`Exchanging authorization code for token at ${outputToken.raw(endpoint)}`) + + const response = await fetch(endpoint, { + method: 'POST', + headers: {'Content-Type': 'application/json'}, + body: JSON.stringify({ + client_id: STORE_AUTH_APP_CLIENT_ID, + code: options.code, + code_verifier: options.codeVerifier, + redirect_uri: options.redirectUri, + }), + }) + + const body = await response.text() + if (!response.ok) { + outputDebug( + outputContent`Token exchange failed with HTTP ${outputToken.raw(String(response.status))}: ${outputToken.raw(body.slice(0, 300))}`, + ) + throw new AbortError( + `Failed to exchange OAuth code for an access token (HTTP ${response.status}).`, + body || response.statusText, + ) + } + + let parsed: StoreTokenResponse + try { + parsed = JSON.parse(body) as StoreTokenResponse + } catch { + throw new AbortError('Received an invalid token response from Shopify.') + } + + outputDebug( + outputContent`Token exchange succeeded: access_token=${outputToken.raw(maskToken(parsed.access_token))}, refresh_token=${outputToken.raw(parsed.refresh_token ? maskToken(parsed.refresh_token) : 'none')}, expires_in=${outputToken.raw(String(parsed.expires_in ?? 'unknown'))}s, user=${outputToken.raw(String(parsed.associated_user?.id ?? 'unknown'))} (${outputToken.raw(parsed.associated_user?.email ?? 'no email')})`, + ) + + return parsed +} + +interface StoreAuthDependencies { + openURL: typeof openURL + waitForStoreAuthCode: typeof waitForStoreAuthCode + exchangeStoreAuthCodeForToken: typeof exchangeStoreAuthCodeForToken + renderInfo: typeof renderInfo + renderSuccess: typeof renderSuccess +} + +const defaultStoreAuthDependencies: StoreAuthDependencies = { + openURL, + waitForStoreAuthCode, + exchangeStoreAuthCodeForToken, + renderInfo, + renderSuccess, +} + +function createPkceBootstrap( + input: StoreAuthInput, + exchangeCodeForToken: typeof exchangeStoreAuthCodeForToken, +): StoreAuthBootstrap { + const store = normalizeStoreFqdn(input.store) + const scopes = parseStoreAuthScopes(input.scopes) + const port = DEFAULT_STORE_AUTH_PORT + const state = randomUUID() + const redirectUri = storeAuthRedirectUri(port) + const codeVerifier = generateCodeVerifier() + const codeChallenge = computeCodeChallenge(codeVerifier) + const authorizationUrl = buildStoreAuthUrl({store, scopes, state, redirectUri, codeChallenge}) + + outputDebug( + outputContent`Starting PKCE auth for ${outputToken.raw(store)} with scopes ${outputToken.raw(scopes.join(','))} (redirect_uri=${outputToken.raw(redirectUri)})`, + ) + + return { + authorization: { + store, + scopes, + state, + port, + redirectUri, + authorizationUrl, + codeVerifier, + }, + waitForAuthCodeOptions: { + store, + state, + port, + }, + exchangeCodeForToken: (code: string) => exchangeCodeForToken({store, code, codeVerifier, redirectUri}), + } +} + +export async function authenticateStoreWithApp( + input: StoreAuthInput, + dependencies: StoreAuthDependencies = defaultStoreAuthDependencies, +): Promise { + const bootstrap = createPkceBootstrap(input, dependencies.exchangeStoreAuthCodeForToken) + const { + authorization: {store, scopes, redirectUri, authorizationUrl}, + } = bootstrap + + dependencies.renderInfo({ + headline: 'Authenticate the app against your store.', + body: [ + `Shopify CLI will open the app authorization page in your browser.`, + `If the browser does not open, use this URL:`, + {link: {label: authorizationUrl, url: authorizationUrl}}, + `Ensure your app allows the redirect URI ${redirectUri}.`, + ], + }) + + const code = await dependencies.waitForStoreAuthCode({ + ...bootstrap.waitForAuthCodeOptions, + onListening: async () => { + await dependencies.openURL(authorizationUrl) + }, + }) + const tokenResponse = await bootstrap.exchangeCodeForToken(code) + + const userId = tokenResponse.associated_user?.id?.toString() + if (!userId) { + throw new AbortError('Shopify did not return associated user information for the online access token.') + } + + const now = Date.now() + const expiresAt = tokenResponse.expires_in ? new Date(now + tokenResponse.expires_in * 1000).toISOString() : undefined + + setStoredStoreAppSession({ + store, + clientId: STORE_AUTH_APP_CLIENT_ID, + userId, + accessToken: tokenResponse.access_token, + refreshToken: tokenResponse.refresh_token, + scopes: resolveGrantedScopes(tokenResponse, scopes), + acquiredAt: new Date(now).toISOString(), + expiresAt, + refreshTokenExpiresAt: tokenResponse.refresh_token_expires_in + ? new Date(now + tokenResponse.refresh_token_expires_in * 1000).toISOString() + : undefined, + associatedUser: tokenResponse.associated_user + ? { + id: tokenResponse.associated_user.id, + email: tokenResponse.associated_user.email, + firstName: tokenResponse.associated_user.first_name, + lastName: tokenResponse.associated_user.last_name, + accountOwner: tokenResponse.associated_user.account_owner, + } + : undefined, + }) + + outputDebug( + outputContent`Session persisted for ${outputToken.raw(store)} (user ${outputToken.raw(userId)}, expires ${outputToken.raw(expiresAt ?? 'unknown')})`, + ) + + const email = tokenResponse.associated_user?.email + const displayName = email ? ` as ${email}` : '' + + dependencies.renderSuccess({ + headline: 'Store authentication succeeded.', + body: [ + `Authenticated${displayName} against ${store}.`, + `Next step:`, + {command: `shopify store execute --store ${store} --query 'query { shop { name id } }'`}, + ], + }) +} diff --git a/packages/cli/src/cli/services/store/execute-request.test.ts b/packages/cli/src/cli/services/store/execute-request.test.ts new file mode 100644 index 00000000000..7146746bced --- /dev/null +++ b/packages/cli/src/cli/services/store/execute-request.test.ts @@ -0,0 +1,127 @@ +import {beforeEach, describe, expect, test, vi} from 'vitest' +import {fileExists, readFile} from '@shopify/cli-kit/node/fs' +import {prepareStoreExecuteRequest} from './execute-request.js' + +vi.mock('@shopify/cli-kit/node/fs') + +describe('prepareStoreExecuteRequest', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + test('returns a prepared request for an inline query', async () => { + const request = await prepareStoreExecuteRequest({ + query: 'query { shop { name } }', + variables: '{"id":"gid://shopify/Shop/1"}', + outputFile: '/tmp/result.json', + version: '2025-07', + }) + + expect(request).toMatchObject({ + query: 'query { shop { name } }', + parsedVariables: {id: 'gid://shopify/Shop/1'}, + outputFile: '/tmp/result.json', + requestedVersion: '2025-07', + }) + }) + + test('reads the query from a file', async () => { + vi.mocked(fileExists).mockResolvedValue(true) + vi.mocked(readFile).mockResolvedValueOnce('query { shop { name } }' as any) + + const request = await prepareStoreExecuteRequest({ + queryFile: '/tmp/operation.graphql', + }) + + expect(request.query).toBe('query { shop { name } }') + }) + + test('throws when the query file does not exist', async () => { + vi.mocked(fileExists).mockResolvedValue(false) + + await expect( + prepareStoreExecuteRequest({ + queryFile: '/tmp/missing.graphql', + }), + ).rejects.toThrow('Query file not found') + }) + + test('throws when the inline query is empty', async () => { + await expect( + prepareStoreExecuteRequest({ + query: ' ', + }), + ).rejects.toThrow('--query flag value is empty') + }) + + test('throws when the query file is empty', async () => { + vi.mocked(fileExists).mockResolvedValue(true) + vi.mocked(readFile).mockResolvedValueOnce(' ' as any) + + await expect( + prepareStoreExecuteRequest({ + queryFile: '/tmp/operation.graphql', + }), + ).rejects.toThrow('is empty') + }) + + test('throws when no query input is provided', async () => { + await expect(prepareStoreExecuteRequest({})).rejects.toThrow('Query should have been provided') + }) + + test('throws when the GraphQL syntax is invalid', async () => { + await expect( + prepareStoreExecuteRequest({ + query: 'query {', + }), + ).rejects.toThrow('Invalid GraphQL syntax') + }) + + test('throws when the document has multiple operations', async () => { + await expect( + prepareStoreExecuteRequest({ + query: 'query First { shop { name } } query Second { shop { id } }', + }), + ).rejects.toThrow('exactly one operation definition') + }) + + test('throws when a mutation is not allowed', async () => { + await expect( + prepareStoreExecuteRequest({ + query: 'mutation { productCreate(product: {title: "Hat"}) { product { id } } }', + }), + ).rejects.toThrow('Mutations are disabled by default') + }) + + test('allows mutations when explicitly enabled', async () => { + const request = await prepareStoreExecuteRequest({ + query: 'mutation { productCreate(product: {title: "Hat"}) { product { id } } }', + allowMutations: true, + }) + + expect(request.parsedOperation.operationDefinition.operation).toBe('mutation') + }) + + test('throws when variables contain invalid JSON', async () => { + await expect( + prepareStoreExecuteRequest({ + query: 'query { shop { name } }', + variables: '{invalid json}', + }), + ).rejects.toThrow('Invalid JSON') + }) + + test('reads variables from a file', async () => { + vi.mocked(fileExists).mockResolvedValue(true) + vi.mocked(readFile) + .mockResolvedValueOnce('query { shop { id } }' as any) + .mockResolvedValueOnce('{"id":"gid://shopify/Shop/1"}' as any) + + const request = await prepareStoreExecuteRequest({ + queryFile: '/tmp/operation.graphql', + variableFile: '/tmp/variables.json', + }) + + expect(request.parsedVariables).toEqual({id: 'gid://shopify/Shop/1'}) + }) +}) diff --git a/packages/cli/src/cli/services/store/execute-request.ts b/packages/cli/src/cli/services/store/execute-request.ts new file mode 100644 index 00000000000..7524b74b87d --- /dev/null +++ b/packages/cli/src/cli/services/store/execute-request.ts @@ -0,0 +1,152 @@ +import {AbortError, BugError} from '@shopify/cli-kit/node/error' +import {fileExists, readFile} from '@shopify/cli-kit/node/fs' +import {outputContent, outputToken} from '@shopify/cli-kit/node/output' +import {OperationDefinitionNode, parse} from 'graphql' + +interface ParsedGraphQLOperation { + operationDefinition: OperationDefinitionNode +} + +export interface PreparedStoreExecuteRequest { + query: string + parsedOperation: ParsedGraphQLOperation + parsedVariables?: {[key: string]: unknown} + outputFile?: string + requestedVersion?: string +} + +async function readQuery(input: {query?: string; queryFile?: string}): Promise { + if (input.query !== undefined) { + if (!input.query.trim()) { + throw new AbortError('The --query flag value is empty. Please provide a valid GraphQL query or mutation.') + } + + return input.query + } + + if (input.queryFile) { + if (!(await fileExists(input.queryFile))) { + throw new AbortError( + outputContent`Query file not found at ${outputToken.path(input.queryFile)}. Please check the path and try again.`, + ) + } + + const query = await readFile(input.queryFile, {encoding: 'utf8'}) + if (!query.trim()) { + throw new AbortError( + outputContent`Query file at ${outputToken.path( + input.queryFile, + )} is empty. Please provide a valid GraphQL query or mutation.`, + ) + } + + return query + } + + throw new BugError( + 'Query should have been provided via --query or --query-file flags due to exactlyOne constraint. This indicates the oclif flag validation failed.', + ) +} + +async function parseVariables( + variables?: string, + variableFile?: string, +): Promise<{[key: string]: unknown} | undefined> { + if (variables) { + try { + return JSON.parse(variables) + } catch (error) { + const errorMessage = error instanceof Error ? error.message : 'Unknown error' + throw new AbortError( + outputContent`Invalid JSON in ${outputToken.yellow('--variables')} flag: ${errorMessage}`, + 'Please provide valid JSON format.', + ) + } + } else if (variableFile) { + if (!(await fileExists(variableFile))) { + throw new AbortError( + outputContent`Variable file not found at ${outputToken.path( + variableFile, + )}. Please check the path and try again.`, + ) + } + + const fileContent = await readFile(variableFile, {encoding: 'utf8'}) + try { + return JSON.parse(fileContent) + } catch (error) { + const errorMessage = error instanceof Error ? error.message : 'Unknown error' + throw new AbortError( + outputContent`Invalid JSON in variable file ${outputToken.path(variableFile)}: ${errorMessage}`, + 'Please provide valid JSON format.', + ) + } + } + + return undefined +} + +function parseGraphQLOperation(graphqlOperation: string): ParsedGraphQLOperation { + let document + + try { + document = parse(graphqlOperation) + } catch (error) { + if (error instanceof Error) { + throw new AbortError(`Invalid GraphQL syntax: ${error.message}`) + } + + throw error + } + + const operationDefinitions = document.definitions.filter( + (definition): definition is OperationDefinitionNode => definition.kind === 'OperationDefinition', + ) + + if (operationDefinitions.length !== 1) { + throw new AbortError( + 'GraphQL document must contain exactly one operation definition. Multiple operations are not supported.', + ) + } + + return { + operationDefinition: operationDefinitions[0]!, + } +} + +function isMutation(operation: ParsedGraphQLOperation): boolean { + return operation.operationDefinition.operation === 'mutation' +} + +function validateMutationsAllowed(operation: ParsedGraphQLOperation, allowMutations = false): void { + if (isMutation(operation) && !allowMutations) { + throw new AbortError( + 'Mutations are disabled by default for shopify store execute.', + 'Re-run with --allow-mutations if you intend to modify store data.', + ) + } +} + +export async function prepareStoreExecuteRequest(input: { + query?: string + queryFile?: string + variables?: string + variableFile?: string + outputFile?: string + version?: string + allowMutations?: boolean +}): Promise { + const query = await readQuery({query: input.query, queryFile: input.queryFile}) + const parsedOperation = parseGraphQLOperation(query) + validateMutationsAllowed(parsedOperation, input.allowMutations) + const parsedVariables = await parseVariables(input.variables, input.variableFile) + + return { + query, + parsedOperation, + parsedVariables, + outputFile: input.outputFile, + requestedVersion: input.version, + } +} + diff --git a/packages/cli/src/cli/services/store/execute-result.test.ts b/packages/cli/src/cli/services/store/execute-result.test.ts new file mode 100644 index 00000000000..2a370560acf --- /dev/null +++ b/packages/cli/src/cli/services/store/execute-result.test.ts @@ -0,0 +1,34 @@ +import {beforeEach, describe, expect, test, vi} from 'vitest' +import {writeFile} from '@shopify/cli-kit/node/fs' +import {renderSuccess} from '@shopify/cli-kit/node/ui' +import {mockAndCaptureOutput} from '@shopify/cli-kit/node/testing/output' +import {writeOrOutputStoreExecuteResult} from './execute-result.js' + +vi.mock('@shopify/cli-kit/node/fs') +vi.mock('@shopify/cli-kit/node/ui') + +describe('writeOrOutputStoreExecuteResult', () => { + beforeEach(() => { + vi.clearAllMocks() + mockAndCaptureOutput().clear() + }) + + test('writes results to a file when outputFile is provided', async () => { + await writeOrOutputStoreExecuteResult({data: {shop: {name: 'Test shop'}}}, '/tmp/results.json') + + expect(writeFile).toHaveBeenCalledWith('/tmp/results.json', expect.stringContaining('Test shop')) + expect(renderSuccess).toHaveBeenCalledWith({ + headline: 'Operation succeeded.', + body: 'Results written to /tmp/results.json', + }) + }) + + test('writes results to stdout when no outputFile is provided', async () => { + const output = mockAndCaptureOutput() + + await writeOrOutputStoreExecuteResult({data: {shop: {name: 'Test shop'}}}) + + expect(renderSuccess).toHaveBeenCalledWith({headline: 'Operation succeeded.'}) + expect(output.info()).toContain('Test shop') + }) +}) diff --git a/packages/cli/src/cli/services/store/execute-result.ts b/packages/cli/src/cli/services/store/execute-result.ts new file mode 100644 index 00000000000..dd284a5deae --- /dev/null +++ b/packages/cli/src/cli/services/store/execute-result.ts @@ -0,0 +1,18 @@ +import {writeFile} from '@shopify/cli-kit/node/fs' +import {outputResult} from '@shopify/cli-kit/node/output' +import {renderSuccess} from '@shopify/cli-kit/node/ui' + +export async function writeOrOutputStoreExecuteResult(result: unknown, outputFile?: string): Promise { + const resultString = JSON.stringify(result, null, 2) + + if (outputFile) { + await writeFile(outputFile, resultString) + renderSuccess({ + headline: 'Operation succeeded.', + body: `Results written to ${outputFile}`, + }) + } else { + renderSuccess({headline: 'Operation succeeded.'}) + outputResult(resultString) + } +} diff --git a/packages/cli/src/cli/services/store/execute.test.ts b/packages/cli/src/cli/services/store/execute.test.ts new file mode 100644 index 00000000000..647c9975b2d --- /dev/null +++ b/packages/cli/src/cli/services/store/execute.test.ts @@ -0,0 +1,218 @@ +import {describe, test, expect, vi, beforeEach, afterEach} from 'vitest' +import {executeStoreOperation} from './execute.js' +import {getStoredStoreAppSession} from './session.js' +import {fetchApiVersions, adminUrl} from '@shopify/cli-kit/node/api/admin' +import {graphqlRequest} from '@shopify/cli-kit/node/api/graphql' +import {renderSingleTask, renderSuccess} from '@shopify/cli-kit/node/ui' +import {fileExists, readFile, writeFile} from '@shopify/cli-kit/node/fs' +import {mockAndCaptureOutput} from '@shopify/cli-kit/node/testing/output' + +vi.mock('./session.js') +vi.mock('@shopify/cli-kit/node/api/graphql') +vi.mock('@shopify/cli-kit/node/ui') +vi.mock('@shopify/cli-kit/node/fs') +vi.mock('@shopify/cli-kit/node/api/admin', async () => { + const actual = await vi.importActual('@shopify/cli-kit/node/api/admin') + return { + ...actual, + fetchApiVersions: vi.fn(), + adminUrl: vi.fn(), + } +}) + +describe('executeStoreOperation', () => { + const store = 'shop.myshopify.com' + const session = {token: 'token', storeFqdn: store} + const storedSession = { + store, + clientId: 'b16de5d7ba3e2e22279a38c22ef025a0', + userId: '42', + accessToken: 'token', + scopes: ['read_products'], + acquiredAt: '2026-03-27T00:00:00.000Z', + } + + beforeEach(() => { + vi.clearAllMocks() + vi.mocked(getStoredStoreAppSession).mockReturnValue(storedSession) + vi.mocked(fetchApiVersions).mockResolvedValue([ + {handle: '2025-10', supported: true}, + {handle: '2025-07', supported: true}, + {handle: 'unstable', supported: false}, + ] as any) + vi.mocked(adminUrl).mockReturnValue('https://shop.myshopify.com/admin/api/2025-10/graphql.json') + vi.mocked(renderSingleTask).mockImplementation(async ({task}) => task(() => {})) + }) + + afterEach(() => { + mockAndCaptureOutput().clear() + }) + + test('executes a query successfully', async () => { + vi.mocked(graphqlRequest).mockResolvedValue({data: {shop: {name: 'Test shop'}}}) + const output = mockAndCaptureOutput() + + await executeStoreOperation({ + store, + query: 'query { shop { name } }', + }) + + expect(renderSingleTask).toHaveBeenCalledWith( + expect.objectContaining({ + title: expect.anything(), + }), + ) + expect(getStoredStoreAppSession).toHaveBeenCalledWith(store) + expect(fetchApiVersions).toHaveBeenCalledWith(session) + expect(graphqlRequest).toHaveBeenCalledWith({ + query: 'query { shop { name } }', + api: 'Admin', + url: 'https://shop.myshopify.com/admin/api/2025-10/graphql.json', + token: 'token', + variables: undefined, + responseOptions: {handleErrors: false}, + }) + expect(output.info()).toContain('"name": "Test shop"') + expect(renderSuccess).toHaveBeenCalledWith({headline: 'Operation succeeded.'}) + }) + + test('passes parsed variables when provided inline', async () => { + vi.mocked(graphqlRequest).mockResolvedValue({data: {shop: {id: 'gid://shopify/Shop/1'}}}) + + await executeStoreOperation({ + store, + query: 'query Shop($id: ID!) { shop { id } }', + variables: '{"id":"gid://shopify/Shop/1"}', + }) + + expect(graphqlRequest).toHaveBeenCalledWith( + expect.objectContaining({ + variables: {id: 'gid://shopify/Shop/1'}, + }), + ) + }) + + test('reads variables from a file', async () => { + vi.mocked(fileExists).mockResolvedValue(true) + vi.mocked(readFile).mockResolvedValue('{"id":"gid://shopify/Shop/1"}' as any) + vi.mocked(graphqlRequest).mockResolvedValue({data: {shop: {id: 'gid://shopify/Shop/1'}}}) + + await executeStoreOperation({ + store, + query: 'query Shop($id: ID!) { shop { id } }', + variableFile: '/tmp/variables.json', + }) + + expect(graphqlRequest).toHaveBeenCalledWith( + expect.objectContaining({ + variables: {id: 'gid://shopify/Shop/1'}, + }), + ) + }) + + test('throws when variables contain invalid JSON', async () => { + await expect( + executeStoreOperation({ + store, + query: 'query { shop { name } }', + variables: '{invalid json}', + }), + ).rejects.toThrow('Invalid JSON') + + expect(graphqlRequest).not.toHaveBeenCalled() + }) + + test('throws when mutations are not explicitly allowed', async () => { + await expect( + executeStoreOperation({ + store, + query: 'mutation { productCreate(product: {title: "Hat"}) { product { id } } }', + }), + ).rejects.toThrow('Mutations are disabled by default') + + expect(getStoredStoreAppSession).not.toHaveBeenCalled() + }) + + test('throws when no stored app session exists', async () => { + vi.mocked(getStoredStoreAppSession).mockReturnValue(undefined) + + await expect( + executeStoreOperation({ + store, + query: 'query { shop { name } }', + }), + ).rejects.toThrow('No stored app authentication found') + }) + + test('allows mutations when explicitly enabled', async () => { + vi.mocked(graphqlRequest).mockResolvedValue({data: {productCreate: {product: {id: 'gid://shopify/Product/1'}}}}) + + await executeStoreOperation({ + store, + query: 'mutation { productCreate(product: {title: "Hat"}) { product { id } } }', + allowMutations: true, + }) + + expect(graphqlRequest).toHaveBeenCalled() + }) + + test('uses the specified API version when provided', async () => { + vi.mocked(graphqlRequest).mockResolvedValue({data: {shop: {name: 'Test shop'}}}) + vi.mocked(adminUrl).mockReturnValue('https://shop.myshopify.com/admin/api/2025-07/graphql.json') + + await executeStoreOperation({ + store, + query: 'query { shop { name } }', + version: '2025-07', + }) + + expect(adminUrl).toHaveBeenCalledWith(store, '2025-07', session) + }) + + test('writes results to a file when outputFile is provided', async () => { + vi.mocked(graphqlRequest).mockResolvedValue({data: {shop: {name: 'Test shop'}}}) + + await executeStoreOperation({ + store, + query: 'query { shop { name } }', + outputFile: '/tmp/results.json', + }) + + expect(writeFile).toHaveBeenCalledWith('/tmp/results.json', expect.stringContaining('Test shop')) + expect(renderSuccess).toHaveBeenCalledWith({ + headline: 'Operation succeeded.', + body: 'Results written to /tmp/results.json', + }) + }) + + test('throws when stored auth is no longer valid', async () => { + vi.mocked(graphqlRequest).mockRejectedValue({ + response: { + status: 401, + }, + }) + + await expect( + executeStoreOperation({ + store, + query: 'query { shop { name } }', + }), + ).rejects.toThrow('Stored app authentication for') + }) + + test('throws on GraphQL errors', async () => { + vi.mocked(graphqlRequest).mockRejectedValue({ + response: { + errors: [{message: 'Field does not exist'}], + }, + }) + + await expect( + executeStoreOperation({ + store, + query: 'query { nope }', + }), + ).rejects.toThrow('GraphQL operation failed.') + }) + +}) diff --git a/packages/cli/src/cli/services/store/execute.ts b/packages/cli/src/cli/services/store/execute.ts new file mode 100644 index 00000000000..899bf5a662d --- /dev/null +++ b/packages/cli/src/cli/services/store/execute.ts @@ -0,0 +1,45 @@ +import {renderSingleTask} from '@shopify/cli-kit/node/ui' +import {outputContent} from '@shopify/cli-kit/node/output' +import {prepareStoreExecuteRequest} from './execute-request.js' +import {writeOrOutputStoreExecuteResult} from './execute-result.js' +import {getStoreGraphQLTarget, StoreGraphQLApi} from './graphql-targets.js' + +interface ExecuteStoreOperationInput { + store: string + api?: StoreGraphQLApi + query?: string + queryFile?: string + variables?: string + variableFile?: string + outputFile?: string + version?: string + allowMutations?: boolean +} + +export async function executeStoreOperation(input: ExecuteStoreOperationInput): Promise { + const target = getStoreGraphQLTarget(input.api ?? 'admin') + + const request = await prepareStoreExecuteRequest({ + query: input.query, + queryFile: input.queryFile, + variables: input.variables, + variableFile: input.variableFile, + outputFile: input.outputFile, + version: input.version, + allowMutations: input.allowMutations, + }) + + const context = await renderSingleTask({ + title: outputContent`Loading stored store auth`, + task: async () => target.prepareContext({store: input.store, requestedVersion: request.requestedVersion}), + renderOptions: {stdout: process.stderr}, + }) + + const result = await target.execute({ + store: input.store, + context, + request, + }) + + await writeOrOutputStoreExecuteResult(result, request.outputFile) +} diff --git a/packages/cli/src/cli/services/store/graphql-targets.test.ts b/packages/cli/src/cli/services/store/graphql-targets.test.ts new file mode 100644 index 00000000000..a5fba30398d --- /dev/null +++ b/packages/cli/src/cli/services/store/graphql-targets.test.ts @@ -0,0 +1,45 @@ +import {beforeEach, describe, expect, test, vi} from 'vitest' +import {prepareStoreExecuteRequest} from './execute-request.js' +import {prepareAdminStoreGraphQLContext} from './admin-graphql-context.js' +import {runAdminStoreGraphQLOperation} from './admin-graphql-transport.js' +import {getStoreGraphQLTarget} from './graphql-targets.js' + +vi.mock('./admin-graphql-context.js') +vi.mock('./admin-graphql-transport.js') + +describe('getStoreGraphQLTarget', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + test('returns the admin target adapter', async () => { + const target = getStoreGraphQLTarget('admin') + const request = await prepareStoreExecuteRequest({query: 'query { shop { name } }'}) + const context = { + adminSession: {token: 'token', storeFqdn: 'shop.myshopify.com'}, + version: '2025-10', + sessionUserId: '42', + } + + vi.mocked(prepareAdminStoreGraphQLContext).mockResolvedValue(context) + vi.mocked(runAdminStoreGraphQLOperation).mockResolvedValue({data: {shop: {name: 'Test shop'}}}) + + await expect(target.prepareContext({store: 'shop.myshopify.com', requestedVersion: '2025-10'})).resolves.toEqual(context) + + await expect(target.execute({store: 'shop.myshopify.com', context, request})).resolves.toEqual({ + data: {shop: {name: 'Test shop'}}, + }) + + expect(prepareAdminStoreGraphQLContext).toHaveBeenCalledWith({ + store: 'shop.myshopify.com', + userSpecifiedVersion: '2025-10', + }) + expect(runAdminStoreGraphQLOperation).toHaveBeenCalledWith({ + store: 'shop.myshopify.com', + adminSession: context.adminSession, + sessionUserId: context.sessionUserId, + version: context.version, + request, + }) + }) +}) diff --git a/packages/cli/src/cli/services/store/graphql-targets.ts b/packages/cli/src/cli/services/store/graphql-targets.ts new file mode 100644 index 00000000000..809568d7e2e --- /dev/null +++ b/packages/cli/src/cli/services/store/graphql-targets.ts @@ -0,0 +1,50 @@ +import {BugError} from '@shopify/cli-kit/node/error' +import {PreparedStoreExecuteRequest} from './execute-request.js' +import {prepareAdminStoreGraphQLContext, AdminStoreGraphQLContext} from './admin-graphql-context.js' +import {runAdminStoreGraphQLOperation} from './admin-graphql-transport.js' + +export type StoreGraphQLApi = 'admin' + +interface PrepareStoreGraphQLTargetContextInput { + store: string + requestedVersion?: string +} + +interface ExecuteStoreGraphQLTargetInput { + store: string + context: TContext + request: PreparedStoreExecuteRequest +} + +// Internal seam for store-scoped GraphQL APIs. Different targets may need different +// auth/context preparation and execution behavior, so each target owns both phases. +interface StoreGraphQLTarget { + id: StoreGraphQLApi + prepareContext(input: PrepareStoreGraphQLTargetContextInput): Promise + execute(input: ExecuteStoreGraphQLTargetInput): Promise +} + +const adminStoreGraphQLTarget: StoreGraphQLTarget = { + id: 'admin', + prepareContext: async ({store, requestedVersion}) => { + return prepareAdminStoreGraphQLContext({store, userSpecifiedVersion: requestedVersion}) + }, + execute: async ({store, context, request}) => { + return runAdminStoreGraphQLOperation({ + store, + adminSession: context.adminSession, + sessionUserId: context.sessionUserId, + version: context.version, + request, + }) + }, +} + +export function getStoreGraphQLTarget(api: StoreGraphQLApi): StoreGraphQLTarget { + switch (api) { + case 'admin': + return adminStoreGraphQLTarget + default: + throw new BugError(`Unsupported store GraphQL API target: ${api satisfies never}`) + } +} diff --git a/packages/cli/src/cli/services/store/session.test.ts b/packages/cli/src/cli/services/store/session.test.ts new file mode 100644 index 00000000000..bf3a52862f4 --- /dev/null +++ b/packages/cli/src/cli/services/store/session.test.ts @@ -0,0 +1,117 @@ +import {describe, test, expect} from 'vitest' +import {LocalStorage} from '@shopify/cli-kit/node/local-storage' +import { + clearStoredStoreAppSession, + getStoredStoreAppSession, + setStoredStoreAppSession, + isSessionExpired, + type StoredStoreAppSession, +} from './session.js' + +function inMemoryStorage() { + const values = new Map() + + return { + get(key: string) { + return values.get(key) as any + }, + set(key: string, value: unknown) { + values.set(key, value) + }, + delete(key: string) { + values.delete(key) + }, + } as LocalStorage> +} + +function buildSession(overrides: Partial = {}): StoredStoreAppSession { + return { + store: 'shop.myshopify.com', + clientId: 'b16de5d7ba3e2e22279a38c22ef025a0', + userId: '42', + accessToken: 'token-1', + scopes: ['read_products'], + acquiredAt: '2026-03-27T00:00:00.000Z', + ...overrides, + } +} + +describe('store session storage', () => { + test('returns the current user session for a store', () => { + const storage = inMemoryStorage() + + setStoredStoreAppSession(buildSession(), storage as any) + + expect(getStoredStoreAppSession('shop.myshopify.com', storage as any)).toEqual(buildSession()) + }) + + test('keeps multiple user sessions per store and returns the current one', () => { + const storage = inMemoryStorage() + const firstSession = buildSession({userId: '42', accessToken: 'token-1'}) + const secondSession = buildSession({userId: '84', accessToken: 'token-2'}) + + setStoredStoreAppSession(firstSession, storage as any) + setStoredStoreAppSession(secondSession, storage as any) + + expect(getStoredStoreAppSession('shop.myshopify.com', storage as any)).toEqual(secondSession) + }) + + test('clears all stored sessions for a store', () => { + const storage = inMemoryStorage() + + setStoredStoreAppSession(buildSession(), storage as any) + clearStoredStoreAppSession('shop.myshopify.com', storage as any) + + expect(getStoredStoreAppSession('shop.myshopify.com', storage as any)).toBeUndefined() + }) + + test('clears only the specified user session and preserves the rest of the bucket', () => { + const storage = inMemoryStorage() + const firstSession = buildSession({userId: '42', accessToken: 'token-1'}) + const secondSession = buildSession({userId: '84', accessToken: 'token-2'}) + + setStoredStoreAppSession(firstSession, storage as any) + setStoredStoreAppSession(secondSession, storage as any) + clearStoredStoreAppSession('shop.myshopify.com', '84', storage as any) + + expect(getStoredStoreAppSession('shop.myshopify.com', storage as any)).toEqual(firstSession) + }) + + test('returns undefined when the current user session is missing from the bucket', () => { + const storage = inMemoryStorage() + storage.set('b16de5d7ba3e2e22279a38c22ef025a0::shop.myshopify.com', { + currentUserId: '999', + sessionsByUserId: { + '42': buildSession(), + }, + }) + + expect(getStoredStoreAppSession('shop.myshopify.com', storage as any)).toBeUndefined() + }) +}) + +describe('isSessionExpired', () => { + test('returns false when expiresAt is not set', () => { + expect(isSessionExpired(buildSession())).toBe(false) + }) + + test('returns false when token is still valid', () => { + const future = new Date(Date.now() + 60 * 60 * 1000).toISOString() + expect(isSessionExpired(buildSession({expiresAt: future}))).toBe(false) + }) + + test('returns true when token is expired', () => { + const past = new Date(Date.now() - 60 * 1000).toISOString() + expect(isSessionExpired(buildSession({expiresAt: past}))).toBe(true) + }) + + test('returns true within the 4-minute expiry margin', () => { + const almostExpired = new Date(Date.now() + 3 * 60 * 1000).toISOString() + expect(isSessionExpired(buildSession({expiresAt: almostExpired}))).toBe(true) + }) + + test('returns false just outside the 4-minute expiry margin', () => { + const safelyValid = new Date(Date.now() + 5 * 60 * 1000).toISOString() + expect(isSessionExpired(buildSession({expiresAt: safelyValid}))).toBe(false) + }) +}) diff --git a/packages/cli/src/cli/services/store/session.ts b/packages/cli/src/cli/services/store/session.ts new file mode 100644 index 00000000000..fe8f2dc5b4a --- /dev/null +++ b/packages/cli/src/cli/services/store/session.ts @@ -0,0 +1,107 @@ +import {LocalStorage} from '@shopify/cli-kit/node/local-storage' +import {storeAuthSessionKey} from './auth-config.js' + +export interface StoredStoreAppSession { + store: string + clientId: string + userId: string + accessToken: string + refreshToken?: string + scopes: string[] + acquiredAt: string + expiresAt?: string + refreshTokenExpiresAt?: string + associatedUser?: { + id: number + email?: string + firstName?: string + lastName?: string + accountOwner?: boolean + } +} + +interface StoredStoreAppSessionBucket { + currentUserId: string + sessionsByUserId: {[userId: string]: StoredStoreAppSession} +} + +interface StoreSessionSchema { + [key: string]: StoredStoreAppSessionBucket +} + +let _storeSessionStorage: LocalStorage | undefined + +// Per-store, per-user session storage for PKCE online tokens. +function storeSessionStorage() { + _storeSessionStorage ??= new LocalStorage({projectName: 'shopify-cli-store'}) + return _storeSessionStorage +} + +export function getStoredStoreAppSession( + store: string, + storage: LocalStorage = storeSessionStorage(), +): StoredStoreAppSession | undefined { + const storedBucket = storage.get(storeAuthSessionKey(store)) + if (!storedBucket) return undefined + + return storedBucket.sessionsByUserId[storedBucket.currentUserId] +} + +export function setStoredStoreAppSession( + session: StoredStoreAppSession, + storage: LocalStorage = storeSessionStorage(), +): void { + const key = storeAuthSessionKey(session.store) + const existingBucket = storage.get(key) + + const nextBucket: StoredStoreAppSessionBucket = { + currentUserId: session.userId, + sessionsByUserId: { + ...(existingBucket?.sessionsByUserId ?? {}), + [session.userId]: session, + }, + } + + storage.set(key, nextBucket) +} + +export function clearStoredStoreAppSession( + store: string, + userIdOrStorage?: string | LocalStorage, + maybeStorage?: LocalStorage, +): void { + const userId = typeof userIdOrStorage === 'string' ? userIdOrStorage : undefined + const storage = + (typeof userIdOrStorage === 'string' ? maybeStorage : userIdOrStorage) ?? storeSessionStorage() + + const key = storeAuthSessionKey(store) + + if (!userId) { + storage.delete(key) + return + } + + const existingBucket = storage.get(key) + if (!existingBucket) return + + const {[userId]: _removedSession, ...remainingSessions} = existingBucket.sessionsByUserId + + const remainingUserIds = Object.keys(remainingSessions) + if (remainingUserIds.length === 0) { + storage.delete(key) + return + } + + storage.set(key, { + currentUserId: + existingBucket.currentUserId === userId ? remainingUserIds[0]! : existingBucket.currentUserId, + sessionsByUserId: remainingSessions, + }) +} + +const EXPIRY_MARGIN_MS = 4 * 60 * 1000 + +export function isSessionExpired(session: StoredStoreAppSession): boolean { + if (!session.expiresAt) return false + return new Date(session.expiresAt).getTime() - EXPIRY_MARGIN_MS < Date.now() +} diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index 4791d509935..429836541d0 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -15,6 +15,8 @@ import HelpCommand from './cli/commands/help.js' import List from './cli/commands/notifications/list.js' import Generate from './cli/commands/notifications/generate.js' import ClearCache from './cli/commands/cache/clear.js' +import StoreAuth from './cli/commands/store/auth.js' +import StoreExecute from './cli/commands/store/execute.js' import {createGlobalProxyAgent} from 'global-agent' import ThemeCommands from '@shopify/theme' import {COMMANDS as HydrogenCommands, HOOKS as HydrogenHooks} from '@shopify/cli-hydrogen' @@ -150,6 +152,8 @@ export const COMMANDS: any = { 'notifications:list': List, 'notifications:generate': Generate, 'cache:clear': ClearCache, + 'store:auth': StoreAuth, + 'store:execute': StoreExecute, } export default runShopifyCLI diff --git a/packages/e2e/data/snapshots/commands.txt b/packages/e2e/data/snapshots/commands.txt index 0c727fc0868..6aa49ab9ee7 100644 --- a/packages/e2e/data/snapshots/commands.txt +++ b/packages/e2e/data/snapshots/commands.txt @@ -88,6 +88,9 @@ │ ├─ unlink │ └─ update ├─ search +├─ store +│ ├─ auth +│ └─ execute ├─ theme │ ├─ check │ ├─ console