diff --git a/.changeset/store-list-json-result-contract.md b/.changeset/store-list-json-result-contract.md new file mode 100644 index 00000000000..4d4d2856b96 --- /dev/null +++ b/.changeset/store-list-json-result-contract.md @@ -0,0 +1,5 @@ +--- +'@shopify/store': minor +--- + +Document and validate the `store list --json` result contract. diff --git a/packages/cli/README.md b/packages/cli/README.md index 445154f2fa8..471d20727ab 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -3897,6 +3897,32 @@ DESCRIPTION Run `shopify organization list` to find organization IDs. + With `--json`, the command returns `StoreListResult`, described by these TypeScript types: + + ```ts + interface StoreListResult { + stores: StoreListEntry[] + organization?: StoreListOrganization + notice?: string + truncated?: boolean + } + + interface StoreListEntry { + id?: string + store: string + createdAt: string + organizationId: string + organizationName: string + name?: string + type?: string + } + + interface StoreListOrganization { + id: string + name: string + } + ``` + EXAMPLES $ shopify store list diff --git a/packages/cli/oclif.manifest.json b/packages/cli/oclif.manifest.json index 98c1bcf5ac5..4a95d50b31f 100644 --- a/packages/cli/oclif.manifest.json +++ b/packages/cli/oclif.manifest.json @@ -7193,8 +7193,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`. In that case, `--organization-id` is required in non-interactive environments.\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`. In that case, `--organization-id` is required in non-interactive environments.\n\nRun `<%= config.bin %> 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`. In that case, `--organization-id` is required in non-interactive environments.\n\nRun `<%= config.bin %> organization list` to find organization IDs.\n\nWith `--json`, the command returns `StoreListResult`, described by these TypeScript types:\n\n```ts\ninterface StoreListResult {\n stores: StoreListEntry[]\n organization?: StoreListOrganization\n notice?: string\n truncated?: boolean\n}\n\ninterface StoreListEntry {\n id?: string\n store: string\n createdAt: string\n organizationId: string\n organizationName: string\n name?: string\n type?: string\n}\n\ninterface StoreListOrganization {\n id: string\n name: string\n}\n```", + "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`. In that case, `--organization-id` is required in non-interactive environments.\n\nRun `<%= config.bin %> organization list` to find organization IDs.\n\nWith `--json`, the command returns `StoreListResult`, described by these TypeScript types:\n\n```ts\ninterface StoreListResult {\n stores: StoreListEntry[]\n organization?: StoreListOrganization\n notice?: string\n truncated?: boolean\n}\n\ninterface StoreListEntry {\n id?: string\n store: string\n createdAt: string\n organizationId: string\n organizationName: string\n name?: string\n type?: string\n}\n\ninterface StoreListOrganization {\n id: string\n name: string\n}\n```", "examples": [ "<%= config.bin %> <%= command.id %>", "<%= config.bin %> <%= command.id %> --organization-id 1234567", diff --git a/packages/store/src/cli/commands/store/list.test.ts b/packages/store/src/cli/commands/store/list.test.ts index 0e8933bc21d..d82f48b6923 100644 --- a/packages/store/src/cli/commands/store/list.test.ts +++ b/packages/store/src/cli/commands/store/list.test.ts @@ -1,6 +1,6 @@ import StoreList from './list.js' import {listStores} from '../../services/store/list.js' -import {writeStoreListResult} from '../../services/store/list/result.js' +import {presentStoreListResult} from '../../services/store/list/result.js' import {describe, expect, test, vi} from 'vitest' vi.mock('../../services/store/list.js') @@ -9,16 +9,16 @@ 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'}) + vi.mocked(listStores).mockResolvedValue({stores: []}) await StoreList.run([]) expect(listStores).toHaveBeenCalledWith({organizationId: undefined}) - expect(writeStoreListResult).toHaveBeenCalledWith({stores: [], source: 'organization'}, 'text') + expect(presentStoreListResult).toHaveBeenCalledWith({stores: []}, 'text') }) test('passes the organization id through to the list service', async () => { - vi.mocked(listStores).mockResolvedValue({stores: [], source: 'organization'}) + vi.mocked(listStores).mockResolvedValue({stores: []}) await StoreList.run(['--organization-id', '1234567']) @@ -26,12 +26,12 @@ describe('store list command', () => { }) test('writes json output when requested', async () => { - vi.mocked(listStores).mockResolvedValue({stores: [], source: 'organization'}) + vi.mocked(listStores).mockResolvedValue({stores: []}) await StoreList.run(['--json']) expect(listStores).toHaveBeenCalledWith({organizationId: undefined}) - expect(writeStoreListResult).toHaveBeenCalledWith({stores: [], source: 'organization'}, 'json') + expect(presentStoreListResult).toHaveBeenCalledWith({stores: []}, 'json') }) test('defines the expected flags', () => { @@ -39,4 +39,9 @@ describe('store list command', () => { expect(StoreList.flags['organization-id']).toBeDefined() expect(StoreList.flags).not.toHaveProperty('from') }) + + test('documents the JSON output schema', () => { + expect(StoreList.description).toContain('interface StoreListResult') + expect(StoreList.description).toContain('stores: StoreListEntry[]') + }) }) diff --git a/packages/store/src/cli/commands/store/list.ts b/packages/store/src/cli/commands/store/list.ts index 3222d3bffb2..bac27523870 100644 --- a/packages/store/src/cli/commands/store/list.ts +++ b/packages/store/src/cli/commands/store/list.ts @@ -1,5 +1,6 @@ import {listStores} from '../../services/store/list.js' -import {writeStoreListResult} from '../../services/store/list/result.js' +import {presentStoreListResult} from '../../services/store/list/result.js' +import {storeListJsonOutputSchema} from '../../services/store/list/types.js' import {storeFlags} from '../../flags.js' import StoreCommand from '../../utilities/store-command.js' import {globalFlags, jsonFlag} from '@shopify/cli-kit/node/cli' @@ -8,6 +9,10 @@ import {Flags} from '@oclif/core' export default class StoreList extends StoreCommand { static summary = 'List stores in a Shopify organization.' + static get jsonOutputSchema() { + return storeListJsonOutputSchema + } + 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 that case, \`--organization-id\` is required in non-interactive environments. @@ -35,6 +40,6 @@ Run \`<%= config.bin %> organization list\` to find organization IDs.` const {flags} = await this.parse(StoreList) const result = await listStores({organizationId: flags['organization-id']}) - writeStoreListResult(result, flags.json ? 'json' : 'text') + presentStoreListResult(result, flags.json ? 'json' : 'text') } } diff --git a/packages/store/src/cli/services/store/list.test.ts b/packages/store/src/cli/services/store/list.test.ts index c309fe9cf70..d3dfe790bb1 100644 --- a/packages/store/src/cli/services/store/list.test.ts +++ b/packages/store/src/cli/services/store/list.test.ts @@ -42,7 +42,6 @@ describe('listStores', () => { expect(renderAutocompletePrompt).not.toHaveBeenCalled() expect(result).toEqual({ stores: [orgEntry], - source: 'organization', organization: {id: '1234', name: 'Acme'}, }) }) @@ -107,7 +106,6 @@ describe('listStores', () => { expect(result).toEqual({ stores: [], - source: 'organization', notice: "Couldn't resolve a Shopify account for the current CLI session.", }) }) @@ -117,7 +115,7 @@ describe('listStores', () => { const result = await listStores() - expect(result).toEqual({stores: [], source: 'organization'}) + expect(result).toEqual({stores: []}) }) test('propagates store listing failures', async () => { diff --git a/packages/store/src/cli/services/store/list.ts b/packages/store/src/cli/services/store/list.ts index 15658f3ba6f..f497b83af04 100644 --- a/packages/store/src/cli/services/store/list.ts +++ b/packages/store/src/cli/services/store/list.ts @@ -1,6 +1,6 @@ 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 {type StoreListEntry, type StoreListOrganization, type StoreListResult} from './list/types.js' import {AbortError} from '@shopify/cli-kit/node/error' import {ensureAuthenticatedBusinessPlatform} from '@shopify/cli-kit/node/session' import {isTTY, renderAutocompletePrompt} from '@shopify/cli-kit/node/ui' @@ -10,20 +10,19 @@ interface ListStoresOptions { organizationId?: number } -export async function listStores(options: ListStoresOptions = {}): Promise { +export async function listStores(options: ListStoresOptions = {}): Promise { const token = await ensureAuthenticatedBusinessPlatform() const organizationsResult = await fetchOrganizationsWithAccessInfo(token) if (!organizationsResult.currentUserResolved) { return { stores: [], - source: 'organization', notice: "Couldn't resolve a Shopify account for the current CLI session.", } } if (organizationsResult.organizations.length === 0) { - return {stores: [], source: 'organization'} + return {stores: []} } if (!options.organizationId && organizationsResult.organizations.length > 1 && !isTTY()) { @@ -43,7 +42,6 @@ export async function listStores(options: ListStoresOptions = {}): Promise { + test('preserves the current JSON wire document', () => { + const result = { + stores: [ + { + id: 'gid://shopify/Shop/1', + store: 'shop.myshopify.com', + createdAt: '2026-05-22T00:00:00Z', + organizationId: '1234', + organizationName: 'Acme', + name: 'My Shop', + type: 'dev', + }, + ], + organization: {id: '1234', name: 'Acme'}, + notice: 'A notice', + truncated: true, + } + + expect(encodeStoreListJson(result)).toBe(`{ + "stores": [ + { + "id": "gid://shopify/Shop/1", + "store": "shop.myshopify.com", + "createdAt": "2026-05-22T00:00:00Z", + "organizationId": "1234", + "organizationName": "Acme", + "name": "My Shop", + "type": "dev" + } + ], + "organization": { + "id": "1234", + "name": "Acme" + }, + "notice": "A notice", + "truncated": true +}`) + }) + + test('omits optional fields when execution data does not provide them', () => { + expect(encodeStoreListJson({stores: []})).toBe(`{ + "stores": [] +}`) + }) +}) diff --git a/packages/store/src/cli/services/store/list/codec.ts b/packages/store/src/cli/services/store/list/codec.ts new file mode 100644 index 00000000000..1ec2d8d303b --- /dev/null +++ b/packages/store/src/cli/services/store/list/codec.ts @@ -0,0 +1,6 @@ +import {storeListJsonOutputSchema, type StoreListResult} from './types.js' + +/** Encode the store:list document without selecting an output channel. */ +export function encodeStoreListJson(result: StoreListResult): string { + return JSON.stringify(storeListJsonOutputSchema.schema.parse(result), null, 2) +} 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 cd84e69811b..c2cca18cf64 100644 --- a/packages/store/src/cli/services/store/list/result.test.ts +++ b/packages/store/src/cli/services/store/list/result.test.ts @@ -1,10 +1,10 @@ -import {writeStoreListResult} from './result.js' +import {presentStoreListResult} 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', () => { +describe('presentStoreListResult', () => { beforeEach(() => { mockAndCaptureOutput().clear() }) @@ -12,9 +12,8 @@ describe('writeStoreListResult', () => { test('renders organization context and rows with subdomain, name, type, and created date', () => { const output = mockAndCaptureOutput() - writeStoreListResult( + presentStoreListResult( { - source: 'organization', organization, stores: [ { @@ -44,9 +43,8 @@ describe('writeStoreListResult', () => { test('renders the subdomain handle for non-myshopify hosts (local dev)', () => { const output = mockAndCaptureOutput() - writeStoreListResult( + presentStoreListResult( { - source: 'organization', organization, stores: [ { @@ -68,9 +66,8 @@ describe('writeStoreListResult', () => { test('writes the unresolved-session notice to stderr and the empty state to stdout', () => { const output = mockAndCaptureOutput() - writeStoreListResult( + presentStoreListResult( { - source: 'organization', stores: [], notice: "Couldn't resolve a Shopify account for the current CLI session.", }, @@ -85,7 +82,7 @@ describe('writeStoreListResult', () => { test('renders the selected organization empty state', () => { const output = mockAndCaptureOutput() - writeStoreListResult({source: 'organization', organization, stores: []}, 'text') + presentStoreListResult({organization, stores: []}, 'text') expect(output.info()).toContain('No stores found in Acme.') }) @@ -93,7 +90,7 @@ describe('writeStoreListResult', () => { test('renders the fallback organization empty state when no organization is selected', () => { const output = mockAndCaptureOutput() - writeStoreListResult({source: 'organization', stores: []}, 'text') + presentStoreListResult({stores: []}, 'text') expect(output.info()).toContain('No stores found in your Shopify organization.') expect(output.info()).toContain('shopify store auth list') @@ -102,9 +99,8 @@ describe('writeStoreListResult', () => { test('emits a {stores, organization} JSON document on stdout', () => { const output = mockAndCaptureOutput() - writeStoreListResult( + presentStoreListResult( { - source: 'organization', organization, stores: [ { @@ -140,9 +136,8 @@ describe('writeStoreListResult', () => { test('includes unresolved-session notices in JSON output', () => { const output = mockAndCaptureOutput() - writeStoreListResult( + presentStoreListResult( { - source: 'organization', stores: [], notice: "Couldn't resolve a Shopify account for the current CLI session.", }, @@ -158,7 +153,6 @@ describe('writeStoreListResult', () => { test('warns on stderr when the listing was truncated, in both text and json', () => { const result = { - source: 'organization' as const, organization, stores: [ { @@ -172,11 +166,11 @@ describe('writeStoreListResult', () => { } const textOutput = mockAndCaptureOutput() - writeStoreListResult(result, 'text') + presentStoreListResult(result, 'text') expect(textOutput.warn()).toContain('Showing the 250 most recent stores in Acme. More stores exist') const jsonOutput = mockAndCaptureOutput() - writeStoreListResult(result, 'json') + presentStoreListResult(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 index 925dbba7fee..b1c62f18f00 100644 --- a/packages/store/src/cli/services/store/list/result.ts +++ b/packages/store/src/cli/services/store/list/result.ts @@ -1,41 +1,31 @@ import {STORE_LIST_LIMIT} from './constants.js' -import {type ListStoresResult, type StoreListEntry} from './types.js' +import {encodeStoreListJson} from './codec.js' +import {type StoreListEntry, type StoreListResult} from './types.js' import {extractSubdomain, formatShortDate} from '../display.js' import {storeTypeLabel} from '../store-type.js' import {outputInfo, outputResult, outputWarn} from '@shopify/cli-kit/node/output' import {renderTable} from '@shopify/cli-kit/node/ui' -export function writeStoreListResult(result: ListStoresResult, format: 'text' | 'json'): void { +export function presentStoreListResult(result: StoreListResult, format: 'text' | 'json'): void { // Human diagnostics always go to stderr so they never corrupt the JSON document on stdout, and so // the truncation signal is visible in both formats. if (result.notice) outputWarn(result.notice) if (result.truncated) outputWarn(truncationWarning(result)) if (format === 'json') { - outputResult( - JSON.stringify( - { - stores: result.stores, - ...(result.organization ? {organization: result.organization} : {}), - ...(result.notice ? {notice: result.notice} : {}), - ...(result.truncated ? {truncated: true} : {}), - }, - null, - 2, - ), - ) + outputResult(encodeStoreListJson(result)) return } renderTextResult(result) } -function truncationWarning(result: ListStoresResult): string { +function truncationWarning(result: StoreListResult): 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 { +function renderTextResult(result: StoreListResult): void { if (result.stores.length === 0) { outputInfo(emptyStateMessage(result)) return @@ -66,7 +56,7 @@ function renderOrganizationTable(stores: StoreListEntry[]): void { }) } -function emptyStateMessage(result: ListStoresResult): string { +function emptyStateMessage(result: StoreListResult): string { if (result.notice) { return [ 'No stores were returned for the current CLI session.', diff --git a/packages/store/src/cli/services/store/list/types.ts b/packages/store/src/cli/services/store/list/types.ts index 274a62c77f9..7335d94a127 100644 --- a/packages/store/src/cli/services/store/list/types.ts +++ b/packages/store/src/cli/services/store/list/types.ts @@ -1,22 +1,37 @@ -export interface StoreListEntry { - id?: string - store: string - createdAt: string - organizationId: string - organizationName: string - name?: string - type?: string -} +import {defineJsonOutputSchema, type InferJsonOutputSchema} from '@shopify/cli-kit/node/json-output-schema' +import {zod} from '@shopify/cli-kit/node/schema' -export interface StoreListOrganization { - id: string - name: string -} +export const StoreListEntrySchema = zod.object({ + id: zod.string().optional(), + store: zod.string(), + createdAt: zod.string(), + organizationId: zod.string(), + organizationName: zod.string(), + name: zod.string().optional(), + type: zod.string().optional(), +}) -export interface ListStoresResult { - stores: StoreListEntry[] - source: 'organization' - organization?: StoreListOrganization - notice?: string - truncated?: boolean -} +export const StoreListOrganizationSchema = zod.object({ + id: zod.string(), + name: zod.string(), +}) + +const StoreListResultSchema = zod.object({ + stores: zod.array(StoreListEntrySchema), + organization: StoreListOrganizationSchema.optional(), + notice: zod.string().optional(), + truncated: zod.boolean().optional(), +}) + +export const storeListJsonOutputSchema = defineJsonOutputSchema({ + name: 'StoreListResult', + schema: StoreListResultSchema, + definitions: { + StoreListEntry: StoreListEntrySchema, + StoreListOrganization: StoreListOrganizationSchema, + }, +}) + +export type StoreListEntry = zod.infer +export type StoreListOrganization = zod.infer +export type StoreListResult = InferJsonOutputSchema