diff --git a/.changeset/lucky-dots-watch.md b/.changeset/lucky-dots-watch.md new file mode 100644 index 00000000000..7cfa73254d6 --- /dev/null +++ b/.changeset/lucky-dots-watch.md @@ -0,0 +1,5 @@ +--- +'@shopify/app': minor +--- + +Add --allow-updates and --allow-delete flags to deploy and release diff --git a/docs-shopify.dev/commands/interfaces/app-deploy.interface.ts b/docs-shopify.dev/commands/interfaces/app-deploy.interface.ts index 5a2e0f7e850..08078c3575c 100644 --- a/docs-shopify.dev/commands/interfaces/app-deploy.interface.ts +++ b/docs-shopify.dev/commands/interfaces/app-deploy.interface.ts @@ -1,5 +1,17 @@ // This is an autogenerated file. Don't edit this file manually. export interface appdeploy { + /** + * Allows removing extensions and configuration without requiring user confirmation. For CI/CD environments, the recommended flag is --allow-updates. + * @environment SHOPIFY_FLAG_ALLOW_DELETES + */ + '--allow-deletes'?: '' + + /** + * Allows adding and updating extensions and configuration without requiring user confirmation. Recommended option for CI/CD environments. + * @environment SHOPIFY_FLAG_ALLOW_UPDATES + */ + '--allow-updates'?: '' + /** * The Client ID of your app. * @environment SHOPIFY_FLAG_CLIENT_ID @@ -13,7 +25,7 @@ export interface appdeploy { '-c, --config '?: string /** - * Deploy without asking for confirmation. + * Deploy without asking for confirmation. Equivalent to --allow-updates --allow-deletes. For CI/CD environments, the recommended flag is --allow-updates. * @environment SHOPIFY_FLAG_FORCE */ '-f, --force'?: '' @@ -37,7 +49,7 @@ export interface appdeploy { '--no-color'?: '' /** - * Creates a version but doesn't release it - it's not made available to merchants. + * Creates a version but doesn't release it - it's not made available to merchants. With this flag, a user confirmation is not required. * @environment SHOPIFY_FLAG_NO_RELEASE */ '--no-release'?: '' diff --git a/docs-shopify.dev/commands/interfaces/app-release.interface.ts b/docs-shopify.dev/commands/interfaces/app-release.interface.ts index f057da4d92a..ad01162281e 100644 --- a/docs-shopify.dev/commands/interfaces/app-release.interface.ts +++ b/docs-shopify.dev/commands/interfaces/app-release.interface.ts @@ -1,5 +1,17 @@ // This is an autogenerated file. Don't edit this file manually. export interface apprelease { + /** + * Allows removing extensions and configuration without requiring user confirmation. For CI/CD environments, the recommended flag is --allow-updates. + * @environment SHOPIFY_FLAG_ALLOW_DELETES + */ + '--allow-deletes'?: '' + + /** + * Allows adding and updating extensions and configuration without requiring user confirmation. Recommended option for CI/CD environments. + * @environment SHOPIFY_FLAG_ALLOW_UPDATES + */ + '--allow-updates'?: '' + /** * The Client ID of your app. * @environment SHOPIFY_FLAG_CLIENT_ID @@ -13,7 +25,7 @@ export interface apprelease { '-c, --config '?: string /** - * Release without asking for confirmation. + * Release without asking for confirmation. Equivalent to --allow-updates --allow-deletes. For CI/CD environments, the recommended flag is --allow-updates. * @environment SHOPIFY_FLAG_FORCE */ '-f, --force'?: '' diff --git a/docs-shopify.dev/generated/generated_docs_data.json b/docs-shopify.dev/generated/generated_docs_data.json index 0d1e110a135..eb04908faa7 100644 --- a/docs-shopify.dev/generated/generated_docs_data.json +++ b/docs-shopify.dev/generated/generated_docs_data.json @@ -396,6 +396,24 @@ "name": "appdeploy", "description": "", "members": [ + { + "filePath": "docs-shopify.dev/commands/interfaces/app-deploy.interface.ts", + "syntaxKind": "PropertySignature", + "name": "--allow-deletes", + "value": "\"\"", + "description": "Allows removing extensions and configuration without requiring user confirmation. For CI/CD environments, the recommended flag is --allow-updates.", + "isOptional": true, + "environmentValue": "SHOPIFY_FLAG_ALLOW_DELETES" + }, + { + "filePath": "docs-shopify.dev/commands/interfaces/app-deploy.interface.ts", + "syntaxKind": "PropertySignature", + "name": "--allow-updates", + "value": "\"\"", + "description": "Allows adding and updating extensions and configuration without requiring user confirmation. Recommended option for CI/CD environments.", + "isOptional": true, + "environmentValue": "SHOPIFY_FLAG_ALLOW_UPDATES" + }, { "filePath": "docs-shopify.dev/commands/interfaces/app-deploy.interface.ts", "syntaxKind": "PropertySignature", @@ -437,7 +455,7 @@ "syntaxKind": "PropertySignature", "name": "--no-release", "value": "\"\"", - "description": "Creates a version but doesn't release it - it's not made available to merchants.", + "description": "Creates a version but doesn't release it - it's not made available to merchants. With this flag, a user confirmation is not required.", "isOptional": true, "environmentValue": "SHOPIFY_FLAG_NO_RELEASE" }, @@ -500,12 +518,12 @@ "syntaxKind": "PropertySignature", "name": "-f, --force", "value": "\"\"", - "description": "Deploy without asking for confirmation.", + "description": "Deploy without asking for confirmation. Equivalent to --allow-updates --allow-deletes. For CI/CD environments, the recommended flag is --allow-updates.", "isOptional": true, "environmentValue": "SHOPIFY_FLAG_FORCE" } ], - "value": "export interface appdeploy {\n /**\n * The Client ID of your app.\n * @environment SHOPIFY_FLAG_CLIENT_ID\n */\n '--client-id '?: string\n\n /**\n * The name of the app configuration.\n * @environment SHOPIFY_FLAG_APP_CONFIG\n */\n '-c, --config '?: string\n\n /**\n * Deploy without asking for confirmation.\n * @environment SHOPIFY_FLAG_FORCE\n */\n '-f, --force'?: ''\n\n /**\n * Optional message that will be associated with this version. This is for internal use only and won't be available externally.\n * @environment SHOPIFY_FLAG_MESSAGE\n */\n '--message '?: string\n\n /**\n * Use with caution: Skips building any elements of the app that require building. You should ensure your app has been prepared in advance, such as by running `shopify app build` or by caching build artifacts.\n * @environment SHOPIFY_FLAG_NO_BUILD\n */\n '--no-build'?: ''\n\n /**\n * Disable color output.\n * @environment SHOPIFY_FLAG_NO_COLOR\n */\n '--no-color'?: ''\n\n /**\n * Creates a version but doesn't release it - it's not made available to merchants.\n * @environment SHOPIFY_FLAG_NO_RELEASE\n */\n '--no-release'?: ''\n\n /**\n * The path to your app directory.\n * @environment SHOPIFY_FLAG_PATH\n */\n '--path '?: string\n\n /**\n * Reset all your settings.\n * @environment SHOPIFY_FLAG_RESET\n */\n '--reset'?: ''\n\n /**\n * URL associated with the new app version.\n * @environment SHOPIFY_FLAG_SOURCE_CONTROL_URL\n */\n '--source-control-url '?: string\n\n /**\n * Increase the verbosity of the output.\n * @environment SHOPIFY_FLAG_VERBOSE\n */\n '--verbose'?: ''\n\n /**\n * Optional version tag that will be associated with this app version. If not provided, an auto-generated identifier will be generated for this app version.\n * @environment SHOPIFY_FLAG_VERSION\n */\n '--version '?: string\n}" + "value": "export interface appdeploy {\n /**\n * Allows removing extensions and configuration without requiring user confirmation. For CI/CD environments, the recommended flag is --allow-updates.\n * @environment SHOPIFY_FLAG_ALLOW_DELETES\n */\n '--allow-deletes'?: ''\n\n /**\n * Allows adding and updating extensions and configuration without requiring user confirmation. Recommended option for CI/CD environments.\n * @environment SHOPIFY_FLAG_ALLOW_UPDATES\n */\n '--allow-updates'?: ''\n\n /**\n * The Client ID of your app.\n * @environment SHOPIFY_FLAG_CLIENT_ID\n */\n '--client-id '?: string\n\n /**\n * The name of the app configuration.\n * @environment SHOPIFY_FLAG_APP_CONFIG\n */\n '-c, --config '?: string\n\n /**\n * Deploy without asking for confirmation. Equivalent to --allow-updates --allow-deletes. For CI/CD environments, the recommended flag is --allow-updates.\n * @environment SHOPIFY_FLAG_FORCE\n */\n '-f, --force'?: ''\n\n /**\n * Optional message that will be associated with this version. This is for internal use only and won't be available externally.\n * @environment SHOPIFY_FLAG_MESSAGE\n */\n '--message '?: string\n\n /**\n * Use with caution: Skips building any elements of the app that require building. You should ensure your app has been prepared in advance, such as by running `shopify app build` or by caching build artifacts.\n * @environment SHOPIFY_FLAG_NO_BUILD\n */\n '--no-build'?: ''\n\n /**\n * Disable color output.\n * @environment SHOPIFY_FLAG_NO_COLOR\n */\n '--no-color'?: ''\n\n /**\n * Creates a version but doesn't release it - it's not made available to merchants. With this flag, a user confirmation is not required.\n * @environment SHOPIFY_FLAG_NO_RELEASE\n */\n '--no-release'?: ''\n\n /**\n * The path to your app directory.\n * @environment SHOPIFY_FLAG_PATH\n */\n '--path '?: string\n\n /**\n * Reset all your settings.\n * @environment SHOPIFY_FLAG_RESET\n */\n '--reset'?: ''\n\n /**\n * URL associated with the new app version.\n * @environment SHOPIFY_FLAG_SOURCE_CONTROL_URL\n */\n '--source-control-url '?: string\n\n /**\n * Increase the verbosity of the output.\n * @environment SHOPIFY_FLAG_VERBOSE\n */\n '--verbose'?: ''\n\n /**\n * Optional version tag that will be associated with this app version. If not provided, an auto-generated identifier will be generated for this app version.\n * @environment SHOPIFY_FLAG_VERSION\n */\n '--version '?: string\n}" } } } @@ -2421,6 +2439,24 @@ "name": "apprelease", "description": "", "members": [ + { + "filePath": "docs-shopify.dev/commands/interfaces/app-release.interface.ts", + "syntaxKind": "PropertySignature", + "name": "--allow-deletes", + "value": "\"\"", + "description": "Allows removing extensions and configuration without requiring user confirmation. For CI/CD environments, the recommended flag is --allow-updates.", + "isOptional": true, + "environmentValue": "SHOPIFY_FLAG_ALLOW_DELETES" + }, + { + "filePath": "docs-shopify.dev/commands/interfaces/app-release.interface.ts", + "syntaxKind": "PropertySignature", + "name": "--allow-updates", + "value": "\"\"", + "description": "Allows adding and updating extensions and configuration without requiring user confirmation. Recommended option for CI/CD environments.", + "isOptional": true, + "environmentValue": "SHOPIFY_FLAG_ALLOW_UPDATES" + }, { "filePath": "docs-shopify.dev/commands/interfaces/app-release.interface.ts", "syntaxKind": "PropertySignature", @@ -2488,12 +2524,12 @@ "syntaxKind": "PropertySignature", "name": "-f, --force", "value": "\"\"", - "description": "Release without asking for confirmation.", + "description": "Release without asking for confirmation. Equivalent to --allow-updates --allow-deletes. For CI/CD environments, the recommended flag is --allow-updates.", "isOptional": true, "environmentValue": "SHOPIFY_FLAG_FORCE" } ], - "value": "export interface apprelease {\n /**\n * The Client ID of your app.\n * @environment SHOPIFY_FLAG_CLIENT_ID\n */\n '--client-id '?: string\n\n /**\n * The name of the app configuration.\n * @environment SHOPIFY_FLAG_APP_CONFIG\n */\n '-c, --config '?: string\n\n /**\n * Release without asking for confirmation.\n * @environment SHOPIFY_FLAG_FORCE\n */\n '-f, --force'?: ''\n\n /**\n * Disable color output.\n * @environment SHOPIFY_FLAG_NO_COLOR\n */\n '--no-color'?: ''\n\n /**\n * The path to your app directory.\n * @environment SHOPIFY_FLAG_PATH\n */\n '--path '?: string\n\n /**\n * Reset all your settings.\n * @environment SHOPIFY_FLAG_RESET\n */\n '--reset'?: ''\n\n /**\n * Increase the verbosity of the output.\n * @environment SHOPIFY_FLAG_VERBOSE\n */\n '--verbose'?: ''\n\n /**\n * The name of the app version to release.\n * @environment SHOPIFY_FLAG_VERSION\n */\n '--version ': string\n}" + "value": "export interface apprelease {\n /**\n * Allows removing extensions and configuration without requiring user confirmation. For CI/CD environments, the recommended flag is --allow-updates.\n * @environment SHOPIFY_FLAG_ALLOW_DELETES\n */\n '--allow-deletes'?: ''\n\n /**\n * Allows adding and updating extensions and configuration without requiring user confirmation. Recommended option for CI/CD environments.\n * @environment SHOPIFY_FLAG_ALLOW_UPDATES\n */\n '--allow-updates'?: ''\n\n /**\n * The Client ID of your app.\n * @environment SHOPIFY_FLAG_CLIENT_ID\n */\n '--client-id '?: string\n\n /**\n * The name of the app configuration.\n * @environment SHOPIFY_FLAG_APP_CONFIG\n */\n '-c, --config '?: string\n\n /**\n * Release without asking for confirmation. Equivalent to --allow-updates --allow-deletes. For CI/CD environments, the recommended flag is --allow-updates.\n * @environment SHOPIFY_FLAG_FORCE\n */\n '-f, --force'?: ''\n\n /**\n * Disable color output.\n * @environment SHOPIFY_FLAG_NO_COLOR\n */\n '--no-color'?: ''\n\n /**\n * The path to your app directory.\n * @environment SHOPIFY_FLAG_PATH\n */\n '--path '?: string\n\n /**\n * Reset all your settings.\n * @environment SHOPIFY_FLAG_RESET\n */\n '--reset'?: ''\n\n /**\n * Increase the verbosity of the output.\n * @environment SHOPIFY_FLAG_VERBOSE\n */\n '--verbose'?: ''\n\n /**\n * The name of the app version to release.\n * @environment SHOPIFY_FLAG_VERSION\n */\n '--version ': string\n}" } } } diff --git a/packages/app/src/cli/commands/app/deploy.ts b/packages/app/src/cli/commands/app/deploy.ts index 1ea49402070..1f169c0b98d 100644 --- a/packages/app/src/cli/commands/app/deploy.ts +++ b/packages/app/src/cli/commands/app/deploy.ts @@ -1,6 +1,5 @@ import {appFlags} from '../../flags.js' import {deploy} from '../../services/deploy.js' -import {getAppConfigurationState} from '../../models/app/loader.js' import {validateVersion} from '../../validations/version-name.js' import {validateMessage} from '../../validations/message.js' import metadata from '../../metadata.js' @@ -27,13 +26,27 @@ export default class Deploy extends AppLinkedCommand { ...appFlags, force: Flags.boolean({ hidden: false, - description: 'Deploy without asking for confirmation.', + description: + 'Deploy without asking for confirmation. Equivalent to --allow-updates --allow-deletes. For CI/CD environments, the recommended flag is --allow-updates.', env: 'SHOPIFY_FLAG_FORCE', char: 'f', }), + 'allow-updates': Flags.boolean({ + hidden: false, + description: + 'Allows adding and updating extensions and configuration without requiring user confirmation. Recommended option for CI/CD environments.', + env: 'SHOPIFY_FLAG_ALLOW_UPDATES', + }), + 'allow-deletes': Flags.boolean({ + hidden: false, + description: + 'Allows removing extensions and configuration without requiring user confirmation. For CI/CD environments, the recommended flag is --allow-updates.', + env: 'SHOPIFY_FLAG_ALLOW_DELETES', + }), 'no-release': Flags.boolean({ hidden: false, - description: "Creates a version but doesn't release it - it's not made available to merchants.", + description: + "Creates a version but doesn't release it - it's not made available to merchants. With this flag, a user confirmation is not required.", env: 'SHOPIFY_FLAG_NO_RELEASE', default: false, }), @@ -80,10 +93,13 @@ export default class Deploy extends AppLinkedCommand { cmd_app_reset_used: flags.reset, })) - const requiredNonTTYFlags = ['force'] - const configurationState = await getAppConfigurationState(flags.path, flags.config) - if (configurationState.state === 'template-only' && !clientId) { - requiredNonTTYFlags.push('client-id') + const force = flags.force || flags['no-release'] + + // When releasing, we require --force or --allow-updates or --allow-deletes for non-TTY. + const requiredNonTTYFlags: string[] = [] + const hasAnyForceFlags = force || flags['allow-updates'] || flags['allow-deletes'] + if (!hasAnyForceFlags) { + requiredNonTTYFlags.push('allow-updates') } this.failMissingNonTTYFlags(flags, requiredNonTTYFlags) @@ -94,13 +110,18 @@ export default class Deploy extends AppLinkedCommand { userProvidedConfigName: flags.config, }) + const allowUpdates = force || flags['allow-updates'] + const allowDeletes = force || flags['allow-deletes'] + const result = await deploy({ app, remoteApp, organization, developerPlatformClient, reset: flags.reset, - force: flags.force, + force, + allowUpdates, + allowDeletes, noRelease: flags['no-release'], message: flags.message, version: flags.version, diff --git a/packages/app/src/cli/commands/app/release.ts b/packages/app/src/cli/commands/app/release.ts index 8fb6d419980..6fe89496ed1 100644 --- a/packages/app/src/cli/commands/app/release.ts +++ b/packages/app/src/cli/commands/app/release.ts @@ -21,10 +21,23 @@ export default class Release extends AppLinkedCommand { ...appFlags, force: Flags.boolean({ hidden: false, - description: 'Release without asking for confirmation.', + description: + 'Release without asking for confirmation. Equivalent to --allow-updates --allow-deletes. For CI/CD environments, the recommended flag is --allow-updates.', env: 'SHOPIFY_FLAG_FORCE', char: 'f', }), + 'allow-updates': Flags.boolean({ + hidden: false, + description: + 'Allows adding and updating extensions and configuration without requiring user confirmation. Recommended option for CI/CD environments.', + env: 'SHOPIFY_FLAG_ALLOW_UPDATES', + }), + 'allow-deletes': Flags.boolean({ + hidden: false, + description: + 'Allows removing extensions and configuration without requiring user confirmation. For CI/CD environments, the recommended flag is --allow-updates.', + env: 'SHOPIFY_FLAG_ALLOW_DELETES', + }), version: Flags.string({ hidden: false, description: 'The name of the app version to release.', @@ -41,7 +54,12 @@ export default class Release extends AppLinkedCommand { cmd_app_reset_used: flags.reset, })) - const requiredNonTTYFlags = ['force'] + // We require --force or --allow-updates or --allow-deletes for non-TTY. + const requiredNonTTYFlags: string[] = [] + const hasAnyForceFlags = flags.force || flags['allow-updates'] || flags['allow-deletes'] + if (!hasAnyForceFlags) { + requiredNonTTYFlags.push('allow-updates') + } const configurationState = await getAppConfigurationState(flags.path, flags.config) if (configurationState.state === 'template-only' && !clientId) { requiredNonTTYFlags.push('client-id') @@ -55,11 +73,16 @@ export default class Release extends AppLinkedCommand { userProvidedConfigName: flags.config, }) + const allowUpdates = flags.force || flags['allow-updates'] + const allowDeletes = flags.force || flags['allow-deletes'] + await release({ app, remoteApp, developerPlatformClient, force: flags.force, + allowUpdates, + allowDeletes, version: flags.version, }) diff --git a/packages/app/src/cli/prompts/deploy-release.test.ts b/packages/app/src/cli/prompts/deploy-release.test.ts index 4eff2440102..1f3331c14e9 100644 --- a/packages/app/src/cli/prompts/deploy-release.test.ts +++ b/packages/app/src/cli/prompts/deploy-release.test.ts @@ -6,11 +6,16 @@ import { buildDashboardBreakdownInfo, buildExtensionBreakdownInfo, } from '../services/context/breakdown-extensions.js' -import {MockInstance, describe, expect, test, vi} from 'vitest' +import {MockInstance, beforeEach, describe, expect, test, vi} from 'vitest' import * as ui from '@shopify/cli-kit/node/ui' vi.mock('@shopify/cli-kit/node/context/local') +beforeEach(() => { + // Mock isTTY to return true so prompts are shown instead of errors in tests + vi.spyOn(ui, 'isTTY').mockReturnValue(true) +}) + describe('deployOrReleaseConfirmationPrompt', () => { describe('when release', () => { test('and force no prompt should be displayed and true returned', async () => { @@ -408,6 +413,142 @@ describe('deployOrReleaseConfirmationPrompt', () => { expect(result).toBe(true) }) }) + + describe('allow-updates and allow-deletes flags', () => { + test('allowUpdates and allowDeletes together should skip prompt and return true', async () => { + // Given + const breakdownInfo = buildCompleteBreakdownInfo() + const renderConfirmationPromptSpyOn = vi.spyOn(ui, 'renderConfirmationPrompt') + vi.spyOn(metadata, 'addPublicMetadata').mockImplementation(async () => {}) + + // When + const result = await deployOrReleaseConfirmationPrompt({ + ...breakdownInfo, + appTitle: 'app title', + release: true, + force: false, + allowUpdates: true, + allowDeletes: true, + }) + + // Then + expect(renderConfirmationPromptSpyOn).not.toHaveBeenCalled() + expect(result).toBe(true) + }) + + test('allowUpdates without deletes should skip prompt and return true', async () => { + // Given + const breakdownInfo = buildCompleteBreakdownInfo() + // Remove all deletions + breakdownInfo.extensionIdentifiersBreakdown.onlyRemote = [] + breakdownInfo.configExtensionIdentifiersBreakdown!.deletedFieldNames = [] + + const renderConfirmationPromptSpyOn = vi.spyOn(ui, 'renderConfirmationPrompt') + vi.spyOn(metadata, 'addPublicMetadata').mockImplementation(async () => {}) + + // When + const result = await deployOrReleaseConfirmationPrompt({ + ...breakdownInfo, + appTitle: 'app title', + release: true, + force: false, + allowUpdates: true, + allowDeletes: false, + }) + + // Then + expect(renderConfirmationPromptSpyOn).not.toHaveBeenCalled() + expect(result).toBe(true) + }) + + test('allowUpdates with deletes should show prompt in TTY', async () => { + // Given + const breakdownInfo = buildCompleteBreakdownInfo() + + const renderDangerousConfirmationPromptSpyOn = vi + .spyOn(ui, 'renderDangerousConfirmationPrompt') + .mockResolvedValue(true) + vi.spyOn(metadata, 'addPublicMetadata').mockImplementation(async () => {}) + vi.spyOn(ui, 'isTTY').mockReturnValue(true) + + // When + const result = await deployOrReleaseConfirmationPrompt({ + ...breakdownInfo, + appTitle: 'app title', + release: true, + force: false, + allowUpdates: true, + allowDeletes: false, + }) + + // Then + expect(renderDangerousConfirmationPromptSpyOn).toHaveBeenCalled() + expect(result).toBe(true) + }) + + test('allowDeletes without updates should skip prompt and return true', async () => { + // Given + const breakdownInfo = buildEmptyBreakdownInfo() + // Add only deletions + breakdownInfo.extensionIdentifiersBreakdown.onlyRemote = [buildExtensionBreakdownInfo('remote', 'uid')] + breakdownInfo.configExtensionIdentifiersBreakdown!.deletedFieldNames = ['deleted field'] + + const renderConfirmationPromptSpyOn = vi.spyOn(ui, 'renderConfirmationPrompt') + vi.spyOn(metadata, 'addPublicMetadata').mockImplementation(async () => {}) + + // When + const result = await deployOrReleaseConfirmationPrompt({ + ...breakdownInfo, + appTitle: 'app title', + release: true, + force: false, + allowUpdates: false, + allowDeletes: true, + }) + + // Then + expect(renderConfirmationPromptSpyOn).not.toHaveBeenCalled() + expect(result).toBe(true) + }) + + test('non-TTY with deletes and without allowDeletes should throw error', async () => { + // Given + const breakdownInfo = buildCompleteBreakdownInfo() + vi.spyOn(metadata, 'addPublicMetadata').mockImplementation(async () => {}) + vi.spyOn(ui, 'isTTY').mockReturnValue(false) + + // When/Then + await expect( + deployOrReleaseConfirmationPrompt({ + ...breakdownInfo, + appTitle: 'app title', + release: true, + force: false, + allowUpdates: true, + allowDeletes: false, + }), + ).rejects.toThrow('This deployment includes changes that require confirmation.') + }) + + test('non-TTY with updates and without allowUpdates should throw error', async () => { + // Given + const breakdownInfo = buildCompleteBreakdownInfo() + vi.spyOn(metadata, 'addPublicMetadata').mockImplementation(async () => {}) + vi.spyOn(ui, 'isTTY').mockReturnValue(false) + + // When/Then + await expect( + deployOrReleaseConfirmationPrompt({ + ...breakdownInfo, + appTitle: 'app title', + release: true, + force: false, + allowUpdates: false, + allowDeletes: true, + }), + ).rejects.toThrow('This deployment includes changes that require confirmation.') + }) + }) }) interface RenderConfirmationPromptContentOptions { diff --git a/packages/app/src/cli/prompts/deploy-release.ts b/packages/app/src/cli/prompts/deploy-release.ts index 11379ce7591..5bb428a5109 100644 --- a/packages/app/src/cli/prompts/deploy-release.ts +++ b/packages/app/src/cli/prompts/deploy-release.ts @@ -5,7 +5,13 @@ import { ExtensionIdentifierBreakdownInfo, ExtensionIdentifiersBreakdown, } from '../services/context/breakdown-extensions.js' -import {InfoTableSection, renderConfirmationPrompt, renderDangerousConfirmationPrompt} from '@shopify/cli-kit/node/ui' +import { + InfoTableSection, + renderConfirmationPrompt, + renderDangerousConfirmationPrompt, + isTTY, +} from '@shopify/cli-kit/node/ui' +import {AbortError} from '@shopify/cli-kit/node/error' interface DeployOrReleaseConfirmationPromptOptions { extensionIdentifiersBreakdown: ExtensionIdentifiersBreakdown @@ -13,6 +19,10 @@ interface DeployOrReleaseConfirmationPromptOptions { appTitle?: string release: boolean force: boolean + /** If true, allow adding and updating extensions and configuration without user confirmation */ + allowUpdates?: boolean + /** If true, allow removing extensions and configuration without user confirmation */ + allowDeletes?: boolean showConfig?: boolean } @@ -28,15 +38,77 @@ interface DeployConfirmationPromptOptions { release: boolean } +/** + * Determines whether the confirmation prompt can be skipped based on the allow flags and change types. + * Returns true if the prompt should be skipped, false if the prompt should be shown. + * Throws an error if in non-TTY mode and there are changes that require confirmation. + */ +function shouldSkipConfirmationPrompt({ + force, + allowUpdates, + allowDeletes, + extensionIdentifiersBreakdown, + configExtensionIdentifiersBreakdown, +}: { + force: boolean + allowUpdates?: boolean + allowDeletes?: boolean + extensionIdentifiersBreakdown: ExtensionIdentifiersBreakdown + configExtensionIdentifiersBreakdown?: ConfigExtensionIdentifiersBreakdown +}): boolean { + // --force is equivalent to --allow-updates --allow-deletes + if (force || (allowUpdates && allowDeletes)) return true + + const hasDeletedExtensions = extensionIdentifiersBreakdown.onlyRemote.length > 0 + const hasDeletedConfig = (configExtensionIdentifiersBreakdown?.deletedFieldNames.length ?? 0) > 0 + const hasDeletes = hasDeletedExtensions || hasDeletedConfig + + const hasNewOrUpdatedExtensions = + extensionIdentifiersBreakdown.toCreate.length > 0 || extensionIdentifiersBreakdown.toUpdate.length > 0 + const hasNewOrUpdatedConfig = + (configExtensionIdentifiersBreakdown?.newFieldNames.length ?? 0) > 0 || + (configExtensionIdentifiersBreakdown?.existingUpdatedFieldNames.length ?? 0) > 0 + const hasUpdates = hasNewOrUpdatedExtensions || hasNewOrUpdatedConfig + + // Skip prompt if we only have updates or deletes and the corresponding allow flag is true + if (allowUpdates && !hasDeletes) return true + if (allowDeletes && !hasUpdates) return true + + // If we're in non-TTY mode and there are changes that require confirmation, throw an error + if (!isTTY() && (hasDeletes || hasUpdates)) { + let suggestedFlag = '--force' + if (hasUpdates && !hasDeletes) suggestedFlag = '--allow-updates' + if (hasDeletes && !hasUpdates) suggestedFlag = '--allow-deletes' + throw new AbortError('This deployment includes changes that require confirmation.', [ + 'Run the command with', + {command: suggestedFlag}, + 'to deploy without confirmation.', + ]) + } + + return false +} + export async function deployOrReleaseConfirmationPrompt({ force, + allowUpdates, + allowDeletes, extensionIdentifiersBreakdown, configExtensionIdentifiersBreakdown, appTitle, release, -}: DeployOrReleaseConfirmationPromptOptions) { +}: DeployOrReleaseConfirmationPromptOptions): Promise { await metadata.addPublicMetadata(() => buildConfigurationBreakdownMetadata(configExtensionIdentifiersBreakdown)) - if (force) return true + + const shouldSkip = shouldSkipConfirmationPrompt({ + force, + allowUpdates, + allowDeletes, + extensionIdentifiersBreakdown, + configExtensionIdentifiersBreakdown, + }) + if (shouldSkip) return true + const extensionsContentPrompt = await buildExtensionsContentPrompt(extensionIdentifiersBreakdown) const configContentPrompt = await buildConfigContentPrompt(release, configExtensionIdentifiersBreakdown) diff --git a/packages/app/src/cli/services/context.ts b/packages/app/src/cli/services/context.ts index 7ae262f5342..a89a30efbca 100644 --- a/packages/app/src/cli/services/context.ts +++ b/packages/app/src/cli/services/context.ts @@ -173,6 +173,8 @@ export async function ensureDeployContext(options: DeployOptions): Promise force: boolean + /** If true, allow adding and updating extensions and configuration without user confirmation */ + allowUpdates?: boolean + /** If true, allow removing extensions and configuration without user confirmation */ + allowDeletes?: boolean release: boolean remoteApp: PartnersAppForIdentifierMatching includeDraftExtensions?: boolean @@ -60,6 +64,8 @@ export async function ensureDeploymentIdsPresence(options: EnsureDeploymentIdsPr appTitle: options.remoteApp?.title, release: options.release, force: options.force, + allowUpdates: options.allowUpdates, + allowDeletes: options.allowDeletes, }) if (!confirmed) throw new AbortSilentError() diff --git a/packages/app/src/cli/services/deploy.ts b/packages/app/src/cli/services/deploy.ts index 00b89f0807a..5cda1effbf9 100644 --- a/packages/app/src/cli/services/deploy.ts +++ b/packages/app/src/cli/services/deploy.ts @@ -35,9 +35,15 @@ export interface DeployOptions { /** If true, ignore any cached appId or extensionId */ reset: boolean - /** If true, proceed with deploy without asking for confirmation */ + /** If true, proceed with deploy without asking for confirmation (equivalent to allowUpdates && allowDeletes) */ force: boolean + /** If true, allow adding and updating extensions and configuration without user confirmation */ + allowUpdates?: boolean + + /** If true, allow removing extensions and configuration without user confirmation */ + allowDeletes?: boolean + /** If true, deploy app without releasing it to the users */ noRelease: boolean @@ -171,7 +177,7 @@ export async function importExtensionsIfNeeded(options: ImportExtensionsIfNeeded } export async function deploy(options: DeployOptions) { - const {remoteApp, developerPlatformClient, noRelease, force} = options + const {remoteApp, developerPlatformClient, noRelease, force, allowUpdates, allowDeletes} = options const app = await importExtensionsIfNeeded({ app: options.app, @@ -184,6 +190,8 @@ export async function deploy(options: DeployOptions) { ...options, app, developerPlatformClient, + allowUpdates, + allowDeletes, }) const release = !noRelease diff --git a/packages/app/src/cli/services/release.ts b/packages/app/src/cli/services/release.ts index 80e5ecd4d4f..cd0e50d4578 100644 --- a/packages/app/src/cli/services/release.ts +++ b/packages/app/src/cli/services/release.ts @@ -20,9 +20,15 @@ interface ReleaseOptions { /** The developer platform client */ developerPlatformClient: DeveloperPlatformClient - /** If true, proceed with deploy without asking for confirmation */ + /** If true, proceed with deploy without asking for confirmation (equivalent to allowUpdates && allowDeletes) */ force: boolean + /** If true, allow adding and updating extensions and configuration without user confirmation */ + allowUpdates?: boolean + + /** If true, allow removing extensions and configuration without user confirmation */ + allowDeletes?: boolean + /** App version tag */ version: string } @@ -51,6 +57,8 @@ export async function release(options: ReleaseOptions) { appTitle: remoteApp.title, release: true, force: options.force, + allowUpdates: options.allowUpdates, + allowDeletes: options.allowDeletes, }) if (!confirmed) throw new AbortSilentError() interface Context { diff --git a/packages/cli/README.md b/packages/cli/README.md index c01aaaa8d26..407a03ae468 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -200,12 +200,18 @@ Deploy your Shopify app. ``` USAGE - $ shopify app deploy [--client-id | -c ] [-f] [--message ] [--no-build] [--no-color] - [--no-release] [--path ] [--reset | ] [--source-control-url ] [--verbose] [--version ] + $ shopify app deploy [--allow-deletes] [--allow-updates] [--client-id | -c ] [-f] [--message + ] [--no-build] [--no-color] [--no-release] [--path ] [--reset | ] [--source-control-url ] + [--verbose] [--version ] FLAGS -c, --config= The name of the app configuration. - -f, --force Deploy without asking for confirmation. + -f, --force Deploy without asking for confirmation. Equivalent to --allow-updates + --allow-deletes. For CI/CD environments, the recommended flag is --allow-updates. + --allow-deletes Allows removing extensions and configuration without requiring user confirmation. + For CI/CD environments, the recommended flag is --allow-updates. + --allow-updates Allows adding and updating extensions and configuration without requiring user + confirmation. Recommended option for CI/CD environments. --client-id= The Client ID of your app. --message= Optional message that will be associated with this version. This is for internal use only and won't be available externally. @@ -214,6 +220,7 @@ FLAGS build` or by caching build artifacts. --no-color Disable color output. --no-release Creates a version but doesn't release it - it's not made available to merchants. + With this flag, a user confirmation is not required. --path= The path to your app directory. --reset Reset all your settings. --source-control-url= URL associated with the new app version. @@ -720,7 +727,12 @@ USAGE FLAGS -c, --config= The name of the app configuration. - -f, --force Release without asking for confirmation. + -f, --force Release without asking for confirmation. Equivalent to --allow-updates --allow-deletes. For + CI/CD environments, the recommended flag is --allow-updates. + --allow-deletes Allows removing extensions and configuration without requiring user confirmation. For CI/CD + environments, the recommended flag is --allow-updates. + --allow-updates Allows adding and updating extensions and configuration without requiring user confirmation. + Recommended option for CI/CD environments. --client-id= The Client ID of your app. --no-color Disable color output. --path= The path to your app directory. diff --git a/packages/cli/oclif.manifest.json b/packages/cli/oclif.manifest.json index 14650db5415..887b8eb591e 100644 --- a/packages/cli/oclif.manifest.json +++ b/packages/cli/oclif.manifest.json @@ -669,6 +669,22 @@ "description": "\"Builds the app\" (https://shopify.dev/docs/api/shopify-cli/app/app-build), then deploys your app configuration and extensions.\n\n This command creates an app version, which is a snapshot of your app configuration and all extensions. This version is then released to users.\n\n This command doesn't deploy your \"web app\" (https://shopify.dev/docs/apps/tools/cli/structure#web-components). You need to \"deploy your web app\" (https://shopify.dev/docs/apps/deployment/web) to your own hosting solution.\n ", "descriptionWithMarkdown": "[Builds the app](https://shopify.dev/docs/api/shopify-cli/app/app-build), then deploys your app configuration and extensions.\n\n This command creates an app version, which is a snapshot of your app configuration and all extensions. This version is then released to users.\n\n This command doesn't deploy your [web app](https://shopify.dev/docs/apps/tools/cli/structure#web-components). You need to [deploy your web app](https://shopify.dev/docs/apps/deployment/web) to your own hosting solution.\n ", "flags": { + "allow-deletes": { + "allowNo": false, + "description": "Allows removing extensions and configuration without requiring user confirmation. For CI/CD environments, the recommended flag is --allow-updates.", + "env": "SHOPIFY_FLAG_ALLOW_DELETES", + "hidden": false, + "name": "allow-deletes", + "type": "boolean" + }, + "allow-updates": { + "allowNo": false, + "description": "Allows adding and updating extensions and configuration without requiring user confirmation. Recommended option for CI/CD environments.", + "env": "SHOPIFY_FLAG_ALLOW_UPDATES", + "hidden": false, + "name": "allow-updates", + "type": "boolean" + }, "client-id": { "description": "The Client ID of your app.", "env": "SHOPIFY_FLAG_CLIENT_ID", @@ -694,7 +710,7 @@ "force": { "allowNo": false, "char": "f", - "description": "Deploy without asking for confirmation.", + "description": "Deploy without asking for confirmation. Equivalent to --allow-updates --allow-deletes. For CI/CD environments, the recommended flag is --allow-updates.", "env": "SHOPIFY_FLAG_FORCE", "hidden": false, "name": "force", @@ -726,7 +742,7 @@ }, "no-release": { "allowNo": false, - "description": "Creates a version but doesn't release it - it's not made available to merchants.", + "description": "Creates a version but doesn't release it - it's not made available to merchants. With this flag, a user confirmation is not required.", "env": "SHOPIFY_FLAG_NO_RELEASE", "hidden": false, "name": "no-release", @@ -2715,6 +2731,22 @@ "description": "Releases an existing app version. Pass the name of the version that you want to release using the `--version` flag.", "descriptionWithMarkdown": "Releases an existing app version. Pass the name of the version that you want to release using the `--version` flag.", "flags": { + "allow-deletes": { + "allowNo": false, + "description": "Allows removing extensions and configuration without requiring user confirmation. For CI/CD environments, the recommended flag is --allow-updates.", + "env": "SHOPIFY_FLAG_ALLOW_DELETES", + "hidden": false, + "name": "allow-deletes", + "type": "boolean" + }, + "allow-updates": { + "allowNo": false, + "description": "Allows adding and updating extensions and configuration without requiring user confirmation. Recommended option for CI/CD environments.", + "env": "SHOPIFY_FLAG_ALLOW_UPDATES", + "hidden": false, + "name": "allow-updates", + "type": "boolean" + }, "client-id": { "description": "The Client ID of your app.", "env": "SHOPIFY_FLAG_CLIENT_ID", @@ -2740,7 +2772,7 @@ "force": { "allowNo": false, "char": "f", - "description": "Release without asking for confirmation.", + "description": "Release without asking for confirmation. Equivalent to --allow-updates --allow-deletes. For CI/CD environments, the recommended flag is --allow-updates.", "env": "SHOPIFY_FLAG_FORCE", "hidden": false, "name": "force",