Skip to content
Merged
5 changes: 5 additions & 0 deletions .changeset/create-dev-store-from-app.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@shopify/app': minor
---

Allow `app dev` to create a development store when the organization has none.
1 change: 1 addition & 0 deletions bin/get-graphql-schemas.js
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ const schemas = [
pathToFile: 'areas/platforms/organizations/db/graphql/organizations_schema.graphql',
localPaths: [
'./packages/app/src/cli/api/graphql/business-platform-organizations/organizations_schema.graphql',
'./packages/organizations/src/cli/api/graphql/business-platform-organizations/organizations_schema.graphql',
'./packages/store/src/cli/api/graphql/business-platform-organizations/organizations_schema.graphql',
],
},
Expand Down
5 changes: 5 additions & 0 deletions graphql.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,11 @@ export default {
functions: projectFactory('functions', 'functions_cli_schema.graphql', 'app'),
adminAsApp: projectFactory('admin', 'admin_schema.graphql'),
organizationsDestinations: projectFactory('business-platform-destinations', 'destinations_schema.graphql', 'organizations'),
organizationsBusinessPlatformOrganizations: projectFactory(
'business-platform-organizations',
'organizations_schema.graphql',
'organizations',
),
storeBusinessPlatformDestinations: projectFactory('business-platform-destinations', 'destinations_schema.graphql', 'store'),
storeBusinessPlatformOrganizations: projectFactory('business-platform-organizations', 'organizations_schema.graphql', 'store'),
},
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
/* eslint-disable @typescript-eslint/consistent-type-definitions */
import * as Types from './types.js'

import {TypedDocumentNode as DocumentNode} from '@graphql-typed-document-node/core'

export type DevStoreCapReachedQueryVariables = Types.Exact<{[key: string]: never}>

export type DevStoreCapReachedQuery = {organization?: {devStoreCapReached: boolean} | null}

export const DevStoreCapReached = {
kind: 'Document',
definitions: [
{
kind: 'OperationDefinition',
operation: 'query',
name: {kind: 'Name', value: 'DevStoreCapReached'},
selectionSet: {
kind: 'SelectionSet',
selections: [
{
kind: 'Field',
name: {kind: 'Name', value: 'organization'},
selectionSet: {
kind: 'SelectionSet',
selections: [
{kind: 'Field', name: {kind: 'Name', value: 'devStoreCapReached'}},
{kind: 'Field', name: {kind: 'Name', value: '__typename'}},
],
},
},
],
},
},
],
} as unknown as DocumentNode<DevStoreCapReachedQuery, DevStoreCapReachedQueryVariables>
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
query DevStoreCapReached {
organization {
devStoreCapReached
}
}
1 change: 1 addition & 0 deletions packages/app/src/cli/commands/app/dev.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ describe('app dev command', () => {
tunnelUrl: undefined,
localhostPort: undefined,
})
expect(storeContext).toHaveBeenCalledWith(expect.objectContaining({storeCreationMode: 'when-empty'}))
expect(dev).toHaveBeenCalledWith(expect.objectContaining({installMkcert: undefined, tunnel: {mode: 'auto'}}))
})
})
Expand Down
1 change: 1 addition & 0 deletions packages/app/src/cli/commands/app/dev.ts
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,7 @@ export default class Dev extends AppLinkedCommand {
appContextResult,
storeFqdn: flags.store,
forceReselectStore: flags.reset,
storeCreationMode: 'when-empty',
})

