Skip to content

Commit 3b51bcf

Browse files
Add typed JSON output to theme list
1 parent cb7a4db commit 3b51bcf

11 files changed

Lines changed: 434 additions & 106 deletions

File tree

‎packages/cli/README.md‎

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7834,6 +7834,86 @@ FLAGS
78347834

78357835
DESCRIPTION
78367836
Lists the themes in your store, along with their IDs and statuses.
7837+
7838+
Output from `--json` conforms to the `ThemeListResult` schema.
7839+
7840+
Use `--json-schema` to print the result, error, and event schemas.
7841+
7842+
```json
7843+
{
7844+
"anyOf": [
7845+
{
7846+
"$ref": "#/definitions/ThemeListEnvironment/properties/result"
7847+
},
7848+
{
7849+
"type": "object",
7850+
"properties": {
7851+
"environments": {
7852+
"type": "array",
7853+
"items": {
7854+
"$ref": "#/definitions/ThemeListEnvironment"
7855+
}
7856+
}
7857+
},
7858+
"required": [
7859+
"environments"
7860+
],
7861+
"additionalProperties": false
7862+
}
7863+
],
7864+
"title": "ThemeListResult",
7865+
"definitions": {
7866+
"ThemeListTheme": {
7867+
"type": "object",
7868+
"properties": {
7869+
"id": {
7870+
"type": "number"
7871+
},
7872+
"name": {
7873+
"type": "string"
7874+
},
7875+
"processing": {
7876+
"type": "boolean"
7877+
},
7878+
"createdAtRuntime": {
7879+
"type": "boolean"
7880+
},
7881+
"role": {
7882+
"type": "string"
7883+
}
7884+
},
7885+
"required": [
7886+
"id",
7887+
"name",
7888+
"processing",
7889+
"createdAtRuntime",
7890+
"role"
7891+
],
7892+
"additionalProperties": false
7893+
},
7894+
"ThemeListEnvironment": {
7895+
"type": "object",
7896+
"properties": {
7897+
"environment": {
7898+
"type": "string"
7899+
},
7900+
"result": {
7901+
"type": "array",
7902+
"items": {
7903+
"$ref": "#/definitions/ThemeListTheme"
7904+
}
7905+
}
7906+
},
7907+
"required": [
7908+
"environment",
7909+
"result"
7910+
],
7911+
"additionalProperties": false
7912+
}
7913+
},
7914+
"$schema": "http://json-schema.org/draft-07/schema#"
7915+
}
7916+
```
78377917
```
78387918
78397919
## `shopify theme metafields pull`

‎packages/cli/oclif.manifest.json‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10370,7 +10370,8 @@
1037010370
"args": {
1037110371
},
1037210372
"customPluginName": "@shopify/theme",
10373-
"description": "Lists the themes in your store, along with their IDs and statuses.",
10373+
"description": "Lists the themes in your store, along with their IDs and statuses.\n\nOutput from `--json` conforms to the `ThemeListResult` schema.\n\nUse `--json-schema` to print the result, error, and event schemas.\n\n```json\n{\n \"anyOf\": [\n {\n \"$ref\": \"#/definitions/ThemeListEnvironment/properties/result\"\n },\n {\n \"type\": \"object\",\n \"properties\": {\n \"environments\": {\n \"type\": \"array\",\n \"items\": {\n \"$ref\": \"#/definitions/ThemeListEnvironment\"\n }\n }\n },\n \"required\": [\n \"environments\"\n ],\n \"additionalProperties\": false\n }\n ],\n \"title\": \"ThemeListResult\",\n \"definitions\": {\n \"ThemeListTheme\": {\n \"type\": \"object\",\n \"properties\": {\n \"id\": {\n \"type\": \"number\"\n },\n \"name\": {\n \"type\": \"string\"\n },\n \"processing\": {\n \"type\": \"boolean\"\n },\n \"createdAtRuntime\": {\n \"type\": \"boolean\"\n },\n \"role\": {\n \"type\": \"string\"\n }\n },\n \"required\": [\n \"id\",\n \"name\",\n \"processing\",\n \"createdAtRuntime\",\n \"role\"\n ],\n \"additionalProperties\": false\n },\n \"ThemeListEnvironment\": {\n \"type\": \"object\",\n \"properties\": {\n \"environment\": {\n \"type\": \"string\"\n },\n \"result\": {\n \"type\": \"array\",\n \"items\": {\n \"$ref\": \"#/definitions/ThemeListTheme\"\n }\n }\n },\n \"required\": [\n \"environment\",\n \"result\"\n ],\n \"additionalProperties\": false\n }\n },\n \"$schema\": \"http://json-schema.org/draft-07/schema#\"\n}\n```",
10374+
"descriptionWithMarkdown": "Lists the themes in your store, along with their IDs and statuses.",
1037410375
"enableJsonFlag": false,
1037510376
"flags": {
1037610377
"auth-alias": {

‎packages/eslint-plugin-cli/rules/json-output-command-exceptions.js‎

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -64,7 +64,6 @@ const commandExceptions = [
6464
'packages/theme/src/cli/commands/theme/delete.ts',
6565
'packages/theme/src/cli/commands/theme/duplicate.ts',
6666
'packages/theme/src/cli/commands/theme/init.ts',
67-
'packages/theme/src/cli/commands/theme/list.ts',
6867
'packages/theme/src/cli/commands/theme/metafields/pull.ts',
6968
'packages/theme/src/cli/commands/theme/open.ts',
7069
'packages/theme/src/cli/commands/theme/package.ts',
Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
import List from './list.js'
2+
import {list} from '../../services/list.js'
3+
import {themeListJsonOutputSchema} from '../../services/list/types.js'
4+
import {captureStandardStreams} from '../../utilities/testing/streams.js'
5+
import {Config} from '@oclif/core'
6+
import {afterEach, expect, test, vi} from 'vitest'
7+
import {ensureAuthenticatedThemes} from '@shopify/cli-kit/node/session'
8+
import {mockAndCaptureOutput} from '@shopify/cli-kit/node/testing/output'
9+
10+
vi.mock('../../services/list.js')
11+
vi.mock('@shopify/cli-kit/node/session')
12+
vi.mock('@shopify/cli-kit/node/environments')
13+
14+
const theme = {id: 1, name: 'Dawn', processing: false, createdAtRuntime: false, role: 'live'}
15+
16+
afterEach(() => vi.unstubAllEnvs())
17+
18+
test('exposes the schema, preserves false fields and rejects invalid themes', () => {
19+
expect(List.jsonOutputSchema).toBe(themeListJsonOutputSchema)
20+
expect(List.flags.json).toBeDefined()
21+
expect(JSON.parse(themeListJsonOutputSchema.encode([theme]))).toEqual([theme])
22+
expect(themeListJsonOutputSchema.encode([])).toBe('[]')
23+
expect(() => themeListJsonOutputSchema.validate([{...theme, id: '1'}])).toThrow()
24+
expect(() => themeListJsonOutputSchema.validate([{...theme, processing: null}])).toThrow()
25+
})
26+
27+
test.each(['single', 'multiple', 'partial failure', 'total failure'])(
28+
'writes one final document to stdout for %s environments',
29+
async (mode) => {
30+
vi.stubEnv('SHOPIFY_UNIT_TEST', 'false')
31+
vi.resetModules()
32+
const {default: StreamList} = await import('./list.js')
33+
const {list: listService} = await import('../../services/list.js')
34+
const {ensureAuthenticatedThemes: authenticate} = await import('@shopify/cli-kit/node/session')
35+
const {loadEnvironment: load} = await import('@shopify/cli-kit/node/environments')
36+
const {runWithCommandEventsForCommand} = await import('@shopify/cli-kit/node/command-events')
37+
const {Config: StreamConfig} = await import('@oclif/core')
38+
const config = new StreamConfig({root: __dirname})
39+
await config.load()
40+
vi.mocked(authenticate).mockImplementation(async (store) => ({storeFqdn: store, token: 'token'}))
41+
vi.mocked(load).mockImplementation(async (environment) => ({
42+
store: `${environment}.myshopify.com`,
43+
password: 'token',
44+
}))
45+
vi.mocked(listService).mockImplementation(async (_flags, session) => {
46+
if (mode === 'total failure' || (mode === 'partial failure' && session.storeFqdn.startsWith('first'))) {
47+
throw new Error('Fetch failed')
48+
}
49+
// Complete in reverse order to prove that completion order does not affect output.
50+
if (session.storeFqdn.startsWith('first')) await new Promise((resolve) => setTimeout(resolve, 10))
51+
return [theme]
52+
})
53+
const streams = captureStandardStreams()
54+
try {
55+
const args =
56+
mode === 'single'
57+
? ['--store=single.myshopify.com', '--password=token']
58+
: ['--environment=first', '--environment=second']
59+
const argv = [...args, '--json']
60+
await runWithCommandEventsForCommand(argv, () => new StreamList(argv, config).run())
61+
} finally {
62+
streams.restore()
63+
}
64+
let successfulEnvironments = ['first', 'second']
65+
if (mode === 'total failure') successfulEnvironments = []
66+
if (mode === 'partial failure') successfulEnvironments = ['second']
67+
const expected =
68+
mode === 'single'
69+
? [theme]
70+
: {
71+
environments: successfulEnvironments.map((environment) => ({environment, result: [theme]})),
72+
}
73+
expect(streams.stdout()).toBe(`${JSON.stringify(expected, null, 2)}\n`)
74+
expect(JSON.parse(streams.stdout())).toEqual(expected)
75+
if (mode.includes('failure')) {
76+
expect(streams.stderr()).toContain('Fetch failed')
77+
const events = streams
78+
.stderr()
79+
.trim()
80+
.split('\n')
81+
.map((line) => JSON.parse(line))
82+
expect(events.every((event) => event.type === 'diagnostic')).toBe(true)
83+
} else expect(streams.stderr()).toBe('')
84+
},
85+
)
86+
87+
test('propagates a single-environment failure without producing a result', async () => {
88+
const output = mockAndCaptureOutput()
89+
const config = new Config({root: __dirname})
90+
await config.load()
91+
vi.mocked(ensureAuthenticatedThemes).mockResolvedValue({storeFqdn: 'shop.myshopify.com', token: 'token'})
92+
vi.mocked(list).mockRejectedValue(new Error('Fetch failed'))
93+
await expect(new List(['--store=shop.myshopify.com', '--password=token', '--json'], config).run()).rejects.toThrow(
94+
'Fetch failed',
95+
)
96+
expect(output.output()).toBe('')
97+
})

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

Lines changed: 25 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,9 @@ import {ALLOWED_ROLES, Role} from '../../utilities/theme-selector/fetch.js'
22
import {themeFlags} from '../../flags.js'
33
import ThemeCommand from '../../utilities/theme-command.js'
44
import {list} from '../../services/list.js'
5+
import {renderThemeListResult} from '../../services/list/result.js'
6+
import {themeListJsonOutputSchema} from '../../services/list/types.js'
7+
import {outputResult} from '@shopify/cli-kit/node/output'
58
import {Flags} from '@oclif/core'
69
import {globalFlags, jsonFlag} from '@shopify/cli-kit/node/cli'
710
import {OutputFlags} from '@oclif/core/interfaces'
@@ -10,7 +13,13 @@ import {AdminSession} from '@shopify/cli-kit/node/session'
1013
type ListFlags = OutputFlags<typeof List.flags>
1114

1215
export default class List extends ThemeCommand {
13-
static description = 'Lists the themes in your store, along with their IDs and statuses.'
16+
static get jsonOutputSchema() {
17+
return themeListJsonOutputSchema
18+
}
19+
20+
static descriptionWithMarkdown = 'Lists the themes in your store, along with their IDs and statuses.'
21+
22+
static description = this.descriptionForHelp()
1423

1524
static flags = {
1625
...globalFlags,
@@ -33,7 +42,20 @@ export default class List extends ThemeCommand {
3342

3443
static multiEnvironmentsFlags = ['store', 'password']
3544

36-
async command(flags: ListFlags, adminSession: AdminSession) {
37-
await list(flags, adminSession)
45+
async command(flags: ListFlags, adminSession: AdminSession, multiEnvironment = false) {
46+
const result = await list(flags, adminSession)
47+
if (flags.json && multiEnvironment) return result
48+
renderThemeListResult(result, flags.json ? 'json' : 'text', {
49+
store: adminSession.storeFqdn,
50+
environment: flags.environment,
51+
})
52+
}
53+
54+
protected collectsEnvironmentResults(flags: {json?: boolean}): boolean {
55+
return Boolean(flags.json)
56+
}
57+
58+
protected renderEnvironmentResults(environments: {environment: string; result: unknown}[]): void {
59+
outputResult(themeListJsonOutputSchema.encode(themeListJsonOutputSchema.validate({environments})))
3860
}
3961
}

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

Lines changed: 12 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import {getDevelopmentTheme} from './local-storage.js'
22
import {list} from './list.js'
3+
import {renderThemeListResult} from './list/result.js'
34
import {fetchStoreThemes} from '../utilities/theme-selector/fetch.js'
45
import {Theme} from '@shopify/cli-kit/node/themes/types'
56
import {renderInfo} from '@shopify/cli-kit/node/ui'
@@ -22,16 +23,16 @@ describe('list', () => {
2223
const developmentThemeId = 5
2324
const hostThemeId = 6
2425
vi.mocked(fetchStoreThemes).mockResolvedValue([
25-
{id: 1, name: 'Theme 1', role: 'live'},
26-
{id: 2, name: 'Theme 2', role: ''},
26+
{id: 1, name: 'Theme 1', processing: false, createdAtRuntime: false, role: 'live'},
27+
{id: 2, name: 'Theme 2', processing: false, createdAtRuntime: false, role: ''},
2728
{id: 3, name: 'Theme 3', role: 'development'},
2829
{id: developmentThemeId, name: 'Theme 5', role: 'development'},
2930
{id: hostThemeId, name: 'Theme 6', role: 'development'},
3031
] as Theme[])
3132
vi.mocked(getDevelopmentTheme).mockReturnValue(developmentThemeId.toString())
3233
vi.mocked(getHostTheme).mockReturnValue(hostThemeId.toString())
3334

34-
await list({json: false}, session)
35+
renderThemeListResult(await list({}, session), 'text', {store: session.storeFqdn})
3536

3637
expect(renderInfo).toHaveBeenCalledWith({
3738
customSections: [
@@ -61,7 +62,7 @@ describe('list', () => {
6162
{id: 5, name: 'Theme 5', role: 'development'},
6263
] as Theme[])
6364

64-
await list({role: 'live', name: '*eMe 3*', json: false}, session)
65+
renderThemeListResult(await list({role: 'live', name: '*eMe 3*'}, session), 'text', {store: session.storeFqdn})
6566

6667
expect(renderInfo).toHaveBeenCalledWith({
6768
customSections: [
@@ -83,22 +84,26 @@ describe('list', () => {
8384
const mockOutput = mockAndCaptureOutput()
8485

8586
vi.mocked(fetchStoreThemes).mockResolvedValue([
86-
{id: 1, name: 'Theme 1', role: 'live'},
87-
{id: 2, name: 'Theme 2', role: ''},
87+
{id: 1, name: 'Theme 1', processing: false, createdAtRuntime: false, role: 'live'},
88+
{id: 2, name: 'Theme 2', processing: false, createdAtRuntime: false, role: ''},
8889
] as Theme[])
8990

90-
await list({json: true}, session)
91+
renderThemeListResult(await list({}, session), 'json', {store: session.storeFqdn})
9192

9293
expect(mockOutput.info()).toMatchInlineSnapshot(`
9394
"[
9495
{
9596
"id": 1,
9697
"name": "Theme 1",
98+
"processing": false,
99+
"createdAtRuntime": false,
97100
"role": "live"
98101
},
99102
{
100103
"id": 2,
101104
"name": "Theme 2",
105+
"processing": false,
106+
"createdAtRuntime": false,
102107
"role": ""
103108
}
104109
]"
Lines changed: 3 additions & 53 deletions
Original file line numberDiff line numberDiff line change
@@ -1,30 +1,15 @@
1-
import {getDevelopmentTheme} from './local-storage.js'
1+
import {ThemeListResult} from './list/types.js'
22
import {Filter, FilterProps, filterThemes} from '../utilities/theme-selector/filter.js'
33
import {ALLOWED_ROLES, fetchStoreThemes, Role} from '../utilities/theme-selector/fetch.js'
4-
import {InlineToken, renderInfo} from '@shopify/cli-kit/node/ui'
54
import {AdminSession} from '@shopify/cli-kit/node/session'
6-
import {getHostTheme} from '@shopify/cli-kit/node/themes/conf'
7-
import {outputResult} from '@shopify/cli-kit/node/output'
85

96
interface Options {
107
role?: Role
118
name?: string
129
id?: number
13-
json: boolean
14-
environment?: string
1510
}
1611

17-
function tabularSection(
18-
title: string,
19-
data: InlineToken[][],
20-
): {title: string; body: {tabularData: InlineToken[][]; firstColumnSubdued?: boolean}} {
21-
return {
22-
title,
23-
body: {tabularData: data},
24-
}
25-
}
26-
27-
export async function list(options: Options, adminSession: AdminSession) {
12+
export async function list(options: Options, adminSession: AdminSession): Promise<ThemeListResult> {
2813
const store = adminSession.storeFqdn
2914
const filter = new Filter({
3015
...ALLOWED_ROLES.reduce((roles: FilterProps, role) => {
@@ -35,44 +20,9 @@ export async function list(options: Options, adminSession: AdminSession) {
3520
})
3621

3722
let storeThemes = await fetchStoreThemes(adminSession)
38-
const developmentTheme = getDevelopmentTheme()
39-
const hostTheme = getHostTheme(store)
4023
if (filter.any()) {
4124
storeThemes = filterThemes(store, storeThemes, filter)
4225
}
4326

44-
if (options.json) {
45-
return outputResult(JSON.stringify(storeThemes, null, 2))
46-
}
47-
48-
const themes = storeThemes.map(({id, name, role}) => {
49-
let formattedRole = ''
50-
if (role) {
51-
formattedRole = `[${role}]`
52-
if ([developmentTheme, hostTheme].includes(`${id}`)) {
53-
formattedRole += ' [current]'
54-
}
55-
}
56-
return [name, formattedRole, `#${id}`]
57-
})
58-
59-
const tableData = [
60-
['name', 'role', 'id'],
61-
['───────────────────────────────', '──────────────────────', '──────────────'],
62-
...themes,
63-
]
64-
65-
renderInfo({
66-
customSections: [
67-
...(options.environment
68-
? [
69-
{
70-
title: `${store} theme library`,
71-
body: [{subdued: `Environment name: ${options.environment}`}],
72-
},
73-
]
74-
: []),
75-
tabularSection('', tableData),
76-
],
77-
})
27+
return storeThemes
7828
}

0 commit comments

Comments
 (0)