From a5eefd4495d94cc40aa7ab78ecc759c7eb4bf9c1 Mon Sep 17 00:00:00 2001 From: Gray Gilmore Date: Fri, 21 Nov 2025 16:55:57 -0800 Subject: [PATCH 1/4] Add missing ThemeManager tests --- .../public/node/themes/theme-manager.test.ts | 199 ++++++++++++++++++ 1 file changed, 199 insertions(+) create mode 100644 packages/cli-kit/src/public/node/themes/theme-manager.test.ts diff --git a/packages/cli-kit/src/public/node/themes/theme-manager.test.ts b/packages/cli-kit/src/public/node/themes/theme-manager.test.ts new file mode 100644 index 00000000000..46d7eedc5a7 --- /dev/null +++ b/packages/cli-kit/src/public/node/themes/theme-manager.test.ts @@ -0,0 +1,199 @@ +import {ThemeManager} from './theme-manager.js' +import {Theme} from './types.js' +import {fetchTheme, themeCreate} from './api.js' +import {DEVELOPMENT_THEME_ROLE, UNPUBLISHED_THEME_ROLE} from './utils.js' +import {BugError} from '../error.js' +import {test, describe, expect, vi, beforeEach} from 'vitest' + +vi.mock('./api.js') +vi.mock('../../../private/node/themes/generate-theme-name.js', () => ({ + generateThemeName: vi.fn((context: string) => `${context} (test-123-hostname)`), +})) + +const session = {token: 'token', storeFqdn: 'my-shop.myshopify.com', refresh: async () => {}} + +class TestThemeManager extends ThemeManager { + protected context = 'test-context' + private storedThemeId: string | undefined + + constructor(adminSession: any) { + super(adminSession) + this.storedThemeId = undefined + } + + getStoredThemeId(): string | undefined { + return this.storedThemeId + } + + setThemeId(themeId: string | undefined): void { + this.themeId = themeId + } + + protected setTheme(themeId: string): void { + this.storedThemeId = themeId + this.themeId = themeId + } + + protected removeTheme(): void { + this.storedThemeId = undefined + this.themeId = undefined + } +} + +const mockTheme: Theme = { + id: 123, + name: 'Test Theme', + role: DEVELOPMENT_THEME_ROLE, + processing: false, + createdAtRuntime: false, +} + +describe('ThemeManager', () => { + let manager: TestThemeManager + + beforeEach(() => { + manager = new TestThemeManager(session) + }) + + describe('findOrCreate', () => { + test('returns an existing theme when one exists', async () => { + // Given + manager.setThemeId('123') + vi.mocked(fetchTheme).mockResolvedValue(mockTheme) + + // When + const result = await manager.findOrCreate() + + // Then + expect(fetchTheme).toHaveBeenCalledWith(123, session) + expect(result).toEqual(mockTheme) + expect(themeCreate).not.toHaveBeenCalled() + }) + + test('creates a new theme when one does not exist', async () => { + // Given + manager.setThemeId(undefined) + vi.mocked(themeCreate).mockResolvedValue(mockTheme) + + expect(manager.getStoredThemeId()).toBeUndefined() + + // When + const result = await manager.findOrCreate() + + // Then + expect(fetchTheme).not.toHaveBeenCalled() + expect(themeCreate).toHaveBeenCalledWith( + { + name: 'test-context (test-123-hostname)', + role: DEVELOPMENT_THEME_ROLE, + }, + session, + ) + expect(result).toEqual(mockTheme) + expect(manager.getStoredThemeId()).toBe('123') + }) + }) + + describe('fetch', () => { + test('returns undefined when no themeId is set', async () => { + // Given + manager.setThemeId(undefined) + + // When + const result = await manager.fetch() + + // Then + expect(result).toBeUndefined() + expect(fetchTheme).not.toHaveBeenCalled() + }) + + test('fetches and returns a theme when themeId is set', async () => { + // Given + manager.setThemeId('123') + vi.mocked(fetchTheme).mockResolvedValue(mockTheme) + + // When + const result = await manager.fetch() + + // Then + expect(fetchTheme).toHaveBeenCalledWith(123, session) + expect(result).toEqual(mockTheme) + }) + + test('removes theme when fetch returns undefined', async () => { + // Given + manager.setThemeId('123') + vi.mocked(fetchTheme).mockResolvedValue(undefined) + + // When + const result = await manager.fetch() + + // Then + expect(fetchTheme).toHaveBeenCalledWith(123, session) + expect(result).toBeUndefined() + expect(manager.getStoredThemeId()).toBeUndefined() + }) + }) + + describe('generateThemeName', () => { + test('generates a theme name with the provided context', () => { + // When + const result = manager.generateThemeName('my-app') + + // Then + expect(result).toBe('my-app (test-123-hostname)') + }) + }) + + describe('create', () => { + test('creates a new theme with default role and generated name', async () => { + // Given + vi.mocked(themeCreate).mockResolvedValue(mockTheme) + + // When + const result = await manager.create() + + // Then + expect(themeCreate).toHaveBeenCalledWith( + { + name: 'test-context (test-123-hostname)', + role: DEVELOPMENT_THEME_ROLE, + }, + session, + ) + expect(result).toEqual(mockTheme) + expect(manager.getStoredThemeId()).toBe('123') + }) + + test('creates a new theme with specified role and name', async () => { + // Given + const customTheme = {...mockTheme, name: 'Custom name', role: UNPUBLISHED_THEME_ROLE} + vi.mocked(themeCreate).mockResolvedValue(customTheme) + + // When + const result = await manager.create(UNPUBLISHED_THEME_ROLE, 'Custom name') + + // Then + expect(themeCreate).toHaveBeenCalledWith( + { + name: 'Custom name', + role: UNPUBLISHED_THEME_ROLE, + }, + session, + ) + expect(result).toEqual(customTheme) + expect(manager.getStoredThemeId()).toBe('123') + }) + + test('throws BugError when theme creation fails', async () => { + // Given + vi.mocked(themeCreate).mockResolvedValue(undefined) + + // When/Then + await expect(manager.create()).rejects.toThrow(BugError) + await expect(manager.create()).rejects.toThrow( + 'Could not create theme with name "test-context (test-123-hostname)" and role "development"', + ) + }) + }) +}) From 43c721d0a5252ce5d743f3158b398ba3777f23c0 Mon Sep 17 00:00:00 2001 From: Gray Gilmore Date: Mon, 2 Feb 2026 11:51:39 -0800 Subject: [PATCH 2/4] Add ability to fetch development themes by name --- .../find_development_theme_by_name.ts | 72 +++++++++++++++++++ .../find_development_theme_by_name.graphql | 10 +++ .../src/public/node/themes/api.test.ts | 64 +++++++++++++++++ .../cli-kit/src/public/node/themes/api.ts | 33 +++++++++ 4 files changed, 179 insertions(+) create mode 100644 packages/cli-kit/src/cli/api/graphql/admin/generated/find_development_theme_by_name.ts create mode 100644 packages/cli-kit/src/cli/api/graphql/admin/queries/find_development_theme_by_name.graphql diff --git a/packages/cli-kit/src/cli/api/graphql/admin/generated/find_development_theme_by_name.ts b/packages/cli-kit/src/cli/api/graphql/admin/generated/find_development_theme_by_name.ts new file mode 100644 index 00000000000..520149fbe76 --- /dev/null +++ b/packages/cli-kit/src/cli/api/graphql/admin/generated/find_development_theme_by_name.ts @@ -0,0 +1,72 @@ +/* eslint-disable @typescript-eslint/consistent-type-definitions */ +import * as Types from './types.js' + +import {TypedDocumentNode as DocumentNode} from '@graphql-typed-document-node/core' + +export type FindDevelopmentThemeByNameQueryVariables = Types.Exact<{ + name: Types.Scalars['String']['input'] +}> + +export type FindDevelopmentThemeByNameQuery = { + themes?: {nodes: {id: string; name: string; role: Types.ThemeRole; processing: boolean}[]} | null +} + +export const FindDevelopmentThemeByName = { + kind: 'Document', + definitions: [ + { + kind: 'OperationDefinition', + operation: 'query', + name: {kind: 'Name', value: 'findDevelopmentThemeByName'}, + variableDefinitions: [ + { + kind: 'VariableDefinition', + variable: {kind: 'Variable', name: {kind: 'Name', value: 'name'}}, + type: {kind: 'NonNullType', type: {kind: 'NamedType', name: {kind: 'Name', value: 'String'}}}, + }, + ], + selectionSet: { + kind: 'SelectionSet', + selections: [ + { + kind: 'Field', + name: {kind: 'Name', value: 'themes'}, + arguments: [ + {kind: 'Argument', name: {kind: 'Name', value: 'first'}, value: {kind: 'IntValue', value: '2'}}, + { + kind: 'Argument', + name: {kind: 'Name', value: 'names'}, + value: {kind: 'ListValue', values: [{kind: 'Variable', name: {kind: 'Name', value: 'name'}}]}, + }, + { + kind: 'Argument', + name: {kind: 'Name', value: 'roles'}, + value: {kind: 'ListValue', values: [{kind: 'EnumValue', value: 'DEVELOPMENT'}]}, + }, + ], + selectionSet: { + kind: 'SelectionSet', + selections: [ + { + kind: 'Field', + name: {kind: 'Name', value: 'nodes'}, + selectionSet: { + kind: 'SelectionSet', + selections: [ + {kind: 'Field', name: {kind: 'Name', value: 'id'}}, + {kind: 'Field', name: {kind: 'Name', value: 'name'}}, + {kind: 'Field', name: {kind: 'Name', value: 'role'}}, + {kind: 'Field', name: {kind: 'Name', value: 'processing'}}, + {kind: 'Field', name: {kind: 'Name', value: '__typename'}}, + ], + }, + }, + {kind: 'Field', name: {kind: 'Name', value: '__typename'}}, + ], + }, + }, + ], + }, + }, + ], +} as unknown as DocumentNode diff --git a/packages/cli-kit/src/cli/api/graphql/admin/queries/find_development_theme_by_name.graphql b/packages/cli-kit/src/cli/api/graphql/admin/queries/find_development_theme_by_name.graphql new file mode 100644 index 00000000000..59ec5b0368a --- /dev/null +++ b/packages/cli-kit/src/cli/api/graphql/admin/queries/find_development_theme_by_name.graphql @@ -0,0 +1,10 @@ +query findDevelopmentThemeByName($name: String!) { + themes(first: 2, names: [$name], roles: [DEVELOPMENT]) { + nodes { + id + name + role + processing + } + } +} diff --git a/packages/cli-kit/src/public/node/themes/api.test.ts b/packages/cli-kit/src/public/node/themes/api.test.ts index 819f1599338..d24bf7384d1 100644 --- a/packages/cli-kit/src/public/node/themes/api.test.ts +++ b/packages/cli-kit/src/public/node/themes/api.test.ts @@ -4,6 +4,7 @@ import { themeDuplicate, fetchTheme, fetchThemes, + findDevelopmentThemeByName, ThemeParams, themeUpdate, themePublish, @@ -24,6 +25,7 @@ import {ThemeFilesUpsert} from '../../../cli/api/graphql/admin/generated/theme_f import {ThemeFilesDelete} from '../../../cli/api/graphql/admin/generated/theme_files_delete.js' import {GetThemes} from '../../../cli/api/graphql/admin/generated/get_themes.js' import {GetTheme} from '../../../cli/api/graphql/admin/generated/get_theme.js' +import {FindDevelopmentThemeByName} from '../../../cli/api/graphql/admin/generated/find_development_theme_by_name.js' import {adminRequestDoc, supportedApiVersions} from '../api/admin.js' import {AbortError} from '../error.js' import {test, vi, expect, describe, beforeEach} from 'vitest' @@ -88,6 +90,68 @@ describe('fetchTheme', () => { }) }) +describe('findDevelopmentThemeByName', () => { + test('returns a development theme with a specific name', async () => { + vi.mocked(adminRequestDoc).mockResolvedValue({ + themes: { + nodes: [{id: 'gid://shopify/OnlineStoreTheme/1', name: 'PR-123', role: 'DEVELOPMENT', processing: false}], + }, + }) + + // When + const theme = await findDevelopmentThemeByName('PR-123', session) + + // Then + expect(adminRequestDoc).toHaveBeenCalledWith({ + query: FindDevelopmentThemeByName, + session, + variables: {name: 'PR-123'}, + responseOptions: {handleErrors: false}, + preferredBehaviour: expectedApiOptions, + }) + + expect(theme).not.toBeNull() + expect(theme!.id).toEqual(1) + expect(theme!.name).toEqual('PR-123') + expect(theme!.processing).toBeFalsy() + }) + + test('returns undefined when a theme is not found', async () => { + vi.mocked(adminRequestDoc).mockResolvedValue({ + themes: { + nodes: [], + }, + }) + + const theme = await findDevelopmentThemeByName('PR-123', session) + + expect(theme).toBeUndefined() + }) + + test('aborts if there is more than one development theme with the same name', async () => { + vi.mocked(adminRequestDoc).mockResolvedValue({ + themes: { + nodes: [ + {id: 'gid://shopify/OnlineStoreTheme/1', name: 'PR-123', role: 'DEVELOPMENT', processing: true}, + {id: 'gid://shopify/OnlineStoreTheme/2', name: 'PR-123', role: 'DEVELOPMENT', processing: true}, + ], + }, + }) + + await expect(findDevelopmentThemeByName('PR-123', session)).rejects.toThrow(AbortError) + }) + + test('returns undefined when the query errors', async () => { + const errorResponse = { + status: 200, + errors: [{message: 'Theme not found'} as any], + } + vi.mocked(adminRequestDoc).mockRejectedValue(new ClientError(errorResponse, {query: ''})) + + await expect(findDevelopmentThemeByName('PR-123', session)).rejects.toThrow() + }) +}) + describe('fetchThemes', () => { test('returns store themes', async () => { // Given diff --git a/packages/cli-kit/src/public/node/themes/api.ts b/packages/cli-kit/src/public/node/themes/api.ts index 4084c4fcebc..d9a6e3bae83 100644 --- a/packages/cli-kit/src/public/node/themes/api.ts +++ b/packages/cli-kit/src/public/node/themes/api.ts @@ -22,6 +22,7 @@ import { import {MetafieldDefinitionsByOwnerType} from '../../../cli/api/graphql/admin/generated/metafield_definitions_by_owner_type.js' import {GetThemes} from '../../../cli/api/graphql/admin/generated/get_themes.js' import {GetTheme} from '../../../cli/api/graphql/admin/generated/get_theme.js' +import {FindDevelopmentThemeByName} from '../../../cli/api/graphql/admin/generated/find_development_theme_by_name.js' import {OnlineStorePasswordProtection} from '../../../cli/api/graphql/admin/generated/online_store_password_protection.js' import {RequestModeInput} from '../http.js' import {adminRequestDoc} from '../api/admin.js' @@ -113,6 +114,38 @@ export async function fetchThemes(session: AdminSession): Promise { } } +export async function findDevelopmentThemeByName(name: string, session: AdminSession): Promise { + recordEvent('theme-api:find-development-theme-by-name') + + const {themes} = await adminRequestDoc({ + query: FindDevelopmentThemeByName, + session, + variables: {name}, + responseOptions: {handleErrors: false}, + preferredBehaviour: THEME_API_NETWORK_BEHAVIOUR, + }) + + if (!themes) { + unexpectedGraphQLError('Failed to fetch themes') + } + + if (themes.nodes.length > 1) { + throw recordError(new AbortError(`More than one development theme is named "${name}"`)) + } + + if (themes.nodes.length === 1) { + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const {id, processing, role, name} = themes.nodes[0]! + + return buildTheme({ + id: parseGid(id), + processing, + role: role.toLowerCase(), + name, + }) + } +} + export async function themeCreate(params: ThemeParams, session: AdminSession): Promise { const themeSource = params.src ?? SkeletonThemeCdn recordEvent('theme-api:create-theme') From 65442061b5a8428b2aecc9319b6bb0727df12683 Mon Sep 17 00:00:00 2001 From: Gray Gilmore Date: Wed, 4 Feb 2026 14:00:51 -0800 Subject: [PATCH 3/4] Change [yours] to [current] in theme list --- .changeset/nice-clowns-bathe.md | 9 +++++++++ packages/theme/src/cli/services/list.test.ts | 4 ++-- packages/theme/src/cli/services/list.ts | 2 +- packages/theme/src/cli/utilities/theme-selector.test.ts | 4 ++-- packages/theme/src/cli/utilities/theme-selector.ts | 4 ++-- 5 files changed, 16 insertions(+), 7 deletions(-) create mode 100644 .changeset/nice-clowns-bathe.md diff --git a/.changeset/nice-clowns-bathe.md b/.changeset/nice-clowns-bathe.md new file mode 100644 index 00000000000..6bda7cc4b2b --- /dev/null +++ b/.changeset/nice-clowns-bathe.md @@ -0,0 +1,9 @@ +--- +'@shopify/cli-kit': patch +'@shopify/theme': patch +'@shopify/cli': patch +--- + +Change wording for current development theme in `theme list` + +Previously you could only have one development theme at a time so we'd add `[yours]` beside the development theme that you were currently attached to. Now you can have multiple development themes so we're changing the language to `[current]` to show which theme you are actively connected to. diff --git a/packages/theme/src/cli/services/list.test.ts b/packages/theme/src/cli/services/list.test.ts index 87ba5b4fa33..1d686495143 100644 --- a/packages/theme/src/cli/services/list.test.ts +++ b/packages/theme/src/cli/services/list.test.ts @@ -44,8 +44,8 @@ describe('list', () => { ['Theme 1', '[live]', '#1'], ['Theme 2', '', '#2'], ['Theme 3', '[development]', '#3'], - ['Theme 5', '[development] [yours]', '#5'], - ['Theme 6', '[development] [yours]', '#6'], + ['Theme 5', '[development] [current]', '#5'], + ['Theme 6', '[development] [current]', '#6'], ], }, }, diff --git a/packages/theme/src/cli/services/list.ts b/packages/theme/src/cli/services/list.ts index bd9809ec7d3..3238de30863 100644 --- a/packages/theme/src/cli/services/list.ts +++ b/packages/theme/src/cli/services/list.ts @@ -50,7 +50,7 @@ export async function list(options: Options, adminSession: AdminSession) { if (role) { formattedRole = `[${role}]` if ([developmentTheme, hostTheme].includes(`${id}`)) { - formattedRole += ' [yours]' + formattedRole += ' [current]' } } return [name, formattedRole, `#${id}`] diff --git a/packages/theme/src/cli/utilities/theme-selector.test.ts b/packages/theme/src/cli/utilities/theme-selector.test.ts index 4acb517e603..7085cb946e9 100644 --- a/packages/theme/src/cli/utilities/theme-selector.test.ts +++ b/packages/theme/src/cli/utilities/theme-selector.test.ts @@ -46,7 +46,7 @@ describe('findOrSelectTheme', () => { expect(theme).toBe(selectedTheme) }) - test('flags development theme as [yours]', async () => { + test('flags development theme as [current]', async () => { // Given const header = 'Select a theme to open' const themes = [mockTheme(7, 'development'), mockTheme(8, 'development')] @@ -64,7 +64,7 @@ describe('findOrSelectTheme', () => { expect(renderAutocompletePrompt).toHaveBeenCalledWith({ message: header, choices: [ - expect.objectContaining({group: 'Development', label: 'theme 7 [yours]'}), + expect.objectContaining({group: 'Development', label: 'theme 7 [current]'}), expect.objectContaining({group: 'Development', label: 'theme 8'}), ], }) diff --git a/packages/theme/src/cli/utilities/theme-selector.ts b/packages/theme/src/cli/utilities/theme-selector.ts index feefa366110..9ec84ea3b76 100644 --- a/packages/theme/src/cli/utilities/theme-selector.ts +++ b/packages/theme/src/cli/utilities/theme-selector.ts @@ -46,11 +46,11 @@ export async function findOrSelectTheme(session: AdminSession, options: FindOrSe const message = options.header ?? '' const choices = themes.map((theme) => { - const yoursLabel = theme.id.toString() === getDevelopmentTheme() ? ' [yours]' : '' + const currentLabel = theme.id.toString() === getDevelopmentTheme() ? ' [current]' : '' return { value: async () => theme, - label: `${theme.name}${yoursLabel}`, + label: `${theme.name}${currentLabel}`, group: capitalize(theme.role), } }) From d9a6149f72da7a0140d51d09b7709e85909b427f Mon Sep 17 00:00:00 2001 From: Gray Gilmore Date: Thu, 5 Feb 2026 14:44:18 -0800 Subject: [PATCH 4/4] Add the ability to create new development themes Adds a new flag called --development-context on theme push that will allow developers to manually generate new development themes. Particularly helpful in CI environments. --- .changeset/puny-plants-allow.md | 9 +++ .../interfaces/theme-push.interface.ts | 6 ++ .../generated/generated_docs_data.json | 11 +++- .../public/node/themes/theme-manager.test.ts | 31 ++++++++++- .../src/public/node/themes/theme-manager.ts | 20 ++++--- packages/cli/README.md | 55 ++++++++++--------- packages/cli/oclif.manifest.json | 15 +++++ packages/theme/src/cli/commands/theme/push.ts | 9 +++ packages/theme/src/cli/services/push.test.ts | 14 +++++ packages/theme/src/cli/services/push.ts | 17 +++++- 10 files changed, 149 insertions(+), 38 deletions(-) create mode 100644 .changeset/puny-plants-allow.md diff --git a/.changeset/puny-plants-allow.md b/.changeset/puny-plants-allow.md new file mode 100644 index 00000000000..bbefb6a4c5f --- /dev/null +++ b/.changeset/puny-plants-allow.md @@ -0,0 +1,9 @@ +--- +'@shopify/cli-kit': minor +'@shopify/theme': minor +'@shopify/cli': minor +--- + +Add `--development-context` flag to `theme push` + +The new `--development-context` flag (short: `-c`) allows you to specify a unique identifier for a development theme context (e.g., PR number, branch name). This gives developers the ability to programmatically create or reuse named development themes; particularly useful when running `shopify theme push` in a CI environment where you might want to associate a particular development theme to a branch or pull request. diff --git a/docs-shopify.dev/commands/interfaces/theme-push.interface.ts b/docs-shopify.dev/commands/interfaces/theme-push.interface.ts index c6adff4c968..9f37a46e913 100644 --- a/docs-shopify.dev/commands/interfaces/theme-push.interface.ts +++ b/docs-shopify.dev/commands/interfaces/theme-push.interface.ts @@ -12,6 +12,12 @@ export interface themepush { */ '-d, --development'?: '' + /** + * Unique identifier for a development theme context (e.g., PR number, branch name). Reuses an existing development theme with this context name, or creates one if none exists. + * @environment SHOPIFY_FLAG_DEVELOPMENT_CONTEXT + */ + '-c, --development-context '?: string + /** * The environment to apply to the current command. * @environment SHOPIFY_FLAG_ENVIRONMENT diff --git a/docs-shopify.dev/generated/generated_docs_data.json b/docs-shopify.dev/generated/generated_docs_data.json index 5d51044f3a6..c709bffba40 100644 --- a/docs-shopify.dev/generated/generated_docs_data.json +++ b/docs-shopify.dev/generated/generated_docs_data.json @@ -7476,6 +7476,15 @@ "isOptional": true, "environmentValue": "SHOPIFY_FLAG_ALLOW_LIVE" }, + { + "filePath": "docs-shopify.dev/commands/interfaces/theme-push.interface.ts", + "syntaxKind": "PropertySignature", + "name": "-c, --development-context ", + "value": "string", + "description": "Unique identifier for a development theme context (e.g., PR number, branch name). Reuses an existing development theme with this context name, or creates one if none exists.", + "isOptional": true, + "environmentValue": "SHOPIFY_FLAG_DEVELOPMENT_CONTEXT" + }, { "filePath": "docs-shopify.dev/commands/interfaces/theme-push.interface.ts", "syntaxKind": "PropertySignature", @@ -7576,7 +7585,7 @@ "environmentValue": "SHOPIFY_FLAG_IGNORE" } ], - "value": "export interface themepush {\n /**\n * Allow push to a live theme.\n * @environment SHOPIFY_FLAG_ALLOW_LIVE\n */\n '-a, --allow-live'?: ''\n\n /**\n * Push theme files from your remote development theme.\n * @environment SHOPIFY_FLAG_DEVELOPMENT\n */\n '-d, --development'?: ''\n\n /**\n * The environment to apply to the current command.\n * @environment SHOPIFY_FLAG_ENVIRONMENT\n */\n '-e, --environment '?: string\n\n /**\n * Skip uploading the specified files (Multiple flags allowed). Wrap the value in double quotes if you're using wildcards.\n * @environment SHOPIFY_FLAG_IGNORE\n */\n '-x, --ignore '?: string\n\n /**\n * Output the result as JSON.\n * @environment SHOPIFY_FLAG_JSON\n */\n '-j, --json'?: ''\n\n /**\n * The listing preset to use for multi-preset themes. Applies preset files from listings/[preset-name] directory.\n * @environment SHOPIFY_FLAG_LISTING\n */\n '--listing '?: string\n\n /**\n * Push theme files from your remote live theme.\n * @environment SHOPIFY_FLAG_LIVE\n */\n '-l, --live'?: ''\n\n /**\n * Disable color output.\n * @environment SHOPIFY_FLAG_NO_COLOR\n */\n '--no-color'?: ''\n\n /**\n * Prevent deleting remote files that don't exist locally.\n * @environment SHOPIFY_FLAG_NODELETE\n */\n '-n, --nodelete'?: ''\n\n /**\n * Upload only the specified files (Multiple flags allowed). Wrap the value in double quotes if you're using wildcards.\n * @environment SHOPIFY_FLAG_ONLY\n */\n '-o, --only '?: string\n\n /**\n * Password generated from the Theme Access app or an Admin API token.\n * @environment SHOPIFY_CLI_THEME_TOKEN\n */\n '--password '?: string\n\n /**\n * The path where you want to run the command. Defaults to the current working directory.\n * @environment SHOPIFY_FLAG_PATH\n */\n '--path '?: string\n\n /**\n * Publish as the live theme after uploading.\n * @environment SHOPIFY_FLAG_PUBLISH\n */\n '-p, --publish'?: ''\n\n /**\n * Store URL. It can be the store prefix (example) or the full myshopify.com URL (example.myshopify.com, https://example.myshopify.com).\n * @environment SHOPIFY_FLAG_STORE\n */\n '-s, --store '?: string\n\n /**\n * Require theme check to pass without errors before pushing. Warnings are allowed.\n * @environment SHOPIFY_FLAG_STRICT_PUSH\n */\n '--strict'?: ''\n\n /**\n * Theme ID or name of the remote theme.\n * @environment SHOPIFY_FLAG_THEME_ID\n */\n '-t, --theme '?: string\n\n /**\n * Create a new unpublished theme and push to it.\n * @environment SHOPIFY_FLAG_UNPUBLISHED\n */\n '-u, --unpublished'?: ''\n\n /**\n * Increase the verbosity of the output.\n * @environment SHOPIFY_FLAG_VERBOSE\n */\n '--verbose'?: ''\n}" + "value": "export interface themepush {\n /**\n * Allow push to a live theme.\n * @environment SHOPIFY_FLAG_ALLOW_LIVE\n */\n '-a, --allow-live'?: ''\n\n /**\n * Push theme files from your remote development theme.\n * @environment SHOPIFY_FLAG_DEVELOPMENT\n */\n '-d, --development'?: ''\n\n /**\n * Unique identifier for a development theme context (e.g., PR number, branch name). Reuses an existing development theme with this context name, or creates one if none exists.\n * @environment SHOPIFY_FLAG_DEVELOPMENT_CONTEXT\n */\n '-c, --development-context '?: string\n\n /**\n * The environment to apply to the current command.\n * @environment SHOPIFY_FLAG_ENVIRONMENT\n */\n '-e, --environment '?: string\n\n /**\n * Skip uploading the specified files (Multiple flags allowed). Wrap the value in double quotes if you're using wildcards.\n * @environment SHOPIFY_FLAG_IGNORE\n */\n '-x, --ignore '?: string\n\n /**\n * Output the result as JSON.\n * @environment SHOPIFY_FLAG_JSON\n */\n '-j, --json'?: ''\n\n /**\n * The listing preset to use for multi-preset themes. Applies preset files from listings/[preset-name] directory.\n * @environment SHOPIFY_FLAG_LISTING\n */\n '--listing '?: string\n\n /**\n * Push theme files from your remote live theme.\n * @environment SHOPIFY_FLAG_LIVE\n */\n '-l, --live'?: ''\n\n /**\n * Disable color output.\n * @environment SHOPIFY_FLAG_NO_COLOR\n */\n '--no-color'?: ''\n\n /**\n * Prevent deleting remote files that don't exist locally.\n * @environment SHOPIFY_FLAG_NODELETE\n */\n '-n, --nodelete'?: ''\n\n /**\n * Upload only the specified files (Multiple flags allowed). Wrap the value in double quotes if you're using wildcards.\n * @environment SHOPIFY_FLAG_ONLY\n */\n '-o, --only '?: string\n\n /**\n * Password generated from the Theme Access app or an Admin API token.\n * @environment SHOPIFY_CLI_THEME_TOKEN\n */\n '--password '?: string\n\n /**\n * The path where you want to run the command. Defaults to the current working directory.\n * @environment SHOPIFY_FLAG_PATH\n */\n '--path '?: string\n\n /**\n * Publish as the live theme after uploading.\n * @environment SHOPIFY_FLAG_PUBLISH\n */\n '-p, --publish'?: ''\n\n /**\n * Store URL. It can be the store prefix (example) or the full myshopify.com URL (example.myshopify.com, https://example.myshopify.com).\n * @environment SHOPIFY_FLAG_STORE\n */\n '-s, --store '?: string\n\n /**\n * Require theme check to pass without errors before pushing. Warnings are allowed.\n * @environment SHOPIFY_FLAG_STRICT_PUSH\n */\n '--strict'?: ''\n\n /**\n * Theme ID or name of the remote theme.\n * @environment SHOPIFY_FLAG_THEME_ID\n */\n '-t, --theme '?: string\n\n /**\n * Create a new unpublished theme and push to it.\n * @environment SHOPIFY_FLAG_UNPUBLISHED\n */\n '-u, --unpublished'?: ''\n\n /**\n * Increase the verbosity of the output.\n * @environment SHOPIFY_FLAG_VERBOSE\n */\n '--verbose'?: ''\n}" } } } diff --git a/packages/cli-kit/src/public/node/themes/theme-manager.test.ts b/packages/cli-kit/src/public/node/themes/theme-manager.test.ts index 46d7eedc5a7..c11eb77b0a7 100644 --- a/packages/cli-kit/src/public/node/themes/theme-manager.test.ts +++ b/packages/cli-kit/src/public/node/themes/theme-manager.test.ts @@ -1,6 +1,6 @@ import {ThemeManager} from './theme-manager.js' import {Theme} from './types.js' -import {fetchTheme, themeCreate} from './api.js' +import {fetchTheme, findDevelopmentThemeByName, themeCreate} from './api.js' import {DEVELOPMENT_THEME_ROLE, UNPUBLISHED_THEME_ROLE} from './utils.js' import {BugError} from '../error.js' import {test, describe, expect, vi, beforeEach} from 'vitest' @@ -92,10 +92,24 @@ describe('ThemeManager', () => { expect(result).toEqual(mockTheme) expect(manager.getStoredThemeId()).toBe('123') }) + + test('searches through development themes of a given name', async () => { + // Given + vi.mocked(findDevelopmentThemeByName).mockResolvedValue(mockTheme) + + // When + const result = await manager.findOrCreate('Dev', DEVELOPMENT_THEME_ROLE) + + // Then + expect(fetchTheme).not.toHaveBeenCalled() + expect(findDevelopmentThemeByName).toHaveBeenCalledWith('Dev', session) + expect(result).toEqual(mockTheme) + expect(themeCreate).not.toHaveBeenCalled() + }) }) describe('fetch', () => { - test('returns undefined when no themeId is set', async () => { + test('returns undefined when no themeId or name is set', async () => { // Given manager.setThemeId(undefined) @@ -120,6 +134,19 @@ describe('ThemeManager', () => { expect(result).toEqual(mockTheme) }) + test('fetches and returns a theme when name is set and role is development', async () => { + // Given + vi.mocked(findDevelopmentThemeByName).mockResolvedValue(mockTheme) + + // When + const result = await manager.fetch('Dev', DEVELOPMENT_THEME_ROLE) + + // Then + expect(fetchTheme).not.toHaveBeenCalled() + expect(findDevelopmentThemeByName).toHaveBeenCalledWith('Dev', session) + expect(result).toEqual(mockTheme) + }) + test('removes theme when fetch returns undefined', async () => { // Given manager.setThemeId('123') diff --git a/packages/cli-kit/src/public/node/themes/theme-manager.ts b/packages/cli-kit/src/public/node/themes/theme-manager.ts index 2a40aa46912..2ae9d197f53 100644 --- a/packages/cli-kit/src/public/node/themes/theme-manager.ts +++ b/packages/cli-kit/src/public/node/themes/theme-manager.ts @@ -1,4 +1,4 @@ -import {fetchTheme, themeCreate} from './api.js' +import {fetchTheme, findDevelopmentThemeByName, themeCreate} from './api.js' import {Theme} from './types.js' import {DEVELOPMENT_THEME_ROLE, Role} from './utils.js' import {generateThemeName} from '../../../private/node/themes/generate-theme-name.js' @@ -13,19 +13,25 @@ export abstract class ThemeManager { constructor(protected adminSession: AdminSession) {} - async findOrCreate(): Promise { - let theme = await this.fetch() + async findOrCreate(name?: string, role?: Role): Promise { + let theme = await this.fetch(name, role) if (!theme) { - theme = await this.create() + theme = await this.create(role, name) } return theme } - async fetch() { - if (!this.themeId) { + async fetch(name?: string, role?: Role) { + if (!this.themeId && !name) { return } - const theme = await fetchTheme(parseInt(this.themeId, 10), this.adminSession) + + const theme = + name && role === DEVELOPMENT_THEME_ROLE + ? await findDevelopmentThemeByName(name, this.adminSession) + : // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + await fetchTheme(parseInt(this.themeId!, 10), this.adminSession) + if (!theme) { this.removeTheme() } diff --git a/packages/cli/README.md b/packages/cli/README.md index 82416a8fed7..272ca8790d3 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -2612,31 +2612,36 @@ USAGE $ shopify theme push --unpublished --json FLAGS - -a, --allow-live [env: SHOPIFY_FLAG_ALLOW_LIVE] Allow push to a live theme. - -d, --development [env: SHOPIFY_FLAG_DEVELOPMENT] Push theme files from your remote development theme. - -e, --environment=... [env: SHOPIFY_FLAG_ENVIRONMENT] The environment to apply to the current command. - -j, --json [env: SHOPIFY_FLAG_JSON] Output the result as JSON. - -l, --live [env: SHOPIFY_FLAG_LIVE] Push theme files from your remote live theme. - -n, --nodelete [env: SHOPIFY_FLAG_NODELETE] Prevent deleting remote files that don't exist locally. - -o, --only=... [env: SHOPIFY_FLAG_ONLY] Upload only the specified files (Multiple flags allowed). Wrap - the value in double quotes if you're using wildcards. - -p, --publish [env: SHOPIFY_FLAG_PUBLISH] Publish as the live theme after uploading. - -s, --store= [env: SHOPIFY_FLAG_STORE] Store URL. It can be the store prefix (example) or the full - myshopify.com URL (example.myshopify.com, https://example.myshopify.com). - -t, --theme= [env: SHOPIFY_FLAG_THEME_ID] Theme ID or name of the remote theme. - -u, --unpublished [env: SHOPIFY_FLAG_UNPUBLISHED] Create a new unpublished theme and push to it. - -x, --ignore=... [env: SHOPIFY_FLAG_IGNORE] Skip uploading the specified files (Multiple flags allowed). - Wrap the value in double quotes if you're using wildcards. - --listing= [env: SHOPIFY_FLAG_LISTING] The listing preset to use for multi-preset themes. Applies - preset files from listings/[preset-name] directory. - --no-color [env: SHOPIFY_FLAG_NO_COLOR] Disable color output. - --password= [env: SHOPIFY_CLI_THEME_TOKEN] Password generated from the Theme Access app or an Admin - API token. - --path= [env: SHOPIFY_FLAG_PATH] The path where you want to run the command. Defaults to the - current working directory. - --strict [env: SHOPIFY_FLAG_STRICT_PUSH] Require theme check to pass without errors before - pushing. Warnings are allowed. - --verbose [env: SHOPIFY_FLAG_VERBOSE] Increase the verbosity of the output. + -a, --allow-live [env: SHOPIFY_FLAG_ALLOW_LIVE] Allow push to a live theme. + -c, --development-context= [env: SHOPIFY_FLAG_DEVELOPMENT_CONTEXT] Unique identifier for a development theme + context (e.g., PR number, branch name). Reuses an existing development theme with + this context name, or creates one if none exists. + -d, --development [env: SHOPIFY_FLAG_DEVELOPMENT] Push theme files from your remote development + theme. + -e, --environment=... [env: SHOPIFY_FLAG_ENVIRONMENT] The environment to apply to the current command. + -j, --json [env: SHOPIFY_FLAG_JSON] Output the result as JSON. + -l, --live [env: SHOPIFY_FLAG_LIVE] Push theme files from your remote live theme. + -n, --nodelete [env: SHOPIFY_FLAG_NODELETE] Prevent deleting remote files that don't exist + locally. + -o, --only=... [env: SHOPIFY_FLAG_ONLY] Upload only the specified files (Multiple flags allowed). + Wrap the value in double quotes if you're using wildcards. + -p, --publish [env: SHOPIFY_FLAG_PUBLISH] Publish as the live theme after uploading. + -s, --store= [env: SHOPIFY_FLAG_STORE] Store URL. It can be the store prefix (example) or the + full myshopify.com URL (example.myshopify.com, https://example.myshopify.com). + -t, --theme= [env: SHOPIFY_FLAG_THEME_ID] Theme ID or name of the remote theme. + -u, --unpublished [env: SHOPIFY_FLAG_UNPUBLISHED] Create a new unpublished theme and push to it. + -x, --ignore=... [env: SHOPIFY_FLAG_IGNORE] Skip uploading the specified files (Multiple flags + allowed). Wrap the value in double quotes if you're using wildcards. + --listing= [env: SHOPIFY_FLAG_LISTING] The listing preset to use for multi-preset themes. + Applies preset files from listings/[preset-name] directory. + --no-color [env: SHOPIFY_FLAG_NO_COLOR] Disable color output. + --password= [env: SHOPIFY_CLI_THEME_TOKEN] Password generated from the Theme Access app or an + Admin API token. + --path= [env: SHOPIFY_FLAG_PATH] The path where you want to run the command. Defaults to + the current working directory. + --strict [env: SHOPIFY_FLAG_STRICT_PUSH] Require theme check to pass without errors before + pushing. Warnings are allowed. + --verbose [env: SHOPIFY_FLAG_VERBOSE] Increase the verbosity of the output. DESCRIPTION Uploads your local theme files to the connected store, overwriting the remote version if specified. diff --git a/packages/cli/oclif.manifest.json b/packages/cli/oclif.manifest.json index bfc1c73c123..946f23fbc42 100644 --- a/packages/cli/oclif.manifest.json +++ b/packages/cli/oclif.manifest.json @@ -7081,6 +7081,21 @@ "name": "development", "type": "boolean" }, + "development-context": { + "char": "c", + "dependsOn": [ + "development" + ], + "description": "Unique identifier for a development theme context (e.g., PR number, branch name). Reuses an existing development theme with this context name, or creates one if none exists.", + "env": "SHOPIFY_FLAG_DEVELOPMENT_CONTEXT", + "exclusive": [ + "theme" + ], + "hasDynamicHelp": false, + "multiple": false, + "name": "development-context", + "type": "option" + }, "environment": { "char": "e", "description": "The environment to apply to the current command.", diff --git a/packages/theme/src/cli/commands/theme/push.ts b/packages/theme/src/cli/commands/theme/push.ts index ea275cd7a95..3bc3d9b1e15 100644 --- a/packages/theme/src/cli/commands/theme/push.ts +++ b/packages/theme/src/cli/commands/theme/push.ts @@ -62,6 +62,14 @@ export default class Push extends ThemeCommand { description: 'Push theme files from your remote development theme.', env: 'SHOPIFY_FLAG_DEVELOPMENT', }), + 'development-context': Flags.string({ + char: 'c', + description: + 'Unique identifier for a development theme context (e.g., PR number, branch name). Reuses an existing development theme with this context name, or creates one if none exists.', + env: 'SHOPIFY_FLAG_DEVELOPMENT_CONTEXT', + dependsOn: ['development'], + exclusive: ['theme'], + }), live: Flags.boolean({ char: 'l', description: 'Push theme files from your remote live theme.', @@ -120,6 +128,7 @@ export default class Push extends ThemeCommand { ...flags, allowLive: flags['allow-live'], noColor: flags['no-color'], + developmentContext: flags['development-context'], }, adminSession, multiEnvironment, diff --git a/packages/theme/src/cli/services/push.test.ts b/packages/theme/src/cli/services/push.test.ts index 2d3d4b27068..28d660dd6af 100644 --- a/packages/theme/src/cli/services/push.test.ts +++ b/packages/theme/src/cli/services/push.test.ts @@ -292,6 +292,20 @@ describe('createOrSelectTheme', async () => { expect(setDevelopmentTheme).toHaveBeenCalled() }) + test('creates development theme when development and development-context flags are provided', async () => { + // Given + vi.mocked(themeCreate).mockResolvedValue(buildTheme({id: 1, name: 'Custom name', role: DEVELOPMENT_THEME_ROLE})) + vi.mocked(fetchTheme).mockResolvedValue(undefined) + const flags: PushFlags = {development: true, developmentContext: 'Custom name'} + + // When + const theme = await createOrSelectTheme(adminSession, flags) + + // Then + expect(theme).toMatchObject({role: DEVELOPMENT_THEME_ROLE, name: 'Custom name'}) + expect(setDevelopmentTheme).toHaveBeenCalled() + }) + test('creates development theme when development and unpublished flags are provided', async () => { // Given vi.mocked(themeCreate).mockResolvedValue(buildTheme({id: 1, name: 'Theme', role: DEVELOPMENT_THEME_ROLE})) diff --git a/packages/theme/src/cli/services/push.ts b/packages/theme/src/cli/services/push.ts index fde42ac60cd..a3e2e4b2a00 100644 --- a/packages/theme/src/cli/services/push.ts +++ b/packages/theme/src/cli/services/push.ts @@ -22,7 +22,12 @@ import { } from '@shopify/cli-kit/node/ui' import {themeEditorUrl, themePreviewUrl} from '@shopify/cli-kit/node/themes/urls' import {cwd, resolvePath} from '@shopify/cli-kit/node/path' -import {LIVE_THEME_ROLE, promptThemeName, UNPUBLISHED_THEME_ROLE} from '@shopify/cli-kit/node/themes/utils' +import { + DEVELOPMENT_THEME_ROLE, + LIVE_THEME_ROLE, + promptThemeName, + UNPUBLISHED_THEME_ROLE, +} from '@shopify/cli-kit/node/themes/utils' import {AbortError} from '@shopify/cli-kit/node/error' import {Severity} from '@shopify/theme-check-node' import {recordError, recordTiming} from '@shopify/cli-kit/node/analytics' @@ -72,6 +77,12 @@ export interface PushFlags { /** Push theme files from your remote development theme. */ development?: boolean + /** + * Unique identifier for a development theme context (e.g., PR number, branch name). + * Reuses an existing development theme with this context name, or creates one if none exists. + */ + developmentContext?: string + /** Push theme files from your remote live theme. */ live?: boolean @@ -379,11 +390,11 @@ export async function createOrSelectTheme( flags: PushFlags, multiEnvironment?: boolean, ): Promise { - const {live, development, unpublished, theme, environment} = flags + const {live, development, unpublished, theme, environment, developmentContext} = flags if (development) { const themeManager = new DevelopmentThemeManager(session) - return themeManager.findOrCreate() + return themeManager.findOrCreate(developmentContext, DEVELOPMENT_THEME_ROLE) } else if (unpublished) { const themeName = theme ?? (await promptThemeName('Name of the new theme')) return themeCreate(