diff --git a/.changeset/unsafe-app-dev.md b/.changeset/unsafe-app-dev.md new file mode 100644 index 00000000000..9c130e88d83 --- /dev/null +++ b/.changeset/unsafe-app-dev.md @@ -0,0 +1,5 @@ +--- +'@shopify/app': minor +--- + +Add an `--unsafe-validation` flag to `shopify app dev`. diff --git a/docs-shopify.dev/generated/generated_docs_data_v2.json b/docs-shopify.dev/generated/generated_docs_data_v2.json index ff8e3489503..f1ea3465e59 100644 --- a/docs-shopify.dev/generated/generated_docs_data_v2.json +++ b/docs-shopify.dev/generated/generated_docs_data_v2.json @@ -1186,6 +1186,15 @@ "isOptional": true, "environmentValue": "SHOPIFY_FLAG_TUNNEL_URL" }, + { + "filePath": "docs-shopify.dev/commands/interfaces/app-dev.interface.ts", + "syntaxKind": "PropertySignature", + "name": "--unsafe-validation", + "value": "''", + "description": "Allow unsafe app version validation during dev previews.", + "isOptional": true, + "environmentValue": "SHOPIFY_FLAG_UNSAFE_VALIDATION" + }, { "filePath": "docs-shopify.dev/commands/interfaces/app-dev.interface.ts", "syntaxKind": "PropertySignature", @@ -1232,7 +1241,7 @@ "environmentValue": "SHOPIFY_FLAG_THEME" } ], - "value": "export interface appdev {\n /**\n * Alias of the Shopify account to use for authentication.\n * @environment SHOPIFY_FLAG_AUTH_ALIAS\n */\n '--auth-alias '?: string\n\n /**\n * Resource URL for checkout UI extension. Format: \"/cart/{productVariantID}:{productQuantity}\"\n * @environment SHOPIFY_FLAG_CHECKOUT_CART_URL\n */\n '--checkout-cart-url '?: string\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 * Install and use mkcert to generate localhost certificates when --use-localhost is enabled without prompting.\n * @environment SHOPIFY_FLAG_INSTALL_MKCERT\n */\n '--install-mkcert'?: ''\n\n /**\n * Print the command's JSON schemas.\n * @environment SHOPIFY_FLAG_JSON_SCHEMA\n */\n '--json-schema'?: ''\n\n /**\n * Port to use for localhost. Must be between 1 and 65535.\n * @environment SHOPIFY_FLAG_LOCALHOST_PORT\n */\n '--localhost-port '?: string\n\n /**\n * Disable color output.\n * @environment SHOPIFY_FLAG_NO_COLOR\n */\n '--no-color'?: ''\n\n /**\n * Uses the app URL from the toml file instead an autogenerated URL for dev.\n * @environment SHOPIFY_FLAG_NO_UPDATE\n */\n '--no-update'?: ''\n\n /**\n * The file path or URL. The file path is to a file that you want updated on idle. The URL path is where you want a webhook posted to report on file changes.\n * @environment SHOPIFY_FLAG_NOTIFY\n */\n '--notify '?: string\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 * Skips the installation of dependencies. Deprecated, use workspaces instead.\n * @environment SHOPIFY_FLAG_SKIP_DEPENDENCIES_INSTALLATION\n */\n '--skip-dependencies-installation'?: ''\n\n /**\n * Store URL. Must be an existing development or Shopify Plus sandbox store.\n * @environment SHOPIFY_FLAG_STORE\n */\n '-s, --store '?: string\n\n /**\n * The password for storefronts with password protection.\n * @environment SHOPIFY_FLAG_STORE_PASSWORD\n */\n '--store-password '?: string\n\n /**\n * Resource URL for subscription UI extension. Format: \"/products/{productId}\"\n * @environment SHOPIFY_FLAG_SUBSCRIPTION_PRODUCT_URL\n */\n '--subscription-product-url '?: string\n\n /**\n * Theme ID or name of the theme app extension host theme.\n * @environment SHOPIFY_FLAG_THEME\n */\n '-t, --theme '?: string\n\n /**\n * Local port of the theme app extension development server. Must be between 1 and 65535.\n * @environment SHOPIFY_FLAG_THEME_APP_EXTENSION_PORT\n */\n '--theme-app-extension-port '?: string\n\n /**\n * Use a custom tunnel, it must be running before executing dev. Format: \"https://my-tunnel-url:port\".\n * @environment SHOPIFY_FLAG_TUNNEL_URL\n */\n '--tunnel-url '?: string\n\n /**\n * Service entry point will listen to localhost. A tunnel won't be used. Will work for testing many app features, but not those that directly invoke your app (E.g: Webhooks)\n * @environment SHOPIFY_FLAG_USE_LOCALHOST\n */\n '--use-localhost'?: ''\n\n /**\n * Increase the verbosity of the output. May include sensitive data.\n * @environment SHOPIFY_FLAG_VERBOSE\n */\n '--verbose'?: ''\n}" + "value": "export interface appdev {\n /**\n * Alias of the Shopify account to use for authentication.\n * @environment SHOPIFY_FLAG_AUTH_ALIAS\n */\n '--auth-alias '?: string\n\n /**\n * Resource URL for checkout UI extension. Format: \"/cart/{productVariantID}:{productQuantity}\"\n * @environment SHOPIFY_FLAG_CHECKOUT_CART_URL\n */\n '--checkout-cart-url '?: string\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 * Install and use mkcert to generate localhost certificates when --use-localhost is enabled without prompting.\n * @environment SHOPIFY_FLAG_INSTALL_MKCERT\n */\n '--install-mkcert'?: ''\n\n /**\n * Print the command's JSON schemas.\n * @environment SHOPIFY_FLAG_JSON_SCHEMA\n */\n '--json-schema'?: ''\n\n /**\n * Port to use for localhost. Must be between 1 and 65535.\n * @environment SHOPIFY_FLAG_LOCALHOST_PORT\n */\n '--localhost-port '?: string\n\n /**\n * Disable color output.\n * @environment SHOPIFY_FLAG_NO_COLOR\n */\n '--no-color'?: ''\n\n /**\n * Uses the app URL from the toml file instead an autogenerated URL for dev.\n * @environment SHOPIFY_FLAG_NO_UPDATE\n */\n '--no-update'?: ''\n\n /**\n * The file path or URL. The file path is to a file that you want updated on idle. The URL path is where you want a webhook posted to report on file changes.\n * @environment SHOPIFY_FLAG_NOTIFY\n */\n '--notify '?: string\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 * Skips the installation of dependencies. Deprecated, use workspaces instead.\n * @environment SHOPIFY_FLAG_SKIP_DEPENDENCIES_INSTALLATION\n */\n '--skip-dependencies-installation'?: ''\n\n /**\n * Store URL. Must be an existing development or Shopify Plus sandbox store.\n * @environment SHOPIFY_FLAG_STORE\n */\n '-s, --store '?: string\n\n /**\n * The password for storefronts with password protection.\n * @environment SHOPIFY_FLAG_STORE_PASSWORD\n */\n '--store-password '?: string\n\n /**\n * Resource URL for subscription UI extension. Format: \"/products/{productId}\"\n * @environment SHOPIFY_FLAG_SUBSCRIPTION_PRODUCT_URL\n */\n '--subscription-product-url '?: string\n\n /**\n * Theme ID or name of the theme app extension host theme.\n * @environment SHOPIFY_FLAG_THEME\n */\n '-t, --theme '?: string\n\n /**\n * Local port of the theme app extension development server. Must be between 1 and 65535.\n * @environment SHOPIFY_FLAG_THEME_APP_EXTENSION_PORT\n */\n '--theme-app-extension-port '?: string\n\n /**\n * Use a custom tunnel, it must be running before executing dev. Format: \"https://my-tunnel-url:port\".\n * @environment SHOPIFY_FLAG_TUNNEL_URL\n */\n '--tunnel-url '?: string\n\n /**\n * Allow unsafe app version validation during dev previews.\n * @environment SHOPIFY_FLAG_UNSAFE_VALIDATION\n */\n '--unsafe-validation'?: ''\n\n /**\n * Service entry point will listen to localhost. A tunnel won't be used. Will work for testing many app features, but not those that directly invoke your app (E.g: Webhooks)\n * @environment SHOPIFY_FLAG_USE_LOCALHOST\n */\n '--use-localhost'?: ''\n\n /**\n * Increase the verbosity of the output. May include sensitive data.\n * @environment SHOPIFY_FLAG_VERBOSE\n */\n '--verbose'?: ''\n}" } }, "appenvpull": { diff --git a/packages/app/src/cli/api/graphql/app-dev/generated/dev-session-create.ts b/packages/app/src/cli/api/graphql/app-dev/generated/dev-session-create.ts index fc7b96cdd62..35d9786e084 100644 --- a/packages/app/src/cli/api/graphql/app-dev/generated/dev-session-create.ts +++ b/packages/app/src/cli/api/graphql/app-dev/generated/dev-session-create.ts @@ -8,6 +8,7 @@ export type DevSessionCreateMutationVariables = Types.Exact<{ appId: Types.Scalars['String']['input'] assetsUrl: Types.Scalars['String']['input'] websocketUrl?: Types.InputMaybe + unsafe?: Types.InputMaybe }> export type DevSessionCreateMutation = { @@ -46,6 +47,11 @@ export const DevSessionCreate = { variable: {kind: 'Variable', name: {kind: 'Name', value: 'websocketUrl'}}, type: {kind: 'NamedType', name: {kind: 'Name', value: 'String'}}, }, + { + kind: 'VariableDefinition', + variable: {kind: 'Variable', name: {kind: 'Name', value: 'unsafe'}}, + type: {kind: 'NamedType', name: {kind: 'Name', value: 'Boolean'}}, + }, ], selectionSet: { kind: 'SelectionSet', @@ -69,6 +75,11 @@ export const DevSessionCreate = { name: {kind: 'Name', value: 'websocketUrl'}, value: {kind: 'Variable', name: {kind: 'Name', value: 'websocketUrl'}}, }, + { + kind: 'Argument', + name: {kind: 'Name', value: 'isUnsafe'}, + value: {kind: 'Variable', name: {kind: 'Name', value: 'unsafe'}}, + }, ], selectionSet: { kind: 'SelectionSet', diff --git a/packages/app/src/cli/api/graphql/app-dev/generated/dev-session-update.ts b/packages/app/src/cli/api/graphql/app-dev/generated/dev-session-update.ts index 84b86c7c51d..aad4bce0038 100644 --- a/packages/app/src/cli/api/graphql/app-dev/generated/dev-session-update.ts +++ b/packages/app/src/cli/api/graphql/app-dev/generated/dev-session-update.ts @@ -9,6 +9,7 @@ export type DevSessionUpdateMutationVariables = Types.Exact<{ assetsUrl?: Types.InputMaybe manifest?: Types.InputMaybe inheritedModuleUids: Types.Scalars['String']['input'][] | Types.Scalars['String']['input'] + unsafe?: Types.InputMaybe }> export type DevSessionUpdateMutation = { @@ -19,6 +20,7 @@ export type DevSessionUpdateMutation = { user?: {id: string; email?: string | null} | null app: {id: string; key: string} } | null + warnings?: {message: string; code: Types.DevSessionWarningCode}[] | null userErrors: {message: string; on: JsonMapType; field?: string[] | null; category: string}[] } | null } @@ -57,6 +59,11 @@ export const DevSessionUpdate = { }, }, }, + { + kind: 'VariableDefinition', + variable: {kind: 'Variable', name: {kind: 'Name', value: 'unsafe'}}, + type: {kind: 'NamedType', name: {kind: 'Name', value: 'Boolean'}}, + }, ], selectionSet: { kind: 'SelectionSet', @@ -85,6 +92,11 @@ export const DevSessionUpdate = { name: {kind: 'Name', value: 'inheritedModuleUids'}, value: {kind: 'Variable', name: {kind: 'Name', value: 'inheritedModuleUids'}}, }, + { + kind: 'Argument', + name: {kind: 'Name', value: 'isUnsafe'}, + value: {kind: 'Variable', name: {kind: 'Name', value: 'unsafe'}}, + }, ], selectionSet: { kind: 'SelectionSet', @@ -125,6 +137,18 @@ export const DevSessionUpdate = { ], }, }, + { + kind: 'Field', + name: {kind: 'Name', value: 'warnings'}, + selectionSet: { + kind: 'SelectionSet', + selections: [ + {kind: 'Field', name: {kind: 'Name', value: 'message'}}, + {kind: 'Field', name: {kind: 'Name', value: 'code'}}, + {kind: 'Field', name: {kind: 'Name', value: '__typename'}}, + ], + }, + }, { kind: 'Field', name: {kind: 'Name', value: 'userErrors'}, diff --git a/packages/app/src/cli/api/graphql/app-dev/queries/dev-session-create.graphql b/packages/app/src/cli/api/graphql/app-dev/queries/dev-session-create.graphql index 6c2dec0846b..709ad193fa3 100644 --- a/packages/app/src/cli/api/graphql/app-dev/queries/dev-session-create.graphql +++ b/packages/app/src/cli/api/graphql/app-dev/queries/dev-session-create.graphql @@ -1,5 +1,5 @@ -mutation DevSessionCreate($appId: String!, $assetsUrl: String!, $websocketUrl: String) { - devSessionCreate(appId: $appId, assetsUrl: $assetsUrl, websocketUrl: $websocketUrl) { +mutation DevSessionCreate($appId: String!, $assetsUrl: String!, $websocketUrl: String, $unsafe: Boolean) { + devSessionCreate(appId: $appId, assetsUrl: $assetsUrl, websocketUrl: $websocketUrl, isUnsafe: $unsafe) { devSession { websocketUrl updatedAt diff --git a/packages/app/src/cli/api/graphql/app-dev/queries/dev-session-update.graphql b/packages/app/src/cli/api/graphql/app-dev/queries/dev-session-update.graphql index bf588a7ac50..5a8043b5513 100644 --- a/packages/app/src/cli/api/graphql/app-dev/queries/dev-session-update.graphql +++ b/packages/app/src/cli/api/graphql/app-dev/queries/dev-session-update.graphql @@ -1,5 +1,5 @@ -mutation DevSessionUpdate($appId: String!, $assetsUrl: String, $manifest: JSON, $inheritedModuleUids: [String!]!) { - devSessionUpdate(appId: $appId, assetsUrl: $assetsUrl, manifest: $manifest, inheritedModuleUids: $inheritedModuleUids) { +mutation DevSessionUpdate($appId: String!, $assetsUrl: String, $manifest: JSON, $inheritedModuleUids: [String!]!, $unsafe: Boolean) { + devSessionUpdate(appId: $appId, assetsUrl: $assetsUrl, manifest: $manifest, inheritedModuleUids: $inheritedModuleUids, isUnsafe: $unsafe) { devSession { websocketUrl updatedAt @@ -12,6 +12,10 @@ mutation DevSessionUpdate($appId: String!, $assetsUrl: String, $manifest: JSON, key } } + warnings { + message + code + } userErrors { message on diff --git a/packages/app/src/cli/api/graphql/business-platform-organizations/generated/types.d.ts b/packages/app/src/cli/api/graphql/business-platform-organizations/generated/types.d.ts index dc9eed074bc..bde45320391 100644 --- a/packages/app/src/cli/api/graphql/business-platform-organizations/generated/types.d.ts +++ b/packages/app/src/cli/api/graphql/business-platform-organizations/generated/types.d.ts @@ -15,7 +15,7 @@ export type Scalars = { Int: { input: number; output: number; } Float: { input: number; output: number; } AccessRoleAssignee: { input: any; output: any; } - /** The ID for a AccessRole. */ + /** The ID for an AccessRole. */ AccessRoleID: { input: any; output: any; } AccessRoleRecordId: { input: any; output: any; } /** The ID for a ActionAudit. */ diff --git a/packages/app/src/cli/commands/app/dev.test.ts b/packages/app/src/cli/commands/app/dev.test.ts index c2a1fc4892b..3dd2badb663 100644 --- a/packages/app/src/cli/commands/app/dev.test.ts +++ b/packages/app/src/cli/commands/app/dev.test.ts @@ -64,7 +64,20 @@ describe('app dev command', () => { localhostPort: undefined, }) expect(storeContext).toHaveBeenCalledWith(expect.objectContaining({storeCreationMode: 'selection-option'})) - expect(dev).toHaveBeenCalledWith(expect.objectContaining({installMkcert: undefined, tunnel: {mode: 'auto'}})) + expect(dev).toHaveBeenCalledWith( + expect.objectContaining({installMkcert: undefined, unsafe: false, tunnel: {mode: 'auto'}}), + ) + }) + }) + + test('passes --unsafe-validation to dev', async () => { + await inTemporaryDirectory(async (tmp) => { + const {store} = mockAppAndStore(tmp) + vi.mocked(getTunnelMode).mockResolvedValue({mode: 'auto'}) + + await Dev.run(['--path', tmp, '--store', store.shopDomain, '--unsafe-validation'], import.meta.url) + + expect(dev).toHaveBeenCalledWith(expect.objectContaining({unsafe: true})) }) }) diff --git a/packages/app/src/cli/commands/app/dev.ts b/packages/app/src/cli/commands/app/dev.ts index 50580db3ea5..c66a0718230 100644 --- a/packages/app/src/cli/commands/app/dev.ts +++ b/packages/app/src/cli/commands/app/dev.ts @@ -40,6 +40,11 @@ export default class Dev extends AppLinkedCommand { env: 'SHOPIFY_FLAG_NO_UPDATE', default: false, }), + 'unsafe-validation': Flags.boolean({ + description: 'Allow unsafe app version validation during dev previews.', + env: 'SHOPIFY_FLAG_UNSAFE_VALIDATION', + default: false, + }), 'subscription-product-url': Flags.string({ description: 'Resource URL for subscription UI extension. Format: "/products/{productId}"', env: 'SHOPIFY_FLAG_SUBSCRIPTION_PRODUCT_URL', @@ -142,6 +147,7 @@ export default class Dev extends AppLinkedCommand { store, directory: flags.path, update: !flags['no-update'], + unsafe: flags['unsafe-validation'], skipDependenciesInstallation: flags['skip-dependencies-installation'], commandConfig: this.config, subscriptionProductUrl: flags['subscription-product-url'], diff --git a/packages/app/src/cli/services/dev.ts b/packages/app/src/cli/services/dev.ts index 24e0b135bb0..39269a19146 100644 --- a/packages/app/src/cli/services/dev.ts +++ b/packages/app/src/cli/services/dev.ts @@ -50,6 +50,7 @@ export interface DevOptions { store: OrganizationStore directory: string update: boolean + unsafe?: boolean commandConfig: Config skipDependenciesInstallation: boolean subscriptionProductUrl?: string diff --git a/packages/app/src/cli/services/dev/processes/dev-session/dev-session-process.test.ts b/packages/app/src/cli/services/dev/processes/dev-session/dev-session-process.test.ts index f8422e5e7de..a1c2ca34314 100644 --- a/packages/app/src/cli/services/dev/processes/dev-session/dev-session-process.test.ts +++ b/packages/app/src/cli/services/dev/processes/dev-session/dev-session-process.test.ts @@ -115,6 +115,19 @@ describe('pushUpdatesForDevSession', () => { expect(stdout.write).toHaveBeenCalledWith(expect.stringContaining('Ready')) }) + test('uses the enabled unsafe value when creating and updating a dev session', async () => { + options.unsafe = true + + await pushUpdatesForDevSession({stderr, stdout, abortSignal: abortController.signal}, options) + await appWatcher.start({stdout, stderr, signal: abortController.signal}) + await flushPromises() + appWatcher.emit('all', {app, extensionEvents: [{type: 'updated', extension: await testWebhookExtensions()}]}) + await flushPromises() + + expect(developerPlatformClient.devSessionCreate).toHaveBeenCalledWith(expect.objectContaining({unsafe: true})) + expect(developerPlatformClient.devSessionUpdate).toHaveBeenCalledWith(expect.objectContaining({unsafe: true})) + }) + test('updates use the extension handle as the output prefix', async () => { // When @@ -182,6 +195,56 @@ describe('pushUpdatesForDevSession', () => { expect(stdout.write).toHaveBeenCalledWith(expect.stringContaining('Update error')) }) + test.each([ + { + code: 'VALIDATION_WARNING', + message: 'Bundle size exceeds the supported development limit', + renderedMessage: 'Bundle size exceeds the supported development limit', + }, + { + code: 'SESSION_TAKEOVER', + message: "You took over another user's session", + renderedMessage: "⚠️ You took over another user's session", + }, + ])('displays $code warnings from dev session updates', async ({code, message, renderedMessage}) => { + developerPlatformClient.devSessionUpdate = vi.fn().mockResolvedValue({ + devSessionUpdate: { + userErrors: [], + warnings: [{message, code}], + }, + }) + + await pushUpdatesForDevSession({stderr, stdout, abortSignal: abortController.signal}, options) + await appWatcher.start({stdout, stderr, signal: abortController.signal}) + await flushPromises() + appWatcher.emit('all', {app, extensionEvents: [{type: 'updated', extension: await testWebhookExtensions()}]}) + await flushPromises() + + expect(stdout.write).toHaveBeenCalledWith(expect.stringContaining(renderedMessage)) + expect(stdout.write).toHaveBeenCalledWith(expect.stringContaining('Updated dev preview on test.myshopify.com')) + }) + + test('displays validation warnings without hiding update errors', async () => { + // Given + developerPlatformClient.devSessionUpdate = vi.fn().mockResolvedValue({ + devSessionUpdate: { + userErrors: [{message: 'Update failed', category: 'test'}], + warnings: [{message: 'Validation warning', code: 'VALIDATION_WARNING'}], + }, + }) + + // When + await pushUpdatesForDevSession({stderr, stdout, abortSignal: abortController.signal}, options) + await appWatcher.start({stdout, stderr, signal: abortController.signal}) + await flushPromises() + appWatcher.emit('all', {app, extensionEvents: [{type: 'updated', extension: await testWebhookExtensions()}]}) + await flushPromises() + + // Then + expect(stdout.write).toHaveBeenCalledWith(expect.stringContaining('Validation warning')) + expect(stdout.write).toHaveBeenCalledWith(expect.stringContaining('Update failed')) + }) + test('handles scope changes and displays updated message', async () => { // Given const appAccess = await testAppAccessConfigExtension(false, undefined, false) @@ -419,6 +482,7 @@ describe('pushUpdatesForDevSession', () => { }, // The unaffected extension is listed in inheritedModuleUids inheritedModuleUids: [unaffectedExtension.uid], + unsafe: false, }) }) @@ -530,6 +594,7 @@ describe('pushUpdatesForDevSession', () => { assetsUrl: 'https://gcs.url', manifest: expect.any(Object), inheritedModuleUids: [], + unsafe: false, }) }) @@ -549,6 +614,7 @@ describe('pushUpdatesForDevSession', () => { appId: 'app123', assetsUrl: 'https://gcs.url', websocketUrl: 'wss://test.dev/extensions', + unsafe: false, }) }) diff --git a/packages/app/src/cli/services/dev/processes/dev-session/dev-session-process.ts b/packages/app/src/cli/services/dev/processes/dev-session/dev-session-process.ts index 28c6a504850..ca62c48c9f3 100644 --- a/packages/app/src/cli/services/dev/processes/dev-session/dev-session-process.ts +++ b/packages/app/src/cli/services/dev/processes/dev-session/dev-session-process.ts @@ -16,6 +16,7 @@ export interface DevSessionProcessOptions { appWatcher: AppEventWatcher appPreviewURL: string devSessionStatusManager: DevSessionStatusManager + unsafe?: boolean } export interface DevSessionProcess extends BaseProcess { diff --git a/packages/app/src/cli/services/dev/processes/dev-session/dev-session.ts b/packages/app/src/cli/services/dev/processes/dev-session/dev-session.ts index dcf11015567..c58008c6351 100644 --- a/packages/app/src/cli/services/dev/processes/dev-session/dev-session.ts +++ b/packages/app/src/cli/services/dev/processes/dev-session/dev-session.ts @@ -308,6 +308,7 @@ export class DevSession { assetsUrl: signedURL, manifest, inheritedModuleUids, + unsafe: this.options.unsafe ?? false, } return this.devSessionUpdateWithRetry(payload) } else { @@ -316,6 +317,7 @@ export class DevSession { appId: this.options.appId, assetsUrl: signedURL, websocketUrl, + unsafe: this.options.unsafe ?? false, } return this.devSessionCreateWithRetry(payload) } @@ -429,6 +431,7 @@ export class DevSession { private async devSessionUpdateWithRetry(payload: DevSessionUpdateOptions): Promise { const result = await this.options.developerPlatformClient.devSessionUpdate(payload) const errors = result.devSessionUpdate?.userErrors ?? [] + const warnings = result.devSessionUpdate?.warnings ?? [] const devSession = result.devSessionUpdate?.devSession // Check for session takeover @@ -459,6 +462,8 @@ export class DevSession { } } + if (warnings.length > 0) await this.logWarnings(warnings) + if (errors.length) return {status: 'remote-error', error: errors} return {status: 'updated'} } @@ -484,19 +489,20 @@ export class DevSession { } } - // Display warnings (non-blocking) - if (warnings.length > 0) { - await Promise.all( - warnings.map((warning) => { - const message = warning.code === 'SESSION_TAKEOVER' ? `⚠️ ${warning.message}` : warning.message - return this.logger.warning(message) - }), - ) - } + if (warnings.length > 0) await this.logWarnings(warnings) // Errors are blocking if (errors.length) return {status: 'remote-error', error: errors} return {status: 'created'} } + + private async logWarnings(warnings: {message: string; code: string}[]): Promise { + await Promise.all( + warnings.map((warning) => { + const message = warning.code === 'SESSION_TAKEOVER' ? `⚠️ ${warning.message}` : warning.message + return this.logger.warning(message) + }), + ) + } } diff --git a/packages/app/src/cli/services/dev/processes/setup-dev-processes.test.ts b/packages/app/src/cli/services/dev/processes/setup-dev-processes.test.ts index de0626c6c3a..ca964b68ca8 100644 --- a/packages/app/src/cli/services/dev/processes/setup-dev-processes.test.ts +++ b/packages/app/src/cli/services/dev/processes/setup-dev-processes.test.ts @@ -349,7 +349,7 @@ describe('setup-dev-processes', () => { expect(res.graphiqlUrl).toBe(`http://localhost:${graphiqlPort}/graphiql?key=${encodeURIComponent(expectedKey)}`) }) - test('process list includes dev-session', async () => { + test.each([false, true])('process list includes dev-session with unsafe=%s', async (unsafe) => { const developerPlatformClient: DeveloperPlatformClient = testDeveloperPlatformClient() const storeFqdn = 'store.myshopify.io' const storeId = '123456789' @@ -359,6 +359,7 @@ describe('setup-dev-processes', () => { ...appContextResult, directory: '', update: false, + unsafe, commandConfig: new Config({root: ''}), skipDependenciesInstallation: false, tunnel: {mode: 'auto'}, @@ -413,6 +414,7 @@ describe('setup-dev-processes', () => { appId: '1234', organizationId: '5678', storeFqdn: 'store.myshopify.io', + unsafe, }, }) }) diff --git a/packages/app/src/cli/services/dev/processes/setup-dev-processes.ts b/packages/app/src/cli/services/dev/processes/setup-dev-processes.ts index 1277bb675d0..23d8f8462bc 100644 --- a/packages/app/src/cli/services/dev/processes/setup-dev-processes.ts +++ b/packages/app/src/cli/services/dev/processes/setup-dev-processes.ts @@ -162,6 +162,7 @@ export async function setupDevProcesses({ appWatcher, appPreviewURL: appPreviewUrl, devSessionStatusManager, + unsafe: commandOptions.unsafe, }), await setupPreviewThemeAppExtensionsProcess({ remoteApp, diff --git a/packages/app/src/cli/utilities/developer-platform-client.ts b/packages/app/src/cli/utilities/developer-platform-client.ts index 448d0284b4b..39ec1f08bac 100644 --- a/packages/app/src/cli/utilities/developer-platform-client.ts +++ b/packages/app/src/cli/utilities/developer-platform-client.ts @@ -121,12 +121,14 @@ interface DevSessionSharedOptions { export interface DevSessionCreateOptions extends DevSessionSharedOptions { assetsUrl?: string websocketUrl?: string + unsafe?: boolean } export interface DevSessionUpdateOptions extends DevSessionSharedOptions { assetsUrl?: string manifest: AppManifest inheritedModuleUids: string[] + unsafe?: boolean } export type DevSessionDeleteOptions = DevSessionSharedOptions diff --git a/packages/app/src/cli/utilities/developer-platform-client/app-management-client.test.ts b/packages/app/src/cli/utilities/developer-platform-client/app-management-client.test.ts index 5c54c82fb6a..29f80d9c85f 100644 --- a/packages/app/src/cli/utilities/developer-platform-client/app-management-client.test.ts +++ b/packages/app/src/cli/utilities/developer-platform-client/app-management-client.test.ts @@ -56,6 +56,7 @@ import { businessPlatformRequestDoc, } from '@shopify/cli-kit/node/api/business-platform' import {appManagementRequestDoc} from '@shopify/cli-kit/node/api/app-management' +import {appDevRequestDoc} from '@shopify/cli-kit/node/api/app-dev' import {BugError} from '@shopify/cli-kit/node/error' import {randomUUID} from '@shopify/cli-kit/node/crypto' import {webhooksRequestDoc} from '@shopify/cli-kit/node/api/webhooks' @@ -63,6 +64,7 @@ import {webhooksRequestDoc} from '@shopify/cli-kit/node/api/webhooks' vi.mock('@shopify/cli-kit/node/http') vi.mock('@shopify/cli-kit/node/api/business-platform') vi.mock('@shopify/cli-kit/node/api/app-management') +vi.mock('@shopify/cli-kit/node/api/app-dev') vi.mock('@shopify/organizations') vi.mock('@shopify/cli-kit/node/api/webhooks') @@ -1019,6 +1021,64 @@ describe('sendSampleWebhook', () => { }) }) +describe('dev session requests', () => { + test('sends the enabled unsafe value to create and update requests', async () => { + const client = AppManagementClient.getInstance() + client.token = () => Promise.resolve('token') + + await client.devSessionCreate({ + appId: 'gid://shopify/App/123', + assetsUrl: 'https://assets.test', + shopFqdn: 'test.myshopify.com', + websocketUrl: 'wss://test.dev/extensions', + unsafe: true, + }) + await client.devSessionUpdate({ + appId: 'gid://shopify/App/123', + assetsUrl: 'https://assets.test', + shopFqdn: 'test.myshopify.com', + manifest: {name: 'App', handle: 'app', modules: []}, + inheritedModuleUids: [], + unsafe: true, + }) + + expect(appDevRequestDoc).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({variables: expect.objectContaining({unsafe: true})}), + ) + expect(appDevRequestDoc).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({variables: expect.objectContaining({unsafe: true})}), + ) + }) + + test('sends false when unsafe is omitted', async () => { + const client = AppManagementClient.getInstance() + client.token = () => Promise.resolve('token') + + await client.devSessionCreate({ + appId: 'gid://shopify/App/123', + assetsUrl: 'https://assets.test', + shopFqdn: 'test.myshopify.com', + }) + await client.devSessionUpdate({ + appId: 'gid://shopify/App/123', + shopFqdn: 'test.myshopify.com', + manifest: {name: 'App', handle: 'app', modules: []}, + inheritedModuleUids: [], + }) + + expect(appDevRequestDoc).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({variables: expect.objectContaining({unsafe: false})}), + ) + expect(appDevRequestDoc).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({variables: expect.objectContaining({unsafe: false})}), + ) + }) +}) + describe('deploy', () => { // Given const client = AppManagementClient.getInstance() 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 c611a5dfa17..ded94038706 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 @@ -1038,12 +1038,13 @@ export class AppManagementClient implements DeveloperPlatformClient { assetsUrl, shopFqdn, websocketUrl, + unsafe, }: DevSessionCreateOptions): Promise { const appIdNumber = String(numberFromGid(appId)) return this.appDevRequest({ query: DevSessionCreate, shopFqdn, - variables: {appId: appIdNumber, assetsUrl: assetsUrl ?? '', websocketUrl}, + variables: {appId: appIdNumber, assetsUrl: assetsUrl ?? '', websocketUrl, unsafe: unsafe ?? false}, requestOptions: {requestMode: 'slow-request'}, }) } @@ -1054,6 +1055,7 @@ export class AppManagementClient implements DeveloperPlatformClient { shopFqdn, manifest, inheritedModuleUids, + unsafe, }: DevSessionUpdateOptions): Promise { const appIdNumber = String(numberFromGid(appId)) const variables: DevSessionUpdateMutationVariables = { @@ -1061,6 +1063,7 @@ export class AppManagementClient implements DeveloperPlatformClient { assetsUrl, manifest: JSON.stringify(manifest), inheritedModuleUids, + unsafe: unsafe ?? false, } return this.appDevRequest({query: DevSessionUpdate, shopFqdn, variables}) } diff --git a/packages/cli/README.md b/packages/cli/README.md index 38c5dc2c8ff..49dc95bcfb1 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -695,7 +695,7 @@ USAGE [--install-mkcert --use-localhost] [--json-schema] [--localhost-port ] [--no-color] [--no-update] [--notify ] [--path ] [--reset | ] [--skip-dependencies-installation] [-s ] [--store-password ] [--subscription-product-url ] [-t ] [--theme-app-extension-port ] [--tunnel-url | ] - [--verbose] + [--unsafe-validation] [--verbose] FLAGS -c, --config= @@ -775,6 +775,10 @@ FLAGS Use a custom tunnel, it must be running before executing dev. Format: "https://my-tunnel-url:port". [env: SHOPIFY_FLAG_TUNNEL_URL] + --unsafe-validation + Allow unsafe app version validation during dev previews. + [env: SHOPIFY_FLAG_UNSAFE_VALIDATION] + --use-localhost Service entry point will listen to localhost. A tunnel won't be used. Will work for testing many app features, but not those that directly invoke your app (E.g: Webhooks) diff --git a/packages/cli/oclif.manifest.json b/packages/cli/oclif.manifest.json index 1b88620390e..3440efbf855 100644 --- a/packages/cli/oclif.manifest.json +++ b/packages/cli/oclif.manifest.json @@ -1248,6 +1248,13 @@ "name": "tunnel-url", "type": "option" }, + "unsafe-validation": { + "allowNo": false, + "description": "Allow unsafe app version validation during dev previews.", + "env": "SHOPIFY_FLAG_UNSAFE_VALIDATION", + "name": "unsafe-validation", + "type": "boolean" + }, "use-localhost": { "allowNo": false, "description": "Service entry point will listen to localhost. A tunnel won't be used. Will work for testing many app features, but not those that directly invoke your app (E.g: Webhooks)", diff --git a/packages/organizations/src/cli/api/graphql/business-platform-organizations/generated/types.d.ts b/packages/organizations/src/cli/api/graphql/business-platform-organizations/generated/types.d.ts index 5df36979c6a..58ea0f6d702 100644 --- a/packages/organizations/src/cli/api/graphql/business-platform-organizations/generated/types.d.ts +++ b/packages/organizations/src/cli/api/graphql/business-platform-organizations/generated/types.d.ts @@ -15,7 +15,7 @@ export type Scalars = { Int: { input: number; output: number; } Float: { input: number; output: number; } AccessRoleAssignee: { input: any; output: any; } - /** The ID for a AccessRole. */ + /** The ID for an AccessRole. */ AccessRoleID: { input: any; output: any; } AccessRoleRecordId: { input: any; output: any; } /** The ID for a ActionAudit. */ diff --git a/packages/store/src/cli/api/graphql/business-platform-organizations/generated/types.d.ts b/packages/store/src/cli/api/graphql/business-platform-organizations/generated/types.d.ts index e5ae0715680..e42f3a79c4b 100644 --- a/packages/store/src/cli/api/graphql/business-platform-organizations/generated/types.d.ts +++ b/packages/store/src/cli/api/graphql/business-platform-organizations/generated/types.d.ts @@ -15,7 +15,7 @@ export type Scalars = { Int: { input: number; output: number; } Float: { input: number; output: number; } AccessRoleAssignee: { input: any; output: any; } - /** The ID for a AccessRole. */ + /** The ID for an AccessRole. */ AccessRoleID: { input: any; output: any; } AccessRoleRecordId: { input: any; output: any; } /** The ID for a ActionAudit. */