From 4ca00ba2c7d3f8681dec294092bb8cf1f358b4c2 Mon Sep 17 00:00:00 2001 From: Jeremy Gayed Date: Fri, 17 Apr 2026 15:50:20 -0400 Subject: [PATCH] Add `shopify store create trial` command Adds a programmatic way to create a Shopify trial store from the CLI, backed by the Signups API's StoreCreate mutation. - Registers `store:create:trial` in @shopify/store with flags for --name, --subdomain, --country (default US), and --json. - Adds cli-kit Signups foundation: `shop-create` OAuth scope, `signupsApi` application + `ensureAuthenticatedSignups`, `signupsFqdn()`, and `signupsRequest` API helper. - Regenerates oclif manifest, README, dev docs, and e2e snapshot. Supersedes #7218. --- .../examples/store-create-trial.example.sh | 1 + .../store-create-trial.interface.ts | 42 ++++++ .../commands/store-create-trial.doc.ts | 34 +++++ packages/cli-kit/src/private/node/session.ts | 22 ++- .../src/private/node/session/scopes.test.ts | 6 + .../src/private/node/session/scopes.ts | 2 + .../src/public/node/api/signups.test.ts | 60 ++++++++ .../cli-kit/src/public/node/api/signups.ts | 32 +++++ .../src/public/node/context/fqdn.test.ts | 25 ++++ .../cli-kit/src/public/node/context/fqdn.ts | 16 +++ .../cli-kit/src/public/node/session.test.ts | 47 ++++++ packages/cli-kit/src/public/node/session.ts | 25 ++++ packages/cli/README.md | 31 ++++ packages/cli/oclif.manifest.json | 77 ++++++++++ packages/e2e/data/snapshots/commands.txt | 2 + .../cli/commands/store/create/trial.test.ts | 92 ++++++++++++ .../src/cli/commands/store/create/trial.ts | 68 +++++++++ .../cli/services/store/create/index.test.ts | 136 ++++++++++++++++++ .../src/cli/services/store/create/index.ts | 90 ++++++++++++ packages/store/src/index.ts | 2 + 20 files changed, 809 insertions(+), 1 deletion(-) create mode 100644 docs-shopify.dev/commands/examples/store-create-trial.example.sh create mode 100644 docs-shopify.dev/commands/interfaces/store-create-trial.interface.ts create mode 100644 docs-shopify.dev/commands/store-create-trial.doc.ts create mode 100644 packages/cli-kit/src/public/node/api/signups.test.ts create mode 100644 packages/cli-kit/src/public/node/api/signups.ts create mode 100644 packages/store/src/cli/commands/store/create/trial.test.ts create mode 100644 packages/store/src/cli/commands/store/create/trial.ts create mode 100644 packages/store/src/cli/services/store/create/index.test.ts create mode 100644 packages/store/src/cli/services/store/create/index.ts diff --git a/docs-shopify.dev/commands/examples/store-create-trial.example.sh b/docs-shopify.dev/commands/examples/store-create-trial.example.sh new file mode 100644 index 00000000000..d8dbcbd159d --- /dev/null +++ b/docs-shopify.dev/commands/examples/store-create-trial.example.sh @@ -0,0 +1 @@ +shopify store create trial [flags] \ No newline at end of file diff --git a/docs-shopify.dev/commands/interfaces/store-create-trial.interface.ts b/docs-shopify.dev/commands/interfaces/store-create-trial.interface.ts new file mode 100644 index 00000000000..c7e55b647e0 --- /dev/null +++ b/docs-shopify.dev/commands/interfaces/store-create-trial.interface.ts @@ -0,0 +1,42 @@ +// This is an autogenerated file. Don't edit this file manually. +/** + * The following flags are available for the `store create trial` command: + * @publicDocs + */ +export interface storecreatetrial { + /** + * The country code for the store (e.g., US, CA, GB). + * @environment SHOPIFY_FLAG_STORE_COUNTRY + */ + '-c, --country '?: string + + /** + * 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-trial.doc.ts b/docs-shopify.dev/commands/store-create-trial.doc.ts new file mode 100644 index 00000000000..7eeaaa96801 --- /dev/null +++ b/docs-shopify.dev/commands/store-create-trial.doc.ts @@ -0,0 +1,34 @@ +// This is an autogenerated file. Don't edit this file manually. +import {ReferenceEntityTemplateSchema} from '@shopify/generate-docs' + +const data: ReferenceEntityTemplateSchema = { + name: 'store create trial', + description: `Creates a new Shopify trial store associated with your account.`, + overviewPreviewDescription: `Create a new Shopify trial store.`, + type: 'command', + isVisualComponent: false, + defaultExample: { + codeblock: { + tabs: [ + { + title: 'store create trial', + code: './examples/store-create-trial.example.sh', + language: 'bash', + }, + ], + title: 'store create trial', + }, + }, + definitions: [ + { + title: 'Flags', + description: 'The following flags are available for the `store create trial` command:', + type: 'storecreatetrial', + }, + ], + category: 'store', + related: [ + ], +} + +export default data \ No newline at end of file diff --git a/packages/cli-kit/src/private/node/session.ts b/packages/cli-kit/src/private/node/session.ts index 3b750d1cdcb..86ed0eb0957 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 } @@ -399,6 +411,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 } @@ -415,7 +431,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) } @@ -426,6 +443,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.test.ts b/packages/cli-kit/src/public/node/context/fqdn.test.ts index 72f51b35dda..cc2a2d0c6a0 100644 --- a/packages/cli-kit/src/public/node/context/fqdn.test.ts +++ b/packages/cli-kit/src/public/node/context/fqdn.test.ts @@ -6,6 +6,7 @@ import { businessPlatformFqdn, appDevFqdn, adminFqdn, + signupsFqdn, } from './fqdn.js' import {Environment, serviceEnvironment} from '../../../private/node/context/service.js' import {expect, describe, test, vi} from 'vitest' @@ -150,6 +151,30 @@ describe('identity', () => { }) }) +describe('signupsFqdn', () => { + test('returns the local fqdn when the environment is local', async () => { + // Given + vi.mocked(serviceEnvironment).mockReturnValue(Environment.Local) + + // When + const got = await signupsFqdn() + + // Then + expect(got).toEqual('shopify.myshopify.io') + }) + + test('returns the production fqdn when the environment is production', async () => { + // Given + vi.mocked(serviceEnvironment).mockReturnValue(Environment.Production) + + // When + const got = await signupsFqdn() + + // Then + expect(got).toEqual('shopify.com') + }) +}) + describe('adminFqdn', () => { test('returns the local fqdn when the environment is local', async () => { // Given 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.test.ts b/packages/cli-kit/src/public/node/session.test.ts index c7ea4b627d5..25999cb8d78 100644 --- a/packages/cli-kit/src/public/node/session.test.ts +++ b/packages/cli-kit/src/public/node/session.test.ts @@ -4,6 +4,7 @@ import { ensureAuthenticatedAppManagementAndBusinessPlatform, ensureAuthenticatedBusinessPlatform, ensureAuthenticatedPartners, + ensureAuthenticatedSignups, ensureAuthenticatedStorefront, ensureAuthenticatedThemes, } from './session.js' @@ -220,6 +221,52 @@ describe('ensureAuthenticatedBusinessPlatform', () => { }) }) +describe('ensureAuthenticatedSignups', () => { + test('returns the identity token and userId when success', async () => { + // Given + vi.mocked(ensureAuthenticated).mockResolvedValueOnce({identity: 'identity_token', userId: '1234-5678'}) + + // When + const got = await ensureAuthenticatedSignups() + + // Then + expect(got).toEqual({token: 'identity_token', userId: '1234-5678'}) + }) + + test('requests the default shop-create scope when no scopes are provided', async () => { + // Given + vi.mocked(ensureAuthenticated).mockResolvedValueOnce({identity: 'identity_token', userId: '1234-5678'}) + + // When + await ensureAuthenticatedSignups() + + // Then + expect(ensureAuthenticated).toHaveBeenCalledWith({signupsApi: {scopes: ['shop-create']}}, expect.anything(), {}) + }) + + test('passes through custom scopes when provided', async () => { + // Given + vi.mocked(ensureAuthenticated).mockResolvedValueOnce({identity: 'identity_token', userId: '1234-5678'}) + + // When + await ensureAuthenticatedSignups(['shop-create']) + + // Then + expect(ensureAuthenticated).toHaveBeenCalledWith({signupsApi: {scopes: ['shop-create']}}, expect.anything(), {}) + }) + + test('throws error if there is no identity token', async () => { + // Given + vi.mocked(ensureAuthenticated).mockResolvedValueOnce({partners: 'partners_token', userId: '1234-5678'}) + + // When + const got = ensureAuthenticatedSignups() + + // Then + await expect(got).rejects.toThrow(`No identity token`) + }) +}) + describe('ensureAuthenticatedAppManagementAndBusinessPlatform', () => { test('returns app management and business platform tokens if success', async () => { // Given 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 54f76ab3515..70550b4c4b8 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -77,6 +77,7 @@ * [`shopify plugins update`](#shopify-plugins-update) * [`shopify search [query]`](#shopify-search-query) * [`shopify store auth`](#shopify-store-auth) +* [`shopify store create trial`](#shopify-store-create-trial) * [`shopify store execute`](#shopify-store-execute) * [`shopify theme check`](#shopify-theme-check) * [`shopify theme console`](#shopify-theme-console) @@ -2139,6 +2140,36 @@ EXAMPLES $ shopify store auth --store shop.myshopify.com --scopes read_products,write_products --json ``` +## `shopify store create trial` + +Create a new Shopify trial store. + +``` +USAGE + $ shopify store create trial [-c ] [-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. + --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 trial store. + + Creates a new Shopify trial store associated with your account. + +EXAMPLES + $ shopify store create trial + + $ shopify store create trial --name "My Store" --country US + + $ shopify store create trial --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 fb2180a152a..872a2d3ebe9 100644 --- a/packages/cli/oclif.manifest.json +++ b/packages/cli/oclif.manifest.json @@ -5862,6 +5862,83 @@ "strict": true, "summary": "Authenticate an app against a store for store commands." }, + "store:create:trial": { + "aliases": [ + ], + "args": { + }, + "customPluginName": "@shopify/store", + "description": "Creates a new Shopify trial store associated with your account.", + "descriptionWithMarkdown": "Creates a new Shopify trial store associated with your account.", + "examples": [ + "<%= config.bin %> <%= command.id %>", + "<%= config.bin %> <%= command.id %> --name \"My Store\" --country US", + "<%= 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" + }, + "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:trial", + "pluginAlias": "@shopify/cli", + "pluginName": "@shopify/cli", + "pluginType": "core", + "strict": true, + "summary": "Create a new Shopify trial store." + }, "store:execute": { "aliases": [ ], diff --git a/packages/e2e/data/snapshots/commands.txt b/packages/e2e/data/snapshots/commands.txt index 9e9afb6c946..6e5b3e19876 100644 --- a/packages/e2e/data/snapshots/commands.txt +++ b/packages/e2e/data/snapshots/commands.txt @@ -94,6 +94,8 @@ ├─ search ├─ store │ ├─ auth +│ ├─ create +│ │ └─ trial │ └─ execute ├─ theme │ ├─ check diff --git a/packages/store/src/cli/commands/store/create/trial.test.ts b/packages/store/src/cli/commands/store/create/trial.test.ts new file mode 100644 index 00000000000..7b69d6c1020 --- /dev/null +++ b/packages/store/src/cli/commands/store/create/trial.test.ts @@ -0,0 +1,92 @@ +import StoreCreateTrial from './trial.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 trial 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 StoreCreateTrial.run([]) + + expect(createStore).toHaveBeenCalledWith({ + name: undefined, + subdomain: undefined, + country: 'US', + }) + }) + + test('passes all provided flags through to the create service', async () => { + vi.mocked(createStore).mockResolvedValue({ + shopPermanentDomain: 'custom.myshopify.com', + polling: false, + shopLoginUrl: null, + }) + + await StoreCreateTrial.run(['--name', 'Custom Store', '--subdomain', 'custom', '--country', 'CA']) + + expect(createStore).toHaveBeenCalledWith({ + name: 'Custom Store', + subdomain: 'custom', + country: 'CA', + }) + }) + + 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 StoreCreateTrial.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 StoreCreateTrial.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 StoreCreateTrial.run([]) + + expect(renderSuccess).toHaveBeenCalled() + expect(renderInfo).toHaveBeenCalledWith( + expect.objectContaining({body: expect.stringContaining('still being configured')}), + ) + }) + + test('defines the expected flags', () => { + expect(StoreCreateTrial.flags.name).toBeDefined() + expect(StoreCreateTrial.flags.subdomain).toBeDefined() + expect(StoreCreateTrial.flags.country).toBeDefined() + expect(StoreCreateTrial.flags.json).toBeDefined() + }) +}) diff --git a/packages/store/src/cli/commands/store/create/trial.ts b/packages/store/src/cli/commands/store/create/trial.ts new file mode 100644 index 00000000000..e3c169d57a3 --- /dev/null +++ b/packages/store/src/cli/commands/store/create/trial.ts @@ -0,0 +1,68 @@ +import {createStore} from '../../../services/store/create/index.js' +import StoreCommand from '../../../utilities/store-command.js' +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 StoreCreateTrial extends StoreCommand { + static summary = 'Create a new Shopify trial store.' + + static descriptionWithMarkdown = `Creates a new Shopify trial store associated with your account.` + + 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 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', + }), + } + + public async run(): Promise { + const {flags} = await this.parse(StoreCreateTrial) + + const result = await createStore({ + name: flags.name, + subdomain: flags.subdomain, + country: flags.country, + }) + + 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/store/src/cli/services/store/create/index.test.ts b/packages/store/src/cli/services/store/create/index.test.ts new file mode 100644 index 00000000000..0287126abb7 --- /dev/null +++ b/packages/store/src/cli/services/store/create/index.test.ts @@ -0,0 +1,136 @@ +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'}) + }) + + 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'}) + + 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'}) + + 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'}) + + 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'}) + + 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'})).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'})).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'})).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'})).rejects.toThrow('Unexpected response from Signups API') + }) + + test('propagates authentication failures from ensureAuthenticatedSignups', async () => { + vi.mocked(ensureAuthenticatedSignups).mockRejectedValue(new Error('Authentication required')) + + await expect(createStore({country: 'US'})).rejects.toThrow('Authentication required') + }) +}) diff --git a/packages/store/src/cli/services/store/create/index.ts b/packages/store/src/cli/services/store/create/index.ts new file mode 100644 index 00000000000..c8796c1454c --- /dev/null +++ b/packages/store/src/cli/services/store/create/index.ts @@ -0,0 +1,90 @@ +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' + +// eslint-disable-next-line @shopify/cli/no-inline-graphql +const StoreCreateMutation = ` + mutation StoreCreate($signup: ShopInput!) { + storeCreate(signup: $signup) { + shopPermanentDomain + polling + shopLoginUrl + userErrors { field message } + } + } +` + +interface StoreCreateInput { + name?: string + subdomain?: string + country: string +} + +interface StoreCreateResult { + shopPermanentDomain: string + polling: boolean + shopLoginUrl: string | null +} + +interface StoreCreateUserError { + field: string[] | null + message: string +} + +export async function createStore(input: StoreCreateInput): Promise { + const {token} = await ensureAuthenticatedSignups() + + 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, + } +} + +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}`) +} + +interface StoreCreateMutationResult { + shopPermanentDomain: string | null + polling: boolean | null + shopLoginUrl: string | null + userErrors: StoreCreateUserError[] +} diff --git a/packages/store/src/index.ts b/packages/store/src/index.ts index 73e67d78154..2907872360a 100644 --- a/packages/store/src/index.ts +++ b/packages/store/src/index.ts @@ -1,8 +1,10 @@ import StoreAuth from './cli/commands/store/auth.js' +import StoreCreateTrial from './cli/commands/store/create/trial.js' import StoreExecute from './cli/commands/store/execute.js' const COMMANDS = { 'store:auth': StoreAuth, + 'store:create:trial': StoreCreateTrial, 'store:execute': StoreExecute, }