Skip to content

Commit 78dbd57

Browse files
Add typed JSON output to store inspection
1 parent 0e5a328 commit 78dbd57

14 files changed

Lines changed: 180 additions & 110 deletions

File tree

‎packages/cli/README.md‎

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4204,6 +4204,33 @@ DESCRIPTION
42044204

42054205
Use `--json` for machine-readable output.
42064206

4207+
Output from `--json` conforms to the `StoreInfoResult` schema.
4208+
4209+
Use `--json-schema` to print the schema directly:
4210+
4211+
```ts
4212+
interface StoreInfoResult {
4213+
id?: string
4214+
displayName?: string
4215+
subdomain: string
4216+
organizationId?: string
4217+
organizationName?: string
4218+
storeOwner?: StoreInfoStoreOwner
4219+
type?: string
4220+
plan?: string
4221+
featurePreview?: string
4222+
adminUrl?: string
4223+
accessUrl?: string
4224+
saveUrl?: string
4225+
authScopes?: string[]
4226+
}
4227+
4228+
interface StoreInfoStoreOwner {
4229+
name?: string
4230+
email?: string
4231+
}
4232+
```
4233+
42074234
EXAMPLES
42084235
$ shopify store info --store shop.myshopify.com
42094236

@@ -4250,6 +4277,34 @@ DESCRIPTION
42504277

42514278
Run `shopify organization list` to find organization IDs.
42524279

4280+
Output from `--json` conforms to the `StoreListResult` schema.
4281+
4282+
Use `--json-schema` to print the schema directly:
4283+
4284+
```ts
4285+
interface StoreListResult {
4286+
stores: StoreListEntry[]
4287+
organization?: StoreListOrganization
4288+
notice?: string
4289+
truncated?: boolean
4290+
}
4291+
4292+
interface StoreListEntry {
4293+
id?: string
4294+
store: string
4295+
createdAt: string
4296+
organizationId: string
4297+
organizationName: string
4298+
name?: string
4299+
type?: string
4300+
}
4301+
4302+
interface StoreListOrganization {
4303+
id: string
4304+
name: string
4305+
}
4306+
```
4307+
42534308
EXAMPLES
42544309
$ shopify store list
42554310

