Skip to content

Commit 521fb07

Browse files
committed
add themeDuplicate theme command
Currently theme duplication is only possible via the admin, but we want to add the ability to duplicate themes with a CLI command. This commit adds a new command `shopify theme duplicate` that will duplicate a store theme.
1 parent c52343d commit 521fb07

File tree

14 files changed

+958
-1
lines changed

14 files changed

+958
-1
lines changed

.changeset/famous-dragons-dress.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
---
2+
'@shopify/features': minor
3+
'@shopify/cli-kit': minor
4+
'@shopify/theme': minor
5+
'@shopify/cli': minor
6+
---
7+
8+
Add new `theme duplicate` command to duplicate store themes
Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
/* eslint-disable @typescript-eslint/consistent-type-definitions */
2+
import * as Types from './types.js'
3+
4+
import {TypedDocumentNode as DocumentNode} from '@graphql-typed-document-node/core'
5+
6+
export type ThemeDuplicateMutationVariables = Types.Exact<{
7+
id: Types.Scalars['ID']['input']
8+
name?: Types.InputMaybe<Types.Scalars['String']['input']>
9+
}>
10+
11+
export type ThemeDuplicateMutation = {
12+
themeDuplicate?: {
13+
newTheme?: {id: string; name: string; role: Types.ThemeRole} | null
14+
userErrors: {field?: string[] | null; message: string}[]
15+
} | null
16+
}
17+
18+
export const ThemeDuplicate = {
19+
kind: 'Document',
20+
definitions: [
21+
{
22+
kind: 'OperationDefinition',
23+
operation: 'mutation',
24+
name: {kind: 'Name', value: 'themeDuplicate'},
25+
variableDefinitions: [
26+
{
27+
kind: 'VariableDefinition',
28+
variable: {kind: 'Variable', name: {kind: 'Name', value: 'id'}},
29+
type: {kind: 'NonNullType', type: {kind: 'NamedType', name: {kind: 'Name', value: 'ID'}}},
30+
},
31+
{
32+
kind: 'VariableDefinition',
33+
variable: {kind: 'Variable', name: {kind: 'Name', value: 'name'}},
34+
type: {kind: 'NamedType', name: {kind: 'Name', value: 'String'}},
35+
},
36+
],
37+
selectionSet: {
38+
kind: 'SelectionSet',
39+
selections: [
40+
{
41+
kind: 'Field',
42+
name: {kind: 'Name', value: 'themeDuplicate'},
43+
arguments: [
44+
{
45+
kind: 'Argument',
46+
name: {kind: 'Name', value: 'id'},
47+
value: {kind: 'Variable', name: {kind: 'Name', value: 'id'}},
48+
},
49+
{
50+
kind: 'Argument',
51+
name: {kind: 'Name', value: 'name'},
52+
value: {kind: 'Variable', name: {kind: 'Name', value: 'name'}},
53+
},
54+
],
55+
selectionSet: {
56+
kind: 'SelectionSet',
57+
selections: [
58+
{
59+
kind: 'Field',
60+
name: {kind: 'Name', value: 'newTheme'},
61+
selectionSet: {
62+
kind: 'SelectionSet',
63+
selections: [
64+
{kind: 'Field', name: {kind: 'Name', value: 'id'}},
65+
{kind: 'Field', name: {kind: 'Name', value: 'name'}},
66+
{kind: 'Field', name: {kind: 'Name', value: 'role'}},
67+
{kind: 'Field', name: {kind: 'Name', value: '__typename'}},
68+
],
69+
},
70+
},
71+
{
72+
kind: 'Field',
73+
name: {kind: 'Name', value: 'userErrors'},
74+
selectionSet: {
75+
kind: 'SelectionSet',
76+
selections: [
77+
{kind: 'Field', name: {kind: 'Name', value: 'field'}},
78+
{kind: 'Field', name: {kind: 'Name', value: 'message'}},
79+
{kind: 'Field', name: {kind: 'Name', value: '__typename'}},
80+
],
81+
},
82+
},
83+
{kind: 'Field', name: {kind: 'Name', value: '__typename'}},
84+
],
85+
},
86+
},
87+
],
88+
},
89+
},
90+
],
91+
} as unknown as DocumentNode<ThemeDuplicateMutation, ThemeDuplicateMutationVariables>
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
mutation themeDuplicate($id: ID!, $name: String) {
2+
themeDuplicate(id: $id, name: $name) {
3+
newTheme {
4+
id
5+
name
6+
role
7+
}
8+
userErrors {
9+
field
10+
message
11+
}
12+
}
13+
}

