Skip to content

Commit b382a89

Browse files
committed
Add confirmation prompt to multi-env theme commands
Previously multi-environment theme commands did not prompt for confirmation before running. This commit adds a confirmation prompt that will be displayed if the command accepts `--force` flag and has not been set to force.
1 parent 1f4fd78 commit b382a89

File tree

3 files changed

+180
-6
lines changed

3 files changed

+180
-6
lines changed

.changeset/dry-waves-agree.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
'@shopify/theme': minor
3+
'@shopify/cli': minor
4+
---
5+
6+
Prompt for confirmation before running multi-environment theme commands that allow `--force` flag

packages/theme/src/cli/utilities/theme-command.test.ts

Lines changed: 111 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import ThemeCommand from './theme-command.js'
2+
import {ensureThemeStore} from './theme-store.js'
23
import {describe, vi, expect, test, beforeEach} from 'vitest'
34
import {Config, Flags} from '@oclif/core'
45
import {AdminSession, ensureAuthenticatedThemes} from '@shopify/cli-kit/node/session'
@@ -9,6 +10,7 @@ import type {Writable} from 'stream'
910
vi.mock('@shopify/cli-kit/node/session')
1011
vi.mock('@shopify/cli-kit/node/environments')
1112
vi.mock('@shopify/cli-kit/node/ui')
13+
vi.mock('./theme-store.js')
1214

1315
const CommandConfig = new Config({root: __dirname})
1416

@@ -45,6 +47,17 @@ class TestThemeCommand extends ThemeCommand {
4547
}
4648
}
4749

