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/cli/README.md b/packages/cli/README.md index ecea5aaef2e..e15bd77cef5 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -4935,6 +4935,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 3121725eac6..4fccd932f11 100644 --- a/packages/cli/oclif.manifest.json +++ b/packages/cli/oclif.manifest.json @@ -9818,7 +9818,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/eslint-plugin-cli/rules/json-output-command-exceptions.js b/packages/eslint-plugin-cli/rules/json-output-command-exceptions.js index 9eb6f761bf7..2934a0d941e 100644 --- a/packages/eslint-plugin-cli/rules/json-output-command-exceptions.js +++ b/packages/eslint-plugin-cli/rules/json-output-command-exceptions.js @@ -81,7 +81,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..90a716795ec 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,187 @@ 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('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), + '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..e0841db8ade 100644 --- a/packages/theme/src/cli/commands/theme/info.ts +++ b/packages/theme/src/cli/commands/theme/info.ts @@ -1,20 +1,27 @@ 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' 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, @@ -42,19 +49,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..c0a0330f51a 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,33 @@ 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 without reading the development theme when no store is configured', () => { + vi.mocked(getThemeStore).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', + development_theme_id: null, + cli_version: '3.91.0', + shell: process.env.SHELL ?? 'unknown', + node_version: process.version, + }) + expect(getDevelopmentTheme).not.toHaveBeenCalled() + }) + + 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', }) }) diff --git a/packages/theme/src/cli/services/info.ts b/packages/theme/src/cli/services/info.ts index b49c24f93fd..9c5060b882a 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 = store ? getDevelopmentTheme() : undefined + const developmentThemeID = Number(developmentTheme) || 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