From 065a1ec13219db87ca065a1b8db5fe4f7a2ccc55 Mon Sep 17 00:00:00 2001 From: Gonzalo Riestra Date: Thu, 24 Sep 2026 11:44:13 +0200 Subject: [PATCH] Add typed JSON output to theme publish --- .../generated/generated_docs_data_v2.json | 11 +- packages/cli/README.md | 104 ++++++++++++- packages/cli/oclif.manifest.json | 11 +- .../rules/json-output-command-exceptions.js | 1 - .../src/cli/commands/theme/publish.test.ts | 146 ++++++++++++++++++ .../theme/src/cli/commands/theme/publish.ts | 23 ++- .../theme/src/cli/services/publish.test.ts | 48 +++++- packages/theme/src/cli/services/publish.ts | 35 ++--- .../theme/src/cli/services/publish/result.ts | 25 +++ .../theme/src/cli/services/publish/types.ts | 21 +++ 10 files changed, 397 insertions(+), 28 deletions(-) create mode 100644 packages/theme/src/cli/commands/theme/publish.test.ts create mode 100644 packages/theme/src/cli/services/publish/result.ts create mode 100644 packages/theme/src/cli/services/publish/types.ts diff --git a/docs-shopify.dev/generated/generated_docs_data_v2.json b/docs-shopify.dev/generated/generated_docs_data_v2.json index 0dc71b93d69..de6d0a03447 100644 --- a/docs-shopify.dev/generated/generated_docs_data_v2.json +++ b/docs-shopify.dev/generated/generated_docs_data_v2.json @@ -8599,6 +8599,15 @@ "isOptional": true, "environmentValue": "SHOPIFY_FLAG_FORCE" }, + { + "filePath": "docs-shopify.dev/commands/interfaces/theme-publish.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-publish.interface.ts", "syntaxKind": "PropertySignature", @@ -8618,7 +8627,7 @@ "environmentValue": "SHOPIFY_FLAG_THEME_ID" } ], - "value": "export interface themepublish {\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 * The environment to apply to the current command.\n * @environment SHOPIFY_FLAG_ENVIRONMENT\n */\n '-e, --environment '?: string\n\n /**\n * Skip confirmation. Required if non interactive.\n * @environment SHOPIFY_FLAG_FORCE\n */\n '-f, --force'?: ''\n\n /**\n * Print the command's JSON schemas.\n * @environment SHOPIFY_FLAG_JSON_SCHEMA\n */\n '--json-schema'?: ''\n\n /**\n * Disable color output.\n * @environment SHOPIFY_FLAG_NO_COLOR\n */\n '--no-color'?: ''\n\n /**\n * Password generated from the Theme Access app or an Admin API token.\n * @environment SHOPIFY_CLI_THEME_TOKEN\n */\n '--password '?: string\n\n /**\n * The path where you want to run the command. Defaults to the current working directory.\n * @environment SHOPIFY_FLAG_PATH\n */\n '--path '?: string\n\n /**\n * 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. Required if non interactive.\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 themepublish {\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 * The environment to apply to the current command.\n * @environment SHOPIFY_FLAG_ENVIRONMENT\n */\n '-e, --environment '?: string\n\n /**\n * Skip confirmation. Required if non interactive.\n * @environment SHOPIFY_FLAG_FORCE\n */\n '-f, --force'?: ''\n\n /**\n * Output the result as JSON. Automatically disables color output.\n * @environment SHOPIFY_FLAG_JSON\n */\n '-j, --json'?: ''\n\n /**\n * Print the command's JSON schemas.\n * @environment SHOPIFY_FLAG_JSON_SCHEMA\n */\n '--json-schema'?: ''\n\n /**\n * Disable color output.\n * @environment SHOPIFY_FLAG_NO_COLOR\n */\n '--no-color'?: ''\n\n /**\n * Password generated from the Theme Access app or an Admin API token.\n * @environment SHOPIFY_CLI_THEME_TOKEN\n */\n '--password '?: string\n\n /**\n * The path where you want to run the command. Defaults to the current working directory.\n * @environment SHOPIFY_FLAG_PATH\n */\n '--path '?: string\n\n /**\n * 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. Required if non interactive.\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}" } }, "themepull": { diff --git a/packages/cli/README.md b/packages/cli/README.md index 081e8bbb300..9df3ee06a96 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -8952,7 +8952,7 @@ Set a remote theme as the live theme. ``` USAGE - $ shopify theme publish [--auth-alias ] [-e ...] [-f] [--json-schema] [--no-color] [--password + $ shopify theme publish [--auth-alias ] [-e ...] [-f] [-j] [--json-schema] [--no-color] [--password ] [--path ] [-s ] [-t ] [--verbose] FLAGS @@ -8964,6 +8964,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). @@ -9010,6 +9014,104 @@ DESCRIPTION If you want to publish your local theme, then you need to run `shopify theme push` first. You're asked to confirm that you want to publish the specified theme. 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 `ThemePublishResult` schema. + + ```json + { + "anyOf": [ + { + "$ref": "#/definitions/ThemePublishEnvironment/properties/result" + }, + { + "type": "object", + "properties": { + "environments": { + "type": "array", + "items": { + "$ref": "#/definitions/ThemePublishEnvironment" + } + } + }, + "required": [ + "environments" + ], + "additionalProperties": false + } + ], + "title": "ThemePublishResult", + "definitions": { + "PublishedTheme": { + "type": "object", + "properties": { + "id": { + "type": "number" + }, + "name": { + "type": "string" + }, + "role": { + "type": "string" + }, + "processing": { + "type": "boolean" + }, + "createdAtRuntime": { + "type": "boolean" + }, + "src": { + "type": "string" + }, + "shop": { + "type": "string" + } + }, + "required": [ + "id", + "name", + "role", + "processing", + "createdAtRuntime", + "shop" + ], + "additionalProperties": false + }, + "ThemePublishEnvironment": { + "type": "object", + "properties": { + "environment": { + "type": "string" + }, + "result": { + "type": "object", + "properties": { + "status": { + "type": "string", + "const": "success" + }, + "theme": { + "$ref": "#/definitions/PublishedTheme" + } + }, + "required": [ + "status", + "theme" + ], + "additionalProperties": false + } + }, + "required": [ + "environment", + "result" + ], + "additionalProperties": false + } + }, + "$schema": "http://json-schema.org/draft-07/schema#" + } + ``` ``` ## `shopify theme pull` diff --git a/packages/cli/oclif.manifest.json b/packages/cli/oclif.manifest.json index 47d0e1d82a1..697a10eda70 100644 --- a/packages/cli/oclif.manifest.json +++ b/packages/cli/oclif.manifest.json @@ -11061,7 +11061,7 @@ "args": { }, "customPluginName": "@shopify/theme", - "description": "Publishes an unpublished theme from your theme library.\n\nIf no theme ID is specified, then you're prompted to select the theme that you want to publish from the list of themes in your store.\n\nYou can run this command only in a directory that matches the \"default Shopify theme folder structure\" (https://shopify.dev/docs/themes/tools/cli#directory-structure).\n\nIf you want to publish your local theme, then you need to run `shopify theme push` first. You're asked to confirm that you want to publish the specified theme. You can skip this confirmation using the `--force` flag.", + "description": "Publishes an unpublished theme from your theme library.\n\nIf no theme ID is specified, then you're prompted to select the theme that you want to publish from the list of themes in your store.\n\nYou can run this command only in a directory that matches the \"default Shopify theme folder structure\" (https://shopify.dev/docs/themes/tools/cli#directory-structure).\n\nIf you want to publish your local theme, then you need to run `shopify theme push` first. You're asked to confirm that you want to publish the specified theme. 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 `ThemePublishResult` schema.\n\n```json\n{\n \"anyOf\": [\n {\n \"$ref\": \"#/definitions/ThemePublishEnvironment/properties/result\"\n },\n {\n \"type\": \"object\",\n \"properties\": {\n \"environments\": {\n \"type\": \"array\",\n \"items\": {\n \"$ref\": \"#/definitions/ThemePublishEnvironment\"\n }\n }\n },\n \"required\": [\n \"environments\"\n ],\n \"additionalProperties\": false\n }\n ],\n \"title\": \"ThemePublishResult\",\n \"definitions\": {\n \"PublishedTheme\": {\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 \"ThemePublishEnvironment\": {\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 \"theme\": {\n \"$ref\": \"#/definitions/PublishedTheme\"\n }\n },\n \"required\": [\n \"status\",\n \"theme\"\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": "Publishes an unpublished theme from your theme library.\n\nIf no theme ID is specified, then you're prompted to select the theme that you want to publish from the list of themes in your store.\n\nYou can run this command only in a directory that matches the [default Shopify theme folder structure](https://shopify.dev/docs/themes/tools/cli#directory-structure).\n\nIf you want to publish your local theme, then you need to run `shopify theme push` first. You're asked to confirm that you want to publish the specified theme. You can skip this confirmation using the `--force` flag.", "enableJsonFlag": false, "flags": { @@ -11090,6 +11090,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 0a32bbd2d92..97a8d0b9ad4 100644 --- a/packages/eslint-plugin-cli/rules/json-output-command-exceptions.js +++ b/packages/eslint-plugin-cli/rules/json-output-command-exceptions.js @@ -49,7 +49,6 @@ const commandExceptions = [ 'packages/theme/src/cli/commands/theme/package.ts', 'packages/theme/src/cli/commands/theme/preview.ts', 'packages/theme/src/cli/commands/theme/profile.ts', - 'packages/theme/src/cli/commands/theme/publish.ts', 'packages/theme/src/cli/commands/theme/pull.ts', 'packages/theme/src/cli/commands/theme/push.ts', 'packages/theme/src/cli/commands/theme/rename.ts', diff --git a/packages/theme/src/cli/commands/theme/publish.test.ts b/packages/theme/src/cli/commands/theme/publish.test.ts new file mode 100644 index 00000000000..8dcd04ade73 --- /dev/null +++ b/packages/theme/src/cli/commands/theme/publish.test.ts @@ -0,0 +1,146 @@ +import Publish from './publish.js' +import {themePublishJsonOutputSchema} from '../../services/publish/types.js' +import {findOrSelectTheme} from '../../utilities/theme-selector.js' +import {Config} from '@oclif/core' +import {ensureAuthenticatedThemes} from '@shopify/cli-kit/node/session' +import {themePublish} 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 {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 originalTheme = {id: 1, name: 'Original', role: 'unpublished', processing: false, createdAtRuntime: false} +const publishedTheme = {...originalTheme, role: 'live'} +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 Publish(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" +[environments.second] +store = "second.myshopify.com" +password = "token" +`, + ) + const cwd = vi.spyOn(process, 'cwd').mockReturnValue(directory) + try { + await run() + } finally { + cwd.mockRestore() + } + }) +} + +describe('theme publish JSON output', () => { + test('exposes its schema and JSON flag in help', () => { + expect(Publish.jsonOutputSchema).toBe(themePublishJsonOutputSchema) + expect(Publish.flags.json).toBeDefined() + expect(Publish.description).toContain('ThemePublishResult') + }) + + test.each([undefined, '', 'https://example.com/theme.zip'])( + 'returns the updated theme and omits missing src (%s)', + async (src) => { + vi.mocked(findOrSelectTheme).mockResolvedValue(originalTheme) + vi.mocked(themePublish).mockResolvedValue({...publishedTheme, src}) + await withCapturedStandardStreams(async ({stdout, stderr}) => { + await run(['--store', store, '--theme', '1', '--force', '--json']) + expect(JSON.parse(stdout())).toEqual({ + status: 'success', + theme: {...publishedTheme, ...(src === undefined ? {} : {src}), shop: store}, + }) + expect(stderr()).toBe('') + }) + }, + ) + + test('routes diagnostics to stderr', async () => { + vi.mocked(findOrSelectTheme).mockResolvedValue(originalTheme) + vi.mocked(themePublish).mockImplementation(async () => { + outputWarn('Retrying request') + return publishedTheme + }) + await withCapturedStandardStreams(async ({stdout, stderr}) => { + await run(['--store', store, '--theme', '1', '--force', '--json']) + expect(JSON.parse(stdout()).theme.role).toBe('live') + expect(JSON.parse(stderr())).toMatchObject({type: 'diagnostic', level: 'warning', message: 'Retrying request'}) + }) + }) + + test('leaves execution failures to the shared error handler without writing a result', async () => { + vi.mocked(findOrSelectTheme).mockResolvedValue(originalTheme) + vi.mocked(themePublish).mockRejectedValue(new Error('Publishing failed')) + await withCapturedStandardStreams(async ({stdout}) => { + await expect(run(['--store', store, '--theme', '1', '--force', '--json'])).rejects.toThrow('Publishing failed') + expect(stdout()).toBe('') + }) + }) + + test.each(['none', 'partial', 'all'])('collects one document in configured order (%s failures)', async (failures) => { + vi.mocked(findOrSelectTheme).mockResolvedValue(originalTheme) + vi.mocked(themePublish).mockImplementation(async (_id, session) => { + if (failures === 'all' || (failures === 'partial' && session.storeFqdn.startsWith('first.'))) { + throw new Error('Publishing failed') + } + return publishedTheme + }) + const exitCode = process.exitCode + await inEnvironments(async () => { + await withCapturedStandardStreams(async ({stdout, stderr}) => { + await run(['--environment', 'first', '--environment', 'second', '--theme', '1', '--force', '--json']) + const environments: string[] = [] + if (failures === 'none') environments.push('first') + if (failures !== 'all') environments.push('second') + expect(JSON.parse(stdout())).toEqual({ + environments: environments.map((environment) => ({ + environment, + result: {status: 'success', theme: {...publishedTheme, shop: `${environment}.myshopify.com`}}, + })), + }) + if (failures === 'none') { + expect(stderr()).toBe('') + } else { + const errors = stderr() + .trim() + .split('\n') + .map((line) => JSON.parse(line)) + expect(errors).toHaveLength(failures === 'all' ? 2 : 1) + expect(errors[0]).toMatchObject({type: 'diagnostic', level: 'error', code: 'theme-environment-failed'}) + } + expect(process.exitCode).toBe(exitCode) + }) + }) + }) + + test.each([ + {status: 'success', theme: {...publishedTheme, id: '1', shop: store}}, + {status: 'success', theme: {...publishedTheme, shop: null}}, + {status: 'success', theme: {...publishedTheme, src: false, shop: store}}, + { + environments: [ + {environment: 'first', result: {status: 'success', theme: {...publishedTheme, role: null, shop: store}}}, + ], + }, + ])('rejects malformed results %#', (result) => { + expect(() => themePublishJsonOutputSchema.validate(result)).toThrow() + }) +}) diff --git a/packages/theme/src/cli/commands/theme/publish.ts b/packages/theme/src/cli/commands/theme/publish.ts index 989b68829cd..c913c682433 100644 --- a/packages/theme/src/cli/commands/theme/publish.ts +++ b/packages/theme/src/cli/commands/theme/publish.ts @@ -1,14 +1,21 @@ +import {themePublishJsonOutputSchema} from '../../services/publish/types.js' +import {renderThemePublishResult} from '../../services/publish/result.js' import {publish} from '../../services/publish.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' type PublishFlags = OutputFlags export default class Publish extends ThemeCommand { + static get jsonOutputSchema() { + return themePublishJsonOutputSchema + } + static summary = 'Set a remote theme as the live theme.' static descriptionWithMarkdown = `Publishes an unpublished theme from your theme library. @@ -23,6 +30,7 @@ If you want to publish your local theme, then you need to run \`shopify theme pu static flags = { ...globalFlags, + ...jsonFlag, ...themeFlags, force: requiredIfNonInteractive( Flags.boolean({ @@ -43,6 +51,17 @@ If you want to publish your local theme, then you need to run \`shopify theme pu static multiEnvironmentsFlags = ['store', 'password', 'theme'] async command(flags: PublishFlags, adminSession: AdminSession, multiEnvironment?: boolean) { - await publish(adminSession, flags, multiEnvironment) + const result = await publish(adminSession, flags, multiEnvironment) + if (!result) return + if (flags.json && multiEnvironment) return result.data + renderThemePublishResult(result, flags.json ? 'json' : 'text', flags.environment) + } + + protected collectsEnvironmentResults(flags: {json?: boolean}): boolean { + return Boolean(flags.json) + } + + protected renderEnvironmentResults(environments: {environment: string; result: unknown}[]): void { + outputResult(themePublishJsonOutputSchema.encode(themePublishJsonOutputSchema.validate({environments}))) } } diff --git a/packages/theme/src/cli/services/publish.test.ts b/packages/theme/src/cli/services/publish.test.ts index fe2af71f2e0..305fe05f5ae 100644 --- a/packages/theme/src/cli/services/publish.test.ts +++ b/packages/theme/src/cli/services/publish.test.ts @@ -1,4 +1,5 @@ -import {publish} from './publish.js' +import {publish as executePublish} from './publish.js' +import {renderThemePublishResult} from './publish/result.js' import {findOrSelectTheme} from '../utilities/theme-selector.js' import {renderSuccess, renderConfirmationPrompt} from '@shopify/cli-kit/node/ui' import {test, describe, expect, vi} from 'vitest' @@ -105,3 +106,48 @@ describe('publish', () => { }) }) }) + +async function publish(...args: Parameters) { + const result = await executePublish(...args) + if (result) renderThemePublishResult(result, 'text') + return result +} + +test('returns the published API data without presenting a final result', async () => { + const publishedTheme = {...theme, name: 'Published name', role: 'live', processing: false, createdAtRuntime: false} + vi.mocked(findOrSelectTheme).mockResolvedValue(theme) + vi.mocked(themePublish).mockResolvedValue(publishedTheme) + const result = await executePublish(session, {...options, force: true}) + expect(result).toEqual({ + data: {status: 'success', theme: {...publishedTheme, shop: session.storeFqdn}}, + originalTheme: theme, + previewUrl: 'https://my-shop.myshopify.com', + }) + expect(renderSuccess).not.toHaveBeenCalled() +}) + +test('skips confirmation when already confirmed for multiple environments', async () => { + vi.mocked(findOrSelectTheme).mockResolvedValue(theme) + vi.mocked(themePublish).mockResolvedValue(theme) + await executePublish(session, options, true) + expect(renderConfirmationPrompt).not.toHaveBeenCalled() + expect(themePublish).toHaveBeenCalledWith(1, session) +}) + +test('keeps the original theme name and environment label in terminal output', async () => { + vi.mocked(findOrSelectTheme).mockResolvedValue(theme) + vi.mocked(themePublish).mockResolvedValue({...theme, name: 'Canonical name', role: 'live'}) + const result = await executePublish(session, {...options, force: true}) + renderThemePublishResult(result!, 'text', ['staging']) + expect(renderSuccess).toHaveBeenCalledWith({ + headline: 'Environment: staging', + body: [ + 'The theme', + "'my theme'", + {subdued: '(#1)'}, + 'is now live at', + {link: {label: 'https://my-shop.myshopify.com', url: 'https://my-shop.myshopify.com'}}, + {char: '.'}, + ], + }) +}) diff --git a/packages/theme/src/cli/services/publish.ts b/packages/theme/src/cli/services/publish.ts index 008976ce45f..ba08560b471 100644 --- a/packages/theme/src/cli/services/publish.ts +++ b/packages/theme/src/cli/services/publish.ts @@ -1,18 +1,21 @@ -import {themeComponent} from '../utilities/theme-ui.js' +import {ThemePublishResult} from './publish/types.js' import {findOrSelectTheme} from '../utilities/theme-selector.js' import {themePublish} from '@shopify/cli-kit/node/themes/api' import {themePreviewUrl} from '@shopify/cli-kit/node/themes/urls' import {Theme} from '@shopify/cli-kit/node/themes/types' -import {renderConfirmationPrompt, renderSuccess} from '@shopify/cli-kit/node/ui' +import {renderConfirmationPrompt} from '@shopify/cli-kit/node/ui' import {AdminSession} from '@shopify/cli-kit/node/session' interface PublishServiceOptions { theme: string | undefined force: boolean - environment?: string } -export async function publish(adminSession: AdminSession, options: PublishServiceOptions, multiEnvironment?: boolean) { +export async function publish( + adminSession: AdminSession, + options: PublishServiceOptions, + multiEnvironment?: boolean, +): Promise { const themeToPublish = await findOrSelectTheme(adminSession, { header: 'Select a theme to publish', filter: { @@ -33,21 +36,11 @@ export async function publish(adminSession: AdminSession, options: PublishServic if (!accept) return } - await themePublish(themeToPublish.id, adminSession) - - renderSuccess({ - headline: options.environment ? `Environment: ${options.environment}` : undefined, - body: [ - 'The theme', - ...themeComponent(themeToPublish), - 'is now live at', - { - link: { - label: previewUrl, - url: previewUrl, - }, - }, - {char: '.'}, - ], - }) + // The API helper rejects missing themes and user errors before returning. + const publishedTheme = (await themePublish(themeToPublish.id, adminSession))! + return { + data: {status: 'success', theme: {...publishedTheme, shop: adminSession.storeFqdn}}, + originalTheme: themeToPublish, + previewUrl, + } } diff --git a/packages/theme/src/cli/services/publish/result.ts b/packages/theme/src/cli/services/publish/result.ts new file mode 100644 index 00000000000..8ea2a9b2506 --- /dev/null +++ b/packages/theme/src/cli/services/publish/result.ts @@ -0,0 +1,25 @@ +import {themePublishJsonOutputSchema, type ThemePublishResult} from './types.js' +import {themeComponent} from '../../utilities/theme-ui.js' +import {outputResult} from '@shopify/cli-kit/node/output' +import {renderSuccess} from '@shopify/cli-kit/node/ui' + +export function renderThemePublishResult( + result: ThemePublishResult, + format: 'text' | 'json', + environment?: string[], +): void { + if (format === 'json') { + outputResult(themePublishJsonOutputSchema.encode(result.data)) + return + } + renderSuccess({ + headline: environment ? `Environment: ${environment}` : undefined, + body: [ + 'The theme', + ...themeComponent(result.originalTheme), + 'is now live at', + {link: {label: result.previewUrl, url: result.previewUrl}}, + {char: '.'}, + ], + }) +} diff --git a/packages/theme/src/cli/services/publish/types.ts b/packages/theme/src/cli/services/publish/types.ts new file mode 100644 index 00000000000..bd76126096e --- /dev/null +++ b/packages/theme/src/cli/services/publish/types.ts @@ -0,0 +1,21 @@ +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' +import type {Theme} from '@shopify/cli-kit/node/themes/types' + +const ThemePublishResultSchema = ThemeMutationSuccessSchema.extend({theme: ThemeMutationThemeSchema}) +const ThemePublishEnvironmentSchema = zod.object({environment: zod.string(), result: ThemePublishResultSchema}) + +export const themePublishJsonOutputSchema = defineJsonOutputSchema({ + name: 'ThemePublishResult', + schema: zod.union([ThemePublishResultSchema, zod.object({environments: zod.array(ThemePublishEnvironmentSchema)})]), + definitions: {PublishedTheme: ThemeMutationThemeSchema, ThemePublishEnvironment: ThemePublishEnvironmentSchema}, +}) + +export type ThemePublishData = zod.infer +export interface ThemePublishResult { + data: ThemePublishData + originalTheme: Theme + previewUrl: string +}