diff --git a/packages/cli/README.md b/packages/cli/README.md index a9794e8cc45..bd6046a3285 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -4385,6 +4385,80 @@ DESCRIPTION Use `--json` for machine-readable output. + Output from `--json` conforms to the `StoreInfoResult` schema. + + Use `--json-schema` to print the result, error, and event schemas. + + ```json + { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "displayName": { + "type": "string" + }, + "subdomain": { + "type": "string" + }, + "organizationId": { + "type": "string" + }, + "organizationName": { + "type": "string" + }, + "storeOwner": { + "$ref": "#/definitions/StoreInfoStoreOwner" + }, + "type": { + "type": "string" + }, + "plan": { + "type": "string" + }, + "featurePreview": { + "type": "string" + }, + "adminUrl": { + "type": "string" + }, + "accessUrl": { + "type": "string" + }, + "saveUrl": { + "type": "string" + }, + "authScopes": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": [ + "subdomain" + ], + "additionalProperties": false, + "title": "StoreInfoResult", + "definitions": { + "StoreInfoStoreOwner": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "email": { + "type": "string" + } + }, + "additionalProperties": false + } + }, + "$schema": "http://json-schema.org/draft-07/schema#" + } + ``` + EXAMPLES $ shopify store info --store shop.myshopify.com @@ -4437,6 +4511,102 @@ DESCRIPTION Run `shopify organization list` to find organization IDs. + Output from `--json` conforms to the `StoreListResult` schema. + + Use `--json-schema` to print the result, error, and event schemas. + + ```json + { + "type": "object", + "properties": { + "stores": { + "type": "array", + "items": { + "$ref": "#/definitions/StoreListEntry" + } + }, + "organization": { + "$ref": "#/definitions/StoreListOrganization" + }, + "storeType": { + "type": "string", + "enum": [ + "dev", + "production", + "client-transfer", + "collaborator" + ] + }, + "notice": { + "type": "string" + }, + "truncated": { + "type": "boolean" + } + }, + "required": [ + "stores" + ], + "additionalProperties": false, + "title": "StoreListResult", + "definitions": { + "StoreListEntry": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "store": { + "type": "string" + }, + "createdAt": { + "type": "string" + }, + "organizationId": { + "type": "string" + }, + "organizationName": { + "type": "string" + }, + "name": { + "type": "string" + }, + "type": { + "type": "string" + }, + "plan": { + "type": "string" + } + }, + "required": [ + "store", + "createdAt", + "organizationId", + "organizationName" + ], + "additionalProperties": false + }, + "StoreListOrganization": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "name": { + "type": "string" + } + }, + "required": [ + "id", + "name" + ], + "additionalProperties": false + } + }, + "$schema": "http://json-schema.org/draft-07/schema#" + } + ``` + EXAMPLES $ shopify store list diff --git a/packages/cli/oclif.manifest.json b/packages/cli/oclif.manifest.json index 6e53b241bfd..ff41ad9bfde 100644 --- a/packages/cli/oclif.manifest.json +++ b/packages/cli/oclif.manifest.json @@ -8765,7 +8765,7 @@ "args": { }, "customPluginName": "@shopify/store", - "description": "Returns available metadata about a store you have access to, such as its id, display name, subdomain, organization, store owner, type, plan, feature preview, admin URL, and access and save URLs for preview stores.\n\nSome details may be omitted when they are not available for the store.\n\nUse `--json` for machine-readable output.", + "description": "Returns available metadata about a store you have access to, such as its id, display name, subdomain, organization, store owner, type, plan, feature preview, admin URL, and access and save URLs for preview stores.\n\nSome details may be omitted when they are not available for the store.\n\nUse `--json` for machine-readable output.\n\nOutput from `--json` conforms to the `StoreInfoResult` schema.\n\nUse `--json-schema` to print the result, error, and event schemas.\n\n```json\n{\n \"type\": \"object\",\n \"properties\": {\n \"id\": {\n \"type\": \"string\"\n },\n \"displayName\": {\n \"type\": \"string\"\n },\n \"subdomain\": {\n \"type\": \"string\"\n },\n \"organizationId\": {\n \"type\": \"string\"\n },\n \"organizationName\": {\n \"type\": \"string\"\n },\n \"storeOwner\": {\n \"$ref\": \"#/definitions/StoreInfoStoreOwner\"\n },\n \"type\": {\n \"type\": \"string\"\n },\n \"plan\": {\n \"type\": \"string\"\n },\n \"featurePreview\": {\n \"type\": \"string\"\n },\n \"adminUrl\": {\n \"type\": \"string\"\n },\n \"accessUrl\": {\n \"type\": \"string\"\n },\n \"saveUrl\": {\n \"type\": \"string\"\n },\n \"authScopes\": {\n \"type\": \"array\",\n \"items\": {\n \"type\": \"string\"\n }\n }\n },\n \"required\": [\n \"subdomain\"\n ],\n \"additionalProperties\": false,\n \"title\": \"StoreInfoResult\",\n \"definitions\": {\n \"StoreInfoStoreOwner\": {\n \"type\": \"object\",\n \"properties\": {\n \"name\": {\n \"type\": \"string\"\n },\n \"email\": {\n \"type\": \"string\"\n }\n },\n \"additionalProperties\": false\n }\n },\n \"$schema\": \"http://json-schema.org/draft-07/schema#\"\n}\n```", "descriptionWithMarkdown": "Returns available metadata about a store you have access to, such as its id, display name, subdomain, organization, store owner, type, plan, feature preview, admin URL, and access and save URLs for preview stores.\n\nSome details may be omitted when they are not available for the store.\n\nUse `--json` for machine-readable output.", "examples": [ "<%= config.bin %> <%= command.id %> --store shop.myshopify.com", @@ -8831,7 +8831,7 @@ "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.", + "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\nOutput from `--json` conforms to the `StoreListResult` schema.\n\nUse `--json-schema` to print the result, error, and event schemas.\n\n```json\n{\n \"type\": \"object\",\n \"properties\": {\n \"stores\": {\n \"type\": \"array\",\n \"items\": {\n \"$ref\": \"#/definitions/StoreListEntry\"\n }\n },\n \"organization\": {\n \"$ref\": \"#/definitions/StoreListOrganization\"\n },\n \"storeType\": {\n \"type\": \"string\",\n \"enum\": [\n \"dev\",\n \"production\",\n \"client-transfer\",\n \"collaborator\"\n ]\n },\n \"notice\": {\n \"type\": \"string\"\n },\n \"truncated\": {\n \"type\": \"boolean\"\n }\n },\n \"required\": [\n \"stores\"\n ],\n \"additionalProperties\": false,\n \"title\": \"StoreListResult\",\n \"definitions\": {\n \"StoreListEntry\": {\n \"type\": \"object\",\n \"properties\": {\n \"id\": {\n \"type\": \"string\"\n },\n \"store\": {\n \"type\": \"string\"\n },\n \"createdAt\": {\n \"type\": \"string\"\n },\n \"organizationId\": {\n \"type\": \"string\"\n },\n \"organizationName\": {\n \"type\": \"string\"\n },\n \"name\": {\n \"type\": \"string\"\n },\n \"type\": {\n \"type\": \"string\"\n },\n \"plan\": {\n \"type\": \"string\"\n }\n },\n \"required\": [\n \"store\",\n \"createdAt\",\n \"organizationId\",\n \"organizationName\"\n ],\n \"additionalProperties\": false\n },\n \"StoreListOrganization\": {\n \"type\": \"object\",\n \"properties\": {\n \"id\": {\n \"type\": \"string\"\n },\n \"name\": {\n \"type\": \"string\"\n }\n },\n \"required\": [\n \"id\",\n \"name\"\n ],\n \"additionalProperties\": false\n }\n },\n \"$schema\": \"http://json-schema.org/draft-07/schema#\"\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.", "examples": [ "<%= config.bin %> <%= command.id %>", diff --git a/packages/store/src/cli/commands/store/info.test.ts b/packages/store/src/cli/commands/store/info.test.ts index f1f43169aea..f89d0a5044d 100644 --- a/packages/store/src/cli/commands/store/info.test.ts +++ b/packages/store/src/cli/commands/store/info.test.ts @@ -1,6 +1,7 @@ import StoreInfo from './info.js' import {getStoreInfo} from '../../services/store/info/index.js' import {renderStoreInfoResult} from '../../services/store/info/result.js' +import {storeInfoJsonOutputSchema} from '../../services/store/info/types.js' import {beforeEach, describe, expect, test, vi} from 'vitest' vi.mock('../../services/store/info/index.js') @@ -18,9 +19,7 @@ describe('store info command', () => { test('passes the store flag through to the service', async () => { await StoreInfo.run(['--store', 'shop.myshopify.com']) - expect(getStoreInfo).toHaveBeenCalledWith({ - store: 'shop.myshopify.com', - }) + expect(getStoreInfo).toHaveBeenCalledWith({store: 'shop.myshopify.com'}) expect(renderStoreInfoResult).toHaveBeenCalledWith( expect.objectContaining({subdomain: 'shop.myshopify.com'}), 'text', @@ -37,4 +36,8 @@ describe('store info command', () => { expect(StoreInfo.flags.store).toBeDefined() expect(StoreInfo.flags.json).toBeDefined() }) + + test('exposes the JSON output schema', () => { + expect(StoreInfo.jsonOutputSchema).toBe(storeInfoJsonOutputSchema) + }) }) diff --git a/packages/store/src/cli/commands/store/info.ts b/packages/store/src/cli/commands/store/info.ts index 972742dd1d2..8e72f9f53ea 100644 --- a/packages/store/src/cli/commands/store/info.ts +++ b/packages/store/src/cli/commands/store/info.ts @@ -1,5 +1,6 @@ import {getStoreInfo} from '../../services/store/info/index.js' import {renderStoreInfoResult} from '../../services/store/info/result.js' +import {storeInfoJsonOutputSchema} from '../../services/store/info/types.js' import StoreCommand from '../../utilities/store-command.js' import {storeFlags} from '../../flags.js' import {globalFlags, jsonFlag} from '@shopify/cli-kit/node/cli' @@ -26,6 +27,10 @@ Use \`--json\` for machine-readable output.` store: storeFlags.store, } + static get jsonOutputSchema() { + return storeInfoJsonOutputSchema + } + public async run(): Promise { const {flags} = await this.parse(StoreInfo) diff --git a/packages/store/src/cli/commands/store/list.test.ts b/packages/store/src/cli/commands/store/list.test.ts index 85ee6c2cb1f..56cb9ac863a 100644 --- a/packages/store/src/cli/commands/store/list.test.ts +++ b/packages/store/src/cli/commands/store/list.test.ts @@ -1,6 +1,7 @@ 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 {storeListJsonOutputSchema} from '../../services/store/list/types.js' import {describe, expect, test, vi} from 'vitest' vi.mock('../../services/store/list.js') @@ -9,16 +10,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, storeType: 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,7 +27,7 @@ describe('store list command', () => { }) test('passes the store type filter through to the list service', async () => { - vi.mocked(listStores).mockResolvedValue({stores: [], source: 'organization'}) + vi.mocked(listStores).mockResolvedValue({stores: []}) await StoreList.run(['--type', 'client-transfer']) @@ -34,12 +35,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, storeType: undefined}) - expect(writeStoreListResult).toHaveBeenCalledWith({stores: [], source: 'organization'}, 'json') + expect(presentStoreListResult).toHaveBeenCalledWith({stores: []}, 'json') }) test('defines the expected flags', () => { @@ -48,4 +49,8 @@ describe('store list command', () => { expect(StoreList.flags.type?.options).toEqual(['dev', 'production', 'client-transfer', 'collaborator']) expect(StoreList.flags).not.toHaveProperty('from') }) + + test('exposes the JSON output schema', () => { + expect(StoreList.jsonOutputSchema).toBe(storeListJsonOutputSchema) + }) }) diff --git a/packages/store/src/cli/commands/store/list.ts b/packages/store/src/cli/commands/store/list.ts index 849c145fdbf..78ae04d5972 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 {storeTypeFilters, type StoreTypeFilter} from '../../services/store/store-type.js' import {storeFlags} from '../../flags.js' import StoreCommand from '../../utilities/store-command.js' @@ -38,6 +39,10 @@ Run \`<%= config.bin %> organization list\` to find organization IDs.` }), } + static get jsonOutputSchema() { + return storeListJsonOutputSchema + } + public async run(): Promise { const {flags} = await this.parse(StoreList) const result = await listStores({ @@ -46,6 +51,6 @@ Run \`<%= config.bin %> organization list\` to find organization IDs.` storeType: flags.type as StoreTypeFilter | undefined, }) - writeStoreListResult(result, flags.json ? 'json' : 'text') + presentStoreListResult(result, flags.json ? 'json' : 'text') } } diff --git a/packages/store/src/cli/services/store/info/index.test.ts b/packages/store/src/cli/services/store/info/index.test.ts index 9d5dfef1ba4..53d7ec92e99 100644 --- a/packages/store/src/cli/services/store/info/index.test.ts +++ b/packages/store/src/cli/services/store/info/index.test.ts @@ -1,4 +1,5 @@ import {getStoreInfo} from './index.js' +import {storeInfoJsonOutputSchema} from './types.js' import {StoreLookupStoreNotFoundError, fetchDestinationsContext} from '../../../utilities/store-lookup/destinations.js' import {fetchOrganizationShop} from '../../../utilities/store-lookup/organization-shop.js' import {STORE_AUTH_APP_CLIENT_ID} from '../auth/config.js' @@ -149,6 +150,7 @@ describe('getStoreInfo', () => { featurePreview: 'extended_variants', adminUrl: 'https://admin.shopify.com/store/shop', }) + expect(storeInfoJsonOutputSchema.validate(result)).toEqual(result) }) test('returns fresh access and save URLs for locally stored preview stores', async () => { diff --git a/packages/store/src/cli/services/store/info/result.ts b/packages/store/src/cli/services/store/info/result.ts index a4c009aab41..6af169ed4fe 100644 --- a/packages/store/src/cli/services/store/info/result.ts +++ b/packages/store/src/cli/services/store/info/result.ts @@ -1,13 +1,13 @@ +import {storeInfoJsonOutputSchema, type StoreInfoResult, type StoreInfoStoreOwner} from './types.js' import {outputResult} from '@shopify/cli-kit/node/output' import {renderInfo, type InlineToken, type LinkToken} from '@shopify/cli-kit/node/ui' import {capitalizeWords} from '@shopify/cli-kit/common/string' -import type {StoreInfoResult, StoreInfoStoreOwner} from './types.js' type StoreInfoOutputFormat = 'text' | 'json' export function renderStoreInfoResult(result: StoreInfoResult, format: StoreInfoOutputFormat): void { if (format === 'json') { - outputResult(JSON.stringify(result, null, 2)) + outputResult(storeInfoJsonOutputSchema.encode(result)) return } const actions = storeActions(result) diff --git a/packages/store/src/cli/services/store/info/types.ts b/packages/store/src/cli/services/store/info/types.ts index ef037f371f2..57ddf51fab1 100644 --- a/packages/store/src/cli/services/store/info/types.ts +++ b/packages/store/src/cli/services/store/info/types.ts @@ -1,24 +1,33 @@ -export interface StoreInfoStoreOwner { - name?: string - email?: string -} +import {defineJsonOutputSchema, type InferJsonOutputSchema} from '@shopify/cli-kit/node/json-output-schema' +import {zod} from '@shopify/cli-kit/node/schema' -export interface StoreInfoResult { - id?: string - displayName?: string - subdomain: string - organizationId?: string - organizationName?: string - storeOwner?: StoreInfoStoreOwner - type?: string - // Admin API public display name for store-auth stores, or public plan handle for BP-backed stores. - plan?: string - featurePreview?: string - adminUrl?: string - accessUrl?: string - saveUrl?: string - // Preapproved Admin API access scopes for the store (currently only preview stores, which - // cache the scopes granted at creation time). Preview stores aren't a logged-in experience, so - // there's no way to grant additional scopes later. - authScopes?: string[] -} +const StoreInfoStoreOwnerSchema = zod.object({ + name: zod.string().optional(), + email: zod.string().optional(), +}) + +export const storeInfoJsonOutputSchema = defineJsonOutputSchema({ + name: 'StoreInfoResult', + schema: zod.object({ + id: zod.string().optional(), + displayName: zod.string().optional(), + subdomain: zod.string(), + organizationId: zod.string().optional(), + organizationName: zod.string().optional(), + storeOwner: StoreInfoStoreOwnerSchema.optional(), + type: zod.string().optional(), + // Admin API public display name for store-auth stores, or public plan handle for BP-backed stores. + plan: zod.string().optional(), + featurePreview: zod.string().optional(), + adminUrl: zod.string().optional(), + accessUrl: zod.string().optional(), + saveUrl: zod.string().optional(), + // Preapproved Admin API access scopes for preview stores. Preview stores aren't a logged-in + // experience, so there's no way to grant additional scopes later. + authScopes: zod.array(zod.string()).optional(), + }), + definitions: {StoreInfoStoreOwner: StoreInfoStoreOwnerSchema}, +}) + +export type StoreInfoStoreOwner = zod.infer +export type StoreInfoResult = InferJsonOutputSchema diff --git a/packages/store/src/cli/services/store/list.test.ts b/packages/store/src/cli/services/store/list.test.ts index 042bab375d1..f6c072416ea 100644 --- a/packages/store/src/cli/services/store/list.test.ts +++ b/packages/store/src/cli/services/store/list.test.ts @@ -1,5 +1,6 @@ import {listStores} from './list.js' import * as bpSource from './list/bp-source.js' +import {storeListJsonOutputSchema} from './list/types.js' import {describe, expect, test, vi} from 'vitest' import {ensureAuthenticatedBusinessPlatform} from '@shopify/cli-kit/node/session' import {AbortError} from '@shopify/cli-kit/node/error' @@ -46,9 +47,9 @@ describe('listStores', () => { expect(renderAutocompletePrompt).not.toHaveBeenCalled() expect(result).toEqual({ stores: [orgEntry], - source: 'organization', organization: {id: '1234', name: 'Acme'}, }) + expect(storeListJsonOutputSchema.validate(result)).toEqual(result) }) test('passes the requested store type to the source and echoes it in the result', async () => { @@ -142,7 +143,6 @@ describe('listStores', () => { expect(result).toEqual({ stores: [], - source: 'organization', notice: "Couldn't resolve a Shopify account for the current CLI session.", }) }) @@ -152,7 +152,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 1451a2aae5f..60eb88a6527 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 {type StoreTypeFilter} from './store-type.js' import {AbortError} from '@shopify/cli-kit/node/error' import {ensureAuthenticatedBusinessPlatform} from '@shopify/cli-kit/node/session' @@ -12,20 +12,19 @@ interface ListStoresOptions { storeType?: StoreTypeFilter } -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()) { @@ -49,7 +48,6 @@ export async function listStores(options: ListStoresOptions = {}): Promise { +describe('presentStoreListResult', () => { beforeEach(() => { mockAndCaptureOutput().clear() }) @@ -13,9 +13,8 @@ describe('writeStoreListResult', () => { test('renders organization context and rows with subdomain, name, type, plan, and created date', () => { const output = mockAndCaptureOutput() - writeStoreListResult( + presentStoreListResult( { - source: 'organization', organization, stores: [ { @@ -47,9 +46,8 @@ describe('writeStoreListResult', () => { test('renders the organization row and the store auth hint in a single info banner above the table', () => { const output = mockAndCaptureOutput() - writeStoreListResult( + presentStoreListResult( { - source: 'organization', organization, stores: [ { @@ -87,9 +85,8 @@ describe('writeStoreListResult', () => { test('leaves the plan column blank when the plan is unrecognized', () => { const output = mockAndCaptureOutput() - writeStoreListResult( + presentStoreListResult( { - source: 'organization', organization, stores: [ { @@ -111,9 +108,8 @@ describe('writeStoreListResult', () => { test('names the active store type filter in the headline', () => { const output = mockAndCaptureOutput() - writeStoreListResult( + presentStoreListResult( { - source: 'organization', organization, storeType: 'client-transfer', stores: [ @@ -136,7 +132,7 @@ describe('writeStoreListResult', () => { test('names the active store type filter in the empty state', () => { const output = mockAndCaptureOutput() - writeStoreListResult({source: 'organization', organization, storeType: 'dev', stores: []}, 'text') + presentStoreListResult({organization, storeType: 'dev', stores: []}, 'text') expect(output.info()).toContain('No dev stores found.') }) @@ -146,7 +142,7 @@ describe('writeStoreListResult', () => { const output = mockAndCaptureOutput() for (const storeType of storeTypeFilters) { - writeStoreListResult({source: 'organization', organization, storeType, stores: []}, 'text') + presentStoreListResult({organization, storeType, stores: []}, 'text') } // Keeps the assertion below from passing vacuously if the filter list is ever emptied. @@ -157,9 +153,8 @@ describe('writeStoreListResult', () => { test('renders the subdomain handle for non-myshopify hosts (local dev)', () => { const output = mockAndCaptureOutput() - writeStoreListResult( + presentStoreListResult( { - source: 'organization', organization, stores: [ { @@ -181,9 +176,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.", }, @@ -198,7 +192,7 @@ describe('writeStoreListResult', () => { test('renders the selected organization empty state in the same banner shape', () => { const output = mockAndCaptureOutput() - writeStoreListResult({source: 'organization', organization, stores: []}, 'text') + presentStoreListResult({organization, stores: []}, 'text') expect(trimmedLines(output.info())).toMatchInlineSnapshot(` "╭─ info ───────────────────────────────────────────────────────────────────────╮ @@ -218,7 +212,7 @@ describe('writeStoreListResult', () => { test('omits the organization row from the 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.') expect(output.info()).toContain('shopify store auth list') @@ -228,9 +222,8 @@ describe('writeStoreListResult', () => { test('emits a {stores, organization} JSON document on stdout', () => { const output = mockAndCaptureOutput() - writeStoreListResult( + presentStoreListResult( { - source: 'organization', organization, stores: [ { @@ -268,7 +261,7 @@ describe('writeStoreListResult', () => { test('includes the active store type filter in JSON output', () => { const output = mockAndCaptureOutput() - writeStoreListResult({source: 'organization', organization, storeType: 'dev', stores: []}, 'json') + presentStoreListResult({organization, storeType: 'dev', stores: []}, 'json') expect(JSON.parse(output.output())).toEqual({stores: [], organization, storeType: 'dev'}) }) @@ -276,9 +269,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.", }, @@ -308,11 +300,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') @@ -322,9 +314,8 @@ describe('writeStoreListResult', () => { test('names the active store type filter in the truncation warning', () => { const output = mockAndCaptureOutput() - writeStoreListResult( + presentStoreListResult( { - source: 'organization', organization, storeType: 'production', stores: [ diff --git a/packages/store/src/cli/services/store/list/result.ts b/packages/store/src/cli/services/store/list/result.ts index 0e73931ae8b..b0c5a9c7fab 100644 --- a/packages/store/src/cli/services/store/list/result.ts +++ b/packages/store/src/cli/services/store/list/result.ts @@ -1,5 +1,10 @@ import {STORE_LIST_LIMIT} from './constants.js' -import {type ListStoresResult, type StoreListEntry, type StoreListOrganization} from './types.js' +import { + storeListJsonOutputSchema, + type StoreListResult, + type StoreListEntry, + type StoreListOrganization, +} from './types.js' import {extractSubdomain, formatShortDate} from '../display.js' import {planLabel} from '../plan.js' import {storeTypeLabel, type StoreTypeFilter} from '../store-type.js' @@ -15,33 +20,21 @@ const STORE_AUTH_HINT: TokenItem = [ {char: '.'}, ] -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.storeType ? {storeType: result.storeType} : {}), - ...(result.notice ? {notice: result.notice} : {}), - ...(result.truncated ? {truncated: true} : {}), - }, - null, - 2, - ), - ) + outputResult(storeListJsonOutputSchema.encode(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 ${storeNounPhrase(result.storeType)}${organization}. More stores exist.` } @@ -52,7 +45,7 @@ function storeNounPhrase(storeType: StoreTypeFilter | undefined): string { return storeType ? `${storeType.replaceAll('-', ' ')} stores` : 'stores' } -function renderTextResult(result: ListStoresResult): void { +function renderTextResult(result: StoreListResult): void { renderInfo({ headline: textResultHeadline(result), customSections: [...organizationSections(result.organization), {body: STORE_AUTH_HINT}], @@ -63,7 +56,7 @@ function renderTextResult(result: ListStoresResult): void { } } -function textResultHeadline(result: ListStoresResult): string { +function textResultHeadline(result: StoreListResult): string { const stores = storeNounPhrase(result.storeType) if (result.stores.length > 0) return `Listing ${stores}.` // The notice explains on stderr why the session couldn't be resolved; this states the outcome. diff --git a/packages/store/src/cli/services/store/list/types.ts b/packages/store/src/cli/services/store/list/types.ts index 7ba7cdf47fb..64d4cc9f8e0 100644 --- a/packages/store/src/cli/services/store/list/types.ts +++ b/packages/store/src/cli/services/store/list/types.ts @@ -1,27 +1,38 @@ -import {type StoreTypeFilter} from '../store-type.js' +import {storeTypeFilters, type StoreTypeFilter} from '../store-type.js' +import {defineJsonOutputSchema, type InferJsonOutputSchema} from '@shopify/cli-kit/node/json-output-schema' +import {zod} from '@shopify/cli-kit/node/schema' -export interface StoreListEntry { - id?: string - store: string - createdAt: string - organizationId: string - organizationName: string - name?: string - type?: string - plan?: string -} +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(), + plan: zod.string().optional(), +}) -export interface StoreListOrganization { - id: string - name: string -} +const StoreListOrganizationSchema = zod.object({ + id: zod.string(), + name: zod.string(), +}) -export interface ListStoresResult { - stores: StoreListEntry[] - source: 'organization' - organization?: StoreListOrganization - // The `--type` filter the listing was narrowed to, echoed back so output can name it. - storeType?: StoreTypeFilter - notice?: string - truncated?: boolean -} +export const storeListJsonOutputSchema = defineJsonOutputSchema({ + name: 'StoreListResult', + schema: zod.object({ + stores: zod.array(StoreListEntrySchema), + organization: StoreListOrganizationSchema.optional(), + storeType: zod.enum(storeTypeFilters as [StoreTypeFilter, ...StoreTypeFilter[]]).optional(), + notice: zod.string().optional(), + truncated: zod.boolean().optional(), + }), + definitions: { + StoreListEntry: StoreListEntrySchema, + StoreListOrganization: StoreListOrganizationSchema, + }, +}) + +export type StoreListEntry = zod.infer +export type StoreListOrganization = zod.infer +export type StoreListResult = InferJsonOutputSchema