From 4e6c544e4c30f540747dee5cad2ae844fc519630 Mon Sep 17 00:00:00 2001 From: Mitch Lillie Date: Wed, 8 Apr 2026 14:43:32 -0700 Subject: [PATCH] Preserve template application_url during app creation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When `shopify app init` uses an extension-only template (e.g. shopify-app-template-extension-only), the template specifies `application_url = "https://extensions.shopifycdn.com"`. Previously this URL was discarded — createApp() always sent hardcoded placeholder URLs to the platform. The subsequent link() step then pulled the placeholder back from the remote, overwriting the local config. Thread template URLs through CreateAppOptions so the platform receives the correct URL at creation time. Falls back to existing defaults when no template URL is provided (backward compatible). Fixes shop/issues-admin-extensibility#2395 Co-Authored-By: Claude Opus 4.6 --- packages/app/src/cli/models/app/app.ts | 4 ++ .../app/src/cli/models/app/loader.test.ts | 50 ++++++++++++++++ packages/app/src/cli/models/app/loader.ts | 5 ++ .../utilities/developer-platform-client.ts | 2 + .../app-management-client.test.ts | 57 +++++++++++++++++++ .../app-management-client.ts | 7 ++- .../partners-client.test.ts | 33 +++++++++++ .../partners-client.ts | 38 +++++-------- 8 files changed, 169 insertions(+), 27 deletions(-) diff --git a/packages/app/src/cli/models/app/app.ts b/packages/app/src/cli/models/app/app.ts index 5c6a5eab81c..2a207145ba8 100644 --- a/packages/app/src/cli/models/app/app.ts +++ b/packages/app/src/cli/models/app/app.ts @@ -432,12 +432,16 @@ export class App< } creationDefaultOptions(): CreateAppOptions { + const applicationUrl = this.configuration.application_url + const redirectUrls = this.configuration.auth?.redirect_urls return { isLaunchable: this.appIsLaunchable(), scopesArray: getAppScopesArray(this.configuration), name: this.name, isEmbedded: this.appIsEmbedded, directory: this.directory, + applicationUrl, + redirectUrls, } } diff --git a/packages/app/src/cli/models/app/loader.test.ts b/packages/app/src/cli/models/app/loader.test.ts index 4f35ca534b0..79c2de4e264 100644 --- a/packages/app/src/cli/models/app/loader.test.ts +++ b/packages/app/src/cli/models/app/loader.test.ts @@ -3504,6 +3504,56 @@ value = true }) }) }) + + test('extracts application_url from template config', async () => { + await inTemporaryDirectory(async (tmpDir) => { + const config = ` +client_id = "" +name = "my-app" +application_url = "https://extensions.shopifycdn.com" +embedded = true + +[access_scopes] +scopes = "write_products" + +[auth] +redirect_urls = ["https://shopify.dev/apps/default-app-home/api/auth"] + ` + await writeFile(joinPath(tmpDir, 'shopify.app.toml'), config) + await writeFile(joinPath(tmpDir, 'package.json'), '{}') + + const result = await loadConfigForAppCreation(tmpDir, 'my-app') + + expect(result).toEqual({ + isLaunchable: false, + scopesArray: ['write_products'], + name: 'my-app', + directory: normalizePath(tmpDir), + isEmbedded: false, + applicationUrl: 'https://extensions.shopifycdn.com', + redirectUrls: ['https://shopify.dev/apps/default-app-home/api/auth'], + }) + }) + }) + + test('defaults applicationUrl and redirectUrls to undefined when not in template config', async () => { + await inTemporaryDirectory(async (tmpDir) => { + const config = ` +client_id = "" +name = "my-app" + +[access_scopes] +scopes = "write_products" + ` + await writeFile(joinPath(tmpDir, 'shopify.app.toml'), config) + await writeFile(joinPath(tmpDir, 'package.json'), '{}') + + const result = await loadConfigForAppCreation(tmpDir, 'my-app') + + expect(result.applicationUrl).toBeUndefined() + expect(result.redirectUrls).toBeUndefined() + }) + }) }) describe('loadOpaqueApp', () => { diff --git a/packages/app/src/cli/models/app/loader.ts b/packages/app/src/cli/models/app/loader.ts index 2e5aad94508..c1c64af1f1c 100644 --- a/packages/app/src/cli/models/app/loader.ts +++ b/packages/app/src/cli/models/app/loader.ts @@ -206,6 +206,9 @@ export async function loadConfigForAppCreation(directory: string, name: string): const isLaunchable = webs.some((web) => isWebType(web, WebType.Frontend) || isWebType(web, WebType.Backend)) const scopesArray = getAppScopesArray(rawConfig as CurrentAppConfiguration) + const appConfig = rawConfig as CurrentAppConfiguration + const applicationUrl = appConfig.application_url + const redirectUrls = appConfig.auth?.redirect_urls return { isLaunchable, @@ -214,6 +217,8 @@ export async function loadConfigForAppCreation(directory: string, name: string): directory: project.directory, // By default, and ONLY for `app init`, we consider the app as embedded if it is launchable. isEmbedded: isLaunchable, + applicationUrl, + redirectUrls, } } diff --git a/packages/app/src/cli/utilities/developer-platform-client.ts b/packages/app/src/cli/utilities/developer-platform-client.ts index 8b27e3b501a..1962aae976a 100644 --- a/packages/app/src/cli/utilities/developer-platform-client.ts +++ b/packages/app/src/cli/utilities/developer-platform-client.ts @@ -120,6 +120,8 @@ export interface CreateAppOptions { scopesArray?: string[] directory?: string isEmbedded?: boolean + applicationUrl?: string + redirectUrls?: string[] } interface AppModuleVersionSpecification { 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 5e530f3c72f..5aef498b7f9 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 @@ -652,6 +652,63 @@ describe('createApp', () => { expect(result).toMatchObject(expectedApp) }) + test('uses applicationUrl and redirectUrls from options when provided', async () => { + // Given + const client = AppManagementClient.getInstance() + const org = testOrganization() + vi.mocked(webhooksRequestDoc).mockResolvedValueOnce({ + publicApiVersions: [{handle: '2024-07'}, {handle: '2024-10'}, {handle: '2025-01'}, {handle: 'unstable'}], + }) + vi.mocked(appManagementRequestDoc).mockResolvedValueOnce({ + appCreate: { + app: {id: '1', key: 'key', activeRoot: {clientCredentials: {secrets: [{key: 'secret'}]}}}, + userErrors: [], + }, + }) + + // When + client.token = () => Promise.resolve('token') + await client.createApp(org, { + name: 'app-name', + isLaunchable: false, + applicationUrl: 'https://extensions.shopifycdn.com', + redirectUrls: ['https://shopify.dev/apps/default-app-home/api/auth'], + }) + + // Then + expect(vi.mocked(appManagementRequestDoc)).toHaveBeenCalledWith({ + query: CreateApp, + token: 'token', + variables: { + organizationId: 'gid://shopify/Organization/1', + initialVersion: { + source: { + name: 'app-name', + modules: expect.arrayContaining([ + { + type: 'app_home', + config: { + app_url: 'https://extensions.shopifycdn.com', + embedded: true, + }, + }, + { + type: 'app_access', + config: { + redirect_url_allowlist: ['https://shopify.dev/apps/default-app-home/api/auth'], + }, + }, + ]), + }, + }, + }, + unauthorizedHandler: { + handler: expect.any(Function), + type: 'token_refresh', + }, + }) + }) + test('sets embedded to true in app home module', async () => { // 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 0e0dbbf6592..6f98dbd5eaa 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 @@ -1203,6 +1203,9 @@ function createAppVars( apiVersion: string, ): CreateAppMutationVariables { const {isLaunchable, scopesArray, name} = options + const defaultAppUrl = isLaunchable ? 'https://example.com' : MAGIC_URL + const defaultRedirectUrl = isLaunchable ? 'https://example.com/api/auth' : MAGIC_REDIRECT_URL + const source: AppVersionSource = { source: { name, @@ -1210,7 +1213,7 @@ function createAppVars( { type: AppHomeSpecIdentifier, config: { - app_url: isLaunchable ? 'https://example.com' : MAGIC_URL, + app_url: options.applicationUrl ?? defaultAppUrl, // Ext-only apps should be embedded = false, however we are hardcoding this to // match Partners behaviour for now // https://github.com/Shopify/develop-app-inner-loop/issues/2789 @@ -1228,7 +1231,7 @@ function createAppVars( { type: AppAccessSpecIdentifier, config: { - redirect_url_allowlist: isLaunchable ? ['https://example.com/api/auth'] : [MAGIC_REDIRECT_URL], + redirect_url_allowlist: options.redirectUrls ?? [defaultRedirectUrl], ...(scopesArray && {scopes: scopesArray.map((scope) => scope.trim()).join(',')}), }, }, diff --git a/packages/app/src/cli/utilities/developer-platform-client/partners-client.test.ts b/packages/app/src/cli/utilities/developer-platform-client/partners-client.test.ts index e2cce11bf64..e4e1d260ae0 100644 --- a/packages/app/src/cli/utilities/developer-platform-client/partners-client.test.ts +++ b/packages/app/src/cli/utilities/developer-platform-client/partners-client.test.ts @@ -152,6 +152,39 @@ describe('createApp', () => { }) }) + test('uses applicationUrl and redirectUrls from options when provided', async () => { + // Given + const partnersClient = PartnersClient.getInstance(testPartnersUserSession) + vi.mocked(appNamePrompt).mockResolvedValue('app-name') + vi.mocked(partnersRequest).mockResolvedValueOnce({appCreate: {app: APP1, userErrors: []}}) + const variables = { + org: 1, + title: LOCAL_APP.name, + appUrl: 'https://extensions.shopifycdn.com', + redir: ['https://shopify.dev/apps/default-app-home/api/auth'], + requestedAccessScopes: ['write_products'], + type: 'undecided', + } + + // When + await partnersClient.createApp( + {...ORG1, source: OrganizationSource.Partners}, + { + name: LOCAL_APP.name, + isLaunchable: false, + scopesArray: ['write_products'], + applicationUrl: 'https://extensions.shopifycdn.com', + redirectUrls: ['https://shopify.dev/apps/default-app-home/api/auth'], + }, + ) + + // Then + expect(partnersRequest).toHaveBeenCalledWith(CreateAppQuery, 'token', variables, undefined, undefined, { + type: 'token_refresh', + handler: expect.any(Function), + }) + }) + test('throws error if requests has a user error', async () => { // Given const partnersClient = PartnersClient.getInstance(testPartnersUserSession) diff --git a/packages/app/src/cli/utilities/developer-platform-client/partners-client.ts b/packages/app/src/cli/utilities/developer-platform-client/partners-client.ts index 9f54e23a50e..e7c849cc964 100644 --- a/packages/app/src/cli/utilities/developer-platform-client/partners-client.ts +++ b/packages/app/src/cli/utilities/developer-platform-client/partners-client.ts @@ -170,30 +170,18 @@ import {CLI_KIT_VERSION} from '@shopify/cli-kit/common/version' const MAGIC_URL = 'https://shopify.dev/apps/default-app-home' const MAGIC_REDIRECT_URL = 'https://shopify.dev/apps/default-app-home/api/auth' -function getAppVars( - org: Organization, - name: string, - isLaunchable = true, - scopesArray?: string[], -): CreateAppQueryVariables { - if (isLaunchable) { - return { - org: parseInt(org.id, 10), - title: name, - appUrl: 'https://example.com', - redir: ['https://example.com/api/auth'], - requestedAccessScopes: scopesArray ?? [], - type: 'undecided', - } - } else { - return { - org: parseInt(org.id, 10), - title: name, - appUrl: MAGIC_URL, - redir: [MAGIC_REDIRECT_URL], - requestedAccessScopes: scopesArray ?? [], - type: 'undecided', - } +function getAppVars(org: Organization, options: CreateAppOptions): CreateAppQueryVariables { + const {name, isLaunchable = true, scopesArray} = options + const defaultAppUrl = isLaunchable ? 'https://example.com' : MAGIC_URL + const defaultRedirectUrl = isLaunchable ? 'https://example.com/api/auth' : MAGIC_REDIRECT_URL + + return { + org: parseInt(org.id, 10), + title: name, + appUrl: options.applicationUrl ?? defaultAppUrl, + redir: options.redirectUrls ?? [defaultRedirectUrl], + requestedAccessScopes: scopesArray ?? [], + type: 'undecided', } } @@ -395,7 +383,7 @@ export class PartnersClient implements DeveloperPlatformClient { } async createApp(org: Organization, options: CreateAppOptions): Promise { - const variables: CreateAppQueryVariables = getAppVars(org, options.name, options.isLaunchable, options.scopesArray) + const variables: CreateAppQueryVariables = getAppVars(org, options) const result: CreateAppQuerySchema = await this.request(CreateAppQuery, variables) if (result.appCreate.userErrors.length > 0) { const errors = result.appCreate.userErrors.map((error) => error.message).join(', ')