From 2b3d2e244b470b9b14e06ed19bceacc2cb30b157 Mon Sep 17 00:00:00 2001 From: Donald Merand Date: Thu, 11 Jun 2026 14:45:20 -0400 Subject: [PATCH 1/5] Add store list for organization stores --- .changeset/store-list-bp-auto.md | 8 + .../interfaces/store-list.interface.ts | 30 +++ .../generated/generated_docs_data_v2.json | 47 ++++ .../services/app/config/link-service.test.ts | 8 +- packages/app/src/cli/services/context.test.ts | 4 +- packages/app/src/cli/services/context.ts | 4 +- .../src/private/node/session/store.test.ts | 45 ++++ .../cli-kit/src/private/node/session/store.ts | 11 +- .../cli-kit/src/public/common/string.test.ts | 19 ++ packages/cli-kit/src/public/common/string.ts | 21 ++ .../cli-kit/src/public/common/url.test.ts | 27 ++- packages/cli-kit/src/public/common/url.ts | 28 ++- packages/cli/README.md | 36 +++ packages/cli/oclif.manifest.json | 59 +++++ packages/e2e/data/snapshots/commands.txt | 3 +- .../src/cli/services/fetch.test.ts | 99 +++++++- .../organizations/src/cli/services/fetch.ts | 28 ++- .../src/cli/services/select.test.ts | 29 ++- .../organizations/src/cli/services/select.ts | 6 + packages/organizations/src/index.ts | 4 +- .../generated/list_accessible_shops.ts | 153 ++++++++++++ .../queries/list_accessible_shops.graphql | 26 ++ .../store/src/cli/commands/store/list.test.ts | 41 ++++ packages/store/src/cli/commands/store/list.ts | 42 ++++ .../src/cli/services/store/info/index.ts | 23 +- .../cli/services/store/list/bp-source.test.ts | 224 ++++++++++++++++++ .../src/cli/services/store/list/bp-source.ts | 93 ++++++++ .../src/cli/services/store/list/constants.ts | 3 + .../src/cli/services/store/list/index.test.ts | 192 +++++++++++++++ .../src/cli/services/store/list/index.ts | 70 ++++++ .../cli/services/store/list/result.test.ts | 163 +++++++++++++ .../src/cli/services/store/list/result.ts | 83 +++++++ .../src/cli/services/store/list/types.ts | 22 ++ .../src/cli/services/store/store-type.ts | 27 +++ packages/store/src/index.ts | 2 + 35 files changed, 1636 insertions(+), 44 deletions(-) create mode 100644 .changeset/store-list-bp-auto.md create mode 100644 docs-shopify.dev/commands/interfaces/store-list.interface.ts create mode 100644 packages/store/src/cli/api/graphql/business-platform-organizations/generated/list_accessible_shops.ts create mode 100644 packages/store/src/cli/api/graphql/business-platform-organizations/queries/list_accessible_shops.graphql create mode 100644 packages/store/src/cli/commands/store/list.test.ts create mode 100644 packages/store/src/cli/commands/store/list.ts create mode 100644 packages/store/src/cli/services/store/list/bp-source.test.ts create mode 100644 packages/store/src/cli/services/store/list/bp-source.ts create mode 100644 packages/store/src/cli/services/store/list/constants.ts create mode 100644 packages/store/src/cli/services/store/list/index.test.ts create mode 100644 packages/store/src/cli/services/store/list/index.ts create mode 100644 packages/store/src/cli/services/store/list/result.test.ts create mode 100644 packages/store/src/cli/services/store/list/result.ts create mode 100644 packages/store/src/cli/services/store/list/types.ts create mode 100644 packages/store/src/cli/services/store/store-type.ts diff --git a/.changeset/store-list-bp-auto.md b/.changeset/store-list-bp-auto.md new file mode 100644 index 00000000000..951de2f5896 --- /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. When some organization lookups fail, the command returns the successful organizations with a warning and failure details for the skipped ones. diff --git a/docs-shopify.dev/commands/interfaces/store-list.interface.ts b/docs-shopify.dev/commands/interfaces/store-list.interface.ts new file mode 100644 index 00000000000..5673b2288ec --- /dev/null +++ b/docs-shopify.dev/commands/interfaces/store-list.interface.ts @@ -0,0 +1,30 @@ +// This is an autogenerated file. Don't edit this file manually. +/** + * The following flags are available for the `store list` command: + * @publicDocs + */ +export interface storelist { + /** + * Output the result as JSON. Automatically disables color output. + * @environment SHOPIFY_FLAG_JSON + */ + '-j, --json'?: '' + + /** + * Disable color output. + * @environment SHOPIFY_FLAG_NO_COLOR + */ + '--no-color'?: '' + + /** + * Filters the store list by organization. If omitted and you belong to more than one organization, you will be prompted to choose one. + * @environment SHOPIFY_FLAG_ORGANIZATION_ID + */ + '--organization-id '?: string + + /** + * Increase the verbosity of the output. + * @environment SHOPIFY_FLAG_VERBOSE + */ + '--verbose'?: '' +} diff --git a/docs-shopify.dev/generated/generated_docs_data_v2.json b/docs-shopify.dev/generated/generated_docs_data_v2.json index b83f906791c..79f5175da5c 100644 --- a/docs-shopify.dev/generated/generated_docs_data_v2.json +++ b/docs-shopify.dev/generated/generated_docs_data_v2.json @@ -4339,6 +4339,53 @@ "value": "export interface storeinfo {\n /**\n * Output the result as JSON. Automatically disables color output.\n * @environment SHOPIFY_FLAG_JSON\n */\n '-j, --json'?: ''\n\n /**\n * Disable color output.\n * @environment SHOPIFY_FLAG_NO_COLOR\n */\n '--no-color'?: ''\n\n /**\n * The myshopify.com domain of the store.\n * @environment SHOPIFY_FLAG_STORE\n */\n '-s, --store ': string\n\n /**\n * Increase the verbosity of the output.\n * @environment SHOPIFY_FLAG_VERBOSE\n */\n '--verbose'?: ''\n}" } }, + "storelist": { + "docs-shopify.dev/commands/interfaces/store-list.interface.ts": { + "filePath": "docs-shopify.dev/commands/interfaces/store-list.interface.ts", + "name": "storelist", + "description": "The following flags are available for the `store list` command:", + "isPublicDocs": true, + "members": [ + { + "filePath": "docs-shopify.dev/commands/interfaces/store-list.interface.ts", + "syntaxKind": "PropertySignature", + "name": "--no-color", + "value": "''", + "description": "Disable color output.", + "isOptional": true, + "environmentValue": "SHOPIFY_FLAG_NO_COLOR" + }, + { + "filePath": "docs-shopify.dev/commands/interfaces/store-list.interface.ts", + "syntaxKind": "PropertySignature", + "name": "--organization-id ", + "value": "string", + "description": "Filters the store list by organization. If omitted and you belong to more than one organization, you will be prompted to choose one.", + "isOptional": true, + "environmentValue": "SHOPIFY_FLAG_ORGANIZATION_ID" + }, + { + "filePath": "docs-shopify.dev/commands/interfaces/store-list.interface.ts", + "syntaxKind": "PropertySignature", + "name": "--verbose", + "value": "''", + "description": "Increase the verbosity of the output.", + "isOptional": true, + "environmentValue": "SHOPIFY_FLAG_VERBOSE" + }, + { + "filePath": "docs-shopify.dev/commands/interfaces/store-list.interface.ts", + "syntaxKind": "PropertySignature", + "name": "-j, --json", + "value": "''", + "description": "Output the result as JSON. Automatically disables color output.", + "isOptional": true, + "environmentValue": "SHOPIFY_FLAG_JSON" + } + ], + "value": "export interface storelist {\n /**\n * Output the result as JSON. Automatically disables color output.\n * @environment SHOPIFY_FLAG_JSON\n */\n '-j, --json'?: ''\n\n /**\n * Disable color output.\n * @environment SHOPIFY_FLAG_NO_COLOR\n */\n '--no-color'?: ''\n\n /**\n * Filters the store list by organization. If omitted and you belong to more than one organization, you will be prompted to choose one.\n * @environment SHOPIFY_FLAG_ORGANIZATION_ID\n */\n '--organization-id '?: string\n\n /**\n * Increase the verbosity of the output.\n * @environment SHOPIFY_FLAG_VERBOSE\n */\n '--verbose'?: ''\n}" + } + }, "themecheck": { "docs-shopify.dev/commands/interfaces/theme-check.interface.ts": { "filePath": "docs-shopify.dev/commands/interfaces/theme-check.interface.ts", 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..8f24a3f6e4a 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,7 +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 {selectOrganizationPrompt} from '@shopify/organizations' +import {selectOrganizationFromList} from '@shopify/organizations' import {beforeEach, describe, expect, test, vi} from 'vitest' import {inTemporaryDirectory, readFile, writeFileSync} from '@shopify/cli-kit/node/fs' import {joinPath} from '@shopify/cli-kit/node/path' @@ -83,7 +83,7 @@ api_version = "2024-01" vi.mocked(selectDeveloperPlatformClient).mockReturnValue(developerPlatformClient) vi.mocked(createAsNewAppPrompt).mockResolvedValue(true) vi.mocked(appNamePrompt).mockResolvedValue('A user provided name') - vi.mocked(selectOrganizationPrompt).mockResolvedValue({ + vi.mocked(selectOrganizationFromList).mockResolvedValue({ id: '12345', businessName: 'test', }) @@ -176,7 +176,7 @@ api_version = "2025-07" vi.mocked(selectDeveloperPlatformClient).mockReturnValue(developerPlatformClient) vi.mocked(createAsNewAppPrompt).mockResolvedValue(true) vi.mocked(appNamePrompt).mockResolvedValue('My App') - vi.mocked(selectOrganizationPrompt).mockResolvedValue({ + vi.mocked(selectOrganizationFromList).mockResolvedValue({ id: '12345', businessName: 'test', }) @@ -224,7 +224,7 @@ required = true vi.mocked(selectDeveloperPlatformClient).mockReturnValue(developerPlatformClient) vi.mocked(createAsNewAppPrompt).mockResolvedValue(true) vi.mocked(appNamePrompt).mockResolvedValue('My App') - vi.mocked(selectOrganizationPrompt).mockResolvedValue({ + vi.mocked(selectOrganizationFromList).mockResolvedValue({ id: '12345', businessName: 'test', }) diff --git a/packages/app/src/cli/services/context.test.ts b/packages/app/src/cli/services/context.test.ts index 9e5b1589e4b..b5ee4f24827 100644 --- a/packages/app/src/cli/services/context.test.ts +++ b/packages/app/src/cli/services/context.test.ts @@ -33,7 +33,7 @@ import { selectDeveloperPlatformClient, } from '../utilities/developer-platform-client.js' import {RemoteAwareExtensionSpecification} from '../models/extensions/specification.js' -import {selectOrganizationPrompt} from '@shopify/organizations' +import {selectOrganizationFromList} from '@shopify/organizations' import {TomlFile} from '@shopify/cli-kit/node/toml/toml-file' import {isServiceAccount, isUserAccount} from '@shopify/cli-kit/node/session' import {afterEach, beforeAll, beforeEach, describe, expect, test, vi} from 'vitest' @@ -145,7 +145,7 @@ beforeAll(async () => { beforeEach(async () => { vi.mocked(getAppIdentifiers).mockReturnValue({app: undefined}) - vi.mocked(selectOrganizationPrompt).mockResolvedValue(ORG1) + vi.mocked(selectOrganizationFromList).mockResolvedValue(ORG1) vi.mocked(selectOrCreateApp).mockResolvedValue(APP1) vi.mocked(selectStore).mockResolvedValue(STORE1) vi.mocked(fetchOrganizations).mockResolvedValue([ORG1, ORG2]) diff --git a/packages/app/src/cli/services/context.ts b/packages/app/src/cli/services/context.ts index fec74a35c74..7b1f268c199 100644 --- a/packages/app/src/cli/services/context.ts +++ b/packages/app/src/cli/services/context.ts @@ -23,7 +23,7 @@ import { DeveloperPlatformClient, selectDeveloperPlatformClient, } from '../utilities/developer-platform-client.js' -import {selectOrganizationPrompt} from '@shopify/organizations' +import {selectOrganizationFromList} from '@shopify/organizations' import {TomlFile} from '@shopify/cli-kit/node/toml/toml-file' import {isServiceAccount, isUserAccount} from '@shopify/cli-kit/node/session' import {tryParseInt} from '@shopify/cli-kit/common/string' @@ -320,7 +320,7 @@ export async function fetchOrCreateOrganizationApp( */ export async function selectOrg(): Promise { const orgs = await fetchOrganizations() - const org = await selectOrganizationPrompt(orgs) + const org = await selectOrganizationFromList(orgs) return org } 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/string.test.ts b/packages/cli-kit/src/public/common/string.test.ts index 4ec1868592e..d88c5e521d5 100644 --- a/packages/cli-kit/src/public/common/string.test.ts +++ b/packages/cli-kit/src/public/common/string.test.ts @@ -1,6 +1,7 @@ import { formatDate, formatLocalDate, + formatShortDate, getRandomName, joinWithAnd, linesToColumns, @@ -108,6 +109,24 @@ describe('formatDate', () => { }) }) +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('formatLocalDate', () => { test('formats an ISO date string to local date string', () => { // Given diff --git a/packages/cli-kit/src/public/common/string.ts b/packages/cli-kit/src/public/common/string.ts index ee19df264a1..3e88665aa1e 100644 --- a/packages/cli-kit/src/public/common/string.ts +++ b/packages/cli-kit/src/public/common/string.ts @@ -375,6 +375,27 @@ export function formatLocalDate(dateString: string): string { return formatDate(localDate) } +const SHORT_DATE_MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'] + +/** + * Formats a date as a short calendar date like "May 22, 2026" (`MMM DD, YYYY`) in UTC. + * + * UTC keeps the output deterministic regardless of the machine timezone. Returns an empty string + * when the value cannot be parsed into a valid date. + * + * @param value - A Date, epoch milliseconds, or a date string such as an ISO 8601 timestamp. + * @returns The formatted date, or an empty string when the value is invalid. + */ +export function formatShortDate(value: Date | number | string): string { + const date = value instanceof Date ? value : new Date(value) + if (Number.isNaN(date.getTime())) return '' + + const month = SHORT_DATE_MONTHS[date.getUTCMonth()] + if (!month) return '' + + return `${month} ${String(date.getUTCDate()).padStart(2, '0')}, ${date.getUTCFullYear()}` +} + /** * Given a list of items, it returns a string with the items joined by commas and the last item joined by "and". * All items are wrapped in double quotes. diff --git a/packages/cli-kit/src/public/common/url.test.ts b/packages/cli-kit/src/public/common/url.test.ts index ed72607d172..6d0880e1ec4 100644 --- a/packages/cli-kit/src/public/common/url.test.ts +++ b/packages/cli-kit/src/public/common/url.test.ts @@ -1,4 +1,4 @@ -import {extractHost, extractMyshopifyHandle, isValidURL, safeParseURL} from './url.js' +import {extractHost, extractMyshopifyHandle, extractSubdomain, isValidURL, safeParseURL} from './url.js' import {describe, expect, test} from 'vitest' describe('isValidURL', () => { @@ -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() @@ -88,3 +93,23 @@ describe('extractMyshopifyHandle', () => { expect(extractMyshopifyHandle(undefined)).toBeUndefined() }) }) + +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 (local dev)', () => { + 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/cli-kit/src/public/common/url.ts b/packages/cli-kit/src/public/common/url.ts index 12b3316b324..febe42b76b9 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 } /** @@ -57,3 +65,17 @@ export function extractMyshopifyHandle(value: string | null | undefined): string const match = host.match(/^([^.]+)\.myshopify\.com$/) return match ? match[1] : undefined } + +/** + * Extracts the leading subdomain label from a URL or host, across environments — e.g. + * `acme.myshopify.com`, `acme.shop.dev`, or local development hosts — rather than assuming a + * `*.myshopify.com` domain. + * + * @param value - A URL or host string, possibly null/undefined. + * @returns The first DNS label, or undefined when no host can be extracted. + */ +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/cli/README.md b/packages/cli/README.md index 62a97253975..f6c5a224537 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -79,6 +79,7 @@ * [`shopify store auth`](#shopify-store-auth) * [`shopify store execute`](#shopify-store-execute) * [`shopify store info`](#shopify-store-info) +* [`shopify store list`](#shopify-store-list) * [`shopify theme check`](#shopify-theme-check) * [`shopify theme console`](#shopify-theme-console) * [`shopify theme delete`](#shopify-theme-delete) @@ -2208,6 +2209,41 @@ EXAMPLES $ shopify store info --store shop.myshopify.com --json ``` +## `shopify store list` + +List stores in a Shopify organization. + +``` +USAGE + $ shopify store list [-j] [--no-color] [--organization-id ] [--verbose] + +FLAGS + -j, --json [env: SHOPIFY_FLAG_JSON] Output the result as JSON. Automatically disables color + output. + --no-color [env: SHOPIFY_FLAG_NO_COLOR] Disable color output. + --organization-id= [env: SHOPIFY_FLAG_ORGANIZATION_ID] Filters the store list by organization. If omitted + and you belong to more than one organization, you will be prompted to choose one. + --verbose [env: SHOPIFY_FLAG_VERBOSE] Increase the verbosity of the output. + +DESCRIPTION + List stores in a Shopify organization. + + 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, provide `--organization-id`. + + Run `shopify organization list` to find organization IDs. + +EXAMPLES + $ shopify store list + + $ shopify store list --organization-id 1234567 + + $ shopify store list --json +``` + ## `shopify theme check` Validate the theme. diff --git a/packages/cli/oclif.manifest.json b/packages/cli/oclif.manifest.json index 72d98e5d31c..d3909b62ccd 100644 --- a/packages/cli/oclif.manifest.json +++ b/packages/cli/oclif.manifest.json @@ -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, provide `--organization-id`.\n\nRun `shopify 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, provide `--organization-id`.\n\nRun `shopify 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": "Filters the store list by organization. If omitted and you belong to more than one organization, you will be prompted to choose one.", + "env": "SHOPIFY_FLAG_ORGANIZATION_ID", + "hasDynamicHelp": false, + "multiple": false, + "name": "organization-id", + "required": false, + "type": "option" + }, + "verbose": { + "allowNo": false, + "description": "Increase the verbosity of the output.", + "env": "SHOPIFY_FLAG_VERBOSE", + "hidden": false, + "name": "verbose", + "type": "boolean" + } + }, + "hasDynamicHelp": false, + "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/e2e/data/snapshots/commands.txt b/packages/e2e/data/snapshots/commands.txt index 4a121e660fe..aef4be50e01 100644 --- a/packages/e2e/data/snapshots/commands.txt +++ b/packages/e2e/data/snapshots/commands.txt @@ -95,7 +95,8 @@ ├─ store │ ├─ auth │ ├─ execute -│ └─ info +│ ├─ info +│ └─ list ├─ theme │ ├─ check │ ├─ console diff --git a/packages/organizations/src/cli/services/fetch.test.ts b/packages/organizations/src/cli/services/fetch.test.ts index 0cd16e8f13b..1fe1125893d 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,97 @@ 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('does not replace a provided token with ambient local auth on unauthorized', 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') + + const requestOptions = vi.mocked(businessPlatformRequestDoc).mock.calls[0]?.[0] as any + await expect(requestOptions.unauthorizedHandler.handler()).resolves.toEqual({}) + expect(ensureAuthenticatedBusinessPlatform).not.toHaveBeenCalled() + }) + + 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..97b625af601 100644 --- a/packages/organizations/src/cli/services/fetch.ts +++ b/packages/organizations/src/cli/services/fetch.ts @@ -5,11 +5,25 @@ 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 () => { + if (token) return {} + const newToken = await ensureAuthenticatedBusinessPlatform() return {token: newToken} }, @@ -17,20 +31,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/cli/services/select.test.ts b/packages/organizations/src/cli/services/select.test.ts index dd6efdfd87c..e6594c0f02a 100644 --- a/packages/organizations/src/cli/services/select.test.ts +++ b/packages/organizations/src/cli/services/select.test.ts @@ -1,4 +1,4 @@ -import {selectOrg} from './select.js' +import {selectOrg, selectOrganizationFromList} from './select.js' import {fetchOrganizations} from './fetch.js' import {selectOrganizationPrompt} from '../prompts/organization.js' import {describe, expect, test, vi} from 'vitest' @@ -11,6 +11,33 @@ const ORGS = [ {id: '5678', businessName: 'Other Org'}, ] +describe('selectOrganizationFromList', () => { + test('returns org matching flag ID from provided organizations', async () => { + const result = await selectOrganizationFromList(ORGS, '5678') + + expect(result).toEqual({id: '5678', businessName: 'Other Org'}) + expect(fetchOrganizations).not.toHaveBeenCalled() + expect(selectOrganizationPrompt).not.toHaveBeenCalled() + }) + + test('throws AbortError when flag ID does not match any provided organization', async () => { + await expect(selectOrganizationFromList(ORGS, '9999')).rejects.toThrow('Organization with ID 9999 not found.') + }) + + test('falls back to prompt when no flag is provided', async () => { + vi.mocked(selectOrganizationPrompt).mockResolvedValue(ORGS[0]!) + + const result = await selectOrganizationFromList(ORGS) + + expect(result).toEqual({id: '1234', businessName: 'My Org'}) + expect(selectOrganizationPrompt).toHaveBeenCalledWith(ORGS) + }) + + test('throws AbortError when no organizations are provided', async () => { + await expect(selectOrganizationFromList([])).rejects.toThrow('No organizations found.') + }) +}) + describe('selectOrg', () => { test('returns org matching flag ID', async () => { vi.mocked(fetchOrganizations).mockResolvedValue(ORGS) diff --git a/packages/organizations/src/cli/services/select.ts b/packages/organizations/src/cli/services/select.ts index e63b3ad7371..bdebc221807 100644 --- a/packages/organizations/src/cli/services/select.ts +++ b/packages/organizations/src/cli/services/select.ts @@ -5,7 +5,13 @@ import {AbortError} from '@shopify/cli-kit/node/error' export async function selectOrg(orgIdFromFlag?: string): Promise { const organizations = await fetchOrganizations() + return selectOrganizationFromList(organizations, orgIdFromFlag) +} +export async function selectOrganizationFromList( + organizations: T[], + orgIdFromFlag?: string, +): Promise { if (organizations.length === 0) { throw new AbortError('No organizations found.', 'Make sure you have access to a Shopify organization.') } diff --git a/packages/organizations/src/index.ts b/packages/organizations/src/index.ts index baa41bd1b32..ed452a498d4 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 {selectOrg} from './cli/services/select.js' +export {fetchOrganizations, fetchOrganizationsWithAccessInfo} from './cli/services/fetch.js' +export {selectOrg, selectOrganizationFromList} 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..b62847b9e04 --- /dev/null +++ b/packages/store/src/cli/commands/store/list.test.ts @@ -0,0 +1,41 @@ +import StoreList from './list.js' +import {listStores} from '../../services/store/list/index.js' +import {writeStoreListResult} from '../../services/store/list/result.js' +import {describe, expect, test, vi} from 'vitest' + +vi.mock('../../services/store/list/index.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() + }) +}) 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..9072d72cf4b --- /dev/null +++ b/packages/store/src/cli/commands/store/list.ts @@ -0,0 +1,42 @@ +import {listStores} 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' + +export default class StoreList extends StoreCommand { + 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, provide \`--organization-id\`. + +Run \`shopify 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': Flags.string({ + description: + 'Filters the store list by organization. If omitted and you belong to more than one organization, you will be prompted to choose one.', + env: 'SHOPIFY_FLAG_ORGANIZATION_ID', + required: false, + }), + } + + 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/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/list/bp-source.test.ts b/packages/store/src/cli/services/store/list/bp-source.test.ts new file mode 100644 index 00000000000..a2803ea1925 --- /dev/null +++ b/packages/store/src/cli/services/store/list/bp-source.test.ts @@ -0,0 +1,224 @@ +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', + }, + ], + }) + 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: []}) + }) + + 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() + }) +}) 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..91dd6d9b642 --- /dev/null +++ b/packages/store/src/cli/services/store/list/bp-source.ts @@ -0,0 +1,93 @@ +import {STORE_LIST_LIMIT} from './constants.js' +import {type StoreListEntry} from './types.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 {ensureAuthenticatedBusinessPlatform} from '@shopify/cli-kit/node/session' +import {type UnauthorizedHandler} from '@shopify/cli-kit/node/api/graphql' +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 ? {hasMore: true} : {}), + } +} + +// 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: UnauthorizedHandler = { + type: 'token_refresh', + handler: async () => ({token: await ensureAuthenticatedBusinessPlatform()}), + } + + 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/index.test.ts b/packages/store/src/cli/services/store/list/index.test.ts new file mode 100644 index 00000000000..57af2225133 --- /dev/null +++ b/packages/store/src/cli/services/store/list/index.test.ts @@ -0,0 +1,192 @@ +import {listStores} from './index.js' +import * as bpSource from './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} from '@shopify/cli-kit/node/ui' +import {fetchOrganizationsWithAccessInfo, selectOrganizationFromList} 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, + }) + vi.mocked(selectOrganizationFromList).mockImplementation(async (availableOrganizations, organizationId) => { + if (organizationId) { + const organization = availableOrganizations.find((candidate) => candidate.id === organizationId) + if (!organization) throw new AbortError(`Organization with ID ${organizationId} not found.`) + return organization + } + + return availableOrganizations[0]! + }) +} + +describe('listStores', () => { + test('returns organization results for the only available organization', async () => { + mockOrganizations([acme]) + vi.spyOn(bpSource, 'listBusinessPlatformStores').mockResolvedValue({entries: [orgEntry]}) + + const result = await listStores() + + expect(bpSource.listBusinessPlatformStores).toHaveBeenCalledWith({token: 'bp-token', organization: acme}) + expect(selectOrganizationFromList).toHaveBeenCalledWith([acme], undefined) + 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: []}) + + await listStores({organizationId: '5678'}) + + expect(bpSource.listBusinessPlatformStores).toHaveBeenCalledWith({token: 'bp-token', organization: beta}) + expect(selectOrganizationFromList).toHaveBeenCalledWith([acme, beta], '5678') + }) + + test('uses the shared organization selector when multiple are available and no id is provided', async () => { + mockOrganizations([acme, beta]) + vi.mocked(isTTY).mockReturnValue(true) + vi.mocked(selectOrganizationFromList).mockResolvedValue(beta) + vi.spyOn(bpSource, 'listBusinessPlatformStores').mockResolvedValue({entries: []}) + + const result = await listStores() + + expect(selectOrganizationFromList).toHaveBeenCalledWith([acme, beta], undefined) + 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(selectOrganizationFromList).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(selectOrganizationFromList).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('rejects a present-but-empty organization id', async () => { + const spy = vi.spyOn(bpSource, 'listBusinessPlatformStores') + + await expect(listStores({organizationId: ' '})).rejects.toThrow('The `--organization-id` value is empty.') + expect(spy).not.toHaveBeenCalled() + }) + + 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}) + + 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'}, + ], + }) + + const result = await listStores() + + expect(result.truncated).toBeUndefined() + }) +}) diff --git a/packages/store/src/cli/services/store/list/index.ts b/packages/store/src/cli/services/store/list/index.ts new file mode 100644 index 00000000000..85f30199d38 --- /dev/null +++ b/packages/store/src/cli/services/store/list/index.ts @@ -0,0 +1,70 @@ +import {listBusinessPlatformStores} from './bp-source.js' +import {STORE_LIST_LIMIT} from './constants.js' +import {type ListStoresResult, type StoreListEntry, type StoreListOrganization} from './types.js' +import {AbortError} from '@shopify/cli-kit/node/error' +import {ensureAuthenticatedBusinessPlatform} from '@shopify/cli-kit/node/session' +import {isTTY} from '@shopify/cli-kit/node/ui' +import {fetchOrganizationsWithAccessInfo, selectOrganizationFromList, type Organization} from '@shopify/organizations' + +interface ListStoresOptions { + organizationId?: string +} + +export async function listStores(options: ListStoresOptions = {}): Promise { + // A present-but-blank organization id (e.g. an empty `SHOPIFY_FLAG_ORGANIZATION_ID`) must not + // silently widen the listing to every accessible organization. + if (options.organizationId !== undefined && options.organizationId.trim() === '') { + throw new AbortError( + 'The `--organization-id` value is empty.', + 'Provide an organization ID, for example `--organization-id 1234567`. Run `shopify organization list` to find IDs.', + ) + } + + 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 selectOrganizationFromList( + organizationsResult.organizations, + options.organizationId, + ) + + const result = await listBusinessPlatformStores({token, organization: selectedOrganization}) + const {stores, truncated} = limitEntries(result.entries, result.hasMore ?? false) + + return { + stores, + source: 'organization', + organization: storeListOrganization(selectedOrganization), + ...(truncated ? {truncated: true} : {}), + } +} + +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/result.test.ts b/packages/store/src/cli/services/store/list/result.test.ts new file mode 100644 index 00000000000..dfb7f3a1336 --- /dev/null +++ b/packages/store/src/cli/services/store/list/result.test.ts @@ -0,0 +1,163 @@ +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, source, 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', + }, + ], + source: 'organization', + organization, + }) + }) + + 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..bef21ce5905 --- /dev/null +++ b/packages/store/src/cli/services/store/list/result.ts @@ -0,0 +1,83 @@ +import {STORE_LIST_LIMIT} from './constants.js' +import {type ListStoresResult, type StoreListEntry} from './types.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' +import {extractSubdomain} from '@shopify/cli-kit/common/url' +import {formatShortDate} from '@shopify/cli-kit/common/string' + +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, + source: result.source, + ...(result.organization ? {organization: result.organization} : {}), + ...(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 From 946e574c5ed4dd6a53ea073a9c13b2b432b9076f Mon Sep 17 00:00:00 2001 From: Donald Merand Date: Mon, 15 Jun 2026 10:18:09 -0400 Subject: [PATCH 2/5] Use shared integer organization-id for store list --- packages/store/src/cli/commands/store/list.test.ts | 3 ++- packages/store/src/cli/commands/store/list.ts | 9 ++------- packages/store/src/cli/flags.ts | 2 +- .../store/src/cli/services/store/list/index.test.ts | 11 ++--------- packages/store/src/cli/services/store/list/index.ts | 13 ++----------- 5 files changed, 9 insertions(+), 29 deletions(-) diff --git a/packages/store/src/cli/commands/store/list.test.ts b/packages/store/src/cli/commands/store/list.test.ts index b62847b9e04..0cf985aabe3 100644 --- a/packages/store/src/cli/commands/store/list.test.ts +++ b/packages/store/src/cli/commands/store/list.test.ts @@ -22,7 +22,7 @@ describe('store list command', () => { await StoreList.run(['--organization-id', '1234567']) - expect(listStores).toHaveBeenCalledWith({organizationId: '1234567'}) + expect(listStores).toHaveBeenCalledWith({organizationId: 1234567}) }) test('writes json output when requested', async () => { @@ -37,5 +37,6 @@ describe('store list command', () => { 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 index 9072d72cf4b..376e058afeb 100644 --- a/packages/store/src/cli/commands/store/list.ts +++ b/packages/store/src/cli/commands/store/list.ts @@ -1,8 +1,8 @@ import {listStores} from '../../services/store/list/index.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' -import {Flags} from '@oclif/core' export default class StoreList extends StoreCommand { static summary = 'List stores in a Shopify organization.' @@ -25,12 +25,7 @@ Run \`shopify organization list\` to find organization IDs.` static flags = { ...globalFlags, ...jsonFlag, - 'organization-id': Flags.string({ - description: - 'Filters the store list by organization. If omitted and you belong to more than one organization, you will be prompted to choose one.', - env: 'SHOPIFY_FLAG_ORGANIZATION_ID', - required: false, - }), + 'organization-id': storeFlags['organization-id'], } public async run(): Promise { 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/list/index.test.ts b/packages/store/src/cli/services/store/list/index.test.ts index 57af2225133..2af549b2b5d 100644 --- a/packages/store/src/cli/services/store/list/index.test.ts +++ b/packages/store/src/cli/services/store/list/index.test.ts @@ -60,7 +60,7 @@ describe('listStores', () => { mockOrganizations([acme, beta]) vi.spyOn(bpSource, 'listBusinessPlatformStores').mockResolvedValue({entries: []}) - await listStores({organizationId: '5678'}) + await listStores({organizationId: 5678}) expect(bpSource.listBusinessPlatformStores).toHaveBeenCalledWith({token: 'bp-token', organization: beta}) expect(selectOrganizationFromList).toHaveBeenCalledWith([acme, beta], '5678') @@ -132,18 +132,11 @@ describe('listStores', () => { await expect(listStores()).rejects.toThrow('Access denied for accessibleShops') }) - test('rejects a present-but-empty organization id', async () => { - const spy = vi.spyOn(bpSource, 'listBusinessPlatformStores') - - await expect(listStores({organizationId: ' '})).rejects.toThrow('The `--organization-id` value is empty.') - expect(spy).not.toHaveBeenCalled() - }) - 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.') + 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 () => { diff --git a/packages/store/src/cli/services/store/list/index.ts b/packages/store/src/cli/services/store/list/index.ts index 85f30199d38..c8ce1da0ec4 100644 --- a/packages/store/src/cli/services/store/list/index.ts +++ b/packages/store/src/cli/services/store/list/index.ts @@ -7,19 +7,10 @@ import {isTTY} from '@shopify/cli-kit/node/ui' import {fetchOrganizationsWithAccessInfo, selectOrganizationFromList, type Organization} from '@shopify/organizations' interface ListStoresOptions { - organizationId?: string + organizationId?: number } export async function listStores(options: ListStoresOptions = {}): Promise { - // A present-but-blank organization id (e.g. an empty `SHOPIFY_FLAG_ORGANIZATION_ID`) must not - // silently widen the listing to every accessible organization. - if (options.organizationId !== undefined && options.organizationId.trim() === '') { - throw new AbortError( - 'The `--organization-id` value is empty.', - 'Provide an organization ID, for example `--organization-id 1234567`. Run `shopify organization list` to find IDs.', - ) - } - const token = await ensureAuthenticatedBusinessPlatform() const organizationsResult = await fetchOrganizationsWithAccessInfo(token) @@ -44,7 +35,7 @@ export async function listStores(options: ListStoresOptions = {}): Promise Date: Mon, 15 Jun 2026 10:29:29 -0400 Subject: [PATCH 3/5] Refresh store list generated docs --- docs-shopify.dev/commands/interfaces/store-list.interface.ts | 2 +- packages/cli/README.md | 4 ++-- packages/cli/oclif.manifest.json | 5 ++--- 3 files changed, 5 insertions(+), 6 deletions(-) diff --git a/docs-shopify.dev/commands/interfaces/store-list.interface.ts b/docs-shopify.dev/commands/interfaces/store-list.interface.ts index 5673b2288ec..674f068aaec 100644 --- a/docs-shopify.dev/commands/interfaces/store-list.interface.ts +++ b/docs-shopify.dev/commands/interfaces/store-list.interface.ts @@ -17,7 +17,7 @@ export interface storelist { '--no-color'?: '' /** - * Filters the store list by organization. If omitted and you belong to more than one organization, you will be prompted to choose one. + * The numeric organization ID. Auto-selects if you belong to a single organization. * @environment SHOPIFY_FLAG_ORGANIZATION_ID */ '--organization-id '?: string diff --git a/packages/cli/README.md b/packages/cli/README.md index f6c5a224537..3c9f2aeac7c 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -2221,8 +2221,8 @@ FLAGS -j, --json [env: SHOPIFY_FLAG_JSON] Output the result as JSON. Automatically disables color output. --no-color [env: SHOPIFY_FLAG_NO_COLOR] Disable color output. - --organization-id= [env: SHOPIFY_FLAG_ORGANIZATION_ID] Filters the store list by organization. If omitted - and you belong to more than one organization, you will be prompted to choose one. + --organization-id= [env: SHOPIFY_FLAG_ORGANIZATION_ID] The numeric organization ID. Auto-selects if you + belong to a single organization. --verbose [env: SHOPIFY_FLAG_VERBOSE] Increase the verbosity of the output. DESCRIPTION diff --git a/packages/cli/oclif.manifest.json b/packages/cli/oclif.manifest.json index d3909b62ccd..cac289ee16f 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, @@ -6097,12 +6097,11 @@ "type": "boolean" }, "organization-id": { - "description": "Filters the store list by organization. If omitted and you belong to more than one organization, you will be prompted to choose one.", + "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", - "required": false, "type": "option" }, "verbose": { From acccc807a7c367f39bdfb8bbbe15cf2734402027 Mon Sep 17 00:00:00 2001 From: Donald Merand Date: Mon, 15 Jun 2026 10:35:36 -0400 Subject: [PATCH 4/5] Refresh generated docs data for store list --- docs-shopify.dev/generated/generated_docs_data_v2.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs-shopify.dev/generated/generated_docs_data_v2.json b/docs-shopify.dev/generated/generated_docs_data_v2.json index 79f5175da5c..684eef74a24 100644 --- a/docs-shopify.dev/generated/generated_docs_data_v2.json +++ b/docs-shopify.dev/generated/generated_docs_data_v2.json @@ -4360,7 +4360,7 @@ "syntaxKind": "PropertySignature", "name": "--organization-id ", "value": "string", - "description": "Filters the store list by organization. If omitted and you belong to more than one organization, you will be prompted to choose one.", + "description": "The numeric organization ID. Auto-selects if you belong to a single organization.", "isOptional": true, "environmentValue": "SHOPIFY_FLAG_ORGANIZATION_ID" }, @@ -4383,7 +4383,7 @@ "environmentValue": "SHOPIFY_FLAG_JSON" } ], - "value": "export interface storelist {\n /**\n * Output the result as JSON. Automatically disables color output.\n * @environment SHOPIFY_FLAG_JSON\n */\n '-j, --json'?: ''\n\n /**\n * Disable color output.\n * @environment SHOPIFY_FLAG_NO_COLOR\n */\n '--no-color'?: ''\n\n /**\n * Filters the store list by organization. If omitted and you belong to more than one organization, you will be prompted to choose one.\n * @environment SHOPIFY_FLAG_ORGANIZATION_ID\n */\n '--organization-id '?: string\n\n /**\n * Increase the verbosity of the output.\n * @environment SHOPIFY_FLAG_VERBOSE\n */\n '--verbose'?: ''\n}" + "value": "export interface storelist {\n /**\n * Output the result as JSON. Automatically disables color output.\n * @environment SHOPIFY_FLAG_JSON\n */\n '-j, --json'?: ''\n\n /**\n * Disable color output.\n * @environment SHOPIFY_FLAG_NO_COLOR\n */\n '--no-color'?: ''\n\n /**\n * The numeric organization ID. Auto-selects if you belong to a single organization.\n * @environment SHOPIFY_FLAG_ORGANIZATION_ID\n */\n '--organization-id '?: string\n\n /**\n * Increase the verbosity of the output.\n * @environment SHOPIFY_FLAG_VERBOSE\n */\n '--verbose'?: ''\n}" } }, "themecheck": { From 7d2be83cfe80b29bda623eb902498990db4aeb55 Mon Sep 17 00:00:00 2001 From: Donald Merand Date: Mon, 15 Jun 2026 10:57:20 -0400 Subject: [PATCH 5/5] Tighten store list auth handling Assisted-By: devx/425e6a4b-6ec9-421c-8f98-ab1725d0b996 --- .changeset/store-list-bp-auto.md | 2 +- .gitignore | 1 + .../interfaces/store-list.interface.ts | 30 ----------- .../generated/generated_docs_data_v2.json | 47 ----------------- .../services/app/config/link-service.test.ts | 12 +++-- packages/app/src/cli/services/context.test.ts | 4 +- packages/app/src/cli/services/context.ts | 8 +-- .../cli-kit/src/public/common/string.test.ts | 19 ------- packages/cli-kit/src/public/common/string.ts | 21 -------- .../cli-kit/src/public/common/url.test.ts | 22 +------- packages/cli-kit/src/public/common/url.ts | 14 ------ packages/cli/README.md | 36 ------------- packages/cli/oclif.manifest.json | 5 +- packages/e2e/data/snapshots/commands.txt | 3 +- .../src/cli/services/fetch.test.ts | 7 +-- .../organizations/src/cli/services/fetch.ts | 2 - .../src/cli/services/select.test.ts | 29 +---------- .../organizations/src/cli/services/select.ts | 6 --- packages/organizations/src/index.ts | 2 +- .../store/src/cli/commands/store/list.test.ts | 4 +- packages/store/src/cli/commands/store/list.ts | 8 +-- .../cli/services/store/business-platform.ts | 19 +++++++ .../src/cli/services/store/create/dev.ts | 9 +--- .../src/cli/services/store/display.test.ts | 40 +++++++++++++++ .../store/src/cli/services/store/display.ts | 21 ++++++++ .../cli/services/store/info/destinations.ts | 12 ++--- .../services/store/info/organization-shop.ts | 9 +--- .../{list/index.test.ts => list.test.ts} | 46 ++++++++--------- .../services/store/{list/index.ts => list.ts} | 50 ++++++++++++++++--- .../cli/services/store/list/bp-source.test.ts | 5 +- .../src/cli/services/store/list/bp-source.ts | 12 ++--- .../cli/services/store/list/result.test.ts | 22 +++++++- .../src/cli/services/store/list/result.ts | 5 +- 33 files changed, 216 insertions(+), 316 deletions(-) delete mode 100644 docs-shopify.dev/commands/interfaces/store-list.interface.ts create mode 100644 packages/store/src/cli/services/store/business-platform.ts create mode 100644 packages/store/src/cli/services/store/display.test.ts create mode 100644 packages/store/src/cli/services/store/display.ts rename packages/store/src/cli/services/store/{list/index.test.ts => list.test.ts} (81%) rename packages/store/src/cli/services/store/{list/index.ts => list.ts} (55%) diff --git a/.changeset/store-list-bp-auto.md b/.changeset/store-list-bp-auto.md index 951de2f5896..9bff6d63cee 100644 --- a/.changeset/store-list-bp-auto.md +++ b/.changeset/store-list-bp-auto.md @@ -5,4 +5,4 @@ '@shopify/cli-kit': minor --- -Add `shopify store list` to list the stores in the Shopify organizations available to the current CLI account. When some organization lookups fail, the command returns the successful organizations with a warning and failure details for the skipped ones. +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/docs-shopify.dev/commands/interfaces/store-list.interface.ts b/docs-shopify.dev/commands/interfaces/store-list.interface.ts deleted file mode 100644 index 674f068aaec..00000000000 --- a/docs-shopify.dev/commands/interfaces/store-list.interface.ts +++ /dev/null @@ -1,30 +0,0 @@ -// This is an autogenerated file. Don't edit this file manually. -/** - * The following flags are available for the `store list` command: - * @publicDocs - */ -export interface storelist { - /** - * Output the result as JSON. Automatically disables color output. - * @environment SHOPIFY_FLAG_JSON - */ - '-j, --json'?: '' - - /** - * Disable color output. - * @environment SHOPIFY_FLAG_NO_COLOR - */ - '--no-color'?: '' - - /** - * The numeric organization ID. Auto-selects if you belong to a single organization. - * @environment SHOPIFY_FLAG_ORGANIZATION_ID - */ - '--organization-id '?: string - - /** - * Increase the verbosity of the output. - * @environment SHOPIFY_FLAG_VERBOSE - */ - '--verbose'?: '' -} diff --git a/docs-shopify.dev/generated/generated_docs_data_v2.json b/docs-shopify.dev/generated/generated_docs_data_v2.json index 684eef74a24..b83f906791c 100644 --- a/docs-shopify.dev/generated/generated_docs_data_v2.json +++ b/docs-shopify.dev/generated/generated_docs_data_v2.json @@ -4339,53 +4339,6 @@ "value": "export interface storeinfo {\n /**\n * Output the result as JSON. Automatically disables color output.\n * @environment SHOPIFY_FLAG_JSON\n */\n '-j, --json'?: ''\n\n /**\n * Disable color output.\n * @environment SHOPIFY_FLAG_NO_COLOR\n */\n '--no-color'?: ''\n\n /**\n * The myshopify.com domain of the store.\n * @environment SHOPIFY_FLAG_STORE\n */\n '-s, --store ': string\n\n /**\n * Increase the verbosity of the output.\n * @environment SHOPIFY_FLAG_VERBOSE\n */\n '--verbose'?: ''\n}" } }, - "storelist": { - "docs-shopify.dev/commands/interfaces/store-list.interface.ts": { - "filePath": "docs-shopify.dev/commands/interfaces/store-list.interface.ts", - "name": "storelist", - "description": "The following flags are available for the `store list` command:", - "isPublicDocs": true, - "members": [ - { - "filePath": "docs-shopify.dev/commands/interfaces/store-list.interface.ts", - "syntaxKind": "PropertySignature", - "name": "--no-color", - "value": "''", - "description": "Disable color output.", - "isOptional": true, - "environmentValue": "SHOPIFY_FLAG_NO_COLOR" - }, - { - "filePath": "docs-shopify.dev/commands/interfaces/store-list.interface.ts", - "syntaxKind": "PropertySignature", - "name": "--organization-id ", - "value": "string", - "description": "The numeric organization ID. Auto-selects if you belong to a single organization.", - "isOptional": true, - "environmentValue": "SHOPIFY_FLAG_ORGANIZATION_ID" - }, - { - "filePath": "docs-shopify.dev/commands/interfaces/store-list.interface.ts", - "syntaxKind": "PropertySignature", - "name": "--verbose", - "value": "''", - "description": "Increase the verbosity of the output.", - "isOptional": true, - "environmentValue": "SHOPIFY_FLAG_VERBOSE" - }, - { - "filePath": "docs-shopify.dev/commands/interfaces/store-list.interface.ts", - "syntaxKind": "PropertySignature", - "name": "-j, --json", - "value": "''", - "description": "Output the result as JSON. Automatically disables color output.", - "isOptional": true, - "environmentValue": "SHOPIFY_FLAG_JSON" - } - ], - "value": "export interface storelist {\n /**\n * Output the result as JSON. Automatically disables color output.\n * @environment SHOPIFY_FLAG_JSON\n */\n '-j, --json'?: ''\n\n /**\n * Disable color output.\n * @environment SHOPIFY_FLAG_NO_COLOR\n */\n '--no-color'?: ''\n\n /**\n * The numeric organization ID. Auto-selects if you belong to a single organization.\n * @environment SHOPIFY_FLAG_ORGANIZATION_ID\n */\n '--organization-id '?: string\n\n /**\n * Increase the verbosity of the output.\n * @environment SHOPIFY_FLAG_VERBOSE\n */\n '--verbose'?: ''\n}" - } - }, "themecheck": { "docs-shopify.dev/commands/interfaces/theme-check.interface.ts": { "filePath": "docs-shopify.dev/commands/interfaces/theme-check.interface.ts", 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 8f24a3f6e4a..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,7 +4,8 @@ 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 {selectOrganizationFromList} from '@shopify/organizations' +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' import {joinPath} from '@shopify/cli-kit/node/path' @@ -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 { @@ -83,7 +87,7 @@ api_version = "2024-01" vi.mocked(selectDeveloperPlatformClient).mockReturnValue(developerPlatformClient) vi.mocked(createAsNewAppPrompt).mockResolvedValue(true) vi.mocked(appNamePrompt).mockResolvedValue('A user provided name') - vi.mocked(selectOrganizationFromList).mockResolvedValue({ + vi.mocked(selectOrganizationPrompt).mockResolvedValue({ id: '12345', businessName: 'test', }) @@ -176,7 +180,7 @@ api_version = "2025-07" vi.mocked(selectDeveloperPlatformClient).mockReturnValue(developerPlatformClient) vi.mocked(createAsNewAppPrompt).mockResolvedValue(true) vi.mocked(appNamePrompt).mockResolvedValue('My App') - vi.mocked(selectOrganizationFromList).mockResolvedValue({ + vi.mocked(selectOrganizationPrompt).mockResolvedValue({ id: '12345', businessName: 'test', }) @@ -224,7 +228,7 @@ required = true vi.mocked(selectDeveloperPlatformClient).mockReturnValue(developerPlatformClient) vi.mocked(createAsNewAppPrompt).mockResolvedValue(true) vi.mocked(appNamePrompt).mockResolvedValue('My App') - vi.mocked(selectOrganizationFromList).mockResolvedValue({ + vi.mocked(selectOrganizationPrompt).mockResolvedValue({ id: '12345', businessName: 'test', }) diff --git a/packages/app/src/cli/services/context.test.ts b/packages/app/src/cli/services/context.test.ts index b5ee4f24827..9e5b1589e4b 100644 --- a/packages/app/src/cli/services/context.test.ts +++ b/packages/app/src/cli/services/context.test.ts @@ -33,7 +33,7 @@ import { selectDeveloperPlatformClient, } from '../utilities/developer-platform-client.js' import {RemoteAwareExtensionSpecification} from '../models/extensions/specification.js' -import {selectOrganizationFromList} from '@shopify/organizations' +import {selectOrganizationPrompt} from '@shopify/organizations' import {TomlFile} from '@shopify/cli-kit/node/toml/toml-file' import {isServiceAccount, isUserAccount} from '@shopify/cli-kit/node/session' import {afterEach, beforeAll, beforeEach, describe, expect, test, vi} from 'vitest' @@ -145,7 +145,7 @@ beforeAll(async () => { beforeEach(async () => { vi.mocked(getAppIdentifiers).mockReturnValue({app: undefined}) - vi.mocked(selectOrganizationFromList).mockResolvedValue(ORG1) + vi.mocked(selectOrganizationPrompt).mockResolvedValue(ORG1) vi.mocked(selectOrCreateApp).mockResolvedValue(APP1) vi.mocked(selectStore).mockResolvedValue(STORE1) vi.mocked(fetchOrganizations).mockResolvedValue([ORG1, ORG2]) diff --git a/packages/app/src/cli/services/context.ts b/packages/app/src/cli/services/context.ts index 7b1f268c199..41555451f9d 100644 --- a/packages/app/src/cli/services/context.ts +++ b/packages/app/src/cli/services/context.ts @@ -23,7 +23,7 @@ import { DeveloperPlatformClient, selectDeveloperPlatformClient, } from '../utilities/developer-platform-client.js' -import {selectOrganizationFromList} from '@shopify/organizations' +import {selectOrganizationPrompt} from '@shopify/organizations' import {TomlFile} from '@shopify/cli-kit/node/toml/toml-file' import {isServiceAccount, isUserAccount} from '@shopify/cli-kit/node/session' import {tryParseInt} from '@shopify/cli-kit/common/string' @@ -320,8 +320,10 @@ export async function fetchOrCreateOrganizationApp( */ export async function selectOrg(): Promise { const orgs = await fetchOrganizations() - const org = await selectOrganizationFromList(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/public/common/string.test.ts b/packages/cli-kit/src/public/common/string.test.ts index d88c5e521d5..4ec1868592e 100644 --- a/packages/cli-kit/src/public/common/string.test.ts +++ b/packages/cli-kit/src/public/common/string.test.ts @@ -1,7 +1,6 @@ import { formatDate, formatLocalDate, - formatShortDate, getRandomName, joinWithAnd, linesToColumns, @@ -109,24 +108,6 @@ describe('formatDate', () => { }) }) -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('formatLocalDate', () => { test('formats an ISO date string to local date string', () => { // Given diff --git a/packages/cli-kit/src/public/common/string.ts b/packages/cli-kit/src/public/common/string.ts index 3e88665aa1e..ee19df264a1 100644 --- a/packages/cli-kit/src/public/common/string.ts +++ b/packages/cli-kit/src/public/common/string.ts @@ -375,27 +375,6 @@ export function formatLocalDate(dateString: string): string { return formatDate(localDate) } -const SHORT_DATE_MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'] - -/** - * Formats a date as a short calendar date like "May 22, 2026" (`MMM DD, YYYY`) in UTC. - * - * UTC keeps the output deterministic regardless of the machine timezone. Returns an empty string - * when the value cannot be parsed into a valid date. - * - * @param value - A Date, epoch milliseconds, or a date string such as an ISO 8601 timestamp. - * @returns The formatted date, or an empty string when the value is invalid. - */ -export function formatShortDate(value: Date | number | string): string { - const date = value instanceof Date ? value : new Date(value) - if (Number.isNaN(date.getTime())) return '' - - const month = SHORT_DATE_MONTHS[date.getUTCMonth()] - if (!month) return '' - - return `${month} ${String(date.getUTCDate()).padStart(2, '0')}, ${date.getUTCFullYear()}` -} - /** * Given a list of items, it returns a string with the items joined by commas and the last item joined by "and". * All items are wrapped in double quotes. diff --git a/packages/cli-kit/src/public/common/url.test.ts b/packages/cli-kit/src/public/common/url.test.ts index 6d0880e1ec4..312809b81ff 100644 --- a/packages/cli-kit/src/public/common/url.test.ts +++ b/packages/cli-kit/src/public/common/url.test.ts @@ -1,4 +1,4 @@ -import {extractHost, extractMyshopifyHandle, extractSubdomain, isValidURL, safeParseURL} from './url.js' +import {extractHost, extractMyshopifyHandle, isValidURL, safeParseURL} from './url.js' import {describe, expect, test} from 'vitest' describe('isValidURL', () => { @@ -93,23 +93,3 @@ describe('extractMyshopifyHandle', () => { expect(extractMyshopifyHandle(undefined)).toBeUndefined() }) }) - -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 (local dev)', () => { - 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/cli-kit/src/public/common/url.ts b/packages/cli-kit/src/public/common/url.ts index febe42b76b9..d8a85db9531 100644 --- a/packages/cli-kit/src/public/common/url.ts +++ b/packages/cli-kit/src/public/common/url.ts @@ -65,17 +65,3 @@ export function extractMyshopifyHandle(value: string | null | undefined): string const match = host.match(/^([^.]+)\.myshopify\.com$/) return match ? match[1] : undefined } - -/** - * Extracts the leading subdomain label from a URL or host, across environments — e.g. - * `acme.myshopify.com`, `acme.shop.dev`, or local development hosts — rather than assuming a - * `*.myshopify.com` domain. - * - * @param value - A URL or host string, possibly null/undefined. - * @returns The first DNS label, or undefined when no host can be extracted. - */ -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/cli/README.md b/packages/cli/README.md index 3c9f2aeac7c..62a97253975 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -79,7 +79,6 @@ * [`shopify store auth`](#shopify-store-auth) * [`shopify store execute`](#shopify-store-execute) * [`shopify store info`](#shopify-store-info) -* [`shopify store list`](#shopify-store-list) * [`shopify theme check`](#shopify-theme-check) * [`shopify theme console`](#shopify-theme-console) * [`shopify theme delete`](#shopify-theme-delete) @@ -2209,41 +2208,6 @@ EXAMPLES $ shopify store info --store shop.myshopify.com --json ``` -## `shopify store list` - -List stores in a Shopify organization. - -``` -USAGE - $ shopify store list [-j] [--no-color] [--organization-id ] [--verbose] - -FLAGS - -j, --json [env: SHOPIFY_FLAG_JSON] Output the result as JSON. Automatically disables color - output. - --no-color [env: SHOPIFY_FLAG_NO_COLOR] Disable color output. - --organization-id= [env: SHOPIFY_FLAG_ORGANIZATION_ID] The numeric organization ID. Auto-selects if you - belong to a single organization. - --verbose [env: SHOPIFY_FLAG_VERBOSE] Increase the verbosity of the output. - -DESCRIPTION - List stores in a Shopify organization. - - 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, provide `--organization-id`. - - Run `shopify organization list` to find organization IDs. - -EXAMPLES - $ shopify store list - - $ shopify store list --organization-id 1234567 - - $ shopify store list --json -``` - ## `shopify theme check` Validate the theme. diff --git a/packages/cli/oclif.manifest.json b/packages/cli/oclif.manifest.json index cac289ee16f..4bff57d73b1 100644 --- a/packages/cli/oclif.manifest.json +++ b/packages/cli/oclif.manifest.json @@ -6071,8 +6071,8 @@ "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, provide `--organization-id`.\n\nRun `shopify 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, provide `--organization-id`.\n\nRun `shopify organization list` to find organization IDs.", + "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", @@ -6114,6 +6114,7 @@ } }, "hasDynamicHelp": false, + "hidden": true, "hiddenAliases": [ ], "id": "store:list", diff --git a/packages/e2e/data/snapshots/commands.txt b/packages/e2e/data/snapshots/commands.txt index aef4be50e01..4a121e660fe 100644 --- a/packages/e2e/data/snapshots/commands.txt +++ b/packages/e2e/data/snapshots/commands.txt @@ -95,8 +95,7 @@ ├─ store │ ├─ auth │ ├─ execute -│ ├─ info -│ └─ list +│ └─ info ├─ theme │ ├─ check │ ├─ console diff --git a/packages/organizations/src/cli/services/fetch.test.ts b/packages/organizations/src/cli/services/fetch.test.ts index 1fe1125893d..4a86d7012e6 100644 --- a/packages/organizations/src/cli/services/fetch.test.ts +++ b/packages/organizations/src/cli/services/fetch.test.ts @@ -103,8 +103,8 @@ describe('fetchOrganizationsWithAccessInfo', () => { expect(businessPlatformRequestDoc).toHaveBeenCalledWith(expect.objectContaining({token: 'pre-resolved-token'})) }) - test('does not replace a provided token with ambient local auth on unauthorized', async () => { - vi.mocked(ensureAuthenticatedBusinessPlatform).mockClear() + 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', @@ -118,8 +118,9 @@ describe('fetchOrganizationsWithAccessInfo', () => { await fetchOrganizationsWithAccessInfo('pre-resolved-token') const requestOptions = vi.mocked(businessPlatformRequestDoc).mock.calls[0]?.[0] as any - await expect(requestOptions.unauthorizedHandler.handler()).resolves.toEqual({}) 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 () => { diff --git a/packages/organizations/src/cli/services/fetch.ts b/packages/organizations/src/cli/services/fetch.ts index 97b625af601..a174465c21c 100644 --- a/packages/organizations/src/cli/services/fetch.ts +++ b/packages/organizations/src/cli/services/fetch.ts @@ -22,8 +22,6 @@ export async function fetchOrganizationsWithAccessInfo( const unauthorizedHandler = { type: 'token_refresh' as const, handler: async () => { - if (token) return {} - const newToken = await ensureAuthenticatedBusinessPlatform() return {token: newToken} }, diff --git a/packages/organizations/src/cli/services/select.test.ts b/packages/organizations/src/cli/services/select.test.ts index e6594c0f02a..dd6efdfd87c 100644 --- a/packages/organizations/src/cli/services/select.test.ts +++ b/packages/organizations/src/cli/services/select.test.ts @@ -1,4 +1,4 @@ -import {selectOrg, selectOrganizationFromList} from './select.js' +import {selectOrg} from './select.js' import {fetchOrganizations} from './fetch.js' import {selectOrganizationPrompt} from '../prompts/organization.js' import {describe, expect, test, vi} from 'vitest' @@ -11,33 +11,6 @@ const ORGS = [ {id: '5678', businessName: 'Other Org'}, ] -describe('selectOrganizationFromList', () => { - test('returns org matching flag ID from provided organizations', async () => { - const result = await selectOrganizationFromList(ORGS, '5678') - - expect(result).toEqual({id: '5678', businessName: 'Other Org'}) - expect(fetchOrganizations).not.toHaveBeenCalled() - expect(selectOrganizationPrompt).not.toHaveBeenCalled() - }) - - test('throws AbortError when flag ID does not match any provided organization', async () => { - await expect(selectOrganizationFromList(ORGS, '9999')).rejects.toThrow('Organization with ID 9999 not found.') - }) - - test('falls back to prompt when no flag is provided', async () => { - vi.mocked(selectOrganizationPrompt).mockResolvedValue(ORGS[0]!) - - const result = await selectOrganizationFromList(ORGS) - - expect(result).toEqual({id: '1234', businessName: 'My Org'}) - expect(selectOrganizationPrompt).toHaveBeenCalledWith(ORGS) - }) - - test('throws AbortError when no organizations are provided', async () => { - await expect(selectOrganizationFromList([])).rejects.toThrow('No organizations found.') - }) -}) - describe('selectOrg', () => { test('returns org matching flag ID', async () => { vi.mocked(fetchOrganizations).mockResolvedValue(ORGS) diff --git a/packages/organizations/src/cli/services/select.ts b/packages/organizations/src/cli/services/select.ts index bdebc221807..e63b3ad7371 100644 --- a/packages/organizations/src/cli/services/select.ts +++ b/packages/organizations/src/cli/services/select.ts @@ -5,13 +5,7 @@ import {AbortError} from '@shopify/cli-kit/node/error' export async function selectOrg(orgIdFromFlag?: string): Promise { const organizations = await fetchOrganizations() - return selectOrganizationFromList(organizations, orgIdFromFlag) -} -export async function selectOrganizationFromList( - organizations: T[], - orgIdFromFlag?: string, -): Promise { if (organizations.length === 0) { throw new AbortError('No organizations found.', 'Make sure you have access to a Shopify organization.') } diff --git a/packages/organizations/src/index.ts b/packages/organizations/src/index.ts index ed452a498d4..317d39c4fd1 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 {selectOrg, selectOrganizationFromList} from './cli/services/select.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/commands/store/list.test.ts b/packages/store/src/cli/commands/store/list.test.ts index 0cf985aabe3..0e8933bc21d 100644 --- a/packages/store/src/cli/commands/store/list.test.ts +++ b/packages/store/src/cli/commands/store/list.test.ts @@ -1,9 +1,9 @@ import StoreList from './list.js' -import {listStores} from '../../services/store/list/index.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/index.js') +vi.mock('../../services/store/list.js') vi.mock('../../services/store/list/result.js') vi.mock('../../services/store/attribution.js') diff --git a/packages/store/src/cli/commands/store/list.ts b/packages/store/src/cli/commands/store/list.ts index 376e058afeb..9920606cf34 100644 --- a/packages/store/src/cli/commands/store/list.ts +++ b/packages/store/src/cli/commands/store/list.ts @@ -1,18 +1,20 @@ -import {listStores} from '../../services/store/list/index.js' +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, provide \`--organization-id\`. +In non-interactive environments, \`--organization-id\` is required. -Run \`shopify organization list\` to find organization IDs.` +Run \`<%= config.bin %> organization list\` to find organization IDs.` static description = this.descriptionWithoutMarkdown() 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/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/index.test.ts b/packages/store/src/cli/services/store/list.test.ts similarity index 81% rename from packages/store/src/cli/services/store/list/index.test.ts rename to packages/store/src/cli/services/store/list.test.ts index 2af549b2b5d..c309fe9cf70 100644 --- a/packages/store/src/cli/services/store/list/index.test.ts +++ b/packages/store/src/cli/services/store/list.test.ts @@ -1,10 +1,10 @@ -import {listStores} from './index.js' -import * as bpSource from './bp-source.js' +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} from '@shopify/cli-kit/node/ui' -import {fetchOrganizationsWithAccessInfo, selectOrganizationFromList} from '@shopify/organizations' +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') @@ -29,26 +29,17 @@ function mockOrganizations(organizations = [acme]) { organizations, currentUserResolved: true, }) - vi.mocked(selectOrganizationFromList).mockImplementation(async (availableOrganizations, organizationId) => { - if (organizationId) { - const organization = availableOrganizations.find((candidate) => candidate.id === organizationId) - if (!organization) throw new AbortError(`Organization with ID ${organizationId} not found.`) - return organization - } - - return availableOrganizations[0]! - }) } describe('listStores', () => { test('returns organization results for the only available organization', async () => { mockOrganizations([acme]) - vi.spyOn(bpSource, 'listBusinessPlatformStores').mockResolvedValue({entries: [orgEntry]}) + vi.spyOn(bpSource, 'listBusinessPlatformStores').mockResolvedValue({entries: [orgEntry], hasMore: false}) const result = await listStores() expect(bpSource.listBusinessPlatformStores).toHaveBeenCalledWith({token: 'bp-token', organization: acme}) - expect(selectOrganizationFromList).toHaveBeenCalledWith([acme], undefined) + expect(renderAutocompletePrompt).not.toHaveBeenCalled() expect(result).toEqual({ stores: [orgEntry], source: 'organization', @@ -58,23 +49,29 @@ describe('listStores', () => { test('uses the requested organization id when provided', async () => { mockOrganizations([acme, beta]) - vi.spyOn(bpSource, 'listBusinessPlatformStores').mockResolvedValue({entries: []}) + vi.spyOn(bpSource, 'listBusinessPlatformStores').mockResolvedValue({entries: [], hasMore: false}) await listStores({organizationId: 5678}) expect(bpSource.listBusinessPlatformStores).toHaveBeenCalledWith({token: 'bp-token', organization: beta}) - expect(selectOrganizationFromList).toHaveBeenCalledWith([acme, beta], '5678') + expect(renderAutocompletePrompt).not.toHaveBeenCalled() }) - test('uses the shared organization selector when multiple are available and no id is provided', async () => { + 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(selectOrganizationFromList).mockResolvedValue(beta) - vi.spyOn(bpSource, 'listBusinessPlatformStores').mockResolvedValue({entries: []}) + vi.mocked(renderAutocompletePrompt).mockResolvedValue('5678') + vi.spyOn(bpSource, 'listBusinessPlatformStores').mockResolvedValue({entries: [], hasMore: false}) const result = await listStores() - expect(selectOrganizationFromList).toHaveBeenCalledWith([acme, beta], undefined) + 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'}) }) @@ -85,7 +82,7 @@ describe('listStores', () => { vi.mocked(isTTY).mockReturnValue(false) await expect(listStores()).rejects.toThrow('An organization ID is required to list stores non-interactively.') - expect(selectOrganizationFromList).not.toHaveBeenCalled() + expect(renderAutocompletePrompt).not.toHaveBeenCalled() expect(spy).not.toHaveBeenCalled() }) @@ -93,7 +90,7 @@ describe('listStores', () => { const spy = vi.spyOn(bpSource, 'listBusinessPlatformStores') mockOrganizations([acme, beta]) vi.mocked(isTTY).mockReturnValue(true) - vi.mocked(selectOrganizationFromList).mockRejectedValue(new AbortError('User cancelled')) + vi.mocked(renderAutocompletePrompt).mockRejectedValue(new AbortError('User cancelled')) await expect(listStores()).rejects.toThrow('User cancelled') expect(spy).not.toHaveBeenCalled() @@ -147,7 +144,7 @@ describe('listStores', () => { organizationId: '1234', organizationName: 'Acme', })) - vi.spyOn(bpSource, 'listBusinessPlatformStores').mockResolvedValue({entries}) + vi.spyOn(bpSource, 'listBusinessPlatformStores').mockResolvedValue({entries, hasMore: false}) const result = await listStores() @@ -176,6 +173,7 @@ describe('listStores', () => { entries: [ {store: 'shop.myshopify.com', createdAt: '2026-01-15T00:00:00Z', organizationId: '1', organizationName: 'Acme'}, ], + hasMore: false, }) const result = await listStores() diff --git a/packages/store/src/cli/services/store/list/index.ts b/packages/store/src/cli/services/store/list.ts similarity index 55% rename from packages/store/src/cli/services/store/list/index.ts rename to packages/store/src/cli/services/store/list.ts index c8ce1da0ec4..15658f3ba6f 100644 --- a/packages/store/src/cli/services/store/list/index.ts +++ b/packages/store/src/cli/services/store/list.ts @@ -1,10 +1,10 @@ -import {listBusinessPlatformStores} from './bp-source.js' -import {STORE_LIST_LIMIT} from './constants.js' -import {type ListStoresResult, type StoreListEntry, type StoreListOrganization} from './types.js' +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} from '@shopify/cli-kit/node/ui' -import {fetchOrganizationsWithAccessInfo, selectOrganizationFromList, type Organization} from '@shopify/organizations' +import {isTTY, renderAutocompletePrompt} from '@shopify/cli-kit/node/ui' +import {fetchOrganizationsWithAccessInfo, type Organization} from '@shopify/organizations' interface ListStoresOptions { organizationId?: number @@ -33,13 +33,13 @@ export async function listStores(options: ListStoresOptions = {}): 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} } diff --git a/packages/store/src/cli/services/store/list/bp-source.test.ts b/packages/store/src/cli/services/store/list/bp-source.test.ts index a2803ea1925..bf1d179003e 100644 --- a/packages/store/src/cli/services/store/list/bp-source.test.ts +++ b/packages/store/src/cli/services/store/list/bp-source.test.ts @@ -79,6 +79,7 @@ describe('listBusinessPlatformStores', () => { type: 'production', }, ], + hasMore: false, }) expect(businessPlatformOrganizationsRequestDoc).toHaveBeenCalledWith( expect.objectContaining({token: 'bp-token', organizationId: '1234', variables: {first: 250}}), @@ -105,7 +106,7 @@ describe('listBusinessPlatformStores', () => { const result = await listBusinessPlatformStores({token: 'bp-token', organization}) - expect(result).toEqual({entries: []}) + expect(result).toEqual({entries: [], hasMore: false}) }) test('fetches a single bounded page for the selected organization and orders newest first', async () => { @@ -219,6 +220,6 @@ describe('listBusinessPlatformStores', () => { const requestOptions = vi.mocked(businessPlatformOrganizationsRequestDoc).mock.calls[0]?.[0] as any await requestOptions.unauthorizedHandler.handler() - expect(ensureAuthenticatedBusinessPlatform).toHaveBeenCalledWith() + 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 index 91dd6d9b642..90e9180c274 100644 --- a/packages/store/src/cli/services/store/list/bp-source.ts +++ b/packages/store/src/cli/services/store/list/bp-source.ts @@ -1,5 +1,6 @@ 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, @@ -7,8 +8,6 @@ import { } 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 {ensureAuthenticatedBusinessPlatform} from '@shopify/cli-kit/node/session' -import {type UnauthorizedHandler} from '@shopify/cli-kit/node/api/graphql' import {type Organization} from '@shopify/organizations' interface ListBusinessPlatformStoresOptions { @@ -19,7 +18,7 @@ interface ListBusinessPlatformStoresOptions { interface BusinessPlatformStoreListResult { entries: StoreListEntry[] // True when the selected organization had more stores than we fetched (server-side limited). - hasMore?: boolean + hasMore: boolean } export async function listBusinessPlatformStores( @@ -29,7 +28,7 @@ export async function listBusinessPlatformStores( return { entries: entries.sort(byCreatedAtDescending), - ...(hasMore ? {hasMore: true} : {}), + hasMore, } } @@ -39,10 +38,7 @@ async function fetchOrganizationStores( token: string, organization: Organization, ): Promise<{entries: StoreListEntry[]; hasMore: boolean}> { - const unauthorizedHandler: UnauthorizedHandler = { - type: 'token_refresh', - handler: async () => ({token: await ensureAuthenticatedBusinessPlatform()}), - } + const unauthorizedHandler = businessPlatformTokenRefreshHandler() const result = await businessPlatformOrganizationsRequestDoc({ query: ListAccessibleShops, 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 dfb7f3a1336..a1c654b97ce 100644 --- a/packages/store/src/cli/services/store/list/result.test.ts +++ b/packages/store/src/cli/services/store/list/result.test.ts @@ -96,7 +96,7 @@ describe('writeStoreListResult', () => { expect(output.info()).toContain('No stores found in your Shopify organization.') }) - test('emits a {stores, source, organization} JSON document on stdout', () => { + test('emits a {stores, organization} JSON document on stdout', () => { const output = mockAndCaptureOutput() writeStoreListResult( @@ -130,11 +130,29 @@ describe('writeStoreListResult', () => { type: 'dev', }, ], - source: 'organization', 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, diff --git a/packages/store/src/cli/services/store/list/result.ts b/packages/store/src/cli/services/store/list/result.ts index bef21ce5905..413590fa20d 100644 --- a/packages/store/src/cli/services/store/list/result.ts +++ b/packages/store/src/cli/services/store/list/result.ts @@ -1,10 +1,9 @@ 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' -import {extractSubdomain} from '@shopify/cli-kit/common/url' -import {formatShortDate} from '@shopify/cli-kit/common/string' 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 @@ -17,8 +16,8 @@ export function writeStoreListResult(result: ListStoresResult, format: 'text' | JSON.stringify( { stores: result.stores, - source: result.source, ...(result.organization ? {organization: result.organization} : {}), + ...(result.notice ? {notice: result.notice} : {}), ...(result.truncated ? {truncated: true} : {}), }, null,