diff --git a/packages/cli/README.md b/packages/cli/README.md index e86afdd0f26..52035094b28 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -8273,6 +8273,86 @@ FLAGS DESCRIPTION Lists the themes in your store, along with their IDs and statuses. + + Use `--json-schema` to print the result, error, and event schemas. + + Output from `--json` conforms to the `ThemeListResult` schema. + + ```json + { + "anyOf": [ + { + "$ref": "#/definitions/ThemeListEnvironment/properties/result" + }, + { + "type": "object", + "properties": { + "environments": { + "type": "array", + "items": { + "$ref": "#/definitions/ThemeListEnvironment" + } + } + }, + "required": [ + "environments" + ], + "additionalProperties": false + } + ], + "title": "ThemeListResult", + "definitions": { + "ThemeListTheme": { + "type": "object", + "properties": { + "id": { + "type": "number" + }, + "name": { + "type": "string" + }, + "processing": { + "type": "boolean" + }, + "createdAtRuntime": { + "type": "boolean" + }, + "role": { + "type": "string" + } + }, + "required": [ + "id", + "name", + "processing", + "createdAtRuntime", + "role" + ], + "additionalProperties": false + }, + "ThemeListEnvironment": { + "type": "object", + "properties": { + "environment": { + "type": "string" + }, + "result": { + "type": "array", + "items": { + "$ref": "#/definitions/ThemeListTheme" + } + } + }, + "required": [ + "environment", + "result" + ], + "additionalProperties": false + } + }, + "$schema": "http://json-schema.org/draft-07/schema#" + } + ``` ``` ## `shopify theme metafields pull` diff --git a/packages/cli/oclif.manifest.json b/packages/cli/oclif.manifest.json index 0e1205a87f5..003b5b78a54 100644 --- a/packages/cli/oclif.manifest.json +++ b/packages/cli/oclif.manifest.json @@ -10379,7 +10379,8 @@ "args": { }, "customPluginName": "@shopify/theme", - "description": "Lists the themes in your store, along with their IDs and statuses.", + "description": "Lists the themes in your store, along with their IDs and statuses.\n\nUse `--json-schema` to print the result, error, and event schemas.\n\nOutput from `--json` conforms to the `ThemeListResult` schema.\n\n```json\n{\n \"anyOf\": [\n {\n \"$ref\": \"#/definitions/ThemeListEnvironment/properties/result\"\n },\n {\n \"type\": \"object\",\n \"properties\": {\n \"environments\": {\n \"type\": \"array\",\n \"items\": {\n \"$ref\": \"#/definitions/ThemeListEnvironment\"\n }\n }\n },\n \"required\": [\n \"environments\"\n ],\n \"additionalProperties\": false\n }\n ],\n \"title\": \"ThemeListResult\",\n \"definitions\": {\n \"ThemeListTheme\": {\n \"type\": \"object\",\n \"properties\": {\n \"id\": {\n \"type\": \"number\"\n },\n \"name\": {\n \"type\": \"string\"\n },\n \"processing\": {\n \"type\": \"boolean\"\n },\n \"createdAtRuntime\": {\n \"type\": \"boolean\"\n },\n \"role\": {\n \"type\": \"string\"\n }\n },\n \"required\": [\n \"id\",\n \"name\",\n \"processing\",\n \"createdAtRuntime\",\n \"role\"\n ],\n \"additionalProperties\": false\n },\n \"ThemeListEnvironment\": {\n \"type\": \"object\",\n \"properties\": {\n \"environment\": {\n \"type\": \"string\"\n },\n \"result\": {\n \"type\": \"array\",\n \"items\": {\n \"$ref\": \"#/definitions/ThemeListTheme\"\n }\n }\n },\n \"required\": [\n \"environment\",\n \"result\"\n ],\n \"additionalProperties\": false\n }\n },\n \"$schema\": \"http://json-schema.org/draft-07/schema#\"\n}\n```", + "descriptionWithMarkdown": "Lists the themes in your store, along with their IDs and statuses.", "enableJsonFlag": false, "flags": { "auth-alias": { 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 831a8691a85..6258320382b 100644 --- a/packages/eslint-plugin-cli/rules/json-output-command-exceptions.js +++ b/packages/eslint-plugin-cli/rules/json-output-command-exceptions.js @@ -47,7 +47,6 @@ const commandExceptions = [ 'packages/theme/src/cli/commands/theme/delete.ts', 'packages/theme/src/cli/commands/theme/duplicate.ts', 'packages/theme/src/cli/commands/theme/init.ts', - 'packages/theme/src/cli/commands/theme/list.ts', 'packages/theme/src/cli/commands/theme/metafields/pull.ts', 'packages/theme/src/cli/commands/theme/open.ts', 'packages/theme/src/cli/commands/theme/package.ts', 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() + }, + } +} diff --git a/packages/theme/src/cli/utilities/theme-command.ts b/packages/theme/src/cli/utilities/theme-command.ts index 18f594e1786..01adf083e66 100644 --- a/packages/theme/src/cli/utilities/theme-command.ts +++ b/packages/theme/src/cli/utilities/theme-command.ts @@ -25,6 +25,7 @@ import {AbortController} from '@shopify/cli-kit/node/abort' import {AbortError} from '@shopify/cli-kit/node/error' import {recordEvent, compileData} from '@shopify/cli-kit/node/analytics' import {addPublicMetadata, addSensitiveMetadata} from '@shopify/cli-kit/node/metadata' +import {commandEventOutputMode, emitCommandEvent} from '@shopify/cli-kit/node/command-events' import {outputDebug} from '@shopify/cli-kit/node/output' import {cwd, joinPath, resolvePath} from '@shopify/cli-kit/node/path' import {fileExistsSync} from '@shopify/cli-kit/node/fs' @@ -69,7 +70,9 @@ export default abstract class ThemeCommand extends Command { _multiEnvironment = false, _args?: ArgOutput, _context?: {stdout?: Writable; stderr?: Writable}, - ): Promise {} + ): Promise { + return undefined + } async run< TFlags extends FlagOutput & {path?: string; verbose?: boolean}, @@ -127,7 +130,13 @@ export default abstract class ThemeCommand extends Command { } const environmentsMap = await this.loadEnvironments(environments, flags, flagsWithoutDefaults) - const validationResults = await this.validateEnvironments(environmentsMap, requiredFlags, commandRequiresAuth) + const collectResults = this.collectsEnvironmentResults(flags) + const validationResults = await this.validateEnvironments( + environmentsMap, + requiredFlags, + commandRequiresAuth, + collectResults, + ) const commandAllowsForceFlag = 'force' in klass.flags @@ -140,9 +149,22 @@ export default abstract class ThemeCommand extends Command { if (!confirmed) return } - await this.runConcurrent(validationResults.valid) + const results = await this.runConcurrent(validationResults.valid, collectResults) + if (collectResults) { + this.renderEnvironmentResults( + validationResults.valid.flatMap(({environment}) => + results.has(environment) ? [{environment, result: results.get(environment)}] : [], + ), + ) + } } + protected collectsEnvironmentResults(_flags: FlagValues): boolean { + return false + } + + protected renderEnvironmentResults(_results: {environment: string; result: unknown}[]): void {} + protected validateNonTTYFlags(flags: FlagOutput): void { // Multiple environments must be validated after their configured flags are loaded. const command = this.constructor @@ -216,6 +238,7 @@ export default abstract class ThemeCommand extends Command { environmentMap: Map, requiredFlags: Exclude, requiresAuth: boolean, + collectResults = false, ) { const valid: ValidEnvironment[] = [] const invalid: {environment: EnvironmentName; reason: string}[] = [] @@ -234,7 +257,13 @@ export default abstract class ThemeCommand extends Command { ) for (const {environmentName, flags, validationFlags, storeAuthSession} of entriesWithStoreAuthSessions) { - const validationResult = this.validConfig(validationFlags, requiredFlags, environmentName, storeAuthSession) + const validationResult = this.validConfig( + validationFlags, + requiredFlags, + environmentName, + storeAuthSession, + collectResults, + ) if (validationResult !== true) { const missingFlagsText = validationResult.join(', ') invalid.push({environment: environmentName, reason: `Missing flags: ${missingFlagsText}`}) @@ -306,7 +335,8 @@ export default abstract class ThemeCommand extends Command { * Run the command in each valid environment concurrently * @param validEnvironments - The valid environments to run the command in */ - private async runConcurrent(validEnvironments: ValidEnvironment[]) { + private async runConcurrent(validEnvironments: ValidEnvironment[], collectResults = false) { + const results = new Map() const abortController = new AbortController() const stores = validEnvironments.map((env) => env.flags.store as string) @@ -315,40 +345,65 @@ export default abstract class ThemeCommand extends Command { stores.length === uniqueStores.size ? [validEnvironments] : this.createSequentialGroups(validEnvironments) for (const runGroup of runGroups) { - // eslint-disable-next-line no-await-in-loop - await renderConcurrent({ - processes: runGroup.map(({environment, flags, requiresAuth, storeAuthSession}) => ({ - prefix: environment, - action: async (stdout: Writable, stderr: Writable, _signal) => { - try { - const store = flags.store as string - await useThemeStoreContext(store, async () => { - const session = requiresAuth ? await this.createSession(flags, storeAuthSession) : undefined - - const commandName = this.constructor.name.toLowerCase() - recordEvent(`theme-command:${commandName}:multi-env:authenticated`) - - try { - await this.command(flags, session, true, {}, {stdout, stderr}) - } finally { - await this.logAnalyticsData(session) - } - }) - - // eslint-disable-next-line no-catch-all/no-catch-all - } catch (error) { - if (error instanceof Error) { - error.message = `Environment ${environment} failed: \n\n${error.message}` + const processes = runGroup.map(({environment, flags, requiresAuth, storeAuthSession}) => ({ + prefix: environment, + action: async (stdout: Writable, stderr: Writable, _signal: AbortSignal) => { + try { + const store = flags.store as string + const result = await useThemeStoreContext(store, async () => { + const session = requiresAuth ? await this.createSession(flags, storeAuthSession) : undefined + + const commandName = this.constructor.name.toLowerCase() + recordEvent(`theme-command:${commandName}:multi-env:authenticated`) + + try { + return await this.command( + collectResults ? {...flags, json: true} : flags, + session, + true, + {}, + {stdout, stderr}, + ) + } finally { + await this.logAnalyticsData(session) + } + }) + + results.set(environment, result) + + // eslint-disable-next-line no-catch-all/no-catch-all + } catch (error) { + if (error instanceof Error) { + error.message = `Environment ${environment} failed: \n\n${error.message}` + if (collectResults || commandEventOutputMode() === 'json') { + emitCommandEvent({ + type: 'diagnostic', + level: 'error', + code: 'theme-environment-failed', + message: error.message, + }) + } else { renderError({body: [error.message]}) } } - }, - })), - abortSignal: abortController.signal, - showTimestamps: true, - renderOptions: {stdout: process.stderr}, - }) + } + }, + })) + if (collectResults) { + // JSON results and events already have their own writers; Ink would decorate their output. + // eslint-disable-next-line no-await-in-loop + await Promise.all(processes.map(({action}) => action(process.stdout, process.stderr, abortController.signal))) + } else { + // eslint-disable-next-line no-await-in-loop + await renderConcurrent({ + processes, + abortSignal: abortController.signal, + showTimestamps: true, + renderOptions: {stdout: process.stderr}, + }) + } } + return results } /** @@ -496,6 +551,7 @@ export default abstract class ThemeCommand extends Command { requiredFlags: Exclude, environmentName: string, storeAuthSession?: AdminSession, + collectResults = false, ): string[] | true { const missingFlags = requiredFlags .filter((flag) => @@ -506,12 +562,21 @@ export default abstract class ThemeCommand extends Command { .map((flag) => (Array.isArray(flag) ? flag.join(' or ') : flag)) if (missingFlags.length > 0) { - renderWarning({ - body: [ - `Missing required flags in environment configuration${environmentName ? ` for ${environmentName}` : ''}:`, - {list: {items: missingFlags}}, - ], - }) + if (collectResults || commandEventOutputMode() === 'json') { + emitCommandEvent({ + type: 'diagnostic', + level: 'warning', + code: 'theme-environment-invalid', + message: `Missing required flags in environment configuration for ${environmentName}: ${missingFlags.join(', ')}`, + }) + } else { + renderWarning({ + body: [ + `Missing required flags in environment configuration${environmentName ? ` for ${environmentName}` : ''}:`, + {list: {items: missingFlags}}, + ], + }) + } return missingFlags }