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 a976adae2b0..95cb731d0a1 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -10750,24 +10750,322 @@ 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: + Successful JSON results include `status`, `changed`, and explicit `originalTheme` and `theme` resources with decimal + string IDs. Failures use the shared `{error}` document. - ```json - { - "theme": { - "id": 108267175958, - "name": "A Duplicated Theme", - "role": "unpublished", - "shop": "mystore.myshopify.com" - } - } - ``` + Use `--json-schema` to print the result, error, and event schemas. + + Output from `--json` conforms to the `ThemeDuplicateResult` schema. ```json { - "message": "The theme 'Summer Edition' could not be duplicated due to errors", - "errors": ["Maximum number of themes reached"], - "requestId": "12345-abcde-67890" + "anyOf": [ + { + "$ref": "#/definitions/ThemeEnvironment/anyOf/0/properties/result" + }, + { + "type": "object", + "properties": { + "environments": { + "type": "array", + "items": { + "$ref": "#/definitions/ThemeEnvironment" + } + } + }, + "required": [ + "environments" + ], + "additionalProperties": false + } + ], + "title": "ThemeDuplicateResult", + "definitions": { + "Theme": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^\\d+$", + "description": "The decimal Online Store theme ID, not a Shopify GID." + }, + "name": { + "type": "string" + }, + "role": { + "type": "string", + "description": "The upstream theme role; known values include live, unpublished, and development." + } + }, + "required": [ + "id", + "name", + "role" + ], + "additionalProperties": false + }, + "ThemeEnvironment": { + "anyOf": [ + { + "type": "object", + "properties": { + "environment": { + "type": "string" + }, + "result": { + "anyOf": [ + { + "type": "object", + "properties": { + "status": { + "type": "string", + "const": "success" + }, + "changed": { + "type": "boolean" + }, + "originalTheme": { + "$ref": "#/definitions/Theme" + }, + "theme": { + "type": "object", + "properties": { + "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": [ + "id", + "name", + "role", + "storeDomain", + "previewUrl" + ], + "additionalProperties": false + } + }, + "required": [ + "status", + "changed", + "originalTheme", + "theme" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "status": { + "type": "string", + "const": "cancelled" + } + }, + "required": [ + "status" + ], + "additionalProperties": false + } + ] + } + }, + "required": [ + "environment", + "result" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "environment": { + "type": "string" + }, + "error": { + "anyOf": [ + { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "abort" + }, + "message": { + "type": "string" + }, + "code": { + "type": "string", + "minLength": 1, + "description": "A stable error code, included only when known." + }, + "tryMessage": { + "type": "string" + }, + "nextSteps": { + "type": "array", + "items": { + "type": "string" + } + }, + "customSections": { + "type": "array", + "items": { + "type": "object", + "properties": { + "title": { + "type": "string" + }, + "body": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "array", + "items": { + "type": "string" + } + } + } + ] + } + }, + "required": [ + "body" + ], + "additionalProperties": false + } + }, + "details": { + "description": "Selected domain details, preserving native API payloads such as GraphQL errors, extensions, and data." + } + }, + "required": [ + "type", + "message" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "bug" + }, + "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" + }, + "nextSteps": { + "$ref": "#/definitions/ThemeEnvironment/anyOf/1/properties/error/anyOf/0/properties/nextSteps" + }, + "customSections": { + "$ref": "#/definitions/ThemeEnvironment/anyOf/1/properties/error/anyOf/0/properties/customSections" + }, + "details": { + "$ref": "#/definitions/ThemeEnvironment/anyOf/1/properties/error/anyOf/0/properties/details" + }, + "stack": { + "type": "string" + } + }, + "required": [ + "type", + "message" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "external" + }, + "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" + }, + "nextSteps": { + "$ref": "#/definitions/ThemeEnvironment/anyOf/1/properties/error/anyOf/0/properties/nextSteps" + }, + "customSections": { + "$ref": "#/definitions/ThemeEnvironment/anyOf/1/properties/error/anyOf/0/properties/customSections" + }, + "details": { + "$ref": "#/definitions/ThemeEnvironment/anyOf/1/properties/error/anyOf/0/properties/details" + }, + "command": { + "type": "string" + }, + "args": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": [ + "type", + "message", + "command", + "args" + ], + "additionalProperties": false + } + ] + } + }, + "required": [ + "environment", + "error" + ], + "additionalProperties": false + } + ] + } + }, + "$schema": "http://json-schema.org/draft-07/schema#" } ``` ``` diff --git a/packages/cli/oclif.manifest.json b/packages/cli/oclif.manifest.json index 01474ba2a4e..2e551f8814e 100644 --- a/packages/cli/oclif.manifest.json +++ b/packages/cli/oclif.manifest.json @@ -11036,8 +11036,8 @@ "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```", - "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```", + "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\nSuccessful JSON results include `status`, `changed`, and explicit `originalTheme` and `theme` resources with decimal string IDs. Failures use the shared `{error}` document.\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 \"$ref\": \"#/definitions/ThemeEnvironment/anyOf/0/properties/result\"\n },\n {\n \"type\": \"object\",\n \"properties\": {\n \"environments\": {\n \"type\": \"array\",\n \"items\": {\n \"$ref\": \"#/definitions/ThemeEnvironment\"\n }\n }\n },\n \"required\": [\n \"environments\"\n ],\n \"additionalProperties\": false\n }\n ],\n \"title\": \"ThemeDuplicateResult\",\n \"definitions\": {\n \"Theme\": {\n \"type\": \"object\",\n \"properties\": {\n \"id\": {\n \"type\": \"string\",\n \"pattern\": \"^\\\\d+$\",\n \"description\": \"The decimal Online Store theme ID, not a Shopify GID.\"\n },\n \"name\": {\n \"type\": \"string\"\n },\n \"role\": {\n \"type\": \"string\",\n \"description\": \"The upstream theme role; known values include live, unpublished, and development.\"\n }\n },\n \"required\": [\n \"id\",\n \"name\",\n \"role\"\n ],\n \"additionalProperties\": false\n },\n \"ThemeEnvironment\": {\n \"anyOf\": [\n {\n \"type\": \"object\",\n \"properties\": {\n \"environment\": {\n \"type\": \"string\"\n },\n \"result\": {\n \"anyOf\": [\n {\n \"type\": \"object\",\n \"properties\": {\n \"status\": {\n \"type\": \"string\",\n \"const\": \"success\"\n },\n \"changed\": {\n \"type\": \"boolean\"\n },\n \"originalTheme\": {\n \"$ref\": \"#/definitions/Theme\"\n },\n \"theme\": {\n \"type\": \"object\",\n \"properties\": {\n \"id\": {\n \"$ref\": \"#/definitions/Theme/properties/id\"\n },\n \"name\": {\n \"$ref\": \"#/definitions/Theme/properties/name\"\n },\n \"role\": {\n \"$ref\": \"#/definitions/Theme/properties/role\"\n },\n \"storeDomain\": {\n \"anyOf\": [\n {\n \"type\": \"string\",\n \"pattern\": \"^[a-z0-9][a-z0-9-]*\\\\.myshopify\\\\.com$\"\n },\n {\n \"type\": \"null\"\n }\n ]\n },\n \"previewUrl\": {\n \"anyOf\": [\n {\n \"type\": \"string\",\n \"format\": \"uri\"\n },\n {\n \"type\": \"null\"\n }\n ]\n }\n },\n \"required\": [\n \"id\",\n \"name\",\n \"role\",\n \"storeDomain\",\n \"previewUrl\"\n ],\n \"additionalProperties\": false\n }\n },\n \"required\": [\n \"status\",\n \"changed\",\n \"originalTheme\",\n \"theme\"\n ],\n \"additionalProperties\": false\n },\n {\n \"type\": \"object\",\n \"properties\": {\n \"status\": {\n \"type\": \"string\",\n \"const\": \"cancelled\"\n }\n },\n \"required\": [\n \"status\"\n ],\n \"additionalProperties\": false\n }\n ]\n }\n },\n \"required\": [\n \"environment\",\n \"result\"\n ],\n \"additionalProperties\": false\n },\n {\n \"type\": \"object\",\n \"properties\": {\n \"environment\": {\n \"type\": \"string\"\n },\n \"error\": {\n \"anyOf\": [\n {\n \"type\": \"object\",\n \"properties\": {\n \"type\": {\n \"type\": \"string\",\n \"const\": \"abort\"\n },\n \"message\": {\n \"type\": \"string\"\n },\n \"code\": {\n \"type\": \"string\",\n \"minLength\": 1,\n \"description\": \"A stable error code, included only when known.\"\n },\n \"tryMessage\": {\n \"type\": \"string\"\n },\n \"nextSteps\": {\n \"type\": \"array\",\n \"items\": {\n \"type\": \"string\"\n }\n },\n \"customSections\": {\n \"type\": \"array\",\n \"items\": {\n \"type\": \"object\",\n \"properties\": {\n \"title\": {\n \"type\": \"string\"\n },\n \"body\": {\n \"anyOf\": [\n {\n \"type\": \"string\"\n },\n {\n \"type\": \"array\",\n \"items\": {\n \"type\": \"array\",\n \"items\": {\n \"type\": \"string\"\n }\n }\n }\n ]\n }\n },\n \"required\": [\n \"body\"\n ],\n \"additionalProperties\": false\n }\n },\n \"details\": {\n \"description\": \"Selected domain details, preserving native API payloads such as GraphQL errors, extensions, and data.\"\n }\n },\n \"required\": [\n \"type\",\n \"message\"\n ],\n \"additionalProperties\": false\n },\n {\n \"type\": \"object\",\n \"properties\": {\n \"type\": {\n \"type\": \"string\",\n \"const\": \"bug\"\n },\n \"message\": {\n \"$ref\": \"#/definitions/ThemeEnvironment/anyOf/1/properties/error/anyOf/0/properties/message\"\n },\n \"code\": {\n \"$ref\": \"#/definitions/ThemeEnvironment/anyOf/1/properties/error/anyOf/0/properties/code\"\n },\n \"tryMessage\": {\n \"$ref\": \"#/definitions/ThemeEnvironment/anyOf/1/properties/error/anyOf/0/properties/tryMessage\"\n },\n \"nextSteps\": {\n \"$ref\": \"#/definitions/ThemeEnvironment/anyOf/1/properties/error/anyOf/0/properties/nextSteps\"\n },\n \"customSections\": {\n \"$ref\": \"#/definitions/ThemeEnvironment/anyOf/1/properties/error/anyOf/0/properties/customSections\"\n },\n \"details\": {\n \"$ref\": \"#/definitions/ThemeEnvironment/anyOf/1/properties/error/anyOf/0/properties/details\"\n },\n \"stack\": {\n \"type\": \"string\"\n }\n },\n \"required\": [\n \"type\",\n \"message\"\n ],\n \"additionalProperties\": false\n },\n {\n \"type\": \"object\",\n \"properties\": {\n \"type\": {\n \"type\": \"string\",\n \"const\": \"external\"\n },\n \"message\": {\n \"$ref\": \"#/definitions/ThemeEnvironment/anyOf/1/properties/error/anyOf/0/properties/message\"\n },\n \"code\": {\n \"$ref\": \"#/definitions/ThemeEnvironment/anyOf/1/properties/error/anyOf/0/properties/code\"\n },\n \"tryMessage\": {\n \"$ref\": \"#/definitions/ThemeEnvironment/anyOf/1/properties/error/anyOf/0/properties/tryMessage\"\n },\n \"nextSteps\": {\n \"$ref\": \"#/definitions/ThemeEnvironment/anyOf/1/properties/error/anyOf/0/properties/nextSteps\"\n },\n \"customSections\": {\n \"$ref\": \"#/definitions/ThemeEnvironment/anyOf/1/properties/error/anyOf/0/properties/customSections\"\n },\n \"details\": {\n \"$ref\": \"#/definitions/ThemeEnvironment/anyOf/1/properties/error/anyOf/0/properties/details\"\n },\n \"command\": {\n \"type\": \"string\"\n },\n \"args\": {\n \"type\": \"array\",\n \"items\": {\n \"type\": \"string\"\n }\n }\n },\n \"required\": [\n \"type\",\n \"message\",\n \"command\",\n \"args\"\n ],\n \"additionalProperties\": false\n }\n ]\n }\n },\n \"required\": [\n \"environment\",\n \"error\"\n ],\n \"additionalProperties\": false\n }\n ]\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\nSuccessful JSON results include `status`, `changed`, and explicit `originalTheme` and `theme` resources with decimal string IDs. Failures use the shared `{error}` document.", "enableJsonFlag": false, "flags": { "auth-alias": { @@ -11144,6 +11144,11 @@ "hiddenAliases": [ ], "id": "theme:duplicate", + "multiEnvironmentsFlags": [ + "store", + "password", + "theme" + ], "pluginAlias": "@shopify/cli", "pluginName": "@shopify/cli", "pluginType": "core", 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..20c0e6c50dc --- /dev/null +++ b/packages/theme/src/cli/commands/theme/duplicate.test.ts @@ -0,0 +1,166 @@ +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 {loadEnvironment} from '@shopify/cli-kit/node/environments' +import {afterEach, describe, expect, test, vi} from 'vitest' + +vi.mock('@shopify/cli-kit/node/session') +vi.mock('@shopify/cli-kit/node/themes/api') +vi.mock('@shopify/cli-kit/node/environments') +vi.mock('../../utilities/theme-selector.js') + +afterEach(() => vi.unstubAllEnvs()) + +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'} +const publicResult = { + status: 'success', + changed: true, + originalTheme: {id: '1', name: 'Original', role: 'unpublished'}, + theme: { + id: '2', + name: 'Copy', + role: 'unpublished', + storeDomain: session.storeFqdn, + previewUrl: 'https://test.myshopify.com?preview_theme_id=2', + }, +} + +async function run(extra: string[] = []) { + const config = new Config({root: __dirname}) + await config.load() + vi.mocked(ensureAuthenticatedThemes).mockResolvedValue(session) + const argv = ['--store', session.storeFqdn, '--theme', '1', '--force', '--json', ...extra] + await runWithCommandEventsForCommand(argv, () => new Duplicate(argv, config).run()) +} + +describe('theme duplicate JSON output', () => { + test('exposes a strict public schema and keeps the JSON flag', () => { + expect(Duplicate.jsonOutputSchema).toBe(themeDuplicateJsonOutputSchema) + expect(Duplicate.flags.json).toBeDefined() + expect(Duplicate.description).toContain('ThemeDuplicateResult') + expect(themeDuplicateJsonOutputSchema.validate(publicResult)).toEqual(publicResult) + expect(() => + themeDuplicateJsonOutputSchema.validate({...publicResult, theme: {...publicResult.theme, id: 2}}), + ).toThrow() + expect(() => + themeDuplicateJsonOutputSchema.validate({ + ...publicResult, + theme: {...publicResult.theme, createdAtRuntime: false}, + }), + ).toThrow() + }) + + test('writes one receipt to stdout 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(publicResult) + expect(JSON.parse(stderr())).toMatchObject({type: 'diagnostic', level: 'warning', message: 'Retrying request'}) + }) + }) + + test.each([undefined, '', 'request-123'])('throws a fatal error with domain details (%s)', async (requestId) => { + vi.mocked(findThemeById).mockResolvedValue(originalTheme) + vi.mocked(themeDuplicate).mockResolvedValue({userErrors: [{message: 'Limit reached'}], requestId}) + await withCapturedStandardStreams(async ({stdout}) => { + await expect(run()).rejects.toMatchObject({ + message: "The theme 'Original' could not be duplicated due to errors", + details: {errors: ['Limit reached'], ...(requestId ? {requestId} : {})}, + }) + expect(stdout()).toBe('') + }) + }) + + 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 expect(run()).rejects.toMatchObject({details: {errors: ['Duplication failed']}}) + expect(stdout()).toBe('') + }) + }) + + test('fails when the API returned no duplicate', async () => { + vi.mocked(findThemeById).mockResolvedValue(originalTheme) + vi.mocked(themeDuplicate).mockResolvedValue({userErrors: []}) + await expect(run()).rejects.toThrow("The theme 'Original' unexpectedly could not be duplicated") + }) + + test('wraps a single explicitly requested environment', async () => { + vi.mocked(loadEnvironment).mockResolvedValue({store: session.storeFqdn, password: 'token', theme: '1'}) + vi.mocked(findThemeById).mockResolvedValue(originalTheme) + vi.mocked(themeDuplicate).mockResolvedValue({theme: copiedTheme, userErrors: []}) + await withCapturedStandardStreams(async ({stdout}) => { + await run(['--environment', 'staging']) + expect(JSON.parse(stdout())).toEqual({environments: [{environment: 'staging', result: publicResult}]}) + }) + }) + + test.each([['staging'], ['staging', 'production']])( + 'does not prompt for CI environments: %j', + async (...environments) => { + vi.stubEnv('CI', '1') + vi.mocked(ensureAuthenticatedThemes).mockResolvedValue(session) + vi.mocked(loadEnvironment).mockResolvedValue({store: session.storeFqdn, password: 'token', theme: 1}) + vi.mocked(findThemeById).mockResolvedValue(originalTheme) + vi.mocked(themeDuplicate).mockResolvedValue({theme: copiedTheme, userErrors: []}) + const config = new Config({root: __dirname}) + await config.load() + const argv = ['--json', ...environments.flatMap((environment) => ['-e', environment])] + + await withCapturedStandardStreams(async ({stdout}) => { + await runWithCommandEventsForCommand(argv, () => new Duplicate(argv, config).run()) + expect(JSON.parse(stdout())).toEqual({ + environments: environments.map((environment) => ({environment, result: publicResult})), + }) + }) + expect(themeDuplicate).toHaveBeenCalledTimes(environments.length) + expect(findThemeById).toHaveBeenCalledWith(session, '1') + }, + ) + + test('reports missing force as an environment error when input is disabled outside CI', async () => { + vi.stubEnv('CI', '') + vi.stubEnv('SHOPIFY_FLAG_NO_INPUT', '1') + vi.mocked(loadEnvironment).mockResolvedValue({store: session.storeFqdn, password: 'token', theme: 1}) + const config = new Config({root: __dirname}) + await config.load() + const argv = ['--json', '-e', 'staging'] + const previousExitCode = process.exitCode + try { + await withCapturedStandardStreams(async ({stdout}) => { + await runWithCommandEventsForCommand(argv, () => new Duplicate(argv, config).run()) + + expect(JSON.parse(stdout())).toMatchObject({ + environments: [{environment: 'staging', error: {type: 'abort', message: expect.stringContaining('--force')}}], + }) + }) + expect(process.exitCode).toBe(1) + expect(ensureAuthenticatedThemes).not.toHaveBeenCalled() + expect(themeDuplicate).not.toHaveBeenCalled() + } finally { + process.exitCode = previousExitCode + } + }) + + 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('') + }) + }) +}) diff --git a/packages/theme/src/cli/commands/theme/duplicate.ts b/packages/theme/src/cli/commands/theme/duplicate.ts index eba7faae604..da27829b0ec 100644 --- a/packages/theme/src/cli/commands/theme/duplicate.ts +++ b/packages/theme/src/cli/commands/theme/duplicate.ts @@ -1,14 +1,21 @@ -import {ensureThemeStore} from '../../utilities/theme-store.js' +import {themeDuplicateJsonOutputSchema} from '../../services/duplicate/types.js' +import {renderThemeDuplicateResult, themeDuplicateJsonResult} from '../../services/duplicate/result.js' +import {configureCLIEnvironment} from '../../utilities/cli-config.js' import {themeFlags} from '../../flags.js' import ThemeCommand from '../../utilities/theme-command.js' import {duplicate} from '../../services/duplicate.js' import {Flags} from '@oclif/core' import {globalFlags, jsonFlag, requiredIfNonInteractive} from '@shopify/cli-kit/node/cli' -import {ensureAuthenticatedThemes} from '@shopify/cli-kit/node/session' +import {AdminSession} from '@shopify/cli-kit/node/session' import {isCI} from '@shopify/cli-kit/node/system' -import type {NonTTYFlagRequirement} from '@shopify/cli-kit/node/base-command' +import type {OutputFlags} from '@oclif/core/interfaces' +import type {FlagOutput, 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'"] @@ -23,26 +30,7 @@ You can optionally name the duplicated theme using the \`--name\` flag. 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.` static description = this.descriptionForHelp() @@ -72,15 +60,20 @@ Sample JSON output: }), } + static multiEnvironmentsFlags = ['store', 'password', 'theme'] + static nonTTYFlagRequirements(): NonTTYFlagRequirement[] { return [{flags: ['force'], when: () => !isCI()}] } - async run(): Promise { - const {flags} = await this.parse(Duplicate) - const store = ensureThemeStore(flags) - const adminSession = await ensureAuthenticatedThemes(store, flags.password) + async command(flags: OutputFlags, adminSession: AdminSession, multiEnvironment = false) { + configureCLIEnvironment(flags) + const result = await duplicate(adminSession, flags.theme, flags, multiEnvironment) + if (flags.json && multiEnvironment) return themeDuplicateJsonResult(result) + renderThemeDuplicateResult(result, flags.json ? 'json' : 'text') + } - await duplicate(adminSession, flags.theme, flags) + protected requiresEnvironmentConfirmation(flags: FlagOutput): boolean { + return !isCI() && super.requiresEnvironmentConfirmation(flags) } } diff --git a/packages/theme/src/cli/services/duplicate.test.ts b/packages/theme/src/cli/services/duplicate.test.ts index 99253fcbbba..038baa9782c 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' @@ -14,10 +13,12 @@ import {AdminSession} from '@shopify/cli-kit/node/session' vi.mock('@shopify/cli-kit/node/system') vi.mock('@shopify/cli-kit/node/ui') vi.mock('@shopify/cli-kit/node/themes/api') -vi.mock('@shopify/cli-kit/node/output') +vi.mock('@shopify/cli-kit/node/output', async (importOriginal) => ({ + ...(await importOriginal()), + outputResult: vi.fn(), +})) 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 +45,6 @@ const options = { describe('duplicate', () => { beforeEach(() => { vi.mocked(themeComponent).mockReturnValue(['theme component']) - vi.mocked(configureCLIEnvironment).mockReturnValue() vi.mocked(outputResult).mockReturnValue() }) @@ -108,6 +108,17 @@ describe('duplicate', () => { expect(renderSuccess).toHaveBeenCalled() }) + test('does not prompt again after environment batch confirmation', async () => { + vi.mocked(isCI).mockReturnValue(false) + vi.mocked(findThemeById).mockResolvedValue(theme) + vi.mocked(themeDuplicate).mockResolvedValue({theme: duplicatedTheme, userErrors: []}) + + await executeDuplicate(session, '1', {}, true) + + expect(renderConfirmationPrompt).not.toHaveBeenCalled() + expect(themeDuplicate).toHaveBeenCalledWith(1, undefined, session) + }) + test('does not prompt for confirmation in CI environment', async () => { // Given vi.mocked(isCI).mockReturnValue(true) @@ -261,15 +272,39 @@ describe('duplicate', () => { }) // When - await duplicate(session, '1', {...options, json: true}) - - // Then - expect(outputResult).toHaveBeenCalledWith( - JSON.stringify({ - message: `The theme '${theme.name}' unexpectedly could not be duplicated `, - errors: [], - requestId: '12345-abcde-67890', - }), + await expect(duplicate(session, '1', {...options, json: true})).rejects.toThrow( + `The theme '${theme.name}' unexpectedly could not be duplicated`, ) + expect(outputResult).not.toHaveBeenCalled() + }) +}) + +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..bdc0c7c98b7 100644 --- a/packages/theme/src/cli/services/duplicate.ts +++ b/packages/theme/src/cli/services/duplicate.ts @@ -1,63 +1,27 @@ +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 - const noPrompts = isCI() || force - - configureCLIEnvironment({ - verbose, - noColor, - }) +export async function duplicate( + adminSession: AdminSession, + themeId: string | undefined, + flags: DuplicateOptions, + multiEnvironment = false, +): Promise { + const {name, force} = flags + const noPrompts = isCI() || Boolean(force) || multiEnvironment 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 +34,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 +47,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..0a80504b91a --- /dev/null +++ b/packages/theme/src/cli/services/duplicate/result.ts @@ -0,0 +1,117 @@ +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' +import {AbortError} from '@shopify/cli-kit/node/error' + +export function themeDuplicateJsonResult(result: ThemeDuplicateResult) { + if (result.status === 'cancelled') return {status: 'cancelled' as const} + const json = toJsonResult(result) + if (json.status === 'failed') { + const error = new AbortError(json.message.trim()) + error.details = {errors: json.errors, ...(json.requestId ? {requestId: json.requestId} : {})} + throw error + } + return json +} + +export function renderThemeDuplicateResult(result: ThemeDuplicateResult, format: 'text' | 'json'): void { + if (format === 'json') { + outputResult(themeDuplicateJsonOutputSchema.encode(themeDuplicateJsonResult(result))) + return + } + if (result.status === 'cancelled') return + const json = toJsonResult(result) + 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..42524504f89 --- /dev/null +++ b/packages/theme/src/cli/services/duplicate/types.ts @@ -0,0 +1,75 @@ +import {ThemeMutationSuccessSchema} from '../theme-mutation/status.js' +import { + defineThemeJsonOutputSchema, + ThemeSchema as PublicThemeSchema, + StoreDomainSchema, + projectTheme, + storeDomain, +} from '../json-output/schema.js' +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(), +}) + +const ThemeDuplicateJsonServiceSchema = zod.discriminatedUnion('status', [ + ThemeMutationSuccessSchema.extend({originalTheme: ThemeSchema, theme: DuplicatedThemeSchema}), + DuplicateErrorSchema, +]) +export const themeDuplicateJsonOutputSchema = defineThemeJsonOutputSchema({ + name: 'ThemeDuplicateResult', + schema: zod + .object({ + status: zod.literal('success'), + changed: zod.boolean(), + originalTheme: PublicThemeSchema, + theme: PublicThemeSchema.extend({ + storeDomain: StoreDomainSchema, + previewUrl: zod.string().url().nullable(), + }).strict(), + }) + .strict(), + definitions: {Theme: PublicThemeSchema}, + project(value) { + const result = ThemeDuplicateJsonServiceSchema.parse(value) + if (result.status === 'failed') throw new TypeError('Failed duplications use the shared fatal error document.') + return { + status: result.status, + changed: true, + originalTheme: projectTheme(result.originalTheme), + theme: { + ...projectTheme(result.theme), + storeDomain: storeDomain(result.theme.shop), + previewUrl: result.theme.preview_url ?? null, + }, + } + }, +}) + +export type ThemeDuplicateJsonResult = zod.infer + +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')})