From a8c738f2e55a70753a710af45757dfac420a67ce Mon Sep 17 00:00:00 2001 From: Josh Faigan Date: Mon, 27 Jan 2025 13:02:53 -0500 Subject: [PATCH 1/4] Add base infrastructure and first command for multi-environment commands This commit adds the groundwork to use commands when passing multiple environments. We are starting with the list command --- .../src/public/node/base-command.test.ts | 13 ++ .../cli-kit/src/public/node/base-command.ts | 9 +- packages/theme/src/cli/commands/theme/list.ts | 16 +-- packages/theme/src/cli/flags.ts | 1 + packages/theme/src/cli/services/info.ts | 2 +- packages/theme/src/cli/services/list.test.ts | 6 +- packages/theme/src/cli/services/list.ts | 4 +- packages/theme/src/cli/utilities/multi-run.ts | 112 ++++++++++++++++++ .../theme/src/cli/utilities/theme-command.ts | 88 ++++++++++++++ 9 files changed, 235 insertions(+), 16 deletions(-) create mode 100644 packages/theme/src/cli/utilities/multi-run.ts 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 6ba92f74b0b..a5fdf47d61a 100644 --- a/packages/cli-kit/src/public/node/base-command.test.ts +++ b/packages/cli-kit/src/public/node/base-command.test.ts @@ -158,6 +158,19 @@ describe('applying environments', async () => { expect(outputMock.info()).toEqual('') }) + runTestInTmpDir('does not apply flags when multiple environments are specified', async (tmpDir: string) => { + // Given + const outputMock = mockAndCaptureOutput() + outputMock.clear() + + // When + await MockCommand.run(['--path', tmpDir, '--environment', 'validEnvironment', '--environment', 'validEnvironment']) + + // Then + expect(testResult).toEqual({}) + expect(outputMock.info()).toEqual('') + }) + runTestInTmpDir('applies a environment when one is specified', 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 9adb73b8881..8905894e6e2 100644 --- a/packages/cli-kit/src/public/node/base-command.ts +++ b/packages/cli-kit/src/public/node/base-command.ts @@ -16,7 +16,7 @@ import {Command, Errors} from '@oclif/core' import {FlagOutput, Input, ParserOutput, FlagInput, ArgOutput} from '@oclif/core/lib/interfaces/parser.js' interface EnvironmentFlags { - environment?: string + environment?: string | string[] path?: string } @@ -47,7 +47,7 @@ abstract class BaseCommand extends Command { // eslint-disable-next-line @typescript-eslint/no-explicit-any protected async init(): Promise { this.exitWithTimestampWhenEnvVariablePresent() - setCurrentCommandId(this.id || '') + setCurrentCommandId(this.id ?? '') if (!isDevelopment()) { // This function runs just prior to `run` await registerCleanBugsnagErrorsFromWithinPlugins(this.config) @@ -139,6 +139,9 @@ This flag is required in non-interactive terminal environments, such as a CI env const environmentsFileName = this.environmentsFilename() if (!flags.environment || !environmentsFileName) return originalResult + // If users pass multiple environments, do not load them and let each command handle it + if (Array.isArray(flags.environment)) return originalResult + // If the specified environment isn't found, don't modify the results const environment = await loadEnvironment(flags.environment, environmentsFileName, {from: flags.path}) if (!environment) return originalResult @@ -152,7 +155,7 @@ This flag is required in non-interactive terminal environments, such as a CI env // Replace the original result with this one. const result = await super.parse(options, [ // Need to specify argv default because we're merging with argsFromEnvironment. - ...(argv || this.argv), + ...(argv ?? this.argv), ...argsFromEnvironment(environment, options, noDefaultsResult), ]) diff --git a/packages/theme/src/cli/commands/theme/list.ts b/packages/theme/src/cli/commands/theme/list.ts index 916fa8517eb..e62f2c2cfc7 100644 --- a/packages/theme/src/cli/commands/theme/list.ts +++ b/packages/theme/src/cli/commands/theme/list.ts @@ -1,11 +1,13 @@ -import {ensureThemeStore} from '../../utilities/theme-store.js' -import {list} from '../../services/list.js' import {ALLOWED_ROLES, Role} from '../../utilities/theme-selector/fetch.js' import {themeFlags} from '../../flags.js' import ThemeCommand from '../../utilities/theme-command.js' +import {list} from '../../services/list.js' import {Flags} from '@oclif/core' -import {ensureAuthenticatedThemes} from '@shopify/cli-kit/node/session' import {globalFlags, jsonFlag} from '@shopify/cli-kit/node/cli' +import {OutputFlags} from '@oclif/core/lib/interfaces/parser.js' +import {AdminSession} from '@shopify/cli-kit/node/session' + +type ListFlags = OutputFlags export default class List extends ThemeCommand { static description = 'Lists the themes in your store, along with their IDs and statuses.' @@ -31,11 +33,9 @@ export default class List extends ThemeCommand { environment: themeFlags.environment, } - async run(): Promise { - const {flags} = await this.parse(List) - const store = ensureThemeStore(flags) - const adminSession = await ensureAuthenticatedThemes(store, flags.password) + static multiEnvironmentsFlags = ['store', 'password'] - await list(adminSession, flags) + async command(flags: ListFlags, adminSession: AdminSession) { + await list(flags, adminSession) } } diff --git a/packages/theme/src/cli/flags.ts b/packages/theme/src/cli/flags.ts index 3c342d62aaf..4cd144a6286 100644 --- a/packages/theme/src/cli/flags.ts +++ b/packages/theme/src/cli/flags.ts @@ -30,5 +30,6 @@ export const themeFlags = { char: 'e', description: 'The environment to apply to the current command.', env: 'SHOPIFY_FLAG_ENVIRONMENT', + multiple: true, }), } diff --git a/packages/theme/src/cli/services/info.ts b/packages/theme/src/cli/services/info.ts index 70cc54dbc61..8944060dbf3 100644 --- a/packages/theme/src/cli/services/info.ts +++ b/packages/theme/src/cli/services/info.ts @@ -21,7 +21,7 @@ interface ThemeInfo { interface ThemeInfoOptions { store?: string password?: string - environment?: string + environment?: string[] development?: boolean theme?: string json?: boolean diff --git a/packages/theme/src/cli/services/list.test.ts b/packages/theme/src/cli/services/list.test.ts index 984480a60b5..e207f49df22 100644 --- a/packages/theme/src/cli/services/list.test.ts +++ b/packages/theme/src/cli/services/list.test.ts @@ -32,7 +32,7 @@ describe('list', () => { vi.mocked(getDevelopmentTheme).mockReturnValue(developmentThemeId.toString()) vi.mocked(getHostTheme).mockReturnValue(hostThemeId.toString()) - await list(session, {json: false}) + await list({json: false}, session) expect(renderTable).toBeCalledWith({ rows: [ @@ -54,7 +54,7 @@ describe('list', () => { {id: 5, name: 'Theme 5', role: 'development'}, ] as Theme[]) - await list(session, {role: 'live', name: '*eMe 3*', json: false}) + await list({role: 'live', name: '*eMe 3*', json: false}, session) expect(renderTable).toBeCalledWith({ rows: [{id: '#3', name: 'Theme 3', role: '[live]'}], @@ -70,7 +70,7 @@ describe('list', () => { {id: 2, name: 'Theme 2', role: ''}, ] as Theme[]) - await list(session, {json: true}) + await list({json: true}, session) expect(mockOutput.info()).toMatchInlineSnapshot(` "[ diff --git a/packages/theme/src/cli/services/list.ts b/packages/theme/src/cli/services/list.ts index 01d225caf16..b7d5351382b 100644 --- a/packages/theme/src/cli/services/list.ts +++ b/packages/theme/src/cli/services/list.ts @@ -14,7 +14,7 @@ interface Options { json: boolean } -export async function list(adminSession: AdminSession, options: Options) { +export async function list(options: Options, adminSession: AdminSession) { const store = adminSession.storeFqdn const filter = new Filter({ ...ALLOWED_ROLES.reduce((roles: FilterProps, role) => { @@ -31,6 +31,8 @@ export async function list(adminSession: AdminSession, options: Options) { storeThemes = filterThemes(store, storeThemes, filter) } + outputInfo(`Store: ${store}`) + if (options.json) { return outputInfo(JSON.stringify(storeThemes, null, 2)) } diff --git a/packages/theme/src/cli/utilities/multi-run.ts b/packages/theme/src/cli/utilities/multi-run.ts new file mode 100644 index 00000000000..586e79a3583 --- /dev/null +++ b/packages/theme/src/cli/utilities/multi-run.ts @@ -0,0 +1,112 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +/* eslint-disable no-await-in-loop */ +import {ensureThemeStore} from './theme-store.js' +import {PushFlags} from '../services/push.js' +import {PullFlags} from '../services/pull.js' +import {loadEnvironment} from '@shopify/cli-kit/node/environments' +import {AdminSession, ensureAuthenticatedThemes} from '@shopify/cli-kit/node/session' +import {outputInfo} from '@shopify/cli-kit/node/output' +import {renderWarning} from '@shopify/cli-kit/node/ui' + +// Base interface for common flags +interface BaseFlags { + store?: string + json?: boolean + environment?: string[] + password?: string + 'no-color': boolean + verbose: boolean + [key: string]: unknown +} + +// Command-specific flag interfaces +export interface CommandFlags { + list: BaseFlags + push: BaseFlags & PushFlags + pull: BaseFlags & PullFlags +} + +type SupportedCommands = keyof CommandFlags + +interface MultiRunOptions { + flags: CommandFlags[T] + additionalRequiredFlags?: string[] + command: (flags: CommandFlags[T], session: AdminSession) => Promise +} + +export async function multiRun({ + flags, + additionalRequiredFlags, + command, +}: MultiRunOptions) { + if (!flags.environment?.length) { + return + } + + // Authenticate all sessions + const sessions: {[key: string]: AdminSession} = {} + const environment = flags.environment + for (const env of environment) { + const envConfig = await loadEnvironment(env, 'shopify.theme.toml') + const store = ensureThemeStore({store: envConfig?.store as any}) + sessions[env] = await ensureAuthenticatedThemes(store, envConfig?.password as any) + } + + // Concurrently run commands + await Promise.all( + environment.map(async (env) => { + const envConfig = await loadEnvironment(env, 'shopify.theme.toml') + const envFlags = { + ...flags, + ...envConfig, + environment: [env], + } as CommandFlags[T] + + const valid = validateEnvironmentConfig(envConfig, { + additionalRequiredFlags, + }) + if (!valid) { + return + } + + const session = sessions[env] + + if (!session) { + throw new Error(`No session found for environment ${env}`) + } + + outputInfo(session.storeFqdn) + + await command(envFlags, session) + }), + ) +} + +interface ValidateEnvironmentOptions { + additionalRequiredFlags?: string[] +} + +/** + * Validates that required flags are present in the environment configuration. + * @param environment - The environment configuration to validate. + * @param options - Options for validation, including any additional required flags. + * @returns True if valid, throws an error if invalid. + */ +export function validateEnvironmentConfig(environment: any, options?: ValidateEnvironmentOptions): boolean { + if (!environment) { + renderWarning({body: 'Environment configuration is empty.'}) + return false + } + + const requiredFlags = ['store', 'password', ...(options?.additionalRequiredFlags ?? [])] + const missingFlags = requiredFlags.filter((flag) => !environment[flag]) + + if (missingFlags.length > 0) { + renderWarning({ + body: ['Missing required flags in environment configuration:', {list: {items: missingFlags}}], + }) + return false + } + + return true +} diff --git a/packages/theme/src/cli/utilities/theme-command.ts b/packages/theme/src/cli/utilities/theme-command.ts index 14bbd6a59c6..15e7a682e45 100644 --- a/packages/theme/src/cli/utilities/theme-command.ts +++ b/packages/theme/src/cli/utilities/theme-command.ts @@ -1,5 +1,11 @@ +import {ensureThemeStore} from './theme-store.js' import {configurationFileName} from '../constants.js' +import {ArgOutput, FlagOutput, Input} from '@oclif/core/lib/interfaces/parser.js' import Command from '@shopify/cli-kit/node/base-command' +import {AdminSession, ensureAuthenticatedThemes} from '@shopify/cli-kit/node/session' +import {loadEnvironment} from '@shopify/cli-kit/node/environments' +import {renderWarning} from '@shopify/cli-kit/node/ui' +import {AbortError} from '@shopify/cli-kit/node/error' export interface FlagValues { [key: string]: boolean | string | string[] | number | undefined @@ -29,4 +35,86 @@ export default abstract class ThemeCommand extends Command { environmentsFilename(): string { return configurationFileName } + + async command(_flags: FlagValues, _session: AdminSession): Promise {} + + async run< + TFlags extends FlagOutput & {path?: string; verbose?: boolean}, + TGlobalFlags extends FlagOutput, + TArgs extends ArgOutput, + >(_opts?: Input): Promise { + // Parse command flags using the current command class definitions + const klass = this.constructor as unknown as Input & { + multiEnvironmentsFlags: string[] + } + const requiredFlags = klass.multiEnvironmentsFlags + const {flags} = await this.parse(klass) + + // Single environment + if (!Array.isArray(flags.environment)) { + const session = await this.ensureAuthenticated(flags) + await this.command(flags, session) + return + } + + // Synchronously authenticate all environments + const sessions: {[storeFqdn: string]: AdminSession} = {} + const environments = flags.environment + + // Authenticate on all environments sequentially to avoid race conditions, + // with authentication happening in parallel. + for (const environmentName of environments) { + // eslint-disable-next-line no-await-in-loop + const environmentConfig = await loadEnvironment(environmentName, 'shopify.theme.toml') + // eslint-disable-next-line no-await-in-loop + sessions[environmentName] = await this.ensureAuthenticated(environmentConfig as FlagValues) + } + + // Concurrently run commands + await Promise.all( + environments.map(async (environment: string) => { + const environmentConfig = await loadEnvironment(environment, 'shopify.theme.toml') + const environmentFlags = { + ...flags, + ...environmentConfig, + environment: [environment], + } + + if (!this.validConfig(environmentConfig as FlagValues, requiredFlags)) return + + const session = sessions[environment] + + if (!session) { + throw new AbortError(`No session found for environment ${environment}`) + } + + return this.command(environmentFlags, session) + }), + ) + } + + private async ensureAuthenticated(flags: FlagValues) { + const store = flags.store as string + const password = flags.password as string + return ensureAuthenticatedThemes(ensureThemeStore({store}), password) + } + + private validConfig(environmentConfig: FlagValues, requiredFlags: string[]): boolean { + if (!environmentConfig) { + renderWarning({body: 'Environment configuration is empty.'}) + return false + } + + const required = ['store', 'password', ...requiredFlags] + const missingFlags = required.filter((flag) => !environmentConfig[flag]) + + if (missingFlags.length > 0) { + renderWarning({ + body: ['Missing required flags in environment configuration:', {list: {items: missingFlags}}], + }) + return false + } + + return true + } } From a7b48fd2bc78f41aacc9a8f6508464e20f2a87f3 Mon Sep 17 00:00:00 2001 From: Josh Faigan Date: Wed, 29 Jan 2025 12:03:50 -0500 Subject: [PATCH 2/4] add changeset and docs --- .changeset/chatty-pandas-sort.md | 5 + packages/cli/README.md | 246 +++++++++--------- packages/cli/oclif.manifest.json | 34 +-- packages/theme/src/cli/utilities/multi-run.ts | 112 -------- 4 files changed, 147 insertions(+), 250 deletions(-) create mode 100644 .changeset/chatty-pandas-sort.md delete mode 100644 packages/theme/src/cli/utilities/multi-run.ts diff --git a/.changeset/chatty-pandas-sort.md b/.changeset/chatty-pandas-sort.md new file mode 100644 index 00000000000..fcb7d3bde54 --- /dev/null +++ b/.changeset/chatty-pandas-sort.md @@ -0,0 +1,5 @@ +--- +'@shopify/cli': minor +--- + +Add multi-environment infrastructure and allow multiple environment usage in theme list command diff --git a/packages/cli/README.md b/packages/cli/README.md index 97edb70e2b7..858f9a56b06 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -1706,23 +1706,23 @@ USAGE [--init] [--list] [--no-color] [-o text|json] [--path ] [--print] [--verbose] [-v] FLAGS - -C, --config= Use the config provided, overriding .theme-check.yml if present - Supports all theme-check: config values, e.g., theme-check:theme-app-extension, - theme-check:recommended, theme-check:all - For backwards compatibility, :theme_app_extension is also supported - -a, --auto-correct Automatically fix offenses - -e, --environment= The environment to apply to the current command. - -o, --output=