From ca62dc87cf036af4424c524a83468f4344786fd9 Mon Sep 17 00:00:00 2001 From: Donald Merand Date: Mon, 3 Aug 2026 16:45:17 -0400 Subject: [PATCH 1/3] Add store list result contract PoC Assisted-By: devx/48f80679-7765-44d2-aae2-f54bc011f8da --- packages/store/src/cli/commands/store/list.ts | 4 +- .../src/cli/services/store/list/codec.test.ts | 49 +++++++++++++++++++ .../src/cli/services/store/list/codec.ts | 24 +++++++++ .../src/cli/services/store/list/result.ts | 18 ++----- 4 files changed, 80 insertions(+), 15 deletions(-) create mode 100644 packages/store/src/cli/services/store/list/codec.test.ts create mode 100644 packages/store/src/cli/services/store/list/codec.ts diff --git a/packages/store/src/cli/commands/store/list.ts b/packages/store/src/cli/commands/store/list.ts index 3222d3bffb2..0f7cdf0d52d 100644 --- a/packages/store/src/cli/commands/store/list.ts +++ b/packages/store/src/cli/commands/store/list.ts @@ -1,5 +1,5 @@ import {listStores} from '../../services/store/list.js' -import {writeStoreListResult} from '../../services/store/list/result.js' +import {presentStoreListResult} 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' @@ -35,6 +35,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/codec.test.ts b/packages/store/src/cli/services/store/list/codec.test.ts new file mode 100644 index 00000000000..ee89eb9f162 --- /dev/null +++ b/packages/store/src/cli/services/store/list/codec.test.ts @@ -0,0 +1,49 @@ +import {encodeStoreListJson, toStoreListDocument} from './codec.js' +import {describe, expect, test} from 'vitest' + +describe('store:list codec', () => { + test('preserves the current JSON wire document and omits internal fields', () => { + const result = { + source: 'organization' as const, + 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 +}`) + expect(toStoreListDocument(result)).not.toHaveProperty('source') + }) + + test('omits optional fields when execution data does not provide them', () => { + expect(toStoreListDocument({source: 'organization', stores: []})).toEqual({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..075a941e718 --- /dev/null +++ b/packages/store/src/cli/services/store/list/codec.ts @@ -0,0 +1,24 @@ +import {type ListStoresResult, type StoreListEntry, type StoreListOrganization} from './types.js' + +/** The version-one JSON document emitted by `store:list --json`. */ +export interface StoreListDocument { + stores: StoreListEntry[] + organization?: StoreListOrganization + notice?: string + truncated?: boolean +} + +/** Project execution data onto the stable store:list JSON contract. */ +export function toStoreListDocument(result: ListStoresResult): StoreListDocument { + return { + stores: result.stores, + ...(result.organization ? {organization: result.organization} : {}), + ...(result.notice ? {notice: result.notice} : {}), + ...(result.truncated ? {truncated: true} : {}), + } +} + +/** Encode the store:list document without selecting an output channel. */ +export function encodeStoreListJson(result: ListStoresResult): string { + return JSON.stringify(toStoreListDocument(result), null, 2) +} diff --git a/packages/store/src/cli/services/store/list/result.ts b/packages/store/src/cli/services/store/list/result.ts index 925dbba7fee..5bbdbbd8067 100644 --- a/packages/store/src/cli/services/store/list/result.ts +++ b/packages/store/src/cli/services/store/list/result.ts @@ -1,29 +1,19 @@ import {STORE_LIST_LIMIT} from './constants.js' +import {encodeStoreListJson} from './codec.js' import {type ListStoresResult, type StoreListEntry} from './types.js' import {extractSubdomain, formatShortDate} from '../display.js' import {storeTypeLabel} from '../store-type.js' import {outputInfo, outputResult, outputWarn} from '@shopify/cli-kit/node/output' import {renderTable} from '@shopify/cli-kit/node/ui' -export function writeStoreListResult(result: ListStoresResult, format: 'text' | 'json'): void { +export function presentStoreListResult(result: ListStoresResult, format: 'text' | 'json'): void { // Human diagnostics always go to stderr so they never corrupt the JSON document on stdout, and so // the truncation signal is visible in both formats. if (result.notice) outputWarn(result.notice) if (result.truncated) outputWarn(truncationWarning(result)) if (format === 'json') { - outputResult( - JSON.stringify( - { - stores: result.stores, - ...(result.organization ? {organization: result.organization} : {}), - ...(result.notice ? {notice: result.notice} : {}), - ...(result.truncated ? {truncated: true} : {}), - }, - null, - 2, - ), - ) + outputResult(encodeStoreListJson(result)) return } @@ -86,6 +76,8 @@ function emptyStateMessage(result: ListStoresResult): string { ].join('\n') } +export const writeStoreListResult = presentStoreListResult + function subdomainFor(store: string): string { return extractSubdomain(store) ?? store } From 603ecc7ed550fc081c2d6a1859e8326bb57da55f Mon Sep 17 00:00:00 2001 From: Donald Merand Date: Tue, 4 Aug 2026 12:09:57 -0400 Subject: [PATCH 2/3] Move store list wire document type into types module Assisted-By: devx/ecf1da3d-df27-457b-ba52-0a62492ff143 --- packages/store/src/cli/services/store/list/codec.ts | 10 +--------- packages/store/src/cli/services/store/list/types.ts | 11 +++++++++++ 2 files changed, 12 insertions(+), 9 deletions(-) diff --git a/packages/store/src/cli/services/store/list/codec.ts b/packages/store/src/cli/services/store/list/codec.ts index 075a941e718..cb0d1ec92e2 100644 --- a/packages/store/src/cli/services/store/list/codec.ts +++ b/packages/store/src/cli/services/store/list/codec.ts @@ -1,12 +1,4 @@ -import {type ListStoresResult, type StoreListEntry, type StoreListOrganization} from './types.js' - -/** The version-one JSON document emitted by `store:list --json`. */ -export interface StoreListDocument { - stores: StoreListEntry[] - organization?: StoreListOrganization - notice?: string - truncated?: boolean -} +import {type ListStoresResult, type StoreListDocument} from './types.js' /** Project execution data onto the stable store:list JSON contract. */ export function toStoreListDocument(result: ListStoresResult): StoreListDocument { diff --git a/packages/store/src/cli/services/store/list/types.ts b/packages/store/src/cli/services/store/list/types.ts index 274a62c77f9..a7dac238e47 100644 --- a/packages/store/src/cli/services/store/list/types.ts +++ b/packages/store/src/cli/services/store/list/types.ts @@ -20,3 +20,14 @@ export interface ListStoresResult { notice?: string truncated?: boolean } + +/** + * The stable JSON document emitted by `store:list --json`. Its exact keys and omission rules are + * pinned by tests. It excludes internal execution fields such as `source`. + */ +export interface StoreListDocument { + stores: StoreListEntry[] + organization?: StoreListOrganization + notice?: string + truncated?: boolean +} From 57936da7de50b535d73a6ff852b3e16210d51673 Mon Sep 17 00:00:00 2001 From: Gonzalo Riestra Date: Mon, 10 Aug 2026 12:10:13 +0200 Subject: [PATCH 3/3] Document the store list JSON result contract --- .changeset/store-list-json-result-contract.md | 5 ++ packages/cli/README.md | 26 ++++++++ packages/cli/oclif.manifest.json | 4 +- .../store/src/cli/commands/store/list.test.ts | 17 +++-- packages/store/src/cli/commands/store/list.ts | 5 ++ .../store/src/cli/services/store/list.test.ts | 4 +- packages/store/src/cli/services/store/list.ts | 8 +-- .../src/cli/services/store/list/codec.test.ts | 10 +-- .../src/cli/services/store/list/codec.ts | 16 +---- .../cli/services/store/list/result.test.ts | 28 ++++---- .../src/cli/services/store/list/result.ts | 12 ++-- .../src/cli/services/store/list/types.ts | 64 ++++++++++--------- 12 files changed, 111 insertions(+), 88 deletions(-) create mode 100644 .changeset/store-list-json-result-contract.md 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 0f7cdf0d52d..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 {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. 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 and omits internal fields', () => { + test('preserves the current JSON wire document', () => { const result = { - source: 'organization' as const, stores: [ { id: 'gid://shopify/Shop/1', @@ -40,10 +39,11 @@ describe('store:list codec', () => { "notice": "A notice", "truncated": true }`) - expect(toStoreListDocument(result)).not.toHaveProperty('source') }) test('omits optional fields when execution data does not provide them', () => { - expect(toStoreListDocument({source: 'organization', stores: []})).toEqual({stores: []}) + 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 index cb0d1ec92e2..1ec2d8d303b 100644 --- a/packages/store/src/cli/services/store/list/codec.ts +++ b/packages/store/src/cli/services/store/list/codec.ts @@ -1,16 +1,6 @@ -import {type ListStoresResult, type StoreListDocument} from './types.js' - -/** Project execution data onto the stable store:list JSON contract. */ -export function toStoreListDocument(result: ListStoresResult): StoreListDocument { - return { - stores: result.stores, - ...(result.organization ? {organization: result.organization} : {}), - ...(result.notice ? {notice: result.notice} : {}), - ...(result.truncated ? {truncated: true} : {}), - } -} +import {storeListJsonOutputSchema, type StoreListResult} from './types.js' /** Encode the store:list document without selecting an output channel. */ -export function encodeStoreListJson(result: ListStoresResult): string { - return JSON.stringify(toStoreListDocument(result), null, 2) +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 5bbdbbd8067..b1c62f18f00 100644 --- a/packages/store/src/cli/services/store/list/result.ts +++ b/packages/store/src/cli/services/store/list/result.ts @@ -1,12 +1,12 @@ import {STORE_LIST_LIMIT} from './constants.js' import {encodeStoreListJson} from './codec.js' -import {type ListStoresResult, type StoreListEntry} from './types.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 presentStoreListResult(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) @@ -20,12 +20,12 @@ export function presentStoreListResult(result: ListStoresResult, format: 'text' 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 @@ -56,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.', @@ -76,8 +76,6 @@ function emptyStateMessage(result: ListStoresResult): string { ].join('\n') } -export const writeStoreListResult = presentStoreListResult - 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 index a7dac238e47..7335d94a127 100644 --- a/packages/store/src/cli/services/store/list/types.ts +++ b/packages/store/src/cli/services/store/list/types.ts @@ -1,33 +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(), +}) -/** - * The stable JSON document emitted by `store:list --json`. Its exact keys and omission rules are - * pinned by tests. It excludes internal execution fields such as `source`. - */ -export interface StoreListDocument { - stores: StoreListEntry[] - organization?: StoreListOrganization - notice?: string - truncated?: boolean -} +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