From 0638841e8269e5e26e95e7b7d12b9f50c0c5df94 Mon Sep 17 00:00:00 2001 From: Ariel Caplan Date: Thu, 17 Sep 2026 20:47:58 +0300 Subject: [PATCH] Resolve store list --organization-id without listing every organization MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `store list` fetched the account's whole organization list before it even looked at `--organization-id`, using it only to validate the flag and to read the organization's name — which `ListAccessibleShops` already returns. Move the single-organization lookup the app client already had into `@shopify/organizations` and use it from `store list`, so an explicit `--organization-id` costs one cached lookup instead of a full listing. `organizationsWithAccessToDestination` requests no `first` and no `pageInfo`, so the listing is whatever single page Business Platform returns. A lookup by ID can't be truncated that way. It is also unfiltered, since the schema offers no `APPS_CLI` filter for it, so it can resolve an organization the account belongs to but can't reach from the CLI — the org-scoped shops request stays the authority there, and such an organization now reports no stores rather than not found. A miss still falls through to the filtered list, which supplies the accessible organizations for the error and re-resolves access granted since the cached miss. Key the lookup's cache by a hash of the Business Platform token. The GraphQL cache key covers only the query, the variables and the CLI version, so the cache `orgFromId` has been using answers one account's lookup with another's organization. Co-Authored-By: Claude Opus 5 (1M context) Assisted-By: devx/e8969e97-6baa-439a-945f-df54a3fb85f3 --- .../store-list-organization-id-lookup.md | 5 + .../app-management-client.test.ts | 37 +++++++- .../app-management-client.ts | 22 ++--- .../generated/find_organization.ts} | 10 +- .../queries/find_organization.graphql} | 2 +- .../src/cli/services/fetch.test.ts | 94 ++++++++++++++++++- .../organizations/src/cli/services/fetch.ts | 61 ++++++++++-- packages/organizations/src/index.ts | 2 +- .../store/src/cli/services/store/list.test.ts | 52 +++++++++- packages/store/src/cli/services/store/list.ts | 29 ++++-- 10 files changed, 271 insertions(+), 43 deletions(-) create mode 100644 .changeset/store-list-organization-id-lookup.md rename packages/{app/src/cli/api/graphql/business-platform-destinations/generated/find-organizations.ts => organizations/src/cli/api/graphql/business-platform-destinations/generated/find_organization.ts} (84%) rename packages/{app/src/cli/api/graphql/business-platform-destinations/queries/find-organizations.graphql => organizations/src/cli/api/graphql/business-platform-destinations/queries/find_organization.graphql} (61%) diff --git a/.changeset/store-list-organization-id-lookup.md b/.changeset/store-list-organization-id-lookup.md new file mode 100644 index 00000000000..e25d97ceca7 --- /dev/null +++ b/.changeset/store-list-organization-id-lookup.md @@ -0,0 +1,5 @@ +--- +'@shopify/store': patch +--- + +Fix `store list --organization-id` reporting a valid organization as not found for accounts that belong to many organizations diff --git a/packages/app/src/cli/utilities/developer-platform-client/app-management-client.test.ts b/packages/app/src/cli/utilities/developer-platform-client/app-management-client.test.ts index 5c54c82fb6a..4d5f0e01c4c 100644 --- a/packages/app/src/cli/utilities/developer-platform-client/app-management-client.test.ts +++ b/packages/app/src/cli/utilities/developer-platform-client/app-management-client.test.ts @@ -41,12 +41,12 @@ import {AppVersionsQuerySchema} from '../../api/graphql/get_versions_list.js' import {BrandingSpecIdentifier} from '../../models/extensions/specifications/app_config_branding.js' import {AppHomeSpecIdentifier} from '../../models/extensions/specifications/app_config_app_home.js' import {AppAccessSpecIdentifier} from '../../models/extensions/specifications/app_config_app_access.js' -import {MinimalAppIdentifiers} from '../../models/organization.js' +import {MinimalAppIdentifiers, OrganizationSource} from '../../models/organization.js' import {CreateAssetUrl} from '../../api/graphql/app-management/generated/create-asset-url.js' import {RequestSourceScanUploadUrl} from '../../api/graphql/app-management/generated/request-source-scan-upload-url.js' import {CreateSourceScan} from '../../api/graphql/app-management/generated/create-source-scan.js' import {SourceExtension} from '../../api/graphql/app-management/generated/types.js' -import {fetchOrganizations} from '@shopify/organizations' +import {fetchOrganizationById, fetchOrganizations} from '@shopify/organizations' import {describe, expect, test, vi, beforeEach} from 'vitest' import {CLI_KIT_VERSION} from '@shopify/cli-kit/common/version' import {fetch} from '@shopify/cli-kit/node/http' @@ -2389,6 +2389,39 @@ describe('uidStrategyFromTypename', () => { }) }) +describe('orgFromId', () => { + test('resolves the organization through the shared lookup and stamps the source', async () => { + vi.mocked(fetchOrganizationById).mockResolvedValueOnce({id: '123', businessName: 'Org 123'}) + + const client = AppManagementClient.getInstance() + client.businessPlatformToken = () => Promise.resolve('business-platform-token') + const unsafeRefreshToken = vi.spyOn(client, 'unsafeRefreshToken').mockResolvedValue('refreshed-token') + client.session = vi.fn().mockResolvedValue({ + token: 'refreshed-token', + businessPlatformToken: 'refreshed-business-platform-token', + }) as unknown as typeof client.session + + const result = await client.orgFromId('123') + + expect(result).toEqual({id: '123', businessName: 'Org 123', source: OrganizationSource.BusinessPlatform}) + // The client's own handler refreshes its cached session, not just the stored token. + const [organizationId, token, unauthorizedHandler] = vi.mocked(fetchOrganizationById).mock.calls[0]! + expect(organizationId).toBe('123') + expect(token).toBe('business-platform-token') + await expect(unauthorizedHandler!.handler()).resolves.toEqual({token: 'refreshed-business-platform-token'}) + expect(unsafeRefreshToken).toHaveBeenCalledOnce() + }) + + test('returns undefined when the lookup finds no organization', async () => { + vi.mocked(fetchOrganizationById).mockResolvedValueOnce(undefined) + + const client = AppManagementClient.getInstance() + client.businessPlatformToken = () => Promise.resolve('business-platform-token') + + await expect(client.orgFromId('9999999')).resolves.toBeUndefined() + }) +}) + describe('devStoreCapReached', () => { test('queries Business Platform and returns the organization development store cap state', async () => { const mockedResponse: DevStoreCapReachedQuery = { diff --git a/packages/app/src/cli/utilities/developer-platform-client/app-management-client.ts b/packages/app/src/cli/utilities/developer-platform-client/app-management-client.ts index c611a5dfa17..85c68e68f6d 100644 --- a/packages/app/src/cli/utilities/developer-platform-client/app-management-client.ts +++ b/packages/app/src/cli/utilities/developer-platform-client/app-management-client.ts @@ -111,7 +111,6 @@ import {AppInstallCount} from '../../api/graphql/app-management/generated/app-in import {CreateApp, CreateAppMutationVariables} from '../../api/graphql/app-management/generated/create-app.js' import {FetchSpecifications} from '../../api/graphql/app-management/generated/specifications.js' import {ListApps} from '../../api/graphql/app-management/generated/apps.js' -import {FindOrganizations} from '../../api/graphql/business-platform-destinations/generated/find-organizations.js' import {UserInfo} from '../../api/graphql/business-platform-destinations/generated/user-info.js' import {AvailableTopics} from '../../api/graphql/webhooks/generated/available-topics.js' import {CliTesting} from '../../api/graphql/webhooks/generated/cli-testing.js' @@ -134,7 +133,7 @@ import { } from '../../api/graphql/app-management/generated/app-logs-subscribe.js' import {SourceExtension} from '../../api/graphql/app-management/generated/types.js' import {WebhookSubscriptionSpecIdentifier} from '../../models/extensions/specifications/app_config_webhook_subscription.js' -import {fetchOrganizations} from '@shopify/organizations' +import {fetchOrganizationById, fetchOrganizations} from '@shopify/organizations' import {getAppAutomationToken} from '@shopify/cli-kit/node/environment' import {ensureAuthenticatedAppManagementAndBusinessPlatform, Session} from '@shopify/cli-kit/node/session' import {isUnitTest} from '@shopify/cli-kit/node/context/local' @@ -377,22 +376,15 @@ export class AppManagementClient implements DeveloperPlatformClient { } async orgFromId(orgId: string): Promise { - const base64Id = encodedGidFromOrganizationIdForBP(orgId) - const variables = {organizationId: base64Id} - const organizationResult = await this.businessPlatformRequest({ - query: FindOrganizations, - variables, - cacheOptions: {cacheTTL: {hours: 6}}, - }) - const org = organizationResult.currentUserAccount?.organization + const org = await fetchOrganizationById( + orgId, + await this.businessPlatformToken(), + this.createUnauthorizedHandler('businessPlatform'), + ) if (!org) { return } - return { - id: orgId, - businessName: org.name, - source: this.organizationSource, - } + return {...org, source: this.organizationSource} } async orgAndApps( diff --git a/packages/app/src/cli/api/graphql/business-platform-destinations/generated/find-organizations.ts b/packages/organizations/src/cli/api/graphql/business-platform-destinations/generated/find_organization.ts similarity index 84% rename from packages/app/src/cli/api/graphql/business-platform-destinations/generated/find-organizations.ts rename to packages/organizations/src/cli/api/graphql/business-platform-destinations/generated/find_organization.ts index 70c948f0e2c..5236ae7891f 100644 --- a/packages/app/src/cli/api/graphql/business-platform-destinations/generated/find-organizations.ts +++ b/packages/organizations/src/cli/api/graphql/business-platform-destinations/generated/find_organization.ts @@ -3,19 +3,19 @@ import * as Types from './types.js' import {TypedDocumentNode as DocumentNode} from '@graphql-typed-document-node/core' -export type FindOrganizationsQueryVariables = Types.Exact<{ +export type FindOrganizationQueryVariables = Types.Exact<{ organizationId: Types.Scalars['OrganizationID']['input'] }> -export type FindOrganizationsQuery = {currentUserAccount?: {organization?: {id: string; name: string} | null} | null} +export type FindOrganizationQuery = {currentUserAccount?: {organization?: {id: string; name: string} | null} | null} -export const FindOrganizations = { +export const FindOrganization = { kind: 'Document', definitions: [ { kind: 'OperationDefinition', operation: 'query', - name: {kind: 'Name', value: 'FindOrganizations'}, + name: {kind: 'Name', value: 'FindOrganization'}, variableDefinitions: [ { kind: 'VariableDefinition', @@ -59,4 +59,4 @@ export const FindOrganizations = { }, }, ], -} as unknown as DocumentNode +} as unknown as DocumentNode diff --git a/packages/app/src/cli/api/graphql/business-platform-destinations/queries/find-organizations.graphql b/packages/organizations/src/cli/api/graphql/business-platform-destinations/queries/find_organization.graphql similarity index 61% rename from packages/app/src/cli/api/graphql/business-platform-destinations/queries/find-organizations.graphql rename to packages/organizations/src/cli/api/graphql/business-platform-destinations/queries/find_organization.graphql index ebf8b3667d0..2843f38f964 100644 --- a/packages/app/src/cli/api/graphql/business-platform-destinations/queries/find-organizations.graphql +++ b/packages/organizations/src/cli/api/graphql/business-platform-destinations/queries/find_organization.graphql @@ -1,4 +1,4 @@ -query FindOrganizations($organizationId: OrganizationID!) { +query FindOrganization($organizationId: OrganizationID!) { currentUserAccount { organization(id: $organizationId) { id diff --git a/packages/organizations/src/cli/services/fetch.test.ts b/packages/organizations/src/cli/services/fetch.test.ts index 7961c9ff4df..6808b77eec2 100644 --- a/packages/organizations/src/cli/services/fetch.test.ts +++ b/packages/organizations/src/cli/services/fetch.test.ts @@ -1,7 +1,8 @@ -import {fetchOrganizations, fetchOrganizationsWithAccessInfo} from './fetch.js' +import {fetchOrganizationById, 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' +import {nonRandomUUID} from '@shopify/cli-kit/node/crypto' vi.mock('@shopify/cli-kit/node/api/business-platform') vi.mock('@shopify/cli-kit/node/session') @@ -210,3 +211,94 @@ describe('fetchOrganizationsWithAccessInfo', () => { }) }) }) + +describe('fetchOrganizationById', () => { + test('returns the organization for a numeric ID, addressed by its encoded GID', async () => { + vi.mocked(businessPlatformRequestDoc).mockResolvedValue({ + currentUserAccount: {organization: {id: ENCODED_GID_1, name: 'My Org'}}, + }) + + const organization = await fetchOrganizationById('1234', 'test-token') + + expect(organization).toEqual({id: '1234', businessName: 'My Org'}) + expect(businessPlatformRequestDoc).toHaveBeenCalledWith( + expect.objectContaining({ + token: 'test-token', + variables: {organizationId: ENCODED_GID_1}, + // Keyed by the token, hashed: the cache is shared across every account on the machine. + cacheOptions: {cacheTTL: {hours: 6}, cacheExtraKey: nonRandomUUID('test-token')}, + }), + ) + }) + + test('keys the cache separately for each token, and never stores the token itself', async () => { + vi.mocked(businessPlatformRequestDoc).mockResolvedValue({ + currentUserAccount: {organization: {id: ENCODED_GID_1, name: 'My Org'}}, + }) + + await fetchOrganizationById('1234', 'first-account-token') + await fetchOrganizationById('1234', 'second-account-token') + + const [first, second] = vi + .mocked(businessPlatformRequestDoc) + .mock.calls.map(([options]) => (options as {cacheOptions: {cacheExtraKey: string}}).cacheOptions.cacheExtraKey) + expect(first).not.toEqual(second) + expect([first, second]).not.toContain('first-account-token') + }) + + test('accepts an organization GID and requests the same encoded ID', async () => { + vi.mocked(businessPlatformRequestDoc).mockResolvedValue({ + currentUserAccount: {organization: {id: ENCODED_GID_1, name: 'My Org'}}, + }) + + const organization = await fetchOrganizationById('gid://organization/Organization/1234', 'test-token') + + expect(organization).toEqual({id: 'gid://organization/Organization/1234', businessName: 'My Org'}) + expect(businessPlatformRequestDoc).toHaveBeenCalledWith( + expect.objectContaining({variables: {organizationId: ENCODED_GID_1}}), + ) + }) + + test('returns undefined when the account cannot reach an organization with that ID', async () => { + vi.mocked(businessPlatformRequestDoc).mockResolvedValue({currentUserAccount: {organization: null}}) + + await expect(fetchOrganizationById('9999999', 'test-token')).resolves.toBeUndefined() + }) + + test('returns undefined when BP cannot resolve currentUserAccount', async () => { + vi.mocked(businessPlatformRequestDoc).mockResolvedValue({currentUserAccount: null}) + + await expect(fetchOrganizationById('1234', 'test-token')).resolves.toBeUndefined() + }) + + test('throws without requesting anything when the ID is not numeric', async () => { + await expect(fetchOrganizationById('not-an-id', 'test-token')).rejects.toThrow('Invalid organization ID: not-an-id') + expect(businessPlatformRequestDoc).not.toHaveBeenCalled() + }) + + test('fetches a token when none is provided, and refreshes it on an unauthorized response', async () => { + vi.mocked(ensureAuthenticatedBusinessPlatform) + .mockResolvedValueOnce('initial-token') + .mockResolvedValueOnce('refreshed-token') + vi.mocked(businessPlatformRequestDoc).mockResolvedValue({ + currentUserAccount: {organization: {id: ENCODED_GID_1, name: 'My Org'}}, + }) + + await fetchOrganizationById('1234') + + const requestOptions = vi.mocked(businessPlatformRequestDoc).mock.calls[0]?.[0] as any + expect(requestOptions.token).toBe('initial-token') + await expect(requestOptions.unauthorizedHandler.handler()).resolves.toEqual({token: 'refreshed-token'}) + }) + + test('uses a caller-provided unauthorized handler instead of the default', async () => { + const unauthorizedHandler = {type: 'token_refresh' as const, handler: async () => ({token: 'client-token'})} + vi.mocked(businessPlatformRequestDoc).mockResolvedValue({ + currentUserAccount: {organization: {id: ENCODED_GID_1, name: 'My Org'}}, + }) + + await fetchOrganizationById('1234', 'test-token', unauthorizedHandler) + + expect(businessPlatformRequestDoc).toHaveBeenCalledWith(expect.objectContaining({unauthorizedHandler})) + }) +}) diff --git a/packages/organizations/src/cli/services/fetch.ts b/packages/organizations/src/cli/services/fetch.ts index 00bfed6fb9d..5b124fb54b7 100644 --- a/packages/organizations/src/cli/services/fetch.ts +++ b/packages/organizations/src/cli/services/fetch.ts @@ -1,9 +1,13 @@ +import {businessPlatformTokenRefreshHandler} from './business-platform.js' +import {FindOrganization} from '../api/graphql/business-platform-destinations/generated/find_organization.js' import {ListOrganizations} from '../api/graphql/business-platform-destinations/generated/organizations.js' import {Organization, OrganizationWithDetails} from '../models/organization.js' import {businessPlatformRequestDoc} from '@shopify/cli-kit/node/api/business-platform' +import {type UnauthorizedHandler} from '@shopify/cli-kit/node/api/graphql' import {ensureAuthenticatedBusinessPlatform} from '@shopify/cli-kit/node/session' +import {nonRandomUUID} from '@shopify/cli-kit/node/crypto' import {AbortError} from '@shopify/cli-kit/node/error' -import {numericIdFromEncodedGid} from '@shopify/cli-kit/common/gid' +import {encodeGid, numericIdFromEncodedGid, numericIdFromGid} from '@shopify/cli-kit/common/gid' interface FetchOrganizationsWithAccessInfoResult { organizations: Organization[] @@ -26,20 +30,59 @@ export async function fetchOrganizationsWithAccessInfo( return fetchOrganizationsWithDetails(token) } -async function fetchOrganizationsWithDetails(token?: string): Promise { +/** + * Looks up a single organization the current account belongs to, by ID. + * + * The lookup is unfiltered, since the schema offers no `APPS_CLI` filter for it, so it can resolve + * an organization the account can't reach from the CLI; the org-scoped request that follows stays + * the authority there. Its result is cached, so callers that report a miss to the user should + * confirm it against `fetchOrganizationsWithAccessInfo` first. + * + * @param organizationId - Numeric organization ID, or an organization GID. + * @param token - Business Platform token. Fetched when not provided. + * @param unauthorizedHandler - Overrides the default token refresh. + * @returns The organization, or undefined when the account has none with that ID. + */ +export async function fetchOrganizationById( + organizationId: string, + token?: string, + unauthorizedHandler?: UnauthorizedHandler, +): Promise { const resolvedToken = token ?? (await ensureAuthenticatedBusinessPlatform()) - const unauthorizedHandler = { - type: 'token_refresh' as const, - handler: async () => { - const newToken = await ensureAuthenticatedBusinessPlatform() - return {token: newToken} - }, + + const result = await businessPlatformRequestDoc({ + query: FindOrganization, + token: resolvedToken, + variables: {organizationId: encodedOrganizationGid(organizationId)}, + unauthorizedHandler: unauthorizedHandler ?? businessPlatformTokenRefreshHandler(), + // The cache key covers only the query and variables, and the cache is shared by every account + // on the machine, so the token that authorized the lookup is part of it — hashed, because the + // key is persisted. A rotated token misses rather than reading another account's entry. + cacheOptions: {cacheTTL: {hours: 6}, cacheExtraKey: nonRandomUUID(resolvedToken)}, + }) + + const organization = result.currentUserAccount?.organization + if (!organization) return undefined + + return {id: organizationId, businessName: organization.name} +} + +// Business Platform destinations endpoints address organizations by a base64-encoded GID. +function encodedOrganizationGid(organizationId: string): string { + const numericId = organizationId.startsWith('gid://') ? numericIdFromGid(organizationId) : organizationId + if (numericId === undefined || !/^\d+$/.test(numericId)) { + throw new AbortError(`Invalid organization ID: ${organizationId}`) } + return encodeGid(`gid://organization/Organization/${numericId}`) +} + +async function fetchOrganizationsWithDetails(token?: string): Promise { + const resolvedToken = token ?? (await ensureAuthenticatedBusinessPlatform()) const result = await businessPlatformRequestDoc({ query: ListOrganizations, token: resolvedToken, - unauthorizedHandler, + unauthorizedHandler: businessPlatformTokenRefreshHandler(), }) if (!result.currentUserAccount) { diff --git a/packages/organizations/src/index.ts b/packages/organizations/src/index.ts index 225e5a98e39..e87beacf904 100644 --- a/packages/organizations/src/index.ts +++ b/packages/organizations/src/index.ts @@ -1,4 +1,4 @@ -export {fetchOrganizations, fetchOrganizationsWithAccessInfo} from './cli/services/fetch.js' +export {fetchOrganizations, fetchOrganizationsWithAccessInfo, fetchOrganizationById} from './cli/services/fetch.js' export {selectOrg} from './cli/services/select.js' export {selectOrganizationPrompt} from './cli/prompts/organization.js' export type {Organization, OrganizationWithDetails, OrganizationStatus} from './cli/models/organization.js' diff --git a/packages/store/src/cli/services/store/list.test.ts b/packages/store/src/cli/services/store/list.test.ts index b8c1a58f04d..ef14208fdfb 100644 --- a/packages/store/src/cli/services/store/list.test.ts +++ b/packages/store/src/cli/services/store/list.test.ts @@ -5,13 +5,14 @@ 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' +import {fetchOrganizationById, fetchOrganizationsWithAccessInfo} from '@shopify/organizations' vi.mock('@shopify/cli-kit/node/session') vi.mock('@shopify/cli-kit/node/ui') vi.mock('@shopify/organizations', async (importOriginal) => ({ ...(await importOriginal()), fetchOrganizationsWithAccessInfo: vi.fn(), + fetchOrganizationById: vi.fn(), })) const acme = {id: '1234', businessName: 'Acme'} @@ -27,12 +28,17 @@ const orgEntry = { type: 'production', } +// The organizations the account can reach. `--organization-id` resolves through the by-ID lookup, +// and anything it can't find falls back to the same set via the filtered list. function mockOrganizations(organizations = [acme]) { vi.mocked(ensureAuthenticatedBusinessPlatform).mockResolvedValue('bp-token') vi.mocked(fetchOrganizationsWithAccessInfo).mockResolvedValue({ organizations, currentUserResolved: true, }) + vi.mocked(fetchOrganizationById).mockImplementation(async (organizationId) => + organizations.find((organization) => organization.id === organizationId), + ) } describe('listStores', () => { @@ -174,6 +180,50 @@ describe('listStores', () => { await expect(listStores({organizationId: 9999999})).rejects.toThrow('Organization with ID 9999999 not found.') }) + test('skips the organization list entirely when the requested organization resolves directly', async () => { + mockOrganizations([acme, beta]) + vi.spyOn(bpSource, 'listBusinessPlatformStores').mockResolvedValue({entries: [orgEntry], hasMore: false}) + + const result = await listStores({organizationId: 1234}) + + expect(fetchOrganizationById).toHaveBeenCalledWith('1234', 'bp-token') + expect(fetchOrganizationsWithAccessInfo).not.toHaveBeenCalled() + expect(result.organization).toEqual({id: '1234', name: 'Acme'}) + }) + + test('falls back to the organization list when the direct lookup misses but access exists', async () => { + mockOrganizations([acme]) + // The cached lookup can trail newly granted access, so the uncached list is the authority. + vi.mocked(fetchOrganizationById).mockResolvedValue(undefined) + vi.spyOn(bpSource, 'listBusinessPlatformStores').mockResolvedValue({entries: [orgEntry], hasMore: false}) + + const result = await listStores({organizationId: 1234}) + + expect(fetchOrganizationsWithAccessInfo).toHaveBeenCalled() + expect(bpSource.listBusinessPlatformStores).toHaveBeenCalledWith({ + token: 'bp-token', + organization: acme, + storeType: undefined, + }) + expect(result.organization).toEqual({id: '1234', name: 'Acme'}) + }) + + test('does not prompt when an organization id is provided but the direct lookup misses', async () => { + mockOrganizations([acme, beta]) + vi.mocked(fetchOrganizationById).mockResolvedValue(undefined) + vi.mocked(isTTY).mockReturnValue(true) + vi.spyOn(bpSource, 'listBusinessPlatformStores').mockResolvedValue({entries: [], hasMore: false}) + + await listStores({organizationId: 5678}) + + expect(renderAutocompletePrompt).not.toHaveBeenCalled() + expect(bpSource.listBusinessPlatformStores).toHaveBeenCalledWith({ + token: 'bp-token', + organization: beta, + storeType: undefined, + }) + }) + 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) => ({ diff --git a/packages/store/src/cli/services/store/list.ts b/packages/store/src/cli/services/store/list.ts index 60eb88a6527..b8d63a912d3 100644 --- a/packages/store/src/cli/services/store/list.ts +++ b/packages/store/src/cli/services/store/list.ts @@ -5,7 +5,7 @@ import {type StoreTypeFilter} from './store-type.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' +import {fetchOrganizationById, fetchOrganizationsWithAccessInfo, type Organization} from '@shopify/organizations' interface ListStoresOptions { organizationId?: number @@ -14,6 +14,15 @@ interface ListStoresOptions { export async function listStores(options: ListStoresOptions = {}): Promise { const token = await ensureAuthenticatedBusinessPlatform() + + // Look the organization up directly instead of paging through every one the account belongs to. + // A miss falls through to the list below, which supplies the accessible organizations for the + // not-found error and re-resolves access granted since the lookup cached that miss. + if (options.organizationId) { + const organization = await fetchOrganizationById(options.organizationId.toString(), token) + if (organization) return listStoresInOrganization(token, organization, options.storeType) + } + const organizationsResult = await fetchOrganizationsWithAccessInfo(token) if (!organizationsResult.currentUserResolved) { @@ -39,17 +48,21 @@ export async function listStores(options: ListStoresOptions = {}): Promise { + const result = await listBusinessPlatformStores({token, organization, storeType}) const {stores, truncated} = limitEntries(result.entries, result.hasMore) return { stores, - organization: storeListOrganization(selectedOrganization), - ...(options.storeType ? {storeType: options.storeType} : {}), + organization: storeListOrganization(organization), + ...(storeType ? {storeType} : {}), ...(truncated ? {truncated: true} : {}), } }