diff --git a/docs-shopify.dev/generated/generated_docs_data_v2.json b/docs-shopify.dev/generated/generated_docs_data_v2.json index 8005ad5320d..f746ea46880 100644 --- a/docs-shopify.dev/generated/generated_docs_data_v2.json +++ b/docs-shopify.dev/generated/generated_docs_data_v2.json @@ -8548,6 +8548,15 @@ "isOptional": true, "environmentValue": "SHOPIFY_FLAG_FORCE" }, + { + "filePath": "docs-shopify.dev/commands/interfaces/theme-delete.interface.ts", + "syntaxKind": "PropertySignature", + "name": "-j, --json", + "value": "''", + "description": "Output the result as JSON. Automatically disables color output.", + "isOptional": true, + "environmentValue": "SHOPIFY_FLAG_JSON" + }, { "filePath": "docs-shopify.dev/commands/interfaces/theme-delete.interface.ts", "syntaxKind": "PropertySignature", @@ -8567,7 +8576,7 @@ "environmentValue": "SHOPIFY_FLAG_THEME_ID" } ], - "value": "export interface themedelete {\n /**\n * Alias of the Shopify account to use for authentication.\n * @environment SHOPIFY_FLAG_AUTH_ALIAS\n */\n '--auth-alias '?: string\n\n /**\n * Delete your development theme. Use --show-all, --development, or --theme in non-interactive environments.\n * @environment SHOPIFY_FLAG_DEVELOPMENT\n */\n '-d, --development'?: ''\n\n /**\n * The environment to apply to the current command.\n * @environment SHOPIFY_FLAG_ENVIRONMENT\n */\n '-e, --environment '?: string\n\n /**\n * Skip confirmation. Required if non interactive.\n * @environment SHOPIFY_FLAG_FORCE\n */\n '-f, --force'?: ''\n\n /**\n * Print the command's JSON schemas.\n * @environment SHOPIFY_FLAG_JSON_SCHEMA\n */\n '--json-schema'?: ''\n\n /**\n * Disable color output.\n * @environment SHOPIFY_FLAG_NO_COLOR\n */\n '--no-color'?: ''\n\n /**\n * Disable interactive prompts and browser authentication.\n * @environment SHOPIFY_FLAG_NO_INPUT\n */\n '--no-input'?: ''\n\n /**\n * Password generated from the Theme Access app or an Admin API token.\n * @environment SHOPIFY_CLI_THEME_TOKEN\n */\n '--password '?: string\n\n /**\n * The path where you want to run the command. Defaults to the current working directory.\n * @environment SHOPIFY_FLAG_PATH\n */\n '--path '?: string\n\n /**\n * Include other development themes in the theme list. Use --show-all, --development, or --theme in non-interactive environments.\n * @environment SHOPIFY_FLAG_SHOW_ALL\n */\n '-a, --show-all'?: ''\n\n /**\n * Store URL. It can be the store prefix (example) or the full myshopify.com URL (example.myshopify.com, https://example.myshopify.com).\n * @environment SHOPIFY_FLAG_STORE\n */\n '-s, --store '?: string\n\n /**\n * Theme ID or name of the remote theme. Use --show-all, --development, or --theme in non-interactive environments.\n * @environment SHOPIFY_FLAG_THEME_ID\n */\n '-t, --theme '?: string\n\n /**\n * Increase the verbosity of the output. May include sensitive data.\n * @environment SHOPIFY_FLAG_VERBOSE\n */\n '--verbose'?: ''\n}" + "value": "export interface themedelete {\n /**\n * Alias of the Shopify account to use for authentication.\n * @environment SHOPIFY_FLAG_AUTH_ALIAS\n */\n '--auth-alias '?: string\n\n /**\n * Delete your development theme. Use --show-all, --development, or --theme in non-interactive environments.\n * @environment SHOPIFY_FLAG_DEVELOPMENT\n */\n '-d, --development'?: ''\n\n /**\n * The environment to apply to the current command.\n * @environment SHOPIFY_FLAG_ENVIRONMENT\n */\n '-e, --environment '?: string\n\n /**\n * Skip confirmation. Required if non interactive.\n * @environment SHOPIFY_FLAG_FORCE\n */\n '-f, --force'?: ''\n\n /**\n * Output the result as JSON. Automatically disables color output.\n * @environment SHOPIFY_FLAG_JSON\n */\n '-j, --json'?: ''\n\n /**\n * Print the command's JSON schemas.\n * @environment SHOPIFY_FLAG_JSON_SCHEMA\n */\n '--json-schema'?: ''\n\n /**\n * Disable color output.\n * @environment SHOPIFY_FLAG_NO_COLOR\n */\n '--no-color'?: ''\n\n /**\n * Disable interactive prompts and browser authentication.\n * @environment SHOPIFY_FLAG_NO_INPUT\n */\n '--no-input'?: ''\n\n /**\n * Password generated from the Theme Access app or an Admin API token.\n * @environment SHOPIFY_CLI_THEME_TOKEN\n */\n '--password '?: string\n\n /**\n * The path where you want to run the command. Defaults to the current working directory.\n * @environment SHOPIFY_FLAG_PATH\n */\n '--path '?: string\n\n /**\n * Include other development themes in the theme list. Use --show-all, --development, or --theme in non-interactive environments.\n * @environment SHOPIFY_FLAG_SHOW_ALL\n */\n '-a, --show-all'?: ''\n\n /**\n * Store URL. It can be the store prefix (example) or the full myshopify.com URL (example.myshopify.com, https://example.myshopify.com).\n * @environment SHOPIFY_FLAG_STORE\n */\n '-s, --store '?: string\n\n /**\n * Theme ID or name of the remote theme. Use --show-all, --development, or --theme in non-interactive environments.\n * @environment SHOPIFY_FLAG_THEME_ID\n */\n '-t, --theme '?: string\n\n /**\n * Increase the verbosity of the output. May include sensitive data.\n * @environment SHOPIFY_FLAG_VERBOSE\n */\n '--verbose'?: ''\n}" } }, "themedev": { diff --git a/packages/cli/README.md b/packages/cli/README.md index 95cb731d0a1..ceb71f12408 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -10444,7 +10444,7 @@ Delete remote themes from the connected store. This command can't be undone. ``` USAGE - $ shopify theme delete [--auth-alias ] [-d] [-e ...] [-f] [--json-schema] [--no-color] + $ shopify theme delete [--auth-alias ] [-d] [-e ...] [-f] [-j] [--json-schema] [--no-color] [--no-input] [--password ] [--path ] [-a] [-s ] [-t ...] [--verbose] FLAGS @@ -10465,6 +10465,10 @@ FLAGS Skip confirmation. Required if non interactive. [env: SHOPIFY_FLAG_FORCE] + -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). @@ -10512,6 +10516,346 @@ DESCRIPTION You're asked to confirm that you want to delete the specified themes before they are deleted. You can skip this confirmation using the `--force` flag. + + Use `--json-schema` to print the result, error, and event schemas. + + Output from `--json` conforms to the `ThemeDeleteResult` schema. + + ```json + { + "anyOf": [ + { + "$ref": "#/definitions/ThemeEnvironment/anyOf/0/properties/result" + }, + { + "type": "object", + "properties": { + "environments": { + "type": "array", + "items": { + "$ref": "#/definitions/ThemeEnvironment" + } + } + }, + "required": [ + "environments" + ], + "additionalProperties": false + } + ], + "title": "ThemeDeleteResult", + "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." + }, + "storeDomain": { + "anyOf": [ + { + "type": "string", + "pattern": "^[a-z0-9][a-z0-9-]*\\.myshopify\\.com$" + }, + { + "type": "null" + } + ] + }, + "processing": { + "type": "boolean" + }, + "sourceUrl": { + "anyOf": [ + { + "type": "string", + "format": "uri" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "id", + "name", + "role", + "storeDomain", + "processing", + "sourceUrl" + ], + "additionalProperties": false, + "description": "The selected theme projection; sourceUrl is null when the upstream source URL is unavailable." + }, + "ThemeEnvironment": { + "anyOf": [ + { + "type": "object", + "properties": { + "environment": { + "type": "string" + }, + "result": { + "anyOf": [ + { + "type": "object", + "properties": { + "status": { + "type": "string", + "enum": [ + "success", + "partial" + ] + }, + "changed": { + "type": "boolean" + }, + "themes": { + "type": "array", + "items": { + "$ref": "#/definitions/Theme" + } + }, + "errors": { + "type": "array", + "items": { + "type": "object", + "properties": { + "themeId": { + "type": "string", + "pattern": "^\\d+$" + }, + "error": { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "abort" + }, + "message": { + "type": "string" + } + }, + "required": [ + "type", + "message" + ], + "additionalProperties": false + } + }, + "required": [ + "themeId", + "error" + ], + "additionalProperties": false + } + } + }, + "required": [ + "status", + "changed", + "themes" + ], + "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#" + } + ``` ``` ## `shopify theme dev` diff --git a/packages/cli/oclif.manifest.json b/packages/cli/oclif.manifest.json index 2e551f8814e..ce13e882d94 100644 --- a/packages/cli/oclif.manifest.json +++ b/packages/cli/oclif.manifest.json @@ -10641,7 +10641,7 @@ "args": { }, "customPluginName": "@shopify/theme", - "description": "Deletes a theme from your store.\n\n You can specify multiple themes by ID. If no theme is specified, then you're prompted to select the theme that you want to delete from the list of themes in your store.\n\n You're asked to confirm that you want to delete the specified themes before they are deleted. You can skip this confirmation using the `--force` flag.", + "description": "Deletes a theme from your store.\n\n You can specify multiple themes by ID. If no theme is specified, then you're prompted to select the theme that you want to delete from the list of themes in your store.\n\n You're asked to confirm that you want to delete the specified themes before they are deleted. You can skip this confirmation using the `--force` flag.\n\nUse `--json-schema` to print the result, error, and event schemas.\n\nOutput from `--json` conforms to the `ThemeDeleteResult` 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\": \"ThemeDeleteResult\",\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 \"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 \"processing\": {\n \"type\": \"boolean\"\n },\n \"sourceUrl\": {\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 \"processing\",\n \"sourceUrl\"\n ],\n \"additionalProperties\": false,\n \"description\": \"The selected theme projection; sourceUrl is null when the upstream source URL is unavailable.\"\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 \"enum\": [\n \"success\",\n \"partial\"\n ]\n },\n \"changed\": {\n \"type\": \"boolean\"\n },\n \"themes\": {\n \"type\": \"array\",\n \"items\": {\n \"$ref\": \"#/definitions/Theme\"\n }\n },\n \"errors\": {\n \"type\": \"array\",\n \"items\": {\n \"type\": \"object\",\n \"properties\": {\n \"themeId\": {\n \"type\": \"string\",\n \"pattern\": \"^\\\\d+$\"\n },\n \"error\": {\n \"type\": \"object\",\n \"properties\": {\n \"type\": {\n \"type\": \"string\",\n \"const\": \"abort\"\n },\n \"message\": {\n \"type\": \"string\"\n }\n },\n \"required\": [\n \"type\",\n \"message\"\n ],\n \"additionalProperties\": false\n }\n },\n \"required\": [\n \"themeId\",\n \"error\"\n ],\n \"additionalProperties\": false\n }\n }\n },\n \"required\": [\n \"status\",\n \"changed\",\n \"themes\"\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": "Deletes a theme from your store.\n\n You can specify multiple themes by ID. If no theme is specified, then you're prompted to select the theme that you want to delete from the list of themes in your store.\n\n You're asked to confirm that you want to delete the specified themes before they are deleted. You can skip this confirmation using the `--force` flag.", "enableJsonFlag": false, "flags": { @@ -10678,6 +10678,15 @@ "name": "force", "type": "boolean" }, + "json": { + "allowNo": false, + "char": "j", + "description": "Output the result as JSON. Automatically disables color output.", + "env": "SHOPIFY_FLAG_JSON", + "hidden": false, + "name": "json", + "type": "boolean" + }, "json-schema": { "allowNo": false, "description": "Print the command's JSON schemas.", 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 13ae2d305e4..7da922919b7 100644 --- a/packages/eslint-plugin-cli/rules/json-output-command-exceptions.js +++ b/packages/eslint-plugin-cli/rules/json-output-command-exceptions.js @@ -26,7 +26,6 @@ const commandExceptions = [ 'packages/plugin-did-you-mean/src/commands/config/autocorrect/on.ts', '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/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/delete.test.ts b/packages/theme/src/cli/commands/theme/delete.test.ts new file mode 100644 index 00000000000..631e0330098 --- /dev/null +++ b/packages/theme/src/cli/commands/theme/delete.test.ts @@ -0,0 +1,234 @@ +import Delete from './delete.js' +import {themeDeleteJsonOutputSchema} from '../../services/delete/types.js' +import {findThemes} from '../../utilities/theme-selector.js' +import {Config} from '@oclif/core' +import {ensureAuthenticatedThemes} from '@shopify/cli-kit/node/session' +import {themeDelete} from '@shopify/cli-kit/node/themes/api' +import {withCapturedStandardStreams} from '@shopify/cli-kit/node/testing/output' +import {runWithCommandEventsForCommand} from '@shopify/cli-kit/node/command-events' +import {inTemporaryDirectory, writeFile} from '@shopify/cli-kit/node/fs' +import {joinPath} from '@shopify/cli-kit/node/path' +import {outputWarn} from '@shopify/cli-kit/node/output' +import {addSensitiveMetadata} from '@shopify/cli-kit/node/metadata' +import {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/metadata') +vi.mock('../../utilities/theme-selector.js') +vi.mock('../../utilities/theme-store.js', () => ({ensureThemeStore: ({store}: {store: string}) => store})) + +const theme = {id: 1, name: 'Original', role: 'unpublished', processing: false, createdAtRuntime: false} +const store = 'test.myshopify.com' + +async function run(argv: string[]) { + const config = new Config({root: __dirname}) + await config.load() + vi.mocked(ensureAuthenticatedThemes).mockImplementation(async (storeFqdn) => ({token: 'token', storeFqdn})) + return runWithCommandEventsForCommand(argv, () => new Delete(argv, config).run()) +} + +async function inEnvironments(run: () => Promise) { + await inTemporaryDirectory(async (directory) => { + await writeFile( + joinPath(directory, 'shopify.theme.toml'), + ` +[environments.first] +store = "first.myshopify.com" +password = "token" +theme = ["1"] +[environments.second] +store = "second.myshopify.com" +password = "token" +theme = ["2"] +[environments.third] +store = "first.myshopify.com" +password = "token" +theme = ["3"] +[environments.invalid] +store = "invalid.myshopify.com" +`, + ) + const cwd = vi.spyOn(process, 'cwd').mockReturnValue(directory) + try { + await run() + } finally { + cwd.mockRestore() + } + }) +} + +function restoreExitCode(value: typeof process.exitCode): void { + process.exitCode = value +} + +describe('theme delete JSON output', () => { + test('exposes its schema and JSON flag in help', () => { + expect(Delete.jsonOutputSchema).toBe(themeDeleteJsonOutputSchema) + expect(Delete.flags.json).toBeDefined() + expect(Delete.description).toContain('ThemeDeleteResult') + }) + + test.each([ + {themes: []}, + {themes: [theme]}, + {themes: [theme, {...theme, id: 2, src: 'https://example.com/theme.zip'}]}, + ])('writes one complete result without terminal output %#', async ({themes}) => { + vi.mocked(findThemes).mockResolvedValue(themes) + await withCapturedStandardStreams(async ({stdout, stderr}) => { + await run(['--store', store, '--theme', '1', '--force', '--json']) + expect(JSON.parse(stdout())).toEqual({ + status: 'success', + changed: themes.length > 0, + themes: themes.map((theme) => ({ + id: String(theme.id), + name: theme.name, + role: theme.role, + processing: theme.processing, + sourceUrl: ('src' in theme ? theme.src : undefined) ?? null, + storeDomain: store, + })), + }) + expect(stderr()).toBe('') + }) + }) + + test('writes diagnostics to stderr', async () => { + vi.mocked(findThemes).mockResolvedValue([theme]) + vi.mocked(themeDelete).mockImplementation(async () => { + outputWarn('Retrying request') + return true + }) + await withCapturedStandardStreams(async ({stdout, stderr}) => { + await run(['--store', store, '--theme', '1', '--force', '--json']) + expect(JSON.parse(stdout())).toHaveProperty('themes') + expect(JSON.parse(stderr())).toMatchObject({type: 'diagnostic', level: 'warning', message: 'Retrying request'}) + }) + }) + + test.each([false, true])('preserves partial deletions and rejects total failure (partial: %s)', async (partial) => { + const originalExitCode = process.exitCode + vi.mocked(findThemes).mockResolvedValue(partial ? [theme, {...theme, id: 2}] : [theme]) + vi.mocked(themeDelete).mockImplementation(async (id) => { + if (id === 1) throw new Error('Deletion failed') + return true + }) + try { + await withCapturedStandardStreams(async ({stdout}) => { + const argv = ['--store', store, '--theme', '1', '--force', '--json'] + if (!partial) { + await expect(run(argv)).rejects.toThrow('Deletion failed') + expect(stdout()).toBe('') + return + } + await run(argv) + expect(JSON.parse(stdout())).toEqual({ + status: 'partial', + changed: true, + themes: [ + {id: '2', name: theme.name, role: theme.role, storeDomain: store, processing: false, sourceUrl: null}, + ], + errors: [{themeId: '1', error: {type: 'abort', message: 'Deletion failed'}}], + }) + expect(process.exitCode).toBe(1) + }) + } finally { + restoreExitCode(originalExitCode) + } + }) + + test('collects results in configured order and sequences mutations for the same store', async () => { + const deleted: number[] = [] + vi.mocked(findThemes).mockImplementation(async (_session, options) => [{...theme, id: Number(options.themes?.[0])}]) + vi.mocked(themeDelete).mockImplementation(async (id) => { + if (id === 1) await new Promise((resolve) => setTimeout(resolve, 20)) + deleted.push(id) + return true + }) + await inEnvironments(async () => { + await withCapturedStandardStreams(async ({stdout, stderr}) => { + await run([ + '--show-all', + '--environment', + 'first', + '--environment', + 'third', + '--environment', + 'second', + '--force', + '--json', + ]) + const result = JSON.parse(stdout()) + expect(result).not.toHaveProperty('status') + expect(result.environments.every(({result}: {result: {status: string}}) => result.status === 'success')).toBe( + true, + ) + expect(result.environments.map(({environment}: {environment: string}) => environment)).toEqual([ + 'first', + 'third', + 'second', + ]) + expect(result.environments.map(({result}: {result: {themes: {id: string}[]}}) => result.themes[0]?.id)).toEqual( + ['1', '3', '2'], + ) + expect(deleted).toEqual([2, 1, 3]) + expect(stderr()).toBe('') + expect(addSensitiveMetadata).toHaveBeenCalled() + }) + }) + }) + + test.each([false, true])('preserves failed environments and exits nonzero (all fail: %s)', async (allFail) => { + vi.mocked(findThemes).mockImplementation(async (_session, options) => [{...theme, id: Number(options.themes?.[0])}]) + vi.mocked(themeDelete).mockImplementation(async (id) => { + if (allFail || id === 1) throw new Error('Deletion failed') + return true + }) + const exitCode = process.exitCode + await inEnvironments(async () => { + await withCapturedStandardStreams(async ({stdout, stderr}) => { + await run(['--show-all', '--environment', 'first', '--environment', 'second', '--force', '--json']) + expect(JSON.parse(stdout()).environments.map(({environment}: {environment: string}) => environment)).toEqual([ + 'first', + 'second', + ]) + const events = stderr() + .trim() + .split('\n') + .map((line) => JSON.parse(line)) + expect(events).toHaveLength(allFail ? 2 : 1) + expect(events[0]).toMatchObject({type: 'diagnostic', level: 'error', code: 'theme-environment-failed'}) + expect(process.exitCode).toBe(1) + restoreExitCode(exitCode) + }) + }) + }) + + test('preserves invalid environments and emits a typed warning', async () => { + vi.mocked(findThemes).mockResolvedValue([theme]) + await inEnvironments(async () => { + await withCapturedStandardStreams(async ({stdout, stderr}) => { + await run(['--show-all', '--environment', 'invalid', '--environment', 'first', '--force', '--json']) + expect(JSON.parse(stdout()).environments.map(({environment}: {environment: string}) => environment)).toEqual([ + 'invalid', + 'first', + ]) + restoreExitCode(undefined) + expect(JSON.parse(stderr())).toMatchObject({ + type: 'diagnostic', + level: 'warning', + code: 'theme-environment-invalid', + }) + }) + }) + }) + + test.each([ + {status: 'success', themes: [{...theme, id: '1', shop: store}]}, + {status: 'success', themes: [{...theme, processing: null, shop: store}]}, + {status: 'success', themes: [{...theme, shop: 1}]}, + {environments: [{environment: 1, result: {status: 'success', themes: []}}]}, + ])('rejects malformed results %#', (result) => { + expect(() => themeDeleteJsonOutputSchema.validate(result)).toThrow() + }) +}) diff --git a/packages/theme/src/cli/commands/theme/delete.ts b/packages/theme/src/cli/commands/theme/delete.ts index cbf2730178f..f5962eba936 100644 --- a/packages/theme/src/cli/commands/theme/delete.ts +++ b/packages/theme/src/cli/commands/theme/delete.ts @@ -1,14 +1,21 @@ +import {themeDeleteJsonOutputSchema} from '../../services/delete/types.js' +import {renderThemeDeleteResult} from '../../services/delete/result.js' import {themesDelete} from '../../services/delete.js' import {themeFlags} from '../../flags.js' import ThemeCommand from '../../utilities/theme-command.js' +import {outputResult} from '@shopify/cli-kit/node/output' import {Flags} from '@oclif/core' -import {globalFlags, requiredIfNonInteractive} from '@shopify/cli-kit/node/cli' +import {globalFlags, jsonFlag, requiredIfNonInteractive} from '@shopify/cli-kit/node/cli' import {OutputFlags} from '@oclif/core/interfaces' import {AdminSession} from '@shopify/cli-kit/node/session' import type {NonTTYFlagRequirement} from '@shopify/cli-kit/node/base-command' type DeleteFlags = OutputFlags export default class Delete extends ThemeCommand { + static get jsonOutputSchema() { + return themeDeleteJsonOutputSchema + } + static summary = "Delete remote themes from the connected store. This command can't be undone." static descriptionWithMarkdown = `Deletes a theme from your store. @@ -21,6 +28,7 @@ export default class Delete extends ThemeCommand { static flags = { ...globalFlags, + ...jsonFlag, ...themeFlags, development: Flags.boolean({ char: 'd', @@ -60,16 +68,32 @@ export default class Delete extends ThemeCommand { const {environment, development, force, theme} = flags const themes = theme ?? [] - await themesDelete( + const result = await themesDelete( adminSession, { selectTheme: flags['show-all'], - environment, development, themes, force, }, multiEnvironment, ) + if (!result) { + if (flags.json && !multiEnvironment) outputResult(themeDeleteJsonOutputSchema.encode({status: 'cancelled'})) + return + } + if (result.status === 'partial') process.exitCode = 1 + if (flags.json && multiEnvironment) return result + renderThemeDeleteResult(result, flags.json ? 'json' : 'text', {store: adminSession.storeFqdn, environment}) + } + + protected collectsEnvironmentResults(flags: {json?: boolean}): boolean { + return Boolean(flags.json) + } + + protected renderEnvironmentResults( + environments: import('../../services/json-output/schema.js').ThemeEnvironmentResult[], + ): void { + outputResult(themeDeleteJsonOutputSchema.encode({environments})) } } diff --git a/packages/theme/src/cli/services/delete.test.ts b/packages/theme/src/cli/services/delete.test.ts index 0c17407802c..ac8ccdbe405 100644 --- a/packages/theme/src/cli/services/delete.test.ts +++ b/packages/theme/src/cli/services/delete.test.ts @@ -1,4 +1,5 @@ -import {themesDelete} from './delete.js' +import {themesDelete as executeDelete} from './delete.js' +import {renderThemeDeleteResult} from './delete/result.js' import {findOrSelectTheme, findThemes} from '../utilities/theme-selector.js' import {themeDelete} from '@shopify/cli-kit/node/themes/api' import {Theme} from '@shopify/cli-kit/node/themes/types' @@ -142,3 +143,42 @@ describe('themesDelete', () => { expect(renderSuccess).not.toBeCalled() }) }) + +async function themesDelete(...args: Parameters) { + const result = await executeDelete(...args) + if (result) renderThemeDeleteResult(result, 'text', {store: args[0].storeFqdn}) + return result +} + +test('returns deletion data without presenting it', async () => { + vi.mocked(findThemes).mockResolvedValue([theme1, theme2]) + const result = await executeDelete(session, {...options, themes: ['1', '2'], force: true}) + expect(result).toEqual({ + status: 'success', + themes: [theme1, theme2].map((theme) => ({...theme, shop: session.storeFqdn})), + }) + expect(renderSuccess).not.toHaveBeenCalled() +}) + +test('does not prompt again for multiple environments', async () => { + vi.mocked(findThemes).mockResolvedValue([theme1]) + await executeDelete(session, {...options, themes: ['1']}, true) + expect(renderConfirmationPrompt).not.toHaveBeenCalled() + expect(themeDelete).toHaveBeenCalledWith(1, session) +}) + +test('preserves the environment label in terminal output', () => { + renderThemeDeleteResult({status: 'success', themes: [{...theme1, shop: session.storeFqdn}]}, 'text', { + store: session.storeFqdn, + environment: ['staging'], + }) + expect(renderSuccess).toHaveBeenCalledWith({ + body: [ + {subdued: 'Environment: staging\n\n'}, + 'The theme', + "'my theme'", + {subdued: '(#1)'}, + 'was deleted from my-shop.myshopify.com.', + ], + }) +}) diff --git a/packages/theme/src/cli/services/delete.ts b/packages/theme/src/cli/services/delete.ts index dc86cb21e61..de41e074c14 100644 --- a/packages/theme/src/cli/services/delete.ts +++ b/packages/theme/src/cli/services/delete.ts @@ -1,29 +1,33 @@ +import {ThemeDeleteResult} from './delete/types.js' import {removeDevelopmentTheme} from './local-storage.js' import {DevelopmentThemeManager} from '../utilities/development-theme-manager.js' -import {themeComponent, themesComponent} from '../utilities/theme-ui.js' +import {themeComponent} from '../utilities/theme-ui.js' import {findOrSelectTheme, findThemes} from '../utilities/theme-selector.js' import {themeDelete} from '@shopify/cli-kit/node/themes/api' import {AdminSession} from '@shopify/cli-kit/node/session' import { renderConfirmationPrompt, RenderConfirmationPromptOptions, - renderSuccess, InlineToken, LinkToken, } from '@shopify/cli-kit/node/ui' import {pluralize} from '@shopify/cli-kit/common/string' import {Theme} from '@shopify/cli-kit/node/themes/types' import {isDevelopmentTheme} from '@shopify/cli-kit/node/themes/utils' +import {AbortError} from '@shopify/cli-kit/node/error' interface DeleteOptions { selectTheme: boolean - environment?: string[] development: boolean force: boolean themes: string[] } -export async function themesDelete(adminSession: AdminSession, options: DeleteOptions, multiEnvironment?: boolean) { +export async function themesDelete( + adminSession: AdminSession, + options: DeleteOptions, + multiEnvironment?: boolean, +): Promise { let themeIds = options.themes if (options.development) { const theme = await new DevelopmentThemeManager(adminSession).find() @@ -37,24 +41,29 @@ export async function themesDelete(adminSession: AdminSession, options: DeleteOp return } - await Promise.all( - themes.map((theme) => { - if (isDevelopmentTheme(theme)) { - removeDevelopmentTheme() - } - return themeDelete(theme.id, adminSession) + const deletions = await Promise.allSettled( + themes.map(async (theme) => { + await themeDelete(theme.id, adminSession) + if (isDevelopmentTheme(theme)) removeDevelopmentTheme() + return {...theme, shop: store} }), ) - - const environment = options.environment ? [{subdued: `Environment: ${options.environment}\n\n`}] : [] - - renderSuccess({ - body: pluralize( - themes, - (themes) => [...environment, `The following themes were deleted from ${store}:`, themesComponent(themes)], - (theme) => [...environment, 'The theme', ...themeComponent(theme), `was deleted from ${store}.`], - ), + const deletedThemes = deletions.flatMap((deletion) => (deletion.status === 'fulfilled' ? [deletion.value] : [])) + const errors = deletions.flatMap((deletion, index) => { + if (deletion.status === 'fulfilled') return [] + const error: unknown = deletion.reason + return [{themeId: themes[index]!.id, message: error instanceof Error ? error.message : String(error)}] }) + if (errors.length > 0 && deletedThemes.length === 0) { + const error = new AbortError(errors[0]!.message) + error.details = {errors: errors.map(({themeId, message}) => ({themeId: String(themeId), message}))} + throw error + } + return { + status: errors.length > 0 ? 'partial' : 'success', + themes: deletedThemes, + ...(errors.length > 0 ? {errors} : {}), + } } async function findThemesByDeleteOptions(adminSession: AdminSession, options: DeleteOptions) { diff --git a/packages/theme/src/cli/services/delete/result.ts b/packages/theme/src/cli/services/delete/result.ts new file mode 100644 index 00000000000..d66a54c75b7 --- /dev/null +++ b/packages/theme/src/cli/services/delete/result.ts @@ -0,0 +1,26 @@ +import {themeDeleteJsonOutputSchema, type ThemeDeleteResult} from './types.js' +import {themeComponent, themesComponent} from '../../utilities/theme-ui.js' +import {pluralize} from '@shopify/cli-kit/common/string' +import {outputResult} from '@shopify/cli-kit/node/output' +import {renderSuccess, renderWarning} from '@shopify/cli-kit/node/ui' + +export function renderThemeDeleteResult( + result: ThemeDeleteResult, + format: 'text' | 'json', + context: {store: string; environment?: string[]}, +): void { + if (format === 'json') { + outputResult(themeDeleteJsonOutputSchema.encode(result)) + return + } + const environment = context.environment ? [{subdued: `Environment: ${context.environment}\n\n`}] : [] + const render = result.status === 'partial' ? renderWarning : renderSuccess + render({ + body: pluralize( + result.themes, + (themes) => [...environment, `The following themes were deleted from ${context.store}:`, themesComponent(themes)], + (theme) => [...environment, 'The theme', ...themeComponent(theme), `was deleted from ${context.store}.`], + ), + ...(result.errors ? {nextSteps: result.errors.map((error) => [`Theme #${error.themeId}: ${error.message}`])} : {}), + }) +} diff --git a/packages/theme/src/cli/services/delete/types.ts b/packages/theme/src/cli/services/delete/types.ts new file mode 100644 index 00000000000..572efc0cb1f --- /dev/null +++ b/packages/theme/src/cli/services/delete/types.ts @@ -0,0 +1,52 @@ +import { + ThemeMutationThemeSchema, + ThemeMutationJsonThemeSchema, + projectThemeMutationTheme, +} from '../theme-mutation/types.js' +import {defineThemeJsonOutputSchema} from '../json-output/schema.js' +import {zod} from '@shopify/cli-kit/node/schema' + +const ThemeDeleteResultSchema = zod.object({ + status: zod.enum(['success', 'partial']), + themes: zod.array(ThemeMutationThemeSchema), + errors: zod.array(zod.object({themeId: zod.number(), message: zod.string()})).optional(), +}) +export const themeDeleteJsonOutputSchema = defineThemeJsonOutputSchema({ + name: 'ThemeDeleteResult', + schema: zod + .object({ + status: zod.enum(['success', 'partial']), + changed: zod.boolean(), + themes: zod.array(ThemeMutationJsonThemeSchema), + errors: zod + .array( + zod + .object({ + themeId: zod.string().regex(/^\d+$/), + error: zod.object({type: zod.literal('abort'), message: zod.string()}).strict(), + }) + .strict(), + ) + .optional(), + }) + .strict(), + definitions: {Theme: ThemeMutationJsonThemeSchema}, + project(value) { + const result = ThemeDeleteResultSchema.parse(value) + return { + status: result.status, + changed: result.themes.length > 0, + themes: result.themes.map(projectThemeMutationTheme), + ...(result.errors + ? { + errors: result.errors.map((error) => ({ + themeId: String(error.themeId), + error: {type: 'abort', message: error.message}, + })), + } + : {}), + } + }, +}) + +export type ThemeDeleteResult = zod.infer diff --git a/packages/theme/src/cli/services/theme-mutation/types.ts b/packages/theme/src/cli/services/theme-mutation/types.ts new file mode 100644 index 00000000000..1504fad746c --- /dev/null +++ b/packages/theme/src/cli/services/theme-mutation/types.ts @@ -0,0 +1,29 @@ +import {ThemeSchema, StoreDomainSchema, projectTheme, storeDomain} from '../json-output/schema.js' +import {zod} from '@shopify/cli-kit/node/schema' + +export const ThemeMutationThemeSchema = zod.object({ + id: zod.number(), + name: zod.string(), + role: zod.string(), + processing: zod.boolean(), + createdAtRuntime: zod.boolean(), + src: zod.string().optional(), + shop: zod.string(), +}) + +export const ThemeMutationJsonThemeSchema = ThemeSchema.extend({ + storeDomain: StoreDomainSchema, + processing: zod.boolean(), + sourceUrl: zod.string().url().nullable(), +}) + .strict() + .describe('The selected theme projection; sourceUrl is null when the upstream source URL is unavailable.') + +export function projectThemeMutationTheme(theme: zod.infer) { + return { + ...projectTheme(theme), + storeDomain: storeDomain(theme.shop), + processing: theme.processing, + sourceUrl: theme.src === '' ? null : (theme.src ?? null), + } +}