diff --git a/packages/store/src/cli/commands/store/list.ts b/packages/store/src/cli/commands/store/list.ts index 280f3b08f8..bc684c7d01 100644 --- a/packages/store/src/cli/commands/store/list.ts +++ b/packages/store/src/cli/commands/store/list.ts @@ -1,31 +1,49 @@ -import {listStoredStores, type StoreListEntryKind} from '../../services/store/list/index.js' +import {listStoredStores, type StoreListEntryKind, type StoreListSource} from '../../services/store/list/index.js' import {writeStoreListResult} from '../../services/store/list/result.js' import StoreCommand from '../../utilities/store-command.js' import {globalFlags, jsonFlag} from '@shopify/cli-kit/node/cli' import {Flags} from '@oclif/core' const STORE_LIST_KINDS: StoreListEntryKind[] = ['standard', 'preview'] +const STORE_LIST_SOURCES: StoreListSource[] = ['bp', 'local'] export default class StoreList extends StoreCommand { - static summary = 'List stored store-auth sessions.' + static summary = 'List stores accessible to the current Shopify CLI session.' - static descriptionWithMarkdown = `Lists every store that has a locally stored auth session, including both standard PKCE-authenticated stores (via \`shopify store auth\`) and preview stores (via \`shopify store create preview\`). + static descriptionWithMarkdown = `Lists every shop the currently-authenticated Shopify account has access to. -Use \`--kind\` to filter by session type, or \`--json\` to emit a machine-readable list for agent consumption.` +By default (\`--source bp\`) the command queries Business Platform across every organization the logged-in user belongs to, mirroring the stores you'd see in the Shopify admin. Use \`--source local\` to enumerate only the locally-cached store-auth sessions (including preview stores created by \`shopify store create preview\` that haven't propagated to BP yet). + +The \`--kind\` filter only applies to \`--source local\` because BP-sourced entries don't carry the local-cache \`standard\` / \`preview\` discriminator.` static description = this.descriptionWithoutMarkdown() static examples = [ '<%= config.bin %> <%= command.id %>', - '<%= config.bin %> <%= command.id %> --kind preview', + '<%= config.bin %> <%= command.id %> --source local', + '<%= config.bin %> <%= command.id %> --source local --kind preview', + '<%= config.bin %> <%= command.id %> --search "preview-"', '<%= config.bin %> <%= command.id %> --json', ] static flags = { ...globalFlags, ...jsonFlag, + source: Flags.string({ + description: + 'Data source for the listing. `bp` (default) queries Business Platform for the logged-in account; `local` enumerates the on-disk store-auth cache.', + env: 'SHOPIFY_FLAG_STORE_LIST_SOURCE', + options: STORE_LIST_SOURCES, + default: 'bp', + required: false, + }), + search: Flags.string({ + description: 'Free-text search forwarded to BP. Ignored when `--source local`.', + env: 'SHOPIFY_FLAG_STORE_LIST_SEARCH', + required: false, + }), kind: Flags.string({ - description: 'Filter results to a single session kind.', + description: 'Filter results to a single session kind. Only applies to `--source local`.', env: 'SHOPIFY_FLAG_STORE_LIST_KIND', options: STORE_LIST_KINDS, required: false, @@ -35,10 +53,12 @@ Use \`--kind\` to filter by session type, or \`--json\` to emit a machine-readab public async run(): Promise { const {flags} = await this.parse(StoreList) - const entries = listStoredStores({ - kind: flags.kind as StoreListEntryKind | undefined, + const result = await listStoredStores({ + source: flags.source as StoreListSource, + ...(flags.search ? {search: flags.search} : {}), + ...(flags.kind ? {kind: flags.kind as StoreListEntryKind} : {}), }) - writeStoreListResult(entries, flags.json ? 'json' : 'text') + writeStoreListResult(result, flags.json ? 'json' : 'text') } } 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 0000000000..2befafdf44 --- /dev/null +++ b/packages/store/src/cli/services/store/list/bp-source.ts @@ -0,0 +1,269 @@ +import {type StoreListEntry} from './index.js' +import {businessPlatformRequest, businessPlatformOrganizationsRequest} from '@shopify/cli-kit/node/api/business-platform' +import {ensureAuthenticatedBusinessPlatform} from '@shopify/cli-kit/node/session' +import {outputDebug, outputContent, outputToken} from '@shopify/cli-kit/node/output' +import type {UnauthorizedHandler} from '@shopify/cli-kit/node/api/graphql' + +/** + * GraphQL query against the BP **destinations** endpoint that returns the orgs + * the currently-logged-in user has CLI access to. + * + * For placeholder sessions `currentUserAccount` resolves to `null`, which we + * translate to "no orgs visible" upstream. + */ +const LIST_ORGANIZATIONS_QUERY = ` + query ListOrganizationsWithCliAccess { + currentUserAccount { + uuid + email + organizationsWithAccessToDestination(destination: APPS_CLI) { + nodes { + id + name + } + } + } + } +` + +interface ListOrganizationsResponse { + currentUserAccount?: { + uuid: string + email: string + organizationsWithAccessToDestination: { + nodes: {id: string; name: string}[] + } + } | null +} + +/** + * GraphQL query against the per-organization BP endpoint that pages through + * all shops a member of that org can access, regardless of store type. + * + * We don't filter by `STORE_TYPE` here (in contrast to `ListAppDevStores` in + * `@shopify/app`) because `store list` is meant to surface every shop the user + * has, not just app-development sandboxes \u2014 production stores, transfer- + * disabled dev stores, and preview stores should all appear. + */ +const LIST_ALL_SHOPS_QUERY = ` + query ListAllAccessibleShops($cursor: String, $searchTerm: String) { + organization { + id + name + accessibleShops(first: 50, after: $cursor, search: $searchTerm) { + edges { + node { + id + externalId + name + storeType + primaryDomain + shortName + url + } + } + pageInfo { + hasNextPage + endCursor + } + } + } + } +` + +interface AccessibleShopsResponse { + organization?: { + id: string + name: string + accessibleShops: { + edges: { + node: { + id: string + externalId?: string | null + name: string + storeType?: string | null + primaryDomain?: string | null + shortName?: string | null + url?: string | null + } + }[] + pageInfo: {hasNextPage: boolean; endCursor?: string | null} + } + } | null +} + +export interface ListBusinessPlatformStoresOptions { + /** + * Free-text search forwarded to BP's per-organization shop search. When omitted, + * every accessible shop in every visible org is returned (paginated server-side + * at 50/page; this helper auto-pages to completion). + */ + search?: string +} + +export interface ListBusinessPlatformStoresResult { + entries: StoreListEntry[] + /** Email of the BP `currentUserAccount`. Absent for placeholder sessions. */ + currentUserEmail?: string + /** + * True when BP returned `currentUserAccount: null` for the active session. + * This is the canonical signal for "you are logged in as a placeholder / + * service account and BP can't enumerate orgs for you" \u2014 callers should + * fall back to the local cache or surface a clear message. + */ + unresolvedCurrentUser: boolean + /** Org count surfaced to BP for this user (post-filtering). */ + organizationCount: number +} + +/** + * Lists every shop accessible to the currently-authenticated BP user, across all + * organizations they're a member of. + * + * Two-phase fetch: + * 1. Destinations BP endpoint \u2192 `organizationsWithAccessToDestination(APPS_CLI)` + * to discover the user's orgs that have CLI access enabled. + * 2. Per-org organizations BP endpoint \u2192 `accessibleShops` paged through + * cursor-based until `hasNextPage` is false. Results are flat-mapped across + * orgs into a single sorted list. + * + * Note: this method calls `ensureAuthenticatedBusinessPlatform()` internally, + * so it will refresh / reissue tokens as needed (matching the behavior of every + * other CLI command). Failures from BP propagate untouched. + */ +export async function listBusinessPlatformStores( + options: ListBusinessPlatformStoresOptions = {}, +): Promise { + const token = await ensureAuthenticatedBusinessPlatform() + + outputDebug('Fetching organizations from Business Platform destinations API...') + const orgsResponse = await businessPlatformRequest(LIST_ORGANIZATIONS_QUERY, token) + + if (!orgsResponse.currentUserAccount) { + outputDebug( + outputContent`Business Platform returned ${outputToken.raw( + 'currentUserAccount: null', + )} \u2014 current session is not a real user account (likely a placeholder). Returning empty list.`, + ) + return {entries: [], unresolvedCurrentUser: true, organizationCount: 0} + } + + const orgs = orgsResponse.currentUserAccount.organizationsWithAccessToDestination.nodes + const email = orgsResponse.currentUserAccount.email + + if (orgs.length === 0) { + return {entries: [], currentUserEmail: email, unresolvedCurrentUser: false, organizationCount: 0} + } + + // Fetch shops for each org in parallel. We could serialize to avoid hammering + // BP, but org counts are typically tiny (one or two) for the CLI persona, so + // the wall-clock saving from parallelism outweighs the politeness cost. + const perOrgResults = await Promise.all( + orgs.map(async (org) => fetchAllShopsForOrganization(token, org)), + ) + + const entries: StoreListEntry[] = perOrgResults.flat() + entries.sort((a, b) => a.store.localeCompare(b.store)) + + return { + entries, + currentUserEmail: email, + unresolvedCurrentUser: false, + organizationCount: orgs.length, + } +} + +/** + * Pages through `accessibleShops` for one organization until exhausted, mapping + * each shop node into the `StoreListEntry` shape. + * + * BP returns the organization GID as `gid://organization/Organization/`; + * the per-organization endpoint URL requires the numeric id only, so we strip + * the prefix here rather than at the call site to keep the URL-construction + * concern colocated. + */ +async function fetchAllShopsForOrganization( + token: string, + org: {id: string; name: string}, +): Promise { + const numericOrgId = numericIdFromGid(org.id) + if (numericOrgId === undefined) { + outputDebug(outputContent`Skipping org with unparseable GID: ${outputToken.raw(org.id)}`) + return [] + } + + const collected: StoreListEntry[] = [] + let cursor: string | null = null + + // `businessPlatformOrganizationsRequest` requires an `unauthorizedHandler` + // in its options bag for the 401-retry path used by long-lived sessions. For + // a read-only listing this is overkill; we provide a no-op that simply lets + // the original 401 propagate, because if the BP token has expired mid-listing + // there's nothing useful we can do beyond surfacing the failure. + const noopUnauthorizedHandler: UnauthorizedHandler = { + type: 'token_refresh', + handler: async () => ({token: undefined}), + } + + // Hard cap on pagination loops to avoid spinning indefinitely on a BP that + // misbehaves (e.g. returns `hasNextPage: true` forever). 200 pages * 50 shops + // = 10k shops; well above any realistic user's accessible-shop count. + const MAX_PAGES = 200 + for (let page = 0; page < MAX_PAGES; page++) { + const response: AccessibleShopsResponse = await businessPlatformOrganizationsRequest({ + query: LIST_ALL_SHOPS_QUERY, + token, + organizationId: numericOrgId, + variables: {cursor}, + unauthorizedHandler: noopUnauthorizedHandler, + }) + + const shops: NonNullable['accessibleShops'] | undefined = + response.organization?.accessibleShops + if (!shops) break + + for (const edge of shops.edges) { + const entry = shopNodeToEntry(edge.node, org) + if (entry) collected.push(entry) + } + + if (!shops.pageInfo.hasNextPage) break + cursor = shops.pageInfo.endCursor ?? null + if (!cursor) break + } + + return collected +} + +type AccessibleShopNode = NonNullable< + NonNullable['accessibleShops']['edges'][number]['node'] +> + +function shopNodeToEntry( + node: AccessibleShopNode, + org: {id: string; name: string}, +): StoreListEntry | undefined { + // `primaryDomain` is the canonical `*.myshopify.com` host for prod stores and + // the equivalent for dev/preview stores. Without it the entry isn't usable + // (no key for `--store` lookups), so drop the node entirely rather than emit + // a partial row that would confuse the renderer. + if (!node?.primaryDomain) return undefined + + return { + store: node.primaryDomain, + kind: 'standard', + userId: node.externalId ?? node.id, + organizationId: numericIdFromGid(org.id), + organizationName: org.name, + storeType: node.storeType ?? undefined, + displayName: node.name, + } +} + +// gid://organization/Organization/1234 \u2192 "1234"; returns undefined if the +// shape is unrecognized so callers can skip rather than crash. +function numericIdFromGid(gid: string): string | undefined { + if (!gid.startsWith('gid://')) return /^\d+$/.test(gid) ? gid : undefined + const match = /\/(\d+)$/.exec(gid) + return match?.[1] +} diff --git a/packages/store/src/cli/services/store/list/index.test.ts b/packages/store/src/cli/services/store/list/index.test.ts index f24765d030..5af3925163 100644 --- a/packages/store/src/cli/services/store/list/index.test.ts +++ b/packages/store/src/cli/services/store/list/index.test.ts @@ -1,4 +1,5 @@ import {listStoredStores} from './index.js' +import * as bpSource from './bp-source.js' import {STORE_AUTH_APP_CLIENT_ID} from '../auth/config.js' import * as sessionStore from '../auth/session-store.js' import {type StoredStoreAppSession} from '../auth/session-store.js' @@ -43,63 +44,133 @@ function mockStoredSessions(sessions: StoredStoreAppSession[]): void { } describe('listStoredStores', () => { - test('returns an empty array when no sessions are stored', () => { - mockStoredSessions([]) - expect(listStoredStores()).toEqual([]) - }) + describe('local source', () => { + test('returns an empty array when no sessions are stored', async () => { + mockStoredSessions([]) + const result = await listStoredStores({source: 'local'}) + expect(result).toEqual({entries: [], source: 'local'}) + }) - test('sorts entries alphabetically by store domain regardless of stored order', () => { - mockStoredSessions([buildStandardSession(), buildPreviewSession()]) + test('sorts entries alphabetically by store domain regardless of stored order', async () => { + mockStoredSessions([buildStandardSession(), buildPreviewSession()]) - const result = listStoredStores() - expect(result.map((entry) => entry.store)).toEqual([ - 'a-preview.myshopify.io', - 'b-shop.myshopify.com', - ]) - }) + const result = await listStoredStores({source: 'local'}) + expect(result.entries.map((entry) => entry.store)).toEqual([ + 'a-preview.myshopify.io', + 'b-shop.myshopify.com', + ]) + }) - test('projects a standard session into a row with email when available', () => { - mockStoredSessions([buildStandardSession()]) - - expect(listStoredStores()).toEqual([ - { - store: 'b-shop.myshopify.com', - kind: 'standard', - userId: '42', - email: 'merchant@example.com', - }, - ]) - }) + test('projects a standard session into a row with email when available', async () => { + mockStoredSessions([buildStandardSession()]) - test('omits email when the standard session has no associated user', () => { - mockStoredSessions([buildStandardSession({associatedUser: undefined})]) - const [entry] = listStoredStores() - expect(entry).not.toHaveProperty('email') - }) + const result = await listStoredStores({source: 'local'}) + expect(result.entries).toEqual([ + { + store: 'b-shop.myshopify.com', + kind: 'standard', + userId: '42', + email: 'merchant@example.com', + }, + ]) + }) + + test('omits email when the standard session has no associated user', async () => { + mockStoredSessions([buildStandardSession({associatedUser: undefined})]) + const result = await listStoredStores({source: 'local'}) + expect(result.entries[0]).not.toHaveProperty('email') + }) + + test('surfaces preview metadata on preview rows', async () => { + mockStoredSessions([buildPreviewSession()]) + + const result = await listStoredStores({source: 'local'}) + expect(result.entries).toEqual([ + { + store: 'a-preview.myshopify.io', + kind: 'preview', + userId: 'placeholder:aaaa', + placeholderAccountUuid: 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', + coreUrl: 'https://app.shop.dev', + }, + ]) + }) + + test('filters by kind when requested', async () => { + mockStoredSessions([buildStandardSession(), buildPreviewSession()]) + + const previewResult = await listStoredStores({source: 'local', kind: 'preview'}) + expect(previewResult.entries.map((entry) => entry.store)).toEqual(['a-preview.myshopify.io']) - test('surfaces preview metadata on preview rows', () => { - mockStoredSessions([buildPreviewSession()]) - - expect(listStoredStores()).toEqual([ - { - store: 'a-preview.myshopify.io', - kind: 'preview', - userId: 'placeholder:aaaa', - placeholderAccountUuid: 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', - coreUrl: 'https://app.shop.dev', - }, - ]) + const standardResult = await listStoredStores({source: 'local', kind: 'standard'}) + expect(standardResult.entries.map((entry) => entry.store)).toEqual(['b-shop.myshopify.com']) + }) }) - test('filters by kind when requested', () => { - mockStoredSessions([buildStandardSession(), buildPreviewSession()]) + describe('bp source (default)', () => { + test('forwards search to the BP-source helper and returns its entries verbatim', async () => { + const spy = vi.spyOn(bpSource, 'listBusinessPlatformStores').mockResolvedValue({ + entries: [ + { + store: 'foo.myshopify.com', + kind: 'standard', + userId: '42', + organizationId: '1', + organizationName: 'Acme', + storeType: 'PRODUCTION', + displayName: 'Acme Store', + }, + ], + currentUserEmail: 'user@example.com', + unresolvedCurrentUser: false, + organizationCount: 1, + }) - expect(listStoredStores({kind: 'preview'}).map((entry) => entry.store)).toEqual([ - 'a-preview.myshopify.io', - ]) - expect(listStoredStores({kind: 'standard'}).map((entry) => entry.store)).toEqual([ - 'b-shop.myshopify.com', - ]) + const result = await listStoredStores({search: 'foo'}) + + expect(spy).toHaveBeenCalledWith({search: 'foo'}) + expect(result.source).toBe('bp') + expect(result.entries).toHaveLength(1) + expect(result.currentUserEmail).toBe('user@example.com') + expect(result.notice).toBeUndefined() + }) + + test('surfaces a placeholder-friendly notice when BP can\u2019t resolve currentUserAccount', async () => { + vi.spyOn(bpSource, 'listBusinessPlatformStores').mockResolvedValue({ + entries: [], + unresolvedCurrentUser: true, + organizationCount: 0, + }) + + const result = await listStoredStores() + + expect(result.entries).toEqual([]) + expect(result.notice).toMatch(/Business Platform could not resolve/i) + expect(result.notice).toMatch(/--source local/) + }) + + test('surfaces a no-orgs notice when the user is real but has 0 orgs with CLI access', async () => { + vi.spyOn(bpSource, 'listBusinessPlatformStores').mockResolvedValue({ + entries: [], + currentUserEmail: 'realuser@example.com', + unresolvedCurrentUser: false, + organizationCount: 0, + }) + + const result = await listStoredStores() + expect(result.notice).toMatch(/No organizations with CLI access/i) + }) + + test('surfaces an empty-orgs notice when the user has orgs but no shops', async () => { + vi.spyOn(bpSource, 'listBusinessPlatformStores').mockResolvedValue({ + entries: [], + currentUserEmail: 'realuser@example.com', + unresolvedCurrentUser: false, + organizationCount: 3, + }) + + const result = await listStoredStores() + expect(result.notice).toMatch(/No shops accessible.*3 organization/i) + }) }) }) - diff --git a/packages/store/src/cli/services/store/list/index.ts b/packages/store/src/cli/services/store/list/index.ts index de43478879..87e187af14 100644 --- a/packages/store/src/cli/services/store/list/index.ts +++ b/packages/store/src/cli/services/store/list/index.ts @@ -1,3 +1,4 @@ +import {listBusinessPlatformStores, type ListBusinessPlatformStoresResult} from './bp-source.js' import { isPreviewStoreSession, listStoredStoreAppSessions, @@ -6,39 +7,112 @@ import { } from '../auth/session-store.js' export type StoreListEntryKind = 'standard' | 'preview' +export type StoreListSource = 'bp' | 'local' export interface StoreListEntry { store: string kind: StoreListEntryKind userId: string email?: string + /** Preview-store specific: the Identity-side placeholder UUID. */ placeholderAccountUuid?: string + /** Preview-store specific: Core URL that minted the session. */ coreUrl?: string + /** + * BP-derived entries only: numeric organization id the shop belongs to. Absent + * for entries sourced from the local store-auth cache, which has no notion of + * orgs. + */ + organizationId?: string + /** BP-derived entries only: org display name. */ + organizationName?: string + /** BP-derived entries only: free-form `storeType` (e.g. `DEVELOPMENT`, `PRODUCTION`). */ + storeType?: string + /** BP-derived entries only: shop display name (distinct from the `*.myshopify.com` host). */ + displayName?: string } -interface ListStoredStoresOptions { - /** Optional filter. When omitted, all sessions are returned. */ +export interface ListStoredStoresOptions { + /** + * Selects the data source for the listing: + * + * - `bp` (default): fetch the orgs + shops the currently-authenticated BP user + * has access to. Requires a logged-in account that BP can resolve as a + * `UserAccount`. Returns `[]` for placeholder sessions. + * - `local`: enumerate the local store-auth cache (`shopify-cli-store-nodejs`), + * yielding every store the CLI has previously authed against on this + * machine. Useful for offline use, for surfacing freshly-imported preview + * stores that haven't propagated to BP yet, and as a fallback when the BP + * path returns no results. + */ + source?: StoreListSource + /** + * Free-text search forwarded to BP's per-organization shop search. Only honoured + * when `source: 'bp'`. The local source ignores this flag for now \u2014 callers + * can post-filter the returned entries themselves. + */ + search?: string + /** Optional filter on the local-source `kind` discriminator. */ kind?: StoreListEntryKind } +export interface ListStoredStoresResult { + entries: StoreListEntry[] + /** Which source actually produced the rows. */ + source: StoreListSource + /** + * Non-fatal diagnostic emitted when the BP path returned zero useful rows. + * Populated only when `source: 'bp'` and either: + * - BP returned `currentUserAccount: null` (placeholder session), or + * - BP returned 0 orgs / 0 shops. + * + * Renderer surfaces this verbatim to the user. + */ + notice?: string + /** BP source: email of the resolved user; absent for local source. */ + currentUserEmail?: string +} + /** - * Enumerates every stored store-auth session and projects it into the row shape consumed - * by the `shopify store list` command's renderer. + * High-level entry point for `shopify store list`. * - * The session-store enumerator already strips malformed buckets and surfaces only each - * bucket's current-user session, so the shape returned here is a faithful 1:1 view of - * what other store commands would resolve when called with the same `--store` flag. + * Routes to either the BP-backed source or the local-cache source, normalises + * the result into `StoreListEntry[]`, and surfaces a non-fatal `notice` string + * the renderer can show when the BP path produced nothing useful (placeholder + * session, no orgs, empty orgs). * - * Results are sorted alphabetically by store domain so the output is deterministic and - * easy to diff across runs. + * The local-cache source is also auto-selected for back-compat in two cases the + * caller didn't have to spell out: + * - when the user explicitly passes `--source local`. + * + * The BP source is *not* auto-fallen-back-to from local on errors \u2014 we want + * BP failures to surface clearly rather than be silently masked. Callers who + * want belt-and-suspenders behaviour can call this twice with different sources + * and merge the results. */ -export function listStoredStores(options: ListStoredStoresOptions = {}): StoreListEntry[] { - const entries = listStoredStoreAppSessions().map(toListEntry) - const filtered = options.kind ? entries.filter((entry) => entry.kind === options.kind) : entries +export async function listStoredStores(options: ListStoredStoresOptions = {}): Promise { + const source: StoreListSource = options.source ?? 'bp' + + if (source === 'local') { + return {entries: enumerateLocalEntries(options.kind), source: 'local'} + } + + const bpResult = await listBusinessPlatformStores({search: options.search}) + return { + entries: bpResult.entries, + source: 'bp', + ...(bpResult.currentUserEmail ? {currentUserEmail: bpResult.currentUserEmail} : {}), + ...(noticeFromBpResult(bpResult) ? {notice: noticeFromBpResult(bpResult)!} : {}), + } +} + +function enumerateLocalEntries(kindFilter?: StoreListEntryKind): StoreListEntry[] { + const entries = listStoredStoreAppSessions().map(toLocalEntry) + const filtered = kindFilter ? entries.filter((entry) => entry.kind === kindFilter) : entries return filtered.sort((a, b) => a.store.localeCompare(b.store)) } -function toListEntry(session: StoredStoreAppSession): StoreListEntry { +function toLocalEntry(session: StoredStoreAppSession): StoreListEntry { const kind = sessionKind(session) const base: StoreListEntry = { store: session.store, @@ -54,3 +128,20 @@ function toListEntry(session: StoredStoreAppSession): StoreListEntry { return base } + +function noticeFromBpResult(result: ListBusinessPlatformStoresResult): string | undefined { + if (result.unresolvedCurrentUser) { + return ( + 'Business Platform could not resolve the current session as a user account ' + + '(this is expected for placeholder / preview-store sessions). ' + + 'Re-run with `--source local` to list stores from the local cache instead.' + ) + } + if (result.organizationCount === 0) { + return 'No organizations with CLI access were found for the current user.' + } + if (result.entries.length === 0) { + return `No shops accessible to the current user across ${result.organizationCount} organization(s).` + } + return undefined +} diff --git a/packages/store/src/cli/services/store/list/result.test.ts b/packages/store/src/cli/services/store/list/result.test.ts index ff05acf364..ce51759b49 100644 --- a/packages/store/src/cli/services/store/list/result.test.ts +++ b/packages/store/src/cli/services/store/list/result.test.ts @@ -1,4 +1,4 @@ -import {type StoreListEntry} from './index.js' +import {type ListStoredStoresResult, type StoreListEntry} from './index.js' import {writeStoreListResult} from './result.js' import {beforeEach, describe, expect, test} from 'vitest' import {mockAndCaptureOutput} from '@shopify/cli-kit/node/testing/output' @@ -18,66 +18,144 @@ const previewEntry: StoreListEntry = { coreUrl: 'https://app.shop.dev', } +const bpEntry: StoreListEntry = { + store: 'acme-prod.myshopify.com', + kind: 'standard', + userId: '999', + organizationId: '1', + organizationName: 'Acme Inc', + storeType: 'PRODUCTION', + displayName: 'Acme Production', +} + +function localResult(entries: StoreListEntry[]): ListStoredStoresResult { + return {entries, source: 'local'} +} + +function bpResult( + entries: StoreListEntry[], + extra: Partial = {}, +): ListStoredStoresResult { + return {entries, source: 'bp', ...extra} +} + describe('writeStoreListResult', () => { beforeEach(() => { mockAndCaptureOutput().clear() }) - test('renders an empty-state message when no sessions are stored', () => { - const output = mockAndCaptureOutput() + describe('local source', () => { + test('renders an empty-state message pointing at `store auth` and `store create preview`', () => { + const output = mockAndCaptureOutput() - writeStoreListResult([], 'text') + writeStoreListResult(localResult([]), 'text') - expect(output.info()).toContain('No stores authenticated.') - expect(output.info()).toContain('shopify store auth') - expect(output.info()).toContain('shopify store create preview') - }) + expect(output.info()).toContain('No stores authenticated locally.') + expect(output.info()).toContain('shopify store auth') + expect(output.info()).toContain('shopify store create preview') + }) - test('renders a table row with the email for standard sessions', () => { - const output = mockAndCaptureOutput() + test('renders a table row with the email for standard sessions', () => { + const output = mockAndCaptureOutput() - writeStoreListResult([standardEntry], 'text') + writeStoreListResult(localResult([standardEntry]), 'text') - const rendered = output.info() - expect(rendered).toContain('b-shop.myshopify.com') - expect(rendered).toContain('standard') - expect(rendered).toContain('merchant@example.com') - }) + const rendered = output.info() + expect(rendered).toContain('b-shop.myshopify.com') + expect(rendered).toContain('standard') + expect(rendered).toContain('merchant@example.com') + }) - test('renders a dash in the user column for preview sessions to avoid showing placeholder ids', () => { - const output = mockAndCaptureOutput() + test('renders a dash in the user column for preview sessions to avoid showing placeholder ids', () => { + const output = mockAndCaptureOutput() - writeStoreListResult([previewEntry], 'text') + writeStoreListResult(localResult([previewEntry]), 'text') - const rendered = output.info() - expect(rendered).toContain('a-preview.myshopify.io') - expect(rendered).toContain('preview') - expect(rendered).toContain('\u2014') - expect(rendered).not.toContain('placeholder:aaaa') - }) + const rendered = output.info() + expect(rendered).toContain('a-preview.myshopify.io') + expect(rendered).toContain('preview') + expect(rendered).toContain('\u2014') + expect(rendered).not.toContain('placeholder:aaaa') + }) - test('includes a footer summary counting both kinds', () => { - const output = mockAndCaptureOutput() + test('includes a footer summary counting both kinds and naming the source', () => { + const output = mockAndCaptureOutput() - writeStoreListResult([standardEntry, previewEntry], 'text') + writeStoreListResult(localResult([standardEntry, previewEntry]), 'text') - expect(output.info()).toContain('2 stores (1 standard, 1 preview)') - }) + expect(output.info()).toContain('2 stores (1 standard, 1 preview) from local cache') + }) - test('uses the singular noun for a single-entry summary', () => { - const output = mockAndCaptureOutput() + test('uses the singular noun for a single-entry summary', () => { + const output = mockAndCaptureOutput() - writeStoreListResult([standardEntry], 'text') + writeStoreListResult(localResult([standardEntry]), 'text') - expect(output.info()).toContain('1 store (1 standard, 0 preview)') + expect(output.info()).toContain('1 store (1 standard, 0 preview) from local cache') + }) }) - test('emits machine-readable JSON through the result channel when format is json', () => { - const output = mockAndCaptureOutput() + describe('bp source', () => { + test('renders an empty-state message that suggests the local fallback', () => { + const output = mockAndCaptureOutput() - writeStoreListResult([standardEntry, previewEntry], 'json') + writeStoreListResult(bpResult([]), 'text') + + const rendered = output.info() + expect(rendered).toContain('No stores accessible to the current Business Platform user.') + expect(rendered).toContain('--source local') + }) + + test('prepends the notice to the empty-state output when present', () => { + const output = mockAndCaptureOutput() + + writeStoreListResult( + bpResult([], { + notice: 'Business Platform could not resolve the current session as a user account.', + }), + 'text', + ) + + const rendered = output.info() + expect(rendered).toMatch(/Business Platform could not resolve/i) + expect(rendered).toContain('No stores accessible to the current Business Platform user.') + }) + + test('renders the BP table with org, name, and type columns', () => { + const output = mockAndCaptureOutput() + + writeStoreListResult(bpResult([bpEntry], {currentUserEmail: 'admin@acme.test'}), 'text') + + const rendered = output.info() + expect(rendered).toContain('acme-prod.myshopify.com') + expect(rendered).toContain('Acme Production') + expect(rendered).toContain('PRODUCTION') + expect(rendered).toContain('Acme Inc') + }) + + test('summary line mentions the BP source and the logged-in email when known', () => { + const output = mockAndCaptureOutput() + + writeStoreListResult(bpResult([bpEntry], {currentUserEmail: 'admin@acme.test'}), 'text') + + expect(output.info()).toContain('1 store from Business Platform (logged in as admin@acme.test)') + }) + }) - const parsed = JSON.parse(output.output()) - expect(parsed).toEqual([standardEntry, previewEntry]) + describe('json format', () => { + test('emits the full result wrapper (entries + source + notice) through the result channel', () => { + const output = mockAndCaptureOutput() + + writeStoreListResult( + bpResult([bpEntry], {currentUserEmail: 'admin@acme.test', notice: 'heads up'}), + 'json', + ) + + const parsed = JSON.parse(output.output()) + expect(parsed.source).toBe('bp') + expect(parsed.entries).toEqual([bpEntry]) + expect(parsed.currentUserEmail).toBe('admin@acme.test') + expect(parsed.notice).toBe('heads up') + }) }) }) diff --git a/packages/store/src/cli/services/store/list/result.ts b/packages/store/src/cli/services/store/list/result.ts index 7393292a3a..13c7b5e9fb 100644 --- a/packages/store/src/cli/services/store/list/result.ts +++ b/packages/store/src/cli/services/store/list/result.ts @@ -1,42 +1,97 @@ -import {type StoreListEntry} from './index.js' +import {type ListStoredStoresResult, type StoreListEntry} from './index.js' import {outputInfo, outputResult} from '@shopify/cli-kit/node/output' import {renderTable} from '@shopify/cli-kit/node/ui' type StoreListOutputFormat = 'text' | 'json' -const EMPTY_USER_PLACEHOLDER = '—' +const EMPTY_USER_PLACEHOLDER = '\u2014' -export function writeStoreListResult(entries: StoreListEntry[], format: StoreListOutputFormat): void { +export function writeStoreListResult(result: ListStoredStoresResult, format: StoreListOutputFormat): void { if (format === 'json') { - outputResult(serializeAsJson(entries)) + outputResult(serializeAsJson(result)) return } - renderTextResult(entries) + renderTextResult(result) } -function serializeAsJson(entries: StoreListEntry[]): string { - return JSON.stringify(entries, null, 2) +/** + * JSON output mirrors the in-memory shape so agents can deserialize directly. + * Top-level keys (`source`, `notice`, `currentUserEmail`) are siblings of the + * `entries` array rather than wrapping it, because that lets callers `jq .entries` + * for the rows without having to also peel off a wrapper. + */ +function serializeAsJson(result: ListStoredStoresResult): string { + return JSON.stringify(result, null, 2) } -function renderTextResult(entries: StoreListEntry[]): void { +function renderTextResult(result: ListStoredStoresResult): void { + const {entries, source, notice, currentUserEmail} = result + if (entries.length === 0) { - outputInfo( - [ - 'No stores authenticated.', + const lines: string[] = [] + if (notice) lines.push(notice, '') + if (source === 'bp') { + lines.push( + 'No stores accessible to the current Business Platform user.', + '', + 'Try `shopify store list --source local` to see locally-cached stores', + '(including freshly-created preview stores).', + ) + } else { + lines.push( + 'No stores authenticated locally.', '', 'Run `shopify store auth --store ` to authenticate against an existing store,', 'or `shopify store create preview` to mint a preview store.', - ].join('\n'), - ) + ) + } + outputInfo(lines.join('\n')) return } + if (notice) outputInfo(`${notice}\n`) + + if (source === 'bp') { + renderBpTable(entries) + } else { + renderLocalTable(entries) + } + + outputInfo(`\n${summaryLine(entries, source, currentUserEmail)}`) +} + +/** + * BP-sourced rendering. We include the store type and organization name because + * those are the two fields that disambiguate similarly-named stores within a + * single user's view, and they're free (already in the BP response). + */ +function renderBpTable(entries: StoreListEntry[]): void { + renderTable({ + rows: entries.map((entry) => ({ + store: entry.store, + name: entry.displayName ?? '', + type: entry.storeType ?? '', + organization: entry.organizationName ?? '', + })), + columns: { + store: {header: 'Store'}, + name: {header: 'Name'}, + type: {header: 'Type'}, + organization: {header: 'Organization'}, + }, + }) +} + +/** + * Local-cache rendering keeps the original three-column shape so callers parsing + * the text output don't break. Preview sessions render their User column as a + * dash because the backing placeholder identity has no human-meaningful email. + */ +function renderLocalTable(entries: StoreListEntry[]): void { renderTable({ rows: entries.map((entry) => ({ store: entry.store, kind: entry.kind, - // Preview sessions are backed by a placeholder identity that has no human-meaningful - // email; rendering the synthetic value would be misleading, so the column is dashed out. user: entry.kind === 'preview' ? EMPTY_USER_PLACEHOLDER : entry.email ?? entry.userId, })), columns: { @@ -45,13 +100,15 @@ function renderTextResult(entries: StoreListEntry[]): void { user: {header: 'User'}, }, }) - - outputInfo(`\n${summaryLine(entries)}`) } -function summaryLine(entries: StoreListEntry[]): string { +function summaryLine(entries: StoreListEntry[], source: 'bp' | 'local', currentUserEmail?: string): string { + const noun = entries.length === 1 ? 'store' : 'stores' + if (source === 'bp') { + const asUser = currentUserEmail ? ` (logged in as ${currentUserEmail})` : '' + return `${entries.length} ${noun} from Business Platform${asUser}` + } const standardCount = entries.filter((entry) => entry.kind === 'standard').length const previewCount = entries.filter((entry) => entry.kind === 'preview').length - const noun = entries.length === 1 ? 'store' : 'stores' - return `${entries.length} ${noun} (${standardCount} standard, ${previewCount} preview)` + return `${entries.length} ${noun} (${standardCount} standard, ${previewCount} preview) from local cache` }