‎packages/cli/oclif.manifest.json‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7803,7 +7803,7 @@
78037803
"args": {
78047804
},
78057805
"customPluginName": "@shopify/store",
7806-
"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.",
7806+
"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 schema directly:\n\n```ts\ninterface StoreInfoResult {\n id?: string\n displayName?: string\n subdomain: string\n organizationId?: string\n organizationName?: string\n storeOwner?: StoreInfoStoreOwner\n type?: string\n plan?: string\n featurePreview?: string\n adminUrl?: string\n accessUrl?: string\n saveUrl?: string\n authScopes?: string[]\n}\n\ninterface StoreInfoStoreOwner {\n name?: string\n email?: string\n}\n```",
78077807
"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.",
78087808
"examples": [
78097809
"<%= config.bin %> <%= command.id %> --store shop.myshopify.com",
@@ -7869,7 +7869,7 @@
78697869
"args": {
78707870
},
78717871
"customPluginName": "@shopify/store",
7872-
"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.",
7872+
"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 schema directly:\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```",
78737873
"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.",
78747874
"examples": [
78757875
"<%= config.bin %> <%= command.id %>",

‎packages/store/src/cli/commands/store/info.test.ts‎

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import StoreInfo from './info.js'
22
import {getStoreInfo} from '../../services/store/info/index.js'
33
import {renderStoreInfoResult} from '../../services/store/info/result.js'
4+
import {storeInfoJsonOutputSchema} from '../../services/store/info/types.js'
45
import {beforeEach, describe, expect, test, vi} from 'vitest'
56

67
vi.mock('../../services/store/info/index.js')
@@ -18,9 +19,7 @@ describe('store info command', () => {
1819
test('passes the store flag through to the service', async () => {
1920
await StoreInfo.run(['--store', 'shop.myshopify.com'])
2021

21-
expect(getStoreInfo).toHaveBeenCalledWith({
22-
store: 'shop.myshopify.com',
23-
})
22+
expect(getStoreInfo).toHaveBeenCalledWith({store: 'shop.myshopify.com'})
2423
expect(renderStoreInfoResult).toHaveBeenCalledWith(
2524
expect.objectContaining({subdomain: 'shop.myshopify.com'}),
2625
'text',
@@ -37,4 +36,8 @@ describe('store info command', () => {
3736
expect(StoreInfo.flags.store).toBeDefined()
3837
expect(StoreInfo.flags.json).toBeDefined()
3938
})
39+
40+
test('exposes the JSON output schema', () => {
41+
expect(StoreInfo.jsonOutputSchema).toBe(storeInfoJsonOutputSchema)
42+
})
4043
})

‎packages/store/src/cli/commands/store/info.ts‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import {getStoreInfo} from '../../services/store/info/index.js'
22
import {renderStoreInfoResult} from '../../services/store/info/result.js'
3+
import {storeInfoJsonOutputSchema} from '../../services/store/info/types.js'
34
import StoreCommand from '../../utilities/store-command.js'
45
import {storeFlags} from '../../flags.js'
56
import {globalFlags, jsonFlag} from '@shopify/cli-kit/node/cli'
@@ -26,6 +27,10 @@ Use \`--json\` for machine-readable output.`
2627
store: storeFlags.store,
2728
}
2829

30+
static get jsonOutputSchema() {
31+
return storeInfoJsonOutputSchema
32+
}
33+
2934
public async run(): Promise<void> {
3035
const {flags} = await this.parse(StoreInfo)
3136

Lines changed: 11 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import StoreList from './list.js'
22
import {listStores} from '../../services/store/list.js'
3-
import {writeStoreListResult} from '../../services/store/list/result.js'
3+
import {presentStoreListResult} from '../../services/store/list/result.js'
4+
import {storeListJsonOutputSchema} from '../../services/store/list/types.js'
45
import {describe, expect, test, vi} from 'vitest'
56

67
vi.mock('../../services/store/list.js')
@@ -9,34 +10,38 @@ vi.mock('../../services/store/attribution.js')
910

1011
describe('store list command', () => {
1112
test('runs the list service and writes text output by default', async () => {
12-
vi.mocked(listStores).mockResolvedValue({stores: [], source: 'organization'})
13+
vi.mocked(listStores).mockResolvedValue({stores: []})
1314

1415
await StoreList.run([])
1516

1617
expect(listStores).toHaveBeenCalledWith({organizationId: undefined})
17-
expect(writeStoreListResult).toHaveBeenCalledWith({stores: [], source: 'organization'}, 'text')
18+
expect(presentStoreListResult).toHaveBeenCalledWith({stores: []}, 'text')
1819
})
1920

2021
test('passes the organization id through to the list service', async () => {
21-
vi.mocked(listStores).mockResolvedValue({stores: [], source: 'organization'})
22+
vi.mocked(listStores).mockResolvedValue({stores: []})
2223

2324
await StoreList.run(['--organization-id', '1234567'])
2425

2526
expect(listStores).toHaveBeenCalledWith({organizationId: 1234567})
2627
})
2728

2829
test('writes json output when requested', async () => {
29-
vi.mocked(listStores).mockResolvedValue({stores: [], source: 'organization'})
30+
vi.mocked(listStores).mockResolvedValue({stores: []})
3031

3132
await StoreList.run(['--json'])
3233

3334
expect(listStores).toHaveBeenCalledWith({organizationId: undefined})
34-
expect(writeStoreListResult).toHaveBeenCalledWith({stores: [], source: 'organization'}, 'json')
35+
expect(presentStoreListResult).toHaveBeenCalledWith({stores: []}, 'json')
3536
})
3637

3738
test('defines the expected flags', () => {
3839
expect(StoreList.flags.json).toBeDefined()
3940
expect(StoreList.flags['organization-id']).toBeDefined()
4041
expect(StoreList.flags).not.toHaveProperty('from')
4142
})
43+
44+
test('exposes the JSON output schema', () => {
45+
expect(StoreList.jsonOutputSchema).toBe(storeListJsonOutputSchema)
46+
})
4247
})

‎packages/store/src/cli/commands/store/list.ts‎

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import {listStores} from '../../services/store/list.js'
2-
import {writeStoreListResult} from '../../services/store/list/result.js'
2+
import {presentStoreListResult} from '../../services/store/list/result.js'
3+
import {storeListJsonOutputSchema} from '../../services/store/list/types.js'
34
import {storeFlags} from '../../flags.js'
45
import StoreCommand from '../../utilities/store-command.js'
56
import {globalFlags, jsonFlag} from '@shopify/cli-kit/node/cli'
@@ -31,10 +32,14 @@ Run \`<%= config.bin %> organization list\` to find organization IDs.`
3132
}),
3233
}
3334

35+
static get jsonOutputSchema() {
36+
return storeListJsonOutputSchema
37+
}
38+
3439
public async run(): Promise<void> {
3540
const {flags} = await this.parse(StoreList)
3641
const result = await listStores({organizationId: flags['organization-id']})
3742

38-
writeStoreListResult(result, flags.json ? 'json' : 'text')
43+
presentStoreListResult(result, flags.json ? 'json' : 'text')
3944
}
4045
}

‎packages/store/src/cli/services/store/info/index.test.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import {getStoreInfo} from './index.js'
2+
import {storeInfoJsonOutputSchema} from './types.js'
23
import {StoreLookupStoreNotFoundError, fetchDestinationsContext} from '../../../utilities/store-lookup/destinations.js'
34
import {fetchOrganizationShop} from '../../../utilities/store-lookup/organization-shop.js'
45
import {STORE_AUTH_APP_CLIENT_ID} from '../auth/config.js'
@@ -149,6 +150,7 @@ describe('getStoreInfo', () => {
149150
featurePreview: 'extended_variants',
150151
adminUrl: 'https://admin.shopify.com/store/shop',
151152
})
153+
expect(storeInfoJsonOutputSchema.validate(result)).toEqual(result)
152154
})
153155

