From dc1ac7048197fedc1e9cf0e80cbfb25c44b0a126 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 | 6 + .../app/src/cli/models/app/loader.test.ts | 74 ++++++++++ packages/app/src/cli/models/app/loader.ts | 7 + .../models/extensions/specifications/admin.ts | 2 + .../specifications/types/app_config.ts | 3 + .../utilities/developer-platform-client.ts | 3 + .../app-management-client.test.ts | 134 ++++++++++++++++++ .../app-management-client.ts | 16 ++- .../partners-client.test.ts | 33 +++++ .../partners-client.ts | 38 ++--- 10 files changed, 289 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..df707939e2b 100644 --- a/packages/app/src/cli/models/app/app.ts +++ b/packages/app/src/cli/models/app/app.ts @@ -432,12 +432,18 @@ export class App< } creationDefaultOptions(): CreateAppOptions { + const applicationUrl = this.configuration.application_url + const redirectUrls = this.configuration.auth?.redirect_urls + const staticRoot = this.configuration.admin?.static_root return { isLaunchable: this.appIsLaunchable(), scopesArray: getAppScopesArray(this.configuration), name: this.name, isEmbedded: this.appIsEmbedded, directory: this.directory, + applicationUrl, + redirectUrls, + staticRoot, } } diff --git a/packages/app/src/cli/models/app/loader.test.ts b/packages/app/src/cli/models/app/loader.test.ts index 4f35ca534b0..23fba409baf 100644 --- a/packages/app/src/cli/models/app/loader.test.ts +++ b/packages/app/src/cli/models/app/loader.test.ts @@ -3504,6 +3504,80 @@ 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'], + staticRoot: undefined, + }) + }) + }) + + test('extracts admin.static_root from template config', async () => { + await inTemporaryDirectory(async (tmpDir) => { + const config = ` +client_id = "" +name = "my-app" +application_url = "https://extensions.shopifycdn.com" +embedded = true + +[admin] +static_root = "./dist" + +[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.staticRoot).toBe('./dist') + }) + }) + + 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..2ff054bc0bf 100644 --- a/packages/app/src/cli/models/app/loader.ts +++ b/packages/app/src/cli/models/app/loader.ts @@ -206,6 +206,10 @@ 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 + const staticRoot = appConfig.admin?.static_root return { isLaunchable, @@ -214,6 +218,9 @@ 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, + staticRoot, } } diff --git a/packages/app/src/cli/models/extensions/specifications/admin.ts b/packages/app/src/cli/models/extensions/specifications/admin.ts index f5002999372..bdd20cb2131 100644 --- a/packages/app/src/cli/models/extensions/specifications/admin.ts +++ b/packages/app/src/cli/models/extensions/specifications/admin.ts @@ -3,6 +3,8 @@ import {BaseConfigType, ZodSchemaType} from '../schemas.js' import {zod} from '@shopify/cli-kit/node/schema' import {joinPath} from '@shopify/cli-kit/node/path' +export const AdminSpecIdentifier = 'admin' + const AdminSchema = zod.object({ admin: zod .object({ diff --git a/packages/app/src/cli/models/extensions/specifications/types/app_config.ts b/packages/app/src/cli/models/extensions/specifications/types/app_config.ts index 0ba266ecb6d..08b52c516f4 100644 --- a/packages/app/src/cli/models/extensions/specifications/types/app_config.ts +++ b/packages/app/src/cli/models/extensions/specifications/types/app_config.ts @@ -28,4 +28,7 @@ export interface AppConfigurationUsedByCli { auth?: { redirect_urls: string[] } + admin?: { + static_root?: string + } } diff --git a/packages/app/src/cli/utilities/developer-platform-client.ts b/packages/app/src/cli/utilities/developer-platform-client.ts index e8d792f0de1..30376507f30 100644 --- a/packages/app/src/cli/utilities/developer-platform-client.ts +++ b/packages/app/src/cli/utilities/developer-platform-client.ts @@ -120,6 +120,9 @@ export interface CreateAppOptions { scopesArray?: string[] directory?: string isEmbedded?: boolean + applicationUrl?: string + redirectUrls?: string[] + staticRoot?: 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..a9c636c4b0f 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,140 @@ 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('includes admin module with static_root when staticRoot is 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', + staticRoot: './dist', + }) + + // Then + expect(vi.mocked(appManagementRequestDoc)).toHaveBeenCalledWith( + expect.objectContaining({ + variables: expect.objectContaining({ + initialVersion: expect.objectContaining({ + source: expect.objectContaining({ + modules: expect.arrayContaining([ + { + type: 'admin', + config: {admin: {static_root: './dist'}}, + }, + ]), + }), + }), + }), + }), + ) + }) + + test('does not include admin module when staticRoot is not 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, + }) + + // Then + expect(vi.mocked(appManagementRequestDoc)).toHaveBeenCalledWith( + expect.objectContaining({ + variables: expect.objectContaining({ + initialVersion: expect.objectContaining({ + source: expect.objectContaining({ + modules: expect.not.arrayContaining([expect.objectContaining({type: 'admin'})]), + }), + }), + }), + }), + ) + }) + 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 918d5775ccc..8ccfa480004 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 @@ -82,6 +82,7 @@ import {ListOrganizations} from '../../api/graphql/business-platform-destination import {AppHomeSpecIdentifier} from '../../models/extensions/specifications/app_config_app_home.js' import {BrandingSpecIdentifier} from '../../models/extensions/specifications/app_config_branding.js' import {AppAccessSpecIdentifier} from '../../models/extensions/specifications/app_config_app_access.js' +import {AdminSpecIdentifier} from '../../models/extensions/specifications/admin.js' import {DevSessionCreate, DevSessionCreateMutation} from '../../api/graphql/app-dev/generated/dev-session-create.js' import { @@ -1212,6 +1213,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, @@ -1219,7 +1223,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 @@ -1237,10 +1241,18 @@ 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(',')}), }, }, + ...(options.staticRoot + ? [ + { + type: AdminSpecIdentifier, + config: {admin: {static_root: options.staticRoot}}, + }, + ] + : []), ], }, } 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 bcf1c8ae1e3..6834829b4c7 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(', ')