Skip to content

Commit d1a063b

Browse files
Add typed JSON output to theme duplicate
1 parent bed6431 commit d1a063b

9 files changed

Lines changed: 365 additions & 154 deletions

File tree

‎packages/cli/README.md‎

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7523,6 +7523,82 @@ DESCRIPTION
75237523
"requestId": "12345-abcde-67890"
75247524
}
75257525
```
7526+
7527+
Output from `--json` conforms to the `ThemeDuplicateResult` schema.
7528+
7529+
Use `--json-schema` to print the result, error, and event schemas.
7530+
7531+
```json
7532+
{
7533+
"anyOf": [
7534+
{
7535+
"type": "object",
7536+
"properties": {
7537+
"theme": {
7538+
"$ref": "#/definitions/DuplicatedTheme"
7539+
}
7540+
},
7541+
"required": [
7542+
"theme"
7543+
],
7544+
"additionalProperties": false
7545+
},
7546+
{
7547+
"$ref": "#/definitions/ThemeDuplicateError"
7548+
}
7549+
],
7550+
"title": "ThemeDuplicateResult",
7551+
"definitions": {
7552+
"DuplicatedTheme": {
7553+
"type": "object",
7554+
"properties": {
7555+
"id": {
7556+
"type": "number"
7557+
},
7558+
"name": {
7559+
"type": "string"
7560+
},
7561+
"role": {
7562+
"type": "string"
7563+
},
7564+
"shop": {
7565+
"type": "string"
7566+
}
7567+
},
7568+
"required": [
7569+
"id",
7570+
"name",
7571+
"role",
7572+
"shop"
7573+
],
7574+
"additionalProperties": false
7575+
},
7576+
"ThemeDuplicateError": {
7577+
"type": "object",
7578+
"properties": {
7579+
"message": {
7580+
"type": "string"
7581+
},
7582+
"errors": {
7583+
"type": "array",
7584+
"items": {
7585+
"type": "string"
7586+
}
7587+
},
7588+
"requestId": {
7589+
"type": "string"
7590+
}
7591+
},
7592+
"required": [
7593+
"message",
7594+
"errors"
7595+
],
7596+
"additionalProperties": false
7597+
}
7598+
},
7599+
"$schema": "http://json-schema.org/draft-07/schema#"
7600+
}
7601+
```
75267602
```
75277603
75287604
## `shopify theme info`

