From ccf40e737186ea96c6da8b3b41fa1c6829b6303f Mon Sep 17 00:00:00 2001 From: Gonzalo Riestra Date: Thu, 24 Sep 2026 11:19:16 +0200 Subject: [PATCH 01/13] Add typed JSON output to theme push --- packages/cli/README.md | 106 ++++++++ packages/cli/oclif.manifest.json | 2 +- .../rules/json-output-command-exceptions.js | 1 - .../theme/src/cli/commands/theme/push.test.ts | 80 ++++++ packages/theme/src/cli/commands/theme/push.ts | 25 +- .../theme/transfer-environments.test.ts | 109 ++++++++ packages/theme/src/cli/services/push.test.ts | 1 + packages/theme/src/cli/services/push.ts | 233 ++++-------------- .../src/cli/services/push/execute.test.ts | 89 +++++++ .../src/cli/services/push/result.test.ts | 156 ++++++++++++ .../theme/src/cli/services/push/result.ts | 102 ++++++++ packages/theme/src/cli/services/push/types.ts | 49 ++++ packages/theme/src/cli/utilities/errors.ts | 6 + .../utilities/theme-transfer-events.test.ts | 111 +++++++++ packages/theme/src/cli/utilities/theme-ui.ts | 25 +- .../theme/src/cli/utilities/theme-uploader.ts | 4 +- 16 files changed, 901 insertions(+), 198 deletions(-) create mode 100644 packages/theme/src/cli/commands/theme/push.test.ts create mode 100644 packages/theme/src/cli/commands/theme/transfer-environments.test.ts create mode 100644 packages/theme/src/cli/services/push/execute.test.ts create mode 100644 packages/theme/src/cli/services/push/result.test.ts create mode 100644 packages/theme/src/cli/services/push/result.ts create mode 100644 packages/theme/src/cli/services/push/types.ts create mode 100644 packages/theme/src/cli/utilities/theme-transfer-events.test.ts diff --git a/packages/cli/README.md b/packages/cli/README.md index 3f62fa76a84..8706112c18c 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -14792,6 +14792,112 @@ DESCRIPTION } } ``` + + + Use `--json-schema` to print the result, error, and event schemas. + + Output from `--json` conforms to the `ThemePushJsonResult` schema. + + ```json + { + "anyOf": [ + { + "type": "object", + "properties": { + "status": { + "type": "string", + "enum": [ + "success", + "failed" + ] + }, + "environment": { + "type": "string" + }, + "theme": { + "$ref": "#/definitions/ThemePushTheme" + } + }, + "required": [ + "status", + "theme" + ], + "additionalProperties": false + }, + { + "type": "array", + "items": { + "type": "object", + "properties": { + "status": { + "$ref": "#/definitions/ThemePushJsonResult/anyOf/0/properties/status" + }, + "environment": { + "type": "string" + }, + "theme": { + "$ref": "#/definitions/ThemePushTheme" + } + }, + "required": [ + "status", + "environment", + "theme" + ], + "additionalProperties": false + } + } + ], + "title": "ThemePushJsonResult", + "definitions": { + "ThemePushTheme": { + "type": "object", + "properties": { + "id": { + "type": "number" + }, + "name": { + "type": "string" + }, + "role": { + "type": "string" + }, + "shop": { + "type": "string" + }, + "editor_url": { + "type": "string" + }, + "preview_url": { + "type": "string" + }, + "warning": { + "type": "string" + }, + "errors": { + "type": "object", + "additionalProperties": { + "type": "array", + "items": { + "type": "string" + } + } + } + }, + "required": [ + "id", + "name", + "role", + "shop", + "editor_url", + "preview_url" + ], + "additionalProperties": false + } + }, + "$schema": "http://json-schema.org/draft-07/schema#" + } + ``` ``` ## `shopify theme rename` diff --git a/packages/cli/oclif.manifest.json b/packages/cli/oclif.manifest.json index 72729f0dbaf..563c84713d2 100644 --- a/packages/cli/oclif.manifest.json +++ b/packages/cli/oclif.manifest.json @@ -12620,7 +12620,7 @@ "args": { }, "customPluginName": "@shopify/theme", - "description": "Uploads your local theme files to Shopify, overwriting the remote version if specified.\n\n If no theme is specified, then you're prompted to select the theme to overwrite from the list of the themes in your store.\n\n You 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\n This command returns the following information:\n\n - A link to the \"editor\" (https://shopify.dev/docs/themes/tools/online-editor) for the theme in the Shopify admin.\n - A \"preview link\" (https://help.shopify.com/manual/online-store/themes/adding-themes#share-a-theme-preview-with-others) that you can share with others.\n\n If you use the `--json` flag, then theme information is returned in JSON format, which can be used as a machine-readable input for scripts or continuous integration.\n\n Sample output:\n\n ```json\n {\n \"theme\": {\n \"id\": 108267175958,\n \"name\": \"MyTheme\",\n \"role\": \"unpublished\",\n \"shop\": \"mystore.myshopify.com\",\n \"editor_url\": \"https://mystore.myshopify.com/admin/themes/108267175958/editor\",\n \"preview_url\": \"https://mystore.myshopify.com/?preview_theme_id=108267175958\"\n }\n }\n ```\n ", + "description": "Uploads your local theme files to Shopify, overwriting the remote version if specified.\n\n If no theme is specified, then you're prompted to select the theme to overwrite from the list of the themes in your store.\n\n You 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\n This command returns the following information:\n\n - A link to the \"editor\" (https://shopify.dev/docs/themes/tools/online-editor) for the theme in the Shopify admin.\n - A \"preview link\" (https://help.shopify.com/manual/online-store/themes/adding-themes#share-a-theme-preview-with-others) that you can share with others.\n\n If you use the `--json` flag, then theme information is returned in JSON format, which can be used as a machine-readable input for scripts or continuous integration.\n\n Sample output:\n\n ```json\n {\n \"theme\": {\n \"id\": 108267175958,\n \"name\": \"MyTheme\",\n \"role\": \"unpublished\",\n \"shop\": \"mystore.myshopify.com\",\n \"editor_url\": \"https://mystore.myshopify.com/admin/themes/108267175958/editor\",\n \"preview_url\": \"https://mystore.myshopify.com/?preview_theme_id=108267175958\"\n }\n }\n ```\n \n\nUse `--json-schema` to print the result, error, and event schemas.\n\nOutput from `--json` conforms to the `ThemePushJsonResult` schema.\n\n```json\n{\n \"anyOf\": [\n {\n \"type\": \"object\",\n \"properties\": {\n \"status\": {\n \"type\": \"string\",\n \"enum\": [\n \"success\",\n \"failed\"\n ]\n },\n \"environment\": {\n \"type\": \"string\"\n },\n \"theme\": {\n \"$ref\": \"#/definitions/ThemePushTheme\"\n }\n },\n \"required\": [\n \"status\",\n \"theme\"\n ],\n \"additionalProperties\": false\n },\n {\n \"type\": \"array\",\n \"items\": {\n \"type\": \"object\",\n \"properties\": {\n \"status\": {\n \"$ref\": \"#/definitions/ThemePushJsonResult/anyOf/0/properties/status\"\n },\n \"environment\": {\n \"type\": \"string\"\n },\n \"theme\": {\n \"$ref\": \"#/definitions/ThemePushTheme\"\n }\n },\n \"required\": [\n \"status\",\n \"environment\",\n \"theme\"\n ],\n \"additionalProperties\": false\n }\n }\n ],\n \"title\": \"ThemePushJsonResult\",\n \"definitions\": {\n \"ThemePushTheme\": {\n \"type\": \"object\",\n \"properties\": {\n \"id\": {\n \"type\": \"number\"\n },\n \"name\": {\n \"type\": \"string\"\n },\n \"role\": {\n \"type\": \"string\"\n },\n \"shop\": {\n \"type\": \"string\"\n },\n \"editor_url\": {\n \"type\": \"string\"\n },\n \"preview_url\": {\n \"type\": \"string\"\n },\n \"warning\": {\n \"type\": \"string\"\n },\n \"errors\": {\n \"type\": \"object\",\n \"additionalProperties\": {\n \"type\": \"array\",\n \"items\": {\n \"type\": \"string\"\n }\n }\n }\n },\n \"required\": [\n \"id\",\n \"name\",\n \"role\",\n \"shop\",\n \"editor_url\",\n \"preview_url\"\n ],\n \"additionalProperties\": false\n }\n },\n \"$schema\": \"http://json-schema.org/draft-07/schema#\"\n}\n```", "descriptionWithMarkdown": "Uploads your local theme files to Shopify, overwriting the remote version if specified.\n\n If no theme is specified, then you're prompted to select the theme to overwrite from the list of the themes in your store.\n\n You 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\n This command returns the following information:\n\n - A link to the [editor](https://shopify.dev/docs/themes/tools/online-editor) for the theme in the Shopify admin.\n - A [preview link](https://help.shopify.com/manual/online-store/themes/adding-themes#share-a-theme-preview-with-others) that you can share with others.\n\n If you use the `--json` flag, then theme information is returned in JSON format, which can be used as a machine-readable input for scripts or continuous integration.\n\n Sample output:\n\n ```json\n {\n \"theme\": {\n \"id\": 108267175958,\n \"name\": \"MyTheme\",\n \"role\": \"unpublished\",\n \"shop\": \"mystore.myshopify.com\",\n \"editor_url\": \"https://mystore.myshopify.com/admin/themes/108267175958/editor\",\n \"preview_url\": \"https://mystore.myshopify.com/?preview_theme_id=108267175958\"\n }\n }\n ```\n ", "enableJsonFlag": false, "flags": { 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 9d716650ead..dad08c72b9e 100644 --- a/packages/eslint-plugin-cli/rules/json-output-command-exceptions.js +++ b/packages/eslint-plugin-cli/rules/json-output-command-exceptions.js @@ -22,7 +22,6 @@ const commandExceptions = [ 'packages/theme/src/cli/commands/theme/preview.ts', 'packages/theme/src/cli/commands/theme/profile.ts', 'packages/theme/src/cli/commands/theme/pull.ts', - 'packages/theme/src/cli/commands/theme/push.ts', 'packages/theme/src/cli/commands/theme/share.ts', // App Security commands, launched before adopting typed JSON output. Remove entries as they adopt it. diff --git a/packages/theme/src/cli/commands/theme/push.test.ts b/packages/theme/src/cli/commands/theme/push.test.ts new file mode 100644 index 00000000000..5bc74f3f6e6 --- /dev/null +++ b/packages/theme/src/cli/commands/theme/push.test.ts @@ -0,0 +1,80 @@ +import Push from './push.js' +import {executeThemePush} from '../../services/push.js' +import {checkThemeBeforePush} from '../../services/push/result.js' +import {themePushJsonOutputSchema} from '../../services/push/types.js' +import {withCapturedStandardStreams} from '@shopify/cli-kit/node/testing/output' +import {describe, expect, test, vi} from 'vitest' +import {Config} from '@oclif/core' +import {AbortError} from '@shopify/cli-kit/node/error' + +vi.mock('../../services/push.js') +vi.mock('../../services/push/result.js', async (importOriginal) => ({ + ...(await importOriginal()), + checkThemeBeforePush: vi.fn(), +})) + +const session = {storeFqdn: 'test.myshopify.com', token: 'token'} + +describe('theme push JSON', () => { + test('exposes the schema in help and retains JSON and inherited flags', () => { + expect(Push.jsonOutputSchema).toBe(themePushJsonOutputSchema) + expect(Push.description).toContain('ThemePushJsonResult') + expect(Push.flags.json).toBeDefined() + expect(Push.baseFlags).toHaveProperty('json-schema') + }) + + test('executes and writes the result through the real encoder', async () => { + vi.mocked(executeThemePush).mockResolvedValue({ + theme: { + id: 1, + name: 'Theme', + role: 'unpublished', + shop: session.storeFqdn, + editor_url: 'editor', + preview_url: 'preview', + }, + published: false, + hasErrors: false, + errors: {}, + }) + const command = new Push([], new Config({root: '.'})) + await withCapturedStandardStreams(async ({stdout, stderr}) => { + await command.command({json: true} as never, session, false) + + expect(JSON.parse(stdout())).toEqual({ + status: 'success', + theme: { + id: 1, + name: 'Theme', + role: 'unpublished', + shop: session.storeFqdn, + editor_url: 'editor', + preview_url: 'preview', + }, + }) + expect(stderr()).toBe('') + }) + }) + + test('retains cancellation without emitting a success result', async () => { + vi.mocked(executeThemePush).mockResolvedValue(undefined) + const command = new Push([], new Config({root: '.'})) + await withCapturedStandardStreams(async ({stdout}) => { + await command.command({json: true} as never, session, false) + + expect(stdout()).toBe('') + }) + }) + + test('propagates strict failures without pushing or writing a result', async () => { + const failure = new AbortError('Theme check failed. Please fix the errors before pushing.') + vi.mocked(checkThemeBeforePush).mockRejectedValueOnce(failure) + const command = new Push([], new Config({root: '.'})) + await withCapturedStandardStreams(async ({stdout}) => { + await expect(command.command({json: true, strict: true} as never, session, false)).rejects.toBe(failure) + + expect(executeThemePush).not.toHaveBeenCalled() + expect(stdout()).toBe('') + }) + }) +}) diff --git a/packages/theme/src/cli/commands/theme/push.ts b/packages/theme/src/cli/commands/theme/push.ts index c87c7dcedb7..0eb519700cd 100644 --- a/packages/theme/src/cli/commands/theme/push.ts +++ b/packages/theme/src/cli/commands/theme/push.ts @@ -1,6 +1,12 @@ import {globFlags, themeFlags} from '../../flags.js' import ThemeCommand from '../../utilities/theme-command.js' -import {push} from '../../services/push.js' +import {executeThemePush} from '../../services/push.js' +import {themePushJsonOutputSchema} from '../../services/push/types.js' +import { + checkThemeBeforePush, + renderThemePushResult, + renderThemePushEnvironmentResults, +} from '../../services/push/result.js' import {Flags} from '@oclif/core' import {globalFlags, jsonFlag} from '@shopify/cli-kit/node/cli' import {recordTiming} from '@shopify/cli-kit/node/analytics' @@ -13,6 +19,10 @@ import type {NonTTYFlagRequirement} from '@shopify/cli-kit/node/base-command' type PushFlags = InferredFlags export default class Push extends ThemeCommand { + static get jsonOutputSchema() { + return themePushJsonOutputSchema + } + static summary = 'Uploads your local theme files to the connected store, overwriting the remote version if specified.' static usage = ['theme push', 'theme push --unpublished --json'] @@ -137,7 +147,8 @@ export default class Push extends ThemeCommand { context?: {stdout?: Writable; stderr?: Writable}, ) { recordTiming('theme-command:push') - await push( + await checkThemeBeforePush(flags) + const result = await executeThemePush( { ...flags, allowLive: flags['allow-live'], @@ -148,7 +159,17 @@ export default class Push extends ThemeCommand { multiEnvironment, context, ) + if (result && !(flags.json && multiEnvironment)) renderThemePushResult(result, flags.json ? 'json' : 'text') recordTiming('theme-command:push') + return result + } + + protected collectsEnvironmentResults(flags: {json?: boolean}): boolean { + return Boolean(flags.json) + } + + protected renderEnvironmentResults(results: {environment: string; result: unknown}[]): void { + renderThemePushEnvironmentResults(results) } protected storeAuthScopes(): string[] { diff --git a/packages/theme/src/cli/commands/theme/transfer-environments.test.ts b/packages/theme/src/cli/commands/theme/transfer-environments.test.ts new file mode 100644 index 00000000000..66e81d73105 --- /dev/null +++ b/packages/theme/src/cli/commands/theme/transfer-environments.test.ts @@ -0,0 +1,109 @@ +import Push from './push.js' +import {executeThemePush} from '../../services/push.js' +import {withCapturedStandardStreams} from '@shopify/cli-kit/node/testing/output' +import {loadEnvironment} from '@shopify/cli-kit/node/environments' +import {ensureAuthenticatedThemes} from '@shopify/cli-kit/node/session' +import {inTemporaryDirectory} from '@shopify/cli-kit/node/fs' +import {runWithCommandEvents, renderCommandEventAsJson} from '@shopify/cli-kit/node/command-events' +import {Config} from '@oclif/core' +import {expect, test, vi} from 'vitest' + +vi.mock('../../services/push.js') +vi.mock('@shopify/cli-kit/node/environments') +vi.mock('@shopify/cli-kit/node/session') +vi.mock('@shopify/cli-kit/node/metadata') + +class TestPush extends Push { + public parse = vi.fn() +} + +// Exercise real environment orchestration, presenter, encoder and streams. +test.each(['none', 'partial', 'total'] as const)( + 'collects environment successes in requested order with %s failures', + async (failures) => { + await inTemporaryDirectory(async (path) => { + vi.mocked(loadEnvironment).mockImplementation(async (name) => ({ + store: name === 'second' ? 'second.myshopify.com' : 'first.myshopify.com', + password: 'password', + path, + theme: '1', + })) + vi.mocked(ensureAuthenticatedThemes).mockImplementation(async (store) => ({storeFqdn: store, token: 'token'})) + let releaseFirst: () => void = () => {} + const secondStarted = new Promise((resolve) => { + releaseFirst = resolve + }) + const executionOrder: string[] = [] + vi.mocked(executeThemePush).mockImplementation(async (flags, session) => { + const environment = flags.environment![0]! + if (environment === 'first') await secondStarted + if (environment === 'second') releaseFirst() + executionOrder.push(environment) + if (failures === 'total' || (failures === 'partial' && environment === 'second')) + throw new Error('upload failed') + return { + environment, + theme: { + id: 1, + name: environment, + role: 'unpublished', + shop: session!.storeFqdn, + editor_url: 'editor', + preview_url: 'preview', + }, + published: false, + hasErrors: false, + errors: {}, + } + }) + const command = new TestPush([], new Config({root: path})) + const flags = {json: true, force: true, environment: ['first', 'second', 'third']} + vi.spyOn(command, 'parse').mockResolvedValue({flags, args: {}} as never) + const exitCode = process.exitCode + await withCapturedStandardStreams(async ({stdout, stderr}) => { + await runWithCommandEvents({outputMode: 'json', sink: renderCommandEventAsJson}, () => command.run()) + + const results = JSON.parse(stdout()) + expect(results.map((result: {environment: string}) => result.environment)).toEqual( + failures === 'total' ? [] : ['first', ...(failures === 'none' ? ['second'] : []), 'third'], + ) + expect(executionOrder).toEqual(['second', 'first', 'third']) + expect(process.exitCode).toBe(exitCode) + const events = stderr().trim() + ? stderr() + .trim() + .split('\n') + .filter(Boolean) + .map((line) => JSON.parse(line)) + : [] + expect(events.filter((event) => event.level === 'error')).toHaveLength( + {none: 0, partial: 1, total: 3}[failures], + ) + }) + }) + }, +) + +test('returns an empty array when every environment is invalid', async () => { + vi.mocked(loadEnvironment).mockResolvedValue({}) + const command = new TestPush([], new Config({root: '.'})) + vi.spyOn(command, 'parse').mockResolvedValue({ + flags: {json: true, force: true, environment: ['first', 'second']}, + args: {}, + } as never) + await withCapturedStandardStreams(async ({stdout, stderr}) => { + await runWithCommandEvents({outputMode: 'json', sink: renderCommandEventAsJson}, () => command.run()) + + expect(JSON.parse(stdout())).toEqual([]) + expect(executeThemePush).not.toHaveBeenCalled() + expect( + stderr() + .trim() + .split('\n') + .map((line) => JSON.parse(line)), + ).toEqual([ + expect.objectContaining({type: 'diagnostic', level: 'warning'}), + expect.objectContaining({type: 'diagnostic', level: 'warning'}), + ]) + }) +}) diff --git a/packages/theme/src/cli/services/push.test.ts b/packages/theme/src/cli/services/push.test.ts index 596aa5e1b31..f89b9d936e3 100644 --- a/packages/theme/src/cli/services/push.test.ts +++ b/packages/theme/src/cli/services/push.test.ts @@ -104,6 +104,7 @@ describe('push', () => { // Then expect(outputResult).toHaveBeenCalledWith( JSON.stringify({ + status: 'failed', theme: { id: 1, name: 'Theme', diff --git a/packages/theme/src/cli/services/push.ts b/packages/theme/src/cli/services/push.ts index ff2c42b517c..1f23015b429 100644 --- a/packages/theme/src/cli/services/push.ts +++ b/packages/theme/src/cli/services/push.ts @@ -1,25 +1,18 @@ -/* eslint-disable tsdoc/syntax */ +import {themePushResultSchema} from './push/types.js' +import {checkThemeBeforePush, renderThemePushResult} from './push/result.js' import {hasRequiredThemeDirectories, mountThemeFileSystem} from '../utilities/theme-fs.js' import {uploadTheme} from '../utilities/theme-uploader.js' -import {ensureDirectoryConfirmed, themeComponent} from '../utilities/theme-ui.js' +import {ensureDirectoryConfirmed} from '../utilities/theme-ui.js' import {DevelopmentThemeManager} from '../utilities/development-theme-manager.js' import {findOrSelectTheme} from '../utilities/theme-selector.js' import {Role} from '../utilities/theme-selector/fetch.js' import {configureCLIEnvironment} from '../utilities/cli-config.js' -import {runThemeCheck} from '../commands/theme/check.js' import {ensureThemeStore} from '../utilities/theme-store.js' import {ensureListingExists} from '../utilities/theme-listing.js' import {AdminSession, ensureAuthenticatedThemes} from '@shopify/cli-kit/node/session' import {themeCreate, fetchChecksums, themePublish} from '@shopify/cli-kit/node/themes/api' -import {Result, Theme} from '@shopify/cli-kit/node/themes/types' -import {outputResult} from '@shopify/cli-kit/node/output' -import { - renderConfirmationPrompt, - RenderConfirmationPromptOptions, - renderError, - renderSuccess, - renderWarning, -} from '@shopify/cli-kit/node/ui' +import {Theme} from '@shopify/cli-kit/node/themes/types' +import {renderConfirmationPrompt, RenderConfirmationPromptOptions, renderError} from '@shopify/cli-kit/node/ui' import {themeEditorUrl, themePreviewUrl} from '@shopify/cli-kit/node/themes/urls' import {cwd, resolvePath} from '@shopify/cli-kit/node/path' import { @@ -28,16 +21,14 @@ import { promptThemeName, UNPUBLISHED_THEME_ROLE, } from '@shopify/cli-kit/node/themes/utils' -import {AbortError} from '@shopify/cli-kit/node/error' -import {Severity} from '@shopify/theme-check-node' -import {recordError, recordTiming} from '@shopify/cli-kit/node/analytics' +import {recordTiming} from '@shopify/cli-kit/node/analytics' +import {commandEventOutputMode, emitCommandEvent} from '@shopify/cli-kit/node/command-events' import {Writable} from 'stream' interface PushOptions { path: string nodelete?: boolean - json?: boolean force?: boolean publish?: boolean ignore?: string[] @@ -48,20 +39,6 @@ interface PushOptions { listing?: string } -interface JsonOutput { - environment?: string - theme: { - id: number - name: string - role: string - shop: string - editor_url: string - preview_url: string - warning?: string - errors?: Result['errors'] - } -} - export interface PushFlags { /** The path to your theme directory. */ path?: string @@ -132,8 +109,8 @@ export interface PushFlags { * * @param flags - The flags for the push operation. */ -export async function push( - flags: PushFlags, +export async function executeThemePush( + flags: Omit, adminSession?: AdminSession, multiEnvironment?: boolean, context?: {stdout?: Writable; stderr?: Writable}, @@ -144,23 +121,6 @@ export async function push( const session = adminSession ?? (await ensureAuthenticatedThemes(ensureThemeStore({store: flags.store}), flags.password)) - if (flags.strict) { - const outputType = flags.json ? 'json' : 'text' - const {offenses} = await runThemeCheck(flags.path ?? cwd(), outputType) - - if (offenses.length > 0) { - const errorOffenses = offenses.filter((offense) => offense.severity === Severity.ERROR) - if (errorOffenses.length > 0) { - throw recordError( - new AbortError( - environment - ? `[${environment}] Theme check failed. Please fix the errors before pushing.` - : 'Theme check failed. Please fix the errors before pushing.', - ), - ) - } - } - } recordTiming('theme-service:push:setup') configureCLIEnvironment({ @@ -189,7 +149,7 @@ export async function push( recordTiming('theme-service:push:setup') - await executePush( + return executePush( selectedTheme, session, { @@ -197,7 +157,6 @@ export async function push( environment, force, ignore: flags.ignore ?? [], - json: flags.json ?? false, multiEnvironment, nodelete: flags.nodelete ?? false, only: flags.only ?? [], @@ -245,57 +204,13 @@ async function executePush( await themePublish(theme.id, session) } - await handlePushOutput(uploadResults, theme, session, options) -} - -/** - * Checks if there are any upload errors in the results. - * - * @param results - The map of upload results. - * @returns {boolean} - Returns true if there are any upload errors, otherwise false. - */ -function hasUploadErrors(results: Map): boolean { - for (const [_key, result] of results.entries()) { - if (!result.success) { - return true - } + const errors: Record = {} + for (const [key, result] of uploadResults) { + if (!result.success && result.errors?.asset) errors[key] = result.errors.asset } - return false -} -/** - * Handles the output based on the push operation results. - * - * @param results - The map of upload results. - * @param theme - The theme being pushed. - * @param session - The admin session for the theme. - * @param options - The options for the push operation. - */ -async function handlePushOutput( - results: Map, - theme: Theme, - session: AdminSession, - options: PushOptions, -) { - if (options.json) { - handleJsonOutput(theme, session, results, options.environment) - } else if (options.publish) { - handlePublishOutput(session, results, options.environment) - } else { - handleOutput(theme, session, results, options.environment) - } -} - -/** - * Handles the JSON output for the push operation. - * - * @param theme - The theme being pushed. - * @param session - The admin session for the theme. - * @param results - The map of upload results. - */ -function handleJsonOutput(theme: Theme, session: AdminSession, results: Map, environment?: string) { - const output: JsonOutput = { - environment, + return themePushResultSchema.parse({ + environment: options.environment, theme: { id: theme.id, name: theme.name, @@ -304,86 +219,10 @@ function handleJsonOutput(theme: Theme, session: AdminSession, results: Map = {} - for (const [key, result] of results.entries()) { - if (!result.success && result.errors?.asset) { - errors[key] = result.errors.asset - } - } - if (Object.keys(errors).length > 0) { - output.theme.errors = errors - } - } - outputResult(JSON.stringify(output)) -} - -/** - * Handles the output for the publish operation. - * - * @param session - The admin session for the theme. - * @param results - The map of upload results. - */ -function handlePublishOutput(session: AdminSession, results: Map, environment?: string) { - const header = environment ? [{subdued: `Environment: ${environment}\n\n`}] : [] - - const hasErrors = hasUploadErrors(results) - if (hasErrors) { - renderWarning({ - body: [...header, `Your theme was published with errors and is now live at https://${session.storeFqdn}`], - }) - } else { - renderSuccess({body: [...header, `Your theme is now live at https://${session.storeFqdn}`]}) - } -} - -/** - * Handles the output for the push operation. - * - * @param theme - The theme being pushed. - * @param session - The admin session for the theme. - * @param results - The map of upload results. - */ -function handleOutput(theme: Theme, session: AdminSession, results: Map, environment?: string) { - const header = environment ? [{subdued: `Environment: ${environment}\n\n`}] : [] - - const hasErrors = hasUploadErrors(results) - const nextSteps = [ - [ - { - link: { - label: 'View your theme', - url: themePreviewUrl(theme, session), - }, - }, - ], - [ - { - link: { - label: 'Customize your theme at the theme editor', - url: themeEditorUrl(theme, session), - }, - }, - ], - ] - - if (hasErrors) { - renderWarning({ - body: [...header, 'The theme', ...themeComponent(theme), 'was pushed with errors'], - nextSteps, - }) - } else { - renderSuccess({ - body: [...header, 'The theme', ...themeComponent(theme), 'was pushed successfully.'], - nextSteps, - }) - } + published: options.publish ?? false, + hasErrors: [...uploadResults.values()].some((result) => !result.success), + errors, + }) } export async function createOrSelectTheme( @@ -439,13 +278,19 @@ async function confirmPushToTheme( } if (multiEnvironment) { - renderError({ - headline: `Environment: ${environment}`, - body: [ - `Can't push theme files to the live theme on ${storeFqdn}`, - 'Use the --allow-live flag to push to a live theme.', - ], - }) + const body = [ + `Can't push theme files to the live theme on ${storeFqdn}`, + 'Use the --allow-live flag to push to a live theme.', + ] + if (commandEventOutputMode() === 'json') { + emitCommandEvent({ + type: 'diagnostic', + level: 'error', + message: `Environment: ${environment}\n${body.join('\n')}`, + }) + } else { + renderError({headline: `Environment: ${environment}`, body}) + } return false } @@ -459,3 +304,17 @@ async function confirmPushToTheme( } return true } + +/** Compatibility adapter for callers of the exported theme API. */ +export async function push( + flags: PushFlags, + adminSession?: AdminSession, + multiEnvironment?: boolean, + context?: {stdout?: Writable; stderr?: Writable}, +): Promise { + const session = + adminSession ?? (await ensureAuthenticatedThemes(ensureThemeStore({store: flags.store}), flags.password)) + await checkThemeBeforePush(flags, true) + const result = await executeThemePush(flags, session, multiEnvironment, context) + if (result) renderThemePushResult(result, flags.json ? 'json' : 'text') +} diff --git a/packages/theme/src/cli/services/push/execute.test.ts b/packages/theme/src/cli/services/push/execute.test.ts new file mode 100644 index 00000000000..65b548efa2c --- /dev/null +++ b/packages/theme/src/cli/services/push/execute.test.ts @@ -0,0 +1,89 @@ +import {executeThemePush} from '../push.js' +import {findOrSelectTheme} from '../../utilities/theme-selector.js' +import {uploadTheme} from '../../utilities/theme-uploader.js' +import {Operation} from '@shopify/cli-kit/node/themes/types' +import {inTemporaryDirectory} from '@shopify/cli-kit/node/fs' +import {buildTheme} from '@shopify/cli-kit/node/themes/factories' +import {fetchChecksums, themePublish} from '@shopify/cli-kit/node/themes/api' +import {renderConfirmationPrompt} from '@shopify/cli-kit/node/ui' +import {mockAndCaptureOutput} from '@shopify/cli-kit/node/testing/output' +import {describe, expect, test, vi} from 'vitest' + +vi.mock('../../utilities/theme-selector.js') +vi.mock('../../utilities/theme-uploader.js') +vi.mock('@shopify/cli-kit/node/themes/api') +vi.mock('@shopify/cli-kit/node/ui', async (importOriginal) => ({ + ...(await importOriginal()), + renderConfirmationPrompt: vi.fn(), +})) + +const session = {storeFqdn: 'test.myshopify.com', token: 'token'} + +describe('push execution', () => { + test('returns transfer facts without rendering a final result', async () => { + await inTemporaryDirectory(async (path) => { + vi.mocked(findOrSelectTheme).mockResolvedValue(buildTheme({id: 1, name: 'Theme', role: 'unpublished'})!) + vi.mocked(fetchChecksums).mockResolvedValue([]) + vi.mocked(uploadTheme).mockImplementation((_theme, _session, _checksums, fileSystem) => ({ + workPromise: Promise.resolve(), + uploadResults: new Map([ + [ + 'assets/theme.css', + {key: 'assets/theme.css', operation: Operation.Upload, success: false, errors: {asset: ['bad CSS']}}, + ], + ['assets/valid.js', {key: 'assets/valid.js', operation: Operation.Upload, success: true}], + ]), + renderThemeSyncProgress: async () => { + await fileSystem.ready() + }, + })) + const output = mockAndCaptureOutput() + output.clear() + const result = await executeThemePush({path, force: true, publish: true, environment: ['staging']}, session) + expect(result).toEqual({ + environment: 'staging', + theme: { + id: 1, + name: 'Theme', + role: 'unpublished', + shop: session.storeFqdn, + editor_url: 'https://test.myshopify.com/admin/themes/1/editor', + preview_url: 'https://test.myshopify.com?preview_theme_id=1', + }, + published: true, + hasErrors: true, + errors: {'assets/theme.css': ['bad CSS']}, + }) + expect(themePublish).toHaveBeenCalledWith(1, session) + expect(output.output()).toBe('') + expect(output.info()).not.toContain('was pushed') + }) + }) + + test('does not upload or publish after live-theme confirmation is declined', async () => { + await inTemporaryDirectory(async (path) => { + vi.mocked(findOrSelectTheme).mockResolvedValue(buildTheme({id: 1, name: 'Theme', role: 'live'})!) + vi.mocked(renderConfirmationPrompt).mockResolvedValue(false) + await expect(executeThemePush({path, force: true, publish: true}, session)).resolves.toBeUndefined() + expect(uploadTheme).not.toHaveBeenCalled() + expect(themePublish).not.toHaveBeenCalled() + }) + }) + + test('propagates publish failures without producing a result', async () => { + await inTemporaryDirectory(async (path) => { + vi.mocked(findOrSelectTheme).mockResolvedValue(buildTheme({id: 1, name: 'Theme', role: 'unpublished'})!) + vi.mocked(fetchChecksums).mockResolvedValue([]) + vi.mocked(uploadTheme).mockImplementation((_theme, _session, _checksums, fileSystem) => ({ + workPromise: Promise.resolve(), + uploadResults: new Map(), + renderThemeSyncProgress: async () => { + await fileSystem.ready() + }, + })) + const failure = new Error('publish failed') + vi.mocked(themePublish).mockRejectedValue(failure) + await expect(executeThemePush({path, force: true, publish: true}, session)).rejects.toBe(failure) + }) + }) +}) diff --git a/packages/theme/src/cli/services/push/result.test.ts b/packages/theme/src/cli/services/push/result.test.ts new file mode 100644 index 00000000000..98c335ebf25 --- /dev/null +++ b/packages/theme/src/cli/services/push/result.test.ts @@ -0,0 +1,156 @@ +import { + renderThemePushResult, + themePushJsonResult, + checkThemeBeforePush, + renderThemePushEnvironmentResults, +} from './result.js' +import {themePushJsonOutputSchema, type ThemePushResult} from './types.js' +import {renderThrownError} from '../../utilities/errors.js' +import {runThemeCheck} from '../../commands/theme/check.js' +import {describe, expect, test, vi} from 'vitest' +import {runWithCommandEvents, renderCommandEventAsJson} from '@shopify/cli-kit/node/command-events' +import {mockAndCaptureOutput, withCapturedStandardStreams} from '@shopify/cli-kit/node/testing/output' +import {Severity, SourceCodeType} from '@shopify/theme-check-node' + +vi.mock('../../commands/theme/check.js') + +function pushResult(overrides: Partial = {}): ThemePushResult { + return { + theme: { + id: 1, + name: 'Theme', + role: 'unpublished', + shop: 'test.myshopify.com', + editor_url: 'https://test.myshopify.com/admin/themes/1/editor', + preview_url: 'https://test.myshopify.com?preview_theme_id=1', + }, + published: false, + hasErrors: false, + errors: {}, + ...overrides, + } +} + +describe('push result', () => { + test('writes compact JSON with a success status and omits absent environment and error fields', () => { + expect(themePushJsonOutputSchema.encode(themePushJsonResult(pushResult()))).toBe( + '{"status":"success","theme":{"id":1,"name":"Theme","role":"unpublished","shop":"test.myshopify.com","editor_url":"https://test.myshopify.com/admin/themes/1/editor","preview_url":"https://test.myshopify.com?preview_theme_id=1"}}', + ) + }) + + test('preserves environment, warning, asset errors and their order', () => { + const result = pushResult({ + environment: 'staging', + hasErrors: true, + errors: {'assets/z.css': ['bad CSS'], 'layout/a.liquid': []}, + }) + expect(themePushJsonOutputSchema.encode(themePushJsonResult(result))).toBe( + '{"status":"failed","environment":"staging","theme":{"id":1,"name":"Theme","role":"unpublished","shop":"test.myshopify.com","editor_url":"https://test.myshopify.com/admin/themes/1/editor","preview_url":"https://test.myshopify.com?preview_theme_id=1","warning":"[staging] The theme \'Theme\' was pushed with errors","errors":{"assets/z.css":["bad CSS"],"layout/a.liquid":[]}}}', + ) + }) + + test('warns without an errors field for failed uploads without asset errors', () => { + const encoded = JSON.parse(themePushJsonOutputSchema.encode(themePushJsonResult(pushResult({hasErrors: true})))) + expect(encoded.status).toBe('failed') + expect(encoded.theme.warning).toBe("The theme 'Theme' was pushed with errors") + expect(encoded.theme).not.toHaveProperty('errors') + }) + + test('reports each environment independently without an overall success status', async () => { + await withCapturedStandardStreams(async ({stdout}) => { + renderThemePushEnvironmentResults([ + {environment: 'production', result: pushResult()}, + {environment: 'staging', result: pushResult({hasErrors: true})}, + {environment: 'cancelled', result: undefined}, + ]) + expect( + JSON.parse(stdout()).map(({environment, status}: {environment: string; status: string}) => ({ + environment, + status, + })), + ).toEqual([ + {environment: 'production', status: 'success'}, + {environment: 'staging', status: 'failed'}, + ]) + }) + }) + + test('requires environment identity for array entries', () => { + expect(() => themePushJsonOutputSchema.validate([themePushJsonResult(pushResult())])).toThrow() + expect(themePushJsonOutputSchema.encode([])).toBe('[]') + }) + + test.each([{id: '1'}, {name: null}, {role: false}, {errors: {file: 'error'}}])( + 'rejects malformed theme fields %j', + (fields) => { + expect(() => + themePushJsonOutputSchema.validate({status: 'success', theme: {...pushResult().theme, ...fields}}), + ).toThrow() + }, + ) + + test.each([false, true])('keeps JSON unchanged when publish is %s', (published) => { + expect(themePushJsonResult(pushResult({published}))).toEqual(themePushJsonResult(pushResult())) + }) + + test.each([ + [false, false, 'was pushed successfully.'], + [false, true, 'was pushed with errors'], + [true, false, 'Your theme is now live at https://test.myshopify.com'], + [true, true, 'Your theme was published with errors and is now live at https://test.myshopify.com'], + ] as const)('preserves text (published=%s, errors=%s)', (published, hasErrors, message) => { + const output = mockAndCaptureOutput() + output.clear() + renderThemePushResult(pushResult({published, hasErrors, environment: 'staging'}), 'text') + const text = `${output.info()}${output.warn()}` + expect(text.replace(/│/g, '').replace(/\s+/g, ' ')).toContain(message) + expect(text).toContain('Environment: staging') + }) + + test('writes one result to stdout and typed upload diagnostics to stderr', async () => { + const result = pushResult({hasErrors: true, errors: {'assets/theme.css': ['bad CSS']}}) + await withCapturedStandardStreams(async ({stdout, stderr}) => { + await runWithCommandEvents({outputMode: 'json', sink: renderCommandEventAsJson}, () => { + renderThrownError('assets/theme.css', new Error('bad CSS')) + renderThemePushResult(result, 'json') + }) + + expect(stdout()).toBe(`${themePushJsonOutputSchema.encode(themePushJsonResult(result))}\n`) + expect(JSON.parse(stderr())).toMatchObject({ + type: 'diagnostic', + level: 'error', + message: 'assets/theme.css\nbad CSS', + }) + }) + }) + + test.each([ + [Severity.WARNING, 'warning'], + [Severity.INFO, 'info'], + ] as const)('keeps strict-check severity %s off stdout', async (severity, level) => { + vi.mocked(runThemeCheck).mockResolvedValue({ + offenses: [ + { + severity, + message: 'warning', + type: SourceCodeType.LiquidHtml, + check: 'check', + uri: 'file:///theme.liquid', + start: {index: 0, line: 0, character: 0}, + end: {index: 1, line: 0, character: 1}, + }, + ], + theme: [], + }) + await withCapturedStandardStreams(async ({stdout, stderr}) => { + await runWithCommandEvents({outputMode: 'json', sink: renderCommandEventAsJson}, async () => { + await checkThemeBeforePush({strict: true, json: true, path: '/theme'}) + renderThemePushResult(pushResult(), 'json') + }) + + expect(runThemeCheck).toHaveBeenCalledWith('/theme', 'silent') + expect(JSON.parse(stdout())).toEqual(themePushJsonResult(pushResult())) + expect(JSON.parse(stderr())).toMatchObject({type: 'diagnostic', level, code: 'check'}) + }) + }) +}) diff --git a/packages/theme/src/cli/services/push/result.ts b/packages/theme/src/cli/services/push/result.ts new file mode 100644 index 00000000000..daa9352a6a9 --- /dev/null +++ b/packages/theme/src/cli/services/push/result.ts @@ -0,0 +1,102 @@ +import { + themePushJsonOutputSchema, + themePushResultSchema, + type ThemePushResult, + type ThemePushJsonResult, +} from './types.js' +import {themeComponent} from '../../utilities/theme-ui.js' +import {PushFlags} from '../push.js' +import {runThemeCheck} from '../../commands/theme/check.js' +import {outputResult} from '@shopify/cli-kit/node/output' +import {renderSuccess, renderWarning} from '@shopify/cli-kit/node/ui' +import {emitCommandEvent} from '@shopify/cli-kit/node/command-events' +import {cwd} from '@shopify/cli-kit/node/path' +import {AbortError} from '@shopify/cli-kit/node/error' +import {recordError} from '@shopify/cli-kit/node/analytics' +import {Severity} from '@shopify/theme-check-node' + +export function themePushJsonResult(result: ThemePushResult): ThemePushJsonResult { + const {environment, theme, hasErrors, errors} = result + return { + status: hasErrors ? 'failed' : 'success', + environment, + theme: { + ...theme, + ...(hasErrors + ? {warning: `${environment ? `[${environment}] ` : ''}The theme '${theme.name}' was pushed with errors`} + : {}), + ...(Object.keys(errors).length > 0 ? {errors} : {}), + }, + } +} + +export function renderThemePushResult(result: ThemePushResult, format: 'text' | 'json'): void { + if (format === 'json') { + outputResult(themePushJsonOutputSchema.encode(themePushJsonResult(result))) + return + } + + const {environment, theme, hasErrors, published} = result + const header = environment ? [{subdued: `Environment: ${environment}\n\n`}] : [] + const render = hasErrors ? renderWarning : renderSuccess + if (published) { + render({ + body: [ + ...header, + hasErrors + ? `Your theme was published with errors and is now live at https://${theme.shop}` + : `Your theme is now live at https://${theme.shop}`, + ], + }) + return + } + + render({ + body: [ + ...header, + 'The theme', + ...themeComponent(theme), + hasErrors ? 'was pushed with errors' : 'was pushed successfully.', + ], + nextSteps: [ + [{link: {label: 'View your theme', url: theme.preview_url}}], + [{link: {label: 'Customize your theme at the theme editor', url: theme.editor_url}}], + ], + }) +} + +export async function checkThemeBeforePush(flags: PushFlags, legacyOutput = false): Promise { + if (!flags.strict) return + const environment = flags.environment?.[0] + const jsonFormat = legacyOutput ? 'json' : 'silent' + const outputFormat = flags.json ? jsonFormat : 'text' + const {offenses} = await runThemeCheck(flags.path ?? cwd(), outputFormat) + if (flags.json && !legacyOutput) { + for (const offense of offenses) { + emitCommandEvent({ + type: 'diagnostic', + level: ({[Severity.ERROR]: 'error', [Severity.WARNING]: 'warning', [Severity.INFO]: 'info'} as const)[ + offense.severity + ], + code: offense.check, + message: `${environment ? `[${environment}] ` : ''}${offense.uri}: ${offense.message}`, + }) + } + } + if (offenses.some((offense) => offense.severity === Severity.ERROR)) { + throw recordError( + new AbortError( + `${environment ? `[${environment}] ` : ''}Theme check failed. Please fix the errors before pushing.`, + ), + ) + } +} + +export function renderThemePushEnvironmentResults(results: {environment: string; result: unknown}[]): void { + const output = results.flatMap(({environment, result}) => + result === undefined + ? [] + : [{...themePushJsonResult({...themePushResultSchema.parse(result), environment}), environment}], + ) + outputResult(themePushJsonOutputSchema.encode(output)) +} diff --git a/packages/theme/src/cli/services/push/types.ts b/packages/theme/src/cli/services/push/types.ts new file mode 100644 index 00000000000..298da3eb0a2 --- /dev/null +++ b/packages/theme/src/cli/services/push/types.ts @@ -0,0 +1,49 @@ +import {ThemeMutationSuccessSchema} from '../theme-mutation/status.js' +import {defineJsonOutputSchema, type InferJsonOutputSchema} from '@shopify/cli-kit/node/json-output-schema' +import {zod} from '@shopify/cli-kit/node/schema' + +const ThemePushThemeSchema = zod.object({ + id: zod.number(), + name: zod.string(), + role: zod.string(), + shop: zod.string(), + editor_url: zod.string(), + preview_url: zod.string(), +}) + +const ThemePushJsonThemeSchema = ThemePushThemeSchema.extend({ + warning: zod.string().optional(), + errors: zod.record(zod.array(zod.string())).optional(), +}) + +const ThemePushJsonResultSchema = zod.object({ + status: zod.union([ThemeMutationSuccessSchema.shape.status, zod.literal('failed')]), + environment: zod.string().optional(), + theme: ThemePushJsonThemeSchema, +}) + +const outputSchema = defineJsonOutputSchema({ + name: 'ThemePushJsonResult', + schema: zod.union([ + ThemePushJsonResultSchema, + zod.array(ThemePushJsonResultSchema.extend({environment: zod.string()})), + ]), + definitions: {ThemePushTheme: ThemePushJsonThemeSchema}, +}) + +export const themePushJsonOutputSchema: typeof outputSchema = { + ...outputSchema, + // Preserve the compact JSON emitted by theme push. + encode: (result: InferJsonOutputSchema) => JSON.stringify(outputSchema.validate(result)), +} + +export const themePushResultSchema = zod.object({ + environment: zod.string().optional(), + theme: ThemePushThemeSchema, + published: zod.boolean(), + hasErrors: zod.boolean(), + errors: zod.record(zod.array(zod.string())), +}) + +export type ThemePushResult = zod.infer +export type ThemePushJsonResult = zod.infer diff --git a/packages/theme/src/cli/utilities/errors.ts b/packages/theme/src/cli/utilities/errors.ts index 7fef4661b07..03d98510df5 100644 --- a/packages/theme/src/cli/utilities/errors.ts +++ b/packages/theme/src/cli/utilities/errors.ts @@ -1,3 +1,4 @@ +import {commandEventOutputMode, emitCommandEvent} from '@shopify/cli-kit/node/command-events' import {AbortError} from '@shopify/cli-kit/node/error' import {outputDebug} from '@shopify/cli-kit/node/output' import {recordError} from '@shopify/cli-kit/node/analytics' @@ -12,6 +13,11 @@ import {createError as createH3Error, type H3Error} from 'h3' export function renderThrownError(headline: string, error: Error | AbortError) { recordError(error) + if (commandEventOutputMode() === 'json') { + emitCommandEvent({type: 'diagnostic', level: 'error', message: `${headline}\n${error.message}`}) + return + } + if (error instanceof AbortError) { error.message = `${headline}\n${error.message}` renderFatalError(error) diff --git a/packages/theme/src/cli/utilities/theme-transfer-events.test.ts b/packages/theme/src/cli/utilities/theme-transfer-events.test.ts new file mode 100644 index 00000000000..2e4b566bc6e --- /dev/null +++ b/packages/theme/src/cli/utilities/theme-transfer-events.test.ts @@ -0,0 +1,111 @@ +import {uploadTheme} from './theme-uploader.js' +import {mountThemeFileSystem} from './theme-fs.js' +import {withCapturedStandardStreams} from '@shopify/cli-kit/node/testing/output' +import {inTemporaryDirectory, mkdir, writeFile} from '@shopify/cli-kit/node/fs' +import {joinPath} from '@shopify/cli-kit/node/path' +import {bulkUploadThemeAssets, deleteThemeAssets} from '@shopify/cli-kit/node/themes/api' +import {Operation} from '@shopify/cli-kit/node/themes/types' +import {buildTheme} from '@shopify/cli-kit/node/themes/factories' +import {runWithCommandEvents, renderCommandEventAsJson} from '@shopify/cli-kit/node/command-events' +import {expect, test, vi} from 'vitest' + +vi.mock('@shopify/cli-kit/node/themes/api') + +test('reports upload polling as updates until the upload finishes', async () => { + await inTemporaryDirectory(async (path) => { + await mkdir(joinPath(path, 'assets')) + await writeFile(joinPath(path, 'assets/theme.css'), 'body {}') + let finishUpload!: () => void + const pendingUpload = new Promise((resolve) => { + finishUpload = resolve + }) + let startedUpload!: () => void + const uploadStarted = new Promise((resolve) => { + startedUpload = resolve + }) + vi.mocked(bulkUploadThemeAssets).mockImplementation(async (_themeId, assets) => { + if (!assets.some((asset) => asset.key === 'assets/theme.css')) return [] + startedUpload() + await pendingUpload + return [{key: 'assets/theme.css', operation: Operation.Upload, success: true}] + }) + vi.mocked(deleteThemeAssets).mockResolvedValue([]) + const sink = vi.fn() + vi.useFakeTimers({toFake: ['setTimeout', 'clearTimeout']}) + const rendering = runWithCommandEvents({outputMode: 'json', sink}, async () => { + const upload = uploadTheme( + buildTheme({id: 1, name: 'Theme', role: 'unpublished'})!, + {storeFqdn: 'test.myshopify.com', token: 'token'}, + [], + mountThemeFileSystem(path), + ) + await upload.renderThemeSyncProgress() + }) + + try { + await uploadStarted + await vi.advanceTimersByTimeAsync(2000) + const events = sink.mock.calls.map(([event]) => event).filter((event) => event.type === 'progress') + expect(events.map((event) => event.status)).toEqual(['started', 'updated', 'updated']) + expect(new Set(events.map((event) => event.operation)).size).toBe(1) + expect(events[0].message).toBe('Uploading files to remote theme [0%]') + finishUpload() + await rendering + const uploadEvents = sink.mock.calls + .map(([event]) => event) + .filter((event) => event.operation === events[0].operation) + expect(uploadEvents.map((event) => event.status)).toEqual(['started', 'updated', 'updated', 'completed']) + expect(uploadEvents.at(-1).message).toBe('Uploading files to remote theme [100%]') + } finally { + finishUpload() + vi.clearAllTimers() + vi.useRealTimers() + await rendering + } + }) +}) + +test('uploads real local files with typed progress and failure diagnostics on stderr', async () => { + await inTemporaryDirectory(async (path) => { + await mkdir(joinPath(path, 'assets')) + await writeFile(joinPath(path, 'assets/theme.css'), 'body {}') + const theme = buildTheme({id: 1, name: 'Theme', role: 'unpublished'})! + vi.mocked(bulkUploadThemeAssets).mockResolvedValue([ + {key: 'assets/theme.css', operation: Operation.Upload, success: false, errors: {asset: ['bad CSS']}}, + ]) + vi.mocked(deleteThemeAssets).mockResolvedValue([]) + await withCapturedStandardStreams(async ({stdout, stderr}) => { + await runWithCommandEvents({outputMode: 'json', sink: renderCommandEventAsJson}, async () => { + const upload = uploadTheme( + theme, + {storeFqdn: 'test.myshopify.com', token: 'token'}, + [], + mountThemeFileSystem(path), + ) + await upload.renderThemeSyncProgress() + expect(upload.uploadResults.get('assets/theme.css')?.success).toBe(false) + }) + + expect(stdout()).toBe('') + const events = stderr() + .trim() + .split('\n') + .map((line) => JSON.parse(line)) + expect(events).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + type: 'progress', + status: 'started', + message: expect.stringContaining('Uploading files to remote theme'), + }), + expect.objectContaining({ + type: 'progress', + status: 'completed', + message: expect.stringContaining('Cleaning your remote theme'), + }), + expect.objectContaining({type: 'diagnostic', level: 'error', message: 'assets/theme.css\nbad CSS'}), + ]), + ) + }) + }) +}) diff --git a/packages/theme/src/cli/utilities/theme-ui.ts b/packages/theme/src/cli/utilities/theme-ui.ts index 7362eb36e0a..86ad247729c 100644 --- a/packages/theme/src/cli/utilities/theme-ui.ts +++ b/packages/theme/src/cli/utilities/theme-ui.ts @@ -1,3 +1,4 @@ +import {commandEventOutputMode, emitCommandEvent} from '@shopify/cli-kit/node/command-events' import {recordEvent} from '@shopify/cli-kit/node/analytics' import {Theme} from '@shopify/cli-kit/node/themes/types' import {LIVE_THEME_ROLE} from '@shopify/cli-kit/node/themes/utils' @@ -7,7 +8,7 @@ import {AbortError} from '@shopify/cli-kit/node/error' import {isInputDisabled} from '@shopify/cli-kit/node/no-input' import {Writable} from 'stream' -export function themeComponent(theme: Theme) { +export function themeComponent(theme: Pick) { return [ `'${theme.name}'`, { @@ -33,14 +34,26 @@ export async function ensureDirectoryConfirmed( } if (multiEnvironment) { - renderError({ - headline: environment ? `Environment: ${environment}` : '', - body: message, - }) + if (commandEventOutputMode() === 'json') { + emitCommandEvent({ + type: 'diagnostic', + level: 'error', + message: `${environment ? `Environment: ${environment}\n` : ''}${message}`, + }) + } else { + renderError({ + headline: environment ? `Environment: ${environment}` : '', + body: message, + }) + } return false } - renderWarning({body: message}) + if (commandEventOutputMode() === 'json') { + emitCommandEvent({type: 'diagnostic', level: 'warning', message}) + } else { + renderWarning({body: message}) + } if (isInputDisabled()) { throw new AbortError( diff --git a/packages/theme/src/cli/utilities/theme-uploader.ts b/packages/theme/src/cli/utilities/theme-uploader.ts index 49522d71faf..fd80330d105 100644 --- a/packages/theme/src/cli/utilities/theme-uploader.ts +++ b/packages/theme/src/cli/utilities/theme-uploader.ts @@ -130,7 +130,7 @@ function createIntervalTask({ const addNextCheck = () => { tasks.push({ title: titleGetter(), - task: async () => { + task: async (_context, task) => { const result = await Promise.race([ promise, new Promise((resolve) => setTimeout(() => resolve('timeout'), updateInterval)), @@ -138,6 +138,8 @@ function createIntervalTask({ if (result === 'timeout') { addNextCheck() + } else { + task.title = titleGetter() } }, }) From 8ad0ee6816657580bb8a677f05641da7747a95d0 Mon Sep 17 00:00:00 2001 From: Gonzalo Riestra Date: Tue, 6 Oct 2026 14:11:55 +0200 Subject: [PATCH 02/13] Align theme push JSON with public output conventions --- .changeset/theme-push-json-contract.md | 4 + packages/cli/README.md | 480 ++++++++++-------- packages/cli/oclif.manifest.json | 4 +- .../theme/src/cli/commands/theme/push.test.ts | 19 +- packages/theme/src/cli/commands/theme/push.ts | 21 +- packages/theme/src/cli/services/push.test.ts | 41 +- packages/theme/src/cli/services/push.ts | 22 +- .../src/cli/services/push/result.test.ts | 81 ++- .../theme/src/cli/services/push/result.ts | 38 +- packages/theme/src/cli/services/push/types.ts | 75 ++- 10 files changed, 399 insertions(+), 386 deletions(-) create mode 100644 .changeset/theme-push-json-contract.md diff --git a/.changeset/theme-push-json-contract.md b/.changeset/theme-push-json-contract.md new file mode 100644 index 00000000000..93f3a4d10ce --- /dev/null +++ b/.changeset/theme-push-json-contract.md @@ -0,0 +1,4 @@ +--- +"@shopify/cli": major +--- +Use string theme IDs, camelCase fields, complete environment batches and partial upload results in theme push JSON. diff --git a/packages/cli/README.md b/packages/cli/README.md index 8706112c18c..ec95d09fe26 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -14782,13 +14782,16 @@ DESCRIPTION ```json { + "status": "success", + "changed": true, + "issues": [], "theme": { - "id": 108267175958, + "id": "108267175958", "name": "MyTheme", "role": "unpublished", - "shop": "mystore.myshopify.com", - "editor_url": "https://mystore.myshopify.com/admin/themes/108267175958/editor", - "preview_url": "https://mystore.myshopify.com/?preview_theme_id=108267175958" + "storeDomain": "mystore.myshopify.com", + "editorUrl": "https://mystore.myshopify.com/admin/themes/108267175958/editor", + "previewUrl": "https://mystore.myshopify.com/?preview_theme_id=108267175958" } } ``` @@ -14798,188 +14801,6 @@ DESCRIPTION Output from `--json` conforms to the `ThemePushJsonResult` schema. - ```json - { - "anyOf": [ - { - "type": "object", - "properties": { - "status": { - "type": "string", - "enum": [ - "success", - "failed" - ] - }, - "environment": { - "type": "string" - }, - "theme": { - "$ref": "#/definitions/ThemePushTheme" - } - }, - "required": [ - "status", - "theme" - ], - "additionalProperties": false - }, - { - "type": "array", - "items": { - "type": "object", - "properties": { - "status": { - "$ref": "#/definitions/ThemePushJsonResult/anyOf/0/properties/status" - }, - "environment": { - "type": "string" - }, - "theme": { - "$ref": "#/definitions/ThemePushTheme" - } - }, - "required": [ - "status", - "environment", - "theme" - ], - "additionalProperties": false - } - } - ], - "title": "ThemePushJsonResult", - "definitions": { - "ThemePushTheme": { - "type": "object", - "properties": { - "id": { - "type": "number" - }, - "name": { - "type": "string" - }, - "role": { - "type": "string" - }, - "shop": { - "type": "string" - }, - "editor_url": { - "type": "string" - }, - "preview_url": { - "type": "string" - }, - "warning": { - "type": "string" - }, - "errors": { - "type": "object", - "additionalProperties": { - "type": "array", - "items": { - "type": "string" - } - } - } - }, - "required": [ - "id", - "name", - "role", - "shop", - "editor_url", - "preview_url" - ], - "additionalProperties": false - } - }, - "$schema": "http://json-schema.org/draft-07/schema#" - } - ``` -``` - -## `shopify theme rename` - -Renames an existing theme. - -``` -USAGE - $ shopify theme rename [--auth-alias ] [-d] [-e ...] [-j] [--json-schema] [-l] [-n ] - [--no-color] [--no-input] [--password ] [--path ] [-s ] [-t ] [--verbose] - -FLAGS - -d, --development - Rename your development theme. Use --development, --live, or --theme in non-interactive environments. - [env: SHOPIFY_FLAG_DEVELOPMENT] - - -e, --environment=... - The environment to apply to the current command. - [env: SHOPIFY_FLAG_ENVIRONMENT] - - -j, --json - Output the result as JSON. Automatically disables color output. - [env: SHOPIFY_FLAG_JSON] - - -l, --live - Rename your remote live theme. Use --development, --live, or --theme in non-interactive environments. - [env: SHOPIFY_FLAG_LIVE] - - -n, --name= - The new name for the theme. Required if non interactive. - [env: SHOPIFY_FLAG_NEW_NAME] - - -s, --store= - Store URL. It can be the store prefix (example) or the full myshopify.com URL (example.myshopify.com, - https://example.myshopify.com). - [env: SHOPIFY_FLAG_STORE] - - -t, --theme= - Theme ID or name of the remote theme. Use --development, --live, or --theme in non-interactive environments. - [env: SHOPIFY_FLAG_THEME_ID] - - --auth-alias= - Alias of the Shopify account to use for authentication. - [env: SHOPIFY_FLAG_AUTH_ALIAS] - - --json-schema - Print the command's JSON schemas. - [env: SHOPIFY_FLAG_JSON_SCHEMA] - - --no-color - Disable color output. - [env: SHOPIFY_FLAG_NO_COLOR] - - --no-input - Disable interactive prompts and browser authentication. - [env: SHOPIFY_FLAG_NO_INPUT] - - --password= - Password generated from the Theme Access app or an Admin API token. - [env: SHOPIFY_CLI_THEME_TOKEN] - - --path= - The path where you want to run the command. Defaults to the current working directory. - [env: SHOPIFY_FLAG_PATH] - - --verbose - Increase the verbosity of the output. May include sensitive data. - [env: SHOPIFY_FLAG_VERBOSE] - -DESCRIPTION - Renames an existing theme. - - Renames a theme in your store. - - If no theme is specified, then you're prompted to select the theme that you want to rename from the list of themes in - your store. - - - Use `--json-schema` to print the result, error, and event schemas. - - Output from `--json` conforms to the `ThemeRenameResult` schema. - ```json { "anyOf": [ @@ -15002,9 +14823,9 @@ DESCRIPTION "additionalProperties": false } ], - "title": "ThemeRenameResult", + "title": "ThemePushJsonResult", "definitions": { - "Theme": { + "ThemePushTheme": { "type": "object", "properties": { "id": { @@ -15030,10 +14851,18 @@ DESCRIPTION } ] }, - "processing": { - "type": "boolean" + "previewUrl": { + "anyOf": [ + { + "type": "string", + "format": "uri" + }, + { + "type": "null" + } + ] }, - "sourceUrl": { + "editorUrl": { "anyOf": [ { "type": "string", @@ -15050,11 +14879,10 @@ DESCRIPTION "name", "role", "storeDomain", - "processing", - "sourceUrl" + "previewUrl", + "editorUrl" ], - "additionalProperties": false, - "description": "The selected theme projection; sourceUrl is null when the upstream source URL is unavailable." + "additionalProperties": false }, "ThemeEnvironment": { "anyOf": [ @@ -15067,29 +14895,70 @@ DESCRIPTION "result": { "anyOf": [ { - "type": "object", - "properties": { - "status": { - "type": "string", - "const": "success" - }, - "changed": { - "type": "boolean" - }, - "originalName": { - "type": "string" + "anyOf": [ + { + "type": "object", + "properties": { + "status": { + "type": "string", + "enum": [ + "success", + "partial" + ] + }, + "changed": { + "type": "boolean" + }, + "theme": { + "$ref": "#/definitions/ThemePushTheme" + }, + "issues": { + "type": "array", + "items": { + "type": "object", + "properties": { + "filePath": { + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "filePath", + "message" + ], + "additionalProperties": false + } + } + }, + "required": [ + "status", + "changed", + "theme", + "issues" + ], + "additionalProperties": false }, - "theme": { - "$ref": "#/definitions/Theme" + { + "type": "object", + "properties": { + "status": { + "type": "string", + "const": "skipped" + }, + "reason": { + "type": "string", + "const": "unsafe-directory" + } + }, + "required": [ + "status", + "reason" + ], + "additionalProperties": false } - }, - "required": [ - "status", - "changed", - "originalName", - "theme" - ], - "additionalProperties": false + ] }, { "type": "object", @@ -15281,6 +15150,185 @@ DESCRIPTION ``` ``` +## `shopify theme rename` + +Renames an existing theme. + +``` +USAGE + $ shopify theme rename [--auth-alias ] [-d] [-e ...] [-j] [--json-schema] [-l] [-n ] + [--no-color] [--no-input] [--password ] [--path ] [-s ] [-t ] [--verbose] + +FLAGS + -d, --development + Rename your development theme. Use --development, --live, or --theme in non-interactive environments. + [env: SHOPIFY_FLAG_DEVELOPMENT] + + -e, --environment=... + The environment to apply to the current command. + [env: SHOPIFY_FLAG_ENVIRONMENT] + + -j, --json + Output the result as JSON. Automatically disables color output. + [env: SHOPIFY_FLAG_JSON] + + -l, --live + Rename your remote live theme. Use --development, --live, or --theme in non-interactive environments. + [env: SHOPIFY_FLAG_LIVE] + + -n, --name= + The new name for the theme. Required if non interactive. + [env: SHOPIFY_FLAG_NEW_NAME] + + -s, --store= + Store URL. It can be the store prefix (example) or the full myshopify.com URL (example.myshopify.com, + https://example.myshopify.com). + [env: SHOPIFY_FLAG_STORE] + + -t, --theme= + Theme ID or name of the remote theme. Use --development, --live, or --theme in non-interactive environments. + [env: SHOPIFY_FLAG_THEME_ID] + + --auth-alias= + Alias of the Shopify account to use for authentication. + [env: SHOPIFY_FLAG_AUTH_ALIAS] + + --json-schema + Print the command's JSON schemas. + [env: SHOPIFY_FLAG_JSON_SCHEMA] + + --no-color + Disable color output. + [env: SHOPIFY_FLAG_NO_COLOR] + + --no-input + Disable interactive prompts and browser authentication. + [env: SHOPIFY_FLAG_NO_INPUT] + + --password= + Password generated from the Theme Access app or an Admin API token. + [env: SHOPIFY_CLI_THEME_TOKEN] + + --path= + The path where you want to run the command. Defaults to the current working directory. + [env: SHOPIFY_FLAG_PATH] + + --verbose + Increase the verbosity of the output. May include sensitive data. + [env: SHOPIFY_FLAG_VERBOSE] + +DESCRIPTION + Renames an existing theme. + + Renames a theme in your store. + + If no theme is specified, then you're prompted to select the theme that you want to rename from the list of themes in + your store. + + + Use `--json-schema` to print the result, error, and event schemas. + + Output from `--json` conforms to the `ThemeRenameResult` schema. + + ```json + { + "anyOf": [ + { + "$ref": "#/definitions/ThemeRenameEnvironment/properties/result" + }, + { + "type": "object", + "properties": { + "environments": { + "type": "array", + "items": { + "$ref": "#/definitions/ThemeRenameEnvironment" + } + } + }, + "required": [ + "environments" + ], + "additionalProperties": false + } + ], + "title": "ThemeRenameResult", + "definitions": { + "RenamedTheme": { + "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 + }, + "ThemeRenameEnvironment": { + "type": "object", + "properties": { + "environment": { + "type": "string" + }, + "result": { + "type": "object", + "properties": { + "status": { + "type": "string", + "const": "success" + }, + "originalName": { + "type": "string" + }, + "theme": { + "$ref": "#/definitions/RenamedTheme" + } + }, + "required": [ + "status", + "originalName", + "theme" + ], + "additionalProperties": false + } + }, + "required": [ + "environment", + "result" + ], + "additionalProperties": false + } + }, + "$schema": "http://json-schema.org/draft-07/schema#" + } + ``` +``` + ## `shopify theme share` Creates a shareable, unpublished, and new theme on your theme library with a randomized name. diff --git a/packages/cli/oclif.manifest.json b/packages/cli/oclif.manifest.json index 563c84713d2..d0e043d9dcc 100644 --- a/packages/cli/oclif.manifest.json +++ b/packages/cli/oclif.manifest.json @@ -12620,8 +12620,8 @@ "args": { }, "customPluginName": "@shopify/theme", - "description": "Uploads your local theme files to Shopify, overwriting the remote version if specified.\n\n If no theme is specified, then you're prompted to select the theme to overwrite from the list of the themes in your store.\n\n You 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\n This command returns the following information:\n\n - A link to the \"editor\" (https://shopify.dev/docs/themes/tools/online-editor) for the theme in the Shopify admin.\n - A \"preview link\" (https://help.shopify.com/manual/online-store/themes/adding-themes#share-a-theme-preview-with-others) that you can share with others.\n\n If you use the `--json` flag, then theme information is returned in JSON format, which can be used as a machine-readable input for scripts or continuous integration.\n\n Sample output:\n\n ```json\n {\n \"theme\": {\n \"id\": 108267175958,\n \"name\": \"MyTheme\",\n \"role\": \"unpublished\",\n \"shop\": \"mystore.myshopify.com\",\n \"editor_url\": \"https://mystore.myshopify.com/admin/themes/108267175958/editor\",\n \"preview_url\": \"https://mystore.myshopify.com/?preview_theme_id=108267175958\"\n }\n }\n ```\n \n\nUse `--json-schema` to print the result, error, and event schemas.\n\nOutput from `--json` conforms to the `ThemePushJsonResult` schema.\n\n```json\n{\n \"anyOf\": [\n {\n \"type\": \"object\",\n \"properties\": {\n \"status\": {\n \"type\": \"string\",\n \"enum\": [\n \"success\",\n \"failed\"\n ]\n },\n \"environment\": {\n \"type\": \"string\"\n },\n \"theme\": {\n \"$ref\": \"#/definitions/ThemePushTheme\"\n }\n },\n \"required\": [\n \"status\",\n \"theme\"\n ],\n \"additionalProperties\": false\n },\n {\n \"type\": \"array\",\n \"items\": {\n \"type\": \"object\",\n \"properties\": {\n \"status\": {\n \"$ref\": \"#/definitions/ThemePushJsonResult/anyOf/0/properties/status\"\n },\n \"environment\": {\n \"type\": \"string\"\n },\n \"theme\": {\n \"$ref\": \"#/definitions/ThemePushTheme\"\n }\n },\n \"required\": [\n \"status\",\n \"environment\",\n \"theme\"\n ],\n \"additionalProperties\": false\n }\n }\n ],\n \"title\": \"ThemePushJsonResult\",\n \"definitions\": {\n \"ThemePushTheme\": {\n \"type\": \"object\",\n \"properties\": {\n \"id\": {\n \"type\": \"number\"\n },\n \"name\": {\n \"type\": \"string\"\n },\n \"role\": {\n \"type\": \"string\"\n },\n \"shop\": {\n \"type\": \"string\"\n },\n \"editor_url\": {\n \"type\": \"string\"\n },\n \"preview_url\": {\n \"type\": \"string\"\n },\n \"warning\": {\n \"type\": \"string\"\n },\n \"errors\": {\n \"type\": \"object\",\n \"additionalProperties\": {\n \"type\": \"array\",\n \"items\": {\n \"type\": \"string\"\n }\n }\n }\n },\n \"required\": [\n \"id\",\n \"name\",\n \"role\",\n \"shop\",\n \"editor_url\",\n \"preview_url\"\n ],\n \"additionalProperties\": false\n }\n },\n \"$schema\": \"http://json-schema.org/draft-07/schema#\"\n}\n```", - "descriptionWithMarkdown": "Uploads your local theme files to Shopify, overwriting the remote version if specified.\n\n If no theme is specified, then you're prompted to select the theme to overwrite from the list of the themes in your store.\n\n You 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\n This command returns the following information:\n\n - A link to the [editor](https://shopify.dev/docs/themes/tools/online-editor) for the theme in the Shopify admin.\n - A [preview link](https://help.shopify.com/manual/online-store/themes/adding-themes#share-a-theme-preview-with-others) that you can share with others.\n\n If you use the `--json` flag, then theme information is returned in JSON format, which can be used as a machine-readable input for scripts or continuous integration.\n\n Sample output:\n\n ```json\n {\n \"theme\": {\n \"id\": 108267175958,\n \"name\": \"MyTheme\",\n \"role\": \"unpublished\",\n \"shop\": \"mystore.myshopify.com\",\n \"editor_url\": \"https://mystore.myshopify.com/admin/themes/108267175958/editor\",\n \"preview_url\": \"https://mystore.myshopify.com/?preview_theme_id=108267175958\"\n }\n }\n ```\n ", + "description": "Uploads your local theme files to Shopify, overwriting the remote version if specified.\n\n If no theme is specified, then you're prompted to select the theme to overwrite from the list of the themes in your store.\n\n You 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\n This command returns the following information:\n\n - A link to the \"editor\" (https://shopify.dev/docs/themes/tools/online-editor) for the theme in the Shopify admin.\n - A \"preview link\" (https://help.shopify.com/manual/online-store/themes/adding-themes#share-a-theme-preview-with-others) that you can share with others.\n\n If you use the `--json` flag, then theme information is returned in JSON format, which can be used as a machine-readable input for scripts or continuous integration.\n\n Sample output:\n\n ```json\n {\n \"status\": \"success\",\n \"changed\": true,\n \"issues\": [],\n \"theme\": {\n \"id\": \"108267175958\",\n \"name\": \"MyTheme\",\n \"role\": \"unpublished\",\n \"storeDomain\": \"mystore.myshopify.com\",\n \"editorUrl\": \"https://mystore.myshopify.com/admin/themes/108267175958/editor\",\n \"previewUrl\": \"https://mystore.myshopify.com/?preview_theme_id=108267175958\"\n }\n }\n ```\n \n\nUse `--json-schema` to print the result, error, and event schemas.\n\nOutput from `--json` conforms to the `ThemePushJsonResult` 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\": \"ThemePushJsonResult\",\n \"definitions\": {\n \"ThemePushTheme\": {\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 \"previewUrl\": {\n \"anyOf\": [\n {\n \"type\": \"string\",\n \"format\": \"uri\"\n },\n {\n \"type\": \"null\"\n }\n ]\n },\n \"editorUrl\": {\n \"anyOf\": [\n {\n \"type\": \"string\",\n \"format\": \"uri\"\n },\n {\n \"type\": \"null\"\n }\n ]\n }\n },\n \"required\": [\n \"id\",\n \"name\",\n \"role\",\n \"storeDomain\",\n \"previewUrl\",\n \"editorUrl\"\n ],\n \"additionalProperties\": false\n },\n \"ThemeEnvironment\": {\n \"anyOf\": [\n {\n \"type\": \"object\",\n \"properties\": {\n \"environment\": {\n \"type\": \"string\"\n },\n \"result\": {\n \"anyOf\": [\n {\n \"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 \"theme\": {\n \"$ref\": \"#/definitions/ThemePushTheme\"\n },\n \"issues\": {\n \"type\": \"array\",\n \"items\": {\n \"type\": \"object\",\n \"properties\": {\n \"filePath\": {\n \"type\": \"string\"\n },\n \"message\": {\n \"type\": \"string\"\n }\n },\n \"required\": [\n \"filePath\",\n \"message\"\n ],\n \"additionalProperties\": false\n }\n }\n },\n \"required\": [\n \"status\",\n \"changed\",\n \"theme\",\n \"issues\"\n ],\n \"additionalProperties\": false\n },\n {\n \"type\": \"object\",\n \"properties\": {\n \"status\": {\n \"type\": \"string\",\n \"const\": \"skipped\"\n },\n \"reason\": {\n \"type\": \"string\",\n \"const\": \"unsafe-directory\"\n }\n },\n \"required\": [\n \"status\",\n \"reason\"\n ],\n \"additionalProperties\": false\n }\n ]\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": "Uploads your local theme files to Shopify, overwriting the remote version if specified.\n\n If no theme is specified, then you're prompted to select the theme to overwrite from the list of the themes in your store.\n\n You 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\n This command returns the following information:\n\n - A link to the [editor](https://shopify.dev/docs/themes/tools/online-editor) for the theme in the Shopify admin.\n - A [preview link](https://help.shopify.com/manual/online-store/themes/adding-themes#share-a-theme-preview-with-others) that you can share with others.\n\n If you use the `--json` flag, then theme information is returned in JSON format, which can be used as a machine-readable input for scripts or continuous integration.\n\n Sample output:\n\n ```json\n {\n \"status\": \"success\",\n \"changed\": true,\n \"issues\": [],\n \"theme\": {\n \"id\": \"108267175958\",\n \"name\": \"MyTheme\",\n \"role\": \"unpublished\",\n \"storeDomain\": \"mystore.myshopify.com\",\n \"editorUrl\": \"https://mystore.myshopify.com/admin/themes/108267175958/editor\",\n \"previewUrl\": \"https://mystore.myshopify.com/?preview_theme_id=108267175958\"\n }\n }\n ```\n ", "enableJsonFlag": false, "flags": { "allow-live": { diff --git a/packages/theme/src/cli/commands/theme/push.test.ts b/packages/theme/src/cli/commands/theme/push.test.ts index 5bc74f3f6e6..c971ce486cd 100644 --- a/packages/theme/src/cli/commands/theme/push.test.ts +++ b/packages/theme/src/cli/commands/theme/push.test.ts @@ -30,8 +30,8 @@ describe('theme push JSON', () => { name: 'Theme', role: 'unpublished', shop: session.storeFqdn, - editor_url: 'editor', - preview_url: 'preview', + editor_url: 'https://test.myshopify.com/admin/themes/1/editor', + preview_url: 'https://test.myshopify.com?preview_theme_id=1', }, published: false, hasErrors: false, @@ -41,16 +41,11 @@ describe('theme push JSON', () => { await withCapturedStandardStreams(async ({stdout, stderr}) => { await command.command({json: true} as never, session, false) - expect(JSON.parse(stdout())).toEqual({ + expect(JSON.parse(stdout())).toMatchObject({ status: 'success', - theme: { - id: 1, - name: 'Theme', - role: 'unpublished', - shop: session.storeFqdn, - editor_url: 'editor', - preview_url: 'preview', - }, + changed: true, + issues: [], + theme: {id: '1', storeDomain: session.storeFqdn}, }) expect(stderr()).toBe('') }) @@ -62,7 +57,7 @@ describe('theme push JSON', () => { await withCapturedStandardStreams(async ({stdout}) => { await command.command({json: true} as never, session, false) - expect(stdout()).toBe('') + expect(JSON.parse(stdout())).toEqual({status: 'cancelled'}) }) }) diff --git a/packages/theme/src/cli/commands/theme/push.ts b/packages/theme/src/cli/commands/theme/push.ts index 0eb519700cd..46283a6df99 100644 --- a/packages/theme/src/cli/commands/theme/push.ts +++ b/packages/theme/src/cli/commands/theme/push.ts @@ -1,3 +1,4 @@ +import {ThemeEnvironmentResult} from '../../services/json-output/schema.js' import {globFlags, themeFlags} from '../../flags.js' import ThemeCommand from '../../utilities/theme-command.js' import {executeThemePush} from '../../services/push.js' @@ -7,6 +8,7 @@ import { renderThemePushResult, renderThemePushEnvironmentResults, } from '../../services/push/result.js' +import {outputResult} from '@shopify/cli-kit/node/output' import {Flags} from '@oclif/core' import {globalFlags, jsonFlag} from '@shopify/cli-kit/node/cli' import {recordTiming} from '@shopify/cli-kit/node/analytics' @@ -44,13 +46,16 @@ export default class Push extends ThemeCommand { \`\`\`json { + "status": "success", + "changed": true, + "issues": [], "theme": { - "id": 108267175958, + "id": "108267175958", "name": "MyTheme", "role": "unpublished", - "shop": "mystore.myshopify.com", - "editor_url": "https://mystore.myshopify.com/admin/themes/108267175958/editor", - "preview_url": "https://mystore.myshopify.com/?preview_theme_id=108267175958" + "storeDomain": "mystore.myshopify.com", + "editorUrl": "https://mystore.myshopify.com/admin/themes/108267175958/editor", + "previewUrl": "https://mystore.myshopify.com/?preview_theme_id=108267175958" } } \`\`\` @@ -159,7 +164,11 @@ export default class Push extends ThemeCommand { multiEnvironment, context, ) - if (result && !(flags.json && multiEnvironment)) renderThemePushResult(result, flags.json ? 'json' : 'text') + if (result?.hasErrors) process.exitCode = 1 + if (!(flags.json && multiEnvironment)) { + if (result) renderThemePushResult(result, flags.json ? 'json' : 'text') + else if (flags.json) outputResult(themePushJsonOutputSchema.encode({status: 'cancelled'})) + } recordTiming('theme-command:push') return result } @@ -168,7 +177,7 @@ export default class Push extends ThemeCommand { return Boolean(flags.json) } - protected renderEnvironmentResults(results: {environment: string; result: unknown}[]): void { + protected renderEnvironmentResults(results: ThemeEnvironmentResult[]): void { renderThemePushEnvironmentResults(results) } diff --git a/packages/theme/src/cli/services/push.test.ts b/packages/theme/src/cli/services/push.test.ts index f89b9d936e3..75a271ba94e 100644 --- a/packages/theme/src/cli/services/push.test.ts +++ b/packages/theme/src/cli/services/push.test.ts @@ -16,7 +16,7 @@ import { UNPUBLISHED_THEME_ROLE, promptThemeName, } from '@shopify/cli-kit/node/themes/utils' -import {renderConfirmationPrompt, renderError} from '@shopify/cli-kit/node/ui' +import {renderConfirmationPrompt} from '@shopify/cli-kit/node/ui' import {AbortError} from '@shopify/cli-kit/node/error' import {Severity, SourceCodeType} from '@shopify/theme-check-node' import {outputResult} from '@shopify/cli-kit/node/output' @@ -44,7 +44,7 @@ const defaultFlags: PullFlags = { ignore: [], force: false, } -const adminSession = {token: '', storeFqdn: ''} +const adminSession = {token: '', storeFqdn: 'example.myshopify.com'} describe('push', () => { beforeEach(() => { @@ -102,24 +102,13 @@ describe('push', () => { await push({...defaultFlags, json: true}, adminSession) // Then - expect(outputResult).toHaveBeenCalledWith( - JSON.stringify({ - status: 'failed', - theme: { - id: 1, - name: 'Theme', - role: 'development', - shop: '', - editor_url: 'https:///admin/themes/1/editor', - preview_url: 'https://?preview_theme_id=1', - warning: "The theme 'Theme' was pushed with errors", - errors: { - 'assets/theme.css': ['Invalid CSS syntax at line 42'], - 'layout/theme.liquid': ['Missing endif tag'], - }, - }, - }), - ) + const result = JSON.parse(vi.mocked(outputResult).mock.calls[0]![0] as string) + expect(result).toMatchObject({ + status: 'partial', + changed: true, + theme: {id: '1', storeDomain: 'example.myshopify.com'}, + issues: [{message: 'Invalid CSS syntax at line 42'}, {message: 'Missing endif tag'}], + }) }) describe('strict mode', () => { @@ -404,17 +393,7 @@ describe('createOrSelectTheme', async () => { const flags: PushFlags = {live: true, environment: ['production']} // When - const theme = await createOrSelectTheme(adminSession, flags, true) - - // Then - expect(theme).toBeUndefined() - expect(renderError).toHaveBeenCalledWith({ - headline: 'Environment: production', - body: [ - `Can't push theme files to the live theme on ${adminSession.storeFqdn}`, - 'Use the --allow-live flag to push to a live theme.', - ], - }) + await expect(createOrSelectTheme(adminSession, flags, true)).rejects.toBeInstanceOf(AbortError) }) }) }) diff --git a/packages/theme/src/cli/services/push.ts b/packages/theme/src/cli/services/push.ts index 1f23015b429..eafab811a21 100644 --- a/packages/theme/src/cli/services/push.ts +++ b/packages/theme/src/cli/services/push.ts @@ -12,7 +12,7 @@ import {ensureListingExists} from '../utilities/theme-listing.js' import {AdminSession, ensureAuthenticatedThemes} from '@shopify/cli-kit/node/session' import {themeCreate, fetchChecksums, themePublish} from '@shopify/cli-kit/node/themes/api' import {Theme} from '@shopify/cli-kit/node/themes/types' -import {renderConfirmationPrompt, RenderConfirmationPromptOptions, renderError} from '@shopify/cli-kit/node/ui' +import {renderConfirmationPrompt, RenderConfirmationPromptOptions} from '@shopify/cli-kit/node/ui' import {themeEditorUrl, themePreviewUrl} from '@shopify/cli-kit/node/themes/urls' import {cwd, resolvePath} from '@shopify/cli-kit/node/path' import { @@ -21,9 +21,9 @@ import { promptThemeName, UNPUBLISHED_THEME_ROLE, } from '@shopify/cli-kit/node/themes/utils' +import {AbortError} from '@shopify/cli-kit/node/error' import {recordTiming} from '@shopify/cli-kit/node/analytics' -import {commandEventOutputMode, emitCommandEvent} from '@shopify/cli-kit/node/command-events' import {Writable} from 'stream' interface PushOptions { @@ -211,6 +211,7 @@ async function executePush( return themePushResultSchema.parse({ environment: options.environment, + directory: options.path, theme: { id: theme.id, name: theme.name, @@ -269,7 +270,7 @@ async function confirmPushToTheme( themeRole: Role, allowLive: boolean | undefined, storeFqdn: string, - environment?: string[], + _environment?: string[], multiEnvironment?: boolean, ) { if (themeRole === LIVE_THEME_ROLE) { @@ -278,20 +279,7 @@ async function confirmPushToTheme( } if (multiEnvironment) { - const body = [ - `Can't push theme files to the live theme on ${storeFqdn}`, - 'Use the --allow-live flag to push to a live theme.', - ] - if (commandEventOutputMode() === 'json') { - emitCommandEvent({ - type: 'diagnostic', - level: 'error', - message: `Environment: ${environment}\n${body.join('\n')}`, - }) - } else { - renderError({headline: `Environment: ${environment}`, body}) - } - return false + throw new AbortError(`Can't push theme files to the live theme on ${storeFqdn}. Use the --allow-live flag.`) } const options: RenderConfirmationPromptOptions = { diff --git a/packages/theme/src/cli/services/push/result.test.ts b/packages/theme/src/cli/services/push/result.test.ts index 98c335ebf25..c33e954932e 100644 --- a/packages/theme/src/cli/services/push/result.test.ts +++ b/packages/theme/src/cli/services/push/result.test.ts @@ -32,65 +32,52 @@ function pushResult(overrides: Partial = {}): ThemePushResult { } describe('push result', () => { - test('writes compact JSON with a success status and omits absent environment and error fields', () => { - expect(themePushJsonOutputSchema.encode(themePushJsonResult(pushResult()))).toBe( - '{"status":"success","theme":{"id":1,"name":"Theme","role":"unpublished","shop":"test.myshopify.com","editor_url":"https://test.myshopify.com/admin/themes/1/editor","preview_url":"https://test.myshopify.com?preview_theme_id=1"}}', - ) - }) - - test('preserves environment, warning, asset errors and their order', () => { - const result = pushResult({ - environment: 'staging', - hasErrors: true, - errors: {'assets/z.css': ['bad CSS'], 'layout/a.liquid': []}, + test('projects string IDs, camelCase URLs and upload outcomes', () => { + expect(themePushJsonResult(pushResult())).toEqual({ + status: 'success', + changed: true, + issues: [], + theme: { + id: '1', + name: 'Theme', + role: 'unpublished', + storeDomain: 'test.myshopify.com', + editorUrl: 'https://test.myshopify.com/admin/themes/1/editor', + previewUrl: 'https://test.myshopify.com?preview_theme_id=1', + }, + }) + expect( + themePushJsonResult(pushResult({hasErrors: true, directory: '/theme', errors: {'assets/z.css': ['bad CSS']}})), + ).toMatchObject({ + status: 'partial', + issues: [{filePath: '/theme/assets/z.css', message: 'bad CSS'}], }) - expect(themePushJsonOutputSchema.encode(themePushJsonResult(result))).toBe( - '{"status":"failed","environment":"staging","theme":{"id":1,"name":"Theme","role":"unpublished","shop":"test.myshopify.com","editor_url":"https://test.myshopify.com/admin/themes/1/editor","preview_url":"https://test.myshopify.com?preview_theme_id=1","warning":"[staging] The theme \'Theme\' was pushed with errors","errors":{"assets/z.css":["bad CSS"],"layout/a.liquid":[]}}}', - ) - }) - - test('warns without an errors field for failed uploads without asset errors', () => { - const encoded = JSON.parse(themePushJsonOutputSchema.encode(themePushJsonResult(pushResult({hasErrors: true})))) - expect(encoded.status).toBe('failed') - expect(encoded.theme.warning).toBe("The theme 'Theme' was pushed with errors") - expect(encoded.theme).not.toHaveProperty('errors') }) - test('reports each environment independently without an overall success status', async () => { + test('retains every environment, including failures and skips', async () => { + process.exitCode = 0 await withCapturedStandardStreams(async ({stdout}) => { renderThemePushEnvironmentResults([ {environment: 'production', result: pushResult()}, {environment: 'staging', result: pushResult({hasErrors: true})}, - {environment: 'cancelled', result: undefined}, + {environment: 'skipped', result: undefined}, + {environment: 'failed', error: {type: 'abort', message: 'Authentication failed'}}, ]) - expect( - JSON.parse(stdout()).map(({environment, status}: {environment: string; status: string}) => ({ - environment, - status, - })), - ).toEqual([ - {environment: 'production', status: 'success'}, - {environment: 'staging', status: 'failed'}, + expect(JSON.parse(stdout()).environments).toMatchObject([ + {environment: 'production', result: {status: 'success'}}, + {environment: 'staging', result: {status: 'partial'}}, + {environment: 'skipped', result: {status: 'skipped'}}, + {environment: 'failed', error: {type: 'abort'}}, ]) + expect(process.exitCode).toBe(1) }) + process.exitCode = 0 }) - test('requires environment identity for array entries', () => { - expect(() => themePushJsonOutputSchema.validate([themePushJsonResult(pushResult())])).toThrow() - expect(themePushJsonOutputSchema.encode([])).toBe('[]') - }) - - test.each([{id: '1'}, {name: null}, {role: false}, {errors: {file: 'error'}}])( - 'rejects malformed theme fields %j', - (fields) => { - expect(() => - themePushJsonOutputSchema.validate({status: 'success', theme: {...pushResult().theme, ...fields}}), - ).toThrow() - }, - ) - - test.each([false, true])('keeps JSON unchanged when publish is %s', (published) => { - expect(themePushJsonResult(pushResult({published}))).toEqual(themePushJsonResult(pushResult())) + test('requires object roots and strict public resources', () => { + expect(() => themePushJsonOutputSchema.validate([])).toThrow() + expect(JSON.parse(themePushJsonOutputSchema.encode({environments: []}))).toEqual({environments: []}) + expect(() => themePushJsonOutputSchema.validate({...themePushJsonResult(pushResult()), accidental: true})).toThrow() }) test.each([ diff --git a/packages/theme/src/cli/services/push/result.ts b/packages/theme/src/cli/services/push/result.ts index daa9352a6a9..9012200ad4b 100644 --- a/packages/theme/src/cli/services/push/result.ts +++ b/packages/theme/src/cli/services/push/result.ts @@ -1,9 +1,5 @@ -import { - themePushJsonOutputSchema, - themePushResultSchema, - type ThemePushResult, - type ThemePushJsonResult, -} from './types.js' +import {themePushJsonOutputSchema, themePushResultSchema, type ThemePushResult, themePushJsonResult} from './types.js' +import {ThemeEnvironmentResult} from '../json-output/schema.js' import {themeComponent} from '../../utilities/theme-ui.js' import {PushFlags} from '../push.js' import {runThemeCheck} from '../../commands/theme/check.js' @@ -15,20 +11,7 @@ import {AbortError} from '@shopify/cli-kit/node/error' import {recordError} from '@shopify/cli-kit/node/analytics' import {Severity} from '@shopify/theme-check-node' -export function themePushJsonResult(result: ThemePushResult): ThemePushJsonResult { - const {environment, theme, hasErrors, errors} = result - return { - status: hasErrors ? 'failed' : 'success', - environment, - theme: { - ...theme, - ...(hasErrors - ? {warning: `${environment ? `[${environment}] ` : ''}The theme '${theme.name}' was pushed with errors`} - : {}), - ...(Object.keys(errors).length > 0 ? {errors} : {}), - }, - } -} +export {themePushJsonResult} from './types.js' export function renderThemePushResult(result: ThemePushResult, format: 'text' | 'json'): void { if (format === 'json') { @@ -92,11 +75,12 @@ export async function checkThemeBeforePush(flags: PushFlags, legacyOutput = fals } } -export function renderThemePushEnvironmentResults(results: {environment: string; result: unknown}[]): void { - const output = results.flatMap(({environment, result}) => - result === undefined - ? [] - : [{...themePushJsonResult({...themePushResultSchema.parse(result), environment}), environment}], - ) - outputResult(themePushJsonOutputSchema.encode(output)) +export function renderThemePushEnvironmentResults(results: ThemeEnvironmentResult[]): void { + const environments = results.map((entry) => { + if ('error' in entry) return entry + const result = entry.result ?? {status: 'skipped', reason: 'unsafe-directory'} + if (themePushResultSchema.safeParse(result).data?.hasErrors) process.exitCode = 1 + return {...entry, result} + }) + outputResult(themePushJsonOutputSchema.encode({environments})) } diff --git a/packages/theme/src/cli/services/push/types.ts b/packages/theme/src/cli/services/push/types.ts index 298da3eb0a2..ae0a2161372 100644 --- a/packages/theme/src/cli/services/push/types.ts +++ b/packages/theme/src/cli/services/push/types.ts @@ -1,6 +1,6 @@ -import {ThemeMutationSuccessSchema} from '../theme-mutation/status.js' -import {defineJsonOutputSchema, type InferJsonOutputSchema} from '@shopify/cli-kit/node/json-output-schema' +import {defineThemeJsonOutputSchema, ThemeLinksSchema, projectTheme, storeDomain} from '../json-output/schema.js' import {zod} from '@shopify/cli-kit/node/schema' +import {cwd, resolvePath, isAbsolutePath} from '@shopify/cli-kit/node/path' const ThemePushThemeSchema = zod.object({ id: zod.number(), @@ -11,39 +11,58 @@ const ThemePushThemeSchema = zod.object({ preview_url: zod.string(), }) -const ThemePushJsonThemeSchema = ThemePushThemeSchema.extend({ - warning: zod.string().optional(), - errors: zod.record(zod.array(zod.string())).optional(), -}) - -const ThemePushJsonResultSchema = zod.object({ - status: zod.union([ThemeMutationSuccessSchema.shape.status, zod.literal('failed')]), - environment: zod.string().optional(), - theme: ThemePushJsonThemeSchema, -}) - -const outputSchema = defineJsonOutputSchema({ - name: 'ThemePushJsonResult', - schema: zod.union([ - ThemePushJsonResultSchema, - zod.array(ThemePushJsonResultSchema.extend({environment: zod.string()})), - ]), - definitions: {ThemePushTheme: ThemePushJsonThemeSchema}, -}) - -export const themePushJsonOutputSchema: typeof outputSchema = { - ...outputSchema, - // Preserve the compact JSON emitted by theme push. - encode: (result: InferJsonOutputSchema) => JSON.stringify(outputSchema.validate(result)), -} - export const themePushResultSchema = zod.object({ environment: zod.string().optional(), + directory: zod.string().optional(), theme: ThemePushThemeSchema, published: zod.boolean(), hasErrors: zod.boolean(), errors: zod.record(zod.array(zod.string())), }) +const ThemePushJsonResultSchema = zod + .object({ + status: zod.enum(['success', 'partial']), + changed: zod.boolean(), + theme: ThemeLinksSchema, + issues: zod.array( + zod + .object({ + filePath: zod.string().refine(isAbsolutePath, 'Expected an absolute filesystem path.'), + message: zod.string(), + }) + .strict(), + ), + }) + .strict() +const SkippedSchema = zod.object({status: zod.literal('skipped'), reason: zod.literal('unsafe-directory')}).strict() + +export function themePushJsonResult(result: ThemePushResult): ThemePushJsonResult { + const {theme, hasErrors, errors} = result + return { + status: hasErrors ? 'partial' : 'success', + changed: true, + theme: { + ...projectTheme(theme), + storeDomain: storeDomain(theme.shop), + editorUrl: theme.editor_url || null, + previewUrl: theme.preview_url || null, + }, + issues: Object.entries(errors).flatMap(([file, messages]) => + messages.map((message) => ({filePath: resolvePath(result.directory ?? cwd(), file), message})), + ), + } +} + +export const themePushJsonOutputSchema = defineThemeJsonOutputSchema({ + name: 'ThemePushJsonResult', + schema: zod.union([ThemePushJsonResultSchema, SkippedSchema]), + definitions: {ThemePushTheme: ThemeLinksSchema}, + project: (value) => { + if (SkippedSchema.safeParse(value).success || ThemePushJsonResultSchema.safeParse(value).success) return value + return themePushJsonResult(themePushResultSchema.parse(value)) + }, +}) + export type ThemePushResult = zod.infer export type ThemePushJsonResult = zod.infer From 78d3436d6dda2a7b5379ef6fcc4337aa93038365 Mon Sep 17 00:00:00 2001 From: Gonzalo Riestra Date: Tue, 6 Oct 2026 14:14:05 +0200 Subject: [PATCH 03/13] Retain skipped theme uploads in environment batches --- packages/theme/src/cli/commands/theme/push.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/theme/src/cli/commands/theme/push.ts b/packages/theme/src/cli/commands/theme/push.ts index 46283a6df99..775afb23446 100644 --- a/packages/theme/src/cli/commands/theme/push.ts +++ b/packages/theme/src/cli/commands/theme/push.ts @@ -170,7 +170,7 @@ export default class Push extends ThemeCommand { else if (flags.json) outputResult(themePushJsonOutputSchema.encode({status: 'cancelled'})) } recordTiming('theme-command:push') - return result + return result ?? (flags.json && multiEnvironment ? {status: 'skipped', reason: 'unsafe-directory'} : undefined) } protected collectsEnvironmentResults(flags: {json?: boolean}): boolean { From 2d24466982e8c2a1bc551d3261fc8cd5a78d99d8 Mon Sep 17 00:00:00 2001 From: Gonzalo Riestra Date: Tue, 6 Oct 2026 14:15:50 +0200 Subject: [PATCH 04/13] Verify upload directory metadata --- packages/theme/src/cli/services/push/execute.test.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/theme/src/cli/services/push/execute.test.ts b/packages/theme/src/cli/services/push/execute.test.ts index 65b548efa2c..d4e8fd97709 100644 --- a/packages/theme/src/cli/services/push/execute.test.ts +++ b/packages/theme/src/cli/services/push/execute.test.ts @@ -42,6 +42,7 @@ describe('push execution', () => { const result = await executeThemePush({path, force: true, publish: true, environment: ['staging']}, session) expect(result).toEqual({ environment: 'staging', + directory: path, theme: { id: 1, name: 'Theme', From c8f9319bd69db628e6c13dd728e52fa1f2adce30 Mon Sep 17 00:00:00 2001 From: Gonzalo Riestra Date: Tue, 6 Oct 2026 14:16:42 +0200 Subject: [PATCH 05/13] Refresh theme push JSON schema documentation --- packages/cli/oclif.manifest.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/cli/oclif.manifest.json b/packages/cli/oclif.manifest.json index d0e043d9dcc..498fffac424 100644 --- a/packages/cli/oclif.manifest.json +++ b/packages/cli/oclif.manifest.json @@ -12620,7 +12620,7 @@ "args": { }, "customPluginName": "@shopify/theme", - "description": "Uploads your local theme files to Shopify, overwriting the remote version if specified.\n\n If no theme is specified, then you're prompted to select the theme to overwrite from the list of the themes in your store.\n\n You 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\n This command returns the following information:\n\n - A link to the \"editor\" (https://shopify.dev/docs/themes/tools/online-editor) for the theme in the Shopify admin.\n - A \"preview link\" (https://help.shopify.com/manual/online-store/themes/adding-themes#share-a-theme-preview-with-others) that you can share with others.\n\n If you use the `--json` flag, then theme information is returned in JSON format, which can be used as a machine-readable input for scripts or continuous integration.\n\n Sample output:\n\n ```json\n {\n \"status\": \"success\",\n \"changed\": true,\n \"issues\": [],\n \"theme\": {\n \"id\": \"108267175958\",\n \"name\": \"MyTheme\",\n \"role\": \"unpublished\",\n \"storeDomain\": \"mystore.myshopify.com\",\n \"editorUrl\": \"https://mystore.myshopify.com/admin/themes/108267175958/editor\",\n \"previewUrl\": \"https://mystore.myshopify.com/?preview_theme_id=108267175958\"\n }\n }\n ```\n \n\nUse `--json-schema` to print the result, error, and event schemas.\n\nOutput from `--json` conforms to the `ThemePushJsonResult` 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\": \"ThemePushJsonResult\",\n \"definitions\": {\n \"ThemePushTheme\": {\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 \"previewUrl\": {\n \"anyOf\": [\n {\n \"type\": \"string\",\n \"format\": \"uri\"\n },\n {\n \"type\": \"null\"\n }\n ]\n },\n \"editorUrl\": {\n \"anyOf\": [\n {\n \"type\": \"string\",\n \"format\": \"uri\"\n },\n {\n \"type\": \"null\"\n }\n ]\n }\n },\n \"required\": [\n \"id\",\n \"name\",\n \"role\",\n \"storeDomain\",\n \"previewUrl\",\n \"editorUrl\"\n ],\n \"additionalProperties\": false\n },\n \"ThemeEnvironment\": {\n \"anyOf\": [\n {\n \"type\": \"object\",\n \"properties\": {\n \"environment\": {\n \"type\": \"string\"\n },\n \"result\": {\n \"anyOf\": [\n {\n \"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 \"theme\": {\n \"$ref\": \"#/definitions/ThemePushTheme\"\n },\n \"issues\": {\n \"type\": \"array\",\n \"items\": {\n \"type\": \"object\",\n \"properties\": {\n \"filePath\": {\n \"type\": \"string\"\n },\n \"message\": {\n \"type\": \"string\"\n }\n },\n \"required\": [\n \"filePath\",\n \"message\"\n ],\n \"additionalProperties\": false\n }\n }\n },\n \"required\": [\n \"status\",\n \"changed\",\n \"theme\",\n \"issues\"\n ],\n \"additionalProperties\": false\n },\n {\n \"type\": \"object\",\n \"properties\": {\n \"status\": {\n \"type\": \"string\",\n \"const\": \"skipped\"\n },\n \"reason\": {\n \"type\": \"string\",\n \"const\": \"unsafe-directory\"\n }\n },\n \"required\": [\n \"status\",\n \"reason\"\n ],\n \"additionalProperties\": false\n }\n ]\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": "Uploads your local theme files to Shopify, overwriting the remote version if specified.\n\n If no theme is specified, then you're prompted to select the theme to overwrite from the list of the themes in your store.\n\n You 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\n This command returns the following information:\n\n - A link to the \"editor\" (https://shopify.dev/docs/themes/tools/online-editor) for the theme in the Shopify admin.\n - A \"preview link\" (https://help.shopify.com/manual/online-store/themes/adding-themes#share-a-theme-preview-with-others) that you can share with others.\n\n If you use the `--json` flag, then theme information is returned in JSON format, which can be used as a machine-readable input for scripts or continuous integration.\n\n Sample output:\n\n ```json\n {\n \"status\": \"success\",\n \"changed\": true,\n \"issues\": [],\n \"theme\": {\n \"id\": \"108267175958\",\n \"name\": \"MyTheme\",\n \"role\": \"unpublished\",\n \"storeDomain\": \"mystore.myshopify.com\",\n \"editorUrl\": \"https://mystore.myshopify.com/admin/themes/108267175958/editor\",\n \"previewUrl\": \"https://mystore.myshopify.com/?preview_theme_id=108267175958\"\n }\n }\n ```\n \n\nUse `--json-schema` to print the result, error, and event schemas.\n\nOutput from `--json` conforms to the `ThemePushJsonResult` 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\": \"ThemePushJsonResult\",\n \"definitions\": {\n \"ThemePushTheme\": {\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 \"previewUrl\": {\n \"anyOf\": [\n {\n \"type\": \"string\",\n \"format\": \"uri\"\n },\n {\n \"type\": \"null\"\n }\n ]\n },\n \"editorUrl\": {\n \"anyOf\": [\n {\n \"type\": \"string\",\n \"format\": \"uri\"\n },\n {\n \"type\": \"null\"\n }\n ]\n }\n },\n \"required\": [\n \"id\",\n \"name\",\n \"role\",\n \"storeDomain\",\n \"previewUrl\",\n \"editorUrl\"\n ],\n \"additionalProperties\": false\n },\n \"ThemeEnvironment\": {\n \"anyOf\": [\n {\n \"type\": \"object\",\n \"properties\": {\n \"environment\": {\n \"type\": \"string\"\n },\n \"result\": {\n \"anyOf\": [\n {\n \"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 \"theme\": {\n \"$ref\": \"#/definitions/ThemePushTheme\"\n },\n \"issues\": {\n \"type\": \"array\",\n \"items\": {\n \"type\": \"object\",\n \"properties\": {\n \"filePath\": {\n \"type\": \"string\"\n },\n \"message\": {\n \"type\": \"string\"\n }\n },\n \"required\": [\n \"filePath\",\n \"message\"\n ],\n \"additionalProperties\": false\n }\n }\n },\n \"required\": [\n \"status\",\n \"changed\",\n \"theme\",\n \"issues\"\n ],\n \"additionalProperties\": false\n },\n {\n \"type\": \"object\",\n \"properties\": {\n \"status\": {\n \"type\": \"string\",\n \"const\": \"skipped\"\n },\n \"reason\": {\n \"type\": \"string\",\n \"const\": \"unsafe-directory\"\n }\n },\n \"required\": [\n \"status\",\n \"reason\"\n ],\n \"additionalProperties\": false\n }\n ]\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": "Uploads your local theme files to Shopify, overwriting the remote version if specified.\n\n If no theme is specified, then you're prompted to select the theme to overwrite from the list of the themes in your store.\n\n You 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\n This command returns the following information:\n\n - A link to the [editor](https://shopify.dev/docs/themes/tools/online-editor) for the theme in the Shopify admin.\n - A [preview link](https://help.shopify.com/manual/online-store/themes/adding-themes#share-a-theme-preview-with-others) that you can share with others.\n\n If you use the `--json` flag, then theme information is returned in JSON format, which can be used as a machine-readable input for scripts or continuous integration.\n\n Sample output:\n\n ```json\n {\n \"status\": \"success\",\n \"changed\": true,\n \"issues\": [],\n \"theme\": {\n \"id\": \"108267175958\",\n \"name\": \"MyTheme\",\n \"role\": \"unpublished\",\n \"storeDomain\": \"mystore.myshopify.com\",\n \"editorUrl\": \"https://mystore.myshopify.com/admin/themes/108267175958/editor\",\n \"previewUrl\": \"https://mystore.myshopify.com/?preview_theme_id=108267175958\"\n }\n }\n ```\n ", "enableJsonFlag": false, "flags": { From 5aedca8174d324c11a94975e6a8f7be70d814a5f Mon Sep 17 00:00:00 2001 From: Gonzalo Riestra Date: Tue, 6 Oct 2026 14:18:49 +0200 Subject: [PATCH 06/13] Refresh theme push JSON schema documentation --- packages/cli/README.md | 265 +++++++++++++++++++++++++++++++++++------ 1 file changed, 227 insertions(+), 38 deletions(-) diff --git a/packages/cli/README.md b/packages/cli/README.md index ec95d09fe26..39c2258e654 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -15234,7 +15234,7 @@ DESCRIPTION { "anyOf": [ { - "$ref": "#/definitions/ThemeRenameEnvironment/properties/result" + "$ref": "#/definitions/ThemeEnvironment/anyOf/0/properties/result" }, { "type": "object", @@ -15242,7 +15242,7 @@ DESCRIPTION "environments": { "type": "array", "items": { - "$ref": "#/definitions/ThemeRenameEnvironment" + "$ref": "#/definitions/ThemeEnvironment" } } }, @@ -15254,74 +15254,263 @@ DESCRIPTION ], "title": "ThemeRenameResult", "definitions": { - "RenamedTheme": { + "Theme": { "type": "object", "properties": { "id": { - "type": "number" + "type": "string", + "pattern": "^\\d+$", + "description": "The decimal Online Store theme ID, not a Shopify GID." }, "name": { "type": "string" }, "role": { - "type": "string" + "type": "string", + "description": "The upstream theme role; known values include live, unpublished, and development." }, - "processing": { - "type": "boolean" + "storeDomain": { + "anyOf": [ + { + "type": "string", + "pattern": "^[a-z0-9][a-z0-9-]*\\.myshopify\\.com$" + }, + { + "type": "null" + } + ] }, - "createdAtRuntime": { + "processing": { "type": "boolean" }, - "src": { - "type": "string" - }, - "shop": { - "type": "string" + "sourceUrl": { + "anyOf": [ + { + "type": "string", + "format": "uri" + }, + { + "type": "null" + } + ] } }, "required": [ "id", "name", "role", + "storeDomain", "processing", - "createdAtRuntime", - "shop" + "sourceUrl" ], - "additionalProperties": false + "additionalProperties": false, + "description": "The selected theme projection; sourceUrl is null when the upstream source URL is unavailable." }, - "ThemeRenameEnvironment": { - "type": "object", - "properties": { - "environment": { - "type": "string" - }, - "result": { + "ThemeEnvironment": { + "anyOf": [ + { "type": "object", "properties": { - "status": { - "type": "string", - "const": "success" + "environment": { + "type": "string" }, - "originalName": { + "result": { + "anyOf": [ + { + "type": "object", + "properties": { + "status": { + "type": "string", + "const": "success" + }, + "changed": { + "type": "boolean" + }, + "originalName": { + "type": "string" + }, + "theme": { + "$ref": "#/definitions/Theme" + } + }, + "required": [ + "status", + "changed", + "originalName", + "theme" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "status": { + "type": "string", + "const": "cancelled" + } + }, + "required": [ + "status" + ], + "additionalProperties": false + } + ] + } + }, + "required": [ + "environment", + "result" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "environment": { "type": "string" }, - "theme": { - "$ref": "#/definitions/RenamedTheme" + "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": [ - "status", - "originalName", - "theme" + "environment", + "error" ], "additionalProperties": false } - }, - "required": [ - "environment", - "result" - ], - "additionalProperties": false + ] } }, "$schema": "http://json-schema.org/draft-07/schema#" From d04f31decb782b91b78d358133e9d2a115f71249 Mon Sep 17 00:00:00 2001 From: Gonzalo Riestra Date: Tue, 6 Oct 2026 14:22:33 +0200 Subject: [PATCH 07/13] Report unchanged theme upload outcomes --- packages/theme/src/cli/services/push.ts | 1 + packages/theme/src/cli/services/push/execute.test.ts | 1 + packages/theme/src/cli/services/push/result.test.ts | 4 ++++ packages/theme/src/cli/services/push/types.ts | 3 ++- 4 files changed, 8 insertions(+), 1 deletion(-) diff --git a/packages/theme/src/cli/services/push.ts b/packages/theme/src/cli/services/push.ts index eafab811a21..f364c78ccf4 100644 --- a/packages/theme/src/cli/services/push.ts +++ b/packages/theme/src/cli/services/push.ts @@ -212,6 +212,7 @@ async function executePush( return themePushResultSchema.parse({ environment: options.environment, directory: options.path, + changed: theme.createdAtRuntime || options.publish || [...uploadResults.values()].some((result) => result.success), theme: { id: theme.id, name: theme.name, diff --git a/packages/theme/src/cli/services/push/execute.test.ts b/packages/theme/src/cli/services/push/execute.test.ts index d4e8fd97709..8345e7e9f90 100644 --- a/packages/theme/src/cli/services/push/execute.test.ts +++ b/packages/theme/src/cli/services/push/execute.test.ts @@ -43,6 +43,7 @@ describe('push execution', () => { expect(result).toEqual({ environment: 'staging', directory: path, + changed: true, theme: { id: 1, name: 'Theme', diff --git a/packages/theme/src/cli/services/push/result.test.ts b/packages/theme/src/cli/services/push/result.test.ts index c33e954932e..9fcbeccdd38 100644 --- a/packages/theme/src/cli/services/push/result.test.ts +++ b/packages/theme/src/cli/services/push/result.test.ts @@ -54,6 +54,10 @@ describe('push result', () => { }) }) + test('preserves successful no-ops', () => { + expect(themePushJsonResult(pushResult({changed: false}))).toMatchObject({status: 'success', changed: false}) + }) + test('retains every environment, including failures and skips', async () => { process.exitCode = 0 await withCapturedStandardStreams(async ({stdout}) => { diff --git a/packages/theme/src/cli/services/push/types.ts b/packages/theme/src/cli/services/push/types.ts index ae0a2161372..5d475cebf51 100644 --- a/packages/theme/src/cli/services/push/types.ts +++ b/packages/theme/src/cli/services/push/types.ts @@ -14,6 +14,7 @@ const ThemePushThemeSchema = zod.object({ export const themePushResultSchema = zod.object({ environment: zod.string().optional(), directory: zod.string().optional(), + changed: zod.boolean().optional(), theme: ThemePushThemeSchema, published: zod.boolean(), hasErrors: zod.boolean(), @@ -41,7 +42,7 @@ export function themePushJsonResult(result: ThemePushResult): ThemePushJsonResul const {theme, hasErrors, errors} = result return { status: hasErrors ? 'partial' : 'success', - changed: true, + changed: result.changed ?? true, theme: { ...projectTheme(theme), storeDomain: storeDomain(theme.shop), From 29003afc517146e8857d66c4512aef893fba9803 Mon Sep 17 00:00:00 2001 From: Gonzalo Riestra Date: Tue, 6 Oct 2026 14:23:26 +0200 Subject: [PATCH 08/13] Fail theme uploads when no requested work completes --- packages/theme/src/cli/services/push.ts | 15 +++++-- .../src/cli/services/push/execute.test.ts | 41 +++++++++++++++++++ 2 files changed, 53 insertions(+), 3 deletions(-) diff --git a/packages/theme/src/cli/services/push.ts b/packages/theme/src/cli/services/push.ts index f364c78ccf4..992148a5031 100644 --- a/packages/theme/src/cli/services/push.ts +++ b/packages/theme/src/cli/services/push.ts @@ -1,4 +1,4 @@ -import {themePushResultSchema} from './push/types.js' +import {themePushResultSchema, themePushJsonResult} from './push/types.js' import {checkThemeBeforePush, renderThemePushResult} from './push/result.js' import {hasRequiredThemeDirectories, mountThemeFileSystem} from '../utilities/theme-fs.js' import {uploadTheme} from '../utilities/theme-uploader.js' @@ -209,10 +209,13 @@ async function executePush( if (!result.success && result.errors?.asset) errors[key] = result.errors.asset } - return themePushResultSchema.parse({ + const result = themePushResultSchema.parse({ environment: options.environment, directory: options.path, - changed: theme.createdAtRuntime || options.publish || [...uploadResults.values()].some((result) => result.success), + changed: + theme.createdAtRuntime === true || + options.publish === true || + [...uploadResults.values()].some((result) => result.success), theme: { id: theme.id, name: theme.name, @@ -225,6 +228,12 @@ async function executePush( hasErrors: [...uploadResults.values()].some((result) => !result.success), errors, }) + if (result.hasErrors && !result.changed) { + const error = new AbortError(`The theme '${theme.name}' could not be pushed.`) + error.details = {themeId: String(theme.id), issues: themePushJsonResult(result).issues} + throw error + } + return result } export async function createOrSelectTheme( diff --git a/packages/theme/src/cli/services/push/execute.test.ts b/packages/theme/src/cli/services/push/execute.test.ts index 8345e7e9f90..a84f196cb94 100644 --- a/packages/theme/src/cli/services/push/execute.test.ts +++ b/packages/theme/src/cli/services/push/execute.test.ts @@ -1,6 +1,7 @@ import {executeThemePush} from '../push.js' import {findOrSelectTheme} from '../../utilities/theme-selector.js' import {uploadTheme} from '../../utilities/theme-uploader.js' +import {joinPath} from '@shopify/cli-kit/node/path' import {Operation} from '@shopify/cli-kit/node/themes/types' import {inTemporaryDirectory} from '@shopify/cli-kit/node/fs' import {buildTheme} from '@shopify/cli-kit/node/themes/factories' @@ -62,6 +63,46 @@ describe('push execution', () => { }) }) + test('fails without a result when every upload fails and no work completed', async () => { + await inTemporaryDirectory(async (path) => { + vi.mocked(findOrSelectTheme).mockResolvedValue(buildTheme({id: 1, name: 'Theme', role: 'unpublished'})!) + vi.mocked(fetchChecksums).mockResolvedValue([]) + vi.mocked(uploadTheme).mockImplementation((_theme, _session, _checksums, fileSystem) => ({ + workPromise: Promise.resolve(), + uploadResults: new Map([ + [ + 'assets/theme.css', + {key: 'assets/theme.css', operation: Operation.Upload, success: false, errors: {asset: ['bad CSS']}}, + ], + ]), + renderThemeSyncProgress: async () => { + await fileSystem.ready() + }, + })) + await expect(executeThemePush({path, force: true}, session)).rejects.toMatchObject({ + details: {themeId: '1', issues: [{filePath: joinPath(path, 'assets/theme.css'), message: 'bad CSS'}]}, + }) + }) + }) + + test('reports unchanged when no upload, deletion or publication was needed', async () => { + await inTemporaryDirectory(async (path) => { + vi.mocked(findOrSelectTheme).mockResolvedValue(buildTheme({id: 1, name: 'Theme', role: 'unpublished'})!) + vi.mocked(fetchChecksums).mockResolvedValue([]) + vi.mocked(uploadTheme).mockImplementation((_theme, _session, _checksums, fileSystem) => ({ + workPromise: Promise.resolve(), + uploadResults: new Map(), + renderThemeSyncProgress: async () => { + await fileSystem.ready() + }, + })) + await expect(executeThemePush({path, force: true}, session)).resolves.toMatchObject({ + changed: false, + hasErrors: false, + }) + }) + }) + test('does not upload or publish after live-theme confirmation is declined', async () => { await inTemporaryDirectory(async (path) => { vi.mocked(findOrSelectTheme).mockResolvedValue(buildTheme({id: 1, name: 'Theme', role: 'live'})!) From c830a5843763cd498fb83ba79237ff93822a81b4 Mon Sep 17 00:00:00 2001 From: Gonzalo Riestra Date: Tue, 6 Oct 2026 14:27:28 +0200 Subject: [PATCH 09/13] Verify complete theme push environment outcomes --- .../theme/transfer-environments.test.ts | 209 +++++++++++------- 1 file changed, 125 insertions(+), 84 deletions(-) diff --git a/packages/theme/src/cli/commands/theme/transfer-environments.test.ts b/packages/theme/src/cli/commands/theme/transfer-environments.test.ts index 66e81d73105..644c49a13e0 100644 --- a/packages/theme/src/cli/commands/theme/transfer-environments.test.ts +++ b/packages/theme/src/cli/commands/theme/transfer-environments.test.ts @@ -1,14 +1,18 @@ import Push from './push.js' + import {executeThemePush} from '../../services/push.js' + import {withCapturedStandardStreams} from '@shopify/cli-kit/node/testing/output' +import {addPublicMetadata} from '@shopify/cli-kit/node/metadata' import {loadEnvironment} from '@shopify/cli-kit/node/environments' import {ensureAuthenticatedThemes} from '@shopify/cli-kit/node/session' import {inTemporaryDirectory} from '@shopify/cli-kit/node/fs' import {runWithCommandEvents, renderCommandEventAsJson} from '@shopify/cli-kit/node/command-events' import {Config} from '@oclif/core' -import {expect, test, vi} from 'vitest' +import {describe, expect, test, vi} from 'vitest' vi.mock('../../services/push.js') + vi.mock('@shopify/cli-kit/node/environments') vi.mock('@shopify/cli-kit/node/session') vi.mock('@shopify/cli-kit/node/metadata') @@ -17,93 +21,130 @@ class TestPush extends Push { public parse = vi.fn() } -// Exercise real environment orchestration, presenter, encoder and streams. -test.each(['none', 'partial', 'total'] as const)( - 'collects environment successes in requested order with %s failures', - async (failures) => { - await inTemporaryDirectory(async (path) => { - vi.mocked(loadEnvironment).mockImplementation(async (name) => ({ - store: name === 'second' ? 'second.myshopify.com' : 'first.myshopify.com', - password: 'password', - path, - theme: '1', - })) - vi.mocked(ensureAuthenticatedThemes).mockImplementation(async (store) => ({storeFqdn: store, token: 'token'})) - let releaseFirst: () => void = () => {} - const secondStarted = new Promise((resolve) => { - releaseFirst = resolve - }) - const executionOrder: string[] = [] - vi.mocked(executeThemePush).mockImplementation(async (flags, session) => { - const environment = flags.environment![0]! - if (environment === 'first') await secondStarted - if (environment === 'second') releaseFirst() - executionOrder.push(environment) - if (failures === 'total' || (failures === 'partial' && environment === 'second')) - throw new Error('upload failed') - return { - environment, - theme: { - id: 1, - name: environment, - role: 'unpublished', - shop: session!.storeFqdn, - editor_url: 'editor', - preview_url: 'preview', - }, - published: false, - hasErrors: false, - errors: {}, +describe.each([TestPush])('%s', (Command) => { + test.each(['none', 'partial', 'total', 'cancelled', 'analytics'] as const)( + 'retains all environments in request order with %s failures', + async (failures) => { + await inTemporaryDirectory(async (path) => { + vi.mocked(loadEnvironment).mockImplementation(async (name) => ({ + store: name === 'second' ? 'second.myshopify.com' : 'first.myshopify.com', + password: 'password', + path, + theme: '1', + })) + vi.mocked(ensureAuthenticatedThemes).mockImplementation(async (store) => ({storeFqdn: store, token: 'token'})) + let releaseFirst: () => void = () => {} + const secondStarted = new Promise((resolve) => { + releaseFirst = resolve + }) + vi.mocked(addPublicMetadata).mockImplementation(async (collect) => { + const metadata = await collect() + if (failures === 'analytics' && metadata?.store_domain === 'second.myshopify.com') + throw new Error('analytics failed') + }) + const executionOrder: string[] = [] + const execute = async (flags: {environment?: string[]}, session?: {storeFqdn: string}) => { + const environment = flags.environment![0]! + if (environment === 'first') await secondStarted + if (environment === 'second') releaseFirst() + executionOrder.push(environment) + if (failures === 'cancelled' && environment === 'second') return undefined + if (failures === 'total' || (failures === 'partial' && environment === 'second')) + throw new Error('upload failed') + return { + environment, + theme: { + id: 1, + name: environment, + role: 'unpublished', + processing: false, + shop: session!.storeFqdn, + editor_url: `https://${session!.storeFqdn}/admin/themes/1/editor`, + preview_url: `https://${session!.storeFqdn}?preview_theme_id=1`, + }, + path, + published: false, + hasErrors: false, + errors: {}, + } } - }) - const command = new TestPush([], new Config({root: path})) - const flags = {json: true, force: true, environment: ['first', 'second', 'third']} - vi.spyOn(command, 'parse').mockResolvedValue({flags, args: {}} as never) - const exitCode = process.exitCode - await withCapturedStandardStreams(async ({stdout, stderr}) => { - await runWithCommandEvents({outputMode: 'json', sink: renderCommandEventAsJson}, () => command.run()) + vi.mocked(executeThemePush).mockImplementation(execute) - const results = JSON.parse(stdout()) - expect(results.map((result: {environment: string}) => result.environment)).toEqual( - failures === 'total' ? [] : ['first', ...(failures === 'none' ? ['second'] : []), 'third'], - ) - expect(executionOrder).toEqual(['second', 'first', 'third']) - expect(process.exitCode).toBe(exitCode) - const events = stderr().trim() - ? stderr() - .trim() - .split('\n') - .filter(Boolean) - .map((line) => JSON.parse(line)) - : [] - expect(events.filter((event) => event.level === 'error')).toHaveLength( - {none: 0, partial: 1, total: 3}[failures], - ) + const command = new Command([], new Config({root: path})) + vi.spyOn(command, 'parse').mockResolvedValue({ + flags: {json: true, force: true, environment: ['first', 'second', 'third']}, + args: {}, + } as never) + const previousExitCode = process.exitCode + process.exitCode = 0 + try { + await withCapturedStandardStreams(async ({stdout, stderr}) => { + await runWithCommandEvents({outputMode: 'json', sink: renderCommandEventAsJson}, () => command.run()) + const {environments} = JSON.parse(stdout()) + expect(environments.map((entry: {environment: string}) => entry.environment)).toEqual([ + 'first', + 'second', + 'third', + ]) + if (failures === 'total') expect(environments.every((entry: {error?: unknown}) => entry.error)).toBe(true) + else { + expect(environments[0]).toMatchObject({result: {theme: {id: '1', storeDomain: 'first.myshopify.com'}}}) + expect(environments[2]).toHaveProperty('result') + if (failures === 'partial') expect(environments[1]).toHaveProperty('error.message', 'upload failed') + else if (failures === 'cancelled') + expect(environments[1]).toMatchObject({result: {status: 'skipped', reason: 'unsafe-directory'}}) + else expect(environments[1]).toHaveProperty('result') + } + expect(executionOrder).toEqual(['second', 'first', 'third']) + expect(process.exitCode).toBe(failures === 'total' || failures === 'partial' ? 1 : 0) + const events = stderr().trim() + ? stderr() + .trim() + .split('\n') + .filter(Boolean) + .map((line) => JSON.parse(line)) + : [] + expect(events.filter((event) => event.level === 'error')).toHaveLength( + {none: 0, partial: 1, total: 3, cancelled: 0, analytics: 0}[failures], + ) + }) + } finally { + // eslint-disable-next-line require-atomic-updates + process.exitCode = previousExitCode + } }) - }) - }, -) + }, + ) -test('returns an empty array when every environment is invalid', async () => { - vi.mocked(loadEnvironment).mockResolvedValue({}) - const command = new TestPush([], new Config({root: '.'})) - vi.spyOn(command, 'parse').mockResolvedValue({ - flags: {json: true, force: true, environment: ['first', 'second']}, - args: {}, - } as never) - await withCapturedStandardStreams(async ({stdout, stderr}) => { - await runWithCommandEvents({outputMode: 'json', sink: renderCommandEventAsJson}, () => command.run()) + test('retains validation errors when every environment is invalid', async () => { + vi.mocked(loadEnvironment).mockResolvedValue({}) + const command = new Command([], new Config({root: '.'})) + vi.spyOn(command, 'parse').mockResolvedValue({ + flags: {json: true, force: true, environment: ['first', 'second']}, + args: {}, + } as never) + const previousExitCode = process.exitCode + process.exitCode = 0 + try { + await withCapturedStandardStreams(async ({stdout}) => { + await runWithCommandEvents({outputMode: 'json', sink: renderCommandEventAsJson}, () => command.run()) + expect(JSON.parse(stdout()).environments).toEqual([ + { + environment: 'first', + error: expect.objectContaining({type: 'abort', message: expect.stringContaining('Missing flags')}), + }, + { + environment: 'second', + error: expect.objectContaining({type: 'abort', message: expect.stringContaining('Missing flags')}), + }, + ]) + expect(executeThemePush).not.toHaveBeenCalled() - expect(JSON.parse(stdout())).toEqual([]) - expect(executeThemePush).not.toHaveBeenCalled() - expect( - stderr() - .trim() - .split('\n') - .map((line) => JSON.parse(line)), - ).toEqual([ - expect.objectContaining({type: 'diagnostic', level: 'warning'}), - expect.objectContaining({type: 'diagnostic', level: 'warning'}), - ]) + expect(process.exitCode).toBe(1) + }) + } finally { + // eslint-disable-next-line require-atomic-updates + process.exitCode = previousExitCode + } }) }) From 4f2fb3d0eec388a85675a88a62798efd9eaed4a1 Mon Sep 17 00:00:00 2001 From: Gonzalo Riestra Date: Tue, 6 Oct 2026 14:30:54 +0200 Subject: [PATCH 10/13] Keep the upload projection type internal --- packages/theme/src/cli/services/push/types.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/theme/src/cli/services/push/types.ts b/packages/theme/src/cli/services/push/types.ts index 5d475cebf51..48c0533214c 100644 --- a/packages/theme/src/cli/services/push/types.ts +++ b/packages/theme/src/cli/services/push/types.ts @@ -66,4 +66,4 @@ export const themePushJsonOutputSchema = defineThemeJsonOutputSchema({ }) export type ThemePushResult = zod.infer -export type ThemePushJsonResult = zod.infer +type ThemePushJsonResult = zod.infer From f4e38fea85c54ea7b8ad9e0855f4a77a5360720d Mon Sep 17 00:00:00 2001 From: Gonzalo Riestra Date: Tue, 6 Oct 2026 15:55:29 +0200 Subject: [PATCH 11/13] Emit native absolute paths at JSON output boundaries --- packages/theme/src/cli/services/push/execute.test.ts | 8 +++++--- packages/theme/src/cli/services/push/result.test.ts | 9 +++++++-- packages/theme/src/cli/services/push/types.ts | 5 ++++- 3 files changed, 16 insertions(+), 6 deletions(-) diff --git a/packages/theme/src/cli/services/push/execute.test.ts b/packages/theme/src/cli/services/push/execute.test.ts index a84f196cb94..480e463030c 100644 --- a/packages/theme/src/cli/services/push/execute.test.ts +++ b/packages/theme/src/cli/services/push/execute.test.ts @@ -1,7 +1,6 @@ import {executeThemePush} from '../push.js' import {findOrSelectTheme} from '../../utilities/theme-selector.js' import {uploadTheme} from '../../utilities/theme-uploader.js' -import {joinPath} from '@shopify/cli-kit/node/path' import {Operation} from '@shopify/cli-kit/node/themes/types' import {inTemporaryDirectory} from '@shopify/cli-kit/node/fs' import {buildTheme} from '@shopify/cli-kit/node/themes/factories' @@ -9,6 +8,9 @@ import {fetchChecksums, themePublish} from '@shopify/cli-kit/node/themes/api' import {renderConfirmationPrompt} from '@shopify/cli-kit/node/ui' import {mockAndCaptureOutput} from '@shopify/cli-kit/node/testing/output' import {describe, expect, test, vi} from 'vitest' +// Native JSON paths must preserve Windows separators instead of pathe normalization. +// eslint-disable-next-line no-restricted-imports +import {resolve as nativePath} from 'node:path' vi.mock('../../utilities/theme-selector.js') vi.mock('../../utilities/theme-uploader.js') @@ -43,7 +45,7 @@ describe('push execution', () => { const result = await executeThemePush({path, force: true, publish: true, environment: ['staging']}, session) expect(result).toEqual({ environment: 'staging', - directory: path, + directory: nativePath(path), changed: true, theme: { id: 1, @@ -80,7 +82,7 @@ describe('push execution', () => { }, })) await expect(executeThemePush({path, force: true}, session)).rejects.toMatchObject({ - details: {themeId: '1', issues: [{filePath: joinPath(path, 'assets/theme.css'), message: 'bad CSS'}]}, + details: {themeId: '1', issues: [{filePath: nativePath(path, 'assets/theme.css'), message: 'bad CSS'}]}, }) }) }) diff --git a/packages/theme/src/cli/services/push/result.test.ts b/packages/theme/src/cli/services/push/result.test.ts index 9fcbeccdd38..29b8d6c1288 100644 --- a/packages/theme/src/cli/services/push/result.test.ts +++ b/packages/theme/src/cli/services/push/result.test.ts @@ -11,6 +11,9 @@ import {describe, expect, test, vi} from 'vitest' import {runWithCommandEvents, renderCommandEventAsJson} from '@shopify/cli-kit/node/command-events' import {mockAndCaptureOutput, withCapturedStandardStreams} from '@shopify/cli-kit/node/testing/output' import {Severity, SourceCodeType} from '@shopify/theme-check-node' +// Native JSON paths must preserve Windows separators instead of pathe normalization. +// eslint-disable-next-line no-restricted-imports +import {resolve as nativePath} from 'node:path' vi.mock('../../commands/theme/check.js') @@ -47,10 +50,12 @@ describe('push result', () => { }, }) expect( - themePushJsonResult(pushResult({hasErrors: true, directory: '/theme', errors: {'assets/z.css': ['bad CSS']}})), + themePushJsonResult( + pushResult({hasErrors: true, directory: nativePath('/theme'), errors: {'assets/z.css': ['bad CSS']}}), + ), ).toMatchObject({ status: 'partial', - issues: [{filePath: '/theme/assets/z.css', message: 'bad CSS'}], + issues: [{filePath: nativePath('/theme/assets/z.css'), message: 'bad CSS'}], }) }) diff --git a/packages/theme/src/cli/services/push/types.ts b/packages/theme/src/cli/services/push/types.ts index 48c0533214c..f6081e628ae 100644 --- a/packages/theme/src/cli/services/push/types.ts +++ b/packages/theme/src/cli/services/push/types.ts @@ -1,6 +1,9 @@ import {defineThemeJsonOutputSchema, ThemeLinksSchema, projectTheme, storeDomain} from '../json-output/schema.js' import {zod} from '@shopify/cli-kit/node/schema' -import {cwd, resolvePath, isAbsolutePath} from '@shopify/cli-kit/node/path' +import {cwd, isAbsolutePath} from '@shopify/cli-kit/node/path' +// Native JSON paths must preserve Windows separators instead of pathe normalization. +// eslint-disable-next-line no-restricted-imports +import {resolve as resolvePath} from 'node:path' const ThemePushThemeSchema = zod.object({ id: zod.number(), From 695863c73537db3019e5d49abd16ee07bb16346d Mon Sep 17 00:00:00 2001 From: Gonzalo Riestra Date: Thu, 8 Oct 2026 10:26:53 +0200 Subject: [PATCH 12/13] Keep service path expectations separate from JSON projection --- packages/theme/src/cli/services/push/execute.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/theme/src/cli/services/push/execute.test.ts b/packages/theme/src/cli/services/push/execute.test.ts index 480e463030c..254a787ba77 100644 --- a/packages/theme/src/cli/services/push/execute.test.ts +++ b/packages/theme/src/cli/services/push/execute.test.ts @@ -45,7 +45,7 @@ describe('push execution', () => { const result = await executeThemePush({path, force: true, publish: true, environment: ['staging']}, session) expect(result).toEqual({ environment: 'staging', - directory: nativePath(path), + directory: path, changed: true, theme: { id: 1, From b538bd90d583233eec721e123a609b82f3545bca Mon Sep 17 00:00:00 2001 From: Gonzalo Riestra Date: Thu, 8 Oct 2026 10:37:24 +0200 Subject: [PATCH 13/13] Count new themes as changed in push results --- .../public/node/themes/theme-manager.test.ts | 6 +-- .../src/public/node/themes/theme-manager.ts | 2 +- packages/cli/README.md | 15 ++++++- packages/cli/oclif.manifest.json | 2 +- packages/theme/src/cli/commands/theme/push.ts | 4 -- .../theme/transfer-environments.test.ts | 7 +++- packages/theme/src/cli/services/push.test.ts | 6 +-- packages/theme/src/cli/services/push.ts | 3 +- .../src/cli/services/push/execute.test.ts | 41 ++++++++++++++++++- .../src/cli/utilities/theme-selector.test.ts | 2 +- .../theme/src/cli/utilities/theme-selector.ts | 2 +- 11 files changed, 71 insertions(+), 19 deletions(-) diff --git a/packages/cli-kit/src/public/node/themes/theme-manager.test.ts b/packages/cli-kit/src/public/node/themes/theme-manager.test.ts index a8b0ef88c19..0d7ee58a84c 100644 --- a/packages/cli-kit/src/public/node/themes/theme-manager.test.ts +++ b/packages/cli-kit/src/public/node/themes/theme-manager.test.ts @@ -90,7 +90,7 @@ describe('ThemeManager', () => { }, session, ) - expect(result).toEqual(mockTheme) + expect(result).toEqual({...mockTheme, createdAtRuntime: true}) expect(manager.getStoredThemeId()).toBe('123') }) @@ -190,7 +190,7 @@ describe('ThemeManager', () => { }, session, ) - expect(result).toEqual(mockTheme) + expect(result).toEqual({...mockTheme, createdAtRuntime: true}) expect(manager.getStoredThemeId()).toBe('123') }) @@ -210,7 +210,7 @@ describe('ThemeManager', () => { }, session, ) - expect(result).toEqual(customTheme) + expect(result).toEqual({...customTheme, createdAtRuntime: true}) expect(manager.getStoredThemeId()).toBe('123') }) diff --git a/packages/cli-kit/src/public/node/themes/theme-manager.ts b/packages/cli-kit/src/public/node/themes/theme-manager.ts index 0eb1e168322..296e3608dc8 100644 --- a/packages/cli-kit/src/public/node/themes/theme-manager.ts +++ b/packages/cli-kit/src/public/node/themes/theme-manager.ts @@ -57,6 +57,6 @@ export abstract class ThemeManager { throw new BugError(`Could not create theme with name "${name}" and role "${role}"`) } this.setTheme(theme.id.toString()) - return theme + return {...theme, createdAtRuntime: true} } } diff --git a/packages/cli/README.md b/packages/cli/README.md index 39c2258e654..27ac0243ea8 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -15381,6 +15381,11 @@ DESCRIPTION "message": { "type": "string" }, + "code": { + "type": "string", + "minLength": 1, + "description": "A stable error code, included only when known." + }, "tryMessage": { "type": "string" }, @@ -15421,7 +15426,9 @@ DESCRIPTION "additionalProperties": false } }, - "details": {} + "details": { + "description": "Selected domain details, preserving native API payloads such as GraphQL errors, extensions, and data." + } }, "required": [ "type", @@ -15439,6 +15446,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" }, @@ -15471,6 +15481,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 498fffac424..c7bb69da89d 100644 --- a/packages/cli/oclif.manifest.json +++ b/packages/cli/oclif.manifest.json @@ -12620,7 +12620,7 @@ "args": { }, "customPluginName": "@shopify/theme", - "description": "Uploads your local theme files to Shopify, overwriting the remote version if specified.\n\n If no theme is specified, then you're prompted to select the theme to overwrite from the list of the themes in your store.\n\n You 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\n This command returns the following information:\n\n - A link to the \"editor\" (https://shopify.dev/docs/themes/tools/online-editor) for the theme in the Shopify admin.\n - A \"preview link\" (https://help.shopify.com/manual/online-store/themes/adding-themes#share-a-theme-preview-with-others) that you can share with others.\n\n If you use the `--json` flag, then theme information is returned in JSON format, which can be used as a machine-readable input for scripts or continuous integration.\n\n Sample output:\n\n ```json\n {\n \"status\": \"success\",\n \"changed\": true,\n \"issues\": [],\n \"theme\": {\n \"id\": \"108267175958\",\n \"name\": \"MyTheme\",\n \"role\": \"unpublished\",\n \"storeDomain\": \"mystore.myshopify.com\",\n \"editorUrl\": \"https://mystore.myshopify.com/admin/themes/108267175958/editor\",\n \"previewUrl\": \"https://mystore.myshopify.com/?preview_theme_id=108267175958\"\n }\n }\n ```\n \n\nUse `--json-schema` to print the result, error, and event schemas.\n\nOutput from `--json` conforms to the `ThemePushJsonResult` 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\": \"ThemePushJsonResult\",\n \"definitions\": {\n \"ThemePushTheme\": {\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 \"previewUrl\": {\n \"anyOf\": [\n {\n \"type\": \"string\",\n \"format\": \"uri\"\n },\n {\n \"type\": \"null\"\n }\n ]\n },\n \"editorUrl\": {\n \"anyOf\": [\n {\n \"type\": \"string\",\n \"format\": \"uri\"\n },\n {\n \"type\": \"null\"\n }\n ]\n }\n },\n \"required\": [\n \"id\",\n \"name\",\n \"role\",\n \"storeDomain\",\n \"previewUrl\",\n \"editorUrl\"\n ],\n \"additionalProperties\": false\n },\n \"ThemeEnvironment\": {\n \"anyOf\": [\n {\n \"type\": \"object\",\n \"properties\": {\n \"environment\": {\n \"type\": \"string\"\n },\n \"result\": {\n \"anyOf\": [\n {\n \"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 \"theme\": {\n \"$ref\": \"#/definitions/ThemePushTheme\"\n },\n \"issues\": {\n \"type\": \"array\",\n \"items\": {\n \"type\": \"object\",\n \"properties\": {\n \"filePath\": {\n \"type\": \"string\"\n },\n \"message\": {\n \"type\": \"string\"\n }\n },\n \"required\": [\n \"filePath\",\n \"message\"\n ],\n \"additionalProperties\": false\n }\n }\n },\n \"required\": [\n \"status\",\n \"changed\",\n \"theme\",\n \"issues\"\n ],\n \"additionalProperties\": false\n },\n {\n \"type\": \"object\",\n \"properties\": {\n \"status\": {\n \"type\": \"string\",\n \"const\": \"skipped\"\n },\n \"reason\": {\n \"type\": \"string\",\n \"const\": \"unsafe-directory\"\n }\n },\n \"required\": [\n \"status\",\n \"reason\"\n ],\n \"additionalProperties\": false\n }\n ]\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": "Uploads your local theme files to Shopify, overwriting the remote version if specified.\n\n If no theme is specified, then you're prompted to select the theme to overwrite from the list of the themes in your store.\n\n You 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\n This command returns the following information:\n\n - A link to the \"editor\" (https://shopify.dev/docs/themes/tools/online-editor) for the theme in the Shopify admin.\n - A \"preview link\" (https://help.shopify.com/manual/online-store/themes/adding-themes#share-a-theme-preview-with-others) that you can share with others.\n\n If you use the `--json` flag, then theme information is returned in JSON format, which can be used as a machine-readable input for scripts or continuous integration.\n\n Sample output:\n\n ```json\n {\n \"status\": \"success\",\n \"changed\": true,\n \"issues\": [],\n \"theme\": {\n \"id\": \"108267175958\",\n \"name\": \"MyTheme\",\n \"role\": \"unpublished\",\n \"storeDomain\": \"mystore.myshopify.com\",\n \"editorUrl\": \"https://mystore.myshopify.com/admin/themes/108267175958/editor\",\n \"previewUrl\": \"https://mystore.myshopify.com/?preview_theme_id=108267175958\"\n }\n }\n ```\n \n\nUse `--json-schema` to print the result, error, and event schemas.\n\nOutput from `--json` conforms to the `ThemePushJsonResult` 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\": \"ThemePushJsonResult\",\n \"definitions\": {\n \"ThemePushTheme\": {\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 \"previewUrl\": {\n \"anyOf\": [\n {\n \"type\": \"string\",\n \"format\": \"uri\"\n },\n {\n \"type\": \"null\"\n }\n ]\n },\n \"editorUrl\": {\n \"anyOf\": [\n {\n \"type\": \"string\",\n \"format\": \"uri\"\n },\n {\n \"type\": \"null\"\n }\n ]\n }\n },\n \"required\": [\n \"id\",\n \"name\",\n \"role\",\n \"storeDomain\",\n \"previewUrl\",\n \"editorUrl\"\n ],\n \"additionalProperties\": false\n },\n \"ThemeEnvironment\": {\n \"anyOf\": [\n {\n \"type\": \"object\",\n \"properties\": {\n \"environment\": {\n \"type\": \"string\"\n },\n \"result\": {\n \"anyOf\": [\n {\n \"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 \"theme\": {\n \"$ref\": \"#/definitions/ThemePushTheme\"\n },\n \"issues\": {\n \"type\": \"array\",\n \"items\": {\n \"type\": \"object\",\n \"properties\": {\n \"filePath\": {\n \"type\": \"string\"\n },\n \"message\": {\n \"type\": \"string\"\n }\n },\n \"required\": [\n \"filePath\",\n \"message\"\n ],\n \"additionalProperties\": false\n }\n }\n },\n \"required\": [\n \"status\",\n \"changed\",\n \"theme\",\n \"issues\"\n ],\n \"additionalProperties\": false\n },\n {\n \"type\": \"object\",\n \"properties\": {\n \"status\": {\n \"type\": \"string\",\n \"const\": \"skipped\"\n },\n \"reason\": {\n \"type\": \"string\",\n \"const\": \"unsafe-directory\"\n }\n },\n \"required\": [\n \"status\",\n \"reason\"\n ],\n \"additionalProperties\": false\n }\n ]\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": "Uploads your local theme files to Shopify, overwriting the remote version if specified.\n\n If no theme is specified, then you're prompted to select the theme to overwrite from the list of the themes in your store.\n\n You 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\n This command returns the following information:\n\n - A link to the [editor](https://shopify.dev/docs/themes/tools/online-editor) for the theme in the Shopify admin.\n - A [preview link](https://help.shopify.com/manual/online-store/themes/adding-themes#share-a-theme-preview-with-others) that you can share with others.\n\n If you use the `--json` flag, then theme information is returned in JSON format, which can be used as a machine-readable input for scripts or continuous integration.\n\n Sample output:\n\n ```json\n {\n \"status\": \"success\",\n \"changed\": true,\n \"issues\": [],\n \"theme\": {\n \"id\": \"108267175958\",\n \"name\": \"MyTheme\",\n \"role\": \"unpublished\",\n \"storeDomain\": \"mystore.myshopify.com\",\n \"editorUrl\": \"https://mystore.myshopify.com/admin/themes/108267175958/editor\",\n \"previewUrl\": \"https://mystore.myshopify.com/?preview_theme_id=108267175958\"\n }\n }\n ```\n ", "enableJsonFlag": false, "flags": { diff --git a/packages/theme/src/cli/commands/theme/push.ts b/packages/theme/src/cli/commands/theme/push.ts index 775afb23446..4aaf791bdcd 100644 --- a/packages/theme/src/cli/commands/theme/push.ts +++ b/packages/theme/src/cli/commands/theme/push.ts @@ -173,10 +173,6 @@ export default class Push extends ThemeCommand { return result ?? (flags.json && multiEnvironment ? {status: 'skipped', reason: 'unsafe-directory'} : undefined) } - protected collectsEnvironmentResults(flags: {json?: boolean}): boolean { - return Boolean(flags.json) - } - protected renderEnvironmentResults(results: ThemeEnvironmentResult[]): void { renderThemePushEnvironmentResults(results) } diff --git a/packages/theme/src/cli/commands/theme/transfer-environments.test.ts b/packages/theme/src/cli/commands/theme/transfer-environments.test.ts index 644c49a13e0..4a972b058f4 100644 --- a/packages/theme/src/cli/commands/theme/transfer-environments.test.ts +++ b/packages/theme/src/cli/commands/theme/transfer-environments.test.ts @@ -70,7 +70,10 @@ describe.each([TestPush])('%s', (Command) => { } vi.mocked(executeThemePush).mockImplementation(execute) - const command = new Command([], new Config({root: path})) + const command = new Command( + ['--environment', 'first', '--environment', 'second', '--environment', 'third'], + new Config({root: path}), + ) vi.spyOn(command, 'parse').mockResolvedValue({ flags: {json: true, force: true, environment: ['first', 'second', 'third']}, args: {}, @@ -118,7 +121,7 @@ describe.each([TestPush])('%s', (Command) => { test('retains validation errors when every environment is invalid', async () => { vi.mocked(loadEnvironment).mockResolvedValue({}) - const command = new Command([], new Config({root: '.'})) + const command = new Command(['--environment', 'first', '--environment', 'second'], new Config({root: '.'})) vi.spyOn(command, 'parse').mockResolvedValue({ flags: {json: true, force: true, environment: ['first', 'second']}, args: {}, diff --git a/packages/theme/src/cli/services/push.test.ts b/packages/theme/src/cli/services/push.test.ts index 75a271ba94e..36bcc77bdc5 100644 --- a/packages/theme/src/cli/services/push.test.ts +++ b/packages/theme/src/cli/services/push.test.ts @@ -264,7 +264,7 @@ describe('createOrSelectTheme', async () => { const theme = await createOrSelectTheme(adminSession, flags) // Then - expect(theme).toMatchObject({role: UNPUBLISHED_THEME_ROLE}) + expect(theme).toMatchObject({role: UNPUBLISHED_THEME_ROLE, createdAtRuntime: true}) expect(setDevelopmentTheme).not.toHaveBeenCalled() }) @@ -278,7 +278,7 @@ describe('createOrSelectTheme', async () => { const theme = await createOrSelectTheme(adminSession, flags) // Then - expect(theme).toMatchObject({role: DEVELOPMENT_THEME_ROLE}) + expect(theme).toMatchObject({role: DEVELOPMENT_THEME_ROLE, createdAtRuntime: true}) expect(setDevelopmentTheme).toHaveBeenCalled() }) @@ -292,7 +292,7 @@ describe('createOrSelectTheme', async () => { const theme = await createOrSelectTheme(adminSession, flags) // Then - expect(theme).toMatchObject({role: DEVELOPMENT_THEME_ROLE, name: 'Custom name'}) + expect(theme).toMatchObject({role: DEVELOPMENT_THEME_ROLE, name: 'Custom name', createdAtRuntime: true}) expect(setDevelopmentTheme).toHaveBeenCalled() }) diff --git a/packages/theme/src/cli/services/push.ts b/packages/theme/src/cli/services/push.ts index 992148a5031..a2ea9a5a0db 100644 --- a/packages/theme/src/cli/services/push.ts +++ b/packages/theme/src/cli/services/push.ts @@ -248,13 +248,14 @@ export async function createOrSelectTheme( return themeManager.findOrCreate(developmentContext, DEVELOPMENT_THEME_ROLE) } else if (unpublished) { const themeName = theme ?? (await promptThemeName('Name of the new theme')) - return themeCreate( + const createdTheme = await themeCreate( { name: themeName, role: UNPUBLISHED_THEME_ROLE, }, session, ) + return createdTheme ? {...createdTheme, createdAtRuntime: true} : undefined } else { const selectedTheme = await findOrSelectTheme(session, { create: true, diff --git a/packages/theme/src/cli/services/push/execute.test.ts b/packages/theme/src/cli/services/push/execute.test.ts index 254a787ba77..60146b0f81d 100644 --- a/packages/theme/src/cli/services/push/execute.test.ts +++ b/packages/theme/src/cli/services/push/execute.test.ts @@ -4,7 +4,7 @@ import {uploadTheme} from '../../utilities/theme-uploader.js' import {Operation} from '@shopify/cli-kit/node/themes/types' import {inTemporaryDirectory} from '@shopify/cli-kit/node/fs' import {buildTheme} from '@shopify/cli-kit/node/themes/factories' -import {fetchChecksums, themePublish} from '@shopify/cli-kit/node/themes/api' +import {fetchChecksums, themeCreate, themePublish} from '@shopify/cli-kit/node/themes/api' import {renderConfirmationPrompt} from '@shopify/cli-kit/node/ui' import {mockAndCaptureOutput} from '@shopify/cli-kit/node/testing/output' import {describe, expect, test, vi} from 'vitest' @@ -23,6 +23,45 @@ vi.mock('@shopify/cli-kit/node/ui', async (importOriginal) => ({ const session = {storeFqdn: 'test.myshopify.com', token: 'token'} describe('push execution', () => { + test.each([false, true])( + 'reports creation as a change even without successful uploads (errors: %s)', + async (hasErrors) => { + await inTemporaryDirectory(async (path) => { + vi.mocked(themeCreate).mockResolvedValue(buildTheme({id: 1, name: 'New theme', role: 'unpublished'})) + vi.mocked(fetchChecksums).mockResolvedValue([]) + vi.mocked(uploadTheme).mockImplementation((_theme, _session, _checksums, fileSystem) => ({ + workPromise: Promise.resolve(), + uploadResults: new Map( + hasErrors + ? [ + [ + 'assets/theme.css', + { + key: 'assets/theme.css', + operation: Operation.Upload, + success: false, + errors: {asset: ['bad CSS']}, + }, + ], + ] + : [], + ), + renderThemeSyncProgress: async () => { + await fileSystem.ready() + }, + })) + + await expect( + executeThemePush({path, unpublished: true, theme: 'New theme', force: true}, session), + ).resolves.toMatchObject({ + changed: true, + hasErrors, + theme: {id: 1, name: 'New theme'}, + }) + }) + }, + ) + test('returns transfer facts without rendering a final result', async () => { await inTemporaryDirectory(async (path) => { vi.mocked(findOrSelectTheme).mockResolvedValue(buildTheme({id: 1, name: 'Theme', role: 'unpublished'})!) diff --git a/packages/theme/src/cli/utilities/theme-selector.test.ts b/packages/theme/src/cli/utilities/theme-selector.test.ts index 778c80504c7..fa812ef6272 100644 --- a/packages/theme/src/cli/utilities/theme-selector.test.ts +++ b/packages/theme/src/cli/utilities/theme-selector.test.ts @@ -103,7 +103,7 @@ describe('findOrSelectTheme', () => { // Then expect(themeCreate).toBeCalledWith({name: 'my new theme', role: 'unpublished'}, session) - expect(actualTheme).toBe(expectedTheme) + expect(actualTheme).toEqual({...expectedTheme, createdAtRuntime: true}) }) }) diff --git a/packages/theme/src/cli/utilities/theme-selector.ts b/packages/theme/src/cli/utilities/theme-selector.ts index eb6661dc565..8cbeef1f5dc 100644 --- a/packages/theme/src/cli/utilities/theme-selector.ts +++ b/packages/theme/src/cli/utilities/theme-selector.ts @@ -114,7 +114,7 @@ export function newThemeOption(session: AdminSession): { throw new AbortError('The theme could not be created.') } - return theme + return {...theme, createdAtRuntime: true} }, label: '[Create a new theme]', group: capitalize(UNPUBLISHED_THEME_ROLE),