From f8ab0a298cf93a0e2ff36db5c9613961422c145c Mon Sep 17 00:00:00 2001 From: Ariel Caplan Date: Thu, 3 Sep 2026 21:50:18 +0300 Subject: [PATCH] Show each store's plan in `store list` MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `store list` reported Subdomain, Name, Type and Created, so telling a Plus store from a Basic one meant running `store info` per store. `accessibleShops` already exposes `planName`, so the listing query now selects it and the table gains a `Plan` column. The plan-handle mapper moves up beside its sibling `store-type.ts` — both `store info` and `store list` map a raw BP plan name to a public handle now — and gains a `planLabel` for the column, mirroring `storeTypeLabel`. An unrecognized plan has no handle, so the cell is left blank rather than showing a raw internal plan name, matching how `store info` omits the row. Co-Authored-By: Claude Opus 5 (1M context) Assisted-By: devx/e362b752-c5f2-41f2-8a72-a957ead40228 --- .changeset/store-list-plan.md | 5 ++ .../generated/list_accessible_shops.ts | 2 + .../queries/list_accessible_shops.graphql | 1 + .../store/src/cli/services/store/constants.ts | 3 +- .../src/cli/services/store/info/index.ts | 4 +- .../src/cli/services/store/info/plan.test.ts | 33 ------------- .../store/src/cli/services/store/info/plan.ts | 10 ---- .../cli/services/store/list/bp-source.test.ts | 12 +++++ .../src/cli/services/store/list/bp-source.ts | 2 + .../cli/services/store/list/result.test.ts | 37 +++++++++++++-- .../src/cli/services/store/list/result.ts | 3 ++ .../src/cli/services/store/list/types.ts | 1 + .../store/src/cli/services/store/plan.test.ts | 47 +++++++++++++++++++ packages/store/src/cli/services/store/plan.ts | 17 +++++++ 14 files changed, 127 insertions(+), 50 deletions(-) create mode 100644 .changeset/store-list-plan.md delete mode 100644 packages/store/src/cli/services/store/info/plan.test.ts delete mode 100644 packages/store/src/cli/services/store/info/plan.ts create mode 100644 packages/store/src/cli/services/store/plan.test.ts create mode 100644 packages/store/src/cli/services/store/plan.ts 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) : '' +}