Skip to content
Open
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
110 changes: 110 additions & 0 deletions packages/cli/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -7962,6 +7962,116 @@ DESCRIPTION
"requestId": "12345-abcde-67890"
}
```

Use `--json-schema` to print the result, error, and event schemas.

Output from `--json` conforms to the `ThemeDuplicateResult` schema.

```json
{
"anyOf": [
{
"type": "object",
"properties": {
"status": {
"type": "string",
"const": "success"
},
"originalTheme": {
"type": "object",
"properties": {
"id": {
"$ref": "#/definitions/DuplicatedTheme/properties/id"
},
"name": {
"$ref": "#/definitions/DuplicatedTheme/properties/name"
},
"role": {
"$ref": "#/definitions/DuplicatedTheme/properties/role"
}
},
"required": [
"id",
"name",
"role"
],
"additionalProperties": false
},
"theme": {
"$ref": "#/definitions/DuplicatedTheme"
}
},
"required": [
"status",
"originalTheme",
"theme"
],
"additionalProperties": false
},
{
"$ref": "#/definitions/ThemeDuplicateError"
}
],
"title": "ThemeDuplicateResult",
"definitions": {
"DuplicatedTheme": {
"type": "object",
"properties": {
"id": {
"type": "number"
},
"name": {
"type": "string"
},
"role": {
"type": "string"
},
"shop": {
"type": "string"
},
"preview_url": {
"type": "string"
}
},
"required": [
"id",
"name",
"role",
"shop"
],
"additionalProperties": false
},
"ThemeDuplicateError": {
"type": "object",
"properties": {
"status": {
"type": "string",
"const": "failed"
},
"message": {
"type": "string"
},
"errors": {
"type": "array",
"items": {
"type": "string"
}
},
"requestId": {
"type": "string"
}
},
"required": [
"status",
"message",
"errors"
],
"additionalProperties": false
}
},
"$schema": "http://json-schema.org/draft-07/schema#"
}
```
```