‎packages/cli/oclif.manifest.json‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9998,7 +9998,7 @@
99989998
"args": {
99999999
},
1000010000
"customPluginName": "@shopify/theme",
10001-
"description": "If you want to duplicate your local theme, you need to run `shopify theme push` first.\n\nIf no theme ID is specified, you're prompted to select the theme that you want to duplicate from the list of themes in your store. You're asked to confirm that you want to duplicate the specified theme.\n\nPrompts and confirmations are not shown when duplicate is run in a CI environment or the `--force` flag is used, therefore you must specify a theme ID using the `--theme` flag.\n\nYou can optionally name the duplicated theme using the `--name` flag.\n\nIf you use the `--json` flag, then theme information is returned in JSON format, which can be used as a machine-readable input for scripts or continuous integration.\n\nSample JSON output:\n\n```json\n{\n \"theme\": {\n \"id\": 108267175958,\n \"name\": \"A Duplicated Theme\",\n \"role\": \"unpublished\",\n \"shop\": \"mystore.myshopify.com\"\n }\n}\n```\n\n```json\n{\n \"message\": \"The theme 'Summer Edition' could not be duplicated due to errors\",\n \"errors\": [\"Maximum number of themes reached\"],\n \"requestId\": \"12345-abcde-67890\"\n}\n```",
10001+
"description": "If you want to duplicate your local theme, you need to run `shopify theme push` first.\n\nIf no theme ID is specified, you're prompted to select the theme that you want to duplicate from the list of themes in your store. You're asked to confirm that you want to duplicate the specified theme.\n\nPrompts and confirmations are not shown when duplicate is run in a CI environment or the `--force` flag is used, therefore you must specify a theme ID using the `--theme` flag.\n\nYou can optionally name the duplicated theme using the `--name` flag.\n\nIf you use the `--json` flag, then theme information is returned in JSON format, which can be used as a machine-readable input for scripts or continuous integration.\n\nSample JSON output:\n\n```json\n{\n \"theme\": {\n \"id\": 108267175958,\n \"name\": \"A Duplicated Theme\",\n \"role\": \"unpublished\",\n \"shop\": \"mystore.myshopify.com\"\n }\n}\n```\n\n```json\n{\n \"message\": \"The theme 'Summer Edition' could not be duplicated due to errors\",\n \"errors\": [\"Maximum number of themes reached\"],\n \"requestId\": \"12345-abcde-67890\"\n}\n```\n\nOutput from `--json` conforms to the `ThemeDuplicateResult` schema.\n\nUse `--json-schema` to print the result, error, and event schemas.\n\n```json\n{\n \"anyOf\": [\n {\n \"type\": \"object\",\n \"properties\": {\n \"theme\": {\n \"$ref\": \"#/definitions/DuplicatedTheme\"\n }\n },\n \"required\": [\n \"theme\"\n ],\n \"additionalProperties\": false\n },\n {\n \"$ref\": \"#/definitions/ThemeDuplicateError\"\n }\n ],\n \"title\": \"ThemeDuplicateResult\",\n \"definitions\": {\n \"DuplicatedTheme\": {\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 },\n \"required\": [\n \"id\",\n \"name\",\n \"role\",\n \"shop\"\n ],\n \"additionalProperties\": false\n },\n \"ThemeDuplicateError\": {\n \"type\": \"object\",\n \"properties\": {\n \"message\": {\n \"type\": \"string\"\n },\n \"errors\": {\n \"type\": \"array\",\n \"items\": {\n \"type\": \"string\"\n }\n },\n \"requestId\": {\n \"type\": \"string\"\n }\n },\n \"required\": [\n \"message\",\n \"errors\"\n ],\n \"additionalProperties\": false\n }\n },\n \"$schema\": \"http://json-schema.org/draft-07/schema#\"\n}\n```",
1000210002
"descriptionWithMarkdown": "If you want to duplicate your local theme, you need to run `shopify theme push` first.\n\nIf no theme ID is specified, you're prompted to select the theme that you want to duplicate from the list of themes in your store. You're asked to confirm that you want to duplicate the specified theme.\n\nPrompts and confirmations are not shown when duplicate is run in a CI environment or the `--force` flag is used, therefore you must specify a theme ID using the `--theme` flag.\n\nYou can optionally name the duplicated theme using the `--name` flag.\n\nIf you use the `--json` flag, then theme information is returned in JSON format, which can be used as a machine-readable input for scripts or continuous integration.\n\nSample JSON output:\n\n```json\n{\n \"theme\": {\n \"id\": 108267175958,\n \"name\": \"A Duplicated Theme\",\n \"role\": \"unpublished\",\n \"shop\": \"mystore.myshopify.com\"\n }\n}\n```\n\n```json\n{\n \"message\": \"The theme 'Summer Edition' could not be duplicated due to errors\",\n \"errors\": [\"Maximum number of themes reached\"],\n \"requestId\": \"12345-abcde-67890\"\n}\n```",
1000310003
"enableJsonFlag": false,
1000410004
"flags": {

‎packages/eslint-plugin-cli/rules/json-output-command-exceptions.js‎

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,6 @@ const commandExceptions = [
6262
'packages/plugin-did-you-mean/src/commands/config/autocorrect/status.ts',
6363
'packages/theme/src/cli/commands/theme/check.ts',
6464
'packages/theme/src/cli/commands/theme/delete.ts',
65-
'packages/theme/src/cli/commands/theme/duplicate.ts',
6665
'packages/theme/src/cli/commands/theme/init.ts',
6766
'packages/theme/src/cli/commands/theme/metafields/pull.ts',
6867
'packages/theme/src/cli/commands/theme/package.ts',
Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
import Duplicate from './duplicate.js'
2+
import {themeDuplicateJsonOutputSchema} from '../../services/duplicate/types.js'
3+
import {findThemeById} from '../../utilities/theme-selector.js'
4+
import {Config} from '@oclif/core'
5+
import {ensureAuthenticatedThemes} from '@shopify/cli-kit/node/session'
6+
import {themeDuplicate} from '@shopify/cli-kit/node/themes/api'
7+
import {withCapturedStandardStreams} from '@shopify/cli-kit/node/testing/output'
8+
import {outputWarn} from '@shopify/cli-kit/node/output'
9+
import {runWithCommandEventsForCommand} from '@shopify/cli-kit/node/command-events'
10+
import {describe, expect, test, vi} from 'vitest'
11+
12+
vi.mock('@shopify/cli-kit/node/session')
13+
vi.mock('@shopify/cli-kit/node/themes/api')
14+
vi.mock('../../utilities/theme-selector.js')
15+
16+
const originalTheme = {id: 1, name: 'Original', role: 'unpublished', processing: false, createdAtRuntime: false}
17+
const copiedTheme = {...originalTheme, id: 2, name: 'Copy'}
18+
const session = {token: 'token', storeFqdn: 'test.myshopify.com'}
19+
20+
async function run() {
21+
const config = new Config({root: __dirname})
22+
await config.load()
23+
vi.mocked(ensureAuthenticatedThemes).mockResolvedValue(session)
24+
const argv = ['--store', session.storeFqdn, '--theme', '1', '--force', '--json']
25+
await runWithCommandEventsForCommand(argv, () => new Duplicate(argv, config).run())
26+
}
27+
28+
describe('theme duplicate JSON output', () => {
29+
test('exposes its schema in help and keeps the JSON flag', () => {
30+
expect(Duplicate.jsonOutputSchema).toBe(themeDuplicateJsonOutputSchema)
31+
expect(Duplicate.flags.json).toBeDefined()
32+
expect(Duplicate.description).toContain('ThemeDuplicateResult')
33+
})
34+
35+
test('writes the exact legacy document and routes diagnostics to stderr', async () => {
36+
vi.mocked(findThemeById).mockResolvedValue(originalTheme)
37+
vi.mocked(themeDuplicate).mockImplementation(async () => {
38+
outputWarn('Retrying request')
39+
return {theme: copiedTheme, userErrors: [], requestId: 'omitted-on-success'}
40+
})
41+
42+
await withCapturedStandardStreams(async ({stdout, stderr}) => {
43+
await run()
44+
expect(stdout()).toBe('{"theme":{"id":2,"name":"Copy","role":"unpublished","shop":"test.myshopify.com"}}\n')
45+
expect(JSON.parse(stderr())).toMatchObject({type: 'diagnostic', level: 'warning', message: 'Retrying request'})
46+
})
47+
})
48+
49+
test.each([undefined, '', 'request-123'])('preserves errors and request ID omission (%s)', async (requestId) => {
50+
vi.mocked(findThemeById).mockResolvedValue(originalTheme)
51+
vi.mocked(themeDuplicate).mockResolvedValue({userErrors: [{message: 'Limit reached'}], requestId})
52+
const exitCode = process.exitCode
53+
54+
await withCapturedStandardStreams(async ({stdout, stderr}) => {
55+
await run()
56+
expect(stdout()).toBe(
57+
`${JSON.stringify({message: "The theme 'Original' could not be duplicated due to errors", errors: ['Limit reached'], requestId})}\n`,
58+
)
59+
expect(stderr()).toBe('')
60+
expect(process.exitCode).toBe(exitCode)
61+
})
62+
})
63+
64+
test('keeps the trailing space in unexpected failure messages', async () => {
65+
vi.mocked(findThemeById).mockResolvedValue(originalTheme)
66+
vi.mocked(themeDuplicate).mockResolvedValue({userErrors: []})
67+
await withCapturedStandardStreams(async ({stdout}) => {
68+
await run()
69+
expect(stdout()).toBe('{"message":"The theme \'Original\' unexpectedly could not be duplicated ","errors":[]}\n')
70+
})
71+
})
72+
73+
test('does not write a result when the API throws', async () => {
74+
vi.mocked(findThemeById).mockResolvedValue(originalTheme)
75+
vi.mocked(themeDuplicate).mockRejectedValue(new Error('Network failure'))
76+
await withCapturedStandardStreams(async ({stdout}) => {
77+
await expect(run()).rejects.toThrow('Network failure')
78+
expect(stdout()).toBe('')
79+
})
80+
})
81+
82+
test.each([
83+
{theme: {id: '2', name: 'Copy', role: 'unpublished', shop: session.storeFqdn}},
84+
{theme: {id: 2, name: null, role: 'unpublished', shop: session.storeFqdn}},
85+
{message: 'Failed', errors: [1]},
86+
{message: 'Failed', errors: [], requestId: null},
87+
])('rejects malformed public results %#', (result) => {
88+
expect(() => themeDuplicateJsonOutputSchema.validate(result)).toThrow()
89+
})
90+
})

‎packages/theme/src/cli/commands/theme/duplicate.ts‎

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,6 @@
1+
import {themeDuplicateJsonOutputSchema} from '../../services/duplicate/types.js'
2+
import {renderThemeDuplicateResult} from '../../services/duplicate/result.js'
3+
import {configureCLIEnvironment} from '../../utilities/cli-config.js'
14
import {ensureThemeStore} from '../../utilities/theme-store.js'
25
import {themeFlags} from '../../flags.js'
36
import ThemeCommand from '../../utilities/theme-command.js'
@@ -9,6 +12,10 @@ import {isCI} from '@shopify/cli-kit/node/system'
912
import type {NonTTYFlagRequirement} from '@shopify/cli-kit/node/base-command'
1013

1114
export default class Duplicate extends ThemeCommand {
15+
static get jsonOutputSchema() {
16+
return themeDuplicateJsonOutputSchema
17+
}
18+
1219
static summary = 'Duplicates a theme from your theme library.'
1320

1421
static usage = ['theme duplicate', "theme duplicate --theme 10 --name 'New Theme'"]
@@ -81,6 +88,8 @@ Sample JSON output:
8188
const store = ensureThemeStore(flags)
8289
const adminSession = await ensureAuthenticatedThemes(store, flags.password)
8390

84-
await duplicate(adminSession, flags.theme, flags)
91+
configureCLIEnvironment(flags)
92+
const result = await duplicate(adminSession, flags.theme, flags)
93+
renderThemeDuplicateResult(result, flags.json ? 'json' : 'text')
8594
}
8695
}

‎packages/theme/src/cli/services/duplicate.test.ts‎

Lines changed: 32 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
1-
// packages/theme/src/cli/services/duplicate.test.ts
2-
import {duplicate} from './duplicate.js'
3-
import {configureCLIEnvironment} from '../utilities/cli-config.js'
1+
import {duplicate as executeDuplicate} from './duplicate.js'
2+
import {renderThemeDuplicateResult} from './duplicate/result.js'
43
import {themeComponent} from '../utilities/theme-ui.js'
54
import {findThemeById, findOrSelectTheme} from '../utilities/theme-selector.js'
65
import {themeDuplicate} from '@shopify/cli-kit/node/themes/api'
@@ -17,7 +16,6 @@ vi.mock('@shopify/cli-kit/node/themes/api')
1716
vi.mock('@shopify/cli-kit/node/output')
1817
vi.mock('../utilities/theme-selector.js')
1918
vi.mock('../utilities/theme-ui.js')
20-
vi.mock('../utilities/cli-config.js')
2119

2220
const session: AdminSession = {
2321
token: 'token',
@@ -44,7 +42,6 @@ const options = {
4442
describe('duplicate', () => {
4543
beforeEach(() => {
4644
vi.mocked(themeComponent).mockReturnValue(['theme component'])
47-
vi.mocked(configureCLIEnvironment).mockReturnValue()
4845
vi.mocked(outputResult).mockReturnValue()
4946
})
5047

@@ -273,3 +270,33 @@ describe('duplicate', () => {
273270
)
274271
})
275272
})
273+
274+
async function duplicate(
275+
session: AdminSession,
276+
themeId: string | undefined,
277+
flags: Parameters<typeof executeDuplicate>[2] & {json?: boolean},
278+
) {
279+
const result = await executeDuplicate(session, themeId, flags)
280+
renderThemeDuplicateResult(result, flags.json ? 'json' : 'text')
281+
return result
282+
}
283+
284+
test('returns a typed result without presenting the final output', async () => {
285+
vi.mocked(isCI).mockReturnValue(true)
286+
vi.mocked(findThemeById).mockResolvedValue(theme)
287+
vi.mocked(themeDuplicate).mockResolvedValue({theme: duplicatedTheme, userErrors: [], requestId: 'request-123'})
288+
289+
const result = await executeDuplicate(session, '1', {force: true})
290+
291+
expect(result).toMatchObject({
292+
status: 'completed',
293+
originalTheme: theme,
294+
theme: duplicatedTheme,
295+
shop: session.storeFqdn,
296+
previewUrl: 'https://my-shop.myshopify.com?preview_theme_id=2',
297+
requestId: 'request-123',
298+
})
299+
expect(outputResult).not.toHaveBeenCalled()
300+
expect(renderSuccess).not.toHaveBeenCalled()
301+
expect(renderError).not.toHaveBeenCalled()
302+
})

0 commit comments

Comments
 (0)