diff --git a/docs-shopify.dev/commands/interfaces/store-create.interface.ts b/docs-shopify.dev/commands/interfaces/store-create.interface.ts index 17dae3f9dc..3cac6a13e5 100644 --- a/docs-shopify.dev/commands/interfaces/store-create.interface.ts +++ b/docs-shopify.dev/commands/interfaces/store-create.interface.ts @@ -16,6 +16,12 @@ export interface storecreate { */ '--dev'?: '' + /** + * Create a client transfer store under a Partner organization. + * @environment SHOPIFY_FLAG_STORE_FOR_CLIENT + */ + '--for-client'?: '' + /** * Output the result as JSON. Automatically disables color output. * @environment SHOPIFY_FLAG_JSON @@ -34,6 +40,12 @@ export interface storecreate { */ '--no-color'?: '' + /** + * The Partner organization ID to create the store under. Required with --for-client. + * @environment SHOPIFY_FLAG_STORE_ORG + */ + '--org '?: string + /** * The custom myshopify.com subdomain for the store. * @environment SHOPIFY_FLAG_STORE_SUBDOMAIN diff --git a/docs-shopify.dev/commands/store-create.doc.ts b/docs-shopify.dev/commands/store-create.doc.ts index a516485ee8..8beca6276a 100644 --- a/docs-shopify.dev/commands/store-create.doc.ts +++ b/docs-shopify.dev/commands/store-create.doc.ts @@ -5,7 +5,7 @@ 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.`, +By default, creates a trial store. Use \`--dev\` to create a development store, or \`--for-client\` to create a client transfer store under a Partner organization.`, overviewPreviewDescription: `Create a new Shopify store.`, type: 'command', isVisualComponent: false, diff --git a/docs-shopify.dev/generated/generated_docs_data.json b/docs-shopify.dev/generated/generated_docs_data.json index 92f8c85bc3..efae69ac06 100644 --- a/docs-shopify.dev/generated/generated_docs_data.json +++ b/docs-shopify.dev/generated/generated_docs_data.json @@ -5916,7 +5916,7 @@ }, { "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.", + "description": "Creates a new Shopify store associated with your account.\n\nBy default, creates a trial store. Use `--dev` to create a development store, or `--for-client` to create a client transfer store under a Partner organization.", "overviewPreviewDescription": "Create a new Shopify store.", "type": "command", "isVisualComponent": false, @@ -5953,6 +5953,15 @@ "isOptional": true, "environmentValue": "SHOPIFY_FLAG_STORE_DEV" }, + { + "filePath": "docs-shopify.dev/commands/interfaces/store-create.interface.ts", + "syntaxKind": "PropertySignature", + "name": "--for-client", + "value": "''", + "description": "Create a client transfer store under a Partner organization.", + "isOptional": true, + "environmentValue": "SHOPIFY_FLAG_STORE_FOR_CLIENT" + }, { "filePath": "docs-shopify.dev/commands/interfaces/store-create.interface.ts", "syntaxKind": "PropertySignature", @@ -5962,6 +5971,15 @@ "isOptional": true, "environmentValue": "SHOPIFY_FLAG_NO_COLOR" }, + { + "filePath": "docs-shopify.dev/commands/interfaces/store-create.interface.ts", + "syntaxKind": "PropertySignature", + "name": "--org ", + "value": "string", + "description": "The Partner organization ID to create the store under. Required with --for-client.", + "isOptional": true, + "environmentValue": "SHOPIFY_FLAG_STORE_ORG" + }, { "filePath": "docs-shopify.dev/commands/interfaces/store-create.interface.ts", "syntaxKind": "PropertySignature", @@ -6008,7 +6026,7 @@ "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}" + "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 * Create a client transfer store under a Partner organization.\n * @environment SHOPIFY_FLAG_STORE_FOR_CLIENT\n */\n '--for-client'?: ''\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 Partner organization ID to create the store under. Required with --for-client.\n * @environment SHOPIFY_FLAG_STORE_ORG\n */\n '--org '?: string\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}" } } } diff --git a/docs-shopify.dev/generated/generated_docs_data_v2.json b/docs-shopify.dev/generated/generated_docs_data_v2.json index 18874f2a19..1b21baa81e 100644 --- a/docs-shopify.dev/generated/generated_docs_data_v2.json +++ b/docs-shopify.dev/generated/generated_docs_data_v2.json @@ -4215,6 +4215,15 @@ "isOptional": true, "environmentValue": "SHOPIFY_FLAG_STORE_DEV" }, + { + "filePath": "docs-shopify.dev/commands/interfaces/store-create.interface.ts", + "syntaxKind": "PropertySignature", + "name": "--for-client", + "value": "''", + "description": "Create a client transfer store under a Partner organization.", + "isOptional": true, + "environmentValue": "SHOPIFY_FLAG_STORE_FOR_CLIENT" + }, { "filePath": "docs-shopify.dev/commands/interfaces/store-create.interface.ts", "syntaxKind": "PropertySignature", @@ -4224,6 +4233,15 @@ "isOptional": true, "environmentValue": "SHOPIFY_FLAG_NO_COLOR" }, + { + "filePath": "docs-shopify.dev/commands/interfaces/store-create.interface.ts", + "syntaxKind": "PropertySignature", + "name": "--org ", + "value": "string", + "description": "The Partner organization ID to create the store under. Required with --for-client.", + "isOptional": true, + "environmentValue": "SHOPIFY_FLAG_STORE_ORG" + }, { "filePath": "docs-shopify.dev/commands/interfaces/store-create.interface.ts", "syntaxKind": "PropertySignature", @@ -4270,7 +4288,7 @@ "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}" + "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 * Create a client transfer store under a Partner organization.\n * @environment SHOPIFY_FLAG_STORE_FOR_CLIENT\n */\n '--for-client'?: ''\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 Partner organization ID to create the store under. Required with --for-client.\n * @environment SHOPIFY_FLAG_STORE_ORG\n */\n '--org '?: string\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": { diff --git a/packages/cli/README.md b/packages/cli/README.md index 37c025adec..16ee507cfd 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -2088,7 +2088,8 @@ Create a new Shopify store. ``` USAGE - $ shopify store create [-c ] [--dev] [-j] [-n ] [--no-color] [--subdomain ] [--verbose] + $ shopify store create [-c ] [--dev] [--for-client] [-j] [-n ] [--no-color] [--org ] + [--subdomain ] [--verbose] FLAGS -c, --country= [default: US, env: SHOPIFY_FLAG_STORE_COUNTRY] The country code for the store (e.g., US, CA, @@ -2096,7 +2097,11 @@ FLAGS -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. + --for-client [env: SHOPIFY_FLAG_STORE_FOR_CLIENT] Create a client transfer store under a Partner + organization. --no-color [env: SHOPIFY_FLAG_NO_COLOR] Disable color output. + --org= [env: SHOPIFY_FLAG_STORE_ORG] The Partner organization ID to create the store under. Required + with --for-client. --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. @@ -2105,7 +2110,8 @@ DESCRIPTION Creates a new Shopify store associated with your account. - By default, creates a trial store. Use `--dev` to create a development store instead. + By default, creates a trial store. Use `--dev` to create a development store, or `--for-client` to create a client + transfer store under a Partner organization. EXAMPLES $ shopify store create @@ -2114,6 +2120,8 @@ EXAMPLES $ shopify store create --name "My Dev Store" --dev + $ shopify store create --name "Client Store" --for-client --org 12345 + $ shopify store create --name "My Store" --json ``` diff --git a/packages/cli/oclif.manifest.json b/packages/cli/oclif.manifest.json index bde549f286..53a3afcf3d 100644 --- a/packages/cli/oclif.manifest.json +++ b/packages/cli/oclif.manifest.json @@ -5807,13 +5807,14 @@ ], "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.", + "description": "Creates a new Shopify store associated with your account.\n\nBy default, creates a trial store. Use `--dev` to create a development store, or `--for-client` to create a client transfer store under a Partner organization.", + "descriptionWithMarkdown": "Creates a new Shopify store associated with your account.\n\nBy default, creates a trial store. Use `--dev` to create a development store, or `--for-client` to create a client transfer store under a Partner organization.", "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 \"Client Store\" --for-client --org 12345", "<%= config.bin %> <%= command.id %> --name \"My Store\" --json" ], "flags": { @@ -5834,6 +5835,13 @@ "name": "dev", "type": "boolean" }, + "for-client": { + "allowNo": false, + "description": "Create a client transfer store under a Partner organization.", + "env": "SHOPIFY_FLAG_STORE_FOR_CLIENT", + "name": "for-client", + "type": "boolean" + }, "json": { "allowNo": false, "char": "j", @@ -5860,6 +5868,14 @@ "name": "no-color", "type": "boolean" }, + "org": { + "description": "The Partner organization ID to create the store under. Required with --for-client.", + "env": "SHOPIFY_FLAG_STORE_ORG", + "hasDynamicHelp": false, + "multiple": false, + "name": "org", + "type": "option" + }, "subdomain": { "description": "The custom myshopify.com subdomain for the store.", "env": "SHOPIFY_FLAG_STORE_SUBDOMAIN", diff --git a/packages/cli/src/cli/commands/store/create.test.ts b/packages/cli/src/cli/commands/store/create.test.ts index 5a55e4078a..f53917f5eb 100644 --- a/packages/cli/src/cli/commands/store/create.test.ts +++ b/packages/cli/src/cli/commands/store/create.test.ts @@ -23,6 +23,8 @@ describe('store create command', () => { subdomain: undefined, country: 'US', dev: false, + forClient: false, + org: undefined, }) }) @@ -33,13 +35,34 @@ describe('store create command', () => { shopLoginUrl: null, }) - await StoreCreate.run(['--name', 'Custom Store', '--subdomain', 'custom', '--country', 'CA', '--dev']) + await StoreCreate.run(['--name', 'Custom Store', '--subdomain', 'custom', '--country', 'CA']) expect(createStore).toHaveBeenCalledWith({ name: 'Custom Store', subdomain: 'custom', country: 'CA', - dev: true, + dev: false, + forClient: false, + org: undefined, + }) + }) + + test('passes --for-client and --org flags through to the create service', async () => { + vi.mocked(createStore).mockResolvedValue({ + shopPermanentDomain: 'client-store.myshopify.com', + polling: false, + shopLoginUrl: null, + }) + + await StoreCreate.run(['--name', 'Client Store', '--for-client', '--org', '12345']) + + expect(createStore).toHaveBeenCalledWith({ + name: 'Client Store', + subdomain: undefined, + country: 'US', + dev: false, + forClient: true, + org: '12345', }) }) @@ -90,6 +113,8 @@ describe('store create command', () => { expect(StoreCreate.flags.subdomain).toBeDefined() expect(StoreCreate.flags.country).toBeDefined() expect(StoreCreate.flags.dev).toBeDefined() + expect(StoreCreate.flags['for-client']).toBeDefined() + expect(StoreCreate.flags.org).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 index 94d3578c59..7e0a4cfb7d 100644 --- a/packages/cli/src/cli/commands/store/create.ts +++ b/packages/cli/src/cli/commands/store/create.ts @@ -10,7 +10,7 @@ export default class StoreCreate extends Command { 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.` +By default, creates a trial store. Use \`--dev\` to create a development store, or \`--for-client\` to create a client transfer store under a Partner organization.` static description = this.descriptionWithoutMarkdown() @@ -18,6 +18,7 @@ By default, creates a trial store. Use \`--dev\` to create a development store i '<%= 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 "Client Store" --for-client --org 12345', '<%= config.bin %> <%= command.id %> --name "My Store" --json', ] @@ -44,6 +45,15 @@ By default, creates a trial store. Use \`--dev\` to create a development store i env: 'SHOPIFY_FLAG_STORE_DEV', default: false, }), + 'for-client': Flags.boolean({ + description: 'Create a client transfer store under a Partner organization.', + env: 'SHOPIFY_FLAG_STORE_FOR_CLIENT', + default: false, + }), + org: Flags.string({ + description: 'The Partner organization ID to create the store under. Required with --for-client.', + env: 'SHOPIFY_FLAG_STORE_ORG', + }), } async run(): Promise { @@ -54,6 +64,8 @@ By default, creates a trial store. Use \`--dev\` to create a development store i subdomain: flags.subdomain, country: flags.country, dev: flags.dev, + forClient: flags['for-client'], + org: flags.org, }) if (flags.json) { diff --git a/packages/cli/src/cli/services/store/create/index.test.ts b/packages/cli/src/cli/services/store/create/index.test.ts index 87069f2baa..69633178f0 100644 --- a/packages/cli/src/cli/services/store/create/index.test.ts +++ b/packages/cli/src/cli/services/store/create/index.test.ts @@ -1,14 +1,24 @@ import {createStore} from './index.js' import {signupsRequest} from '@shopify/cli-kit/node/api/signups' -import {ensureAuthenticatedSignups} from '@shopify/cli-kit/node/session' +import {businessPlatformOrganizationsRequest} from '@shopify/cli-kit/node/api/business-platform' +import { + ensureAuthenticatedSignups, + ensureAuthenticatedAppManagementAndBusinessPlatform, +} 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/api/business-platform') vi.mock('@shopify/cli-kit/node/session') describe('createStore', () => { beforeEach(() => { vi.mocked(ensureAuthenticatedSignups).mockResolvedValue({token: 'test-token', userId: 'user-1'}) + vi.mocked(ensureAuthenticatedAppManagementAndBusinessPlatform).mockResolvedValue({ + appManagementToken: 'app-token', + businessPlatformToken: 'bp-token', + userId: 'user-1', + }) }) describe('trial store (dev: false)', () => { @@ -22,7 +32,7 @@ describe('createStore', () => { }, }) - const result = await createStore({country: 'US', dev: false}) + const result = await createStore({country: 'US', dev: false, forClient: false}) expect(signupsRequest).toHaveBeenCalledWith(expect.stringContaining('StoreCreate'), 'test-token', { signup: {country: 'US'}, @@ -44,7 +54,13 @@ describe('createStore', () => { }, }) - const result = await createStore({name: 'My Custom Store', subdomain: 'my-custom', country: 'CA', dev: false}) + const result = await createStore({ + name: 'My Custom Store', + subdomain: 'my-custom', + country: 'CA', + dev: false, + forClient: false, + }) expect(signupsRequest).toHaveBeenCalledWith(expect.stringContaining('StoreCreate'), 'test-token', { signup: {shopName: 'My Custom Store', subdomain: 'my-custom', country: 'CA'}, @@ -62,7 +78,7 @@ describe('createStore', () => { }, }) - const result = await createStore({country: 'US', dev: false}) + const result = await createStore({country: 'US', dev: false, forClient: false}) expect(result.polling).toBe(true) }) @@ -77,7 +93,7 @@ describe('createStore', () => { }, }) - const result = await createStore({country: 'US', dev: false}) + const result = await createStore({country: 'US', dev: false, forClient: false}) expect(result.polling).toBe(false) }) @@ -92,7 +108,7 @@ describe('createStore', () => { }, }) - await expect(createStore({subdomain: 'taken', country: 'US', dev: false})).rejects.toThrow( + await expect(createStore({subdomain: 'taken', country: 'US', dev: false, forClient: false})).rejects.toThrow( 'signup.subdomain: Subdomain is already taken', ) }) @@ -110,7 +126,7 @@ describe('createStore', () => { }, }) - await expect(createStore({country: 'US', dev: false})).rejects.toThrow( + await expect(createStore({country: 'US', dev: false, forClient: false})).rejects.toThrow( 'signup.subdomain: Subdomain is already taken\nAccount limit reached', ) }) @@ -120,13 +136,15 @@ describe('createStore', () => { storeCreate: {shopPermanentDomain: null, polling: null, shopLoginUrl: null, userErrors: []}, }) - await expect(createStore({country: 'US', dev: false})).rejects.toThrow('no domain returned') + await expect(createStore({country: 'US', dev: false, forClient: 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') + await expect(createStore({country: 'US', dev: false, forClient: false})).rejects.toThrow( + 'Unexpected response from Signups API', + ) }) }) @@ -141,7 +159,7 @@ describe('createStore', () => { }, }) - const result = await createStore({name: 'Dev Store', country: 'US', dev: true}) + const result = await createStore({name: 'Dev Store', country: 'US', dev: true, forClient: false}) expect(signupsRequest).toHaveBeenCalledWith(expect.stringContaining('AppDevelopmentStoreCreate'), 'test-token', { shopInformation: { @@ -169,22 +187,17 @@ describe('createStore', () => { }, }) - await expect(createStore({country: 'XX', dev: true})).rejects.toThrow( + await expect(createStore({country: 'XX', dev: true, forClient: false})).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: [], - }, + appDevelopmentStoreCreate: {permanentDomain: null, loginUrl: null, shopId: null, userErrors: []}, }) - await expect(createStore({country: 'US', dev: true})).rejects.toThrow( + await expect(createStore({country: 'US', dev: true, forClient: false})).rejects.toThrow( 'Development store creation failed: no domain returned', ) }) @@ -199,7 +212,7 @@ describe('createStore', () => { }, }) - await createStore({country: 'US', dev: true}) + await createStore({country: 'US', dev: true, forClient: false}) expect(signupsRequest).toHaveBeenCalledWith( expect.stringContaining('AppDevelopmentStoreCreate'), @@ -211,20 +224,126 @@ describe('createStore', () => { 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') + await expect(createStore({country: 'US', dev: true, forClient: false})).rejects.toThrow( + 'Unexpected response from Signups API', + ) + }) + }) + + describe('client transfer store (forClient: true)', () => { + test('calls Organizations API CreateClientDevelopmentShop with correct variables', async () => { + vi.mocked(businessPlatformOrganizationsRequest).mockResolvedValue({ + createClientDevelopmentShop: { + shopDomain: 'client-store.myshopify.com', + shopAdminUrl: 'https://admin.shopify.com/store/client-store', + userErrors: [], + }, + }) + + const result = await createStore({ + name: 'Client Store', + country: 'CA', + dev: false, + forClient: true, + org: '12345', + }) + + expect(businessPlatformOrganizationsRequest).toHaveBeenCalledWith({ + query: expect.stringContaining('CreateClientDevelopmentShop'), + token: 'bp-token', + organizationId: '12345', + unauthorizedHandler: expect.any(Object), + }) + expect(result).toEqual({ + shopPermanentDomain: 'client-store.myshopify.com', + polling: false, + shopLoginUrl: 'https://admin.shopify.com/store/client-store', + }) + }) + + test('defaults shopName to "Client Store" when no name is provided', async () => { + vi.mocked(businessPlatformOrganizationsRequest).mockResolvedValue({ + createClientDevelopmentShop: { + shopDomain: 'client-store.myshopify.com', + shopAdminUrl: null, + userErrors: [], + }, + }) + + await createStore({country: 'US', dev: false, forClient: true, org: '12345'}) + + expect(businessPlatformOrganizationsRequest).toHaveBeenCalledWith( + expect.objectContaining({ + query: expect.stringContaining('shopName: "Client Store"').valueOf() + ? expect.stringContaining('CreateClientDevelopmentShop') + : expect.anything(), + }), + ) + }) + + test('throws an AbortError when the API returns user errors', async () => { + vi.mocked(businessPlatformOrganizationsRequest).mockResolvedValue({ + createClientDevelopmentShop: { + shopDomain: null, + shopAdminUrl: null, + userErrors: [{field: null, message: 'Only Partner organizations can create client development shops'}], + }, + }) + + await expect(createStore({country: 'US', dev: false, forClient: true, org: '12345'})).rejects.toThrow( + 'Only Partner organizations can create client development shops', + ) + }) + + test('throws an AbortError when no domain is returned', async () => { + vi.mocked(businessPlatformOrganizationsRequest).mockResolvedValue({ + createClientDevelopmentShop: {shopDomain: null, shopAdminUrl: null, userErrors: []}, + }) + + await expect(createStore({country: 'US', dev: false, forClient: true, org: '12345'})).rejects.toThrow( + 'Client transfer store creation failed: no domain returned', + ) + }) + + test('throws an AbortError when createClientDevelopmentShop response is null', async () => { + vi.mocked(businessPlatformOrganizationsRequest).mockResolvedValue({createClientDevelopmentShop: null}) + + await expect(createStore({country: 'US', dev: false, forClient: true, org: '12345'})).rejects.toThrow( + 'Unexpected response from Organizations 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() + describe('flag validation', () => { + test('throws when --subdomain is used with --dev', async () => { + await expect(createStore({subdomain: 'my-store', country: 'US', dev: true, forClient: false})).rejects.toThrow( + 'The --subdomain flag is not supported when creating a development store.', + ) + expect(ensureAuthenticatedSignups).not.toHaveBeenCalled() + }) + + test('throws when --subdomain is used with --for-client', async () => { + await expect( + createStore({subdomain: 'my-store', country: 'US', dev: false, forClient: true, org: '12345'}), + ).rejects.toThrow('The --subdomain flag is not supported when creating a client transfer store.') + }) + + test('throws when --for-client is used without --org', async () => { + await expect(createStore({country: 'US', dev: false, forClient: true})).rejects.toThrow( + 'The --org flag is required when creating a client transfer store.', + ) + }) + + test('throws when --for-client and --dev are used together', async () => { + await expect(createStore({country: 'US', dev: true, forClient: true, org: '12345'})).rejects.toThrow( + "The --for-client and --dev flags can't be used together.", + ) + }) }) 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') + await expect(createStore({country: 'US', dev: false, forClient: 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 index a447cc629c..7f5625c676 100644 --- a/packages/cli/src/cli/services/store/create/index.ts +++ b/packages/cli/src/cli/services/store/create/index.ts @@ -1,5 +1,9 @@ import {signupsRequest} from '@shopify/cli-kit/node/api/signups' -import {ensureAuthenticatedSignups} from '@shopify/cli-kit/node/session' +import {businessPlatformOrganizationsRequest} from '@shopify/cli-kit/node/api/business-platform' +import { + ensureAuthenticatedSignups, + ensureAuthenticatedAppManagementAndBusinessPlatform, +} 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' @@ -28,11 +32,24 @@ const AppDevelopmentStoreCreateMutation = ` } ` +// eslint-disable-next-line @shopify/cli/no-inline-graphql +const CreateClientDevelopmentShopMutation = ` + mutation CreateClientDevelopmentShop($shopName: String!, $countryCode: String!) { + createClientDevelopmentShop(shopName: $shopName, countryCode: $countryCode) { + shopDomain + shopAdminUrl + userErrors { message field } + } + } +` + export interface StoreCreateInput { name?: string subdomain?: string country: string dev: boolean + forClient: boolean + org?: string } export interface StoreCreateResult { @@ -47,13 +64,28 @@ interface StoreCreateUserError { } export async function createStore(input: StoreCreateInput): Promise { - if (input.dev && input.subdomain) { + if (input.forClient && input.dev) { + throw new AbortError("The --for-client and --dev flags can't be used together.", 'Use one or the other.') + } + + if (input.forClient && !input.org) { + throw new AbortError( + 'The --org flag is required when creating a client transfer store.', + 'Provide your Partner organization ID with --org.', + ) + } + + if ((input.dev || input.forClient) && input.subdomain) { throw new AbortError( - 'The --subdomain flag is not supported when creating a development store.', - 'Remove --subdomain or remove --dev.', + `The --subdomain flag is not supported when creating a ${input.dev ? 'development' : 'client transfer'} store.`, + `Remove --subdomain or remove --${input.dev ? 'dev' : 'for-client'}.`, ) } + if (input.forClient) { + return createClientTransferStore(input) + } + const {token} = await ensureAuthenticatedSignups() if (input.dev) { @@ -144,6 +176,48 @@ ${outputToken.json(variables)} } } +async function createClientTransferStore(input: StoreCreateInput): Promise { + const {businessPlatformToken} = await ensureAuthenticatedAppManagementAndBusinessPlatform() + + const variables = { + shopName: input.name ?? 'Client Store', + countryCode: input.country, + } + + outputDebug(outputContent`Calling Organizations API CreateClientDevelopmentShop with variables: +${outputToken.json(variables)} +`) + + const result = await businessPlatformOrganizationsRequest<{ + createClientDevelopmentShop: ClientDevShopCreateMutationResult | null + }>({ + query: CreateClientDevelopmentShopMutation, + token: businessPlatformToken, + organizationId: input.org!, + unauthorizedHandler: {type: 'token_refresh', handler: async () => ({token: undefined})}, + }) + + if (!result.createClientDevelopmentShop) { + throw new AbortError('Unexpected response from Organizations API: createClientDevelopmentShop was null.') + } + + throwOnUserErrors(result.createClientDevelopmentShop.userErrors ?? []) + + if (!result.createClientDevelopmentShop.shopDomain) { + throw new AbortError('Client transfer store creation failed: no domain returned.') + } + + outputDebug( + outputContent`CreateClientDevelopmentShop response: domain=${outputToken.raw(result.createClientDevelopmentShop.shopDomain)}`, + ) + + return { + shopPermanentDomain: result.createClientDevelopmentShop.shopDomain, + polling: false, + shopLoginUrl: result.createClientDevelopmentShop.shopAdminUrl, + } +} + function throwOnUserErrors(userErrors: StoreCreateUserError[]): void { if (userErrors.length === 0) return const messages = userErrors @@ -178,3 +252,9 @@ interface AppDevStoreCreateMutationResult { shopId: string | null userErrors: StoreCreateUserError[] } + +interface ClientDevShopCreateMutationResult { + shopDomain: string | null + shopAdminUrl: string | null + userErrors: StoreCreateUserError[] | null +}