diff --git a/packages/app/src/cli/prompts/dev.ts b/packages/app/src/cli/prompts/dev.ts index 5f8abbebded..ec029106447 100644 --- a/packages/app/src/cli/prompts/dev.ts +++ b/packages/app/src/cli/prompts/dev.ts @@ -7,13 +7,9 @@ import { devStoreNamePrompt as sharedDevStoreNamePrompt, devStorePlanPrompt as sharedDevStorePlanPrompt, devStoreDemoDataPrompt as sharedDevStoreDemoDataPrompt, + storeChoiceList, } from '@shopify/organizations' -import { - RenderAutocompleteOptions, - renderAutocompletePrompt, - renderConfirmationPrompt, - renderTextPrompt, -} from '@shopify/cli-kit/node/ui' +import {renderAutocompletePrompt, renderConfirmationPrompt, renderTextPrompt} from '@shopify/cli-kit/node/ui' import {outputCompleted} from '@shopify/cli-kit/node/output' import type {DevStorePlan} from '@shopify/organizations' @@ -78,9 +74,12 @@ interface SelectStorePromptOptions { onCreateStore?: () => Promise } -interface ExtraAutoCompletePropsForStoreSelect { - search?: RenderAutocompleteOptions['search'] -} +/** + * Picks the dev store to preview the project on. The picking mechanics are shared with the `store` + * commands; this adds what is specific to `app dev` — the wording, and the organization store shape. + */ +// The value the "create a new store" choice submits. Namespaced so it can't collide with a store id. +const CREATE_STORE_CHOICE = '__create_new_dev_store__' export async function selectStorePrompt({ stores, @@ -96,48 +95,22 @@ export async function selectStorePrompt({ return stores[0] } - const storeToChoice = (store: OrganizationStore): RenderAutocompleteOptions['choices'][number] => { - let label = store.shopName - if (showDomainOnPrompt && store.shopDomain) { - label = `${store.shopName} (${store.shopDomain})` - } - return {label, value: store.shopId} - } - - let currentStores = stores - const storesById = new Map(stores.map((store) => [store.shopId, store])) - const createStoreChoice = '__create_new_dev_store__' - const choices = () => [ - ...currentStores.map(storeToChoice), - ...(onCreateStore ? [{label: 'Create a new dev store', value: createStoreChoice}] : []), - ] - - 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: choices(), - meta: { - hasNextPage: result.hasMorePages, - }, - } - } - } + const {promptProps, storeFor} = storeChoiceList({ + stores, + toChoice: (store) => ({id: store.shopId, domain: store.shopDomain, name: store.shopName}), + showDomain: showDomainOnPrompt, + ...(onSearchForStoresByName ? {onSearch: onSearchForStoresByName} : {}), + ...(onCreateStore ? {extraChoices: [{label: 'Create a new dev store', value: CREATE_STORE_CHOICE}]} : {}), + }) - const id = await renderAutocompletePrompt({ + const selectedValue = await renderAutocompletePrompt({ message: 'Which store would you like to use to view your project?', - choices: choices(), hasMorePages, - ...extraAutocompletePromptProps, + ...promptProps, }) - if (id === createStoreChoice) return onCreateStore?.() - return storesById.get(id) + + if (selectedValue === CREATE_STORE_CHOICE) return onCreateStore?.() + return storeFor(selectedValue) } export async function appNamePrompt(currentName: string): Promise { diff --git a/packages/cli/oclif.manifest.json b/packages/cli/oclif.manifest.json index 4b179f39c06..2154f9e7a32 100644 --- a/packages/cli/oclif.manifest.json +++ b/packages/cli/oclif.manifest.json @@ -7360,10 +7360,11 @@ "args": { }, "customPluginName": "@shopify/store", - "description": "Deletes a dev store from your organization.", - "descriptionWithMarkdown": "Deletes a dev store from your organization.", + "description": "Deletes a dev store from your organization.\n\nWhen `--store` is omitted, the command prompts you to pick one of your organization's dev stores, so the flag is only required in non-interactive environments.", + "descriptionWithMarkdown": "Deletes a dev store from your organization.\n\nWhen `--store` is omitted, the command prompts you to pick one of your organization's dev stores, so the flag is only required in non-interactive environments.", "enableJsonFlag": false, "examples": [ + "<%= config.bin %> <%= command.id %>", "<%= config.bin %> <%= command.id %> --store shop.myshopify.com --organization-id 1234567", "<%= config.bin %> <%= command.id %> --store shop.myshopify.com --organization-id 1234567 --json", "<%= config.bin %> <%= command.id %> --store shop.myshopify.com --organization-id 1234567 --force" @@ -7404,12 +7405,11 @@ }, "store": { "char": "s", - "description": "The myshopify.com domain of the store.", + "description": "The myshopify.com domain of the store. Required if non interactive.", "env": "SHOPIFY_FLAG_STORE", "hasDynamicHelp": false, "multiple": false, "name": "store", - "required": true, "type": "option" }, "verbose": { diff --git a/packages/organizations/src/cli/prompts/store.test.ts b/packages/organizations/src/cli/prompts/store.test.ts new file mode 100644 index 00000000000..8a6612e9790 --- /dev/null +++ b/packages/organizations/src/cli/prompts/store.test.ts @@ -0,0 +1,101 @@ +import {storeChoiceList} from './store.js' +import {describe, expect, test, vi} from 'vitest' + +interface TestStore { + shopId: string + shopDomain: string + shopName?: string + extra?: string +} + +const first: TestStore = {shopId: '1', shopDomain: 'first.myshopify.com', shopName: 'First', extra: 'kept'} +const second: TestStore = {shopId: '2', shopDomain: 'second.myshopify.com', shopName: 'Second'} +const third: TestStore = {shopId: '3', shopDomain: 'third.myshopify.com', shopName: 'Third'} + +function toChoice(store: TestStore) { + return {id: store.shopId, domain: store.shopDomain, ...(store.shopName ? {name: store.shopName} : {})} +} + +function listFor(stores: TestStore[], overrides = {}) { + return storeChoiceList({stores, toChoice, ...overrides}) +} + +describe('storeChoiceList', () => { + test('names each store by its name and domain', () => { + expect(listFor([first, second]).promptProps.choices).toEqual([ + {label: 'First (first.myshopify.com)', value: '1'}, + {label: 'Second (second.myshopify.com)', value: '2'}, + ]) + }) + + test('names stores without the domain when showDomain is false', () => { + expect(listFor([first], {showDomain: false}).promptProps.choices).toEqual([{label: 'First', value: '1'}]) + }) + + test('falls back to the domain for a store with no name', () => { + const stores = [{shopId: '1', shopDomain: 'first.myshopify.com'}] + + expect(listFor(stores).promptProps.choices).toEqual([{label: 'first.myshopify.com', value: '1'}]) + }) + + test('falls back to the name for a store with no domain', () => { + const stores = [{shopId: '1', shopDomain: '', shopName: 'First'}] + + expect(listFor(stores).promptProps.choices).toEqual([{label: 'First', value: '1'}]) + }) + + test('offers the extra choices below the stores', () => { + const extraChoices = [{label: 'Create a new dev store', value: 'create'}] + + expect(listFor([first], {extraChoices}).promptProps.choices).toEqual([ + {label: 'First (first.myshopify.com)', value: '1'}, + {label: 'Create a new dev store', value: 'create'}, + ]) + }) + + test('resolves a submitted value back to the caller store, shape intact', () => { + expect(listFor([first, second]).storeFor('1')).toEqual(first) + }) + + test('leaves search unset when the caller cannot search remotely, so the prompt filters in memory', () => { + expect(listFor([first]).promptProps).not.toHaveProperty('search') + }) + + test('labels remote search results and reports whether more pages remain', async () => { + const onSearch = vi.fn().mockResolvedValue({stores: [third], hasMorePages: true}) + const extraChoices = [{label: 'Create a new dev store', value: 'create'}] + + const results = await listFor([first], {onSearch, extraChoices}).promptProps.search!('thi') + + expect(onSearch).toHaveBeenCalledWith('thi') + expect(results.data).toEqual([ + {label: 'Third (third.myshopify.com)', value: '3'}, + {label: 'Create a new dev store', value: 'create'}, + ]) + expect(results.meta).toEqual({hasNextPage: true}) + }) + + test('resolves a store that only a search offered', async () => { + const onSearch = vi.fn().mockResolvedValue({stores: [third], hasMorePages: false}) + const list = listFor([first], {onSearch}) + + await list.promptProps.search!('thi') + + expect(list.storeFor('3')).toEqual(third) + }) + + test('still resolves a store from the initial list after a search', async () => { + const onSearch = vi.fn().mockResolvedValue({stores: [third], hasMorePages: false}) + const list = listFor([first], {onSearch}) + + await list.promptProps.search!('thi') + + expect(list.storeFor('1')).toEqual(first) + }) + + test('resolves nothing for a value that is not a store', () => { + const extraChoices = [{label: 'Create a new dev store', value: 'create'}] + + expect(listFor([first], {extraChoices}).storeFor('create')).toBeUndefined() + }) +}) diff --git a/packages/organizations/src/cli/prompts/store.ts b/packages/organizations/src/cli/prompts/store.ts new file mode 100644 index 00000000000..e8f253d3390 --- /dev/null +++ b/packages/organizations/src/cli/prompts/store.ts @@ -0,0 +1,79 @@ +import {type RenderAutocompleteOptions} from '@shopify/cli-kit/node/ui' + +/** The identity and labelling data a store picker needs, whatever shape the caller's store has. */ +export interface StoreChoice { + // Stable identifier for the store, used as the value the prompt submits. + id: string + // The store's myshopify.com domain. + domain: string + // Human-readable store name, when the caller knows one. + name?: string +} + +interface PromptChoice { + label: string + value: string +} + +interface StoreChoiceListOptions { + stores: T[] + // Projects a store onto the data the picker needs, so callers keep their own store shape. + toChoice: (store: T) => StoreChoice + // Whether labels name the domain alongside the store name. Defaults to true. + showDomain?: boolean + // Searches the caller's source by name, replacing the offered stores with the results. + onSearch?: (term: string) => Promise<{stores: T[]; hasMorePages: boolean}> + // Choices offered below the stores, such as creating one. Kept through searches. + extraChoices?: PromptChoice[] +} + +interface StoreChoiceList { + // Spread into `renderAutocompletePrompt`. `search` is absent, rather than set to undefined, when + // the caller can't search remotely: an explicit `search: undefined` overrides the prompt's own + // in-memory filtering instead of leaving it in place. + promptProps: {choices: PromptChoice[]; search?: RenderAutocompleteOptions['search']} + // The store a submitted value stands for, or undefined for one of the extra choices. + storeFor: (value: string) => T | undefined +} + +/** + * Labels a set of stores for an autocomplete prompt and resolves what the developer submits back to + * the store it stands for, including stores that only a remote search brought in. + * + * Only this bookkeeping is shared. The wording, the surrounding flow, and any extra choices stay + * with the caller, so `app dev` and the `store` commands can share it without sharing their flows. + */ +export function storeChoiceList(options: StoreChoiceListOptions): StoreChoiceList { + const {stores, toChoice, showDomain = true, extraChoices = [], onSearch} = options + + // Filled in as choices are built, so whatever a search offered stays resolvable afterwards. + const storesByValue = new Map() + const choicesFor = (offered: T[]): PromptChoice[] => { + const storeChoices = offered.map((store) => { + const choice = toChoice(store) + storesByValue.set(choice.id, store) + + return toPromptChoice(choice, showDomain) + }) + + return [...storeChoices, ...extraChoices] + } + + const promptProps: StoreChoiceList['promptProps'] = {choices: choicesFor(stores)} + if (onSearch) { + promptProps.search = async (term) => { + const {stores: found, hasMorePages} = await onSearch(term) + + return {data: choicesFor(found), meta: {hasNextPage: hasMorePages}} + } + } + + return {promptProps, storeFor: (value) => storesByValue.get(value)} +} + +// Names a store by name and domain together, falling back to whichever of the two the caller knows. +function toPromptChoice({id, domain, name}: StoreChoice, showDomain: boolean): PromptChoice { + const label = showDomain && name && domain ? `${name} (${domain})` : (name ?? domain) + + return {label, value: id} +} diff --git a/packages/organizations/src/index.ts b/packages/organizations/src/index.ts index 4d6a46a5f57..786a1ba11ab 100644 --- a/packages/organizations/src/index.ts +++ b/packages/organizations/src/index.ts @@ -1,6 +1,8 @@ export {fetchOrganizations, fetchOrganizationsWithAccessInfo} from './cli/services/fetch.js' export {selectOrg} from './cli/services/select.js' export {selectOrganizationPrompt} from './cli/prompts/organization.js' +export {storeChoiceList} from './cli/prompts/store.js' +export type {StoreChoice} from './cli/prompts/store.js' export type {Organization} from './cli/models/organization.js' export {businessPlatformTokenRefreshHandler} from './cli/services/business-platform.js' export {createDevStore, devStorePlanHandles} from './cli/services/dev/create-dev-store.js' diff --git a/packages/store/src/cli/api/graphql/business-platform-organizations/generated/list_accessible_shops.ts b/packages/store/src/cli/api/graphql/business-platform-organizations/generated/list_accessible_shops.ts index 6fafc7cad55..9aee3b125b0 100644 --- a/packages/store/src/cli/api/graphql/business-platform-organizations/generated/list_accessible_shops.ts +++ b/packages/store/src/cli/api/graphql/business-platform-organizations/generated/list_accessible_shops.ts @@ -5,6 +5,8 @@ import {TypedDocumentNode as DocumentNode} from '@graphql-typed-document-node/co export type ListAccessibleShopsQueryVariables = Types.Exact<{ first: Types.Scalars['Int']['input'] + filters?: Types.InputMaybe + search?: Types.InputMaybe }> export type ListAccessibleShopsQuery = { @@ -41,6 +43,19 @@ export const ListAccessibleShops = { variable: {kind: 'Variable', name: {kind: 'Name', value: 'first'}}, type: {kind: 'NonNullType', type: {kind: 'NamedType', name: {kind: 'Name', value: 'Int'}}}, }, + { + kind: 'VariableDefinition', + variable: {kind: 'Variable', name: {kind: 'Name', value: 'filters'}}, + type: { + kind: 'ListType', + type: {kind: 'NonNullType', type: {kind: 'NamedType', name: {kind: 'Name', value: 'ShopFilterInput'}}}, + }, + }, + { + kind: 'VariableDefinition', + variable: {kind: 'Variable', name: {kind: 'Name', value: 'search'}}, + type: {kind: 'NamedType', name: {kind: 'Name', value: 'String'}}, + }, ], selectionSet: { kind: 'SelectionSet', @@ -70,31 +85,12 @@ export const ListAccessibleShops = { { kind: 'Argument', name: {kind: 'Name', value: 'filters'}, - value: { - kind: 'ListValue', - values: [ - { - kind: 'ObjectValue', - fields: [ - { - kind: 'ObjectField', - name: {kind: 'Name', value: 'field'}, - value: {kind: 'EnumValue', value: 'STORE_STATUS'}, - }, - { - kind: 'ObjectField', - name: {kind: 'Name', value: 'operator'}, - value: {kind: 'EnumValue', value: 'EQUALS'}, - }, - { - kind: 'ObjectField', - name: {kind: 'Name', value: 'value'}, - value: {kind: 'StringValue', value: 'active', block: false}, - }, - ], - }, - ], - }, + value: {kind: 'Variable', name: {kind: 'Name', value: 'filters'}}, + }, + { + kind: 'Argument', + name: {kind: 'Name', value: 'search'}, + value: {kind: 'Variable', name: {kind: 'Name', value: 'search'}}, }, ], selectionSet: { diff --git a/packages/store/src/cli/api/graphql/business-platform-organizations/generated/types.d.ts b/packages/store/src/cli/api/graphql/business-platform-organizations/generated/types.d.ts index df0155548f4..e5ae0715680 100644 --- a/packages/store/src/cli/api/graphql/business-platform-organizations/generated/types.d.ts +++ b/packages/store/src/cli/api/graphql/business-platform-organizations/generated/types.d.ts @@ -91,6 +91,62 @@ export type Scalars = { URL: { input: string; output: string; } }; +/** Operators for filter queries. */ +export type Operator = + /** Between operator. */ + | 'BETWEEN' + /** Equals operator. */ + | 'EQUALS' + /** + * In operator. Accepts a comma-separated string of values (e.g. + * "value1,value2,value3"). Not supported for all filter fields. + */ + | 'IN'; + +/** Field options for filtering shop queries. */ +export type ShopFilterField = + /** + * The phase of the client transfer process. Requires + * `store_type=client_transfer`. Values: `in_development`, `pending`, `completed`. + */ + | 'CLIENT_TRANSFER_PHASE' + /** + * The status of the collaborator relationship. Requires + * `store_type=collaborator`. Values: `active`, `access_pending`, `expired`. + */ + | 'COLLABORATOR_RELATIONSHIP_STATUS' + /** The GID of the counterpart organization. Requires `store_type=client_transfer` or `store_type=collaborator`. */ + | 'COUNTERPART_ORGANIZATION_ID' + /** The GID of the owning organization of the shop. */ + | 'OWNER_ORGANIZATION_ID' + /** + * The plan of the shop. Values: `basic`, `grow`, `plus`, `frozen`, `advanced`, + * `inactive`, `cancelled`, `client_transfer`, `plus_client_transfer`, + * `development_legacy`, `custom`, `fraudulent`, `staff`, `trial`, + * `plus_development`, `retail`, `shop_pay_commerce_components`, `non_profit`. + * With the `In` operator, use raw plan names (e.g. "professional,shopify_plus"). + */ + | 'SHOP_PLAN' + /** The active/inactive status of the shop. Values: `active`, `inactive`. */ + | 'STORE_STATUS' + /** + * The type of the shop. Does not support the `In` operator. Values: + * `development`, `production`, `app_development`, `development_superset`, + * `client_transfer`, `collaborator`. + */ + | 'STORE_TYPE'; + +/** + * Represents a single filter option for shop queries. When using the `In` + * operator, pass a comma-separated string of values (e.g. "value1,value2"). + * Maximum 20 values. + */ +export type ShopFilterInput = { + field: ShopFilterField; + operator: Operator; + value: Scalars['String']['input']; +}; + export type Store = | 'APP_DEVELOPMENT' | 'CLIENT_TRANSFER' diff --git a/packages/store/src/cli/api/graphql/business-platform-organizations/queries/list_accessible_shops.graphql b/packages/store/src/cli/api/graphql/business-platform-organizations/queries/list_accessible_shops.graphql index 68c73de4cab..fb5962d6110 100644 --- a/packages/store/src/cli/api/graphql/business-platform-organizations/queries/list_accessible_shops.graphql +++ b/packages/store/src/cli/api/graphql/business-platform-organizations/queries/list_accessible_shops.graphql @@ -1,12 +1,8 @@ -query ListAccessibleShops($first: Int!) { +query ListAccessibleShops($first: Int!, $filters: [ShopFilterInput!], $search: String) { organization { id name - accessibleShops( - first: $first - sort: SHOP_CREATED_AT_DESC - filters: [{field: STORE_STATUS, operator: EQUALS, value: "active"}] - ) { + accessibleShops(first: $first, sort: SHOP_CREATED_AT_DESC, filters: $filters, search: $search) { edges { node { id diff --git a/packages/store/src/cli/commands/store/delete.test.ts b/packages/store/src/cli/commands/store/delete.test.ts index 309bbf91d99..4b249c0a8c1 100644 --- a/packages/store/src/cli/commands/store/delete.test.ts +++ b/packages/store/src/cli/commands/store/delete.test.ts @@ -1,13 +1,17 @@ import StoreDelete from './delete.js' import {deleteDevStore} from '../../services/store/delete/dev.js' +import {selectDevStore} from '../../services/store/select.js' import {resolveOrganizationForStore} from '../../utilities/store-lookup/organization.js' import {AbortError} from '@shopify/cli-kit/node/error' import {outputResult} from '@shopify/cli-kit/node/output' +import {terminalSupportsPrompting} from '@shopify/cli-kit/node/system' import {isTTY, renderDangerousConfirmationPrompt} from '@shopify/cli-kit/node/ui' import {describe, expect, test, vi, beforeEach} from 'vitest' vi.mock('../../services/store/delete/dev.js') +vi.mock('../../services/store/select.js') vi.mock('../../utilities/store-lookup/organization.js') +vi.mock('@shopify/cli-kit/node/system') vi.mock('@shopify/cli-kit/node/output', async (importOriginal) => { const actual: Record = await importOriginal() @@ -30,7 +34,9 @@ const defaultOrg = {id: '12345', businessName: 'Test Org'} beforeEach(() => { vi.mocked(resolveOrganizationForStore).mockResolvedValue(defaultOrg) + vi.mocked(selectDevStore).mockResolvedValue({store: 'selected-store.myshopify.com', organization: defaultOrg}) vi.mocked(isTTY).mockReturnValue(true) + vi.mocked(terminalSupportsPrompting).mockReturnValue(true) vi.mocked(renderDangerousConfirmationPrompt).mockResolvedValue(true) }) @@ -69,6 +75,43 @@ describe('store delete command', () => { expect(deleteDevStore).toHaveBeenCalledWith(expect.objectContaining({organization: defaultOrg})) }) + test('prompts for a dev store when --store is omitted', async () => { + await StoreDelete.run([]) + + expect(selectDevStore).toHaveBeenCalledWith({ + organizationId: undefined, + message: 'Which dev store do you want to delete?', + }) + // The selector already knows which organization owns the store it returned. + expect(resolveOrganizationForStore).not.toHaveBeenCalled() + expect(deleteDevStore).toHaveBeenCalledWith({ + store: 'selected-store.myshopify.com', + organization: defaultOrg, + json: false, + }) + }) + + test('passes --organization-id through to the store selector', async () => { + await StoreDelete.run(['--organization-id', '12345']) + + expect(selectDevStore).toHaveBeenCalledWith(expect.objectContaining({organizationId: '12345'})) + }) + + test('confirms the selected store before deleting it', async () => { + await StoreDelete.run([]) + + expect(renderDangerousConfirmationPrompt).toHaveBeenCalledWith({ + message: `Delete dev store selected-store.myshopify.com? This can't be undone.`, + confirmation: 'selected-store.myshopify.com', + }) + }) + + test('does not prompt for a store when --store is provided', async () => { + await StoreDelete.run(['--store', 'my-store.myshopify.com']) + + expect(selectDevStore).not.toHaveBeenCalled() + }) + test('defines the expected flags', () => { expect(StoreDelete.flags.store).toBeDefined() expect(StoreDelete.flags['organization-id']).toBeDefined() diff --git a/packages/store/src/cli/commands/store/delete.ts b/packages/store/src/cli/commands/store/delete.ts index dfdac8e72dc..cfbd7201698 100644 --- a/packages/store/src/cli/commands/store/delete.ts +++ b/packages/store/src/cli/commands/store/delete.ts @@ -1,5 +1,6 @@ import {deleteDevStore} from '../../services/store/delete/dev.js' -import {storeFlags} from '../../flags.js' +import {selectDevStore, type SelectedDevStore} from '../../services/store/select.js' +import {selectableStoreFlag, storeFlags} from '../../flags.js' import {resolveOrganizationForStore} from '../../utilities/store-lookup/organization.js' import Command from '@shopify/cli-kit/node/base-command' import {globalFlags, jsonFlag} from '@shopify/cli-kit/node/cli' @@ -13,11 +14,14 @@ export default class StoreDelete extends Command { static summary = 'Delete a dev store.' - static descriptionWithMarkdown = 'Deletes a dev store from your organization.' + static descriptionWithMarkdown = `Deletes a dev store from your organization. + +When \`--store\` is omitted, the command prompts you to pick one of your organization's dev stores, so the flag is only required in non-interactive environments.` static description = this.descriptionWithoutMarkdown() static examples = [ + '<%= config.bin %> <%= command.id %>', '<%= config.bin %> <%= command.id %> --store shop.myshopify.com --organization-id 1234567', '<%= config.bin %> <%= command.id %> --store shop.myshopify.com --organization-id 1234567 --json', '<%= config.bin %> <%= command.id %> --store shop.myshopify.com --organization-id 1234567 --force', @@ -26,7 +30,7 @@ export default class StoreDelete extends Command { static flags = { ...globalFlags, ...jsonFlag, - store: storeFlags.store, + store: selectableStoreFlag, 'organization-id': storeFlags['organization-id'], force: Flags.boolean({ char: 'f', @@ -48,18 +52,18 @@ export default class StoreDelete extends Command { ]) } - const organization = await resolveOrganizationForStore(flags.store, flags['organization-id']?.toString()) + const {store, organization} = await resolveStoreToDelete(flags.store, flags['organization-id']?.toString()) if (!flags.force) { const confirmed = await renderDangerousConfirmationPrompt({ - message: `Delete dev store ${flags.store}? This can't be undone.`, - confirmation: flags.store, + message: `Delete dev store ${store}? This can't be undone.`, + confirmation: store, }) if (!confirmed) throw new AbortSilentError() } await deleteDevStore({ - store: flags.store, + store, organization, json: flags.json, }) @@ -85,3 +89,16 @@ export default class StoreDelete extends Command { } } } + +// A `--store` value only needs the organization that owns it looked up. Without one, the selector +// resolves the organization first and lists its dev stores to pick from. +async function resolveStoreToDelete( + store: string | undefined, + organizationId: string | undefined, +): Promise { + if (store) { + return {store, organization: await resolveOrganizationForStore(store, organizationId)} + } + + return selectDevStore({organizationId, message: 'Which dev store do you want to delete?'}) +} diff --git a/packages/store/src/cli/commands/store/non-interactive-flags.test.ts b/packages/store/src/cli/commands/store/non-interactive-flags.test.ts index dc3d2d4dbf1..3587c61f3d4 100644 --- a/packages/store/src/cli/commands/store/non-interactive-flags.test.ts +++ b/packages/store/src/cli/commands/store/non-interactive-flags.test.ts @@ -8,6 +8,7 @@ describe('non-interactive store command flags', () => { {command: StoreCreateDev, flag: 'name'}, {command: StoreCreateDev, flag: 'organization-id'}, {command: StoreCreateDev, flag: 'plan'}, + {command: StoreDelete, flag: 'store'}, ])('$command.name marks --$flag as required', ({command, flag}) => { const flags = command.flags as Record expect(flags[flag]!.requiredIfNonInteractive).toBe(true) diff --git a/packages/store/src/cli/flags.ts b/packages/store/src/cli/flags.ts index 8cf359262f4..088a6b0b3bd 100644 --- a/packages/store/src/cli/flags.ts +++ b/packages/store/src/cli/flags.ts @@ -2,6 +2,7 @@ import {normalizeStoreFqdn} from '@shopify/cli-kit/node/context/fqdn' import {normalizeBulkOperationId} from '@shopify/cli-kit/node/api/bulk-operations' import {resolvePath} from '@shopify/cli-kit/node/path' import {AbortError} from '@shopify/cli-kit/node/error' +import {requiredIfNonInteractive} from '@shopify/cli-kit/node/cli' import {Flags} from '@oclif/core' // Error message shown when a `--country` flag value is not a two-letter code. @@ -31,20 +32,30 @@ export const countryFlag = Flags.string({ }, }) +// Shared base for the `--store` flag so the domain normalization lives in one place. Commands +// reference either the required flag below or, when they can prompt for the store instead, the +// optional `selectableStoreFlag`. +const storeFlagBase = { + char: 's', + description: 'The myshopify.com domain of the store.', + env: 'SHOPIFY_FLAG_STORE', + parse: async (input: string) => normalizeStoreFqdn(input), +} as const + export const storeFlags = { - store: Flags.string({ - char: 's', - description: 'The myshopify.com domain of the store.', - env: 'SHOPIFY_FLAG_STORE', - parse: async (input) => normalizeStoreFqdn(input), - required: true, - }), + store: Flags.string({...storeFlagBase, required: true}), 'organization-id': Flags.integer({ description: 'The numeric organization ID. Auto-selects if you belong to a single organization.', env: 'SHOPIFY_FLAG_ORGANIZATION_ID', }), } +/** + * `--store` for commands that show a store selector when it's omitted, so the flag is only + * required where prompting is impossible. + */ +export const selectableStoreFlag = requiredIfNonInteractive(Flags.string(storeFlagBase)) + // Shared base for the bulk operation `--id` flag so the GID normalization lives in one place. // Commands reference the exported flags directly (status = optional, cancel = required). const bulkOperationIdBase = { diff --git a/packages/store/src/cli/services/store/list/bp-source.test.ts b/packages/store/src/cli/services/store/list/bp-source.test.ts index 16130ce8994..98dcb14ae63 100644 --- a/packages/store/src/cli/services/store/list/bp-source.test.ts +++ b/packages/store/src/cli/services/store/list/bp-source.test.ts @@ -64,11 +64,7 @@ describe('listBusinessPlatformStores', () => { vi.mocked(businessPlatformOrganizationsRequestDoc).mockResolvedValue(shopPage()) const result = await listBusinessPlatformStores({token: 'bp-token', organization}) - const requestOptions = latestBusinessPlatformRequestOptions() - expect(JSON.stringify(requestOptions.query)).toContain('STORE_STATUS') - expect(JSON.stringify(requestOptions.query)).toContain('EQUALS') - expect(JSON.stringify(requestOptions.query)).toContain('active') expect(result).toEqual({ entries: [ { @@ -84,7 +80,44 @@ describe('listBusinessPlatformStores', () => { hasMore: false, }) expect(businessPlatformOrganizationsRequestDoc).toHaveBeenCalledWith( - expect.objectContaining({token: 'bp-token', organizationId: '1234', variables: {first: 250}}), + expect.objectContaining({ + token: 'bp-token', + organizationId: '1234', + variables: { + first: 250, + filters: [{field: 'STORE_STATUS', operator: 'EQUALS', value: 'active'}], + search: undefined, + }, + }), + ) + }) + + test('narrows the query to one store type when the caller asks for it', async () => { + vi.mocked(businessPlatformOrganizationsRequestDoc).mockResolvedValue(shopPage()) + + await listBusinessPlatformStores({token: 'bp-token', organization, storeTypeFilter: 'development_superset'}) + + expect(businessPlatformOrganizationsRequestDoc).toHaveBeenCalledWith( + expect.objectContaining({ + variables: { + first: 250, + filters: [ + {field: 'STORE_STATUS', operator: 'EQUALS', value: 'active'}, + {field: 'STORE_TYPE', operator: 'EQUALS', value: 'development_superset'}, + ], + search: undefined, + }, + }), + ) + }) + + test('passes a search term through to the query', async () => { + vi.mocked(businessPlatformOrganizationsRequestDoc).mockResolvedValue(shopPage()) + + await listBusinessPlatformStores({token: 'bp-token', organization, searchTerm: 'acme'}) + + expect(businessPlatformOrganizationsRequestDoc).toHaveBeenCalledWith( + expect.objectContaining({variables: expect.objectContaining({search: 'acme'})}), ) }) @@ -139,7 +172,13 @@ describe('listBusinessPlatformStores', () => { expect(result.entries.map((entry) => entry.store)).toEqual(['newer.myshopify.com', 'older.myshopify.com']) expect(businessPlatformOrganizationsRequestDoc).toHaveBeenCalledTimes(1) expect(businessPlatformOrganizationsRequestDoc).toHaveBeenCalledWith( - expect.objectContaining({variables: {first: 250}}), + expect.objectContaining({ + variables: { + first: 250, + filters: [{field: 'STORE_STATUS', operator: 'EQUALS', value: 'active'}], + search: undefined, + }, + }), ) }) diff --git a/packages/store/src/cli/services/store/list/bp-source.ts b/packages/store/src/cli/services/store/list/bp-source.ts index 90e9180c274..30be4a6fb86 100644 --- a/packages/store/src/cli/services/store/list/bp-source.ts +++ b/packages/store/src/cli/services/store/list/bp-source.ts @@ -6,6 +6,7 @@ import { ListAccessibleShops, type ListAccessibleShopsQuery, } from '../../../api/graphql/business-platform-organizations/generated/list_accessible_shops.js' +import {type ShopFilterInput} from '../../../api/graphql/business-platform-organizations/generated/types.js' import {businessPlatformOrganizationsRequestDoc} from '@shopify/cli-kit/node/api/business-platform' import {extractHost} from '@shopify/cli-kit/common/url' import {type Organization} from '@shopify/organizations' @@ -13,6 +14,10 @@ import {type Organization} from '@shopify/organizations' interface ListBusinessPlatformStoresOptions { token: string organization: Organization + // A BP `STORE_TYPE` filter value, to list only the stores of that type. + storeTypeFilter?: string + // Free-text term BP matches against store names and domains. + searchTerm?: string } interface BusinessPlatformStoreListResult { @@ -24,7 +29,7 @@ interface BusinessPlatformStoreListResult { export async function listBusinessPlatformStores( options: ListBusinessPlatformStoresOptions, ): Promise { - const {entries, hasMore} = await fetchOrganizationStores(options.token, options.organization) + const {entries, hasMore} = await fetchOrganizationStores(options) return { entries: entries.sort(byCreatedAtDescending), @@ -35,16 +40,16 @@ export async function listBusinessPlatformStores( // Fetches one server-sorted page of the selected organization's newest stores. The page size is the // maximum number of stores we can display, and hasMore reflects whether more stores exist beyond it. async function fetchOrganizationStores( - token: string, - organization: Organization, + options: ListBusinessPlatformStoresOptions, ): Promise<{entries: StoreListEntry[]; hasMore: boolean}> { + const {token, organization} = options const unauthorizedHandler = businessPlatformTokenRefreshHandler() const result = await businessPlatformOrganizationsRequestDoc({ query: ListAccessibleShops, token, organizationId: organization.id, - variables: {first: STORE_LIST_LIMIT}, + variables: {first: STORE_LIST_LIMIT, filters: shopFilters(options.storeTypeFilter), search: options.searchTerm}, unauthorizedHandler, }) @@ -60,6 +65,17 @@ async function fetchOrganizationStores( return {entries, hasMore: accessibleShops.pageInfo.hasNextPage} } +// The active-store filter every listing shares, plus the caller's store type when it asked for one. +function shopFilters(storeTypeFilter: string | undefined): ShopFilterInput[] { + const filters: ShopFilterInput[] = [{field: 'STORE_STATUS', operator: 'EQUALS', value: 'active'}] + + if (storeTypeFilter) { + filters.push({field: 'STORE_TYPE', operator: 'EQUALS', value: storeTypeFilter}) + } + + return filters +} + type ShopNode = NonNullable< NonNullable['accessibleShops']>['edges'][number]['node'] > diff --git a/packages/store/src/cli/services/store/select.test.ts b/packages/store/src/cli/services/store/select.test.ts new file mode 100644 index 00000000000..e1ee13cd242 --- /dev/null +++ b/packages/store/src/cli/services/store/select.test.ts @@ -0,0 +1,129 @@ +import {selectDevStore} from './select.js' +import * as bpSource from './list/bp-source.js' +import {type StoreListEntry} from './list/types.js' +import {describe, expect, test, vi} from 'vitest' +import {AbortError} from '@shopify/cli-kit/node/error' +import {ensureAuthenticatedBusinessPlatform} from '@shopify/cli-kit/node/session' +import {renderAutocompletePrompt} from '@shopify/cli-kit/node/ui' +import {selectOrg} from '@shopify/organizations' + +vi.mock('@shopify/cli-kit/node/session') +vi.mock('@shopify/cli-kit/node/ui') +vi.mock('@shopify/organizations', async (importOriginal) => { + const actual: Record = await importOriginal() + return {...actual, selectOrg: vi.fn()} +}) + +const acme = {id: '1234', businessName: 'Acme'} + +function storeEntry(overrides: Partial = {}): StoreListEntry { + return { + store: 'shop.myshopify.com', + createdAt: '2026-01-15T00:00:00Z', + organizationId: '1234', + organizationName: 'Acme', + name: 'Shop', + type: 'dev', + ...overrides, + } +} + +function mockStores(entries: StoreListEntry[], hasMore = false) { + vi.mocked(ensureAuthenticatedBusinessPlatform).mockResolvedValue('bp-token') + vi.mocked(selectOrg).mockResolvedValue(acme) + return vi.spyOn(bpSource, 'listBusinessPlatformStores').mockResolvedValue({entries, hasMore}) +} + +describe('selectDevStore', () => { + test('asks Business Platform for the organization dev stores and returns the selection', async () => { + const listStores = mockStores([ + storeEntry({store: 'first.myshopify.com', name: 'First'}), + storeEntry({store: 'second.myshopify.com', name: 'Second'}), + ]) + vi.mocked(renderAutocompletePrompt).mockResolvedValue('second.myshopify.com') + + const selected = await selectDevStore({organizationId: '1234', message: 'Which dev store?'}) + + expect(selectOrg).toHaveBeenCalledWith('1234') + // The store type is filtered server-side, so the page holds dev stores rather than a mix. + expect(listStores).toHaveBeenCalledWith({ + token: 'bp-token', + organization: acme, + storeTypeFilter: 'development_superset', + }) + expect(renderAutocompletePrompt).toHaveBeenCalledWith({ + message: 'Which dev store?', + choices: [ + {label: 'First (first.myshopify.com)', value: 'first.myshopify.com'}, + {label: 'Second (second.myshopify.com)', value: 'second.myshopify.com'}, + ], + hasMorePages: false, + search: expect.any(Function), + }) + expect(selected).toEqual({store: 'second.myshopify.com', organization: acme}) + }) + + test('resolves the organization without an ID when none is provided', async () => { + mockStores([storeEntry()]) + vi.mocked(renderAutocompletePrompt).mockResolvedValue('shop.myshopify.com') + + await selectDevStore({message: 'Which dev store?'}) + + expect(selectOrg).toHaveBeenCalledWith(undefined) + }) + + test('prompts for a lone dev store rather than auto-selecting it', async () => { + mockStores([storeEntry()]) + vi.mocked(renderAutocompletePrompt).mockResolvedValue('shop.myshopify.com') + + const selected = await selectDevStore({message: 'Which dev store?'}) + + expect(renderAutocompletePrompt).toHaveBeenCalledOnce() + expect(selected).toEqual({store: 'shop.myshopify.com', organization: acme}) + }) + + test('labels a store without a name by its domain alone', async () => { + mockStores([storeEntry({name: undefined})]) + vi.mocked(renderAutocompletePrompt).mockResolvedValue('shop.myshopify.com') + + await selectDevStore({message: 'Which dev store?'}) + + expect(renderAutocompletePrompt).toHaveBeenCalledWith( + expect.objectContaining({choices: [{label: 'shop.myshopify.com', value: 'shop.myshopify.com'}]}), + ) + }) + + test('searches Business Platform for stores beyond the fetched page', async () => { + const listStores = mockStores([storeEntry({store: 'first.myshopify.com', name: 'First'})], true) + vi.mocked(renderAutocompletePrompt).mockImplementation(async ({search}) => { + listStores.mockResolvedValue({entries: [storeEntry({store: 'far.myshopify.com', name: 'Far'})], hasMore: false}) + const results = await search!('far') + expect(results.data).toEqual([{label: 'Far (far.myshopify.com)', value: 'far.myshopify.com'}]) + return 'far.myshopify.com' + }) + + const selected = await selectDevStore({message: 'Which dev store?'}) + + expect(listStores).toHaveBeenLastCalledWith({ + token: 'bp-token', + organization: acme, + storeTypeFilter: 'development_superset', + searchTerm: 'far', + }) + // The prompt reports the extra pages, so its hint to type a name is accurate. + expect(renderAutocompletePrompt).toHaveBeenCalledWith(expect.objectContaining({hasMorePages: true})) + expect(selected).toEqual({store: 'far.myshopify.com', organization: acme}) + }) + + test('aborts when the organization has no dev stores', async () => { + mockStores([]) + + await expect(selectDevStore({message: 'Which dev store?'})).rejects.toThrow( + new AbortError( + 'No dev stores found in Acme.', + 'Create one with `shopify store create dev --organization-id 1234`.', + ), + ) + expect(renderAutocompletePrompt).not.toHaveBeenCalled() + }) +}) diff --git a/packages/store/src/cli/services/store/select.ts b/packages/store/src/cli/services/store/select.ts new file mode 100644 index 00000000000..325fc641380 --- /dev/null +++ b/packages/store/src/cli/services/store/select.ts @@ -0,0 +1,63 @@ +import {listBusinessPlatformStores} from './list/bp-source.js' +import {type StoreListEntry} from './list/types.js' +import {DEV_STORE_TYPE_FILTER} from './store-type.js' +import {AbortError} from '@shopify/cli-kit/node/error' +import {ensureAuthenticatedBusinessPlatform} from '@shopify/cli-kit/node/session' +import {selectOrg, storeChoiceList, type Organization, type StoreChoice} from '@shopify/organizations' +import {renderAutocompletePrompt} from '@shopify/cli-kit/node/ui' + +interface SelectDevStoreOptions { + // The `--organization-id` flag value, when one was given. Skips the organization prompt. + organizationId?: string + // The question shown above the store choices. + message: string +} + +export interface SelectedDevStore { + store: string + organization: Organization +} + +/** + * Prompts for a dev store when a command was run without `--store`: first for the organization that + * owns it (skipped when `--organization-id` was given, or when only one organization is available), + * then for one of that organization's dev stores. + */ +export async function selectDevStore(options: SelectDevStoreOptions): Promise { + const organization = await selectOrg(options.organizationId) + const token = await ensureAuthenticatedBusinessPlatform() + const listDevStores = async (searchTerm?: string) => { + const {entries, hasMore} = await listBusinessPlatformStores({ + token, + organization, + storeTypeFilter: DEV_STORE_TYPE_FILTER, + ...(searchTerm ? {searchTerm} : {}), + }) + + return {stores: entries, hasMorePages: hasMore} + } + + const {stores, hasMorePages} = await listDevStores() + if (stores.length === 0) { + throw new AbortError( + `No dev stores found in ${organization.businessName}.`, + `Create one with \`shopify store create dev --organization-id ${organization.id}\`.`, + ) + } + + const {promptProps, storeFor} = storeChoiceList({stores, toChoice: toStoreChoice, onSearch: listDevStores}) + + // A lone dev store is still offered as a choice rather than auto-selected: the caller is about to + // act on it, so the developer should see which store that is before confirming. + const selectedValue = await renderAutocompletePrompt({message: options.message, hasMorePages, ...promptProps}) + const selected = storeFor(selectedValue) + + // Every choice offered is a store, so the prompt can only submit one we can resolve. + if (!selected) throw new AbortError('No dev store was selected.') + + return {store: selected.store, organization} +} + +function toStoreChoice(entry: StoreListEntry): StoreChoice { + return {id: entry.store, domain: entry.store, ...(entry.name ? {name: entry.name} : {})} +} diff --git a/packages/store/src/cli/services/store/store-type.ts b/packages/store/src/cli/services/store/store-type.ts index da051566b0f..b98fb692fb7 100644 --- a/packages/store/src/cli/services/store/store-type.ts +++ b/packages/store/src/cli/services/store/store-type.ts @@ -13,6 +13,11 @@ const STORE_TYPE_HANDLES: {[key in Store]: string} = { PRODUCTION: 'production', } +// BP's `STORE_TYPE` filter alias for "development OR app_development", so one query returns both +// the legacy dev store and the kind `store create dev` makes. Asking BP for dev stores keeps the +// page size meaningful, which filtering the response client-side would not. +export const DEV_STORE_TYPE_FILTER = 'development_superset' + // Returns undefined for an unrecognized value (e.g. a newer enum member than the generated types // know about) so the field is omitted rather than shown as a guessed handle. export function storeTypeHandle(storeType: string | null | undefined): string | undefined {