From 6bb0975d5db2cb70c0c2a568fa2461097817a691 Mon Sep 17 00:00:00 2001 From: Gonzalo Riestra Date: Thu, 24 Sep 2026 11:12:42 +0200 Subject: [PATCH 1/5] Add typed JSON output to theme list --- packages/cli/README.md | 80 +++++++++++++++ packages/cli/oclif.manifest.json | 3 +- .../rules/json-output-command-exceptions.js | 1 - .../theme/src/cli/commands/theme/list.test.ts | 97 +++++++++++++++++++ packages/theme/src/cli/commands/theme/list.ts | 28 +++++- packages/theme/src/cli/services/list.test.ts | 19 ++-- packages/theme/src/cli/services/list.ts | 56 +---------- .../theme/src/cli/services/list/result.ts | 58 +++++++++++ packages/theme/src/cli/services/list/types.ts | 24 +++++ .../src/cli/utilities/testing/streams.ts | 27 ++++++ 10 files changed, 328 insertions(+), 65 deletions(-) create mode 100644 packages/theme/src/cli/commands/theme/list.test.ts create mode 100644 packages/theme/src/cli/services/list/result.ts create mode 100644 packages/theme/src/cli/services/list/types.ts create mode 100644 packages/theme/src/cli/utilities/testing/streams.ts diff --git a/packages/cli/README.md b/packages/cli/README.md index 43549d09578..a5eaa3dfc65 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -12611,6 +12611,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` diff --git a/packages/cli/oclif.manifest.json b/packages/cli/oclif.manifest.json index 3bf4c1e8235..9304d3d4d24 100644 --- a/packages/cli/oclif.manifest.json +++ b/packages/cli/oclif.manifest.json @@ -11594,7 +11594,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": { diff --git a/packages/eslint-plugin-cli/rules/json-output-command-exceptions.js b/packages/eslint-plugin-cli/rules/json-output-command-exceptions.js index ab28c35659e..144f2b2aec5 100644 --- a/packages/eslint-plugin-cli/rules/json-output-command-exceptions.js +++ b/packages/eslint-plugin-cli/rules/json-output-command-exceptions.js @@ -19,7 +19,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', diff --git a/packages/theme/src/cli/commands/theme/list.test.ts b/packages/theme/src/cli/commands/theme/list.test.ts new file mode 100644 index 00000000000..ffd3fbacdc6 --- /dev/null +++ b/packages/theme/src/cli/commands/theme/list.test.ts @@ -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('') +}) diff --git a/packages/theme/src/cli/commands/theme/list.ts b/packages/theme/src/cli/commands/theme/list.ts index 531be086d93..c25e407190c 100644 --- a/packages/theme/src/cli/commands/theme/list.ts +++ b/packages/theme/src/cli/commands/theme/list.ts @@ -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' @@ -10,7 +13,13 @@ import {AdminSession} from '@shopify/cli-kit/node/session' type ListFlags = OutputFlags 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, @@ -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}))) } } diff --git a/packages/theme/src/cli/services/list.test.ts b/packages/theme/src/cli/services/list.test.ts index fd7079871ab..8ed2a328c47 100644 --- a/packages/theme/src/cli/services/list.test.ts +++ b/packages/theme/src/cli/services/list.test.ts @@ -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' @@ -22,8 +23,8 @@ 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'}, @@ -31,7 +32,7 @@ describe('list', () => { 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: [ @@ -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: [ @@ -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": "" } ]" diff --git a/packages/theme/src/cli/services/list.ts b/packages/theme/src/cli/services/list.ts index 84313242878..23c10a2df9c 100644 --- a/packages/theme/src/cli/services/list.ts +++ b/packages/theme/src/cli/services/list.ts @@ -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 { const store = adminSession.storeFqdn const filter = new Filter({ ...ALLOWED_ROLES.reduce((roles: FilterProps, role) => { @@ -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 } diff --git a/packages/theme/src/cli/services/list/result.ts b/packages/theme/src/cli/services/list/result.ts new file mode 100644 index 00000000000..5b123877686 --- /dev/null +++ b/packages/theme/src/cli/services/list/result.ts @@ -0,0 +1,58 @@ +import {themeListJsonOutputSchema, type ThemeListResult} from './types.js' +import {getDevelopmentTheme} from '../local-storage.js' +import {getHostTheme} from '@shopify/cli-kit/node/themes/conf' +import {InlineToken, renderInfo} from '@shopify/cli-kit/node/ui' +import {outputResult} from '@shopify/cli-kit/node/output' + +function tabularSection( + title: string, + data: InlineToken[][], +): {title: string; body: {tabularData: InlineToken[][]; firstColumnSubdued?: boolean}} { + return { + title, + body: {tabularData: data}, + } +} + +export function renderThemeListResult( + result: ThemeListResult, + format: 'text' | 'json', + options: {store: string; environment?: string | string[]}, +): void { + if (format === 'json') { + outputResult(themeListJsonOutputSchema.encode(result)) + return + } + const developmentTheme = getDevelopmentTheme() + const hostTheme = getHostTheme(options.store) + const themes = result.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: `${options.store} theme library`, + body: [{subdued: `Environment name: ${options.environment}`}], + }, + ] + : []), + tabularSection('', tableData), + ], + }) +} diff --git a/packages/theme/src/cli/services/list/types.ts b/packages/theme/src/cli/services/list/types.ts new file mode 100644 index 00000000000..fc39d8f09c8 --- /dev/null +++ b/packages/theme/src/cli/services/list/types.ts @@ -0,0 +1,24 @@ +import {defineJsonOutputSchema} from '@shopify/cli-kit/node/json-output-schema' +import {zod} from '@shopify/cli-kit/node/schema' + +// Keep the field order produced by buildTheme for existing JSON consumers. +const ThemeListThemeSchema = zod.object({ + id: zod.number(), + name: zod.string(), + processing: zod.boolean(), + createdAtRuntime: zod.boolean(), + role: zod.string(), +}) +const ThemeListResultSchema = zod.array(ThemeListThemeSchema) +const ThemeListEnvironmentSchema = zod.object({ + environment: zod.string(), + result: ThemeListResultSchema, +}) + +export const themeListJsonOutputSchema = defineJsonOutputSchema({ + name: 'ThemeListResult', + schema: zod.union([ThemeListResultSchema, zod.object({environments: zod.array(ThemeListEnvironmentSchema)})]), + definitions: {ThemeListTheme: ThemeListThemeSchema, ThemeListEnvironment: ThemeListEnvironmentSchema}, +}) + +export type ThemeListResult = zod.infer diff --git a/packages/theme/src/cli/utilities/testing/streams.ts b/packages/theme/src/cli/utilities/testing/streams.ts new file mode 100644 index 00000000000..b1745b2f1a1 --- /dev/null +++ b/packages/theme/src/cli/utilities/testing/streams.ts @@ -0,0 +1,27 @@ +import {vi} from 'vitest' +// Use Node's console so Vitest does not intercept stderr before the stream capture. +// eslint-disable-next-line n/prefer-global/console +import {Console} from 'node:console' + +export function captureStandardStreams() { + vi.stubGlobal('console', Object.assign(new Console({stdout: process.stdout, stderr: process.stderr}), {Console})) + const stdout: string[] = [] + const stderr: string[] = [] + const stdoutSpy = vi.spyOn(process.stdout, 'write').mockImplementation((chunk: string | Uint8Array) => { + stdout.push(Buffer.from(chunk).toString('utf8')) + return true + }) + const stderrSpy = vi.spyOn(process.stderr, 'write').mockImplementation((chunk: string | Uint8Array) => { + stderr.push(Buffer.from(chunk).toString('utf8')) + return true + }) + return { + stdout: () => stdout.join(''), + stderr: () => stderr.join(''), + restore: () => { + stdoutSpy.mockRestore() + stderrSpy.mockRestore() + vi.unstubAllGlobals() + }, + } +} From 0b8e0f7848170e89c196aee56e4559618e075058 Mon Sep 17 00:00:00 2001 From: Gonzalo Riestra Date: Tue, 6 Oct 2026 13:57:33 +0200 Subject: [PATCH 2/5] Return strict theme library JSON objects with decimal string IDs --- .changeset/theme-list-json-contract.md | 5 + packages/cli/README.md | 252 ++++++++++++++++-- packages/cli/oclif.manifest.json | 2 +- .../theme/src/cli/commands/theme/list.test.ts | 29 +- packages/theme/src/cli/commands/theme/list.ts | 2 +- packages/theme/src/cli/services/list.test.ts | 32 +-- packages/theme/src/cli/services/list/types.ts | 25 +- 7 files changed, 282 insertions(+), 65 deletions(-) create mode 100644 .changeset/theme-list-json-contract.md diff --git a/.changeset/theme-list-json-contract.md b/.changeset/theme-list-json-contract.md new file mode 100644 index 00000000000..83e569126ac --- /dev/null +++ b/.changeset/theme-list-json-contract.md @@ -0,0 +1,5 @@ +--- +'@shopify/cli': major +--- + +Return `{themes}` with string IDs and complete environment batches from `theme list --json`, excluding internal runtime fields. diff --git a/packages/cli/README.md b/packages/cli/README.md index a5eaa3dfc65..68fda8dfd1f 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -12620,7 +12620,7 @@ DESCRIPTION { "anyOf": [ { - "$ref": "#/definitions/ThemeListEnvironment/properties/result" + "$ref": "#/definitions/ThemeEnvironment/anyOf/0/properties/result" }, { "type": "object", @@ -12628,7 +12628,7 @@ DESCRIPTION "environments": { "type": "array", "items": { - "$ref": "#/definitions/ThemeListEnvironment" + "$ref": "#/definitions/ThemeEnvironment" } } }, @@ -12640,52 +12640,246 @@ DESCRIPTION ], "title": "ThemeListResult", "definitions": { - "ThemeListTheme": { + "Theme": { "type": "object", "properties": { "id": { - "type": "number" + "type": "string", + "pattern": "^\\d+$", + "description": "The decimal Online Store theme ID, not a Shopify GID." }, "name": { "type": "string" }, - "processing": { - "type": "boolean" - }, - "createdAtRuntime": { - "type": "boolean" - }, "role": { - "type": "string" + "type": "string", + "description": "The upstream theme role; known values include main, unpublished, and development." } }, "required": [ "id", "name", - "processing", - "createdAtRuntime", "role" ], "additionalProperties": false }, - "ThemeListEnvironment": { - "type": "object", - "properties": { - "environment": { - "type": "string" + "ThemeEnvironment": { + "anyOf": [ + { + "type": "object", + "properties": { + "environment": { + "type": "string" + }, + "result": { + "anyOf": [ + { + "type": "object", + "properties": { + "themes": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "$ref": "#/definitions/Theme/properties/id" + }, + "name": { + "$ref": "#/definitions/Theme/properties/name" + }, + "role": { + "$ref": "#/definitions/Theme/properties/role" + }, + "processing": { + "type": "boolean" + } + }, + "required": [ + "id", + "name", + "role", + "processing" + ], + "additionalProperties": false + } + } + }, + "required": [ + "themes" + ], + "additionalProperties": false, + "description": "The complete theme library after applying the requested filters." + }, + { + "type": "object", + "properties": { + "status": { + "type": "string", + "const": "cancelled" + } + }, + "required": [ + "status" + ], + "additionalProperties": false + } + ] + } + }, + "required": [ + "environment", + "result" + ], + "additionalProperties": false }, - "result": { - "type": "array", - "items": { - "$ref": "#/definitions/ThemeListTheme" - } + { + "type": "object", + "properties": { + "environment": { + "type": "string" + }, + "error": { + "anyOf": [ + { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "abort" + }, + "message": { + "type": "string" + }, + "tryMessage": { + "type": "string" + }, + "nextSteps": { + "type": "array", + "items": { + "type": "string" + } + }, + "customSections": { + "type": "array", + "items": { + "type": "object", + "properties": { + "title": { + "type": "string" + }, + "body": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "array", + "items": { + "type": "string" + } + } + } + ] + } + }, + "required": [ + "body" + ], + "additionalProperties": false + } + }, + "details": {} + }, + "required": [ + "type", + "message" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "bug" + }, + "message": { + "$ref": "#/definitions/ThemeEnvironment/anyOf/1/properties/error/anyOf/0/properties/message" + }, + "tryMessage": { + "$ref": "#/definitions/ThemeEnvironment/anyOf/1/properties/error/anyOf/0/properties/tryMessage" + }, + "nextSteps": { + "$ref": "#/definitions/ThemeEnvironment/anyOf/1/properties/error/anyOf/0/properties/nextSteps" + }, + "customSections": { + "$ref": "#/definitions/ThemeEnvironment/anyOf/1/properties/error/anyOf/0/properties/customSections" + }, + "details": { + "$ref": "#/definitions/ThemeEnvironment/anyOf/1/properties/error/anyOf/0/properties/details" + }, + "stack": { + "type": "string" + } + }, + "required": [ + "type", + "message" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "external" + }, + "message": { + "$ref": "#/definitions/ThemeEnvironment/anyOf/1/properties/error/anyOf/0/properties/message" + }, + "tryMessage": { + "$ref": "#/definitions/ThemeEnvironment/anyOf/1/properties/error/anyOf/0/properties/tryMessage" + }, + "nextSteps": { + "$ref": "#/definitions/ThemeEnvironment/anyOf/1/properties/error/anyOf/0/properties/nextSteps" + }, + "customSections": { + "$ref": "#/definitions/ThemeEnvironment/anyOf/1/properties/error/anyOf/0/properties/customSections" + }, + "details": { + "$ref": "#/definitions/ThemeEnvironment/anyOf/1/properties/error/anyOf/0/properties/details" + }, + "command": { + "type": "string" + }, + "args": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": [ + "type", + "message", + "command", + "args" + ], + "additionalProperties": false + } + ] + } + }, + "required": [ + "environment", + "error" + ], + "additionalProperties": false } - }, - "required": [ - "environment", - "result" - ], - "additionalProperties": false + ] } }, "$schema": "http://json-schema.org/draft-07/schema#" diff --git a/packages/cli/oclif.manifest.json b/packages/cli/oclif.manifest.json index 9304d3d4d24..50b2427c517 100644 --- a/packages/cli/oclif.manifest.json +++ b/packages/cli/oclif.manifest.json @@ -11594,7 +11594,7 @@ "args": { }, "customPluginName": "@shopify/theme", - "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```", + "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/ThemeEnvironment/anyOf/0/properties/result\"\n },\n {\n \"type\": \"object\",\n \"properties\": {\n \"environments\": {\n \"type\": \"array\",\n \"items\": {\n \"$ref\": \"#/definitions/ThemeEnvironment\"\n }\n }\n },\n \"required\": [\n \"environments\"\n ],\n \"additionalProperties\": false\n }\n ],\n \"title\": \"ThemeListResult\",\n \"definitions\": {\n \"Theme\": {\n \"type\": \"object\",\n \"properties\": {\n \"id\": {\n \"type\": \"string\",\n \"pattern\": \"^\\\\d+$\",\n \"description\": \"The decimal Online Store theme ID, not a Shopify GID.\"\n },\n \"name\": {\n \"type\": \"string\"\n },\n \"role\": {\n \"type\": \"string\",\n \"description\": \"The upstream theme role; known values include main, unpublished, and development.\"\n }\n },\n \"required\": [\n \"id\",\n \"name\",\n \"role\"\n ],\n \"additionalProperties\": false\n },\n \"ThemeEnvironment\": {\n \"anyOf\": [\n {\n \"type\": \"object\",\n \"properties\": {\n \"environment\": {\n \"type\": \"string\"\n },\n \"result\": {\n \"anyOf\": [\n {\n \"type\": \"object\",\n \"properties\": {\n \"themes\": {\n \"type\": \"array\",\n \"items\": {\n \"type\": \"object\",\n \"properties\": {\n \"id\": {\n \"$ref\": \"#/definitions/Theme/properties/id\"\n },\n \"name\": {\n \"$ref\": \"#/definitions/Theme/properties/name\"\n },\n \"role\": {\n \"$ref\": \"#/definitions/Theme/properties/role\"\n },\n \"processing\": {\n \"type\": \"boolean\"\n }\n },\n \"required\": [\n \"id\",\n \"name\",\n \"role\",\n \"processing\"\n ],\n \"additionalProperties\": false\n }\n }\n },\n \"required\": [\n \"themes\"\n ],\n \"additionalProperties\": false,\n \"description\": \"The complete theme library after applying the requested filters.\"\n },\n {\n \"type\": \"object\",\n \"properties\": {\n \"status\": {\n \"type\": \"string\",\n \"const\": \"cancelled\"\n }\n },\n \"required\": [\n \"status\"\n ],\n \"additionalProperties\": false\n }\n ]\n }\n },\n \"required\": [\n \"environment\",\n \"result\"\n ],\n \"additionalProperties\": false\n },\n {\n \"type\": \"object\",\n \"properties\": {\n \"environment\": {\n \"type\": \"string\"\n },\n \"error\": {\n \"anyOf\": [\n {\n \"type\": \"object\",\n \"properties\": {\n \"type\": {\n \"type\": \"string\",\n \"const\": \"abort\"\n },\n \"message\": {\n \"type\": \"string\"\n },\n \"tryMessage\": {\n \"type\": \"string\"\n },\n \"nextSteps\": {\n \"type\": \"array\",\n \"items\": {\n \"type\": \"string\"\n }\n },\n \"customSections\": {\n \"type\": \"array\",\n \"items\": {\n \"type\": \"object\",\n \"properties\": {\n \"title\": {\n \"type\": \"string\"\n },\n \"body\": {\n \"anyOf\": [\n {\n \"type\": \"string\"\n },\n {\n \"type\": \"array\",\n \"items\": {\n \"type\": \"array\",\n \"items\": {\n \"type\": \"string\"\n }\n }\n }\n ]\n }\n },\n \"required\": [\n \"body\"\n ],\n \"additionalProperties\": false\n }\n },\n \"details\": {}\n },\n \"required\": [\n \"type\",\n \"message\"\n ],\n \"additionalProperties\": false\n },\n {\n \"type\": \"object\",\n \"properties\": {\n \"type\": {\n \"type\": \"string\",\n \"const\": \"bug\"\n },\n \"message\": {\n \"$ref\": \"#/definitions/ThemeEnvironment/anyOf/1/properties/error/anyOf/0/properties/message\"\n },\n \"tryMessage\": {\n \"$ref\": \"#/definitions/ThemeEnvironment/anyOf/1/properties/error/anyOf/0/properties/tryMessage\"\n },\n \"nextSteps\": {\n \"$ref\": \"#/definitions/ThemeEnvironment/anyOf/1/properties/error/anyOf/0/properties/nextSteps\"\n },\n \"customSections\": {\n \"$ref\": \"#/definitions/ThemeEnvironment/anyOf/1/properties/error/anyOf/0/properties/customSections\"\n },\n \"details\": {\n \"$ref\": \"#/definitions/ThemeEnvironment/anyOf/1/properties/error/anyOf/0/properties/details\"\n },\n \"stack\": {\n \"type\": \"string\"\n }\n },\n \"required\": [\n \"type\",\n \"message\"\n ],\n \"additionalProperties\": false\n },\n {\n \"type\": \"object\",\n \"properties\": {\n \"type\": {\n \"type\": \"string\",\n \"const\": \"external\"\n },\n \"message\": {\n \"$ref\": \"#/definitions/ThemeEnvironment/anyOf/1/properties/error/anyOf/0/properties/message\"\n },\n \"tryMessage\": {\n \"$ref\": \"#/definitions/ThemeEnvironment/anyOf/1/properties/error/anyOf/0/properties/tryMessage\"\n },\n \"nextSteps\": {\n \"$ref\": \"#/definitions/ThemeEnvironment/anyOf/1/properties/error/anyOf/0/properties/nextSteps\"\n },\n \"customSections\": {\n \"$ref\": \"#/definitions/ThemeEnvironment/anyOf/1/properties/error/anyOf/0/properties/customSections\"\n },\n \"details\": {\n \"$ref\": \"#/definitions/ThemeEnvironment/anyOf/1/properties/error/anyOf/0/properties/details\"\n },\n \"command\": {\n \"type\": \"string\"\n },\n \"args\": {\n \"type\": \"array\",\n \"items\": {\n \"type\": \"string\"\n }\n }\n },\n \"required\": [\n \"type\",\n \"message\",\n \"command\",\n \"args\"\n ],\n \"additionalProperties\": false\n }\n ]\n }\n },\n \"required\": [\n \"environment\",\n \"error\"\n ],\n \"additionalProperties\": false\n }\n ]\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": { diff --git a/packages/theme/src/cli/commands/theme/list.test.ts b/packages/theme/src/cli/commands/theme/list.test.ts index ffd3fbacdc6..d8b3373430d 100644 --- a/packages/theme/src/cli/commands/theme/list.test.ts +++ b/packages/theme/src/cli/commands/theme/list.test.ts @@ -12,19 +12,24 @@ 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'} +const publicTheme = {id: '1', name: 'Dawn', role: 'live', processing: false} afterEach(() => vi.unstubAllEnvs()) +function restoreExitCode(value: typeof process.exitCode): void { + process.exitCode = value +} + 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() + expect(JSON.parse(themeListJsonOutputSchema.encode([theme]))).toEqual({themes: [publicTheme]}) + expect(JSON.parse(themeListJsonOutputSchema.encode([]))).toEqual({themes: []}) + expect(() => themeListJsonOutputSchema.validate({themes: [{...publicTheme, id: 1}]})).toThrow() + expect(() => themeListJsonOutputSchema.validate({themes: [{...publicTheme, processing: null}]})).toThrow() }) -test.each(['single', 'multiple', 'partial failure', 'total failure'])( +test.each(['single', 'named single', 'multiple', 'partial failure', 'total failure'])( 'writes one final document to stdout for %s environments', async (mode) => { vi.stubEnv('SHOPIFY_UNIT_TEST', 'false') @@ -51,24 +56,32 @@ test.each(['single', 'multiple', 'partial failure', 'total failure'])( return [theme] }) const streams = captureStandardStreams() + const originalExitCode = process.exitCode try { + const namedEnvironments = mode === 'named single' ? ['first'] : ['first', 'second'] const args = mode === 'single' ? ['--store=single.myshopify.com', '--password=token'] - : ['--environment=first', '--environment=second'] + : namedEnvironments.map((environment) => `--environment=${environment}`) const argv = [...args, '--json'] await runWithCommandEventsForCommand(argv, () => new StreamList(argv, config).run()) + if (mode.includes('failure')) expect(process.exitCode).toBe(1) } finally { streams.restore() + restoreExitCode(originalExitCode) } let successfulEnvironments = ['first', 'second'] if (mode === 'total failure') successfulEnvironments = [] if (mode === 'partial failure') successfulEnvironments = ['second'] const expected = mode === 'single' - ? [theme] + ? {themes: [publicTheme]} : { - environments: successfulEnvironments.map((environment) => ({environment, result: [theme]})), + environments: (mode === 'named single' ? ['first'] : ['first', 'second']).map((environment) => + successfulEnvironments.includes(environment) + ? {environment, result: {themes: [publicTheme]}} + : {environment, error: {type: 'abort', message: 'Fetch failed'}}, + ), } expect(streams.stdout()).toBe(`${JSON.stringify(expected, null, 2)}\n`) expect(JSON.parse(streams.stdout())).toEqual(expected) diff --git a/packages/theme/src/cli/commands/theme/list.ts b/packages/theme/src/cli/commands/theme/list.ts index c25e407190c..71ea09edefe 100644 --- a/packages/theme/src/cli/commands/theme/list.ts +++ b/packages/theme/src/cli/commands/theme/list.ts @@ -56,6 +56,6 @@ export default class List extends ThemeCommand { } protected renderEnvironmentResults(environments: {environment: string; result: unknown}[]): void { - outputResult(themeListJsonOutputSchema.encode(themeListJsonOutputSchema.validate({environments}))) + outputResult(themeListJsonOutputSchema.encode({environments})) } } diff --git a/packages/theme/src/cli/services/list.test.ts b/packages/theme/src/cli/services/list.test.ts index 8ed2a328c47..4bec74d1813 100644 --- a/packages/theme/src/cli/services/list.test.ts +++ b/packages/theme/src/cli/services/list.test.ts @@ -91,22 +91,22 @@ describe('list', () => { 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": "" - } - ]" + "{ + "themes": [ + { + "id": "1", + "name": "Theme 1", + "role": "live", + "processing": false + }, + { + "id": "2", + "name": "Theme 2", + "role": "", + "processing": false + } + ] + }" `) }) }) diff --git a/packages/theme/src/cli/services/list/types.ts b/packages/theme/src/cli/services/list/types.ts index fc39d8f09c8..1097400a1a2 100644 --- a/packages/theme/src/cli/services/list/types.ts +++ b/packages/theme/src/cli/services/list/types.ts @@ -1,7 +1,6 @@ -import {defineJsonOutputSchema} from '@shopify/cli-kit/node/json-output-schema' +import {defineThemeJsonOutputSchema, ThemeSchema as PublicThemeSchema, projectTheme} from '../json-output/schema.js' import {zod} from '@shopify/cli-kit/node/schema' -// Keep the field order produced by buildTheme for existing JSON consumers. const ThemeListThemeSchema = zod.object({ id: zod.number(), name: zod.string(), @@ -10,15 +9,21 @@ const ThemeListThemeSchema = zod.object({ role: zod.string(), }) const ThemeListResultSchema = zod.array(ThemeListThemeSchema) -const ThemeListEnvironmentSchema = zod.object({ - environment: zod.string(), - result: ThemeListResultSchema, -}) - -export const themeListJsonOutputSchema = defineJsonOutputSchema({ +export const themeListJsonOutputSchema = defineThemeJsonOutputSchema({ name: 'ThemeListResult', - schema: zod.union([ThemeListResultSchema, zod.object({environments: zod.array(ThemeListEnvironmentSchema)})]), - definitions: {ThemeListTheme: ThemeListThemeSchema, ThemeListEnvironment: ThemeListEnvironmentSchema}, + schema: zod + .object({themes: zod.array(PublicThemeSchema.extend({processing: zod.boolean()}).strict())}) + .strict() + .describe('The complete theme library after applying the requested filters.'), + definitions: {Theme: PublicThemeSchema}, + project(value) { + return { + themes: ThemeListResultSchema.parse(value).map((theme) => ({ + ...projectTheme(theme), + processing: theme.processing, + })), + } + }, }) export type ThemeListResult = zod.infer From 4fde7f557986edd30b577cf5f47ffaec42776542 Mon Sep 17 00:00:00 2001 From: Gonzalo Riestra Date: Tue, 6 Oct 2026 14:16:40 +0200 Subject: [PATCH 3/5] Refresh theme list JSON schema documentation --- packages/cli/README.md | 2 +- packages/cli/oclif.manifest.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/cli/README.md b/packages/cli/README.md index 68fda8dfd1f..c6020993ed9 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -12653,7 +12653,7 @@ DESCRIPTION }, "role": { "type": "string", - "description": "The upstream theme role; known values include main, unpublished, and development." + "description": "The upstream theme role; known values include live, unpublished, and development." } }, "required": [ diff --git a/packages/cli/oclif.manifest.json b/packages/cli/oclif.manifest.json index 50b2427c517..5c15b425e53 100644 --- a/packages/cli/oclif.manifest.json +++ b/packages/cli/oclif.manifest.json @@ -11594,7 +11594,7 @@ "args": { }, "customPluginName": "@shopify/theme", - "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/ThemeEnvironment/anyOf/0/properties/result\"\n },\n {\n \"type\": \"object\",\n \"properties\": {\n \"environments\": {\n \"type\": \"array\",\n \"items\": {\n \"$ref\": \"#/definitions/ThemeEnvironment\"\n }\n }\n },\n \"required\": [\n \"environments\"\n ],\n \"additionalProperties\": false\n }\n ],\n \"title\": \"ThemeListResult\",\n \"definitions\": {\n \"Theme\": {\n \"type\": \"object\",\n \"properties\": {\n \"id\": {\n \"type\": \"string\",\n \"pattern\": \"^\\\\d+$\",\n \"description\": \"The decimal Online Store theme ID, not a Shopify GID.\"\n },\n \"name\": {\n \"type\": \"string\"\n },\n \"role\": {\n \"type\": \"string\",\n \"description\": \"The upstream theme role; known values include main, unpublished, and development.\"\n }\n },\n \"required\": [\n \"id\",\n \"name\",\n \"role\"\n ],\n \"additionalProperties\": false\n },\n \"ThemeEnvironment\": {\n \"anyOf\": [\n {\n \"type\": \"object\",\n \"properties\": {\n \"environment\": {\n \"type\": \"string\"\n },\n \"result\": {\n \"anyOf\": [\n {\n \"type\": \"object\",\n \"properties\": {\n \"themes\": {\n \"type\": \"array\",\n \"items\": {\n \"type\": \"object\",\n \"properties\": {\n \"id\": {\n \"$ref\": \"#/definitions/Theme/properties/id\"\n },\n \"name\": {\n \"$ref\": \"#/definitions/Theme/properties/name\"\n },\n \"role\": {\n \"$ref\": \"#/definitions/Theme/properties/role\"\n },\n \"processing\": {\n \"type\": \"boolean\"\n }\n },\n \"required\": [\n \"id\",\n \"name\",\n \"role\",\n \"processing\"\n ],\n \"additionalProperties\": false\n }\n }\n },\n \"required\": [\n \"themes\"\n ],\n \"additionalProperties\": false,\n \"description\": \"The complete theme library after applying the requested filters.\"\n },\n {\n \"type\": \"object\",\n \"properties\": {\n \"status\": {\n \"type\": \"string\",\n \"const\": \"cancelled\"\n }\n },\n \"required\": [\n \"status\"\n ],\n \"additionalProperties\": false\n }\n ]\n }\n },\n \"required\": [\n \"environment\",\n \"result\"\n ],\n \"additionalProperties\": false\n },\n {\n \"type\": \"object\",\n \"properties\": {\n \"environment\": {\n \"type\": \"string\"\n },\n \"error\": {\n \"anyOf\": [\n {\n \"type\": \"object\",\n \"properties\": {\n \"type\": {\n \"type\": \"string\",\n \"const\": \"abort\"\n },\n \"message\": {\n \"type\": \"string\"\n },\n \"tryMessage\": {\n \"type\": \"string\"\n },\n \"nextSteps\": {\n \"type\": \"array\",\n \"items\": {\n \"type\": \"string\"\n }\n },\n \"customSections\": {\n \"type\": \"array\",\n \"items\": {\n \"type\": \"object\",\n \"properties\": {\n \"title\": {\n \"type\": \"string\"\n },\n \"body\": {\n \"anyOf\": [\n {\n \"type\": \"string\"\n },\n {\n \"type\": \"array\",\n \"items\": {\n \"type\": \"array\",\n \"items\": {\n \"type\": \"string\"\n }\n }\n }\n ]\n }\n },\n \"required\": [\n \"body\"\n ],\n \"additionalProperties\": false\n }\n },\n \"details\": {}\n },\n \"required\": [\n \"type\",\n \"message\"\n ],\n \"additionalProperties\": false\n },\n {\n \"type\": \"object\",\n \"properties\": {\n \"type\": {\n \"type\": \"string\",\n \"const\": \"bug\"\n },\n \"message\": {\n \"$ref\": \"#/definitions/ThemeEnvironment/anyOf/1/properties/error/anyOf/0/properties/message\"\n },\n \"tryMessage\": {\n \"$ref\": \"#/definitions/ThemeEnvironment/anyOf/1/properties/error/anyOf/0/properties/tryMessage\"\n },\n \"nextSteps\": {\n \"$ref\": \"#/definitions/ThemeEnvironment/anyOf/1/properties/error/anyOf/0/properties/nextSteps\"\n },\n \"customSections\": {\n \"$ref\": \"#/definitions/ThemeEnvironment/anyOf/1/properties/error/anyOf/0/properties/customSections\"\n },\n \"details\": {\n \"$ref\": \"#/definitions/ThemeEnvironment/anyOf/1/properties/error/anyOf/0/properties/details\"\n },\n \"stack\": {\n \"type\": \"string\"\n }\n },\n \"required\": [\n \"type\",\n \"message\"\n ],\n \"additionalProperties\": false\n },\n {\n \"type\": \"object\",\n \"properties\": {\n \"type\": {\n \"type\": \"string\",\n \"const\": \"external\"\n },\n \"message\": {\n \"$ref\": \"#/definitions/ThemeEnvironment/anyOf/1/properties/error/anyOf/0/properties/message\"\n },\n \"tryMessage\": {\n \"$ref\": \"#/definitions/ThemeEnvironment/anyOf/1/properties/error/anyOf/0/properties/tryMessage\"\n },\n \"nextSteps\": {\n \"$ref\": \"#/definitions/ThemeEnvironment/anyOf/1/properties/error/anyOf/0/properties/nextSteps\"\n },\n \"customSections\": {\n \"$ref\": \"#/definitions/ThemeEnvironment/anyOf/1/properties/error/anyOf/0/properties/customSections\"\n },\n \"details\": {\n \"$ref\": \"#/definitions/ThemeEnvironment/anyOf/1/properties/error/anyOf/0/properties/details\"\n },\n \"command\": {\n \"type\": \"string\"\n },\n \"args\": {\n \"type\": \"array\",\n \"items\": {\n \"type\": \"string\"\n }\n }\n },\n \"required\": [\n \"type\",\n \"message\",\n \"command\",\n \"args\"\n ],\n \"additionalProperties\": false\n }\n ]\n }\n },\n \"required\": [\n \"environment\",\n \"error\"\n ],\n \"additionalProperties\": false\n }\n ]\n }\n },\n \"$schema\": \"http://json-schema.org/draft-07/schema#\"\n}\n```", + "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/ThemeEnvironment/anyOf/0/properties/result\"\n },\n {\n \"type\": \"object\",\n \"properties\": {\n \"environments\": {\n \"type\": \"array\",\n \"items\": {\n \"$ref\": \"#/definitions/ThemeEnvironment\"\n }\n }\n },\n \"required\": [\n \"environments\"\n ],\n \"additionalProperties\": false\n }\n ],\n \"title\": \"ThemeListResult\",\n \"definitions\": {\n \"Theme\": {\n \"type\": \"object\",\n \"properties\": {\n \"id\": {\n \"type\": \"string\",\n \"pattern\": \"^\\\\d+$\",\n \"description\": \"The decimal Online Store theme ID, not a Shopify GID.\"\n },\n \"name\": {\n \"type\": \"string\"\n },\n \"role\": {\n \"type\": \"string\",\n \"description\": \"The upstream theme role; known values include live, unpublished, and development.\"\n }\n },\n \"required\": [\n \"id\",\n \"name\",\n \"role\"\n ],\n \"additionalProperties\": false\n },\n \"ThemeEnvironment\": {\n \"anyOf\": [\n {\n \"type\": \"object\",\n \"properties\": {\n \"environment\": {\n \"type\": \"string\"\n },\n \"result\": {\n \"anyOf\": [\n {\n \"type\": \"object\",\n \"properties\": {\n \"themes\": {\n \"type\": \"array\",\n \"items\": {\n \"type\": \"object\",\n \"properties\": {\n \"id\": {\n \"$ref\": \"#/definitions/Theme/properties/id\"\n },\n \"name\": {\n \"$ref\": \"#/definitions/Theme/properties/name\"\n },\n \"role\": {\n \"$ref\": \"#/definitions/Theme/properties/role\"\n },\n \"processing\": {\n \"type\": \"boolean\"\n }\n },\n \"required\": [\n \"id\",\n \"name\",\n \"role\",\n \"processing\"\n ],\n \"additionalProperties\": false\n }\n }\n },\n \"required\": [\n \"themes\"\n ],\n \"additionalProperties\": false,\n \"description\": \"The complete theme library after applying the requested filters.\"\n },\n {\n \"type\": \"object\",\n \"properties\": {\n \"status\": {\n \"type\": \"string\",\n \"const\": \"cancelled\"\n }\n },\n \"required\": [\n \"status\"\n ],\n \"additionalProperties\": false\n }\n ]\n }\n },\n \"required\": [\n \"environment\",\n \"result\"\n ],\n \"additionalProperties\": false\n },\n {\n \"type\": \"object\",\n \"properties\": {\n \"environment\": {\n \"type\": \"string\"\n },\n \"error\": {\n \"anyOf\": [\n {\n \"type\": \"object\",\n \"properties\": {\n \"type\": {\n \"type\": \"string\",\n \"const\": \"abort\"\n },\n \"message\": {\n \"type\": \"string\"\n },\n \"tryMessage\": {\n \"type\": \"string\"\n },\n \"nextSteps\": {\n \"type\": \"array\",\n \"items\": {\n \"type\": \"string\"\n }\n },\n \"customSections\": {\n \"type\": \"array\",\n \"items\": {\n \"type\": \"object\",\n \"properties\": {\n \"title\": {\n \"type\": \"string\"\n },\n \"body\": {\n \"anyOf\": [\n {\n \"type\": \"string\"\n },\n {\n \"type\": \"array\",\n \"items\": {\n \"type\": \"array\",\n \"items\": {\n \"type\": \"string\"\n }\n }\n }\n ]\n }\n },\n \"required\": [\n \"body\"\n ],\n \"additionalProperties\": false\n }\n },\n \"details\": {}\n },\n \"required\": [\n \"type\",\n \"message\"\n ],\n \"additionalProperties\": false\n },\n {\n \"type\": \"object\",\n \"properties\": {\n \"type\": {\n \"type\": \"string\",\n \"const\": \"bug\"\n },\n \"message\": {\n \"$ref\": \"#/definitions/ThemeEnvironment/anyOf/1/properties/error/anyOf/0/properties/message\"\n },\n \"tryMessage\": {\n \"$ref\": \"#/definitions/ThemeEnvironment/anyOf/1/properties/error/anyOf/0/properties/tryMessage\"\n },\n \"nextSteps\": {\n \"$ref\": \"#/definitions/ThemeEnvironment/anyOf/1/properties/error/anyOf/0/properties/nextSteps\"\n },\n \"customSections\": {\n \"$ref\": \"#/definitions/ThemeEnvironment/anyOf/1/properties/error/anyOf/0/properties/customSections\"\n },\n \"details\": {\n \"$ref\": \"#/definitions/ThemeEnvironment/anyOf/1/properties/error/anyOf/0/properties/details\"\n },\n \"stack\": {\n \"type\": \"string\"\n }\n },\n \"required\": [\n \"type\",\n \"message\"\n ],\n \"additionalProperties\": false\n },\n {\n \"type\": \"object\",\n \"properties\": {\n \"type\": {\n \"type\": \"string\",\n \"const\": \"external\"\n },\n \"message\": {\n \"$ref\": \"#/definitions/ThemeEnvironment/anyOf/1/properties/error/anyOf/0/properties/message\"\n },\n \"tryMessage\": {\n \"$ref\": \"#/definitions/ThemeEnvironment/anyOf/1/properties/error/anyOf/0/properties/tryMessage\"\n },\n \"nextSteps\": {\n \"$ref\": \"#/definitions/ThemeEnvironment/anyOf/1/properties/error/anyOf/0/properties/nextSteps\"\n },\n \"customSections\": {\n \"$ref\": \"#/definitions/ThemeEnvironment/anyOf/1/properties/error/anyOf/0/properties/customSections\"\n },\n \"details\": {\n \"$ref\": \"#/definitions/ThemeEnvironment/anyOf/1/properties/error/anyOf/0/properties/details\"\n },\n \"command\": {\n \"type\": \"string\"\n },\n \"args\": {\n \"type\": \"array\",\n \"items\": {\n \"type\": \"string\"\n }\n }\n },\n \"required\": [\n \"type\",\n \"message\",\n \"command\",\n \"args\"\n ],\n \"additionalProperties\": false\n }\n ]\n }\n },\n \"required\": [\n \"environment\",\n \"error\"\n ],\n \"additionalProperties\": false\n }\n ]\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": { From 00ce914ff611c253799a57d757ae6fe6dabe0d89 Mon Sep 17 00:00:00 2001 From: Gonzalo Riestra Date: Tue, 6 Oct 2026 15:47:11 +0200 Subject: [PATCH 4/5] Export shared theme schema when list reuses it --- packages/theme/src/cli/services/json-output/schema.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/theme/src/cli/services/json-output/schema.ts b/packages/theme/src/cli/services/json-output/schema.ts index 3a2142d6ee6..37405ee5359 100644 --- a/packages/theme/src/cli/services/json-output/schema.ts +++ b/packages/theme/src/cli/services/json-output/schema.ts @@ -10,7 +10,7 @@ export const StoreDomainSchema = zod .string() .regex(/^[a-z0-9][a-z0-9-]*\.myshopify\.com$/) .nullable() -const ThemeSchema = zod +export const ThemeSchema = zod .object({ id: ThemeIdSchema, name: zod.string(), From 297bd123bdf89f0f3b722cee3838d93887eb7b28 Mon Sep 17 00:00:00 2001 From: Gonzalo Riestra Date: Thu, 8 Oct 2026 10:35:22 +0200 Subject: [PATCH 5/5] Allow empty theme lists and refresh JSON schema documentation --- packages/cli/README.md | 15 +++++++- packages/cli/oclif.manifest.json | 2 +- .../theme/src/cli/commands/theme/list.test.ts | 9 ++--- packages/theme/src/cli/commands/theme/list.ts | 4 -- packages/theme/src/cli/services/list.test.ts | 38 ++++++++++++++++++- packages/theme/src/cli/services/list.ts | 6 ++- .../src/cli/utilities/testing/streams.ts | 27 ------------- .../utilities/theme-selector/fetch.test.ts | 6 +++ .../src/cli/utilities/theme-selector/fetch.ts | 4 +- .../cli/utilities/theme-selector/filter.ts | 22 +++++++---- 10 files changed, 81 insertions(+), 52 deletions(-) delete mode 100644 packages/theme/src/cli/utilities/testing/streams.ts diff --git a/packages/cli/README.md b/packages/cli/README.md index c6020993ed9..2499cde2e71 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -12750,6 +12750,11 @@ DESCRIPTION "message": { "type": "string" }, + "code": { + "type": "string", + "minLength": 1, + "description": "A stable error code, included only when known." + }, "tryMessage": { "type": "string" }, @@ -12790,7 +12795,9 @@ DESCRIPTION "additionalProperties": false } }, - "details": {} + "details": { + "description": "Selected domain details, preserving native API payloads such as GraphQL errors, extensions, and data." + } }, "required": [ "type", @@ -12808,6 +12815,9 @@ DESCRIPTION "message": { "$ref": "#/definitions/ThemeEnvironment/anyOf/1/properties/error/anyOf/0/properties/message" }, + "code": { + "$ref": "#/definitions/ThemeEnvironment/anyOf/1/properties/error/anyOf/0/properties/code" + }, "tryMessage": { "$ref": "#/definitions/ThemeEnvironment/anyOf/1/properties/error/anyOf/0/properties/tryMessage" }, @@ -12840,6 +12850,9 @@ DESCRIPTION "message": { "$ref": "#/definitions/ThemeEnvironment/anyOf/1/properties/error/anyOf/0/properties/message" }, + "code": { + "$ref": "#/definitions/ThemeEnvironment/anyOf/1/properties/error/anyOf/0/properties/code" + }, "tryMessage": { "$ref": "#/definitions/ThemeEnvironment/anyOf/1/properties/error/anyOf/0/properties/tryMessage" }, diff --git a/packages/cli/oclif.manifest.json b/packages/cli/oclif.manifest.json index 5c15b425e53..33838e01fbc 100644 --- a/packages/cli/oclif.manifest.json +++ b/packages/cli/oclif.manifest.json @@ -11594,7 +11594,7 @@ "args": { }, "customPluginName": "@shopify/theme", - "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/ThemeEnvironment/anyOf/0/properties/result\"\n },\n {\n \"type\": \"object\",\n \"properties\": {\n \"environments\": {\n \"type\": \"array\",\n \"items\": {\n \"$ref\": \"#/definitions/ThemeEnvironment\"\n }\n }\n },\n \"required\": [\n \"environments\"\n ],\n \"additionalProperties\": false\n }\n ],\n \"title\": \"ThemeListResult\",\n \"definitions\": {\n \"Theme\": {\n \"type\": \"object\",\n \"properties\": {\n \"id\": {\n \"type\": \"string\",\n \"pattern\": \"^\\\\d+$\",\n \"description\": \"The decimal Online Store theme ID, not a Shopify GID.\"\n },\n \"name\": {\n \"type\": \"string\"\n },\n \"role\": {\n \"type\": \"string\",\n \"description\": \"The upstream theme role; known values include live, unpublished, and development.\"\n }\n },\n \"required\": [\n \"id\",\n \"name\",\n \"role\"\n ],\n \"additionalProperties\": false\n },\n \"ThemeEnvironment\": {\n \"anyOf\": [\n {\n \"type\": \"object\",\n \"properties\": {\n \"environment\": {\n \"type\": \"string\"\n },\n \"result\": {\n \"anyOf\": [\n {\n \"type\": \"object\",\n \"properties\": {\n \"themes\": {\n \"type\": \"array\",\n \"items\": {\n \"type\": \"object\",\n \"properties\": {\n \"id\": {\n \"$ref\": \"#/definitions/Theme/properties/id\"\n },\n \"name\": {\n \"$ref\": \"#/definitions/Theme/properties/name\"\n },\n \"role\": {\n \"$ref\": \"#/definitions/Theme/properties/role\"\n },\n \"processing\": {\n \"type\": \"boolean\"\n }\n },\n \"required\": [\n \"id\",\n \"name\",\n \"role\",\n \"processing\"\n ],\n \"additionalProperties\": false\n }\n }\n },\n \"required\": [\n \"themes\"\n ],\n \"additionalProperties\": false,\n \"description\": \"The complete theme library after applying the requested filters.\"\n },\n {\n \"type\": \"object\",\n \"properties\": {\n \"status\": {\n \"type\": \"string\",\n \"const\": \"cancelled\"\n }\n },\n \"required\": [\n \"status\"\n ],\n \"additionalProperties\": false\n }\n ]\n }\n },\n \"required\": [\n \"environment\",\n \"result\"\n ],\n \"additionalProperties\": false\n },\n {\n \"type\": \"object\",\n \"properties\": {\n \"environment\": {\n \"type\": \"string\"\n },\n \"error\": {\n \"anyOf\": [\n {\n \"type\": \"object\",\n \"properties\": {\n \"type\": {\n \"type\": \"string\",\n \"const\": \"abort\"\n },\n \"message\": {\n \"type\": \"string\"\n },\n \"tryMessage\": {\n \"type\": \"string\"\n },\n \"nextSteps\": {\n \"type\": \"array\",\n \"items\": {\n \"type\": \"string\"\n }\n },\n \"customSections\": {\n \"type\": \"array\",\n \"items\": {\n \"type\": \"object\",\n \"properties\": {\n \"title\": {\n \"type\": \"string\"\n },\n \"body\": {\n \"anyOf\": [\n {\n \"type\": \"string\"\n },\n {\n \"type\": \"array\",\n \"items\": {\n \"type\": \"array\",\n \"items\": {\n \"type\": \"string\"\n }\n }\n }\n ]\n }\n },\n \"required\": [\n \"body\"\n ],\n \"additionalProperties\": false\n }\n },\n \"details\": {}\n },\n \"required\": [\n \"type\",\n \"message\"\n ],\n \"additionalProperties\": false\n },\n {\n \"type\": \"object\",\n \"properties\": {\n \"type\": {\n \"type\": \"string\",\n \"const\": \"bug\"\n },\n \"message\": {\n \"$ref\": \"#/definitions/ThemeEnvironment/anyOf/1/properties/error/anyOf/0/properties/message\"\n },\n \"tryMessage\": {\n \"$ref\": \"#/definitions/ThemeEnvironment/anyOf/1/properties/error/anyOf/0/properties/tryMessage\"\n },\n \"nextSteps\": {\n \"$ref\": \"#/definitions/ThemeEnvironment/anyOf/1/properties/error/anyOf/0/properties/nextSteps\"\n },\n \"customSections\": {\n \"$ref\": \"#/definitions/ThemeEnvironment/anyOf/1/properties/error/anyOf/0/properties/customSections\"\n },\n \"details\": {\n \"$ref\": \"#/definitions/ThemeEnvironment/anyOf/1/properties/error/anyOf/0/properties/details\"\n },\n \"stack\": {\n \"type\": \"string\"\n }\n },\n \"required\": [\n \"type\",\n \"message\"\n ],\n \"additionalProperties\": false\n },\n {\n \"type\": \"object\",\n \"properties\": {\n \"type\": {\n \"type\": \"string\",\n \"const\": \"external\"\n },\n \"message\": {\n \"$ref\": \"#/definitions/ThemeEnvironment/anyOf/1/properties/error/anyOf/0/properties/message\"\n },\n \"tryMessage\": {\n \"$ref\": \"#/definitions/ThemeEnvironment/anyOf/1/properties/error/anyOf/0/properties/tryMessage\"\n },\n \"nextSteps\": {\n \"$ref\": \"#/definitions/ThemeEnvironment/anyOf/1/properties/error/anyOf/0/properties/nextSteps\"\n },\n \"customSections\": {\n \"$ref\": \"#/definitions/ThemeEnvironment/anyOf/1/properties/error/anyOf/0/properties/customSections\"\n },\n \"details\": {\n \"$ref\": \"#/definitions/ThemeEnvironment/anyOf/1/properties/error/anyOf/0/properties/details\"\n },\n \"command\": {\n \"type\": \"string\"\n },\n \"args\": {\n \"type\": \"array\",\n \"items\": {\n \"type\": \"string\"\n }\n }\n },\n \"required\": [\n \"type\",\n \"message\",\n \"command\",\n \"args\"\n ],\n \"additionalProperties\": false\n }\n ]\n }\n },\n \"required\": [\n \"environment\",\n \"error\"\n ],\n \"additionalProperties\": false\n }\n ]\n }\n },\n \"$schema\": \"http://json-schema.org/draft-07/schema#\"\n}\n```", + "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/ThemeEnvironment/anyOf/0/properties/result\"\n },\n {\n \"type\": \"object\",\n \"properties\": {\n \"environments\": {\n \"type\": \"array\",\n \"items\": {\n \"$ref\": \"#/definitions/ThemeEnvironment\"\n }\n }\n },\n \"required\": [\n \"environments\"\n ],\n \"additionalProperties\": false\n }\n ],\n \"title\": \"ThemeListResult\",\n \"definitions\": {\n \"Theme\": {\n \"type\": \"object\",\n \"properties\": {\n \"id\": {\n \"type\": \"string\",\n \"pattern\": \"^\\\\d+$\",\n \"description\": \"The decimal Online Store theme ID, not a Shopify GID.\"\n },\n \"name\": {\n \"type\": \"string\"\n },\n \"role\": {\n \"type\": \"string\",\n \"description\": \"The upstream theme role; known values include live, unpublished, and development.\"\n }\n },\n \"required\": [\n \"id\",\n \"name\",\n \"role\"\n ],\n \"additionalProperties\": false\n },\n \"ThemeEnvironment\": {\n \"anyOf\": [\n {\n \"type\": \"object\",\n \"properties\": {\n \"environment\": {\n \"type\": \"string\"\n },\n \"result\": {\n \"anyOf\": [\n {\n \"type\": \"object\",\n \"properties\": {\n \"themes\": {\n \"type\": \"array\",\n \"items\": {\n \"type\": \"object\",\n \"properties\": {\n \"id\": {\n \"$ref\": \"#/definitions/Theme/properties/id\"\n },\n \"name\": {\n \"$ref\": \"#/definitions/Theme/properties/name\"\n },\n \"role\": {\n \"$ref\": \"#/definitions/Theme/properties/role\"\n },\n \"processing\": {\n \"type\": \"boolean\"\n }\n },\n \"required\": [\n \"id\",\n \"name\",\n \"role\",\n \"processing\"\n ],\n \"additionalProperties\": false\n }\n }\n },\n \"required\": [\n \"themes\"\n ],\n \"additionalProperties\": false,\n \"description\": \"The complete theme library after applying the requested filters.\"\n },\n {\n \"type\": \"object\",\n \"properties\": {\n \"status\": {\n \"type\": \"string\",\n \"const\": \"cancelled\"\n }\n },\n \"required\": [\n \"status\"\n ],\n \"additionalProperties\": false\n }\n ]\n }\n },\n \"required\": [\n \"environment\",\n \"result\"\n ],\n \"additionalProperties\": false\n },\n {\n \"type\": \"object\",\n \"properties\": {\n \"environment\": {\n \"type\": \"string\"\n },\n \"error\": {\n \"anyOf\": [\n {\n \"type\": \"object\",\n \"properties\": {\n \"type\": {\n \"type\": \"string\",\n \"const\": \"abort\"\n },\n \"message\": {\n \"type\": \"string\"\n },\n \"code\": {\n \"type\": \"string\",\n \"minLength\": 1,\n \"description\": \"A stable error code, included only when known.\"\n },\n \"tryMessage\": {\n \"type\": \"string\"\n },\n \"nextSteps\": {\n \"type\": \"array\",\n \"items\": {\n \"type\": \"string\"\n }\n },\n \"customSections\": {\n \"type\": \"array\",\n \"items\": {\n \"type\": \"object\",\n \"properties\": {\n \"title\": {\n \"type\": \"string\"\n },\n \"body\": {\n \"anyOf\": [\n {\n \"type\": \"string\"\n },\n {\n \"type\": \"array\",\n \"items\": {\n \"type\": \"array\",\n \"items\": {\n \"type\": \"string\"\n }\n }\n }\n ]\n }\n },\n \"required\": [\n \"body\"\n ],\n \"additionalProperties\": false\n }\n },\n \"details\": {\n \"description\": \"Selected domain details, preserving native API payloads such as GraphQL errors, extensions, and data.\"\n }\n },\n \"required\": [\n \"type\",\n \"message\"\n ],\n \"additionalProperties\": false\n },\n {\n \"type\": \"object\",\n \"properties\": {\n \"type\": {\n \"type\": \"string\",\n \"const\": \"bug\"\n },\n \"message\": {\n \"$ref\": \"#/definitions/ThemeEnvironment/anyOf/1/properties/error/anyOf/0/properties/message\"\n },\n \"code\": {\n \"$ref\": \"#/definitions/ThemeEnvironment/anyOf/1/properties/error/anyOf/0/properties/code\"\n },\n \"tryMessage\": {\n \"$ref\": \"#/definitions/ThemeEnvironment/anyOf/1/properties/error/anyOf/0/properties/tryMessage\"\n },\n \"nextSteps\": {\n \"$ref\": \"#/definitions/ThemeEnvironment/anyOf/1/properties/error/anyOf/0/properties/nextSteps\"\n },\n \"customSections\": {\n \"$ref\": \"#/definitions/ThemeEnvironment/anyOf/1/properties/error/anyOf/0/properties/customSections\"\n },\n \"details\": {\n \"$ref\": \"#/definitions/ThemeEnvironment/anyOf/1/properties/error/anyOf/0/properties/details\"\n },\n \"stack\": {\n \"type\": \"string\"\n }\n },\n \"required\": [\n \"type\",\n \"message\"\n ],\n \"additionalProperties\": false\n },\n {\n \"type\": \"object\",\n \"properties\": {\n \"type\": {\n \"type\": \"string\",\n \"const\": \"external\"\n },\n \"message\": {\n \"$ref\": \"#/definitions/ThemeEnvironment/anyOf/1/properties/error/anyOf/0/properties/message\"\n },\n \"code\": {\n \"$ref\": \"#/definitions/ThemeEnvironment/anyOf/1/properties/error/anyOf/0/properties/code\"\n },\n \"tryMessage\": {\n \"$ref\": \"#/definitions/ThemeEnvironment/anyOf/1/properties/error/anyOf/0/properties/tryMessage\"\n },\n \"nextSteps\": {\n \"$ref\": \"#/definitions/ThemeEnvironment/anyOf/1/properties/error/anyOf/0/properties/nextSteps\"\n },\n \"customSections\": {\n \"$ref\": \"#/definitions/ThemeEnvironment/anyOf/1/properties/error/anyOf/0/properties/customSections\"\n },\n \"details\": {\n \"$ref\": \"#/definitions/ThemeEnvironment/anyOf/1/properties/error/anyOf/0/properties/details\"\n },\n \"command\": {\n \"type\": \"string\"\n },\n \"args\": {\n \"type\": \"array\",\n \"items\": {\n \"type\": \"string\"\n }\n }\n },\n \"required\": [\n \"type\",\n \"message\",\n \"command\",\n \"args\"\n ],\n \"additionalProperties\": false\n }\n ]\n }\n },\n \"required\": [\n \"environment\",\n \"error\"\n ],\n \"additionalProperties\": false\n }\n ]\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": { diff --git a/packages/theme/src/cli/commands/theme/list.test.ts b/packages/theme/src/cli/commands/theme/list.test.ts index d8b3373430d..6b55a2bd16c 100644 --- a/packages/theme/src/cli/commands/theme/list.test.ts +++ b/packages/theme/src/cli/commands/theme/list.test.ts @@ -1,11 +1,10 @@ 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' +import {mockAndCaptureOutput, mockAndCaptureStandardStreams} from '@shopify/cli-kit/node/testing/output' vi.mock('../../services/list.js') vi.mock('@shopify/cli-kit/node/session') @@ -55,7 +54,7 @@ test.each(['single', 'named single', 'multiple', 'partial failure', 'total failu if (session.storeFqdn.startsWith('first')) await new Promise((resolve) => setTimeout(resolve, 10)) return [theme] }) - const streams = captureStandardStreams() + const streams = mockAndCaptureStandardStreams() const originalExitCode = process.exitCode try { const namedEnvironments = mode === 'named single' ? ['first'] : ['first', 'second'] @@ -80,10 +79,10 @@ test.each(['single', 'named single', 'multiple', 'partial failure', 'total failu environments: (mode === 'named single' ? ['first'] : ['first', 'second']).map((environment) => successfulEnvironments.includes(environment) ? {environment, result: {themes: [publicTheme]}} - : {environment, error: {type: 'abort', message: 'Fetch failed'}}, + : {environment, error: {type: 'bug', message: 'Fetch failed', stack: expect.any(String)}}, ), } - expect(streams.stdout()).toBe(`${JSON.stringify(expected, null, 2)}\n`) + expect(streams.stdout()).toBe(`${JSON.stringify(JSON.parse(streams.stdout()), null, 2)}\n`) expect(JSON.parse(streams.stdout())).toEqual(expected) if (mode.includes('failure')) { expect(streams.stderr()).toContain('Fetch failed') diff --git a/packages/theme/src/cli/commands/theme/list.ts b/packages/theme/src/cli/commands/theme/list.ts index 71ea09edefe..b4818f37351 100644 --- a/packages/theme/src/cli/commands/theme/list.ts +++ b/packages/theme/src/cli/commands/theme/list.ts @@ -51,10 +51,6 @@ export default class List extends ThemeCommand { }) } - protected collectsEnvironmentResults(flags: {json?: boolean}): boolean { - return Boolean(flags.json) - } - protected renderEnvironmentResults(environments: {environment: string; result: unknown}[]): void { outputResult(themeListJsonOutputSchema.encode({environments})) } diff --git a/packages/theme/src/cli/services/list.test.ts b/packages/theme/src/cli/services/list.test.ts index 4bec74d1813..6335250f22b 100644 --- a/packages/theme/src/cli/services/list.test.ts +++ b/packages/theme/src/cli/services/list.test.ts @@ -6,9 +6,13 @@ import {Theme} from '@shopify/cli-kit/node/themes/types' import {renderInfo} from '@shopify/cli-kit/node/ui' import {describe, expect, vi, test} from 'vitest' import {getHostTheme} from '@shopify/cli-kit/node/themes/conf' -import {mockAndCaptureOutput} from '@shopify/cli-kit/node/testing/output' +import {mockAndCaptureOutput, withCapturedStandardStreams} from '@shopify/cli-kit/node/testing/output' +import {AbortError} from '@shopify/cli-kit/node/error' -vi.mock('../utilities/theme-selector/fetch.js') +vi.mock('../utilities/theme-selector/fetch.js', async (importOriginal) => ({ + ...(await importOriginal()), + fetchStoreThemes: vi.fn(), +})) vi.mock('@shopify/cli-kit/node/ui') vi.mock('@shopify/cli-kit/node/themes/conf') vi.mock('./local-storage.js') @@ -19,6 +23,36 @@ const session = { } describe('list', () => { + test.each([{role: 'development' as const}, {name: 'missing'}, {name: '*missing*'}, {id: 999}])( + 'returns an empty JSON collection when no themes match %j', + async (options) => { + vi.mocked(fetchStoreThemes).mockResolvedValue([ + {id: 1, name: 'Dawn', processing: false, createdAtRuntime: false, role: 'live'}, + ]) + + const result = await list({...options, json: true}, session) + expect(result).toEqual([]) + expect(fetchStoreThemes).toHaveBeenCalledWith(session, {allowEmpty: true}) + await withCapturedStandardStreams(async ({stdout, stderr}) => { + renderThemeListResult(result, 'json', {store: session.storeFqdn}) + expect(JSON.parse(stdout())).toEqual({themes: []}) + expect(stderr()).toBe('') + }) + }, + ) + + test.each([{role: 'development' as const}, {name: 'missing'}, {name: '*missing*'}, {id: 999}])( + 'rejects unmatched filters in text mode: %j', + async (options) => { + vi.mocked(fetchStoreThemes).mockResolvedValue([ + {id: 1, name: 'Dawn', processing: false, createdAtRuntime: false, role: 'live'}, + ]) + + await expect(list(options, session)).rejects.toBeInstanceOf(AbortError) + expect(fetchStoreThemes).toHaveBeenCalledWith(session, {allowEmpty: false}) + }, + ) + test('should call the renderInfo function, with correctly formatted data', async () => { const developmentThemeId = 5 const hostThemeId = 6 diff --git a/packages/theme/src/cli/services/list.ts b/packages/theme/src/cli/services/list.ts index 23c10a2df9c..e83d65b5346 100644 --- a/packages/theme/src/cli/services/list.ts +++ b/packages/theme/src/cli/services/list.ts @@ -4,6 +4,7 @@ import {ALLOWED_ROLES, fetchStoreThemes, Role} from '../utilities/theme-selector import {AdminSession} from '@shopify/cli-kit/node/session' interface Options { + json?: boolean role?: Role name?: string id?: number @@ -19,9 +20,10 @@ export async function list(options: Options, adminSession: AdminSession): Promis theme: options.id?.toString() ?? options.name, }) - let storeThemes = await fetchStoreThemes(adminSession) + const allowEmpty = Boolean(options.json) + let storeThemes = await fetchStoreThemes(adminSession, {allowEmpty}) if (filter.any()) { - storeThemes = filterThemes(store, storeThemes, filter) + storeThemes = filterThemes(store, storeThemes, filter, {allowEmpty}) } return storeThemes diff --git a/packages/theme/src/cli/utilities/testing/streams.ts b/packages/theme/src/cli/utilities/testing/streams.ts deleted file mode 100644 index b1745b2f1a1..00000000000 --- a/packages/theme/src/cli/utilities/testing/streams.ts +++ /dev/null @@ -1,27 +0,0 @@ -import {vi} from 'vitest' -// Use Node's console so Vitest does not intercept stderr before the stream capture. -// eslint-disable-next-line n/prefer-global/console -import {Console} from 'node:console' - -export function captureStandardStreams() { - vi.stubGlobal('console', Object.assign(new Console({stdout: process.stdout, stderr: process.stderr}), {Console})) - const stdout: string[] = [] - const stderr: string[] = [] - const stdoutSpy = vi.spyOn(process.stdout, 'write').mockImplementation((chunk: string | Uint8Array) => { - stdout.push(Buffer.from(chunk).toString('utf8')) - return true - }) - const stderrSpy = vi.spyOn(process.stderr, 'write').mockImplementation((chunk: string | Uint8Array) => { - stderr.push(Buffer.from(chunk).toString('utf8')) - return true - }) - return { - stdout: () => stdout.join(''), - stderr: () => stderr.join(''), - restore: () => { - stdoutSpy.mockRestore() - stderrSpy.mockRestore() - vi.unstubAllGlobals() - }, - } -} diff --git a/packages/theme/src/cli/utilities/theme-selector/fetch.test.ts b/packages/theme/src/cli/utilities/theme-selector/fetch.test.ts index 30c3b5a831b..e9c48f74df2 100644 --- a/packages/theme/src/cli/utilities/theme-selector/fetch.test.ts +++ b/packages/theme/src/cli/utilities/theme-selector/fetch.test.ts @@ -30,6 +30,12 @@ describe('publicFetchStoreThemes', () => { }) describe('fetchStoreThemes', () => { + test('returns an empty collection when empty results are allowed', async () => { + vi.mocked(fetchThemes).mockResolvedValue([]) + + await expect(fetchStoreThemes(session, {allowEmpty: true})).resolves.toEqual([]) + }) + test('returns only allowed themes', async () => { // Given vi.mocked(fetchThemes).mockResolvedValue([ diff --git a/packages/theme/src/cli/utilities/theme-selector/fetch.ts b/packages/theme/src/cli/utilities/theme-selector/fetch.ts index 0ec4dbee22d..947971b013d 100644 --- a/packages/theme/src/cli/utilities/theme-selector/fetch.ts +++ b/packages/theme/src/cli/utilities/theme-selector/fetch.ts @@ -17,11 +17,11 @@ export async function publicFetchStoreThemes(store: string, password: string) { return fetchStoreThemes(adminSession) } -export async function fetchStoreThemes(session: AdminSession) { +export async function fetchStoreThemes(session: AdminSession, {allowEmpty = false}: {allowEmpty?: boolean} = {}) { const store = session.storeFqdn const themes = (await fetchThemes(session)).filter(isRoleAllowed) - if (themes.length === 0) { + if (themes.length === 0 && !allowEmpty) { throw new AbortError(`There are no themes in the ${store} store`) } diff --git a/packages/theme/src/cli/utilities/theme-selector/filter.ts b/packages/theme/src/cli/utilities/theme-selector/filter.ts index d7a5b11f6bd..68cf9a18490 100644 --- a/packages/theme/src/cli/utilities/theme-selector/filter.ts +++ b/packages/theme/src/cli/utilities/theme-selector/filter.ts @@ -2,11 +2,16 @@ import {ALLOWED_ROLES} from './fetch.js' import {Theme} from '@shopify/cli-kit/node/themes/types' import {AbortError} from '@shopify/cli-kit/node/error' -export function filterThemes(store: string, themes: Theme[], filter: Filter): Theme[] { - return filterByRole(store, themes, filter) ?? filterByTheme(store, themes, filter) +export function filterThemes( + store: string, + themes: Theme[], + filter: Filter, + {allowEmpty = false}: {allowEmpty?: boolean} = {}, +): Theme[] { + return filterByRole(store, themes, filter, allowEmpty) ?? filterByTheme(store, themes, filter, allowEmpty) } -function filterByRole(store: string, themes: Theme[], filter: Filter) { +function filterByRole(store: string, themes: Theme[], filter: Filter, allowEmpty: boolean) { const role = filter.role if (!role) { @@ -17,10 +22,10 @@ function filterByRole(store: string, themes: Theme[], filter: Filter) { return filterArray(themes, (theme) => { return theme.role === role - }).orThrow(error) + }).orThrow(error, allowEmpty) } -function filterByTheme(store: string, themes: Theme[], filter: Filter) { +function filterByTheme(store: string, themes: Theme[], filter: Filter, allowEmpty: boolean) { const identifiers = filter.themeIdentifiers return identifiers.flatMap((identifier) => { @@ -36,14 +41,14 @@ function filterByTheme(store: string, themes: Theme[], filter: Filter) { } return isPartialMatch(theme.name.toLowerCase(), identifier.toLowerCase()) - }).orThrow(error) + }).orThrow(error, allowEmpty) }) } function filterArray( themes: Theme[], predicate: (theme: Theme) => boolean, -): {orThrow: (error: string) => Theme[] | never} { +): {orThrow: (error: string, allowEmpty: boolean) => Theme[]} { const filteredThemes = themes.filter(predicate) if (filteredThemes.length > 0) { @@ -51,7 +56,8 @@ function filterArray( } return { - orThrow: (errorMessage: string) => { + orThrow: (errorMessage: string, allowEmpty: boolean) => { + if (allowEmpty) return [] throw new AbortError(errorMessage) }, }