From f6653009016a9c9243bafe7c1da0250b86d59f3f Mon Sep 17 00:00:00 2001 From: Gonzalo Riestra Date: Thu, 24 Sep 2026 11:42:41 +0200 Subject: [PATCH] Add typed JSON output to theme delete --- .../generated/generated_docs_data_v2.json | 11 +- packages/cli/README.md | 109 +++++++++- packages/cli/oclif.manifest.json | 11 +- .../rules/json-output-command-exceptions.js | 1 - .../src/cli/commands/theme/delete.test.ts | 200 ++++++++++++++++++ .../theme/src/cli/commands/theme/delete.ts | 24 ++- .../theme/src/cli/services/delete.test.ts | 42 +++- packages/theme/src/cli/services/delete.ts | 21 +- .../theme/src/cli/services/delete/result.ts | 24 +++ .../theme/src/cli/services/delete/types.ts | 15 ++ .../src/cli/services/theme-mutation/types.ts | 11 + 11 files changed, 447 insertions(+), 22 deletions(-) create mode 100644 packages/theme/src/cli/commands/theme/delete.test.ts create mode 100644 packages/theme/src/cli/services/delete/result.ts create mode 100644 packages/theme/src/cli/services/delete/types.ts create mode 100644 packages/theme/src/cli/services/theme-mutation/types.ts diff --git a/docs-shopify.dev/generated/generated_docs_data_v2.json b/docs-shopify.dev/generated/generated_docs_data_v2.json index eddad510b82..0dc71b93d69 100644 --- a/docs-shopify.dev/generated/generated_docs_data_v2.json +++ b/docs-shopify.dev/generated/generated_docs_data_v2.json @@ -7290,6 +7290,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", @@ -7309,7 +7318,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 * 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 * 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 eee7ae49794..081e8bbb300 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -7648,8 +7648,8 @@ 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] [--password - ] [--path ] [-a] [-s ] [-t ...] [--verbose] + $ shopify theme delete [--auth-alias ] [-d] [-e ...] [-f] [-j] [--json-schema] [--no-color] + [--password ] [--path ] [-a] [-s ] [-t ...] [--verbose] FLAGS -a, --show-all @@ -7669,6 +7669,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). @@ -7712,6 +7716,107 @@ 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/ThemeDeleteEnvironment/properties/result" + }, + { + "type": "object", + "properties": { + "environments": { + "type": "array", + "items": { + "$ref": "#/definitions/ThemeDeleteEnvironment" + } + } + }, + "required": [ + "environments" + ], + "additionalProperties": false + } + ], + "title": "ThemeDeleteResult", + "definitions": { + "DeletedTheme": { + "type": "object", + "properties": { + "id": { + "type": "number" + }, + "name": { + "type": "string" + }, + "role": { + "type": "string" + }, + "processing": { + "type": "boolean" + }, + "createdAtRuntime": { + "type": "boolean" + }, + "src": { + "type": "string" + }, + "shop": { + "type": "string" + } + }, + "required": [ + "id", + "name", + "role", + "processing", + "createdAtRuntime", + "shop" + ], + "additionalProperties": false + }, + "ThemeDeleteEnvironment": { + "type": "object", + "properties": { + "environment": { + "type": "string" + }, + "result": { + "type": "object", + "properties": { + "status": { + "type": "string", + "const": "success" + }, + "themes": { + "type": "array", + "items": { + "$ref": "#/definitions/DeletedTheme" + } + } + }, + "required": [ + "status", + "themes" + ], + "additionalProperties": false + } + }, + "required": [ + "environment", + "result" + ], + "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 10eec728c0b..47d0e1d82a1 100644 --- a/packages/cli/oclif.manifest.json +++ b/packages/cli/oclif.manifest.json @@ -9626,7 +9626,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/ThemeDeleteEnvironment/properties/result\"\n },\n {\n \"type\": \"object\",\n \"properties\": {\n \"environments\": {\n \"type\": \"array\",\n \"items\": {\n \"$ref\": \"#/definitions/ThemeDeleteEnvironment\"\n }\n }\n },\n \"required\": [\n \"environments\"\n ],\n \"additionalProperties\": false\n }\n ],\n \"title\": \"ThemeDeleteResult\",\n \"definitions\": {\n \"DeletedTheme\": {\n \"type\": \"object\",\n \"properties\": {\n \"id\": {\n \"type\": \"number\"\n },\n \"name\": {\n \"type\": \"string\"\n },\n \"role\": {\n \"type\": \"string\"\n },\n \"processing\": {\n \"type\": \"boolean\"\n },\n \"createdAtRuntime\": {\n \"type\": \"boolean\"\n },\n \"src\": {\n \"type\": \"string\"\n },\n \"shop\": {\n \"type\": \"string\"\n }\n },\n \"required\": [\n \"id\",\n \"name\",\n \"role\",\n \"processing\",\n \"createdAtRuntime\",\n \"shop\"\n ],\n \"additionalProperties\": false\n },\n \"ThemeDeleteEnvironment\": {\n \"type\": \"object\",\n \"properties\": {\n \"environment\": {\n \"type\": \"string\"\n },\n \"result\": {\n \"type\": \"object\",\n \"properties\": {\n \"status\": {\n \"type\": \"string\",\n \"const\": \"success\"\n },\n \"themes\": {\n \"type\": \"array\",\n \"items\": {\n \"$ref\": \"#/definitions/DeletedTheme\"\n }\n }\n },\n \"required\": [\n \"status\",\n \"themes\"\n ],\n \"additionalProperties\": false\n }\n },\n \"required\": [\n \"environment\",\n \"result\"\n ],\n \"additionalProperties\": false\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": { @@ -9663,6 +9663,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 c3a3b57cae6..0a32bbd2d92 100644 --- a/packages/eslint-plugin-cli/rules/json-output-command-exceptions.js +++ b/packages/eslint-plugin-cli/rules/json-output-command-exceptions.js @@ -44,7 +44,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..d40da3d56ba --- /dev/null +++ b/packages/theme/src/cli/commands/theme/delete.test.ts @@ -0,0 +1,200 @@ +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() + } + }) +} + +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', + themes: themes.map((theme) => ({...theme, shop: 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])('does not emit success when any deletion fails (partial: %s)', async (partial) => { + vi.mocked(findThemes).mockResolvedValue([theme]) + 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 + }) + await withCapturedStandardStreams(async ({stdout}) => { + await expect(run(['--store', store, '--theme', '1', '--force', '--json'])).rejects.toThrow('Deletion failed') + expect(stdout()).toBe('') + }) + }) + + 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: number}[]}}) => result.themes[0]?.id)).toEqual( + [1, 3, 2], + ) + expect(deleted).toEqual([2, 1, 3]) + expect(stderr()).toBe('') + expect(addSensitiveMetadata).toHaveBeenCalled() + }) + }) + }) + + test.each([false, true])('omits failed environments and preserves exit behavior (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( + allFail ? [] : ['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(exitCode) + }) + }) + }) + + test('omits 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([ + 'first', + ]) + 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..1c5f1fe042c 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,26 @@ 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) return + 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: {environment: string; result: unknown}[]): void { + outputResult(themeDeleteJsonOutputSchema.encode(themeDeleteJsonOutputSchema.validate({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..4de5388c1ff 100644 --- a/packages/theme/src/cli/services/delete.ts +++ b/packages/theme/src/cli/services/delete.ts @@ -1,13 +1,13 @@ +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' @@ -17,13 +17,16 @@ import {isDevelopmentTheme} from '@shopify/cli-kit/node/themes/utils' 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() @@ -46,15 +49,7 @@ export async function themesDelete(adminSession: AdminSession, options: DeleteOp }), ) - 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}.`], - ), - }) + return {status: 'success', themes: themes.map((theme) => ({...theme, shop: store}))} } 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..6a9eea56e26 --- /dev/null +++ b/packages/theme/src/cli/services/delete/result.ts @@ -0,0 +1,24 @@ +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} 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`}] : [] + renderSuccess({ + 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}.`], + ), + }) +} 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..e14edd10395 --- /dev/null +++ b/packages/theme/src/cli/services/delete/types.ts @@ -0,0 +1,15 @@ +import {ThemeMutationSuccessSchema} from '../theme-mutation/status.js' +import {ThemeMutationThemeSchema} from '../theme-mutation/types.js' +import {defineJsonOutputSchema} from '@shopify/cli-kit/node/json-output-schema' +import {zod} from '@shopify/cli-kit/node/schema' + +const ThemeDeleteResultSchema = ThemeMutationSuccessSchema.extend({themes: zod.array(ThemeMutationThemeSchema)}) +const ThemeDeleteEnvironmentSchema = zod.object({environment: zod.string(), result: ThemeDeleteResultSchema}) + +export const themeDeleteJsonOutputSchema = defineJsonOutputSchema({ + name: 'ThemeDeleteResult', + schema: zod.union([ThemeDeleteResultSchema, zod.object({environments: zod.array(ThemeDeleteEnvironmentSchema)})]), + definitions: {DeletedTheme: ThemeMutationThemeSchema, ThemeDeleteEnvironment: ThemeDeleteEnvironmentSchema}, +}) + +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..323db14fe50 --- /dev/null +++ b/packages/theme/src/cli/services/theme-mutation/types.ts @@ -0,0 +1,11 @@ +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(), +})