## `shopify theme info`
Expand Down
2 changes: 1 addition & 1 deletion packages/cli/oclif.manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -10007,7 +10007,7 @@
"args": {
},
"customPluginName": "@shopify/theme",
"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```",
"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\nUse `--json-schema` to print the result, error, and event schemas.\n\nOutput from `--json` conforms to the `ThemeDuplicateResult` schema.\n\n```json\n{\n \"anyOf\": [\n {\n \"type\": \"object\",\n \"properties\": {\n \"status\": {\n \"type\": \"string\",\n \"const\": \"success\"\n },\n \"originalTheme\": {\n \"type\": \"object\",\n \"properties\": {\n \"id\": {\n \"$ref\": \"#/definitions/DuplicatedTheme/properties/id\"\n },\n \"name\": {\n \"$ref\": \"#/definitions/DuplicatedTheme/properties/name\"\n },\n \"role\": {\n \"$ref\": \"#/definitions/DuplicatedTheme/properties/role\"\n }\n },\n \"required\": [\n \"id\",\n \"name\",\n \"role\"\n ],\n \"additionalProperties\": false\n },\n \"theme\": {\n \"$ref\": \"#/definitions/DuplicatedTheme\"\n }\n },\n \"required\": [\n \"status\",\n \"originalTheme\",\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 \"preview_url\": {\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 \"status\": {\n \"type\": \"string\",\n \"const\": \"failed\"\n },\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 \"status\",\n \"message\",\n \"errors\"\n ],\n \"additionalProperties\": false\n }\n },\n \"$schema\": \"http://json-schema.org/draft-07/schema#\"\n}\n```",
"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```",
"enableJsonFlag": false,
"flags": {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,6 @@ const commandExceptions = [
'packages/plugin-did-you-mean/src/commands/config/autocorrect/status.ts',
'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/init.ts',
'packages/theme/src/cli/commands/theme/metafields/pull.ts',
'packages/theme/src/cli/commands/theme/package.ts',
Expand Down
112 changes: 112 additions & 0 deletions packages/theme/src/cli/commands/theme/duplicate.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
import Duplicate from './duplicate.js'
import {themeDuplicateJsonOutputSchema} from '../../services/duplicate/types.js'
import {findThemeById} from '../../utilities/theme-selector.js'
import {Config} from '@oclif/core'
import {ensureAuthenticatedThemes} from '@shopify/cli-kit/node/session'
import {themeDuplicate} from '@shopify/cli-kit/node/themes/api'
import {withCapturedStandardStreams} from '@shopify/cli-kit/node/testing/output'
import {outputWarn} from '@shopify/cli-kit/node/output'
import {runWithCommandEventsForCommand} from '@shopify/cli-kit/node/command-events'
import {describe, expect, test, vi} from 'vitest'

vi.mock('@shopify/cli-kit/node/session')
vi.mock('@shopify/cli-kit/node/themes/api')
vi.mock('../../utilities/theme-selector.js')

const originalTheme = {id: 1, name: 'Original', role: 'unpublished', processing: false, createdAtRuntime: false}
const copiedTheme = {...originalTheme, id: 2, name: 'Copy'}
const session = {token: 'token', storeFqdn: 'test.myshopify.com'}

async function run() {
const config = new Config({root: __dirname})
await config.load()
vi.mocked(ensureAuthenticatedThemes).mockResolvedValue(session)
const argv = ['--store', session.storeFqdn, '--theme', '1', '--force', '--json']
await runWithCommandEventsForCommand(argv, () => new Duplicate(argv, config).run())
}

describe('theme duplicate JSON output', () => {
test('exposes its schema in help and keeps the JSON flag', () => {
expect(Duplicate.jsonOutputSchema).toBe(themeDuplicateJsonOutputSchema)
expect(Duplicate.flags.json).toBeDefined()
expect(Duplicate.description).toContain('ThemeDuplicateResult')
})

test('writes the duplication receipt and routes diagnostics to stderr', async () => {
vi.mocked(findThemeById).mockResolvedValue(originalTheme)
vi.mocked(themeDuplicate).mockImplementation(async () => {
outputWarn('Retrying request')
return {theme: copiedTheme, userErrors: [], requestId: 'omitted-on-success'}
})

await withCapturedStandardStreams(async ({stdout, stderr}) => {
await run()
expect(JSON.parse(stdout())).toEqual({
status: 'success',
originalTheme: {id: 1, name: 'Original', role: 'unpublished'},
theme: {
id: 2,
name: 'Copy',
role: 'unpublished',
shop: session.storeFqdn,
preview_url: 'https://test.myshopify.com?preview_theme_id=2',
},
})
expect(JSON.parse(stderr())).toMatchObject({type: 'diagnostic', level: 'warning', message: 'Retrying request'})
})
})

test.each([undefined, '', 'request-123'])('preserves errors and request ID omission (%s)', async (requestId) => {
vi.mocked(findThemeById).mockResolvedValue(originalTheme)
vi.mocked(themeDuplicate).mockResolvedValue({userErrors: [{message: 'Limit reached'}], requestId})
const exitCode = process.exitCode

await withCapturedStandardStreams(async ({stdout, stderr}) => {
await run()
expect(stdout()).toBe(
`${JSON.stringify({status: 'failed', message: "The theme 'Original' could not be duplicated due to errors", errors: ['Limit reached'], requestId})}\n`,
)
expect(stderr()).toBe('')
expect(process.exitCode).toBe(exitCode)
})
})

test('reports API errors even when a theme is returned', async () => {
vi.mocked(findThemeById).mockResolvedValue(originalTheme)
vi.mocked(themeDuplicate).mockResolvedValue({theme: copiedTheme, userErrors: [{message: 'Duplication failed'}]})
await withCapturedStandardStreams(async ({stdout}) => {
await run()
expect(JSON.parse(stdout())).toMatchObject({status: 'failed', errors: ['Duplication failed']})
expect(JSON.parse(stdout())).not.toHaveProperty('theme')
})
})

test('keeps the trailing space in unexpected failure messages', async () => {
vi.mocked(findThemeById).mockResolvedValue(originalTheme)
vi.mocked(themeDuplicate).mockResolvedValue({userErrors: []})
await withCapturedStandardStreams(async ({stdout}) => {
await run()
expect(stdout()).toBe(
'{"status":"failed","message":"The theme \'Original\' unexpectedly could not be duplicated ","errors":[]}\n',
)
})
})

test('does not write a result when the API throws', async () => {
vi.mocked(findThemeById).mockResolvedValue(originalTheme)
vi.mocked(themeDuplicate).mockRejectedValue(new Error('Network failure'))
await withCapturedStandardStreams(async ({stdout}) => {
await expect(run()).rejects.toThrow('Network failure')
expect(stdout()).toBe('')
})
})

test.each([
{status: 'success', originalTheme, theme: {id: '2', name: 'Copy', role: 'unpublished', shop: session.storeFqdn}},
{status: 'success', originalTheme, theme: {id: 2, name: null, role: 'unpublished', shop: session.storeFqdn}},
{status: 'failed', message: 'Failed', errors: [1]},
{status: 'failed', message: 'Failed', errors: [], requestId: null},
])('rejects malformed public results %#', (result) => {
expect(() => themeDuplicateJsonOutputSchema.validate(result)).toThrow()
})
})
11 changes: 10 additions & 1 deletion packages/theme/src/cli/commands/theme/duplicate.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
import {themeDuplicateJsonOutputSchema} from '../../services/duplicate/types.js'
import {renderThemeDuplicateResult} from '../../services/duplicate/result.js'
import {configureCLIEnvironment} from '../../utilities/cli-config.js'
import {ensureThemeStore} from '../../utilities/theme-store.js'
import {themeFlags} from '../../flags.js'
import ThemeCommand from '../../utilities/theme-command.js'
Expand All @@ -9,6 +12,10 @@ import {isCI} from '@shopify/cli-kit/node/system'
import type {NonTTYFlagRequirement} from '@shopify/cli-kit/node/base-command'

export default class Duplicate extends ThemeCommand {
static get jsonOutputSchema() {
return themeDuplicateJsonOutputSchema
}

static summary = 'Duplicates a theme from your theme library.'

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

await duplicate(adminSession, flags.theme, flags)
configureCLIEnvironment(flags)
const result = await duplicate(adminSession, flags.theme, flags)
renderThemeDuplicateResult(result, flags.json ? 'json' : 'text')
}
}
38 changes: 33 additions & 5 deletions packages/theme/src/cli/services/duplicate.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
// packages/theme/src/cli/services/duplicate.test.ts
import {duplicate} from './duplicate.js'
import {configureCLIEnvironment} from '../utilities/cli-config.js'
import {duplicate as executeDuplicate} from './duplicate.js'
import {renderThemeDuplicateResult} from './duplicate/result.js'
import {themeComponent} from '../utilities/theme-ui.js'
import {findThemeById, findOrSelectTheme} from '../utilities/theme-selector.js'
import {themeDuplicate} from '@shopify/cli-kit/node/themes/api'
Expand All @@ -17,7 +16,6 @@ vi.mock('@shopify/cli-kit/node/themes/api')
vi.mock('@shopify/cli-kit/node/output')
vi.mock('../utilities/theme-selector.js')
vi.mock('../utilities/theme-ui.js')
vi.mock('../utilities/cli-config.js')

const session: AdminSession = {
token: 'token',
Expand All @@ -44,7 +42,6 @@ const options = {
describe('duplicate', () => {
beforeEach(() => {
vi.mocked(themeComponent).mockReturnValue(['theme component'])
vi.mocked(configureCLIEnvironment).mockReturnValue()
vi.mocked(outputResult).mockReturnValue()
})

Expand Down Expand Up @@ -266,10 +263,41 @@ describe('duplicate', () => {
// Then
expect(outputResult).toHaveBeenCalledWith(
JSON.stringify({
status: 'failed',
message: `The theme '${theme.name}' unexpectedly could not be duplicated `,
errors: [],
requestId: '12345-abcde-67890',
}),
)
})
})

async function duplicate(
session: AdminSession,
themeId: string | undefined,
flags: Parameters<typeof executeDuplicate>[2] & {json?: boolean},
) {
const result = await executeDuplicate(session, themeId, flags)
renderThemeDuplicateResult(result, flags.json ? 'json' : 'text')
return result
}

test('returns a typed result without presenting the final output', async () => {
vi.mocked(isCI).mockReturnValue(true)
vi.mocked(findThemeById).mockResolvedValue(theme)
vi.mocked(themeDuplicate).mockResolvedValue({theme: duplicatedTheme, userErrors: [], requestId: 'request-123'})

const result = await executeDuplicate(session, '1', {force: true})

expect(result).toMatchObject({
status: 'completed',
originalTheme: theme,
theme: duplicatedTheme,
shop: session.storeFqdn,
previewUrl: 'https://my-shop.myshopify.com?preview_theme_id=2',
requestId: 'request-123',
})
expect(outputResult).not.toHaveBeenCalled()
expect(renderSuccess).not.toHaveBeenCalled()
expect(renderError).not.toHaveBeenCalled()
})
Loading
Loading