From e092ae9221060913eedefaaa4f02fcbbafebe8c5 Mon Sep 17 00:00:00 2001 From: Gonzalo Riestra Date: Thu, 24 Sep 2026 11:42:41 +0200 Subject: [PATCH 1/8] Add typed JSON output to theme delete --- 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 + 9 files changed, 330 insertions(+), 19 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/packages/cli/oclif.manifest.json b/packages/cli/oclif.manifest.json index 377ef11fe1a..793c6059742 100644 --- a/packages/cli/oclif.manifest.json +++ b/packages/cli/oclif.manifest.json @@ -10799,7 +10799,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": { @@ -10836,6 +10836,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 dd090c49bb0..f8639f9394c 100644 --- a/packages/eslint-plugin-cli/rules/json-output-command-exceptions.js +++ b/packages/eslint-plugin-cli/rules/json-output-command-exceptions.js @@ -16,7 +16,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(), +}) From 45282e3f07b7843f4bf9da3c20f4b51680934dfd Mon Sep 17 00:00:00 2001 From: Gonzalo Riestra Date: Tue, 6 Oct 2026 14:04:13 +0200 Subject: [PATCH 2/8] Preserve deleted themes in partial JSON operation results --- packages/cli/README.md | 333 +++++++++++++++++- packages/cli/oclif.manifest.json | 2 +- .../src/cli/commands/theme/delete.test.ts | 64 +++- .../theme/src/cli/commands/theme/delete.ts | 12 +- packages/theme/src/cli/services/delete.ts | 30 +- .../theme/src/cli/services/delete/result.ts | 6 +- .../theme/src/cli/services/delete/types.ts | 55 ++- .../src/cli/services/theme-mutation/types.ts | 18 + 8 files changed, 481 insertions(+), 39 deletions(-) diff --git a/packages/cli/README.md b/packages/cli/README.md index df1561903a3..05486a541ae 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -11710,7 +11710,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 @@ -11731,6 +11731,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). @@ -11778,6 +11782,333 @@ 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 main, 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" + }, + "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": {} + }, + "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" + }, + "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" + }, + "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 793c6059742..0020ffd84cd 100644 --- a/packages/cli/oclif.manifest.json +++ b/packages/cli/oclif.manifest.json @@ -10799,7 +10799,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.\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```", + "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 main, 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 \"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 },\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 \"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 \"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": { diff --git a/packages/theme/src/cli/commands/theme/delete.test.ts b/packages/theme/src/cli/commands/theme/delete.test.ts index d40da3d56ba..a85ec450a22 100644 --- a/packages/theme/src/cli/commands/theme/delete.test.ts +++ b/packages/theme/src/cli/commands/theme/delete.test.ts @@ -58,6 +58,10 @@ store = "invalid.myshopify.com" }) } +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) @@ -75,7 +79,15 @@ describe('theme delete JSON output', () => { await run(['--store', store, '--theme', '1', '--force', '--json']) expect(JSON.parse(stdout())).toEqual({ status: 'success', - themes: themes.map((theme) => ({...theme, shop: store})), + changed: themes.length > 0, + themes: themes.map((theme) => ({ + id: String(theme.id), + name: theme.name, + role: theme.role, + processing: theme.processing, + sourceUrl: theme.src ?? null, + storeDomain: store, + })), }) expect(stderr()).toBe('') }) @@ -94,17 +106,35 @@ describe('theme delete JSON output', () => { }) }) - test.each([false, true])('does not emit success when any deletion fails (partial: %s)', async (partial) => { - vi.mocked(findThemes).mockResolvedValue([theme]) + 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 }) - await withCapturedStandardStreams(async ({stdout}) => { - await expect(run(['--store', store, '--theme', '1', '--force', '--json'])).rejects.toThrow('Deletion failed') - expect(stdout()).toBe('') - }) + 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 () => { @@ -138,8 +168,8 @@ describe('theme delete JSON output', () => { 'third', 'second', ]) - expect(result.environments.map(({result}: {result: {themes: {id: number}[]}}) => result.themes[0]?.id)).toEqual( - [1, 3, 2], + 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('') @@ -148,7 +178,7 @@ describe('theme delete JSON output', () => { }) }) - test.each([false, true])('omits failed environments and preserves exit behavior (all fail: %s)', async (allFail) => { + 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') @@ -158,28 +188,32 @@ describe('theme delete JSON output', () => { 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'], - ) + 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(exitCode) + expect(process.exitCode).toBe(1) + restoreExitCode(exitCode) }) }) }) - test('omits invalid environments and emits a typed warning', async () => { + 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', diff --git a/packages/theme/src/cli/commands/theme/delete.ts b/packages/theme/src/cli/commands/theme/delete.ts index 1c5f1fe042c..f5962eba936 100644 --- a/packages/theme/src/cli/commands/theme/delete.ts +++ b/packages/theme/src/cli/commands/theme/delete.ts @@ -78,7 +78,11 @@ export default class Delete extends ThemeCommand { }, multiEnvironment, ) - if (!result) return + 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}) } @@ -87,7 +91,9 @@ export default class Delete extends ThemeCommand { return Boolean(flags.json) } - protected renderEnvironmentResults(environments: {environment: string; result: unknown}[]): void { - outputResult(themeDeleteJsonOutputSchema.encode(themeDeleteJsonOutputSchema.validate({environments}))) + protected renderEnvironmentResults( + environments: import('../../services/json-output/schema.js').ThemeEnvironmentResult[], + ): void { + outputResult(themeDeleteJsonOutputSchema.encode({environments})) } } diff --git a/packages/theme/src/cli/services/delete.ts b/packages/theme/src/cli/services/delete.ts index 4de5388c1ff..de41e074c14 100644 --- a/packages/theme/src/cli/services/delete.ts +++ b/packages/theme/src/cli/services/delete.ts @@ -14,6 +14,7 @@ import { 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 @@ -40,16 +41,29 @@ export async function themesDelete( 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} }), ) - - return {status: 'success', themes: themes.map((theme) => ({...theme, shop: 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 index 6a9eea56e26..d66a54c75b7 100644 --- a/packages/theme/src/cli/services/delete/result.ts +++ b/packages/theme/src/cli/services/delete/result.ts @@ -2,7 +2,7 @@ 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' +import {renderSuccess, renderWarning} from '@shopify/cli-kit/node/ui' export function renderThemeDeleteResult( result: ThemeDeleteResult, @@ -14,11 +14,13 @@ export function renderThemeDeleteResult( return } const environment = context.environment ? [{subdued: `Environment: ${context.environment}\n\n`}] : [] - renderSuccess({ + 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 index e14edd10395..572efc0cb1f 100644 --- a/packages/theme/src/cli/services/delete/types.ts +++ b/packages/theme/src/cli/services/delete/types.ts @@ -1,15 +1,52 @@ -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 { + 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 = ThemeMutationSuccessSchema.extend({themes: zod.array(ThemeMutationThemeSchema)}) -const ThemeDeleteEnvironmentSchema = zod.object({environment: zod.string(), result: ThemeDeleteResultSchema}) - -export const themeDeleteJsonOutputSchema = defineJsonOutputSchema({ +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.union([ThemeDeleteResultSchema, zod.object({environments: zod.array(ThemeDeleteEnvironmentSchema)})]), - definitions: {DeletedTheme: ThemeMutationThemeSchema, ThemeDeleteEnvironment: ThemeDeleteEnvironmentSchema}, + 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 index 323db14fe50..1504fad746c 100644 --- a/packages/theme/src/cli/services/theme-mutation/types.ts +++ b/packages/theme/src/cli/services/theme-mutation/types.ts @@ -1,3 +1,4 @@ +import {ThemeSchema, StoreDomainSchema, projectTheme, storeDomain} from '../json-output/schema.js' import {zod} from '@shopify/cli-kit/node/schema' export const ThemeMutationThemeSchema = zod.object({ @@ -9,3 +10,20 @@ export const ThemeMutationThemeSchema = zod.object({ 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), + } +} From 3a222c0258e40d5e05e5d19d019aa997619cc919 Mon Sep 17 00:00:00 2001 From: Gonzalo Riestra Date: Tue, 6 Oct 2026 14:16:41 +0200 Subject: [PATCH 3/8] Refresh theme delete JSON schema documentation --- packages/cli/README.md | 2 +- packages/cli/oclif.manifest.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/cli/README.md b/packages/cli/README.md index 05486a541ae..2e8fd151b1d 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -11824,7 +11824,7 @@ DESCRIPTION }, "role": { "type": "string", - "description": "The upstream theme role; known values include main, unpublished, and development." + "description": "The upstream theme role; known values include live, unpublished, and development." }, "storeDomain": { "anyOf": [ diff --git a/packages/cli/oclif.manifest.json b/packages/cli/oclif.manifest.json index 0020ffd84cd..b1fda0a6893 100644 --- a/packages/cli/oclif.manifest.json +++ b/packages/cli/oclif.manifest.json @@ -10799,7 +10799,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.\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 main, 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 \"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 },\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 \"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 \"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```", + "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 \"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 },\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 \"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 \"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": { From 53b06e71fb6adfa4e610032999c84da1f79a7909 Mon Sep 17 00:00:00 2001 From: Gonzalo Riestra Date: Tue, 6 Oct 2026 14:17:12 +0200 Subject: [PATCH 4/8] Refresh generated theme delete command docs --- .../generated/generated_docs_data_v2.json | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/docs-shopify.dev/generated/generated_docs_data_v2.json b/docs-shopify.dev/generated/generated_docs_data_v2.json index ae3145a9463..71997a7d9fe 100644 --- a/docs-shopify.dev/generated/generated_docs_data_v2.json +++ b/docs-shopify.dev/generated/generated_docs_data_v2.json @@ -8665,6 +8665,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", @@ -8684,7 +8693,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": { From ed78de0772de0a3f94dc1b748ebba7350603d91b Mon Sep 17 00:00:00 2001 From: Gonzalo Riestra Date: Tue, 6 Oct 2026 15:47:12 +0200 Subject: [PATCH 5/8] Narrow delete fixture union before reading source URL --- packages/theme/src/cli/commands/theme/delete.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/theme/src/cli/commands/theme/delete.test.ts b/packages/theme/src/cli/commands/theme/delete.test.ts index a85ec450a22..631e0330098 100644 --- a/packages/theme/src/cli/commands/theme/delete.test.ts +++ b/packages/theme/src/cli/commands/theme/delete.test.ts @@ -85,7 +85,7 @@ describe('theme delete JSON output', () => { name: theme.name, role: theme.role, processing: theme.processing, - sourceUrl: theme.src ?? null, + sourceUrl: ('src' in theme ? theme.src : undefined) ?? null, storeDomain: store, })), }) From 4dafe47f5c195918f3c9e156541b5225516aaab7 Mon Sep 17 00:00:00 2001 From: Gonzalo Riestra Date: Thu, 8 Oct 2026 10:36:22 +0200 Subject: [PATCH 6/8] Refresh JSON schema documentation after rebase --- packages/cli/README.md | 15 ++++++++++++++- packages/cli/oclif.manifest.json | 2 +- packages/theme/src/cli/commands/theme/delete.ts | 4 ---- 3 files changed, 15 insertions(+), 6 deletions(-) diff --git a/packages/cli/README.md b/packages/cli/README.md index 2e8fd151b1d..85a30c69f42 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -11974,6 +11974,11 @@ DESCRIPTION "message": { "type": "string" }, + "code": { + "type": "string", + "minLength": 1, + "description": "A stable error code, included only when known." + }, "tryMessage": { "type": "string" }, @@ -12014,7 +12019,9 @@ DESCRIPTION "additionalProperties": false } }, - "details": {} + "details": { + "description": "Selected domain details, preserving native API payloads such as GraphQL errors, extensions, and data." + } }, "required": [ "type", @@ -12032,6 +12039,9 @@ DESCRIPTION "message": { "$ref": "#/definitions/ThemeEnvironment/anyOf/1/properties/error/anyOf/0/properties/message" }, + "code": { + "$ref": "#/definitions/ThemeEnvironment/anyOf/1/properties/error/anyOf/0/properties/code" + }, "tryMessage": { "$ref": "#/definitions/ThemeEnvironment/anyOf/1/properties/error/anyOf/0/properties/tryMessage" }, @@ -12064,6 +12074,9 @@ DESCRIPTION "message": { "$ref": "#/definitions/ThemeEnvironment/anyOf/1/properties/error/anyOf/0/properties/message" }, + "code": { + "$ref": "#/definitions/ThemeEnvironment/anyOf/1/properties/error/anyOf/0/properties/code" + }, "tryMessage": { "$ref": "#/definitions/ThemeEnvironment/anyOf/1/properties/error/anyOf/0/properties/tryMessage" }, diff --git a/packages/cli/oclif.manifest.json b/packages/cli/oclif.manifest.json index b1fda0a6893..ac935388674 100644 --- a/packages/cli/oclif.manifest.json +++ b/packages/cli/oclif.manifest.json @@ -10799,7 +10799,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.\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 \"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 },\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 \"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 \"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```", + "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": { diff --git a/packages/theme/src/cli/commands/theme/delete.ts b/packages/theme/src/cli/commands/theme/delete.ts index f5962eba936..3f3c5ad514c 100644 --- a/packages/theme/src/cli/commands/theme/delete.ts +++ b/packages/theme/src/cli/commands/theme/delete.ts @@ -87,10 +87,6 @@ export default class Delete extends ThemeCommand { 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 { From 9c9dd2b61bf7c64a85d432ff0daba53ed895f850 Mon Sep 17 00:00:00 2001 From: Gonzalo Riestra Date: Wed, 14 Oct 2026 10:37:12 +0200 Subject: [PATCH 7/8] Preserve remote theme deletions when local cleanup fails --- .../src/cli/commands/theme/delete.test.ts | 26 ++++++++++++++ .../theme/src/cli/services/delete.test.ts | 34 +++++++++++++++++++ packages/theme/src/cli/services/delete.ts | 14 +++++++- 3 files changed, 73 insertions(+), 1 deletion(-) diff --git a/packages/theme/src/cli/commands/theme/delete.test.ts b/packages/theme/src/cli/commands/theme/delete.test.ts index 631e0330098..29d04514216 100644 --- a/packages/theme/src/cli/commands/theme/delete.test.ts +++ b/packages/theme/src/cli/commands/theme/delete.test.ts @@ -1,6 +1,7 @@ import Delete from './delete.js' import {themeDeleteJsonOutputSchema} from '../../services/delete/types.js' import {findThemes} from '../../utilities/theme-selector.js' +import * as localStorage from '../../services/local-storage.js' import {Config} from '@oclif/core' import {ensureAuthenticatedThemes} from '@shopify/cli-kit/node/session' import {themeDelete} from '@shopify/cli-kit/node/themes/api' @@ -10,6 +11,7 @@ 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 {AbortError} from '@shopify/cli-kit/node/error' import {describe, expect, test, vi} from 'vitest' vi.mock('@shopify/cli-kit/node/session') @@ -106,6 +108,30 @@ describe('theme delete JSON output', () => { }) }) + test('keeps successful JSON output when development theme cleanup fails', async () => { + vi.mocked(findThemes).mockResolvedValue([{...theme, role: 'development'}]) + const cleanup = vi.spyOn(localStorage, 'removeDevelopmentTheme').mockImplementation(() => { + throw new AbortError('Local storage is not writable') + }) + const exitCode = process.exitCode + try { + await withCapturedStandardStreams(async ({stdout, stderr}) => { + await run(['--store', store, '--theme', '1', '--force', '--json']) + + expect(JSON.parse(stdout())).toMatchObject({status: 'success', changed: true, themes: [{id: '1'}]}) + expect(JSON.parse(stderr())).toMatchObject({ + type: 'diagnostic', + level: 'warning', + message: expect.stringContaining('Local storage is not writable'), + }) + expect(process.exitCode).toBe(exitCode) + }) + } finally { + cleanup.mockRestore() + restoreExitCode(exitCode) + } + }) + 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]) diff --git a/packages/theme/src/cli/services/delete.test.ts b/packages/theme/src/cli/services/delete.test.ts index ac8ccdbe405..789bb3e67ca 100644 --- a/packages/theme/src/cli/services/delete.test.ts +++ b/packages/theme/src/cli/services/delete.test.ts @@ -1,13 +1,17 @@ import {themesDelete as executeDelete} from './delete.js' import {renderThemeDeleteResult} from './delete/result.js' +import {removeDevelopmentTheme} from './local-storage.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' import {test, describe, expect, vi} from 'vitest' import {renderConfirmationPrompt, renderSuccess} from '@shopify/cli-kit/node/ui' +import {AbortError} from '@shopify/cli-kit/node/error' +import {mockAndCaptureOutput} from '@shopify/cli-kit/node/testing/output' vi.mock('@shopify/cli-kit/node/ui') vi.mock('@shopify/cli-kit/node/themes/api') +vi.mock('./local-storage.js') vi.mock('../utilities/theme-selector.js') vi.mock('../utilities/development-theme-manager.js', () => { const DevelopmentThemeManager = vi.fn() @@ -167,6 +171,36 @@ test('does not prompt again for multiple environments', async () => { expect(themeDelete).toHaveBeenCalledWith(1, session) }) +test.each([false, true])('preserves remote deletions when local cleanup fails (multiple: %s)', async (multiple) => { + const output = mockAndCaptureOutput() + const developmentTheme = {...theme1, role: 'development'} + const themes = multiple ? [developmentTheme, theme2] : [developmentTheme] + vi.mocked(findThemes).mockResolvedValue(themes) + vi.mocked(removeDevelopmentTheme).mockImplementation(() => { + throw new AbortError('Local storage is not writable') + }) + + const result = await executeDelete(session, {...options, themes: ['1', '2'], force: true}) + + expect(result).toEqual({ + status: 'success', + themes: themes.map((theme) => ({...theme, shop: session.storeFqdn})), + }) + expect(output.warn()).toContain( + 'Theme #1 was deleted, but its local development theme reference could not be removed', + ) + expect(output.warn()).toContain('Local storage is not writable') +}) + +test('does not remove local state when remote deletion fails', async () => { + vi.mocked(findThemes).mockResolvedValue([{...theme1, role: 'development'}]) + vi.mocked(themeDelete).mockRejectedValue(new Error('Deletion failed')) + + await expect(executeDelete(session, {...options, themes: ['1'], force: true})).rejects.toThrow('Deletion failed') + + expect(removeDevelopmentTheme).not.toHaveBeenCalled() +}) + test('preserves the environment label in terminal output', () => { renderThemeDeleteResult({status: 'success', themes: [{...theme1, shop: session.storeFqdn}]}, 'text', { store: session.storeFqdn, diff --git a/packages/theme/src/cli/services/delete.ts b/packages/theme/src/cli/services/delete.ts index de41e074c14..0f189481419 100644 --- a/packages/theme/src/cli/services/delete.ts +++ b/packages/theme/src/cli/services/delete.ts @@ -15,6 +15,7 @@ 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' +import {outputWarn} from '@shopify/cli-kit/node/output' interface DeleteOptions { selectTheme: boolean @@ -44,11 +45,22 @@ export async function themesDelete( const deletions = await Promise.allSettled( themes.map(async (theme) => { await themeDelete(theme.id, adminSession) - if (isDevelopmentTheme(theme)) removeDevelopmentTheme() return {...theme, shop: store} }), ) const deletedThemes = deletions.flatMap((deletion) => (deletion.status === 'fulfilled' ? [deletion.value] : [])) + for (const theme of deletedThemes) { + if (!isDevelopmentTheme(theme)) continue + try { + removeDevelopmentTheme() + // eslint-disable-next-line no-catch-all/no-catch-all -- Local cleanup cannot undo a completed remote deletion. + } catch (error) { + const message = error instanceof Error ? error.message : String(error) + outputWarn( + `Theme #${theme.id} was deleted, but its local development theme reference could not be removed: ${message}`, + ) + } + } const errors = deletions.flatMap((deletion, index) => { if (deletion.status === 'fulfilled') return [] const error: unknown = deletion.reason From 58f6b73ecbc9ee7263abc4dbdd920b25b705201f Mon Sep 17 00:00:00 2001 From: Gonzalo Riestra Date: Wed, 14 Oct 2026 10:51:13 +0200 Subject: [PATCH 8/8] Preserve fatal errors when all theme deletions fail --- .../theme/src/cli/services/delete.test.ts | 31 +++++++++++++++++++ packages/theme/src/cli/services/delete.ts | 4 ++- 2 files changed, 34 insertions(+), 1 deletion(-) diff --git a/packages/theme/src/cli/services/delete.test.ts b/packages/theme/src/cli/services/delete.test.ts index 789bb3e67ca..e672da1fc3f 100644 --- a/packages/theme/src/cli/services/delete.test.ts +++ b/packages/theme/src/cli/services/delete.test.ts @@ -201,6 +201,37 @@ test('does not remove local state when remote deletion fails', async () => { expect(removeDevelopmentTheme).not.toHaveBeenCalled() }) +test.each([false, true])('preserves the first fatal error when all deletions fail (multiple: %s)', async (multiple) => { + const error = new AbortError('Missing theme access scope', 'Check the access token scopes.', [ + 'Request the required theme access scope.', + ]) + error.code = 'MISSING_THEME_SCOPE' + error.customSections = [{title: 'Required access', body: 'write_themes'}] + error.details = {requiredScope: 'write_themes'} + vi.mocked(findThemes).mockResolvedValue(multiple ? [theme1, theme2] : [theme1]) + vi.mocked(themeDelete).mockRejectedValueOnce(error).mockRejectedValueOnce(new Error('Another deletion failed')) + + await expect(executeDelete(session, {...options, themes: ['1', '2'], force: true})).rejects.toBe(error) + expect(error.details).toEqual({requiredScope: 'write_themes'}) +}) + +test('includes each theme failure when ordinary errors cause total failure', async () => { + vi.mocked(findThemes).mockResolvedValue([theme1, theme2]) + vi.mocked(themeDelete) + .mockRejectedValueOnce(new Error('First deletion failed')) + .mockRejectedValueOnce(new Error('Second deletion failed')) + + await expect(executeDelete(session, {...options, themes: ['1', '2'], force: true})).rejects.toMatchObject({ + message: 'First deletion failed', + details: { + errors: [ + {themeId: '1', message: 'First deletion failed'}, + {themeId: '2', message: 'Second deletion failed'}, + ], + }, + }) +}) + test('preserves the environment label in terminal output', () => { renderThemeDeleteResult({status: 'success', themes: [{...theme1, shop: session.storeFqdn}]}, 'text', { store: session.storeFqdn, diff --git a/packages/theme/src/cli/services/delete.ts b/packages/theme/src/cli/services/delete.ts index 0f189481419..f8e9dbb397b 100644 --- a/packages/theme/src/cli/services/delete.ts +++ b/packages/theme/src/cli/services/delete.ts @@ -14,7 +14,7 @@ import { 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' +import {AbortError, FatalError} from '@shopify/cli-kit/node/error' import {outputWarn} from '@shopify/cli-kit/node/output' interface DeleteOptions { @@ -67,6 +67,8 @@ export async function themesDelete( return [{themeId: themes[index]!.id, message: error instanceof Error ? error.message : String(error)}] }) if (errors.length > 0 && deletedThemes.length === 0) { + const firstError: unknown = deletions.find((deletion) => deletion.status === 'rejected')?.reason + if (firstError instanceof FatalError) throw firstError const error = new AbortError(errors[0]!.message) error.details = {errors: errors.map(({themeId, message}) => ({themeId: String(themeId), message}))} throw error