diff --git a/.changeset/theme-console-finite-json.md b/.changeset/theme-console-finite-json.md new file mode 100644 index 00000000000..4b7bf4db2e2 --- /dev/null +++ b/.changeset/theme-console-finite-json.md @@ -0,0 +1,5 @@ +--- +'@shopify/cli': minor +--- + +Add finite Liquid console execution with repeated scripts, script files, and JSON output. diff --git a/docs-shopify.dev/generated/generated_docs_data_v2.json b/docs-shopify.dev/generated/generated_docs_data_v2.json index bd1fca43978..30eae534302 100644 --- a/docs-shopify.dev/generated/generated_docs_data_v2.json +++ b/docs-shopify.dev/generated/generated_docs_data_v2.json @@ -8681,6 +8681,24 @@ "isOptional": true, "environmentValue": "SHOPIFY_FLAG_PATH" }, + { + "filePath": "docs-shopify.dev/commands/interfaces/theme-console.interface.ts", + "syntaxKind": "PropertySignature", + "name": "--script ", + "value": "string", + "description": "Liquid command to evaluate. Repeat to run commands in order and exit.", + "isOptional": true, + "environmentValue": "SHOPIFY_FLAG_SCRIPT" + }, + { + "filePath": "docs-shopify.dev/commands/interfaces/theme-console.interface.ts", + "syntaxKind": "PropertySignature", + "name": "--script-file ", + "value": "string", + "description": "File containing one Liquid command per line. Evaluates the commands and exits.", + "isOptional": true, + "environmentValue": "SHOPIFY_FLAG_SCRIPT_FILE" + }, { "filePath": "docs-shopify.dev/commands/interfaces/theme-console.interface.ts", "syntaxKind": "PropertySignature", @@ -8717,6 +8735,15 @@ "isOptional": true, "environmentValue": "SHOPIFY_FLAG_ENVIRONMENT" }, + { + "filePath": "docs-shopify.dev/commands/interfaces/theme-console.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-console.interface.ts", "syntaxKind": "PropertySignature", @@ -8727,7 +8754,7 @@ "environmentValue": "SHOPIFY_FLAG_STORE" } ], - "value": "export interface themeconsole {\n /**\n * Alias of the Shopify account to use for authentication.\n * @environment SHOPIFY_FLAG_AUTH_ALIAS\n */\n '--auth-alias '?: string\n\n /**\n * The environment to apply to the current command.\n * @environment SHOPIFY_FLAG_ENVIRONMENT\n */\n '-e, --environment '?: string\n\n /**\n * Print the command's JSON schemas.\n * @environment SHOPIFY_FLAG_JSON_SCHEMA\n */\n '--json-schema'?: ''\n\n /**\n * Disable color output.\n * @environment SHOPIFY_FLAG_NO_COLOR\n */\n '--no-color'?: ''\n\n /**\n * Disable interactive prompts and browser authentication.\n * @environment SHOPIFY_FLAG_NO_INPUT\n */\n '--no-input'?: ''\n\n /**\n * Password generated from the Theme Access app or an Admin API token.\n * @environment SHOPIFY_CLI_THEME_TOKEN\n */\n '--password '?: string\n\n /**\n * The path where you want to run the command. Defaults to the current working directory.\n * @environment SHOPIFY_FLAG_PATH\n */\n '--path '?: string\n\n /**\n * 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 * The password for storefronts with password protection.\n * @environment SHOPIFY_FLAG_STORE_PASSWORD\n */\n '--store-password '?: string\n\n /**\n * The url to be used as context\n * @environment SHOPIFY_FLAG_URL\n */\n '--url '?: 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 themeconsole {\n /**\n * Alias of the Shopify account to use for authentication.\n * @environment SHOPIFY_FLAG_AUTH_ALIAS\n */\n '--auth-alias '?: string\n\n /**\n * The environment to apply to the current command.\n * @environment SHOPIFY_FLAG_ENVIRONMENT\n */\n '-e, --environment '?: string\n\n /**\n * Output the result as JSON. Automatically disables color output.\n * @environment SHOPIFY_FLAG_JSON\n */\n '-j, --json'?: ''\n\n /**\n * Print the command's JSON schemas.\n * @environment SHOPIFY_FLAG_JSON_SCHEMA\n */\n '--json-schema'?: ''\n\n /**\n * Disable color output.\n * @environment SHOPIFY_FLAG_NO_COLOR\n */\n '--no-color'?: ''\n\n /**\n * Disable interactive prompts and browser authentication.\n * @environment SHOPIFY_FLAG_NO_INPUT\n */\n '--no-input'?: ''\n\n /**\n * Password generated from the Theme Access app or an Admin API token.\n * @environment SHOPIFY_CLI_THEME_TOKEN\n */\n '--password '?: string\n\n /**\n * The path where you want to run the command. Defaults to the current working directory.\n * @environment SHOPIFY_FLAG_PATH\n */\n '--path '?: string\n\n /**\n * Liquid command to evaluate. Repeat to run commands in order and exit.\n * @environment SHOPIFY_FLAG_SCRIPT\n */\n '--script '?: string\n\n /**\n * File containing one Liquid command per line. Evaluates the commands and exits.\n * @environment SHOPIFY_FLAG_SCRIPT_FILE\n */\n '--script-file '?: 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 * The password for storefronts with password protection.\n * @environment SHOPIFY_FLAG_STORE_PASSWORD\n */\n '--store-password '?: string\n\n /**\n * The url to be used as context\n * @environment SHOPIFY_FLAG_URL\n */\n '--url '?: string\n\n /**\n * Increase the verbosity of the output. May include sensitive data.\n * @environment SHOPIFY_FLAG_VERBOSE\n */\n '--verbose'?: ''\n}" } }, "themedelete": { diff --git a/packages/cli/README.md b/packages/cli/README.md index 1bdcda90476..b8338fe1aae 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -12872,6 +12872,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] + -s, --store= Store URL. It can be the store prefix (example) or the full myshopify.com URL (example.myshopify.com, https://example.myshopify.com). @@ -12901,6 +12905,14 @@ FLAGS The path where you want to run the command. Defaults to the current working directory. [env: SHOPIFY_FLAG_PATH] + --script=... + Liquid command to evaluate. Repeat to run commands in order and exit. + [env: SHOPIFY_FLAG_SCRIPT] + + --script-file= + File containing one Liquid command per line. Evaluates the commands and exits. + [env: SHOPIFY_FLAG_SCRIPT_FILE] + --store-password= The password for storefronts with password protection. [env: SHOPIFY_FLAG_STORE_PASSWORD] @@ -12919,7 +12931,391 @@ DESCRIPTION Starts the Shopify Liquid REPL (read-eval-print loop) tool. This tool provides an interactive terminal interface for evaluating Liquid code and exploring Liquid objects, filters, and tags using real store data. - You can also provide context to the console using a URL, as some Liquid objects are context-specific + You can also provide context to the console using a URL, as some Liquid objects are context-specific. + + Use repeatable `--script` flags or `--script-file` to evaluate commands sequentially in one session and exit. Script + files contain one console command per line; blank lines are ignored. Stop at the first error and skip remaining + commands. + + `--json` requires `--script` or `--script-file` and returns one object with ordered results. Use `--no-input` as well + to prevent authentication and password prompts. + + Use `--json-schema` to print the result, error, and event schemas. + + Output from `--json` conforms to the `ThemeConsoleResult` 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": "ThemeConsoleResult", + "definitions": { + "ConsoleInputResult": { + "anyOf": [ + { + "type": "object", + "properties": { + "input": { + "type": "string" + }, + "status": { + "type": "string", + "const": "success" + }, + "value": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "boolean" + }, + { + "type": "null" + }, + { + "type": "array", + "items": { + "$ref": "#/definitions/LiquidValue" + } + }, + { + "type": "object", + "additionalProperties": { + "$ref": "#/definitions/LiquidValue" + } + } + ], + "description": "The native Liquid value. Assignments return null." + } + }, + "required": [ + "input", + "status", + "value" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "input": { + "type": "string" + }, + "status": { + "type": "string", + "const": "failed" + }, + "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/ConsoleInputResult/anyOf/1/properties/error/anyOf/0/properties/message" + }, + "code": { + "$ref": "#/definitions/ConsoleInputResult/anyOf/1/properties/error/anyOf/0/properties/code" + }, + "tryMessage": { + "$ref": "#/definitions/ConsoleInputResult/anyOf/1/properties/error/anyOf/0/properties/tryMessage" + }, + "nextSteps": { + "$ref": "#/definitions/ConsoleInputResult/anyOf/1/properties/error/anyOf/0/properties/nextSteps" + }, + "customSections": { + "$ref": "#/definitions/ConsoleInputResult/anyOf/1/properties/error/anyOf/0/properties/customSections" + }, + "details": { + "$ref": "#/definitions/ConsoleInputResult/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/ConsoleInputResult/anyOf/1/properties/error/anyOf/0/properties/message" + }, + "code": { + "$ref": "#/definitions/ConsoleInputResult/anyOf/1/properties/error/anyOf/0/properties/code" + }, + "tryMessage": { + "$ref": "#/definitions/ConsoleInputResult/anyOf/1/properties/error/anyOf/0/properties/tryMessage" + }, + "nextSteps": { + "$ref": "#/definitions/ConsoleInputResult/anyOf/1/properties/error/anyOf/0/properties/nextSteps" + }, + "customSections": { + "$ref": "#/definitions/ConsoleInputResult/anyOf/1/properties/error/anyOf/0/properties/customSections" + }, + "details": { + "$ref": "#/definitions/ConsoleInputResult/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": [ + "input", + "status", + "error" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "input": { + "type": "string" + }, + "status": { + "type": "string", + "const": "skipped" + }, + "reason": { + "type": "string", + "const": "previous-input-failed" + } + }, + "required": [ + "input", + "status", + "reason" + ], + "additionalProperties": false + } + ] + }, + "LiquidValue": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "boolean" + }, + { + "type": "null" + }, + { + "type": "array", + "items": { + "$ref": "#/definitions/LiquidValue" + } + }, + { + "type": "object", + "additionalProperties": { + "$ref": "#/definitions/LiquidValue" + } + } + ] + }, + "ThemeEnvironment": { + "anyOf": [ + { + "type": "object", + "properties": { + "environment": { + "type": "string" + }, + "result": { + "anyOf": [ + { + "type": "object", + "properties": { + "status": { + "type": "string", + "enum": [ + "success", + "partial" + ] + }, + "results": { + "type": "array", + "items": { + "$ref": "#/definitions/ConsoleInputResult" + } + } + }, + "required": [ + "status", + "results" + ], + "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": { + "$ref": "#/definitions/ConsoleInputResult/anyOf/1/properties/error" + } + }, + "required": [ + "environment", + "error" + ], + "additionalProperties": false + } + ] + } + }, + "$schema": "http://json-schema.org/draft-07/schema#" + } + ``` + +EXAMPLES + $ shopify theme console --store example --script "shop.name" --script "shop.currency" --json --no-input + + $ shopify theme console --store example --script-file ./commands.txt --json --no-input ``` ## `shopify theme delete` diff --git a/packages/cli/oclif.manifest.json b/packages/cli/oclif.manifest.json index 6acae62800c..07f2cb2ba0d 100644 --- a/packages/cli/oclif.manifest.json +++ b/packages/cli/oclif.manifest.json @@ -10848,9 +10848,13 @@ "args": { }, "customPluginName": "@shopify/theme", - "description": "Starts the Shopify Liquid REPL (read-eval-print loop) tool. This tool provides an interactive terminal interface for evaluating Liquid code and exploring Liquid objects, filters, and tags using real store data.\n\n You can also provide context to the console using a URL, as some Liquid objects are context-specific", - "descriptionWithMarkdown": "Starts the Shopify Liquid REPL (read-eval-print loop) tool. This tool provides an interactive terminal interface for evaluating Liquid code and exploring Liquid objects, filters, and tags using real store data.\n\n You can also provide context to the console using a URL, as some Liquid objects are context-specific", + "description": "Starts the Shopify Liquid REPL (read-eval-print loop) tool. This tool provides an interactive terminal interface for evaluating Liquid code and exploring Liquid objects, filters, and tags using real store data.\n\nYou can also provide context to the console using a URL, as some Liquid objects are context-specific.\n\nUse repeatable `--script` flags or `--script-file` to evaluate commands sequentially in one session and exit. Script files contain one console command per line; blank lines are ignored. Stop at the first error and skip remaining commands.\n\n`--json` requires `--script` or `--script-file` and returns one object with ordered results. Use `--no-input` as well to prevent authentication and password prompts.\n\nUse `--json-schema` to print the result, error, and event schemas.\n\nOutput from `--json` conforms to the `ThemeConsoleResult` 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\": \"ThemeConsoleResult\",\n \"definitions\": {\n \"ConsoleInputResult\": {\n \"anyOf\": [\n {\n \"type\": \"object\",\n \"properties\": {\n \"input\": {\n \"type\": \"string\"\n },\n \"status\": {\n \"type\": \"string\",\n \"const\": \"success\"\n },\n \"value\": {\n \"anyOf\": [\n {\n \"type\": \"string\"\n },\n {\n \"type\": \"number\"\n },\n {\n \"type\": \"boolean\"\n },\n {\n \"type\": \"null\"\n },\n {\n \"type\": \"array\",\n \"items\": {\n \"$ref\": \"#/definitions/LiquidValue\"\n }\n },\n {\n \"type\": \"object\",\n \"additionalProperties\": {\n \"$ref\": \"#/definitions/LiquidValue\"\n }\n }\n ],\n \"description\": \"The native Liquid value. Assignments return null.\"\n }\n },\n \"required\": [\n \"input\",\n \"status\",\n \"value\"\n ],\n \"additionalProperties\": false\n },\n {\n \"type\": \"object\",\n \"properties\": {\n \"input\": {\n \"type\": \"string\"\n },\n \"status\": {\n \"type\": \"string\",\n \"const\": \"failed\"\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/ConsoleInputResult/anyOf/1/properties/error/anyOf/0/properties/message\"\n },\n \"code\": {\n \"$ref\": \"#/definitions/ConsoleInputResult/anyOf/1/properties/error/anyOf/0/properties/code\"\n },\n \"tryMessage\": {\n \"$ref\": \"#/definitions/ConsoleInputResult/anyOf/1/properties/error/anyOf/0/properties/tryMessage\"\n },\n \"nextSteps\": {\n \"$ref\": \"#/definitions/ConsoleInputResult/anyOf/1/properties/error/anyOf/0/properties/nextSteps\"\n },\n \"customSections\": {\n \"$ref\": \"#/definitions/ConsoleInputResult/anyOf/1/properties/error/anyOf/0/properties/customSections\"\n },\n \"details\": {\n \"$ref\": \"#/definitions/ConsoleInputResult/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/ConsoleInputResult/anyOf/1/properties/error/anyOf/0/properties/message\"\n },\n \"code\": {\n \"$ref\": \"#/definitions/ConsoleInputResult/anyOf/1/properties/error/anyOf/0/properties/code\"\n },\n \"tryMessage\": {\n \"$ref\": \"#/definitions/ConsoleInputResult/anyOf/1/properties/error/anyOf/0/properties/tryMessage\"\n },\n \"nextSteps\": {\n \"$ref\": \"#/definitions/ConsoleInputResult/anyOf/1/properties/error/anyOf/0/properties/nextSteps\"\n },\n \"customSections\": {\n \"$ref\": \"#/definitions/ConsoleInputResult/anyOf/1/properties/error/anyOf/0/properties/customSections\"\n },\n \"details\": {\n \"$ref\": \"#/definitions/ConsoleInputResult/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 \"input\",\n \"status\",\n \"error\"\n ],\n \"additionalProperties\": false\n },\n {\n \"type\": \"object\",\n \"properties\": {\n \"input\": {\n \"type\": \"string\"\n },\n \"status\": {\n \"type\": \"string\",\n \"const\": \"skipped\"\n },\n \"reason\": {\n \"type\": \"string\",\n \"const\": \"previous-input-failed\"\n }\n },\n \"required\": [\n \"input\",\n \"status\",\n \"reason\"\n ],\n \"additionalProperties\": false\n }\n ]\n },\n \"LiquidValue\": {\n \"anyOf\": [\n {\n \"type\": \"string\"\n },\n {\n \"type\": \"number\"\n },\n {\n \"type\": \"boolean\"\n },\n {\n \"type\": \"null\"\n },\n {\n \"type\": \"array\",\n \"items\": {\n \"$ref\": \"#/definitions/LiquidValue\"\n }\n },\n {\n \"type\": \"object\",\n \"additionalProperties\": {\n \"$ref\": \"#/definitions/LiquidValue\"\n }\n }\n ]\n },\n \"ThemeEnvironment\": {\n \"anyOf\": [\n {\n \"type\": \"object\",\n \"properties\": {\n \"environment\": {\n \"type\": \"string\"\n },\n \"result\": {\n \"anyOf\": [\n {\n \"type\": \"object\",\n \"properties\": {\n \"status\": {\n \"type\": \"string\",\n \"enum\": [\n \"success\",\n \"partial\"\n ]\n },\n \"results\": {\n \"type\": \"array\",\n \"items\": {\n \"$ref\": \"#/definitions/ConsoleInputResult\"\n }\n }\n },\n \"required\": [\n \"status\",\n \"results\"\n ],\n \"additionalProperties\": false\n },\n {\n \"type\": \"object\",\n \"properties\": {\n \"status\": {\n \"type\": \"string\",\n \"const\": \"cancelled\"\n }\n },\n \"required\": [\n \"status\"\n ],\n \"additionalProperties\": false\n }\n ]\n }\n },\n \"required\": [\n \"environment\",\n \"result\"\n ],\n \"additionalProperties\": false\n },\n {\n \"type\": \"object\",\n \"properties\": {\n \"environment\": {\n \"type\": \"string\"\n },\n \"error\": {\n \"$ref\": \"#/definitions/ConsoleInputResult/anyOf/1/properties/error\"\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": "Starts the Shopify Liquid REPL (read-eval-print loop) tool. This tool provides an interactive terminal interface for evaluating Liquid code and exploring Liquid objects, filters, and tags using real store data.\n\nYou can also provide context to the console using a URL, as some Liquid objects are context-specific.\n\nUse repeatable `--script` flags or `--script-file` to evaluate commands sequentially in one session and exit. Script files contain one console command per line; blank lines are ignored. Stop at the first error and skip remaining commands.\n\n`--json` requires `--script` or `--script-file` and returns one object with ordered results. Use `--no-input` as well to prevent authentication and password prompts.", "enableJsonFlag": false, + "examples": [ + "<%= config.bin %> <%= command.id %> --store example --script \"shop.name\" --script \"shop.currency\" --json --no-input", + "<%= config.bin %> <%= command.id %> --store example --script-file ./commands.txt --json --no-input" + ], "flags": { "auth-alias": { "description": "Alias of the Shopify account to use for authentication.", @@ -10869,6 +10873,15 @@ "name": "environment", "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.", @@ -10908,6 +10921,28 @@ "noCacheDefault": true, "type": "option" }, + "script": { + "description": "Liquid command to evaluate. Repeat to run commands in order and exit.", + "env": "SHOPIFY_FLAG_SCRIPT", + "exclusive": [ + "script-file" + ], + "hasDynamicHelp": false, + "multiple": true, + "name": "script", + "type": "option" + }, + "script-file": { + "description": "File containing one Liquid command per line. Evaluates the commands and exits.", + "env": "SHOPIFY_FLAG_SCRIPT_FILE", + "exclusive": [ + "script" + ], + "hasDynamicHelp": false, + "multiple": false, + "name": "script-file", + "type": "option" + }, "store": { "char": "s", "description": "Store URL. It can be the store prefix (example) or the full myshopify.com URL (example.myshopify.com, https://example.myshopify.com).", 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 a2165527d7a..0cee4941680 100644 --- a/packages/eslint-plugin-cli/rules/json-output-command-exceptions.js +++ b/packages/eslint-plugin-cli/rules/json-output-command-exceptions.js @@ -15,7 +15,6 @@ const commandExceptions = [ 'packages/app/src/cli/commands/app/graphiql.ts', 'packages/app/src/cli/commands/app/logs.ts', 'packages/store/src/cli/commands/store/graphiql.ts', - 'packages/theme/src/cli/commands/theme/console.ts', 'packages/theme/src/cli/commands/theme/dev.ts', 'packages/theme/src/cli/commands/theme/language-server.ts', ] diff --git a/packages/theme/src/cli/commands/theme/console.test.ts b/packages/theme/src/cli/commands/theme/console.test.ts new file mode 100644 index 00000000000..d6fffdea710 --- /dev/null +++ b/packages/theme/src/cli/commands/theme/console.test.ts @@ -0,0 +1,167 @@ +import Console from './console.js' +import {ensureReplEnv, evaluateConsoleInputs, initializeRepl} from '../../services/console.js' +import {themeConsoleJsonOutputSchema} from '../../services/console/types.js' +import {afterEach, expect, test, vi} from 'vitest' +import {Config} from '@oclif/core' +import {ensureAuthenticatedThemes} from '@shopify/cli-kit/node/session' +import {AbortError} from '@shopify/cli-kit/node/error' +import {loadEnvironment} from '@shopify/cli-kit/node/environments' +import {inTemporaryDirectory, writeFile} from '@shopify/cli-kit/node/fs' +import {joinPath} from '@shopify/cli-kit/node/path' +import {withCapturedStandardStreams} from '@shopify/cli-kit/node/testing/output' + +vi.mock('../../services/console.js') +vi.mock('@shopify/cli-kit/node/session') +vi.mock('@shopify/cli-kit/node/environments') + +afterEach(() => vi.unstubAllEnvs()) + +function restoreExitCode(value: typeof process.exitCode): void { + process.exitCode = value +} + +async function runConsole(argv: string[]) { + const config = new Config({root: __dirname}) + await config.load() + vi.mocked(ensureAuthenticatedThemes).mockResolvedValue({storeFqdn: 'shop.myshopify.com', token: 'token'}) + vi.mocked(ensureReplEnv).mockResolvedValue({themeId: '123', storePassword: undefined}) + await new Console(['--store=shop.myshopify.com', '--password=token', ...argv], config).run() +} + +test.each([{flags: []}, {flags: ['--no-input']}])( + 'rejects JSON without supplied commands before authentication: $flags', + async ({flags}) => { + await expect(runConsole(['--json', ...flags])).rejects.toThrow('Use --script or --script-file with --json') + expect(ensureAuthenticatedThemes).not.toHaveBeenCalled() + expect(ensureReplEnv).not.toHaveBeenCalled() + }, +) + +test('rejects conflicting script flags before authentication', async () => { + await expect(runConsole(['--script', 'shop.name', '--script-file', 'commands.txt', '--json'])).rejects.toThrow() + expect(ensureAuthenticatedThemes).not.toHaveBeenCalled() +}) + +test.each(['', ' '])('rejects blank inline commands: %j', async (input) => { + await expect(runConsole(['--script', input, '--json'])).rejects.toThrow('Each --script must contain a Liquid command') + expect(ensureAuthenticatedThemes).not.toHaveBeenCalled() +}) + +test.each([false, true])('writes one document for repeated scripts with no-input=%s', async (noInput) => { + const result = { + status: 'success' as const, + results: [ + {input: 'shop.name', status: 'success' as const, value: 'Test shop'}, + {input: 'false', status: 'success' as const, value: false}, + ], + } + vi.mocked(evaluateConsoleInputs).mockResolvedValue(result) + + await withCapturedStandardStreams(async (streams) => { + await runConsole(['--script', 'shop.name', '--script', 'false', '--json', ...(noInput ? ['--no-input'] : [])]) + expect(JSON.parse(streams.stdout())).toEqual(result) + expect(streams.stderr()).toBe('') + expect(streams.stdout()).not.toContain('Welcome') + }) + expect(evaluateConsoleInputs).toHaveBeenCalledWith({ + inputs: ['shop.name', 'false'], + adminSession: {storeFqdn: 'shop.myshopify.com', token: 'token'}, + themeId: '123', + url: '/', + themeAccessPassword: 'token', + storefrontPassword: undefined, + }) + expect(initializeRepl).not.toHaveBeenCalled() +}) + +test('reads commands from a real file before setting up the console session', async () => { + vi.mocked(evaluateConsoleInputs).mockResolvedValue({status: 'success', results: []}) + await inTemporaryDirectory(async (directory) => { + const scriptFile = joinPath(directory, 'commands.txt') + await writeFile(scriptFile, 'shop.name\r\nshop.currency\r\n') + await runConsole(['--script-file', scriptFile, '--json']) + }) + expect(evaluateConsoleInputs).toHaveBeenCalledWith(expect.objectContaining({inputs: ['shop.name', 'shop.currency']})) +}) + +test('rejects a missing file before creating the REPL theme', async () => { + await inTemporaryDirectory(async (directory) => { + await expect(runConsole(['--script-file', joinPath(directory, 'missing.txt'), '--json'])).rejects.toThrow() + }) + expect(ensureReplEnv).not.toHaveBeenCalled() +}) + +test('runs supplied commands in text mode without starting the REPL', async () => { + vi.mocked(evaluateConsoleInputs).mockResolvedValue({ + status: 'success', + results: [{input: 'shop.name', status: 'success', value: 'Test shop'}], + }) + await withCapturedStandardStreams(async (streams) => { + await runConsole(['--script', 'shop.name', '--no-input']) + expect(streams.stderr()).toContain('"Test shop"') + expect(streams.stdout()).toBe('') + }) + expect(initializeRepl).not.toHaveBeenCalled() +}) + +test('keeps the interactive console when scripts are omitted', async () => { + await runConsole([]) + expect(initializeRepl).toHaveBeenCalledWith( + {storeFqdn: 'shop.myshopify.com', token: 'token'}, + '123', + '/', + 'token', + undefined, + ) + expect(evaluateConsoleInputs).not.toHaveBeenCalled() +}) + +test('returns failed results with a nonzero exit code', async () => { + const result = { + status: 'partial' as const, + results: [ + {input: 'invalid', status: 'failed' as const, error: {type: 'abort' as const, message: 'Invalid command'}}, + ], + } + vi.mocked(evaluateConsoleInputs).mockResolvedValue(result) + const previousExitCode = process.exitCode + try { + await withCapturedStandardStreams(async (streams) => { + await runConsole(['--script', 'invalid', '--json']) + expect(JSON.parse(streams.stdout())).toEqual(result) + expect(process.exitCode).toBe(1) + }) + } finally { + restoreExitCode(previousExitCode) + } +}) + +test('does not write a result when session setup fails', async () => { + vi.mocked(evaluateConsoleInputs).mockRejectedValue(new AbortError('Session expired')) + await withCapturedStandardStreams(async (streams) => { + await expect(runConsole(['--script', 'shop.name', '--json'])).rejects.toThrow('Session expired') + expect(streams.stdout()).toBe('') + }) +}) + +test('wraps a named environment in the theme environment contract', async () => { + const result = { + status: 'success' as const, + results: [{input: 'shop.name', status: 'success' as const, value: 'Shop'}], + } + vi.mocked(loadEnvironment).mockResolvedValue({store: 'shop.myshopify.com', password: 'token'}) + vi.mocked(evaluateConsoleInputs).mockResolvedValue(result) + await withCapturedStandardStreams(async (streams) => { + await runConsole(['--environment', 'development', '--script', 'shop.name', '--json']) + expect(JSON.parse(streams.stdout())).toEqual({environments: [{environment: 'development', result}]}) + }) +}) + +test('exposes a strict schema that preserves native Liquid values', () => { + expect(Console.jsonOutputSchema).toBe(themeConsoleJsonOutputSchema) + for (const value of [null, false, 0, '', [1, false], {product_id: 1, tags: ['a']}]) { + const result = {status: 'success', results: [{input: 'value', status: 'success', value}]} + expect(JSON.parse(themeConsoleJsonOutputSchema.encode(result))).toEqual(result) + } + expect(() => themeConsoleJsonOutputSchema.encode({status: 'success', results: [], extra: true})).toThrow() +}) diff --git a/packages/theme/src/cli/commands/theme/console.ts b/packages/theme/src/cli/commands/theme/console.ts index 928c7c100c3..6236d1b0c43 100644 --- a/packages/theme/src/cli/commands/theme/console.ts +++ b/packages/theme/src/cli/commands/theme/console.ts @@ -1,28 +1,58 @@ -import {ensureReplEnv, initializeRepl} from '../../services/console.js' +import {ensureReplEnv, evaluateConsoleInputs, initializeRepl} from '../../services/console.js' +import {loadConsoleInputs} from '../../services/console/input.js' +import {themeConsoleJsonOutputSchema} from '../../services/console/types.js' +import {presentValue} from '../../utilities/repl/presenter.js' import ThemeCommand, {RequiredFlags} from '../../utilities/theme-command.js' import {themeFlags} from '../../flags.js' -import {globalFlags} from '@shopify/cli-kit/node/cli' +import {globalFlags, jsonFlag} from '@shopify/cli-kit/node/cli' import {AdminSession} from '@shopify/cli-kit/node/session' import {Flags} from '@oclif/core' import {recordEvent} from '@shopify/cli-kit/node/analytics' import {InferredFlags} from '@oclif/core/interfaces' +import {AbortError} from '@shopify/cli-kit/node/error' +import {outputResult} from '@shopify/cli-kit/node/output' +import {renderError} from '@shopify/cli-kit/node/ui' type ConsoleFlags = InferredFlags export default class Console extends ThemeCommand { + static jsonOutputSchema = themeConsoleJsonOutputSchema + static summary = 'Shopify Liquid REPL (read-eval-print loop) tool' static usage = ['theme console', 'theme console --url /products/classic-leather-jacket'] static descriptionWithMarkdown = `Starts the Shopify Liquid REPL (read-eval-print loop) tool. This tool provides an interactive terminal interface for evaluating Liquid code and exploring Liquid objects, filters, and tags using real store data. - You can also provide context to the console using a URL, as some Liquid objects are context-specific` +You can also provide context to the console using a URL, as some Liquid objects are context-specific. + +Use repeatable \`--script\` flags or \`--script-file\` to evaluate commands sequentially in one session and exit. Script files contain one console command per line; blank lines are ignored. Stop at the first error and skip remaining commands. + +\`--json\` requires \`--script\` or \`--script-file\` and returns one object with ordered results. Use \`--no-input\` as well to prevent authentication and password prompts.` + + static examples = [ + '<%= config.bin %> <%= command.id %> --store example --script "shop.name" --script "shop.currency" --json --no-input', + '<%= config.bin %> <%= command.id %> --store example --script-file ./commands.txt --json --no-input', + ] static description = this.descriptionForHelp() static flags = { ...globalFlags, + ...jsonFlag, ...themeFlags, + script: Flags.string({ + description: 'Liquid command to evaluate. Repeat to run commands in order and exit.', + multiple: true, + multipleNonGreedy: true, + exclusive: ['script-file'], + env: 'SHOPIFY_FLAG_SCRIPT', + }), + 'script-file': Flags.string({ + description: 'File containing one Liquid command per line. Evaluates the commands and exits.', + exclusive: ['script'], + env: 'SHOPIFY_FLAG_SCRIPT_FILE', + }), url: Flags.string({ description: 'The url to be used as context', env: 'SHOPIFY_FLAG_URL', @@ -36,13 +66,45 @@ export default class Console extends ThemeCommand { static multiEnvironmentsFlags: RequiredFlags = null - async command(flags: ConsoleFlags, adminSession: AdminSession) { + async command(flags: ConsoleFlags, adminSession: AdminSession, multiEnvironment = false) { const {url, password: themeAccessPassword} = flags + const inputs = await loadConsoleInputs(flags.script, flags['script-file']) const {themeId, storePassword} = await ensureReplEnv(adminSession, flags['store-password']) recordEvent('theme-command:console:single-env:authenticated') - await initializeRepl(adminSession, themeId, url, themeAccessPassword, storePassword) + if (inputs === undefined) { + await initializeRepl(adminSession, themeId, url, themeAccessPassword, storePassword) + return + } + + const result = await evaluateConsoleInputs({ + inputs, + adminSession, + themeId, + url, + themeAccessPassword, + storefrontPassword: storePassword, + }) + if (result.status === 'partial') process.exitCode = 1 + if (flags.json && multiEnvironment) return result + if (flags.json) { + outputResult(themeConsoleJsonOutputSchema.encode(result)) + } else { + for (const entry of result.results) { + if (entry.status === 'success') presentValue(entry.value) + if (entry.status === 'failed') renderError({body: entry.error.message}) + } + } + } + + protected validateCommandFlags(flags: ConsoleFlags): void { + if (flags.json && !flags.script?.length && !flags['script-file']) { + throw new AbortError('Use --script or --script-file with --json to supply Liquid commands.') + } + if (flags.script?.some((input) => input.trim().length === 0)) { + throw new AbortError('Each --script must contain a Liquid command.') + } } } diff --git a/packages/theme/src/cli/services/console.ts b/packages/theme/src/cli/services/console.ts index 4ad2674ed7f..bd47ec6bb55 100644 --- a/packages/theme/src/cli/services/console.ts +++ b/packages/theme/src/cli/services/console.ts @@ -1,11 +1,17 @@ +import {ConsoleResult} from './console/types.js' import {isStorefrontPasswordProtected} from '../utilities/theme-environment/storefront-session.js' import {REPLThemeManager} from '../utilities/repl/repl-theme-manager.js' import {ensureValidPassword} from '../utilities/theme-environment/storefront-password-prompt.js' -import {replLoop} from '../utilities/repl/repl.js' -import {initializeDevServerSession} from '../utilities/theme-environment/dev-server-session.js' +import {DELIMITER_WARNING, hasDelimiter, replLoop} from '../utilities/repl/repl.js' +import {evaluate, SessionItem} from '../utilities/repl/evaluator.js' +import {hasJsonError, JSON_ERROR_MESSAGE} from '../utilities/repl/presenter.js' +import {fetchDevServerSession, initializeDevServerSession} from '../utilities/theme-environment/dev-server-session.js' +import {extractFetchErrorInfo} from '../utilities/errors.js' import {AdminSession} from '@shopify/cli-kit/node/session' import {outputInfo} from '@shopify/cli-kit/node/output' import {AbortError} from '@shopify/cli-kit/node/error' +import {errorToJson} from '@shopify/cli-kit/node/error/serialization' +import {isError as isH3Error} from 'h3' export async function ensureReplEnv(adminSession: AdminSession, storePasswordFlag?: string) { const themeId = await findOrCreateReplTheme(adminSession) @@ -36,6 +42,53 @@ export async function initializeRepl( ) { outputInfo('Welcome to Shopify Liquid console\n(press Ctrl + C to exit)') + validateThemeAccessPassword(themeAccessPassword) + const session = await initializeDevServerSession(themeId, adminSession, themeAccessPassword, storefrontPassword) + + return replLoop(session, themeId, url) +} + +export async function evaluateConsoleInputs(options: { + inputs: string[] + adminSession: AdminSession + themeId: string + url: string + themeAccessPassword?: string + storefrontPassword?: string +}): Promise { + const {inputs, adminSession, themeId, url, themeAccessPassword, storefrontPassword} = options + validateThemeAccessPassword(themeAccessPassword) + const session = await fetchDevServerSession(themeId, adminSession, themeAccessPassword, storefrontPassword) + const replSession: SessionItem[] = [] + const results: ConsoleResult['results'] = [] + let failed = false + + for (const input of inputs) { + if (failed) { + results.push({input, status: 'skipped', reason: 'previous-input-failed'}) + continue + } + try { + if (hasDelimiter(input)) throw new AbortError(DELIMITER_WARNING) + // Assignments must finish before the next expression reads the shared Liquid context. + // eslint-disable-next-line no-await-in-loop + const value = await evaluate({snippet: input, themeSession: session, themeId, url, replSession, mode: 'batch'}) + if (hasJsonError(value)) throw new AbortError(JSON_ERROR_MESSAGE) + results.push({input, status: 'success', value: value ?? null}) + } catch (error) { + if (!(error instanceof AbortError) && !isH3Error(error)) throw error + const evaluationError = isH3Error(error) + ? new AbortError(extractFetchErrorInfo(error, 'Liquid evaluation failed').headline) + : error + failed = true + results.push({input, status: 'failed', error: errorToJson(evaluationError)}) + } + } + + return {status: failed ? 'partial' : 'success', results} +} + +function validateThemeAccessPassword(themeAccessPassword?: string): void { if (themeAccessPassword?.startsWith('shpat_')) { throw new AbortError( 'Unable to use Admin API tokens with the console command', @@ -49,8 +102,4 @@ Alternatively, you can authenticate normally by not passing the --password flag. Learn more: https://shopify.dev/docs/storefronts/themes/tools/theme-access`, ) } - - const session = await initializeDevServerSession(themeId, adminSession, themeAccessPassword, storefrontPassword) - - return replLoop(session, themeId, url) } diff --git a/packages/theme/src/cli/services/console/batch.test.ts b/packages/theme/src/cli/services/console/batch.test.ts new file mode 100644 index 00000000000..6be16c429db --- /dev/null +++ b/packages/theme/src/cli/services/console/batch.test.ts @@ -0,0 +1,180 @@ +import {evaluateConsoleInputs} from '../console.js' +import { + fetchDevServerSession, + initializeDevServerSession, +} from '../../utilities/theme-environment/dev-server-session.js' +import {render} from '../../utilities/theme-environment/storefront-renderer.js' +import {replLoop} from '../../utilities/repl/repl.js' +import {afterEach, expect, test, vi} from 'vitest' +import {AbortError} from '@shopify/cli-kit/node/error' +import {withCapturedStandardStreams} from '@shopify/cli-kit/node/testing/output' + +vi.mock('../../utilities/theme-environment/dev-server-session.js') +vi.mock('../../utilities/theme-environment/storefront-renderer.js') +vi.mock('../../utilities/repl/repl.js', async (importOriginal) => ({ + ...(await importOriginal()), + replLoop: vi.fn(), +})) + +afterEach(() => vi.unstubAllGlobals()) + +const options = { + adminSession: {storeFqdn: 'shop.myshopify.com', token: 'token'}, + themeId: '123', + url: '/', +} + +function displayResponse(value: unknown): Response { + return new Response(`
\n${JSON.stringify([{type: 'display', value}])}\n
`) +} + +test('evaluates repeated inputs in order without starting a REPL or refresh timer', async () => { + const session = {...options.adminSession, storefrontToken: 'token', sessionCookies: {}} + vi.mocked(fetchDevServerSession).mockResolvedValue(session) + vi.mocked(render).mockResolvedValueOnce(displayResponse('Shop')).mockResolvedValueOnce(displayResponse(false)) + + const result = await evaluateConsoleInputs({...options, inputs: ['shop.name', 'false']}) + + expect(result).toEqual({ + status: 'success', + results: [ + {input: 'shop.name', status: 'success', value: 'Shop'}, + {input: 'false', status: 'success', value: false}, + ], + }) + expect(fetchDevServerSession).toHaveBeenCalledWith('123', options.adminSession, undefined, undefined) + expect(initializeDevServerSession).not.toHaveBeenCalled() + expect(replLoop).not.toHaveBeenCalled() +}) + +test('waits for an assignment before evaluating expressions in the same session', async () => { + let assignmentFinished = false + vi.mocked(render) + .mockResolvedValueOnce(new Response('
\nLiquid syntax error: Unexpected character\n
')) + .mockImplementationOnce(async () => { + await new Promise((resolve) => setTimeout(resolve, 1)) + assignmentFinished = true + return new Response('
\n[{"type":"context","value":""}]\n
') + }) + .mockImplementationOnce(async (_session, context) => { + expect(assignmentFinished).toBe(true) + expect(context.replaceTemplates?.['snippets/eval.liquid']).toContain('{% assign name = shop.name %}') + return displayResponse('SHOP') + }) + + await expect( + evaluateConsoleInputs({...options, inputs: ['assign name = shop.name', 'name | upcase']}), + ).resolves.toEqual({ + status: 'success', + results: [ + {input: 'assign name = shop.name', status: 'success', value: null}, + {input: 'name | upcase', status: 'success', value: 'SHOP'}, + ], + }) + expect(render).toHaveBeenCalledTimes(3) +}) + +test('retains completed results, reports an error and skips dependent inputs', async () => { + vi.mocked(render) + .mockResolvedValueOnce(displayResponse('Shop')) + .mockImplementation(async () => new Response("
\nLiquid syntax error: Unknown tag 'invalid'\n
")) + + await withCapturedStandardStreams(async (streams) => { + await expect( + evaluateConsoleInputs({...options, inputs: ['shop.name', 'invalid', 'shop.currency']}), + ).resolves.toEqual({ + status: 'partial', + results: [ + {input: 'shop.name', status: 'success', value: 'Shop'}, + { + input: 'invalid', + status: 'failed', + error: {type: 'abort', message: "Unknown object, property, tag, or filter: 'invalid'"}, + }, + {input: 'shop.currency', status: 'skipped', reason: 'previous-input-failed'}, + ], + }) + expect(streams.stdout()).toBe('') + expect(streams.stderr()).toBe('') + }) + expect(render).toHaveBeenCalledTimes(4) +}) + +test('reports invalid delimiters without making an evaluation request', async () => { + const result = await evaluateConsoleInputs({...options, inputs: ['{{ shop.name }}', 'shop.currency']}) + expect(result.results[0]).toMatchObject({status: 'failed', error: {type: 'abort'}}) + expect(result.results[1]).toMatchObject({status: 'skipped'}) + expect(render).not.toHaveBeenCalled() +}) + +test('reports an expired session and skips subsequent inputs', async () => { + vi.mocked(render).mockRejectedValue(new AbortError('Session expired')) + const result = await evaluateConsoleInputs({...options, inputs: ['shop.name', 'shop.currency']}) + expect(result.results).toEqual([ + {input: 'shop.name', status: 'failed', error: {type: 'abort', message: 'Session expired'}}, + {input: 'shop.currency', status: 'skipped', reason: 'previous-input-failed'}, + ]) +}) + +test('retains completed results and skips subsequent inputs when a storefront request fails', async () => { + const {render: renderStorefront} = await vi.importActual< + typeof import('../../utilities/theme-environment/storefront-renderer.js') + >('../../utilities/theme-environment/storefront-renderer.js') + vi.mocked(render).mockImplementation(renderStorefront) + vi.mocked(fetchDevServerSession).mockResolvedValue({ + ...options.adminSession, + storefrontToken: 'token', + sessionCookies: {}, + }) + const fetchMock = vi + .fn() + .mockResolvedValueOnce(displayResponse('Shop')) + .mockRejectedValueOnce(new TypeError('fetch failed', {cause: {code: 'ECONNRESET'}})) + vi.stubGlobal('fetch', fetchMock) + + const result = await evaluateConsoleInputs({...options, inputs: ['shop.name', 'shop.currency', 'shop.id']}) + + expect(result).toEqual({ + status: 'partial', + results: [ + {input: 'shop.name', status: 'success', value: 'Shop'}, + { + input: 'shop.currency', + status: 'failed', + error: { + type: 'abort', + message: expect.stringContaining('Liquid evaluation failed with status 502 (Bad Gateway).'), + }, + }, + {input: 'shop.id', status: 'skipped', reason: 'previous-input-failed'}, + ], + }) + expect(fetchMock).toHaveBeenCalledTimes(2) +}) + +test('propagates session setup failures', async () => { + vi.mocked(fetchDevServerSession).mockRejectedValue(new AbortError('Cannot create a session')) + await expect(evaluateConsoleInputs({...options, inputs: ['shop.name']})).rejects.toThrow('Cannot create a session') + expect(render).not.toHaveBeenCalled() +}) + +test('reports Liquid objects that cannot be serialized as evaluation failures', async () => { + vi.mocked(render).mockResolvedValue(displayResponse({error: 'json not allowed for this object'})) + const result = await evaluateConsoleInputs({...options, inputs: ['product']}) + expect(result.results[0]).toMatchObject({ + status: 'failed', + error: {type: 'abort', message: expect.stringContaining("Object can't be printed")}, + }) +}) + +test('propagates unexpected errors instead of treating them as Liquid failures', async () => { + vi.mocked(render).mockRejectedValue(new TypeError('Invalid response')) + await expect(evaluateConsoleInputs({...options, inputs: ['shop.name']})).rejects.toThrow('Invalid response') +}) + +test('rejects Admin API tokens before creating the session', async () => { + await expect( + evaluateConsoleInputs({...options, inputs: ['shop.name'], themeAccessPassword: 'shpat_token'}), + ).rejects.toThrow('Unable to use Admin API tokens with the console command') + expect(fetchDevServerSession).not.toHaveBeenCalled() +}) diff --git a/packages/theme/src/cli/services/console/input.test.ts b/packages/theme/src/cli/services/console/input.test.ts new file mode 100644 index 00000000000..abc0b77e697 --- /dev/null +++ b/packages/theme/src/cli/services/console/input.test.ts @@ -0,0 +1,36 @@ +import {loadConsoleInputs} from './input.js' +import {expect, test} from 'vitest' +import {inTemporaryDirectory, writeFile} from '@shopify/cli-kit/node/fs' +import {joinPath} from '@shopify/cli-kit/node/path' + +test('keeps inline inputs in order and distinguishes interactive execution', async () => { + await expect(loadConsoleInputs()).resolves.toBeUndefined() + await expect(loadConsoleInputs(['shop.name', 'shop.currency'])).resolves.toEqual(['shop.name', 'shop.currency']) +}) + +test.each(['\n', '\r\n'])('reads one command per line with %j line endings', async (newline) => { + await inTemporaryDirectory(async (directory) => { + const scriptFile = joinPath(directory, 'commands.txt') + await writeFile(scriptFile, ['assign name = shop.name', '', ' ', 'name | upcase', ''].join(newline)) + + await expect(loadConsoleInputs(undefined, scriptFile)).resolves.toEqual([ + 'assign name = shop.name', + 'name | upcase', + ]) + }) +}) + +test('rejects an empty script file', async () => { + await inTemporaryDirectory(async (directory) => { + const scriptFile = joinPath(directory, 'commands.txt') + await writeFile(scriptFile, '\n \r\n') + + await expect(loadConsoleInputs(undefined, scriptFile)).rejects.toThrow('at least one Liquid command') + }) +}) + +test('rejects an unreadable script file', async () => { + await inTemporaryDirectory(async (directory) => { + await expect(loadConsoleInputs(undefined, joinPath(directory, 'missing.txt'))).rejects.toThrow() + }) +}) diff --git a/packages/theme/src/cli/services/console/input.ts b/packages/theme/src/cli/services/console/input.ts new file mode 100644 index 00000000000..964a8aa2a80 --- /dev/null +++ b/packages/theme/src/cli/services/console/input.ts @@ -0,0 +1,11 @@ +import {readFile} from '@shopify/cli-kit/node/fs' +import {AbortError} from '@shopify/cli-kit/node/error' + +export async function loadConsoleInputs(inputs?: string[], scriptFile?: string): Promise { + if (scriptFile === undefined) return inputs + + const contents = await readFile(scriptFile) + const commands = contents.split(/\r?\n/).filter((line) => line.trim().length > 0) + if (commands.length === 0) throw new AbortError('The script file must contain at least one Liquid command.') + return commands +} diff --git a/packages/theme/src/cli/services/console/types.test.ts b/packages/theme/src/cli/services/console/types.test.ts new file mode 100644 index 00000000000..cc1497bb2d4 --- /dev/null +++ b/packages/theme/src/cli/services/console/types.test.ts @@ -0,0 +1,22 @@ +import {themeConsoleJsonOutputSchema} from './types.js' +import {expect, test} from 'vitest' +import {jsonSchemaValidate} from '@shopify/cli-kit/node/json-schema' + +test('the discoverable JSON Schema validates native values, failures and skipped inputs', () => { + const schema = themeConsoleJsonOutputSchema.jsonSchema + const result = { + status: 'partial', + results: [ + {input: 'shop', status: 'success', value: {native_key: [false, null, 0, 'value']}}, + {input: 'invalid', status: 'failed', error: {type: 'abort', message: 'Invalid command'}}, + {input: 'shop.name', status: 'skipped', reason: 'previous-input-failed'}, + ], + } + + expect(jsonSchemaValidate(result, schema, 'fail').state).toBe('ok') + expect(jsonSchemaValidate({environments: [{environment: 'development', result}]}, schema, 'fail').state).toBe('ok') + expect(() => + themeConsoleJsonOutputSchema.validate({status: 'success', results: [{input: 'shop.name', status: 'success'}]}), + ).toThrow() + expect(() => themeConsoleJsonOutputSchema.validate({...result, extra: true})).toThrow() +}) diff --git a/packages/theme/src/cli/services/console/types.ts b/packages/theme/src/cli/services/console/types.ts new file mode 100644 index 00000000000..5deb5de85de --- /dev/null +++ b/packages/theme/src/cli/services/console/types.ts @@ -0,0 +1,45 @@ +import {defineThemeJsonOutputSchema} from '../json-output/schema.js' +import {LiquidValue} from '../../utilities/repl/evaluator.js' +import {zod} from '@shopify/cli-kit/node/schema' +import {JsonErrorSchema} from '@shopify/cli-kit/node/error/schema' + +const LiquidValueSchema: zod.ZodType = zod.lazy(() => + zod.union([ + zod.string(), + zod.number().finite(), + zod.boolean(), + zod.null(), + zod.array(LiquidValueSchema), + zod.record(LiquidValueSchema), + ]), +) + +const ConsoleInputResultSchema = zod.discriminatedUnion('status', [ + zod + .object({ + input: zod.string(), + status: zod.literal('success'), + value: LiquidValueSchema.describe('The native Liquid value. Assignments return null.'), + }) + .strict(), + zod.object({input: zod.string(), status: zod.literal('failed'), error: JsonErrorSchema}).strict(), + zod + .object({input: zod.string(), status: zod.literal('skipped'), reason: zod.literal('previous-input-failed')}) + .strict(), +]) + +const ConsoleResultSchema = zod + .object({ + status: zod.enum(['success', 'partial']), + results: zod.array(ConsoleInputResultSchema), + }) + .strict() + +export type ConsoleResult = zod.infer + +export const themeConsoleJsonOutputSchema = defineThemeJsonOutputSchema({ + name: 'ThemeConsoleResult', + schema: ConsoleResultSchema, + definitions: {ConsoleInputResult: ConsoleInputResultSchema, LiquidValue: LiquidValueSchema}, + project: (value) => value, +}) diff --git a/packages/theme/src/cli/utilities/repl/evaluator.test.ts b/packages/theme/src/cli/utilities/repl/evaluator.test.ts index d55a10f7e0e..984868a5be6 100644 --- a/packages/theme/src/cli/utilities/repl/evaluator.test.ts +++ b/packages/theme/src/cli/utilities/repl/evaluator.test.ts @@ -6,7 +6,11 @@ import {outputContent, outputInfo, outputToken} from '@shopify/cli-kit/node/outp import {AbortError} from '@shopify/cli-kit/node/error' vi.mock('../theme-environment/storefront-renderer') -vi.mock('@shopify/cli-kit/node/output') +vi.mock('@shopify/cli-kit/node/output', async (importOriginal) => ({ + ...(await importOriginal()), + outputInfo: vi.fn(), + outputDebug: vi.fn(), +})) describe('evaluate', () => { let mockConfig: EvaluationConfig @@ -33,6 +37,37 @@ describe('evaluate', () => { expect(result).toBe(123123) }) + test.each([null, false, 0, '', ['a', 1], {product_id: 1, tags: ['a']}])( + 'preserves a native Liquid value without evaluating it as a context: %j', + async (value) => { + vi.mocked(render).mockResolvedValue( + createMockResponse({ + status: 200, + text: `
\n${JSON.stringify([{type: 'display', value}])}\n
`, + }) as any, + ) + await expect(evaluate({...mockConfig, snippet: 'value', mode: 'batch'})).resolves.toEqual(value) + expect(render).toHaveBeenCalledOnce() + expect(mockConfig.replSession).toEqual([]) + }, + ) + + test('throws a Liquid syntax error in batch mode without printing text', async () => { + vi.mocked(render).mockResolvedValue( + createMockResponse({status: 200, text: "
\nLiquid syntax error: Unknown tag 'invalid'\n
"}) as any, + ) + await expect(evaluate({...mockConfig, snippet: 'invalid', mode: 'batch'})).rejects.toThrow( + "Unknown object, property, tag, or filter: 'invalid'", + ) + expect(outputInfo).not.toHaveBeenCalled() + }) + + test('throws for unsuccessful HTTP responses in batch mode', async () => { + vi.mocked(render).mockResolvedValue(createMockResponse({status: 500, text: 'Internal server error'}) as any) + await expect(evaluate({...mockConfig, snippet: 'shop.name', mode: 'batch'})).rejects.toThrow('HTTP 500') + expect(render).toHaveBeenCalledOnce() + }) + describe('when the URL is not prefixed with a forward slash', () => { test('should add a forward slash to the URL', async () => { const mockResponse = createMockResponse({ @@ -130,7 +165,7 @@ Liquid syntax error (snippets/eval line 1): Liquid error: undefined method 'unkn expect(result).toBeUndefined() expect(outputInfo).toHaveBeenCalledOnce() expect(outputInfo).toHaveBeenCalledWith( - outputContent`${outputToken.errorText("Liquid error: undefined method 'unknown_object' for nil:NilClass")}`, + outputContent`${outputToken.errorText("Liquid syntax error: Liquid error: undefined method 'unknown_object' for nil:NilClass")}`, ) }) @@ -207,7 +242,7 @@ Liquid syntax error (snippets/eval line 1): Liquid error: undefined method 'unkn vi.mocked(render).mockResolvedValue(mockResponse as any) await expect(evaluate({...mockConfig, snippet: 'asdf'})).rejects.toThrow( - new AbortError('Page not found. Please provide a valid --url value.'), + new AbortError('Page not found. Please provide a valid --url value!'), ) }) }) diff --git a/packages/theme/src/cli/utilities/repl/evaluator.ts b/packages/theme/src/cli/utilities/repl/evaluator.ts index 643f46c8e9f..4ea3940399d 100644 --- a/packages/theme/src/cli/utilities/repl/evaluator.ts +++ b/packages/theme/src/cli/utilities/repl/evaluator.ts @@ -8,22 +8,25 @@ export interface SessionItem { value: string } +export type LiquidValue = string | number | boolean | null | LiquidValue[] | {[key: string]: LiquidValue} + export interface EvaluationConfig { themeSession: DevServerSession themeId: string url: string replSession: SessionItem[] snippet: string + mode?: 'interactive' | 'batch' } -export async function evaluate(config: EvaluationConfig): Promise { - return ( - (await evalResult(config)) ?? - (await evalContext(config)) ?? - (await evalAssignmentContext(config)) ?? - (await evalSyntaxError(config)) ?? - undefined - ) +export async function evaluate(config: EvaluationConfig): Promise { + const result = await evalResult(config) + if (result !== undefined) return result.value + if (await evalContext(config)) return undefined + if (await evalAssignmentContext(config)) return undefined + await evalSyntaxError(config) + if (config.mode === 'batch') throw new AbortError('Unable to evaluate the Liquid command.') + return undefined } async function evalResult(config: EvaluationConfig) { @@ -45,7 +48,9 @@ async function evalContext(config: EvaluationConfig) { if (successfulRequest(request.status, text)) { config.replSession.push(JSON.parse(json)) + return true } + return false } async function evalAssignmentContext(config: EvaluationConfig) { @@ -53,7 +58,7 @@ async function evalAssignmentContext(config: EvaluationConfig) { if (isSmartAssignment(config.snippet)) { config.snippet = `assign ${config.snippet}` - outputInfo(outputContent`${outputToken.gray(`> ${config.snippet}`)}`) + if (config.mode !== 'batch') outputInfo(outputContent`${outputToken.gray(`> ${config.snippet}`)}`) return evalContext(config) } } @@ -74,19 +79,18 @@ async function evalSyntaxError(config: EvaluationConfig) { if (hasLiquidError(body)) { const error = body.replace(/ \(snippets\/eval line \d+\)/, '') - printSyntaxError(config.snippet, error) + printSyntaxError(config, error) } } -function printSyntaxError(snippet: string, error: string) { +function printSyntaxError(config: EvaluationConfig, error: string) { + let message = stripHTMLContent(error) if (error.includes('Unknown tag')) { - outputInfo(outputContent`${outputToken.errorText(`Unknown object, property, tag, or filter: '${snippet}'`)}`) - return + message = `Unknown object, property, tag, or filter: '${config.snippet}'` } - - const resultContent = stripHTMLContent(error) - if (resultContent) { - outputInfo(outputContent`${outputToken.errorText(resultContent)}`) + if (message) { + if (config.mode === 'batch') throw new AbortError(message) + outputInfo(outputContent`${outputToken.errorText(message)}`) } } @@ -122,6 +126,10 @@ async function makeRequest(config: EvaluationConfig): Promise { notFoundError() } + if (config.mode === 'batch' && response.status !== 200) { + throw new AbortError(`Liquid evaluation failed (HTTP ${response.status}).`) + } + return response } @@ -130,11 +138,11 @@ function buildRequestBody(config: EvaluationConfig): string { return `[${items.join(',').replace(/\\"/g, '"')}]` } -function parseDisplayResult(result: string): string | number | undefined { +function parseDisplayResult(result: string): {value: LiquidValue} | undefined { const resultContent = stripHTMLContent(result) if (resultContent) { const displayObject = JSON.parse(resultContent)?.find((item: SessionItem) => item.type === 'display') - return displayObject?.value + if (displayObject && 'value' in displayObject) return {value: displayObject.value} } } diff --git a/packages/theme/src/cli/utilities/repl/presenter.ts b/packages/theme/src/cli/utilities/repl/presenter.ts index 72bae9827e4..cf04e6d546f 100644 --- a/packages/theme/src/cli/utilities/repl/presenter.ts +++ b/packages/theme/src/cli/utilities/repl/presenter.ts @@ -1,10 +1,11 @@ import {outputContent, outputInfo, outputToken} from '@shopify/cli-kit/node/output' +export const JSON_ERROR_MESSAGE = + "Object can't be printed, but you can access its fields. Read more at https://shopify.dev/docs/api/liquid." + export function presentValue(value?: unknown) { if (hasJsonError(value)) { - outputInfo( - "Object can't be printed, but you can access its fields. Read more at https://shopify.dev/docs/api/liquid.", - ) + outputInfo(JSON_ERROR_MESSAGE) return } @@ -17,7 +18,7 @@ export function presentValue(value?: unknown) { renderValue(formattedOutput) } -function hasJsonError(output: unknown): boolean { +export function hasJsonError(output: unknown): boolean { if (Array.isArray(output)) { return output.length > 0 ? hasJsonError(output[0]) : false } diff --git a/packages/theme/src/cli/utilities/repl/repl.ts b/packages/theme/src/cli/utilities/repl/repl.ts index b440a6275b4..84eafebfc1e 100644 --- a/packages/theme/src/cli/utilities/repl/repl.ts +++ b/packages/theme/src/cli/utilities/repl/repl.ts @@ -62,6 +62,6 @@ export async function handleInput( } } -function hasDelimiter(input: string): boolean { +export function hasDelimiter(input: string): boolean { return /^\s*(\{\{|{%)/.test(input) }