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`
102 changes: 102 additions & 0 deletions packages/cli/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4935,6 +4935,108 @@ 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 result, error, and event schemas.

```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#"
}
```
```

## `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 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": {
"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 @@ 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-command-exceptions.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('')
})
})
Loading
Loading