diff --git a/.changeset/store-list-bp-auto.md b/.changeset/store-list-bp-auto.md new file mode 100644 index 00000000000..9bff6d63cee --- /dev/null +++ b/.changeset/store-list-bp-auto.md @@ -0,0 +1,8 @@ +--- +'@shopify/cli': minor +'@shopify/store': minor +'@shopify/organizations': minor +'@shopify/cli-kit': minor +--- + +Add `shopify store list` to list the stores in the Shopify organizations available to the current CLI account. diff --git a/.gitignore b/.gitignore index 893d5f0a5dd..5bee3d7cdab 100644 --- a/.gitignore +++ b/.gitignore @@ -191,6 +191,7 @@ packages/ui-extensions-server-kit/index.* packages/ui-extensions-server-kit/testing.* packages/ui-extensions-server-kit/dist packages/app/src/cli/api/graphql/*/*_schema.graphql +packages/store/src/cli/api/graphql/business-platform-organizations/organizations_schema.graphql packages/ui-extensions-test-utils/*.d.ts packages/ui-extensions-test-utils/!typings.d.ts packages/ui-extensions-test-utils/dist diff --git a/packages/app/src/cli/services/app/config/link-service.test.ts b/packages/app/src/cli/services/app/config/link-service.test.ts index 124ee465d0f..1fb6aba1059 100644 --- a/packages/app/src/cli/services/app/config/link-service.test.ts +++ b/packages/app/src/cli/services/app/config/link-service.test.ts @@ -4,6 +4,7 @@ import {DeveloperPlatformClient, selectDeveloperPlatformClient} from '../../../u import {OrganizationApp, OrganizationSource} from '../../../models/organization.js' import {appNamePrompt, createAsNewAppPrompt} from '../../../prompts/dev.js' import {selectConfigName} from '../../../prompts/config.js' +import {fetchOrganizations} from '../../dev/fetch.js' import {selectOrganizationPrompt} from '@shopify/organizations' import {beforeEach, describe, expect, test, vi} from 'vitest' import {inTemporaryDirectory, readFile, writeFileSync} from '@shopify/cli-kit/node/fs' @@ -21,6 +22,9 @@ vi.mock('../../../models/app/validation/multi-cli-warning.js') beforeEach(async () => { // Default mock for selectConfigName - tests that need a specific value can override vi.mocked(selectConfigName).mockResolvedValue('shopify.app.toml') + vi.mocked(fetchOrganizations).mockResolvedValue([ + {id: '12345', businessName: 'test', source: OrganizationSource.BusinessPlatform}, + ]) }) function buildDeveloperPlatformClient(): DeveloperPlatformClient { diff --git a/packages/app/src/cli/services/context.ts b/packages/app/src/cli/services/context.ts index fec74a35c74..41555451f9d 100644 --- a/packages/app/src/cli/services/context.ts +++ b/packages/app/src/cli/services/context.ts @@ -320,8 +320,10 @@ export async function fetchOrCreateOrganizationApp( */ export async function selectOrg(): Promise { const orgs = await fetchOrganizations() - const org = await selectOrganizationPrompt(orgs) - return org + if (orgs.length === 0) { + throw new AbortError('No organizations found.', 'Make sure you have access to a Shopify organization.') + } + return selectOrganizationPrompt(orgs) } interface ReusedValuesOptions { diff --git a/packages/cli-kit/src/private/node/session/store.test.ts b/packages/cli-kit/src/private/node/session/store.test.ts index 816011cd773..2d2ab6b0fd8 100644 --- a/packages/cli-kit/src/private/node/session/store.test.ts +++ b/packages/cli-kit/src/private/node/session/store.test.ts @@ -71,6 +71,51 @@ describe('session store', () => { // Then expect(result).toEqual(mockSessions) }) + + test('returns undefined and discards malformed JSON session content', async () => { + // Given + vi.mocked(getSessions).mockReturnValue('{not valid json') + + // When + const result = await fetch() + + // Then + expect(result).toBeUndefined() + expect(removeSessions).toHaveBeenCalled() + expect(removeCurrentSessionId).toHaveBeenCalled() + }) + + test('returns undefined and discards schema-invalid session content', async () => { + // Given + vi.mocked(getSessions).mockReturnValue(JSON.stringify({not: 'a valid sessions object'})) + vi.mocked(removeSessions).mockClear() + vi.mocked(removeCurrentSessionId).mockClear() + + // When + const result = await fetch() + + // Then + expect(result).toBeUndefined() + expect(removeSessions).toHaveBeenCalled() + expect(removeCurrentSessionId).toHaveBeenCalled() + }) + + test('rethrows non-SyntaxError parse failures without discarding sessions', async () => { + // Given + vi.mocked(getSessions).mockReturnValue(JSON.stringify(mockSessions)) + vi.mocked(removeSessions).mockClear() + vi.mocked(removeCurrentSessionId).mockClear() + const parseSpy = vi.spyOn(JSON, 'parse').mockImplementation(() => { + throw new Error('unexpected parse failure') + }) + + // Then + await expect(fetch()).rejects.toThrow('unexpected parse failure') + expect(removeSessions).not.toHaveBeenCalled() + expect(removeCurrentSessionId).not.toHaveBeenCalled() + + parseSpy.mockRestore() + }) }) describe('remove', () => { diff --git a/packages/cli-kit/src/private/node/session/store.ts b/packages/cli-kit/src/private/node/session/store.ts index bba1336a38b..86902433c44 100644 --- a/packages/cli-kit/src/private/node/session/store.ts +++ b/packages/cli-kit/src/private/node/session/store.ts @@ -23,7 +23,16 @@ export async function fetch(): Promise { if (!content) { return undefined } - const contentJson = JSON.parse(content) + + let contentJson: unknown + try { + contentJson = JSON.parse(content) + } catch (error) { + if (!(error instanceof SyntaxError)) throw error + await remove() + return undefined + } + const parsedSessions = await SessionsSchema.safeParseAsync(contentJson) if (parsedSessions.success) { return parsedSessions.data diff --git a/packages/cli-kit/src/public/common/url.test.ts b/packages/cli-kit/src/public/common/url.test.ts index ed72607d172..312809b81ff 100644 --- a/packages/cli-kit/src/public/common/url.test.ts +++ b/packages/cli-kit/src/public/common/url.test.ts @@ -67,6 +67,11 @@ describe('extractHost', () => { expect(extractHost('shop.myshopify.com/admin')).toBe('shop.myshopify.com') }) + test('returns the host for a bare host with a port (opaque URL guard)', () => { + expect(extractHost('my-shop.shop.dev:9292/admin')).toBe('my-shop.shop.dev') + expect(extractHost('localhost:3000')).toBe('localhost') + }) + test('returns undefined for null/undefined/empty input', () => { expect(extractHost(null)).toBeUndefined() expect(extractHost(undefined)).toBeUndefined() diff --git a/packages/cli-kit/src/public/common/url.ts b/packages/cli-kit/src/public/common/url.ts index 12b3316b324..d8a85db9531 100644 --- a/packages/cli-kit/src/public/common/url.ts +++ b/packages/cli-kit/src/public/common/url.ts @@ -40,9 +40,17 @@ export function safeParseURL(url: string): URL | undefined { export function extractHost(value: string | null | undefined): string | undefined { if (!value) return undefined const lowered = value.toLowerCase() - const parsed = safeParseURL(lowered) - if (parsed) return parsed.hostname - return lowered.replace(/^https?:\/\//, '').split('/')[0] + // A bare `host:port` (e.g. `my-shop.shop.dev:9292`) parses as an opaque URL whose hostname is + // empty (the host is read as the scheme), so try parsing with an explicit scheme as well and only + // accept a non-empty hostname. + for (const candidate of [lowered, `https://${lowered}`]) { + const hostname = safeParseURL(candidate)?.hostname + if (hostname) return hostname + } + // Never return an empty string: callers using `extractHost(value) ?? value` must keep the input. + const fallback = lowered.replace(/^https?:\/\//, '').split('/')[0] + if (fallback) return fallback + return undefined } /** diff --git a/packages/cli/oclif.manifest.json b/packages/cli/oclif.manifest.json index 72d98e5d31c..4bff57d73b1 100644 --- a/packages/cli/oclif.manifest.json +++ b/packages/cli/oclif.manifest.json @@ -5785,7 +5785,7 @@ "type": "boolean" }, "organization-id": { - "description": "The organization to create the store in (numeric ID). Auto-selects if you belong to a single org.", + "description": "The numeric organization ID. Auto-selects if you belong to a single organization.", "env": "SHOPIFY_FLAG_ORGANIZATION_ID", "hasDynamicHelp": false, "multiple": false, @@ -6065,6 +6065,65 @@ "strict": true, "summary": "Surface metadata about a Shopify store." }, + "store:list": { + "aliases": [ + ], + "args": { + }, + "customPluginName": "@shopify/store", + "description": "Lists stores in a Shopify organization available to the current CLI account.\n\nWhen more than one organization is available, the command prompts you to pick one unless you provide `--organization-id`.\nIn non-interactive environments, `--organization-id` is required.\n\nRun `<%= config.bin %> organization list` to find organization IDs.", + "descriptionWithMarkdown": "Lists stores in a Shopify organization available to the current CLI account.\n\nWhen more than one organization is available, the command prompts you to pick one unless you provide `--organization-id`.\nIn non-interactive environments, `--organization-id` is required.\n\nRun `<%= config.bin %> organization list` to find organization IDs.", + "examples": [ + "<%= config.bin %> <%= command.id %>", + "<%= config.bin %> <%= command.id %> --organization-id 1234567", + "<%= config.bin %> <%= command.id %> --json" + ], + "flags": { + "json": { + "allowNo": false, + "char": "j", + "description": "Output the result as JSON. Automatically disables color output.", + "env": "SHOPIFY_FLAG_JSON", + "hidden": false, + "name": "json", + "type": "boolean" + }, + "no-color": { + "allowNo": false, + "description": "Disable color output.", + "env": "SHOPIFY_FLAG_NO_COLOR", + "hidden": false, + "name": "no-color", + "type": "boolean" + }, + "organization-id": { + "description": "The numeric organization ID. Auto-selects if you belong to a single organization.", + "env": "SHOPIFY_FLAG_ORGANIZATION_ID", + "hasDynamicHelp": false, + "multiple": false, + "name": "organization-id", + "type": "option" + }, + "verbose": { + "allowNo": false, + "description": "Increase the verbosity of the output.", + "env": "SHOPIFY_FLAG_VERBOSE", + "hidden": false, + "name": "verbose", + "type": "boolean" + } + }, + "hasDynamicHelp": false, + "hidden": true, + "hiddenAliases": [ + ], + "id": "store:list", + "pluginAlias": "@shopify/cli", + "pluginName": "@shopify/cli", + "pluginType": "core", + "strict": true, + "summary": "List stores in a Shopify organization." + }, "theme:check": { "aliases": [ ], diff --git a/packages/organizations/src/cli/services/fetch.test.ts b/packages/organizations/src/cli/services/fetch.test.ts index 0cd16e8f13b..4a86d7012e6 100644 --- a/packages/organizations/src/cli/services/fetch.test.ts +++ b/packages/organizations/src/cli/services/fetch.test.ts @@ -1,4 +1,4 @@ -import {fetchOrganizations} from './fetch.js' +import {fetchOrganizations, fetchOrganizationsWithAccessInfo} from './fetch.js' import {describe, expect, test, vi} from 'vitest' import {businessPlatformRequestDoc} from '@shopify/cli-kit/node/api/business-platform' import {ensureAuthenticatedBusinessPlatform} from '@shopify/cli-kit/node/session' @@ -15,6 +15,7 @@ describe('fetchOrganizations', () => { vi.mocked(businessPlatformRequestDoc).mockResolvedValue({ currentUserAccount: { uuid: 'user-uuid', + email: 'merchant@example.com', organizationsWithAccessToDestination: { nodes: [ {id: ENCODED_GID_1, name: 'My Org'}, @@ -47,6 +48,7 @@ describe('fetchOrganizations', () => { vi.mocked(businessPlatformRequestDoc).mockResolvedValue({ currentUserAccount: { uuid: 'user-uuid', + email: 'merchant@example.com', organizationsWithAccessToDestination: { nodes: [], }, @@ -62,6 +64,7 @@ describe('fetchOrganizations', () => { vi.mocked(businessPlatformRequestDoc).mockResolvedValue({ currentUserAccount: { uuid: 'user-uuid', + email: 'merchant@example.com', organizationsWithAccessToDestination: { nodes: [{id: ENCODED_GID_1, name: 'My Org'}], }, @@ -80,3 +83,98 @@ describe('fetchOrganizations', () => { ) }) }) + +describe('fetchOrganizationsWithAccessInfo', () => { + test('uses a provided token without re-authenticating', async () => { + vi.mocked(ensureAuthenticatedBusinessPlatform).mockClear() + vi.mocked(businessPlatformRequestDoc).mockResolvedValue({ + currentUserAccount: { + uuid: 'user-uuid', + email: 'merchant@example.com', + organizationsWithAccessToDestination: { + nodes: [{id: ENCODED_GID_1, name: 'My Org'}], + }, + }, + }) + + await fetchOrganizationsWithAccessInfo('pre-resolved-token') + + expect(ensureAuthenticatedBusinessPlatform).not.toHaveBeenCalled() + expect(businessPlatformRequestDoc).toHaveBeenCalledWith(expect.objectContaining({token: 'pre-resolved-token'})) + }) + + test('refreshes a provided token on unauthorized without authenticating before the first request', async () => { + vi.mocked(ensureAuthenticatedBusinessPlatform).mockResolvedValue('refreshed-token') + vi.mocked(businessPlatformRequestDoc).mockResolvedValue({ + currentUserAccount: { + uuid: 'user-uuid', + email: 'merchant@example.com', + organizationsWithAccessToDestination: { + nodes: [{id: ENCODED_GID_1, name: 'My Org'}], + }, + }, + }) + + await fetchOrganizationsWithAccessInfo('pre-resolved-token') + + const requestOptions = vi.mocked(businessPlatformRequestDoc).mock.calls[0]?.[0] as any + expect(ensureAuthenticatedBusinessPlatform).not.toHaveBeenCalled() + await expect(requestOptions.unauthorizedHandler.handler()).resolves.toEqual({token: 'refreshed-token'}) + expect(ensureAuthenticatedBusinessPlatform).toHaveBeenCalledOnce() + }) + + test('refreshes ambient local auth on unauthorized when no token is provided', async () => { + vi.mocked(ensureAuthenticatedBusinessPlatform) + .mockResolvedValueOnce('initial-token') + .mockResolvedValueOnce('refreshed-token') + vi.mocked(businessPlatformRequestDoc).mockResolvedValue({ + currentUserAccount: { + uuid: 'user-uuid', + email: 'merchant@example.com', + organizationsWithAccessToDestination: { + nodes: [{id: ENCODED_GID_1, name: 'My Org'}], + }, + }, + }) + + await fetchOrganizationsWithAccessInfo() + + const requestOptions = vi.mocked(businessPlatformRequestDoc).mock.calls[0]?.[0] as any + await expect(requestOptions.unauthorizedHandler.handler()).resolves.toEqual({token: 'refreshed-token'}) + expect(businessPlatformRequestDoc).toHaveBeenCalledWith(expect.objectContaining({token: 'initial-token'})) + }) + + test('returns organizations plus current-user metadata when the session resolves to a user', async () => { + vi.mocked(ensureAuthenticatedBusinessPlatform).mockResolvedValue('test-token') + vi.mocked(businessPlatformRequestDoc).mockResolvedValue({ + currentUserAccount: { + uuid: 'user-uuid', + email: 'merchant@example.com', + organizationsWithAccessToDestination: { + nodes: [{id: ENCODED_GID_1, name: 'My Org'}], + }, + }, + }) + + const result = await fetchOrganizationsWithAccessInfo() + + expect(result).toEqual({ + organizations: [{id: '1234', businessName: 'My Org'}], + currentUserResolved: true, + }) + }) + + test('returns unresolved current-user metadata when BP cannot resolve currentUserAccount', async () => { + vi.mocked(ensureAuthenticatedBusinessPlatform).mockResolvedValue('test-token') + vi.mocked(businessPlatformRequestDoc).mockResolvedValue({ + currentUserAccount: null, + }) + + const result = await fetchOrganizationsWithAccessInfo() + + expect(result).toEqual({ + organizations: [], + currentUserResolved: false, + }) + }) +}) diff --git a/packages/organizations/src/cli/services/fetch.ts b/packages/organizations/src/cli/services/fetch.ts index 8f93aff21d1..a174465c21c 100644 --- a/packages/organizations/src/cli/services/fetch.ts +++ b/packages/organizations/src/cli/services/fetch.ts @@ -5,8 +5,20 @@ import {ensureAuthenticatedBusinessPlatform} from '@shopify/cli-kit/node/session import {AbortError} from '@shopify/cli-kit/node/error' import {numericIdFromEncodedGid} from '@shopify/cli-kit/common/gid' +interface FetchOrganizationsWithAccessInfoResult { + organizations: Organization[] + currentUserResolved: boolean +} + export async function fetchOrganizations(): Promise { - const token = await ensureAuthenticatedBusinessPlatform() + const result = await fetchOrganizationsWithAccessInfo() + return result.organizations +} + +export async function fetchOrganizationsWithAccessInfo( + token?: string, +): Promise { + const resolvedToken = token ?? (await ensureAuthenticatedBusinessPlatform()) const unauthorizedHandler = { type: 'token_refresh' as const, handler: async () => { @@ -17,20 +29,24 @@ export async function fetchOrganizations(): Promise { const result = await businessPlatformRequestDoc({ query: ListOrganizations, - token, + token: resolvedToken, unauthorizedHandler, }) if (!result.currentUserAccount) { - return [] + return {organizations: [], currentUserResolved: false} } - const orgs = result.currentUserAccount.organizationsWithAccessToDestination.nodes - return orgs.map((org) => { + const organizations = result.currentUserAccount.organizationsWithAccessToDestination.nodes.map((org) => { const id = numericIdFromEncodedGid(org.id) if (id === undefined) { throw new AbortError(`Failed to decode organization ID from: ${org.id}`) } return {id, businessName: org.name} }) + + return { + organizations, + currentUserResolved: true, + } } diff --git a/packages/organizations/src/index.ts b/packages/organizations/src/index.ts index baa41bd1b32..317d39c4fd1 100644 --- a/packages/organizations/src/index.ts +++ b/packages/organizations/src/index.ts @@ -1,4 +1,4 @@ -export {fetchOrganizations} from './cli/services/fetch.js' +export {fetchOrganizations, fetchOrganizationsWithAccessInfo} from './cli/services/fetch.js' export {selectOrg} from './cli/services/select.js' export {selectOrganizationPrompt} from './cli/prompts/organization.js' export type {Organization} from './cli/models/organization.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 new file mode 100644 index 00000000000..6fafc7cad55 --- /dev/null +++ b/packages/store/src/cli/api/graphql/business-platform-organizations/generated/list_accessible_shops.ts @@ -0,0 +1,153 @@ +/* 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 ListAccessibleShopsQueryVariables = Types.Exact<{ + first: Types.Scalars['Int']['input'] +}> + +export type ListAccessibleShopsQuery = { + organization?: { + id: string + name: string + accessibleShops?: { + edges: { + node: { + id: string + shopifyShopId?: string | null + name: string + storeType?: Types.Store | null + primaryDomain?: string | null + url?: string | null + createdAt: unknown + } + }[] + pageInfo: {hasNextPage: boolean} + } | null + } | null +} + +export const ListAccessibleShops = { + kind: 'Document', + definitions: [ + { + kind: 'OperationDefinition', + operation: 'query', + name: {kind: 'Name', value: 'ListAccessibleShops'}, + variableDefinitions: [ + { + kind: 'VariableDefinition', + variable: {kind: 'Variable', name: {kind: 'Name', value: 'first'}}, + type: {kind: 'NonNullType', type: {kind: 'NamedType', name: {kind: 'Name', value: 'Int'}}}, + }, + ], + selectionSet: { + kind: 'SelectionSet', + selections: [ + { + kind: 'Field', + name: {kind: 'Name', value: 'organization'}, + selectionSet: { + kind: 'SelectionSet', + selections: [ + {kind: 'Field', name: {kind: 'Name', value: 'id'}}, + {kind: 'Field', name: {kind: 'Name', value: 'name'}}, + { + kind: 'Field', + name: {kind: 'Name', value: 'accessibleShops'}, + arguments: [ + { + kind: 'Argument', + name: {kind: 'Name', value: 'first'}, + value: {kind: 'Variable', name: {kind: 'Name', value: 'first'}}, + }, + { + kind: 'Argument', + name: {kind: 'Name', value: 'sort'}, + value: {kind: 'EnumValue', value: 'SHOP_CREATED_AT_DESC'}, + }, + { + 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}, + }, + ], + }, + ], + }, + }, + ], + selectionSet: { + kind: 'SelectionSet', + selections: [ + { + kind: 'Field', + name: {kind: 'Name', value: 'edges'}, + selectionSet: { + kind: 'SelectionSet', + selections: [ + { + kind: 'Field', + name: {kind: 'Name', value: 'node'}, + selectionSet: { + kind: 'SelectionSet', + selections: [ + {kind: 'Field', name: {kind: 'Name', value: 'id'}}, + {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: 'primaryDomain'}}, + {kind: 'Field', name: {kind: 'Name', value: 'url'}}, + {kind: 'Field', name: {kind: 'Name', value: 'createdAt'}}, + {kind: 'Field', name: {kind: 'Name', value: '__typename'}}, + ], + }, + }, + {kind: 'Field', name: {kind: 'Name', value: '__typename'}}, + ], + }, + }, + { + kind: 'Field', + name: {kind: 'Name', value: 'pageInfo'}, + selectionSet: { + kind: 'SelectionSet', + selections: [ + {kind: 'Field', name: {kind: 'Name', value: 'hasNextPage'}}, + {kind: 'Field', name: {kind: 'Name', value: '__typename'}}, + ], + }, + }, + {kind: 'Field', name: {kind: 'Name', value: '__typename'}}, + ], + }, + }, + {kind: 'Field', name: {kind: 'Name', value: '__typename'}}, + ], + }, + }, + ], + }, + }, + ], +} as unknown as DocumentNode 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 new file mode 100644 index 00000000000..68c73de4cab --- /dev/null +++ b/packages/store/src/cli/api/graphql/business-platform-organizations/queries/list_accessible_shops.graphql @@ -0,0 +1,26 @@ +query ListAccessibleShops($first: Int!) { + organization { + id + name + accessibleShops( + first: $first + sort: SHOP_CREATED_AT_DESC + filters: [{field: STORE_STATUS, operator: EQUALS, value: "active"}] + ) { + edges { + node { + id + shopifyShopId + name + storeType + primaryDomain + url + createdAt + } + } + pageInfo { + hasNextPage + } + } + } +} diff --git a/packages/store/src/cli/commands/store/list.test.ts b/packages/store/src/cli/commands/store/list.test.ts new file mode 100644 index 00000000000..0e8933bc21d --- /dev/null +++ b/packages/store/src/cli/commands/store/list.test.ts @@ -0,0 +1,42 @@ +import StoreList from './list.js' +import {listStores} from '../../services/store/list.js' +import {writeStoreListResult} from '../../services/store/list/result.js' +import {describe, expect, test, vi} from 'vitest' + +vi.mock('../../services/store/list.js') +vi.mock('../../services/store/list/result.js') +vi.mock('../../services/store/attribution.js') + +describe('store list command', () => { + test('runs the list service and writes text output by default', async () => { + vi.mocked(listStores).mockResolvedValue({stores: [], source: 'organization'}) + + await StoreList.run([]) + + expect(listStores).toHaveBeenCalledWith({organizationId: undefined}) + expect(writeStoreListResult).toHaveBeenCalledWith({stores: [], source: 'organization'}, 'text') + }) + + test('passes the organization id through to the list service', async () => { + vi.mocked(listStores).mockResolvedValue({stores: [], source: 'organization'}) + + await StoreList.run(['--organization-id', '1234567']) + + expect(listStores).toHaveBeenCalledWith({organizationId: 1234567}) + }) + + test('writes json output when requested', async () => { + vi.mocked(listStores).mockResolvedValue({stores: [], source: 'organization'}) + + await StoreList.run(['--json']) + + expect(listStores).toHaveBeenCalledWith({organizationId: undefined}) + expect(writeStoreListResult).toHaveBeenCalledWith({stores: [], source: 'organization'}, 'json') + }) + + test('defines the expected flags', () => { + expect(StoreList.flags.json).toBeDefined() + expect(StoreList.flags['organization-id']).toBeDefined() + expect(StoreList.flags).not.toHaveProperty('from') + }) +}) diff --git a/packages/store/src/cli/commands/store/list.ts b/packages/store/src/cli/commands/store/list.ts new file mode 100644 index 00000000000..9920606cf34 --- /dev/null +++ b/packages/store/src/cli/commands/store/list.ts @@ -0,0 +1,39 @@ +import {listStores} from '../../services/store/list.js' +import {writeStoreListResult} from '../../services/store/list/result.js' +import {storeFlags} from '../../flags.js' +import StoreCommand from '../../utilities/store-command.js' +import {globalFlags, jsonFlag} from '@shopify/cli-kit/node/cli' + +export default class StoreList extends StoreCommand { + static hidden = true + + static summary = 'List stores in a Shopify organization.' + + static descriptionWithMarkdown = `Lists stores in a Shopify organization available to the current CLI account. + +When more than one organization is available, the command prompts you to pick one unless you provide \`--organization-id\`. +In non-interactive environments, \`--organization-id\` is required. + +Run \`<%= config.bin %> organization list\` to find organization IDs.` + + static description = this.descriptionWithoutMarkdown() + + static examples = [ + '<%= config.bin %> <%= command.id %>', + '<%= config.bin %> <%= command.id %> --organization-id 1234567', + '<%= config.bin %> <%= command.id %> --json', + ] + + static flags = { + ...globalFlags, + ...jsonFlag, + 'organization-id': storeFlags['organization-id'], + } + + public async run(): Promise { + const {flags} = await this.parse(StoreList) + const result = await listStores({organizationId: flags['organization-id']}) + + writeStoreListResult(result, flags.json ? 'json' : 'text') + } +} diff --git a/packages/store/src/cli/flags.ts b/packages/store/src/cli/flags.ts index 3f3c84115b3..d0c7d52df71 100644 --- a/packages/store/src/cli/flags.ts +++ b/packages/store/src/cli/flags.ts @@ -27,7 +27,7 @@ export const storeFlags = { required: true, }), 'organization-id': Flags.integer({ - description: 'The organization to create the store in (numeric ID). Auto-selects if you belong to a single org.', + description: 'The numeric organization ID. Auto-selects if you belong to a single organization.', env: 'SHOPIFY_FLAG_ORGANIZATION_ID', }), } diff --git a/packages/store/src/cli/services/store/business-platform.ts b/packages/store/src/cli/services/store/business-platform.ts new file mode 100644 index 00000000000..1ce2fefbcfa --- /dev/null +++ b/packages/store/src/cli/services/store/business-platform.ts @@ -0,0 +1,19 @@ +import {type UnauthorizedHandler} from '@shopify/cli-kit/node/api/graphql' +import {ensureAuthenticatedBusinessPlatform} from '@shopify/cli-kit/node/session' + +interface BusinessPlatformTokenRefreshHandlerOptions { + noPrompt?: boolean +} + +export function businessPlatformTokenRefreshHandler( + options: BusinessPlatformTokenRefreshHandlerOptions = {}, +): UnauthorizedHandler { + return { + type: 'token_refresh', + handler: async () => ({token: await refreshBusinessPlatformToken(options)}), + } +} + +async function refreshBusinessPlatformToken(options: BusinessPlatformTokenRefreshHandlerOptions): Promise { + return ensureAuthenticatedBusinessPlatform([], {noPrompt: options.noPrompt}) +} diff --git a/packages/store/src/cli/services/store/create/dev.ts b/packages/store/src/cli/services/store/create/dev.ts index bfb43918c96..796c07392cb 100644 --- a/packages/store/src/cli/services/store/create/dev.ts +++ b/packages/store/src/cli/services/store/create/dev.ts @@ -1,3 +1,4 @@ +import {businessPlatformTokenRefreshHandler} from '../business-platform.js' import {CreateAppDevelopmentStore} from '../../../api/graphql/business-platform-organizations/generated/create_app_development_store.js' import { PollStoreCreation, @@ -48,13 +49,7 @@ function friendlyStatus(status: StoreCreationStatus): string { export async function createDevStore(options: CreateDevStoreOptions): Promise { const org = await selectOrg(options.organizationId?.toString()) const token = await ensureAuthenticatedBusinessPlatform() - const unauthorizedHandler = { - type: 'token_refresh' as const, - handler: async () => { - const newToken = await ensureAuthenticatedBusinessPlatform() - return {token: newToken} - }, - } + const unauthorizedHandler = businessPlatformTokenRefreshHandler() const mutationResult = await businessPlatformOrganizationsRequestDoc({ query: CreateAppDevelopmentStore, diff --git a/packages/store/src/cli/services/store/display.test.ts b/packages/store/src/cli/services/store/display.test.ts new file mode 100644 index 00000000000..00afde69dec --- /dev/null +++ b/packages/store/src/cli/services/store/display.test.ts @@ -0,0 +1,40 @@ +import {extractSubdomain, formatShortDate} from './display.js' +import {describe, expect, test} from 'vitest' + +describe('formatShortDate', () => { + test('formats an ISO timestamp as MMM DD, YYYY in UTC', () => { + expect(formatShortDate('2026-05-22T16:26:20Z')).toBe('May 22, 2026') + }) + + test('zero-pads the day and uses UTC', () => { + expect(formatShortDate('2026-01-05T23:59:59Z')).toBe('Jan 05, 2026') + }) + + test('accepts a Date instance', () => { + expect(formatShortDate(new Date('2025-12-15T12:00:00Z'))).toBe('Dec 15, 2025') + }) + + test('returns an empty string for an unparseable value', () => { + expect(formatShortDate('not-a-date')).toBe('') + }) +}) + +describe('extractSubdomain', () => { + test('extracts the first label from a myshopify.com host', () => { + expect(extractSubdomain('https://my-shop.myshopify.com')).toBe('my-shop') + }) + + test('extracts the first label from non-myshopify hosts', () => { + expect(extractSubdomain('my-shop.my.shop.dev')).toBe('my-shop') + expect(extractSubdomain('https://acme.shop.dev/admin')).toBe('acme') + }) + + test('extracts the first label from a bare host with a port', () => { + expect(extractSubdomain('my-shop.shop.dev:9292/admin')).toBe('my-shop') + }) + + test('returns undefined for null/undefined input', () => { + expect(extractSubdomain(null)).toBeUndefined() + expect(extractSubdomain(undefined)).toBeUndefined() + }) +}) diff --git a/packages/store/src/cli/services/store/display.ts b/packages/store/src/cli/services/store/display.ts new file mode 100644 index 00000000000..160d4bc9d4d --- /dev/null +++ b/packages/store/src/cli/services/store/display.ts @@ -0,0 +1,21 @@ +import {extractHost} from '@shopify/cli-kit/common/url' + +const shortDateFormatter = new Intl.DateTimeFormat('en-US', { + timeZone: 'UTC', + month: 'short', + day: '2-digit', + year: 'numeric', +}) + +export function formatShortDate(value: Date | number | string): string { + const date = value instanceof Date ? value : new Date(value) + if (Number.isNaN(date.getTime())) return '' + + return shortDateFormatter.format(date) +} + +export function extractSubdomain(value: string | null | undefined): string | undefined { + const host = extractHost(value) + if (!host) return undefined + return host.split('.')[0] +} diff --git a/packages/store/src/cli/services/store/info/destinations.ts b/packages/store/src/cli/services/store/info/destinations.ts index a56538c9be8..9e6ade13968 100644 --- a/packages/store/src/cli/services/store/info/destinations.ts +++ b/packages/store/src/cli/services/store/info/destinations.ts @@ -1,3 +1,4 @@ +import {businessPlatformTokenRefreshHandler} from '../business-platform.js' import {StoreInfoDestinations} from '../../../api/graphql/business-platform-destinations/generated/store-info-destinations.js' import {StoreInfoOwningOrg} from '../../../api/graphql/business-platform-destinations/generated/store-info-owning-org.js' import {AbortError} from '@shopify/cli-kit/node/error' @@ -15,6 +16,7 @@ import type { StoreInfoOwningOrgQueryVariables, } from '../../../api/graphql/business-platform-destinations/generated/store-info-owning-org.js' import type {DestinationsContext, OwningOrgInternal} from './types.js' +import type {UnauthorizedHandler} from '@shopify/cli-kit/node/api/graphql' type DestinationNodeFromQuery = NonNullable< StoreInfoDestinationsQuery['currentUserAccount'] @@ -30,13 +32,7 @@ export class StoreInfoBusinessPlatformStoreNotFoundError extends AbortError {} export async function fetchDestinationsContext(options: FetchDestinationsContextOptions): Promise { const token = options.token ?? (await ensureAuthenticatedBusinessPlatform([], {noPrompt: options.noPrompt})) - const unauthorizedHandler = { - type: 'token_refresh' as const, - handler: async () => { - const newToken = await ensureAuthenticatedBusinessPlatform([], {noPrompt: options.noPrompt}) - return {token: newToken} - }, - } + const unauthorizedHandler = businessPlatformTokenRefreshHandler({noPrompt: options.noPrompt}) // `options.store` is already a normalized FQDN; extractHost canonicalizes it (lowercased, // scheme/path stripped) so it lines up with the hosts BP returns. @@ -73,7 +69,7 @@ export async function fetchDestinationsContext(options: FetchDestinationsContext async function fetchOwningOrg( destinationPublicId: string, token: string, - unauthorizedHandler: {type: 'token_refresh'; handler: () => Promise<{token: string}>}, + unauthorizedHandler: UnauthorizedHandler, ): Promise { try { const orgResponse = await businessPlatformRequestDoc({ diff --git a/packages/store/src/cli/services/store/info/index.ts b/packages/store/src/cli/services/store/info/index.ts index 3b90798dafb..163440a4e82 100644 --- a/packages/store/src/cli/services/store/info/index.ts +++ b/packages/store/src/cli/services/store/info/index.ts @@ -6,6 +6,7 @@ import {recordStoreFqdnMetadata} from '../attribution.js' import {loadStoredStoreSession} from '../auth/session-lifecycle.js' import {getCurrentStoredStoreAppSession} from '../auth/session-store.js' import {claimPreviewStore, getPreviewStore} from '../create/preview/client.js' +import {storeTypeHandle} from '../store-type.js' import {AbortError} from '@shopify/cli-kit/node/error' import {adminUrl} from '@shopify/cli-kit/node/api/admin' import {graphqlRequest} from '@shopify/cli-kit/node/api/graphql' @@ -13,7 +14,6 @@ import {compact} from '@shopify/cli-kit/common/object' import {extractMyshopifyHandle} from '@shopify/cli-kit/common/url' import {setLastSeenUserId} from '@shopify/cli-kit/node/session' import {outputDebug} from '@shopify/cli-kit/node/output' -import type {Store} from '../../../api/graphql/business-platform-organizations/generated/types.js' import type {DestinationsContext, OrganizationShopFields, StoreInfoResult, StoreInfoStoreOwner} from './types.js' import type {StoredStoreAppSession} from '../auth/session-store.js' @@ -227,7 +227,7 @@ function buildBusinessPlatformResult(args: BuildBusinessPlatformResultArgs): Sto organizationId: destinationsCtx.owningOrg?.id, organizationName: destinationsCtx.owningOrg?.name, storeOwner: buildBusinessPlatformStoreOwner(orgShop), - type: mapStoreType(orgShop?.storeType), + type: storeTypeHandle(orgShop?.storeType), plan: mapPlanToPublicHandle(orgShop?.planName), featurePreview: orgShop?.developerPreviewHandle, adminUrl: buildAdminUrl(extractMyshopifyHandle(store)), @@ -274,22 +274,3 @@ function buildAdminUrl(handle: string | undefined): string | undefined { if (!handle) return undefined return `https://admin.shopify.com/store/${encodeURIComponent(handle)}` } - -// The public store-type handle surfaced as `type` for every member of the BP `Store` enum. -// Declared as a fully-keyed record so adding a value to the enum fails type-checking here until -// it's given an explicit handle, rather than silently falling back to a lowercased raw value. -const storeTypeHandles: {[key in Store]: string} = { - APP_DEVELOPMENT: 'dev', - CLIENT_TRANSFER: 'client_transfer', - COLLABORATOR: 'collaborator', - DEVELOPMENT: 'dev', - DEVELOPMENT_SUPERSET: 'dev', - PRODUCTION: 'production', -} - -// 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. -function mapStoreType(storeType: Store | undefined): string | undefined { - if (!storeType) return undefined - return storeTypeHandles[storeType] -} diff --git a/packages/store/src/cli/services/store/info/organization-shop.ts b/packages/store/src/cli/services/store/info/organization-shop.ts index 00a4185661b..680b98e8e3d 100644 --- a/packages/store/src/cli/services/store/info/organization-shop.ts +++ b/packages/store/src/cli/services/store/info/organization-shop.ts @@ -1,3 +1,4 @@ +import {businessPlatformTokenRefreshHandler} from '../business-platform.js' import {StoreInfoShop} from '../../../api/graphql/business-platform-organizations/generated/store-info-shop.js' import {BugError} from '@shopify/cli-kit/node/error' import {businessPlatformOrganizationsRequestDoc} from '@shopify/cli-kit/node/api/business-platform' @@ -18,13 +19,7 @@ interface FetchOrganizationShopOptions { export async function fetchOrganizationShop(options: FetchOrganizationShopOptions): Promise { const token = options.token ?? (await ensureAuthenticatedBusinessPlatform([], {noPrompt: options.noPrompt})) - const unauthorizedHandler = { - type: 'token_refresh' as const, - handler: async () => { - const newToken = await ensureAuthenticatedBusinessPlatform([], {noPrompt: options.noPrompt}) - return {token: newToken} - }, - } + const unauthorizedHandler = businessPlatformTokenRefreshHandler({noPrompt: options.noPrompt}) const response = await businessPlatformOrganizationsRequestDoc({ query: StoreInfoShop, diff --git a/packages/store/src/cli/services/store/list.test.ts b/packages/store/src/cli/services/store/list.test.ts new file mode 100644 index 00000000000..c309fe9cf70 --- /dev/null +++ b/packages/store/src/cli/services/store/list.test.ts @@ -0,0 +1,183 @@ +import {listStores} from './list.js' +import * as bpSource from './list/bp-source.js' +import {describe, expect, test, vi} from 'vitest' +import {ensureAuthenticatedBusinessPlatform} from '@shopify/cli-kit/node/session' +import {AbortError} from '@shopify/cli-kit/node/error' +import {isTTY, renderAutocompletePrompt} from '@shopify/cli-kit/node/ui' +import {fetchOrganizationsWithAccessInfo} from '@shopify/organizations' + +vi.mock('@shopify/cli-kit/node/session') +vi.mock('@shopify/cli-kit/node/ui') +vi.mock('@shopify/organizations') + +const acme = {id: '1234', businessName: 'Acme'} +const beta = {id: '5678', businessName: 'Beta'} + +const orgEntry = { + id: 'gid://shopify/Shop/1', + store: 'shop.myshopify.com', + createdAt: '2026-01-15T00:00:00Z', + organizationId: '1234', + organizationName: 'Acme', + name: 'Shop', + type: 'production', +} + +function mockOrganizations(organizations = [acme]) { + vi.mocked(ensureAuthenticatedBusinessPlatform).mockResolvedValue('bp-token') + vi.mocked(fetchOrganizationsWithAccessInfo).mockResolvedValue({ + organizations, + currentUserResolved: true, + }) +} + +describe('listStores', () => { + test('returns organization results for the only available organization', async () => { + mockOrganizations([acme]) + vi.spyOn(bpSource, 'listBusinessPlatformStores').mockResolvedValue({entries: [orgEntry], hasMore: false}) + + const result = await listStores() + + expect(bpSource.listBusinessPlatformStores).toHaveBeenCalledWith({token: 'bp-token', organization: acme}) + expect(renderAutocompletePrompt).not.toHaveBeenCalled() + expect(result).toEqual({ + stores: [orgEntry], + source: 'organization', + organization: {id: '1234', name: 'Acme'}, + }) + }) + + test('uses the requested organization id when provided', async () => { + mockOrganizations([acme, beta]) + vi.spyOn(bpSource, 'listBusinessPlatformStores').mockResolvedValue({entries: [], hasMore: false}) + + await listStores({organizationId: 5678}) + + expect(bpSource.listBusinessPlatformStores).toHaveBeenCalledWith({token: 'bp-token', organization: beta}) + expect(renderAutocompletePrompt).not.toHaveBeenCalled() + }) + + test('prompts for an organization when multiple are available and no id is provided', async () => { + mockOrganizations([acme, beta]) + vi.mocked(isTTY).mockReturnValue(true) + vi.mocked(renderAutocompletePrompt).mockResolvedValue('5678') + vi.spyOn(bpSource, 'listBusinessPlatformStores').mockResolvedValue({entries: [], hasMore: false}) + + const result = await listStores() + + expect(renderAutocompletePrompt).toHaveBeenCalledWith({ + message: 'Which organization do you want to use?', + choices: [ + {label: 'Acme', value: '1234'}, + {label: 'Beta', value: '5678'}, + ], + }) + expect(bpSource.listBusinessPlatformStores).toHaveBeenCalledWith({token: 'bp-token', organization: beta}) + expect(result.organization).toEqual({id: '5678', name: 'Beta'}) + }) + + test('requires an organization id non-interactively when multiple organizations are available', async () => { + const spy = vi.spyOn(bpSource, 'listBusinessPlatformStores') + mockOrganizations([acme, beta]) + vi.mocked(isTTY).mockReturnValue(false) + + await expect(listStores()).rejects.toThrow('An organization ID is required to list stores non-interactively.') + expect(renderAutocompletePrompt).not.toHaveBeenCalled() + expect(spy).not.toHaveBeenCalled() + }) + + test('propagates organization prompt cancellation', async () => { + const spy = vi.spyOn(bpSource, 'listBusinessPlatformStores') + mockOrganizations([acme, beta]) + vi.mocked(isTTY).mockReturnValue(true) + vi.mocked(renderAutocompletePrompt).mockRejectedValue(new AbortError('User cancelled')) + + await expect(listStores()).rejects.toThrow('User cancelled') + expect(spy).not.toHaveBeenCalled() + }) + + test('returns a notice when the current CLI session cannot be resolved to an account', async () => { + vi.mocked(ensureAuthenticatedBusinessPlatform).mockResolvedValue('bp-token') + vi.mocked(fetchOrganizationsWithAccessInfo).mockResolvedValue({ + organizations: [], + currentUserResolved: false, + }) + + const result = await listStores() + + expect(result).toEqual({ + stores: [], + source: 'organization', + notice: "Couldn't resolve a Shopify account for the current CLI session.", + }) + }) + + test('returns an empty result when the current account has no organizations', async () => { + mockOrganizations([]) + + const result = await listStores() + + expect(result).toEqual({stores: [], source: 'organization'}) + }) + + test('propagates store listing failures', async () => { + mockOrganizations([acme]) + vi.spyOn(bpSource, 'listBusinessPlatformStores').mockRejectedValue( + new AbortError('Access denied for accessibleShops'), + ) + + await expect(listStores()).rejects.toThrow('Access denied for accessibleShops') + }) + + test('throws with the accessible organizations when the requested organization is not found', async () => { + mockOrganizations([acme]) + vi.spyOn(bpSource, 'listBusinessPlatformStores') + + await expect(listStores({organizationId: 9999999})).rejects.toThrow('Organization with ID 9999999 not found.') + }) + + test('caps the listing at 250 entries and flags truncation when more were returned', async () => { + mockOrganizations([acme]) + const entries = Array.from({length: 251}, (_unused, index) => ({ + store: `shop-${index}.myshopify.com`, + createdAt: '2026-01-15T00:00:00Z', + organizationId: '1234', + organizationName: 'Acme', + })) + vi.spyOn(bpSource, 'listBusinessPlatformStores').mockResolvedValue({entries, hasMore: false}) + + const result = await listStores() + + expect(result.stores).toHaveLength(250) + expect(result.truncated).toBe(true) + }) + + test('flags truncation when the source reports more stores even under the limit', async () => { + mockOrganizations([acme]) + vi.spyOn(bpSource, 'listBusinessPlatformStores').mockResolvedValue({ + entries: [ + {store: 'shop.myshopify.com', createdAt: '2026-01-15T00:00:00Z', organizationId: '1', organizationName: 'Acme'}, + ], + hasMore: true, + }) + + const result = await listStores() + + expect(result.stores).toHaveLength(1) + expect(result.truncated).toBe(true) + }) + + test('omits truncated when at or under the limit and nothing more remains', async () => { + mockOrganizations([acme]) + vi.spyOn(bpSource, 'listBusinessPlatformStores').mockResolvedValue({ + entries: [ + {store: 'shop.myshopify.com', createdAt: '2026-01-15T00:00:00Z', organizationId: '1', organizationName: 'Acme'}, + ], + hasMore: false, + }) + + const result = await listStores() + + expect(result.truncated).toBeUndefined() + }) +}) diff --git a/packages/store/src/cli/services/store/list.ts b/packages/store/src/cli/services/store/list.ts new file mode 100644 index 00000000000..15658f3ba6f --- /dev/null +++ b/packages/store/src/cli/services/store/list.ts @@ -0,0 +1,95 @@ +import {listBusinessPlatformStores} from './list/bp-source.js' +import {STORE_LIST_LIMIT} from './list/constants.js' +import {type ListStoresResult, type StoreListEntry, type StoreListOrganization} from './list/types.js' +import {AbortError} from '@shopify/cli-kit/node/error' +import {ensureAuthenticatedBusinessPlatform} from '@shopify/cli-kit/node/session' +import {isTTY, renderAutocompletePrompt} from '@shopify/cli-kit/node/ui' +import {fetchOrganizationsWithAccessInfo, type Organization} from '@shopify/organizations' + +interface ListStoresOptions { + organizationId?: number +} + +export async function listStores(options: ListStoresOptions = {}): Promise { + const token = await ensureAuthenticatedBusinessPlatform() + const organizationsResult = await fetchOrganizationsWithAccessInfo(token) + + if (!organizationsResult.currentUserResolved) { + return { + stores: [], + source: 'organization', + notice: "Couldn't resolve a Shopify account for the current CLI session.", + } + } + + if (organizationsResult.organizations.length === 0) { + return {stores: [], source: 'organization'} + } + + if (!options.organizationId && organizationsResult.organizations.length > 1 && !isTTY()) { + throw new AbortError( + 'An organization ID is required to list stores non-interactively.', + 'Provide `--organization-id`, for example `--organization-id 1234567`. Run `shopify organization list` to find IDs.', + ) + } + + const selectedOrganization = await selectStoreListOrganization( + organizationsResult.organizations, + options.organizationId, + ) + + const result = await listBusinessPlatformStores({token, organization: selectedOrganization}) + const {stores, truncated} = limitEntries(result.entries, result.hasMore) + + return { + stores, + source: 'organization', + organization: storeListOrganization(selectedOrganization), + ...(truncated ? {truncated: true} : {}), + } +} + +async function selectStoreListOrganization( + organizations: Organization[], + organizationId?: number, +): Promise { + if (organizationId) { + const selectedOrganization = organizations.find((organization) => organization.id === organizationId.toString()) + if (!selectedOrganization) { + throw new AbortError( + `Organization with ID ${organizationId} not found.`, + `Available organizations: ${organizations + .map((organization) => `${organization.businessName} (${organization.id})`) + .join(', ')}`, + ) + } + return selectedOrganization + } + + if (organizations.length === 1) { + return organizations[0]! + } + + const uniqueNames = new Set(organizations.map((organization) => organization.businessName)) + const hasDuplicateNames = uniqueNames.size < organizations.length + const selectedOrganizationId = await renderAutocompletePrompt({ + message: 'Which organization do you want to use?', + choices: organizations.map((organization) => ({ + label: hasDuplicateNames ? `${organization.businessName} (${organization.id})` : organization.businessName, + value: organization.id, + })), + }) + + return organizations.find((organization) => organization.id === selectedOrganizationId)! +} + +function storeListOrganization(organization: Organization): StoreListOrganization { + return {id: organization.id, name: organization.businessName} +} + +// Caps the listing at STORE_LIST_LIMIT, keeping the already-sorted (newest-first) head. The result +// is truncated when the source reported more stores than it fetched, or when the selected +// organization produced more than the limit. +function limitEntries(entries: StoreListEntry[], hasMore: boolean): {stores: StoreListEntry[]; truncated: boolean} { + return {stores: entries.slice(0, STORE_LIST_LIMIT), truncated: hasMore || entries.length > STORE_LIST_LIMIT} +} 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 new file mode 100644 index 00000000000..bf1d179003e --- /dev/null +++ b/packages/store/src/cli/services/store/list/bp-source.test.ts @@ -0,0 +1,225 @@ +import {listBusinessPlatformStores} from './bp-source.js' +import {beforeEach, describe, expect, test, vi} from 'vitest' +import {businessPlatformOrganizationsRequestDoc} from '@shopify/cli-kit/node/api/business-platform' +import {mockAndCaptureOutput} from '@shopify/cli-kit/node/testing/output' +import {ensureAuthenticatedBusinessPlatform} from '@shopify/cli-kit/node/session' +import {AbortError} from '@shopify/cli-kit/node/error' + +vi.mock('@shopify/cli-kit/node/api/business-platform') +vi.mock('@shopify/cli-kit/node/session') + +const organization = {id: '1234', businessName: 'Acme'} + +function shopPage({ + organizationId = '1234', + shopifyShopId = '1', + name = 'Acme Production', + storeType = 'PRODUCTION', + primaryDomain = 'acme.myshopify.com', + url = null, + createdAt = '2026-01-15T00:00:00Z', + hasNextPage = false, +}: { + organizationId?: string + shopifyShopId?: string + name?: string + storeType?: string + primaryDomain?: string | null + url?: string | null + createdAt?: string + hasNextPage?: boolean +} = {}) { + return { + organization: { + id: organizationId, + name: 'Ignored response org name', + accessibleShops: { + edges: [ + { + node: { + id: `gid://shopify/Shop/${shopifyShopId}`, + shopifyShopId, + name, + storeType, + primaryDomain, + url, + createdAt, + }, + }, + ], + pageInfo: {hasNextPage, endCursor: null}, + }, + }, + } +} + +describe('listBusinessPlatformStores', () => { + beforeEach(() => { + mockAndCaptureOutput().clear() + }) + + test('fetches active stores for the resolved organization', async () => { + vi.mocked(businessPlatformOrganizationsRequestDoc).mockResolvedValue(shopPage()) + + const result = await listBusinessPlatformStores({token: 'bp-token', organization}) + const requestOptions = vi.mocked(businessPlatformOrganizationsRequestDoc).mock.calls[0]?.[0] as any + + 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: [ + { + id: 'gid://shopify/Shop/1', + store: 'acme.myshopify.com', + createdAt: '2026-01-15T00:00:00Z', + organizationId: '1234', + organizationName: 'Acme', + name: 'Acme Production', + type: 'production', + }, + ], + hasMore: false, + }) + expect(businessPlatformOrganizationsRequestDoc).toHaveBeenCalledWith( + expect.objectContaining({token: 'bp-token', organizationId: '1234', variables: {first: 250}}), + ) + }) + + test('uses the selected organization name instead of the response organization name', async () => { + vi.mocked(businessPlatformOrganizationsRequestDoc).mockResolvedValue( + shopPage({organizationId: '5678', primaryDomain: 'beta.myshopify.com'}), + ) + + const result = await listBusinessPlatformStores({ + token: 'bp-token', + organization: {id: '5678', businessName: 'Beta'}, + }) + + expect(result.entries[0]?.organizationName).toBe('Beta') + }) + + test('skips accessible shops that have no URL or primary domain', async () => { + vi.mocked(businessPlatformOrganizationsRequestDoc).mockResolvedValue( + shopPage({primaryDomain: null, url: null, name: 'Missing Domain Shop'}), + ) + + const result = await listBusinessPlatformStores({token: 'bp-token', organization}) + + expect(result).toEqual({entries: [], hasMore: false}) + }) + + test('fetches a single bounded page for the selected organization and orders newest first', async () => { + vi.mocked(businessPlatformOrganizationsRequestDoc).mockResolvedValue({ + organization: { + id: '1234', + name: 'Acme', + accessibleShops: { + edges: [ + { + node: { + id: 'gid://shopify/Shop/1', + shopifyShopId: '1', + name: 'Older Shop', + storeType: 'PRODUCTION', + primaryDomain: 'older.myshopify.com', + url: null, + createdAt: '2025-01-01T00:00:00Z', + }, + }, + { + node: { + id: 'gid://shopify/Shop/2', + shopifyShopId: '2', + name: 'Newer Shop', + storeType: 'DEVELOPMENT', + primaryDomain: 'newer.myshopify.com', + url: null, + createdAt: '2026-05-01T00:00:00Z', + }, + }, + ], + pageInfo: {hasNextPage: false}, + }, + }, + } as any) + + const result = await listBusinessPlatformStores({token: 'bp-token', organization}) + + 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}}), + ) + }) + + test('sorts stores with matching created dates by store host', async () => { + vi.mocked(businessPlatformOrganizationsRequestDoc).mockResolvedValue({ + organization: { + id: '1234', + name: 'Acme', + accessibleShops: { + edges: [ + { + node: { + id: 'gid://shopify/Shop/2', + shopifyShopId: '2', + name: 'B Shop', + storeType: 'DEVELOPMENT', + primaryDomain: 'b-shop.myshopify.com', + url: null, + createdAt: '2026-05-01T00:00:00Z', + }, + }, + { + node: { + id: 'gid://shopify/Shop/1', + shopifyShopId: '1', + name: 'A Shop', + storeType: 'DEVELOPMENT', + primaryDomain: 'a-shop.myshopify.com', + url: null, + createdAt: '2026-05-01T00:00:00Z', + }, + }, + ], + pageInfo: {hasNextPage: false}, + }, + }, + } as any) + + const result = await listBusinessPlatformStores({token: 'bp-token', organization}) + + expect(result.entries.map((entry) => entry.store)).toEqual(['a-shop.myshopify.com', 'b-shop.myshopify.com']) + }) + + test('reports hasMore when the selected organization has more stores than the fetched page', async () => { + vi.mocked(businessPlatformOrganizationsRequestDoc).mockResolvedValue(shopPage({hasNextPage: true})) + + const result = await listBusinessPlatformStores({token: 'bp-token', organization}) + + expect(result.hasMore).toBe(true) + }) + + test('raises the underlying error when the selected organization listing fails', async () => { + vi.mocked(businessPlatformOrganizationsRequestDoc).mockRejectedValue( + new AbortError('Access denied for accessibleShops'), + ) + + await expect(listBusinessPlatformStores({token: 'bp-token', organization})).rejects.toThrow( + 'Access denied for accessibleShops', + ) + }) + + test('refreshes the Business Platform token when the store request is unauthorized', async () => { + vi.mocked(businessPlatformOrganizationsRequestDoc).mockResolvedValue(shopPage()) + vi.mocked(ensureAuthenticatedBusinessPlatform).mockResolvedValue('refreshed-token') + + await listBusinessPlatformStores({token: 'bp-token', organization}) + + const requestOptions = vi.mocked(businessPlatformOrganizationsRequestDoc).mock.calls[0]?.[0] as any + await requestOptions.unauthorizedHandler.handler() + + expect(ensureAuthenticatedBusinessPlatform).toHaveBeenCalledWith([], {noPrompt: 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 new file mode 100644 index 00000000000..90e9180c274 --- /dev/null +++ b/packages/store/src/cli/services/store/list/bp-source.ts @@ -0,0 +1,89 @@ +import {STORE_LIST_LIMIT} from './constants.js' +import {type StoreListEntry} from './types.js' +import {businessPlatformTokenRefreshHandler} from '../business-platform.js' +import {storeTypeHandle} from '../store-type.js' +import { + ListAccessibleShops, + type ListAccessibleShopsQuery, +} from '../../../api/graphql/business-platform-organizations/generated/list_accessible_shops.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' + +interface ListBusinessPlatformStoresOptions { + token: string + organization: Organization +} + +interface BusinessPlatformStoreListResult { + entries: StoreListEntry[] + // True when the selected organization had more stores than we fetched (server-side limited). + hasMore: boolean +} + +export async function listBusinessPlatformStores( + options: ListBusinessPlatformStoresOptions, +): Promise { + const {entries, hasMore} = await fetchOrganizationStores(options.token, options.organization) + + return { + entries: entries.sort(byCreatedAtDescending), + hasMore, + } +} + +// 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, +): Promise<{entries: StoreListEntry[]; hasMore: boolean}> { + const unauthorizedHandler = businessPlatformTokenRefreshHandler() + + const result = await businessPlatformOrganizationsRequestDoc({ + query: ListAccessibleShops, + token, + organizationId: organization.id, + variables: {first: STORE_LIST_LIMIT}, + unauthorizedHandler, + }) + + const accessibleShops = result.organization?.accessibleShops + if (!accessibleShops) return {entries: [], hasMore: false} + + const entries: StoreListEntry[] = [] + for (const edge of accessibleShops.edges) { + const entry = toStoreListEntry(edge.node, organization) + if (entry) entries.push(entry) + } + + return {entries, hasMore: accessibleShops.pageInfo.hasNextPage} +} + +type ShopNode = NonNullable< + NonNullable['accessibleShops']>['edges'][number]['node'] +> + +function toStoreListEntry(node: ShopNode, organization: Organization): StoreListEntry | undefined { + const store = node.url ?? node.primaryDomain + if (!store) return undefined + + return { + // Build the Shop GID from the numeric shopifyShopId (matches `store info`'s admin GID). + // Note: the node's `externalId`/`id` are encoded BP GIDs, not the bare shop id. + ...(node.shopifyShopId ? {id: `gid://shopify/Shop/${node.shopifyShopId}`} : {}), + // Canonicalize the host from the BP-returned URL/domain. Do not run it through the user-input + // normalizer (normalizeStoreFqdn), which would append `.myshopify.com` to custom domains. + store: extractHost(store) ?? store, + createdAt: typeof node.createdAt === 'string' ? node.createdAt : String(node.createdAt), + organizationId: organization.id, + organizationName: organization.businessName, + name: node.name, + type: storeTypeHandle(node.storeType), + } +} + +function byCreatedAtDescending(left: StoreListEntry, right: StoreListEntry): number { + if (left.createdAt === right.createdAt) return left.store.localeCompare(right.store) + return right.createdAt.localeCompare(left.createdAt) +} diff --git a/packages/store/src/cli/services/store/list/constants.ts b/packages/store/src/cli/services/store/list/constants.ts new file mode 100644 index 00000000000..3aa928d1ff8 --- /dev/null +++ b/packages/store/src/cli/services/store/list/constants.ts @@ -0,0 +1,3 @@ +// Maximum number of stores shown by `store list`. Listings are capped to this value and truncation +// is signalled when more stores exist. +export const STORE_LIST_LIMIT = 250 diff --git a/packages/store/src/cli/services/store/list/result.test.ts b/packages/store/src/cli/services/store/list/result.test.ts new file mode 100644 index 00000000000..a1c654b97ce --- /dev/null +++ b/packages/store/src/cli/services/store/list/result.test.ts @@ -0,0 +1,181 @@ +import {writeStoreListResult} from './result.js' +import {beforeEach, describe, expect, test} from 'vitest' +import {mockAndCaptureOutput} from '@shopify/cli-kit/node/testing/output' + +const organization = {id: '1234', name: 'Acme'} + +describe('writeStoreListResult', () => { + beforeEach(() => { + mockAndCaptureOutput().clear() + }) + + test('renders organization context and rows with subdomain, name, type, and created date', () => { + const output = mockAndCaptureOutput() + + writeStoreListResult( + { + source: 'organization', + organization, + stores: [ + { + id: 'gid://shopify/Shop/1', + store: 'my-shop.myshopify.com', + createdAt: '2026-05-22T00:00:00Z', + organizationId: '1234', + organizationName: 'Acme', + name: 'My Shop', + type: 'dev', + }, + ], + }, + 'text', + ) + + expect(output.info()).toContain('Organization: Acme (1234)') + expect(output.info()).toContain('Subdomain') + expect(output.info()).toContain('my-shop') + expect(output.info()).not.toContain('my-shop.myshopify.com') + expect(output.info()).toContain('My Shop') + expect(output.info()).toContain('Dev') + expect(output.info()).toContain('May 22, 2026') + }) + + test('renders the subdomain handle for non-myshopify hosts (local dev)', () => { + const output = mockAndCaptureOutput() + + writeStoreListResult( + { + source: 'organization', + organization, + stores: [ + { + store: 'my-shop.my.shop.dev', + createdAt: '2026-05-22T00:00:00Z', + organizationId: '1234', + organizationName: 'Acme', + name: 'My Shop', + }, + ], + }, + 'text', + ) + + expect(output.info()).toContain('my-shop') + expect(output.info()).not.toContain('my-shop.my.shop.dev') + }) + + test('writes the unresolved-session notice to stderr and the empty state to stdout', () => { + const output = mockAndCaptureOutput() + + writeStoreListResult( + { + source: 'organization', + stores: [], + notice: "Couldn't resolve a Shopify account for the current CLI session.", + }, + 'text', + ) + + expect(output.warn()).toContain("Couldn't resolve a Shopify account for the current CLI session.") + expect(output.info()).toContain('No stores were returned for the current CLI session.') + }) + + test('renders the selected organization empty state', () => { + const output = mockAndCaptureOutput() + + writeStoreListResult({source: 'organization', organization, stores: []}, 'text') + + expect(output.info()).toContain('No stores found in Acme.') + }) + + test('renders the fallback organization empty state when no organization is selected', () => { + const output = mockAndCaptureOutput() + + writeStoreListResult({source: 'organization', stores: []}, 'text') + + expect(output.info()).toContain('No stores found in your Shopify organization.') + }) + + test('emits a {stores, organization} JSON document on stdout', () => { + const output = mockAndCaptureOutput() + + writeStoreListResult( + { + source: 'organization', + organization, + stores: [ + { + id: 'gid://shopify/Shop/1', + store: 'shop.myshopify.com', + createdAt: '2026-05-22T00:00:00Z', + organizationId: '1234', + organizationName: 'Acme', + name: 'My Shop', + type: 'dev', + }, + ], + }, + 'json', + ) + + expect(JSON.parse(output.output())).toEqual({ + stores: [ + { + id: 'gid://shopify/Shop/1', + store: 'shop.myshopify.com', + createdAt: '2026-05-22T00:00:00Z', + organizationId: '1234', + organizationName: 'Acme', + name: 'My Shop', + type: 'dev', + }, + ], + organization, + }) + }) + + test('includes unresolved-session notices in JSON output', () => { + const output = mockAndCaptureOutput() + + writeStoreListResult( + { + source: 'organization', + stores: [], + notice: "Couldn't resolve a Shopify account for the current CLI session.", + }, + 'json', + ) + + expect(JSON.parse(output.output().slice(output.output().indexOf('{')))).toEqual({ + stores: [], + notice: "Couldn't resolve a Shopify account for the current CLI session.", + }) + expect(output.warn()).toContain("Couldn't resolve a Shopify account for the current CLI session.") + }) + + test('warns on stderr when the listing was truncated, in both text and json', () => { + const result = { + source: 'organization' as const, + organization, + stores: [ + { + store: 'shop.myshopify.com', + createdAt: '2026-05-22T00:00:00Z', + organizationId: '1234', + organizationName: 'Acme', + }, + ], + truncated: true, + } + + const textOutput = mockAndCaptureOutput() + writeStoreListResult(result, 'text') + expect(textOutput.warn()).toContain('Showing the 250 most recent stores in Acme. More stores exist') + + const jsonOutput = mockAndCaptureOutput() + writeStoreListResult(result, 'json') + expect(jsonOutput.warn()).toContain('Showing the 250 most recent stores in Acme. More stores exist') + // The structured truncation flag is part of the JSON document on stdout (prose stays on stderr). + expect(jsonOutput.output()).toContain('"truncated": true') + }) +}) diff --git a/packages/store/src/cli/services/store/list/result.ts b/packages/store/src/cli/services/store/list/result.ts new file mode 100644 index 00000000000..413590fa20d --- /dev/null +++ b/packages/store/src/cli/services/store/list/result.ts @@ -0,0 +1,82 @@ +import {STORE_LIST_LIMIT} from './constants.js' +import {type ListStoresResult, type StoreListEntry} from './types.js' +import {extractSubdomain, formatShortDate} from '../display.js' +import {storeTypeLabel} from '../store-type.js' +import {outputInfo, outputResult, outputWarn} from '@shopify/cli-kit/node/output' +import {renderTable} from '@shopify/cli-kit/node/ui' + +export function writeStoreListResult(result: ListStoresResult, format: 'text' | 'json'): void { + // Human diagnostics always go to stderr so they never corrupt the JSON document on stdout, and so + // the truncation signal is visible in both formats. + if (result.notice) outputWarn(result.notice) + if (result.truncated) outputWarn(truncationWarning(result)) + + if (format === 'json') { + outputResult( + JSON.stringify( + { + stores: result.stores, + ...(result.organization ? {organization: result.organization} : {}), + ...(result.notice ? {notice: result.notice} : {}), + ...(result.truncated ? {truncated: true} : {}), + }, + null, + 2, + ), + ) + return + } + + renderTextResult(result) +} + +function truncationWarning(result: ListStoresResult): string { + const organization = result.organization ? ` in ${result.organization.name}` : ' in this organization' + return `Showing the ${STORE_LIST_LIMIT} most recent stores${organization}. More stores exist.` +} + +function renderTextResult(result: ListStoresResult): void { + if (result.stores.length === 0) { + outputInfo(emptyStateMessage(result)) + return + } + + if (result.organization) { + outputInfo(`Organization: ${result.organization.name} (${result.organization.id})`) + } + + renderOrganizationTable(result.stores) +} + +function renderOrganizationTable(stores: StoreListEntry[]): void { + renderTable({ + rows: stores.map((entry) => ({ + subdomain: subdomainFor(entry.store), + name: entry.name ?? '', + type: storeTypeLabel(entry.type), + created: formatShortDate(entry.createdAt), + })), + columns: { + subdomain: {header: 'Subdomain'}, + name: {header: 'Name'}, + type: {header: 'Type'}, + created: {header: 'Created'}, + }, + }) +} + +function emptyStateMessage(result: ListStoresResult): string { + if (result.notice) { + return 'No stores were returned for the current CLI session.' + } + + if (result.organization) { + return `No stores found in ${result.organization.name}.` + } + + return 'No stores found in your Shopify organization.' +} + +function subdomainFor(store: string): string { + return extractSubdomain(store) ?? store +} diff --git a/packages/store/src/cli/services/store/list/types.ts b/packages/store/src/cli/services/store/list/types.ts new file mode 100644 index 00000000000..274a62c77f9 --- /dev/null +++ b/packages/store/src/cli/services/store/list/types.ts @@ -0,0 +1,22 @@ +export interface StoreListEntry { + id?: string + store: string + createdAt: string + organizationId: string + organizationName: string + name?: string + type?: string +} + +export interface StoreListOrganization { + id: string + name: string +} + +export interface ListStoresResult { + stores: StoreListEntry[] + source: 'organization' + organization?: StoreListOrganization + notice?: string + truncated?: boolean +} diff --git a/packages/store/src/cli/services/store/store-type.ts b/packages/store/src/cli/services/store/store-type.ts new file mode 100644 index 00000000000..da051566b0f --- /dev/null +++ b/packages/store/src/cli/services/store/store-type.ts @@ -0,0 +1,27 @@ +import {type Store} from '../../api/graphql/business-platform-organizations/generated/types.js' +import {capitalizeWords} from '@shopify/cli-kit/common/string' + +// The public store-type handle for every member of the BP `Store` enum, shared by `store info` +// (the `type` field) and `store list`. Declared as a fully-keyed record so adding a value to the +// enum fails type-checking here until it's given an explicit handle. +const STORE_TYPE_HANDLES: {[key in Store]: string} = { + APP_DEVELOPMENT: 'dev', + CLIENT_TRANSFER: 'client_transfer', + COLLABORATOR: 'collaborator', + DEVELOPMENT: 'dev', + DEVELOPMENT_SUPERSET: 'dev', + PRODUCTION: 'production', +} + +// 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 { + if (!storeType) return undefined + return STORE_TYPE_HANDLES[storeType as Store] +} + +// Title-cased label for the `store list` table column (`dev` -> `Dev`, `client_transfer` -> +// `Client Transfer`). +export function storeTypeLabel(handle: string | undefined): string { + return handle ? capitalizeWords(handle) : '' +} diff --git a/packages/store/src/index.ts b/packages/store/src/index.ts index 0a62708b125..eede458c992 100644 --- a/packages/store/src/index.ts +++ b/packages/store/src/index.ts @@ -3,6 +3,7 @@ import StoreCreateDev from './cli/commands/store/create/dev.js' import StoreCreatePreview from './cli/commands/store/create/preview.js' import StoreExecute from './cli/commands/store/execute.js' import StoreInfo from './cli/commands/store/info.js' +import StoreList from './cli/commands/store/list.js' const COMMANDS = { 'store:auth': StoreAuth, @@ -10,6 +11,7 @@ const COMMANDS = { 'store:create:preview': StoreCreatePreview, 'store:execute': StoreExecute, 'store:info': StoreInfo, + 'store:list': StoreList, } export default COMMANDS