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-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 3f62fa76a84..27ac0243ea8 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -14782,16 +14782,372 @@ 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" } } ``` + + + Use `--json-schema` to print the result, error, and event schemas. + + Output from `--json` conforms to the `ThemePushJsonResult` schema. + + ```json + { + "anyOf": [ + { + "$ref": "#/definitions/ThemeEnvironment/anyOf/0/properties/result" + }, + { + "type": "object", + "properties": { + "environments": { + "type": "array", + "items": { + "$ref": "#/definitions/ThemeEnvironment" + } + } + }, + "required": [ + "environments" + ], + "additionalProperties": false + } + ], + "title": "ThemePushJsonResult", + "definitions": { + "ThemePushTheme": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^\\d+$", + "description": "The decimal Online Store theme ID, not a Shopify GID." + }, + "name": { + "type": "string" + }, + "role": { + "type": "string", + "description": "The upstream theme role; known values include live, unpublished, and development." + }, + "storeDomain": { + "anyOf": [ + { + "type": "string", + "pattern": "^[a-z0-9][a-z0-9-]*\\.myshopify\\.com$" + }, + { + "type": "null" + } + ] + }, + "previewUrl": { + "anyOf": [ + { + "type": "string", + "format": "uri" + }, + { + "type": "null" + } + ] + }, + "editorUrl": { + "anyOf": [ + { + "type": "string", + "format": "uri" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "id", + "name", + "role", + "storeDomain", + "previewUrl", + "editorUrl" + ], + "additionalProperties": false + }, + "ThemeEnvironment": { + "anyOf": [ + { + "type": "object", + "properties": { + "environment": { + "type": "string" + }, + "result": { + "anyOf": [ + { + "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 + }, + { + "type": "object", + "properties": { + "status": { + "type": "string", + "const": "skipped" + }, + "reason": { + "type": "string", + "const": "unsafe-directory" + } + }, + "required": [ + "status", + "reason" + ], + "additionalProperties": false + } + ] + }, + { + "type": "object", + "properties": { + "status": { + "type": "string", + "const": "cancelled" + } + }, + "required": [ + "status" + ], + "additionalProperties": false + } + ] + } + }, + "required": [ + "environment", + "result" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "environment": { + "type": "string" + }, + "error": { + "anyOf": [ + { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "abort" + }, + "message": { + "type": "string" + }, + "code": { + "type": "string", + "minLength": 1, + "description": "A stable error code, included only when known." + }, + "tryMessage": { + "type": "string" + }, + "nextSteps": { + "type": "array", + "items": { + "type": "string" + } + }, + "customSections": { + "type": "array", + "items": { + "type": "object", + "properties": { + "title": { + "type": "string" + }, + "body": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "array", + "items": { + "type": "string" + } + } + } + ] + } + }, + "required": [ + "body" + ], + "additionalProperties": false + } + }, + "details": { + "description": "Selected domain details, preserving native API payloads such as GraphQL errors, extensions, and data." + } + }, + "required": [ + "type", + "message" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "bug" + }, + "message": { + "$ref": "#/definitions/ThemeEnvironment/anyOf/1/properties/error/anyOf/0/properties/message" + }, + "code": { + "$ref": "#/definitions/ThemeEnvironment/anyOf/1/properties/error/anyOf/0/properties/code" + }, + "tryMessage": { + "$ref": "#/definitions/ThemeEnvironment/anyOf/1/properties/error/anyOf/0/properties/tryMessage" + }, + "nextSteps": { + "$ref": "#/definitions/ThemeEnvironment/anyOf/1/properties/error/anyOf/0/properties/nextSteps" + }, + "customSections": { + "$ref": "#/definitions/ThemeEnvironment/anyOf/1/properties/error/anyOf/0/properties/customSections" + }, + "details": { + "$ref": "#/definitions/ThemeEnvironment/anyOf/1/properties/error/anyOf/0/properties/details" + }, + "stack": { + "type": "string" + } + }, + "required": [ + "type", + "message" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "external" + }, + "message": { + "$ref": "#/definitions/ThemeEnvironment/anyOf/1/properties/error/anyOf/0/properties/message" + }, + "code": { + "$ref": "#/definitions/ThemeEnvironment/anyOf/1/properties/error/anyOf/0/properties/code" + }, + "tryMessage": { + "$ref": "#/definitions/ThemeEnvironment/anyOf/1/properties/error/anyOf/0/properties/tryMessage" + }, + "nextSteps": { + "$ref": "#/definitions/ThemeEnvironment/anyOf/1/properties/error/anyOf/0/properties/nextSteps" + }, + "customSections": { + "$ref": "#/definitions/ThemeEnvironment/anyOf/1/properties/error/anyOf/0/properties/customSections" + }, + "details": { + "$ref": "#/definitions/ThemeEnvironment/anyOf/1/properties/error/anyOf/0/properties/details" + }, + "command": { + "type": "string" + }, + "args": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": [ + "type", + "message", + "command", + "args" + ], + "additionalProperties": false + } + ] + } + }, + "required": [ + "environment", + "error" + ], + "additionalProperties": false + } + ] + } + }, + "$schema": "http://json-schema.org/draft-07/schema#" + } + ``` ``` ## `shopify theme rename` diff --git a/packages/cli/oclif.manifest.json b/packages/cli/oclif.manifest.json index 72729f0dbaf..c7bb69da89d 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 ", - "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 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": { "allow-live": { 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..c971ce486cd --- /dev/null +++ b/packages/theme/src/cli/commands/theme/push.test.ts @@ -0,0 +1,75 @@ +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: 'https://test.myshopify.com/admin/themes/1/editor', + preview_url: 'https://test.myshopify.com?preview_theme_id=1', + }, + 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())).toMatchObject({ + status: 'success', + changed: true, + issues: [], + theme: {id: '1', storeDomain: session.storeFqdn}, + }) + 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(JSON.parse(stdout())).toEqual({status: 'cancelled'}) + }) + }) + + 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..4aaf791bdcd 100644 --- a/packages/theme/src/cli/commands/theme/push.ts +++ b/packages/theme/src/cli/commands/theme/push.ts @@ -1,6 +1,14 @@ +import {ThemeEnvironmentResult} from '../../services/json-output/schema.js' 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 {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' @@ -13,6 +21,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'] @@ -34,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" } } \`\`\` @@ -137,7 +152,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 +164,17 @@ export default class Push extends ThemeCommand { multiEnvironment, context, ) + 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 ?? (flags.json && multiEnvironment ? {status: 'skipped', reason: 'unsafe-directory'} : undefined) + } + + protected renderEnvironmentResults(results: ThemeEnvironmentResult[]): 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..4a972b058f4 --- /dev/null +++ b/packages/theme/src/cli/commands/theme/transfer-environments.test.ts @@ -0,0 +1,153 @@ +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 {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') + +class TestPush extends Push { + public parse = vi.fn() +} + +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: {}, + } + } + vi.mocked(executeThemePush).mockImplementation(execute) + + 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: {}, + } 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('retains validation errors when every environment is invalid', async () => { + vi.mocked(loadEnvironment).mockResolvedValue({}) + 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: {}, + } 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(process.exitCode).toBe(1) + }) + } finally { + // eslint-disable-next-line require-atomic-updates + process.exitCode = previousExitCode + } + }) +}) diff --git a/packages/theme/src/cli/services/push.test.ts b/packages/theme/src/cli/services/push.test.ts index 596aa5e1b31..36bcc77bdc5 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,23 +102,13 @@ describe('push', () => { await push({...defaultFlags, json: true}, adminSession) // Then - expect(outputResult).toHaveBeenCalledWith( - JSON.stringify({ - 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', () => { @@ -274,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() }) @@ -288,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() }) @@ -302,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() }) @@ -403,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 ff2c42b517c..a2ea9a5a0db 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, 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' -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} 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 { @@ -29,15 +22,13 @@ import { 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 {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,18 @@ 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, + const result = themePushResultSchema.parse({ + environment: options.environment, + directory: options.path, + changed: + theme.createdAtRuntime === true || + options.publish === true || + [...uploadResults.values()].some((result) => result.success), theme: { id: theme.id, name: theme.name, @@ -304,86 +224,16 @@ function handleJsonOutput(theme: Theme, session: AdminSession, results: Map !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 } - const hasErrors = hasUploadErrors(results) - if (hasErrors) { - const message = `${environment ? `[${environment}] ` : ''}The theme '${theme.name}' was pushed with errors` - output.theme.warning = message - - // Add errors from results - const errors: Record = {} - 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, - }) - } + return result } export async function createOrSelectTheme( @@ -398,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, @@ -430,7 +281,7 @@ async function confirmPushToTheme( themeRole: Role, allowLive: boolean | undefined, storeFqdn: string, - environment?: string[], + _environment?: string[], multiEnvironment?: boolean, ) { if (themeRole === LIVE_THEME_ROLE) { @@ -439,14 +290,7 @@ 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.', - ], - }) - return false + throw new AbortError(`Can't push theme files to the live theme on ${storeFqdn}. Use the --allow-live flag.`) } const options: RenderConfirmationPromptOptions = { @@ -459,3 +303,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..60146b0f81d --- /dev/null +++ b/packages/theme/src/cli/services/push/execute.test.ts @@ -0,0 +1,173 @@ +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, 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' +// 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') +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.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'})!) + 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', + directory: path, + changed: true, + 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('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: nativePath(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'})!) + 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..29b8d6c1288 --- /dev/null +++ b/packages/theme/src/cli/services/push/result.test.ts @@ -0,0 +1,152 @@ +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' +// 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') + +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('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: nativePath('/theme'), errors: {'assets/z.css': ['bad CSS']}}), + ), + ).toMatchObject({ + status: 'partial', + issues: [{filePath: nativePath('/theme/assets/z.css'), message: 'bad CSS'}], + }) + }) + + 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}) => { + renderThemePushEnvironmentResults([ + {environment: 'production', result: pushResult()}, + {environment: 'staging', result: pushResult({hasErrors: true})}, + {environment: 'skipped', result: undefined}, + {environment: 'failed', error: {type: 'abort', message: 'Authentication 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 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([ + [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..9012200ad4b --- /dev/null +++ b/packages/theme/src/cli/services/push/result.ts @@ -0,0 +1,86 @@ +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' +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 {themePushJsonResult} from './types.js' + +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: 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 new file mode 100644 index 00000000000..f6081e628ae --- /dev/null +++ b/packages/theme/src/cli/services/push/types.ts @@ -0,0 +1,72 @@ +import {defineThemeJsonOutputSchema, ThemeLinksSchema, projectTheme, storeDomain} from '../json-output/schema.js' +import {zod} from '@shopify/cli-kit/node/schema' +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(), + name: zod.string(), + role: zod.string(), + shop: zod.string(), + editor_url: zod.string(), + preview_url: zod.string(), +}) + +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(), + 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: result.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 +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-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), 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() } }, })