From ece75cbacd28575602604569d602f6b94ebbd17a Mon Sep 17 00:00:00 2001 From: Gonzalo Riestra Date: Wed, 21 Oct 2026 14:11:57 +0200 Subject: [PATCH 1/5] [Feature] Add finite JSON execution to theme console --- .changeset/theme-console-finite-json.md | 5 + .../generated/generated_docs_data_v2.json | 29 +- docs/cli/json-output.md | 19 + packages/cli/README.md | 398 +++++++++++++++++- packages/cli/oclif.manifest.json | 39 +- .../rules/json-output-command-exceptions.js | 1 - .../src/cli/commands/theme/console.test.ts | 167 ++++++++ .../theme/src/cli/commands/theme/console.ts | 74 +++- packages/theme/src/cli/services/console.ts | 56 ++- .../src/cli/services/console/batch.test.ts | 142 +++++++ .../src/cli/services/console/input.test.ts | 33 ++ .../theme/src/cli/services/console/input.ts | 11 + .../src/cli/services/console/types.test.ts | 22 + .../theme/src/cli/services/console/types.ts | 45 ++ .../src/cli/utilities/repl/evaluator.test.ts | 41 +- .../theme/src/cli/utilities/repl/evaluator.ts | 46 +- .../theme/src/cli/utilities/repl/presenter.ts | 9 +- packages/theme/src/cli/utilities/repl/repl.ts | 2 +- 18 files changed, 1096 insertions(+), 43 deletions(-) create mode 100644 .changeset/theme-console-finite-json.md create mode 100644 packages/theme/src/cli/commands/theme/console.test.ts create mode 100644 packages/theme/src/cli/services/console/batch.test.ts create mode 100644 packages/theme/src/cli/services/console/input.test.ts create mode 100644 packages/theme/src/cli/services/console/input.ts create mode 100644 packages/theme/src/cli/services/console/types.test.ts create mode 100644 packages/theme/src/cli/services/console/types.ts diff --git a/.changeset/theme-console-finite-json.md b/.changeset/theme-console-finite-json.md new file mode 100644 index 00000000000..2b047c7109a --- /dev/null +++ b/.changeset/theme-console-finite-json.md @@ -0,0 +1,5 @@ +--- +'@shopify/cli': minor +--- + +Add finite Liquid console execution with repeated inputs, input 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..8a63f6f8b9f 100644 --- a/docs-shopify.dev/generated/generated_docs_data_v2.json +++ b/docs-shopify.dev/generated/generated_docs_data_v2.json @@ -8636,6 +8636,24 @@ "isOptional": true, "environmentValue": "SHOPIFY_FLAG_AUTH_ALIAS" }, + { + "filePath": "docs-shopify.dev/commands/interfaces/theme-console.interface.ts", + "syntaxKind": "PropertySignature", + "name": "--input ", + "value": "string", + "description": "Liquid command to evaluate. Repeat to run commands in order and exit.", + "isOptional": true, + "environmentValue": "SHOPIFY_FLAG_INPUT" + }, + { + "filePath": "docs-shopify.dev/commands/interfaces/theme-console.interface.ts", + "syntaxKind": "PropertySignature", + "name": "--input-file ", + "value": "string", + "description": "File containing one Liquid command per line. Evaluates the commands and exits.", + "isOptional": true, + "environmentValue": "SHOPIFY_FLAG_INPUT_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 * Liquid command to evaluate. Repeat to run commands in order and exit.\n * @environment SHOPIFY_FLAG_INPUT\n */\n '--input '?: string\n\n /**\n * File containing one Liquid command per line. Evaluates the commands and exits.\n * @environment SHOPIFY_FLAG_INPUT_FILE\n */\n '--input-file '?: 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 * 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/docs/cli/json-output.md b/docs/cli/json-output.md index aea695beaf9..d294eb515be 100644 --- a/docs/cli/json-output.md +++ b/docs/cli/json-output.md @@ -238,6 +238,25 @@ When a JSON command prompts, stderr also contains human-readable UI and terminal interactive mode, stderr is not a pure JSONL stream. For automation, use `--json --no-input` to capture JSON side events from stderr; missing required input then produces a fatal error instead of a prompt. +## Finite Liquid console execution + +`shopify theme console --json` requires either repeatable `--input` flags or `--input-file`. These input flags select +finite execution in both text and JSON modes. Without input flags, the command remains an interactive text REPL. +Use `--no-input` as well to prevent authentication and storefront-password prompts. + +Input files contain one console command per line. Blank lines are ignored, and LF and CRLF line endings are supported. +Inline inputs and input files are mutually exclusive. Commands run sequentially in one session, so assignments are +available to subsequent expressions. + +JSON stdout contains one `ThemeConsoleResult` document with `status` and an ordered `results` array. Each result has +the original `input` and a `status`: `success` includes the native Liquid `value`, `failed` includes the shared `error` +fields, and `skipped` includes `reason: "previous-input-failed"`. Liquid objects retain their original keys; assignments +return a null value. Evaluation stops at the first failure and the remaining inputs are skipped. Completed results +are retained, the overall status is `partial`, and the command exits nonzero. A successful batch has `status: "success"`. +Session setup and authentication failures use the fatal error envelope. Evaluation failures, including transport +failures during a command, are retained in the batch. Welcome text and readline prompts are absent from JSON stdout. +Explicit theme environments use the ordinary `{environments: [...]}` wrapper. + ## Exempt only streaming commands Long-lived commands that produce an open-ended event stream don't have one finite result. Track these exemptions in diff --git a/packages/cli/README.md b/packages/cli/README.md index 1bdcda90476..97545f15d4f 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). @@ -12881,6 +12885,14 @@ FLAGS Alias of the Shopify account to use for authentication. [env: SHOPIFY_FLAG_AUTH_ALIAS] + --input=... + Liquid command to evaluate. Repeat to run commands in order and exit. + [env: SHOPIFY_FLAG_INPUT] + + --input-file= + File containing one Liquid command per line. Evaluates the commands and exits. + [env: SHOPIFY_FLAG_INPUT_FILE] + --json-schema Print the command's JSON schemas. [env: SHOPIFY_FLAG_JSON_SCHEMA] @@ -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 `--input` flags or `--input-file` to evaluate commands sequentially in one session and exit. Input + files contain one console command per line; blank lines are ignored. Stop at the first error and skip remaining + commands. + + `--json` requires `--input` or `--input-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 --input "shop.name" --input "shop.currency" --json --no-input + + $ shopify theme console --store example --input-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..a33190d7eaf 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 `--input` flags or `--input-file` to evaluate commands sequentially in one session and exit. Input files contain one console command per line; blank lines are ignored. Stop at the first error and skip remaining commands.\n\n`--json` requires `--input` or `--input-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 `--input` flags or `--input-file` to evaluate commands sequentially in one session and exit. Input files contain one console command per line; blank lines are ignored. Stop at the first error and skip remaining commands.\n\n`--json` requires `--input` or `--input-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 --input \"shop.name\" --input \"shop.currency\" --json --no-input", + "<%= config.bin %> <%= command.id %> --store example --input-file ./commands.txt --json --no-input" + ], "flags": { "auth-alias": { "description": "Alias of the Shopify account to use for authentication.", @@ -10869,6 +10873,37 @@ "name": "environment", "type": "option" }, + "input": { + "description": "Liquid command to evaluate. Repeat to run commands in order and exit.", + "env": "SHOPIFY_FLAG_INPUT", + "exclusive": [ + "input-file" + ], + "hasDynamicHelp": false, + "multiple": true, + "name": "input", + "type": "option" + }, + "input-file": { + "description": "File containing one Liquid command per line. Evaluates the commands and exits.", + "env": "SHOPIFY_FLAG_INPUT_FILE", + "exclusive": [ + "input" + ], + "hasDynamicHelp": false, + "multiple": false, + "name": "input-file", + "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 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..9f88653101e --- /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 --input or --input-file with --json') + expect(ensureAuthenticatedThemes).not.toHaveBeenCalled() + expect(ensureReplEnv).not.toHaveBeenCalled() + }, +) + +test('rejects conflicting input flags before authentication', async () => { + await expect(runConsole(['--input', 'shop.name', '--input-file', 'commands.txt', '--json'])).rejects.toThrow() + expect(ensureAuthenticatedThemes).not.toHaveBeenCalled() +}) + +test.each(['', ' '])('rejects blank inline commands: %j', async (input) => { + await expect(runConsole(['--input', input, '--json'])).rejects.toThrow('Each --input must contain a Liquid command') + expect(ensureAuthenticatedThemes).not.toHaveBeenCalled() +}) + +test.each([false, true])('writes one document for repeated inputs 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(['--input', 'shop.name', '--input', '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 inputFile = joinPath(directory, 'commands.txt') + await writeFile(inputFile, 'shop.name\r\nshop.currency\r\n') + await runConsole(['--input-file', inputFile, '--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(['--input-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(['--input', 'shop.name', '--no-input']) + expect(streams.stderr()).toContain('"Test shop"') + expect(streams.stdout()).toBe('') + }) + expect(initializeRepl).not.toHaveBeenCalled() +}) + +test('keeps the interactive console when inputs 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(['--input', '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(['--input', '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', '--input', '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..07c06f297d3 100644 --- a/packages/theme/src/cli/commands/theme/console.ts +++ b/packages/theme/src/cli/commands/theme/console.ts @@ -1,28 +1,60 @@ -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 get jsonOutputSchema() { + return 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 \`--input\` flags or \`--input-file\` to evaluate commands sequentially in one session and exit. Input files contain one console command per line; blank lines are ignored. Stop at the first error and skip remaining commands. + +\`--json\` requires \`--input\` or \`--input-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 --input "shop.name" --input "shop.currency" --json --no-input', + '<%= config.bin %> <%= command.id %> --store example --input-file ./commands.txt --json --no-input', + ] static description = this.descriptionForHelp() static flags = { ...globalFlags, + ...jsonFlag, ...themeFlags, + input: Flags.string({ + description: 'Liquid command to evaluate. Repeat to run commands in order and exit.', + multiple: true, + multipleNonGreedy: true, + exclusive: ['input-file'], + env: 'SHOPIFY_FLAG_INPUT', + }), + 'input-file': Flags.string({ + description: 'File containing one Liquid command per line. Evaluates the commands and exits.', + exclusive: ['input'], + env: 'SHOPIFY_FLAG_INPUT_FILE', + }), url: Flags.string({ description: 'The url to be used as context', env: 'SHOPIFY_FLAG_URL', @@ -36,13 +68,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.input, flags['input-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.input?.length && !flags['input-file']) { + throw new AbortError('Use --input or --input-file with --json to supply Liquid commands.') + } + if (flags.input?.some((input) => input.trim().length === 0)) { + throw new AbortError('Each --input 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..65a28b06965 100644 --- a/packages/theme/src/cli/services/console.ts +++ b/packages/theme/src/cli/services/console.ts @@ -1,11 +1,15 @@ +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 {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' export async function ensureReplEnv(adminSession: AdminSession, storePasswordFlag?: string) { const themeId = await findOrCreateReplTheme(adminSession) @@ -36,6 +40,50 @@ 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)) throw error + failed = true + results.push({input, status: 'failed', error: errorToJson(error)}) + } + } + + 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 +97,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..05b6904684d --- /dev/null +++ b/packages/theme/src/cli/services/console/batch.test.ts @@ -0,0 +1,142 @@ +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 {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(), +})) + +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 evaluation transport failure 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('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..5dca1dd28a2 --- /dev/null +++ b/packages/theme/src/cli/services/console/input.test.ts @@ -0,0 +1,33 @@ +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 inputFile = joinPath(directory, 'commands.txt') + await writeFile(inputFile, ['assign name = shop.name', '', ' ', 'name | upcase', ''].join(newline)) + + await expect(loadConsoleInputs(undefined, inputFile)).resolves.toEqual(['assign name = shop.name', 'name | upcase']) + }) +}) + +test('rejects an empty input file', async () => { + await inTemporaryDirectory(async (directory) => { + const inputFile = joinPath(directory, 'commands.txt') + await writeFile(inputFile, '\n \r\n') + + await expect(loadConsoleInputs(undefined, inputFile)).rejects.toThrow('at least one Liquid command') + }) +}) + +test('rejects an unreadable input 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..497fa3bda58 --- /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[], inputFile?: string): Promise { + if (inputFile === undefined) return inputs + + const contents = await readFile(inputFile) + const commands = contents.split(/\r?\n/).filter((line) => line.trim().length > 0) + if (commands.length === 0) throw new AbortError('The input 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) } From 343f644992c894bf1139e7e0344729938bfe0fe5 Mon Sep 17 00:00:00 2001 From: Gonzalo Riestra Date: Mon, 26 Oct 2026 15:47:38 +0100 Subject: [PATCH 2/5] Revert theme console additions to JSON output guide --- docs/cli/json-output.md | 19 ------------------- 1 file changed, 19 deletions(-) diff --git a/docs/cli/json-output.md b/docs/cli/json-output.md index d294eb515be..aea695beaf9 100644 --- a/docs/cli/json-output.md +++ b/docs/cli/json-output.md @@ -238,25 +238,6 @@ When a JSON command prompts, stderr also contains human-readable UI and terminal interactive mode, stderr is not a pure JSONL stream. For automation, use `--json --no-input` to capture JSON side events from stderr; missing required input then produces a fatal error instead of a prompt. -## Finite Liquid console execution - -`shopify theme console --json` requires either repeatable `--input` flags or `--input-file`. These input flags select -finite execution in both text and JSON modes. Without input flags, the command remains an interactive text REPL. -Use `--no-input` as well to prevent authentication and storefront-password prompts. - -Input files contain one console command per line. Blank lines are ignored, and LF and CRLF line endings are supported. -Inline inputs and input files are mutually exclusive. Commands run sequentially in one session, so assignments are -available to subsequent expressions. - -JSON stdout contains one `ThemeConsoleResult` document with `status` and an ordered `results` array. Each result has -the original `input` and a `status`: `success` includes the native Liquid `value`, `failed` includes the shared `error` -fields, and `skipped` includes `reason: "previous-input-failed"`. Liquid objects retain their original keys; assignments -return a null value. Evaluation stops at the first failure and the remaining inputs are skipped. Completed results -are retained, the overall status is `partial`, and the command exits nonzero. A successful batch has `status: "success"`. -Session setup and authentication failures use the fatal error envelope. Evaluation failures, including transport -failures during a command, are retained in the batch. Welcome text and readline prompts are absent from JSON stdout. -Explicit theme environments use the ordinary `{environments: [...]}` wrapper. - ## Exempt only streaming commands Long-lived commands that produce an open-ended event stream don't have one finite result. Track these exemptions in From 027497361f431a593cf6d53da64a7590e4d18786 Mon Sep 17 00:00:00 2001 From: Gonzalo Riestra Date: Tue, 27 Oct 2026 12:41:15 +0100 Subject: [PATCH 3/5] Preserve Liquid console batch results on network failures --- packages/theme/src/cli/services/console.ts | 9 +++- .../src/cli/services/console/batch.test.ts | 42 ++++++++++++++++++- 2 files changed, 47 insertions(+), 4 deletions(-) diff --git a/packages/theme/src/cli/services/console.ts b/packages/theme/src/cli/services/console.ts index 65a28b06965..bd47ec6bb55 100644 --- a/packages/theme/src/cli/services/console.ts +++ b/packages/theme/src/cli/services/console.ts @@ -6,10 +6,12 @@ import {DELIMITER_WARNING, hasDelimiter, replLoop} from '../utilities/repl/repl. 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) @@ -74,9 +76,12 @@ export async function evaluateConsoleInputs(options: { if (hasJsonError(value)) throw new AbortError(JSON_ERROR_MESSAGE) results.push({input, status: 'success', value: value ?? null}) } catch (error) { - if (!(error instanceof AbortError)) throw 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(error)}) + results.push({input, status: 'failed', error: errorToJson(evaluationError)}) } } diff --git a/packages/theme/src/cli/services/console/batch.test.ts b/packages/theme/src/cli/services/console/batch.test.ts index 05b6904684d..6be16c429db 100644 --- a/packages/theme/src/cli/services/console/batch.test.ts +++ b/packages/theme/src/cli/services/console/batch.test.ts @@ -5,7 +5,7 @@ import { } 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 {expect, test, vi} from 'vitest' +import {afterEach, expect, test, vi} from 'vitest' import {AbortError} from '@shopify/cli-kit/node/error' import {withCapturedStandardStreams} from '@shopify/cli-kit/node/testing/output' @@ -16,6 +16,8 @@ vi.mock('../../utilities/repl/repl.js', async (importOriginal) => ({ replLoop: vi.fn(), })) +afterEach(() => vi.unstubAllGlobals()) + const options = { adminSession: {storeFqdn: 'shop.myshopify.com', token: 'token'}, themeId: '123', @@ -105,7 +107,7 @@ test('reports invalid delimiters without making an evaluation request', async () expect(render).not.toHaveBeenCalled() }) -test('reports an evaluation transport failure and skips subsequent inputs', async () => { +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([ @@ -114,6 +116,42 @@ test('reports an evaluation transport failure and skips subsequent inputs', asyn ]) }) +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') From 8e15767bae783c10e94b1419a00ae10b941e6fbd Mon Sep 17 00:00:00 2001 From: Gonzalo Riestra Date: Tue, 27 Oct 2026 16:05:12 +0100 Subject: [PATCH 4/5] Rename theme console input flags to script flags --- .changeset/theme-console-finite-json.md | 2 +- .../generated/generated_docs_data_v2.json | 38 +++++++------- packages/cli/README.md | 26 +++++----- packages/cli/oclif.manifest.json | 52 +++++++++---------- .../src/cli/commands/theme/console.test.ts | 30 +++++------ .../theme/src/cli/commands/theme/console.ts | 30 +++++------ .../src/cli/services/console/input.test.ts | 19 ++++--- .../theme/src/cli/services/console/input.ts | 8 +-- 8 files changed, 104 insertions(+), 101 deletions(-) diff --git a/.changeset/theme-console-finite-json.md b/.changeset/theme-console-finite-json.md index 2b047c7109a..4b7bf4db2e2 100644 --- a/.changeset/theme-console-finite-json.md +++ b/.changeset/theme-console-finite-json.md @@ -2,4 +2,4 @@ '@shopify/cli': minor --- -Add finite Liquid console execution with repeated inputs, input files, and JSON output. +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 8a63f6f8b9f..30eae534302 100644 --- a/docs-shopify.dev/generated/generated_docs_data_v2.json +++ b/docs-shopify.dev/generated/generated_docs_data_v2.json @@ -8636,24 +8636,6 @@ "isOptional": true, "environmentValue": "SHOPIFY_FLAG_AUTH_ALIAS" }, - { - "filePath": "docs-shopify.dev/commands/interfaces/theme-console.interface.ts", - "syntaxKind": "PropertySignature", - "name": "--input ", - "value": "string", - "description": "Liquid command to evaluate. Repeat to run commands in order and exit.", - "isOptional": true, - "environmentValue": "SHOPIFY_FLAG_INPUT" - }, - { - "filePath": "docs-shopify.dev/commands/interfaces/theme-console.interface.ts", - "syntaxKind": "PropertySignature", - "name": "--input-file ", - "value": "string", - "description": "File containing one Liquid command per line. Evaluates the commands and exits.", - "isOptional": true, - "environmentValue": "SHOPIFY_FLAG_INPUT_FILE" - }, { "filePath": "docs-shopify.dev/commands/interfaces/theme-console.interface.ts", "syntaxKind": "PropertySignature", @@ -8699,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", @@ -8754,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 * Liquid command to evaluate. Repeat to run commands in order and exit.\n * @environment SHOPIFY_FLAG_INPUT\n */\n '--input '?: string\n\n /**\n * File containing one Liquid command per line. Evaluates the commands and exits.\n * @environment SHOPIFY_FLAG_INPUT_FILE\n */\n '--input-file '?: 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 * 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 97545f15d4f..b8338fe1aae 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -12885,14 +12885,6 @@ FLAGS Alias of the Shopify account to use for authentication. [env: SHOPIFY_FLAG_AUTH_ALIAS] - --input=... - Liquid command to evaluate. Repeat to run commands in order and exit. - [env: SHOPIFY_FLAG_INPUT] - - --input-file= - File containing one Liquid command per line. Evaluates the commands and exits. - [env: SHOPIFY_FLAG_INPUT_FILE] - --json-schema Print the command's JSON schemas. [env: SHOPIFY_FLAG_JSON_SCHEMA] @@ -12913,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] @@ -12933,12 +12933,12 @@ DESCRIPTION You can also provide context to the console using a URL, as some Liquid objects are context-specific. - Use repeatable `--input` flags or `--input-file` to evaluate commands sequentially in one session and exit. Input + 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 `--input` or `--input-file` and returns one object with ordered results. Use `--no-input` as well to - prevent authentication and password prompts. + `--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. @@ -13313,9 +13313,9 @@ DESCRIPTION ``` EXAMPLES - $ shopify theme console --store example --input "shop.name" --input "shop.currency" --json --no-input + $ shopify theme console --store example --script "shop.name" --script "shop.currency" --json --no-input - $ shopify theme console --store example --input-file ./commands.txt --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 a33190d7eaf..07f2cb2ba0d 100644 --- a/packages/cli/oclif.manifest.json +++ b/packages/cli/oclif.manifest.json @@ -10848,12 +10848,12 @@ "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\nYou can also provide context to the console using a URL, as some Liquid objects are context-specific.\n\nUse repeatable `--input` flags or `--input-file` to evaluate commands sequentially in one session and exit. Input files contain one console command per line; blank lines are ignored. Stop at the first error and skip remaining commands.\n\n`--json` requires `--input` or `--input-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 `--input` flags or `--input-file` to evaluate commands sequentially in one session and exit. Input files contain one console command per line; blank lines are ignored. Stop at the first error and skip remaining commands.\n\n`--json` requires `--input` or `--input-file` and returns one object with ordered results. Use `--no-input` as well to prevent authentication and password prompts.", + "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 --input \"shop.name\" --input \"shop.currency\" --json --no-input", - "<%= config.bin %> <%= command.id %> --store example --input-file ./commands.txt --json --no-input" + "<%= 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": { @@ -10873,28 +10873,6 @@ "name": "environment", "type": "option" }, - "input": { - "description": "Liquid command to evaluate. Repeat to run commands in order and exit.", - "env": "SHOPIFY_FLAG_INPUT", - "exclusive": [ - "input-file" - ], - "hasDynamicHelp": false, - "multiple": true, - "name": "input", - "type": "option" - }, - "input-file": { - "description": "File containing one Liquid command per line. Evaluates the commands and exits.", - "env": "SHOPIFY_FLAG_INPUT_FILE", - "exclusive": [ - "input" - ], - "hasDynamicHelp": false, - "multiple": false, - "name": "input-file", - "type": "option" - }, "json": { "allowNo": false, "char": "j", @@ -10943,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/theme/src/cli/commands/theme/console.test.ts b/packages/theme/src/cli/commands/theme/console.test.ts index 9f88653101e..d6fffdea710 100644 --- a/packages/theme/src/cli/commands/theme/console.test.ts +++ b/packages/theme/src/cli/commands/theme/console.test.ts @@ -31,23 +31,23 @@ async function runConsole(argv: string[]) { test.each([{flags: []}, {flags: ['--no-input']}])( 'rejects JSON without supplied commands before authentication: $flags', async ({flags}) => { - await expect(runConsole(['--json', ...flags])).rejects.toThrow('Use --input or --input-file with --json') + 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 input flags before authentication', async () => { - await expect(runConsole(['--input', 'shop.name', '--input-file', 'commands.txt', '--json'])).rejects.toThrow() +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(['--input', input, '--json'])).rejects.toThrow('Each --input must contain a Liquid command') + 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 inputs with no-input=%s', async (noInput) => { +test.each([false, true])('writes one document for repeated scripts with no-input=%s', async (noInput) => { const result = { status: 'success' as const, results: [ @@ -58,7 +58,7 @@ test.each([false, true])('writes one document for repeated inputs with no-input= vi.mocked(evaluateConsoleInputs).mockResolvedValue(result) await withCapturedStandardStreams(async (streams) => { - await runConsole(['--input', 'shop.name', '--input', 'false', '--json', ...(noInput ? ['--no-input'] : [])]) + 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') @@ -77,16 +77,16 @@ test.each([false, true])('writes one document for repeated inputs with no-input= 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 inputFile = joinPath(directory, 'commands.txt') - await writeFile(inputFile, 'shop.name\r\nshop.currency\r\n') - await runConsole(['--input-file', inputFile, '--json']) + 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(['--input-file', joinPath(directory, 'missing.txt'), '--json'])).rejects.toThrow() + await expect(runConsole(['--script-file', joinPath(directory, 'missing.txt'), '--json'])).rejects.toThrow() }) expect(ensureReplEnv).not.toHaveBeenCalled() }) @@ -97,14 +97,14 @@ test('runs supplied commands in text mode without starting the REPL', async () = results: [{input: 'shop.name', status: 'success', value: 'Test shop'}], }) await withCapturedStandardStreams(async (streams) => { - await runConsole(['--input', 'shop.name', '--no-input']) + 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 inputs are omitted', async () => { +test('keeps the interactive console when scripts are omitted', async () => { await runConsole([]) expect(initializeRepl).toHaveBeenCalledWith( {storeFqdn: 'shop.myshopify.com', token: 'token'}, @@ -127,7 +127,7 @@ test('returns failed results with a nonzero exit code', async () => { const previousExitCode = process.exitCode try { await withCapturedStandardStreams(async (streams) => { - await runConsole(['--input', 'invalid', '--json']) + await runConsole(['--script', 'invalid', '--json']) expect(JSON.parse(streams.stdout())).toEqual(result) expect(process.exitCode).toBe(1) }) @@ -139,7 +139,7 @@ test('returns failed results with a nonzero exit code', async () => { 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(['--input', 'shop.name', '--json'])).rejects.toThrow('Session expired') + await expect(runConsole(['--script', 'shop.name', '--json'])).rejects.toThrow('Session expired') expect(streams.stdout()).toBe('') }) }) @@ -152,7 +152,7 @@ test('wraps a named environment in the theme environment contract', async () => vi.mocked(loadEnvironment).mockResolvedValue({store: 'shop.myshopify.com', password: 'token'}) vi.mocked(evaluateConsoleInputs).mockResolvedValue(result) await withCapturedStandardStreams(async (streams) => { - await runConsole(['--environment', 'development', '--input', 'shop.name', '--json']) + await runConsole(['--environment', 'development', '--script', 'shop.name', '--json']) expect(JSON.parse(streams.stdout())).toEqual({environments: [{environment: 'development', result}]}) }) }) diff --git a/packages/theme/src/cli/commands/theme/console.ts b/packages/theme/src/cli/commands/theme/console.ts index 07c06f297d3..57b0f94ee6c 100644 --- a/packages/theme/src/cli/commands/theme/console.ts +++ b/packages/theme/src/cli/commands/theme/console.ts @@ -28,13 +28,13 @@ export default class Console extends ThemeCommand { You can also provide context to the console using a URL, as some Liquid objects are context-specific. -Use repeatable \`--input\` flags or \`--input-file\` to evaluate commands sequentially in one session and exit. Input files contain one console command per line; blank lines are ignored. Stop at the first error and skip remaining commands. +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 \`--input\` or \`--input-file\` and returns one object with ordered results. Use \`--no-input\` as well to prevent authentication and password prompts.` +\`--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 --input "shop.name" --input "shop.currency" --json --no-input', - '<%= config.bin %> <%= command.id %> --store example --input-file ./commands.txt --json --no-input', + '<%= 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() @@ -43,17 +43,17 @@ Use repeatable \`--input\` flags or \`--input-file\` to evaluate commands sequen ...globalFlags, ...jsonFlag, ...themeFlags, - input: Flags.string({ + script: Flags.string({ description: 'Liquid command to evaluate. Repeat to run commands in order and exit.', multiple: true, multipleNonGreedy: true, - exclusive: ['input-file'], - env: 'SHOPIFY_FLAG_INPUT', + exclusive: ['script-file'], + env: 'SHOPIFY_FLAG_SCRIPT', }), - 'input-file': Flags.string({ + 'script-file': Flags.string({ description: 'File containing one Liquid command per line. Evaluates the commands and exits.', - exclusive: ['input'], - env: 'SHOPIFY_FLAG_INPUT_FILE', + exclusive: ['script'], + env: 'SHOPIFY_FLAG_SCRIPT_FILE', }), url: Flags.string({ description: 'The url to be used as context', @@ -70,7 +70,7 @@ Use repeatable \`--input\` flags or \`--input-file\` to evaluate commands sequen async command(flags: ConsoleFlags, adminSession: AdminSession, multiEnvironment = false) { const {url, password: themeAccessPassword} = flags - const inputs = await loadConsoleInputs(flags.input, flags['input-file']) + const inputs = await loadConsoleInputs(flags.script, flags['script-file']) const {themeId, storePassword} = await ensureReplEnv(adminSession, flags['store-password']) @@ -102,11 +102,11 @@ Use repeatable \`--input\` flags or \`--input-file\` to evaluate commands sequen } protected validateCommandFlags(flags: ConsoleFlags): void { - if (flags.json && !flags.input?.length && !flags['input-file']) { - throw new AbortError('Use --input or --input-file with --json to supply Liquid commands.') + 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.input?.some((input) => input.trim().length === 0)) { - throw new AbortError('Each --input must contain a Liquid command.') + 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/input.test.ts b/packages/theme/src/cli/services/console/input.test.ts index 5dca1dd28a2..abc0b77e697 100644 --- a/packages/theme/src/cli/services/console/input.test.ts +++ b/packages/theme/src/cli/services/console/input.test.ts @@ -10,23 +10,26 @@ test('keeps inline inputs in order and distinguishes interactive execution', asy test.each(['\n', '\r\n'])('reads one command per line with %j line endings', async (newline) => { await inTemporaryDirectory(async (directory) => { - const inputFile = joinPath(directory, 'commands.txt') - await writeFile(inputFile, ['assign name = shop.name', '', ' ', 'name | upcase', ''].join(newline)) + const scriptFile = joinPath(directory, 'commands.txt') + await writeFile(scriptFile, ['assign name = shop.name', '', ' ', 'name | upcase', ''].join(newline)) - await expect(loadConsoleInputs(undefined, inputFile)).resolves.toEqual(['assign name = shop.name', 'name | upcase']) + await expect(loadConsoleInputs(undefined, scriptFile)).resolves.toEqual([ + 'assign name = shop.name', + 'name | upcase', + ]) }) }) -test('rejects an empty input file', async () => { +test('rejects an empty script file', async () => { await inTemporaryDirectory(async (directory) => { - const inputFile = joinPath(directory, 'commands.txt') - await writeFile(inputFile, '\n \r\n') + const scriptFile = joinPath(directory, 'commands.txt') + await writeFile(scriptFile, '\n \r\n') - await expect(loadConsoleInputs(undefined, inputFile)).rejects.toThrow('at least one Liquid command') + await expect(loadConsoleInputs(undefined, scriptFile)).rejects.toThrow('at least one Liquid command') }) }) -test('rejects an unreadable input file', async () => { +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 index 497fa3bda58..964a8aa2a80 100644 --- a/packages/theme/src/cli/services/console/input.ts +++ b/packages/theme/src/cli/services/console/input.ts @@ -1,11 +1,11 @@ import {readFile} from '@shopify/cli-kit/node/fs' import {AbortError} from '@shopify/cli-kit/node/error' -export async function loadConsoleInputs(inputs?: string[], inputFile?: string): Promise { - if (inputFile === undefined) return inputs +export async function loadConsoleInputs(inputs?: string[], scriptFile?: string): Promise { + if (scriptFile === undefined) return inputs - const contents = await readFile(inputFile) + 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 input file must contain at least one Liquid command.') + if (commands.length === 0) throw new AbortError('The script file must contain at least one Liquid command.') return commands } From 7b8f1c421728943c9b4df70015cb8b8ad74ebe75 Mon Sep 17 00:00:00 2001 From: Gonzalo Riestra Date: Wed, 28 Oct 2026 12:57:06 +0100 Subject: [PATCH 5/5] Declare the theme console schema as a static field --- packages/theme/src/cli/commands/theme/console.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/packages/theme/src/cli/commands/theme/console.ts b/packages/theme/src/cli/commands/theme/console.ts index 57b0f94ee6c..6236d1b0c43 100644 --- a/packages/theme/src/cli/commands/theme/console.ts +++ b/packages/theme/src/cli/commands/theme/console.ts @@ -16,9 +16,7 @@ import {renderError} from '@shopify/cli-kit/node/ui' type ConsoleFlags = InferredFlags export default class Console extends ThemeCommand { - static get jsonOutputSchema() { - return themeConsoleJsonOutputSchema - } + static jsonOutputSchema = themeConsoleJsonOutputSchema static summary = 'Shopify Liquid REPL (read-eval-print loop) tool'