50+
class TestThemeCommandWithForce extends TestThemeCommand {
51+
static flags = {
52+
...TestThemeCommand.flags,
53+
force: Flags.boolean({
54+
char: 'f',
55+
description: 'Skip confirmation',
56+
env: 'SHOPIFY_FLAG_FORCE',
57+
}),
58+
}
59+
}
60+
4861
describe('ThemeCommand', () => {
4962
let mockSession: AdminSession
5063

@@ -53,6 +66,7 @@ describe('ThemeCommand', () => {
5366
token: 'test-token',
5467
storeFqdn: 'test-store.myshopify.com',
5568
}
69+
vi.mocked(ensureThemeStore).mockReturnValue('test-store.myshopify.com')
5670
vi.mocked(ensureAuthenticatedThemes).mockResolvedValue(mockSession)
5771
})
5872

@@ -61,7 +75,6 @@ describe('ThemeCommand', () => {
6175
// Given
6276
await CommandConfig.load()
6377
const command = new TestThemeCommand([], CommandConfig)
64-
6578
// When
6679
await command.run()
6780

@@ -136,6 +149,103 @@ describe('ThemeCommand', () => {
136149
})
137150

138151
describe('multi environment', () => {
152+
test('commands with --force flag should not prompt for confirmation', async () => {
153+
// Given
154+
const environmentConfig = {store: 'store.myshopify.com'}
155+
vi.mocked(loadEnvironment).mockResolvedValue(environmentConfig)
156+
vi.mocked(renderConfirmationPrompt).mockResolvedValue(true)
157+
vi.mocked(renderConcurrent).mockResolvedValue(undefined)
158+
159+
await CommandConfig.load()
160+
const command = new TestThemeCommandWithForce(
161+
['--environment', 'development', '--environment', 'staging', '--force'],
162+
CommandConfig,
163+
)
164+
165+
// When
166+
await command.run()
167+
168+
// Then
169+
expect(renderConfirmationPrompt).not.toHaveBeenCalled()
170+
expect(renderConcurrent).toHaveBeenCalledOnce()
171+
expect(renderConcurrent).toHaveBeenCalledWith(
172+
expect.objectContaining({
173+
processes: expect.arrayContaining([
174+
expect.objectContaining({prefix: 'development'}),
175+
expect.objectContaining({prefix: 'staging'}),
176+
]),
177+
showTimestamps: true,
178+
}),
179+
)
180+
})
181+
182+
test('commands that do not allow --force flag should not prompt for confirmation', async () => {
183+
// Given
184+
const environmentConfig = {store: 'store.myshopify.com'}
185+
vi.mocked(loadEnvironment).mockResolvedValue(environmentConfig)
186+
vi.mocked(renderConfirmationPrompt).mockResolvedValue(true)
187+
vi.mocked(renderConcurrent).mockResolvedValue(undefined)
188+
189+
await CommandConfig.load()
190+
const command = new TestThemeCommand(['--environment', 'development', '--environment', 'staging'], CommandConfig)
191+
192+
// When
193+
await command.run()
194+
195+
// Then
196+
expect(renderConfirmationPrompt).not.toHaveBeenCalled()
197+
expect(renderConcurrent).toHaveBeenCalledOnce()
198+
})
199+
200+
test('commands without --force flag that allow it should prompt for confirmation', async () => {
201+
// Given
202+
const environmentConfig = {store: 'store.myshopify.com'}
203+
vi.mocked(loadEnvironment).mockResolvedValue(environmentConfig)
204+
vi.mocked(renderConfirmationPrompt).mockResolvedValue(true)
205+
vi.mocked(renderConcurrent).mockResolvedValue(undefined)
206+
207+
await CommandConfig.load()
208+
const command = new TestThemeCommandWithForce(
209+
['--environment', 'development', '--environment', 'staging'],
210+
CommandConfig,
211+
)
212+
213+
// When
214+
await command.run()
215+
216+
// Then
217+
expect(renderConfirmationPrompt).toHaveBeenCalledOnce()
218+
expect(renderConfirmationPrompt).toHaveBeenCalledWith(
219+
expect.objectContaining({
220+
message: expect.any(Array),
221+
confirmationMessage: 'Yes, proceed',
222+
cancellationMessage: 'Cancel',
223+
}),
224+
)
225+
expect(renderConcurrent).toHaveBeenCalledOnce()
226+
})
227+
228+
test('should not execute command if confirmation is cancelled', async () => {
229+
// Given
230+
const environmentConfig = {store: 'store.myshopify.com'}
231+
vi.mocked(loadEnvironment).mockResolvedValue(environmentConfig)
232+
vi.mocked(renderConfirmationPrompt).mockResolvedValue(false)
233+
vi.mocked(renderConcurrent).mockResolvedValue(undefined)
234+
235+
await CommandConfig.load()
236+
const command = new TestThemeCommandWithForce(
237+
['--environment', 'development', '--environment', 'staging'],
238+
CommandConfig,
239+
)
240+
241+
// When
242+
await command.run()
243+
244+
// Then
245+
expect(renderConfirmationPrompt).toHaveBeenCalledOnce()
246+
expect(renderConcurrent).not.toHaveBeenCalled()
247+
})
248+
139249
test('should not execute commands in environments that are missing required flags', async () => {
140250
// Given
141251
vi.mocked(loadEnvironment)

packages/theme/src/cli/utilities/theme-command.ts

Lines changed: 63 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,13 @@ import {Input} from '@oclif/core/interfaces'
44
import Command, {ArgOutput, FlagOutput} from '@shopify/cli-kit/node/base-command'
55
import {AdminSession, ensureAuthenticatedThemes} from '@shopify/cli-kit/node/session'
66
import {loadEnvironment} from '@shopify/cli-kit/node/environments'
7-
import {renderWarning, renderConcurrent, renderError} from '@shopify/cli-kit/node/ui'
7+
import {
8+
renderWarning,
9+
renderConcurrent,
10+
renderConfirmationPrompt,
11+
RenderConfirmationPromptOptions,
12+
renderError,
13+
} from '@shopify/cli-kit/node/ui'
814
import {AbortController} from '@shopify/cli-kit/node/abort'
915
import type {Writable} from 'stream'
1016

@@ -53,6 +59,7 @@ export default abstract class ThemeCommand extends Command {
5359
// Parse command flags using the current command class definitions
5460
const klass = this.constructor as unknown as Input<TFlags, TGlobalFlags, TArgs> & {
5561
multiEnvironmentsFlags: string[]
62+
flags: FlagOutput
5663
}
5764
const requiredFlags = klass.multiEnvironmentsFlags
5865
const {flags} = await this.parse(klass)
@@ -71,6 +78,13 @@ export default abstract class ThemeCommand extends Command {
7178
const environmentsMap = await this.loadEnvironments(environments, flags)
7279
const validationResults = await this.validateEnvironments(environmentsMap, requiredFlags)
7380

81+
const commandAllowsForceFlag = 'force' in klass.flags
82+
83+
if (commandAllowsForceFlag && !flags.force) {
84+
const confirmed = await this.showConfirmation(this.constructor.name, requiredFlags, validationResults)
85+
if (!confirmed) return
86+
}
87+
7488
await this.runConcurrent(validationResults.valid)
7589
}
7690

@@ -114,22 +128,66 @@ export default abstract class ThemeCommand extends Command {
114128
const invalid: {environment: EnvironmentName; reason: string}[] = []
115129

116130
for (const [environmentName, environmentFlags] of environmentMap) {
117-
// eslint-disable-next-line no-await-in-loop
118-
const session = await this.createSession(environmentFlags)
119-
120131
const validationResult = this.validConfig(environmentFlags, requiredFlags, environmentName)
121132
if (validationResult !== true) {
122133
const missingFlagsText = validationResult.join(', ')
123134
invalid.push({environment: environmentName, reason: `Missing flags: ${missingFlagsText}`})
124135
continue
125136
}
126137

138+
// eslint-disable-next-line no-await-in-loop
139+
const session = await this.createSession(environmentFlags)
140+
127141
valid.push({environment: environmentName, flags: environmentFlags, session})
128142
}
129143

130144
return {valid, invalid}
131145
}
132146

147+
/**
148+
* Show a confirmation prompt
149+
* @param commandName - The name of the command being run
150+
* @param requiredFlags - The flags required to run the command
151+
* @param validationResults - The environments split into valid and invalid
152+
* @returns Whether the user confirmed the action
153+
*/
154+
private async showConfirmation(
155+
commandName: string,
156+
requiredFlags: string[],
157+
validationResults: {
158+
valid: {environment: string; flags: FlagValues}[]
159+
invalid: {environment: string; reason: string}[]
160+
},
161+
) {
162+
const command = commandName.toLowerCase()
163+
const message = [`Run ${command} in the following environments?`]
164+
165+
const options: RenderConfirmationPromptOptions = {
166+
message,
167+
confirmationMessage: 'Yes, proceed',
168+
cancellationMessage: 'Cancel',
169+
}
170+
171+
const environmentDetails = [
172+
...validationResults.valid.map(({environment, flags}) => {
173+
const flagDetails = requiredFlags
174+
.map((flag) => (flag.includes('password') ? flag : `${flag}: ${String(flags[flag])}`))
175+
.join(', ')
176+
177+
return [environment, {subdued: flagDetails || 'No flags required'}]
178+
}),
179+
...validationResults.invalid.map(({environment, reason}) => [environment, {error: `Skipping | ${reason}`}]),
180+
]
181+
182+
options.infoTable = {Environment: environmentDetails}
183+
184+
if (validationResults.invalid.length > 0) {
185+
options.confirmationMessage = 'Proceed anyway (will skip invalid environments)'
186+
}
187+
188+
return renderConfirmationPrompt(options)
189+
}
190+
133191
/**
134192
* Run the command in each valid environment concurrently
135193
* @param validEnvironments - The valid environments to run the command in
@@ -161,7 +219,7 @@ export default abstract class ThemeCommand extends Command {
161219
}
162220

163221
/**
164-
* Create an authenticated session object from the flags
222+
* Create an unauthenticated session object from store and password
165223
* @param flags - The environment flags containing store and password
166224
* @returns The unauthenticated session object
167225
*/

0 commit comments

Comments
 (0)