From ba89dda485034f2ef5014fb2c07bcc395f333401 Mon Sep 17 00:00:00 2001 From: Gonzalo Riestra Date: Thu, 24 Sep 2026 11:17:55 +0200 Subject: [PATCH 1/5] Add typed JSON output to theme duplicate --- packages/cli/README.md | 110 ++++++++++++ packages/cli/oclif.manifest.json | 2 +- .../rules/json-output-command-exceptions.js | 1 - .../src/cli/commands/theme/duplicate.test.ts | 112 ++++++++++++ .../theme/src/cli/commands/theme/duplicate.ts | 11 +- .../theme/src/cli/services/duplicate.test.ts | 38 +++- packages/theme/src/cli/services/duplicate.ts | 168 +++--------------- .../src/cli/services/duplicate/result.ts | 107 +++++++++++ .../theme/src/cli/services/duplicate/types.ts | 44 +++++ .../src/cli/services/theme-mutation/status.ts | 3 + 10 files changed, 442 insertions(+), 154 deletions(-) create mode 100644 packages/theme/src/cli/commands/theme/duplicate.test.ts create mode 100644 packages/theme/src/cli/services/duplicate/result.ts create mode 100644 packages/theme/src/cli/services/duplicate/types.ts create mode 100644 packages/theme/src/cli/services/theme-mutation/status.ts diff --git a/packages/cli/README.md b/packages/cli/README.md index a976adae2b0..7336dcdb2c8 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -10770,6 +10770,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` diff --git a/packages/cli/oclif.manifest.json b/packages/cli/oclif.manifest.json index 01474ba2a4e..463906561f9 100644 --- a/packages/cli/oclif.manifest.json +++ b/packages/cli/oclif.manifest.json @@ -11036,7 +11036,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": { diff --git a/packages/eslint-plugin-cli/rules/json-output-command-exceptions.js b/packages/eslint-plugin-cli/rules/json-output-command-exceptions.js index 37e3b9092d6..13ae2d305e4 100644 --- a/packages/eslint-plugin-cli/rules/json-output-command-exceptions.js +++ b/packages/eslint-plugin-cli/rules/json-output-command-exceptions.js @@ -27,7 +27,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', diff --git a/packages/theme/src/cli/commands/theme/duplicate.test.ts b/packages/theme/src/cli/commands/theme/duplicate.test.ts new file mode 100644 index 00000000000..099576bb109 --- /dev/null +++ b/packages/theme/src/cli/commands/theme/duplicate.test.ts @@ -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() + }) +}) diff --git a/packages/theme/src/cli/commands/theme/duplicate.ts b/packages/theme/src/cli/commands/theme/duplicate.ts index eba7faae604..d95255186d7 100644 --- a/packages/theme/src/cli/commands/theme/duplicate.ts +++ b/packages/theme/src/cli/commands/theme/duplicate.ts @@ -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' @@ -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'"] @@ -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') } } diff --git a/packages/theme/src/cli/services/duplicate.test.ts b/packages/theme/src/cli/services/duplicate.test.ts index 99253fcbbba..2610de2696d 100644 --- a/packages/theme/src/cli/services/duplicate.test.ts +++ b/packages/theme/src/cli/services/duplicate.test.ts @@ -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' @@ -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', @@ -44,7 +42,6 @@ const options = { describe('duplicate', () => { beforeEach(() => { vi.mocked(themeComponent).mockReturnValue(['theme component']) - vi.mocked(configureCLIEnvironment).mockReturnValue() vi.mocked(outputResult).mockReturnValue() }) @@ -266,6 +263,7 @@ 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', @@ -273,3 +271,33 @@ describe('duplicate', () => { ) }) }) + +async function duplicate( + session: AdminSession, + themeId: string | undefined, + flags: Parameters[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() +}) diff --git a/packages/theme/src/cli/services/duplicate.ts b/packages/theme/src/cli/services/duplicate.ts index 496a27c7356..2ab52beb6e7 100644 --- a/packages/theme/src/cli/services/duplicate.ts +++ b/packages/theme/src/cli/services/duplicate.ts @@ -1,63 +1,26 @@ +import {themeDuplicateResultSchema, ThemeDuplicateResult} from './duplicate/types.js' import {findOrSelectTheme, findThemeById} from '../utilities/theme-selector.js' -import {configureCLIEnvironment} from '../utilities/cli-config.js' -import {themeComponent} from '../utilities/theme-ui.js' -import {renderConfirmationPrompt, renderError, renderSuccess} from '@shopify/cli-kit/node/ui' -import {AdminSession} from '@shopify/cli-kit/node/session' -import {outputResult} from '@shopify/cli-kit/node/output' -import {Theme} from '@shopify/cli-kit/node/themes/types' import {themePreviewUrl} from '@shopify/cli-kit/node/themes/urls' -import {themeDuplicate, ThemeDuplicateResult} from '@shopify/cli-kit/node/themes/api' +import {renderConfirmationPrompt} from '@shopify/cli-kit/node/ui' +import {AdminSession} from '@shopify/cli-kit/node/session' +import {themeDuplicate} from '@shopify/cli-kit/node/themes/api' import {isCI} from '@shopify/cli-kit/node/system' -interface DuplicateFlags { - /** Password generated from the Theme Access app. */ - password?: string - - /** Store URL. It can be the store prefix (example) or the full myshopify.com URL (example.myshopify.com, https://example.myshopify.com). */ - store?: string - - /** Theme ID */ - theme?: string - - /** Output JSON instead of UI. */ - json?: boolean - - /** Disable color output. */ - noColor?: boolean - - /** Increase the verbosity of the output. */ - verbose?: boolean - - /** name the duplicated theme. */ +interface DuplicateOptions { name?: string - - /** Environment */ - environment?: string[] | undefined - - /** Force the duplicate operation to run without prompts or confirmations. */ force?: boolean } -/** - * Initiates the duplicate process based on provided flags. - * - * @param adminSession - The admin session for the theme. - * @param theme - The theme to duplicate. - * @param flags - The flags for the duplicate operation. - */ -export async function duplicate(adminSession: AdminSession, themeId: string | undefined, flags: DuplicateFlags) { - const {name, verbose, noColor, json, force} = flags +export async function duplicate( + adminSession: AdminSession, + themeId: string | undefined, + flags: DuplicateOptions, +): Promise { + const {name, force} = flags const noPrompts = isCI() || force - configureCLIEnvironment({ - verbose, - noColor, - }) - if (noPrompts && !themeId) { - const message = 'A theme ID is required to duplicate a theme, specify one with the --theme flag' - json ? outputResult(JSON.stringify({message, errors: []})) : renderError({body: [message]}) - return + return {status: 'missing-theme-id'} } const themeToDuplicate = themeId @@ -70,15 +33,11 @@ export async function duplicate(adminSession: AdminSession, themeId: string | un }) if (!themeToDuplicate) { - const message = `No theme with ID ${themeId} could be found. Use shopify theme list to find a theme ID.` - json ? outputResult(JSON.stringify({message, errors: []})) : renderError({body: [message]}) - return + return {status: 'not-found', themeId: themeId!} } if (themeToDuplicate?.role === 'development') { - const message = "Development themes can't be duplicated. Use shopify theme push to upload it to the store first." - json ? outputResult(JSON.stringify({message, errors: []})) : renderError({body: [message]}) - return + return {status: 'development-theme'} } if (!noPrompts) { @@ -87,99 +46,16 @@ export async function duplicate(adminSession: AdminSession, themeId: string | un confirmationMessage: `Yes, duplicate '${themeToDuplicate.name}'`, cancellationMessage: 'No, cancel duplicate', }) - if (!accept) return + if (!accept) return {status: 'cancelled'} } const result = await themeDuplicate(themeToDuplicate.id, name, adminSession) - json ? handleJsonOutput(themeToDuplicate, adminSession, result) : handleOutput(themeToDuplicate, adminSession, result) -} - -/** - * Handles the output for the duplicate operation. - * - * @param theme - The theme being duplicated. - * @param session - The admin session for the theme. - * @param result - The results of duplication. - */ -function handleOutput(theme: Theme, session: AdminSession, result: ThemeDuplicateResult) { - if (result.userErrors && result.userErrors.length > 0) { - const errors = result.userErrors - .map((error: {field?: string[] | null; message: string}) => error.message) - .join(', ') - renderError({ - body: [ - 'The theme', - ...themeComponent(theme), - 'could not be duplicated due to errors: ', - {subdued: errors}, - {char: '.'}, - ...(result.requestId ? ['\nRequest ID: ', {subdued: result.requestId}] : []), - ], - }) - } else if (result.theme) { - renderSuccess({ - body: ['The theme', ...themeComponent(theme), 'has been duplicated', {char: '.'}], - nextSteps: [ - [ - { - link: { - label: 'View the duplicated theme', - url: themePreviewUrl(result.theme, session), - }, - }, - ], - ], - }) - } else { - renderError({ - body: [ - 'The theme', - ...themeComponent(theme), - 'unexpectedly could not be duplicated', - {char: '.'}, - ...(result.requestId ? ['\nRequest ID: ', {subdued: result.requestId}] : []), - ], - }) - } -} - -/** - * Handles the JSON output for the duplicate operation. - * - * @param theme - The theme being duplicated. - * @param session - The admin session for the theme. - * @param result - The results of duplication. - */ -function handleJsonOutput(theme: Theme, session: AdminSession, result: ThemeDuplicateResult) { - if (result.userErrors && result.userErrors.length > 0) { - outputResult( - JSON.stringify({ - message: `The theme '${theme.name}' could not be duplicated due to errors`, - errors: result.userErrors.map((error: {field?: string[] | null; message: string}) => error.message), - requestId: result.requestId, - }), - ) - } else if (result.theme) { - const {id, name, role} = result.theme - - const output = { - theme: { - id, - name, - role, - shop: session.storeFqdn, - }, - } - - outputResult(JSON.stringify(output)) - } else { - outputResult( - JSON.stringify({ - message: `The theme '${theme.name}' unexpectedly could not be duplicated `, - errors: [], - requestId: result.requestId, - }), - ) - } + return themeDuplicateResultSchema.parse({ + status: 'completed', + originalTheme: themeToDuplicate, + shop: adminSession.storeFqdn, + previewUrl: result.theme ? themePreviewUrl(result.theme, adminSession) : undefined, + ...result, + }) } diff --git a/packages/theme/src/cli/services/duplicate/result.ts b/packages/theme/src/cli/services/duplicate/result.ts new file mode 100644 index 00000000000..a59bccdaca3 --- /dev/null +++ b/packages/theme/src/cli/services/duplicate/result.ts @@ -0,0 +1,107 @@ +import {themeDuplicateJsonOutputSchema, type ThemeDuplicateResult, type ThemeDuplicateJsonResult} from './types.js' +import {themeComponent} from '../../utilities/theme-ui.js' +import {renderError, renderSuccess} from '@shopify/cli-kit/node/ui' +import {outputResult} from '@shopify/cli-kit/node/output' + +export function renderThemeDuplicateResult(result: ThemeDuplicateResult, format: 'text' | 'json'): void { + if (result.status === 'cancelled') return + const json = toJsonResult(result) + if (format === 'json') { + // Keep compact JSON output; the shared encoder indents its output. + outputResult(JSON.stringify(themeDuplicateJsonOutputSchema.validate(json))) + } else if (result.status === 'completed') { + renderTextResult(result) + } else if ('message' in json) { + renderError({body: [json.message]}) + } +} + +function toJsonResult(result: Exclude): ThemeDuplicateJsonResult { + switch (result.status) { + case 'missing-theme-id': + return { + status: 'failed', + message: 'A theme ID is required to duplicate a theme, specify one with the --theme flag', + errors: [], + } + case 'not-found': + return { + status: 'failed', + message: `No theme with ID ${result.themeId} could be found. Use shopify theme list to find a theme ID.`, + errors: [], + } + case 'development-theme': + return { + status: 'failed', + message: "Development themes can't be duplicated. Use shopify theme push to upload it to the store first.", + errors: [], + } + case 'completed': { + if (result.userErrors.length > 0) { + return { + status: 'failed', + message: `The theme '${result.originalTheme.name}' could not be duplicated due to errors`, + errors: result.userErrors.map((error) => error.message), + requestId: result.requestId, + } + } + if (result.theme) { + const {id, name, role} = result.theme + return { + status: 'success', + originalTheme: result.originalTheme, + theme: {id, name, role, shop: result.shop, preview_url: result.previewUrl}, + } + } + return { + status: 'failed', + message: `The theme '${result.originalTheme.name}' unexpectedly could not be duplicated `, + errors: [], + requestId: result.requestId, + } + } + } +} + +function renderTextResult(result: Extract) { + const theme = result.originalTheme + if (result.userErrors && result.userErrors.length > 0) { + const errors = result.userErrors + .map((error: {field?: string[] | null; message: string}) => error.message) + .join(', ') + renderError({ + body: [ + 'The theme', + ...themeComponent(theme), + 'could not be duplicated due to errors: ', + {subdued: errors}, + {char: '.'}, + ...(result.requestId ? ['\nRequest ID: ', {subdued: result.requestId}] : []), + ], + }) + } else if (result.theme) { + renderSuccess({ + body: ['The theme', ...themeComponent(theme), 'has been duplicated', {char: '.'}], + nextSteps: [ + [ + { + link: { + label: 'View the duplicated theme', + url: result.previewUrl!, + }, + }, + ], + ], + }) + } else { + renderError({ + body: [ + 'The theme', + ...themeComponent(theme), + 'unexpectedly could not be duplicated', + {char: '.'}, + ...(result.requestId ? ['\nRequest ID: ', {subdued: result.requestId}] : []), + ], + }) + } +} diff --git a/packages/theme/src/cli/services/duplicate/types.ts b/packages/theme/src/cli/services/duplicate/types.ts new file mode 100644 index 00000000000..17f13dc5e9b --- /dev/null +++ b/packages/theme/src/cli/services/duplicate/types.ts @@ -0,0 +1,44 @@ +import {ThemeMutationSuccessSchema} from '../theme-mutation/status.js' +import {defineJsonOutputSchema, type InferJsonOutputSchema} from '@shopify/cli-kit/node/json-output-schema' +import {zod} from '@shopify/cli-kit/node/schema' + +const ThemeSchema = zod.object({id: zod.number(), name: zod.string(), role: zod.string()}) +const DuplicatedThemeSchema = ThemeSchema.extend({shop: zod.string(), preview_url: zod.string().optional()}) +const DuplicateErrorSchema = zod.object({ + status: zod.literal('failed'), + message: zod.string(), + errors: zod.array(zod.string()), + requestId: zod.string().optional(), +}) + +export const themeDuplicateJsonOutputSchema = defineJsonOutputSchema({ + name: 'ThemeDuplicateResult', + schema: zod.discriminatedUnion('status', [ + ThemeMutationSuccessSchema.extend({originalTheme: ThemeSchema, theme: DuplicatedThemeSchema}), + DuplicateErrorSchema, + ]), + definitions: {DuplicatedTheme: DuplicatedThemeSchema, ThemeDuplicateError: DuplicateErrorSchema}, +}) + +export type ThemeDuplicateJsonResult = InferJsonOutputSchema + +export const themeDuplicateResultSchema = zod.discriminatedUnion('status', [ + zod.object({status: zod.literal('missing-theme-id')}), + zod.object({status: zod.literal('not-found'), themeId: zod.string()}), + zod.object({status: zod.literal('development-theme')}), + zod.object({status: zod.literal('cancelled')}), + zod.object({ + status: zod.literal('completed'), + originalTheme: ThemeSchema.extend({ + createdAtRuntime: zod.boolean().default(false), + processing: zod.boolean().default(false), + }), + shop: zod.string(), + previewUrl: zod.string().optional(), + theme: ThemeSchema.optional(), + userErrors: zod.array(zod.object({field: zod.array(zod.string()).nullish(), message: zod.string()})), + requestId: zod.string().optional(), + }), +]) + +export type ThemeDuplicateResult = zod.infer diff --git a/packages/theme/src/cli/services/theme-mutation/status.ts b/packages/theme/src/cli/services/theme-mutation/status.ts new file mode 100644 index 00000000000..13f93ee57fe --- /dev/null +++ b/packages/theme/src/cli/services/theme-mutation/status.ts @@ -0,0 +1,3 @@ +import {zod} from '@shopify/cli-kit/node/schema' + +export const ThemeMutationSuccessSchema = zod.object({status: zod.literal('success')}) From 102c40956306a95f601944662a702d417d836c05 Mon Sep 17 00:00:00 2001 From: Gonzalo Riestra Date: Tue, 6 Oct 2026 14:01:25 +0200 Subject: [PATCH 2/5] Return theme duplication results and fatal errors through public JSON contracts --- .changeset/theme-duplicate-json-contract.md | 5 + packages/cli/README.md | 1092 ++++++++--------- packages/cli/oclif.manifest.json | 9 +- .../src/cli/commands/theme/duplicate.test.ts | 91 +- .../theme/src/cli/commands/theme/duplicate.ts | 36 +- .../theme/src/cli/services/duplicate.test.ts | 18 +- .../src/cli/services/duplicate/result.ts | 26 +- .../theme/src/cli/services/duplicate/types.ts | 47 +- 8 files changed, 625 insertions(+), 699 deletions(-) create mode 100644 .changeset/theme-duplicate-json-contract.md diff --git a/.changeset/theme-duplicate-json-contract.md b/.changeset/theme-duplicate-json-contract.md new file mode 100644 index 00000000000..52482e6191f --- /dev/null +++ b/.changeset/theme-duplicate-json-contract.md @@ -0,0 +1,5 @@ +--- +'@shopify/cli': major +--- + +Use string IDs, camelCase resources, cancellation results, and fatal error envelopes for `theme duplicate --json`. diff --git a/packages/cli/README.md b/packages/cli/README.md index 7336dcdb2c8..37e86049c49 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -10750,205 +10750,13 @@ DESCRIPTION If 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. - Sample JSON output: - - ```json - { - "theme": { - "id": 108267175958, - "name": "A Duplicated Theme", - "role": "unpublished", - "shop": "mystore.myshopify.com" - } - } - ``` - - ```json - { - "message": "The theme 'Summer Edition' could not be duplicated due to errors", - "errors": ["Maximum number of themes reached"], - "requestId": "12345-abcde-67890" - } - ``` + Successful JSON results include `status`, `changed`, and explicit `originalTheme` and `theme` resources with decimal + string IDs. Failures use the shared `{error}` document. 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` - -Displays information about your theme environment, including your current store. Can also retrieve information about a specific theme. - -``` -USAGE - $ shopify theme info [--auth-alias ] [-d] [-e ...] [-j] [--json-schema] [--no-color] - [--no-input] [--password ] [--path ] [-s ] [-t ] [--verbose] - -FLAGS - -d, --development - Retrieve info from your development theme. - [env: SHOPIFY_FLAG_DEVELOPMENT] - - -e, --environment=... - The environment to apply to the current command. - [env: SHOPIFY_FLAG_ENVIRONMENT] - - -j, --json - Output the result as JSON. Automatically disables color output. - [env: SHOPIFY_FLAG_JSON] - - -s, --store= - Store URL. It can be the store prefix (example) or the full myshopify.com URL (example.myshopify.com, - https://example.myshopify.com). - [env: SHOPIFY_FLAG_STORE] - - -t, --theme= - Theme ID or name of the remote theme. - [env: SHOPIFY_FLAG_THEME_ID] - - --auth-alias= - Alias of the Shopify account to use for authentication. - [env: SHOPIFY_FLAG_AUTH_ALIAS] - - --json-schema - Print the command's JSON schemas. - [env: SHOPIFY_FLAG_JSON_SCHEMA] - - --no-color - Disable color output. - [env: SHOPIFY_FLAG_NO_COLOR] - - --no-input - Disable interactive prompts and browser authentication. - [env: SHOPIFY_FLAG_NO_INPUT] - - --password= - Password generated from the Theme Access app or an Admin API token. - [env: SHOPIFY_CLI_THEME_TOKEN] - - --path= - The path where you want to run the command. Defaults to the current working directory. - [env: SHOPIFY_FLAG_PATH] - - --verbose - Increase the verbosity of the output. May include sensitive data. - [env: SHOPIFY_FLAG_VERBOSE] - -DESCRIPTION - Displays information about your theme environment, including your current store. Can also retrieve information about a - specific theme. - - Use `--json-schema` to print the result, error, and event schemas. - - Output from `--json` conforms to the `ThemeInfoResult` schema. - ```json { "anyOf": [ @@ -10971,7 +10779,7 @@ DESCRIPTION "additionalProperties": false } ], - "title": "ThemeInfoResult", + "title": "ThemeDuplicateResult", "definitions": { "Theme": { "type": "object", @@ -10986,92 +10794,13 @@ DESCRIPTION }, "role": { "type": "string", - "description": "The upstream theme role; known values include live, unpublished, and development." - }, - "storeDomain": { - "anyOf": [ - { - "type": "string", - "pattern": "^[a-z0-9][a-z0-9-]*\\.myshopify\\.com$" - }, - { - "type": "null" - } - ] - }, - "previewUrl": { - "anyOf": [ - { - "type": "string", - "format": "uri" - }, - { - "type": "null" - } - ] - }, - "editorUrl": { - "anyOf": [ - { - "type": "string", - "format": "uri" - }, - { - "type": "null" - } - ] + "description": "The upstream theme role; known values include main, unpublished, and development." } }, "required": [ "id", "name", - "role", - "storeDomain", - "previewUrl", - "editorUrl" - ], - "additionalProperties": false - }, - "ThemeEnvironmentInfo": { - "type": "object", - "properties": { - "storeDomain": { - "$ref": "#/definitions/Theme/properties/storeDomain" - }, - "developmentThemeId": { - "anyOf": [ - { - "$ref": "#/definitions/Theme/properties/id" - }, - { - "type": "null" - } - ], - "description": "The decimal Online Store theme ID, not a Shopify GID." - }, - "cliVersion": { - "type": "string" - }, - "os": { - "type": "string" - }, - "shell": { - "type": [ - "string", - "null" - ] - }, - "nodeVersion": { - "type": "string" - } - }, - "required": [ - "storeDomain", - "developmentThemeId", - "cliVersion", - "os", - "shell", - "nodeVersion" + "role" ], "additionalProperties": false }, @@ -11086,23 +10815,70 @@ DESCRIPTION "result": { "anyOf": [ { - "anyOf": [ - { + "type": "object", + "properties": { + "status": { + "type": "string", + "const": "success" + }, + "changed": { + "type": "boolean" + }, + "originalTheme": { + "$ref": "#/definitions/Theme" + }, + "theme": { "type": "object", "properties": { - "theme": { - "$ref": "#/definitions/Theme" + "id": { + "$ref": "#/definitions/Theme/properties/id" + }, + "name": { + "$ref": "#/definitions/Theme/properties/name" + }, + "role": { + "$ref": "#/definitions/Theme/properties/role" + }, + "storeDomain": { + "anyOf": [ + { + "type": "string", + "pattern": "^[a-z0-9][a-z0-9-]*\\.myshopify\\.com$" + }, + { + "type": "null" + } + ] + }, + "previewUrl": { + "anyOf": [ + { + "type": "string", + "format": "uri" + }, + { + "type": "null" + } + ] } }, "required": [ - "theme" + "id", + "name", + "role", + "storeDomain", + "previewUrl" ], "additionalProperties": false - }, - { - "$ref": "#/definitions/ThemeEnvironmentInfo" } - ] + }, + "required": [ + "status", + "changed", + "originalTheme", + "theme" + ], + "additionalProperties": false }, { "type": "object", @@ -11144,11 +10920,6 @@ DESCRIPTION "message": { "type": "string" }, - "code": { - "type": "string", - "minLength": 1, - "description": "A stable error code, included only when known." - }, "tryMessage": { "type": "string" }, @@ -11189,9 +10960,7 @@ DESCRIPTION "additionalProperties": false } }, - "details": { - "description": "Selected domain details, preserving native API payloads such as GraphQL errors, extensions, and data." - } + "details": {} }, "required": [ "type", @@ -11209,9 +10978,6 @@ DESCRIPTION "message": { "$ref": "#/definitions/ThemeEnvironment/anyOf/1/properties/error/anyOf/0/properties/message" }, - "code": { - "$ref": "#/definitions/ThemeEnvironment/anyOf/1/properties/error/anyOf/0/properties/code" - }, "tryMessage": { "$ref": "#/definitions/ThemeEnvironment/anyOf/1/properties/error/anyOf/0/properties/tryMessage" }, @@ -11244,9 +11010,6 @@ DESCRIPTION "message": { "$ref": "#/definitions/ThemeEnvironment/anyOf/1/properties/error/anyOf/0/properties/message" }, - "code": { - "$ref": "#/definitions/ThemeEnvironment/anyOf/1/properties/error/anyOf/0/properties/code" - }, "tryMessage": { "$ref": "#/definitions/ThemeEnvironment/anyOf/1/properties/error/anyOf/0/properties/tryMessage" }, @@ -11294,111 +11057,20 @@ DESCRIPTION ``` ``` -## `shopify theme init [name] [flags]` - -Clones a Git repository to use as a starting point for building a new theme. - -``` -USAGE - $ shopify theme init [name] [flags] - -ARGUMENTS - [NAME] Name of the new theme - -FLAGS - -l, --latest - Downloads the latest release of the `clone-url` - [env: SHOPIFY_FLAG_LATEST] - - -u, --clone-url= - [default: https://github.com/Shopify/skeleton-theme.git] The Git URL to clone from. Defaults to Shopify's Skeleton - theme. - [env: SHOPIFY_FLAG_CLONE_URL] - - --auth-alias= - Alias of the Shopify account to use for authentication. - [env: SHOPIFY_FLAG_AUTH_ALIAS] - - --json-schema - Print the command's JSON schemas. - [env: SHOPIFY_FLAG_JSON_SCHEMA] - - --no-color - Disable color output. - [env: SHOPIFY_FLAG_NO_COLOR] - - --no-input - Disable interactive prompts and browser authentication. - [env: SHOPIFY_FLAG_NO_INPUT] - - --path= - The path where you want to run the command. Defaults to the current working directory. - [env: SHOPIFY_FLAG_PATH] - - --verbose - Increase the verbosity of the output. May include sensitive data. - [env: SHOPIFY_FLAG_VERBOSE] - -DESCRIPTION - Clones a Git repository to use as a starting point for building a new theme. - - Clones a Git repository to your local machine to use as the starting point for building a theme. - - If no Git repository is specified, then this command creates a copy of Shopify's "Skeleton theme" - (https://github.com/Shopify/skeleton-theme.git), with the specified name in the current folder. If no name is - provided, then you're prompted to enter one. - - > Caution: If you're building a theme for the Shopify Theme Store, then you can use our example theme as a starting - point. However, the theme that you submit needs to be "substantively different from existing themes" - (https://shopify.dev/docs/themes/store/requirements#uniqueness) so that it provides added value for users. -``` - -## `shopify theme language-server` +## `shopify theme info` -Start a Language Server Protocol server. +Displays information about your theme environment, including your current store. Can also retrieve information about a specific theme. ``` USAGE - $ shopify theme language-server [--auth-alias ] [--json-schema] [--no-color] [--no-input] [--verbose] + $ shopify theme info [--auth-alias ] [-d] [-e ...] [-j] [--json-schema] [--no-color] + [--no-input] [--password ] [--path ] [-s ] [-t ] [--verbose] FLAGS - --auth-alias= - Alias of the Shopify account to use for authentication. - [env: SHOPIFY_FLAG_AUTH_ALIAS] - - --json-schema - Print the command's JSON schemas. - [env: SHOPIFY_FLAG_JSON_SCHEMA] - - --no-color - Disable color output. - [env: SHOPIFY_FLAG_NO_COLOR] - - --no-input - Disable interactive prompts and browser authentication. - [env: SHOPIFY_FLAG_NO_INPUT] - - --verbose - Increase the verbosity of the output. May include sensitive data. - [env: SHOPIFY_FLAG_VERBOSE] - -DESCRIPTION - Start a Language Server Protocol server. - - Starts the "Language Server" (https://shopify.dev/docs/themes/tools/cli/language-server). -``` - -## `shopify theme list` - -Lists the themes in your store, along with their IDs and statuses. - -``` -USAGE - $ shopify theme list [--auth-alias ] [-e ...] [--id ] [-j] [--json-schema] [--name - ] [--no-color] [--no-input] [--password ] [--path ] [--role live|unpublished|development] [-s - ] [--verbose] + -d, --development + Retrieve info from your development theme. + [env: SHOPIFY_FLAG_DEVELOPMENT] -FLAGS -e, --environment=... The environment to apply to the current command. [env: SHOPIFY_FLAG_ENVIRONMENT] @@ -11412,22 +11084,18 @@ FLAGS https://example.myshopify.com). [env: SHOPIFY_FLAG_STORE] + -t, --theme= + Theme ID or name of the remote theme. + [env: SHOPIFY_FLAG_THEME_ID] + --auth-alias= Alias of the Shopify account to use for authentication. [env: SHOPIFY_FLAG_AUTH_ALIAS] - --id= - Only list theme with the given ID. - [env: SHOPIFY_FLAG_ID] - --json-schema Print the command's JSON schemas. [env: SHOPIFY_FLAG_JSON_SCHEMA] - --name= - Only list themes that contain the given name. - [env: SHOPIFY_FLAG_NAME] - --no-color Disable color output. [env: SHOPIFY_FLAG_NO_COLOR] @@ -11444,21 +11112,17 @@ FLAGS The path where you want to run the command. Defaults to the current working directory. [env: SHOPIFY_FLAG_PATH] - --role=