diff --git a/docs-shopify.dev/commands/examples/store-create.example.sh b/docs-shopify.dev/commands/examples/store-create.example.sh new file mode 100644 index 00000000000..0dd369d1ef8 --- /dev/null +++ b/docs-shopify.dev/commands/examples/store-create.example.sh @@ -0,0 +1 @@ +shopify store create [flags] \ No newline at end of file diff --git a/docs-shopify.dev/commands/interfaces/store-create.interface.ts b/docs-shopify.dev/commands/interfaces/store-create.interface.ts new file mode 100644 index 00000000000..17dae3f9dcc --- /dev/null +++ b/docs-shopify.dev/commands/interfaces/store-create.interface.ts @@ -0,0 +1,48 @@ +// This is an autogenerated file. Don't edit this file manually. +/** + * The following flags are available for the `store create` command: + * @publicDocs + */ +export interface storecreate { + /** + * The country code for the store (e.g., US, CA, GB). + * @environment SHOPIFY_FLAG_STORE_COUNTRY + */ + '-c, --country '?: string + + /** + * Create a development store instead of a trial store. + * @environment SHOPIFY_FLAG_STORE_DEV + */ + '--dev'?: '' + + /** + * Output the result as JSON. Automatically disables color output. + * @environment SHOPIFY_FLAG_JSON + */ + '-j, --json'?: '' + + /** + * The name of the store. + * @environment SHOPIFY_FLAG_STORE_NAME + */ + '-n, --name '?: string + + /** + * Disable color output. + * @environment SHOPIFY_FLAG_NO_COLOR + */ + '--no-color'?: '' + + /** + * The custom myshopify.com subdomain for the store. + * @environment SHOPIFY_FLAG_STORE_SUBDOMAIN + */ + '--subdomain '?: string + + /** + * Increase the verbosity of the output. + * @environment SHOPIFY_FLAG_VERBOSE + */ + '--verbose'?: '' +} diff --git a/docs-shopify.dev/commands/store-create.doc.ts b/docs-shopify.dev/commands/store-create.doc.ts new file mode 100644 index 00000000000..a516485ee8a --- /dev/null +++ b/docs-shopify.dev/commands/store-create.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 create', + description: `Creates a new Shopify store associated with your account. + +By default, creates a trial store. Use \`--dev\` to create a development store instead.`, + overviewPreviewDescription: `Create a new Shopify store.`, + type: 'command', + isVisualComponent: false, + defaultExample: { + codeblock: { + tabs: [ + { + title: 'store create', + code: './examples/store-create.example.sh', + language: 'bash', + }, + ], + title: 'store create', + }, + }, + definitions: [ + { + title: 'Flags', + description: 'The following flags are available for the `store create` command:', + type: 'storecreate', + }, + ], + 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 7f9685fd0ed..92f8c85bc38 100644 --- a/docs-shopify.dev/generated/generated_docs_data.json +++ b/docs-shopify.dev/generated/generated_docs_data.json @@ -5914,6 +5914,108 @@ "category": "store", "related": [] }, + { + "name": "store create", + "description": "Creates a new Shopify store associated with your account.\n\nBy default, creates a trial store. Use `--dev` to create a development store instead.", + "overviewPreviewDescription": "Create a new Shopify store.", + "type": "command", + "isVisualComponent": false, + "defaultExample": { + "codeblock": { + "tabs": [ + { + "title": "store create", + "code": "shopify store create [flags]", + "language": "bash" + } + ], + "title": "store create" + } + }, + "definitions": [ + { + "title": "Flags", + "description": "The following flags are available for the `store create` command:", + "type": "storecreate", + "typeDefinitions": { + "storecreate": { + "filePath": "docs-shopify.dev/commands/interfaces/store-create.interface.ts", + "name": "storecreate", + "description": "The following flags are available for the `store create` command:", + "isPublicDocs": true, + "members": [ + { + "filePath": "docs-shopify.dev/commands/interfaces/store-create.interface.ts", + "syntaxKind": "PropertySignature", + "name": "--dev", + "value": "''", + "description": "Create a development store instead of a trial store.", + "isOptional": true, + "environmentValue": "SHOPIFY_FLAG_STORE_DEV" + }, + { + "filePath": "docs-shopify.dev/commands/interfaces/store-create.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-create.interface.ts", + "syntaxKind": "PropertySignature", + "name": "--subdomain ", + "value": "string", + "description": "The custom myshopify.com subdomain for the store.", + "isOptional": true, + "environmentValue": "SHOPIFY_FLAG_STORE_SUBDOMAIN" + }, + { + "filePath": "docs-shopify.dev/commands/interfaces/store-create.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-create.interface.ts", + "syntaxKind": "PropertySignature", + "name": "-c, --country ", + "value": "string", + "description": "The country code for the store (e.g., US, CA, GB).", + "isOptional": true, + "environmentValue": "SHOPIFY_FLAG_STORE_COUNTRY" + }, + { + "filePath": "docs-shopify.dev/commands/interfaces/store-create.interface.ts", + "syntaxKind": "PropertySignature", + "name": "-j, --json", + "value": "''", + "description": "Output the result as JSON. Automatically disables color output.", + "isOptional": true, + "environmentValue": "SHOPIFY_FLAG_JSON" + }, + { + "filePath": "docs-shopify.dev/commands/interfaces/store-create.interface.ts", + "syntaxKind": "PropertySignature", + "name": "-n, --name ", + "value": "string", + "description": "The name of the store.", + "isOptional": true, + "environmentValue": "SHOPIFY_FLAG_STORE_NAME" + } + ], + "value": "export interface storecreate {\n /**\n * The country code for the store (e.g., US, CA, GB).\n * @environment SHOPIFY_FLAG_STORE_COUNTRY\n */\n '-c, --country '?: string\n\n /**\n * Create a development store instead of a trial store.\n * @environment SHOPIFY_FLAG_STORE_DEV\n */\n '--dev'?: ''\n\n /**\n * Output the result as JSON. Automatically disables color output.\n * @environment SHOPIFY_FLAG_JSON\n */\n '-j, --json'?: ''\n\n /**\n * The name of the store.\n * @environment SHOPIFY_FLAG_STORE_NAME\n */\n '-n, --name '?: string\n\n /**\n * Disable color output.\n * @environment SHOPIFY_FLAG_NO_COLOR\n */\n '--no-color'?: ''\n\n /**\n * The custom myshopify.com subdomain for the store.\n * @environment SHOPIFY_FLAG_STORE_SUBDOMAIN\n */\n '--subdomain '?: 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.", diff --git a/docs-shopify.dev/generated/generated_docs_data_v2.json b/docs-shopify.dev/generated/generated_docs_data_v2.json index 399d9637bda..18874f2a190 100644 --- a/docs-shopify.dev/generated/generated_docs_data_v2.json +++ b/docs-shopify.dev/generated/generated_docs_data_v2.json @@ -4199,6 +4199,80 @@ "value": "export interface storeauth {\n /**\n * Output the result as JSON. Automatically disables color output.\n * @environment SHOPIFY_FLAG_JSON\n */\n '-j, --json'?: ''\n\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}" } }, + "storecreate": { + "docs-shopify.dev/commands/interfaces/store-create.interface.ts": { + "filePath": "docs-shopify.dev/commands/interfaces/store-create.interface.ts", + "name": "storecreate", + "description": "The following flags are available for the `store create` command:", + "isPublicDocs": true, + "members": [ + { + "filePath": "docs-shopify.dev/commands/interfaces/store-create.interface.ts", + "syntaxKind": "PropertySignature", + "name": "--dev", + "value": "''", + "description": "Create a development store instead of a trial store.", + "isOptional": true, + "environmentValue": "SHOPIFY_FLAG_STORE_DEV" + }, + { + "filePath": "docs-shopify.dev/commands/interfaces/store-create.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-create.interface.ts", + "syntaxKind": "PropertySignature", + "name": "--subdomain ", + "value": "string", + "description": "The custom myshopify.com subdomain for the store.", + "isOptional": true, + "environmentValue": "SHOPIFY_FLAG_STORE_SUBDOMAIN" + }, + { + "filePath": "docs-shopify.dev/commands/interfaces/store-create.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-create.interface.ts", + "syntaxKind": "PropertySignature", + "name": "-c, --country ", + "value": "string", + "description": "The country code for the store (e.g., US, CA, GB).", + "isOptional": true, + "environmentValue": "SHOPIFY_FLAG_STORE_COUNTRY" + }, + { + "filePath": "docs-shopify.dev/commands/interfaces/store-create.interface.ts", + "syntaxKind": "PropertySignature", + "name": "-j, --json", + "value": "''", + "description": "Output the result as JSON. Automatically disables color output.", + "isOptional": true, + "environmentValue": "SHOPIFY_FLAG_JSON" + }, + { + "filePath": "docs-shopify.dev/commands/interfaces/store-create.interface.ts", + "syntaxKind": "PropertySignature", + "name": "-n, --name ", + "value": "string", + "description": "The name of the store.", + "isOptional": true, + "environmentValue": "SHOPIFY_FLAG_STORE_NAME" + } + ], + "value": "export interface storecreate {\n /**\n * The country code for the store (e.g., US, CA, GB).\n * @environment SHOPIFY_FLAG_STORE_COUNTRY\n */\n '-c, --country '?: string\n\n /**\n * Create a development store instead of a trial store.\n * @environment SHOPIFY_FLAG_STORE_DEV\n */\n '--dev'?: ''\n\n /**\n * Output the result as JSON. Automatically disables color output.\n * @environment SHOPIFY_FLAG_JSON\n */\n '-j, --json'?: ''\n\n /**\n * The name of the store.\n * @environment SHOPIFY_FLAG_STORE_NAME\n */\n '-n, --name '?: string\n\n /**\n * Disable color output.\n * @environment SHOPIFY_FLAG_NO_COLOR\n */\n '--no-color'?: ''\n\n /**\n * The custom myshopify.com subdomain for the store.\n * @environment SHOPIFY_FLAG_STORE_SUBDOMAIN\n */\n '--subdomain '?: string\n\n /**\n * Increase the verbosity of the output.\n * @environment SHOPIFY_FLAG_VERBOSE\n */\n '--verbose'?: ''\n}" + } + }, "storeexecute": { "docs-shopify.dev/commands/interfaces/store-execute.interface.ts": { "filePath": "docs-shopify.dev/commands/interfaces/store-execute.interface.ts", diff --git a/packages/cli-kit/src/private/node/session.ts b/packages/cli-kit/src/private/node/session.ts index d838b529a9d..17df96739c4 100644 --- a/packages/cli-kit/src/private/node/session.ts +++ b/packages/cli-kit/src/private/node/session.ts @@ -92,6 +92,16 @@ interface BusinessPlatformAPIOAuthOptions { scopes: BusinessPlatformScope[] } +/** + * A scope supported by the Signups API. + * The Signups API uses the Identity bearer token directly (no application token exchange). + */ +export type SignupsScope = 'shop-create' +interface SignupsAPIOAuthOptions { + /** List of scopes to request permissions for. */ + scopes: SignupsScope[] +} + /** * It represents the authentication requirements and * is the input necessary to trigger the authentication @@ -103,6 +113,7 @@ export interface OAuthApplications { partnersApi?: PartnersAPIOAuthOptions businessPlatformApi?: BusinessPlatformAPIOAuthOptions appManagementApi?: AppManagementAPIOauthOptions + signupsApi?: SignupsAPIOAuthOptions } export interface OAuthSession { @@ -111,6 +122,7 @@ export interface OAuthSession { storefront?: string businessPlatform?: string appManagement?: string + identity?: string userId: string } @@ -397,6 +409,10 @@ async function tokensFor(applications: OAuthApplications, session: Session): Pro tokens.appManagement = session.applications[appId]?.accessToken } + if (applications.signupsApi) { + tokens.identity = session.identity.accessToken + } + return tokens } @@ -413,7 +429,8 @@ function getFlattenScopes(apps: OAuthApplications): string[] { const storefront = apps.storefrontRendererApi?.scopes ?? [] const businessPlatform = apps.businessPlatformApi?.scopes ?? [] const appManagement = apps.appManagementApi?.scopes ?? [] - const requestedScopes = [...admin, ...partner, ...storefront, ...businessPlatform, ...appManagement] + const signups = apps.signupsApi?.scopes ?? [] + const requestedScopes = [...admin, ...partner, ...storefront, ...businessPlatform, ...appManagement, ...signups] return allDefaultScopes(requestedScopes) } @@ -424,6 +441,9 @@ function getFlattenScopes(apps: OAuthApplications): string[] { * @returns An object containing the scopes for each application. */ function getExchangeScopes(apps: OAuthApplications): ExchangeScopes { + // Note: signupsApi is intentionally excluded here. The Signups API uses the Identity bearer + // token directly rather than an exchanged application token. Its scopes are included in + // getFlattenScopes so they appear on the Identity token, but no exchange is needed. const adminScope = apps.adminApi?.scopes ?? [] const partnerScope = apps.partnersApi?.scopes ?? [] const storefrontScopes = apps.storefrontRendererApi?.scopes ?? [] diff --git a/packages/cli-kit/src/private/node/session/scopes.test.ts b/packages/cli-kit/src/private/node/session/scopes.test.ts index 8a42421dda5..1fd10699646 100644 --- a/packages/cli-kit/src/private/node/session/scopes.test.ts +++ b/packages/cli-kit/src/private/node/session/scopes.test.ts @@ -26,6 +26,12 @@ describe('allDefaultScopes', () => { ]) }) + test('transforms shop-create scope to full URI', async () => { + const got = allDefaultScopes(['shop-create']) + + expect(got).toContain('https://api.shopify.com/auth/shop.create') + }) + test('includes App Management and Store Management', async () => { // When const got = allDefaultScopes([]) diff --git a/packages/cli-kit/src/private/node/session/scopes.ts b/packages/cli-kit/src/private/node/session/scopes.ts index 109f65f9726..32cca4b1f6b 100644 --- a/packages/cli-kit/src/private/node/session/scopes.ts +++ b/packages/cli-kit/src/private/node/session/scopes.ts @@ -81,6 +81,8 @@ function scopeTransform(scope: string): string { return 'https://api.shopify.com/auth/organization.on-demand-user-access' case 'app-management': return 'https://api.shopify.com/auth/organization.apps.manage' + case 'shop-create': + return 'https://api.shopify.com/auth/shop.create' default: return scope } diff --git a/packages/cli-kit/src/public/node/api/signups.test.ts b/packages/cli-kit/src/public/node/api/signups.test.ts new file mode 100644 index 00000000000..b2c25e58d33 --- /dev/null +++ b/packages/cli-kit/src/public/node/api/signups.test.ts @@ -0,0 +1,60 @@ +import {signupsRequest} from './signups.js' +import {graphqlRequest} from './graphql.js' +import {handleDeprecations} from './partners.js' +import {signupsFqdn} from '../context/fqdn.js' +import {beforeEach, describe, expect, test, vi} from 'vitest' + +vi.mock('./graphql.js') +vi.mock('../context/fqdn.js') + +const signupsFqdnValue = 'shopify.com' +const url = `https://${signupsFqdnValue}/services/signups/graphql` +const mockedToken = 'identity-token' + +beforeEach(() => { + vi.mocked(signupsFqdn).mockResolvedValue(signupsFqdnValue) +}) + +describe('signupsRequest', () => { + test('calls graphqlRequest with correct parameters', async () => { + vi.mocked(graphqlRequest).mockResolvedValue({storeCreate: {shopPermanentDomain: 'test.myshopify.com'}}) + const query = 'mutation StoreCreate($signup: ShopInput!) { storeCreate(signup: $signup) { shopPermanentDomain } }' + const variables = {signup: {country: 'US'}} + + await signupsRequest(query, mockedToken, variables) + + expect(graphqlRequest).toHaveBeenCalledWith({ + query, + api: 'Signups', + url, + token: mockedToken, + variables, + responseOptions: {onResponse: handleDeprecations}, + }) + }) + + test('calls graphqlRequest without variables when not provided', async () => { + vi.mocked(graphqlRequest).mockResolvedValue({}) + const query = 'query { __schema { types { name } } }' + + await signupsRequest(query, mockedToken) + + expect(graphqlRequest).toHaveBeenCalledWith({ + query, + api: 'Signups', + url, + token: mockedToken, + variables: undefined, + responseOptions: {onResponse: handleDeprecations}, + }) + }) + + test('returns the response from graphqlRequest', async () => { + const expectedResponse = {storeCreate: {shopPermanentDomain: 'new-store.myshopify.com', polling: false}} + vi.mocked(graphqlRequest).mockResolvedValue(expectedResponse) + + const result = await signupsRequest('query', mockedToken) + + expect(result).toEqual(expectedResponse) + }) +}) diff --git a/packages/cli-kit/src/public/node/api/signups.ts b/packages/cli-kit/src/public/node/api/signups.ts new file mode 100644 index 00000000000..95326e6aac9 --- /dev/null +++ b/packages/cli-kit/src/public/node/api/signups.ts @@ -0,0 +1,32 @@ +import {GraphQLVariables, graphqlRequest} from './graphql.js' +import {handleDeprecations} from './partners.js' +import {signupsFqdn} from '../context/fqdn.js' + +async function setupRequest(token: string) { + const api = 'Signups' + const fqdn = await signupsFqdn() + const url = `https://${fqdn}/services/signups/graphql` + return { + token, + api, + url, + responseOptions: {onResponse: handleDeprecations}, + } +} + +/** + * Executes a GraphQL query against the Signups API. + * Uses the Identity bearer token directly (no application token exchange). + * + * @param query - GraphQL query to execute. + * @param token - Identity access token. + * @param variables - GraphQL variables to pass to the query. + * @returns The response of the query of generic type . + */ +export async function signupsRequest(query: string, token: string, variables?: GraphQLVariables): Promise { + return graphqlRequest({ + ...(await setupRequest(token)), + query, + variables, + }) +} diff --git a/packages/cli-kit/src/public/node/context/fqdn.ts b/packages/cli-kit/src/public/node/context/fqdn.ts index 85e6036bb1f..e587654ecba 100644 --- a/packages/cli-kit/src/public/node/context/fqdn.ts +++ b/packages/cli-kit/src/public/node/context/fqdn.ts @@ -102,6 +102,22 @@ export async function businessPlatformFqdn(): Promise { } } +/** + * It returns the Signups API service we should interact with. + * + * @returns Fully-qualified domain of the Signups service we should interact with. + */ +export async function signupsFqdn(): Promise { + const environment = serviceEnvironment() + const productionFqdn = 'shopify.com' + switch (environment) { + case 'local': + return new DevServerCore().host('shopify') + default: + return productionFqdn + } +} + /** * It returns the Identity service we should interact with. * diff --git a/packages/cli-kit/src/public/node/session.ts b/packages/cli-kit/src/public/node/session.ts index 73a3f28862d..b3b9244465d 100644 --- a/packages/cli-kit/src/public/node/session.ts +++ b/packages/cli-kit/src/public/node/session.ts @@ -15,6 +15,7 @@ import { BusinessPlatformScope, EnsureAuthenticatedAdditionalOptions, PartnersAPIScope, + SignupsScope, StorefrontRendererScope, ensureAuthenticated, setLastSeenAuthMethod, @@ -274,6 +275,30 @@ ${outputToken.json(scopes)} return tokens.businessPlatform } +/** + * Ensure that we have a valid session to access the Signups API. + * The Signups API uses the Identity bearer token directly (no application token exchange). + * + * @param scopes - Optional array of extra scopes to authenticate with. + * @param env - Optional environment variables to use. + * @param options - Optional extra options to use. + * @returns The Identity access token and user ID. + */ +export async function ensureAuthenticatedSignups( + scopes: SignupsScope[] = ['shop-create'], + env = process.env, + options: EnsureAuthenticatedAdditionalOptions = {}, +): Promise<{token: string; userId: string}> { + outputDebug(outputContent`Ensuring that the user is authenticated with the Signups API with the following scopes: +${outputToken.json(scopes)} +`) + const tokens = await ensureAuthenticated({signupsApi: {scopes}}, env, options) + if (!tokens.identity) { + throw new BugError('No identity token found after ensuring authenticated') + } + return {token: tokens.identity, userId: tokens.userId} +} + /** * Logout from Shopify. * diff --git a/packages/cli/README.md b/packages/cli/README.md index 9a25dfeb007..37c025adec9 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -74,6 +74,7 @@ * [`shopify plugins update`](#shopify-plugins-update) * [`shopify search [query]`](#shopify-search-query) * [`shopify store auth`](#shopify-store-auth) +* [`shopify store create`](#shopify-store-create) * [`shopify store execute`](#shopify-store-execute) * [`shopify theme check`](#shopify-theme-check) * [`shopify theme console`](#shopify-theme-console) @@ -2081,6 +2082,41 @@ EXAMPLES $ shopify store auth --store shop.myshopify.com --scopes read_products,write_products --json ``` +## `shopify store create` + +Create a new Shopify store. + +``` +USAGE + $ shopify store create [-c ] [--dev] [-j] [-n ] [--no-color] [--subdomain ] [--verbose] + +FLAGS + -c, --country= [default: US, env: SHOPIFY_FLAG_STORE_COUNTRY] The country code for the store (e.g., US, CA, + GB). + -j, --json [env: SHOPIFY_FLAG_JSON] Output the result as JSON. Automatically disables color output. + -n, --name= [env: SHOPIFY_FLAG_STORE_NAME] The name of the store. + --dev [env: SHOPIFY_FLAG_STORE_DEV] Create a development store instead of a trial store. + --no-color [env: SHOPIFY_FLAG_NO_COLOR] Disable color output. + --subdomain= [env: SHOPIFY_FLAG_STORE_SUBDOMAIN] The custom myshopify.com subdomain for the store. + --verbose [env: SHOPIFY_FLAG_VERBOSE] Increase the verbosity of the output. + +DESCRIPTION + Create a new Shopify store. + + Creates a new Shopify store associated with your account. + + By default, creates a trial store. Use `--dev` to create a development store instead. + +EXAMPLES + $ shopify store create + + $ shopify store create --name "My Store" --country US + + $ shopify store create --name "My Dev Store" --dev + + $ shopify store create --name "My Store" --json +``` + ## `shopify store execute` Execute GraphQL queries and mutations on a store. diff --git a/packages/cli/oclif.manifest.json b/packages/cli/oclif.manifest.json index 82dbca3b896..bde549f286f 100644 --- a/packages/cli/oclif.manifest.json +++ b/packages/cli/oclif.manifest.json @@ -5802,6 +5802,91 @@ "strict": true, "summary": "Authenticate an app against a store for store commands." }, + "store:create": { + "aliases": [ + ], + "args": { + }, + "description": "Creates a new Shopify store associated with your account.\n\nBy default, creates a trial store. Use `--dev` to create a development store instead.", + "descriptionWithMarkdown": "Creates a new Shopify store associated with your account.\n\nBy default, creates a trial store. Use `--dev` to create a development store instead.", + "enableJsonFlag": false, + "examples": [ + "<%= config.bin %> <%= command.id %>", + "<%= config.bin %> <%= command.id %> --name \"My Store\" --country US", + "<%= config.bin %> <%= command.id %> --name \"My Dev Store\" --dev", + "<%= config.bin %> <%= command.id %> --name \"My Store\" --json" + ], + "flags": { + "country": { + "char": "c", + "default": "US", + "description": "The country code for the store (e.g., US, CA, GB).", + "env": "SHOPIFY_FLAG_STORE_COUNTRY", + "hasDynamicHelp": false, + "multiple": false, + "name": "country", + "type": "option" + }, + "dev": { + "allowNo": false, + "description": "Create a development store instead of a trial store.", + "env": "SHOPIFY_FLAG_STORE_DEV", + "name": "dev", + "type": "boolean" + }, + "json": { + "allowNo": false, + "char": "j", + "description": "Output the result as JSON. Automatically disables color output.", + "env": "SHOPIFY_FLAG_JSON", + "hidden": false, + "name": "json", + "type": "boolean" + }, + "name": { + "char": "n", + "description": "The name of the store.", + "env": "SHOPIFY_FLAG_STORE_NAME", + "hasDynamicHelp": false, + "multiple": false, + "name": "name", + "type": "option" + }, + "no-color": { + "allowNo": false, + "description": "Disable color output.", + "env": "SHOPIFY_FLAG_NO_COLOR", + "hidden": false, + "name": "no-color", + "type": "boolean" + }, + "subdomain": { + "description": "The custom myshopify.com subdomain for the store.", + "env": "SHOPIFY_FLAG_STORE_SUBDOMAIN", + "hasDynamicHelp": false, + "multiple": false, + "name": "subdomain", + "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:create", + "pluginAlias": "@shopify/cli", + "pluginName": "@shopify/cli", + "pluginType": "core", + "strict": true, + "summary": "Create a new Shopify store." + }, "store:execute": { "aliases": [ ], diff --git a/packages/cli/src/cli/commands/store/create.test.ts b/packages/cli/src/cli/commands/store/create.test.ts new file mode 100644 index 00000000000..5a55e4078a5 --- /dev/null +++ b/packages/cli/src/cli/commands/store/create.test.ts @@ -0,0 +1,95 @@ +import StoreCreate from './create.js' +import {createStore} from '../../services/store/create/index.js' +import {outputResult} from '@shopify/cli-kit/node/output' +import {renderSuccess, renderInfo} from '@shopify/cli-kit/node/ui' +import {describe, expect, test, vi} from 'vitest' + +vi.mock('../../services/store/create/index.js') +vi.mock('@shopify/cli-kit/node/output') +vi.mock('@shopify/cli-kit/node/ui') + +describe('store create command', () => { + test('passes parsed flags through to the create service with defaults', async () => { + vi.mocked(createStore).mockResolvedValue({ + shopPermanentDomain: 'my-store.myshopify.com', + polling: false, + shopLoginUrl: null, + }) + + await StoreCreate.run([]) + + expect(createStore).toHaveBeenCalledWith({ + name: undefined, + subdomain: undefined, + country: 'US', + dev: false, + }) + }) + + test('passes all provided flags through to the create service', async () => { + vi.mocked(createStore).mockResolvedValue({ + shopPermanentDomain: 'custom.myshopify.com', + polling: false, + shopLoginUrl: null, + }) + + await StoreCreate.run(['--name', 'Custom Store', '--subdomain', 'custom', '--country', 'CA', '--dev']) + + expect(createStore).toHaveBeenCalledWith({ + name: 'Custom Store', + subdomain: 'custom', + country: 'CA', + dev: true, + }) + }) + + test('outputs JSON via outputResult when --json is provided', async () => { + const result = { + shopPermanentDomain: 'my-store.myshopify.com', + polling: false, + shopLoginUrl: null, + } + vi.mocked(createStore).mockResolvedValue(result) + + await StoreCreate.run(['--json']) + + expect(outputResult).toHaveBeenCalledWith(JSON.stringify(result, null, 2)) + expect(renderSuccess).not.toHaveBeenCalled() + }) + + test('renders success message with store domain when not using --json', async () => { + vi.mocked(createStore).mockResolvedValue({ + shopPermanentDomain: 'my-store.myshopify.com', + polling: false, + shopLoginUrl: 'https://my-store.myshopify.com/admin', + }) + + await StoreCreate.run([]) + + expect(renderSuccess).toHaveBeenCalledWith(expect.objectContaining({headline: 'Store created successfully.'})) + expect(renderInfo).not.toHaveBeenCalled() + }) + + test('renders polling info banner when store is still configuring', async () => { + vi.mocked(createStore).mockResolvedValue({ + shopPermanentDomain: 'my-store.myshopify.com', + polling: true, + shopLoginUrl: null, + }) + + await StoreCreate.run([]) + + expect(renderSuccess).toHaveBeenCalled() + expect(renderInfo).toHaveBeenCalledWith( + expect.objectContaining({body: expect.stringContaining('still being configured')}), + ) + }) + + test('defines the expected flags', () => { + expect(StoreCreate.flags.name).toBeDefined() + expect(StoreCreate.flags.subdomain).toBeDefined() + expect(StoreCreate.flags.country).toBeDefined() + expect(StoreCreate.flags.dev).toBeDefined() + expect(StoreCreate.flags.json).toBeDefined() + }) +}) diff --git a/packages/cli/src/cli/commands/store/create.ts b/packages/cli/src/cli/commands/store/create.ts new file mode 100644 index 00000000000..94d3578c590 --- /dev/null +++ b/packages/cli/src/cli/commands/store/create.ts @@ -0,0 +1,77 @@ +import {createStore} from '../../services/store/create/index.js' +import Command from '@shopify/cli-kit/node/base-command' +import {globalFlags, jsonFlag} from '@shopify/cli-kit/node/cli' +import {outputResult} from '@shopify/cli-kit/node/output' +import {renderSuccess, renderInfo} from '@shopify/cli-kit/node/ui' +import {Flags} from '@oclif/core' + +export default class StoreCreate extends Command { + static summary = 'Create a new Shopify store.' + + static descriptionWithMarkdown = `Creates a new Shopify store associated with your account. + +By default, creates a trial store. Use \`--dev\` to create a development store instead.` + + static description = this.descriptionWithoutMarkdown() + + static examples = [ + '<%= config.bin %> <%= command.id %>', + '<%= config.bin %> <%= command.id %> --name "My Store" --country US', + '<%= config.bin %> <%= command.id %> --name "My Dev Store" --dev', + '<%= config.bin %> <%= command.id %> --name "My Store" --json', + ] + + static flags = { + ...globalFlags, + ...jsonFlag, + name: Flags.string({ + char: 'n', + description: 'The name of the store.', + env: 'SHOPIFY_FLAG_STORE_NAME', + }), + subdomain: Flags.string({ + description: 'The custom myshopify.com subdomain for the store.', + env: 'SHOPIFY_FLAG_STORE_SUBDOMAIN', + }), + country: Flags.string({ + char: 'c', + description: 'The country code for the store (e.g., US, CA, GB).', + env: 'SHOPIFY_FLAG_STORE_COUNTRY', + default: 'US', + }), + dev: Flags.boolean({ + description: 'Create a development store instead of a trial store.', + env: 'SHOPIFY_FLAG_STORE_DEV', + default: false, + }), + } + + async run(): Promise { + const {flags} = await this.parse(StoreCreate) + + const result = await createStore({ + name: flags.name, + subdomain: flags.subdomain, + country: flags.country, + dev: flags.dev, + }) + + if (flags.json) { + outputResult(JSON.stringify(result, null, 2)) + return + } + + renderSuccess({ + headline: 'Store created successfully.', + body: `Domain: ${result.shopPermanentDomain}`, + nextSteps: [ + ...(result.shopLoginUrl ? [`Open your store: ${result.shopLoginUrl}`] : []), + `Run ${['shopify', 'app', 'dev', '--store', result.shopPermanentDomain].join(' ')} to start developing`, + ], + }) + + if (result.polling) { + renderInfo({body: 'Your store is still being configured. It may take a moment before it is fully ready.'}) + } + } +} diff --git a/packages/cli/src/cli/services/store/create/index.test.ts b/packages/cli/src/cli/services/store/create/index.test.ts new file mode 100644 index 00000000000..87069f2baa8 --- /dev/null +++ b/packages/cli/src/cli/services/store/create/index.test.ts @@ -0,0 +1,230 @@ +import {createStore} from './index.js' +import {signupsRequest} from '@shopify/cli-kit/node/api/signups' +import {ensureAuthenticatedSignups} from '@shopify/cli-kit/node/session' +import {beforeEach, describe, expect, test, vi} from 'vitest' + +vi.mock('@shopify/cli-kit/node/api/signups') +vi.mock('@shopify/cli-kit/node/session') + +describe('createStore', () => { + beforeEach(() => { + vi.mocked(ensureAuthenticatedSignups).mockResolvedValue({token: 'test-token', userId: 'user-1'}) + }) + + describe('trial store (dev: false)', () => { + test('creates a trial store with minimal input and returns the result', async () => { + vi.mocked(signupsRequest).mockResolvedValue({ + storeCreate: { + shopPermanentDomain: 'my-store.myshopify.com', + polling: false, + shopLoginUrl: 'https://my-store.myshopify.com/admin', + userErrors: [], + }, + }) + + const result = await createStore({country: 'US', dev: false}) + + expect(signupsRequest).toHaveBeenCalledWith(expect.stringContaining('StoreCreate'), 'test-token', { + signup: {country: 'US'}, + }) + expect(result).toEqual({ + shopPermanentDomain: 'my-store.myshopify.com', + polling: false, + shopLoginUrl: 'https://my-store.myshopify.com/admin', + }) + }) + + test('passes name and subdomain to the StoreCreate mutation', async () => { + vi.mocked(signupsRequest).mockResolvedValue({ + storeCreate: { + shopPermanentDomain: 'my-custom.myshopify.com', + polling: false, + shopLoginUrl: null, + userErrors: [], + }, + }) + + const result = await createStore({name: 'My Custom Store', subdomain: 'my-custom', country: 'CA', dev: false}) + + expect(signupsRequest).toHaveBeenCalledWith(expect.stringContaining('StoreCreate'), 'test-token', { + signup: {shopName: 'My Custom Store', subdomain: 'my-custom', country: 'CA'}, + }) + expect(result.shopPermanentDomain).toBe('my-custom.myshopify.com') + }) + + test('returns polling as true when the store is still being configured', async () => { + vi.mocked(signupsRequest).mockResolvedValue({ + storeCreate: { + shopPermanentDomain: 'async-store.myshopify.com', + polling: true, + shopLoginUrl: null, + userErrors: [], + }, + }) + + const result = await createStore({country: 'US', dev: false}) + + expect(result.polling).toBe(true) + }) + + test('coerces null polling to false', async () => { + vi.mocked(signupsRequest).mockResolvedValue({ + storeCreate: { + shopPermanentDomain: 'store.myshopify.com', + polling: null, + shopLoginUrl: null, + userErrors: [], + }, + }) + + const result = await createStore({country: 'US', dev: false}) + + expect(result.polling).toBe(false) + }) + + test('throws an AbortError with field context when the API returns user errors', async () => { + vi.mocked(signupsRequest).mockResolvedValue({ + storeCreate: { + shopPermanentDomain: null, + polling: null, + shopLoginUrl: null, + userErrors: [{field: ['signup', 'subdomain'], message: 'Subdomain is already taken'}], + }, + }) + + await expect(createStore({subdomain: 'taken', country: 'US', dev: false})).rejects.toThrow( + 'signup.subdomain: Subdomain is already taken', + ) + }) + + test('throws an AbortError joining multiple user errors', async () => { + vi.mocked(signupsRequest).mockResolvedValue({ + storeCreate: { + shopPermanentDomain: null, + polling: null, + shopLoginUrl: null, + userErrors: [ + {field: ['signup', 'subdomain'], message: 'Subdomain is already taken'}, + {field: null, message: 'Account limit reached'}, + ], + }, + }) + + await expect(createStore({country: 'US', dev: false})).rejects.toThrow( + 'signup.subdomain: Subdomain is already taken\nAccount limit reached', + ) + }) + + test('throws an AbortError when no domain is returned despite no user errors', async () => { + vi.mocked(signupsRequest).mockResolvedValue({ + storeCreate: {shopPermanentDomain: null, polling: null, shopLoginUrl: null, userErrors: []}, + }) + + await expect(createStore({country: 'US', dev: false})).rejects.toThrow('no domain returned') + }) + + test('throws an AbortError when storeCreate response is null', async () => { + vi.mocked(signupsRequest).mockResolvedValue({storeCreate: null}) + + await expect(createStore({country: 'US', dev: false})).rejects.toThrow('Unexpected response from Signups API') + }) + }) + + describe('development store (dev: true)', () => { + test('calls AppDevelopmentStoreCreate mutation with correct variables', async () => { + vi.mocked(signupsRequest).mockResolvedValue({ + appDevelopmentStoreCreate: { + permanentDomain: 'dev-store.myshopify.com', + loginUrl: 'https://admin.shopify.com/login?returnPath=/store/dev-store', + shopId: 'gid://shopify/Shop/1', + userErrors: [], + }, + }) + + const result = await createStore({name: 'Dev Store', country: 'US', dev: true}) + + expect(signupsRequest).toHaveBeenCalledWith(expect.stringContaining('AppDevelopmentStoreCreate'), 'test-token', { + shopInformation: { + shopName: 'Dev Store', + country: 'US', + priceLookupKey: 'BASIC_APP_DEVELOPMENT', + ipAddress: expect.any(String), + userAgent: 'Shopify CLI', + }, + }) + expect(result).toEqual({ + shopPermanentDomain: 'dev-store.myshopify.com', + polling: false, + shopLoginUrl: 'https://admin.shopify.com/login?returnPath=/store/dev-store', + }) + }) + + test('throws an AbortError when the API returns user errors for dev store', async () => { + vi.mocked(signupsRequest).mockResolvedValue({ + appDevelopmentStoreCreate: { + permanentDomain: null, + loginUrl: null, + shopId: null, + userErrors: [{field: ['shop_information', 'country'], message: 'Invalid country code'}], + }, + }) + + await expect(createStore({country: 'XX', dev: true})).rejects.toThrow( + 'shop_information.country: Invalid country code', + ) + }) + + test('throws an AbortError when no domain is returned for dev store', async () => { + vi.mocked(signupsRequest).mockResolvedValue({ + appDevelopmentStoreCreate: { + permanentDomain: null, + loginUrl: null, + shopId: null, + userErrors: [], + }, + }) + + await expect(createStore({country: 'US', dev: true})).rejects.toThrow( + 'Development store creation failed: no domain returned', + ) + }) + + test('defaults shopName to "Dev Store" when no name is provided', async () => { + vi.mocked(signupsRequest).mockResolvedValue({ + appDevelopmentStoreCreate: { + permanentDomain: 'random-domain.myshopify.com', + loginUrl: null, + shopId: 'gid://shopify/Shop/2', + userErrors: [], + }, + }) + + await createStore({country: 'US', dev: true}) + + expect(signupsRequest).toHaveBeenCalledWith( + expect.stringContaining('AppDevelopmentStoreCreate'), + 'test-token', + expect.objectContaining({shopInformation: expect.objectContaining({shopName: 'Dev Store'})}), + ) + }) + + test('throws an AbortError when appDevelopmentStoreCreate response is null', async () => { + vi.mocked(signupsRequest).mockResolvedValue({appDevelopmentStoreCreate: null}) + + await expect(createStore({country: 'US', dev: true})).rejects.toThrow('Unexpected response from Signups API') + }) + }) + + test('throws an AbortError when --subdomain is used with --dev', async () => { + await expect(createStore({subdomain: 'my-store', country: 'US', dev: true})).rejects.toThrow( + 'The --subdomain flag is not supported when creating a development store.', + ) + expect(ensureAuthenticatedSignups).not.toHaveBeenCalled() + }) + + test('propagates authentication failures from ensureAuthenticatedSignups', async () => { + vi.mocked(ensureAuthenticatedSignups).mockRejectedValue(new Error('Authentication required')) + + await expect(createStore({country: 'US', dev: false})).rejects.toThrow('Authentication required') + }) +}) diff --git a/packages/cli/src/cli/services/store/create/index.ts b/packages/cli/src/cli/services/store/create/index.ts new file mode 100644 index 00000000000..a447cc629c4 --- /dev/null +++ b/packages/cli/src/cli/services/store/create/index.ts @@ -0,0 +1,180 @@ +import {signupsRequest} from '@shopify/cli-kit/node/api/signups' +import {ensureAuthenticatedSignups} from '@shopify/cli-kit/node/session' +import {AbortError} from '@shopify/cli-kit/node/error' +import {outputContent, outputDebug, outputToken} from '@shopify/cli-kit/node/output' +import {networkInterfaces} from 'os' + +// eslint-disable-next-line @shopify/cli/no-inline-graphql +const StoreCreateMutation = ` + mutation StoreCreate($signup: ShopInput!) { + storeCreate(signup: $signup) { + shopPermanentDomain + polling + shopLoginUrl + userErrors { field message } + } + } +` + +// eslint-disable-next-line @shopify/cli/no-inline-graphql +const AppDevelopmentStoreCreateMutation = ` + mutation AppDevelopmentStoreCreate($shopInformation: AppDevelopmentStoreInput!) { + appDevelopmentStoreCreate(shopInformation: $shopInformation) { + permanentDomain + loginUrl + shopId + userErrors { field message } + } + } +` + +export interface StoreCreateInput { + name?: string + subdomain?: string + country: string + dev: boolean +} + +export interface StoreCreateResult { + shopPermanentDomain: string + polling: boolean + shopLoginUrl: string | null +} + +interface StoreCreateUserError { + field: string[] | null + message: string +} + +export async function createStore(input: StoreCreateInput): Promise { + if (input.dev && input.subdomain) { + throw new AbortError( + 'The --subdomain flag is not supported when creating a development store.', + 'Remove --subdomain or remove --dev.', + ) + } + + const {token} = await ensureAuthenticatedSignups() + + if (input.dev) { + return createDevStore(input, token) + } + return createTrialStore(input, token) +} + +async function createTrialStore(input: StoreCreateInput, token: string): Promise { + const variables = { + signup: { + country: input.country, + ...(input.name ? {shopName: input.name} : {}), + ...(input.subdomain ? {subdomain: input.subdomain} : {}), + }, + } + + outputDebug(outputContent`Calling Signups API StoreCreate with variables: +${outputToken.json(variables)} +`) + + const result = await signupsRequest<{storeCreate: StoreCreateMutationResult | null}>( + StoreCreateMutation, + token, + variables, + ) + + if (!result.storeCreate) { + throw new AbortError('Unexpected response from Signups API: storeCreate was null.') + } + + throwOnUserErrors(result.storeCreate.userErrors) + + if (!result.storeCreate.shopPermanentDomain) { + throw new AbortError('Store creation failed: no domain returned.') + } + + outputDebug( + outputContent`StoreCreate response: domain=${outputToken.raw(result.storeCreate.shopPermanentDomain)} polling=${outputToken.raw(String(result.storeCreate.polling))}`, + ) + + return { + shopPermanentDomain: result.storeCreate.shopPermanentDomain, + polling: result.storeCreate.polling ?? false, + shopLoginUrl: result.storeCreate.shopLoginUrl, + } +} + +async function createDevStore(input: StoreCreateInput, token: string): Promise { + const variables = { + shopInformation: { + shopName: input.name ?? 'Dev Store', + country: input.country, + priceLookupKey: 'BASIC_APP_DEVELOPMENT', + ipAddress: localIpAddress(), + userAgent: 'Shopify CLI', + }, + } + + outputDebug(outputContent`Calling Signups API AppDevelopmentStoreCreate with variables: +${outputToken.json(variables)} +`) + + const result = await signupsRequest<{appDevelopmentStoreCreate: AppDevStoreCreateMutationResult | null}>( + AppDevelopmentStoreCreateMutation, + token, + variables, + ) + + if (!result.appDevelopmentStoreCreate) { + throw new AbortError('Unexpected response from Signups API: appDevelopmentStoreCreate was null.') + } + + throwOnUserErrors(result.appDevelopmentStoreCreate.userErrors) + + if (!result.appDevelopmentStoreCreate.permanentDomain) { + throw new AbortError('Development store creation failed: no domain returned.') + } + + outputDebug( + outputContent`AppDevelopmentStoreCreate response: domain=${outputToken.raw(result.appDevelopmentStoreCreate.permanentDomain)}`, + ) + + return { + shopPermanentDomain: result.appDevelopmentStoreCreate.permanentDomain, + polling: false, + shopLoginUrl: result.appDevelopmentStoreCreate.loginUrl, + } +} + +function throwOnUserErrors(userErrors: StoreCreateUserError[]): void { + if (userErrors.length === 0) return + const messages = userErrors + .map((userError) => (userError.field ? `${userError.field.join('.')}: ${userError.message}` : userError.message)) + .join('\n') + throw new AbortError(`Store creation failed:\n${messages}`) +} + +function localIpAddress(): string { + const interfaces = networkInterfaces() + for (const addresses of Object.values(interfaces)) { + if (!addresses) continue + for (const addr of addresses) { + if (addr.family === 'IPv4' && !addr.internal) { + return addr.address + } + } + } + return '127.0.0.1' +} + +interface StoreCreateMutationResult { + shopPermanentDomain: string | null + polling: boolean | null + shopLoginUrl: string | null + userErrors: StoreCreateUserError[] +} + +interface AppDevStoreCreateMutationResult { + permanentDomain: string | null + loginUrl: string | null + shopId: string | null + userErrors: StoreCreateUserError[] +} diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index 429836541d0..c8d3a28202e 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -16,6 +16,7 @@ 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 StoreCreate from './cli/commands/store/create.js' import StoreExecute from './cli/commands/store/execute.js' import {createGlobalProxyAgent} from 'global-agent' import ThemeCommands from '@shopify/theme' @@ -153,6 +154,7 @@ export const COMMANDS: any = { 'notifications:generate': Generate, 'cache:clear': ClearCache, 'store:auth': StoreAuth, + 'store:create': StoreCreate, 'store:execute': StoreExecute, } diff --git a/packages/e2e/data/snapshots/commands.txt b/packages/e2e/data/snapshots/commands.txt index 6aa49ab9ee7..5ff9fd0cfeb 100644 --- a/packages/e2e/data/snapshots/commands.txt +++ b/packages/e2e/data/snapshots/commands.txt @@ -90,6 +90,7 @@ ├─ search ├─ store │ ├─ auth +│ ├─ create │ └─ execute ├─ theme │ ├─ check