From caa728cbb95d5478ba2af6b8850c0a57382cc8f3 Mon Sep 17 00:00:00 2001 From: Gonzalo Riestra Date: Thu, 24 Sep 2026 11:23:20 +0200 Subject: [PATCH 1/6] Add typed JSON output to theme preview --- packages/cli/oclif.manifest.json | 4 +- .../rules/json-output-command-exceptions.js | 1 - .../src/cli/commands/theme/preview.test.ts | 89 +++++++++++--- .../theme/src/cli/commands/theme/preview.ts | 20 +++- .../src/cli/services/dev-override.test.ts | 113 +++--------------- .../theme/src/cli/services/dev-override.ts | 35 +----- .../cli/services/dev-override/codec.test.ts | 25 ++++ .../src/cli/services/dev-override/codec.ts | 6 + .../cli/services/dev-override/result.test.ts | 66 ++++++++++ .../src/cli/services/dev-override/result.ts | 33 +++++ .../src/cli/services/dev-override/types.ts | 12 ++ 11 files changed, 259 insertions(+), 145 deletions(-) create mode 100644 packages/theme/src/cli/services/dev-override/codec.test.ts create mode 100644 packages/theme/src/cli/services/dev-override/codec.ts create mode 100644 packages/theme/src/cli/services/dev-override/result.test.ts create mode 100644 packages/theme/src/cli/services/dev-override/result.ts create mode 100644 packages/theme/src/cli/services/dev-override/types.ts diff --git a/packages/cli/oclif.manifest.json b/packages/cli/oclif.manifest.json index 5c9f58d93c5..3533ec63cc2 100644 --- a/packages/cli/oclif.manifest.json +++ b/packages/cli/oclif.manifest.json @@ -12115,7 +12115,7 @@ "args": { }, "customPluginName": "@shopify/theme", - "description": "Applies a JSON overrides file to a theme and creates or updates a preview. This lets you quickly preview changes.\n\n The command returns a preview URL and a preview identifier. You can reuse the preview identifier with `--preview-id` to update an existing preview instead of creating a new one.", + "description": "Applies a JSON overrides file to a theme and creates or updates a preview. This lets you quickly preview changes.\n\n The command returns a preview URL and a preview identifier. You can reuse the preview identifier with `--preview-id` to update an existing preview instead of creating a new one.\n\nUse `--json-schema` to print the result, error, and event schemas.\n\nOutput from `--json` conforms to the `ThemePreviewResult` schema.\n\n```json\n{\n \"type\": \"object\",\n \"properties\": {\n \"url\": {\n \"type\": \"string\"\n },\n \"preview_identifier\": {\n \"type\": \"string\"\n }\n },\n \"required\": [\n \"url\",\n \"preview_identifier\"\n ],\n \"additionalProperties\": false,\n \"title\": \"ThemePreviewResult\",\n \"$schema\": \"http://json-schema.org/draft-07/schema#\"\n}\n```", "descriptionWithMarkdown": "Applies a JSON overrides file to a theme and creates or updates a preview. This lets you quickly preview changes.\n\n The command returns a preview URL and a preview identifier. You can reuse the preview identifier with `--preview-id` to update an existing preview instead of creating a new one.", "enableJsonFlag": false, "flags": { @@ -12138,8 +12138,10 @@ }, "json": { "allowNo": false, + "char": "j", "description": "Output the preview URL and identifier as JSON.", "env": "SHOPIFY_FLAG_JSON", + "hidden": false, "name": "json", "type": "boolean" }, 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 82c9f33b6b3..4105010e169 100644 --- a/packages/eslint-plugin-cli/rules/json-output-command-exceptions.js +++ b/packages/eslint-plugin-cli/rules/json-output-command-exceptions.js @@ -9,7 +9,6 @@ const commandExceptions = [ 'packages/theme/src/cli/commands/theme/init.ts', 'packages/theme/src/cli/commands/theme/metafields/pull.ts', 'packages/theme/src/cli/commands/theme/package.ts', - 'packages/theme/src/cli/commands/theme/preview.ts', 'packages/theme/src/cli/commands/theme/profile.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/preview.test.ts b/packages/theme/src/cli/commands/theme/preview.test.ts index c0076dcef95..64438afc51c 100644 --- a/packages/theme/src/cli/commands/theme/preview.test.ts +++ b/packages/theme/src/cli/commands/theme/preview.test.ts @@ -1,13 +1,23 @@ import Preview from './preview.js' +import {themePreviewJsonOutputSchema} from '../../services/dev-override/types.js' import {devWithOverrideFile} from '../../services/dev-override.js' import {findOrSelectTheme} from '../../utilities/theme-selector.js' import {ensureThemeStore} from '../../utilities/theme-store.js' +import {openURL} from '@shopify/cli-kit/node/system' +import {runWithCommandEventsForCommand} from '@shopify/cli-kit/node/command-events' +import {renderSuccess} from '@shopify/cli-kit/node/ui' import {buildTheme} from '@shopify/cli-kit/node/themes/factories' import {recordEvent} from '@shopify/cli-kit/node/analytics' import {ensureAuthenticatedThemes} from '@shopify/cli-kit/node/session' +import {withCapturedStandardStreams} from '@shopify/cli-kit/node/testing/output' import {Config} from '@oclif/core' import {describe, vi, expect, test, beforeEach} from 'vitest' +vi.mock('@shopify/cli-kit/node/ui') +vi.mock('@shopify/cli-kit/node/system', async (importOriginal) => ({ + ...(await importOriginal()), + openURL: vi.fn(), +})) vi.mock('@shopify/cli-kit/node/session') vi.mock('@shopify/cli-kit/node/analytics', () => ({ recordEvent: vi.fn(), @@ -24,6 +34,8 @@ vi.mock('../../utilities/theme-store.js') const CommandConfig = new Config({root: __dirname}) +const result = {url: 'https://abc123.shopifypreview.com', preview_identifier: 'abc123'} + const adminSession = {token: 'test-token', storeFqdn: 'test-store.myshopify.com'} const namedTheme = buildTheme({id: 2, name: 'My Theme', role: 'unpublished'})! @@ -38,7 +50,7 @@ describe('Preview', () => { vi.mocked(ensureThemeStore).mockReturnValue('test-store.myshopify.com') vi.mocked(ensureAuthenticatedThemes).mockResolvedValue(adminSession) vi.mocked(findOrSelectTheme).mockResolvedValue(namedTheme) - vi.mocked(devWithOverrideFile).mockResolvedValue(undefined) + vi.mocked(devWithOverrideFile).mockResolvedValue(result) }) test('calls devWithOverrideFile with minimum options passed into the command', async () => { @@ -52,7 +64,6 @@ describe('Preview', () => { adminSession, overrideJson: '/path/to/overrides.json', themeId: expectedTheme.id.toString(), - open: false, }), ) }) @@ -71,16 +82,18 @@ describe('Preview', () => { ) }) - test('passes --open to devWithOverrideFile when provided', async () => { + test('opens the resulting preview when requested', async () => { + vi.mocked(openURL).mockResolvedValue(true) const expectedTheme = buildTheme({id: 5, name: 'Expected Theme', role: 'unpublished'})! vi.mocked(findOrSelectTheme).mockResolvedValue(expectedTheme) await run(['--overrides=/path/to/overrides.json', `--theme=${expectedTheme.id}`, '--open']) + expect(openURL).toHaveBeenCalledWith(result.url) + expect(devWithOverrideFile).toHaveBeenCalledWith( expect.objectContaining({ themeId: expectedTheme.id.toString(), - open: true, }), ) }) @@ -108,17 +121,65 @@ describe('Preview', () => { ) }) - test('passes --json to devWithOverrideFile when provided', async () => { - const expectedTheme = buildTheme({id: 5, name: 'Expected Theme', role: 'unpublished'})! - vi.mocked(findOrSelectTheme).mockResolvedValue(expectedTheme) + test('writes the JSON result to stdout through the real presenter and writer', async () => { + await withCapturedStandardStreams(async ({stdout, stderr}) => { + await runWithCommandEventsForCommand(['--json'], () => + run(['--overrides=/path/to/overrides.json', '--theme=2', '--json']), + ) + + expect(JSON.parse(stdout())).toEqual(result) + expect(stderr()).toBe('') + }) + expect(renderSuccess).not.toHaveBeenCalled() + expect(openURL).not.toHaveBeenCalled() + expect(devWithOverrideFile).toHaveBeenCalledWith({ + adminSession, + overrideJson: '/path/to/overrides.json', + themeId: '2', + previewIdentifier: undefined, + password: undefined, + }) + }) - await run(['--overrides=/path/to/overrides.json', `--theme=${expectedTheme.id}`, '--json']) + test('exposes its result schema in help', () => { + expect(Preview.jsonOutputSchema).toBe(themePreviewJsonOutputSchema) + expect(Preview.description).toContain('ThemePreviewResult') + expect(Preview.description).toContain('preview_identifier') + expect(Preview.flags.json.env).toBe('SHOPIFY_FLAG_JSON') + }) - expect(devWithOverrideFile).toHaveBeenCalledWith( - expect.objectContaining({ - themeId: expectedTheme.id.toString(), - json: true, - }), - ) + test('propagates failures without printing a success result or opening a browser', async () => { + const error = new Error('Failed to parse override file') + vi.mocked(devWithOverrideFile).mockRejectedValue(error) + + await withCapturedStandardStreams(async ({stdout, stderr}) => { + await expect(run(['--overrides=/path/to/overrides.json', '--theme=2', '--json', '--open'])).rejects.toBe(error) + + expect(stdout()).toBe('') + expect(stderr()).toBe('') + }) + + expect(renderSuccess).not.toHaveBeenCalled() + expect(openURL).not.toHaveBeenCalled() + }) + + test('keeps browser failures nonfatal and sends a typed warning to stderr', async () => { + const error = new Error('Browser unavailable') + vi.mocked(openURL).mockRejectedValue(error) + + await withCapturedStandardStreams(async ({stdout, stderr}) => { + await runWithCommandEventsForCommand(['--json'], () => + run(['--overrides=/path/to/overrides.json', '--theme=2', '--json', '--open']), + ) + + const events = stderr() + .trim() + .split('\n') + .map((line) => JSON.parse(line)) + expect(events).toMatchObject([ + {type: 'diagnostic', level: 'warning', message: `Failed to open theme preview.\n${error.stack}`}, + ]) + expect(JSON.parse(stdout())).toEqual(result) + }) }) }) diff --git a/packages/theme/src/cli/commands/theme/preview.ts b/packages/theme/src/cli/commands/theme/preview.ts index 66b2fea0061..9b9c1865f31 100644 --- a/packages/theme/src/cli/commands/theme/preview.ts +++ b/packages/theme/src/cli/commands/theme/preview.ts @@ -1,15 +1,22 @@ import {themeFlags} from '../../flags.js' import ThemeCommand, {RequiredFlags} from '../../utilities/theme-command.js' import {devWithOverrideFile} from '../../services/dev-override.js' +import {renderThemePreviewResult, renderThemePreviewOpenError} from '../../services/dev-override/result.js' +import {themePreviewJsonOutputSchema} from '../../services/dev-override/types.js' import {findOrSelectTheme} from '../../utilities/theme-selector.js' import {Flags} from '@oclif/core' -import {globalFlags} from '@shopify/cli-kit/node/cli' +import {globalFlags, jsonFlag} from '@shopify/cli-kit/node/cli' +import {openURL} from '@shopify/cli-kit/node/system' import {AdminSession} from '@shopify/cli-kit/node/session' import {InferredFlags} from '@oclif/core/interfaces' type PreviewFlags = InferredFlags export default class Preview extends ThemeCommand { + static get jsonOutputSchema() { + return themePreviewJsonOutputSchema + } + static summary = 'Applies JSON overrides to a theme and returns a preview URL.' static descriptionWithMarkdown = `Applies a JSON overrides file to a theme and creates or updates a preview. This lets you quickly preview changes. @@ -20,6 +27,7 @@ export default class Preview extends ThemeCommand { static flags = { ...globalFlags, + ...jsonFlag, ...themeFlags, theme: Flags.string({ char: 't', @@ -42,6 +50,7 @@ export default class Preview extends ThemeCommand { default: false, }), json: Flags.boolean({ + ...jsonFlag.json, description: 'Output the preview URL and identifier as JSON.', env: 'SHOPIFY_FLAG_JSON', default: false, @@ -52,14 +61,17 @@ export default class Preview extends ThemeCommand { async command(flags: PreviewFlags, adminSession: AdminSession) { const theme = await findOrSelectTheme(adminSession, {filter: {theme: flags.theme}}) - await devWithOverrideFile({ + const result = await devWithOverrideFile({ adminSession, overrideJson: flags.overrides, themeId: theme.id.toString(), previewIdentifier: flags['preview-id'], - open: flags.open, password: flags.password, - json: flags.json, }) + const format = flags.json ? 'json' : 'text' + renderThemePreviewResult(result, format, Boolean(flags['preview-id'])) + if (flags.open) { + openURL(result.url).catch((error: Error) => renderThemePreviewOpenError(error, format)) + } } } diff --git a/packages/theme/src/cli/services/dev-override.test.ts b/packages/theme/src/cli/services/dev-override.test.ts index 093c91335cc..334f7af112a 100644 --- a/packages/theme/src/cli/services/dev-override.test.ts +++ b/packages/theme/src/cli/services/dev-override.test.ts @@ -1,16 +1,13 @@ import {devWithOverrideFile} from './dev-override.js' -import {openURLSafely} from './dev.js' import {fetchDevServerSession} from '../utilities/theme-environment/dev-server-session.js' import {createThemePreview, updateThemePreview} from '../utilities/theme-previews/preview.js' import {describe, expect, test, vi} from 'vitest' import {renderSuccess} from '@shopify/cli-kit/node/ui' -import {collectedLogs, clearCollectedLogs} from '@shopify/cli-kit/node/output' import {inTemporaryDirectory, writeFile} from '@shopify/cli-kit/node/fs' import {joinPath} from '@shopify/cli-kit/node/path' vi.mock('../utilities/theme-environment/dev-server-session.js') vi.mock('../utilities/theme-previews/preview.js') -vi.mock('./dev.js', () => ({openURLSafely: vi.fn()})) vi.mock('@shopify/cli-kit/node/ui') const adminSession = {token: 'token', storeFqdn: 'store.myshopify.com'} @@ -30,7 +27,7 @@ describe('devWithOverrideFile', () => { const overrideJson = joinPath(tmpDir, 'missing.json') // When/Then - await expect(devWithOverrideFile({adminSession, overrideJson, themeId: '123', open: false})).rejects.toThrow( + await expect(devWithOverrideFile({adminSession, overrideJson, themeId: '123'})).rejects.toThrow( `Override file not found: ${overrideJson}`, ) }) @@ -46,7 +43,7 @@ describe('devWithOverrideFile', () => { const expectedThemeId = '789' // When - await devWithOverrideFile({adminSession, overrideJson, themeId: expectedThemeId, open: false}) + const result = await devWithOverrideFile({adminSession, overrideJson, themeId: expectedThemeId}) // Then expect(fetchDevServerSession).toHaveBeenCalledWith(expectedThemeId, adminSession, undefined) @@ -56,21 +53,12 @@ describe('devWithOverrideFile', () => { expect.objectContaining({ session: mockSession, themeId: expectedThemeId, + overridesContent: JSON.stringify({templates: {}}), }), ) expect(updateThemePreview).not.toHaveBeenCalled() - expect(renderSuccess).toHaveBeenCalledWith( - expect.objectContaining({ - body: [ - { - list: { - title: 'Preview is ready', - items: [{link: {url: expectedPreviewUrl}}, `Preview ID: ${expectedPreviewId}`], - }, - }, - ], - }), - ) + expect(result).toEqual({url: expectedPreviewUrl, preview_identifier: expectedPreviewId}) + expect(renderSuccess).not.toHaveBeenCalled() }) }) @@ -84,13 +72,11 @@ describe('devWithOverrideFile', () => { const expectedThemeId = '789' // When - await devWithOverrideFile({ + const result = await devWithOverrideFile({ adminSession, overrideJson, themeId: expectedThemeId, previewIdentifier: expectedPreviewId, - open: false, - json: false, }) // Then @@ -98,22 +84,13 @@ describe('devWithOverrideFile', () => { expect.objectContaining({ session: mockSession, themeId: expectedThemeId, + overridesContent: JSON.stringify({templates: {}}), previewIdentifier: expectedPreviewId, }), ) expect(createThemePreview).not.toHaveBeenCalled() - expect(renderSuccess).toHaveBeenCalledWith( - expect.objectContaining({ - body: [ - { - list: { - title: 'Preview updated', - items: [{link: {url: expectedPreviewUrl}}, `Preview ID: ${expectedPreviewId}`], - }, - }, - ], - }), - ) + expect(result).toEqual({url: expectedPreviewUrl, preview_identifier: expectedPreviewId}) + expect(renderSuccess).not.toHaveBeenCalled() }) }) @@ -128,46 +105,12 @@ describe('devWithOverrideFile', () => { adminSession, overrideJson, themeId: '123', - open: false, - json: false, }).catch((err) => err) expect(error.message).toBe(`Failed to parse override file: ${overrideJson}`) expect(error.tryMessage).toMatch(/not valid json/i) }) }) - test('opens the preview URL when open is true', async () => { - await inTemporaryDirectory(async (tmpDir) => { - // Given - const overrideJson = joinPath(tmpDir, 'overrides.json') - await writeFile(overrideJson, JSON.stringify({templates: {}})) - vi.mocked(fetchDevServerSession).mockResolvedValue(mockSession) - vi.mocked(createThemePreview).mockResolvedValue({url: expectedPreviewUrl, preview_identifier: expectedPreviewId}) - - // When - await devWithOverrideFile({adminSession, overrideJson, themeId: '789', open: true}) - - // Then - expect(openURLSafely).toHaveBeenCalledWith(expectedPreviewUrl, 'theme preview') - }) - }) - - test('does not open the preview URL when open is false', async () => { - await inTemporaryDirectory(async (tmpDir) => { - // Given - const overrideJson = joinPath(tmpDir, 'overrides.json') - await writeFile(overrideJson, JSON.stringify({templates: {}})) - vi.mocked(fetchDevServerSession).mockResolvedValue(mockSession) - vi.mocked(createThemePreview).mockResolvedValue({url: expectedPreviewUrl, preview_identifier: expectedPreviewId}) - - // When - await devWithOverrideFile({adminSession, overrideJson, themeId: '789', open: false}) - - // Then - expect(openURLSafely).not.toHaveBeenCalled() - }) - }) - test('passes password to fetchDevServerSession when provided', async () => { await inTemporaryDirectory(async (tmpDir) => { // Given @@ -181,8 +124,6 @@ describe('devWithOverrideFile', () => { adminSession, overrideJson, themeId: '789', - open: false, - json: false, password: 'shptka_abc123', }) @@ -191,39 +132,19 @@ describe('devWithOverrideFile', () => { }) }) - test('outputs JSON when json flag is true', async () => { + test.each([undefined, 'existing-preview'])('propagates API failures for preview %s', async (previewIdentifier) => { await inTemporaryDirectory(async (tmpDir) => { - // Given const overrideJson = joinPath(tmpDir, 'overrides.json') - await writeFile(overrideJson, JSON.stringify({templates: {}})) + await writeFile(overrideJson, '{}') vi.mocked(fetchDevServerSession).mockResolvedValue(mockSession) - vi.mocked(createThemePreview).mockResolvedValue({url: expectedPreviewUrl, preview_identifier: expectedPreviewId}) - clearCollectedLogs() + const error = new Error('Theme preview request failed') + vi.mocked(createThemePreview).mockRejectedValue(error) + vi.mocked(updateThemePreview).mockRejectedValue(error) - // When - await devWithOverrideFile({adminSession, overrideJson, themeId: '789', open: false, json: true}) - - // Then - const expectedJson = JSON.stringify({url: expectedPreviewUrl, preview_identifier: expectedPreviewId}) - expect(collectedLogs.info).toContainEqual(expectedJson) + await expect(devWithOverrideFile({adminSession, overrideJson, themeId: '123', previewIdentifier})).rejects.toBe( + error, + ) expect(renderSuccess).not.toHaveBeenCalled() }) }) - - test('renders success body by default when json flag is omitted', async () => { - await inTemporaryDirectory(async (tmpDir) => { - // Given - const overrideJson = joinPath(tmpDir, 'overrides.json') - await writeFile(overrideJson, JSON.stringify({templates: {}})) - vi.mocked(fetchDevServerSession).mockResolvedValue(mockSession) - vi.mocked(createThemePreview).mockResolvedValue({url: expectedPreviewUrl, preview_identifier: expectedPreviewId}) - clearCollectedLogs() - - // When - await devWithOverrideFile({adminSession, overrideJson, themeId: '789', open: false}) - - // Then - expect(renderSuccess).toHaveBeenCalled() - }) - }) }) diff --git a/packages/theme/src/cli/services/dev-override.ts b/packages/theme/src/cli/services/dev-override.ts index be29c90c226..d199ad7e98d 100644 --- a/packages/theme/src/cli/services/dev-override.ts +++ b/packages/theme/src/cli/services/dev-override.ts @@ -1,8 +1,6 @@ -import {openURLSafely} from './dev.js' +import {type ThemePreviewResult} from './dev-override/types.js' import {fetchDevServerSession} from '../utilities/theme-environment/dev-server-session.js' import {createThemePreview, updateThemePreview} from '../utilities/theme-previews/preview.js' -import {renderSuccess} from '@shopify/cli-kit/node/ui' -import {outputInfo} from '@shopify/cli-kit/node/output' import {AdminSession} from '@shopify/cli-kit/node/session' import {AbortError} from '@shopify/cli-kit/node/error' import {readFile, fileExistsSync} from '@shopify/cli-kit/node/fs' @@ -16,16 +14,14 @@ interface DevWithOverrideFileOptions { overrideJson: string themeId: string previewIdentifier?: string - open: boolean password?: string - json?: boolean } /** * Reads a JSON overrides file and creates or updates a Storefront preview. - * The resulting preview URL is displayed to the user. + * Returns the preview URL and identifier. */ -export async function devWithOverrideFile(options: DevWithOverrideFileOptions) { +export async function devWithOverrideFile(options: DevWithOverrideFileOptions): Promise { if (!fileExistsSync(options.overrideJson)) { throw new AbortError(`Override file not found: ${options.overrideJson}`) } @@ -42,35 +38,16 @@ export async function devWithOverrideFile(options: DevWithOverrideFileOptions) { const session = await fetchDevServerSession(options.themeId, options.adminSession, options.password) const overridesContent = JSON.stringify(overrides) - const preview = options.previewIdentifier - ? await updateThemePreview({ + return options.previewIdentifier + ? updateThemePreview({ session, overridesContent, themeId: options.themeId, previewIdentifier: options.previewIdentifier, }) - : await createThemePreview({ + : createThemePreview({ session, overridesContent, themeId: options.themeId, }) - - if (options.json) { - outputInfo(JSON.stringify({url: preview.url, preview_identifier: preview.preview_identifier})) - } else { - renderSuccess({ - body: [ - { - list: { - title: options.previewIdentifier ? 'Preview updated' : 'Preview is ready', - items: [{link: {url: preview.url}}, `Preview ID: ${preview.preview_identifier}`], - }, - }, - ], - }) - } - - if (options.open) { - openURLSafely(preview.url, 'theme preview') - } } diff --git a/packages/theme/src/cli/services/dev-override/codec.test.ts b/packages/theme/src/cli/services/dev-override/codec.test.ts new file mode 100644 index 00000000000..3a28d3ba2ef --- /dev/null +++ b/packages/theme/src/cli/services/dev-override/codec.test.ts @@ -0,0 +1,25 @@ +import {encodeThemePreviewResult} from './codec.js' +import {themePreviewJsonOutputSchema} from './types.js' +import {expect, test} from 'vitest' + +const result = {url: 'https://abc123.shopifypreview.com', preview_identifier: 'abc123'} + +test('preserves the compact JSON wire format and key order', () => { + expect(encodeThemePreviewResult(result)).toBe( + '{"url":"https://abc123.shopifypreview.com","preview_identifier":"abc123"}', + ) +}) + +test('omits internal API fields', () => { + const response = {...result, internal: 'private'} + expect(JSON.parse(encodeThemePreviewResult(response))).toEqual(result) +}) + +test.each([ + {url: null, preview_identifier: 'abc123'}, + {url: 'https://abc123.shopifypreview.com', preview_identifier: 123}, + {url: 'https://abc123.shopifypreview.com'}, + {preview_identifier: 'abc123'}, +])('rejects invalid preview results %j', (invalid) => { + expect(() => themePreviewJsonOutputSchema.validate(invalid)).toThrow() +}) diff --git a/packages/theme/src/cli/services/dev-override/codec.ts b/packages/theme/src/cli/services/dev-override/codec.ts new file mode 100644 index 00000000000..cbe4b597fec --- /dev/null +++ b/packages/theme/src/cli/services/dev-override/codec.ts @@ -0,0 +1,6 @@ +import {themePreviewJsonOutputSchema, type ThemePreviewResult} from './types.js' + +export function encodeThemePreviewResult(result: ThemePreviewResult): string { + // Keep the compact wire format and key order used by the original preview command. + return JSON.stringify(themePreviewJsonOutputSchema.validate(result)) +} diff --git a/packages/theme/src/cli/services/dev-override/result.test.ts b/packages/theme/src/cli/services/dev-override/result.test.ts new file mode 100644 index 00000000000..bedd48f1cba --- /dev/null +++ b/packages/theme/src/cli/services/dev-override/result.test.ts @@ -0,0 +1,66 @@ +import {renderThemePreviewResult, renderThemePreviewOpenError} from './result.js' +import {expect, test, vi} from 'vitest' +import {renderSuccess, renderWarning} from '@shopify/cli-kit/node/ui' +import {runWithCommandEventsForCommand} from '@shopify/cli-kit/node/command-events' +import {withCapturedStandardStreams} from '@shopify/cli-kit/node/testing/output' + +vi.mock('@shopify/cli-kit/node/ui') + +const result = {url: 'https://abc123.shopifypreview.com', preview_identifier: 'abc123'} + +test.each([false, true])('preserves the success banner with updated=%s', (updated) => { + renderThemePreviewResult(result, 'text', updated) + + expect(renderSuccess).toHaveBeenCalledWith({ + body: [ + { + list: { + title: updated ? 'Preview updated' : 'Preview is ready', + items: [{link: {url: result.url}}, `Preview ID: ${result.preview_identifier}`], + }, + }, + ], + }) +}) + +test('preserves the browser warning in text mode', () => { + const error = new Error('Browser unavailable') + renderThemePreviewOpenError(error, 'text') + + expect(renderWarning).toHaveBeenCalledWith({headline: 'Failed to open theme preview.', body: error.stack}) +}) + +test('sends browser failures as diagnostic events to stderr', async () => { + const error = new Error('Browser unavailable') + await withCapturedStandardStreams(async ({stdout, stderr}) => { + await runWithCommandEventsForCommand(['--json'], () => renderThemePreviewOpenError(error, 'json')) + + expect(stdout()).toBe('') + expect(JSON.parse(stderr())).toMatchObject({ + type: 'diagnostic', + level: 'warning', + message: `Failed to open theme preview.\n${error.stack}`, + }) + }) + expect(renderWarning).not.toHaveBeenCalled() +}) + +test('writes compact JSON to stdout outside the command event context', async () => { + await withCapturedStandardStreams(({stdout, stderr}) => { + renderThemePreviewResult(result, 'json', false) + + expect(stdout()).toBe('{"url":"https://abc123.shopifypreview.com","preview_identifier":"abc123"}\n') + expect(stderr()).toBe('') + }) + expect(renderSuccess).not.toHaveBeenCalled() +}) + +test('writes the JSON result to stdout without a diagnostic wrapper during the command lifecycle', async () => { + await withCapturedStandardStreams(async ({stdout, stderr}) => { + await runWithCommandEventsForCommand(['--json'], () => renderThemePreviewResult(result, 'json', false)) + + expect(stdout()).toBe('{"url":"https://abc123.shopifypreview.com","preview_identifier":"abc123"}\n') + expect(stderr()).toBe('') + }) + expect(renderSuccess).not.toHaveBeenCalled() +}) diff --git a/packages/theme/src/cli/services/dev-override/result.ts b/packages/theme/src/cli/services/dev-override/result.ts new file mode 100644 index 00000000000..985691e26d4 --- /dev/null +++ b/packages/theme/src/cli/services/dev-override/result.ts @@ -0,0 +1,33 @@ +import {type ThemePreviewResult} from './types.js' +import {encodeThemePreviewResult} from './codec.js' +import {outputResult} from '@shopify/cli-kit/node/output' +import {renderSuccess, renderWarning} from '@shopify/cli-kit/node/ui' +import {emitCommandEvent} from '@shopify/cli-kit/node/command-events' + +export function renderThemePreviewResult(result: ThemePreviewResult, format: 'text' | 'json', updated: boolean): void { + if (format === 'json') { + outputResult(encodeThemePreviewResult(result)) + return + } + + renderSuccess({ + body: [ + { + list: { + title: updated ? 'Preview updated' : 'Preview is ready', + items: [{link: {url: result.url}}, `Preview ID: ${result.preview_identifier}`], + }, + }, + ], + }) +} + +export function renderThemePreviewOpenError(error: Error, format: 'text' | 'json'): void { + const headline = 'Failed to open theme preview.' + const body = error.stack ?? error.message + if (format === 'json') { + emitCommandEvent({type: 'diagnostic', level: 'warning', message: `${headline}\n${body}`}) + } else { + renderWarning({headline, body}) + } +} diff --git a/packages/theme/src/cli/services/dev-override/types.ts b/packages/theme/src/cli/services/dev-override/types.ts new file mode 100644 index 00000000000..a3e51a1e626 --- /dev/null +++ b/packages/theme/src/cli/services/dev-override/types.ts @@ -0,0 +1,12 @@ +import {defineJsonOutputSchema, type InferJsonOutputSchema} from '@shopify/cli-kit/node/json-output-schema' +import {zod} from '@shopify/cli-kit/node/schema' + +export const themePreviewJsonOutputSchema = defineJsonOutputSchema({ + name: 'ThemePreviewResult', + schema: zod.object({ + url: zod.string(), + preview_identifier: zod.string(), + }), +}) + +export type ThemePreviewResult = InferJsonOutputSchema From 64db43dd33536744d67d31bb99fc7271968b79da Mon Sep 17 00:00:00 2001 From: Gonzalo Riestra Date: Tue, 6 Oct 2026 14:13:58 +0200 Subject: [PATCH 2/6] Normalize theme preview JSON and await browser diagnostics --- .changeset/theme-preview-json-contract.md | 5 ++ .../src/cli/commands/theme/preview.test.ts | 66 ++++++++++++++++++- .../theme/src/cli/commands/theme/preview.ts | 17 ++++- .../cli/services/dev-override/codec.test.ts | 27 ++++++-- .../src/cli/services/dev-override/codec.ts | 3 +- .../cli/services/dev-override/result.test.ts | 6 +- .../src/cli/services/dev-override/types.ts | 29 +++++--- 7 files changed, 129 insertions(+), 24 deletions(-) create mode 100644 .changeset/theme-preview-json-contract.md diff --git a/.changeset/theme-preview-json-contract.md b/.changeset/theme-preview-json-contract.md new file mode 100644 index 00000000000..edf26429585 --- /dev/null +++ b/.changeset/theme-preview-json-contract.md @@ -0,0 +1,5 @@ +--- +'@shopify/cli': major +--- + +Return theme preview JSON as a strict `preview` resource with an opaque string `id` and retain every requested environment. diff --git a/packages/theme/src/cli/commands/theme/preview.test.ts b/packages/theme/src/cli/commands/theme/preview.test.ts index 64438afc51c..935e102f8ed 100644 --- a/packages/theme/src/cli/commands/theme/preview.test.ts +++ b/packages/theme/src/cli/commands/theme/preview.test.ts @@ -127,7 +127,10 @@ describe('Preview', () => { run(['--overrides=/path/to/overrides.json', '--theme=2', '--json']), ) - expect(JSON.parse(stdout())).toEqual(result) + expect(JSON.parse(stdout())).toEqual({ + status: 'success', + preview: {id: result.preview_identifier, url: result.url}, + }) expect(stderr()).toBe('') }) expect(renderSuccess).not.toHaveBeenCalled() @@ -144,7 +147,7 @@ describe('Preview', () => { test('exposes its result schema in help', () => { expect(Preview.jsonOutputSchema).toBe(themePreviewJsonOutputSchema) expect(Preview.description).toContain('ThemePreviewResult') - expect(Preview.description).toContain('preview_identifier') + expect(Preview.description).toContain('ThemePreview') expect(Preview.flags.json.env).toBe('SHOPIFY_FLAG_JSON') }) @@ -163,6 +166,30 @@ describe('Preview', () => { expect(openURL).not.toHaveBeenCalled() }) + test('waits for a slow browser failure before writing the final result', async () => { + let rejectBrowser: ((error: Error) => void) | undefined + vi.mocked(openURL).mockImplementation( + () => + new Promise((_resolve, reject) => { + rejectBrowser = reject + }), + ) + await withCapturedStandardStreams(async ({stdout, stderr}) => { + const execution = runWithCommandEventsForCommand(['--json'], () => + run(['--overrides=/path/to/overrides.json', '--theme=2', '--json', '--open']), + ) + await vi.waitFor(() => expect(openURL).toHaveBeenCalled()) + expect(stdout()).toBe('') + rejectBrowser?.(new Error('Browser unavailable')) + await execution + expect(JSON.parse(stderr())).toMatchObject({type: 'diagnostic', level: 'warning'}) + expect(JSON.parse(stdout())).toEqual({ + status: 'success', + preview: {id: result.preview_identifier, url: result.url}, + }) + }) + }) + test('keeps browser failures nonfatal and sends a typed warning to stderr', async () => { const error = new Error('Browser unavailable') vi.mocked(openURL).mockRejectedValue(error) @@ -179,7 +206,40 @@ describe('Preview', () => { expect(events).toMatchObject([ {type: 'diagnostic', level: 'warning', message: `Failed to open theme preview.\n${error.stack}`}, ]) - expect(JSON.parse(stdout())).toEqual(result) + expect(JSON.parse(stdout())).toEqual({ + status: 'success', + preview: {id: result.preview_identifier, url: result.url}, + }) + }) + }) +}) + +test('returns one explicit environment result', async () => { + const {loadEnvironment} = await import('@shopify/cli-kit/node/environments') + vi.mocked(loadEnvironment).mockResolvedValue({ + store: adminSession.storeFqdn, + theme: '2', + overrides: '/path/to/overrides.json', + }) + vi.mocked(ensureThemeStore).mockReturnValue(adminSession.storeFqdn) + vi.mocked(ensureAuthenticatedThemes).mockResolvedValue(adminSession) + vi.mocked(findOrSelectTheme).mockResolvedValue(namedTheme) + vi.mocked(devWithOverrideFile).mockResolvedValue(result) + await withCapturedStandardStreams(async ({stdout, stderr}) => { + await runWithCommandEventsForCommand(['--json'], () => + run(['--environment=staging', '--overrides=/path/to/overrides.json', '--theme=2', '--json']), + ) + expect(JSON.parse(stdout())).toEqual({ + environments: [ + { + environment: 'staging', + result: { + status: 'success', + preview: {id: result.preview_identifier, url: result.url}, + }, + }, + ], }) + expect(stderr()).toBe('') }) }) diff --git a/packages/theme/src/cli/commands/theme/preview.ts b/packages/theme/src/cli/commands/theme/preview.ts index 9b9c1865f31..c90811f9e09 100644 --- a/packages/theme/src/cli/commands/theme/preview.ts +++ b/packages/theme/src/cli/commands/theme/preview.ts @@ -9,6 +9,8 @@ import {globalFlags, jsonFlag} from '@shopify/cli-kit/node/cli' import {openURL} from '@shopify/cli-kit/node/system' import {AdminSession} from '@shopify/cli-kit/node/session' import {InferredFlags} from '@oclif/core/interfaces' +import {outputResult} from '@shopify/cli-kit/node/output' +import type {ThemeEnvironmentResult} from '../../services/json-output/schema.js' type PreviewFlags = InferredFlags @@ -59,7 +61,7 @@ export default class Preview extends ThemeCommand { static multiEnvironmentsFlags: RequiredFlags = null - async command(flags: PreviewFlags, adminSession: AdminSession) { + async command(flags: PreviewFlags, adminSession: AdminSession, multiEnvironment = false) { const theme = await findOrSelectTheme(adminSession, {filter: {theme: flags.theme}}) const result = await devWithOverrideFile({ adminSession, @@ -69,9 +71,18 @@ export default class Preview extends ThemeCommand { password: flags.password, }) const format = flags.json ? 'json' : 'text' - renderThemePreviewResult(result, format, Boolean(flags['preview-id'])) if (flags.open) { - openURL(result.url).catch((error: Error) => renderThemePreviewOpenError(error, format)) + await openURL(result.url).catch((error: Error) => renderThemePreviewOpenError(error, format)) } + if (!multiEnvironment || !flags.json) renderThemePreviewResult(result, format, Boolean(flags['preview-id'])) + return result + } + + protected collectsEnvironmentResults(flags: Partial): boolean { + return Boolean(flags.json) + } + + protected renderEnvironmentResults(environments: ThemeEnvironmentResult[]): void { + outputResult(themePreviewJsonOutputSchema.encode({environments})) } } diff --git a/packages/theme/src/cli/services/dev-override/codec.test.ts b/packages/theme/src/cli/services/dev-override/codec.test.ts index 3a28d3ba2ef..688b3d07828 100644 --- a/packages/theme/src/cli/services/dev-override/codec.test.ts +++ b/packages/theme/src/cli/services/dev-override/codec.test.ts @@ -4,15 +4,19 @@ import {expect, test} from 'vitest' const result = {url: 'https://abc123.shopifypreview.com', preview_identifier: 'abc123'} -test('preserves the compact JSON wire format and key order', () => { - expect(encodeThemePreviewResult(result)).toBe( - '{"url":"https://abc123.shopifypreview.com","preview_identifier":"abc123"}', - ) +test('projects the native preview response to the strict public result', () => { + expect(JSON.parse(encodeThemePreviewResult(result))).toEqual({ + status: 'success', + preview: {id: 'abc123', url: result.url}, + }) }) test('omits internal API fields', () => { const response = {...result, internal: 'private'} - expect(JSON.parse(encodeThemePreviewResult(response))).toEqual(result) + expect(JSON.parse(encodeThemePreviewResult(response))).toEqual({ + status: 'success', + preview: {id: 'abc123', url: result.url}, + }) }) test.each([ @@ -23,3 +27,16 @@ test.each([ ])('rejects invalid preview results %j', (invalid) => { expect(() => themePreviewJsonOutputSchema.validate(invalid)).toThrow() }) + +test('validates the opaque preview namespace, URL and strict object fields', () => { + const output = JSON.parse(encodeThemePreviewResult(result)) + expect(themePreviewJsonOutputSchema.validate(output)).toEqual(output) + expect(() => themePreviewJsonOutputSchema.validate({...output, unknown: true})).toThrow() + expect(() => themePreviewJsonOutputSchema.validate({...output, preview: {...output.preview, id: ''}})).toThrow() + expect(() => + themePreviewJsonOutputSchema.validate({...output, preview: {...output.preview, url: 'invalid'}}), + ).toThrow() + expect(() => + themePreviewJsonOutputSchema.validate({...output, preview: {...output.preview, unknown: true}}), + ).toThrow() +}) diff --git a/packages/theme/src/cli/services/dev-override/codec.ts b/packages/theme/src/cli/services/dev-override/codec.ts index cbe4b597fec..fa029988973 100644 --- a/packages/theme/src/cli/services/dev-override/codec.ts +++ b/packages/theme/src/cli/services/dev-override/codec.ts @@ -1,6 +1,5 @@ import {themePreviewJsonOutputSchema, type ThemePreviewResult} from './types.js' export function encodeThemePreviewResult(result: ThemePreviewResult): string { - // Keep the compact wire format and key order used by the original preview command. - return JSON.stringify(themePreviewJsonOutputSchema.validate(result)) + return themePreviewJsonOutputSchema.encode(result) } diff --git a/packages/theme/src/cli/services/dev-override/result.test.ts b/packages/theme/src/cli/services/dev-override/result.test.ts index bedd48f1cba..30daa04252b 100644 --- a/packages/theme/src/cli/services/dev-override/result.test.ts +++ b/packages/theme/src/cli/services/dev-override/result.test.ts @@ -45,11 +45,11 @@ test('sends browser failures as diagnostic events to stderr', async () => { expect(renderWarning).not.toHaveBeenCalled() }) -test('writes compact JSON to stdout outside the command event context', async () => { +test('writes one JSON object to stdout outside the command event context', async () => { await withCapturedStandardStreams(({stdout, stderr}) => { renderThemePreviewResult(result, 'json', false) - expect(stdout()).toBe('{"url":"https://abc123.shopifypreview.com","preview_identifier":"abc123"}\n') + expect(JSON.parse(stdout())).toEqual({status: 'success', preview: {id: 'abc123', url: result.url}}) expect(stderr()).toBe('') }) expect(renderSuccess).not.toHaveBeenCalled() @@ -59,7 +59,7 @@ test('writes the JSON result to stdout without a diagnostic wrapper during the c await withCapturedStandardStreams(async ({stdout, stderr}) => { await runWithCommandEventsForCommand(['--json'], () => renderThemePreviewResult(result, 'json', false)) - expect(stdout()).toBe('{"url":"https://abc123.shopifypreview.com","preview_identifier":"abc123"}\n') + expect(JSON.parse(stdout())).toEqual({status: 'success', preview: {id: 'abc123', url: result.url}}) expect(stderr()).toBe('') }) expect(renderSuccess).not.toHaveBeenCalled() diff --git a/packages/theme/src/cli/services/dev-override/types.ts b/packages/theme/src/cli/services/dev-override/types.ts index a3e51a1e626..95da979d60b 100644 --- a/packages/theme/src/cli/services/dev-override/types.ts +++ b/packages/theme/src/cli/services/dev-override/types.ts @@ -1,12 +1,25 @@ -import {defineJsonOutputSchema, type InferJsonOutputSchema} from '@shopify/cli-kit/node/json-output-schema' +import {defineThemeJsonOutputSchema} from '../json-output/schema.js' import {zod} from '@shopify/cli-kit/node/schema' -export const themePreviewJsonOutputSchema = defineJsonOutputSchema({ +/** The native preview API response retained for text and compatibility callers. */ +export interface ThemePreviewResult { + url: string + preview_identifier: string +} + +const PreviewSchema = zod + .object({ + id: zod.string().min(1).describe('The opaque Storefront preview identifier, not a theme ID or Shopify GID.'), + url: zod.string().url(), + }) + .strict() + +export const themePreviewJsonOutputSchema = defineThemeJsonOutputSchema({ name: 'ThemePreviewResult', - schema: zod.object({ - url: zod.string(), - preview_identifier: zod.string(), - }), + schema: zod.object({status: zod.literal('success'), preview: PreviewSchema}).strict(), + definitions: {ThemePreview: PreviewSchema}, + project: (value) => { + const result = value as ThemePreviewResult + return {status: 'success', preview: {id: result.preview_identifier, url: result.url}} + }, }) - -export type ThemePreviewResult = InferJsonOutputSchema From fe07c4be2c4d8ab0d5ac41edb5eece5f6b2e0bc8 Mon Sep 17 00:00:00 2001 From: Gonzalo Riestra Date: Tue, 6 Oct 2026 14:16:43 +0200 Subject: [PATCH 3/6] Refresh theme preview JSON schema documentation --- packages/cli/README.md | 260 ++++++++++++++++++++++++++++++- packages/cli/oclif.manifest.json | 2 +- 2 files changed, 256 insertions(+), 6 deletions(-) diff --git a/packages/cli/README.md b/packages/cli/README.md index 0a7192145df..4aa42a3d945 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -14820,7 +14820,7 @@ Applies JSON overrides to a theme and returns a preview URL. ``` USAGE - $ shopify theme preview --overrides -t [--auth-alias ] [-e ...] [--json] + $ shopify theme preview --overrides -t [--auth-alias ] [-e ...] [-j] [--json-schema] [--no-color] [--no-input] [--open] [--password ] [--path ] [--preview-id ] [-s ] [--verbose] @@ -14829,6 +14829,10 @@ FLAGS The environment to apply to the current command. [env: SHOPIFY_FLAG_ENVIRONMENT] + -j, --json + Output the preview URL and identifier as JSON. + [env: SHOPIFY_FLAG_JSON] + -s, --store= Store URL. It can be the store prefix (example) or the full myshopify.com URL (example.myshopify.com, https://example.myshopify.com). @@ -14842,10 +14846,6 @@ FLAGS Alias of the Shopify account to use for authentication. [env: SHOPIFY_FLAG_AUTH_ALIAS] - --json - Output the preview URL and identifier as JSON. - [env: SHOPIFY_FLAG_JSON] - --json-schema Print the command's JSON schemas. [env: SHOPIFY_FLAG_JSON_SCHEMA] @@ -14889,6 +14889,256 @@ DESCRIPTION The command returns a preview URL and a preview identifier. You can reuse the preview identifier with `--preview-id` to update an existing preview instead of creating a new one. + + Use `--json-schema` to print the result, error, and event schemas. + + Output from `--json` conforms to the `ThemePreviewResult` 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": "ThemePreviewResult", + "definitions": { + "ThemePreview": { + "type": "object", + "properties": { + "id": { + "type": "string", + "minLength": 1, + "description": "The opaque Storefront preview identifier, not a theme ID or Shopify GID." + }, + "url": { + "type": "string", + "format": "uri" + } + }, + "required": [ + "id", + "url" + ], + "additionalProperties": false + }, + "ThemeEnvironment": { + "anyOf": [ + { + "type": "object", + "properties": { + "environment": { + "type": "string" + }, + "result": { + "anyOf": [ + { + "type": "object", + "properties": { + "status": { + "type": "string", + "const": "success" + }, + "preview": { + "$ref": "#/definitions/ThemePreview" + } + }, + "required": [ + "status", + "preview" + ], + "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 profile` diff --git a/packages/cli/oclif.manifest.json b/packages/cli/oclif.manifest.json index 3533ec63cc2..f6945758485 100644 --- a/packages/cli/oclif.manifest.json +++ b/packages/cli/oclif.manifest.json @@ -12115,7 +12115,7 @@ "args": { }, "customPluginName": "@shopify/theme", - "description": "Applies a JSON overrides file to a theme and creates or updates a preview. This lets you quickly preview changes.\n\n The command returns a preview URL and a preview identifier. You can reuse the preview identifier with `--preview-id` to update an existing preview instead of creating a new one.\n\nUse `--json-schema` to print the result, error, and event schemas.\n\nOutput from `--json` conforms to the `ThemePreviewResult` schema.\n\n```json\n{\n \"type\": \"object\",\n \"properties\": {\n \"url\": {\n \"type\": \"string\"\n },\n \"preview_identifier\": {\n \"type\": \"string\"\n }\n },\n \"required\": [\n \"url\",\n \"preview_identifier\"\n ],\n \"additionalProperties\": false,\n \"title\": \"ThemePreviewResult\",\n \"$schema\": \"http://json-schema.org/draft-07/schema#\"\n}\n```", + "description": "Applies a JSON overrides file to a theme and creates or updates a preview. This lets you quickly preview changes.\n\n The command returns a preview URL and a preview identifier. You can reuse the preview identifier with `--preview-id` to update an existing preview instead of creating a new one.\n\nUse `--json-schema` to print the result, error, and event schemas.\n\nOutput from `--json` conforms to the `ThemePreviewResult` 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\": \"ThemePreviewResult\",\n \"definitions\": {\n \"ThemePreview\": {\n \"type\": \"object\",\n \"properties\": {\n \"id\": {\n \"type\": \"string\",\n \"minLength\": 1,\n \"description\": \"The opaque Storefront preview identifier, not a theme ID or Shopify GID.\"\n },\n \"url\": {\n \"type\": \"string\",\n \"format\": \"uri\"\n }\n },\n \"required\": [\n \"id\",\n \"url\"\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 \"type\": \"object\",\n \"properties\": {\n \"status\": {\n \"type\": \"string\",\n \"const\": \"success\"\n },\n \"preview\": {\n \"$ref\": \"#/definitions/ThemePreview\"\n }\n },\n \"required\": [\n \"status\",\n \"preview\"\n ],\n \"additionalProperties\": false\n },\n {\n \"type\": \"object\",\n \"properties\": {\n \"status\": {\n \"type\": \"string\",\n \"const\": \"cancelled\"\n }\n },\n \"required\": [\n \"status\"\n ],\n \"additionalProperties\": false\n }\n ]\n }\n },\n \"required\": [\n \"environment\",\n \"result\"\n ],\n \"additionalProperties\": false\n },\n {\n \"type\": \"object\",\n \"properties\": {\n \"environment\": {\n \"type\": \"string\"\n },\n \"error\": {\n \"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": "Applies a JSON overrides file to a theme and creates or updates a preview. This lets you quickly preview changes.\n\n The command returns a preview URL and a preview identifier. You can reuse the preview identifier with `--preview-id` to update an existing preview instead of creating a new one.", "enableJsonFlag": false, "flags": { From 773c78c651d0f77e2d490fc6b49b87c039f2a54c Mon Sep 17 00:00:00 2001 From: Gonzalo Riestra Date: Tue, 6 Oct 2026 14:17:14 +0200 Subject: [PATCH 4/6] Refresh generated theme preview command docs --- .../generated/generated_docs_data_v2.json | 20 +++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/docs-shopify.dev/generated/generated_docs_data_v2.json b/docs-shopify.dev/generated/generated_docs_data_v2.json index 44b4377d86a..987d414b6d5 100644 --- a/docs-shopify.dev/generated/generated_docs_data_v2.json +++ b/docs-shopify.dev/generated/generated_docs_data_v2.json @@ -9801,15 +9801,6 @@ "isOptional": true, "environmentValue": "SHOPIFY_FLAG_AUTH_ALIAS" }, - { - "filePath": "docs-shopify.dev/commands/interfaces/theme-preview.interface.ts", - "syntaxKind": "PropertySignature", - "name": "--json", - "value": "''", - "description": "Output the preview URL and identifier as JSON.", - "isOptional": true, - "environmentValue": "SHOPIFY_FLAG_JSON" - }, { "filePath": "docs-shopify.dev/commands/interfaces/theme-preview.interface.ts", "syntaxKind": "PropertySignature", @@ -9899,6 +9890,15 @@ "isOptional": true, "environmentValue": "SHOPIFY_FLAG_ENVIRONMENT" }, + { + "filePath": "docs-shopify.dev/commands/interfaces/theme-preview.interface.ts", + "syntaxKind": "PropertySignature", + "name": "-j, --json", + "value": "''", + "description": "Output the preview URL and identifier as JSON.", + "isOptional": true, + "environmentValue": "SHOPIFY_FLAG_JSON" + }, { "filePath": "docs-shopify.dev/commands/interfaces/theme-preview.interface.ts", "syntaxKind": "PropertySignature", @@ -9917,7 +9917,7 @@ "environmentValue": "SHOPIFY_FLAG_THEME_ID" } ], - "value": "export interface themepreview {\n /**\n * Alias of the Shopify account to use for authentication.\n * @environment SHOPIFY_FLAG_AUTH_ALIAS\n */\n '--auth-alias '?: string\n\n /**\n * The environment to apply to the current command.\n * @environment SHOPIFY_FLAG_ENVIRONMENT\n */\n '-e, --environment '?: string\n\n /**\n * Output the preview URL and identifier as JSON.\n * @environment SHOPIFY_FLAG_JSON\n */\n '--json'?: ''\n\n /**\n * Print the command's JSON schemas.\n * @environment SHOPIFY_FLAG_JSON_SCHEMA\n */\n '--json-schema'?: ''\n\n /**\n * Disable color output.\n * @environment SHOPIFY_FLAG_NO_COLOR\n */\n '--no-color'?: ''\n\n /**\n * Disable interactive prompts and browser authentication.\n * @environment SHOPIFY_FLAG_NO_INPUT\n */\n '--no-input'?: ''\n\n /**\n * Automatically launch the theme preview in your default web browser.\n * @environment SHOPIFY_FLAG_OPEN\n */\n '--open'?: ''\n\n /**\n * Path to a JSON overrides file.\n * @environment SHOPIFY_FLAG_OVERRIDES\n */\n '--overrides ': 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 * An existing preview identifier to update instead of creating a new preview.\n * @environment SHOPIFY_FLAG_PREVIEW_ID\n */\n '--preview-id '?: 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.\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 themepreview {\n /**\n * Alias of the Shopify account to use for authentication.\n * @environment SHOPIFY_FLAG_AUTH_ALIAS\n */\n '--auth-alias '?: string\n\n /**\n * The environment to apply to the current command.\n * @environment SHOPIFY_FLAG_ENVIRONMENT\n */\n '-e, --environment '?: string\n\n /**\n * Output the preview URL and identifier as JSON.\n * @environment SHOPIFY_FLAG_JSON\n */\n '-j, --json'?: ''\n\n /**\n * Print the command's JSON schemas.\n * @environment SHOPIFY_FLAG_JSON_SCHEMA\n */\n '--json-schema'?: ''\n\n /**\n * Disable color output.\n * @environment SHOPIFY_FLAG_NO_COLOR\n */\n '--no-color'?: ''\n\n /**\n * Disable interactive prompts and browser authentication.\n * @environment SHOPIFY_FLAG_NO_INPUT\n */\n '--no-input'?: ''\n\n /**\n * Automatically launch the theme preview in your default web browser.\n * @environment SHOPIFY_FLAG_OPEN\n */\n '--open'?: ''\n\n /**\n * Path to a JSON overrides file.\n * @environment SHOPIFY_FLAG_OVERRIDES\n */\n '--overrides ': 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 * An existing preview identifier to update instead of creating a new preview.\n * @environment SHOPIFY_FLAG_PREVIEW_ID\n */\n '--preview-id '?: 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.\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}" } }, "themeprofile": { From 685aa6eafb30d1601e9c21071d84bc8dac7b3592 Mon Sep 17 00:00:00 2001 From: Gonzalo Riestra Date: Thu, 8 Oct 2026 10:38:26 +0200 Subject: [PATCH 5/6] Load required preview values from theme environments --- .../generated/generated_docs_data_v2.json | 4 +++- packages/cli/README.md | 23 +++++++++++++++---- packages/cli/oclif.manifest.json | 4 +--- .../src/cli/commands/theme/preview.test.ts | 14 ++++++++--- .../theme/src/cli/commands/theme/preview.ts | 10 ++++---- 5 files changed, 37 insertions(+), 18 deletions(-) diff --git a/docs-shopify.dev/generated/generated_docs_data_v2.json b/docs-shopify.dev/generated/generated_docs_data_v2.json index 987d414b6d5..db719cbdf08 100644 --- a/docs-shopify.dev/generated/generated_docs_data_v2.json +++ b/docs-shopify.dev/generated/generated_docs_data_v2.json @@ -9843,6 +9843,7 @@ "name": "--overrides ", "value": "string", "description": "Path to a JSON overrides file.", + "isOptional": true, "environmentValue": "SHOPIFY_FLAG_OVERRIDES" }, { @@ -9914,10 +9915,11 @@ "name": "-t, --theme ", "value": "string", "description": "Theme ID or name of the remote theme.", + "isOptional": true, "environmentValue": "SHOPIFY_FLAG_THEME_ID" } ], - "value": "export interface themepreview {\n /**\n * Alias of the Shopify account to use for authentication.\n * @environment SHOPIFY_FLAG_AUTH_ALIAS\n */\n '--auth-alias '?: string\n\n /**\n * The environment to apply to the current command.\n * @environment SHOPIFY_FLAG_ENVIRONMENT\n */\n '-e, --environment '?: string\n\n /**\n * Output the preview URL and identifier as JSON.\n * @environment SHOPIFY_FLAG_JSON\n */\n '-j, --json'?: ''\n\n /**\n * Print the command's JSON schemas.\n * @environment SHOPIFY_FLAG_JSON_SCHEMA\n */\n '--json-schema'?: ''\n\n /**\n * Disable color output.\n * @environment SHOPIFY_FLAG_NO_COLOR\n */\n '--no-color'?: ''\n\n /**\n * Disable interactive prompts and browser authentication.\n * @environment SHOPIFY_FLAG_NO_INPUT\n */\n '--no-input'?: ''\n\n /**\n * Automatically launch the theme preview in your default web browser.\n * @environment SHOPIFY_FLAG_OPEN\n */\n '--open'?: ''\n\n /**\n * Path to a JSON overrides file.\n * @environment SHOPIFY_FLAG_OVERRIDES\n */\n '--overrides ': 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 * An existing preview identifier to update instead of creating a new preview.\n * @environment SHOPIFY_FLAG_PREVIEW_ID\n */\n '--preview-id '?: 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.\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 themepreview {\n /**\n * Alias of the Shopify account to use for authentication.\n * @environment SHOPIFY_FLAG_AUTH_ALIAS\n */\n '--auth-alias '?: string\n\n /**\n * The environment to apply to the current command.\n * @environment SHOPIFY_FLAG_ENVIRONMENT\n */\n '-e, --environment '?: string\n\n /**\n * Output the preview URL and identifier as JSON.\n * @environment SHOPIFY_FLAG_JSON\n */\n '-j, --json'?: ''\n\n /**\n * Print the command's JSON schemas.\n * @environment SHOPIFY_FLAG_JSON_SCHEMA\n */\n '--json-schema'?: ''\n\n /**\n * Disable color output.\n * @environment SHOPIFY_FLAG_NO_COLOR\n */\n '--no-color'?: ''\n\n /**\n * Disable interactive prompts and browser authentication.\n * @environment SHOPIFY_FLAG_NO_INPUT\n */\n '--no-input'?: ''\n\n /**\n * Automatically launch the theme preview in your default web browser.\n * @environment SHOPIFY_FLAG_OPEN\n */\n '--open'?: ''\n\n /**\n * Path to a JSON overrides file.\n * @environment SHOPIFY_FLAG_OVERRIDES\n */\n '--overrides '?: 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 * An existing preview identifier to update instead of creating a new preview.\n * @environment SHOPIFY_FLAG_PREVIEW_ID\n */\n '--preview-id '?: 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.\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}" } }, "themeprofile": { diff --git a/packages/cli/README.md b/packages/cli/README.md index 4aa42a3d945..0de98d45ea7 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -14820,8 +14820,8 @@ Applies JSON overrides to a theme and returns a preview URL. ``` USAGE - $ shopify theme preview --overrides -t [--auth-alias ] [-e ...] [-j] - [--json-schema] [--no-color] [--no-input] [--open] [--password ] [--path ] [--preview-id ] [-s + $ shopify theme preview [--auth-alias ] [-e ...] [-j] [--json-schema] [--no-color] [--no-input] + [--open] [--overrides ] [--password ] [--path ] [--preview-id ] [-s ] [-t ] [--verbose] FLAGS @@ -14839,7 +14839,7 @@ FLAGS [env: SHOPIFY_FLAG_STORE] -t, --theme= - (required) Theme ID or name of the remote theme. + Theme ID or name of the remote theme. [env: SHOPIFY_FLAG_THEME_ID] --auth-alias= @@ -14863,7 +14863,7 @@ FLAGS [env: SHOPIFY_FLAG_OPEN] --overrides= - (required) Path to a JSON overrides file. + Path to a JSON overrides file. [env: SHOPIFY_FLAG_OVERRIDES] --password= @@ -15004,6 +15004,11 @@ DESCRIPTION "message": { "type": "string" }, + "code": { + "type": "string", + "minLength": 1, + "description": "A stable error code, included only when known." + }, "tryMessage": { "type": "string" }, @@ -15044,7 +15049,9 @@ DESCRIPTION "additionalProperties": false } }, - "details": {} + "details": { + "description": "Selected domain details, preserving native API payloads such as GraphQL errors, extensions, and data." + } }, "required": [ "type", @@ -15062,6 +15069,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" }, @@ -15094,6 +15104,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 f6945758485..8da6e94555f 100644 --- a/packages/cli/oclif.manifest.json +++ b/packages/cli/oclif.manifest.json @@ -12115,7 +12115,7 @@ "args": { }, "customPluginName": "@shopify/theme", - "description": "Applies a JSON overrides file to a theme and creates or updates a preview. This lets you quickly preview changes.\n\n The command returns a preview URL and a preview identifier. You can reuse the preview identifier with `--preview-id` to update an existing preview instead of creating a new one.\n\nUse `--json-schema` to print the result, error, and event schemas.\n\nOutput from `--json` conforms to the `ThemePreviewResult` 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\": \"ThemePreviewResult\",\n \"definitions\": {\n \"ThemePreview\": {\n \"type\": \"object\",\n \"properties\": {\n \"id\": {\n \"type\": \"string\",\n \"minLength\": 1,\n \"description\": \"The opaque Storefront preview identifier, not a theme ID or Shopify GID.\"\n },\n \"url\": {\n \"type\": \"string\",\n \"format\": \"uri\"\n }\n },\n \"required\": [\n \"id\",\n \"url\"\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 \"type\": \"object\",\n \"properties\": {\n \"status\": {\n \"type\": \"string\",\n \"const\": \"success\"\n },\n \"preview\": {\n \"$ref\": \"#/definitions/ThemePreview\"\n }\n },\n \"required\": [\n \"status\",\n \"preview\"\n ],\n \"additionalProperties\": false\n },\n {\n \"type\": \"object\",\n \"properties\": {\n \"status\": {\n \"type\": \"string\",\n \"const\": \"cancelled\"\n }\n },\n \"required\": [\n \"status\"\n ],\n \"additionalProperties\": false\n }\n ]\n }\n },\n \"required\": [\n \"environment\",\n \"result\"\n ],\n \"additionalProperties\": false\n },\n {\n \"type\": \"object\",\n \"properties\": {\n \"environment\": {\n \"type\": \"string\"\n },\n \"error\": {\n \"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": "Applies a JSON overrides file to a theme and creates or updates a preview. This lets you quickly preview changes.\n\n The command returns a preview URL and a preview identifier. You can reuse the preview identifier with `--preview-id` to update an existing preview instead of creating a new one.\n\nUse `--json-schema` to print the result, error, and event schemas.\n\nOutput from `--json` conforms to the `ThemePreviewResult` 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\": \"ThemePreviewResult\",\n \"definitions\": {\n \"ThemePreview\": {\n \"type\": \"object\",\n \"properties\": {\n \"id\": {\n \"type\": \"string\",\n \"minLength\": 1,\n \"description\": \"The opaque Storefront preview identifier, not a theme ID or Shopify GID.\"\n },\n \"url\": {\n \"type\": \"string\",\n \"format\": \"uri\"\n }\n },\n \"required\": [\n \"id\",\n \"url\"\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 \"type\": \"object\",\n \"properties\": {\n \"status\": {\n \"type\": \"string\",\n \"const\": \"success\"\n },\n \"preview\": {\n \"$ref\": \"#/definitions/ThemePreview\"\n }\n },\n \"required\": [\n \"status\",\n \"preview\"\n ],\n \"additionalProperties\": false\n },\n {\n \"type\": \"object\",\n \"properties\": {\n \"status\": {\n \"type\": \"string\",\n \"const\": \"cancelled\"\n }\n },\n \"required\": [\n \"status\"\n ],\n \"additionalProperties\": false\n }\n ]\n }\n },\n \"required\": [\n \"environment\",\n \"result\"\n ],\n \"additionalProperties\": false\n },\n {\n \"type\": \"object\",\n \"properties\": {\n \"environment\": {\n \"type\": \"string\"\n },\n \"error\": {\n \"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": "Applies a JSON overrides file to a theme and creates or updates a preview. This lets you quickly preview changes.\n\n The command returns a preview URL and a preview identifier. You can reuse the preview identifier with `--preview-id` to update an existing preview instead of creating a new one.", "enableJsonFlag": false, "flags": { @@ -12180,7 +12180,6 @@ "hasDynamicHelp": false, "multiple": false, "name": "overrides", - "required": true, "type": "option" }, "password": { @@ -12224,7 +12223,6 @@ "hasDynamicHelp": false, "multiple": false, "name": "theme", - "required": true, "type": "option" }, "verbose": { diff --git a/packages/theme/src/cli/commands/theme/preview.test.ts b/packages/theme/src/cli/commands/theme/preview.test.ts index 935e102f8ed..02e52fe3f3d 100644 --- a/packages/theme/src/cli/commands/theme/preview.test.ts +++ b/packages/theme/src/cli/commands/theme/preview.test.ts @@ -226,9 +226,7 @@ test('returns one explicit environment result', async () => { vi.mocked(findOrSelectTheme).mockResolvedValue(namedTheme) vi.mocked(devWithOverrideFile).mockResolvedValue(result) await withCapturedStandardStreams(async ({stdout, stderr}) => { - await runWithCommandEventsForCommand(['--json'], () => - run(['--environment=staging', '--overrides=/path/to/overrides.json', '--theme=2', '--json']), - ) + await runWithCommandEventsForCommand(['--json'], () => run(['--environment=staging', '--json'])) expect(JSON.parse(stdout())).toEqual({ environments: [ { @@ -243,3 +241,13 @@ test('returns one explicit environment result', async () => { expect(stderr()).toBe('') }) }) + +test.each([['--theme=2'], ['--overrides=/path/to/overrides.json']])( + 'rejects missing required preview options: %j', + async (flag) => { + await expect(run([flag, '--json'])).rejects.toThrow( + 'Specify both --theme and --overrides, either as flags or in an environment.', + ) + expect(devWithOverrideFile).not.toHaveBeenCalled() + }, +) diff --git a/packages/theme/src/cli/commands/theme/preview.ts b/packages/theme/src/cli/commands/theme/preview.ts index c90811f9e09..9faf50bc298 100644 --- a/packages/theme/src/cli/commands/theme/preview.ts +++ b/packages/theme/src/cli/commands/theme/preview.ts @@ -10,6 +10,7 @@ import {openURL} from '@shopify/cli-kit/node/system' import {AdminSession} from '@shopify/cli-kit/node/session' import {InferredFlags} from '@oclif/core/interfaces' import {outputResult} from '@shopify/cli-kit/node/output' +import {AbortError} from '@shopify/cli-kit/node/error' import type {ThemeEnvironmentResult} from '../../services/json-output/schema.js' type PreviewFlags = InferredFlags @@ -35,12 +36,10 @@ export default class Preview extends ThemeCommand { char: 't', description: 'Theme ID or name of the remote theme.', env: 'SHOPIFY_FLAG_THEME_ID', - required: true, }), overrides: Flags.string({ description: 'Path to a JSON overrides file.', env: 'SHOPIFY_FLAG_OVERRIDES', - required: true, }), 'preview-id': Flags.string({ description: 'An existing preview identifier to update instead of creating a new preview.', @@ -62,6 +61,9 @@ export default class Preview extends ThemeCommand { static multiEnvironmentsFlags: RequiredFlags = null async command(flags: PreviewFlags, adminSession: AdminSession, multiEnvironment = false) { + if (!flags.theme || !flags.overrides) { + throw new AbortError('Specify both --theme and --overrides, either as flags or in an environment.') + } const theme = await findOrSelectTheme(adminSession, {filter: {theme: flags.theme}}) const result = await devWithOverrideFile({ adminSession, @@ -78,10 +80,6 @@ export default class Preview extends ThemeCommand { return result } - protected collectsEnvironmentResults(flags: Partial): boolean { - return Boolean(flags.json) - } - protected renderEnvironmentResults(environments: ThemeEnvironmentResult[]): void { outputResult(themePreviewJsonOutputSchema.encode({environments})) } From 44cc4db42320c9a9227373612524928b2b9fbcc4 Mon Sep 17 00:00:00 2001 From: Gonzalo Riestra Date: Wed, 14 Oct 2026 10:41:15 +0200 Subject: [PATCH 6/6] Warn when opening a theme preview returns false --- packages/cli/oclif.manifest.json | 7 +- .../src/cli/commands/theme/preview.test.ts | 154 +++++++++++++++++- .../theme/src/cli/commands/theme/preview.ts | 24 ++- .../src/cli/services/dev-override/codec.ts | 5 - .../cli/services/dev-override/result.test.ts | 10 +- .../src/cli/services/dev-override/result.ts | 14 +- .../{codec.test.ts => types.test.ts} | 7 +- .../theme/src/cli/utilities/theme-command.ts | 19 ++- 8 files changed, 203 insertions(+), 37 deletions(-) delete mode 100644 packages/theme/src/cli/services/dev-override/codec.ts rename packages/theme/src/cli/services/dev-override/{codec.test.ts => types.test.ts} (85%) diff --git a/packages/cli/oclif.manifest.json b/packages/cli/oclif.manifest.json index 8da6e94555f..00d5278b318 100644 --- a/packages/cli/oclif.manifest.json +++ b/packages/cli/oclif.manifest.json @@ -12238,7 +12238,12 @@ "hiddenAliases": [ ], "id": "theme:preview", - "multiEnvironmentsFlags": null, + "multiEnvironmentsFlags": [ + "store", + "password", + "theme", + "overrides" + ], "pluginAlias": "@shopify/cli", "pluginName": "@shopify/cli", "pluginType": "core", diff --git a/packages/theme/src/cli/commands/theme/preview.test.ts b/packages/theme/src/cli/commands/theme/preview.test.ts index 02e52fe3f3d..a356221c016 100644 --- a/packages/theme/src/cli/commands/theme/preview.test.ts +++ b/packages/theme/src/cli/commands/theme/preview.test.ts @@ -5,15 +5,20 @@ import {findOrSelectTheme} from '../../utilities/theme-selector.js' import {ensureThemeStore} from '../../utilities/theme-store.js' import {openURL} from '@shopify/cli-kit/node/system' import {runWithCommandEventsForCommand} from '@shopify/cli-kit/node/command-events' -import {renderSuccess} from '@shopify/cli-kit/node/ui' +import {renderSuccess, renderWarning} from '@shopify/cli-kit/node/ui' import {buildTheme} from '@shopify/cli-kit/node/themes/factories' import {recordEvent} from '@shopify/cli-kit/node/analytics' import {ensureAuthenticatedThemes} from '@shopify/cli-kit/node/session' import {withCapturedStandardStreams} from '@shopify/cli-kit/node/testing/output' import {Config} from '@oclif/core' +import {inTemporaryDirectory, writeFile} from '@shopify/cli-kit/node/fs' +import {joinPath} from '@shopify/cli-kit/node/path' import {describe, vi, expect, test, beforeEach} from 'vitest' -vi.mock('@shopify/cli-kit/node/ui') +vi.mock('@shopify/cli-kit/node/ui', async (importOriginal) => { + const actual = await importOriginal() + return {...actual, renderSuccess: vi.fn(), renderWarning: vi.fn(actual.renderWarning)} +}) vi.mock('@shopify/cli-kit/node/system', async (importOriginal) => ({ ...(await importOriginal()), openURL: vi.fn(), @@ -53,6 +58,36 @@ describe('Preview', () => { vi.mocked(devWithOverrideFile).mockResolvedValue(result) }) + test.each(['default', 'staging'].flatMap((environment) => [false, true].map((json) => ({environment, json}))))( + 'uses the remembered store for the $environment environment with json=$json', + async ({environment, json}) => { + const environments = await import('@shopify/cli-kit/node/environments') + const actual = await vi.importActual('@shopify/cli-kit/node/environments') + vi.mocked(environments.loadEnvironment).mockImplementation(actual.loadEnvironment) + vi.mocked(environments.environmentFilePath).mockImplementation(actual.environmentFilePath) + await inTemporaryDirectory(async (directory) => { + await writeFile( + joinPath(directory, 'shopify.theme.toml'), + `[environments.${environment}]\ntheme = "2"\noverrides = "/path/to/overrides.json"\n`, + ) + const config = new Config({root: __dirname}) + await config.load() + const argv = [ + '--path', + directory, + ...(environment === 'default' ? [] : ['--environment', environment]), + ...(json ? ['--json'] : []), + ] + await withCapturedStandardStreams(async () => { + await runWithCommandEventsForCommand(argv, () => new Preview(argv, config).run()) + }) + expect(ensureThemeStore).toHaveBeenCalledWith(expect.objectContaining({store: undefined})) + expect(ensureAuthenticatedThemes).toHaveBeenCalledWith(adminSession.storeFqdn, undefined) + expect(devWithOverrideFile).toHaveBeenCalledWith(expect.objectContaining({adminSession, themeId: '2'})) + }) + }, + ) + test('calls devWithOverrideFile with minimum options passed into the command', async () => { const expectedTheme = buildTheme({id: 5, name: 'Expected Theme', role: 'unpublished'})! vi.mocked(findOrSelectTheme).mockResolvedValue(expectedTheme) @@ -204,7 +239,7 @@ describe('Preview', () => { .split('\n') .map((line) => JSON.parse(line)) expect(events).toMatchObject([ - {type: 'diagnostic', level: 'warning', message: `Failed to open theme preview.\n${error.stack}`}, + {type: 'diagnostic', level: 'warning', message: `Failed to open theme preview.\n\n${error.stack}`}, ]) expect(JSON.parse(stdout())).toEqual({ status: 'success', @@ -212,6 +247,43 @@ describe('Preview', () => { }) }) }) + + test.each([false, true])('reports the browser open result in JSON diagnostics (opened: %s)', async (opened) => { + vi.mocked(openURL).mockResolvedValue(opened) + + await withCapturedStandardStreams(async ({stdout, stderr}) => { + await runWithCommandEventsForCommand(['--json'], () => + run(['--overrides=/path/to/overrides.json', '--theme=2', '--json', '--open']), + ) + + expect(JSON.parse(stdout())).toEqual({ + status: 'success', + preview: {id: result.preview_identifier, url: result.url}, + }) + if (opened) { + expect(stderr()).toBe('') + } else { + expect(JSON.parse(stderr())).toMatchObject({ + type: 'diagnostic', + level: 'warning', + message: expect.stringContaining(`Open this URL manually: ${result.url}`), + }) + } + }) + expect(openURL).toHaveBeenCalledWith(result.url) + }) + + test('warns in text mode when the browser could not be opened', async () => { + vi.mocked(openURL).mockResolvedValue(false) + + await run(['--overrides=/path/to/overrides.json', '--theme=2', '--open']) + + expect(renderWarning).toHaveBeenCalledWith({ + headline: 'Failed to open theme preview.', + body: expect.stringContaining(`Open this URL manually: ${result.url}`), + }) + expect(renderSuccess).toHaveBeenCalledOnce() + }) }) test('returns one explicit environment result', async () => { @@ -249,5 +321,81 @@ test.each([['--theme=2'], ['--overrides=/path/to/overrides.json']])( 'Specify both --theme and --overrides, either as flags or in an environment.', ) expect(devWithOverrideFile).not.toHaveBeenCalled() + expect(ensureAuthenticatedThemes).not.toHaveBeenCalled() + }, +) + +test.each(['none', 'validation', 'store', 'execution'])( + 'retains every preview environment with %s failures', + async (failure) => { + const {loadEnvironment} = await import('@shopify/cli-kit/node/environments') + vi.mocked(loadEnvironment).mockImplementation(async (environment) => ({ + ...(failure === 'store' && environment === 'staging' ? {} : {store: `${environment}.myshopify.com`}), + password: 'token', + theme: '2', + ...(failure === 'validation' && environment === 'staging' ? {} : {overrides: '/path/to/overrides.json'}), + })) + vi.mocked(ensureThemeStore).mockImplementation(({store}) => store!) + vi.mocked(ensureAuthenticatedThemes).mockImplementation(async (storeFqdn) => ({storeFqdn, token: 'token'})) + vi.mocked(findOrSelectTheme).mockResolvedValue(namedTheme) + vi.mocked(devWithOverrideFile).mockImplementation(async ({adminSession}) => { + if (failure === 'execution' && adminSession.storeFqdn === 'staging.myshopify.com') { + throw new Error('Preview failed') + } + return result + }) + const config = new Config({root: __dirname}) + await config.load() + const argv = ['--json', '-e', 'staging', '-e', 'production'] + const missingFlag = failure === 'store' ? 'store' : 'overrides' + const previousExitCode = process.exitCode + process.exitCode = 0 + try { + await withCapturedStandardStreams(async ({stdout}) => { + await runWithCommandEventsForCommand(argv, () => new Preview(argv, config).run()) + expect(JSON.parse(stdout())).toEqual({ + environments: ['staging', 'production'].map((environment) => ({ + environment, + ...(environment === 'staging' && failure !== 'none' + ? { + error: expect.objectContaining({ + message: expect.stringContaining(failure === 'execution' ? 'Preview failed' : missingFlag), + }), + } + : {result: {status: 'success', preview: {id: result.preview_identifier, url: result.url}}}), + })), + }) + }) + expect(ensureAuthenticatedThemes).toHaveBeenCalledTimes(['validation', 'store'].includes(failure) ? 1 : 2) + expect(devWithOverrideFile).toHaveBeenCalledTimes(['validation', 'store'].includes(failure) ? 1 : 2) + expect(process.exitCode).toBe(failure === 'none' ? 0 : 1) + } finally { + process.exitCode = previousExitCode + } + }, +) + +test.each(['theme', 'overrides'])( + 'validates missing %s in a JSON environment before authentication', + async (missing) => { + const {loadEnvironment} = await import('@shopify/cli-kit/node/environments') + vi.mocked(loadEnvironment).mockResolvedValue({ + store: adminSession.storeFqdn, + ...(missing === 'theme' ? {overrides: '/path/to/overrides.json'} : {theme: '2'}), + }) + const previousExitCode = process.exitCode + try { + await withCapturedStandardStreams(async ({stdout}) => { + await runWithCommandEventsForCommand(['--json'], () => run(['--environment=staging', '--json'])) + expect(JSON.parse(stdout())).toMatchObject({ + environments: [{environment: 'staging', error: {message: expect.stringContaining(missing)}}], + }) + }) + expect(process.exitCode).toBe(1) + expect(ensureAuthenticatedThemes).not.toHaveBeenCalled() + expect(devWithOverrideFile).not.toHaveBeenCalled() + } finally { + process.exitCode = previousExitCode + } }, ) diff --git a/packages/theme/src/cli/commands/theme/preview.ts b/packages/theme/src/cli/commands/theme/preview.ts index 9faf50bc298..1e651055960 100644 --- a/packages/theme/src/cli/commands/theme/preview.ts +++ b/packages/theme/src/cli/commands/theme/preview.ts @@ -58,23 +58,23 @@ export default class Preview extends ThemeCommand { }), } - static multiEnvironmentsFlags: RequiredFlags = null + static multiEnvironmentsFlags: RequiredFlags = ['store', 'password', 'theme', 'overrides'] async command(flags: PreviewFlags, adminSession: AdminSession, multiEnvironment = false) { - if (!flags.theme || !flags.overrides) { - throw new AbortError('Specify both --theme and --overrides, either as flags or in an environment.') - } const theme = await findOrSelectTheme(adminSession, {filter: {theme: flags.theme}}) const result = await devWithOverrideFile({ adminSession, - overrideJson: flags.overrides, + overrideJson: flags.overrides!, themeId: theme.id.toString(), previewIdentifier: flags['preview-id'], password: flags.password, }) const format = flags.json ? 'json' : 'text' if (flags.open) { - await openURL(result.url).catch((error: Error) => renderThemePreviewOpenError(error, format)) + const opened = await openURL(result.url).catch((error: Error) => renderThemePreviewOpenError(error)) + if (opened === false) { + renderThemePreviewOpenError(new Error(`Could not open the browser. Open this URL manually: ${result.url}`)) + } } if (!multiEnvironment || !flags.json) renderThemePreviewResult(result, format, Boolean(flags['preview-id'])) return result @@ -83,4 +83,16 @@ export default class Preview extends ThemeCommand { protected renderEnvironmentResults(environments: ThemeEnvironmentResult[]): void { outputResult(themePreviewJsonOutputSchema.encode({environments})) } + + protected validateCommandFlags(flags: PreviewFlags): void { + if (!flags.theme || !flags.overrides) { + throw new AbortError('Specify both --theme and --overrides, either as flags or in an environment.') + } + } + + protected requiredFlagsForSingleEnvironment( + requiredFlags: Exclude, + ): Exclude { + return super.requiredFlagsForSingleEnvironment(requiredFlags).filter((flag) => flag !== 'store') + } } diff --git a/packages/theme/src/cli/services/dev-override/codec.ts b/packages/theme/src/cli/services/dev-override/codec.ts deleted file mode 100644 index fa029988973..00000000000 --- a/packages/theme/src/cli/services/dev-override/codec.ts +++ /dev/null @@ -1,5 +0,0 @@ -import {themePreviewJsonOutputSchema, type ThemePreviewResult} from './types.js' - -export function encodeThemePreviewResult(result: ThemePreviewResult): string { - return themePreviewJsonOutputSchema.encode(result) -} diff --git a/packages/theme/src/cli/services/dev-override/result.test.ts b/packages/theme/src/cli/services/dev-override/result.test.ts index 30daa04252b..95cbbc730ef 100644 --- a/packages/theme/src/cli/services/dev-override/result.test.ts +++ b/packages/theme/src/cli/services/dev-override/result.test.ts @@ -25,24 +25,26 @@ test.each([false, true])('preserves the success banner with updated=%s', (update test('preserves the browser warning in text mode', () => { const error = new Error('Browser unavailable') - renderThemePreviewOpenError(error, 'text') + renderThemePreviewOpenError(error) expect(renderWarning).toHaveBeenCalledWith({headline: 'Failed to open theme preview.', body: error.stack}) }) test('sends browser failures as diagnostic events to stderr', async () => { + const {renderWarning: actualRenderWarning} = + await vi.importActual('@shopify/cli-kit/node/ui') + vi.mocked(renderWarning).mockImplementationOnce(actualRenderWarning) const error = new Error('Browser unavailable') await withCapturedStandardStreams(async ({stdout, stderr}) => { - await runWithCommandEventsForCommand(['--json'], () => renderThemePreviewOpenError(error, 'json')) + await runWithCommandEventsForCommand(['--json'], () => renderThemePreviewOpenError(error)) expect(stdout()).toBe('') expect(JSON.parse(stderr())).toMatchObject({ type: 'diagnostic', level: 'warning', - message: `Failed to open theme preview.\n${error.stack}`, + message: `Failed to open theme preview.\n\n${error.stack}`, }) }) - expect(renderWarning).not.toHaveBeenCalled() }) test('writes one JSON object to stdout outside the command event context', async () => { diff --git a/packages/theme/src/cli/services/dev-override/result.ts b/packages/theme/src/cli/services/dev-override/result.ts index 985691e26d4..1364ec5b785 100644 --- a/packages/theme/src/cli/services/dev-override/result.ts +++ b/packages/theme/src/cli/services/dev-override/result.ts @@ -1,12 +1,10 @@ -import {type ThemePreviewResult} from './types.js' -import {encodeThemePreviewResult} from './codec.js' +import {themePreviewJsonOutputSchema, type ThemePreviewResult} from './types.js' import {outputResult} from '@shopify/cli-kit/node/output' import {renderSuccess, renderWarning} from '@shopify/cli-kit/node/ui' -import {emitCommandEvent} from '@shopify/cli-kit/node/command-events' export function renderThemePreviewResult(result: ThemePreviewResult, format: 'text' | 'json', updated: boolean): void { if (format === 'json') { - outputResult(encodeThemePreviewResult(result)) + outputResult(themePreviewJsonOutputSchema.encode(result)) return } @@ -22,12 +20,8 @@ export function renderThemePreviewResult(result: ThemePreviewResult, format: 'te }) } -export function renderThemePreviewOpenError(error: Error, format: 'text' | 'json'): void { +export function renderThemePreviewOpenError(error: Error): void { const headline = 'Failed to open theme preview.' const body = error.stack ?? error.message - if (format === 'json') { - emitCommandEvent({type: 'diagnostic', level: 'warning', message: `${headline}\n${body}`}) - } else { - renderWarning({headline, body}) - } + renderWarning({headline, body}) } diff --git a/packages/theme/src/cli/services/dev-override/codec.test.ts b/packages/theme/src/cli/services/dev-override/types.test.ts similarity index 85% rename from packages/theme/src/cli/services/dev-override/codec.test.ts rename to packages/theme/src/cli/services/dev-override/types.test.ts index 688b3d07828..b27456de879 100644 --- a/packages/theme/src/cli/services/dev-override/codec.test.ts +++ b/packages/theme/src/cli/services/dev-override/types.test.ts @@ -1,11 +1,10 @@ -import {encodeThemePreviewResult} from './codec.js' import {themePreviewJsonOutputSchema} from './types.js' import {expect, test} from 'vitest' const result = {url: 'https://abc123.shopifypreview.com', preview_identifier: 'abc123'} test('projects the native preview response to the strict public result', () => { - expect(JSON.parse(encodeThemePreviewResult(result))).toEqual({ + expect(JSON.parse(themePreviewJsonOutputSchema.encode(result))).toEqual({ status: 'success', preview: {id: 'abc123', url: result.url}, }) @@ -13,7 +12,7 @@ test('projects the native preview response to the strict public result', () => { test('omits internal API fields', () => { const response = {...result, internal: 'private'} - expect(JSON.parse(encodeThemePreviewResult(response))).toEqual({ + expect(JSON.parse(themePreviewJsonOutputSchema.encode(response))).toEqual({ status: 'success', preview: {id: 'abc123', url: result.url}, }) @@ -29,7 +28,7 @@ test.each([ }) test('validates the opaque preview namespace, URL and strict object fields', () => { - const output = JSON.parse(encodeThemePreviewResult(result)) + const output = JSON.parse(themePreviewJsonOutputSchema.encode(result)) expect(themePreviewJsonOutputSchema.validate(output)).toEqual(output) expect(() => themePreviewJsonOutputSchema.validate({...output, unknown: true})).toThrow() expect(() => themePreviewJsonOutputSchema.validate({...output, preview: {...output.preview, id: ''}})).toThrow() diff --git a/packages/theme/src/cli/utilities/theme-command.ts b/packages/theme/src/cli/utilities/theme-command.ts index bda942b0fd9..06426023589 100644 --- a/packages/theme/src/cli/utilities/theme-command.ts +++ b/packages/theme/src/cli/utilities/theme-command.ts @@ -102,9 +102,10 @@ export default abstract class ThemeCommand extends Command { const collectResults = hasExplicitEnvironment && this.collectsEnvironmentResults(flags) // Check if store flag is required by the command - const storeIsRequired = - requiredFlags !== null && - requiredFlags.some((flag) => (Array.isArray(flag) ? flag.includes('store') : flag === 'store')) + const singleEnvironmentFlags = this.requiredFlagsForSingleEnvironment(requiredFlags ?? []) + const storeIsRequired = singleEnvironmentFlags.some((flag) => + Array.isArray(flag) ? flag.includes('store') : flag === 'store', + ) // Single environment or no environment if (environments.length <= 1 && !(environments.length > 0 && collectResults)) { @@ -112,6 +113,7 @@ export default abstract class ThemeCommand extends Command { throw new AbortError(`Please provide a valid environment.`) } + this.validateCommandFlags(flags) const session = commandRequiresAuth ? await this.createSession(flags) : undefined const commandName = this.constructor.name.toLowerCase() @@ -145,7 +147,7 @@ export default abstract class ThemeCommand extends Command { const environmentsMap = await this.loadEnvironments(environments, flags, flagsWithoutDefaults) const validationResults = await this.validateEnvironments( environmentsMap, - environments.length === 1 ? (requiredFlags ?? []).filter((flag) => flag !== 'password') : (requiredFlags ?? []), + environments.length === 1 ? singleEnvironmentFlags : (requiredFlags ?? []), commandRequiresAuth, collectResults, ) @@ -180,6 +182,14 @@ export default abstract class ThemeCommand extends Command { } } + protected validateCommandFlags(_flags: FlagValues): void {} + + protected requiredFlagsForSingleEnvironment( + requiredFlags: Exclude, + ): Exclude { + return requiredFlags.filter((flag) => flag !== 'password') + } + protected collectsEnvironmentResults(flags: FlagValues): boolean { const command = this.constructor as unknown as {jsonOutputSchema?: unknown} return Boolean(flags.json && command.jsonOutputSchema) @@ -357,6 +367,7 @@ export default abstract class ThemeCommand extends Command { continue } try { + this.validateCommandFlags(flags) super.validateNonTTYFlags(flags) } catch (error) { if (!collectResults) throw error