diff --git a/packages/cli/README.md b/packages/cli/README.md index bb0141ad7b0..eee7ae49794 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -7962,6 +7962,116 @@ DESCRIPTION "requestId": "12345-abcde-67890" } ``` + + Use `--json-schema` to print the result, error, and event schemas. + + Output from `--json` conforms to the `ThemeDuplicateResult` schema. + + ```json + { + "anyOf": [ + { + "type": "object", + "properties": { + "status": { + "type": "string", + "const": "success" + }, + "originalTheme": { + "type": "object", + "properties": { + "id": { + "$ref": "#/definitions/DuplicatedTheme/properties/id" + }, + "name": { + "$ref": "#/definitions/DuplicatedTheme/properties/name" + }, + "role": { + "$ref": "#/definitions/DuplicatedTheme/properties/role" + } + }, + "required": [ + "id", + "name", + "role" + ], + "additionalProperties": false + }, + "theme": { + "$ref": "#/definitions/DuplicatedTheme" + } + }, + "required": [ + "status", + "originalTheme", + "theme" + ], + "additionalProperties": false + }, + { + "$ref": "#/definitions/ThemeDuplicateError" + } + ], + "title": "ThemeDuplicateResult", + "definitions": { + "DuplicatedTheme": { + "type": "object", + "properties": { + "id": { + "type": "number" + }, + "name": { + "type": "string" + }, + "role": { + "type": "string" + }, + "shop": { + "type": "string" + }, + "preview_url": { + "type": "string" + } + }, + "required": [ + "id", + "name", + "role", + "shop" + ], + "additionalProperties": false + }, + "ThemeDuplicateError": { + "type": "object", + "properties": { + "status": { + "type": "string", + "const": "failed" + }, + "message": { + "type": "string" + }, + "errors": { + "type": "array", + "items": { + "type": "string" + } + }, + "requestId": { + "type": "string" + } + }, + "required": [ + "status", + "message", + "errors" + ], + "additionalProperties": false + } + }, + "$schema": "http://json-schema.org/draft-07/schema#" + } + ``` ``` ## `shopify theme info` diff --git a/packages/cli/oclif.manifest.json b/packages/cli/oclif.manifest.json index c27b8af6f30..10eec728c0b 100644 --- a/packages/cli/oclif.manifest.json +++ b/packages/cli/oclif.manifest.json @@ -10007,7 +10007,7 @@ "args": { }, "customPluginName": "@shopify/theme", - "description": "If you want to duplicate your local theme, you need to run `shopify theme push` first.\n\nIf no theme ID is specified, you're prompted to select the theme that you want to duplicate from the list of themes in your store. You're asked to confirm that you want to duplicate the specified theme.\n\nPrompts and confirmations are not shown when duplicate is run in a CI environment or the `--force` flag is used, therefore you must specify a theme ID using the `--theme` flag.\n\nYou can optionally name the duplicated theme using the `--name` flag.\n\nIf you use the `--json` flag, then theme information is returned in JSON format, which can be used as a machine-readable input for scripts or continuous integration.\n\nSample JSON output:\n\n```json\n{\n \"theme\": {\n \"id\": 108267175958,\n \"name\": \"A Duplicated Theme\",\n \"role\": \"unpublished\",\n \"shop\": \"mystore.myshopify.com\"\n }\n}\n```\n\n```json\n{\n \"message\": \"The theme 'Summer Edition' could not be duplicated due to errors\",\n \"errors\": [\"Maximum number of themes reached\"],\n \"requestId\": \"12345-abcde-67890\"\n}\n```", + "description": "If you want to duplicate your local theme, you need to run `shopify theme push` first.\n\nIf no theme ID is specified, you're prompted to select the theme that you want to duplicate from the list of themes in your store. You're asked to confirm that you want to duplicate the specified theme.\n\nPrompts and confirmations are not shown when duplicate is run in a CI environment or the `--force` flag is used, therefore you must specify a theme ID using the `--theme` flag.\n\nYou can optionally name the duplicated theme using the `--name` flag.\n\nIf you use the `--json` flag, then theme information is returned in JSON format, which can be used as a machine-readable input for scripts or continuous integration.\n\nSample JSON output:\n\n```json\n{\n \"theme\": {\n \"id\": 108267175958,\n \"name\": \"A Duplicated Theme\",\n \"role\": \"unpublished\",\n \"shop\": \"mystore.myshopify.com\"\n }\n}\n```\n\n```json\n{\n \"message\": \"The theme 'Summer Edition' could not be duplicated due to errors\",\n \"errors\": [\"Maximum number of themes reached\"],\n \"requestId\": \"12345-abcde-67890\"\n}\n```\n\nUse `--json-schema` to print the result, error, and event schemas.\n\nOutput from `--json` conforms to the `ThemeDuplicateResult` schema.\n\n```json\n{\n \"anyOf\": [\n {\n \"type\": \"object\",\n \"properties\": {\n \"status\": {\n \"type\": \"string\",\n \"const\": \"success\"\n },\n \"originalTheme\": {\n \"type\": \"object\",\n \"properties\": {\n \"id\": {\n \"$ref\": \"#/definitions/DuplicatedTheme/properties/id\"\n },\n \"name\": {\n \"$ref\": \"#/definitions/DuplicatedTheme/properties/name\"\n },\n \"role\": {\n \"$ref\": \"#/definitions/DuplicatedTheme/properties/role\"\n }\n },\n \"required\": [\n \"id\",\n \"name\",\n \"role\"\n ],\n \"additionalProperties\": false\n },\n \"theme\": {\n \"$ref\": \"#/definitions/DuplicatedTheme\"\n }\n },\n \"required\": [\n \"status\",\n \"originalTheme\",\n \"theme\"\n ],\n \"additionalProperties\": false\n },\n {\n \"$ref\": \"#/definitions/ThemeDuplicateError\"\n }\n ],\n \"title\": \"ThemeDuplicateResult\",\n \"definitions\": {\n \"DuplicatedTheme\": {\n \"type\": \"object\",\n \"properties\": {\n \"id\": {\n \"type\": \"number\"\n },\n \"name\": {\n \"type\": \"string\"\n },\n \"role\": {\n \"type\": \"string\"\n },\n \"shop\": {\n \"type\": \"string\"\n },\n \"preview_url\": {\n \"type\": \"string\"\n }\n },\n \"required\": [\n \"id\",\n \"name\",\n \"role\",\n \"shop\"\n ],\n \"additionalProperties\": false\n },\n \"ThemeDuplicateError\": {\n \"type\": \"object\",\n \"properties\": {\n \"status\": {\n \"type\": \"string\",\n \"const\": \"failed\"\n },\n \"message\": {\n \"type\": \"string\"\n },\n \"errors\": {\n \"type\": \"array\",\n \"items\": {\n \"type\": \"string\"\n }\n },\n \"requestId\": {\n \"type\": \"string\"\n }\n },\n \"required\": [\n \"status\",\n \"message\",\n \"errors\"\n ],\n \"additionalProperties\": false\n }\n },\n \"$schema\": \"http://json-schema.org/draft-07/schema#\"\n}\n```", "descriptionWithMarkdown": "If you want to duplicate your local theme, you need to run `shopify theme push` first.\n\nIf no theme ID is specified, you're prompted to select the theme that you want to duplicate from the list of themes in your store. You're asked to confirm that you want to duplicate the specified theme.\n\nPrompts and confirmations are not shown when duplicate is run in a CI environment or the `--force` flag is used, therefore you must specify a theme ID using the `--theme` flag.\n\nYou can optionally name the duplicated theme using the `--name` flag.\n\nIf you use the `--json` flag, then theme information is returned in JSON format, which can be used as a machine-readable input for scripts or continuous integration.\n\nSample JSON output:\n\n```json\n{\n \"theme\": {\n \"id\": 108267175958,\n \"name\": \"A Duplicated Theme\",\n \"role\": \"unpublished\",\n \"shop\": \"mystore.myshopify.com\"\n }\n}\n```\n\n```json\n{\n \"message\": \"The theme 'Summer Edition' could not be duplicated due to errors\",\n \"errors\": [\"Maximum number of themes reached\"],\n \"requestId\": \"12345-abcde-67890\"\n}\n```", "enableJsonFlag": false, "flags": { 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 3b529edd941..c3a3b57cae6 100644 --- a/packages/eslint-plugin-cli/rules/json-output-command-exceptions.js +++ b/packages/eslint-plugin-cli/rules/json-output-command-exceptions.js @@ -45,7 +45,6 @@ const commandExceptions = [ 'packages/plugin-did-you-mean/src/commands/config/autocorrect/status.ts', 'packages/theme/src/cli/commands/theme/check.ts', 'packages/theme/src/cli/commands/theme/delete.ts', - 'packages/theme/src/cli/commands/theme/duplicate.ts', 'packages/theme/src/cli/commands/theme/init.ts', 'packages/theme/src/cli/commands/theme/metafields/pull.ts', 'packages/theme/src/cli/commands/theme/package.ts', 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')})