Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/theme-info-json-schema.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@shopify/theme': minor
---

Add a JSON output schema for `theme info`
32 changes: 32 additions & 0 deletions packages/cli/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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]`
Expand Down
3 changes: 2 additions & 1 deletion packages/cli/oclif.manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
196 changes: 149 additions & 47 deletions packages/theme/src/cli/commands/theme/info.test.ts
Original file line number Diff line number Diff line change
@@ -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})
Expand All @@ -18,86 +19,187 @@
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(

Check failure on line 143 in packages/theme/src/cli/commands/theme/info.test.ts

View workflow job for this annotation

GitHub Actions / Unit tests with Node 24.1.0 in ubuntu-latest

[@shopify/theme] src/cli/commands/theme/info.test.ts > Info > is removed from the JSON legacy exemption list

Error: ENOENT: no such file or directory, open '/home/runner/work/cli/cli/packages/eslint-plugin-cli/rules/json-output-legacy-command-paths.js' ❯ src/cli/commands/theme/info.test.ts:143:32 ⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯ Serialized Error: { errno: -2, code: 'ENOENT', syscall: 'open', path: '/home/runner/work/cli/cli/packages/eslint-plugin-cli/rules/json-output-legacy-command-paths.js' }

Check failure on line 143 in packages/theme/src/cli/commands/theme/info.test.ts

View workflow job for this annotation

GitHub Actions / Unit tests with Node 26.1.0 in ubuntu-latest

[@shopify/theme] src/cli/commands/theme/info.test.ts > Info > is removed from the JSON legacy exemption list

Error: ENOENT: no such file or directory, open '/home/runner/work/cli/cli/packages/eslint-plugin-cli/rules/json-output-legacy-command-paths.js' ❯ src/cli/commands/theme/info.test.ts:143:32 ⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯ Serialized Error: { errno: -2, code: 'ENOENT', syscall: 'open', path: '/home/runner/work/cli/cli/packages/eslint-plugin-cli/rules/json-output-legacy-command-paths.js' }

Check failure on line 143 in packages/theme/src/cli/commands/theme/info.test.ts

View workflow job for this annotation

GitHub Actions / Unit tests with Node 26.1.0 in windows-latest (shard 1/2)

[@shopify/theme] src/cli/commands/theme/info.test.ts > Info > is removed from the JSON legacy exemption list

Error: ENOENT: no such file or directory, open 'D:\a\cli\cli\packages\eslint-plugin-cli\rules\json-output-legacy-command-paths.js' ❯ src/cli/commands/theme/info.test.ts:143:32 ⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯ Serialized Error: { errno: -4058, code: 'ENOENT', syscall: 'open', path: 'D:\a\cli\cli\packages\eslint-plugin-cli\rules\json-output-legacy-command-paths.js' }

Check failure on line 143 in packages/theme/src/cli/commands/theme/info.test.ts

View workflow job for this annotation

GitHub Actions / Unit tests with Node 24.1.0 in windows-latest (shard 1/2)

[@shopify/theme] src/cli/commands/theme/info.test.ts > Info > is removed from the JSON legacy exemption list

Error: ENOENT: no such file or directory, open 'D:\a\cli\cli\packages\eslint-plugin-cli\rules\json-output-legacy-command-paths.js' ❯ src/cli/commands/theme/info.test.ts:143:32 ⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯ Serialized Error: { errno: -4058, code: 'ENOENT', syscall: 'open', path: 'D:\a\cli\cli\packages\eslint-plugin-cli\rules\json-output-legacy-command-paths.js' }
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('')
})
})
32 changes: 15 additions & 17 deletions packages/theme/src/cli/commands/theme/info.ts
Original file line number Diff line number Diff line change
@@ -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<typeof Info.flags>

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,
Expand Down Expand Up @@ -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')
}
Expand Down
Loading
Loading