Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
80 changes: 80 additions & 0 deletions packages/cli/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8273,6 +8273,86 @@ FLAGS

DESCRIPTION
Lists the themes in your store, along with their IDs and statuses.

Use `--json-schema` to print the result, error, and event schemas.

Output from `--json` conforms to the `ThemeListResult` schema.

```json
{
"anyOf": [
{
"$ref": "#/definitions/ThemeListEnvironment/properties/result"
},
{
"type": "object",
"properties": {
"environments": {
"type": "array",
"items": {
"$ref": "#/definitions/ThemeListEnvironment"
}
}
},
"required": [
"environments"
],
"additionalProperties": false
}
],
"title": "ThemeListResult",
"definitions": {
"ThemeListTheme": {
"type": "object",
"properties": {
"id": {
"type": "number"
},
"name": {
"type": "string"
},
"processing": {
"type": "boolean"
},
"createdAtRuntime": {
"type": "boolean"
},
"role": {
"type": "string"
}
},
"required": [
"id",
"name",
"processing",
"createdAtRuntime",
"role"
],
"additionalProperties": false
},
"ThemeListEnvironment": {
"type": "object",
"properties": {
"environment": {
"type": "string"
},
"result": {
"type": "array",
"items": {
"$ref": "#/definitions/ThemeListTheme"
}
}
},
"required": [
"environment",
"result"
],
"additionalProperties": false
}
},
"$schema": "http://json-schema.org/draft-07/schema#"
}
```
```

## `shopify theme metafields pull`
Expand Down
3 changes: 2 additions & 1 deletion packages/cli/oclif.manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -10379,7 +10379,8 @@
"args": {
},
"customPluginName": "@shopify/theme",
"description": "Lists the themes in your store, along with their IDs and statuses.",
"description": "Lists the themes in your store, along with their IDs and statuses.\n\nUse `--json-schema` to print the result, error, and event schemas.\n\nOutput from `--json` conforms to the `ThemeListResult` schema.\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```",
"descriptionWithMarkdown": "Lists the themes in your store, along with their IDs and statuses.",
"enableJsonFlag": false,
"flags": {
"auth-alias": {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,6 @@ const commandExceptions = [
'packages/theme/src/cli/commands/theme/delete.ts',
'packages/theme/src/cli/commands/theme/duplicate.ts',
'packages/theme/src/cli/commands/theme/init.ts',
'packages/theme/src/cli/commands/theme/list.ts',
'packages/theme/src/cli/commands/theme/metafields/pull.ts',
'packages/theme/src/cli/commands/theme/open.ts',
'packages/theme/src/cli/commands/theme/package.ts',
Expand Down
97 changes: 97 additions & 0 deletions packages/theme/src/cli/commands/theme/list.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
import List from './list.js'
import {list} from '../../services/list.js'
import {themeListJsonOutputSchema} from '../../services/list/types.js'
import {captureStandardStreams} from '../../utilities/testing/streams.js'
import {Config} from '@oclif/core'
import {afterEach, expect, test, vi} from 'vitest'
import {ensureAuthenticatedThemes} from '@shopify/cli-kit/node/session'
import {mockAndCaptureOutput} from '@shopify/cli-kit/node/testing/output'

vi.mock('../../services/list.js')
vi.mock('@shopify/cli-kit/node/session')
vi.mock('@shopify/cli-kit/node/environments')

const theme = {id: 1, name: 'Dawn', processing: false, createdAtRuntime: false, role: 'live'}

afterEach(() => vi.unstubAllEnvs())

test('exposes the schema, preserves false fields and rejects invalid themes', () => {
expect(List.jsonOutputSchema).toBe(themeListJsonOutputSchema)
expect(List.flags.json).toBeDefined()
expect(JSON.parse(themeListJsonOutputSchema.encode([theme]))).toEqual([theme])
expect(themeListJsonOutputSchema.encode([])).toBe('[]')
expect(() => themeListJsonOutputSchema.validate([{...theme, id: '1'}])).toThrow()
expect(() => themeListJsonOutputSchema.validate([{...theme, processing: null}])).toThrow()
})

test.each(['single', 'multiple', 'partial failure', 'total failure'])(
'writes one final document to stdout for %s environments',
async (mode) => {
vi.stubEnv('SHOPIFY_UNIT_TEST', 'false')
vi.resetModules()
const {default: StreamList} = await import('./list.js')
const {list: listService} = await import('../../services/list.js')
const {ensureAuthenticatedThemes: authenticate} = await import('@shopify/cli-kit/node/session')
const {loadEnvironment: load} = await import('@shopify/cli-kit/node/environments')
const {runWithCommandEventsForCommand} = await import('@shopify/cli-kit/node/command-events')
const {Config: StreamConfig} = await import('@oclif/core')
const config = new StreamConfig({root: __dirname})
await config.load()
vi.mocked(authenticate).mockImplementation(async (store) => ({storeFqdn: store, token: 'token'}))
vi.mocked(load).mockImplementation(async (environment) => ({
store: `${environment}.myshopify.com`,
password: 'token',
}))
vi.mocked(listService).mockImplementation(async (_flags, session) => {
if (mode === 'total failure' || (mode === 'partial failure' && session.storeFqdn.startsWith('first'))) {
throw new Error('Fetch failed')
}
// Complete in reverse order to prove that completion order does not affect output.
if (session.storeFqdn.startsWith('first')) await new Promise((resolve) => setTimeout(resolve, 10))
return [theme]
})
const streams = captureStandardStreams()
try {
const args =
mode === 'single'
? ['--store=single.myshopify.com', '--password=token']
: ['--environment=first', '--environment=second']
const argv = [...args, '--json']
await runWithCommandEventsForCommand(argv, () => new StreamList(argv, config).run())
} finally {
streams.restore()
}
let successfulEnvironments = ['first', 'second']
if (mode === 'total failure') successfulEnvironments = []
if (mode === 'partial failure') successfulEnvironments = ['second']
const expected =
mode === 'single'
? [theme]
: {
environments: successfulEnvironments.map((environment) => ({environment, result: [theme]})),
}
expect(streams.stdout()).toBe(`${JSON.stringify(expected, null, 2)}\n`)
expect(JSON.parse(streams.stdout())).toEqual(expected)
if (mode.includes('failure')) {
expect(streams.stderr()).toContain('Fetch failed')
const events = streams
.stderr()
.trim()
.split('\n')
.map((line) => JSON.parse(line))
expect(events.every((event) => event.type === 'diagnostic')).toBe(true)
} else expect(streams.stderr()).toBe('')
},
)

test('propagates a single-environment failure without producing a result', async () => {
const output = mockAndCaptureOutput()
const config = new Config({root: __dirname})
await config.load()
vi.mocked(ensureAuthenticatedThemes).mockResolvedValue({storeFqdn: 'shop.myshopify.com', token: 'token'})
vi.mocked(list).mockRejectedValue(new Error('Fetch failed'))
await expect(new List(['--store=shop.myshopify.com', '--password=token', '--json'], config).run()).rejects.toThrow(
'Fetch failed',
)
expect(output.output()).toBe('')
})
28 changes: 25 additions & 3 deletions packages/theme/src/cli/commands/theme/list.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,9 @@ import {ALLOWED_ROLES, Role} from '../../utilities/theme-selector/fetch.js'
import {themeFlags} from '../../flags.js'
import ThemeCommand from '../../utilities/theme-command.js'
import {list} from '../../services/list.js'
import {renderThemeListResult} from '../../services/list/result.js'
import {themeListJsonOutputSchema} from '../../services/list/types.js'
import {outputResult} from '@shopify/cli-kit/node/output'
import {Flags} from '@oclif/core'
import {globalFlags, jsonFlag} from '@shopify/cli-kit/node/cli'
import {OutputFlags} from '@oclif/core/interfaces'
Expand All @@ -10,7 +13,13 @@ import {AdminSession} from '@shopify/cli-kit/node/session'
type ListFlags = OutputFlags<typeof List.flags>

export default class List extends ThemeCommand {
static description = 'Lists the themes in your store, along with their IDs and statuses.'
static get jsonOutputSchema() {
return themeListJsonOutputSchema
}

static descriptionWithMarkdown = 'Lists the themes in your store, along with their IDs and statuses.'

static description = this.descriptionForHelp()

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

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

async command(flags: ListFlags, adminSession: AdminSession) {
await list(flags, adminSession)
async command(flags: ListFlags, adminSession: AdminSession, multiEnvironment = false) {
const result = await list(flags, adminSession)
if (flags.json && multiEnvironment) return result
renderThemeListResult(result, flags.json ? 'json' : 'text', {
store: adminSession.storeFqdn,
environment: flags.environment,
})
}

protected collectsEnvironmentResults(flags: {json?: boolean}): boolean {
return Boolean(flags.json)
}

protected renderEnvironmentResults(environments: {environment: string; result: unknown}[]): void {
outputResult(themeListJsonOutputSchema.encode(themeListJsonOutputSchema.validate({environments})))
}
}
19 changes: 12 additions & 7 deletions packages/theme/src/cli/services/list.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import {getDevelopmentTheme} from './local-storage.js'
import {list} from './list.js'
import {renderThemeListResult} from './list/result.js'
import {fetchStoreThemes} from '../utilities/theme-selector/fetch.js'
import {Theme} from '@shopify/cli-kit/node/themes/types'
import {renderInfo} from '@shopify/cli-kit/node/ui'
Expand All @@ -22,16 +23,16 @@ describe('list', () => {
const developmentThemeId = 5
const hostThemeId = 6
vi.mocked(fetchStoreThemes).mockResolvedValue([
{id: 1, name: 'Theme 1', role: 'live'},
{id: 2, name: 'Theme 2', role: ''},
{id: 1, name: 'Theme 1', processing: false, createdAtRuntime: false, role: 'live'},
{id: 2, name: 'Theme 2', processing: false, createdAtRuntime: false, role: ''},
{id: 3, name: 'Theme 3', role: 'development'},
{id: developmentThemeId, name: 'Theme 5', role: 'development'},
{id: hostThemeId, name: 'Theme 6', role: 'development'},
] as Theme[])
vi.mocked(getDevelopmentTheme).mockReturnValue(developmentThemeId.toString())
vi.mocked(getHostTheme).mockReturnValue(hostThemeId.toString())

await list({json: false}, session)
renderThemeListResult(await list({}, session), 'text', {store: session.storeFqdn})

expect(renderInfo).toHaveBeenCalledWith({
customSections: [
Expand Down Expand Up @@ -61,7 +62,7 @@ describe('list', () => {
{id: 5, name: 'Theme 5', role: 'development'},
] as Theme[])

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

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

vi.mocked(fetchStoreThemes).mockResolvedValue([
{id: 1, name: 'Theme 1', role: 'live'},
{id: 2, name: 'Theme 2', role: ''},
{id: 1, name: 'Theme 1', processing: false, createdAtRuntime: false, role: 'live'},
{id: 2, name: 'Theme 2', processing: false, createdAtRuntime: false, role: ''},
] as Theme[])

await list({json: true}, session)
renderThemeListResult(await list({}, session), 'json', {store: session.storeFqdn})

expect(mockOutput.info()).toMatchInlineSnapshot(`
"[
{
"id": 1,
"name": "Theme 1",
"processing": false,
"createdAtRuntime": false,
"role": "live"
},
{
"id": 2,
"name": "Theme 2",
"processing": false,
"createdAtRuntime": false,
"role": ""
}
]"
Expand Down
56 changes: 3 additions & 53 deletions packages/theme/src/cli/services/list.ts
Original file line number Diff line number Diff line change
@@ -1,30 +1,15 @@
import {getDevelopmentTheme} from './local-storage.js'
import {ThemeListResult} from './list/types.js'
import {Filter, FilterProps, filterThemes} from '../utilities/theme-selector/filter.js'
import {ALLOWED_ROLES, fetchStoreThemes, Role} from '../utilities/theme-selector/fetch.js'
import {InlineToken, renderInfo} from '@shopify/cli-kit/node/ui'
import {AdminSession} from '@shopify/cli-kit/node/session'
import {getHostTheme} from '@shopify/cli-kit/node/themes/conf'
import {outputResult} from '@shopify/cli-kit/node/output'

interface Options {
role?: Role
name?: string
id?: number
json: boolean
environment?: string
}

function tabularSection(
title: string,
data: InlineToken[][],
): {title: string; body: {tabularData: InlineToken[][]; firstColumnSubdued?: boolean}} {
return {
title,
body: {tabularData: data},
}
}

export async function list(options: Options, adminSession: AdminSession) {
export async function list(options: Options, adminSession: AdminSession): Promise<ThemeListResult> {
const store = adminSession.storeFqdn
const filter = new Filter({
...ALLOWED_ROLES.reduce((roles: FilterProps, role) => {
Expand All @@ -35,44 +20,9 @@ export async function list(options: Options, adminSession: AdminSession) {
})

let storeThemes = await fetchStoreThemes(adminSession)
const developmentTheme = getDevelopmentTheme()
const hostTheme = getHostTheme(store)
if (filter.any()) {
storeThemes = filterThemes(store, storeThemes, filter)
}

if (options.json) {
return outputResult(JSON.stringify(storeThemes, null, 2))
}

const themes = storeThemes.map(({id, name, role}) => {
let formattedRole = ''
if (role) {
formattedRole = `[${role}]`
if ([developmentTheme, hostTheme].includes(`${id}`)) {
formattedRole += ' [current]'
}
}
return [name, formattedRole, `#${id}`]
})

const tableData = [
['name', 'role', 'id'],
['───────────────────────────────', '──────────────────────', '──────────────'],
...themes,
]

renderInfo({
customSections: [
...(options.environment
? [
{
title: `${store} theme library`,
body: [{subdued: `Environment name: ${options.environment}`}],
},
]
: []),
tabularSection('', tableData),
],
})
return storeThemes
}
Loading
Loading