diff --git a/packages/cli-kit/src/public/node/base-command.test.ts b/packages/cli-kit/src/public/node/base-command.test.ts index ac160e36f8a..d5372564854 100644 --- a/packages/cli-kit/src/public/node/base-command.test.ts +++ b/packages/cli-kit/src/public/node/base-command.test.ts @@ -2,7 +2,7 @@ import Command from './base-command.js' import {Environments} from './environments.js' import {encodeToml as encodeTOML} from './toml/codec.js' import {globalFlags, jsonFlag, requiredIfNonInteractive} from './cli.js' -import {emitCommandEvent} from './command-events.js' +import {emitCommandEvent, runWithCommandEvents} from './command-events.js' import {inTemporaryDirectory, mkdir, writeFile} from './fs.js' import {joinPath, resolvePath, cwd} from './path.js' import {mockAndCaptureOutput} from './testing/output.js' @@ -10,7 +10,7 @@ import {unstyled} from './output.js' import {defineJsonOutputSchema} from './json-output-schema.js' import {zod} from './schema.js' import {afterEach, beforeEach, describe, expect, test, vi} from 'vitest' -import {Flags} from '@oclif/core' +import {Config, Flags} from '@oclif/core' import {Ajv} from 'ajv' let originalStdinIsTTY: boolean | undefined @@ -54,6 +54,7 @@ class MockCommand extends Command { default: 'default stringy', }), password: Flags.string({}), + 'store-password': Flags.string({}), environment: Flags.string({ multiple: true, default: [], @@ -220,6 +221,11 @@ const environmentWithPassword = { password: 'password', } +const environmentWithCredentials = { + password: 'admin-password', + 'store-password': 'storefront-secret', +} + const allEnvironments: Environments = { environments: { validEnvironment, @@ -232,6 +238,7 @@ const allEnvironments: Environments = { environmentMatchingDefault, environmentWithDefaultOverride, environmentWithPassword, + environmentWithCredentials, }, } @@ -754,6 +761,31 @@ describe('applying environments', async () => { `) }) + runTestInTmpDir('reports environment settings as JSON diagnostics with masked passwords', async (tmpDir) => { + const sink = vi.fn() + const output = mockAndCaptureOutput() + output.clear() + await runWithCommandEvents({outputMode: 'json', sink}, async () => { + const config = new Config({root: __dirname}) + await config.load() + const command = new MockCommand(['--path', tmpDir, '--environment', 'environmentWithCredentials'], config) + await command.run() + }) + + expect(sink).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'diagnostic', + level: 'info', + message: + 'Using applicable flags from environmentWithCredentials environment:\npassword: ********word\nstore-password: ********cret', + }), + ) + for (const credential of Object.values(environmentWithCredentials)) { + expect(JSON.stringify(sink.mock.calls)).not.toContain(credential) + } + expect(output.info()).toBe('') + }) + runTestInTmpDir('reports environment settings with masked passwords', async (tmpDir: string) => { // Given const outputMock = mockAndCaptureOutput() diff --git a/packages/cli-kit/src/public/node/base-command.ts b/packages/cli-kit/src/public/node/base-command.ts index ad894ebb811..8a23e55beb2 100644 --- a/packages/cli-kit/src/public/node/base-command.ts +++ b/packages/cli-kit/src/public/node/base-command.ts @@ -1,7 +1,7 @@ import {isDevelopment} from './context/local.js' import {addPublicMetadata} from './metadata.js' import {AbortError} from './error.js' -import {runWithCommandEventsForCommand} from './command-events.js' +import {commandEventOutputMode, emitCommandEvent, runWithCommandEventsForCommand} from './command-events.js' import {outputContent, outputResult, outputToken} from './output.js' import {setCurrentSessionAlias} from './session.js' import {terminalSupportsPrompting} from './system.js' @@ -145,7 +145,7 @@ abstract class BaseCommand extends Command { result = await this.resultWithEnvironment(result, options, argv) await setCurrentSessionAlias(result.flags['auth-alias']) await addFromParsedFlags(result.flags) - this.failMissingNonTTYFlagRequirements(result.flags, this.applicableNonTTYFlagRequirements(result.flags)) + this.validateNonTTYFlags(result.flags) return {...result, ...{argv: result.argv as string[]}} } @@ -154,6 +154,10 @@ abstract class BaseCommand extends Command { return undefined } + protected validateNonTTYFlags(flags: FlagOutput): void { + this.failMissingNonTTYFlagRequirements(flags, this.applicableNonTTYFlagRequirements(flags)) + } + protected failMissingNonTTYFlags(flags: FlagOutput, requiredFlags: string[]): void { this.failMissingNonTTYFlagRequirements( flags, @@ -322,13 +326,21 @@ function reportEnvironmentApplication< const userSpecifiedThisFlag = Object.prototype.hasOwnProperty.call(noDefaultsFlags, name) const environmentContainsFlag = Object.prototype.hasOwnProperty.call(environment, name) if (!userSpecifiedThisFlag && environmentContainsFlag) { - const valueToReport = name === 'password' ? `********${value.substr(-4)}` : value + const valueToReport = name === 'password' || name === 'store-password' ? `********${value.substr(-4)}` : value changes[name] = valueToReport } } if (Object.keys(changes).length === 0) return const items = Object.entries(changes).map(([name, value]) => `${name}: ${value}`) + if (commandEventOutputMode() === 'json') { + emitCommandEvent({ + type: 'diagnostic', + level: 'info', + message: `Using applicable flags from ${environmentName} environment:\n${items.join('\n')}`, + }) + return + } // eslint-disable-next-line no-void void import('./ui.js').then(({renderInfo}) => { renderInfo({ diff --git a/packages/cli/README.md b/packages/cli/README.md index 01447abaa18..e86afdd0f26 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -8022,6 +8022,106 @@ FLAGS DESCRIPTION Displays information about your theme environment, including your current store. Can also retrieve information about a specific theme. + + Use `--json-schema` to print the result, error, and event schemas. + + Output from `--json` conforms to the `ThemeInfoResult` schema. + + ```json + { + "anyOf": [ + { + "$ref": "#/definitions/ThemeInfoThemeResult" + }, + { + "$ref": "#/definitions/ThemeEnvironmentInfo" + } + ], + "title": "ThemeInfoResult", + "definitions": { + "ThemeInfoTheme": { + "type": "object", + "properties": { + "id": { + "type": "number" + }, + "name": { + "type": "string" + }, + "role": { + "type": "string" + }, + "shop": { + "type": "string" + }, + "preview_url": { + "type": "string" + }, + "editor_url": { + "type": "string" + } + }, + "required": [ + "id", + "name", + "role", + "shop", + "preview_url", + "editor_url" + ], + "additionalProperties": false + }, + "ThemeInfoThemeResult": { + "type": "object", + "properties": { + "theme": { + "$ref": "#/definitions/ThemeInfoTheme" + } + }, + "required": [ + "theme" + ], + "additionalProperties": false + }, + "ThemeEnvironmentInfo": { + "type": "object", + "properties": { + "store": { + "type": "string" + }, + "development_theme_id": { + "type": [ + "number", + "null" + ] + }, + "cli_version": { + "type": "string" + }, + "os": { + "type": "string" + }, + "shell": { + "type": "string" + }, + "node_version": { + "type": "string" + } + }, + "required": [ + "store", + "development_theme_id", + "cli_version", + "os", + "shell", + "node_version" + ], + "additionalProperties": false + } + }, + "$schema": "http://json-schema.org/draft-07/schema#" + } + ``` ``` ## `shopify theme init [name] [flags]` diff --git a/packages/cli/oclif.manifest.json b/packages/cli/oclif.manifest.json index 89a92620102..0e1205a87f5 100644 --- a/packages/cli/oclif.manifest.json +++ b/packages/cli/oclif.manifest.json @@ -10124,7 +10124,8 @@ "args": { }, "customPluginName": "@shopify/theme", - "description": "Displays information about your theme environment, including your current store. Can also retrieve information about a specific theme.", + "description": "Displays information about your theme environment, including your current store. Can also retrieve information about a specific theme.\n\nUse `--json-schema` to print the result, error, and event schemas.\n\nOutput from `--json` conforms to the `ThemeInfoResult` schema.\n\n```json\n{\n \"anyOf\": [\n {\n \"$ref\": \"#/definitions/ThemeInfoThemeResult\"\n },\n {\n \"$ref\": \"#/definitions/ThemeEnvironmentInfo\"\n }\n ],\n \"title\": \"ThemeInfoResult\",\n \"definitions\": {\n \"ThemeInfoTheme\": {\n \"type\": \"object\",\n \"properties\": {\n \"id\": {\n \"type\": \"number\"\n },\n \"name\": {\n \"type\": \"string\"\n },\n \"role\": {\n \"type\": \"string\"\n },\n \"shop\": {\n \"type\": \"string\"\n },\n \"preview_url\": {\n \"type\": \"string\"\n },\n \"editor_url\": {\n \"type\": \"string\"\n }\n },\n \"required\": [\n \"id\",\n \"name\",\n \"role\",\n \"shop\",\n \"preview_url\",\n \"editor_url\"\n ],\n \"additionalProperties\": false\n },\n \"ThemeInfoThemeResult\": {\n \"type\": \"object\",\n \"properties\": {\n \"theme\": {\n \"$ref\": \"#/definitions/ThemeInfoTheme\"\n }\n },\n \"required\": [\n \"theme\"\n ],\n \"additionalProperties\": false\n },\n \"ThemeEnvironmentInfo\": {\n \"type\": \"object\",\n \"properties\": {\n \"store\": {\n \"type\": \"string\"\n },\n \"development_theme_id\": {\n \"type\": [\n \"number\",\n \"null\"\n ]\n },\n \"cli_version\": {\n \"type\": \"string\"\n },\n \"os\": {\n \"type\": \"string\"\n },\n \"shell\": {\n \"type\": \"string\"\n },\n \"node_version\": {\n \"type\": \"string\"\n }\n },\n \"required\": [\n \"store\",\n \"development_theme_id\",\n \"cli_version\",\n \"os\",\n \"shell\",\n \"node_version\"\n ],\n \"additionalProperties\": false\n }\n },\n \"$schema\": \"http://json-schema.org/draft-07/schema#\"\n}\n```", + "descriptionWithMarkdown": "Displays information about your theme environment, including your current store. Can also retrieve information about a specific theme.", "enableJsonFlag": false, "flags": { "auth-alias": { 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 a74387bce8f..831a8691a85 100644 --- a/packages/eslint-plugin-cli/rules/json-output-command-exceptions.js +++ b/packages/eslint-plugin-cli/rules/json-output-command-exceptions.js @@ -46,7 +46,6 @@ const commandExceptions = [ 'packages/theme/src/cli/commands/theme/check.ts', 'packages/theme/src/cli/commands/theme/delete.ts', 'packages/theme/src/cli/commands/theme/duplicate.ts', - 'packages/theme/src/cli/commands/theme/info.ts', 'packages/theme/src/cli/commands/theme/init.ts', 'packages/theme/src/cli/commands/theme/list.ts', 'packages/theme/src/cli/commands/theme/metafields/pull.ts', diff --git a/packages/theme/src/cli/commands/theme/info.test.ts b/packages/theme/src/cli/commands/theme/info.test.ts index 558289e1477..af519720bd4 100644 --- a/packages/theme/src/cli/commands/theme/info.test.ts +++ b/packages/theme/src/cli/commands/theme/info.test.ts @@ -1,15 +1,31 @@ import Info from './info.js' -import {themeEnvironmentInfoJSON, fetchDevInfo, fetchThemeInfo, formatThemeInfo} from '../../services/info.js' -import {describe, vi, expect, test} from 'vitest' +import {fetchThemeInfo, getThemeEnvironmentInfo} from '../../services/info.js' +import {themeInfoJsonOutputSchema} from '../../services/info/types.js' +import * as infoResult from '../../services/info/result.js' +import {beforeEach, describe, expect, test, vi} from 'vitest' import {Config} from '@oclif/core' import {ensureAuthenticatedThemes} from '@shopify/cli-kit/node/session' -import {outputResult} from '@shopify/cli-kit/node/output' -import {renderInfo} from '@shopify/cli-kit/node/ui' +import {mockAndCaptureOutput} from '@shopify/cli-kit/node/testing/output' +import {renderConcurrent, renderInfo} from '@shopify/cli-kit/node/ui' +import {loadEnvironment} from '@shopify/cli-kit/node/environments' +import {recordTiming} from '@shopify/cli-kit/node/analytics' +import {readFileSync} from 'node:fs' + +import type {Writable} from 'stream' vi.mock('../../services/info.js') vi.mock('@shopify/cli-kit/node/session') -vi.mock('@shopify/cli-kit/node/output') vi.mock('@shopify/cli-kit/node/ui') +vi.mock('@shopify/cli-kit/node/environments') +vi.mock('@shopify/cli-kit/node/analytics', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + recordTiming: vi.fn(), + recordEvent: vi.fn(), + compileData: vi.fn(() => ({timings: {}, errors: {}, retries: {}, events: {}})), + } +}) const CommandConfig = new Config({root: __dirname}) @@ -18,86 +34,303 @@ const session = { storeFqdn: 'my-shop.myshopify.com', } -describe('Info', () => { - async function run(argv: string[]) { - await CommandConfig.load() - vi.mocked(ensureAuthenticatedThemes).mockResolvedValue(session) - const info = new Info(['--store=my-shop.myshopify.com', '--password=test-password', ...argv], CommandConfig) - await info.run() +const themeResult = { + theme: { + id: 123, + name: 'my theme', + role: 'live', + shop: 'my-shop.myshopify.com', + preview_url: 'https://my-shop.myshopify.com/preview', + editor_url: 'https://my-shop.myshopify.com/editor', + }, +} + +const secondThemeResult = { + theme: { + id: 456, + name: 'second theme', + role: 'live', + shop: 'my-shop.myshopify.com', + preview_url: 'https://my-shop.myshopify.com/preview', + editor_url: 'https://my-shop.myshopify.com/editor', + }, +} + +const environmentResult = { + store: 'my-shop.myshopify.com', + development_theme_id: null, + cli_version: '3.91.0', + os: 'darwin-arm64', + shell: '/bin/zsh', + node_version: 'v24.15.0', +} + +function restoreUnitTestEnvironment(value: string | undefined): void { + process.env.SHOPIFY_UNIT_TEST = value +} + +function captureStandardStreams() { + const stdout: string[] = [] + const stderr: string[] = [] + + const stdoutSpy = vi.spyOn(process.stdout, 'write').mockImplementation(((chunk: string | Uint8Array) => { + stdout.push(typeof chunk === 'string' ? chunk : Buffer.from(chunk).toString('utf8')) + return true + }) as typeof process.stdout.write) + const stderrSpy = vi.spyOn(process.stderr, 'write').mockImplementation(((chunk: string | Uint8Array) => { + stderr.push(typeof chunk === 'string' ? chunk : Buffer.from(chunk).toString('utf8')) + return true + }) as typeof process.stderr.write) + + return { + stdout: () => stdout.join(''), + stderr: () => stderr.join(''), + restore: () => { + stdoutSpy.mockRestore() + stderrSpy.mockRestore() + }, } +} - describe('when theme or development flag is provided', () => { - const mockThemeInfo = { - theme: { - id: 123, - name: 'my theme', - role: 'live', - shop: 'my-shop.myshopify.com', - preview_url: 'https://my-shop.myshopify.com/preview', - editor_url: 'https://my-shop.myshopify.com/editor', - }, +async function run(argv: string[]) { + await CommandConfig.load() + vi.mocked(ensureAuthenticatedThemes).mockResolvedValue(session) + const info = new Info(['--store=my-shop.myshopify.com', '--password=test-password', ...argv], CommandConfig) + await info.run() +} + +async function runMultiEnvironment(argv: string[]) { + await CommandConfig.load() + vi.mocked(ensureAuthenticatedThemes).mockResolvedValue(session) + const info = new Info(['--password=test-password', ...argv], CommandConfig) + await info.run() +} + +function executeConcurrentProcessesInOrder() { + vi.mocked(renderConcurrent).mockImplementation(async ({processes}) => { + for (const process of processes) { + // eslint-disable-next-line no-await-in-loop + await process.action({} as Writable, {} as Writable, {} as never) } + }) +} +describe('Info', () => { + beforeEach(() => { + mockAndCaptureOutput().clear() + vi.mocked(loadEnvironment).mockReset() + vi.mocked(renderConcurrent).mockReset() + vi.mocked(recordTiming).mockClear() + }) + + describe('when theme or development flag is provided', () => { test('outputs JSON when --json flag is passed', async () => { - vi.mocked(fetchThemeInfo).mockResolvedValue(mockThemeInfo) + vi.mocked(fetchThemeInfo).mockResolvedValue(themeResult) await run(['--theme', '123', '--json']) expect(fetchThemeInfo).toHaveBeenCalled() - expect(outputResult).toHaveBeenCalledWith(JSON.stringify(mockThemeInfo, null, 2)) + expect(JSON.parse(mockAndCaptureOutput().output())).toEqual(themeResult) expect(renderInfo).not.toHaveBeenCalled() }) test('renders formatted info when no --json flag is passed', async () => { - const mockFormatted = { - customSections: [{title: 'Theme Details', body: {tabularData: [], firstColumnSubdued: true}}], - } - vi.mocked(fetchThemeInfo).mockResolvedValue(mockThemeInfo) - vi.mocked(formatThemeInfo).mockResolvedValue(mockFormatted) + vi.mocked(fetchThemeInfo).mockResolvedValue(themeResult) await run(['--theme', '123']) expect(fetchThemeInfo).toHaveBeenCalled() - expect(formatThemeInfo).toHaveBeenCalled() expect(renderInfo).toHaveBeenCalled() - expect(outputResult).not.toHaveBeenCalled() + expect(mockAndCaptureOutput().output()).toBe('') }) - test('throws error when theme is not found', async () => { + test('throws an error when theme is not found without rendering a result', async () => { vi.mocked(fetchThemeInfo).mockResolvedValue(undefined) - await expect(run(['--theme', '999'])).rejects.toThrow() + await expect(run(['--theme', '999'])).rejects.toThrow('Theme not found!') + expect(renderInfo).not.toHaveBeenCalled() + expect(mockAndCaptureOutput().output()).toBe('') }) }) describe('when no theme or development flag is provided', () => { test('outputs JSON when --json flag is passed', async () => { - const mockDevInfo = { - store: 'my-shop.myshopify.com', - development_theme_id: null, - cli_version: '3.91.0', - os: 'darwin-arm64', - shell: '/bin/zsh', - node_version: 'v23.6.1', - } - vi.mocked(themeEnvironmentInfoJSON).mockReturnValue(mockDevInfo) + vi.mocked(getThemeEnvironmentInfo).mockReturnValue({result: environmentResult, developmentTheme: undefined}) await run(['--json']) - expect(themeEnvironmentInfoJSON).toHaveBeenCalled() - expect(outputResult).toHaveBeenCalledWith(JSON.stringify(mockDevInfo, null, 2)) + expect(getThemeEnvironmentInfo).toHaveBeenCalledWith({cliVersion: expect.any(String)}) + expect(JSON.parse(mockAndCaptureOutput().output())).toEqual(environmentResult) expect(renderInfo).not.toHaveBeenCalled() }) test('renders info when no --json flag is passed', async () => { - const mockSections = [{title: 'Theme Configuration', body: {tabularData: [], firstColumnSubdued: true}}] - vi.mocked(fetchDevInfo).mockResolvedValue(mockSections) + vi.mocked(getThemeEnvironmentInfo).mockReturnValue({result: environmentResult, developmentTheme: undefined}) await run([]) - expect(fetchDevInfo).toHaveBeenCalled() + expect(getThemeEnvironmentInfo).toHaveBeenCalledWith({cliVersion: expect.any(String)}) expect(renderInfo).toHaveBeenCalled() - expect(outputResult).not.toHaveBeenCalled() + expect(mockAndCaptureOutput().output()).toBe('') + }) + }) + + test('defines the JSON output schema', () => { + expect(Info.jsonOutputSchema).toBe(themeInfoJsonOutputSchema) + }) + + test('includes the JSON output schema in the help description', () => { + expect(Info.description).toContain('ThemeInfoResult') + expect(Info.description).toContain('--json-schema') + }) + + test('is removed from the JSON legacy exemption list', () => { + const legacyCommandPaths = readFileSync( + new URL('../../../../../eslint-plugin-cli/rules/json-output-command-exceptions.js', import.meta.url), + 'utf8', + ) + + expect(legacyCommandPaths).not.toContain("'packages/theme/src/cli/commands/theme/info.ts'") + }) + + test('writes the selected theme JSON document to stdout without text on stderr', async () => { + const originalUnitTestEnv = process.env.SHOPIFY_UNIT_TEST + process.env.SHOPIFY_UNIT_TEST = 'false' + vi.resetModules() + const streams = captureStandardStreams() + + try { + const {default: StreamInfo} = await import('./info.js') + const {fetchThemeInfo} = await import('../../services/info.js') + const {ensureAuthenticatedThemes} = await import('@shopify/cli-kit/node/session') + const {Config} = await import('@oclif/core') + const streamConfig = new Config({root: __dirname}) + await streamConfig.load() + vi.mocked(ensureAuthenticatedThemes).mockResolvedValue(session) + vi.mocked(fetchThemeInfo).mockResolvedValue(themeResult) + + await new StreamInfo( + ['--store=my-shop.myshopify.com', '--password=test-password', '--theme', '123', '--json'], + streamConfig, + ).run() + } finally { + streams.restore() + restoreUnitTestEnvironment(originalUnitTestEnv) + } + + const expectedStdout = `{ + "theme": { + "id": 123, + "name": "my theme", + "role": "live", + "shop": "my-shop.myshopify.com", + "preview_url": "https://my-shop.myshopify.com/preview", + "editor_url": "https://my-shop.myshopify.com/editor" + } +}\n` + + expect(streams.stdout()).toBe(expectedStdout) + expect(JSON.parse(streams.stdout())).toEqual(themeResult) + expect(streams.stderr()).toBe('') + }) + + test('writes the environment JSON document to stdout without text on stderr', async () => { + const originalUnitTestEnv = process.env.SHOPIFY_UNIT_TEST + process.env.SHOPIFY_UNIT_TEST = 'false' + vi.resetModules() + const streams = captureStandardStreams() + + try { + const {default: StreamInfo} = await import('./info.js') + const {getThemeEnvironmentInfo} = await import('../../services/info.js') + const {ensureAuthenticatedThemes} = await import('@shopify/cli-kit/node/session') + const {Config} = await import('@oclif/core') + const streamConfig = new Config({root: __dirname}) + await streamConfig.load() + vi.mocked(ensureAuthenticatedThemes).mockResolvedValue(session) + vi.mocked(getThemeEnvironmentInfo).mockReturnValue({result: environmentResult, developmentTheme: undefined}) + + await new StreamInfo(['--store=my-shop.myshopify.com', '--password=test-password', '--json'], streamConfig).run() + } finally { + streams.restore() + restoreUnitTestEnvironment(originalUnitTestEnv) + } + + const expectedStdout = `{ + "store": "my-shop.myshopify.com", + "development_theme_id": null, + "cli_version": "3.91.0", + "os": "darwin-arm64", + "shell": "/bin/zsh", + "node_version": "v24.15.0" +}\n` + + expect(streams.stdout()).toBe(expectedStdout) + expect(JSON.parse(streams.stdout())).toEqual(environmentResult) + expect(streams.stderr()).toBe('') + }) + + describe('multi-environment JSON output', () => { + test('emits each environment result as its own existing JSON document', async () => { + vi.mocked(loadEnvironment) + .mockResolvedValueOnce({store: 'store1.myshopify.com', theme: '123'}) + .mockResolvedValueOnce({store: 'store2.myshopify.com', theme: '456'}) + vi.mocked(fetchThemeInfo).mockImplementation(async (_session, options) => + options.theme === '456' ? secondThemeResult : themeResult, + ) + executeConcurrentProcessesInOrder() + const output = mockAndCaptureOutput() + output.clear() + + await runMultiEnvironment(['--environment', 'first', '--environment', 'second', '--json']) + + const expectedOutput = `${JSON.stringify(themeResult, null, 2)}\n${JSON.stringify(secondThemeResult, null, 2)}` + + expect(output.output()).toBe(expectedOutput) + expect(output.output()).not.toContain('environments') + expect(renderInfo).not.toHaveBeenCalled() + }) + + test('renders each environment result independently in text mode', async () => { + vi.mocked(loadEnvironment) + .mockResolvedValueOnce({store: 'store1.myshopify.com', theme: '123'}) + .mockResolvedValueOnce({store: 'store2.myshopify.com', theme: '456'}) + vi.mocked(fetchThemeInfo).mockResolvedValue(themeResult) + executeConcurrentProcessesInOrder() + const output = mockAndCaptureOutput() + output.clear() + + await runMultiEnvironment(['--environment', 'first', '--environment', 'second']) + + expect(output.output()).toBe('') + expect(renderInfo).toHaveBeenCalledTimes(2) + }) + }) + + describe('analytics timing', () => { + test.each([false, true])('records timings around fetching and presenting the result with json=%s', async (json) => { + const pendingFetch = Promise.withResolvers() + const fetchStarted = Promise.withResolvers() + vi.mocked(fetchThemeInfo).mockImplementation(() => { + fetchStarted.resolve() + return pendingFetch.promise + }) + const presentation = vi.spyOn(infoResult, 'renderThemeInfoResult') + + const runningCommand = run(['--theme', '123', ...(json ? ['--json'] : [])]) + await fetchStarted.promise + + expect(vi.mocked(recordTiming).mock.calls).toEqual([['theme-command:info']]) + expect(presentation).not.toHaveBeenCalled() + + pendingFetch.resolve(themeResult) + await runningCommand + + expect(presentation).toHaveBeenCalledWith(themeResult, json ? 'json' : 'text', expect.any(Object)) + expect(vi.mocked(recordTiming).mock.calls).toEqual([['theme-command:info'], ['theme-command:info']]) + expect(vi.mocked(recordTiming).mock.invocationCallOrder[1]).toBeGreaterThan( + presentation.mock.invocationCallOrder[0] ?? Infinity, + ) }) }) }) diff --git a/packages/theme/src/cli/commands/theme/info.ts b/packages/theme/src/cli/commands/theme/info.ts index 71a7dbdf92b..9fa46690f53 100644 --- a/packages/theme/src/cli/commands/theme/info.ts +++ b/packages/theme/src/cli/commands/theme/info.ts @@ -1,21 +1,27 @@ import ThemeCommand from '../../utilities/theme-command.js' -import {fetchThemeInfo, fetchDevInfo, formatThemeInfo, themeEnvironmentInfoJSON} from '../../services/info.js' +import {fetchThemeInfo, getThemeEnvironmentInfo} from '../../services/info.js' +import {renderThemeInfoResult} from '../../services/info/result.js' +import {themeInfoJsonOutputSchema} from '../../services/info/types.js' import {themeFlags} from '../../flags.js' import {Flags} from '@oclif/core' import {AdminSession} from '@shopify/cli-kit/node/session' import {AbortError} from '@shopify/cli-kit/node/error' import {globalFlags, jsonFlag} from '@shopify/cli-kit/node/cli' -import {outputResult} from '@shopify/cli-kit/node/output' -import {renderInfo} from '@shopify/cli-kit/node/ui' import {OutputFlags} from '@oclif/core/interfaces' import {recordTiming} from '@shopify/cli-kit/node/analytics' type InfoFlags = OutputFlags export default class Info extends ThemeCommand { - static description = + static get jsonOutputSchema() { + return themeInfoJsonOutputSchema + } + + static descriptionWithMarkdown = 'Displays information about your theme environment, including your current store. Can also retrieve information about a specific theme.' + static description = this.descriptionForHelp() + static flags = { ...globalFlags, ...jsonFlag, @@ -42,19 +48,10 @@ export default class Info extends ThemeCommand { throw new AbortError('Theme not found!') } - if (flags.json) { - return outputResult(JSON.stringify(output, null, 2)) - } - - const formattedInfo = await formatThemeInfo(output, flags) - renderInfo(formattedInfo) + renderThemeInfoResult(output, flags.json ? 'json' : 'text', flags) } else { - if (flags.json) { - return outputResult(JSON.stringify(themeEnvironmentInfoJSON({cliVersion: this.config.version}), null, 2)) - } - const infoMessage = await fetchDevInfo({cliVersion: this.config.version}) - - renderInfo({customSections: infoMessage}) + const {result, developmentTheme} = getThemeEnvironmentInfo({cliVersion: this.config.version}) + renderThemeInfoResult(result, flags.json ? 'json' : 'text', {developmentTheme}) } recordTiming('theme-command:info') } diff --git a/packages/theme/src/cli/services/info.test.ts b/packages/theme/src/cli/services/info.test.ts index 0f8223ba07e..c0a0330f51a 100644 --- a/packages/theme/src/cli/services/info.test.ts +++ b/packages/theme/src/cli/services/info.test.ts @@ -1,4 +1,5 @@ -import {themeInfoJSON, fetchThemeInfo, themeEnvironmentInfoJSON} from './info.js' +import {getThemeEnvironmentInfo, themeInfoJSON, fetchThemeInfo, themeEnvironmentInfoJSON} from './info.js' +import {themeInfoJsonOutputSchema} from './info/types.js' import {getDevelopmentTheme, getThemeStore} from './local-storage.js' import {DevelopmentThemeManager} from '../utilities/development-theme-manager.js' import {findOrSelectTheme} from '../utilities/theme-selector.js' @@ -47,6 +48,7 @@ describe('info', () => { expect(output).toHaveProperty('theme.shop', session.storeFqdn) expect(output).toHaveProperty('theme.preview_url', expect.stringContaining(session.storeFqdn)) expect(output).toHaveProperty('theme.editor_url', expect.stringContaining(session.storeFqdn)) + expect(themeInfoJsonOutputSchema.validate(output)).toEqual(output) }) describe('themeEnvironmentInfoJSON', () => { @@ -65,6 +67,33 @@ describe('info', () => { expect(output).toHaveProperty('os', expect.stringContaining('-')) expect(output).toHaveProperty('shell', process.env.SHELL ?? 'unknown') expect(output).toHaveProperty('node_version', process.version) + expect(themeInfoJsonOutputSchema.validate(output)).toEqual(output) + }) + }) + + test('uses the JSON fallback values without reading the development theme when no store is configured', () => { + vi.mocked(getThemeStore).mockReturnValue(undefined) + vi.mocked(getDevelopmentTheme).mockImplementation(() => { + throw new Error('The development theme needs a configured store') + }) + + expect(themeEnvironmentInfoJSON({cliVersion: '3.91.0'})).toMatchObject({ + store: 'Not configured', + development_theme_id: null, + cli_version: '3.91.0', + shell: process.env.SHELL ?? 'unknown', + node_version: process.version, + }) + expect(getDevelopmentTheme).not.toHaveBeenCalled() + }) + + test('retains the raw development theme ID for text presentation', () => { + vi.mocked(getThemeStore).mockReturnValue('my-shop.myshopify.com') + vi.mocked(getDevelopmentTheme).mockReturnValue('0') + + expect(getThemeEnvironmentInfo({cliVersion: '3.91.0'})).toMatchObject({ + result: {development_theme_id: null}, + developmentTheme: '0', }) }) diff --git a/packages/theme/src/cli/services/info.ts b/packages/theme/src/cli/services/info.ts index b49c24f93fd..9c5060b882a 100644 --- a/packages/theme/src/cli/services/info.ts +++ b/packages/theme/src/cli/services/info.ts @@ -5,19 +5,7 @@ import {platformAndArch} from '@shopify/cli-kit/node/os' import {themeEditorUrl, themePreviewUrl} from '@shopify/cli-kit/node/themes/urls' import {Theme} from '@shopify/cli-kit/node/themes/types' import {AdminSession} from '@shopify/cli-kit/node/session' -import {AlertCustomSection, InlineToken} from '@shopify/cli-kit/node/ui' -import {recordEvent} from '@shopify/cli-kit/node/analytics' - -interface ThemeInfo { - theme: { - id: number - name: string - role: string - shop: string - editor_url: string - preview_url: string - } -} +import type {ThemeEnvironmentInfo, ThemeInfoThemeResult} from './info/types.js' interface ThemeInfoOptions { store?: string @@ -28,16 +16,7 @@ interface ThemeInfoOptions { json?: boolean } -interface ThemeEnvironmentInfo { - store: string - development_theme_id: number | null - cli_version: string - os: string - shell: string - node_version: string -} - -export function themeInfoJSON(theme: Theme, adminSession: AdminSession): ThemeInfo { +export function themeInfoJSON(theme: Theme, adminSession: AdminSession): ThemeInfoThemeResult { return { theme: { id: theme.id, @@ -51,26 +30,35 @@ export function themeInfoJSON(theme: Theme, adminSession: AdminSession): ThemeIn } export function themeEnvironmentInfoJSON(config: {cliVersion: string}): ThemeEnvironmentInfo { + return getThemeEnvironmentInfo(config).result +} + +export function getThemeEnvironmentInfo(config: {cliVersion: string}): { + result: ThemeEnvironmentInfo + developmentTheme: string | undefined +} { const {platform, arch} = platformAndArch() const store = getThemeStore() - let developmentThemeID = null - if (store) { - developmentThemeID = Number(getDevelopmentTheme()) || null - } + const developmentTheme = store ? getDevelopmentTheme() : undefined + const developmentThemeID = Number(developmentTheme) || null + return { - store: store ?? 'Not configured', - development_theme_id: developmentThemeID, - cli_version: config.cliVersion, - os: `${platform}-${arch}`, - shell: process.env.SHELL ?? 'unknown', - node_version: process.version, + result: { + store: store ?? 'Not configured', + development_theme_id: developmentThemeID, + cli_version: config.cliVersion, + os: `${platform}-${arch}`, + shell: process.env.SHELL ?? 'unknown', + node_version: process.version, + }, + developmentTheme, } } export async function fetchThemeInfo( adminSession: AdminSession, options: ThemeInfoOptions, -): Promise { +): Promise { let theme if (options.development) { const developmentThemeManager = new DevelopmentThemeManager(adminSession) @@ -81,75 +69,3 @@ export async function fetchThemeInfo( } return theme ? themeInfoJSON(theme, adminSession) : undefined } - -export async function fetchDevInfo(config: {cliVersion: string}): Promise { - return [devConfigSection(), await systemInfoSection(config)] -} - -function devConfigSection(): AlertCustomSection { - const store = getThemeStore() ?? 'Not configured' - const developmentTheme = getDevelopmentTheme() - - recordEvent(`theme-command:info:dev-theme-loaded:${developmentTheme}`) - - return tabularSection('Theme Configuration', [ - ['Store', store], - ['Development Theme ID', developmentTheme ? `#${developmentTheme}` : {subdued: 'Not set'}], - ]) -} - -async function systemInfoSection(config: {cliVersion: string}): Promise { - const {platform, arch} = platformAndArch() - return tabularSection('Tooling and System', [ - ['Shopify CLI', config.cliVersion], - ['OS', `${platform}-${arch}`], - ['Shell', process.env.SHELL ?? 'unknown'], - ['Node version', process.version], - ]) -} - -function tabularSection(title: string, data: InlineToken[][]): AlertCustomSection { - return { - title, - body: {tabularData: data, firstColumnSubdued: true}, - } -} - -export async function formatThemeInfo(output: ThemeInfo, flags: {environment?: string}) { - const tabularData = Object.entries(output.theme).map(([key, val]) => { - if (key === 'editor_url' || key === 'preview_url') { - const url = String(val) - // Here, we create descriptive labels for the links - const label = key === 'editor_url' ? 'Open in Theme Editor' : 'Preview Theme' - return [formatKey(key), {link: {url, label}}] - } else if (key === 'id') { - return [formatKey(key), `#${val}`] - } else { - return [formatKey(key), `${val}`] - } - }) - - return { - customSections: [ - ...(flags.environment - ? [ - { - title: `Theme information`, - body: [{subdued: `Environment name: ${flags.environment}`}], - }, - ] - : []), - { - title: 'Theme Details', - body: {tabularData, firstColumnSubdued: true}, - }, - ], - } -} - -function formatKey(key: string): string { - return key - .split('_') - .map((word) => word.charAt(0).toUpperCase() + word.slice(1)) - .join(' ') -} diff --git a/packages/theme/src/cli/services/info/result.test.ts b/packages/theme/src/cli/services/info/result.test.ts new file mode 100644 index 00000000000..2d9e4201162 --- /dev/null +++ b/packages/theme/src/cli/services/info/result.test.ts @@ -0,0 +1,96 @@ +import {renderThemeInfoResult} from './result.js' +import {describe, expect, test, vi} from 'vitest' +import {mockAndCaptureOutput} from '@shopify/cli-kit/node/testing/output' +import {renderInfo} from '@shopify/cli-kit/node/ui' + +vi.mock('@shopify/cli-kit/node/ui') + +const themeResult = { + theme: { + id: 123, + name: 'My theme', + role: 'live', + shop: 'my-shop.myshopify.com', + preview_url: 'https://my-shop.myshopify.com/preview', + editor_url: 'https://my-shop.myshopify.com/editor', + }, +} + +const environmentResult = { + store: 'my-shop.myshopify.com', + development_theme_id: null, + cli_version: '3.91.0', + os: 'darwin-arm64', + shell: '/bin/zsh', + node_version: 'v24.15.0', +} + +describe('renderThemeInfoResult', () => { + test.each([themeResult, environmentResult])('encodes the JSON result shape', (result) => { + const output = mockAndCaptureOutput() + output.clear() + + renderThemeInfoResult(result, 'json') + + expect(JSON.parse(output.output())).toEqual(result) + expect(renderInfo).not.toHaveBeenCalled() + }) + + test('renders selected theme information as text', () => { + renderThemeInfoResult(themeResult, 'text', {environment: 'development'}) + + expect(renderInfo).toHaveBeenCalledWith({ + customSections: [ + { + title: 'Theme information', + body: [{subdued: 'Environment name: development'}], + }, + { + title: 'Theme Details', + body: { + firstColumnSubdued: true, + tabularData: [ + ['Id', '#123'], + ['Name', 'My theme'], + ['Role', 'live'], + ['Shop', 'my-shop.myshopify.com'], + ['Preview Url', {link: {url: 'https://my-shop.myshopify.com/preview', label: 'Preview Theme'}}], + ['Editor Url', {link: {url: 'https://my-shop.myshopify.com/editor', label: 'Open in Theme Editor'}}], + ], + }, + }, + ], + }) + }) + + test('renders environment information as text', () => { + renderThemeInfoResult(environmentResult, 'text') + + expect(renderInfo).toHaveBeenCalledWith({ + customSections: [ + { + title: 'Theme Configuration', + body: { + firstColumnSubdued: true, + tabularData: [ + ['Store', 'my-shop.myshopify.com'], + ['Development Theme ID', {subdued: 'Not set'}], + ], + }, + }, + { + title: 'Tooling and System', + body: { + firstColumnSubdued: true, + tabularData: [ + ['Shopify CLI', '3.91.0'], + ['OS', 'darwin-arm64'], + ['Shell', '/bin/zsh'], + ['Node version', 'v24.15.0'], + ], + }, + }, + ], + }) + }) +}) diff --git a/packages/theme/src/cli/services/info/result.ts b/packages/theme/src/cli/services/info/result.ts new file mode 100644 index 00000000000..3961d11060e --- /dev/null +++ b/packages/theme/src/cli/services/info/result.ts @@ -0,0 +1,99 @@ +import { + themeInfoJsonOutputSchema, + type ThemeEnvironmentInfo, + type ThemeInfoResult, + type ThemeInfoThemeResult, +} from './types.js' +import {recordEvent} from '@shopify/cli-kit/node/analytics' +import {outputResult} from '@shopify/cli-kit/node/output' +import {renderInfo, type AlertCustomSection, type InlineToken} from '@shopify/cli-kit/node/ui' + +type ThemeInfoOutputFormat = 'text' | 'json' + +interface ThemeInfoPresentationOptions { + environment?: string | string[] + developmentTheme?: string +} + +export function renderThemeInfoResult( + result: ThemeInfoResult, + format: ThemeInfoOutputFormat, + options: ThemeInfoPresentationOptions = {}, +): void { + if (format === 'json') { + outputResult(themeInfoJsonOutputSchema.encode(result)) + return + } + + if ('theme' in result) { + renderInfo(formatThemeInfo(result, options)) + } else { + renderInfo({customSections: themeEnvironmentInfoSections(result, options)}) + } +} + +function formatThemeInfo(output: ThemeInfoThemeResult, options: ThemeInfoPresentationOptions) { + const tabularData = Object.entries(output.theme).map(([key, value]) => { + if (key === 'editor_url' || key === 'preview_url') { + const url = String(value) + const label = key === 'editor_url' ? 'Open in Theme Editor' : 'Preview Theme' + return [formatKey(key), {link: {url, label}}] + } + if (key === 'id') return [formatKey(key), `#${value}`] + return [formatKey(key), `${value}`] + }) + + return { + customSections: [ + ...(options.environment + ? [ + { + title: 'Theme information', + body: [{subdued: `Environment name: ${options.environment}`}], + }, + ] + : []), + { + title: 'Theme Details', + body: {tabularData, firstColumnSubdued: true}, + }, + ], + } +} + +function themeEnvironmentInfoSections( + result: ThemeEnvironmentInfo, + options: ThemeInfoPresentationOptions, +): AlertCustomSection[] { + const developmentTheme = Object.hasOwn(options, 'developmentTheme') + ? options.developmentTheme + : result.development_theme_id + recordEvent(`theme-command:info:dev-theme-loaded:${developmentTheme}`) + + return [ + tabularSection('Theme Configuration', [ + ['Store', result.store], + ['Development Theme ID', developmentTheme ? `#${developmentTheme}` : {subdued: 'Not set'}], + ]), + tabularSection('Tooling and System', [ + ['Shopify CLI', result.cli_version], + ['OS', result.os], + ['Shell', result.shell], + ['Node version', result.node_version], + ]), + ] +} + +function tabularSection(title: string, data: InlineToken[][]): AlertCustomSection { + return { + title, + body: {tabularData: data, firstColumnSubdued: true}, + } +} + +function formatKey(key: string): string { + return key + .split('_') + .map((word) => word.charAt(0).toUpperCase() + word.slice(1)) + .join(' ') +} diff --git a/packages/theme/src/cli/services/info/types.test.ts b/packages/theme/src/cli/services/info/types.test.ts new file mode 100644 index 00000000000..f6b4fe6b777 --- /dev/null +++ b/packages/theme/src/cli/services/info/types.test.ts @@ -0,0 +1,44 @@ +import {themeInfoJsonOutputSchema} from './types.js' +import {describe, expect, test} from 'vitest' + +const themeResult = { + theme: { + id: 123, + name: 'My theme', + role: 'live', + shop: 'my-shop.myshopify.com', + preview_url: 'https://my-shop.myshopify.com/preview', + editor_url: 'https://my-shop.myshopify.com/editor', + }, +} + +const environmentResult = { + store: 'my-shop.myshopify.com', + development_theme_id: null, + cli_version: '3.91.0', + os: 'darwin-arm64', + shell: '/bin/zsh', + node_version: 'v24.15.0', +} + +describe('themeInfoJsonOutputSchema', () => { + test.each([themeResult, environmentResult])('validates the existing result shape', (result) => { + expect(themeInfoJsonOutputSchema.validate(result)).toEqual(result) + }) + + test('rejects a theme result with an invalid theme ID', () => { + expect(() => + themeInfoJsonOutputSchema.validate({...themeResult, theme: {...themeResult.theme, id: '123'}}), + ).toThrow() + }) + + test('rejects an environment result with an invalid development theme ID', () => { + expect(() => themeInfoJsonOutputSchema.validate({...environmentResult, development_theme_id: '123'})).toThrow() + }) + + test('rejects a multi-environment wrapper', () => { + expect(() => + themeInfoJsonOutputSchema.validate({environments: [{environment: 'development', result: themeResult}]}), + ).toThrow() + }) +}) diff --git a/packages/theme/src/cli/services/info/types.ts b/packages/theme/src/cli/services/info/types.ts new file mode 100644 index 00000000000..8a57879cae1 --- /dev/null +++ b/packages/theme/src/cli/services/info/types.ts @@ -0,0 +1,38 @@ +import {defineJsonOutputSchema, type InferJsonOutputSchema} from '@shopify/cli-kit/node/json-output-schema' +import {zod} from '@shopify/cli-kit/node/schema' + +const ThemeInfoThemeSchema = zod.object({ + id: zod.number(), + name: zod.string(), + role: zod.string(), + shop: zod.string(), + preview_url: zod.string(), + editor_url: zod.string(), +}) + +const ThemeInfoThemeResultSchema = zod.object({ + theme: ThemeInfoThemeSchema, +}) + +const ThemeEnvironmentInfoSchema = zod.object({ + store: zod.string(), + development_theme_id: zod.number().nullable(), + cli_version: zod.string(), + os: zod.string(), + shell: zod.string(), + node_version: zod.string(), +}) + +export const themeInfoJsonOutputSchema = defineJsonOutputSchema({ + name: 'ThemeInfoResult', + schema: zod.union([ThemeInfoThemeResultSchema, ThemeEnvironmentInfoSchema]), + definitions: { + ThemeInfoTheme: ThemeInfoThemeSchema, + ThemeInfoThemeResult: ThemeInfoThemeResultSchema, + ThemeEnvironmentInfo: ThemeEnvironmentInfoSchema, + }, +}) + +export type ThemeInfoResult = InferJsonOutputSchema +export type ThemeInfoThemeResult = zod.infer +export type ThemeEnvironmentInfo = zod.infer diff --git a/packages/theme/src/cli/utilities/theme-command-environments.test.ts b/packages/theme/src/cli/utilities/theme-command-environments.test.ts new file mode 100644 index 00000000000..743f668f14c --- /dev/null +++ b/packages/theme/src/cli/utilities/theme-command-environments.test.ts @@ -0,0 +1,138 @@ +import ThemeCommand from './theme-command.js' +import {Config, Flags} from '@oclif/core' +import {FlagOutput} from '@shopify/cli-kit/node/base-command' +import {jsonFlag, requiredIfNonInteractive} from '@shopify/cli-kit/node/cli' +import {inTemporaryDirectory, writeFile} from '@shopify/cli-kit/node/fs' +import {joinPath} from '@shopify/cli-kit/node/path' +import * as pathUtilities from '@shopify/cli-kit/node/path' +import {AbortError} from '@shopify/cli-kit/node/error' +import {renderConcurrent} from '@shopify/cli-kit/node/ui' +import {afterEach, expect, test, vi} from 'vitest' + +vi.mock('@shopify/cli-kit/node/ui', async (importOriginal) => ({ + ...(await importOriginal()), + renderConcurrent: vi.fn(async ({processes, abortSignal}) => { + for (const processToRun of processes) { + // eslint-disable-next-line no-await-in-loop + await processToRun.action(process.stdout, process.stderr, abortSignal) + } + }), +})) + +afterEach(() => { + vi.unstubAllEnvs() +}) + +function environmentCommand(path: string) { + class EnvironmentCommand extends ThemeCommand { + static flags = { + ...jsonFlag, + environment: Flags.string({multiple: true, env: 'SHOPIFY_FLAG_ENVIRONMENT'}), + path: Flags.string({default: path, env: 'SHOPIFY_FLAG_PATH'}), + store: Flags.string({env: 'SHOPIFY_FLAG_STORE'}), + theme: Flags.string({env: 'SHOPIFY_FLAG_THEME_ID'}), + live: Flags.boolean({env: 'SHOPIFY_FLAG_LIVE'}), + 'allow-live': Flags.boolean({env: 'SHOPIFY_FLAG_ALLOW_LIVE'}), + } + + static multiEnvironmentsFlags = ['store', ['theme', 'live']] + + static nonTTYFlagRequirements(flags: FlagOutput) { + return [{flags: ['theme', 'live']}, ...(flags.live ? [{flags: ['allow-live']}] : [])] + } + + calls: FlagOutput[] = [] + + async command(flags: FlagOutput) { + this.calls.push(flags) + } + } + return EnvironmentCommand +} + +test.each([false, true])( + 'validates configured theme selectors after loading every environment (json: %s)', + async (json) => { + vi.stubEnv('CI', '1') + await inTemporaryDirectory(async (path) => { + await writeFile( + joinPath(path, 'shopify.theme.toml'), + '[environments.a]\nstore = "a.myshopify.com"\ntheme = "123"\n[environments.b]\nstore = "b.myshopify.com"\ntheme = "456"\n', + ) + const TestCommand = environmentCommand(path) + const config = new Config({root: __dirname}) + await config.load() + const command = new TestCommand(['--environment', 'a', '--environment', 'b', ...(json ? ['--json'] : [])], config) + + await command.run() + + expect(command.calls.map((flags) => flags.theme)).toEqual(['123', '456']) + }) + }, +) + +test('checks conditional safety flags in every environment before running any command', async () => { + vi.stubEnv('CI', '1') + await inTemporaryDirectory(async (path) => { + await writeFile( + joinPath(path, 'shopify.theme.toml'), + '[environments.a]\nstore = "a.myshopify.com"\ntheme = "123"\n[environments.b]\nstore = "b.myshopify.com"\nlive = true\n', + ) + const TestCommand = environmentCommand(path) + const config = new Config({root: __dirname}) + await config.load() + const command = new TestCommand(['--environment', 'a', '--environment', 'b'], config) + + await expect(command.run()).rejects.toThrow('--allow-live') + expect(command.calls).toEqual([]) + expect(renderConcurrent).not.toHaveBeenCalled() + }) +}) + +test('keeps noninteractive validation for commands that parse flags in their own runner', async () => { + vi.stubEnv('CI', '1') + class CommandWithOwnRunner extends ThemeCommand { + static flags = { + environment: Flags.string({multiple: true, env: 'SHOPIFY_FLAG_ENVIRONMENT'}), + theme: requiredIfNonInteractive(Flags.string({env: 'SHOPIFY_FLAG_THEME_ID'})), + } + + async run() { + await this.parse(CommandWithOwnRunner) + } + } + const config = new Config({root: __dirname}) + await config.load() + const command = new CommandWithOwnRunner(['--environment', 'a', '--environment', 'b'], config) + + await expect(command.run()).rejects.toThrow('--theme') +}) + +test.each([false, true])( + 'rejects global --path with recovery advice when the config file exists: %s', + async (configExists) => { + await inTemporaryDirectory(async (path) => { + if (configExists) { + await writeFile(joinPath(path, 'shopify.theme.toml'), '') + } + const cwdSpy = vi.spyOn(pathUtilities, 'cwd').mockReturnValue(path) + try { + const TestCommand = environmentCommand(path) + const config = new Config({root: __dirname}) + await config.load() + const command = new TestCommand(['--environment', 'a', '--environment', 'b', '--path', path], config) + + const runningCommand = command.run() + await expect(runningCommand).rejects.toBeInstanceOf(AbortError) + await expect(runningCommand).rejects.toMatchObject({ + message: "Can't use `--path` flag with multiple environments.", + tryMessage: configExists + ? "Configure each environment's theme path in your shopify.theme.toml file instead." + : 'Run this command from the directory containing shopify.theme.toml. No shopify.theme.toml found in current directory.', + }) + } finally { + cwdSpy.mockRestore() + } + }) + }, +) diff --git a/packages/theme/src/cli/utilities/theme-command.test.ts b/packages/theme/src/cli/utilities/theme-command.test.ts index 31d8d27605a..c4c43bb8488 100644 --- a/packages/theme/src/cli/utilities/theme-command.test.ts +++ b/packages/theme/src/cli/utilities/theme-command.test.ts @@ -38,6 +38,8 @@ vi.mock('@shopify/cli-kit/node/fs') const CommandConfig = new Config({root: __dirname}) class TestThemeCommand extends ThemeCommand { + static id = 'theme:test' + static flags = { environment: Flags.string({ multiple: true, @@ -613,7 +615,7 @@ describe('ThemeCommand', () => { expect(fileExistsSync).toHaveBeenCalledWith('current/working/directory') }) - test('multiple environments provided - displays warning if not allowed', async () => { + test('multiple environments provided - fails if not allowed', async () => { // Given const environmentConfig = {store: 'store.myshopify.com'} vi.mocked(loadEnvironment).mockResolvedValue(environmentConfig) @@ -627,15 +629,7 @@ describe('ThemeCommand', () => { CommandConfig, ) - // When - await command.run() - - // Then - expect(renderWarning).toHaveBeenCalledWith( - expect.objectContaining({ - body: 'This command does not support multiple environments.', - }), - ) + await expect(command.run()).rejects.toThrow('This command does not support multiple environments.') }) }) @@ -767,7 +761,7 @@ describe('ThemeCommand', () => { expect(renderConfirmationPrompt).toHaveBeenCalledOnce() expect(renderConfirmationPrompt).toHaveBeenCalledWith( expect.objectContaining({ - message: ['Run testthemecommandwithpathflag in the following environments?'], + message: ['Run theme test in the following environments?'], infoTable: { Environment: [ ['development', {subdued: 'store: store1.myshopify.com, password, path: /home/.../theme1'}], @@ -965,60 +959,6 @@ describe('ThemeCommand', () => { ) }) - test('commands should display an error if the --path flag is used', async () => { - // Given - const environmentConfig = {store: 'store.myshopify.com'} - vi.mocked(loadEnvironment).mockResolvedValue(environmentConfig) - vi.mocked(renderConfirmationPrompt).mockResolvedValue(true) - - await CommandConfig.load() - const command = new TestThemeCommand( - ['--environment', 'command-error', '--environment', 'development', '--path', 'path'], - CommandConfig, - ) - - // When - await command.run() - - // Then - expect(renderError).toHaveBeenCalledWith( - expect.objectContaining({ - body: [ - "Can't use `--path` flag with multiple environments.", - "Configure each environment's theme path in your shopify.theme.toml file instead.", - ], - }), - ) - }) - - test('commands should display an error if the --path flag is used and no shopify.theme.toml is found', async () => { - // Given - const environmentConfig = {store: 'store.myshopify.com'} - vi.mocked(loadEnvironment).mockResolvedValue(environmentConfig) - vi.mocked(renderConfirmationPrompt).mockResolvedValue(true) - vi.mocked(fileExistsSync).mockReturnValue(false) - - await CommandConfig.load() - const command = new TestThemeCommand( - ['--environment', 'command-error', '--environment', 'development', '--path', 'path'], - CommandConfig, - ) - - // When - await command.run() - - // Then - expect(renderError).toHaveBeenCalledWith( - expect.objectContaining({ - body: [ - "Can't use `--path` flag with multiple environments.", - 'Run this command from the directory containing shopify.theme.toml.', - 'No shopify.theme.toml found in current directory.', - ], - }), - ) - }) - test('CLI and shopify.theme.toml flag values take precedence over defaults', async () => { // Given vi.mocked(loadEnvironment) diff --git a/packages/theme/src/cli/utilities/theme-command.ts b/packages/theme/src/cli/utilities/theme-command.ts index da8b70b23a9..18f594e1786 100644 --- a/packages/theme/src/cli/utilities/theme-command.ts +++ b/packages/theme/src/cli/utilities/theme-command.ts @@ -118,14 +118,12 @@ export default abstract class ThemeCommand extends Command { // Multiple environments if (requiredFlags === null) { - renderWarning({body: 'This command does not support multiple environments.'}) - return + throw new AbortError('This command does not support multiple environments.') } const {flags: flagsWithoutDefaults} = await this.parse(noDefaultsOptions(klass), this.argv) if ('path' in flagsWithoutDefaults) { this.errorOnGlobalPath() - return } const environmentsMap = await this.loadEnvironments(environments, flags, flagsWithoutDefaults) @@ -134,13 +132,31 @@ export default abstract class ThemeCommand extends Command { const commandAllowsForceFlag = 'force' in klass.flags if (commandAllowsForceFlag && !flags.force) { - const confirmed = await this.showConfirmation(this.constructor.name, requiredFlags, validationResults) + const confirmed = await this.showConfirmation( + (this.id ?? 'theme').replaceAll(':', ' '), + requiredFlags, + validationResults, + ) if (!confirmed) return } await this.runConcurrent(validationResults.valid) } + protected validateNonTTYFlags(flags: FlagOutput): void { + // Multiple environments must be validated after their configured flags are loaded. + const command = this.constructor + if ( + 'multiEnvironmentsFlags' in command && + command.multiEnvironmentsFlags !== undefined && + Array.isArray(flags.environment) && + flags.environment.length > 1 + ) { + return + } + super.validateNonTTYFlags(flags) + } + /** * Admin API scopes that a stored `store auth` session must include for this * command to reuse it. Commands opt in to reusing store auth sessions by @@ -224,6 +240,7 @@ export default abstract class ThemeCommand extends Command { invalid.push({environment: environmentName, reason: `Missing flags: ${missingFlagsText}`}) continue } + super.validateNonTTYFlags(flags) valid.push({environment: environmentName, flags, requiresAuth, storeAuthSession}) } @@ -514,17 +531,12 @@ export default abstract class ThemeCommand extends Command { const tomlPath = joinPath(cwd(), 'shopify.theme.toml') const tomlInCwd = fileExistsSync(tomlPath) - renderError({ - body: [ - "Can't use `--path` flag with multiple environments.", - ...(tomlInCwd - ? ["Configure each environment's theme path in your shopify.theme.toml file instead."] - : [ - 'Run this command from the directory containing shopify.theme.toml.', - 'No shopify.theme.toml found in current directory.', - ]), - ], - }) + throw new AbortError( + "Can't use `--path` flag with multiple environments.", + tomlInCwd + ? "Configure each environment's theme path in your shopify.theme.toml file instead." + : 'Run this command from the directory containing shopify.theme.toml. No shopify.theme.toml found in current directory.', + ) } private async logAnalyticsData(session?: AdminSession): Promise {