diff --git a/.changeset/bundle-size-exception-cta.md b/.changeset/bundle-size-exception-cta.md new file mode 100644 index 00000000000..95a2b417896 --- /dev/null +++ b/.changeset/bundle-size-exception-cta.md @@ -0,0 +1,6 @@ +--- +'@shopify/app': minor +'@shopify/cli': minor +--- + +Point at the bundle size exception request flow from bundle-size deploy errors diff --git a/.changeset/deploy-archive-metafiles.md b/.changeset/deploy-archive-metafiles.md new file mode 100644 index 00000000000..aaf7dcedf5c --- /dev/null +++ b/.changeset/deploy-archive-metafiles.md @@ -0,0 +1,6 @@ +--- +'@shopify/app': minor +'@shopify/cli': minor +--- + +Include esbuild metafiles in the app deploy archive to enable bundle-composition analysis diff --git a/packages/app/src/cli/api/graphql/app-management/generated/bundle-size-exception-request.ts b/packages/app/src/cli/api/graphql/app-management/generated/bundle-size-exception-request.ts new file mode 100644 index 00000000000..5025f6532c3 --- /dev/null +++ b/packages/app/src/cli/api/graphql/app-management/generated/bundle-size-exception-request.ts @@ -0,0 +1,120 @@ +/* eslint-disable @typescript-eslint/consistent-type-definitions */ +import * as Types from './types.js' +import {JsonMapType} from '@shopify/cli-kit/node/toml' + +import {TypedDocumentNode as DocumentNode} from '@graphql-typed-document-node/core' + +export type BundleSizeExceptionRequestMutationVariables = Types.Exact<{ + appId: Types.Scalars['ID']['input'] + reason: Types.Scalars['String']['input'] + extensions: Types.UiExtensionSizeContextInput[] | Types.UiExtensionSizeContextInput +}> + +export type BundleSizeExceptionRequestMutation = { + appUiExtensionBundleSizeExceptionRequest: { + exception?: {status: Types.UiExtensionBundleSizeExceptionStatus; effectiveLimitKb: number} | null + userErrors: { + field?: string[] | null + message: string + category: string + code?: Types.Code | null + on: JsonMapType + }[] + } +} + +export const BundleSizeExceptionRequest = { + kind: 'Document', + definitions: [ + { + kind: 'OperationDefinition', + operation: 'mutation', + name: {kind: 'Name', value: 'BundleSizeExceptionRequest'}, + variableDefinitions: [ + { + kind: 'VariableDefinition', + variable: {kind: 'Variable', name: {kind: 'Name', value: 'appId'}}, + type: {kind: 'NonNullType', type: {kind: 'NamedType', name: {kind: 'Name', value: 'ID'}}}, + }, + { + kind: 'VariableDefinition', + variable: {kind: 'Variable', name: {kind: 'Name', value: 'reason'}}, + type: {kind: 'NonNullType', type: {kind: 'NamedType', name: {kind: 'Name', value: 'String'}}}, + }, + { + kind: 'VariableDefinition', + variable: {kind: 'Variable', name: {kind: 'Name', value: 'extensions'}}, + type: { + kind: 'NonNullType', + type: { + kind: 'ListType', + type: { + kind: 'NonNullType', + type: {kind: 'NamedType', name: {kind: 'Name', value: 'UiExtensionSizeContextInput'}}, + }, + }, + }, + }, + ], + selectionSet: { + kind: 'SelectionSet', + selections: [ + { + kind: 'Field', + name: {kind: 'Name', value: 'appUiExtensionBundleSizeExceptionRequest'}, + arguments: [ + { + kind: 'Argument', + name: {kind: 'Name', value: 'appId'}, + value: {kind: 'Variable', name: {kind: 'Name', value: 'appId'}}, + }, + { + kind: 'Argument', + name: {kind: 'Name', value: 'reason'}, + value: {kind: 'Variable', name: {kind: 'Name', value: 'reason'}}, + }, + { + kind: 'Argument', + name: {kind: 'Name', value: 'extensions'}, + value: {kind: 'Variable', name: {kind: 'Name', value: 'extensions'}}, + }, + ], + selectionSet: { + kind: 'SelectionSet', + selections: [ + { + kind: 'Field', + name: {kind: 'Name', value: 'exception'}, + selectionSet: { + kind: 'SelectionSet', + selections: [ + {kind: 'Field', name: {kind: 'Name', value: 'status'}}, + {kind: 'Field', name: {kind: 'Name', value: 'effectiveLimitKb'}}, + {kind: 'Field', name: {kind: 'Name', value: '__typename'}}, + ], + }, + }, + { + kind: 'Field', + name: {kind: 'Name', value: 'userErrors'}, + selectionSet: { + kind: 'SelectionSet', + selections: [ + {kind: 'Field', name: {kind: 'Name', value: 'field'}}, + {kind: 'Field', name: {kind: 'Name', value: 'message'}}, + {kind: 'Field', name: {kind: 'Name', value: 'category'}}, + {kind: 'Field', name: {kind: 'Name', value: 'code'}}, + {kind: 'Field', name: {kind: 'Name', value: 'on'}}, + {kind: 'Field', name: {kind: 'Name', value: '__typename'}}, + ], + }, + }, + {kind: 'Field', name: {kind: 'Name', value: '__typename'}}, + ], + }, + }, + ], + }, + }, + ], +} as unknown as DocumentNode diff --git a/packages/app/src/cli/api/graphql/app-management/generated/bundle-size-exception-status.ts b/packages/app/src/cli/api/graphql/app-management/generated/bundle-size-exception-status.ts new file mode 100644 index 00000000000..4965050401c --- /dev/null +++ b/packages/app/src/cli/api/graphql/app-management/generated/bundle-size-exception-status.ts @@ -0,0 +1,68 @@ +/* eslint-disable @typescript-eslint/consistent-type-definitions */ +import * as Types from './types.js' + +import {TypedDocumentNode as DocumentNode} from '@graphql-typed-document-node/core' + +export type BundleSizeExceptionStatusQueryVariables = Types.Exact<{ + appId: Types.Scalars['ID']['input'] +}> + +export type BundleSizeExceptionStatusQuery = { + app: { + id: string + uiExtensionBundleSizeException: {status: Types.UiExtensionBundleSizeExceptionStatus; effectiveLimitKb: number} + } +} + +export const BundleSizeExceptionStatus = { + kind: 'Document', + definitions: [ + { + kind: 'OperationDefinition', + operation: 'query', + name: {kind: 'Name', value: 'BundleSizeExceptionStatus'}, + variableDefinitions: [ + { + kind: 'VariableDefinition', + variable: {kind: 'Variable', name: {kind: 'Name', value: 'appId'}}, + type: {kind: 'NonNullType', type: {kind: 'NamedType', name: {kind: 'Name', value: 'ID'}}}, + }, + ], + selectionSet: { + kind: 'SelectionSet', + selections: [ + { + kind: 'Field', + name: {kind: 'Name', value: 'app'}, + arguments: [ + { + kind: 'Argument', + name: {kind: 'Name', value: 'id'}, + value: {kind: 'Variable', name: {kind: 'Name', value: 'appId'}}, + }, + ], + selectionSet: { + kind: 'SelectionSet', + selections: [ + {kind: 'Field', name: {kind: 'Name', value: 'id'}}, + { + kind: 'Field', + name: {kind: 'Name', value: 'uiExtensionBundleSizeException'}, + selectionSet: { + kind: 'SelectionSet', + selections: [ + {kind: 'Field', name: {kind: 'Name', value: 'status'}}, + {kind: 'Field', name: {kind: 'Name', value: 'effectiveLimitKb'}}, + {kind: 'Field', name: {kind: 'Name', value: '__typename'}}, + ], + }, + }, + {kind: 'Field', name: {kind: 'Name', value: '__typename'}}, + ], + }, + }, + ], + }, + }, + ], +} as unknown as DocumentNode diff --git a/packages/app/src/cli/api/graphql/app-management/generated/types.d.ts b/packages/app/src/cli/api/graphql/app-management/generated/types.d.ts index 75419c09eb1..53a707fa2d6 100644 --- a/packages/app/src/cli/api/graphql/app-management/generated/types.d.ts +++ b/packages/app/src/cli/api/graphql/app-management/generated/types.d.ts @@ -78,6 +78,33 @@ export type SourceExtension = /** ZIP file extension. */ | 'ZIP'; +/** The review status of an app's bundle size exception for Remote-DOM UI extensions. */ +export type UiExtensionBundleSizeExceptionStatus = + /** The most recent exception request was denied or a granted exception was revoked. */ + | 'DENIED' + /** An exception has been granted. */ + | 'GRANTED' + /** No exception has been requested for this app. */ + | 'NONE' + /** An exception request is awaiting review by Shopify. */ + | 'PENDING'; + +/** Machine-collected size context for one Remote-DOM UI extension, used to review a bundle size exception request. */ +export type UiExtensionSizeContextInput = { + /** The API version of the extension. */ + apiVersion: Scalars['String']['input']; + /** The compressed (deflate) bundle size in KB, measured with the same algorithm the platform enforces. */ + compressedKb: Scalars['Int']['input']; + /** The handle of the extension. */ + handle: Scalars['String']['input']; + /** The uncompressed bundle size in KB. */ + rawKb: Scalars['Int']['input']; + /** The extension targets declared by the extension. */ + targets?: InputMaybe>; + /** The module uid of the extension. */ + uid: Scalars['String']['input']; +}; + /** The input fields for app version metadata. */ export type VersionMetadataInput = { /** Message associated with this app version. */ diff --git a/packages/app/src/cli/api/graphql/app-management/queries/bundle-size-exception-request.graphql b/packages/app/src/cli/api/graphql/app-management/queries/bundle-size-exception-request.graphql new file mode 100644 index 00000000000..3aceac4d63b --- /dev/null +++ b/packages/app/src/cli/api/graphql/app-management/queries/bundle-size-exception-request.graphql @@ -0,0 +1,15 @@ +mutation BundleSizeExceptionRequest($appId: ID!, $reason: String!, $extensions: [UiExtensionSizeContextInput!]!) { + appUiExtensionBundleSizeExceptionRequest(appId: $appId, reason: $reason, extensions: $extensions) { + exception { + status + effectiveLimitKb + } + userErrors { + field + message + category + code + on + } + } +} diff --git a/packages/app/src/cli/api/graphql/app-management/queries/bundle-size-exception-status.graphql b/packages/app/src/cli/api/graphql/app-management/queries/bundle-size-exception-status.graphql new file mode 100644 index 00000000000..a8fd8d70db4 --- /dev/null +++ b/packages/app/src/cli/api/graphql/app-management/queries/bundle-size-exception-status.graphql @@ -0,0 +1,9 @@ +query BundleSizeExceptionStatus($appId: ID!) { + app(id: $appId) { + id + uiExtensionBundleSizeException { + status + effectiveLimitKb + } + } +} diff --git a/packages/app/src/cli/commands/app/bundle-size-exception/request.ts b/packages/app/src/cli/commands/app/bundle-size-exception/request.ts new file mode 100644 index 00000000000..5dee678cc48 --- /dev/null +++ b/packages/app/src/cli/commands/app/bundle-size-exception/request.ts @@ -0,0 +1,43 @@ +import {appFlags} from '../../../flags.js' +import {linkedAppContext} from '../../../services/app-context.js' +import {requestBundleSizeException} from '../../../services/bundle-size-exception.js' +import AppLinkedCommand, {AppLinkedCommandOutput} from '../../../utilities/app-linked-command.js' +import {globalFlags} from '@shopify/cli-kit/node/cli' +import {Flags} from '@oclif/core' + +export default class BundleSizeExceptionRequest extends AppLinkedCommand { + static hidden = true + + static summary = "Request a bundle size exception for the app's Remote-DOM UI extensions." + + static descriptionWithMarkdown = `Remote-DOM UI extension bundles (API version 2025-10 or later) are limited to 64 KB (compressed). If you can't reduce your bundle below the limit, you can ask Shopify for a bundle size exception. + + The command measures each Remote-DOM UI extension bundle locally (using the same compression the platform enforces), collects your justification, and submits the request. Shopify reviews every request; once approved, \`shopify app deploy\` succeeds with no further changes.` + + static description = this.descriptionWithoutMarkdown() + + static flags = { + ...globalFlags, + ...appFlags, + reason: Flags.string({ + description: + "Why the bundle can't be reduced below the current limit. When provided, the command runs non-interactively.", + env: 'SHOPIFY_FLAG_BUNDLE_SIZE_EXCEPTION_REASON', + }), + } + + public async run(): Promise { + const {flags} = await this.parse(BundleSizeExceptionRequest) + + const appContextResult = await linkedAppContext({ + directory: flags.path, + clientId: flags['client-id'], + forceRelink: flags.reset, + userProvidedConfigName: flags.config, + }) + + await requestBundleSizeException({appContextResult, reason: flags.reason}) + + return {app: appContextResult.app} + } +} diff --git a/packages/app/src/cli/commands/app/bundle-size-exception/status.ts b/packages/app/src/cli/commands/app/bundle-size-exception/status.ts new file mode 100644 index 00000000000..72380468af7 --- /dev/null +++ b/packages/app/src/cli/commands/app/bundle-size-exception/status.ts @@ -0,0 +1,37 @@ +import {appFlags} from '../../../flags.js' +import {linkedAppContext} from '../../../services/app-context.js' +import {bundleSizeExceptionStatus} from '../../../services/bundle-size-exception.js' +import AppLinkedCommand, {AppLinkedCommandOutput} from '../../../utilities/app-linked-command.js' +import {globalFlags} from '@shopify/cli-kit/node/cli' + +export default class BundleSizeExceptionStatus extends AppLinkedCommand { + static hidden = true + + static summary = "Check the state of the app's bundle size exception request." + + static descriptionWithMarkdown = `Shows whether the app has a Remote-DOM UI extension bundle size exception, a request pending review, or a denied request, along with the app's current bundle size limit. + + Use \`shopify app bundle-size-exception request\` to request an exception.` + + static description = this.descriptionWithoutMarkdown() + + static flags = { + ...globalFlags, + ...appFlags, + } + + public async run(): Promise { + const {flags} = await this.parse(BundleSizeExceptionStatus) + + const appContextResult = await linkedAppContext({ + directory: flags.path, + clientId: flags['client-id'], + forceRelink: flags.reset, + userProvidedConfigName: flags.config, + }) + + await bundleSizeExceptionStatus({appContextResult}) + + return {app: appContextResult.app} + } +} diff --git a/packages/app/src/cli/index.ts b/packages/app/src/cli/index.ts index fc2d9c42b10..37fba5ecdea 100644 --- a/packages/app/src/cli/index.ts +++ b/packages/app/src/cli/index.ts @@ -1,6 +1,8 @@ import Build from './commands/app/build.js' import BulkCancel from './commands/app/bulk/cancel.js' import BulkStatus from './commands/app/bulk/status.js' +import BundleSizeExceptionRequest from './commands/app/bundle-size-exception/request.js' +import BundleSizeExceptionStatus from './commands/app/bundle-size-exception/status.js' import ConfigLink from './commands/app/config/link.js' import ConfigUse from './commands/app/config/use.js' import ConfigPull from './commands/app/config/pull.js' @@ -45,6 +47,8 @@ export const commands: {[key: string]: typeof AppLinkedCommand | typeof AppUnlin 'app:build': Build, 'app:bulk:cancel': BulkCancel, 'app:bulk:status': BulkStatus, + 'app:bundle-size-exception:request': BundleSizeExceptionRequest, + 'app:bundle-size-exception:status': BundleSizeExceptionStatus, 'app:deploy': Deploy, 'app:dev': Dev, 'app:dev:clean': DevClean, diff --git a/packages/app/src/cli/models/app/app.test-data.ts b/packages/app/src/cli/models/app/app.test-data.ts index f66757b9036..a6409e529a2 100644 --- a/packages/app/src/cli/models/app/app.test-data.ts +++ b/packages/app/src/cli/models/app/app.test-data.ts @@ -39,6 +39,7 @@ import { DevSessionDeleteOptions, DevSessionUpdateOptions, Store, + UiExtensionBundleSizeExceptionRequestOptions, } from '../../utilities/developer-platform-client.js' import {AllAppExtensionRegistrationsQuerySchema} from '../../api/graphql/all_app_extension_registrations.js' import {AppDeploySchema, AppDeployVariables} from '../../api/graphql/app_deploy.js' @@ -1370,6 +1371,16 @@ export function testDeveloperPlatformClient( Promise.resolve( `Looks like you don't have any dev stores associated with ${org.businessName}'s Partner Dashboard. Create a store in Partner Dashboard https://partners.shopify.com/1234/stores`, ), + uiExtensionBundleSizeException: (_app: MinimalAppIdentifiers) => + Promise.resolve({status: 'NONE' as const, effectiveLimitKb: 64}), + uiExtensionBundleSizeExceptionRequest: ( + _app: MinimalAppIdentifiers, + _options: UiExtensionBundleSizeExceptionRequestOptions, + ) => + Promise.resolve({ + exception: {status: 'PENDING' as const, effectiveLimitKb: 64}, + userErrors: [], + }), ...stubs, } const retVal: Partial = clientStub diff --git a/packages/app/src/cli/models/extensions/specifications/ui_extension.ts b/packages/app/src/cli/models/extensions/specifications/ui_extension.ts index 0312bc80480..9665edc240c 100644 --- a/packages/app/src/cli/models/extensions/specifications/ui_extension.ts +++ b/packages/app/src/cli/models/extensions/specifications/ui_extension.ts @@ -529,7 +529,7 @@ async function validateUIExtensionPointConfig( return ok({}) } -function isRemoteDomExtension( +export function isRemoteDomExtension( config: ExtensionInstance['configuration'], ): config is ExtensionInstance<{api_version: string}>['configuration'] { const apiVersion = config.api_version diff --git a/packages/app/src/cli/services/bundle-size-exception.test.ts b/packages/app/src/cli/services/bundle-size-exception.test.ts new file mode 100644 index 00000000000..f695e896367 --- /dev/null +++ b/packages/app/src/cli/services/bundle-size-exception.test.ts @@ -0,0 +1,237 @@ +import {bundleSizeExceptionStatus, requestBundleSizeException} from './bundle-size-exception.js' +import {LoadedAppContextOutput} from './app-context.js' +import {getBundleSize} from './build/bundle-size.js' +import {testAppLinked, testDeveloperPlatformClient, testUIExtension} from '../models/app/app.test-data.js' +import {ExtensionInstance} from '../models/extensions/extension-instance.js' +import { + renderConcurrent, + renderConfirmationPrompt, + renderInfo, + renderSuccess, + renderTextPrompt, + renderWarning, +} from '@shopify/cli-kit/node/ui' +import {describe, expect, test, vi, beforeEach} from 'vitest' + +vi.mock('@shopify/cli-kit/node/ui') +vi.mock('./build/bundle-size.js') + +const REMOTE_APP = {id: 'gid://shopify/App/123', title: 'Test App', apiKey: 'api-key', organizationId: '1'} + +async function remoteDomExtension(): Promise { + return testUIExtension({ + configuration: { + name: 'checkout-ui', + type: 'ui_extension', + handle: 'checkout-ui', + api_version: '2025-10', + metafields: [], + extension_points: [{target: 'purchase.checkout.block.render', module: './src/index.js'}], + }, + }) +} + +async function legacyExtension(): Promise { + return testUIExtension({ + configuration: { + name: 'legacy-ui', + type: 'ui_extension', + handle: 'legacy-ui', + api_version: '2025-07', + metafields: [], + extension_points: [{target: 'purchase.checkout.block.render', module: './src/index.js'}], + }, + }) +} + +function appContextResult(extensions: ExtensionInstance[], client = testDeveloperPlatformClient()) { + return { + app: testAppLinked({allExtensions: extensions}), + remoteApp: REMOTE_APP, + developerPlatformClient: client, + } as unknown as LoadedAppContextOutput +} + +function mockMeasuredKb(compressedKb: number) { + vi.mocked(getBundleSize).mockResolvedValue({ + path: 'dist/checkout-ui.js', + rawBytes: compressedKb * 3 * 1024, + compressedBytes: compressedKb * 1024, + }) +} + +beforeEach(() => { + vi.mocked(renderConcurrent).mockResolvedValue(undefined) + vi.mocked(renderTextPrompt).mockResolvedValue('The checkout flow needs the full SDK') + vi.mocked(renderConfirmationPrompt).mockResolvedValue(true) +}) + +describe('requestBundleSizeException', () => { + test('throws when the app has no remote-DOM UI extensions', async () => { + const options = {appContextResult: appContextResult([await legacyExtension()])} + + await expect(requestBundleSizeException(options)).rejects.toThrow(/no Remote-DOM UI extensions/) + }) + + test('submits a request with measured sizes and renders success', async () => { + const extension = await remoteDomExtension() + mockMeasuredKb(90) + const client = testDeveloperPlatformClient() + const options = {appContextResult: appContextResult([extension], client)} + + await requestBundleSizeException(options) + + expect(client.uiExtensionBundleSizeExceptionRequest).toHaveBeenCalledWith(REMOTE_APP, { + reason: 'The checkout flow needs the full SDK', + extensions: [ + { + uid: extension.uid, + handle: 'checkout-ui', + apiVersion: '2025-10', + rawKb: 270, + compressedKb: 90, + targets: ['purchase.checkout.block.render'], + }, + ], + }) + expect(renderSuccess).toHaveBeenCalledWith(expect.objectContaining({headline: 'Bundle size exception requested.'})) + }) + + test('skips prompts when a reason flag is provided', async () => { + const extension = await remoteDomExtension() + mockMeasuredKb(90) + const client = testDeveloperPlatformClient() + const options = {appContextResult: appContextResult([extension], client), reason: 'Automation reason'} + + await requestBundleSizeException(options) + + expect(renderTextPrompt).not.toHaveBeenCalled() + expect(renderConfirmationPrompt).not.toHaveBeenCalled() + expect(client.uiExtensionBundleSizeExceptionRequest).toHaveBeenCalledWith( + REMOTE_APP, + expect.objectContaining({reason: 'Automation reason'}), + ) + }) + + test('does not submit when a request is already pending', async () => { + const extension = await remoteDomExtension() + mockMeasuredKb(90) + const client = testDeveloperPlatformClient({ + uiExtensionBundleSizeException: vi.fn().mockResolvedValue({status: 'PENDING', effectiveLimitKb: 64}), + }) + const options = {appContextResult: appContextResult([extension], client)} + + await requestBundleSizeException(options) + + expect(client.uiExtensionBundleSizeExceptionRequest).not.toHaveBeenCalled() + expect(renderInfo).toHaveBeenCalledWith( + expect.objectContaining({headline: 'A bundle size exception request is already pending review.'}), + ) + }) + + test('does not submit when the bundles fit the effective limit', async () => { + const extension = await remoteDomExtension() + mockMeasuredKb(50) + const client = testDeveloperPlatformClient() + const options = {appContextResult: appContextResult([extension], client)} + + await requestBundleSizeException(options) + + expect(client.uiExtensionBundleSizeExceptionRequest).not.toHaveBeenCalled() + expect(renderInfo).toHaveBeenCalledWith(expect.objectContaining({headline: 'No exception needed.'})) + }) + + test('recognizes an already granted higher limit', async () => { + const extension = await remoteDomExtension() + mockMeasuredKb(90) + const client = testDeveloperPlatformClient({ + uiExtensionBundleSizeException: vi.fn().mockResolvedValue({status: 'GRANTED', effectiveLimitKb: 128}), + }) + const options = {appContextResult: appContextResult([extension], client)} + + await requestBundleSizeException(options) + + expect(client.uiExtensionBundleSizeExceptionRequest).not.toHaveBeenCalled() + expect(renderInfo).toHaveBeenCalledWith(expect.objectContaining({headline: 'No exception needed.'})) + }) + + test('does not submit when the confirmation is declined', async () => { + const extension = await remoteDomExtension() + mockMeasuredKb(90) + vi.mocked(renderConfirmationPrompt).mockResolvedValue(false) + const client = testDeveloperPlatformClient() + const options = {appContextResult: appContextResult([extension], client)} + + await requestBundleSizeException(options) + + expect(client.uiExtensionBundleSizeExceptionRequest).not.toHaveBeenCalled() + }) + + test('surfaces server user errors', async () => { + const extension = await remoteDomExtension() + mockMeasuredKb(90) + const client = testDeveloperPlatformClient({ + uiExtensionBundleSizeExceptionRequest: vi.fn().mockResolvedValue({ + exception: null, + userErrors: [{message: 'A bundle size exception request for this app is already pending review'}], + }), + }) + const options = {appContextResult: appContextResult([extension], client)} + + await expect(requestBundleSizeException(options)).rejects.toThrow(/already pending review/) + }) +}) + +describe('bundleSizeExceptionStatus', () => { + function statusClient(status: 'NONE' | 'PENDING' | 'GRANTED' | 'DENIED', effectiveLimitKb: number) { + return testDeveloperPlatformClient({ + uiExtensionBundleSizeException: vi.fn().mockResolvedValue({status, effectiveLimitKb}), + }) + } + + test('renders success with the raised limit when granted', async () => { + const options = {appContextResult: appContextResult([], statusClient('GRANTED', 128))} + + await bundleSizeExceptionStatus(options) + + expect(renderSuccess).toHaveBeenCalledWith( + expect.objectContaining({ + headline: 'Bundle size exception granted.', + body: [expect.stringContaining('128 KB (compressed)')], + }), + ) + }) + + test('renders pending review info with the current limit', async () => { + const options = {appContextResult: appContextResult([], statusClient('PENDING', 64))} + + await bundleSizeExceptionStatus(options) + + expect(renderInfo).toHaveBeenCalledWith( + expect.objectContaining({headline: 'Bundle size exception request pending review.'}), + ) + }) + + test('renders a warning with the appeal path when denied', async () => { + const options = {appContextResult: appContextResult([], statusClient('DENIED', 64))} + + await bundleSizeExceptionStatus(options) + + expect(renderWarning).toHaveBeenCalledWith( + expect.objectContaining({headline: 'Bundle size exception request denied.'}), + ) + }) + + test('renders the default limit and the request command when there is no exception activity', async () => { + const options = {appContextResult: appContextResult([], statusClient('NONE', 64))} + + await bundleSizeExceptionStatus(options) + + expect(renderInfo).toHaveBeenCalledWith( + expect.objectContaining({ + headline: 'No bundle size exception.', + body: [expect.stringContaining('64 KB (compressed)')], + }), + ) + }) +}) diff --git a/packages/app/src/cli/services/bundle-size-exception.ts b/packages/app/src/cli/services/bundle-size-exception.ts new file mode 100644 index 00000000000..d1da1edf593 --- /dev/null +++ b/packages/app/src/cli/services/bundle-size-exception.ts @@ -0,0 +1,236 @@ +import {LoadedAppContextOutput} from './app-context.js' +import {getBundleSize} from './build/bundle-size.js' +import {AppLinkedInterface} from '../models/app/app.js' +import {ExtensionInstance} from '../models/extensions/extension-instance.js' +import {isRemoteDomExtension} from '../models/extensions/specifications/ui_extension.js' +import {UiExtensionSizeContextInput} from '../api/graphql/app-management/generated/types.js' +import {AbortError} from '@shopify/cli-kit/node/error' +import {AbortSignal} from '@shopify/cli-kit/node/abort' +import {inTemporaryDirectory} from '@shopify/cli-kit/node/fs' +import { + renderConcurrent, + renderConfirmationPrompt, + renderInfo, + renderSuccess, + renderTextPrompt, + renderWarning, +} from '@shopify/cli-kit/node/ui' +import {Writable} from 'stream' + +interface MeasuredExtension { + extension: ExtensionInstance + rawKb: number + compressedKb: number +} + +interface RequestBundleSizeExceptionOptions { + appContextResult: LoadedAppContextOutput + /** Justification for the exception. When provided, prompts are skipped (automation mode). */ + reason?: string +} + +/** + * Submits a bundle size exception request for the app's Remote-DOM UI extensions. + * + * The CLI measures each Remote-DOM UI extension locally (same deflate algorithm the platform + * enforces) and submits the measurements with the partner's justification. Shopify reviews + * every request and decides what to grant; the CLI never proposes a new limit. + */ +export async function requestBundleSizeException(options: RequestBundleSizeExceptionOptions): Promise { + const {app, remoteApp, developerPlatformClient} = options.appContextResult + + const extensions = remoteDomUiExtensions(app) + if (extensions.length === 0) { + throw new AbortError( + 'This app has no Remote-DOM UI extensions (API version 2025-10 or later).', + 'Bundle size exceptions only apply to UI extensions on API version 2025-10 or later.', + ) + } + + const measured = await buildAndMeasure(app, extensions) + const maxCompressedKb = Math.max(...measured.map((entry) => entry.compressedKb)) + + const state = await developerPlatformClient.uiExtensionBundleSizeException(remoteApp) + + if (state.status === 'PENDING') { + renderInfo({ + headline: 'A bundle size exception request is already pending review.', + body: [ + 'Shopify is reviewing your request. Once approved,', + {command: 'shopify app deploy'}, + 'will succeed with no further changes.', + ], + }) + return + } + + if (maxCompressedKb <= state.effectiveLimitKb) { + renderInfo({ + headline: 'No exception needed.', + body: [ + `All Remote-DOM UI extension bundles are within the app's current ${state.effectiveLimitKb} KB (compressed) limit.`, + ], + customSections: [measuredSection(measured, state.effectiveLimitKb)], + }) + return + } + + const interactive = options.reason === undefined + const reason = + options.reason ?? + (await renderTextPrompt({ + message: "Why can't the bundle be reduced below the current limit (and what did you already try)?", + validate: (value) => { + if (value.trim().length < 10) return 'Please provide a meaningful justification (at least 10 characters).' + }, + })) + + if (interactive) { + const confirmed = await renderConfirmationPrompt({ + message: `Request a bundle size exception for this app? Shopify will review the request; your extensions and their measured sizes will be shared with the review team.`, + confirmationMessage: 'Yes, submit the request', + cancellationMessage: 'No, cancel', + }) + if (!confirmed) return + } + + const result = await developerPlatformClient.uiExtensionBundleSizeExceptionRequest(remoteApp, { + reason, + extensions: measured.map(toSizeContext), + }) + + const userErrors = result.userErrors ?? [] + if (userErrors.length > 0) { + throw new AbortError(userErrors.map((error) => error.message).join('\n')) + } + + renderSuccess({ + headline: 'Bundle size exception requested.', + body: [ + 'Shopify will review your request. Once approved,', + {command: 'shopify app deploy'}, + 'will succeed with no further changes.', + ], + nextSteps: [ + ['Re-run', {command: 'shopify app bundle-size-exception request'}, 'to check the review state.'], + ['You can keep reducing your bundle size in the meantime.'], + ], + }) +} + +interface BundleSizeExceptionStatusOptions { + appContextResult: LoadedAppContextOutput +} + +/** + * Shows the state of the app's bundle size exception request. + * + * Only reads the status and effective limit from the platform; it doesn't build or + * measure anything, so it's fast enough to poll while a request is under review. + */ +export async function bundleSizeExceptionStatus(options: BundleSizeExceptionStatusOptions): Promise { + const {remoteApp, developerPlatformClient} = options.appContextResult + + const state = await developerPlatformClient.uiExtensionBundleSizeException(remoteApp) + const limit = `${state.effectiveLimitKb} KB (compressed)` + + switch (state.status) { + case 'GRANTED': + renderSuccess({ + headline: 'Bundle size exception granted.', + body: [`Remote-DOM UI extension bundles up to ${limit} will pass deploy validation.`], + }) + break + case 'PENDING': + renderInfo({ + headline: 'Bundle size exception request pending review.', + body: [ + `The current limit is ${limit}. Shopify is reviewing your request; once approved,`, + {command: 'shopify app deploy'}, + 'will succeed with no further changes.', + ], + }) + break + case 'DENIED': + renderWarning({ + headline: 'Bundle size exception request denied.', + body: [ + `A previous request for this app was denied or revoked, and the current limit is ${limit}.`, + 'Contact Shopify support to appeal.', + ], + }) + break + case 'NONE': + renderInfo({ + headline: 'No bundle size exception.', + body: [`Remote-DOM UI extension bundles are limited to ${limit}.`], + nextSteps: [ + ['Run', {command: 'shopify app bundle-size-exception request'}, "if your bundle can't fit the limit."], + ], + }) + } +} + +function measuredSection(measured: MeasuredExtension[], effectiveLimitKb: number) { + return { + title: 'Measured extension bundles (compressed with the same algorithm Shopify enforces)', + body: { + list: { + items: measured.map((entry) => { + const overLimit = entry.compressedKb > effectiveLimitKb + const flag = overLimit ? ' — exceeds the limit' : '' + return `${entry.extension.handle}: ${entry.compressedKb} KB compressed (${entry.rawKb} KB raw)${flag}` + }), + }, + }, + } +} + +function remoteDomUiExtensions(app: AppLinkedInterface): ExtensionInstance[] { + return app.allExtensions.filter( + (extension) => + extension.specification.identifier === 'ui_extension' && isRemoteDomExtension(extension.configuration), + ) +} + +/** + * Builds the given UI extensions for production (minified, matching what a deploy uploads) + * into a temporary directory and measures each bundle's raw and deflate-compressed size. + */ +async function buildAndMeasure(app: AppLinkedInterface, extensions: ExtensionInstance[]): Promise { + return inTemporaryDirectory(async (bundleDirectory) => { + await renderConcurrent({ + processes: extensions.map((extension) => ({ + prefix: extension.localIdentifier, + action: async (stdout: Writable, stderr: Writable, signal: AbortSignal) => { + await extension.buildForBundle({stderr, stdout, signal, app, environment: 'production'}, bundleDirectory) + }, + })), + showTimestamps: false, + }) + + return Promise.all( + extensions.map(async (extension) => { + const {rawBytes, compressedBytes} = await getBundleSize(extension.outputPath) + return { + extension, + // Ceil so the requested exception always covers the measured bytes. + rawKb: Math.ceil(rawBytes / 1024), + compressedKb: Math.ceil(compressedBytes / 1024), + } + }), + ) + }) +} + +function toSizeContext(entry: MeasuredExtension): UiExtensionSizeContextInput { + const extensionPoints = (entry.extension.configuration.extension_points ?? []) as {target?: string}[] + return { + uid: entry.extension.uid, + handle: entry.extension.handle, + apiVersion: entry.extension.configuration.api_version ?? 'unknown', + rawKb: entry.rawKb, + compressedKb: entry.compressedKb, + targets: extensionPoints.map((point) => point.target).filter((target): target is string => Boolean(target)), + } +} diff --git a/packages/app/src/cli/services/bundle.test.ts b/packages/app/src/cli/services/bundle.test.ts index b6ac856d765..18f0d1fff0e 100644 --- a/packages/app/src/cli/services/bundle.test.ts +++ b/packages/app/src/cli/services/bundle.test.ts @@ -140,7 +140,7 @@ describe('compressBundle', () => { }) }) - test('excludes .metafile.json files from the zip', async () => { + test('keeps .metafile.json files in the zip and excludes sourcemaps', async () => { await inTemporaryDirectory(async (tmpDir) => { // Given const inputDir = joinPath(tmpDir, 'input') @@ -153,6 +153,8 @@ describe('compressBundle', () => { await compressBundle(inputDir, outputZip) // Then + expect(BUNDLE_EXCLUSION_PATTERNS).not.toContain('!**/*.metafile.json') + expect(BUNDLE_EXCLUSION_PATTERNS).toContain('!**/*.js.map') expect(zip).toHaveBeenCalledWith( expect.objectContaining({ matchFilePattern: ['**/*', ...BUNDLE_EXCLUSION_PATTERNS], diff --git a/packages/app/src/cli/services/bundle.ts b/packages/app/src/cli/services/bundle.ts index ec0f0b9d20b..c4f5f47d12c 100644 --- a/packages/app/src/cli/services/bundle.ts +++ b/packages/app/src/cli/services/bundle.ts @@ -24,7 +24,7 @@ export async function writeManifestToBundle(appManifest: AppManifest, bundlePath await writeFile(manifestPath, JSON.stringify(appManifest, null, 2)) } -export const BUNDLE_EXCLUSION_PATTERNS = ['!**/*.js.map', '!**/*.metafile.json'] +export const BUNDLE_EXCLUSION_PATTERNS = ['!**/*.js.map'] export async function compressBundle(inputDirectory: string, outputPath: string, customMatchFilePattern?: string[]) { const matchFilePattern = customMatchFilePattern ?? ['**/*', ...BUNDLE_EXCLUSION_PATTERNS] diff --git a/packages/app/src/cli/services/deploy/upload.test.ts b/packages/app/src/cli/services/deploy/upload.test.ts index c01e3154c8f..36083b496eb 100644 --- a/packages/app/src/cli/services/deploy/upload.test.ts +++ b/packages/app/src/cli/services/deploy/upload.test.ts @@ -831,4 +831,108 @@ describe('deploymentErrorsToCustomSections', () => { }, ]) }) + + test('adds a bundle size exception call-to-action for structured bundle_size_limit errors', () => { + // Given + const errors = [ + { + field: ['base'], + message: 'Your script size is 87 KB which exceeds the 64 KB limit', + category: 'invalid', + on: [ + { + type: 'app_module', + user_identifier: 'checkout-ui-uid', + }, + { + type: 'bundle_size_limit', + code: 'bundle_size_limit_exceeded', + limit_kb: 64, + actual_kb: 87, + }, + ], + details: [], + }, + ] + + const appModules = [ + { + uid: 'checkout-ui-uid', + handle: 'checkout-ui', + config: '{}', + context: '', + specificationIdentifier: 'ui_extension', + }, + ] + + // When + const customSections = deploymentErrorsToCustomSections( + errors as any as AppDeploySchema['appDeploy']['userErrors'], + {}, + appModules, + ) + + // Then + expect(customSections).toEqual([ + { + title: 'checkout-ui', + body: [ + { + list: { + title: '\nValidation errors', + items: ['base: Your script size is 87 KB which exceeds the 64 KB limit'], + }, + }, + { + list: { + title: '\nBundle size exception', + items: [ + "This extension's bundle exceeds 64 KB (compressed). If you can't reduce it, run `shopify app bundle-size-exception request` to ask Shopify for a bundle size exception.", + ], + }, + }, + ], + }, + ]) + }) + + test('does not add a bundle size exception call-to-action for unrelated errors', () => { + // Given + const errors = [ + { + field: ['name'], + message: 'is too long', + category: 'invalid', + on: [ + { + type: 'app_module', + user_identifier: 'checkout-ui-uid', + }, + ], + details: [], + }, + ] + + const appModules = [ + { + uid: 'checkout-ui-uid', + handle: 'checkout-ui', + config: '{}', + context: '', + specificationIdentifier: 'ui_extension', + }, + ] + + // When + const customSections = deploymentErrorsToCustomSections( + errors as any as AppDeploySchema['appDeploy']['userErrors'], + {}, + appModules, + ) + + // Then + const section = customSections[0]! + const titles = (section.body as {list: {title: string}}[]).map((token) => token.list.title) + expect(titles).not.toContain('\nBundle size exception') + }) }) diff --git a/packages/app/src/cli/services/deploy/upload.ts b/packages/app/src/cli/services/deploy/upload.ts index b0c22decfc2..8ffa5570bf3 100644 --- a/packages/app/src/cli/services/deploy/upload.ts +++ b/packages/app/src/cli/services/deploy/upload.ts @@ -143,6 +143,35 @@ export async function uploadExtensionsBundle( const VALIDATION_ERRORS_TITLE = '\nValidation errors' const GENERIC_ERRORS_TITLE = '\n' +const BUNDLE_SIZE_EXCEPTION_TITLE = '\nBundle size exception' + +interface BundleSizeLimitErrorDetail { + type: 'bundle_size_limit' + limit_kb?: number +} + +function bundleSizeLimitDetail( + error: AppDeploySchema['appDeploy']['userErrors'][number], +): BundleSizeLimitErrorDetail | undefined { + const on = error.on as unknown + if (!Array.isArray(on)) return undefined + return on.find( + (entry): entry is BundleSizeLimitErrorDetail => + typeof entry === 'object' && entry !== null && (entry as {type?: unknown}).type === 'bundle_size_limit', + ) +} + +function bundleSizeExceptionSection(detail: BundleSizeLimitErrorDetail): ListToken { + const limitText = detail.limit_kb ? `${detail.limit_kb} KB (compressed)` : 'the current limit' + return { + list: { + title: BUNDLE_SIZE_EXCEPTION_TITLE, + items: [ + `This extension's bundle exceeds ${limitText}. If you can't reduce it, run \`shopify app bundle-size-exception request\` to ask Shopify for a bundle size exception.`, + ], + }, + } +} export function deploymentErrorsToCustomSections( errors: AppDeploySchema['appDeploy']['userErrors'], @@ -322,10 +351,10 @@ function appManagementErrorsSection( const errorMessage = `${field}: ${error.message}` // Find or create section for this extension - const existingSection = sections.find((section) => section.title === extensionName) + let section = sections.find((existingSection) => existingSection.title === extensionName) - if (existingSection) { - const sectionBody = existingSection.body as ListToken[] + if (section) { + const sectionBody = section.body as ListToken[] const errorsList = sectionBody.find((listToken) => listToken.list.title === VALIDATION_ERRORS_TITLE) if (errorsList) { @@ -334,7 +363,7 @@ function appManagementErrorsSection( } } } else { - sections.push({ + section = { title: extensionName, body: [ { @@ -344,7 +373,18 @@ function appManagementErrorsSection( }, }, ], - }) + } + sections.push(section) + } + + // When the failure is a bundle size limit error, point at the exception request flow. + const sizeLimitDetail = bundleSizeLimitDetail(error) + if (sizeLimitDetail) { + const sectionBody = section.body as ListToken[] + const alreadyPresent = sectionBody.some((listToken) => listToken.list.title === BUNDLE_SIZE_EXCEPTION_TITLE) + if (!alreadyPresent) { + sectionBody.push(bundleSizeExceptionSection(sizeLimitDetail)) + } } return sections diff --git a/packages/app/src/cli/utilities/developer-platform-client.ts b/packages/app/src/cli/utilities/developer-platform-client.ts index 3a1c04790e2..8a78d3338fd 100644 --- a/packages/app/src/cli/utilities/developer-platform-client.ts +++ b/packages/app/src/cli/utilities/developer-platform-client.ts @@ -40,6 +40,8 @@ import { AppLogsSubscribeMutation, AppLogsSubscribeMutationVariables, } from '../api/graphql/app-management/generated/app-logs-subscribe.js' +import {BundleSizeExceptionRequestMutation} from '../api/graphql/app-management/generated/bundle-size-exception-request.js' +import {UiExtensionSizeContextInput} from '../api/graphql/app-management/generated/types.js' import {Store} from '../api/graphql/business-platform-organizations/generated/types.js' import {Session} from '@shopify/cli-kit/node/session' import {TokenItem} from '@shopify/cli-kit/node/ui' @@ -172,6 +174,21 @@ export interface UserError { on?: JsonMapType } +export type UiExtensionBundleSizeExceptionStatusValue = 'NONE' | 'PENDING' | 'GRANTED' | 'DENIED' + +export interface UiExtensionBundleSizeExceptionState { + status: UiExtensionBundleSizeExceptionStatusValue + effectiveLimitKb: number +} + +export interface UiExtensionBundleSizeExceptionRequestOptions { + reason: string + extensions: UiExtensionSizeContextInput[] +} + +export type UiExtensionBundleSizeExceptionRequestResult = + BundleSizeExceptionRequestMutation['appUiExtensionBundleSizeExceptionRequest'] + interface ErrorDetail { extension_id?: number | string extension_title?: string @@ -247,6 +264,11 @@ export interface DeveloperPlatformClient { devSessionUpdate: (input: DevSessionUpdateOptions) => Promise devSessionDelete: (input: DevSessionSharedOptions) => Promise getCreateDevStoreLink: (org: Organization) => Promise + uiExtensionBundleSizeException: (app: MinimalAppIdentifiers) => Promise + uiExtensionBundleSizeExceptionRequest: ( + app: MinimalAppIdentifiers, + options: UiExtensionBundleSizeExceptionRequestOptions, + ) => Promise } /** diff --git a/packages/app/src/cli/utilities/developer-platform-client/app-management-client.ts b/packages/app/src/cli/utilities/developer-platform-client/app-management-client.ts index dd868bd532f..90a769d4ef0 100644 --- a/packages/app/src/cli/utilities/developer-platform-client/app-management-client.ts +++ b/packages/app/src/cli/utilities/developer-platform-client/app-management-client.ts @@ -29,6 +29,9 @@ import { DevSessionCreateOptions, DevSessionDeleteOptions, UserError, + UiExtensionBundleSizeExceptionState, + UiExtensionBundleSizeExceptionRequestOptions, + UiExtensionBundleSizeExceptionRequestResult, } from '../developer-platform-client.js' import { MinimalAppIdentifiers, @@ -114,6 +117,8 @@ import { AppLogsSubscribeMutation, AppLogsSubscribeMutationVariables, } from '../../api/graphql/app-management/generated/app-logs-subscribe.js' +import {BundleSizeExceptionStatus} from '../../api/graphql/app-management/generated/bundle-size-exception-status.js' +import {BundleSizeExceptionRequest} from '../../api/graphql/app-management/generated/bundle-size-exception-request.js' import {SourceExtension} from '../../api/graphql/app-management/generated/types.js' import {WebhookSubscriptionSpecIdentifier} from '../../models/extensions/specifications/app_config_webhook_subscription.js' import {fetchOrganizations} from '@shopify/organizations' @@ -1029,6 +1034,29 @@ export class AppManagementClient implements DeveloperPlatformClient { ] } + async uiExtensionBundleSizeException(app: MinimalAppIdentifiers): Promise { + const result = await this.appManagementRequest({ + query: BundleSizeExceptionStatus, + variables: {appId: app.id}, + }) + return result.app.uiExtensionBundleSizeException + } + + async uiExtensionBundleSizeExceptionRequest( + app: MinimalAppIdentifiers, + options: UiExtensionBundleSizeExceptionRequestOptions, + ): Promise { + const result = await this.appManagementRequest({ + query: BundleSizeExceptionRequest, + variables: { + appId: app.id, + reason: options.reason, + extensions: options.extensions, + }, + }) + return result.appUiExtensionBundleSizeExceptionRequest + } + private async activeAppVersionRawResult(apiKey: string): Promise { return this.appManagementRequest({query: ActiveAppReleaseFromApiKey, variables: {apiKey}}) } diff --git a/packages/cli/oclif.manifest.json b/packages/cli/oclif.manifest.json index 45ea376b354..b285a3dd678 100644 --- a/packages/cli/oclif.manifest.json +++ b/packages/cli/oclif.manifest.json @@ -462,6 +462,188 @@ "strict": true, "summary": "Check the status of bulk operations." }, + "app:bundle-size-exception:request": { + "aliases": [ + ], + "args": { + }, + "customPluginName": "@shopify/app", + "description": "Remote-DOM UI extension bundles (API version 2025-10 or later) are limited to 64 KB (compressed). If you can't reduce your bundle below the limit, you can ask Shopify for a bundle size exception.\n\n The command measures each Remote-DOM UI extension bundle locally (using the same compression the platform enforces), collects your justification, and submits the request. Shopify reviews every request; once approved, `shopify app deploy` succeeds with no further changes.", + "descriptionWithMarkdown": "Remote-DOM UI extension bundles (API version 2025-10 or later) are limited to 64 KB (compressed). If you can't reduce your bundle below the limit, you can ask Shopify for a bundle size exception.\n\n The command measures each Remote-DOM UI extension bundle locally (using the same compression the platform enforces), collects your justification, and submits the request. Shopify reviews every request; once approved, `shopify app deploy` succeeds with no further changes.", + "flags": { + "auth-alias": { + "description": "Alias of the Shopify account to use for authentication.", + "env": "SHOPIFY_FLAG_AUTH_ALIAS", + "hasDynamicHelp": false, + "multiple": false, + "name": "auth-alias", + "type": "option" + }, + "client-id": { + "description": "The Client ID of your app.", + "env": "SHOPIFY_FLAG_CLIENT_ID", + "exclusive": [ + "config" + ], + "hasDynamicHelp": false, + "hidden": false, + "multiple": false, + "name": "client-id", + "type": "option" + }, + "config": { + "char": "c", + "description": "The name of the app configuration.", + "env": "SHOPIFY_FLAG_APP_CONFIG", + "hasDynamicHelp": false, + "hidden": false, + "multiple": false, + "name": "config", + "type": "option" + }, + "no-color": { + "allowNo": false, + "description": "Disable color output.", + "env": "SHOPIFY_FLAG_NO_COLOR", + "hidden": false, + "name": "no-color", + "type": "boolean" + }, + "path": { + "description": "The path to your app directory.", + "env": "SHOPIFY_FLAG_PATH", + "hasDynamicHelp": false, + "multiple": false, + "name": "path", + "noCacheDefault": true, + "type": "option" + }, + "reason": { + "description": "Why the bundle can't be reduced below the current limit. When provided, the command runs non-interactively.", + "env": "SHOPIFY_FLAG_BUNDLE_SIZE_EXCEPTION_REASON", + "hasDynamicHelp": false, + "multiple": false, + "name": "reason", + "type": "option" + }, + "reset": { + "allowNo": false, + "description": "Reset all your settings.", + "env": "SHOPIFY_FLAG_RESET", + "exclusive": [ + "config" + ], + "hidden": false, + "name": "reset", + "type": "boolean" + }, + "verbose": { + "allowNo": false, + "description": "Increase the verbosity of the output. May include sensitive data.", + "env": "SHOPIFY_FLAG_VERBOSE", + "hidden": false, + "name": "verbose", + "type": "boolean" + } + }, + "hasDynamicHelp": false, + "hidden": true, + "hiddenAliases": [ + ], + "id": "app:bundle-size-exception:request", + "pluginAlias": "@shopify/cli", + "pluginName": "@shopify/cli", + "pluginType": "core", + "strict": true, + "summary": "Request a bundle size exception for the app's Remote-DOM UI extensions." + }, + "app:bundle-size-exception:status": { + "aliases": [ + ], + "args": { + }, + "customPluginName": "@shopify/app", + "description": "Shows whether the app has a Remote-DOM UI extension bundle size exception, a request pending review, or a denied request, along with the app's current bundle size limit.\n\n Use `shopify app bundle-size-exception request` to request an exception.", + "descriptionWithMarkdown": "Shows whether the app has a Remote-DOM UI extension bundle size exception, a request pending review, or a denied request, along with the app's current bundle size limit.\n\n Use `shopify app bundle-size-exception request` to request an exception.", + "flags": { + "auth-alias": { + "description": "Alias of the Shopify account to use for authentication.", + "env": "SHOPIFY_FLAG_AUTH_ALIAS", + "hasDynamicHelp": false, + "multiple": false, + "name": "auth-alias", + "type": "option" + }, + "client-id": { + "description": "The Client ID of your app.", + "env": "SHOPIFY_FLAG_CLIENT_ID", + "exclusive": [ + "config" + ], + "hasDynamicHelp": false, + "hidden": false, + "multiple": false, + "name": "client-id", + "type": "option" + }, + "config": { + "char": "c", + "description": "The name of the app configuration.", + "env": "SHOPIFY_FLAG_APP_CONFIG", + "hasDynamicHelp": false, + "hidden": false, + "multiple": false, + "name": "config", + "type": "option" + }, + "no-color": { + "allowNo": false, + "description": "Disable color output.", + "env": "SHOPIFY_FLAG_NO_COLOR", + "hidden": false, + "name": "no-color", + "type": "boolean" + }, + "path": { + "description": "The path to your app directory.", + "env": "SHOPIFY_FLAG_PATH", + "hasDynamicHelp": false, + "multiple": false, + "name": "path", + "noCacheDefault": true, + "type": "option" + }, + "reset": { + "allowNo": false, + "description": "Reset all your settings.", + "env": "SHOPIFY_FLAG_RESET", + "exclusive": [ + "config" + ], + "hidden": false, + "name": "reset", + "type": "boolean" + }, + "verbose": { + "allowNo": false, + "description": "Increase the verbosity of the output. May include sensitive data.", + "env": "SHOPIFY_FLAG_VERBOSE", + "hidden": false, + "name": "verbose", + "type": "boolean" + } + }, + "hasDynamicHelp": false, + "hidden": true, + "hiddenAliases": [ + ], + "id": "app:bundle-size-exception:status", + "pluginAlias": "@shopify/cli", + "pluginName": "@shopify/cli", + "pluginType": "core", + "strict": true, + "summary": "Check the state of the app's bundle size exception request." + }, "app:config:link": { "aliases": [ ],