const devOptions: DevOptions = {
Expand Down
49 changes: 49 additions & 0 deletions packages/app/src/cli/prompts/dev.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,36 @@ describe('selectStore', () => {
expect(outputMock.output()).toMatch('Using your default dev store, store1, to preview your project')
})

test('creates directly when the list is empty and a creation handler is provided', async () => {
const onCreateStoreWhenEmpty = vi.fn().mockResolvedValue(STORE1)

const got = await selectStorePrompt({
stores: [],
showDomainOnPrompt: defaultShowDomainOnPrompt,
onCreateStoreWhenEmpty,
})

expect(got).toEqual(STORE1)
expect(onCreateStoreWhenEmpty).toHaveBeenCalledOnce()
expect(renderAutocompletePrompt).not.toHaveBeenCalled()
})

test('returns the only store without creating when a creation handler is provided', async () => {
const onCreateStoreWhenEmpty = vi.fn().mockResolvedValue(STORE2)
const outputMock = mockAndCaptureOutput()

const got = await selectStorePrompt({
stores: [STORE1],
showDomainOnPrompt: defaultShowDomainOnPrompt,
onCreateStoreWhenEmpty,
})

expect(got).toEqual(STORE1)
expect(onCreateStoreWhenEmpty).not.toHaveBeenCalled()
expect(renderAutocompletePrompt).not.toBeCalled()
expect(outputMock.output()).toMatch('Using your default dev store, store1, to preview your project')
})

test('returns store if user selects one', async () => {
// Given
const stores: OrganizationStore[] = [STORE1, STORE2]
Expand Down Expand Up @@ -216,6 +246,25 @@ describe('selectStore', () => {
search: expect.any(Function),
})
})

test('returns an initial store after a search when clearing input restores the initial choices', async () => {
const stores: OrganizationStore[] = [STORE1, STORE2]
const onSearchForStoresByName = vi.fn().mockResolvedValue({stores: [STORE3], hasMorePages: false})
vi.mocked(renderAutocompletePrompt).mockImplementation(async ({search}) => {
await search!('store3')
return STORE1.shopId
})

const got = await selectStorePrompt({
stores,
showDomainOnPrompt: defaultShowDomainOnPrompt,
onSearchForStoresByName,
})

expect(got).toEqual(STORE1)
expect(onSearchForStoresByName).toHaveBeenCalledWith('store3')
expect(onSearchForStoresByName).toHaveBeenCalledOnce()
})
})

