From 234452b8286d2d278851f84f208a8e2ed373b8ac Mon Sep 17 00:00:00 2001 From: Gonzalo Riestra Date: Thu, 24 Sep 2026 11:21:36 +0200 Subject: [PATCH] Add typed JSON output to theme pull --- .../generated/generated_docs_data_v2.json | 11 +- packages/cli/README.md | 102 +++++++++- packages/cli/oclif.manifest.json | 11 +- .../rules/json-output-command-exceptions.js | 1 - .../theme/src/cli/commands/theme/pull.test.ts | 73 +++++++ packages/theme/src/cli/commands/theme/pull.ts | 28 ++- .../theme/transfer-environments.test.ts | 181 ++++++++++-------- packages/theme/src/cli/services/pull.test.ts | 6 +- packages/theme/src/cli/services/pull.ts | 57 +++--- .../src/cli/services/pull/execute.test.ts | 95 +++++++++ .../src/cli/services/pull/result.test.ts | 56 ++++++ .../theme/src/cli/services/pull/result.ts | 27 +++ packages/theme/src/cli/services/pull/types.ts | 27 +++ 13 files changed, 555 insertions(+), 120 deletions(-) create mode 100644 packages/theme/src/cli/commands/theme/pull.test.ts create mode 100644 packages/theme/src/cli/services/pull/execute.test.ts create mode 100644 packages/theme/src/cli/services/pull/result.test.ts create mode 100644 packages/theme/src/cli/services/pull/result.ts create mode 100644 packages/theme/src/cli/services/pull/types.ts diff --git a/docs-shopify.dev/generated/generated_docs_data_v2.json b/docs-shopify.dev/generated/generated_docs_data_v2.json index 5cab3081839..7be513a2efc 100644 --- a/docs-shopify.dev/generated/generated_docs_data_v2.json +++ b/docs-shopify.dev/generated/generated_docs_data_v2.json @@ -8709,6 +8709,15 @@ "isOptional": true, "environmentValue": "SHOPIFY_FLAG_ENVIRONMENT" }, + { + "filePath": "docs-shopify.dev/commands/interfaces/theme-pull.interface.ts", + "syntaxKind": "PropertySignature", + "name": "-j, --json", + "value": "''", + "description": "Output the result as JSON. Automatically disables color output.", + "isOptional": true, + "environmentValue": "SHOPIFY_FLAG_JSON" + }, { "filePath": "docs-shopify.dev/commands/interfaces/theme-pull.interface.ts", "syntaxKind": "PropertySignature", @@ -8764,7 +8773,7 @@ "environmentValue": "SHOPIFY_FLAG_IGNORE" } ], - "value": "export interface themepull {\n /**\n * Alias of the Shopify account to use for authentication.\n * @environment SHOPIFY_FLAG_AUTH_ALIAS\n */\n '--auth-alias '?: string\n\n /**\n * Pull theme files from your remote development theme. Use --development, --live, or --theme in non-interactive environments.\n * @environment SHOPIFY_FLAG_DEVELOPMENT\n */\n '-d, --development'?: ''\n\n /**\n * The environment to apply to the current command.\n * @environment SHOPIFY_FLAG_ENVIRONMENT\n */\n '-e, --environment '?: string\n\n /**\n * Skip downloading the specified files (Multiple flags allowed). Wrap the value in double quotes if you're using wildcards.\n * @environment SHOPIFY_FLAG_IGNORE\n */\n '-x, --ignore '?: string\n\n /**\n * Print the command's JSON schemas.\n * @environment SHOPIFY_FLAG_JSON_SCHEMA\n */\n '--json-schema'?: ''\n\n /**\n * Pull theme files from your remote live theme. Use --development, --live, or --theme in non-interactive environments.\n * @environment SHOPIFY_FLAG_LIVE\n */\n '-l, --live'?: ''\n\n /**\n * Disable color output.\n * @environment SHOPIFY_FLAG_NO_COLOR\n */\n '--no-color'?: ''\n\n /**\n * Prevent deleting local files that don't exist remotely.\n * @environment SHOPIFY_FLAG_NODELETE\n */\n '-n, --nodelete'?: ''\n\n /**\n * Download only the specified files (Multiple flags allowed). Wrap the value in double quotes if you're using wildcards.\n * @environment SHOPIFY_FLAG_ONLY\n */\n '-o, --only '?: string\n\n /**\n * Password generated from the Theme Access app or an Admin API token.\n * @environment SHOPIFY_CLI_THEME_TOKEN\n */\n '--password '?: string\n\n /**\n * The path where you want to run the command. Defaults to the current working directory.\n * @environment SHOPIFY_FLAG_PATH\n */\n '--path '?: string\n\n /**\n * Store URL. It can be the store prefix (example) or the full myshopify.com URL (example.myshopify.com, https://example.myshopify.com).\n * @environment SHOPIFY_FLAG_STORE\n */\n '-s, --store '?: string\n\n /**\n * Theme ID or name of the remote theme. Use --development, --live, or --theme in non-interactive environments.\n * @environment SHOPIFY_FLAG_THEME_ID\n */\n '-t, --theme '?: string\n\n /**\n * Increase the verbosity of the output. May include sensitive data.\n * @environment SHOPIFY_FLAG_VERBOSE\n */\n '--verbose'?: ''\n}" + "value": "export interface themepull {\n /**\n * Alias of the Shopify account to use for authentication.\n * @environment SHOPIFY_FLAG_AUTH_ALIAS\n */\n '--auth-alias '?: string\n\n /**\n * Pull theme files from your remote development theme. Use --development, --live, or --theme in non-interactive environments.\n * @environment SHOPIFY_FLAG_DEVELOPMENT\n */\n '-d, --development'?: ''\n\n /**\n * The environment to apply to the current command.\n * @environment SHOPIFY_FLAG_ENVIRONMENT\n */\n '-e, --environment '?: string\n\n /**\n * Skip downloading the specified files (Multiple flags allowed). Wrap the value in double quotes if you're using wildcards.\n * @environment SHOPIFY_FLAG_IGNORE\n */\n '-x, --ignore '?: string\n\n /**\n * Output the result as JSON. Automatically disables color output.\n * @environment SHOPIFY_FLAG_JSON\n */\n '-j, --json'?: ''\n\n /**\n * Print the command's JSON schemas.\n * @environment SHOPIFY_FLAG_JSON_SCHEMA\n */\n '--json-schema'?: ''\n\n /**\n * Pull theme files from your remote live theme. Use --development, --live, or --theme in non-interactive environments.\n * @environment SHOPIFY_FLAG_LIVE\n */\n '-l, --live'?: ''\n\n /**\n * Disable color output.\n * @environment SHOPIFY_FLAG_NO_COLOR\n */\n '--no-color'?: ''\n\n /**\n * Prevent deleting local files that don't exist remotely.\n * @environment SHOPIFY_FLAG_NODELETE\n */\n '-n, --nodelete'?: ''\n\n /**\n * Download only the specified files (Multiple flags allowed). Wrap the value in double quotes if you're using wildcards.\n * @environment SHOPIFY_FLAG_ONLY\n */\n '-o, --only '?: string\n\n /**\n * Password generated from the Theme Access app or an Admin API token.\n * @environment SHOPIFY_CLI_THEME_TOKEN\n */\n '--password '?: string\n\n /**\n * The path where you want to run the command. Defaults to the current working directory.\n * @environment SHOPIFY_FLAG_PATH\n */\n '--path '?: string\n\n /**\n * Store URL. It can be the store prefix (example) or the full myshopify.com URL (example.myshopify.com, https://example.myshopify.com).\n * @environment SHOPIFY_FLAG_STORE\n */\n '-s, --store '?: string\n\n /**\n * Theme ID or name of the remote theme. Use --development, --live, or --theme in non-interactive environments.\n * @environment SHOPIFY_FLAG_THEME_ID\n */\n '-t, --theme '?: string\n\n /**\n * Increase the verbosity of the output. May include sensitive data.\n * @environment SHOPIFY_FLAG_VERBOSE\n */\n '--verbose'?: ''\n}" } }, "themepush": { diff --git a/packages/cli/README.md b/packages/cli/README.md index 3cb6d98ad4f..d3437178576 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -9120,7 +9120,7 @@ Download your remote theme files locally. ``` USAGE - $ shopify theme pull [--auth-alias ] [-d] [-e ...] [-x ...] [--json-schema] [-l] + $ shopify theme pull [--auth-alias ] [-d] [-e ...] [-x ...] [-j] [--json-schema] [-l] [--no-color] [-n] [-o ...] [--password ] [--path ] [-s ] [-t ] [--verbose] FLAGS @@ -9133,6 +9133,10 @@ FLAGS The environment to apply to the current command. [env: SHOPIFY_FLAG_ENVIRONMENT] + -j, --json + Output the result as JSON. Automatically disables color output. + [env: SHOPIFY_FLAG_JSON] + -l, --live Pull theme files from your remote live theme. Use --development, --live, or --theme in non-interactive environments. [env: SHOPIFY_FLAG_LIVE] @@ -9190,6 +9194,102 @@ DESCRIPTION Retrieves theme files from Shopify. If no theme is specified, then you're prompted to select the theme to pull from the list of the themes in your store. + + Use `--json-schema` to print the result, error, and event schemas. + + Output from `--json` conforms to the `ThemePullResult` schema. + + ```json + { + "anyOf": [ + { + "type": "object", + "properties": { + "environment": { + "type": "string" + }, + "path": { + "type": "string" + }, + "theme": { + "$ref": "#/definitions/ThemePullTheme" + } + }, + "required": [ + "path", + "theme" + ], + "additionalProperties": false + }, + { + "type": "array", + "items": { + "type": "object", + "properties": { + "environment": { + "type": "string" + }, + "path": { + "$ref": "#/definitions/ThemePullResult/anyOf/0/properties/path" + }, + "theme": { + "$ref": "#/definitions/ThemePullTheme" + } + }, + "required": [ + "environment", + "path", + "theme" + ], + "additionalProperties": false + } + } + ], + "title": "ThemePullResult", + "definitions": { + "ThemePullTheme": { + "type": "object", + "properties": { + "id": { + "type": "number" + }, + "name": { + "type": "string" + }, + "role": { + "type": "string" + }, + "processing": { + "type": "boolean" + }, + "src": { + "type": "string" + }, + "shop": { + "type": "string" + }, + "editor_url": { + "type": "string" + }, + "preview_url": { + "type": "string" + } + }, + "required": [ + "id", + "name", + "role", + "processing", + "shop", + "editor_url", + "preview_url" + ], + "additionalProperties": false + } + }, + "$schema": "http://json-schema.org/draft-07/schema#" + } + ``` ``` ## `shopify theme push` diff --git a/packages/cli/oclif.manifest.json b/packages/cli/oclif.manifest.json index 85a43c4cbfb..7028c16ee29 100644 --- a/packages/cli/oclif.manifest.json +++ b/packages/cli/oclif.manifest.json @@ -11179,7 +11179,7 @@ "args": { }, "customPluginName": "@shopify/theme", - "description": "Retrieves theme files from Shopify.\n\nIf no theme is specified, then you're prompted to select the theme to pull from the list of the themes in your store.", + "description": "Retrieves theme files from Shopify.\n\nIf no theme is specified, then you're prompted to select the theme to pull from the list of the themes in your store.\n\nUse `--json-schema` to print the result, error, and event schemas.\n\nOutput from `--json` conforms to the `ThemePullResult` schema.\n\n```json\n{\n \"anyOf\": [\n {\n \"type\": \"object\",\n \"properties\": {\n \"environment\": {\n \"type\": \"string\"\n },\n \"path\": {\n \"type\": \"string\"\n },\n \"theme\": {\n \"$ref\": \"#/definitions/ThemePullTheme\"\n }\n },\n \"required\": [\n \"path\",\n \"theme\"\n ],\n \"additionalProperties\": false\n },\n {\n \"type\": \"array\",\n \"items\": {\n \"type\": \"object\",\n \"properties\": {\n \"environment\": {\n \"type\": \"string\"\n },\n \"path\": {\n \"$ref\": \"#/definitions/ThemePullResult/anyOf/0/properties/path\"\n },\n \"theme\": {\n \"$ref\": \"#/definitions/ThemePullTheme\"\n }\n },\n \"required\": [\n \"environment\",\n \"path\",\n \"theme\"\n ],\n \"additionalProperties\": false\n }\n }\n ],\n \"title\": \"ThemePullResult\",\n \"definitions\": {\n \"ThemePullTheme\": {\n \"type\": \"object\",\n \"properties\": {\n \"id\": {\n \"type\": \"number\"\n },\n \"name\": {\n \"type\": \"string\"\n },\n \"role\": {\n \"type\": \"string\"\n },\n \"processing\": {\n \"type\": \"boolean\"\n },\n \"src\": {\n \"type\": \"string\"\n },\n \"shop\": {\n \"type\": \"string\"\n },\n \"editor_url\": {\n \"type\": \"string\"\n },\n \"preview_url\": {\n \"type\": \"string\"\n }\n },\n \"required\": [\n \"id\",\n \"name\",\n \"role\",\n \"processing\",\n \"shop\",\n \"editor_url\",\n \"preview_url\"\n ],\n \"additionalProperties\": false\n }\n },\n \"$schema\": \"http://json-schema.org/draft-07/schema#\"\n}\n```", "descriptionWithMarkdown": "Retrieves theme files from Shopify.\n\nIf no theme is specified, then you're prompted to select the theme to pull from the list of the themes in your store.", "enableJsonFlag": false, "flags": { @@ -11226,6 +11226,15 @@ "name": "ignore", "type": "option" }, + "json": { + "allowNo": false, + "char": "j", + "description": "Output the result as JSON. Automatically disables color output.", + "env": "SHOPIFY_FLAG_JSON", + "hidden": false, + "name": "json", + "type": "boolean" + }, "json-schema": { "allowNo": false, "description": "Print the command's JSON schemas.", diff --git a/packages/eslint-plugin-cli/rules/json-output-command-exceptions.js b/packages/eslint-plugin-cli/rules/json-output-command-exceptions.js index 25b409c59cf..245175f2627 100644 --- a/packages/eslint-plugin-cli/rules/json-output-command-exceptions.js +++ b/packages/eslint-plugin-cli/rules/json-output-command-exceptions.js @@ -49,7 +49,6 @@ const commandExceptions = [ 'packages/theme/src/cli/commands/theme/package.ts', 'packages/theme/src/cli/commands/theme/preview.ts', 'packages/theme/src/cli/commands/theme/profile.ts', - 'packages/theme/src/cli/commands/theme/pull.ts', 'packages/theme/src/cli/commands/theme/share.ts', // Streaming commands without a single finite result. diff --git a/packages/theme/src/cli/commands/theme/pull.test.ts b/packages/theme/src/cli/commands/theme/pull.test.ts new file mode 100644 index 00000000000..1149b9aacf1 --- /dev/null +++ b/packages/theme/src/cli/commands/theme/pull.test.ts @@ -0,0 +1,73 @@ +import Pull from './pull.js' +import {executeThemePull} from '../../services/pull.js' +import {themePullJsonOutputSchema} from '../../services/pull/types.js' +import {withCapturedStandardStreams} from '@shopify/cli-kit/node/testing/output' +import {describe, expect, test, vi} from 'vitest' +import {Config} from '@oclif/core' +import {AbortError} from '@shopify/cli-kit/node/error' + +vi.mock('../../services/pull.js') +const session = {storeFqdn: 'test.myshopify.com', token: 'token'} + +describe('theme pull JSON', () => { + test('exposes the schema in help and retains JSON and inherited flags', () => { + expect(Pull.jsonOutputSchema).toBe(themePullJsonOutputSchema) + expect(Pull.description).toContain('ThemePullResult') + expect(Pull.flags.json).toBeDefined() + expect(Pull.baseFlags).toHaveProperty('json-schema') + }) + + test('executes and writes the result through the real encoder', async () => { + vi.mocked(executeThemePull).mockResolvedValue({ + theme: { + id: 1, + name: 'Theme', + role: 'unpublished', + processing: false, + shop: session.storeFqdn, + editor_url: 'editor', + preview_url: 'preview', + }, + path: '/theme', + }) + const command = new Pull([], new Config({root: '.'})) + await withCapturedStandardStreams(async ({stdout, stderr}) => { + await command.command({json: true} as never, session, false) + + expect(JSON.parse(stdout())).toEqual({ + path: '/theme', + theme: { + id: 1, + name: 'Theme', + role: 'unpublished', + processing: false, + shop: session.storeFqdn, + editor_url: 'editor', + preview_url: 'preview', + }, + }) + expect(stderr()).toBe('') + }) + }) + + test('retains cancellation without emitting a success result', async () => { + vi.mocked(executeThemePull).mockResolvedValue(undefined) + const command = new Pull([], new Config({root: '.'})) + await withCapturedStandardStreams(async ({stdout}) => { + await command.command({json: true} as never, session, false) + + expect(stdout()).toBe('') + }) + }) + + test('propagates download failures without writing a result', async () => { + const failure = new AbortError('download failed') + vi.mocked(executeThemePull).mockRejectedValueOnce(failure) + const command = new Pull([], new Config({root: '.'})) + await withCapturedStandardStreams(async ({stdout}) => { + await expect(command.command({json: true} as never, session, false)).rejects.toBe(failure) + + expect(stdout()).toBe('') + }) + }) +}) diff --git a/packages/theme/src/cli/commands/theme/pull.ts b/packages/theme/src/cli/commands/theme/pull.ts index 605eaaa5dd5..0253d39f497 100644 --- a/packages/theme/src/cli/commands/theme/pull.ts +++ b/packages/theme/src/cli/commands/theme/pull.ts @@ -1,7 +1,9 @@ import {globFlags, themeFlags} from '../../flags.js' import ThemeCommand, {RequiredFlags} from '../../utilities/theme-command.js' -import {pull} from '../../services/pull.js' -import {globalFlags} from '@shopify/cli-kit/node/cli' +import {executeThemePull} from '../../services/pull.js' +import {renderThemePullResult, renderThemePullEnvironmentResults} from '../../services/pull/result.js' +import {themePullJsonOutputSchema} from '../../services/pull/types.js' +import {globalFlags, jsonFlag} from '@shopify/cli-kit/node/cli' import {Flags} from '@oclif/core' import {recordTiming} from '@shopify/cli-kit/node/analytics' import {InferredFlags} from '@oclif/core/interfaces' @@ -12,6 +14,10 @@ import type {NonTTYFlagRequirement} from '@shopify/cli-kit/node/base-command' type PullFlags = InferredFlags export default class Pull extends ThemeCommand { + static get jsonOutputSchema() { + return themePullJsonOutputSchema + } + static summary = 'Download your remote theme files locally.' static descriptionWithMarkdown = `Retrieves theme files from Shopify. @@ -22,6 +28,7 @@ If no theme is specified, then you're prompted to select the theme to pull from static flags = { ...globalFlags, + ...jsonFlag, ...themeFlags, ...globFlags('download'), theme: Flags.string({ @@ -69,8 +76,23 @@ If no theme is specified, then you're prompted to select the theme to pull from context?: {stdout?: Writable; stderr?: Writable}, ) { recordTiming('theme-command:pull') - await pull({...flags, noColor: flags['no-color']}, adminSession, multiEnvironment, context) + const result = await executeThemePull( + {...flags, noColor: flags['no-color']}, + adminSession, + multiEnvironment, + context, + ) + if (result && !(flags.json && multiEnvironment)) renderThemePullResult(result, flags.json ? 'json' : 'text') recordTiming('theme-command:pull') + return result + } + + protected collectsEnvironmentResults(flags: {json?: boolean}): boolean { + return Boolean(flags.json) + } + + protected renderEnvironmentResults(results: {environment: string; result: unknown}[]): void { + renderThemePullEnvironmentResults(results) } protected storeAuthScopes(): string[] { diff --git a/packages/theme/src/cli/commands/theme/transfer-environments.test.ts b/packages/theme/src/cli/commands/theme/transfer-environments.test.ts index 66e81d73105..79319aa5e76 100644 --- a/packages/theme/src/cli/commands/theme/transfer-environments.test.ts +++ b/packages/theme/src/cli/commands/theme/transfer-environments.test.ts @@ -1,4 +1,6 @@ import Push from './push.js' +import Pull from './pull.js' +import {executeThemePull} from '../../services/pull.js' import {executeThemePush} from '../../services/push.js' import {withCapturedStandardStreams} from '@shopify/cli-kit/node/testing/output' import {loadEnvironment} from '@shopify/cli-kit/node/environments' @@ -6,9 +8,10 @@ import {ensureAuthenticatedThemes} from '@shopify/cli-kit/node/session' import {inTemporaryDirectory} from '@shopify/cli-kit/node/fs' import {runWithCommandEvents, renderCommandEventAsJson} from '@shopify/cli-kit/node/command-events' import {Config} from '@oclif/core' -import {expect, test, vi} from 'vitest' +import {describe, expect, test, vi} from 'vitest' vi.mock('../../services/push.js') +vi.mock('../../services/pull.js') vi.mock('@shopify/cli-kit/node/environments') vi.mock('@shopify/cli-kit/node/session') vi.mock('@shopify/cli-kit/node/metadata') @@ -17,93 +20,103 @@ class TestPush extends Push { public parse = vi.fn() } -// Exercise real environment orchestration, presenter, encoder and streams. -test.each(['none', 'partial', 'total'] as const)( - 'collects environment successes in requested order with %s failures', - async (failures) => { - await inTemporaryDirectory(async (path) => { - vi.mocked(loadEnvironment).mockImplementation(async (name) => ({ - store: name === 'second' ? 'second.myshopify.com' : 'first.myshopify.com', - password: 'password', - path, - theme: '1', - })) - vi.mocked(ensureAuthenticatedThemes).mockImplementation(async (store) => ({storeFqdn: store, token: 'token'})) - let releaseFirst: () => void = () => {} - const secondStarted = new Promise((resolve) => { - releaseFirst = resolve - }) - const executionOrder: string[] = [] - vi.mocked(executeThemePush).mockImplementation(async (flags, session) => { - const environment = flags.environment![0]! - if (environment === 'first') await secondStarted - if (environment === 'second') releaseFirst() - executionOrder.push(environment) - if (failures === 'total' || (failures === 'partial' && environment === 'second')) - throw new Error('upload failed') - return { - environment, - theme: { - id: 1, - name: environment, - role: 'unpublished', - shop: session!.storeFqdn, - editor_url: 'editor', - preview_url: 'preview', - }, - published: false, - hasErrors: false, - errors: {}, +class TestPull extends Pull { + public parse = vi.fn() +} + +describe.each([TestPush, TestPull])('%s', (Command) => { + // Exercise real environment orchestration, presenter, encoder and streams. + test.each(['none', 'partial', 'total'] as const)( + 'collects environment successes in requested order with %s failures', + async (failures) => { + await inTemporaryDirectory(async (path) => { + vi.mocked(loadEnvironment).mockImplementation(async (name) => ({ + store: name === 'second' ? 'second.myshopify.com' : 'first.myshopify.com', + password: 'password', + path, + theme: '1', + })) + vi.mocked(ensureAuthenticatedThemes).mockImplementation(async (store) => ({storeFqdn: store, token: 'token'})) + let releaseFirst: () => void = () => {} + const secondStarted = new Promise((resolve) => { + releaseFirst = resolve + }) + const executionOrder: string[] = [] + const execute = async (flags: {environment?: string[]}, session?: {storeFqdn: string}) => { + const environment = flags.environment![0]! + if (environment === 'first') await secondStarted + if (environment === 'second') releaseFirst() + executionOrder.push(environment) + if (failures === 'total' || (failures === 'partial' && environment === 'second')) + throw new Error('upload failed') + return { + environment, + theme: { + id: 1, + name: environment, + role: 'unpublished', + processing: false, + shop: session!.storeFqdn, + editor_url: 'editor', + preview_url: 'preview', + }, + path, + published: false, + hasErrors: false, + errors: {}, + } } - }) - const command = new TestPush([], new Config({root: path})) - const flags = {json: true, force: true, environment: ['first', 'second', 'third']} - vi.spyOn(command, 'parse').mockResolvedValue({flags, args: {}} as never) - const exitCode = process.exitCode - await withCapturedStandardStreams(async ({stdout, stderr}) => { - await runWithCommandEvents({outputMode: 'json', sink: renderCommandEventAsJson}, () => command.run()) + vi.mocked(executeThemePush).mockImplementation(execute) + vi.mocked(executeThemePull).mockImplementation(execute) + const command = new Command([], new Config({root: path})) + const flags = {json: true, force: true, environment: ['first', 'second', 'third']} + vi.spyOn(command, 'parse').mockResolvedValue({flags, args: {}} as never) + const exitCode = process.exitCode + await withCapturedStandardStreams(async ({stdout, stderr}) => { + await runWithCommandEvents({outputMode: 'json', sink: renderCommandEventAsJson}, () => command.run()) - const results = JSON.parse(stdout()) - expect(results.map((result: {environment: string}) => result.environment)).toEqual( - failures === 'total' ? [] : ['first', ...(failures === 'none' ? ['second'] : []), 'third'], - ) - expect(executionOrder).toEqual(['second', 'first', 'third']) - expect(process.exitCode).toBe(exitCode) - const events = stderr().trim() - ? stderr() - .trim() - .split('\n') - .filter(Boolean) - .map((line) => JSON.parse(line)) - : [] - expect(events.filter((event) => event.level === 'error')).toHaveLength( - {none: 0, partial: 1, total: 3}[failures], - ) + const results = JSON.parse(stdout()) + expect(results.map((result: {environment: string}) => result.environment)).toEqual( + failures === 'total' ? [] : ['first', ...(failures === 'none' ? ['second'] : []), 'third'], + ) + expect(executionOrder).toEqual(['second', 'first', 'third']) + expect(process.exitCode).toBe(exitCode) + const events = stderr().trim() + ? stderr() + .trim() + .split('\n') + .filter(Boolean) + .map((line) => JSON.parse(line)) + : [] + expect(events.filter((event) => event.level === 'error')).toHaveLength( + {none: 0, partial: 1, total: 3}[failures], + ) + }) }) - }) - }, -) + }, + ) -test('returns an empty array when every environment is invalid', async () => { - vi.mocked(loadEnvironment).mockResolvedValue({}) - const command = new TestPush([], new Config({root: '.'})) - vi.spyOn(command, 'parse').mockResolvedValue({ - flags: {json: true, force: true, environment: ['first', 'second']}, - args: {}, - } as never) - await withCapturedStandardStreams(async ({stdout, stderr}) => { - await runWithCommandEvents({outputMode: 'json', sink: renderCommandEventAsJson}, () => command.run()) + test('returns an empty array when every environment is invalid', async () => { + vi.mocked(loadEnvironment).mockResolvedValue({}) + const command = new Command([], new Config({root: '.'})) + vi.spyOn(command, 'parse').mockResolvedValue({ + flags: {json: true, force: true, environment: ['first', 'second']}, + args: {}, + } as never) + await withCapturedStandardStreams(async ({stdout, stderr}) => { + await runWithCommandEvents({outputMode: 'json', sink: renderCommandEventAsJson}, () => command.run()) - expect(JSON.parse(stdout())).toEqual([]) - expect(executeThemePush).not.toHaveBeenCalled() - expect( - stderr() - .trim() - .split('\n') - .map((line) => JSON.parse(line)), - ).toEqual([ - expect.objectContaining({type: 'diagnostic', level: 'warning'}), - expect.objectContaining({type: 'diagnostic', level: 'warning'}), - ]) + expect(JSON.parse(stdout())).toEqual([]) + expect(executeThemePush).not.toHaveBeenCalled() + expect( + stderr() + .trim() + .split('\n') + .map((line) => JSON.parse(line)), + ).toEqual([ + expect.objectContaining({type: 'diagnostic', level: 'warning'}), + expect.objectContaining({type: 'diagnostic', level: 'warning'}), + ]) + }) }) }) diff --git a/packages/theme/src/cli/services/pull.test.ts b/packages/theme/src/cli/services/pull.test.ts index 3656d80bee2..87a6d0b3ec3 100644 --- a/packages/theme/src/cli/services/pull.test.ts +++ b/packages/theme/src/cli/services/pull.test.ts @@ -23,7 +23,10 @@ vi.mock('../utilities/theme-selector.js') vi.mock('../utilities/theme-store.js') vi.mock('../utilities/theme-fs.js') vi.mock('../utilities/theme-downloader.js') -vi.mock('../utilities/theme-ui.js') +vi.mock('../utilities/theme-ui.js', async (importOriginal) => ({ + ...(await importOriginal()), + ensureDirectoryConfirmed: vi.fn(), +})) vi.mock('@shopify/cli-kit/node/context/local') vi.mock('@shopify/cli-kit/node/session') vi.mock('@shopify/cli-kit/node/themes/api') @@ -56,7 +59,6 @@ describe('pull', () => { vi.mocked(ensureAuthenticatedThemes).mockResolvedValue(adminSession) vi.mocked(mountThemeFileSystem).mockReturnValue(localThemeFileSystem) vi.mocked(fetchChecksums).mockResolvedValue([]) - vi.mocked(themeComponent).mockReturnValue([]) findDevelopmentThemeSpy.mockClear() fetchDevelopmentThemeSpy.mockClear() }) diff --git a/packages/theme/src/cli/services/pull.ts b/packages/theme/src/cli/services/pull.ts index de4835e194d..0649b7dba59 100644 --- a/packages/theme/src/cli/services/pull.ts +++ b/packages/theme/src/cli/services/pull.ts @@ -1,6 +1,8 @@ +import {themePullResultSchema, type ThemePullResult} from './pull/types.js' +import {renderThemePullResult} from './pull/result.js' import {downloadTheme} from '../utilities/theme-downloader.js' import {hasRequiredThemeDirectories, mountThemeFileSystem} from '../utilities/theme-fs.js' -import {ensureDirectoryConfirmed, themeComponent} from '../utilities/theme-ui.js' +import {ensureDirectoryConfirmed} from '../utilities/theme-ui.js' import {rejectGeneratedStaticAssets} from '../utilities/asset-checksum.js' import {ensureThemeStore} from '../utilities/theme-store.js' import {DevelopmentThemeManager} from '../utilities/development-theme-manager.js' @@ -9,7 +11,6 @@ import {configureCLIEnvironment} from '../utilities/cli-config.js' import {Theme} from '@shopify/cli-kit/node/themes/types' import {AdminSession, ensureAuthenticatedThemes} from '@shopify/cli-kit/node/session' import {fetchChecksums} from '@shopify/cli-kit/node/themes/api' -import {renderSuccess} from '@shopify/cli-kit/node/ui' import {glob} from '@shopify/cli-kit/node/fs' import {cwd} from '@shopify/cli-kit/node/path' import {insideGitDirectory, isClean} from '@shopify/cli-kit/node/git' @@ -100,12 +101,12 @@ export interface PullFlags { * * @param flags - All flags are optional. */ -export async function pull( +export async function executeThemePull( flags: PullFlags, session?: AdminSession, multiEnvironment?: boolean, context?: {stdout?: Writable; stderr?: Writable}, -): Promise { +): Promise { recordTiming('theme-service:pull:setup') configureCLIEnvironment({verbose: flags.verbose, noColor: flags.noColor}) @@ -131,7 +132,7 @@ export async function pull( }) recordTiming('theme-service:pull:setup') - await executePull( + return executePull( theme, adminSession, { @@ -168,28 +169,19 @@ async function executePull( await downloadTheme(theme, session, themeChecksums, themeFileSystem, options, context) - const header = options.environment ? `Environment: ${options.environment}` : '' - renderSuccess({ - headline: header, - body: ['The theme', ...themeComponent(theme), 'has been pulled.'], - nextSteps: [ - [ - { - link: { - label: 'View your theme', - url: themePreviewUrl(theme, session), - }, - }, - ], - [ - { - link: { - label: 'Customize your theme at the theme editor', - url: themeEditorUrl(theme, session), - }, - }, - ], - ], + return themePullResultSchema.parse({ + environment: options.environment, + path: options.path, + theme: { + id: theme.id, + name: theme.name, + role: theme.role, + processing: theme.processing, + src: theme.src, + shop: session.storeFqdn, + editor_url: themeEditorUrl(theme, session), + preview_url: themePreviewUrl(theme, session), + }, }) } @@ -253,3 +245,14 @@ async function validateDirectory(path: string, force: boolean, environment?: str return true } + +/** Compatibility adapter for callers of the exported theme API. */ +export async function pull( + flags: PullFlags, + session?: AdminSession, + multiEnvironment?: boolean, + context?: {stdout?: Writable; stderr?: Writable}, +): Promise { + const result = await executeThemePull(flags, session, multiEnvironment, context) + if (result) renderThemePullResult(result, 'text') +} diff --git a/packages/theme/src/cli/services/pull/execute.test.ts b/packages/theme/src/cli/services/pull/execute.test.ts new file mode 100644 index 00000000000..a88db7149a8 --- /dev/null +++ b/packages/theme/src/cli/services/pull/execute.test.ts @@ -0,0 +1,95 @@ +import {renderThemePullResult} from './result.js' +import {executeThemePull} from '../pull.js' +import {findOrSelectTheme} from '../../utilities/theme-selector.js' +import {DevelopmentThemeManager} from '../../utilities/development-theme-manager.js' +import {withCapturedStandardStreams} from '@shopify/cli-kit/node/testing/output' +import {inTemporaryDirectory, mkdir, writeFile, readFile, fileExists} from '@shopify/cli-kit/node/fs' +import {joinPath} from '@shopify/cli-kit/node/path' +import {fetchChecksums, fetchThemeAssets} from '@shopify/cli-kit/node/themes/api' +import {buildTheme} from '@shopify/cli-kit/node/themes/factories' +import {runWithCommandEvents, renderCommandEventAsJson} from '@shopify/cli-kit/node/command-events' +import {describe, expect, test, vi} from 'vitest' + +vi.mock('../../utilities/theme-selector.js') +vi.mock('@shopify/cli-kit/node/themes/api') + +const session = {storeFqdn: 'test.myshopify.com', token: 'token'} + +describe('pull execution', () => { + test.each([false, true])('downloads real files and honors nodelete=%s with JSON progress', async (nodelete) => { + await inTemporaryDirectory(async (path) => { + await mkdir(joinPath(path, 'assets')) + await writeFile(joinPath(path, 'assets/old.css'), 'old') + vi.spyOn(DevelopmentThemeManager.prototype, 'fetch').mockResolvedValue(undefined) + vi.mocked(findOrSelectTheme).mockResolvedValue( + buildTheme({id: 1, name: 'Theme', role: 'unpublished', processing: false})!, + ) + vi.mocked(fetchChecksums).mockResolvedValue([{key: 'assets/theme.css', checksum: 'new'}]) + vi.mocked(fetchThemeAssets).mockResolvedValue([{key: 'assets/theme.css', checksum: 'new', value: 'body {}'}]) + await withCapturedStandardStreams(async ({stdout, stderr}) => { + await runWithCommandEvents({outputMode: 'json', sink: renderCommandEventAsJson}, async () => { + const result = await executeThemePull({path, force: true, nodelete}, session) + expect(result).toEqual({ + path, + theme: { + id: 1, + name: 'Theme', + role: 'unpublished', + processing: false, + shop: session.storeFqdn, + editor_url: 'https://test.myshopify.com/admin/themes/1/editor', + preview_url: 'https://test.myshopify.com?preview_theme_id=1', + }, + }) + renderThemePullResult(result!, 'json') + }) + + await expect(readFile(joinPath(path, 'assets/theme.css'))).resolves.toBe('body {}') + await expect(fileExists(joinPath(path, 'assets/old.css'))).resolves.toBe(nodelete) + expect(JSON.parse(stdout())).toMatchObject({path, theme: {id: 1}}) + const events = stderr() + .trim() + .split('\n') + .map((line) => JSON.parse(line)) + expect(events).toEqual( + expect.arrayContaining([ + expect.objectContaining({type: 'progress', status: 'completed', message: 'Theme download complete'}), + ]), + ) + }) + }) + }) + + test('returns metadata when no files need downloading', async () => { + await inTemporaryDirectory(async (path) => { + vi.spyOn(DevelopmentThemeManager.prototype, 'fetch').mockResolvedValue(undefined) + vi.mocked(findOrSelectTheme).mockResolvedValue({ + ...buildTheme({id: 1, name: 'Theme', role: 'live'})!, + src: 'https://example.com/theme.zip', + }) + vi.mocked(fetchChecksums).mockResolvedValue([]) + const result = await runWithCommandEvents({outputMode: 'json'}, () => + executeThemePull({path, force: true}, session), + ) + expect(result?.theme).toMatchObject({role: 'live', src: 'https://example.com/theme.zip'}) + expect(fetchThemeAssets).not.toHaveBeenCalled() + }) + }) + + test('propagates download errors without writing a final result', async () => { + await inTemporaryDirectory(async (path) => { + vi.spyOn(DevelopmentThemeManager.prototype, 'fetch').mockResolvedValue(undefined) + vi.mocked(findOrSelectTheme).mockResolvedValue(buildTheme({id: 1, name: 'Theme', role: 'unpublished'})!) + vi.mocked(fetchChecksums).mockResolvedValue([{key: 'assets/theme.css', checksum: 'new'}]) + const failure = new Error('download failed') + vi.mocked(fetchThemeAssets).mockRejectedValue(failure) + await withCapturedStandardStreams(async ({stdout}) => { + await runWithCommandEvents({outputMode: 'json', sink: renderCommandEventAsJson}, async () => { + await expect(executeThemePull({path, force: true}, session)).rejects.toBe(failure) + }) + + expect(stdout()).toBe('') + }) + }) + }) +}) diff --git a/packages/theme/src/cli/services/pull/result.test.ts b/packages/theme/src/cli/services/pull/result.test.ts new file mode 100644 index 00000000000..00b8adf437b --- /dev/null +++ b/packages/theme/src/cli/services/pull/result.test.ts @@ -0,0 +1,56 @@ +import {themePullJsonOutputSchema, type ThemePullResult} from './types.js' +import {renderThemePullResult} from './result.js' +import {mockAndCaptureOutput, withCapturedStandardStreams} from '@shopify/cli-kit/node/testing/output' +import {describe, expect, test} from 'vitest' + +function result(): ThemePullResult { + return { + path: '/theme', + theme: { + id: 1, + name: 'Theme', + role: 'unpublished', + processing: false, + shop: 'test.myshopify.com', + editor_url: 'editor', + preview_url: 'preview', + }, + } +} + +describe('pull result', () => { + test('encodes false and omits optional source and environment', () => { + const value = JSON.parse(themePullJsonOutputSchema.encode(result())) + expect(value).toEqual(result()) + expect(value).not.toHaveProperty('environment') + expect(value.theme).not.toHaveProperty('src') + expect(value.theme.processing).toBe(false) + }) + + test.each([{id: '1'}, {processing: null}, {src: false}])('rejects invalid theme fields %j', (fields) => { + expect(() => themePullJsonOutputSchema.validate({...result(), theme: {...result().theme, ...fields}})).toThrow() + }) + + test('rejects a missing local path', () => { + expect(() => themePullJsonOutputSchema.validate({theme: result().theme})).toThrow() + }) + + test('writes the real encoded result to stdout', async () => { + await withCapturedStandardStreams(({stdout, stderr}) => { + renderThemePullResult(result(), 'json') + + expect(stdout()).toBe(`${themePullJsonOutputSchema.encode(result())}\n`) + expect(stderr()).toBe('') + }) + }) + + test('preserves the success banner and links', () => { + const output = mockAndCaptureOutput() + output.clear() + renderThemePullResult({...result(), environment: 'staging'}, 'text') + expect(output.info()).toContain('Environment: staging') + expect(output.info()).toContain("The theme 'Theme' (#1) has been pulled.") + expect(output.info()).toContain('View your theme') + expect(output.info()).toContain('Customize your theme at the theme editor') + }) +}) diff --git a/packages/theme/src/cli/services/pull/result.ts b/packages/theme/src/cli/services/pull/result.ts new file mode 100644 index 00000000000..2a085349cbc --- /dev/null +++ b/packages/theme/src/cli/services/pull/result.ts @@ -0,0 +1,27 @@ +import {themePullJsonOutputSchema, themePullResultSchema, type ThemePullResult} from './types.js' +import {themeComponent} from '../../utilities/theme-ui.js' +import {outputResult} from '@shopify/cli-kit/node/output' +import {renderSuccess} from '@shopify/cli-kit/node/ui' + +export function renderThemePullResult(result: ThemePullResult, format: 'text' | 'json'): void { + if (format === 'json') { + outputResult(themePullJsonOutputSchema.encode(result)) + return + } + const {environment, theme} = result + renderSuccess({ + headline: environment ? `Environment: ${environment}` : '', + body: ['The theme', ...themeComponent(theme), 'has been pulled.'], + nextSteps: [ + [{link: {label: 'View your theme', url: theme.preview_url}}], + [{link: {label: 'Customize your theme at the theme editor', url: theme.editor_url}}], + ], + }) +} + +export function renderThemePullEnvironmentResults(results: {environment: string; result: unknown}[]): void { + const output = results.flatMap(({environment, result}) => + result === undefined ? [] : [{...themePullResultSchema.parse(result), environment}], + ) + outputResult(themePullJsonOutputSchema.encode(output)) +} diff --git a/packages/theme/src/cli/services/pull/types.ts b/packages/theme/src/cli/services/pull/types.ts new file mode 100644 index 00000000000..658534c4290 --- /dev/null +++ b/packages/theme/src/cli/services/pull/types.ts @@ -0,0 +1,27 @@ +import {defineJsonOutputSchema} from '@shopify/cli-kit/node/json-output-schema' +import {zod} from '@shopify/cli-kit/node/schema' + +const ThemePullThemeSchema = zod.object({ + id: zod.number(), + name: zod.string(), + role: zod.string(), + processing: zod.boolean(), + src: zod.string().optional(), + shop: zod.string(), + editor_url: zod.string(), + preview_url: zod.string(), +}) + +export const themePullResultSchema = zod.object({ + environment: zod.string().optional(), + path: zod.string(), + theme: ThemePullThemeSchema, +}) + +export const themePullJsonOutputSchema = defineJsonOutputSchema({ + name: 'ThemePullResult', + schema: zod.union([themePullResultSchema, zod.array(themePullResultSchema.extend({environment: zod.string()}))]), + definitions: {ThemePullTheme: ThemePullThemeSchema}, +}) + +export type ThemePullResult = zod.infer