From 8d4ae9e9b1bc2ce3aed9580799623cfdee12dcfc Mon Sep 17 00:00:00 2001 From: Donald Merand Date: Thu, 10 Sep 2026 21:31:16 -0400 Subject: [PATCH 1/6] Add JSON schema for theme info Assisted-By: devx/9dd3b67e-58e9-4a01-a721-43265d02ccd2 --- .changeset/theme-info-json-schema.md | 5 + .../rules/json-output-command-exceptions.js | 1 - .../theme/src/cli/commands/theme/info.test.ts | 191 +++++++++++++----- packages/theme/src/cli/commands/theme/info.ts | 25 +-- packages/theme/src/cli/services/info.test.ts | 38 +++- packages/theme/src/cli/services/info.ts | 128 ++---------- .../src/cli/services/info/result.test.ts | 96 +++++++++ .../theme/src/cli/services/info/result.ts | 99 +++++++++ .../theme/src/cli/services/info/types.test.ts | 38 ++++ packages/theme/src/cli/services/info/types.ts | 38 ++++ 10 files changed, 489 insertions(+), 170 deletions(-) create mode 100644 .changeset/theme-info-json-schema.md create mode 100644 packages/theme/src/cli/services/info/result.test.ts create mode 100644 packages/theme/src/cli/services/info/result.ts create mode 100644 packages/theme/src/cli/services/info/types.test.ts create mode 100644 packages/theme/src/cli/services/info/types.ts diff --git a/.changeset/theme-info-json-schema.md b/.changeset/theme-info-json-schema.md new file mode 100644 index 00000000000..ed9595ceffe --- /dev/null +++ b/.changeset/theme-info-json-schema.md @@ -0,0 +1,5 @@ +--- +'@shopify/theme': minor +--- + +Add a JSON output schema for `theme info` 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 a74387bce8f..831a8691a85 100644 --- a/packages/eslint-plugin-cli/rules/json-output-command-exceptions.js +++ b/packages/eslint-plugin-cli/rules/json-output-command-exceptions.js @@ -46,7 +46,6 @@ const commandExceptions = [ 'packages/theme/src/cli/commands/theme/check.ts', 'packages/theme/src/cli/commands/theme/delete.ts', 'packages/theme/src/cli/commands/theme/duplicate.ts', - 'packages/theme/src/cli/commands/theme/info.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', diff --git a/packages/theme/src/cli/commands/theme/info.test.ts b/packages/theme/src/cli/commands/theme/info.test.ts index 558289e1477..e384f06240c 100644 --- a/packages/theme/src/cli/commands/theme/info.test.ts +++ b/packages/theme/src/cli/commands/theme/info.test.ts @@ -1,14 +1,15 @@ import Info from './info.js' -import {themeEnvironmentInfoJSON, fetchDevInfo, fetchThemeInfo, formatThemeInfo} from '../../services/info.js' -import {describe, vi, expect, test} from 'vitest' +import {fetchThemeInfo, getThemeEnvironmentInfo} from '../../services/info.js' +import {themeInfoJsonOutputSchema} from '../../services/info/types.js' +import {beforeEach, describe, expect, test, vi} from 'vitest' import {Config} from '@oclif/core' import {ensureAuthenticatedThemes} from '@shopify/cli-kit/node/session' -import {outputResult} from '@shopify/cli-kit/node/output' +import {mockAndCaptureOutput} from '@shopify/cli-kit/node/testing/output' import {renderInfo} from '@shopify/cli-kit/node/ui' +import {readFileSync} from 'node:fs' vi.mock('../../services/info.js') vi.mock('@shopify/cli-kit/node/session') -vi.mock('@shopify/cli-kit/node/output') vi.mock('@shopify/cli-kit/node/ui') const CommandConfig = new Config({root: __dirname}) @@ -18,86 +19,182 @@ const session = { storeFqdn: 'my-shop.myshopify.com', } -describe('Info', () => { - async function run(argv: string[]) { - await CommandConfig.load() - vi.mocked(ensureAuthenticatedThemes).mockResolvedValue(session) - const info = new Info(['--store=my-shop.myshopify.com', '--password=test-password', ...argv], CommandConfig) - await info.run() +const themeResult = { + theme: { + id: 123, + name: 'my theme', + role: 'live', + shop: 'my-shop.myshopify.com', + preview_url: 'https://my-shop.myshopify.com/preview', + editor_url: 'https://my-shop.myshopify.com/editor', + }, +} + +const environmentResult = { + store: 'my-shop.myshopify.com', + development_theme_id: null, + cli_version: '3.91.0', + os: 'darwin-arm64', + shell: '/bin/zsh', + node_version: 'v24.15.0', +} + +function restoreUnitTestEnvironment(value: string | undefined): void { + process.env.SHOPIFY_UNIT_TEST = value +} + +function captureStandardStreams() { + const stdout: string[] = [] + const stderr: string[] = [] + + const stdoutSpy = vi.spyOn(process.stdout, 'write').mockImplementation(((chunk: string | Uint8Array) => { + stdout.push(typeof chunk === 'string' ? chunk : Buffer.from(chunk).toString('utf8')) + return true + }) as typeof process.stdout.write) + const stderrSpy = vi.spyOn(process.stderr, 'write').mockImplementation(((chunk: string | Uint8Array) => { + stderr.push(typeof chunk === 'string' ? chunk : Buffer.from(chunk).toString('utf8')) + return true + }) as typeof process.stderr.write) + + return { + stdout: () => stdout.join(''), + stderr: () => stderr.join(''), + restore: () => { + stdoutSpy.mockRestore() + stderrSpy.mockRestore() + }, } +} - describe('when theme or development flag is provided', () => { - const mockThemeInfo = { - theme: { - id: 123, - name: 'my theme', - role: 'live', - shop: 'my-shop.myshopify.com', - preview_url: 'https://my-shop.myshopify.com/preview', - editor_url: 'https://my-shop.myshopify.com/editor', - }, - } +async function run(argv: string[]) { + await CommandConfig.load() + vi.mocked(ensureAuthenticatedThemes).mockResolvedValue(session) + const info = new Info(['--store=my-shop.myshopify.com', '--password=test-password', ...argv], CommandConfig) + await info.run() +} +describe('Info', () => { + beforeEach(() => { + mockAndCaptureOutput().clear() + }) + + describe('when theme or development flag is provided', () => { test('outputs JSON when --json flag is passed', async () => { - vi.mocked(fetchThemeInfo).mockResolvedValue(mockThemeInfo) + vi.mocked(fetchThemeInfo).mockResolvedValue(themeResult) await run(['--theme', '123', '--json']) expect(fetchThemeInfo).toHaveBeenCalled() - expect(outputResult).toHaveBeenCalledWith(JSON.stringify(mockThemeInfo, null, 2)) + expect(JSON.parse(mockAndCaptureOutput().output())).toEqual(themeResult) expect(renderInfo).not.toHaveBeenCalled() }) test('renders formatted info when no --json flag is passed', async () => { - const mockFormatted = { - customSections: [{title: 'Theme Details', body: {tabularData: [], firstColumnSubdued: true}}], - } - vi.mocked(fetchThemeInfo).mockResolvedValue(mockThemeInfo) - vi.mocked(formatThemeInfo).mockResolvedValue(mockFormatted) + vi.mocked(fetchThemeInfo).mockResolvedValue(themeResult) await run(['--theme', '123']) expect(fetchThemeInfo).toHaveBeenCalled() - expect(formatThemeInfo).toHaveBeenCalled() expect(renderInfo).toHaveBeenCalled() - expect(outputResult).not.toHaveBeenCalled() + expect(mockAndCaptureOutput().output()).toBe('') }) - test('throws error when theme is not found', async () => { + test('throws an error when theme is not found without rendering a result', async () => { vi.mocked(fetchThemeInfo).mockResolvedValue(undefined) - await expect(run(['--theme', '999'])).rejects.toThrow() + await expect(run(['--theme', '999'])).rejects.toThrow('Theme not found!') + expect(renderInfo).not.toHaveBeenCalled() + expect(mockAndCaptureOutput().output()).toBe('') }) }) describe('when no theme or development flag is provided', () => { test('outputs JSON when --json flag is passed', async () => { - const mockDevInfo = { - store: 'my-shop.myshopify.com', - development_theme_id: null, - cli_version: '3.91.0', - os: 'darwin-arm64', - shell: '/bin/zsh', - node_version: 'v23.6.1', - } - vi.mocked(themeEnvironmentInfoJSON).mockReturnValue(mockDevInfo) + vi.mocked(getThemeEnvironmentInfo).mockReturnValue({result: environmentResult, developmentTheme: undefined}) await run(['--json']) - expect(themeEnvironmentInfoJSON).toHaveBeenCalled() - expect(outputResult).toHaveBeenCalledWith(JSON.stringify(mockDevInfo, null, 2)) + expect(getThemeEnvironmentInfo).toHaveBeenCalledWith({cliVersion: expect.any(String)}) + expect(JSON.parse(mockAndCaptureOutput().output())).toEqual(environmentResult) expect(renderInfo).not.toHaveBeenCalled() }) test('renders info when no --json flag is passed', async () => { - const mockSections = [{title: 'Theme Configuration', body: {tabularData: [], firstColumnSubdued: true}}] - vi.mocked(fetchDevInfo).mockResolvedValue(mockSections) + vi.mocked(getThemeEnvironmentInfo).mockReturnValue({result: environmentResult, developmentTheme: undefined}) await run([]) - expect(fetchDevInfo).toHaveBeenCalled() + expect(getThemeEnvironmentInfo).toHaveBeenCalledWith({cliVersion: expect.any(String)}) expect(renderInfo).toHaveBeenCalled() - expect(outputResult).not.toHaveBeenCalled() + expect(mockAndCaptureOutput().output()).toBe('') }) }) + + test('defines the JSON output schema', () => { + expect(Info.jsonOutputSchema).toBe(themeInfoJsonOutputSchema) + }) + + test('is removed from the JSON legacy exemption list', () => { + const legacyCommandPaths = readFileSync( + new URL('../../../../../eslint-plugin-cli/rules/json-output-legacy-command-paths.js', import.meta.url), + 'utf8', + ) + + expect(legacyCommandPaths).not.toContain("'packages/theme/src/cli/commands/theme/info.ts'") + }) + + test('writes the selected theme JSON document to stdout without text on stderr', async () => { + const originalUnitTestEnv = process.env.SHOPIFY_UNIT_TEST + process.env.SHOPIFY_UNIT_TEST = 'false' + vi.resetModules() + const streams = captureStandardStreams() + + try { + const {default: StreamInfo} = await import('./info.js') + const {fetchThemeInfo} = await import('../../services/info.js') + const {ensureAuthenticatedThemes} = await import('@shopify/cli-kit/node/session') + const {Config} = await import('@oclif/core') + const streamConfig = new Config({root: __dirname}) + await streamConfig.load() + vi.mocked(ensureAuthenticatedThemes).mockResolvedValue(session) + vi.mocked(fetchThemeInfo).mockResolvedValue(themeResult) + + await new StreamInfo( + ['--store=my-shop.myshopify.com', '--password=test-password', '--theme', '123', '--json'], + streamConfig, + ).run() + } finally { + streams.restore() + restoreUnitTestEnvironment(originalUnitTestEnv) + } + + expect(JSON.parse(streams.stdout())).toEqual(themeResult) + expect(streams.stderr()).toBe('') + }) + + test('writes the environment JSON document to stdout without text on stderr', async () => { + const originalUnitTestEnv = process.env.SHOPIFY_UNIT_TEST + process.env.SHOPIFY_UNIT_TEST = 'false' + vi.resetModules() + const streams = captureStandardStreams() + + try { + const {default: StreamInfo} = await import('./info.js') + const {getThemeEnvironmentInfo} = await import('../../services/info.js') + const {ensureAuthenticatedThemes} = await import('@shopify/cli-kit/node/session') + const {Config} = await import('@oclif/core') + const streamConfig = new Config({root: __dirname}) + await streamConfig.load() + vi.mocked(ensureAuthenticatedThemes).mockResolvedValue(session) + vi.mocked(getThemeEnvironmentInfo).mockReturnValue({result: environmentResult, developmentTheme: undefined}) + + await new StreamInfo(['--store=my-shop.myshopify.com', '--password=test-password', '--json'], streamConfig).run() + } finally { + streams.restore() + restoreUnitTestEnvironment(originalUnitTestEnv) + } + + expect(JSON.parse(streams.stdout())).toEqual(environmentResult) + expect(streams.stderr()).toBe('') + }) }) diff --git a/packages/theme/src/cli/commands/theme/info.ts b/packages/theme/src/cli/commands/theme/info.ts index 71a7dbdf92b..5feb6fe6f8f 100644 --- a/packages/theme/src/cli/commands/theme/info.ts +++ b/packages/theme/src/cli/commands/theme/info.ts @@ -1,12 +1,12 @@ import ThemeCommand from '../../utilities/theme-command.js' -import {fetchThemeInfo, fetchDevInfo, formatThemeInfo, themeEnvironmentInfoJSON} from '../../services/info.js' +import {fetchThemeInfo, getThemeEnvironmentInfo} from '../../services/info.js' +import {renderThemeInfoResult} from '../../services/info/result.js' +import {themeInfoJsonOutputSchema} from '../../services/info/types.js' import {themeFlags} from '../../flags.js' import {Flags} from '@oclif/core' import {AdminSession} from '@shopify/cli-kit/node/session' import {AbortError} from '@shopify/cli-kit/node/error' import {globalFlags, jsonFlag} from '@shopify/cli-kit/node/cli' -import {outputResult} from '@shopify/cli-kit/node/output' -import {renderInfo} from '@shopify/cli-kit/node/ui' import {OutputFlags} from '@oclif/core/interfaces' import {recordTiming} from '@shopify/cli-kit/node/analytics' @@ -34,6 +34,10 @@ export default class Info extends ThemeCommand { static multiEnvironmentsFlags = ['store', 'password'] + static get jsonOutputSchema() { + return themeInfoJsonOutputSchema + } + async command(flags: InfoFlags, adminSession: AdminSession): Promise { recordTiming('theme-command:info') if (flags.theme || flags.development) { @@ -42,19 +46,10 @@ export default class Info extends ThemeCommand { throw new AbortError('Theme not found!') } - if (flags.json) { - return outputResult(JSON.stringify(output, null, 2)) - } - - const formattedInfo = await formatThemeInfo(output, flags) - renderInfo(formattedInfo) + renderThemeInfoResult(output, flags.json ? 'json' : 'text', flags) } else { - if (flags.json) { - return outputResult(JSON.stringify(themeEnvironmentInfoJSON({cliVersion: this.config.version}), null, 2)) - } - const infoMessage = await fetchDevInfo({cliVersion: this.config.version}) - - renderInfo({customSections: infoMessage}) + const {result, developmentTheme} = getThemeEnvironmentInfo({cliVersion: this.config.version}) + renderThemeInfoResult(result, flags.json ? 'json' : 'text', {developmentTheme}) } recordTiming('theme-command:info') } diff --git a/packages/theme/src/cli/services/info.test.ts b/packages/theme/src/cli/services/info.test.ts index 0f8223ba07e..5052a938c31 100644 --- a/packages/theme/src/cli/services/info.test.ts +++ b/packages/theme/src/cli/services/info.test.ts @@ -1,4 +1,5 @@ -import {themeInfoJSON, fetchThemeInfo, themeEnvironmentInfoJSON} from './info.js' +import {getThemeEnvironmentInfo, themeInfoJSON, fetchThemeInfo, themeEnvironmentInfoJSON} from './info.js' +import {themeInfoJsonOutputSchema} from './info/types.js' import {getDevelopmentTheme, getThemeStore} from './local-storage.js' import {DevelopmentThemeManager} from '../utilities/development-theme-manager.js' import {findOrSelectTheme} from '../utilities/theme-selector.js' @@ -47,6 +48,7 @@ describe('info', () => { expect(output).toHaveProperty('theme.shop', session.storeFqdn) expect(output).toHaveProperty('theme.preview_url', expect.stringContaining(session.storeFqdn)) expect(output).toHaveProperty('theme.editor_url', expect.stringContaining(session.storeFqdn)) + expect(themeInfoJsonOutputSchema.validate(output)).toEqual(output) }) describe('themeEnvironmentInfoJSON', () => { @@ -65,6 +67,40 @@ describe('info', () => { expect(output).toHaveProperty('os', expect.stringContaining('-')) expect(output).toHaveProperty('shell', process.env.SHELL ?? 'unknown') expect(output).toHaveProperty('node_version', process.version) + expect(themeInfoJsonOutputSchema.validate(output)).toEqual(output) + }) + }) + + test('uses the JSON fallback values when no store or development theme is configured', () => { + vi.mocked(getThemeStore).mockReturnValue(undefined) + vi.mocked(getDevelopmentTheme).mockReturnValue(undefined) + + expect(themeEnvironmentInfoJSON({cliVersion: '3.91.0'})).toMatchObject({ + store: 'Not configured', + development_theme_id: null, + cli_version: '3.91.0', + shell: process.env.SHELL ?? 'unknown', + node_version: process.version, + }) + }) + + test('retains the raw development theme ID for text presentation', () => { + vi.mocked(getThemeStore).mockReturnValue('my-shop.myshopify.com') + vi.mocked(getDevelopmentTheme).mockReturnValue('0') + + expect(getThemeEnvironmentInfo({cliVersion: '3.91.0'})).toMatchObject({ + result: {development_theme_id: null}, + developmentTheme: '0', + }) + }) + + test('retains the raw development theme ID for text presentation when no store is configured', () => { + vi.mocked(getThemeStore).mockReturnValue(undefined) + vi.mocked(getDevelopmentTheme).mockReturnValue('123') + + expect(getThemeEnvironmentInfo({cliVersion: '3.91.0'})).toMatchObject({ + result: {development_theme_id: null}, + developmentTheme: '123', }) }) diff --git a/packages/theme/src/cli/services/info.ts b/packages/theme/src/cli/services/info.ts index b49c24f93fd..29a2f0ed03c 100644 --- a/packages/theme/src/cli/services/info.ts +++ b/packages/theme/src/cli/services/info.ts @@ -5,19 +5,7 @@ import {platformAndArch} from '@shopify/cli-kit/node/os' import {themeEditorUrl, themePreviewUrl} from '@shopify/cli-kit/node/themes/urls' import {Theme} from '@shopify/cli-kit/node/themes/types' import {AdminSession} from '@shopify/cli-kit/node/session' -import {AlertCustomSection, InlineToken} from '@shopify/cli-kit/node/ui' -import {recordEvent} from '@shopify/cli-kit/node/analytics' - -interface ThemeInfo { - theme: { - id: number - name: string - role: string - shop: string - editor_url: string - preview_url: string - } -} +import type {ThemeEnvironmentInfo, ThemeInfoThemeResult} from './info/types.js' interface ThemeInfoOptions { store?: string @@ -28,16 +16,7 @@ interface ThemeInfoOptions { json?: boolean } -interface ThemeEnvironmentInfo { - store: string - development_theme_id: number | null - cli_version: string - os: string - shell: string - node_version: string -} - -export function themeInfoJSON(theme: Theme, adminSession: AdminSession): ThemeInfo { +export function themeInfoJSON(theme: Theme, adminSession: AdminSession): ThemeInfoThemeResult { return { theme: { id: theme.id, @@ -51,26 +30,35 @@ export function themeInfoJSON(theme: Theme, adminSession: AdminSession): ThemeIn } export function themeEnvironmentInfoJSON(config: {cliVersion: string}): ThemeEnvironmentInfo { + return getThemeEnvironmentInfo(config).result +} + +export function getThemeEnvironmentInfo(config: {cliVersion: string}): { + result: ThemeEnvironmentInfo + developmentTheme: string | undefined +} { const {platform, arch} = platformAndArch() const store = getThemeStore() - let developmentThemeID = null - if (store) { - developmentThemeID = Number(getDevelopmentTheme()) || null - } + const developmentTheme = getDevelopmentTheme() + const developmentThemeID = store ? Number(developmentTheme) || null : null + return { - store: store ?? 'Not configured', - development_theme_id: developmentThemeID, - cli_version: config.cliVersion, - os: `${platform}-${arch}`, - shell: process.env.SHELL ?? 'unknown', - node_version: process.version, + result: { + store: store ?? 'Not configured', + development_theme_id: developmentThemeID, + cli_version: config.cliVersion, + os: `${platform}-${arch}`, + shell: process.env.SHELL ?? 'unknown', + node_version: process.version, + }, + developmentTheme, } } export async function fetchThemeInfo( adminSession: AdminSession, options: ThemeInfoOptions, -): Promise { +): Promise { let theme if (options.development) { const developmentThemeManager = new DevelopmentThemeManager(adminSession) @@ -81,75 +69,3 @@ export async function fetchThemeInfo( } return theme ? themeInfoJSON(theme, adminSession) : undefined } - -export async function fetchDevInfo(config: {cliVersion: string}): Promise { - return [devConfigSection(), await systemInfoSection(config)] -} - -function devConfigSection(): AlertCustomSection { - const store = getThemeStore() ?? 'Not configured' - const developmentTheme = getDevelopmentTheme() - - recordEvent(`theme-command:info:dev-theme-loaded:${developmentTheme}`) - - return tabularSection('Theme Configuration', [ - ['Store', store], - ['Development Theme ID', developmentTheme ? `#${developmentTheme}` : {subdued: 'Not set'}], - ]) -} - -async function systemInfoSection(config: {cliVersion: string}): Promise { - const {platform, arch} = platformAndArch() - return tabularSection('Tooling and System', [ - ['Shopify CLI', config.cliVersion], - ['OS', `${platform}-${arch}`], - ['Shell', process.env.SHELL ?? 'unknown'], - ['Node version', process.version], - ]) -} - -function tabularSection(title: string, data: InlineToken[][]): AlertCustomSection { - return { - title, - body: {tabularData: data, firstColumnSubdued: true}, - } -} - -export async function formatThemeInfo(output: ThemeInfo, flags: {environment?: string}) { - const tabularData = Object.entries(output.theme).map(([key, val]) => { - if (key === 'editor_url' || key === 'preview_url') { - const url = String(val) - // Here, we create descriptive labels for the links - const label = key === 'editor_url' ? 'Open in Theme Editor' : 'Preview Theme' - return [formatKey(key), {link: {url, label}}] - } else if (key === 'id') { - return [formatKey(key), `#${val}`] - } else { - return [formatKey(key), `${val}`] - } - }) - - return { - customSections: [ - ...(flags.environment - ? [ - { - title: `Theme information`, - body: [{subdued: `Environment name: ${flags.environment}`}], - }, - ] - : []), - { - title: 'Theme Details', - body: {tabularData, firstColumnSubdued: true}, - }, - ], - } -} - -function formatKey(key: string): string { - return key - .split('_') - .map((word) => word.charAt(0).toUpperCase() + word.slice(1)) - .join(' ') -} diff --git a/packages/theme/src/cli/services/info/result.test.ts b/packages/theme/src/cli/services/info/result.test.ts new file mode 100644 index 00000000000..2d9e4201162 --- /dev/null +++ b/packages/theme/src/cli/services/info/result.test.ts @@ -0,0 +1,96 @@ +import {renderThemeInfoResult} from './result.js' +import {describe, expect, test, vi} from 'vitest' +import {mockAndCaptureOutput} from '@shopify/cli-kit/node/testing/output' +import {renderInfo} from '@shopify/cli-kit/node/ui' + +vi.mock('@shopify/cli-kit/node/ui') + +const themeResult = { + theme: { + id: 123, + name: 'My theme', + role: 'live', + shop: 'my-shop.myshopify.com', + preview_url: 'https://my-shop.myshopify.com/preview', + editor_url: 'https://my-shop.myshopify.com/editor', + }, +} + +const environmentResult = { + store: 'my-shop.myshopify.com', + development_theme_id: null, + cli_version: '3.91.0', + os: 'darwin-arm64', + shell: '/bin/zsh', + node_version: 'v24.15.0', +} + +describe('renderThemeInfoResult', () => { + test.each([themeResult, environmentResult])('encodes the JSON result shape', (result) => { + const output = mockAndCaptureOutput() + output.clear() + + renderThemeInfoResult(result, 'json') + + expect(JSON.parse(output.output())).toEqual(result) + expect(renderInfo).not.toHaveBeenCalled() + }) + + test('renders selected theme information as text', () => { + renderThemeInfoResult(themeResult, 'text', {environment: 'development'}) + + expect(renderInfo).toHaveBeenCalledWith({ + customSections: [ + { + title: 'Theme information', + body: [{subdued: 'Environment name: development'}], + }, + { + title: 'Theme Details', + body: { + firstColumnSubdued: true, + tabularData: [ + ['Id', '#123'], + ['Name', 'My theme'], + ['Role', 'live'], + ['Shop', 'my-shop.myshopify.com'], + ['Preview Url', {link: {url: 'https://my-shop.myshopify.com/preview', label: 'Preview Theme'}}], + ['Editor Url', {link: {url: 'https://my-shop.myshopify.com/editor', label: 'Open in Theme Editor'}}], + ], + }, + }, + ], + }) + }) + + test('renders environment information as text', () => { + renderThemeInfoResult(environmentResult, 'text') + + expect(renderInfo).toHaveBeenCalledWith({ + customSections: [ + { + title: 'Theme Configuration', + body: { + firstColumnSubdued: true, + tabularData: [ + ['Store', 'my-shop.myshopify.com'], + ['Development Theme ID', {subdued: 'Not set'}], + ], + }, + }, + { + title: 'Tooling and System', + body: { + firstColumnSubdued: true, + tabularData: [ + ['Shopify CLI', '3.91.0'], + ['OS', 'darwin-arm64'], + ['Shell', '/bin/zsh'], + ['Node version', 'v24.15.0'], + ], + }, + }, + ], + }) + }) +}) diff --git a/packages/theme/src/cli/services/info/result.ts b/packages/theme/src/cli/services/info/result.ts new file mode 100644 index 00000000000..3961d11060e --- /dev/null +++ b/packages/theme/src/cli/services/info/result.ts @@ -0,0 +1,99 @@ +import { + themeInfoJsonOutputSchema, + type ThemeEnvironmentInfo, + type ThemeInfoResult, + type ThemeInfoThemeResult, +} from './types.js' +import {recordEvent} from '@shopify/cli-kit/node/analytics' +import {outputResult} from '@shopify/cli-kit/node/output' +import {renderInfo, type AlertCustomSection, type InlineToken} from '@shopify/cli-kit/node/ui' + +type ThemeInfoOutputFormat = 'text' | 'json' + +interface ThemeInfoPresentationOptions { + environment?: string | string[] + developmentTheme?: string +} + +export function renderThemeInfoResult( + result: ThemeInfoResult, + format: ThemeInfoOutputFormat, + options: ThemeInfoPresentationOptions = {}, +): void { + if (format === 'json') { + outputResult(themeInfoJsonOutputSchema.encode(result)) + return + } + + if ('theme' in result) { + renderInfo(formatThemeInfo(result, options)) + } else { + renderInfo({customSections: themeEnvironmentInfoSections(result, options)}) + } +} + +function formatThemeInfo(output: ThemeInfoThemeResult, options: ThemeInfoPresentationOptions) { + const tabularData = Object.entries(output.theme).map(([key, value]) => { + if (key === 'editor_url' || key === 'preview_url') { + const url = String(value) + const label = key === 'editor_url' ? 'Open in Theme Editor' : 'Preview Theme' + return [formatKey(key), {link: {url, label}}] + } + if (key === 'id') return [formatKey(key), `#${value}`] + return [formatKey(key), `${value}`] + }) + + return { + customSections: [ + ...(options.environment + ? [ + { + title: 'Theme information', + body: [{subdued: `Environment name: ${options.environment}`}], + }, + ] + : []), + { + title: 'Theme Details', + body: {tabularData, firstColumnSubdued: true}, + }, + ], + } +} + +function themeEnvironmentInfoSections( + result: ThemeEnvironmentInfo, + options: ThemeInfoPresentationOptions, +): AlertCustomSection[] { + const developmentTheme = Object.hasOwn(options, 'developmentTheme') + ? options.developmentTheme + : result.development_theme_id + recordEvent(`theme-command:info:dev-theme-loaded:${developmentTheme}`) + + return [ + tabularSection('Theme Configuration', [ + ['Store', result.store], + ['Development Theme ID', developmentTheme ? `#${developmentTheme}` : {subdued: 'Not set'}], + ]), + tabularSection('Tooling and System', [ + ['Shopify CLI', result.cli_version], + ['OS', result.os], + ['Shell', result.shell], + ['Node version', result.node_version], + ]), + ] +} + +function tabularSection(title: string, data: InlineToken[][]): AlertCustomSection { + return { + title, + body: {tabularData: data, firstColumnSubdued: true}, + } +} + +function formatKey(key: string): string { + return key + .split('_') + .map((word) => word.charAt(0).toUpperCase() + word.slice(1)) + .join(' ') +} diff --git a/packages/theme/src/cli/services/info/types.test.ts b/packages/theme/src/cli/services/info/types.test.ts new file mode 100644 index 00000000000..c244fd71ac1 --- /dev/null +++ b/packages/theme/src/cli/services/info/types.test.ts @@ -0,0 +1,38 @@ +import {themeInfoJsonOutputSchema} from './types.js' +import {describe, expect, test} from 'vitest' + +const themeResult = { + theme: { + id: 123, + name: 'My theme', + role: 'live', + shop: 'my-shop.myshopify.com', + preview_url: 'https://my-shop.myshopify.com/preview', + editor_url: 'https://my-shop.myshopify.com/editor', + }, +} + +const environmentResult = { + store: 'my-shop.myshopify.com', + development_theme_id: null, + cli_version: '3.91.0', + os: 'darwin-arm64', + shell: '/bin/zsh', + node_version: 'v24.15.0', +} + +describe('themeInfoJsonOutputSchema', () => { + test.each([themeResult, environmentResult])('validates the existing result shape', (result) => { + expect(themeInfoJsonOutputSchema.validate(result)).toEqual(result) + }) + + test('rejects a theme result with an invalid theme ID', () => { + expect(() => + themeInfoJsonOutputSchema.validate({...themeResult, theme: {...themeResult.theme, id: '123'}}), + ).toThrow() + }) + + test('rejects an environment result with an invalid development theme ID', () => { + expect(() => themeInfoJsonOutputSchema.validate({...environmentResult, development_theme_id: '123'})).toThrow() + }) +}) diff --git a/packages/theme/src/cli/services/info/types.ts b/packages/theme/src/cli/services/info/types.ts new file mode 100644 index 00000000000..8a57879cae1 --- /dev/null +++ b/packages/theme/src/cli/services/info/types.ts @@ -0,0 +1,38 @@ +import {defineJsonOutputSchema, type InferJsonOutputSchema} from '@shopify/cli-kit/node/json-output-schema' +import {zod} from '@shopify/cli-kit/node/schema' + +const ThemeInfoThemeSchema = zod.object({ + id: zod.number(), + name: zod.string(), + role: zod.string(), + shop: zod.string(), + preview_url: zod.string(), + editor_url: zod.string(), +}) + +const ThemeInfoThemeResultSchema = zod.object({ + theme: ThemeInfoThemeSchema, +}) + +const ThemeEnvironmentInfoSchema = zod.object({ + store: zod.string(), + development_theme_id: zod.number().nullable(), + cli_version: zod.string(), + os: zod.string(), + shell: zod.string(), + node_version: zod.string(), +}) + +export const themeInfoJsonOutputSchema = defineJsonOutputSchema({ + name: 'ThemeInfoResult', + schema: zod.union([ThemeInfoThemeResultSchema, ThemeEnvironmentInfoSchema]), + definitions: { + ThemeInfoTheme: ThemeInfoThemeSchema, + ThemeInfoThemeResult: ThemeInfoThemeResultSchema, + ThemeEnvironmentInfo: ThemeEnvironmentInfoSchema, + }, +}) + +export type ThemeInfoResult = InferJsonOutputSchema +export type ThemeInfoThemeResult = zod.infer +export type ThemeEnvironmentInfo = zod.infer From 4f4573496935e550ca972e05f7f2a2274eea667d Mon Sep 17 00:00:00 2001 From: Donald Merand Date: Thu, 10 Sep 2026 21:44:38 -0400 Subject: [PATCH 2/6] Document theme info JSON schema Assisted-By: devx/9dd3b67e-58e9-4a01-a721-43265d02ccd2 --- packages/cli/README.md | 32 +++++++++++++++++++ packages/cli/oclif.manifest.json | 3 +- .../theme/src/cli/commands/theme/info.test.ts | 5 +++ packages/theme/src/cli/commands/theme/info.ts | 15 +++++---- packages/theme/src/cli/services/info.test.ts | 17 +++------- packages/theme/src/cli/services/info.ts | 4 +-- 6 files changed, 55 insertions(+), 21 deletions(-) diff --git a/packages/cli/README.md b/packages/cli/README.md index 01447abaa18..90a6e9f73b3 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -8022,6 +8022,38 @@ FLAGS DESCRIPTION Displays information about your theme environment, including your current store. Can also retrieve information about a specific theme. + + Use `--json` for machine-readable output. + + Output from `--json` conforms to the `ThemeInfoResult` schema. + + Use `--json-schema` to print the schema directly: + + ```ts + type ThemeInfoResult = ThemeInfoThemeResult | ThemeEnvironmentInfo + + interface ThemeInfoTheme { + id: number + name: string + role: string + shop: string + preview_url: string + editor_url: string + } + + interface ThemeInfoThemeResult { + theme: ThemeInfoTheme + } + + interface ThemeEnvironmentInfo { + store: string + development_theme_id: number | null + cli_version: string + os: string + shell: string + node_version: string + } + ``` ``` ## `shopify theme init [name] [flags]` diff --git a/packages/cli/oclif.manifest.json b/packages/cli/oclif.manifest.json index 89a92620102..ee17391a4b4 100644 --- a/packages/cli/oclif.manifest.json +++ b/packages/cli/oclif.manifest.json @@ -10124,7 +10124,8 @@ "args": { }, "customPluginName": "@shopify/theme", - "description": "Displays information about your theme environment, including your current store. Can also retrieve information about a specific theme.", + "description": "Displays information about your theme environment, including your current store. Can also retrieve information about a specific theme.\n\nUse `--json` for machine-readable output.\n\nOutput from `--json` conforms to the `ThemeInfoResult` schema.\n\nUse `--json-schema` to print the schema directly:\n\n```ts\ntype ThemeInfoResult = ThemeInfoThemeResult | ThemeEnvironmentInfo\n\ninterface ThemeInfoTheme {\n id: number\n name: string\n role: string\n shop: string\n preview_url: string\n editor_url: string\n}\n\ninterface ThemeInfoThemeResult {\n theme: ThemeInfoTheme\n}\n\ninterface ThemeEnvironmentInfo {\n store: string\n development_theme_id: number | null\n cli_version: string\n os: string\n shell: string\n node_version: string\n}\n```", + "descriptionWithMarkdown": "Displays information about your theme environment, including your current store. Can also retrieve information about a specific theme.\n\nUse `--json` for machine-readable output.", "enableJsonFlag": false, "flags": { "auth-alias": { diff --git a/packages/theme/src/cli/commands/theme/info.test.ts b/packages/theme/src/cli/commands/theme/info.test.ts index e384f06240c..90a716795ec 100644 --- a/packages/theme/src/cli/commands/theme/info.test.ts +++ b/packages/theme/src/cli/commands/theme/info.test.ts @@ -134,6 +134,11 @@ describe('Info', () => { expect(Info.jsonOutputSchema).toBe(themeInfoJsonOutputSchema) }) + test('includes the JSON output schema in the help description', () => { + expect(Info.description).toContain('ThemeInfoResult') + expect(Info.description).toContain('--json-schema') + }) + test('is removed from the JSON legacy exemption list', () => { const legacyCommandPaths = readFileSync( new URL('../../../../../eslint-plugin-cli/rules/json-output-legacy-command-paths.js', import.meta.url), diff --git a/packages/theme/src/cli/commands/theme/info.ts b/packages/theme/src/cli/commands/theme/info.ts index 5feb6fe6f8f..e0841db8ade 100644 --- a/packages/theme/src/cli/commands/theme/info.ts +++ b/packages/theme/src/cli/commands/theme/info.ts @@ -13,8 +13,15 @@ import {recordTiming} from '@shopify/cli-kit/node/analytics' type InfoFlags = OutputFlags export default class Info extends ThemeCommand { - static description = - 'Displays information about your theme environment, including your current store. Can also retrieve information about a specific theme.' + static get jsonOutputSchema() { + return themeInfoJsonOutputSchema + } + + static descriptionWithMarkdown = `Displays information about your theme environment, including your current store. Can also retrieve information about a specific theme. + +Use \`--json\` for machine-readable output.` + + static description = this.descriptionForHelp() static flags = { ...globalFlags, @@ -34,10 +41,6 @@ export default class Info extends ThemeCommand { static multiEnvironmentsFlags = ['store', 'password'] - static get jsonOutputSchema() { - return themeInfoJsonOutputSchema - } - async command(flags: InfoFlags, adminSession: AdminSession): Promise { recordTiming('theme-command:info') if (flags.theme || flags.development) { diff --git a/packages/theme/src/cli/services/info.test.ts b/packages/theme/src/cli/services/info.test.ts index 5052a938c31..c0a0330f51a 100644 --- a/packages/theme/src/cli/services/info.test.ts +++ b/packages/theme/src/cli/services/info.test.ts @@ -71,9 +71,11 @@ describe('info', () => { }) }) - test('uses the JSON fallback values when no store or development theme is configured', () => { + test('uses the JSON fallback values without reading the development theme when no store is configured', () => { vi.mocked(getThemeStore).mockReturnValue(undefined) - vi.mocked(getDevelopmentTheme).mockReturnValue(undefined) + vi.mocked(getDevelopmentTheme).mockImplementation(() => { + throw new Error('The development theme needs a configured store') + }) expect(themeEnvironmentInfoJSON({cliVersion: '3.91.0'})).toMatchObject({ store: 'Not configured', @@ -82,6 +84,7 @@ describe('info', () => { shell: process.env.SHELL ?? 'unknown', node_version: process.version, }) + expect(getDevelopmentTheme).not.toHaveBeenCalled() }) test('retains the raw development theme ID for text presentation', () => { @@ -94,16 +97,6 @@ describe('info', () => { }) }) - test('retains the raw development theme ID for text presentation when no store is configured', () => { - vi.mocked(getThemeStore).mockReturnValue(undefined) - vi.mocked(getDevelopmentTheme).mockReturnValue('123') - - expect(getThemeEnvironmentInfo({cliVersion: '3.91.0'})).toMatchObject({ - result: {development_theme_id: null}, - developmentTheme: '123', - }) - }) - test('fetch theme info by id', async () => { // Given vi.mocked(findOrSelectTheme).mockResolvedValue(theme) diff --git a/packages/theme/src/cli/services/info.ts b/packages/theme/src/cli/services/info.ts index 29a2f0ed03c..9c5060b882a 100644 --- a/packages/theme/src/cli/services/info.ts +++ b/packages/theme/src/cli/services/info.ts @@ -39,8 +39,8 @@ export function getThemeEnvironmentInfo(config: {cliVersion: string}): { } { const {platform, arch} = platformAndArch() const store = getThemeStore() - const developmentTheme = getDevelopmentTheme() - const developmentThemeID = store ? Number(developmentTheme) || null : null + const developmentTheme = store ? getDevelopmentTheme() : undefined + const developmentThemeID = Number(developmentTheme) || null return { result: { From 8fe9c068fee6e7d2ffc92647ed8f72c173e1205a Mon Sep 17 00:00:00 2001 From: Donald Merand Date: Mon, 14 Sep 2026 10:57:20 -0400 Subject: [PATCH 3/6] Refresh theme info JSON schema docs Assisted-By: devx/b173c0b4-2d65-4396-a5eb-d3c4d4c3a4ff --- packages/cli/README.md | 118 ++++++++++++++---- packages/cli/oclif.manifest.json | 2 +- .../theme/src/cli/commands/theme/info.test.ts | 2 +- 3 files changed, 96 insertions(+), 26 deletions(-) diff --git a/packages/cli/README.md b/packages/cli/README.md index 90a6e9f73b3..74bc497af91 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -8027,31 +8027,101 @@ DESCRIPTION Output from `--json` conforms to the `ThemeInfoResult` schema. - Use `--json-schema` to print the schema directly: - - ```ts - type ThemeInfoResult = ThemeInfoThemeResult | ThemeEnvironmentInfo - - interface ThemeInfoTheme { - id: number - name: string - role: string - shop: string - preview_url: string - editor_url: string - } - - interface ThemeInfoThemeResult { - theme: ThemeInfoTheme - } + Use `--json-schema` to print the result, error, and event schemas. - interface ThemeEnvironmentInfo { - store: string - development_theme_id: number | null - cli_version: string - os: string - shell: string - node_version: string + ```json + { + "anyOf": [ + { + "$ref": "#/definitions/ThemeInfoThemeResult" + }, + { + "$ref": "#/definitions/ThemeEnvironmentInfo" + } + ], + "title": "ThemeInfoResult", + "definitions": { + "ThemeInfoTheme": { + "type": "object", + "properties": { + "id": { + "type": "number" + }, + "name": { + "type": "string" + }, + "role": { + "type": "string" + }, + "shop": { + "type": "string" + }, + "preview_url": { + "type": "string" + }, + "editor_url": { + "type": "string" + } + }, + "required": [ + "id", + "name", + "role", + "shop", + "preview_url", + "editor_url" + ], + "additionalProperties": false + }, + "ThemeInfoThemeResult": { + "type": "object", + "properties": { + "theme": { + "$ref": "#/definitions/ThemeInfoTheme" + } + }, + "required": [ + "theme" + ], + "additionalProperties": false + }, + "ThemeEnvironmentInfo": { + "type": "object", + "properties": { + "store": { + "type": "string" + }, + "development_theme_id": { + "type": [ + "number", + "null" + ] + }, + "cli_version": { + "type": "string" + }, + "os": { + "type": "string" + }, + "shell": { + "type": "string" + }, + "node_version": { + "type": "string" + } + }, + "required": [ + "store", + "development_theme_id", + "cli_version", + "os", + "shell", + "node_version" + ], + "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 ee17391a4b4..24b921e2f03 100644 --- a/packages/cli/oclif.manifest.json +++ b/packages/cli/oclif.manifest.json @@ -10124,7 +10124,7 @@ "args": { }, "customPluginName": "@shopify/theme", - "description": "Displays information about your theme environment, including your current store. Can also retrieve information about a specific theme.\n\nUse `--json` for machine-readable output.\n\nOutput from `--json` conforms to the `ThemeInfoResult` schema.\n\nUse `--json-schema` to print the schema directly:\n\n```ts\ntype ThemeInfoResult = ThemeInfoThemeResult | ThemeEnvironmentInfo\n\ninterface ThemeInfoTheme {\n id: number\n name: string\n role: string\n shop: string\n preview_url: string\n editor_url: string\n}\n\ninterface ThemeInfoThemeResult {\n theme: ThemeInfoTheme\n}\n\ninterface ThemeEnvironmentInfo {\n store: string\n development_theme_id: number | null\n cli_version: string\n os: string\n shell: string\n node_version: string\n}\n```", + "description": "Displays information about your theme environment, including your current store. Can also retrieve information about a specific theme.\n\nUse `--json` for machine-readable output.\n\nOutput from `--json` conforms to the `ThemeInfoResult` schema.\n\nUse `--json-schema` to print the result, error, and event schemas.\n\n```json\n{\n \"anyOf\": [\n {\n \"$ref\": \"#/definitions/ThemeInfoThemeResult\"\n },\n {\n \"$ref\": \"#/definitions/ThemeEnvironmentInfo\"\n }\n ],\n \"title\": \"ThemeInfoResult\",\n \"definitions\": {\n \"ThemeInfoTheme\": {\n \"type\": \"object\",\n \"properties\": {\n \"id\": {\n \"type\": \"number\"\n },\n \"name\": {\n \"type\": \"string\"\n },\n \"role\": {\n \"type\": \"string\"\n },\n \"shop\": {\n \"type\": \"string\"\n },\n \"preview_url\": {\n \"type\": \"string\"\n },\n \"editor_url\": {\n \"type\": \"string\"\n }\n },\n \"required\": [\n \"id\",\n \"name\",\n \"role\",\n \"shop\",\n \"preview_url\",\n \"editor_url\"\n ],\n \"additionalProperties\": false\n },\n \"ThemeInfoThemeResult\": {\n \"type\": \"object\",\n \"properties\": {\n \"theme\": {\n \"$ref\": \"#/definitions/ThemeInfoTheme\"\n }\n },\n \"required\": [\n \"theme\"\n ],\n \"additionalProperties\": false\n },\n \"ThemeEnvironmentInfo\": {\n \"type\": \"object\",\n \"properties\": {\n \"store\": {\n \"type\": \"string\"\n },\n \"development_theme_id\": {\n \"type\": [\n \"number\",\n \"null\"\n ]\n },\n \"cli_version\": {\n \"type\": \"string\"\n },\n \"os\": {\n \"type\": \"string\"\n },\n \"shell\": {\n \"type\": \"string\"\n },\n \"node_version\": {\n \"type\": \"string\"\n }\n },\n \"required\": [\n \"store\",\n \"development_theme_id\",\n \"cli_version\",\n \"os\",\n \"shell\",\n \"node_version\"\n ],\n \"additionalProperties\": false\n }\n },\n \"$schema\": \"http://json-schema.org/draft-07/schema#\"\n}\n```", "descriptionWithMarkdown": "Displays information about your theme environment, including your current store. Can also retrieve information about a specific theme.\n\nUse `--json` for machine-readable output.", "enableJsonFlag": false, "flags": { diff --git a/packages/theme/src/cli/commands/theme/info.test.ts b/packages/theme/src/cli/commands/theme/info.test.ts index 90a716795ec..483e7133903 100644 --- a/packages/theme/src/cli/commands/theme/info.test.ts +++ b/packages/theme/src/cli/commands/theme/info.test.ts @@ -141,7 +141,7 @@ describe('Info', () => { test('is removed from the JSON legacy exemption list', () => { const legacyCommandPaths = readFileSync( - new URL('../../../../../eslint-plugin-cli/rules/json-output-legacy-command-paths.js', import.meta.url), + new URL('../../../../../eslint-plugin-cli/rules/json-output-command-exceptions.js', import.meta.url), 'utf8', ) From 003a134e3ad97bb20a733568986c9907e6feb18c Mon Sep 17 00:00:00 2001 From: Donald Merand Date: Tue, 15 Sep 2026 12:34:50 -0400 Subject: [PATCH 4/6] Collect theme info results for multiple environments Assisted-By: devx/0033c079-2574-4c3b-a8f1-d9a752834d90 --- .changeset/theme-info-json-schema.md | 2 +- packages/cli/README.md | 41 ++++ packages/cli/oclif.manifest.json | 2 +- .../theme/src/cli/commands/theme/info.test.ts | 193 +++++++++++++++++- packages/theme/src/cli/commands/theme/info.ts | 34 ++- .../src/cli/services/info/result.test.ts | 57 +++++- .../theme/src/cli/services/info/result.ts | 5 + .../theme/src/cli/services/info/types.test.ts | 33 +++ packages/theme/src/cli/services/info/types.ts | 18 +- .../src/cli/utilities/theme-command.test.ts | 175 +++++++++++++++- .../theme/src/cli/utilities/theme-command.ts | 37 +++- 11 files changed, 578 insertions(+), 19 deletions(-) diff --git a/.changeset/theme-info-json-schema.md b/.changeset/theme-info-json-schema.md index ed9595ceffe..b939df96c11 100644 --- a/.changeset/theme-info-json-schema.md +++ b/.changeset/theme-info-json-schema.md @@ -2,4 +2,4 @@ '@shopify/theme': minor --- -Add a JSON output schema for `theme info` +Add JSON output for `theme info`, including one document for multi-environment runs diff --git a/packages/cli/README.md b/packages/cli/README.md index 74bc497af91..c990b6e3d71 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -8037,6 +8037,9 @@ DESCRIPTION }, { "$ref": "#/definitions/ThemeEnvironmentInfo" + }, + { + "$ref": "#/definitions/ThemeInfoMultiEnvironmentResult" } ], "title": "ThemeInfoResult", @@ -8119,6 +8122,44 @@ DESCRIPTION "node_version" ], "additionalProperties": false + }, + "ThemeInfoMultiEnvironmentEntry": { + "type": "object", + "properties": { + "environment": { + "type": "string" + }, + "result": { + "anyOf": [ + { + "$ref": "#/definitions/ThemeInfoThemeResult" + }, + { + "$ref": "#/definitions/ThemeEnvironmentInfo" + } + ] + } + }, + "required": [ + "environment", + "result" + ], + "additionalProperties": false + }, + "ThemeInfoMultiEnvironmentResult": { + "type": "object", + "properties": { + "environments": { + "type": "array", + "items": { + "$ref": "#/definitions/ThemeInfoMultiEnvironmentEntry" + } + } + }, + "required": [ + "environments" + ], + "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 24b921e2f03..e751a942d9f 100644 --- a/packages/cli/oclif.manifest.json +++ b/packages/cli/oclif.manifest.json @@ -10124,7 +10124,7 @@ "args": { }, "customPluginName": "@shopify/theme", - "description": "Displays information about your theme environment, including your current store. Can also retrieve information about a specific theme.\n\nUse `--json` for machine-readable output.\n\nOutput from `--json` conforms to the `ThemeInfoResult` schema.\n\nUse `--json-schema` to print the result, error, and event schemas.\n\n```json\n{\n \"anyOf\": [\n {\n \"$ref\": \"#/definitions/ThemeInfoThemeResult\"\n },\n {\n \"$ref\": \"#/definitions/ThemeEnvironmentInfo\"\n }\n ],\n \"title\": \"ThemeInfoResult\",\n \"definitions\": {\n \"ThemeInfoTheme\": {\n \"type\": \"object\",\n \"properties\": {\n \"id\": {\n \"type\": \"number\"\n },\n \"name\": {\n \"type\": \"string\"\n },\n \"role\": {\n \"type\": \"string\"\n },\n \"shop\": {\n \"type\": \"string\"\n },\n \"preview_url\": {\n \"type\": \"string\"\n },\n \"editor_url\": {\n \"type\": \"string\"\n }\n },\n \"required\": [\n \"id\",\n \"name\",\n \"role\",\n \"shop\",\n \"preview_url\",\n \"editor_url\"\n ],\n \"additionalProperties\": false\n },\n \"ThemeInfoThemeResult\": {\n \"type\": \"object\",\n \"properties\": {\n \"theme\": {\n \"$ref\": \"#/definitions/ThemeInfoTheme\"\n }\n },\n \"required\": [\n \"theme\"\n ],\n \"additionalProperties\": false\n },\n \"ThemeEnvironmentInfo\": {\n \"type\": \"object\",\n \"properties\": {\n \"store\": {\n \"type\": \"string\"\n },\n \"development_theme_id\": {\n \"type\": [\n \"number\",\n \"null\"\n ]\n },\n \"cli_version\": {\n \"type\": \"string\"\n },\n \"os\": {\n \"type\": \"string\"\n },\n \"shell\": {\n \"type\": \"string\"\n },\n \"node_version\": {\n \"type\": \"string\"\n }\n },\n \"required\": [\n \"store\",\n \"development_theme_id\",\n \"cli_version\",\n \"os\",\n \"shell\",\n \"node_version\"\n ],\n \"additionalProperties\": false\n }\n },\n \"$schema\": \"http://json-schema.org/draft-07/schema#\"\n}\n```", + "description": "Displays information about your theme environment, including your current store. Can also retrieve information about a specific theme.\n\nUse `--json` for machine-readable output.\n\nOutput from `--json` conforms to the `ThemeInfoResult` schema.\n\nUse `--json-schema` to print the result, error, and event schemas.\n\n```json\n{\n \"anyOf\": [\n {\n \"$ref\": \"#/definitions/ThemeInfoThemeResult\"\n },\n {\n \"$ref\": \"#/definitions/ThemeEnvironmentInfo\"\n },\n {\n \"$ref\": \"#/definitions/ThemeInfoMultiEnvironmentResult\"\n }\n ],\n \"title\": \"ThemeInfoResult\",\n \"definitions\": {\n \"ThemeInfoTheme\": {\n \"type\": \"object\",\n \"properties\": {\n \"id\": {\n \"type\": \"number\"\n },\n \"name\": {\n \"type\": \"string\"\n },\n \"role\": {\n \"type\": \"string\"\n },\n \"shop\": {\n \"type\": \"string\"\n },\n \"preview_url\": {\n \"type\": \"string\"\n },\n \"editor_url\": {\n \"type\": \"string\"\n }\n },\n \"required\": [\n \"id\",\n \"name\",\n \"role\",\n \"shop\",\n \"preview_url\",\n \"editor_url\"\n ],\n \"additionalProperties\": false\n },\n \"ThemeInfoThemeResult\": {\n \"type\": \"object\",\n \"properties\": {\n \"theme\": {\n \"$ref\": \"#/definitions/ThemeInfoTheme\"\n }\n },\n \"required\": [\n \"theme\"\n ],\n \"additionalProperties\": false\n },\n \"ThemeEnvironmentInfo\": {\n \"type\": \"object\",\n \"properties\": {\n \"store\": {\n \"type\": \"string\"\n },\n \"development_theme_id\": {\n \"type\": [\n \"number\",\n \"null\"\n ]\n },\n \"cli_version\": {\n \"type\": \"string\"\n },\n \"os\": {\n \"type\": \"string\"\n },\n \"shell\": {\n \"type\": \"string\"\n },\n \"node_version\": {\n \"type\": \"string\"\n }\n },\n \"required\": [\n \"store\",\n \"development_theme_id\",\n \"cli_version\",\n \"os\",\n \"shell\",\n \"node_version\"\n ],\n \"additionalProperties\": false\n },\n \"ThemeInfoMultiEnvironmentEntry\": {\n \"type\": \"object\",\n \"properties\": {\n \"environment\": {\n \"type\": \"string\"\n },\n \"result\": {\n \"anyOf\": [\n {\n \"$ref\": \"#/definitions/ThemeInfoThemeResult\"\n },\n {\n \"$ref\": \"#/definitions/ThemeEnvironmentInfo\"\n }\n ]\n }\n },\n \"required\": [\n \"environment\",\n \"result\"\n ],\n \"additionalProperties\": false\n },\n \"ThemeInfoMultiEnvironmentResult\": {\n \"type\": \"object\",\n \"properties\": {\n \"environments\": {\n \"type\": \"array\",\n \"items\": {\n \"$ref\": \"#/definitions/ThemeInfoMultiEnvironmentEntry\"\n }\n }\n },\n \"required\": [\n \"environments\"\n ],\n \"additionalProperties\": false\n }\n },\n \"$schema\": \"http://json-schema.org/draft-07/schema#\"\n}\n```", "descriptionWithMarkdown": "Displays information about your theme environment, including your current store. Can also retrieve information about a specific theme.\n\nUse `--json` for machine-readable output.", "enableJsonFlag": false, "flags": { diff --git a/packages/theme/src/cli/commands/theme/info.test.ts b/packages/theme/src/cli/commands/theme/info.test.ts index 483e7133903..5bd120e89a5 100644 --- a/packages/theme/src/cli/commands/theme/info.test.ts +++ b/packages/theme/src/cli/commands/theme/info.test.ts @@ -5,12 +5,26 @@ import {beforeEach, describe, expect, test, vi} from 'vitest' import {Config} from '@oclif/core' import {ensureAuthenticatedThemes} from '@shopify/cli-kit/node/session' import {mockAndCaptureOutput} from '@shopify/cli-kit/node/testing/output' -import {renderInfo} from '@shopify/cli-kit/node/ui' +import {renderConcurrent, renderError, renderInfo} from '@shopify/cli-kit/node/ui' +import {loadEnvironment} from '@shopify/cli-kit/node/environments' +import {recordTiming} from '@shopify/cli-kit/node/analytics' import {readFileSync} from 'node:fs' +import type {Writable} from 'stream' + vi.mock('../../services/info.js') vi.mock('@shopify/cli-kit/node/session') vi.mock('@shopify/cli-kit/node/ui') +vi.mock('@shopify/cli-kit/node/environments') +vi.mock('@shopify/cli-kit/node/analytics', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + recordTiming: vi.fn(), + recordEvent: vi.fn(), + compileData: vi.fn(() => ({timings: {}, errors: {}, retries: {}, events: {}})), + } +}) const CommandConfig = new Config({root: __dirname}) @@ -73,9 +87,29 @@ async function run(argv: string[]) { await info.run() } +async function runMultiEnvironment(argv: string[]) { + await CommandConfig.load() + vi.mocked(ensureAuthenticatedThemes).mockResolvedValue(session) + const info = new Info(['--password=test-password', ...argv], CommandConfig) + await info.run() +} + +function executeConcurrentProcessesInOrder() { + vi.mocked(renderConcurrent).mockImplementation(async ({processes}) => { + for (const process of processes) { + // eslint-disable-next-line no-await-in-loop + await process.action({} as Writable, {} as Writable, {} as never) + } + }) +} + describe('Info', () => { beforeEach(() => { mockAndCaptureOutput().clear() + vi.mocked(loadEnvironment).mockReset() + vi.mocked(renderConcurrent).mockReset() + vi.mocked(recordTiming).mockClear() + vi.mocked(renderError).mockClear() }) describe('when theme or development flag is provided', () => { @@ -202,4 +236,161 @@ describe('Info', () => { expect(JSON.parse(streams.stdout())).toEqual(environmentResult) expect(streams.stderr()).toBe('') }) + + describe('multi-environment JSON output', () => { + test('collects requested environments in order even when completion order differs', async () => { + vi.mocked(loadEnvironment) + .mockResolvedValueOnce({store: 'store1.myshopify.com', theme: '123'}) + .mockResolvedValueOnce({store: 'store2.myshopify.com', theme: '456'}) + .mockResolvedValueOnce({store: 'store3.myshopify.com', theme: '789'}) + vi.mocked(fetchThemeInfo).mockResolvedValue(themeResult) + vi.mocked(renderConcurrent).mockImplementation(async ({processes}) => { + for (const process of [...processes].reverse()) { + // eslint-disable-next-line no-await-in-loop + await process.action({} as Writable, {} as Writable, {} as never) + } + }) + const output = mockAndCaptureOutput() + output.clear() + + await runMultiEnvironment([ + '--environment', + 'first', + '--environment', + 'second', + '--environment', + 'third', + '--json', + ]) + + expect(JSON.parse(output.output())).toEqual({ + environments: [ + {environment: 'first', result: themeResult}, + {environment: 'second', result: themeResult}, + {environment: 'third', result: themeResult}, + ], + }) + }) + + test('writes one multi-environment JSON document to stdout with no text on stderr', async () => { + const originalUnitTestEnv = process.env.SHOPIFY_UNIT_TEST + process.env.SHOPIFY_UNIT_TEST = 'false' + vi.resetModules() + const streams = captureStandardStreams() + + try { + const {default: StreamInfo} = await import('./info.js') + const {fetchThemeInfo, getThemeEnvironmentInfo} = await import('../../services/info.js') + const {ensureAuthenticatedThemes} = await import('@shopify/cli-kit/node/session') + const {renderConcurrent} = await import('@shopify/cli-kit/node/ui') + const {loadEnvironment} = await import('@shopify/cli-kit/node/environments') + const {Config} = await import('@oclif/core') + const streamConfig = new Config({root: __dirname}) + await streamConfig.load() + vi.mocked(ensureAuthenticatedThemes).mockResolvedValue(session) + vi.mocked(loadEnvironment) + .mockResolvedValueOnce({store: 'store1.myshopify.com', theme: '123'}) + .mockResolvedValueOnce({store: 'store2.myshopify.com'}) + vi.mocked(fetchThemeInfo).mockResolvedValue(themeResult) + vi.mocked(getThemeEnvironmentInfo).mockReturnValue({result: environmentResult, developmentTheme: undefined}) + vi.mocked(renderConcurrent).mockImplementation(async ({processes}) => { + for (const process of processes) { + // eslint-disable-next-line no-await-in-loop + await process.action({} as Writable, {} as Writable, {} as never) + } + }) + + await new StreamInfo( + ['--password=test-password', '--environment', 'theme-env', '--environment', 'environment-env', '--json'], + streamConfig, + ).run() + } finally { + streams.restore() + restoreUnitTestEnvironment(originalUnitTestEnv) + } + + expect(JSON.parse(streams.stdout())).toEqual({ + environments: [ + {environment: 'theme-env', result: themeResult}, + {environment: 'environment-env', result: environmentResult}, + ], + }) + expect(streams.stderr()).toBe('') + }) + + test('omits failed environments and keeps their errors on stderr', async () => { + vi.mocked(loadEnvironment) + .mockResolvedValueOnce({store: 'store1.myshopify.com', theme: '123'}) + .mockResolvedValueOnce({store: 'store2.myshopify.com', theme: '404'}) + .mockResolvedValueOnce({store: 'store3.myshopify.com', theme: '789'}) + vi.mocked(fetchThemeInfo).mockImplementation(async (_session, options) => + options.theme === '404' ? undefined : themeResult, + ) + executeConcurrentProcessesInOrder() + const output = mockAndCaptureOutput() + output.clear() + + await runMultiEnvironment(['--environment', 'ok', '--environment', 'bad', '--environment', 'another', '--json']) + + expect(JSON.parse(output.output())).toEqual({ + environments: [ + {environment: 'ok', result: themeResult}, + {environment: 'another', result: themeResult}, + ], + }) + expect(renderError).toHaveBeenCalledWith( + expect.objectContaining({body: ['Environment bad failed: \n\nTheme not found!']}), + ) + }) + + test('emits an empty wrapper when every environment fails and preserves exit behavior', async () => { + vi.mocked(loadEnvironment) + .mockResolvedValueOnce({store: 'store1.myshopify.com', theme: '123'}) + .mockResolvedValueOnce({store: 'store2.myshopify.com', theme: '456'}) + vi.mocked(fetchThemeInfo).mockResolvedValue(undefined) + executeConcurrentProcessesInOrder() + const output = mockAndCaptureOutput() + output.clear() + + await expect( + runMultiEnvironment(['--environment', 'first', '--environment', 'second', '--json']), + ).resolves.toBeUndefined() + + expect(JSON.parse(output.output())).toEqual({environments: []}) + expect(renderError).toHaveBeenCalledTimes(2) + }) + + test('does not emit the JSON wrapper in text mode', async () => { + vi.mocked(loadEnvironment) + .mockResolvedValueOnce({store: 'store1.myshopify.com', theme: '123'}) + .mockResolvedValueOnce({store: 'store2.myshopify.com', theme: '456'}) + vi.mocked(fetchThemeInfo).mockResolvedValue(themeResult) + executeConcurrentProcessesInOrder() + const output = mockAndCaptureOutput() + output.clear() + + await runMultiEnvironment(['--environment', 'first', '--environment', 'second']) + + expect(output.output()).toBe('') + expect(renderInfo).toHaveBeenCalledTimes(2) + }) + }) + + describe('analytics timing', () => { + test('records only the opening timing in JSON mode', async () => { + vi.mocked(fetchThemeInfo).mockResolvedValue(themeResult) + + await run(['--theme', '123', '--json']) + + expect(vi.mocked(recordTiming).mock.calls).toEqual([['theme-command:info']]) + }) + + test('records opening and closing timings in text mode', async () => { + vi.mocked(fetchThemeInfo).mockResolvedValue(themeResult) + + await run(['--theme', '123']) + + expect(vi.mocked(recordTiming).mock.calls).toEqual([['theme-command:info'], ['theme-command:info']]) + }) + }) }) diff --git a/packages/theme/src/cli/commands/theme/info.ts b/packages/theme/src/cli/commands/theme/info.ts index e0841db8ade..55ba596035f 100644 --- a/packages/theme/src/cli/commands/theme/info.ts +++ b/packages/theme/src/cli/commands/theme/info.ts @@ -1,7 +1,7 @@ -import ThemeCommand from '../../utilities/theme-command.js' +import ThemeCommand, {type ThemeCommandMultiEnvironmentEntry} from '../../utilities/theme-command.js' import {fetchThemeInfo, getThemeEnvironmentInfo} from '../../services/info.js' -import {renderThemeInfoResult} from '../../services/info/result.js' -import {themeInfoJsonOutputSchema} from '../../services/info/types.js' +import {renderThemeInfoMultiEnvironmentResult, renderThemeInfoResult} from '../../services/info/result.js' +import {themeInfoJsonOutputSchema, type ThemeInfoResult} from '../../services/info/types.js' import {themeFlags} from '../../flags.js' import {Flags} from '@oclif/core' import {AdminSession} from '@shopify/cli-kit/node/session' @@ -12,7 +12,7 @@ import {recordTiming} from '@shopify/cli-kit/node/analytics' type InfoFlags = OutputFlags -export default class Info extends ThemeCommand { +export default class Info extends ThemeCommand { static get jsonOutputSchema() { return themeInfoJsonOutputSchema } @@ -41,19 +41,41 @@ Use \`--json\` for machine-readable output.` static multiEnvironmentsFlags = ['store', 'password'] - async command(flags: InfoFlags, adminSession: AdminSession): Promise { + async command( + flags: InfoFlags, + adminSession: AdminSession, + multiEnvironment = false, + ): Promise { recordTiming('theme-command:info') + if (flags.theme || flags.development) { const output = await fetchThemeInfo(adminSession, flags) if (!output) { throw new AbortError('Theme not found!') } + if (multiEnvironment && flags.json) return output + renderThemeInfoResult(output, flags.json ? 'json' : 'text', flags) } else { const {result, developmentTheme} = getThemeEnvironmentInfo({cliVersion: this.config.version}) + + if (multiEnvironment && flags.json) return result + renderThemeInfoResult(result, flags.json ? 'json' : 'text', {developmentTheme}) } - recordTiming('theme-command:info') + + if (!flags.json) recordTiming('theme-command:info') + + return undefined + } + + protected onMultiEnvironmentComplete( + entries: ThemeCommandMultiEnvironmentEntry[], + flags: InfoFlags, + ): void { + if (flags.json !== true) return + + renderThemeInfoMultiEnvironmentResult({environments: entries}) } } diff --git a/packages/theme/src/cli/services/info/result.test.ts b/packages/theme/src/cli/services/info/result.test.ts index 2d9e4201162..9fd14697f04 100644 --- a/packages/theme/src/cli/services/info/result.test.ts +++ b/packages/theme/src/cli/services/info/result.test.ts @@ -1,4 +1,4 @@ -import {renderThemeInfoResult} from './result.js' +import {renderThemeInfoMultiEnvironmentResult, renderThemeInfoResult} from './result.js' import {describe, expect, test, vi} from 'vitest' import {mockAndCaptureOutput} from '@shopify/cli-kit/node/testing/output' import {renderInfo} from '@shopify/cli-kit/node/ui' @@ -63,6 +63,61 @@ describe('renderThemeInfoResult', () => { }) }) + test('encodes the multi-environment wrapper as exactly one JSON document on stdout', () => { + const output = mockAndCaptureOutput() + output.clear() + + renderThemeInfoMultiEnvironmentResult({ + environments: [ + {environment: 'development', result: themeResult}, + {environment: 'staging', result: environmentResult}, + ], + }) + + expect(output.output()).toBe( + `{ + "environments": [ + { + "environment": "development", + "result": { + "theme": { + "id": 123, + "name": "My theme", + "role": "live", + "shop": "my-shop.myshopify.com", + "preview_url": "https://my-shop.myshopify.com/preview", + "editor_url": "https://my-shop.myshopify.com/editor" + } + } + }, + { + "environment": "staging", + "result": { + "store": "my-shop.myshopify.com", + "development_theme_id": null, + "cli_version": "3.91.0", + "os": "darwin-arm64", + "shell": "/bin/zsh", + "node_version": "v24.15.0" + } + } + ] +}`, + ) + expect(output.error()).toBe('') + expect(renderInfo).not.toHaveBeenCalled() + }) + + test('encodes an empty multi-environment wrapper as one JSON document', () => { + const output = mockAndCaptureOutput() + output.clear() + + renderThemeInfoMultiEnvironmentResult({environments: []}) + + expect(output.output()).toBe(`{\n "environments": []\n}`) + expect(output.error()).toBe('') + }) + test('renders environment information as text', () => { renderThemeInfoResult(environmentResult, 'text') diff --git a/packages/theme/src/cli/services/info/result.ts b/packages/theme/src/cli/services/info/result.ts index 3961d11060e..195e07ab935 100644 --- a/packages/theme/src/cli/services/info/result.ts +++ b/packages/theme/src/cli/services/info/result.ts @@ -1,6 +1,7 @@ import { themeInfoJsonOutputSchema, type ThemeEnvironmentInfo, + type ThemeInfoMultiEnvironmentResult, type ThemeInfoResult, type ThemeInfoThemeResult, } from './types.js' @@ -32,6 +33,10 @@ export function renderThemeInfoResult( } } +export function renderThemeInfoMultiEnvironmentResult(result: ThemeInfoMultiEnvironmentResult): void { + outputResult(themeInfoJsonOutputSchema.encode(result)) +} + function formatThemeInfo(output: ThemeInfoThemeResult, options: ThemeInfoPresentationOptions) { const tabularData = Object.entries(output.theme).map(([key, value]) => { if (key === 'editor_url' || key === 'preview_url') { diff --git a/packages/theme/src/cli/services/info/types.test.ts b/packages/theme/src/cli/services/info/types.test.ts index c244fd71ac1..18280467e25 100644 --- a/packages/theme/src/cli/services/info/types.test.ts +++ b/packages/theme/src/cli/services/info/types.test.ts @@ -21,11 +21,26 @@ const environmentResult = { node_version: 'v24.15.0', } +const multiEnvironmentResult = { + environments: [ + {environment: 'development', result: themeResult}, + {environment: 'staging', result: environmentResult}, + ], +} + describe('themeInfoJsonOutputSchema', () => { test.each([themeResult, environmentResult])('validates the existing result shape', (result) => { expect(themeInfoJsonOutputSchema.validate(result)).toEqual(result) }) + test('validates a multi-environment wrapper', () => { + expect(themeInfoJsonOutputSchema.validate(multiEnvironmentResult)).toEqual(multiEnvironmentResult) + }) + + test('validates an empty multi-environment wrapper', () => { + expect(themeInfoJsonOutputSchema.validate({environments: []})).toEqual({environments: []}) + }) + test('rejects a theme result with an invalid theme ID', () => { expect(() => themeInfoJsonOutputSchema.validate({...themeResult, theme: {...themeResult.theme, id: '123'}}), @@ -35,4 +50,22 @@ describe('themeInfoJsonOutputSchema', () => { test('rejects an environment result with an invalid development theme ID', () => { expect(() => themeInfoJsonOutputSchema.validate({...environmentResult, development_theme_id: '123'})).toThrow() }) + + test('rejects a multi-environment entry with an invalid result', () => { + expect(() => + themeInfoJsonOutputSchema.validate({ + environments: [ + {environment: 'development', result: {...themeResult, theme: {...themeResult.theme, id: '123'}}}, + ], + }), + ).toThrow() + }) + + test('rejects a multi-environment entry without an environment name', () => { + expect(() => themeInfoJsonOutputSchema.validate({environments: [{result: themeResult}]})).toThrow() + }) + + test('rejects multi-environment entries that are not an array', () => { + expect(() => themeInfoJsonOutputSchema.validate({environments: {development: themeResult}})).toThrow() + }) }) diff --git a/packages/theme/src/cli/services/info/types.ts b/packages/theme/src/cli/services/info/types.ts index 8a57879cae1..21f7507e8f7 100644 --- a/packages/theme/src/cli/services/info/types.ts +++ b/packages/theme/src/cli/services/info/types.ts @@ -1,4 +1,4 @@ -import {defineJsonOutputSchema, type InferJsonOutputSchema} from '@shopify/cli-kit/node/json-output-schema' +import {defineJsonOutputSchema} from '@shopify/cli-kit/node/json-output-schema' import {zod} from '@shopify/cli-kit/node/schema' const ThemeInfoThemeSchema = zod.object({ @@ -23,16 +23,28 @@ const ThemeEnvironmentInfoSchema = zod.object({ node_version: zod.string(), }) +const ThemeInfoMultiEnvironmentEntrySchema = zod.object({ + environment: zod.string(), + result: zod.union([ThemeInfoThemeResultSchema, ThemeEnvironmentInfoSchema]), +}) + +const ThemeInfoMultiEnvironmentResultSchema = zod.object({ + environments: zod.array(ThemeInfoMultiEnvironmentEntrySchema), +}) + export const themeInfoJsonOutputSchema = defineJsonOutputSchema({ name: 'ThemeInfoResult', - schema: zod.union([ThemeInfoThemeResultSchema, ThemeEnvironmentInfoSchema]), + schema: zod.union([ThemeInfoThemeResultSchema, ThemeEnvironmentInfoSchema, ThemeInfoMultiEnvironmentResultSchema]), definitions: { ThemeInfoTheme: ThemeInfoThemeSchema, ThemeInfoThemeResult: ThemeInfoThemeResultSchema, ThemeEnvironmentInfo: ThemeEnvironmentInfoSchema, + ThemeInfoMultiEnvironmentEntry: ThemeInfoMultiEnvironmentEntrySchema, + ThemeInfoMultiEnvironmentResult: ThemeInfoMultiEnvironmentResultSchema, }, }) -export type ThemeInfoResult = InferJsonOutputSchema +export type ThemeInfoResult = ThemeInfoThemeResult | ThemeEnvironmentInfo export type ThemeInfoThemeResult = zod.infer export type ThemeEnvironmentInfo = zod.infer +export type ThemeInfoMultiEnvironmentResult = zod.infer diff --git a/packages/theme/src/cli/utilities/theme-command.test.ts b/packages/theme/src/cli/utilities/theme-command.test.ts index 31d8d27605a..b09cb545e12 100644 --- a/packages/theme/src/cli/utilities/theme-command.test.ts +++ b/packages/theme/src/cli/utilities/theme-command.test.ts @@ -1,4 +1,4 @@ -import ThemeCommand, {RequiredFlags} from './theme-command.js' +import ThemeCommand, {RequiredFlags, type ThemeCommandMultiEnvironmentEntry} from './theme-command.js' import {ensureThemeStore} from './theme-store.js' import {describe, vi, expect, test, beforeEach} from 'vitest' import {Config, Flags} from '@oclif/core' @@ -157,6 +157,42 @@ class TestNoMultiEnvThemeCommand extends TestThemeCommand { static multiEnvironmentsFlags: RequiredFlags = null } +class TestResultThemeCommand extends ThemeCommand { + static flags = {...TestThemeCommand.flags} + + static multiEnvironmentsFlags: RequiredFlags = ['store'] + + completedEntries: ThemeCommandMultiEnvironmentEntry[] = [] + lifecycle: string[] = [] + + async command(flags: any, _session?: AdminSession, _multiEnvironment = false): Promise { + const environment = flags.environment?.[0] as string + this.lifecycle.push(`command:${environment}`) + + if (environment === 'command-error') { + throw new Error('Mocking a command error') + } + + return `result-for-${environment}` + } + + protected onMultiEnvironmentComplete(entries: ThemeCommandMultiEnvironmentEntry[]): void { + this.lifecycle.push('complete') + this.completedEntries = entries + } +} + +class TestResultThemeCommandWithDefaultHook extends ThemeCommand { + static flags = {...TestThemeCommand.flags} + + static multiEnvironmentsFlags: RequiredFlags = ['store'] + + async command(flags: any): Promise { + const environment = flags.environment?.[0] as string + return `result-for-${environment}` + } +} + class TestThemeCommandWithoutStoreRequired extends ThemeCommand { static flags = { environment: Flags.string({ @@ -1189,4 +1225,141 @@ describe('ThemeCommand', () => { expect(ensureAuthenticatedThemes).not.toHaveBeenCalled() }) }) + + describe('multi environment result collection', () => { + test('collects successful results in requested order when completion order differs', async () => { + // Given + vi.mocked(loadEnvironment) + .mockResolvedValueOnce({store: 'store1.myshopify.com'}) + .mockResolvedValueOnce({store: 'store2.myshopify.com'}) + .mockResolvedValueOnce({store: 'store3.myshopify.com'}) + vi.mocked(ensureThemeStore).mockImplementation((options: any) => options.store) + + await CommandConfig.load() + const command = new TestResultThemeCommand( + ['--environment', 'first', '--environment', 'second', '--environment', 'third'], + CommandConfig, + ) + + // Completion happens in reverse requested order + vi.mocked(renderConcurrent).mockImplementation(async ({processes}) => { + for (const process of [...processes].reverse()) { + // eslint-disable-next-line no-await-in-loop + await process.action({} as Writable, {} as Writable, {} as any) + } + }) + + // When + await command.run() + + // Then + expect(command.lifecycle.filter((event) => event.startsWith('command:'))).toEqual([ + 'command:third', + 'command:second', + 'command:first', + ]) + expect(command.completedEntries).toEqual([ + {environment: 'first', result: 'result-for-first'}, + {environment: 'second', result: 'result-for-second'}, + {environment: 'third', result: 'result-for-third'}, + ]) + }) + + test('omits failed executions from the collected results', async () => { + // Given + vi.mocked(loadEnvironment) + .mockResolvedValueOnce({store: 'store1.myshopify.com'}) + .mockResolvedValueOnce({store: 'store2.myshopify.com'}) + .mockResolvedValueOnce({store: 'store3.myshopify.com'}) + vi.mocked(ensureThemeStore).mockImplementation((options: any) => options.store) + vi.mocked(renderConcurrent).mockImplementation(async ({processes}) => { + for (const process of processes) { + // eslint-disable-next-line no-await-in-loop + await process.action({} as Writable, {} as Writable, {} as any) + } + }) + + await CommandConfig.load() + const command = new TestResultThemeCommand( + ['--environment', 'ok', '--environment', 'command-error', '--environment', 'another'], + CommandConfig, + ) + + // When + await command.run() + + // Then + expect(command.completedEntries).toEqual([ + {environment: 'ok', result: 'result-for-ok'}, + {environment: 'another', result: 'result-for-another'}, + ]) + expect(renderError).toHaveBeenCalledWith( + expect.objectContaining({body: ['Environment command-error failed: \n\nMocking a command error']}), + ) + }) + + test('runs the completion hook after all duplicate-store groups finish', async () => { + // Given + vi.mocked(loadEnvironment) + .mockResolvedValueOnce({store: 'store1.myshopify.com'}) + .mockResolvedValueOnce({store: 'store1.myshopify.com'}) + .mockResolvedValueOnce({store: 'store2.myshopify.com'}) + vi.mocked(ensureThemeStore).mockImplementation((options: any) => options.store) + + await CommandConfig.load() + const command = new TestResultThemeCommand( + ['--environment', 'first', '--environment', 'second', '--environment', 'third'], + CommandConfig, + ) + vi.mocked(renderConcurrent).mockImplementation(async ({processes}) => { + command.lifecycle.push(`group:${processes.map((process) => process.prefix).join('+')}`) + for (const process of processes) { + // eslint-disable-next-line no-await-in-loop + await process.action({} as Writable, {} as Writable, {} as any) + } + }) + + // When + await command.run() + + // Then + expect(vi.mocked(renderConcurrent)).toHaveBeenCalledTimes(2) + expect(command.lifecycle).toEqual([ + 'group:first+third', + 'command:first', + 'command:third', + 'group:second', + 'command:second', + 'complete', + ]) + expect(command.completedEntries.map((entry) => entry.environment)).toEqual(['first', 'second', 'third']) + }) + + test('the default completion hook leaves command output unchanged', async () => { + // Given + vi.mocked(loadEnvironment) + .mockResolvedValueOnce({store: 'store1.myshopify.com'}) + .mockResolvedValueOnce({store: 'store2.myshopify.com'}) + vi.mocked(ensureThemeStore).mockImplementation((options: any) => options.store) + vi.mocked(renderConcurrent).mockImplementation(async ({processes}) => { + for (const process of processes) { + // eslint-disable-next-line no-await-in-loop + await process.action({} as Writable, {} as Writable, {} as any) + } + }) + + await CommandConfig.load() + const command = new TestResultThemeCommandWithDefaultHook( + ['--environment', 'first', '--environment', 'second'], + CommandConfig, + ) + + // When + await command.run() + + // Then + expect(mockAndCaptureOutput().output()).toBe('') + expect(renderError).not.toHaveBeenCalled() + }) + }) }) diff --git a/packages/theme/src/cli/utilities/theme-command.ts b/packages/theme/src/cli/utilities/theme-command.ts index da8b70b23a9..0bd79632510 100644 --- a/packages/theme/src/cli/utilities/theme-command.ts +++ b/packages/theme/src/cli/utilities/theme-command.ts @@ -40,6 +40,12 @@ interface ValidEnvironment { storeAuthSession?: AdminSession } type EnvironmentName = string + +export interface ThemeCommandMultiEnvironmentEntry { + environment: EnvironmentName + result: TResult +} + /** * Flags required to run a command in multiple environments * @@ -56,7 +62,7 @@ type EnvironmentName = string */ export type RequiredFlags = (string | string[])[] | null -export default abstract class ThemeCommand extends Command { +export default abstract class ThemeCommand extends Command { static baseFlags = {...Command.baseFlags, ...authAliasFlag} environmentsFilename(): string { @@ -69,7 +75,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}, @@ -138,9 +146,25 @@ export default abstract class ThemeCommand extends Command { if (!confirmed) return } - await this.runConcurrent(validationResults.valid) + const successfulResults = new Map() + await this.runConcurrent(validationResults.valid, successfulResults) + + const entries = validationResults.valid + .filter(({environment}) => successfulResults.has(environment)) + .map(({environment}) => ({environment, result: successfulResults.get(environment) as TResult})) + + this.onMultiEnvironmentComplete(entries, flags) } + /** + * Receives the successful multi-environment results in the requested environment order. + * The default does nothing so commands keep their current output behavior. + */ + protected onMultiEnvironmentComplete( + _entries: ThemeCommandMultiEnvironmentEntry[], + _flags: FlagValues, + ): void {} + /** * Admin API scopes that a stored `store auth` session must include for this * command to reuse it. Commands opt in to reusing store auth sessions by @@ -289,7 +313,7 @@ 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[], successfulResults: Map) { const abortController = new AbortController() const stores = validEnvironments.map((env) => env.flags.store as string) @@ -312,7 +336,10 @@ export default abstract class ThemeCommand extends Command { recordEvent(`theme-command:${commandName}:multi-env:authenticated`) try { - await this.command(flags, session, true, {}, {stdout, stderr}) + const result = await this.command(flags, session, true, {}, {stdout, stderr}) + if (result !== undefined) { + successfulResults.set(environment, result) + } } finally { await this.logAnalyticsData(session) } From a2aeae21198776537646a9314417f4b292a862bf Mon Sep 17 00:00:00 2001 From: Donald Merand Date: Tue, 15 Sep 2026 12:53:50 -0400 Subject: [PATCH 5/6] Harden theme info multi-environment results and JSON output tests Assisted-By: devx/1fb42754-a68b-4733-9ea3-74521b5da069 --- .../theme/src/cli/commands/theme/info.test.ts | 22 +++++ .../src/cli/utilities/theme-command.test.ts | 98 +++++++++++++++++++ .../theme/src/cli/utilities/theme-command.ts | 16 +-- 3 files changed, 130 insertions(+), 6 deletions(-) diff --git a/packages/theme/src/cli/commands/theme/info.test.ts b/packages/theme/src/cli/commands/theme/info.test.ts index 5bd120e89a5..c44463deddb 100644 --- a/packages/theme/src/cli/commands/theme/info.test.ts +++ b/packages/theme/src/cli/commands/theme/info.test.ts @@ -207,6 +207,18 @@ describe('Info', () => { restoreUnitTestEnvironment(originalUnitTestEnv) } + const expectedStdout = `{ + "theme": { + "id": 123, + "name": "my theme", + "role": "live", + "shop": "my-shop.myshopify.com", + "preview_url": "https://my-shop.myshopify.com/preview", + "editor_url": "https://my-shop.myshopify.com/editor" + } +}\n` + + expect(streams.stdout()).toBe(expectedStdout) expect(JSON.parse(streams.stdout())).toEqual(themeResult) expect(streams.stderr()).toBe('') }) @@ -233,6 +245,16 @@ describe('Info', () => { restoreUnitTestEnvironment(originalUnitTestEnv) } + const expectedStdout = `{ + "store": "my-shop.myshopify.com", + "development_theme_id": null, + "cli_version": "3.91.0", + "os": "darwin-arm64", + "shell": "/bin/zsh", + "node_version": "v24.15.0" +}\n` + + expect(streams.stdout()).toBe(expectedStdout) expect(JSON.parse(streams.stdout())).toEqual(environmentResult) expect(streams.stderr()).toBe('') }) diff --git a/packages/theme/src/cli/utilities/theme-command.test.ts b/packages/theme/src/cli/utilities/theme-command.test.ts index b09cb545e12..cae699e3dc4 100644 --- a/packages/theme/src/cli/utilities/theme-command.test.ts +++ b/packages/theme/src/cli/utilities/theme-command.test.ts @@ -1298,6 +1298,45 @@ describe('ThemeCommand', () => { ) }) + test('omits an environment from results when its analytics cleanup fails', async () => { + // Given + vi.mocked(loadEnvironment) + .mockResolvedValueOnce({store: 'store1.myshopify.com'}) + .mockResolvedValueOnce({store: 'store2.myshopify.com'}) + vi.mocked(ensureThemeStore).mockImplementation((options: any) => options.store) + vi.mocked(ensureAuthenticatedThemes).mockImplementation(async (store) => ({ + token: 'test-token', + storeFqdn: store, + })) + vi.mocked(renderConcurrent).mockImplementation(async ({processes}) => { + for (const process of processes) { + // eslint-disable-next-line no-await-in-loop + await process.action({} as Writable, {} as Writable, {} as any) + } + }) + + // The second environment's analytics cleanup fails after its command returns a result. + vi.mocked(addPublicMetadata).mockImplementation(async (getMetadata) => { + const metadata = (await getMetadata()) as {store_domain?: string} + if (metadata.store_domain === 'store2.myshopify.com') throw new Error('analytics unavailable') + }) + + await CommandConfig.load() + const command = new TestResultThemeCommand( + ['--environment', 'ok', '--environment', 'analytics-fails'], + CommandConfig, + ) + + // When + await command.run() + + // Then + expect(command.completedEntries).toEqual([{environment: 'ok', result: 'result-for-ok'}]) + expect(renderError).toHaveBeenCalledWith( + expect.objectContaining({body: ['Environment analytics-fails failed: \n\nanalytics unavailable']}), + ) + }) + test('runs the completion hook after all duplicate-store groups finish', async () => { // Given vi.mocked(loadEnvironment) @@ -1361,5 +1400,64 @@ describe('ThemeCommand', () => { expect(mockAndCaptureOutput().output()).toBe('') expect(renderError).not.toHaveBeenCalled() }) + + test('does not resolve until an asynchronous completion hook finishes', async () => { + // Given + vi.mocked(loadEnvironment) + .mockResolvedValueOnce({store: 'store1.myshopify.com'}) + .mockResolvedValueOnce({store: 'store2.myshopify.com'}) + vi.mocked(ensureThemeStore).mockImplementation((options: any) => options.store) + vi.mocked(renderConcurrent).mockImplementation(async ({processes}) => { + for (const process of processes) { + // eslint-disable-next-line no-await-in-loop + await process.action({} as Writable, {} as Writable, {} as any) + } + }) + + let signalHookStarted: () => void = () => {} + const hookStarted = new Promise((resolve) => { + signalHookStarted = resolve + }) + let releaseHook: () => void = () => {} + + class AsyncCompletionThemeCommand extends ThemeCommand { + static flags = {...TestThemeCommand.flags} + + static multiEnvironmentsFlags: RequiredFlags = ['store'] + + async command(flags: any): Promise { + return `result-for-${flags.environment?.[0]}` + } + + protected async onMultiEnvironmentComplete(): Promise { + signalHookStarted() + await new Promise((resolve) => { + releaseHook = resolve + }) + } + } + + await CommandConfig.load() + const command = new AsyncCompletionThemeCommand( + ['--environment', 'first', '--environment', 'second'], + CommandConfig, + ) + + // When + let didResolve = false + const runPromise = command.run().then(() => { + didResolve = true + }) + + await hookStarted + await Promise.resolve() + + // Then + expect(didResolve).toBe(false) + + releaseHook() + await runPromise + expect(didResolve).toBe(true) + }) }) }) diff --git a/packages/theme/src/cli/utilities/theme-command.ts b/packages/theme/src/cli/utilities/theme-command.ts index 0bd79632510..26335eb4c53 100644 --- a/packages/theme/src/cli/utilities/theme-command.ts +++ b/packages/theme/src/cli/utilities/theme-command.ts @@ -153,7 +153,7 @@ export default abstract class ThemeCommand extends Command { .filter(({environment}) => successfulResults.has(environment)) .map(({environment}) => ({environment, result: successfulResults.get(environment) as TResult})) - this.onMultiEnvironmentComplete(entries, flags) + await this.onMultiEnvironmentComplete(entries, flags) } /** @@ -163,7 +163,7 @@ export default abstract class ThemeCommand extends Command { protected onMultiEnvironmentComplete( _entries: ThemeCommandMultiEnvironmentEntry[], _flags: FlagValues, - ): void {} + ): void | Promise {} /** * Admin API scopes that a stored `store auth` session must include for this @@ -335,14 +335,18 @@ export default abstract class ThemeCommand extends Command { const commandName = this.constructor.name.toLowerCase() recordEvent(`theme-command:${commandName}:multi-env:authenticated`) + let result: TResult | undefined try { - const result = await this.command(flags, session, true, {}, {stdout, stderr}) - if (result !== undefined) { - successfulResults.set(environment, result) - } + result = await this.command(flags, session, true, {}, {stdout, stderr}) } finally { await this.logAnalyticsData(session) } + + // Only publish a result after analytics cleanup succeeds, so a + // cleanup failure reports the environment as failed. + if (result !== undefined) { + successfulResults.set(environment, result) + } }) // eslint-disable-next-line no-catch-all/no-catch-all From 078784efe914249f58319ccc8ee3dcd927717dac Mon Sep 17 00:00:00 2001 From: Donald Merand Date: Thu, 17 Sep 2026 11:15:57 -0400 Subject: [PATCH 6/6] Preserve theme info multi-environment JSON Assisted-By: devx/8b520d2e-bc34-4a3d-b5fa-748b2fff362e --- .changeset/theme-info-json-schema.md | 5 - .../src/public/node/base-command.test.ts | 36 +- .../cli-kit/src/public/node/base-command.ts | 18 +- packages/cli/README.md | 45 +-- packages/cli/oclif.manifest.json | 4 +- .../theme/src/cli/commands/theme/info.test.ts | 158 ++------ packages/theme/src/cli/commands/theme/info.ts | 39 +- .../src/cli/services/info/result.test.ts | 57 +-- .../theme/src/cli/services/info/result.ts | 5 - .../theme/src/cli/services/info/types.test.ts | 31 +- packages/theme/src/cli/services/info/types.ts | 18 +- .../theme-command-environments.test.ts | 138 +++++++ .../src/cli/utilities/theme-command.test.ts | 343 +----------------- .../theme/src/cli/utilities/theme-command.ts | 81 ++--- 14 files changed, 279 insertions(+), 699 deletions(-) delete mode 100644 .changeset/theme-info-json-schema.md create mode 100644 packages/theme/src/cli/utilities/theme-command-environments.test.ts diff --git a/.changeset/theme-info-json-schema.md b/.changeset/theme-info-json-schema.md deleted file mode 100644 index b939df96c11..00000000000 --- a/.changeset/theme-info-json-schema.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@shopify/theme': minor ---- - -Add JSON output for `theme info`, including one document for multi-environment runs diff --git a/packages/cli-kit/src/public/node/base-command.test.ts b/packages/cli-kit/src/public/node/base-command.test.ts index ac160e36f8a..d5372564854 100644 --- a/packages/cli-kit/src/public/node/base-command.test.ts +++ b/packages/cli-kit/src/public/node/base-command.test.ts @@ -2,7 +2,7 @@ import Command from './base-command.js' import {Environments} from './environments.js' import {encodeToml as encodeTOML} from './toml/codec.js' import {globalFlags, jsonFlag, requiredIfNonInteractive} from './cli.js' -import {emitCommandEvent} from './command-events.js' +import {emitCommandEvent, runWithCommandEvents} from './command-events.js' import {inTemporaryDirectory, mkdir, writeFile} from './fs.js' import {joinPath, resolvePath, cwd} from './path.js' import {mockAndCaptureOutput} from './testing/output.js' @@ -10,7 +10,7 @@ import {unstyled} from './output.js' import {defineJsonOutputSchema} from './json-output-schema.js' import {zod} from './schema.js' import {afterEach, beforeEach, describe, expect, test, vi} from 'vitest' -import {Flags} from '@oclif/core' +import {Config, Flags} from '@oclif/core' import {Ajv} from 'ajv' let originalStdinIsTTY: boolean | undefined @@ -54,6 +54,7 @@ class MockCommand extends Command { default: 'default stringy', }), password: Flags.string({}), + 'store-password': Flags.string({}), environment: Flags.string({ multiple: true, default: [], @@ -220,6 +221,11 @@ const environmentWithPassword = { password: 'password', } +const environmentWithCredentials = { + password: 'admin-password', + 'store-password': 'storefront-secret', +} + const allEnvironments: Environments = { environments: { validEnvironment, @@ -232,6 +238,7 @@ const allEnvironments: Environments = { environmentMatchingDefault, environmentWithDefaultOverride, environmentWithPassword, + environmentWithCredentials, }, } @@ -754,6 +761,31 @@ describe('applying environments', async () => { `) }) + runTestInTmpDir('reports environment settings as JSON diagnostics with masked passwords', async (tmpDir) => { + const sink = vi.fn() + const output = mockAndCaptureOutput() + output.clear() + await runWithCommandEvents({outputMode: 'json', sink}, async () => { + const config = new Config({root: __dirname}) + await config.load() + const command = new MockCommand(['--path', tmpDir, '--environment', 'environmentWithCredentials'], config) + await command.run() + }) + + expect(sink).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'diagnostic', + level: 'info', + message: + 'Using applicable flags from environmentWithCredentials environment:\npassword: ********word\nstore-password: ********cret', + }), + ) + for (const credential of Object.values(environmentWithCredentials)) { + expect(JSON.stringify(sink.mock.calls)).not.toContain(credential) + } + expect(output.info()).toBe('') + }) + runTestInTmpDir('reports environment settings with masked passwords', async (tmpDir: string) => { // Given const outputMock = mockAndCaptureOutput() diff --git a/packages/cli-kit/src/public/node/base-command.ts b/packages/cli-kit/src/public/node/base-command.ts index ad894ebb811..8a23e55beb2 100644 --- a/packages/cli-kit/src/public/node/base-command.ts +++ b/packages/cli-kit/src/public/node/base-command.ts @@ -1,7 +1,7 @@ import {isDevelopment} from './context/local.js' import {addPublicMetadata} from './metadata.js' import {AbortError} from './error.js' -import {runWithCommandEventsForCommand} from './command-events.js' +import {commandEventOutputMode, emitCommandEvent, runWithCommandEventsForCommand} from './command-events.js' import {outputContent, outputResult, outputToken} from './output.js' import {setCurrentSessionAlias} from './session.js' import {terminalSupportsPrompting} from './system.js' @@ -145,7 +145,7 @@ abstract class BaseCommand extends Command { result = await this.resultWithEnvironment(result, options, argv) await setCurrentSessionAlias(result.flags['auth-alias']) await addFromParsedFlags(result.flags) - this.failMissingNonTTYFlagRequirements(result.flags, this.applicableNonTTYFlagRequirements(result.flags)) + this.validateNonTTYFlags(result.flags) return {...result, ...{argv: result.argv as string[]}} } @@ -154,6 +154,10 @@ abstract class BaseCommand extends Command { return undefined } + protected validateNonTTYFlags(flags: FlagOutput): void { + this.failMissingNonTTYFlagRequirements(flags, this.applicableNonTTYFlagRequirements(flags)) + } + protected failMissingNonTTYFlags(flags: FlagOutput, requiredFlags: string[]): void { this.failMissingNonTTYFlagRequirements( flags, @@ -322,13 +326,21 @@ function reportEnvironmentApplication< const userSpecifiedThisFlag = Object.prototype.hasOwnProperty.call(noDefaultsFlags, name) const environmentContainsFlag = Object.prototype.hasOwnProperty.call(environment, name) if (!userSpecifiedThisFlag && environmentContainsFlag) { - const valueToReport = name === 'password' ? `********${value.substr(-4)}` : value + const valueToReport = name === 'password' || name === 'store-password' ? `********${value.substr(-4)}` : value changes[name] = valueToReport } } if (Object.keys(changes).length === 0) return const items = Object.entries(changes).map(([name, value]) => `${name}: ${value}`) + if (commandEventOutputMode() === 'json') { + emitCommandEvent({ + type: 'diagnostic', + level: 'info', + message: `Using applicable flags from ${environmentName} environment:\n${items.join('\n')}`, + }) + return + } // eslint-disable-next-line no-void void import('./ui.js').then(({renderInfo}) => { renderInfo({ diff --git a/packages/cli/README.md b/packages/cli/README.md index c990b6e3d71..e86afdd0f26 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -8023,12 +8023,10 @@ DESCRIPTION Displays information about your theme environment, including your current store. Can also retrieve information about a specific theme. - Use `--json` for machine-readable output. + Use `--json-schema` to print the result, error, and event schemas. Output from `--json` conforms to the `ThemeInfoResult` schema. - Use `--json-schema` to print the result, error, and event schemas. - ```json { "anyOf": [ @@ -8037,9 +8035,6 @@ DESCRIPTION }, { "$ref": "#/definitions/ThemeEnvironmentInfo" - }, - { - "$ref": "#/definitions/ThemeInfoMultiEnvironmentResult" } ], "title": "ThemeInfoResult", @@ -8122,44 +8117,6 @@ DESCRIPTION "node_version" ], "additionalProperties": false - }, - "ThemeInfoMultiEnvironmentEntry": { - "type": "object", - "properties": { - "environment": { - "type": "string" - }, - "result": { - "anyOf": [ - { - "$ref": "#/definitions/ThemeInfoThemeResult" - }, - { - "$ref": "#/definitions/ThemeEnvironmentInfo" - } - ] - } - }, - "required": [ - "environment", - "result" - ], - "additionalProperties": false - }, - "ThemeInfoMultiEnvironmentResult": { - "type": "object", - "properties": { - "environments": { - "type": "array", - "items": { - "$ref": "#/definitions/ThemeInfoMultiEnvironmentEntry" - } - } - }, - "required": [ - "environments" - ], - "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 e751a942d9f..0e1205a87f5 100644 --- a/packages/cli/oclif.manifest.json +++ b/packages/cli/oclif.manifest.json @@ -10124,8 +10124,8 @@ "args": { }, "customPluginName": "@shopify/theme", - "description": "Displays information about your theme environment, including your current store. Can also retrieve information about a specific theme.\n\nUse `--json` for machine-readable output.\n\nOutput from `--json` conforms to the `ThemeInfoResult` schema.\n\nUse `--json-schema` to print the result, error, and event schemas.\n\n```json\n{\n \"anyOf\": [\n {\n \"$ref\": \"#/definitions/ThemeInfoThemeResult\"\n },\n {\n \"$ref\": \"#/definitions/ThemeEnvironmentInfo\"\n },\n {\n \"$ref\": \"#/definitions/ThemeInfoMultiEnvironmentResult\"\n }\n ],\n \"title\": \"ThemeInfoResult\",\n \"definitions\": {\n \"ThemeInfoTheme\": {\n \"type\": \"object\",\n \"properties\": {\n \"id\": {\n \"type\": \"number\"\n },\n \"name\": {\n \"type\": \"string\"\n },\n \"role\": {\n \"type\": \"string\"\n },\n \"shop\": {\n \"type\": \"string\"\n },\n \"preview_url\": {\n \"type\": \"string\"\n },\n \"editor_url\": {\n \"type\": \"string\"\n }\n },\n \"required\": [\n \"id\",\n \"name\",\n \"role\",\n \"shop\",\n \"preview_url\",\n \"editor_url\"\n ],\n \"additionalProperties\": false\n },\n \"ThemeInfoThemeResult\": {\n \"type\": \"object\",\n \"properties\": {\n \"theme\": {\n \"$ref\": \"#/definitions/ThemeInfoTheme\"\n }\n },\n \"required\": [\n \"theme\"\n ],\n \"additionalProperties\": false\n },\n \"ThemeEnvironmentInfo\": {\n \"type\": \"object\",\n \"properties\": {\n \"store\": {\n \"type\": \"string\"\n },\n \"development_theme_id\": {\n \"type\": [\n \"number\",\n \"null\"\n ]\n },\n \"cli_version\": {\n \"type\": \"string\"\n },\n \"os\": {\n \"type\": \"string\"\n },\n \"shell\": {\n \"type\": \"string\"\n },\n \"node_version\": {\n \"type\": \"string\"\n }\n },\n \"required\": [\n \"store\",\n \"development_theme_id\",\n \"cli_version\",\n \"os\",\n \"shell\",\n \"node_version\"\n ],\n \"additionalProperties\": false\n },\n \"ThemeInfoMultiEnvironmentEntry\": {\n \"type\": \"object\",\n \"properties\": {\n \"environment\": {\n \"type\": \"string\"\n },\n \"result\": {\n \"anyOf\": [\n {\n \"$ref\": \"#/definitions/ThemeInfoThemeResult\"\n },\n {\n \"$ref\": \"#/definitions/ThemeEnvironmentInfo\"\n }\n ]\n }\n },\n \"required\": [\n \"environment\",\n \"result\"\n ],\n \"additionalProperties\": false\n },\n \"ThemeInfoMultiEnvironmentResult\": {\n \"type\": \"object\",\n \"properties\": {\n \"environments\": {\n \"type\": \"array\",\n \"items\": {\n \"$ref\": \"#/definitions/ThemeInfoMultiEnvironmentEntry\"\n }\n }\n },\n \"required\": [\n \"environments\"\n ],\n \"additionalProperties\": false\n }\n },\n \"$schema\": \"http://json-schema.org/draft-07/schema#\"\n}\n```", - "descriptionWithMarkdown": "Displays information about your theme environment, including your current store. Can also retrieve information about a specific theme.\n\nUse `--json` for machine-readable output.", + "description": "Displays information about your theme environment, including your current store. Can also retrieve information about a specific theme.\n\nUse `--json-schema` to print the result, error, and event schemas.\n\nOutput from `--json` conforms to the `ThemeInfoResult` schema.\n\n```json\n{\n \"anyOf\": [\n {\n \"$ref\": \"#/definitions/ThemeInfoThemeResult\"\n },\n {\n \"$ref\": \"#/definitions/ThemeEnvironmentInfo\"\n }\n ],\n \"title\": \"ThemeInfoResult\",\n \"definitions\": {\n \"ThemeInfoTheme\": {\n \"type\": \"object\",\n \"properties\": {\n \"id\": {\n \"type\": \"number\"\n },\n \"name\": {\n \"type\": \"string\"\n },\n \"role\": {\n \"type\": \"string\"\n },\n \"shop\": {\n \"type\": \"string\"\n },\n \"preview_url\": {\n \"type\": \"string\"\n },\n \"editor_url\": {\n \"type\": \"string\"\n }\n },\n \"required\": [\n \"id\",\n \"name\",\n \"role\",\n \"shop\",\n \"preview_url\",\n \"editor_url\"\n ],\n \"additionalProperties\": false\n },\n \"ThemeInfoThemeResult\": {\n \"type\": \"object\",\n \"properties\": {\n \"theme\": {\n \"$ref\": \"#/definitions/ThemeInfoTheme\"\n }\n },\n \"required\": [\n \"theme\"\n ],\n \"additionalProperties\": false\n },\n \"ThemeEnvironmentInfo\": {\n \"type\": \"object\",\n \"properties\": {\n \"store\": {\n \"type\": \"string\"\n },\n \"development_theme_id\": {\n \"type\": [\n \"number\",\n \"null\"\n ]\n },\n \"cli_version\": {\n \"type\": \"string\"\n },\n \"os\": {\n \"type\": \"string\"\n },\n \"shell\": {\n \"type\": \"string\"\n },\n \"node_version\": {\n \"type\": \"string\"\n }\n },\n \"required\": [\n \"store\",\n \"development_theme_id\",\n \"cli_version\",\n \"os\",\n \"shell\",\n \"node_version\"\n ],\n \"additionalProperties\": false\n }\n },\n \"$schema\": \"http://json-schema.org/draft-07/schema#\"\n}\n```", + "descriptionWithMarkdown": "Displays information about your theme environment, including your current store. Can also retrieve information about a specific theme.", "enableJsonFlag": false, "flags": { "auth-alias": { diff --git a/packages/theme/src/cli/commands/theme/info.test.ts b/packages/theme/src/cli/commands/theme/info.test.ts index c44463deddb..af519720bd4 100644 --- a/packages/theme/src/cli/commands/theme/info.test.ts +++ b/packages/theme/src/cli/commands/theme/info.test.ts @@ -1,11 +1,12 @@ import Info from './info.js' import {fetchThemeInfo, getThemeEnvironmentInfo} from '../../services/info.js' import {themeInfoJsonOutputSchema} from '../../services/info/types.js' +import * as infoResult from '../../services/info/result.js' import {beforeEach, describe, expect, test, vi} from 'vitest' import {Config} from '@oclif/core' import {ensureAuthenticatedThemes} from '@shopify/cli-kit/node/session' import {mockAndCaptureOutput} from '@shopify/cli-kit/node/testing/output' -import {renderConcurrent, renderError, renderInfo} from '@shopify/cli-kit/node/ui' +import {renderConcurrent, renderInfo} from '@shopify/cli-kit/node/ui' import {loadEnvironment} from '@shopify/cli-kit/node/environments' import {recordTiming} from '@shopify/cli-kit/node/analytics' import {readFileSync} from 'node:fs' @@ -44,6 +45,17 @@ const themeResult = { }, } +const secondThemeResult = { + theme: { + id: 456, + name: 'second theme', + role: 'live', + shop: 'my-shop.myshopify.com', + preview_url: 'https://my-shop.myshopify.com/preview', + editor_url: 'https://my-shop.myshopify.com/editor', + }, +} + const environmentResult = { store: 'my-shop.myshopify.com', development_theme_id: null, @@ -109,7 +121,6 @@ describe('Info', () => { vi.mocked(loadEnvironment).mockReset() vi.mocked(renderConcurrent).mockReset() vi.mocked(recordTiming).mockClear() - vi.mocked(renderError).mockClear() }) describe('when theme or development flag is provided', () => { @@ -260,129 +271,27 @@ describe('Info', () => { }) describe('multi-environment JSON output', () => { - test('collects requested environments in order even when completion order differs', async () => { + test('emits each environment result as its own existing JSON document', async () => { vi.mocked(loadEnvironment) .mockResolvedValueOnce({store: 'store1.myshopify.com', theme: '123'}) .mockResolvedValueOnce({store: 'store2.myshopify.com', theme: '456'}) - .mockResolvedValueOnce({store: 'store3.myshopify.com', theme: '789'}) - vi.mocked(fetchThemeInfo).mockResolvedValue(themeResult) - vi.mocked(renderConcurrent).mockImplementation(async ({processes}) => { - for (const process of [...processes].reverse()) { - // eslint-disable-next-line no-await-in-loop - await process.action({} as Writable, {} as Writable, {} as never) - } - }) - const output = mockAndCaptureOutput() - output.clear() - - await runMultiEnvironment([ - '--environment', - 'first', - '--environment', - 'second', - '--environment', - 'third', - '--json', - ]) - - expect(JSON.parse(output.output())).toEqual({ - environments: [ - {environment: 'first', result: themeResult}, - {environment: 'second', result: themeResult}, - {environment: 'third', result: themeResult}, - ], - }) - }) - - test('writes one multi-environment JSON document to stdout with no text on stderr', async () => { - const originalUnitTestEnv = process.env.SHOPIFY_UNIT_TEST - process.env.SHOPIFY_UNIT_TEST = 'false' - vi.resetModules() - const streams = captureStandardStreams() - - try { - const {default: StreamInfo} = await import('./info.js') - const {fetchThemeInfo, getThemeEnvironmentInfo} = await import('../../services/info.js') - const {ensureAuthenticatedThemes} = await import('@shopify/cli-kit/node/session') - const {renderConcurrent} = await import('@shopify/cli-kit/node/ui') - const {loadEnvironment} = await import('@shopify/cli-kit/node/environments') - const {Config} = await import('@oclif/core') - const streamConfig = new Config({root: __dirname}) - await streamConfig.load() - vi.mocked(ensureAuthenticatedThemes).mockResolvedValue(session) - vi.mocked(loadEnvironment) - .mockResolvedValueOnce({store: 'store1.myshopify.com', theme: '123'}) - .mockResolvedValueOnce({store: 'store2.myshopify.com'}) - vi.mocked(fetchThemeInfo).mockResolvedValue(themeResult) - vi.mocked(getThemeEnvironmentInfo).mockReturnValue({result: environmentResult, developmentTheme: undefined}) - vi.mocked(renderConcurrent).mockImplementation(async ({processes}) => { - for (const process of processes) { - // eslint-disable-next-line no-await-in-loop - await process.action({} as Writable, {} as Writable, {} as never) - } - }) - - await new StreamInfo( - ['--password=test-password', '--environment', 'theme-env', '--environment', 'environment-env', '--json'], - streamConfig, - ).run() - } finally { - streams.restore() - restoreUnitTestEnvironment(originalUnitTestEnv) - } - - expect(JSON.parse(streams.stdout())).toEqual({ - environments: [ - {environment: 'theme-env', result: themeResult}, - {environment: 'environment-env', result: environmentResult}, - ], - }) - expect(streams.stderr()).toBe('') - }) - - test('omits failed environments and keeps their errors on stderr', async () => { - vi.mocked(loadEnvironment) - .mockResolvedValueOnce({store: 'store1.myshopify.com', theme: '123'}) - .mockResolvedValueOnce({store: 'store2.myshopify.com', theme: '404'}) - .mockResolvedValueOnce({store: 'store3.myshopify.com', theme: '789'}) vi.mocked(fetchThemeInfo).mockImplementation(async (_session, options) => - options.theme === '404' ? undefined : themeResult, + options.theme === '456' ? secondThemeResult : themeResult, ) executeConcurrentProcessesInOrder() const output = mockAndCaptureOutput() output.clear() - await runMultiEnvironment(['--environment', 'ok', '--environment', 'bad', '--environment', 'another', '--json']) + await runMultiEnvironment(['--environment', 'first', '--environment', 'second', '--json']) - expect(JSON.parse(output.output())).toEqual({ - environments: [ - {environment: 'ok', result: themeResult}, - {environment: 'another', result: themeResult}, - ], - }) - expect(renderError).toHaveBeenCalledWith( - expect.objectContaining({body: ['Environment bad failed: \n\nTheme not found!']}), - ) - }) - - test('emits an empty wrapper when every environment fails and preserves exit behavior', async () => { - vi.mocked(loadEnvironment) - .mockResolvedValueOnce({store: 'store1.myshopify.com', theme: '123'}) - .mockResolvedValueOnce({store: 'store2.myshopify.com', theme: '456'}) - vi.mocked(fetchThemeInfo).mockResolvedValue(undefined) - executeConcurrentProcessesInOrder() - const output = mockAndCaptureOutput() - output.clear() + const expectedOutput = `${JSON.stringify(themeResult, null, 2)}\n${JSON.stringify(secondThemeResult, null, 2)}` - await expect( - runMultiEnvironment(['--environment', 'first', '--environment', 'second', '--json']), - ).resolves.toBeUndefined() - - expect(JSON.parse(output.output())).toEqual({environments: []}) - expect(renderError).toHaveBeenCalledTimes(2) + expect(output.output()).toBe(expectedOutput) + expect(output.output()).not.toContain('environments') + expect(renderInfo).not.toHaveBeenCalled() }) - test('does not emit the JSON wrapper in text mode', async () => { + test('renders each environment result independently in text mode', async () => { vi.mocked(loadEnvironment) .mockResolvedValueOnce({store: 'store1.myshopify.com', theme: '123'}) .mockResolvedValueOnce({store: 'store2.myshopify.com', theme: '456'}) @@ -399,20 +308,29 @@ describe('Info', () => { }) describe('analytics timing', () => { - test('records only the opening timing in JSON mode', async () => { - vi.mocked(fetchThemeInfo).mockResolvedValue(themeResult) + test.each([false, true])('records timings around fetching and presenting the result with json=%s', async (json) => { + const pendingFetch = Promise.withResolvers() + const fetchStarted = Promise.withResolvers() + vi.mocked(fetchThemeInfo).mockImplementation(() => { + fetchStarted.resolve() + return pendingFetch.promise + }) + const presentation = vi.spyOn(infoResult, 'renderThemeInfoResult') - await run(['--theme', '123', '--json']) + const runningCommand = run(['--theme', '123', ...(json ? ['--json'] : [])]) + await fetchStarted.promise expect(vi.mocked(recordTiming).mock.calls).toEqual([['theme-command:info']]) - }) + expect(presentation).not.toHaveBeenCalled() - test('records opening and closing timings in text mode', async () => { - vi.mocked(fetchThemeInfo).mockResolvedValue(themeResult) - - await run(['--theme', '123']) + pendingFetch.resolve(themeResult) + await runningCommand + expect(presentation).toHaveBeenCalledWith(themeResult, json ? 'json' : 'text', expect.any(Object)) expect(vi.mocked(recordTiming).mock.calls).toEqual([['theme-command:info'], ['theme-command:info']]) + expect(vi.mocked(recordTiming).mock.invocationCallOrder[1]).toBeGreaterThan( + presentation.mock.invocationCallOrder[0] ?? Infinity, + ) }) }) }) diff --git a/packages/theme/src/cli/commands/theme/info.ts b/packages/theme/src/cli/commands/theme/info.ts index 55ba596035f..9fa46690f53 100644 --- a/packages/theme/src/cli/commands/theme/info.ts +++ b/packages/theme/src/cli/commands/theme/info.ts @@ -1,7 +1,7 @@ -import ThemeCommand, {type ThemeCommandMultiEnvironmentEntry} from '../../utilities/theme-command.js' +import ThemeCommand from '../../utilities/theme-command.js' import {fetchThemeInfo, getThemeEnvironmentInfo} from '../../services/info.js' -import {renderThemeInfoMultiEnvironmentResult, renderThemeInfoResult} from '../../services/info/result.js' -import {themeInfoJsonOutputSchema, type ThemeInfoResult} from '../../services/info/types.js' +import {renderThemeInfoResult} from '../../services/info/result.js' +import {themeInfoJsonOutputSchema} from '../../services/info/types.js' import {themeFlags} from '../../flags.js' import {Flags} from '@oclif/core' import {AdminSession} from '@shopify/cli-kit/node/session' @@ -12,14 +12,13 @@ import {recordTiming} from '@shopify/cli-kit/node/analytics' type InfoFlags = OutputFlags -export default class Info extends ThemeCommand { +export default class Info extends ThemeCommand { static get jsonOutputSchema() { return themeInfoJsonOutputSchema } - static descriptionWithMarkdown = `Displays information about your theme environment, including your current store. Can also retrieve information about a specific theme. - -Use \`--json\` for machine-readable output.` + static descriptionWithMarkdown = + 'Displays information about your theme environment, including your current store. Can also retrieve information about a specific theme.' static description = this.descriptionForHelp() @@ -41,41 +40,19 @@ Use \`--json\` for machine-readable output.` static multiEnvironmentsFlags = ['store', 'password'] - async command( - flags: InfoFlags, - adminSession: AdminSession, - multiEnvironment = false, - ): Promise { + async command(flags: InfoFlags, adminSession: AdminSession): Promise { recordTiming('theme-command:info') - if (flags.theme || flags.development) { const output = await fetchThemeInfo(adminSession, flags) if (!output) { throw new AbortError('Theme not found!') } - if (multiEnvironment && flags.json) return output - renderThemeInfoResult(output, flags.json ? 'json' : 'text', flags) } else { const {result, developmentTheme} = getThemeEnvironmentInfo({cliVersion: this.config.version}) - - if (multiEnvironment && flags.json) return result - renderThemeInfoResult(result, flags.json ? 'json' : 'text', {developmentTheme}) } - - if (!flags.json) recordTiming('theme-command:info') - - return undefined - } - - protected onMultiEnvironmentComplete( - entries: ThemeCommandMultiEnvironmentEntry[], - flags: InfoFlags, - ): void { - if (flags.json !== true) return - - renderThemeInfoMultiEnvironmentResult({environments: entries}) + recordTiming('theme-command:info') } } diff --git a/packages/theme/src/cli/services/info/result.test.ts b/packages/theme/src/cli/services/info/result.test.ts index 9fd14697f04..2d9e4201162 100644 --- a/packages/theme/src/cli/services/info/result.test.ts +++ b/packages/theme/src/cli/services/info/result.test.ts @@ -1,4 +1,4 @@ -import {renderThemeInfoMultiEnvironmentResult, renderThemeInfoResult} from './result.js' +import {renderThemeInfoResult} from './result.js' import {describe, expect, test, vi} from 'vitest' import {mockAndCaptureOutput} from '@shopify/cli-kit/node/testing/output' import {renderInfo} from '@shopify/cli-kit/node/ui' @@ -63,61 +63,6 @@ describe('renderThemeInfoResult', () => { }) }) - test('encodes the multi-environment wrapper as exactly one JSON document on stdout', () => { - const output = mockAndCaptureOutput() - output.clear() - - renderThemeInfoMultiEnvironmentResult({ - environments: [ - {environment: 'development', result: themeResult}, - {environment: 'staging', result: environmentResult}, - ], - }) - - expect(output.output()).toBe( - `{ - "environments": [ - { - "environment": "development", - "result": { - "theme": { - "id": 123, - "name": "My theme", - "role": "live", - "shop": "my-shop.myshopify.com", - "preview_url": "https://my-shop.myshopify.com/preview", - "editor_url": "https://my-shop.myshopify.com/editor" - } - } - }, - { - "environment": "staging", - "result": { - "store": "my-shop.myshopify.com", - "development_theme_id": null, - "cli_version": "3.91.0", - "os": "darwin-arm64", - "shell": "/bin/zsh", - "node_version": "v24.15.0" - } - } - ] -}`, - ) - expect(output.error()).toBe('') - expect(renderInfo).not.toHaveBeenCalled() - }) - - test('encodes an empty multi-environment wrapper as one JSON document', () => { - const output = mockAndCaptureOutput() - output.clear() - - renderThemeInfoMultiEnvironmentResult({environments: []}) - - expect(output.output()).toBe(`{\n "environments": []\n}`) - expect(output.error()).toBe('') - }) - test('renders environment information as text', () => { renderThemeInfoResult(environmentResult, 'text') diff --git a/packages/theme/src/cli/services/info/result.ts b/packages/theme/src/cli/services/info/result.ts index 195e07ab935..3961d11060e 100644 --- a/packages/theme/src/cli/services/info/result.ts +++ b/packages/theme/src/cli/services/info/result.ts @@ -1,7 +1,6 @@ import { themeInfoJsonOutputSchema, type ThemeEnvironmentInfo, - type ThemeInfoMultiEnvironmentResult, type ThemeInfoResult, type ThemeInfoThemeResult, } from './types.js' @@ -33,10 +32,6 @@ export function renderThemeInfoResult( } } -export function renderThemeInfoMultiEnvironmentResult(result: ThemeInfoMultiEnvironmentResult): void { - outputResult(themeInfoJsonOutputSchema.encode(result)) -} - function formatThemeInfo(output: ThemeInfoThemeResult, options: ThemeInfoPresentationOptions) { const tabularData = Object.entries(output.theme).map(([key, value]) => { if (key === 'editor_url' || key === 'preview_url') { diff --git a/packages/theme/src/cli/services/info/types.test.ts b/packages/theme/src/cli/services/info/types.test.ts index 18280467e25..f6b4fe6b777 100644 --- a/packages/theme/src/cli/services/info/types.test.ts +++ b/packages/theme/src/cli/services/info/types.test.ts @@ -21,26 +21,11 @@ const environmentResult = { node_version: 'v24.15.0', } -const multiEnvironmentResult = { - environments: [ - {environment: 'development', result: themeResult}, - {environment: 'staging', result: environmentResult}, - ], -} - describe('themeInfoJsonOutputSchema', () => { test.each([themeResult, environmentResult])('validates the existing result shape', (result) => { expect(themeInfoJsonOutputSchema.validate(result)).toEqual(result) }) - test('validates a multi-environment wrapper', () => { - expect(themeInfoJsonOutputSchema.validate(multiEnvironmentResult)).toEqual(multiEnvironmentResult) - }) - - test('validates an empty multi-environment wrapper', () => { - expect(themeInfoJsonOutputSchema.validate({environments: []})).toEqual({environments: []}) - }) - test('rejects a theme result with an invalid theme ID', () => { expect(() => themeInfoJsonOutputSchema.validate({...themeResult, theme: {...themeResult.theme, id: '123'}}), @@ -51,21 +36,9 @@ describe('themeInfoJsonOutputSchema', () => { expect(() => themeInfoJsonOutputSchema.validate({...environmentResult, development_theme_id: '123'})).toThrow() }) - test('rejects a multi-environment entry with an invalid result', () => { + test('rejects a multi-environment wrapper', () => { expect(() => - themeInfoJsonOutputSchema.validate({ - environments: [ - {environment: 'development', result: {...themeResult, theme: {...themeResult.theme, id: '123'}}}, - ], - }), + themeInfoJsonOutputSchema.validate({environments: [{environment: 'development', result: themeResult}]}), ).toThrow() }) - - test('rejects a multi-environment entry without an environment name', () => { - expect(() => themeInfoJsonOutputSchema.validate({environments: [{result: themeResult}]})).toThrow() - }) - - test('rejects multi-environment entries that are not an array', () => { - expect(() => themeInfoJsonOutputSchema.validate({environments: {development: themeResult}})).toThrow() - }) }) diff --git a/packages/theme/src/cli/services/info/types.ts b/packages/theme/src/cli/services/info/types.ts index 21f7507e8f7..8a57879cae1 100644 --- a/packages/theme/src/cli/services/info/types.ts +++ b/packages/theme/src/cli/services/info/types.ts @@ -1,4 +1,4 @@ -import {defineJsonOutputSchema} from '@shopify/cli-kit/node/json-output-schema' +import {defineJsonOutputSchema, type InferJsonOutputSchema} from '@shopify/cli-kit/node/json-output-schema' import {zod} from '@shopify/cli-kit/node/schema' const ThemeInfoThemeSchema = zod.object({ @@ -23,28 +23,16 @@ const ThemeEnvironmentInfoSchema = zod.object({ node_version: zod.string(), }) -const ThemeInfoMultiEnvironmentEntrySchema = zod.object({ - environment: zod.string(), - result: zod.union([ThemeInfoThemeResultSchema, ThemeEnvironmentInfoSchema]), -}) - -const ThemeInfoMultiEnvironmentResultSchema = zod.object({ - environments: zod.array(ThemeInfoMultiEnvironmentEntrySchema), -}) - export const themeInfoJsonOutputSchema = defineJsonOutputSchema({ name: 'ThemeInfoResult', - schema: zod.union([ThemeInfoThemeResultSchema, ThemeEnvironmentInfoSchema, ThemeInfoMultiEnvironmentResultSchema]), + schema: zod.union([ThemeInfoThemeResultSchema, ThemeEnvironmentInfoSchema]), definitions: { ThemeInfoTheme: ThemeInfoThemeSchema, ThemeInfoThemeResult: ThemeInfoThemeResultSchema, ThemeEnvironmentInfo: ThemeEnvironmentInfoSchema, - ThemeInfoMultiEnvironmentEntry: ThemeInfoMultiEnvironmentEntrySchema, - ThemeInfoMultiEnvironmentResult: ThemeInfoMultiEnvironmentResultSchema, }, }) -export type ThemeInfoResult = ThemeInfoThemeResult | ThemeEnvironmentInfo +export type ThemeInfoResult = InferJsonOutputSchema export type ThemeInfoThemeResult = zod.infer export type ThemeEnvironmentInfo = zod.infer -export type ThemeInfoMultiEnvironmentResult = zod.infer diff --git a/packages/theme/src/cli/utilities/theme-command-environments.test.ts b/packages/theme/src/cli/utilities/theme-command-environments.test.ts new file mode 100644 index 00000000000..743f668f14c --- /dev/null +++ b/packages/theme/src/cli/utilities/theme-command-environments.test.ts @@ -0,0 +1,138 @@ +import ThemeCommand from './theme-command.js' +import {Config, Flags} from '@oclif/core' +import {FlagOutput} from '@shopify/cli-kit/node/base-command' +import {jsonFlag, requiredIfNonInteractive} from '@shopify/cli-kit/node/cli' +import {inTemporaryDirectory, writeFile} from '@shopify/cli-kit/node/fs' +import {joinPath} from '@shopify/cli-kit/node/path' +import * as pathUtilities from '@shopify/cli-kit/node/path' +import {AbortError} from '@shopify/cli-kit/node/error' +import {renderConcurrent} from '@shopify/cli-kit/node/ui' +import {afterEach, expect, test, vi} from 'vitest' + +vi.mock('@shopify/cli-kit/node/ui', async (importOriginal) => ({ + ...(await importOriginal()), + renderConcurrent: vi.fn(async ({processes, abortSignal}) => { + for (const processToRun of processes) { + // eslint-disable-next-line no-await-in-loop + await processToRun.action(process.stdout, process.stderr, abortSignal) + } + }), +})) + +afterEach(() => { + vi.unstubAllEnvs() +}) + +function environmentCommand(path: string) { + class EnvironmentCommand extends ThemeCommand { + static flags = { + ...jsonFlag, + environment: Flags.string({multiple: true, env: 'SHOPIFY_FLAG_ENVIRONMENT'}), + path: Flags.string({default: path, env: 'SHOPIFY_FLAG_PATH'}), + store: Flags.string({env: 'SHOPIFY_FLAG_STORE'}), + theme: Flags.string({env: 'SHOPIFY_FLAG_THEME_ID'}), + live: Flags.boolean({env: 'SHOPIFY_FLAG_LIVE'}), + 'allow-live': Flags.boolean({env: 'SHOPIFY_FLAG_ALLOW_LIVE'}), + } + + static multiEnvironmentsFlags = ['store', ['theme', 'live']] + + static nonTTYFlagRequirements(flags: FlagOutput) { + return [{flags: ['theme', 'live']}, ...(flags.live ? [{flags: ['allow-live']}] : [])] + } + + calls: FlagOutput[] = [] + + async command(flags: FlagOutput) { + this.calls.push(flags) + } + } + return EnvironmentCommand +} + +test.each([false, true])( + 'validates configured theme selectors after loading every environment (json: %s)', + async (json) => { + vi.stubEnv('CI', '1') + await inTemporaryDirectory(async (path) => { + await writeFile( + joinPath(path, 'shopify.theme.toml'), + '[environments.a]\nstore = "a.myshopify.com"\ntheme = "123"\n[environments.b]\nstore = "b.myshopify.com"\ntheme = "456"\n', + ) + const TestCommand = environmentCommand(path) + const config = new Config({root: __dirname}) + await config.load() + const command = new TestCommand(['--environment', 'a', '--environment', 'b', ...(json ? ['--json'] : [])], config) + + await command.run() + + expect(command.calls.map((flags) => flags.theme)).toEqual(['123', '456']) + }) + }, +) + +test('checks conditional safety flags in every environment before running any command', async () => { + vi.stubEnv('CI', '1') + await inTemporaryDirectory(async (path) => { + await writeFile( + joinPath(path, 'shopify.theme.toml'), + '[environments.a]\nstore = "a.myshopify.com"\ntheme = "123"\n[environments.b]\nstore = "b.myshopify.com"\nlive = true\n', + ) + const TestCommand = environmentCommand(path) + const config = new Config({root: __dirname}) + await config.load() + const command = new TestCommand(['--environment', 'a', '--environment', 'b'], config) + + await expect(command.run()).rejects.toThrow('--allow-live') + expect(command.calls).toEqual([]) + expect(renderConcurrent).not.toHaveBeenCalled() + }) +}) + +test('keeps noninteractive validation for commands that parse flags in their own runner', async () => { + vi.stubEnv('CI', '1') + class CommandWithOwnRunner extends ThemeCommand { + static flags = { + environment: Flags.string({multiple: true, env: 'SHOPIFY_FLAG_ENVIRONMENT'}), + theme: requiredIfNonInteractive(Flags.string({env: 'SHOPIFY_FLAG_THEME_ID'})), + } + + async run() { + await this.parse(CommandWithOwnRunner) + } + } + const config = new Config({root: __dirname}) + await config.load() + const command = new CommandWithOwnRunner(['--environment', 'a', '--environment', 'b'], config) + + await expect(command.run()).rejects.toThrow('--theme') +}) + +test.each([false, true])( + 'rejects global --path with recovery advice when the config file exists: %s', + async (configExists) => { + await inTemporaryDirectory(async (path) => { + if (configExists) { + await writeFile(joinPath(path, 'shopify.theme.toml'), '') + } + const cwdSpy = vi.spyOn(pathUtilities, 'cwd').mockReturnValue(path) + try { + const TestCommand = environmentCommand(path) + const config = new Config({root: __dirname}) + await config.load() + const command = new TestCommand(['--environment', 'a', '--environment', 'b', '--path', path], config) + + const runningCommand = command.run() + await expect(runningCommand).rejects.toBeInstanceOf(AbortError) + await expect(runningCommand).rejects.toMatchObject({ + message: "Can't use `--path` flag with multiple environments.", + tryMessage: configExists + ? "Configure each environment's theme path in your shopify.theme.toml file instead." + : 'Run this command from the directory containing shopify.theme.toml. No shopify.theme.toml found in current directory.', + }) + } finally { + cwdSpy.mockRestore() + } + }) + }, +) diff --git a/packages/theme/src/cli/utilities/theme-command.test.ts b/packages/theme/src/cli/utilities/theme-command.test.ts index cae699e3dc4..c4c43bb8488 100644 --- a/packages/theme/src/cli/utilities/theme-command.test.ts +++ b/packages/theme/src/cli/utilities/theme-command.test.ts @@ -1,4 +1,4 @@ -import ThemeCommand, {RequiredFlags, type ThemeCommandMultiEnvironmentEntry} from './theme-command.js' +import ThemeCommand, {RequiredFlags} from './theme-command.js' import {ensureThemeStore} from './theme-store.js' import {describe, vi, expect, test, beforeEach} from 'vitest' import {Config, Flags} from '@oclif/core' @@ -38,6 +38,8 @@ vi.mock('@shopify/cli-kit/node/fs') const CommandConfig = new Config({root: __dirname}) class TestThemeCommand extends ThemeCommand { + static id = 'theme:test' + static flags = { environment: Flags.string({ multiple: true, @@ -157,42 +159,6 @@ class TestNoMultiEnvThemeCommand extends TestThemeCommand { static multiEnvironmentsFlags: RequiredFlags = null } -class TestResultThemeCommand extends ThemeCommand { - static flags = {...TestThemeCommand.flags} - - static multiEnvironmentsFlags: RequiredFlags = ['store'] - - completedEntries: ThemeCommandMultiEnvironmentEntry[] = [] - lifecycle: string[] = [] - - async command(flags: any, _session?: AdminSession, _multiEnvironment = false): Promise { - const environment = flags.environment?.[0] as string - this.lifecycle.push(`command:${environment}`) - - if (environment === 'command-error') { - throw new Error('Mocking a command error') - } - - return `result-for-${environment}` - } - - protected onMultiEnvironmentComplete(entries: ThemeCommandMultiEnvironmentEntry[]): void { - this.lifecycle.push('complete') - this.completedEntries = entries - } -} - -class TestResultThemeCommandWithDefaultHook extends ThemeCommand { - static flags = {...TestThemeCommand.flags} - - static multiEnvironmentsFlags: RequiredFlags = ['store'] - - async command(flags: any): Promise { - const environment = flags.environment?.[0] as string - return `result-for-${environment}` - } -} - class TestThemeCommandWithoutStoreRequired extends ThemeCommand { static flags = { environment: Flags.string({ @@ -649,7 +615,7 @@ describe('ThemeCommand', () => { expect(fileExistsSync).toHaveBeenCalledWith('current/working/directory') }) - test('multiple environments provided - displays warning if not allowed', async () => { + test('multiple environments provided - fails if not allowed', async () => { // Given const environmentConfig = {store: 'store.myshopify.com'} vi.mocked(loadEnvironment).mockResolvedValue(environmentConfig) @@ -663,15 +629,7 @@ describe('ThemeCommand', () => { CommandConfig, ) - // When - await command.run() - - // Then - expect(renderWarning).toHaveBeenCalledWith( - expect.objectContaining({ - body: 'This command does not support multiple environments.', - }), - ) + await expect(command.run()).rejects.toThrow('This command does not support multiple environments.') }) }) @@ -803,7 +761,7 @@ describe('ThemeCommand', () => { expect(renderConfirmationPrompt).toHaveBeenCalledOnce() expect(renderConfirmationPrompt).toHaveBeenCalledWith( expect.objectContaining({ - message: ['Run testthemecommandwithpathflag in the following environments?'], + message: ['Run theme test in the following environments?'], infoTable: { Environment: [ ['development', {subdued: 'store: store1.myshopify.com, password, path: /home/.../theme1'}], @@ -1001,60 +959,6 @@ describe('ThemeCommand', () => { ) }) - test('commands should display an error if the --path flag is used', async () => { - // Given - const environmentConfig = {store: 'store.myshopify.com'} - vi.mocked(loadEnvironment).mockResolvedValue(environmentConfig) - vi.mocked(renderConfirmationPrompt).mockResolvedValue(true) - - await CommandConfig.load() - const command = new TestThemeCommand( - ['--environment', 'command-error', '--environment', 'development', '--path', 'path'], - CommandConfig, - ) - - // When - await command.run() - - // Then - expect(renderError).toHaveBeenCalledWith( - expect.objectContaining({ - body: [ - "Can't use `--path` flag with multiple environments.", - "Configure each environment's theme path in your shopify.theme.toml file instead.", - ], - }), - ) - }) - - test('commands should display an error if the --path flag is used and no shopify.theme.toml is found', async () => { - // Given - const environmentConfig = {store: 'store.myshopify.com'} - vi.mocked(loadEnvironment).mockResolvedValue(environmentConfig) - vi.mocked(renderConfirmationPrompt).mockResolvedValue(true) - vi.mocked(fileExistsSync).mockReturnValue(false) - - await CommandConfig.load() - const command = new TestThemeCommand( - ['--environment', 'command-error', '--environment', 'development', '--path', 'path'], - CommandConfig, - ) - - // When - await command.run() - - // Then - expect(renderError).toHaveBeenCalledWith( - expect.objectContaining({ - body: [ - "Can't use `--path` flag with multiple environments.", - 'Run this command from the directory containing shopify.theme.toml.', - 'No shopify.theme.toml found in current directory.', - ], - }), - ) - }) - test('CLI and shopify.theme.toml flag values take precedence over defaults', async () => { // Given vi.mocked(loadEnvironment) @@ -1225,239 +1129,4 @@ describe('ThemeCommand', () => { expect(ensureAuthenticatedThemes).not.toHaveBeenCalled() }) }) - - describe('multi environment result collection', () => { - test('collects successful results in requested order when completion order differs', async () => { - // Given - vi.mocked(loadEnvironment) - .mockResolvedValueOnce({store: 'store1.myshopify.com'}) - .mockResolvedValueOnce({store: 'store2.myshopify.com'}) - .mockResolvedValueOnce({store: 'store3.myshopify.com'}) - vi.mocked(ensureThemeStore).mockImplementation((options: any) => options.store) - - await CommandConfig.load() - const command = new TestResultThemeCommand( - ['--environment', 'first', '--environment', 'second', '--environment', 'third'], - CommandConfig, - ) - - // Completion happens in reverse requested order - vi.mocked(renderConcurrent).mockImplementation(async ({processes}) => { - for (const process of [...processes].reverse()) { - // eslint-disable-next-line no-await-in-loop - await process.action({} as Writable, {} as Writable, {} as any) - } - }) - - // When - await command.run() - - // Then - expect(command.lifecycle.filter((event) => event.startsWith('command:'))).toEqual([ - 'command:third', - 'command:second', - 'command:first', - ]) - expect(command.completedEntries).toEqual([ - {environment: 'first', result: 'result-for-first'}, - {environment: 'second', result: 'result-for-second'}, - {environment: 'third', result: 'result-for-third'}, - ]) - }) - - test('omits failed executions from the collected results', async () => { - // Given - vi.mocked(loadEnvironment) - .mockResolvedValueOnce({store: 'store1.myshopify.com'}) - .mockResolvedValueOnce({store: 'store2.myshopify.com'}) - .mockResolvedValueOnce({store: 'store3.myshopify.com'}) - vi.mocked(ensureThemeStore).mockImplementation((options: any) => options.store) - vi.mocked(renderConcurrent).mockImplementation(async ({processes}) => { - for (const process of processes) { - // eslint-disable-next-line no-await-in-loop - await process.action({} as Writable, {} as Writable, {} as any) - } - }) - - await CommandConfig.load() - const command = new TestResultThemeCommand( - ['--environment', 'ok', '--environment', 'command-error', '--environment', 'another'], - CommandConfig, - ) - - // When - await command.run() - - // Then - expect(command.completedEntries).toEqual([ - {environment: 'ok', result: 'result-for-ok'}, - {environment: 'another', result: 'result-for-another'}, - ]) - expect(renderError).toHaveBeenCalledWith( - expect.objectContaining({body: ['Environment command-error failed: \n\nMocking a command error']}), - ) - }) - - test('omits an environment from results when its analytics cleanup fails', async () => { - // Given - vi.mocked(loadEnvironment) - .mockResolvedValueOnce({store: 'store1.myshopify.com'}) - .mockResolvedValueOnce({store: 'store2.myshopify.com'}) - vi.mocked(ensureThemeStore).mockImplementation((options: any) => options.store) - vi.mocked(ensureAuthenticatedThemes).mockImplementation(async (store) => ({ - token: 'test-token', - storeFqdn: store, - })) - vi.mocked(renderConcurrent).mockImplementation(async ({processes}) => { - for (const process of processes) { - // eslint-disable-next-line no-await-in-loop - await process.action({} as Writable, {} as Writable, {} as any) - } - }) - - // The second environment's analytics cleanup fails after its command returns a result. - vi.mocked(addPublicMetadata).mockImplementation(async (getMetadata) => { - const metadata = (await getMetadata()) as {store_domain?: string} - if (metadata.store_domain === 'store2.myshopify.com') throw new Error('analytics unavailable') - }) - - await CommandConfig.load() - const command = new TestResultThemeCommand( - ['--environment', 'ok', '--environment', 'analytics-fails'], - CommandConfig, - ) - - // When - await command.run() - - // Then - expect(command.completedEntries).toEqual([{environment: 'ok', result: 'result-for-ok'}]) - expect(renderError).toHaveBeenCalledWith( - expect.objectContaining({body: ['Environment analytics-fails failed: \n\nanalytics unavailable']}), - ) - }) - - test('runs the completion hook after all duplicate-store groups finish', async () => { - // Given - vi.mocked(loadEnvironment) - .mockResolvedValueOnce({store: 'store1.myshopify.com'}) - .mockResolvedValueOnce({store: 'store1.myshopify.com'}) - .mockResolvedValueOnce({store: 'store2.myshopify.com'}) - vi.mocked(ensureThemeStore).mockImplementation((options: any) => options.store) - - await CommandConfig.load() - const command = new TestResultThemeCommand( - ['--environment', 'first', '--environment', 'second', '--environment', 'third'], - CommandConfig, - ) - vi.mocked(renderConcurrent).mockImplementation(async ({processes}) => { - command.lifecycle.push(`group:${processes.map((process) => process.prefix).join('+')}`) - for (const process of processes) { - // eslint-disable-next-line no-await-in-loop - await process.action({} as Writable, {} as Writable, {} as any) - } - }) - - // When - await command.run() - - // Then - expect(vi.mocked(renderConcurrent)).toHaveBeenCalledTimes(2) - expect(command.lifecycle).toEqual([ - 'group:first+third', - 'command:first', - 'command:third', - 'group:second', - 'command:second', - 'complete', - ]) - expect(command.completedEntries.map((entry) => entry.environment)).toEqual(['first', 'second', 'third']) - }) - - test('the default completion hook leaves command output unchanged', async () => { - // Given - vi.mocked(loadEnvironment) - .mockResolvedValueOnce({store: 'store1.myshopify.com'}) - .mockResolvedValueOnce({store: 'store2.myshopify.com'}) - vi.mocked(ensureThemeStore).mockImplementation((options: any) => options.store) - vi.mocked(renderConcurrent).mockImplementation(async ({processes}) => { - for (const process of processes) { - // eslint-disable-next-line no-await-in-loop - await process.action({} as Writable, {} as Writable, {} as any) - } - }) - - await CommandConfig.load() - const command = new TestResultThemeCommandWithDefaultHook( - ['--environment', 'first', '--environment', 'second'], - CommandConfig, - ) - - // When - await command.run() - - // Then - expect(mockAndCaptureOutput().output()).toBe('') - expect(renderError).not.toHaveBeenCalled() - }) - - test('does not resolve until an asynchronous completion hook finishes', async () => { - // Given - vi.mocked(loadEnvironment) - .mockResolvedValueOnce({store: 'store1.myshopify.com'}) - .mockResolvedValueOnce({store: 'store2.myshopify.com'}) - vi.mocked(ensureThemeStore).mockImplementation((options: any) => options.store) - vi.mocked(renderConcurrent).mockImplementation(async ({processes}) => { - for (const process of processes) { - // eslint-disable-next-line no-await-in-loop - await process.action({} as Writable, {} as Writable, {} as any) - } - }) - - let signalHookStarted: () => void = () => {} - const hookStarted = new Promise((resolve) => { - signalHookStarted = resolve - }) - let releaseHook: () => void = () => {} - - class AsyncCompletionThemeCommand extends ThemeCommand { - static flags = {...TestThemeCommand.flags} - - static multiEnvironmentsFlags: RequiredFlags = ['store'] - - async command(flags: any): Promise { - return `result-for-${flags.environment?.[0]}` - } - - protected async onMultiEnvironmentComplete(): Promise { - signalHookStarted() - await new Promise((resolve) => { - releaseHook = resolve - }) - } - } - - await CommandConfig.load() - const command = new AsyncCompletionThemeCommand( - ['--environment', 'first', '--environment', 'second'], - CommandConfig, - ) - - // When - let didResolve = false - const runPromise = command.run().then(() => { - didResolve = true - }) - - await hookStarted - await Promise.resolve() - - // Then - expect(didResolve).toBe(false) - - releaseHook() - await runPromise - expect(didResolve).toBe(true) - }) - }) }) diff --git a/packages/theme/src/cli/utilities/theme-command.ts b/packages/theme/src/cli/utilities/theme-command.ts index 26335eb4c53..18f594e1786 100644 --- a/packages/theme/src/cli/utilities/theme-command.ts +++ b/packages/theme/src/cli/utilities/theme-command.ts @@ -40,12 +40,6 @@ interface ValidEnvironment { storeAuthSession?: AdminSession } type EnvironmentName = string - -export interface ThemeCommandMultiEnvironmentEntry { - environment: EnvironmentName - result: TResult -} - /** * Flags required to run a command in multiple environments * @@ -62,7 +56,7 @@ export interface ThemeCommandMultiEnvironmentEntry { */ export type RequiredFlags = (string | string[])[] | null -export default abstract class ThemeCommand extends Command { +export default abstract class ThemeCommand extends Command { static baseFlags = {...Command.baseFlags, ...authAliasFlag} environmentsFilename(): string { @@ -75,9 +69,7 @@ export default abstract class ThemeCommand extends Command { _multiEnvironment = false, _args?: ArgOutput, _context?: {stdout?: Writable; stderr?: Writable}, - ): Promise { - return undefined - } + ): Promise {} async run< TFlags extends FlagOutput & {path?: string; verbose?: boolean}, @@ -126,14 +118,12 @@ export default abstract class ThemeCommand extends Command { // Multiple environments if (requiredFlags === null) { - renderWarning({body: 'This command does not support multiple environments.'}) - return + throw new AbortError('This command does not support multiple environments.') } const {flags: flagsWithoutDefaults} = await this.parse(noDefaultsOptions(klass), this.argv) if ('path' in flagsWithoutDefaults) { this.errorOnGlobalPath() - return } const environmentsMap = await this.loadEnvironments(environments, flags, flagsWithoutDefaults) @@ -142,28 +132,30 @@ export default abstract class ThemeCommand extends Command { const commandAllowsForceFlag = 'force' in klass.flags if (commandAllowsForceFlag && !flags.force) { - const confirmed = await this.showConfirmation(this.constructor.name, requiredFlags, validationResults) + const confirmed = await this.showConfirmation( + (this.id ?? 'theme').replaceAll(':', ' '), + requiredFlags, + validationResults, + ) if (!confirmed) return } - const successfulResults = new Map() - await this.runConcurrent(validationResults.valid, successfulResults) - - const entries = validationResults.valid - .filter(({environment}) => successfulResults.has(environment)) - .map(({environment}) => ({environment, result: successfulResults.get(environment) as TResult})) - - await this.onMultiEnvironmentComplete(entries, flags) + await this.runConcurrent(validationResults.valid) } - /** - * Receives the successful multi-environment results in the requested environment order. - * The default does nothing so commands keep their current output behavior. - */ - protected onMultiEnvironmentComplete( - _entries: ThemeCommandMultiEnvironmentEntry[], - _flags: FlagValues, - ): void | Promise {} + protected validateNonTTYFlags(flags: FlagOutput): void { + // Multiple environments must be validated after their configured flags are loaded. + const command = this.constructor + if ( + 'multiEnvironmentsFlags' in command && + command.multiEnvironmentsFlags !== undefined && + Array.isArray(flags.environment) && + flags.environment.length > 1 + ) { + return + } + super.validateNonTTYFlags(flags) + } /** * Admin API scopes that a stored `store auth` session must include for this @@ -248,6 +240,7 @@ export default abstract class ThemeCommand extends Command { invalid.push({environment: environmentName, reason: `Missing flags: ${missingFlagsText}`}) continue } + super.validateNonTTYFlags(flags) valid.push({environment: environmentName, flags, requiresAuth, storeAuthSession}) } @@ -313,7 +306,7 @@ 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[], successfulResults: Map) { + private async runConcurrent(validEnvironments: ValidEnvironment[]) { const abortController = new AbortController() const stores = validEnvironments.map((env) => env.flags.store as string) @@ -335,18 +328,11 @@ export default abstract class ThemeCommand extends Command { const commandName = this.constructor.name.toLowerCase() recordEvent(`theme-command:${commandName}:multi-env:authenticated`) - let result: TResult | undefined try { - result = await this.command(flags, session, true, {}, {stdout, stderr}) + await this.command(flags, session, true, {}, {stdout, stderr}) } finally { await this.logAnalyticsData(session) } - - // Only publish a result after analytics cleanup succeeds, so a - // cleanup failure reports the environment as failed. - if (result !== undefined) { - successfulResults.set(environment, result) - } }) // eslint-disable-next-line no-catch-all/no-catch-all @@ -545,17 +531,12 @@ export default abstract class ThemeCommand extends Command { const tomlPath = joinPath(cwd(), 'shopify.theme.toml') const tomlInCwd = fileExistsSync(tomlPath) - renderError({ - body: [ - "Can't use `--path` flag with multiple environments.", - ...(tomlInCwd - ? ["Configure each environment's theme path in your shopify.theme.toml file instead."] - : [ - 'Run this command from the directory containing shopify.theme.toml.', - 'No shopify.theme.toml found in current directory.', - ]), - ], - }) + throw new AbortError( + "Can't use `--path` flag with multiple environments.", + tomlInCwd + ? "Configure each environment's theme path in your shopify.theme.toml file instead." + : 'Run this command from the directory containing shopify.theme.toml. No shopify.theme.toml found in current directory.', + ) } private async logAnalyticsData(session?: AdminSession): Promise {