packages/cli-kit/src/public/node/themes/api.test.ts

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import {
22
themeCreate,
33
themeDelete,
4+
themeDuplicate,
45
fetchTheme,
56
fetchThemes,
67
ThemeParams,
@@ -14,6 +15,7 @@ import {
1415
} from './api.js'
1516
import {Operation} from './types.js'
1617
import {ThemeDelete} from '../../../cli/api/graphql/admin/generated/theme_delete.js'
18+
import {ThemeDuplicate} from '../../../cli/api/graphql/admin/generated/theme_duplicate.js'
1719
import {ThemeUpdate} from '../../../cli/api/graphql/admin/generated/theme_update.js'
1820
import {ThemePublish} from '../../../cli/api/graphql/admin/generated/theme_publish.js'
1921
import {ThemeCreate} from '../../../cli/api/graphql/admin/generated/theme_create.js'
@@ -444,6 +446,42 @@ describe('themeDelete', () => {
444446
}
445447
})
446448

449+
describe('themeDuplicate', () => {
450+
for (const [sessionType, session] of Object.entries(sessions)) {
451+
test(`duplicates a theme with graphql with a ${sessionType} session`, async () => {
452+
// Given
453+
const id = 123
454+
const name = 'duplicate theme'
455+
456+
vi.mocked(adminRequestDoc).mockResolvedValue({
457+
themeDuplicate: {
458+
newTheme: {id: `gid://shopify/OnlineStoreTheme/${id}`, name, role: 'UNPUBLISHED'},
459+
userErrors: [],
460+
},
461+
})
462+
// When
463+
const response = await themeDuplicate(id, name, session)
464+
465+
// Then
466+
expect(adminRequestDoc).toHaveBeenCalledWith({
467+
query: ThemeDuplicate,
468+
session,
469+
variables: {id: `gid://shopify/OnlineStoreTheme/${id}`, name},
470+
requestBehaviour: expectedApiOptions,
471+
version: '2025-10',
472+
responseOptions: {
473+
onResponse: expect.any(Function),
474+
},
475+
})
476+
expect(response).toMatchObject({
477+
theme: {id, name, role: 'unpublished'},
478+
userErrors: [],
479+
requestId: undefined,
480+
})
481+
})
482+
}
483+
})
484+
447485
describe('request errors', () => {
448486
test(`returns AbortError when graphql returns user error`, async () => {
449487
// Given

packages/cli-kit/src/public/node/themes/api.ts

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import {buildTheme} from './factories.js'
33
import {Result, Checksum, Key, Theme, ThemeAsset, Operation} from './types.js'
44
import {ThemeUpdate} from '../../../cli/api/graphql/admin/generated/theme_update.js'
55
import {ThemeDelete} from '../../../cli/api/graphql/admin/generated/theme_delete.js'
6+
import {ThemeDuplicate} from '../../../cli/api/graphql/admin/generated/theme_duplicate.js'
67
import {ThemePublish} from '../../../cli/api/graphql/admin/generated/theme_publish.js'
78
import {ThemeCreate} from '../../../cli/api/graphql/admin/generated/theme_create.js'
89
import {GetThemeFileBodies} from '../../../cli/api/graphql/admin/generated/get_theme_file_bodies.js'
@@ -446,6 +447,73 @@ export async function themeDelete(id: number, session: AdminSession): Promise<bo
446447
return true
447448
}
448449

450+
export interface ThemeDuplicateResult {
451+
theme?: Theme
452+
userErrors: {field?: string[] | null; message: string}[]
453+
requestId?: string
454+
}
455+
456+
export async function themeDuplicate(
457+
id: number,
458+
name: string | undefined,
459+
session: AdminSession,
460+
): Promise<ThemeDuplicateResult> {
461+
let requestId: string | undefined
462+
463+
const {themeDuplicate} = await adminRequestDoc({
464+
query: ThemeDuplicate,
465+
session,
466+
variables: {id: composeThemeGid(id), name},
467+
requestBehaviour: THEME_API_NETWORK_BEHAVIOUR,
468+
version: '2025-10',
469+
responseOptions: {
470+
onResponse: (response) => {
471+
requestId = response.headers.get('x-request-id') ?? undefined
472+
},
473+
},
474+
})
475+
476+
if (!themeDuplicate) {
477+
// An unexpected error occurred during the GraphQL request execution
478+
return {
479+
theme: undefined,
480+
userErrors: [{message: 'Failed to duplicate theme'}],
481+
requestId,
482+
}
483+
}
484+
485+
const {newTheme, userErrors} = themeDuplicate
486+
487+
if (userErrors.length > 0) {
488+
return {
489+
theme: undefined,
490+
userErrors,
491+
requestId,
492+
}
493+
}
494+
495+
if (!newTheme) {
496+
// An unexpected error if neither theme nor userErrors are returned
497+
return {
498+
theme: undefined,
499+
userErrors: [{message: 'Failed to duplicate theme'}],
500+
requestId,
501+
}
502+
}
503+
504+
const theme = buildTheme({
505+
id: parseGid(newTheme.id),
506+
name: newTheme.name,
507+
role: newTheme.role.toLowerCase(),
508+
})
509+
510+
return {
511+
theme,
512+
userErrors: [],
513+
requestId,
514+
}
515+
}
516+
449517
export async function metafieldDefinitionsByOwnerType(type: MetafieldOwnerType, session: AdminSession) {
450518
const {metafieldDefinitions} = await adminRequestDoc({
451519
query: MetafieldDefinitionsByOwnerType,

packages/cli/README.md

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,7 @@
6767
* [`shopify theme console`](#shopify-theme-console)
6868
* [`shopify theme delete`](#shopify-theme-delete)
6969
* [`shopify theme dev`](#shopify-theme-dev)
70+
* [`shopify theme duplicate`](#shopify-theme-duplicate)
7071
* [`shopify theme info`](#shopify-theme-info)
7172
* [`shopify theme init [name] [flags]`](#shopify-theme-init-name-flags)
7273
* [`shopify theme language-server`](#shopify-theme-language-server)
@@ -1931,6 +1932,65 @@ DESCRIPTION
19311932
(https://shopify.dev/docs/themes/tools/cli#directory-structure).
19321933
```
19331934

1935+
## `shopify theme duplicate`
1936+
1937+
Duplicates a theme from your theme library.
1938+
1939+
```
1940+
USAGE
1941+
$ shopify theme duplicate
1942+
$ shopify theme duplicate --theme 10 --name 'New Theme'
1943+
1944+
FLAGS
1945+
-e, --environment=<value>... The environment to apply to the current command.
1946+
-f, --force Force the duplicate operation to run without prompts or confirmations.
1947+
-j, --json Output the result as JSON.
1948+
-n, --name=<value> Name of the newly duplicated theme.
1949+
-s, --store=<value> Store URL. It can be the store prefix (example) or the full myshopify.com URL
1950+
(example.myshopify.com, https://example.myshopify.com).
1951+
-t, --theme=<value> Theme ID or name of the remote theme.
1952+
--no-color Disable color output.
1953+
--password=<value> Password generated from the Theme Access app.
1954+
--verbose Increase the verbosity of the output.
1955+
1956+
DESCRIPTION
1957+
Duplicates a theme from your theme library.
1958+
1959+
If you want to duplicate your local theme, you need to run `shopify theme push` first.
1960+
1961+
If no theme ID is specified, you're prompted to select the theme that you want to duplicate from the list of themes in
1962+
your store. You're asked to confirm that you want to duplicate the specified theme.
1963+
1964+
Prompts and confirmations are not shown when duplicate is run in a CI environment or the `--force` flag is used,
1965+
therefore you must specify a theme ID using the `--theme` flag.
1966+
1967+
You can optionally name the duplicated theme using the `--name` flag.
1968+
1969+
If you use the `--json` flag, then theme information is returned in JSON format, which can be used as a
1970+
machine-readable input for scripts or continuous integration.
1971+
1972+
Sample JSON output:
1973+
1974+
```json
1975+
{
1976+
"theme": {
1977+
"id": 108267175958,
1978+
"name": "A Duplicated Theme",
1979+
"role": "unpublished",
1980+
"shop": "mystore.myshopify.com"
1981+
}
1982+
}
1983+
```
1984+
1985+
```json
1986+
{
1987+
"message": "The theme 'Summer Edition' could not be duplicated due to errors",
1988+
"errors": ["Maximum number of themes reached"],
1989+
"requestId": "12345-abcde-67890"
1990+
}
1991+
```
1992+
```
1993+
19341994
## `shopify theme info`
19351995
19361996
Displays information about your theme environment, including your current store. Can also retrieve information about a specific theme.

0 commit comments

Comments
 (0)