diff --git a/.changeset/store-list-plan.md b/.changeset/store-list-plan.md new file mode 100644 index 00000000000..f77e02c274c --- /dev/null +++ b/.changeset/store-list-plan.md @@ -0,0 +1,5 @@ +--- +'@shopify/store': minor +--- + +Show each store's plan in `shopify store list` 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..bd4e7efb10d 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 @@ -18,6 +18,7 @@ export type ListAccessibleShopsQuery = { shopifyShopId?: string | null name: string storeType?: Types.Store | null + planName?: string | null primaryDomain?: string | null url?: string | null createdAt: unknown @@ -116,6 +117,7 @@ export const ListAccessibleShops = { {kind: 'Field', name: {kind: 'Name', value: 'shopifyShopId'}}, {kind: 'Field', name: {kind: 'Name', value: 'name'}}, {kind: 'Field', name: {kind: 'Name', value: 'storeType'}}, + {kind: 'Field', name: {kind: 'Name', value: 'planName'}}, {kind: 'Field', name: {kind: 'Name', value: 'primaryDomain'}}, {kind: 'Field', name: {kind: 'Name', value: 'url'}}, {kind: 'Field', name: {kind: 'Name', value: 'createdAt'}}, 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..d0f2420e217 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 @@ -13,6 +13,7 @@ query ListAccessibleShops($first: Int!) { shopifyShopId name storeType + planName primaryDomain url createdAt diff --git a/packages/store/src/cli/services/store/constants.ts b/packages/store/src/cli/services/store/constants.ts index 6540ac548d8..bea841e836c 100644 --- a/packages/store/src/cli/services/store/constants.ts +++ b/packages/store/src/cli/services/store/constants.ts @@ -2,7 +2,8 @@ export {devStorePlanHandles} from '@shopify/organizations' export type {DevStorePlan} from '@shopify/organizations' /** - * `store info`: a raw BP plan name (`Shop.planName`) → the public plan handle it reports. + * A raw BP plan name (`Shop.planName`) → the public plan handle reported by `store info` and + * `store list`. * The raw names are Shopify-internal and intentionally differ from the marketing names * (e.g. `professional` is Grow, `unlimited` is Advanced). The public handle is also accepted * as a key, because the exact form BP returns isn't pinned down by the schema. Anything not diff --git a/packages/store/src/cli/services/store/info/index.ts b/packages/store/src/cli/services/store/info/index.ts index 3472a91d901..f0499157d43 100644 --- a/packages/store/src/cli/services/store/info/index.ts +++ b/packages/store/src/cli/services/store/info/index.ts @@ -1,9 +1,9 @@ -import {mapPlanToPublicHandle} from './plan.js' import {classifyAdminApiError, throwIfStoredStoreAuthIsInvalid} from '../admin-errors.js' import {recordStoreFqdnMetadata} from '../attribution.js' import {throwStoredAuthInvalidError} from '../auth/recovery.js' import {loadStoredStoreSession} from '../auth/session-lifecycle.js' import {getPreviewStore, PreviewStoreRequestError} from '../create/preview/client.js' +import {planHandle} from '../plan.js' import {storeTypeHandle} from '../store-type.js' import {StoreLookupStoreNotFoundError, fetchDestinationsContext} from '../../../utilities/store-lookup/destinations.js' import {fetchOrganizationShop} from '../../../utilities/store-lookup/organization-shop.js' @@ -242,7 +242,7 @@ function buildBusinessPlatformResult(args: BuildBusinessPlatformResultArgs): Sto organizationName: destinationsCtx.owningOrg?.name, storeOwner: buildBusinessPlatformStoreOwner(orgShop), type: storeTypeHandle(orgShop?.storeType), - plan: mapPlanToPublicHandle(orgShop?.planName), + plan: planHandle(orgShop?.planName), featurePreview: orgShop?.developerPreviewHandle, adminUrl: buildAdminUrl(extractMyshopifyHandle(store)), } diff --git a/packages/store/src/cli/services/store/info/plan.test.ts b/packages/store/src/cli/services/store/info/plan.test.ts deleted file mode 100644 index e893b93b2e1..00000000000 --- a/packages/store/src/cli/services/store/info/plan.test.ts +++ /dev/null @@ -1,33 +0,0 @@ -import {mapPlanToPublicHandle} from './plan.js' -import {describe, test, expect} from 'vitest' - -describe('mapPlanToPublicHandle', () => { - test('maps internal plan names to public handles', () => { - expect(mapPlanToPublicHandle('basic')).toBe('basic') - expect(mapPlanToPublicHandle('professional')).toBe('grow') - expect(mapPlanToPublicHandle('unlimited')).toBe('advanced') - expect(mapPlanToPublicHandle('shopify_plus')).toBe('plus') - }) - - test('accepts the public handles themselves', () => { - expect(mapPlanToPublicHandle('grow')).toBe('grow') - expect(mapPlanToPublicHandle('advanced')).toBe('advanced') - expect(mapPlanToPublicHandle('plus')).toBe('plus') - }) - - test('is case-insensitive', () => { - expect(mapPlanToPublicHandle('Professional')).toBe('grow') - expect(mapPlanToPublicHandle('SHOPIFY_PLUS')).toBe('plus') - }) - - test('returns undefined for unrecognized plans', () => { - expect(mapPlanToPublicHandle('staff')).toBeUndefined() - expect(mapPlanToPublicHandle('development_legacy')).toBeUndefined() - expect(mapPlanToPublicHandle('some_new_plan')).toBeUndefined() - }) - - test('returns undefined when no plan is provided', () => { - expect(mapPlanToPublicHandle(undefined)).toBeUndefined() - expect(mapPlanToPublicHandle('')).toBeUndefined() - }) -}) diff --git a/packages/store/src/cli/services/store/info/plan.ts b/packages/store/src/cli/services/store/info/plan.ts deleted file mode 100644 index 6add4fa5501..00000000000 --- a/packages/store/src/cli/services/store/info/plan.ts +++ /dev/null @@ -1,10 +0,0 @@ -import {PLAN_HANDLES_BY_NAME} from '../constants.js' - -/** - * Maps a raw BP plan name (`Shop.planName`) to its public handle, or undefined when the plan - * isn't recognized. Matching is case-insensitive; see {@link PLAN_HANDLES_BY_NAME}. - */ -export function mapPlanToPublicHandle(planName: string | undefined): string | undefined { - if (!planName) return undefined - return PLAN_HANDLES_BY_NAME[planName.toLowerCase()] -} 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..f935d6a08bd 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 @@ -21,6 +21,7 @@ function accessibleShopNode(overrides: Partial = {}): Access shopifyShopId: '1', name: 'Acme Production', storeType: 'PRODUCTION', + planName: 'shopify_plus', primaryDomain: 'acme.myshopify.com', url: null, createdAt: '2026-01-15T00:00:00Z', @@ -79,6 +80,7 @@ describe('listBusinessPlatformStores', () => { organizationName: 'Acme', name: 'Acme Production', type: 'production', + plan: 'plus', }, ], hasMore: false, @@ -111,6 +113,16 @@ describe('listBusinessPlatformStores', () => { expect(result).toEqual({entries: [], hasMore: false}) }) + test('omits the plan for an unrecognized plan name', async () => { + vi.mocked(businessPlatformOrganizationsRequestDoc).mockResolvedValue( + shopPage({shops: [accessibleShopNode({planName: 'some_new_plan'})]}), + ) + + const result = await listBusinessPlatformStores({token: 'bp-token', organization}) + + expect(result.entries[0]?.plan).toBeUndefined() + }) + test('fetches a single bounded page for the selected organization and orders newest first', async () => { vi.mocked(businessPlatformOrganizationsRequestDoc).mockResolvedValue( shopPage({ 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..780d0e1c257 100644 --- a/packages/store/src/cli/services/store/list/bp-source.ts +++ b/packages/store/src/cli/services/store/list/bp-source.ts @@ -1,6 +1,7 @@ import {STORE_LIST_LIMIT} from './constants.js' import {type StoreListEntry} from './types.js' import {businessPlatformTokenRefreshHandler} from '../business-platform.js' +import {planHandle} from '../plan.js' import {storeTypeHandle} from '../store-type.js' import { ListAccessibleShops, @@ -80,6 +81,7 @@ function toStoreListEntry(node: ShopNode, organization: Organization): StoreList organizationName: organization.businessName, name: node.name, type: storeTypeHandle(node.storeType), + plan: planHandle(node.planName), } } diff --git a/packages/store/src/cli/services/store/list/result.test.ts b/packages/store/src/cli/services/store/list/result.test.ts index e68b3291225..80527ae2bf1 100644 --- a/packages/store/src/cli/services/store/list/result.test.ts +++ b/packages/store/src/cli/services/store/list/result.test.ts @@ -9,7 +9,7 @@ describe('writeStoreListResult', () => { mockAndCaptureOutput().clear() }) - test('renders organization context and rows with subdomain, name, type, and created date', () => { + test('renders organization context and rows with subdomain, name, type, plan, and created date', () => { const output = mockAndCaptureOutput() writeStoreListResult( @@ -25,6 +25,7 @@ describe('writeStoreListResult', () => { organizationName: 'Acme', name: 'My Shop', type: 'dev', + plan: 'plus', }, ], }, @@ -37,6 +38,7 @@ describe('writeStoreListResult', () => { expect(output.info()).not.toContain('my-shop.myshopify.com') expect(output.info()).toContain('My Shop') expect(output.info()).toContain('Dev') + expect(output.info()).toContain('Plus') expect(output.info()).toContain('May 22, 2026') expect(output.info()).toContain('shopify store auth list') }) @@ -56,6 +58,7 @@ describe('writeStoreListResult', () => { organizationName: 'Acme', name: 'My Shop', type: 'dev', + plan: 'grow', }, ], }, @@ -74,12 +77,36 @@ describe('writeStoreListResult', () => { │ │ ╰──────────────────────────────────────────────────────────────────────────────╯ - Subdomain Name Type Created - ───────── ─────── ──── ──────────── - my-shop My Shop Dev May 22, 2026" + Subdomain Name Type Plan Created + ───────── ─────── ──── ──── ──────────── + my-shop My Shop Dev Grow May 22, 2026" `) }) + test('leaves the plan column blank when the plan is unrecognized', () => { + const output = mockAndCaptureOutput() + + writeStoreListResult( + { + source: 'organization', + organization, + stores: [ + { + store: 'my-shop.myshopify.com', + createdAt: '2026-05-22T00:00:00Z', + organizationId: '1234', + organizationName: 'Acme', + name: 'My Shop', + type: 'dev', + }, + ], + }, + 'text', + ) + + expect(trimmedLines(output.info())).toContain('my-shop My Shop Dev May 22, 2026') + }) + test('renders the subdomain handle for non-myshopify hosts (local dev)', () => { const output = mockAndCaptureOutput() @@ -167,6 +194,7 @@ describe('writeStoreListResult', () => { organizationName: 'Acme', name: 'My Shop', type: 'dev', + plan: 'plus', }, ], }, @@ -183,6 +211,7 @@ describe('writeStoreListResult', () => { organizationName: 'Acme', name: 'My Shop', type: 'dev', + plan: 'plus', }, ], organization, diff --git a/packages/store/src/cli/services/store/list/result.ts b/packages/store/src/cli/services/store/list/result.ts index 721d5007319..424a3621be2 100644 --- a/packages/store/src/cli/services/store/list/result.ts +++ b/packages/store/src/cli/services/store/list/result.ts @@ -1,6 +1,7 @@ import {STORE_LIST_LIMIT} from './constants.js' import {type ListStoresResult, type StoreListEntry, type StoreListOrganization} from './types.js' import {extractSubdomain, formatShortDate} from '../display.js' +import {planLabel} from '../plan.js' import {storeTypeLabel} from '../store-type.js' import {outputResult, outputWarn} from '@shopify/cli-kit/node/output' import {renderInfo, renderTable, type AlertCustomSection, type TokenItem} from '@shopify/cli-kit/node/ui' @@ -81,12 +82,14 @@ function renderOrganizationTable(stores: StoreListEntry[]): void { subdomain: subdomainFor(entry.store), name: entry.name ?? '', type: storeTypeLabel(entry.type), + plan: planLabel(entry.plan), created: formatShortDate(entry.createdAt), })), columns: { subdomain: {header: 'Subdomain'}, name: {header: 'Name'}, type: {header: 'Type'}, + plan: {header: 'Plan'}, created: {header: 'Created'}, }, }) diff --git a/packages/store/src/cli/services/store/list/types.ts b/packages/store/src/cli/services/store/list/types.ts index 274a62c77f9..04dfcb8ebc2 100644 --- a/packages/store/src/cli/services/store/list/types.ts +++ b/packages/store/src/cli/services/store/list/types.ts @@ -6,6 +6,7 @@ export interface StoreListEntry { organizationName: string name?: string type?: string + plan?: string } export interface StoreListOrganization { diff --git a/packages/store/src/cli/services/store/plan.test.ts b/packages/store/src/cli/services/store/plan.test.ts new file mode 100644 index 00000000000..eda412afcae --- /dev/null +++ b/packages/store/src/cli/services/store/plan.test.ts @@ -0,0 +1,47 @@ +import {planHandle, planLabel} from './plan.js' +import {describe, test, expect} from 'vitest' + +describe('planHandle', () => { + test('maps internal plan names to public handles', () => { + expect(planHandle('basic')).toBe('basic') + expect(planHandle('professional')).toBe('grow') + expect(planHandle('unlimited')).toBe('advanced') + expect(planHandle('shopify_plus')).toBe('plus') + }) + + test('accepts the public handles themselves', () => { + expect(planHandle('grow')).toBe('grow') + expect(planHandle('advanced')).toBe('advanced') + expect(planHandle('plus')).toBe('plus') + }) + + test('is case-insensitive', () => { + expect(planHandle('Professional')).toBe('grow') + expect(planHandle('SHOPIFY_PLUS')).toBe('plus') + }) + + test('returns undefined for unrecognized plans', () => { + expect(planHandle('staff')).toBeUndefined() + expect(planHandle('development_legacy')).toBeUndefined() + expect(planHandle('some_new_plan')).toBeUndefined() + }) + + test('returns undefined when no plan is provided', () => { + expect(planHandle(undefined)).toBeUndefined() + expect(planHandle(null)).toBeUndefined() + expect(planHandle('')).toBeUndefined() + }) +}) + +describe('planLabel', () => { + test('title-cases the public handle', () => { + expect(planLabel('basic')).toBe('Basic') + expect(planLabel('grow')).toBe('Grow') + expect(planLabel('advanced')).toBe('Advanced') + expect(planLabel('plus')).toBe('Plus') + }) + + test('renders an empty column for an unrecognized plan', () => { + expect(planLabel(undefined)).toBe('') + }) +}) diff --git a/packages/store/src/cli/services/store/plan.ts b/packages/store/src/cli/services/store/plan.ts new file mode 100644 index 00000000000..2177931d556 --- /dev/null +++ b/packages/store/src/cli/services/store/plan.ts @@ -0,0 +1,17 @@ +import {PLAN_HANDLES_BY_NAME} from './constants.js' +import {capitalizeWords} from '@shopify/cli-kit/common/string' + +/** + * Maps a raw BP plan name (`Shop.planName`) to its public handle, or undefined when the plan + * isn't recognized. Matching is case-insensitive; see {@link PLAN_HANDLES_BY_NAME}. + */ +export function planHandle(planName: string | null | undefined): string | undefined { + if (!planName) return undefined + return PLAN_HANDLES_BY_NAME[planName.toLowerCase()] +} + +// Title-cased label for the `store list` table column (`plus` -> `Plus`). Unrecognized plans have +// no handle, so the column is left blank rather than showing a raw internal plan name. +export function planLabel(handle: string | undefined): string { + return handle ? capitalizeWords(handle) : '' +}