From ea4bcdf59b6ee83b2187a052a931126216d207cc Mon Sep 17 00:00:00 2001 From: Gonzalo Riestra Date: Thu, 24 Sep 2026 11:21:36 +0200 Subject: [PATCH 01/10] Add typed JSON output to theme pull --- 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 | 132 +++++++----------- 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 ++++ 11 files changed, 399 insertions(+), 114 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/packages/cli/oclif.manifest.json b/packages/cli/oclif.manifest.json index c7bb69da89d..bc6cc5b3c8f 100644 --- a/packages/cli/oclif.manifest.json +++ b/packages/cli/oclif.manifest.json @@ -12456,7 +12456,7 @@ "args": { }, "customPluginName": "@shopify/theme", - "description": "Retrieves theme files from Shopify.\n\nIf no theme is specified, then you're prompted to select the theme to pull from the list of the themes in your store.", + "description": "Retrieves theme files from Shopify.\n\nIf no theme is specified, then you're prompted to select the theme to pull from the list of the themes in your store.\n\nUse `--json-schema` to print the result, error, and event schemas.\n\nOutput from `--json` conforms to the `ThemePullResult` schema.\n\n```json\n{\n \"anyOf\": [\n {\n \"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": { @@ -12503,6 +12503,15 @@ "name": "ignore", "type": "option" }, + "json": { + "allowNo": false, + "char": "j", + "description": "Output the result as JSON. Automatically disables color output.", + "env": "SHOPIFY_FLAG_JSON", + "hidden": false, + "name": "json", + "type": "boolean" + }, "json-schema": { "allowNo": false, "description": "Print the command's JSON schemas.", diff --git a/packages/eslint-plugin-cli/rules/json-output-command-exceptions.js b/packages/eslint-plugin-cli/rules/json-output-command-exceptions.js index dad08c72b9e..82a80f99176 100644 --- a/packages/eslint-plugin-cli/rules/json-output-command-exceptions.js +++ b/packages/eslint-plugin-cli/rules/json-output-command-exceptions.js @@ -21,7 +21,6 @@ const commandExceptions = [ 'packages/theme/src/cli/commands/theme/package.ts', 'packages/theme/src/cli/commands/theme/preview.ts', 'packages/theme/src/cli/commands/theme/profile.ts', - 'packages/theme/src/cli/commands/theme/pull.ts', 'packages/theme/src/cli/commands/theme/share.ts', // App Security commands, launched before adopting typed JSON output. Remove entries as they adopt it. diff --git a/packages/theme/src/cli/commands/theme/pull.test.ts b/packages/theme/src/cli/commands/theme/pull.test.ts new file mode 100644 index 00000000000..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 4a972b058f4..d59b139176a 100644 --- a/packages/theme/src/cli/commands/theme/transfer-environments.test.ts +++ b/packages/theme/src/cli/commands/theme/transfer-environments.test.ts @@ -1,9 +1,8 @@ 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 {addPublicMetadata} from '@shopify/cli-kit/node/metadata' import {loadEnvironment} from '@shopify/cli-kit/node/environments' import {ensureAuthenticatedThemes} from '@shopify/cli-kit/node/session' import {inTemporaryDirectory} from '@shopify/cli-kit/node/fs' @@ -12,7 +11,7 @@ import {Config} from '@oclif/core' import {describe, expect, test, vi} from 'vitest' vi.mock('../../services/push.js') - +vi.mock('../../services/pull.js') vi.mock('@shopify/cli-kit/node/environments') vi.mock('@shopify/cli-kit/node/session') vi.mock('@shopify/cli-kit/node/metadata') @@ -21,9 +20,14 @@ class TestPush extends Push { public parse = vi.fn() } -describe.each([TestPush])('%s', (Command) => { - test.each(['none', 'partial', 'total', 'cancelled', 'analytics'] as const)( - 'retains all environments in request order with %s failures', +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) => ({ @@ -37,18 +41,12 @@ describe.each([TestPush])('%s', (Command) => { const secondStarted = new Promise((resolve) => { releaseFirst = resolve }) - vi.mocked(addPublicMetadata).mockImplementation(async (collect) => { - const metadata = await collect() - if (failures === 'analytics' && metadata?.store_domain === 'second.myshopify.com') - throw new Error('analytics failed') - }) const executionOrder: string[] = [] const execute = async (flags: {environment?: string[]}, session?: {storeFqdn: string}) => { const environment = flags.environment![0]! if (environment === 'first') await secondStarted if (environment === 'second') releaseFirst() executionOrder.push(environment) - if (failures === 'cancelled' && environment === 'second') return undefined if (failures === 'total' || (failures === 'partial' && environment === 'second')) throw new Error('upload failed') return { @@ -59,8 +57,8 @@ describe.each([TestPush])('%s', (Command) => { role: 'unpublished', processing: false, shop: session!.storeFqdn, - editor_url: `https://${session!.storeFqdn}/admin/themes/1/editor`, - preview_url: `https://${session!.storeFqdn}?preview_theme_id=1`, + editor_url: 'editor', + preview_url: 'preview', }, path, published: false, @@ -69,85 +67,59 @@ describe.each([TestPush])('%s', (Command) => { } } vi.mocked(executeThemePush).mockImplementation(execute) - + vi.mocked(executeThemePull).mockImplementation(execute) const command = new Command( ['--environment', 'first', '--environment', 'second', '--environment', 'third'], new Config({root: path}), ) - vi.spyOn(command, 'parse').mockResolvedValue({ - flags: {json: true, force: true, environment: ['first', 'second', 'third']}, - args: {}, - } as never) - const previousExitCode = process.exitCode - process.exitCode = 0 - try { - await withCapturedStandardStreams(async ({stdout, stderr}) => { - await runWithCommandEvents({outputMode: 'json', sink: renderCommandEventAsJson}, () => command.run()) - const {environments} = JSON.parse(stdout()) - expect(environments.map((entry: {environment: string}) => entry.environment)).toEqual([ - 'first', - 'second', - 'third', - ]) - if (failures === 'total') expect(environments.every((entry: {error?: unknown}) => entry.error)).toBe(true) - else { - expect(environments[0]).toMatchObject({result: {theme: {id: '1', storeDomain: 'first.myshopify.com'}}}) - expect(environments[2]).toHaveProperty('result') - if (failures === 'partial') expect(environments[1]).toHaveProperty('error.message', 'upload failed') - else if (failures === 'cancelled') - expect(environments[1]).toMatchObject({result: {status: 'skipped', reason: 'unsafe-directory'}}) - else expect(environments[1]).toHaveProperty('result') - } - expect(executionOrder).toEqual(['second', 'first', 'third']) - expect(process.exitCode).toBe(failures === 'total' || failures === 'partial' ? 1 : 0) - const events = stderr().trim() - ? stderr() - .trim() - .split('\n') - .filter(Boolean) - .map((line) => JSON.parse(line)) - : [] - expect(events.filter((event) => event.level === 'error')).toHaveLength( - {none: 0, partial: 1, total: 3, cancelled: 0, analytics: 0}[failures], - ) - }) - } finally { - // eslint-disable-next-line require-atomic-updates - process.exitCode = previousExitCode - } + 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], + ) + }) }) }, ) - test('retains validation errors when every environment is invalid', async () => { + test('returns an empty array when every environment is invalid', async () => { vi.mocked(loadEnvironment).mockResolvedValue({}) const command = new Command(['--environment', 'first', '--environment', 'second'], new Config({root: '.'})) vi.spyOn(command, 'parse').mockResolvedValue({ flags: {json: true, force: true, environment: ['first', 'second']}, args: {}, } as never) - const previousExitCode = process.exitCode - process.exitCode = 0 - try { - await withCapturedStandardStreams(async ({stdout}) => { - await runWithCommandEvents({outputMode: 'json', sink: renderCommandEventAsJson}, () => command.run()) - expect(JSON.parse(stdout()).environments).toEqual([ - { - environment: 'first', - error: expect.objectContaining({type: 'abort', message: expect.stringContaining('Missing flags')}), - }, - { - environment: 'second', - error: expect.objectContaining({type: 'abort', message: expect.stringContaining('Missing flags')}), - }, - ]) - expect(executeThemePush).not.toHaveBeenCalled() + await withCapturedStandardStreams(async ({stdout, stderr}) => { + await runWithCommandEvents({outputMode: 'json', sink: renderCommandEventAsJson}, () => command.run()) - expect(process.exitCode).toBe(1) - }) - } finally { - // eslint-disable-next-line require-atomic-updates - process.exitCode = previousExitCode - } + 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 From fd7481bdebbba9d6983eae0eb3ad3b550e760dac Mon Sep 17 00:00:00 2001 From: Gonzalo Riestra Date: Tue, 6 Oct 2026 14:12:56 +0200 Subject: [PATCH 02/10] Align theme pull JSON with public result conventions --- packages/cli/README.md | 342 +++++++++++++++++- packages/cli/oclif.manifest.json | 2 +- .../theme/src/cli/commands/theme/pull.test.ts | 22 +- packages/theme/src/cli/commands/theme/pull.ts | 9 +- .../src/cli/services/pull/result.test.ts | 30 +- .../theme/src/cli/services/pull/result.ts | 16 +- packages/theme/src/cli/services/pull/types.ts | 40 +- 7 files changed, 430 insertions(+), 31 deletions(-) diff --git a/packages/cli/README.md b/packages/cli/README.md index 27ac0243ea8..62344b86625 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -14574,7 +14574,7 @@ Download your remote theme files locally. ``` USAGE - $ shopify theme pull [--auth-alias ] [-d] [-e ...] [-x ...] [--json-schema] [-l] + $ shopify theme pull [--auth-alias ] [-d] [-e ...] [-x ...] [-j] [--json-schema] [-l] [--no-color] [--no-input] [-n] [-o ...] [--password ] [--path ] [-s ] [-t ] [--verbose] @@ -14588,6 +14588,10 @@ FLAGS The environment to apply to the current command. [env: SHOPIFY_FLAG_ENVIRONMENT] + -j, --json + Output the result as JSON. Automatically disables color output. + [env: SHOPIFY_FLAG_JSON] + -l, --live Pull theme files from your remote live theme. Use --development, --live, or --theme in non-interactive environments. [env: SHOPIFY_FLAG_LIVE] @@ -14649,6 +14653,342 @@ DESCRIPTION Retrieves theme files from Shopify. If no theme is specified, then you're prompted to select the theme to pull from the list of the themes in your store. + + Use `--json-schema` to print the result, error, and event schemas. + + Output from `--json` conforms to the `ThemePullResult` schema. + + ```json + { + "anyOf": [ + { + "$ref": "#/definitions/ThemeEnvironment/anyOf/0/properties/result" + }, + { + "type": "object", + "properties": { + "environments": { + "type": "array", + "items": { + "$ref": "#/definitions/ThemeEnvironment" + } + } + }, + "required": [ + "environments" + ], + "additionalProperties": false + } + ], + "title": "ThemePullResult", + "definitions": { + "ThemePullTheme": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^\\d+$", + "description": "The decimal Online Store theme ID, not a Shopify GID." + }, + "name": { + "type": "string" + }, + "role": { + "type": "string", + "description": "The upstream theme role; known values include main, unpublished, and development." + }, + "storeDomain": { + "anyOf": [ + { + "type": "string", + "pattern": "^[a-z0-9][a-z0-9-]*\\.myshopify\\.com$" + }, + { + "type": "null" + } + ] + }, + "previewUrl": { + "anyOf": [ + { + "type": "string", + "format": "uri" + }, + { + "type": "null" + } + ] + }, + "editorUrl": { + "anyOf": [ + { + "type": "string", + "format": "uri" + }, + { + "type": "null" + } + ] + }, + "processing": { + "type": "boolean" + }, + "sourceUrl": { + "anyOf": [ + { + "type": "string", + "format": "uri" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "id", + "name", + "role", + "storeDomain", + "previewUrl", + "editorUrl", + "processing", + "sourceUrl" + ], + "additionalProperties": false + }, + "ThemeEnvironment": { + "anyOf": [ + { + "type": "object", + "properties": { + "environment": { + "type": "string" + }, + "result": { + "anyOf": [ + { + "anyOf": [ + { + "type": "object", + "properties": { + "status": { + "type": "string", + "const": "success" + }, + "changed": { + "type": "boolean" + }, + "directory": { + "type": "string" + }, + "theme": { + "$ref": "#/definitions/ThemePullTheme" + } + }, + "required": [ + "status", + "changed", + "directory", + "theme" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "status": { + "type": "string", + "const": "skipped" + }, + "reason": { + "type": "string", + "const": "unsafe-directory" + } + }, + "required": [ + "status", + "reason" + ], + "additionalProperties": false + } + ] + }, + { + "type": "object", + "properties": { + "status": { + "type": "string", + "const": "cancelled" + } + }, + "required": [ + "status" + ], + "additionalProperties": false + } + ] + } + }, + "required": [ + "environment", + "result" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "environment": { + "type": "string" + }, + "error": { + "anyOf": [ + { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "abort" + }, + "message": { + "type": "string" + }, + "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": {} + }, + "required": [ + "type", + "message" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "bug" + }, + "message": { + "$ref": "#/definitions/ThemeEnvironment/anyOf/1/properties/error/anyOf/0/properties/message" + }, + "tryMessage": { + "$ref": "#/definitions/ThemeEnvironment/anyOf/1/properties/error/anyOf/0/properties/tryMessage" + }, + "nextSteps": { + "$ref": "#/definitions/ThemeEnvironment/anyOf/1/properties/error/anyOf/0/properties/nextSteps" + }, + "customSections": { + "$ref": "#/definitions/ThemeEnvironment/anyOf/1/properties/error/anyOf/0/properties/customSections" + }, + "details": { + "$ref": "#/definitions/ThemeEnvironment/anyOf/1/properties/error/anyOf/0/properties/details" + }, + "stack": { + "type": "string" + } + }, + "required": [ + "type", + "message" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "external" + }, + "message": { + "$ref": "#/definitions/ThemeEnvironment/anyOf/1/properties/error/anyOf/0/properties/message" + }, + "tryMessage": { + "$ref": "#/definitions/ThemeEnvironment/anyOf/1/properties/error/anyOf/0/properties/tryMessage" + }, + "nextSteps": { + "$ref": "#/definitions/ThemeEnvironment/anyOf/1/properties/error/anyOf/0/properties/nextSteps" + }, + "customSections": { + "$ref": "#/definitions/ThemeEnvironment/anyOf/1/properties/error/anyOf/0/properties/customSections" + }, + "details": { + "$ref": "#/definitions/ThemeEnvironment/anyOf/1/properties/error/anyOf/0/properties/details" + }, + "command": { + "type": "string" + }, + "args": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": [ + "type", + "message", + "command", + "args" + ], + "additionalProperties": false + } + ] + } + }, + "required": [ + "environment", + "error" + ], + "additionalProperties": false + } + ] + } + }, + "$schema": "http://json-schema.org/draft-07/schema#" + } + ``` ``` ## `shopify theme push` diff --git a/packages/cli/oclif.manifest.json b/packages/cli/oclif.manifest.json index bc6cc5b3c8f..0b52d436f11 100644 --- a/packages/cli/oclif.manifest.json +++ b/packages/cli/oclif.manifest.json @@ -12456,7 +12456,7 @@ "args": { }, "customPluginName": "@shopify/theme", - "description": "Retrieves theme files from Shopify.\n\nIf no theme is specified, then you're prompted to select the theme to pull from the list of the themes in your store.\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```", + "description": "Retrieves theme files from Shopify.\n\nIf no theme is specified, then you're prompted to select the theme to pull from the list of the themes in your store.\n\nUse `--json-schema` to print the result, error, and event schemas.\n\nOutput from `--json` conforms to the `ThemePullResult` schema.\n\n```json\n{\n \"anyOf\": [\n {\n \"$ref\": \"#/definitions/ThemeEnvironment/anyOf/0/properties/result\"\n },\n {\n \"type\": \"object\",\n \"properties\": {\n \"environments\": {\n \"type\": \"array\",\n \"items\": {\n \"$ref\": \"#/definitions/ThemeEnvironment\"\n }\n }\n },\n \"required\": [\n \"environments\"\n ],\n \"additionalProperties\": false\n }\n ],\n \"title\": \"ThemePullResult\",\n \"definitions\": {\n \"ThemePullTheme\": {\n \"type\": \"object\",\n \"properties\": {\n \"id\": {\n \"type\": \"string\",\n \"pattern\": \"^\\\\d+$\",\n \"description\": \"The decimal Online Store theme ID, not a Shopify GID.\"\n },\n \"name\": {\n \"type\": \"string\"\n },\n \"role\": {\n \"type\": \"string\",\n \"description\": \"The upstream theme role; known values include main, unpublished, and development.\"\n },\n \"storeDomain\": {\n \"anyOf\": [\n {\n \"type\": \"string\",\n \"pattern\": \"^[a-z0-9][a-z0-9-]*\\\\.myshopify\\\\.com$\"\n },\n {\n \"type\": \"null\"\n }\n ]\n },\n \"previewUrl\": {\n \"anyOf\": [\n {\n \"type\": \"string\",\n \"format\": \"uri\"\n },\n {\n \"type\": \"null\"\n }\n ]\n },\n \"editorUrl\": {\n \"anyOf\": [\n {\n \"type\": \"string\",\n \"format\": \"uri\"\n },\n {\n \"type\": \"null\"\n }\n ]\n },\n \"processing\": {\n \"type\": \"boolean\"\n },\n \"sourceUrl\": {\n \"anyOf\": [\n {\n \"type\": \"string\",\n \"format\": \"uri\"\n },\n {\n \"type\": \"null\"\n }\n ]\n }\n },\n \"required\": [\n \"id\",\n \"name\",\n \"role\",\n \"storeDomain\",\n \"previewUrl\",\n \"editorUrl\",\n \"processing\",\n \"sourceUrl\"\n ],\n \"additionalProperties\": false\n },\n \"ThemeEnvironment\": {\n \"anyOf\": [\n {\n \"type\": \"object\",\n \"properties\": {\n \"environment\": {\n \"type\": \"string\"\n },\n \"result\": {\n \"anyOf\": [\n {\n \"anyOf\": [\n {\n \"type\": \"object\",\n \"properties\": {\n \"status\": {\n \"type\": \"string\",\n \"const\": \"success\"\n },\n \"changed\": {\n \"type\": \"boolean\"\n },\n \"directory\": {\n \"type\": \"string\"\n },\n \"theme\": {\n \"$ref\": \"#/definitions/ThemePullTheme\"\n }\n },\n \"required\": [\n \"status\",\n \"changed\",\n \"directory\",\n \"theme\"\n ],\n \"additionalProperties\": false\n },\n {\n \"type\": \"object\",\n \"properties\": {\n \"status\": {\n \"type\": \"string\",\n \"const\": \"skipped\"\n },\n \"reason\": {\n \"type\": \"string\",\n \"const\": \"unsafe-directory\"\n }\n },\n \"required\": [\n \"status\",\n \"reason\"\n ],\n \"additionalProperties\": false\n }\n ]\n },\n {\n \"type\": \"object\",\n \"properties\": {\n \"status\": {\n \"type\": \"string\",\n \"const\": \"cancelled\"\n }\n },\n \"required\": [\n \"status\"\n ],\n \"additionalProperties\": false\n }\n ]\n }\n },\n \"required\": [\n \"environment\",\n \"result\"\n ],\n \"additionalProperties\": false\n },\n {\n \"type\": \"object\",\n \"properties\": {\n \"environment\": {\n \"type\": \"string\"\n },\n \"error\": {\n \"anyOf\": [\n {\n \"type\": \"object\",\n \"properties\": {\n \"type\": {\n \"type\": \"string\",\n \"const\": \"abort\"\n },\n \"message\": {\n \"type\": \"string\"\n },\n \"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 },\n \"required\": [\n \"type\",\n \"message\"\n ],\n \"additionalProperties\": false\n },\n {\n \"type\": \"object\",\n \"properties\": {\n \"type\": {\n \"type\": \"string\",\n \"const\": \"bug\"\n },\n \"message\": {\n \"$ref\": \"#/definitions/ThemeEnvironment/anyOf/1/properties/error/anyOf/0/properties/message\"\n },\n \"tryMessage\": {\n \"$ref\": \"#/definitions/ThemeEnvironment/anyOf/1/properties/error/anyOf/0/properties/tryMessage\"\n },\n \"nextSteps\": {\n \"$ref\": \"#/definitions/ThemeEnvironment/anyOf/1/properties/error/anyOf/0/properties/nextSteps\"\n },\n \"customSections\": {\n \"$ref\": \"#/definitions/ThemeEnvironment/anyOf/1/properties/error/anyOf/0/properties/customSections\"\n },\n \"details\": {\n \"$ref\": \"#/definitions/ThemeEnvironment/anyOf/1/properties/error/anyOf/0/properties/details\"\n },\n \"stack\": {\n \"type\": \"string\"\n }\n },\n \"required\": [\n \"type\",\n \"message\"\n ],\n \"additionalProperties\": false\n },\n {\n \"type\": \"object\",\n \"properties\": {\n \"type\": {\n \"type\": \"string\",\n \"const\": \"external\"\n },\n \"message\": {\n \"$ref\": \"#/definitions/ThemeEnvironment/anyOf/1/properties/error/anyOf/0/properties/message\"\n },\n \"tryMessage\": {\n \"$ref\": \"#/definitions/ThemeEnvironment/anyOf/1/properties/error/anyOf/0/properties/tryMessage\"\n },\n \"nextSteps\": {\n \"$ref\": \"#/definitions/ThemeEnvironment/anyOf/1/properties/error/anyOf/0/properties/nextSteps\"\n },\n \"customSections\": {\n \"$ref\": \"#/definitions/ThemeEnvironment/anyOf/1/properties/error/anyOf/0/properties/customSections\"\n },\n \"details\": {\n \"$ref\": \"#/definitions/ThemeEnvironment/anyOf/1/properties/error/anyOf/0/properties/details\"\n },\n \"command\": {\n \"type\": \"string\"\n },\n \"args\": {\n \"type\": \"array\",\n \"items\": {\n \"type\": \"string\"\n }\n }\n },\n \"required\": [\n \"type\",\n \"message\",\n \"command\",\n \"args\"\n ],\n \"additionalProperties\": false\n }\n ]\n }\n },\n \"required\": [\n \"environment\",\n \"error\"\n ],\n \"additionalProperties\": false\n }\n ]\n }\n },\n \"$schema\": \"http://json-schema.org/draft-07/schema#\"\n}\n```", "descriptionWithMarkdown": "Retrieves theme files from Shopify.\n\nIf no theme is specified, then you're prompted to select the theme to pull from the list of the themes in your store.", "enableJsonFlag": false, "flags": { diff --git a/packages/theme/src/cli/commands/theme/pull.test.ts b/packages/theme/src/cli/commands/theme/pull.test.ts index 1149b9aacf1..c3574e83df4 100644 --- a/packages/theme/src/cli/commands/theme/pull.test.ts +++ b/packages/theme/src/cli/commands/theme/pull.test.ts @@ -25,8 +25,8 @@ describe('theme pull JSON', () => { role: 'unpublished', processing: false, shop: session.storeFqdn, - editor_url: 'editor', - preview_url: 'preview', + editor_url: 'https://test.myshopify.com/admin/themes/1/editor', + preview_url: 'https://test.myshopify.com?preview_theme_id=1', }, path: '/theme', }) @@ -34,17 +34,11 @@ describe('theme pull JSON', () => { 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(JSON.parse(stdout())).toMatchObject({ + status: 'success', + changed: true, + directory: '/theme', + theme: {id: '1', storeDomain: session.storeFqdn, sourceUrl: null}, }) expect(stderr()).toBe('') }) @@ -56,7 +50,7 @@ describe('theme pull JSON', () => { await withCapturedStandardStreams(async ({stdout}) => { await command.command({json: true} as never, session, false) - expect(stdout()).toBe('') + expect(JSON.parse(stdout())).toEqual({status: 'cancelled'}) }) }) diff --git a/packages/theme/src/cli/commands/theme/pull.ts b/packages/theme/src/cli/commands/theme/pull.ts index 0253d39f497..280cfc80124 100644 --- a/packages/theme/src/cli/commands/theme/pull.ts +++ b/packages/theme/src/cli/commands/theme/pull.ts @@ -1,8 +1,10 @@ +import {ThemeEnvironmentResult} from '../../services/json-output/schema.js' import {globFlags, themeFlags} from '../../flags.js' import ThemeCommand, {RequiredFlags} from '../../utilities/theme-command.js' import {executeThemePull} from '../../services/pull.js' import {renderThemePullResult, renderThemePullEnvironmentResults} from '../../services/pull/result.js' import {themePullJsonOutputSchema} from '../../services/pull/types.js' +import {outputResult} from '@shopify/cli-kit/node/output' import {globalFlags, jsonFlag} from '@shopify/cli-kit/node/cli' import {Flags} from '@oclif/core' import {recordTiming} from '@shopify/cli-kit/node/analytics' @@ -82,7 +84,10 @@ If no theme is specified, then you're prompted to select the theme to pull from multiEnvironment, context, ) - if (result && !(flags.json && multiEnvironment)) renderThemePullResult(result, flags.json ? 'json' : 'text') + if (!(flags.json && multiEnvironment)) { + if (result) renderThemePullResult(result, flags.json ? 'json' : 'text') + else if (flags.json) outputResult(themePullJsonOutputSchema.encode({status: 'cancelled'})) + } recordTiming('theme-command:pull') return result } @@ -91,7 +96,7 @@ If no theme is specified, then you're prompted to select the theme to pull from return Boolean(flags.json) } - protected renderEnvironmentResults(results: {environment: string; result: unknown}[]): void { + protected renderEnvironmentResults(results: ThemeEnvironmentResult[]): void { renderThemePullEnvironmentResults(results) } diff --git a/packages/theme/src/cli/services/pull/result.test.ts b/packages/theme/src/cli/services/pull/result.test.ts index 00b8adf437b..48a9136f213 100644 --- a/packages/theme/src/cli/services/pull/result.test.ts +++ b/packages/theme/src/cli/services/pull/result.test.ts @@ -1,5 +1,5 @@ import {themePullJsonOutputSchema, type ThemePullResult} from './types.js' -import {renderThemePullResult} from './result.js' +import {renderThemePullResult, renderThemePullEnvironmentResults} from './result.js' import {mockAndCaptureOutput, withCapturedStandardStreams} from '@shopify/cli-kit/node/testing/output' import {describe, expect, test} from 'vitest' @@ -12,8 +12,8 @@ function result(): ThemePullResult { role: 'unpublished', processing: false, shop: 'test.myshopify.com', - editor_url: 'editor', - preview_url: 'preview', + editor_url: 'https://test.myshopify.com/admin/themes/1/editor', + preview_url: 'https://test.myshopify.com?preview_theme_id=1', }, } } @@ -21,7 +21,12 @@ function result(): ThemePullResult { 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).toMatchObject({ + status: 'success', + changed: true, + directory: '/theme', + theme: {id: '1', sourceUrl: null, storeDomain: 'test.myshopify.com'}, + }) expect(value).not.toHaveProperty('environment') expect(value.theme).not.toHaveProperty('src') expect(value.theme.processing).toBe(false) @@ -44,6 +49,23 @@ describe('pull result', () => { }) }) + test('retains explicit single environments, skips and errors', async () => { + await withCapturedStandardStreams(({stdout}) => { + renderThemePullEnvironmentResults([{environment: 'staging', result: result()}]) + expect(JSON.parse(stdout()).environments).toMatchObject([{environment: 'staging', result: {directory: '/theme'}}]) + }) + await withCapturedStandardStreams(({stdout}) => { + renderThemePullEnvironmentResults([ + {environment: 'skipped', result: undefined}, + {environment: 'failed', error: {type: 'abort', message: 'Download failed'}}, + ]) + expect(JSON.parse(stdout()).environments).toEqual([ + {environment: 'skipped', result: {status: 'skipped', reason: 'unsafe-directory'}}, + {environment: 'failed', error: {type: 'abort', message: 'Download failed'}}, + ]) + }) + }) + test('preserves the success banner and links', () => { const output = mockAndCaptureOutput() output.clear() diff --git a/packages/theme/src/cli/services/pull/result.ts b/packages/theme/src/cli/services/pull/result.ts index 2a085349cbc..08e6b05a9d3 100644 --- a/packages/theme/src/cli/services/pull/result.ts +++ b/packages/theme/src/cli/services/pull/result.ts @@ -1,4 +1,5 @@ -import {themePullJsonOutputSchema, themePullResultSchema, type ThemePullResult} from './types.js' +import {themePullJsonOutputSchema, type ThemePullResult} from './types.js' +import {ThemeEnvironmentResult} from '../json-output/schema.js' import {themeComponent} from '../../utilities/theme-ui.js' import {outputResult} from '@shopify/cli-kit/node/output' import {renderSuccess} from '@shopify/cli-kit/node/ui' @@ -19,9 +20,14 @@ export function renderThemePullResult(result: ThemePullResult, format: 'text' | }) } -export function renderThemePullEnvironmentResults(results: {environment: string; result: unknown}[]): void { - const output = results.flatMap(({environment, result}) => - result === undefined ? [] : [{...themePullResultSchema.parse(result), environment}], +export function renderThemePullEnvironmentResults(results: ThemeEnvironmentResult[]): void { + const environments = results.map((entry) => + 'error' in entry + ? entry + : { + ...entry, + result: entry.result ?? {status: 'skipped', reason: 'unsafe-directory'}, + }, ) - outputResult(themePullJsonOutputSchema.encode(output)) + outputResult(themePullJsonOutputSchema.encode({environments})) } diff --git a/packages/theme/src/cli/services/pull/types.ts b/packages/theme/src/cli/services/pull/types.ts index 658534c4290..a8e84af3141 100644 --- a/packages/theme/src/cli/services/pull/types.ts +++ b/packages/theme/src/cli/services/pull/types.ts @@ -1,4 +1,5 @@ -import {defineJsonOutputSchema} from '@shopify/cli-kit/node/json-output-schema' +import {defineThemeJsonOutputSchema, ThemeLinksSchema, projectTheme, storeDomain} from '../json-output/schema.js' +import {isAbsolutePath, resolvePath} from '@shopify/cli-kit/node/path' import {zod} from '@shopify/cli-kit/node/schema' const ThemePullThemeSchema = zod.object({ @@ -18,10 +19,41 @@ export const themePullResultSchema = zod.object({ theme: ThemePullThemeSchema, }) -export const themePullJsonOutputSchema = defineJsonOutputSchema({ +const ThemePullJsonThemeSchema = ThemeLinksSchema.extend({ + processing: zod.boolean(), + sourceUrl: zod.string().url().nullable(), +}).strict() +const ThemePullJsonResultSchema = zod + .object({ + status: zod.literal('success'), + changed: zod.boolean(), + directory: zod.string().refine(isAbsolutePath, 'Expected an absolute filesystem path.'), + theme: ThemePullJsonThemeSchema, + }) + .strict() +const SkippedSchema = zod.object({status: zod.literal('skipped'), reason: zod.literal('unsafe-directory')}).strict() + +export const themePullJsonOutputSchema = defineThemeJsonOutputSchema({ name: 'ThemePullResult', - schema: zod.union([themePullResultSchema, zod.array(themePullResultSchema.extend({environment: zod.string()}))]), - definitions: {ThemePullTheme: ThemePullThemeSchema}, + schema: zod.union([ThemePullJsonResultSchema, SkippedSchema]), + definitions: {ThemePullTheme: ThemePullJsonThemeSchema}, + project: (value) => { + if (SkippedSchema.safeParse(value).success) return value + const {theme, path} = themePullResultSchema.parse(value) + return { + status: 'success', + changed: true, + directory: resolvePath(path), + theme: { + ...projectTheme(theme), + storeDomain: storeDomain(theme.shop), + processing: theme.processing, + sourceUrl: theme.src === '' ? null : (theme.src ?? null), + editorUrl: theme.editor_url || null, + previewUrl: theme.preview_url || null, + }, + } + }, }) export type ThemePullResult = zod.infer From dc6dab408404faf580150257678a95f96c775cec Mon Sep 17 00:00:00 2001 From: Gonzalo Riestra Date: Tue, 6 Oct 2026 14:14:05 +0200 Subject: [PATCH 03/10] Retain skipped theme downloads in environment batches --- packages/theme/src/cli/commands/theme/pull.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/theme/src/cli/commands/theme/pull.ts b/packages/theme/src/cli/commands/theme/pull.ts index 280cfc80124..030e71eec4a 100644 --- a/packages/theme/src/cli/commands/theme/pull.ts +++ b/packages/theme/src/cli/commands/theme/pull.ts @@ -89,7 +89,7 @@ If no theme is specified, then you're prompted to select the theme to pull from else if (flags.json) outputResult(themePullJsonOutputSchema.encode({status: 'cancelled'})) } recordTiming('theme-command:pull') - return result + return result ?? (flags.json && multiEnvironment ? {status: 'skipped', reason: 'unsafe-directory'} : undefined) } protected collectsEnvironmentResults(flags: {json?: boolean}): boolean { From b4a9efa27f8f17dfab1fb59089c207a3dcbd4449 Mon Sep 17 00:00:00 2001 From: Gonzalo Riestra Date: Tue, 6 Oct 2026 14:15:51 +0200 Subject: [PATCH 04/10] Verify public download projection against real files --- packages/theme/src/cli/services/pull/execute.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/theme/src/cli/services/pull/execute.test.ts b/packages/theme/src/cli/services/pull/execute.test.ts index a88db7149a8..e60aa462ee9 100644 --- a/packages/theme/src/cli/services/pull/execute.test.ts +++ b/packages/theme/src/cli/services/pull/execute.test.ts @@ -46,7 +46,7 @@ describe('pull execution', () => { 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}}) + expect(JSON.parse(stdout())).toMatchObject({status: 'success', directory: path, theme: {id: '1'}}) const events = stderr() .trim() .split('\n') From 1398c36c668fa60d3dadce84fa581766842b1716 Mon Sep 17 00:00:00 2001 From: Gonzalo Riestra Date: Tue, 6 Oct 2026 14:16:42 +0200 Subject: [PATCH 05/10] Refresh theme pull JSON schema documentation --- packages/cli/README.md | 2 +- packages/cli/oclif.manifest.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/cli/README.md b/packages/cli/README.md index 62344b86625..16704f14512 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -14695,7 +14695,7 @@ DESCRIPTION }, "role": { "type": "string", - "description": "The upstream theme role; known values include main, unpublished, and development." + "description": "The upstream theme role; known values include live, unpublished, and development." }, "storeDomain": { "anyOf": [ diff --git a/packages/cli/oclif.manifest.json b/packages/cli/oclif.manifest.json index 0b52d436f11..30cc03ba21b 100644 --- a/packages/cli/oclif.manifest.json +++ b/packages/cli/oclif.manifest.json @@ -12456,7 +12456,7 @@ "args": { }, "customPluginName": "@shopify/theme", - "description": "Retrieves theme files from Shopify.\n\nIf no theme is specified, then you're prompted to select the theme to pull from the list of the themes in your store.\n\nUse `--json-schema` to print the result, error, and event schemas.\n\nOutput from `--json` conforms to the `ThemePullResult` schema.\n\n```json\n{\n \"anyOf\": [\n {\n \"$ref\": \"#/definitions/ThemeEnvironment/anyOf/0/properties/result\"\n },\n {\n \"type\": \"object\",\n \"properties\": {\n \"environments\": {\n \"type\": \"array\",\n \"items\": {\n \"$ref\": \"#/definitions/ThemeEnvironment\"\n }\n }\n },\n \"required\": [\n \"environments\"\n ],\n \"additionalProperties\": false\n }\n ],\n \"title\": \"ThemePullResult\",\n \"definitions\": {\n \"ThemePullTheme\": {\n \"type\": \"object\",\n \"properties\": {\n \"id\": {\n \"type\": \"string\",\n \"pattern\": \"^\\\\d+$\",\n \"description\": \"The decimal Online Store theme ID, not a Shopify GID.\"\n },\n \"name\": {\n \"type\": \"string\"\n },\n \"role\": {\n \"type\": \"string\",\n \"description\": \"The upstream theme role; known values include main, unpublished, and development.\"\n },\n \"storeDomain\": {\n \"anyOf\": [\n {\n \"type\": \"string\",\n \"pattern\": \"^[a-z0-9][a-z0-9-]*\\\\.myshopify\\\\.com$\"\n },\n {\n \"type\": \"null\"\n }\n ]\n },\n \"previewUrl\": {\n \"anyOf\": [\n {\n \"type\": \"string\",\n \"format\": \"uri\"\n },\n {\n \"type\": \"null\"\n }\n ]\n },\n \"editorUrl\": {\n \"anyOf\": [\n {\n \"type\": \"string\",\n \"format\": \"uri\"\n },\n {\n \"type\": \"null\"\n }\n ]\n },\n \"processing\": {\n \"type\": \"boolean\"\n },\n \"sourceUrl\": {\n \"anyOf\": [\n {\n \"type\": \"string\",\n \"format\": \"uri\"\n },\n {\n \"type\": \"null\"\n }\n ]\n }\n },\n \"required\": [\n \"id\",\n \"name\",\n \"role\",\n \"storeDomain\",\n \"previewUrl\",\n \"editorUrl\",\n \"processing\",\n \"sourceUrl\"\n ],\n \"additionalProperties\": false\n },\n \"ThemeEnvironment\": {\n \"anyOf\": [\n {\n \"type\": \"object\",\n \"properties\": {\n \"environment\": {\n \"type\": \"string\"\n },\n \"result\": {\n \"anyOf\": [\n {\n \"anyOf\": [\n {\n \"type\": \"object\",\n \"properties\": {\n \"status\": {\n \"type\": \"string\",\n \"const\": \"success\"\n },\n \"changed\": {\n \"type\": \"boolean\"\n },\n \"directory\": {\n \"type\": \"string\"\n },\n \"theme\": {\n \"$ref\": \"#/definitions/ThemePullTheme\"\n }\n },\n \"required\": [\n \"status\",\n \"changed\",\n \"directory\",\n \"theme\"\n ],\n \"additionalProperties\": false\n },\n {\n \"type\": \"object\",\n \"properties\": {\n \"status\": {\n \"type\": \"string\",\n \"const\": \"skipped\"\n },\n \"reason\": {\n \"type\": \"string\",\n \"const\": \"unsafe-directory\"\n }\n },\n \"required\": [\n \"status\",\n \"reason\"\n ],\n \"additionalProperties\": false\n }\n ]\n },\n {\n \"type\": \"object\",\n \"properties\": {\n \"status\": {\n \"type\": \"string\",\n \"const\": \"cancelled\"\n }\n },\n \"required\": [\n \"status\"\n ],\n \"additionalProperties\": false\n }\n ]\n }\n },\n \"required\": [\n \"environment\",\n \"result\"\n ],\n \"additionalProperties\": false\n },\n {\n \"type\": \"object\",\n \"properties\": {\n \"environment\": {\n \"type\": \"string\"\n },\n \"error\": {\n \"anyOf\": [\n {\n \"type\": \"object\",\n \"properties\": {\n \"type\": {\n \"type\": \"string\",\n \"const\": \"abort\"\n },\n \"message\": {\n \"type\": \"string\"\n },\n \"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 },\n \"required\": [\n \"type\",\n \"message\"\n ],\n \"additionalProperties\": false\n },\n {\n \"type\": \"object\",\n \"properties\": {\n \"type\": {\n \"type\": \"string\",\n \"const\": \"bug\"\n },\n \"message\": {\n \"$ref\": \"#/definitions/ThemeEnvironment/anyOf/1/properties/error/anyOf/0/properties/message\"\n },\n \"tryMessage\": {\n \"$ref\": \"#/definitions/ThemeEnvironment/anyOf/1/properties/error/anyOf/0/properties/tryMessage\"\n },\n \"nextSteps\": {\n \"$ref\": \"#/definitions/ThemeEnvironment/anyOf/1/properties/error/anyOf/0/properties/nextSteps\"\n },\n \"customSections\": {\n \"$ref\": \"#/definitions/ThemeEnvironment/anyOf/1/properties/error/anyOf/0/properties/customSections\"\n },\n \"details\": {\n \"$ref\": \"#/definitions/ThemeEnvironment/anyOf/1/properties/error/anyOf/0/properties/details\"\n },\n \"stack\": {\n \"type\": \"string\"\n }\n },\n \"required\": [\n \"type\",\n \"message\"\n ],\n \"additionalProperties\": false\n },\n {\n \"type\": \"object\",\n \"properties\": {\n \"type\": {\n \"type\": \"string\",\n \"const\": \"external\"\n },\n \"message\": {\n \"$ref\": \"#/definitions/ThemeEnvironment/anyOf/1/properties/error/anyOf/0/properties/message\"\n },\n \"tryMessage\": {\n \"$ref\": \"#/definitions/ThemeEnvironment/anyOf/1/properties/error/anyOf/0/properties/tryMessage\"\n },\n \"nextSteps\": {\n \"$ref\": \"#/definitions/ThemeEnvironment/anyOf/1/properties/error/anyOf/0/properties/nextSteps\"\n },\n \"customSections\": {\n \"$ref\": \"#/definitions/ThemeEnvironment/anyOf/1/properties/error/anyOf/0/properties/customSections\"\n },\n \"details\": {\n \"$ref\": \"#/definitions/ThemeEnvironment/anyOf/1/properties/error/anyOf/0/properties/details\"\n },\n \"command\": {\n \"type\": \"string\"\n },\n \"args\": {\n \"type\": \"array\",\n \"items\": {\n \"type\": \"string\"\n }\n }\n },\n \"required\": [\n \"type\",\n \"message\",\n \"command\",\n \"args\"\n ],\n \"additionalProperties\": false\n }\n ]\n }\n },\n \"required\": [\n \"environment\",\n \"error\"\n ],\n \"additionalProperties\": false\n }\n ]\n }\n },\n \"$schema\": \"http://json-schema.org/draft-07/schema#\"\n}\n```", + "description": "Retrieves theme files from Shopify.\n\nIf no theme is specified, then you're prompted to select the theme to pull from the list of the themes in your store.\n\nUse `--json-schema` to print the result, error, and event schemas.\n\nOutput from `--json` conforms to the `ThemePullResult` schema.\n\n```json\n{\n \"anyOf\": [\n {\n \"$ref\": \"#/definitions/ThemeEnvironment/anyOf/0/properties/result\"\n },\n {\n \"type\": \"object\",\n \"properties\": {\n \"environments\": {\n \"type\": \"array\",\n \"items\": {\n \"$ref\": \"#/definitions/ThemeEnvironment\"\n }\n }\n },\n \"required\": [\n \"environments\"\n ],\n \"additionalProperties\": false\n }\n ],\n \"title\": \"ThemePullResult\",\n \"definitions\": {\n \"ThemePullTheme\": {\n \"type\": \"object\",\n \"properties\": {\n \"id\": {\n \"type\": \"string\",\n \"pattern\": \"^\\\\d+$\",\n \"description\": \"The decimal Online Store theme ID, not a Shopify GID.\"\n },\n \"name\": {\n \"type\": \"string\"\n },\n \"role\": {\n \"type\": \"string\",\n \"description\": \"The upstream theme role; known values include live, unpublished, and development.\"\n },\n \"storeDomain\": {\n \"anyOf\": [\n {\n \"type\": \"string\",\n \"pattern\": \"^[a-z0-9][a-z0-9-]*\\\\.myshopify\\\\.com$\"\n },\n {\n \"type\": \"null\"\n }\n ]\n },\n \"previewUrl\": {\n \"anyOf\": [\n {\n \"type\": \"string\",\n \"format\": \"uri\"\n },\n {\n \"type\": \"null\"\n }\n ]\n },\n \"editorUrl\": {\n \"anyOf\": [\n {\n \"type\": \"string\",\n \"format\": \"uri\"\n },\n {\n \"type\": \"null\"\n }\n ]\n },\n \"processing\": {\n \"type\": \"boolean\"\n },\n \"sourceUrl\": {\n \"anyOf\": [\n {\n \"type\": \"string\",\n \"format\": \"uri\"\n },\n {\n \"type\": \"null\"\n }\n ]\n }\n },\n \"required\": [\n \"id\",\n \"name\",\n \"role\",\n \"storeDomain\",\n \"previewUrl\",\n \"editorUrl\",\n \"processing\",\n \"sourceUrl\"\n ],\n \"additionalProperties\": false\n },\n \"ThemeEnvironment\": {\n \"anyOf\": [\n {\n \"type\": \"object\",\n \"properties\": {\n \"environment\": {\n \"type\": \"string\"\n },\n \"result\": {\n \"anyOf\": [\n {\n \"anyOf\": [\n {\n \"type\": \"object\",\n \"properties\": {\n \"status\": {\n \"type\": \"string\",\n \"const\": \"success\"\n },\n \"changed\": {\n \"type\": \"boolean\"\n },\n \"directory\": {\n \"type\": \"string\"\n },\n \"theme\": {\n \"$ref\": \"#/definitions/ThemePullTheme\"\n }\n },\n \"required\": [\n \"status\",\n \"changed\",\n \"directory\",\n \"theme\"\n ],\n \"additionalProperties\": false\n },\n {\n \"type\": \"object\",\n \"properties\": {\n \"status\": {\n \"type\": \"string\",\n \"const\": \"skipped\"\n },\n \"reason\": {\n \"type\": \"string\",\n \"const\": \"unsafe-directory\"\n }\n },\n \"required\": [\n \"status\",\n \"reason\"\n ],\n \"additionalProperties\": false\n }\n ]\n },\n {\n \"type\": \"object\",\n \"properties\": {\n \"status\": {\n \"type\": \"string\",\n \"const\": \"cancelled\"\n }\n },\n \"required\": [\n \"status\"\n ],\n \"additionalProperties\": false\n }\n ]\n }\n },\n \"required\": [\n \"environment\",\n \"result\"\n ],\n \"additionalProperties\": false\n },\n {\n \"type\": \"object\",\n \"properties\": {\n \"environment\": {\n \"type\": \"string\"\n },\n \"error\": {\n \"anyOf\": [\n {\n \"type\": \"object\",\n \"properties\": {\n \"type\": {\n \"type\": \"string\",\n \"const\": \"abort\"\n },\n \"message\": {\n \"type\": \"string\"\n },\n \"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 },\n \"required\": [\n \"type\",\n \"message\"\n ],\n \"additionalProperties\": false\n },\n {\n \"type\": \"object\",\n \"properties\": {\n \"type\": {\n \"type\": \"string\",\n \"const\": \"bug\"\n },\n \"message\": {\n \"$ref\": \"#/definitions/ThemeEnvironment/anyOf/1/properties/error/anyOf/0/properties/message\"\n },\n \"tryMessage\": {\n \"$ref\": \"#/definitions/ThemeEnvironment/anyOf/1/properties/error/anyOf/0/properties/tryMessage\"\n },\n \"nextSteps\": {\n \"$ref\": \"#/definitions/ThemeEnvironment/anyOf/1/properties/error/anyOf/0/properties/nextSteps\"\n },\n \"customSections\": {\n \"$ref\": \"#/definitions/ThemeEnvironment/anyOf/1/properties/error/anyOf/0/properties/customSections\"\n },\n \"details\": {\n \"$ref\": \"#/definitions/ThemeEnvironment/anyOf/1/properties/error/anyOf/0/properties/details\"\n },\n \"stack\": {\n \"type\": \"string\"\n }\n },\n \"required\": [\n \"type\",\n \"message\"\n ],\n \"additionalProperties\": false\n },\n {\n \"type\": \"object\",\n \"properties\": {\n \"type\": {\n \"type\": \"string\",\n \"const\": \"external\"\n },\n \"message\": {\n \"$ref\": \"#/definitions/ThemeEnvironment/anyOf/1/properties/error/anyOf/0/properties/message\"\n },\n \"tryMessage\": {\n \"$ref\": \"#/definitions/ThemeEnvironment/anyOf/1/properties/error/anyOf/0/properties/tryMessage\"\n },\n \"nextSteps\": {\n \"$ref\": \"#/definitions/ThemeEnvironment/anyOf/1/properties/error/anyOf/0/properties/nextSteps\"\n },\n \"customSections\": {\n \"$ref\": \"#/definitions/ThemeEnvironment/anyOf/1/properties/error/anyOf/0/properties/customSections\"\n },\n \"details\": {\n \"$ref\": \"#/definitions/ThemeEnvironment/anyOf/1/properties/error/anyOf/0/properties/details\"\n },\n \"command\": {\n \"type\": \"string\"\n },\n \"args\": {\n \"type\": \"array\",\n \"items\": {\n \"type\": \"string\"\n }\n }\n },\n \"required\": [\n \"type\",\n \"message\",\n \"command\",\n \"args\"\n ],\n \"additionalProperties\": false\n }\n ]\n }\n },\n \"required\": [\n \"environment\",\n \"error\"\n ],\n \"additionalProperties\": false\n }\n ]\n }\n },\n \"$schema\": \"http://json-schema.org/draft-07/schema#\"\n}\n```", "descriptionWithMarkdown": "Retrieves theme files from Shopify.\n\nIf no theme is specified, then you're prompted to select the theme to pull from the list of the themes in your store.", "enableJsonFlag": false, "flags": { From d7ddb84e11c0b798fa3872394093c0a46e44321c Mon Sep 17 00:00:00 2001 From: Gonzalo Riestra Date: Tue, 6 Oct 2026 14:17:13 +0200 Subject: [PATCH 06/10] Refresh generated theme pull command docs --- .../generated/generated_docs_data_v2.json | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/docs-shopify.dev/generated/generated_docs_data_v2.json b/docs-shopify.dev/generated/generated_docs_data_v2.json index 67cb65e55e3..d07beac2a71 100644 --- a/docs-shopify.dev/generated/generated_docs_data_v2.json +++ b/docs-shopify.dev/generated/generated_docs_data_v2.json @@ -10201,6 +10201,15 @@ "isOptional": true, "environmentValue": "SHOPIFY_FLAG_ENVIRONMENT" }, + { + "filePath": "docs-shopify.dev/commands/interfaces/theme-pull.interface.ts", + "syntaxKind": "PropertySignature", + "name": "-j, --json", + "value": "''", + "description": "Output the result as JSON. Automatically disables color output.", + "isOptional": true, + "environmentValue": "SHOPIFY_FLAG_JSON" + }, { "filePath": "docs-shopify.dev/commands/interfaces/theme-pull.interface.ts", "syntaxKind": "PropertySignature", @@ -10256,7 +10265,7 @@ "environmentValue": "SHOPIFY_FLAG_IGNORE" } ], - "value": "export interface themepull {\n /**\n * Alias of the Shopify account to use for authentication.\n * @environment SHOPIFY_FLAG_AUTH_ALIAS\n */\n '--auth-alias '?: string\n\n /**\n * Pull theme files from your remote development theme. Use --development, --live, or --theme in non-interactive environments.\n * @environment SHOPIFY_FLAG_DEVELOPMENT\n */\n '-d, --development'?: ''\n\n /**\n * The environment to apply to the current command.\n * @environment SHOPIFY_FLAG_ENVIRONMENT\n */\n '-e, --environment '?: string\n\n /**\n * Skip downloading the specified files (Multiple flags allowed). Wrap the value in double quotes if you're using wildcards.\n * @environment SHOPIFY_FLAG_IGNORE\n */\n '-x, --ignore '?: string\n\n /**\n * Print the command's JSON schemas.\n * @environment SHOPIFY_FLAG_JSON_SCHEMA\n */\n '--json-schema'?: ''\n\n /**\n * Pull theme files from your remote live theme. Use --development, --live, or --theme in non-interactive environments.\n * @environment SHOPIFY_FLAG_LIVE\n */\n '-l, --live'?: ''\n\n /**\n * Disable color output.\n * @environment SHOPIFY_FLAG_NO_COLOR\n */\n '--no-color'?: ''\n\n /**\n * Disable interactive prompts and browser authentication.\n * @environment SHOPIFY_FLAG_NO_INPUT\n */\n '--no-input'?: ''\n\n /**\n * Prevent deleting local files that don't exist remotely.\n * @environment SHOPIFY_FLAG_NODELETE\n */\n '-n, --nodelete'?: ''\n\n /**\n * Download only the specified files (Multiple flags allowed). Wrap the value in double quotes if you're using wildcards.\n * @environment SHOPIFY_FLAG_ONLY\n */\n '-o, --only '?: string\n\n /**\n * Password generated from the Theme Access app or an Admin API token.\n * @environment SHOPIFY_CLI_THEME_TOKEN\n */\n '--password '?: string\n\n /**\n * The path where you want to run the command. Defaults to the current working directory.\n * @environment SHOPIFY_FLAG_PATH\n */\n '--path '?: string\n\n /**\n * Store URL. It can be the store prefix (example) or the full myshopify.com URL (example.myshopify.com, https://example.myshopify.com).\n * @environment SHOPIFY_FLAG_STORE\n */\n '-s, --store '?: string\n\n /**\n * Theme ID or name of the remote theme. Use --development, --live, or --theme in non-interactive environments.\n * @environment SHOPIFY_FLAG_THEME_ID\n */\n '-t, --theme '?: string\n\n /**\n * Increase the verbosity of the output. May include sensitive data.\n * @environment SHOPIFY_FLAG_VERBOSE\n */\n '--verbose'?: ''\n}" + "value": "export interface themepull {\n /**\n * Alias of the Shopify account to use for authentication.\n * @environment SHOPIFY_FLAG_AUTH_ALIAS\n */\n '--auth-alias '?: string\n\n /**\n * Pull theme files from your remote development theme. Use --development, --live, or --theme in non-interactive environments.\n * @environment SHOPIFY_FLAG_DEVELOPMENT\n */\n '-d, --development'?: ''\n\n /**\n * The environment to apply to the current command.\n * @environment SHOPIFY_FLAG_ENVIRONMENT\n */\n '-e, --environment '?: string\n\n /**\n * Skip downloading the specified files (Multiple flags allowed). Wrap the value in double quotes if you're using wildcards.\n * @environment SHOPIFY_FLAG_IGNORE\n */\n '-x, --ignore '?: string\n\n /**\n * Output the result as JSON. Automatically disables color output.\n * @environment SHOPIFY_FLAG_JSON\n */\n '-j, --json'?: ''\n\n /**\n * Print the command's JSON schemas.\n * @environment SHOPIFY_FLAG_JSON_SCHEMA\n */\n '--json-schema'?: ''\n\n /**\n * Pull theme files from your remote live theme. Use --development, --live, or --theme in non-interactive environments.\n * @environment SHOPIFY_FLAG_LIVE\n */\n '-l, --live'?: ''\n\n /**\n * Disable color output.\n * @environment SHOPIFY_FLAG_NO_COLOR\n */\n '--no-color'?: ''\n\n /**\n * Disable interactive prompts and browser authentication.\n * @environment SHOPIFY_FLAG_NO_INPUT\n */\n '--no-input'?: ''\n\n /**\n * Prevent deleting local files that don't exist remotely.\n * @environment SHOPIFY_FLAG_NODELETE\n */\n '-n, --nodelete'?: ''\n\n /**\n * Download only the specified files (Multiple flags allowed). Wrap the value in double quotes if you're using wildcards.\n * @environment SHOPIFY_FLAG_ONLY\n */\n '-o, --only '?: string\n\n /**\n * Password generated from the Theme Access app or an Admin API token.\n * @environment SHOPIFY_CLI_THEME_TOKEN\n */\n '--password '?: string\n\n /**\n * The path where you want to run the command. Defaults to the current working directory.\n * @environment SHOPIFY_FLAG_PATH\n */\n '--path '?: string\n\n /**\n * Store URL. It can be the store prefix (example) or the full myshopify.com URL (example.myshopify.com, https://example.myshopify.com).\n * @environment SHOPIFY_FLAG_STORE\n */\n '-s, --store '?: string\n\n /**\n * Theme ID or name of the remote theme. Use --development, --live, or --theme in non-interactive environments.\n * @environment SHOPIFY_FLAG_THEME_ID\n */\n '-t, --theme '?: string\n\n /**\n * Increase the verbosity of the output. May include sensitive data.\n * @environment SHOPIFY_FLAG_VERBOSE\n */\n '--verbose'?: ''\n}" } }, "themepush": { From d80c1b928fb49a7dd11453632db0a0f73694ea41 Mon Sep 17 00:00:00 2001 From: Gonzalo Riestra Date: Tue, 6 Oct 2026 14:22:38 +0200 Subject: [PATCH 07/10] Report unchanged theme download outcomes --- packages/theme/src/cli/services/pull.ts | 3 ++- packages/theme/src/cli/services/pull/execute.test.ts | 2 ++ packages/theme/src/cli/services/pull/types.ts | 5 +++-- packages/theme/src/cli/utilities/theme-downloader.ts | 1 + 4 files changed, 8 insertions(+), 3 deletions(-) diff --git a/packages/theme/src/cli/services/pull.ts b/packages/theme/src/cli/services/pull.ts index 0649b7dba59..2d2b18cbfce 100644 --- a/packages/theme/src/cli/services/pull.ts +++ b/packages/theme/src/cli/services/pull.ts @@ -167,11 +167,12 @@ async function executePull( const themeChecksums = rejectGeneratedStaticAssets(remoteChecksums) recordTiming('theme-service:pull:file-system') - await downloadTheme(theme, session, themeChecksums, themeFileSystem, options, context) + const changed = await downloadTheme(theme, session, themeChecksums, themeFileSystem, options, context) return themePullResultSchema.parse({ environment: options.environment, path: options.path, + changed, theme: { id: theme.id, name: theme.name, diff --git a/packages/theme/src/cli/services/pull/execute.test.ts b/packages/theme/src/cli/services/pull/execute.test.ts index e60aa462ee9..fa1a49d3f3b 100644 --- a/packages/theme/src/cli/services/pull/execute.test.ts +++ b/packages/theme/src/cli/services/pull/execute.test.ts @@ -31,6 +31,7 @@ describe('pull execution', () => { const result = await executeThemePull({path, force: true, nodelete}, session) expect(result).toEqual({ path, + changed: true, theme: { id: 1, name: 'Theme', @@ -72,6 +73,7 @@ describe('pull execution', () => { executeThemePull({path, force: true}, session), ) expect(result?.theme).toMatchObject({role: 'live', src: 'https://example.com/theme.zip'}) + expect(result?.changed).toBe(false) expect(fetchThemeAssets).not.toHaveBeenCalled() }) }) diff --git a/packages/theme/src/cli/services/pull/types.ts b/packages/theme/src/cli/services/pull/types.ts index a8e84af3141..9e2a31a0b70 100644 --- a/packages/theme/src/cli/services/pull/types.ts +++ b/packages/theme/src/cli/services/pull/types.ts @@ -16,6 +16,7 @@ const ThemePullThemeSchema = zod.object({ export const themePullResultSchema = zod.object({ environment: zod.string().optional(), path: zod.string(), + changed: zod.boolean().optional(), theme: ThemePullThemeSchema, }) @@ -39,10 +40,10 @@ export const themePullJsonOutputSchema = defineThemeJsonOutputSchema({ definitions: {ThemePullTheme: ThemePullJsonThemeSchema}, project: (value) => { if (SkippedSchema.safeParse(value).success) return value - const {theme, path} = themePullResultSchema.parse(value) + const {theme, path, changed} = themePullResultSchema.parse(value) return { status: 'success', - changed: true, + changed: changed ?? true, directory: resolvePath(path), theme: { ...projectTheme(theme), diff --git a/packages/theme/src/cli/utilities/theme-downloader.ts b/packages/theme/src/cli/utilities/theme-downloader.ts index a32dd8effe0..66d75893595 100644 --- a/packages/theme/src/cli/utilities/theme-downloader.ts +++ b/packages/theme/src/cli/utilities/theme-downloader.ts @@ -31,6 +31,7 @@ export async function downloadTheme( noProgressBar: options.multiEnvironment, }) } + return deleteTasks.length > 0 || downloadTasks.length > 0 } function buildDeleteTasks(remoteChecksums: Checksum[], themeFileSystem: ThemeFileSystem, options: DownloadOptions) { From 47aa7a298314c61cfb6efb346faef210f4d46320 Mon Sep 17 00:00:00 2001 From: Gonzalo Riestra Date: Tue, 6 Oct 2026 14:27:29 +0200 Subject: [PATCH 08/10] Verify complete theme pull environment outcomes --- .../theme/transfer-environments.test.ts | 124 +++++++++++------- 1 file changed, 78 insertions(+), 46 deletions(-) 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 d59b139176a..672275279e3 100644 --- a/packages/theme/src/cli/commands/theme/transfer-environments.test.ts +++ b/packages/theme/src/cli/commands/theme/transfer-environments.test.ts @@ -1,8 +1,10 @@ import Push from './push.js' import Pull from './pull.js' -import {executeThemePull} from '../../services/pull.js' + import {executeThemePush} from '../../services/push.js' +import {executeThemePull} from '../../services/pull.js' import {withCapturedStandardStreams} from '@shopify/cli-kit/node/testing/output' +import {addPublicMetadata} from '@shopify/cli-kit/node/metadata' import {loadEnvironment} from '@shopify/cli-kit/node/environments' import {ensureAuthenticatedThemes} from '@shopify/cli-kit/node/session' import {inTemporaryDirectory} from '@shopify/cli-kit/node/fs' @@ -19,15 +21,13 @@ vi.mock('@shopify/cli-kit/node/metadata') class TestPush extends Push { public parse = vi.fn() } - class TestPull extends Pull { public parse = vi.fn() } describe.each([TestPush, 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', + test.each(['none', 'partial', 'total', 'cancelled', 'analytics'] as const)( + 'retains all environments in request order with %s failures', async (failures) => { await inTemporaryDirectory(async (path) => { vi.mocked(loadEnvironment).mockImplementation(async (name) => ({ @@ -41,12 +41,18 @@ describe.each([TestPush, TestPull])('%s', (Command) => { const secondStarted = new Promise((resolve) => { releaseFirst = resolve }) + vi.mocked(addPublicMetadata).mockImplementation(async (collect) => { + const metadata = await collect() + if (failures === 'analytics' && metadata?.store_domain === 'second.myshopify.com') + throw new Error('analytics failed') + }) const executionOrder: string[] = [] const execute = async (flags: {environment?: string[]}, session?: {storeFqdn: string}) => { const environment = flags.environment![0]! if (environment === 'first') await secondStarted if (environment === 'second') releaseFirst() executionOrder.push(environment) + if (failures === 'cancelled' && environment === 'second') return undefined if (failures === 'total' || (failures === 'partial' && environment === 'second')) throw new Error('upload failed') return { @@ -57,8 +63,8 @@ describe.each([TestPush, TestPull])('%s', (Command) => { role: 'unpublished', processing: false, shop: session!.storeFqdn, - editor_url: 'editor', - preview_url: 'preview', + editor_url: `https://${session!.storeFqdn}/admin/themes/1/editor`, + preview_url: `https://${session!.storeFqdn}?preview_theme_id=1`, }, path, published: false, @@ -72,54 +78,80 @@ describe.each([TestPush, TestPull])('%s', (Command) => { ['--environment', 'first', '--environment', 'second', '--environment', 'third'], 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], - ) - }) + vi.spyOn(command, 'parse').mockResolvedValue({ + flags: {json: true, force: true, environment: ['first', 'second', 'third']}, + args: {}, + } as never) + const previousExitCode = process.exitCode + process.exitCode = 0 + try { + await withCapturedStandardStreams(async ({stdout, stderr}) => { + await runWithCommandEvents({outputMode: 'json', sink: renderCommandEventAsJson}, () => command.run()) + const {environments} = JSON.parse(stdout()) + expect(environments.map((entry: {environment: string}) => entry.environment)).toEqual([ + 'first', + 'second', + 'third', + ]) + if (failures === 'total') expect(environments.every((entry: {error?: unknown}) => entry.error)).toBe(true) + else { + expect(environments[0]).toMatchObject({result: {theme: {id: '1', storeDomain: 'first.myshopify.com'}}}) + expect(environments[2]).toHaveProperty('result') + if (failures === 'partial') expect(environments[1]).toHaveProperty('error.message', 'upload failed') + else if (failures === 'cancelled') + expect(environments[1]).toMatchObject({result: {status: 'skipped', reason: 'unsafe-directory'}}) + else expect(environments[1]).toHaveProperty('result') + } + expect(executionOrder).toEqual(['second', 'first', 'third']) + expect(process.exitCode).toBe(failures === 'total' || failures === 'partial' ? 1 : 0) + const events = stderr().trim() + ? stderr() + .trim() + .split('\n') + .filter(Boolean) + .map((line) => JSON.parse(line)) + : [] + expect(events.filter((event) => event.level === 'error')).toHaveLength( + {none: 0, partial: 1, total: 3, cancelled: 0, analytics: 0}[failures], + ) + }) + } finally { + // eslint-disable-next-line require-atomic-updates + process.exitCode = previousExitCode + } }) }, ) - test('returns an empty array when every environment is invalid', async () => { + test('retains validation errors when every environment is invalid', async () => { vi.mocked(loadEnvironment).mockResolvedValue({}) const command = new Command(['--environment', 'first', '--environment', 'second'], new Config({root: '.'})) vi.spyOn(command, 'parse').mockResolvedValue({ flags: {json: true, force: true, environment: ['first', 'second']}, args: {}, } as never) - 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'}), - ]) - }) + const previousExitCode = process.exitCode + process.exitCode = 0 + try { + await withCapturedStandardStreams(async ({stdout}) => { + await runWithCommandEvents({outputMode: 'json', sink: renderCommandEventAsJson}, () => command.run()) + expect(JSON.parse(stdout()).environments).toEqual([ + { + environment: 'first', + error: expect.objectContaining({type: 'abort', message: expect.stringContaining('Missing flags')}), + }, + { + environment: 'second', + error: expect.objectContaining({type: 'abort', message: expect.stringContaining('Missing flags')}), + }, + ]) + expect(executeThemePush).not.toHaveBeenCalled() + expect(executeThemePull).not.toHaveBeenCalled() + expect(process.exitCode).toBe(1) + }) + } finally { + // eslint-disable-next-line require-atomic-updates + process.exitCode = previousExitCode + } }) }) From 08339ae97933c9e3ef9db08789e8ca418bbb4156 Mon Sep 17 00:00:00 2001 From: Gonzalo Riestra Date: Tue, 6 Oct 2026 15:55:30 +0200 Subject: [PATCH 09/10] Emit native absolute paths at JSON output boundaries --- packages/theme/src/cli/commands/theme/pull.test.ts | 5 ++++- packages/theme/src/cli/services/pull/execute.test.ts | 5 ++++- packages/theme/src/cli/services/pull/result.test.ts | 9 +++++++-- packages/theme/src/cli/services/pull/types.ts | 5 ++++- 4 files changed, 19 insertions(+), 5 deletions(-) diff --git a/packages/theme/src/cli/commands/theme/pull.test.ts b/packages/theme/src/cli/commands/theme/pull.test.ts index c3574e83df4..d09fe1855ad 100644 --- a/packages/theme/src/cli/commands/theme/pull.test.ts +++ b/packages/theme/src/cli/commands/theme/pull.test.ts @@ -5,6 +5,9 @@ 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' +// Native JSON paths must preserve Windows separators instead of pathe normalization. +// eslint-disable-next-line no-restricted-imports +import {resolve as nativePath} from 'node:path' vi.mock('../../services/pull.js') const session = {storeFqdn: 'test.myshopify.com', token: 'token'} @@ -37,7 +40,7 @@ describe('theme pull JSON', () => { expect(JSON.parse(stdout())).toMatchObject({ status: 'success', changed: true, - directory: '/theme', + directory: nativePath('/theme'), theme: {id: '1', storeDomain: session.storeFqdn, sourceUrl: null}, }) expect(stderr()).toBe('') diff --git a/packages/theme/src/cli/services/pull/execute.test.ts b/packages/theme/src/cli/services/pull/execute.test.ts index fa1a49d3f3b..f73bae003e2 100644 --- a/packages/theme/src/cli/services/pull/execute.test.ts +++ b/packages/theme/src/cli/services/pull/execute.test.ts @@ -9,6 +9,9 @@ import {fetchChecksums, fetchThemeAssets} from '@shopify/cli-kit/node/themes/api import {buildTheme} from '@shopify/cli-kit/node/themes/factories' import {runWithCommandEvents, renderCommandEventAsJson} from '@shopify/cli-kit/node/command-events' import {describe, expect, test, vi} from 'vitest' +// Native JSON paths must preserve Windows separators instead of pathe normalization. +// eslint-disable-next-line no-restricted-imports +import {resolve as nativePath} from 'node:path' vi.mock('../../utilities/theme-selector.js') vi.mock('@shopify/cli-kit/node/themes/api') @@ -47,7 +50,7 @@ describe('pull execution', () => { await expect(readFile(joinPath(path, 'assets/theme.css'))).resolves.toBe('body {}') await expect(fileExists(joinPath(path, 'assets/old.css'))).resolves.toBe(nodelete) - expect(JSON.parse(stdout())).toMatchObject({status: 'success', directory: path, theme: {id: '1'}}) + expect(JSON.parse(stdout())).toMatchObject({status: 'success', directory: nativePath(path), theme: {id: '1'}}) const events = stderr() .trim() .split('\n') diff --git a/packages/theme/src/cli/services/pull/result.test.ts b/packages/theme/src/cli/services/pull/result.test.ts index 48a9136f213..2e2188094d7 100644 --- a/packages/theme/src/cli/services/pull/result.test.ts +++ b/packages/theme/src/cli/services/pull/result.test.ts @@ -2,6 +2,9 @@ import {themePullJsonOutputSchema, type ThemePullResult} from './types.js' import {renderThemePullResult, renderThemePullEnvironmentResults} from './result.js' import {mockAndCaptureOutput, withCapturedStandardStreams} from '@shopify/cli-kit/node/testing/output' import {describe, expect, test} from 'vitest' +// Native JSON paths must preserve Windows separators instead of pathe normalization. +// eslint-disable-next-line no-restricted-imports +import {resolve as nativePath} from 'node:path' function result(): ThemePullResult { return { @@ -24,7 +27,7 @@ describe('pull result', () => { expect(value).toMatchObject({ status: 'success', changed: true, - directory: '/theme', + directory: nativePath('/theme'), theme: {id: '1', sourceUrl: null, storeDomain: 'test.myshopify.com'}, }) expect(value).not.toHaveProperty('environment') @@ -52,7 +55,9 @@ describe('pull result', () => { test('retains explicit single environments, skips and errors', async () => { await withCapturedStandardStreams(({stdout}) => { renderThemePullEnvironmentResults([{environment: 'staging', result: result()}]) - expect(JSON.parse(stdout()).environments).toMatchObject([{environment: 'staging', result: {directory: '/theme'}}]) + expect(JSON.parse(stdout()).environments).toMatchObject([ + {environment: 'staging', result: {directory: nativePath('/theme')}}, + ]) }) await withCapturedStandardStreams(({stdout}) => { renderThemePullEnvironmentResults([ diff --git a/packages/theme/src/cli/services/pull/types.ts b/packages/theme/src/cli/services/pull/types.ts index 9e2a31a0b70..d223e40b900 100644 --- a/packages/theme/src/cli/services/pull/types.ts +++ b/packages/theme/src/cli/services/pull/types.ts @@ -1,6 +1,9 @@ import {defineThemeJsonOutputSchema, ThemeLinksSchema, projectTheme, storeDomain} from '../json-output/schema.js' -import {isAbsolutePath, resolvePath} from '@shopify/cli-kit/node/path' +import {isAbsolutePath} from '@shopify/cli-kit/node/path' import {zod} from '@shopify/cli-kit/node/schema' +// Native JSON paths must preserve Windows separators instead of pathe normalization. +// eslint-disable-next-line no-restricted-imports +import {resolve as resolvePath} from 'node:path' const ThemePullThemeSchema = zod.object({ id: zod.number(), From a538b6d55f6d03a71fd371975fa19802bdc77dd1 Mon Sep 17 00:00:00 2001 From: Gonzalo Riestra Date: Thu, 8 Oct 2026 10:37:44 +0200 Subject: [PATCH 10/10] Refresh JSON schema documentation after rebase --- packages/cli/README.md | 15 ++++- packages/cli/oclif.manifest.json | 2 +- .../theme/src/cli/commands/theme/pull.test.ts | 55 +++++++++++++++++++ packages/theme/src/cli/commands/theme/pull.ts | 4 -- 4 files changed, 70 insertions(+), 6 deletions(-) diff --git a/packages/cli/README.md b/packages/cli/README.md index 16704f14512..8d252136202 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -14854,6 +14854,11 @@ DESCRIPTION "message": { "type": "string" }, + "code": { + "type": "string", + "minLength": 1, + "description": "A stable error code, included only when known." + }, "tryMessage": { "type": "string" }, @@ -14894,7 +14899,9 @@ DESCRIPTION "additionalProperties": false } }, - "details": {} + "details": { + "description": "Selected domain details, preserving native API payloads such as GraphQL errors, extensions, and data." + } }, "required": [ "type", @@ -14912,6 +14919,9 @@ DESCRIPTION "message": { "$ref": "#/definitions/ThemeEnvironment/anyOf/1/properties/error/anyOf/0/properties/message" }, + "code": { + "$ref": "#/definitions/ThemeEnvironment/anyOf/1/properties/error/anyOf/0/properties/code" + }, "tryMessage": { "$ref": "#/definitions/ThemeEnvironment/anyOf/1/properties/error/anyOf/0/properties/tryMessage" }, @@ -14944,6 +14954,9 @@ DESCRIPTION "message": { "$ref": "#/definitions/ThemeEnvironment/anyOf/1/properties/error/anyOf/0/properties/message" }, + "code": { + "$ref": "#/definitions/ThemeEnvironment/anyOf/1/properties/error/anyOf/0/properties/code" + }, "tryMessage": { "$ref": "#/definitions/ThemeEnvironment/anyOf/1/properties/error/anyOf/0/properties/tryMessage" }, diff --git a/packages/cli/oclif.manifest.json b/packages/cli/oclif.manifest.json index 30cc03ba21b..a33f0addfed 100644 --- a/packages/cli/oclif.manifest.json +++ b/packages/cli/oclif.manifest.json @@ -12456,7 +12456,7 @@ "args": { }, "customPluginName": "@shopify/theme", - "description": "Retrieves theme files from Shopify.\n\nIf no theme is specified, then you're prompted to select the theme to pull from the list of the themes in your store.\n\nUse `--json-schema` to print the result, error, and event schemas.\n\nOutput from `--json` conforms to the `ThemePullResult` schema.\n\n```json\n{\n \"anyOf\": [\n {\n \"$ref\": \"#/definitions/ThemeEnvironment/anyOf/0/properties/result\"\n },\n {\n \"type\": \"object\",\n \"properties\": {\n \"environments\": {\n \"type\": \"array\",\n \"items\": {\n \"$ref\": \"#/definitions/ThemeEnvironment\"\n }\n }\n },\n \"required\": [\n \"environments\"\n ],\n \"additionalProperties\": false\n }\n ],\n \"title\": \"ThemePullResult\",\n \"definitions\": {\n \"ThemePullTheme\": {\n \"type\": \"object\",\n \"properties\": {\n \"id\": {\n \"type\": \"string\",\n \"pattern\": \"^\\\\d+$\",\n \"description\": \"The decimal Online Store theme ID, not a Shopify GID.\"\n },\n \"name\": {\n \"type\": \"string\"\n },\n \"role\": {\n \"type\": \"string\",\n \"description\": \"The upstream theme role; known values include live, unpublished, and development.\"\n },\n \"storeDomain\": {\n \"anyOf\": [\n {\n \"type\": \"string\",\n \"pattern\": \"^[a-z0-9][a-z0-9-]*\\\\.myshopify\\\\.com$\"\n },\n {\n \"type\": \"null\"\n }\n ]\n },\n \"previewUrl\": {\n \"anyOf\": [\n {\n \"type\": \"string\",\n \"format\": \"uri\"\n },\n {\n \"type\": \"null\"\n }\n ]\n },\n \"editorUrl\": {\n \"anyOf\": [\n {\n \"type\": \"string\",\n \"format\": \"uri\"\n },\n {\n \"type\": \"null\"\n }\n ]\n },\n \"processing\": {\n \"type\": \"boolean\"\n },\n \"sourceUrl\": {\n \"anyOf\": [\n {\n \"type\": \"string\",\n \"format\": \"uri\"\n },\n {\n \"type\": \"null\"\n }\n ]\n }\n },\n \"required\": [\n \"id\",\n \"name\",\n \"role\",\n \"storeDomain\",\n \"previewUrl\",\n \"editorUrl\",\n \"processing\",\n \"sourceUrl\"\n ],\n \"additionalProperties\": false\n },\n \"ThemeEnvironment\": {\n \"anyOf\": [\n {\n \"type\": \"object\",\n \"properties\": {\n \"environment\": {\n \"type\": \"string\"\n },\n \"result\": {\n \"anyOf\": [\n {\n \"anyOf\": [\n {\n \"type\": \"object\",\n \"properties\": {\n \"status\": {\n \"type\": \"string\",\n \"const\": \"success\"\n },\n \"changed\": {\n \"type\": \"boolean\"\n },\n \"directory\": {\n \"type\": \"string\"\n },\n \"theme\": {\n \"$ref\": \"#/definitions/ThemePullTheme\"\n }\n },\n \"required\": [\n \"status\",\n \"changed\",\n \"directory\",\n \"theme\"\n ],\n \"additionalProperties\": false\n },\n {\n \"type\": \"object\",\n \"properties\": {\n \"status\": {\n \"type\": \"string\",\n \"const\": \"skipped\"\n },\n \"reason\": {\n \"type\": \"string\",\n \"const\": \"unsafe-directory\"\n }\n },\n \"required\": [\n \"status\",\n \"reason\"\n ],\n \"additionalProperties\": false\n }\n ]\n },\n {\n \"type\": \"object\",\n \"properties\": {\n \"status\": {\n \"type\": \"string\",\n \"const\": \"cancelled\"\n }\n },\n \"required\": [\n \"status\"\n ],\n \"additionalProperties\": false\n }\n ]\n }\n },\n \"required\": [\n \"environment\",\n \"result\"\n ],\n \"additionalProperties\": false\n },\n {\n \"type\": \"object\",\n \"properties\": {\n \"environment\": {\n \"type\": \"string\"\n },\n \"error\": {\n \"anyOf\": [\n {\n \"type\": \"object\",\n \"properties\": {\n \"type\": {\n \"type\": \"string\",\n \"const\": \"abort\"\n },\n \"message\": {\n \"type\": \"string\"\n },\n \"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 },\n \"required\": [\n \"type\",\n \"message\"\n ],\n \"additionalProperties\": false\n },\n {\n \"type\": \"object\",\n \"properties\": {\n \"type\": {\n \"type\": \"string\",\n \"const\": \"bug\"\n },\n \"message\": {\n \"$ref\": \"#/definitions/ThemeEnvironment/anyOf/1/properties/error/anyOf/0/properties/message\"\n },\n \"tryMessage\": {\n \"$ref\": \"#/definitions/ThemeEnvironment/anyOf/1/properties/error/anyOf/0/properties/tryMessage\"\n },\n \"nextSteps\": {\n \"$ref\": \"#/definitions/ThemeEnvironment/anyOf/1/properties/error/anyOf/0/properties/nextSteps\"\n },\n \"customSections\": {\n \"$ref\": \"#/definitions/ThemeEnvironment/anyOf/1/properties/error/anyOf/0/properties/customSections\"\n },\n \"details\": {\n \"$ref\": \"#/definitions/ThemeEnvironment/anyOf/1/properties/error/anyOf/0/properties/details\"\n },\n \"stack\": {\n \"type\": \"string\"\n }\n },\n \"required\": [\n \"type\",\n \"message\"\n ],\n \"additionalProperties\": false\n },\n {\n \"type\": \"object\",\n \"properties\": {\n \"type\": {\n \"type\": \"string\",\n \"const\": \"external\"\n },\n \"message\": {\n \"$ref\": \"#/definitions/ThemeEnvironment/anyOf/1/properties/error/anyOf/0/properties/message\"\n },\n \"tryMessage\": {\n \"$ref\": \"#/definitions/ThemeEnvironment/anyOf/1/properties/error/anyOf/0/properties/tryMessage\"\n },\n \"nextSteps\": {\n \"$ref\": \"#/definitions/ThemeEnvironment/anyOf/1/properties/error/anyOf/0/properties/nextSteps\"\n },\n \"customSections\": {\n \"$ref\": \"#/definitions/ThemeEnvironment/anyOf/1/properties/error/anyOf/0/properties/customSections\"\n },\n \"details\": {\n \"$ref\": \"#/definitions/ThemeEnvironment/anyOf/1/properties/error/anyOf/0/properties/details\"\n },\n \"command\": {\n \"type\": \"string\"\n },\n \"args\": {\n \"type\": \"array\",\n \"items\": {\n \"type\": \"string\"\n }\n }\n },\n \"required\": [\n \"type\",\n \"message\",\n \"command\",\n \"args\"\n ],\n \"additionalProperties\": false\n }\n ]\n }\n },\n \"required\": [\n \"environment\",\n \"error\"\n ],\n \"additionalProperties\": false\n }\n ]\n }\n },\n \"$schema\": \"http://json-schema.org/draft-07/schema#\"\n}\n```", + "description": "Retrieves theme files from Shopify.\n\nIf no theme is specified, then you're prompted to select the theme to pull from the list of the themes in your store.\n\nUse `--json-schema` to print the result, error, and event schemas.\n\nOutput from `--json` conforms to the `ThemePullResult` schema.\n\n```json\n{\n \"anyOf\": [\n {\n \"$ref\": \"#/definitions/ThemeEnvironment/anyOf/0/properties/result\"\n },\n {\n \"type\": \"object\",\n \"properties\": {\n \"environments\": {\n \"type\": \"array\",\n \"items\": {\n \"$ref\": \"#/definitions/ThemeEnvironment\"\n }\n }\n },\n \"required\": [\n \"environments\"\n ],\n \"additionalProperties\": false\n }\n ],\n \"title\": \"ThemePullResult\",\n \"definitions\": {\n \"ThemePullTheme\": {\n \"type\": \"object\",\n \"properties\": {\n \"id\": {\n \"type\": \"string\",\n \"pattern\": \"^\\\\d+$\",\n \"description\": \"The decimal Online Store theme ID, not a Shopify GID.\"\n },\n \"name\": {\n \"type\": \"string\"\n },\n \"role\": {\n \"type\": \"string\",\n \"description\": \"The upstream theme role; known values include live, unpublished, and development.\"\n },\n \"storeDomain\": {\n \"anyOf\": [\n {\n \"type\": \"string\",\n \"pattern\": \"^[a-z0-9][a-z0-9-]*\\\\.myshopify\\\\.com$\"\n },\n {\n \"type\": \"null\"\n }\n ]\n },\n \"previewUrl\": {\n \"anyOf\": [\n {\n \"type\": \"string\",\n \"format\": \"uri\"\n },\n {\n \"type\": \"null\"\n }\n ]\n },\n \"editorUrl\": {\n \"anyOf\": [\n {\n \"type\": \"string\",\n \"format\": \"uri\"\n },\n {\n \"type\": \"null\"\n }\n ]\n },\n \"processing\": {\n \"type\": \"boolean\"\n },\n \"sourceUrl\": {\n \"anyOf\": [\n {\n \"type\": \"string\",\n \"format\": \"uri\"\n },\n {\n \"type\": \"null\"\n }\n ]\n }\n },\n \"required\": [\n \"id\",\n \"name\",\n \"role\",\n \"storeDomain\",\n \"previewUrl\",\n \"editorUrl\",\n \"processing\",\n \"sourceUrl\"\n ],\n \"additionalProperties\": false\n },\n \"ThemeEnvironment\": {\n \"anyOf\": [\n {\n \"type\": \"object\",\n \"properties\": {\n \"environment\": {\n \"type\": \"string\"\n },\n \"result\": {\n \"anyOf\": [\n {\n \"anyOf\": [\n {\n \"type\": \"object\",\n \"properties\": {\n \"status\": {\n \"type\": \"string\",\n \"const\": \"success\"\n },\n \"changed\": {\n \"type\": \"boolean\"\n },\n \"directory\": {\n \"type\": \"string\"\n },\n \"theme\": {\n \"$ref\": \"#/definitions/ThemePullTheme\"\n }\n },\n \"required\": [\n \"status\",\n \"changed\",\n \"directory\",\n \"theme\"\n ],\n \"additionalProperties\": false\n },\n {\n \"type\": \"object\",\n \"properties\": {\n \"status\": {\n \"type\": \"string\",\n \"const\": \"skipped\"\n },\n \"reason\": {\n \"type\": \"string\",\n \"const\": \"unsafe-directory\"\n }\n },\n \"required\": [\n \"status\",\n \"reason\"\n ],\n \"additionalProperties\": false\n }\n ]\n },\n {\n \"type\": \"object\",\n \"properties\": {\n \"status\": {\n \"type\": \"string\",\n \"const\": \"cancelled\"\n }\n },\n \"required\": [\n \"status\"\n ],\n \"additionalProperties\": false\n }\n ]\n }\n },\n \"required\": [\n \"environment\",\n \"result\"\n ],\n \"additionalProperties\": false\n },\n {\n \"type\": \"object\",\n \"properties\": {\n \"environment\": {\n \"type\": \"string\"\n },\n \"error\": {\n \"anyOf\": [\n {\n \"type\": \"object\",\n \"properties\": {\n \"type\": {\n \"type\": \"string\",\n \"const\": \"abort\"\n },\n \"message\": {\n \"type\": \"string\"\n },\n \"code\": {\n \"type\": \"string\",\n \"minLength\": 1,\n \"description\": \"A stable error code, included only when known.\"\n },\n \"tryMessage\": {\n \"type\": \"string\"\n },\n \"nextSteps\": {\n \"type\": \"array\",\n \"items\": {\n \"type\": \"string\"\n }\n },\n \"customSections\": {\n \"type\": \"array\",\n \"items\": {\n \"type\": \"object\",\n \"properties\": {\n \"title\": {\n \"type\": \"string\"\n },\n \"body\": {\n \"anyOf\": [\n {\n \"type\": \"string\"\n },\n {\n \"type\": \"array\",\n \"items\": {\n \"type\": \"array\",\n \"items\": {\n \"type\": \"string\"\n }\n }\n }\n ]\n }\n },\n \"required\": [\n \"body\"\n ],\n \"additionalProperties\": false\n }\n },\n \"details\": {\n \"description\": \"Selected domain details, preserving native API payloads such as GraphQL errors, extensions, and data.\"\n }\n },\n \"required\": [\n \"type\",\n \"message\"\n ],\n \"additionalProperties\": false\n },\n {\n \"type\": \"object\",\n \"properties\": {\n \"type\": {\n \"type\": \"string\",\n \"const\": \"bug\"\n },\n \"message\": {\n \"$ref\": \"#/definitions/ThemeEnvironment/anyOf/1/properties/error/anyOf/0/properties/message\"\n },\n \"code\": {\n \"$ref\": \"#/definitions/ThemeEnvironment/anyOf/1/properties/error/anyOf/0/properties/code\"\n },\n \"tryMessage\": {\n \"$ref\": \"#/definitions/ThemeEnvironment/anyOf/1/properties/error/anyOf/0/properties/tryMessage\"\n },\n \"nextSteps\": {\n \"$ref\": \"#/definitions/ThemeEnvironment/anyOf/1/properties/error/anyOf/0/properties/nextSteps\"\n },\n \"customSections\": {\n \"$ref\": \"#/definitions/ThemeEnvironment/anyOf/1/properties/error/anyOf/0/properties/customSections\"\n },\n \"details\": {\n \"$ref\": \"#/definitions/ThemeEnvironment/anyOf/1/properties/error/anyOf/0/properties/details\"\n },\n \"stack\": {\n \"type\": \"string\"\n }\n },\n \"required\": [\n \"type\",\n \"message\"\n ],\n \"additionalProperties\": false\n },\n {\n \"type\": \"object\",\n \"properties\": {\n \"type\": {\n \"type\": \"string\",\n \"const\": \"external\"\n },\n \"message\": {\n \"$ref\": \"#/definitions/ThemeEnvironment/anyOf/1/properties/error/anyOf/0/properties/message\"\n },\n \"code\": {\n \"$ref\": \"#/definitions/ThemeEnvironment/anyOf/1/properties/error/anyOf/0/properties/code\"\n },\n \"tryMessage\": {\n \"$ref\": \"#/definitions/ThemeEnvironment/anyOf/1/properties/error/anyOf/0/properties/tryMessage\"\n },\n \"nextSteps\": {\n \"$ref\": \"#/definitions/ThemeEnvironment/anyOf/1/properties/error/anyOf/0/properties/nextSteps\"\n },\n \"customSections\": {\n \"$ref\": \"#/definitions/ThemeEnvironment/anyOf/1/properties/error/anyOf/0/properties/customSections\"\n },\n \"details\": {\n \"$ref\": \"#/definitions/ThemeEnvironment/anyOf/1/properties/error/anyOf/0/properties/details\"\n },\n \"command\": {\n \"type\": \"string\"\n },\n \"args\": {\n \"type\": \"array\",\n \"items\": {\n \"type\": \"string\"\n }\n }\n },\n \"required\": [\n \"type\",\n \"message\",\n \"command\",\n \"args\"\n ],\n \"additionalProperties\": false\n }\n ]\n }\n },\n \"required\": [\n \"environment\",\n \"error\"\n ],\n \"additionalProperties\": false\n }\n ]\n }\n },\n \"$schema\": \"http://json-schema.org/draft-07/schema#\"\n}\n```", "descriptionWithMarkdown": "Retrieves theme files from Shopify.\n\nIf no theme is specified, then you're prompted to select the theme to pull from the list of the themes in your store.", "enableJsonFlag": false, "flags": { diff --git a/packages/theme/src/cli/commands/theme/pull.test.ts b/packages/theme/src/cli/commands/theme/pull.test.ts index d09fe1855ad..ba982a8357a 100644 --- a/packages/theme/src/cli/commands/theme/pull.test.ts +++ b/packages/theme/src/cli/commands/theme/pull.test.ts @@ -5,14 +5,69 @@ import {withCapturedStandardStreams} from '@shopify/cli-kit/node/testing/output' import {describe, expect, test, vi} from 'vitest' import {Config} from '@oclif/core' import {AbortError} from '@shopify/cli-kit/node/error' +import {ensureAuthenticatedThemes} from '@shopify/cli-kit/node/session' +import {inTemporaryDirectory, writeFile} from '@shopify/cli-kit/node/fs' +import {joinPath} from '@shopify/cli-kit/node/path' +import * as pathUtilities from '@shopify/cli-kit/node/path' // Native JSON paths must preserve Windows separators instead of pathe normalization. // eslint-disable-next-line no-restricted-imports import {resolve as nativePath} from 'node:path' vi.mock('../../services/pull.js') +vi.mock('@shopify/cli-kit/node/session') const session = {storeFqdn: 'test.myshopify.com', token: 'token'} describe('theme pull JSON', () => { + test('uses the current directory for one named environment without a configured path', async () => { + const previousExitCode = process.exitCode + try { + await inTemporaryDirectory(async (directory) => { + await writeFile( + joinPath(directory, 'shopify.theme.toml'), + '[environments.staging]\nstore = "test.myshopify.com"\npassword = "token"\ntheme = "1"\n', + ) + const cwdSpy = vi.spyOn(pathUtilities, 'cwd').mockReturnValue(directory) + try { + vi.mocked(ensureAuthenticatedThemes).mockResolvedValue(session) + vi.mocked(executeThemePull).mockResolvedValue({ + theme: { + id: 1, + name: 'Theme', + role: 'unpublished', + processing: false, + shop: session.storeFqdn, + editor_url: 'https://test.myshopify.com/admin/themes/1/editor', + preview_url: 'https://test.myshopify.com?preview_theme_id=1', + }, + path: directory, + }) + const config = new Config({root: __dirname}) + await config.load() + const command = new Pull(['--json', '--force', '-e', 'staging'], config) + + await withCapturedStandardStreams(async ({stdout}) => { + await command.run() + + expect(JSON.parse(stdout())).toMatchObject({ + environments: [{environment: 'staging', result: {directory: nativePath(directory), status: 'success'}}], + }) + }) + expect(executeThemePull).toHaveBeenCalledWith( + expect.objectContaining({path: directory, theme: '1'}), + session, + true, + expect.anything(), + ) + } finally { + cwdSpy.mockRestore() + } + }) + } finally { + // eslint-disable-next-line require-atomic-updates + process.exitCode = previousExitCode + } + }) + test('exposes the schema in help and retains JSON and inherited flags', () => { expect(Pull.jsonOutputSchema).toBe(themePullJsonOutputSchema) expect(Pull.description).toContain('ThemePullResult') diff --git a/packages/theme/src/cli/commands/theme/pull.ts b/packages/theme/src/cli/commands/theme/pull.ts index 030e71eec4a..c4ca5dd67a6 100644 --- a/packages/theme/src/cli/commands/theme/pull.ts +++ b/packages/theme/src/cli/commands/theme/pull.ts @@ -92,10 +92,6 @@ If no theme is specified, then you're prompted to select the theme to pull from return result ?? (flags.json && multiEnvironment ? {status: 'skipped', reason: 'unsafe-directory'} : undefined) } - protected collectsEnvironmentResults(flags: {json?: boolean}): boolean { - return Boolean(flags.json) - } - protected renderEnvironmentResults(results: ThemeEnvironmentResult[]): void { renderThemePullEnvironmentResults(results) }