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 43549d09578..2499cde2e71 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -12611,6 +12611,293 @@ 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/ThemeEnvironment/anyOf/0/properties/result" + }, + { + "type": "object", + "properties": { + "environments": { + "type": "array", + "items": { + "$ref": "#/definitions/ThemeEnvironment" + } + } + }, + "required": [ + "environments" + ], + "additionalProperties": false + } + ], + "title": "ThemeListResult", + "definitions": { + "Theme": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^\\d+$", + "description": "The decimal Online Store theme ID, not a Shopify GID." + }, + "name": { + "type": "string" + }, + "role": { + "type": "string", + "description": "The upstream theme role; known values include live, unpublished, and development." + } + }, + "required": [ + "id", + "name", + "role" + ], + "additionalProperties": false + }, + "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 + }, + { + "type": "object", + "properties": { + "environment": { + "type": "string" + }, + "error": { + "anyOf": [ + { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "abort" + }, + "message": { + "type": "string" + }, + "code": { + "type": "string", + "minLength": 1, + "description": "A stable error code, included only when known." + }, + "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": { + "description": "Selected domain details, preserving native API payloads such as GraphQL errors, extensions, and data." + } + }, + "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" + }, + "code": { + "$ref": "#/definitions/ThemeEnvironment/anyOf/1/properties/error/anyOf/0/properties/code" + }, + "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" + }, + "code": { + "$ref": "#/definitions/ThemeEnvironment/anyOf/1/properties/error/anyOf/0/properties/code" + }, + "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 + } + ] + } + }, + "$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..33838e01fbc 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/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": { "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..6b55a2bd16c --- /dev/null +++ b/packages/theme/src/cli/commands/theme/list.test.ts @@ -0,0 +1,109 @@ +import List from './list.js' +import {list} from '../../services/list.js' +import {themeListJsonOutputSchema} from '../../services/list/types.js' +import {Config} from '@oclif/core' +import {afterEach, expect, test, vi} from 'vitest' +import {ensureAuthenticatedThemes} from '@shopify/cli-kit/node/session' +import {mockAndCaptureOutput, mockAndCaptureStandardStreams} 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'} +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({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', 'named 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 = mockAndCaptureStandardStreams() + const originalExitCode = process.exitCode + try { + const namedEnvironments = mode === 'named single' ? ['first'] : ['first', 'second'] + const args = + mode === 'single' + ? ['--store=single.myshopify.com', '--password=token'] + : 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' + ? {themes: [publicTheme]} + : { + environments: (mode === 'named single' ? ['first'] : ['first', 'second']).map((environment) => + successfulEnvironments.includes(environment) + ? {environment, result: {themes: [publicTheme]}} + : {environment, error: {type: 'bug', message: 'Fetch failed', stack: expect.any(String)}}, + ), + } + 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') + 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..b4818f37351 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,16 @@ 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 renderEnvironmentResults(environments: {environment: string; result: unknown}[]): void { + outputResult(themeListJsonOutputSchema.encode({environments})) } } 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(), diff --git a/packages/theme/src/cli/services/list.test.ts b/packages/theme/src/cli/services/list.test.ts index fd7079871ab..6335250f22b 100644 --- a/packages/theme/src/cli/services/list.test.ts +++ b/packages/theme/src/cli/services/list.test.ts @@ -1,13 +1,18 @@ 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' 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') @@ -18,12 +23,42 @@ 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 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 +66,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 +96,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,25 +118,29 @@ 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", - "role": "live" - }, - { - "id": 2, - "name": "Theme 2", - "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.ts b/packages/theme/src/cli/services/list.ts index 84313242878..e83d65b5346 100644 --- a/packages/theme/src/cli/services/list.ts +++ b/packages/theme/src/cli/services/list.ts @@ -1,30 +1,16 @@ -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 { + json?: boolean 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) => { @@ -34,45 +20,11 @@ export async function list(options: Options, adminSession: AdminSession) { theme: options.id?.toString() ?? options.name, }) - let storeThemes = await fetchStoreThemes(adminSession) - const developmentTheme = getDevelopmentTheme() - const hostTheme = getHostTheme(store) + const allowEmpty = Boolean(options.json) + let storeThemes = await fetchStoreThemes(adminSession, {allowEmpty}) if (filter.any()) { - storeThemes = filterThemes(store, storeThemes, filter) - } - - if (options.json) { - return outputResult(JSON.stringify(storeThemes, null, 2)) + storeThemes = filterThemes(store, storeThemes, filter, {allowEmpty}) } - 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..1097400a1a2 --- /dev/null +++ b/packages/theme/src/cli/services/list/types.ts @@ -0,0 +1,29 @@ +import {defineThemeJsonOutputSchema, ThemeSchema as PublicThemeSchema, projectTheme} from '../json-output/schema.js' +import {zod} from '@shopify/cli-kit/node/schema' + +const ThemeListThemeSchema = zod.object({ + id: zod.number(), + name: zod.string(), + processing: zod.boolean(), + createdAtRuntime: zod.boolean(), + role: zod.string(), +}) +const ThemeListResultSchema = zod.array(ThemeListThemeSchema) +export const themeListJsonOutputSchema = defineThemeJsonOutputSchema({ + name: 'ThemeListResult', + 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 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) }, }