diff --git a/docs-shopify.dev/generated/generated_docs_data_v2.json b/docs-shopify.dev/generated/generated_docs_data_v2.json index 67cb65e55e3..d07beac2a71 100644 --- a/docs-shopify.dev/generated/generated_docs_data_v2.json +++ b/docs-shopify.dev/generated/generated_docs_data_v2.json @@ -10201,6 +10201,15 @@ "isOptional": true, "environmentValue": "SHOPIFY_FLAG_ENVIRONMENT" }, + { + "filePath": "docs-shopify.dev/commands/interfaces/theme-pull.interface.ts", + "syntaxKind": "PropertySignature", + "name": "-j, --json", + "value": "''", + "description": "Output the result as JSON. Automatically disables color output.", + "isOptional": true, + "environmentValue": "SHOPIFY_FLAG_JSON" + }, { "filePath": "docs-shopify.dev/commands/interfaces/theme-pull.interface.ts", "syntaxKind": "PropertySignature", @@ -10256,7 +10265,7 @@ "environmentValue": "SHOPIFY_FLAG_IGNORE" } ], - "value": "export interface themepull {\n /**\n * Alias of the Shopify account to use for authentication.\n * @environment SHOPIFY_FLAG_AUTH_ALIAS\n */\n '--auth-alias '?: string\n\n /**\n * Pull theme files from your remote development theme. Use --development, --live, or --theme in non-interactive environments.\n * @environment SHOPIFY_FLAG_DEVELOPMENT\n */\n '-d, --development'?: ''\n\n /**\n * The environment to apply to the current command.\n * @environment SHOPIFY_FLAG_ENVIRONMENT\n */\n '-e, --environment '?: string\n\n /**\n * Skip downloading the specified files (Multiple flags allowed). Wrap the value in double quotes if you're using wildcards.\n * @environment SHOPIFY_FLAG_IGNORE\n */\n '-x, --ignore '?: string\n\n /**\n * Print the command's JSON schemas.\n * @environment SHOPIFY_FLAG_JSON_SCHEMA\n */\n '--json-schema'?: ''\n\n /**\n * Pull theme files from your remote live theme. Use --development, --live, or --theme in non-interactive environments.\n * @environment SHOPIFY_FLAG_LIVE\n */\n '-l, --live'?: ''\n\n /**\n * Disable color output.\n * @environment SHOPIFY_FLAG_NO_COLOR\n */\n '--no-color'?: ''\n\n /**\n * Disable interactive prompts and browser authentication.\n * @environment SHOPIFY_FLAG_NO_INPUT\n */\n '--no-input'?: ''\n\n /**\n * Prevent deleting local files that don't exist remotely.\n * @environment SHOPIFY_FLAG_NODELETE\n */\n '-n, --nodelete'?: ''\n\n /**\n * Download only the specified files (Multiple flags allowed). Wrap the value in double quotes if you're using wildcards.\n * @environment SHOPIFY_FLAG_ONLY\n */\n '-o, --only '?: string\n\n /**\n * Password generated from the Theme Access app or an Admin API token.\n * @environment SHOPIFY_CLI_THEME_TOKEN\n */\n '--password '?: string\n\n /**\n * The path where you want to run the command. Defaults to the current working directory.\n * @environment SHOPIFY_FLAG_PATH\n */\n '--path '?: string\n\n /**\n * Store URL. It can be the store prefix (example) or the full myshopify.com URL (example.myshopify.com, https://example.myshopify.com).\n * @environment SHOPIFY_FLAG_STORE\n */\n '-s, --store '?: string\n\n /**\n * Theme ID or name of the remote theme. Use --development, --live, or --theme in non-interactive environments.\n * @environment SHOPIFY_FLAG_THEME_ID\n */\n '-t, --theme '?: string\n\n /**\n * Increase the verbosity of the output. May include sensitive data.\n * @environment SHOPIFY_FLAG_VERBOSE\n */\n '--verbose'?: ''\n}" + "value": "export interface themepull {\n /**\n * Alias of the Shopify account to use for authentication.\n * @environment SHOPIFY_FLAG_AUTH_ALIAS\n */\n '--auth-alias '?: string\n\n /**\n * Pull theme files from your remote development theme. Use --development, --live, or --theme in non-interactive environments.\n * @environment SHOPIFY_FLAG_DEVELOPMENT\n */\n '-d, --development'?: ''\n\n /**\n * The environment to apply to the current command.\n * @environment SHOPIFY_FLAG_ENVIRONMENT\n */\n '-e, --environment '?: string\n\n /**\n * Skip downloading the specified files (Multiple flags allowed). Wrap the value in double quotes if you're using wildcards.\n * @environment SHOPIFY_FLAG_IGNORE\n */\n '-x, --ignore '?: string\n\n /**\n * Output the result as JSON. Automatically disables color output.\n * @environment SHOPIFY_FLAG_JSON\n */\n '-j, --json'?: ''\n\n /**\n * Print the command's JSON schemas.\n * @environment SHOPIFY_FLAG_JSON_SCHEMA\n */\n '--json-schema'?: ''\n\n /**\n * Pull theme files from your remote live theme. Use --development, --live, or --theme in non-interactive environments.\n * @environment SHOPIFY_FLAG_LIVE\n */\n '-l, --live'?: ''\n\n /**\n * Disable color output.\n * @environment SHOPIFY_FLAG_NO_COLOR\n */\n '--no-color'?: ''\n\n /**\n * Disable interactive prompts and browser authentication.\n * @environment SHOPIFY_FLAG_NO_INPUT\n */\n '--no-input'?: ''\n\n /**\n * Prevent deleting local files that don't exist remotely.\n * @environment SHOPIFY_FLAG_NODELETE\n */\n '-n, --nodelete'?: ''\n\n /**\n * Download only the specified files (Multiple flags allowed). Wrap the value in double quotes if you're using wildcards.\n * @environment SHOPIFY_FLAG_ONLY\n */\n '-o, --only '?: string\n\n /**\n * Password generated from the Theme Access app or an Admin API token.\n * @environment SHOPIFY_CLI_THEME_TOKEN\n */\n '--password '?: string\n\n /**\n * The path where you want to run the command. Defaults to the current working directory.\n * @environment SHOPIFY_FLAG_PATH\n */\n '--path '?: string\n\n /**\n * Store URL. It can be the store prefix (example) or the full myshopify.com URL (example.myshopify.com, https://example.myshopify.com).\n * @environment SHOPIFY_FLAG_STORE\n */\n '-s, --store '?: string\n\n /**\n * Theme ID or name of the remote theme. Use --development, --live, or --theme in non-interactive environments.\n * @environment SHOPIFY_FLAG_THEME_ID\n */\n '-t, --theme '?: string\n\n /**\n * Increase the verbosity of the output. May include sensitive data.\n * @environment SHOPIFY_FLAG_VERBOSE\n */\n '--verbose'?: ''\n}" } }, "themepush": { diff --git a/packages/cli/README.md b/packages/cli/README.md index 27ac0243ea8..8d252136202 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -14574,7 +14574,7 @@ Download your remote theme files locally. ``` USAGE - $ shopify theme pull [--auth-alias ] [-d] [-e ...] [-x ...] [--json-schema] [-l] + $ shopify theme pull [--auth-alias ] [-d] [-e ...] [-x ...] [-j] [--json-schema] [-l] [--no-color] [--no-input] [-n] [-o ...] [--password ] [--path ] [-s ] [-t ] [--verbose] @@ -14588,6 +14588,10 @@ FLAGS 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 Pull theme files from your remote live theme. Use --development, --live, or --theme in non-interactive environments. [env: SHOPIFY_FLAG_LIVE] @@ -14649,6 +14653,355 @@ DESCRIPTION Retrieves theme files from Shopify. If no theme is specified, then you're prompted to select the theme to pull from the list of the themes in your store. + + Use `--json-schema` to print the result, error, and event schemas. + + Output from `--json` conforms to the `ThemePullResult` 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": "ThemePullResult", + "definitions": { + "ThemePullTheme": { + "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" + } + ] + }, + "processing": { + "type": "boolean" + }, + "sourceUrl": { + "anyOf": [ + { + "type": "string", + "format": "uri" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "id", + "name", + "role", + "storeDomain", + "previewUrl", + "editorUrl", + "processing", + "sourceUrl" + ], + "additionalProperties": false + }, + "ThemeEnvironment": { + "anyOf": [ + { + "type": "object", + "properties": { + "environment": { + "type": "string" + }, + "result": { + "anyOf": [ + { + "anyOf": [ + { + "type": "object", + "properties": { + "status": { + "type": "string", + "const": "success" + }, + "changed": { + "type": "boolean" + }, + "directory": { + "type": "string" + }, + "theme": { + "$ref": "#/definitions/ThemePullTheme" + } + }, + "required": [ + "status", + "changed", + "directory", + "theme" + ], + "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 push` diff --git a/packages/cli/oclif.manifest.json b/packages/cli/oclif.manifest.json index c7bb69da89d..a33f0addfed 100644 --- a/packages/cli/oclif.manifest.json +++ b/packages/cli/oclif.manifest.json @@ -12456,7 +12456,7 @@ "args": { }, "customPluginName": "@shopify/theme", - "description": "Retrieves theme files from Shopify.\n\nIf no theme is specified, then you're prompted to select the theme to pull from the list of the themes in your store.", + "description": "Retrieves theme files from Shopify.\n\nIf no theme is specified, then you're prompted to select the theme to pull from the list of the themes in your store.\n\nUse `--json-schema` to print the result, error, and event schemas.\n\nOutput from `--json` conforms to the `ThemePullResult` 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\": \"ThemePullResult\",\n \"definitions\": {\n \"ThemePullTheme\": {\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 \"processing\": {\n \"type\": \"boolean\"\n },\n \"sourceUrl\": {\n \"anyOf\": [\n {\n \"type\": \"string\",\n \"format\": \"uri\"\n },\n {\n \"type\": \"null\"\n }\n ]\n }\n },\n \"required\": [\n \"id\",\n \"name\",\n \"role\",\n \"storeDomain\",\n \"previewUrl\",\n \"editorUrl\",\n \"processing\",\n \"sourceUrl\"\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 \"const\": \"success\"\n },\n \"changed\": {\n \"type\": \"boolean\"\n },\n \"directory\": {\n \"type\": \"string\"\n },\n \"theme\": {\n \"$ref\": \"#/definitions/ThemePullTheme\"\n }\n },\n \"required\": [\n \"status\",\n \"changed\",\n \"directory\",\n \"theme\"\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": "Retrieves theme files from Shopify.\n\nIf no theme is specified, then you're prompted to select the theme to pull from the list of the themes in your store.", "enableJsonFlag": false, "flags": { @@ -12503,6 +12503,15 @@ "name": "ignore", "type": "option" }, + "json": { + "allowNo": false, + "char": "j", + "description": "Output the result as JSON. Automatically disables color output.", + "env": "SHOPIFY_FLAG_JSON", + "hidden": false, + "name": "json", + "type": "boolean" + }, "json-schema": { "allowNo": false, "description": "Print the command's JSON schemas.", diff --git a/packages/eslint-plugin-cli/rules/json-output-command-exceptions.js b/packages/eslint-plugin-cli/rules/json-output-command-exceptions.js index dad08c72b9e..82a80f99176 100644 --- a/packages/eslint-plugin-cli/rules/json-output-command-exceptions.js +++ b/packages/eslint-plugin-cli/rules/json-output-command-exceptions.js @@ -21,7 +21,6 @@ const commandExceptions = [ 'packages/theme/src/cli/commands/theme/package.ts', 'packages/theme/src/cli/commands/theme/preview.ts', 'packages/theme/src/cli/commands/theme/profile.ts', - 'packages/theme/src/cli/commands/theme/pull.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/pull.test.ts b/packages/theme/src/cli/commands/theme/pull.test.ts new file mode 100644 index 00000000000..ba982a8357a --- /dev/null +++ b/packages/theme/src/cli/commands/theme/pull.test.ts @@ -0,0 +1,125 @@ +import Pull from './pull.js' +import {executeThemePull} from '../../services/pull.js' +import {themePullJsonOutputSchema} from '../../services/pull/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' +import {ensureAuthenticatedThemes} from '@shopify/cli-kit/node/session' +import {inTemporaryDirectory, writeFile} from '@shopify/cli-kit/node/fs' +import {joinPath} from '@shopify/cli-kit/node/path' +import * as pathUtilities 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 nativePath} from 'node:path' + +vi.mock('../../services/pull.js') +vi.mock('@shopify/cli-kit/node/session') +const session = {storeFqdn: 'test.myshopify.com', token: 'token'} + +describe('theme pull JSON', () => { + test('uses the current directory for one named environment without a configured path', async () => { + const previousExitCode = process.exitCode + try { + await inTemporaryDirectory(async (directory) => { + await writeFile( + joinPath(directory, 'shopify.theme.toml'), + '[environments.staging]\nstore = "test.myshopify.com"\npassword = "token"\ntheme = "1"\n', + ) + const cwdSpy = vi.spyOn(pathUtilities, 'cwd').mockReturnValue(directory) + try { + vi.mocked(ensureAuthenticatedThemes).mockResolvedValue(session) + vi.mocked(executeThemePull).mockResolvedValue({ + theme: { + id: 1, + name: 'Theme', + role: 'unpublished', + processing: false, + shop: session.storeFqdn, + editor_url: 'https://test.myshopify.com/admin/themes/1/editor', + preview_url: 'https://test.myshopify.com?preview_theme_id=1', + }, + path: directory, + }) + const config = new Config({root: __dirname}) + await config.load() + const command = new Pull(['--json', '--force', '-e', 'staging'], config) + + await withCapturedStandardStreams(async ({stdout}) => { + await command.run() + + expect(JSON.parse(stdout())).toMatchObject({ + environments: [{environment: 'staging', result: {directory: nativePath(directory), status: 'success'}}], + }) + }) + expect(executeThemePull).toHaveBeenCalledWith( + expect.objectContaining({path: directory, theme: '1'}), + session, + true, + expect.anything(), + ) + } finally { + cwdSpy.mockRestore() + } + }) + } finally { + // eslint-disable-next-line require-atomic-updates + process.exitCode = previousExitCode + } + }) + + test('exposes the schema in help and retains JSON and inherited flags', () => { + expect(Pull.jsonOutputSchema).toBe(themePullJsonOutputSchema) + expect(Pull.description).toContain('ThemePullResult') + expect(Pull.flags.json).toBeDefined() + expect(Pull.baseFlags).toHaveProperty('json-schema') + }) + + test('executes and writes the result through the real encoder', async () => { + vi.mocked(executeThemePull).mockResolvedValue({ + theme: { + id: 1, + name: 'Theme', + role: 'unpublished', + processing: false, + shop: session.storeFqdn, + editor_url: 'https://test.myshopify.com/admin/themes/1/editor', + preview_url: 'https://test.myshopify.com?preview_theme_id=1', + }, + path: '/theme', + }) + const command = new Pull([], 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, + directory: nativePath('/theme'), + theme: {id: '1', storeDomain: session.storeFqdn, sourceUrl: null}, + }) + expect(stderr()).toBe('') + }) + }) + + test('retains cancellation without emitting a success result', async () => { + vi.mocked(executeThemePull).mockResolvedValue(undefined) + const command = new Pull([], 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 download failures without writing a result', async () => { + const failure = new AbortError('download failed') + vi.mocked(executeThemePull).mockRejectedValueOnce(failure) + const command = new Pull([], new Config({root: '.'})) + await withCapturedStandardStreams(async ({stdout}) => { + await expect(command.command({json: true} as never, session, false)).rejects.toBe(failure) + + expect(stdout()).toBe('') + }) + }) +}) diff --git a/packages/theme/src/cli/commands/theme/pull.ts b/packages/theme/src/cli/commands/theme/pull.ts index 605eaaa5dd5..c4ca5dd67a6 100644 --- a/packages/theme/src/cli/commands/theme/pull.ts +++ b/packages/theme/src/cli/commands/theme/pull.ts @@ -1,7 +1,11 @@ +import {ThemeEnvironmentResult} from '../../services/json-output/schema.js' import {globFlags, themeFlags} from '../../flags.js' import ThemeCommand, {RequiredFlags} from '../../utilities/theme-command.js' -import {pull} from '../../services/pull.js' -import {globalFlags} from '@shopify/cli-kit/node/cli' +import {executeThemePull} from '../../services/pull.js' +import {renderThemePullResult, renderThemePullEnvironmentResults} from '../../services/pull/result.js' +import {themePullJsonOutputSchema} from '../../services/pull/types.js' +import {outputResult} from '@shopify/cli-kit/node/output' +import {globalFlags, jsonFlag} from '@shopify/cli-kit/node/cli' import {Flags} from '@oclif/core' import {recordTiming} from '@shopify/cli-kit/node/analytics' import {InferredFlags} from '@oclif/core/interfaces' @@ -12,6 +16,10 @@ import type {NonTTYFlagRequirement} from '@shopify/cli-kit/node/base-command' type PullFlags = InferredFlags export default class Pull extends ThemeCommand { + static get jsonOutputSchema() { + return themePullJsonOutputSchema + } + static summary = 'Download your remote theme files locally.' static descriptionWithMarkdown = `Retrieves theme files from Shopify. @@ -22,6 +30,7 @@ If no theme is specified, then you're prompted to select the theme to pull from static flags = { ...globalFlags, + ...jsonFlag, ...themeFlags, ...globFlags('download'), theme: Flags.string({ @@ -69,8 +78,22 @@ If no theme is specified, then you're prompted to select the theme to pull from context?: {stdout?: Writable; stderr?: Writable}, ) { recordTiming('theme-command:pull') - await pull({...flags, noColor: flags['no-color']}, adminSession, multiEnvironment, context) + const result = await executeThemePull( + {...flags, noColor: flags['no-color']}, + adminSession, + multiEnvironment, + context, + ) + if (!(flags.json && multiEnvironment)) { + if (result) renderThemePullResult(result, flags.json ? 'json' : 'text') + else if (flags.json) outputResult(themePullJsonOutputSchema.encode({status: 'cancelled'})) + } recordTiming('theme-command:pull') + return result ?? (flags.json && multiEnvironment ? {status: 'skipped', reason: 'unsafe-directory'} : undefined) + } + + protected renderEnvironmentResults(results: ThemeEnvironmentResult[]): void { + renderThemePullEnvironmentResults(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 index 4a972b058f4..672275279e3 100644 --- a/packages/theme/src/cli/commands/theme/transfer-environments.test.ts +++ b/packages/theme/src/cli/commands/theme/transfer-environments.test.ts @@ -1,7 +1,8 @@ import Push from './push.js' +import Pull from './pull.js' import {executeThemePush} from '../../services/push.js' - +import {executeThemePull} from '../../services/pull.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' @@ -12,7 +13,7 @@ import {Config} from '@oclif/core' import {describe, expect, test, vi} from 'vitest' vi.mock('../../services/push.js') - +vi.mock('../../services/pull.js') vi.mock('@shopify/cli-kit/node/environments') vi.mock('@shopify/cli-kit/node/session') vi.mock('@shopify/cli-kit/node/metadata') @@ -20,8 +21,11 @@ vi.mock('@shopify/cli-kit/node/metadata') class TestPush extends Push { public parse = vi.fn() } +class TestPull extends Pull { + public parse = vi.fn() +} -describe.each([TestPush])('%s', (Command) => { +describe.each([TestPush, TestPull])('%s', (Command) => { test.each(['none', 'partial', 'total', 'cancelled', 'analytics'] as const)( 'retains all environments in request order with %s failures', async (failures) => { @@ -69,7 +73,7 @@ describe.each([TestPush])('%s', (Command) => { } } vi.mocked(executeThemePush).mockImplementation(execute) - + vi.mocked(executeThemePull).mockImplementation(execute) const command = new Command( ['--environment', 'first', '--environment', 'second', '--environment', 'third'], new Config({root: path}), @@ -142,7 +146,7 @@ describe.each([TestPush])('%s', (Command) => { }, ]) expect(executeThemePush).not.toHaveBeenCalled() - + expect(executeThemePull).not.toHaveBeenCalled() expect(process.exitCode).toBe(1) }) } finally { diff --git a/packages/theme/src/cli/services/pull.test.ts b/packages/theme/src/cli/services/pull.test.ts index 3656d80bee2..87a6d0b3ec3 100644 --- a/packages/theme/src/cli/services/pull.test.ts +++ b/packages/theme/src/cli/services/pull.test.ts @@ -23,7 +23,10 @@ vi.mock('../utilities/theme-selector.js') vi.mock('../utilities/theme-store.js') vi.mock('../utilities/theme-fs.js') vi.mock('../utilities/theme-downloader.js') -vi.mock('../utilities/theme-ui.js') +vi.mock('../utilities/theme-ui.js', async (importOriginal) => ({ + ...(await importOriginal()), + ensureDirectoryConfirmed: vi.fn(), +})) vi.mock('@shopify/cli-kit/node/context/local') vi.mock('@shopify/cli-kit/node/session') vi.mock('@shopify/cli-kit/node/themes/api') @@ -56,7 +59,6 @@ describe('pull', () => { vi.mocked(ensureAuthenticatedThemes).mockResolvedValue(adminSession) vi.mocked(mountThemeFileSystem).mockReturnValue(localThemeFileSystem) vi.mocked(fetchChecksums).mockResolvedValue([]) - vi.mocked(themeComponent).mockReturnValue([]) findDevelopmentThemeSpy.mockClear() fetchDevelopmentThemeSpy.mockClear() }) diff --git a/packages/theme/src/cli/services/pull.ts b/packages/theme/src/cli/services/pull.ts index de4835e194d..2d2b18cbfce 100644 --- a/packages/theme/src/cli/services/pull.ts +++ b/packages/theme/src/cli/services/pull.ts @@ -1,6 +1,8 @@ +import {themePullResultSchema, type ThemePullResult} from './pull/types.js' +import {renderThemePullResult} from './pull/result.js' import {downloadTheme} from '../utilities/theme-downloader.js' import {hasRequiredThemeDirectories, mountThemeFileSystem} from '../utilities/theme-fs.js' -import {ensureDirectoryConfirmed, themeComponent} from '../utilities/theme-ui.js' +import {ensureDirectoryConfirmed} from '../utilities/theme-ui.js' import {rejectGeneratedStaticAssets} from '../utilities/asset-checksum.js' import {ensureThemeStore} from '../utilities/theme-store.js' import {DevelopmentThemeManager} from '../utilities/development-theme-manager.js' @@ -9,7 +11,6 @@ import {configureCLIEnvironment} from '../utilities/cli-config.js' import {Theme} from '@shopify/cli-kit/node/themes/types' import {AdminSession, ensureAuthenticatedThemes} from '@shopify/cli-kit/node/session' import {fetchChecksums} from '@shopify/cli-kit/node/themes/api' -import {renderSuccess} from '@shopify/cli-kit/node/ui' import {glob} from '@shopify/cli-kit/node/fs' import {cwd} from '@shopify/cli-kit/node/path' import {insideGitDirectory, isClean} from '@shopify/cli-kit/node/git' @@ -100,12 +101,12 @@ export interface PullFlags { * * @param flags - All flags are optional. */ -export async function pull( +export async function executeThemePull( flags: PullFlags, session?: AdminSession, multiEnvironment?: boolean, context?: {stdout?: Writable; stderr?: Writable}, -): Promise { +): Promise { recordTiming('theme-service:pull:setup') configureCLIEnvironment({verbose: flags.verbose, noColor: flags.noColor}) @@ -131,7 +132,7 @@ export async function pull( }) recordTiming('theme-service:pull:setup') - await executePull( + return executePull( theme, adminSession, { @@ -166,30 +167,22 @@ async function executePull( const themeChecksums = rejectGeneratedStaticAssets(remoteChecksums) recordTiming('theme-service:pull:file-system') - await downloadTheme(theme, session, themeChecksums, themeFileSystem, options, context) - - const header = options.environment ? `Environment: ${options.environment}` : '' - renderSuccess({ - headline: header, - body: ['The theme', ...themeComponent(theme), 'has been pulled.'], - nextSteps: [ - [ - { - link: { - label: 'View your theme', - url: themePreviewUrl(theme, session), - }, - }, - ], - [ - { - link: { - label: 'Customize your theme at the theme editor', - url: themeEditorUrl(theme, session), - }, - }, - ], - ], + const changed = await downloadTheme(theme, session, themeChecksums, themeFileSystem, options, context) + + return themePullResultSchema.parse({ + environment: options.environment, + path: options.path, + changed, + theme: { + id: theme.id, + name: theme.name, + role: theme.role, + processing: theme.processing, + src: theme.src, + shop: session.storeFqdn, + editor_url: themeEditorUrl(theme, session), + preview_url: themePreviewUrl(theme, session), + }, }) } @@ -253,3 +246,14 @@ async function validateDirectory(path: string, force: boolean, environment?: str return true } + +/** Compatibility adapter for callers of the exported theme API. */ +export async function pull( + flags: PullFlags, + session?: AdminSession, + multiEnvironment?: boolean, + context?: {stdout?: Writable; stderr?: Writable}, +): Promise { + const result = await executeThemePull(flags, session, multiEnvironment, context) + if (result) renderThemePullResult(result, 'text') +} diff --git a/packages/theme/src/cli/services/pull/execute.test.ts b/packages/theme/src/cli/services/pull/execute.test.ts new file mode 100644 index 00000000000..f73bae003e2 --- /dev/null +++ b/packages/theme/src/cli/services/pull/execute.test.ts @@ -0,0 +1,100 @@ +import {renderThemePullResult} from './result.js' +import {executeThemePull} from '../pull.js' +import {findOrSelectTheme} from '../../utilities/theme-selector.js' +import {DevelopmentThemeManager} from '../../utilities/development-theme-manager.js' +import {withCapturedStandardStreams} from '@shopify/cli-kit/node/testing/output' +import {inTemporaryDirectory, mkdir, writeFile, readFile, fileExists} from '@shopify/cli-kit/node/fs' +import {joinPath} from '@shopify/cli-kit/node/path' +import {fetchChecksums, fetchThemeAssets} from '@shopify/cli-kit/node/themes/api' +import {buildTheme} from '@shopify/cli-kit/node/themes/factories' +import {runWithCommandEvents, renderCommandEventAsJson} from '@shopify/cli-kit/node/command-events' +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('@shopify/cli-kit/node/themes/api') + +const session = {storeFqdn: 'test.myshopify.com', token: 'token'} + +describe('pull execution', () => { + test.each([false, true])('downloads real files and honors nodelete=%s with JSON progress', async (nodelete) => { + await inTemporaryDirectory(async (path) => { + await mkdir(joinPath(path, 'assets')) + await writeFile(joinPath(path, 'assets/old.css'), 'old') + vi.spyOn(DevelopmentThemeManager.prototype, 'fetch').mockResolvedValue(undefined) + vi.mocked(findOrSelectTheme).mockResolvedValue( + buildTheme({id: 1, name: 'Theme', role: 'unpublished', processing: false})!, + ) + vi.mocked(fetchChecksums).mockResolvedValue([{key: 'assets/theme.css', checksum: 'new'}]) + vi.mocked(fetchThemeAssets).mockResolvedValue([{key: 'assets/theme.css', checksum: 'new', value: 'body {}'}]) + await withCapturedStandardStreams(async ({stdout, stderr}) => { + await runWithCommandEvents({outputMode: 'json', sink: renderCommandEventAsJson}, async () => { + const result = await executeThemePull({path, force: true, nodelete}, session) + expect(result).toEqual({ + path, + changed: true, + theme: { + id: 1, + name: 'Theme', + role: 'unpublished', + processing: false, + shop: session.storeFqdn, + editor_url: 'https://test.myshopify.com/admin/themes/1/editor', + preview_url: 'https://test.myshopify.com?preview_theme_id=1', + }, + }) + renderThemePullResult(result!, 'json') + }) + + await expect(readFile(joinPath(path, 'assets/theme.css'))).resolves.toBe('body {}') + await expect(fileExists(joinPath(path, 'assets/old.css'))).resolves.toBe(nodelete) + expect(JSON.parse(stdout())).toMatchObject({status: 'success', directory: nativePath(path), theme: {id: '1'}}) + const events = stderr() + .trim() + .split('\n') + .map((line) => JSON.parse(line)) + expect(events).toEqual( + expect.arrayContaining([ + expect.objectContaining({type: 'progress', status: 'completed', message: 'Theme download complete'}), + ]), + ) + }) + }) + }) + + test('returns metadata when no files need downloading', async () => { + await inTemporaryDirectory(async (path) => { + vi.spyOn(DevelopmentThemeManager.prototype, 'fetch').mockResolvedValue(undefined) + vi.mocked(findOrSelectTheme).mockResolvedValue({ + ...buildTheme({id: 1, name: 'Theme', role: 'live'})!, + src: 'https://example.com/theme.zip', + }) + vi.mocked(fetchChecksums).mockResolvedValue([]) + const result = await runWithCommandEvents({outputMode: 'json'}, () => + executeThemePull({path, force: true}, session), + ) + expect(result?.theme).toMatchObject({role: 'live', src: 'https://example.com/theme.zip'}) + expect(result?.changed).toBe(false) + expect(fetchThemeAssets).not.toHaveBeenCalled() + }) + }) + + test('propagates download errors without writing a final result', async () => { + await inTemporaryDirectory(async (path) => { + vi.spyOn(DevelopmentThemeManager.prototype, 'fetch').mockResolvedValue(undefined) + vi.mocked(findOrSelectTheme).mockResolvedValue(buildTheme({id: 1, name: 'Theme', role: 'unpublished'})!) + vi.mocked(fetchChecksums).mockResolvedValue([{key: 'assets/theme.css', checksum: 'new'}]) + const failure = new Error('download failed') + vi.mocked(fetchThemeAssets).mockRejectedValue(failure) + await withCapturedStandardStreams(async ({stdout}) => { + await runWithCommandEvents({outputMode: 'json', sink: renderCommandEventAsJson}, async () => { + await expect(executeThemePull({path, force: true}, session)).rejects.toBe(failure) + }) + + expect(stdout()).toBe('') + }) + }) + }) +}) diff --git a/packages/theme/src/cli/services/pull/result.test.ts b/packages/theme/src/cli/services/pull/result.test.ts new file mode 100644 index 00000000000..2e2188094d7 --- /dev/null +++ b/packages/theme/src/cli/services/pull/result.test.ts @@ -0,0 +1,83 @@ +import {themePullJsonOutputSchema, type ThemePullResult} from './types.js' +import {renderThemePullResult, renderThemePullEnvironmentResults} from './result.js' +import {mockAndCaptureOutput, withCapturedStandardStreams} from '@shopify/cli-kit/node/testing/output' +import {describe, expect, test} 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' + +function result(): ThemePullResult { + return { + path: '/theme', + theme: { + id: 1, + name: 'Theme', + role: 'unpublished', + processing: false, + shop: 'test.myshopify.com', + editor_url: 'https://test.myshopify.com/admin/themes/1/editor', + preview_url: 'https://test.myshopify.com?preview_theme_id=1', + }, + } +} + +describe('pull result', () => { + test('encodes false and omits optional source and environment', () => { + const value = JSON.parse(themePullJsonOutputSchema.encode(result())) + expect(value).toMatchObject({ + status: 'success', + changed: true, + directory: nativePath('/theme'), + theme: {id: '1', sourceUrl: null, storeDomain: 'test.myshopify.com'}, + }) + expect(value).not.toHaveProperty('environment') + expect(value.theme).not.toHaveProperty('src') + expect(value.theme.processing).toBe(false) + }) + + test.each([{id: '1'}, {processing: null}, {src: false}])('rejects invalid theme fields %j', (fields) => { + expect(() => themePullJsonOutputSchema.validate({...result(), theme: {...result().theme, ...fields}})).toThrow() + }) + + test('rejects a missing local path', () => { + expect(() => themePullJsonOutputSchema.validate({theme: result().theme})).toThrow() + }) + + test('writes the real encoded result to stdout', async () => { + await withCapturedStandardStreams(({stdout, stderr}) => { + renderThemePullResult(result(), 'json') + + expect(stdout()).toBe(`${themePullJsonOutputSchema.encode(result())}\n`) + expect(stderr()).toBe('') + }) + }) + + test('retains explicit single environments, skips and errors', async () => { + await withCapturedStandardStreams(({stdout}) => { + renderThemePullEnvironmentResults([{environment: 'staging', result: result()}]) + expect(JSON.parse(stdout()).environments).toMatchObject([ + {environment: 'staging', result: {directory: nativePath('/theme')}}, + ]) + }) + await withCapturedStandardStreams(({stdout}) => { + renderThemePullEnvironmentResults([ + {environment: 'skipped', result: undefined}, + {environment: 'failed', error: {type: 'abort', message: 'Download failed'}}, + ]) + expect(JSON.parse(stdout()).environments).toEqual([ + {environment: 'skipped', result: {status: 'skipped', reason: 'unsafe-directory'}}, + {environment: 'failed', error: {type: 'abort', message: 'Download failed'}}, + ]) + }) + }) + + test('preserves the success banner and links', () => { + const output = mockAndCaptureOutput() + output.clear() + renderThemePullResult({...result(), environment: 'staging'}, 'text') + expect(output.info()).toContain('Environment: staging') + expect(output.info()).toContain("The theme 'Theme' (#1) has been pulled.") + expect(output.info()).toContain('View your theme') + expect(output.info()).toContain('Customize your theme at the theme editor') + }) +}) diff --git a/packages/theme/src/cli/services/pull/result.ts b/packages/theme/src/cli/services/pull/result.ts new file mode 100644 index 00000000000..08e6b05a9d3 --- /dev/null +++ b/packages/theme/src/cli/services/pull/result.ts @@ -0,0 +1,33 @@ +import {themePullJsonOutputSchema, type ThemePullResult} from './types.js' +import {ThemeEnvironmentResult} from '../json-output/schema.js' +import {themeComponent} from '../../utilities/theme-ui.js' +import {outputResult} from '@shopify/cli-kit/node/output' +import {renderSuccess} from '@shopify/cli-kit/node/ui' + +export function renderThemePullResult(result: ThemePullResult, format: 'text' | 'json'): void { + if (format === 'json') { + outputResult(themePullJsonOutputSchema.encode(result)) + return + } + const {environment, theme} = result + renderSuccess({ + headline: environment ? `Environment: ${environment}` : '', + body: ['The theme', ...themeComponent(theme), 'has been pulled.'], + nextSteps: [ + [{link: {label: 'View your theme', url: theme.preview_url}}], + [{link: {label: 'Customize your theme at the theme editor', url: theme.editor_url}}], + ], + }) +} + +export function renderThemePullEnvironmentResults(results: ThemeEnvironmentResult[]): void { + const environments = results.map((entry) => + 'error' in entry + ? entry + : { + ...entry, + result: entry.result ?? {status: 'skipped', reason: 'unsafe-directory'}, + }, + ) + outputResult(themePullJsonOutputSchema.encode({environments})) +} diff --git a/packages/theme/src/cli/services/pull/types.ts b/packages/theme/src/cli/services/pull/types.ts new file mode 100644 index 00000000000..d223e40b900 --- /dev/null +++ b/packages/theme/src/cli/services/pull/types.ts @@ -0,0 +1,63 @@ +import {defineThemeJsonOutputSchema, ThemeLinksSchema, projectTheme, storeDomain} from '../json-output/schema.js' +import {isAbsolutePath} from '@shopify/cli-kit/node/path' +import {zod} from '@shopify/cli-kit/node/schema' +// 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 ThemePullThemeSchema = zod.object({ + id: zod.number(), + name: zod.string(), + role: zod.string(), + processing: zod.boolean(), + src: zod.string().optional(), + shop: zod.string(), + editor_url: zod.string(), + preview_url: zod.string(), +}) + +export const themePullResultSchema = zod.object({ + environment: zod.string().optional(), + path: zod.string(), + changed: zod.boolean().optional(), + theme: ThemePullThemeSchema, +}) + +const ThemePullJsonThemeSchema = ThemeLinksSchema.extend({ + processing: zod.boolean(), + sourceUrl: zod.string().url().nullable(), +}).strict() +const ThemePullJsonResultSchema = zod + .object({ + status: zod.literal('success'), + changed: zod.boolean(), + directory: zod.string().refine(isAbsolutePath, 'Expected an absolute filesystem path.'), + theme: ThemePullJsonThemeSchema, + }) + .strict() +const SkippedSchema = zod.object({status: zod.literal('skipped'), reason: zod.literal('unsafe-directory')}).strict() + +export const themePullJsonOutputSchema = defineThemeJsonOutputSchema({ + name: 'ThemePullResult', + schema: zod.union([ThemePullJsonResultSchema, SkippedSchema]), + definitions: {ThemePullTheme: ThemePullJsonThemeSchema}, + project: (value) => { + if (SkippedSchema.safeParse(value).success) return value + const {theme, path, changed} = themePullResultSchema.parse(value) + return { + status: 'success', + changed: changed ?? true, + directory: resolvePath(path), + theme: { + ...projectTheme(theme), + storeDomain: storeDomain(theme.shop), + processing: theme.processing, + sourceUrl: theme.src === '' ? null : (theme.src ?? null), + editorUrl: theme.editor_url || null, + previewUrl: theme.preview_url || null, + }, + } + }, +}) + +export type ThemePullResult = zod.infer diff --git a/packages/theme/src/cli/utilities/theme-downloader.ts b/packages/theme/src/cli/utilities/theme-downloader.ts index a32dd8effe0..66d75893595 100644 --- a/packages/theme/src/cli/utilities/theme-downloader.ts +++ b/packages/theme/src/cli/utilities/theme-downloader.ts @@ -31,6 +31,7 @@ export async function downloadTheme( noProgressBar: options.multiEnvironment, }) } + return deleteTasks.length > 0 || downloadTasks.length > 0 } function buildDeleteTasks(remoteChecksums: Checksum[], themeFileSystem: ThemeFileSystem, options: DownloadOptions) {