154156
test('returns fresh access and save URLs for locally stored preview stores', async () => {

‎packages/store/src/cli/services/store/info/result.ts‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,13 @@
1+
import {storeInfoJsonOutputSchema, type StoreInfoResult, type StoreInfoStoreOwner} from './types.js'
12
import {outputResult} from '@shopify/cli-kit/node/output'
23
import {renderInfo, type InlineToken, type LinkToken} from '@shopify/cli-kit/node/ui'
34
import {capitalizeWords} from '@shopify/cli-kit/common/string'
4-
import type {StoreInfoResult, StoreInfoStoreOwner} from './types.js'
55

66
type StoreInfoOutputFormat = 'text' | 'json'
77

88
export function renderStoreInfoResult(result: StoreInfoResult, format: StoreInfoOutputFormat): void {
99
if (format === 'json') {
10-
outputResult(JSON.stringify(result, null, 2))
10+
outputResult(storeInfoJsonOutputSchema.encode(result))
1111
return
1212
}
1313
const actions = storeActions(result)
Lines changed: 32 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1,24 +1,33 @@
1-
export interface StoreInfoStoreOwner {
2-
name?: string
3-
email?: string
4-
}
1+
import {defineJsonOutputSchema, type InferJsonOutputSchema} from '@shopify/cli-kit/node/json-output-schema'
2+
import {zod} from '@shopify/cli-kit/node/schema'
53

6-
export interface StoreInfoResult {
7-
id?: string
8-
displayName?: string
9-
subdomain: string
10-
organizationId?: string
11-
organizationName?: string
12-
storeOwner?: StoreInfoStoreOwner
13-
type?: string
14-
// Admin API public display name for store-auth stores, or public plan handle for BP-backed stores.
15-
plan?: string
16-
featurePreview?: string
17-
adminUrl?: string
18-
accessUrl?: string
19-
saveUrl?: string
20-
// Preapproved Admin API access scopes for the store (currently only preview stores, which
21-
// cache the scopes granted at creation time). Preview stores aren't a logged-in experience, so
22-
// there's no way to grant additional scopes later.
23-
authScopes?: string[]
24-
}
4+
const StoreInfoStoreOwnerSchema = zod.object({
5+
name: zod.string().optional(),
6+
email: zod.string().optional(),
7+
})
8+
9+
export const storeInfoJsonOutputSchema = defineJsonOutputSchema({
10+
name: 'StoreInfoResult',
11+
schema: zod.object({
12+
id: zod.string().optional(),
13+
displayName: zod.string().optional(),
14+
subdomain: zod.string(),
15+
organizationId: zod.string().optional(),
16+
organizationName: zod.string().optional(),
17+
storeOwner: StoreInfoStoreOwnerSchema.optional(),
18+
type: zod.string().optional(),
19+
// Admin API public display name for store-auth stores, or public plan handle for BP-backed stores.
20+
plan: zod.string().optional(),
21+
featurePreview: zod.string().optional(),
22+
adminUrl: zod.string().optional(),
23+
accessUrl: zod.string().optional(),
24+
saveUrl: zod.string().optional(),
25+
// Preapproved Admin API access scopes for preview stores. Preview stores aren't a logged-in
26+
// experience, so there's no way to grant additional scopes later.
27+
authScopes: zod.array(zod.string()).optional(),
28+
}),
29+
definitions: {StoreInfoStoreOwner: StoreInfoStoreOwnerSchema},
30+
})
31+
32+
export type StoreInfoStoreOwner = zod.infer<typeof StoreInfoStoreOwnerSchema>
33+
export type StoreInfoResult = InferJsonOutputSchema<typeof storeInfoJsonOutputSchema>

‎packages/store/src/cli/services/store/list.test.ts‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import {listStores} from './list.js'
22
import * as bpSource from './list/bp-source.js'
3+
import {storeListJsonOutputSchema} from './list/types.js'
34
import {describe, expect, test, vi} from 'vitest'
45
import {ensureAuthenticatedBusinessPlatform} from '@shopify/cli-kit/node/session'
56
import {AbortError} from '@shopify/cli-kit/node/error'
@@ -42,9 +43,9 @@ describe('listStores', () => {
4243
expect(renderAutocompletePrompt).not.toHaveBeenCalled()
4344
expect(result).toEqual({
4445
stores: [orgEntry],
45-
source: 'organization',
4646
organization: {id: '1234', name: 'Acme'},
4747
})
48+
expect(storeListJsonOutputSchema.validate(result)).toEqual(result)
4849
})
4950

5051
test('uses the requested organization id when provided', async () => {
@@ -107,7 +108,6 @@ describe('listStores', () => {
107108

108109
expect(result).toEqual({
109110
stores: [],
110-
source: 'organization',
111111
notice: "Couldn't resolve a Shopify account for the current CLI session.",
112112
})
113113
})
@@ -117,7 +117,7 @@ describe('listStores', () => {
117117

118118
const result = await listStores()
119119

120-
expect(result).toEqual({stores: [], source: 'organization'})
120+
expect(result).toEqual({stores: []})
121121
})
122122

123123
test('propagates store listing failures', async () => {

0 commit comments

Comments
 (0)