describe('appName', () => {
Expand Down
23 changes: 21 additions & 2 deletions packages/app/src/cli/prompts/dev.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,26 @@ import {getTomls} from '../utilities/app/config/getTomls.js'
import {Paginateable} from '../utilities/developer-platform-client.js'
import {APP_NAME_MAX_LENGTH} from '../models/app/validation/common.js'
import {ApplicationURLs} from '../services/dev/urls.js'
import {
devStoreNamePrompt as sharedDevStoreNamePrompt,
devStorePlanPrompt as sharedDevStorePlanPrompt,
} from '@shopify/organizations'
import {
RenderAutocompleteOptions,
renderAutocompletePrompt,
renderConfirmationPrompt,
renderTextPrompt,
} from '@shopify/cli-kit/node/ui'
import {outputCompleted} from '@shopify/cli-kit/node/output'
import type {DevStorePlan} from '@shopify/organizations'

export function devStoreNamePrompt(): Promise<string> {
return sharedDevStoreNamePrompt()
}

export function devStorePlanPrompt(): Promise<DevStorePlan> {
return sharedDevStorePlanPrompt()
}

export async function selectAppPrompt(
onSearchForAppsByName: (term: string) => Promise<{apps: MinimalOrganizationApp[]; hasMorePages: boolean}>,
Expand Down Expand Up @@ -56,6 +69,7 @@ interface SelectStorePromptOptions {
stores: OrganizationStore[]
hasMorePages?: boolean
showDomainOnPrompt: boolean
onCreateStoreWhenEmpty?: () => Promise<OrganizationStore | undefined>
}

interface ExtraAutoCompletePropsForStoreSelect {
Expand All @@ -67,8 +81,9 @@ export async function selectStorePrompt({
hasMorePages = false,
onSearchForStoresByName,
showDomainOnPrompt = true,
onCreateStoreWhenEmpty,
}: SelectStorePromptOptions): Promise<OrganizationStore | undefined> {
if (stores.length === 0) return undefined
if (stores.length === 0) return onCreateStoreWhenEmpty?.()
if (stores.length === 1) {
outputCompleted(`Using your default dev store, ${stores[0]!.shopName}, to preview your project.`)
return stores[0]
Expand All @@ -83,12 +98,16 @@ export async function selectStorePrompt({
}

let currentStores = stores
const storesById = new Map(stores.map((store) => [store.shopId, store]))

const extraAutocompletePromptProps: ExtraAutoCompletePropsForStoreSelect = {}
if (onSearchForStoresByName) {
extraAutocompletePromptProps.search = async (term) => {
const result = await onSearchForStoresByName(term)
currentStores = result.stores
if (currentStores.length > 0) {
currentStores.forEach((store) => storesById.set(store.shopId, store))
}

return {
data: currentStores.map(storeToChoice),
Expand All @@ -105,7 +124,7 @@ export async function selectStorePrompt({
hasMorePages,
...extraAutocompletePromptProps,
})
return currentStores.find((store) => store.shopId === id)
return storesById.get(id)
}

export async function appNamePrompt(currentName: string): Promise<string> {
Expand Down
51 changes: 51 additions & 0 deletions packages/app/src/cli/services/dev/cap.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import {devStoreCapReached} from './cap.js'
import {testDeveloperPlatformClient} from '../../models/app/app.test-data.js'
import {ClientName, DeveloperPlatformClient} from '../../utilities/developer-platform-client.js'
import {describe, expect, test, vi} from 'vitest'

describe('devStoreCapReached', () => {
test('returns the cap value for an app-management client', async () => {
const client = testDeveloperPlatformClient({
clientName: ClientName.AppManagement,
devStoreCapReached: vi.fn().mockResolvedValue(true),
})

await expect(devStoreCapReached('1', client)).resolves.toBe(true)
expect(client.devStoreCapReached).toHaveBeenCalledWith('1')
})

test('keeps the client receiver when the cap query uses this', async () => {
const client = testDeveloperPlatformClient({clientName: ClientName.AppManagement})
client.devStoreCapReached = async function (this: DeveloperPlatformClient) {
return this.clientName === ClientName.AppManagement
}

await expect(devStoreCapReached('1', client)).resolves.toBe(true)
})

test('fails open when the cap request fails', async () => {
const client = testDeveloperPlatformClient({
clientName: ClientName.AppManagement,
devStoreCapReached: vi.fn().mockRejectedValue(new Error('field is unavailable')),
})

await expect(devStoreCapReached('1', client)).resolves.toBe(false)
})

test('fails open when the app-management client does not expose the cap query', async () => {
const client = testDeveloperPlatformClient({clientName: ClientName.AppManagement})

expect(client.devStoreCapReached).toBeUndefined()
await expect(devStoreCapReached('1', client)).resolves.toBe(false)
})

test('does not query Partners clients', async () => {
const client = testDeveloperPlatformClient({
clientName: ClientName.Partners,
devStoreCapReached: vi.fn().mockResolvedValue(true),
})

await expect(devStoreCapReached('1', client)).resolves.toBe(false)
expect(client.devStoreCapReached).not.toHaveBeenCalled()
})
})
17 changes: 17 additions & 0 deletions packages/app/src/cli/services/dev/cap.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import {ClientName, DeveloperPlatformClient} from '../../utilities/developer-platform-client.js'

export async function devStoreCapReached(
organizationId: string,
developerPlatformClient: DeveloperPlatformClient,
): Promise<boolean> {
if (developerPlatformClient.clientName !== ClientName.AppManagement || !developerPlatformClient.devStoreCapReached) {
return false
}

try {
return await developerPlatformClient.devStoreCapReached(organizationId)
// eslint-disable-next-line no-catch-all/no-catch-all
} catch {
return false
}
}
15 changes: 8 additions & 7 deletions packages/app/src/cli/services/dev/fetch.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import {fetchOrganizations, fetchStore, NoOrgError} from './fetch.js'
import {fetchOrganizations, fetchStore, NoOrgError, StoreNotFoundError} from './fetch.js'
import {Organization, OrganizationSource, OrganizationStore} from '../../models/organization.js'
import {
testPartnersServiceSession,
Expand Down Expand Up @@ -97,12 +97,13 @@ describe('fetchStore', () => {
const got = fetchStore(ORG1, 'domain1', developerPlatformClient)

// Then
await expect(got).rejects.toThrow(
new AbortError(
`Could not find store for domain domain1 in organization org1.`,
`Ensure you have provided the correct store domain, that the store is a dev store, and that you have access to the store.`,
),
)
await expect(got).rejects.toBeInstanceOf(AbortError)
await expect(got).rejects.toBeInstanceOf(StoreNotFoundError)
await expect(got).rejects.toMatchObject({
message: 'Could not find store for domain domain1 in organization org1.',
tryMessage:
'Ensure you have provided the correct store domain, that the store is a dev store, and that you have access to the store.',
})
})
})

Expand Down
4 changes: 3 additions & 1 deletion packages/app/src/cli/services/dev/fetch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ import {AccountInfo, isServiceAccount, isUserAccount} from '@shopify/cli-kit/nod
import {AbortError} from '@shopify/cli-kit/node/error'
import {outputContent, outputToken} from '@shopify/cli-kit/node/output'

export class StoreNotFoundError extends AbortError {}

export class NoOrgError extends AbortError {
constructor(partnersAccount: AccountInfo, organizationId?: string) {
let accountIdentifier = 'unknown'
Expand Down Expand Up @@ -115,7 +117,7 @@ export async function fetchStore(
const storeTypeMessage = isDevStoresOnly
? 'Ensure you have provided the correct store domain, that the store is a dev store, and that you have access to the store.'
: 'Ensure you have provided the correct store domain and that you have access to the store.'
throw new AbortError(
throw new StoreNotFoundError(
`Could not find store for domain ${storeFqdn} in organization ${org.businessName}.`,
storeTypeMessage,
)
Expand Down
Loading
Loading