Skip to content

Commit 77cd421

Browse files
MitchLillieclaude
andcommitted
Preserve template application_url during app creation
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 <noreply@anthropic.com>
1 parent 9dfe97c commit 77cd421

10 files changed

Lines changed: 289 additions & 27 deletions

File tree

packages/app/src/cli/models/app/app.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -432,12 +432,18 @@ export class App<
432432
}
433433

434434
creationDefaultOptions(): CreateAppOptions {
435+
const applicationUrl = this.configuration.application_url
436+
const redirectUrls = this.configuration.auth?.redirect_urls
437+
const staticRoot = this.configuration.admin?.static_root
435438
return {
436439
isLaunchable: this.appIsLaunchable(),
437440
scopesArray: getAppScopesArray(this.configuration),
438441
name: this.name,
439442
isEmbedded: this.appIsEmbedded,
440443
directory: this.directory,
444+
applicationUrl,
445+
redirectUrls,
446+
staticRoot,
441447
}
442448
}
443449

packages/app/src/cli/models/app/loader.test.ts

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3504,6 +3504,80 @@ value = true
35043504
})
35053505
})
35063506
})
3507+
3508+
test('extracts application_url from template config', async () => {
3509+
await inTemporaryDirectory(async (tmpDir) => {
3510+
const config = `
3511+
client_id = ""
3512+
name = "my-app"
3513+
application_url = "https://extensions.shopifycdn.com"
3514+
embedded = true
3515+
3516+
[access_scopes]
3517+
scopes = "write_products"
3518+
3519+
[auth]
3520+
redirect_urls = ["https://shopify.dev/apps/default-app-home/api/auth"]
3521+
`
3522+
await writeFile(joinPath(tmpDir, 'shopify.app.toml'), config)
3523+
await writeFile(joinPath(tmpDir, 'package.json'), '{}')
3524+
3525+
const result = await loadConfigForAppCreation(tmpDir, 'my-app')
3526+
3527+
expect(result).toEqual({
3528+
isLaunchable: false,
3529+
scopesArray: ['write_products'],
3530+
name: 'my-app',
3531+
directory: normalizePath(tmpDir),
3532+
isEmbedded: false,
3533+
applicationUrl: 'https://extensions.shopifycdn.com',
3534+
redirectUrls: ['https://shopify.dev/apps/default-app-home/api/auth'],
3535+
staticRoot: undefined,
3536+
})
3537+
})
3538+
})
3539+
3540+
test('extracts admin.static_root from template config', async () => {
3541+
await inTemporaryDirectory(async (tmpDir) => {
3542+
const config = `
3543+
client_id = ""
3544+
name = "my-app"
3545+
application_url = "https://extensions.shopifycdn.com"
3546+
embedded = true
3547+
3548+
[admin]
3549+
static_root = "./dist"
3550+
3551+
[access_scopes]
3552+
scopes = "write_products"
3553+
`
3554+
await writeFile(joinPath(tmpDir, 'shopify.app.toml'), config)
3555+
await writeFile(joinPath(tmpDir, 'package.json'), '{}')
3556+
3557+
const result = await loadConfigForAppCreation(tmpDir, 'my-app')
3558+
3559+
expect(result.staticRoot).toBe('./dist')
3560+
})
3561+
})
3562+
3563+
test('defaults applicationUrl and redirectUrls to undefined when not in template config', async () => {
3564+
await inTemporaryDirectory(async (tmpDir) => {
3565+
const config = `
3566+
client_id = ""
3567+
name = "my-app"
3568+
3569+
[access_scopes]
3570+
scopes = "write_products"
3571+
`
3572+
await writeFile(joinPath(tmpDir, 'shopify.app.toml'), config)
3573+
await writeFile(joinPath(tmpDir, 'package.json'), '{}')
3574+
3575+
const result = await loadConfigForAppCreation(tmpDir, 'my-app')
3576+
3577+
expect(result.applicationUrl).toBeUndefined()
3578+
expect(result.redirectUrls).toBeUndefined()
3579+
})
3580+
})
35073581
})
35083582

35093583
describe('loadOpaqueApp', () => {

packages/app/src/cli/models/app/loader.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -206,6 +206,10 @@ export async function loadConfigForAppCreation(directory: string, name: string):
206206
const isLaunchable = webs.some((web) => isWebType(web, WebType.Frontend) || isWebType(web, WebType.Backend))
207207

208208
const scopesArray = getAppScopesArray(rawConfig as CurrentAppConfiguration)
209+
const appConfig = rawConfig as CurrentAppConfiguration
210+
const applicationUrl = appConfig.application_url
211+
const redirectUrls = appConfig.auth?.redirect_urls
212+
const staticRoot = appConfig.admin?.static_root
209213

210214
return {
211215
isLaunchable,
@@ -214,6 +218,9 @@ export async function loadConfigForAppCreation(directory: string, name: string):
214218
directory: project.directory,
215219
// By default, and ONLY for `app init`, we consider the app as embedded if it is launchable.
216220
isEmbedded: isLaunchable,
221+
applicationUrl,
222+
redirectUrls,
223+
staticRoot,
217224
}
218225
}
219226

packages/app/src/cli/models/extensions/specifications/admin.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@ import {BaseConfigType, ZodSchemaType} from '../schemas.js'
33
import {zod} from '@shopify/cli-kit/node/schema'
44
import {joinPath} from '@shopify/cli-kit/node/path'
55

6+
export const AdminSpecIdentifier = 'admin'
7+
68
const AdminSchema = zod.object({
79
admin: zod
810
.object({

packages/app/src/cli/models/extensions/specifications/types/app_config.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,4 +28,7 @@ export interface AppConfigurationUsedByCli {
2828
auth?: {
2929
redirect_urls: string[]
3030
}
31+
admin?: {
32+
static_root?: string
33+
}
3134
}

packages/app/src/cli/utilities/developer-platform-client.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -120,6 +120,9 @@ export interface CreateAppOptions {
120120
scopesArray?: string[]
121121
directory?: string
122122
isEmbedded?: boolean
123+
applicationUrl?: string
124+
redirectUrls?: string[]
125+
staticRoot?: string
123126
}
124127

125128
interface AppModuleVersionSpecification {

packages/app/src/cli/utilities/developer-platform-client/app-management-client.test.ts

Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -652,6 +652,140 @@ describe('createApp', () => {
652652
expect(result).toMatchObject(expectedApp)
653653
})
654654

655+
test('uses applicationUrl and redirectUrls from options when provided', async () => {
656+
// Given
657+
const client = AppManagementClient.getInstance()
658+
const org = testOrganization()
659+
vi.mocked(webhooksRequestDoc).mockResolvedValueOnce({
660+
publicApiVersions: [{handle: '2024-07'}, {handle: '2024-10'}, {handle: '2025-01'}, {handle: 'unstable'}],
661+
})
662+
vi.mocked(appManagementRequestDoc).mockResolvedValueOnce({
663+
appCreate: {
664+
app: {id: '1', key: 'key', activeRoot: {clientCredentials: {secrets: [{key: 'secret'}]}}},
665+
userErrors: [],
666+
},
667+
})
668+
669+
// When
670+
client.token = () => Promise.resolve('token')
671+
await client.createApp(org, {
672+
name: 'app-name',
673+
isLaunchable: false,
674+
applicationUrl: 'https://extensions.shopifycdn.com',
675+
redirectUrls: ['https://shopify.dev/apps/default-app-home/api/auth'],
676+
})
677+
678+
// Then
679+
expect(vi.mocked(appManagementRequestDoc)).toHaveBeenCalledWith({
680+
query: CreateApp,
681+
token: 'token',
682+
variables: {
683+
organizationId: 'gid://shopify/Organization/1',
684+
initialVersion: {
685+
source: {
686+
name: 'app-name',
687+
modules: expect.arrayContaining([
688+
{
689+
type: 'app_home',
690+
config: {
691+
app_url: 'https://extensions.shopifycdn.com',
692+
embedded: true,
693+
},
694+
},
695+
{
696+
type: 'app_access',
697+
config: {
698+
redirect_url_allowlist: ['https://shopify.dev/apps/default-app-home/api/auth'],
699+
},
700+
},
701+
]),
702+
},
703+
},
704+
},
705+
unauthorizedHandler: {
706+
handler: expect.any(Function),
707+
type: 'token_refresh',
708+
},
709+
})
710+
})
711+
712+
test('includes admin module with static_root when staticRoot is provided', async () => {
713+
// Given
714+
const client = AppManagementClient.getInstance()
715+
const org = testOrganization()
716+
vi.mocked(webhooksRequestDoc).mockResolvedValueOnce({
717+
publicApiVersions: [{handle: '2024-07'}, {handle: '2024-10'}, {handle: '2025-01'}, {handle: 'unstable'}],
718+
})
719+
vi.mocked(appManagementRequestDoc).mockResolvedValueOnce({
720+
appCreate: {
721+
app: {id: '1', key: 'key', activeRoot: {clientCredentials: {secrets: [{key: 'secret'}]}}},
722+
userErrors: [],
723+
},
724+
})
725+
726+
// When
727+
client.token = () => Promise.resolve('token')
728+
await client.createApp(org, {
729+
name: 'app-name',
730+
isLaunchable: false,
731+
applicationUrl: 'https://extensions.shopifycdn.com',
732+
staticRoot: './dist',
733+
})
734+
735+
// Then
736+
expect(vi.mocked(appManagementRequestDoc)).toHaveBeenCalledWith(
737+
expect.objectContaining({
738+
variables: expect.objectContaining({
739+
initialVersion: expect.objectContaining({
740+
source: expect.objectContaining({
741+
modules: expect.arrayContaining([
742+
{
743+
type: 'admin',
744+
config: {admin: {static_root: './dist'}},
745+
},
746+
]),
747+
}),
748+
}),
749+
}),
750+
}),
751+
)
752+
})
753+
754+
test('does not include admin module when staticRoot is not provided', async () => {
755+
// Given
756+
const client = AppManagementClient.getInstance()
757+
const org = testOrganization()
758+
vi.mocked(webhooksRequestDoc).mockResolvedValueOnce({
759+
publicApiVersions: [{handle: '2024-07'}, {handle: '2024-10'}, {handle: '2025-01'}, {handle: 'unstable'}],
760+
})
761+
vi.mocked(appManagementRequestDoc).mockResolvedValueOnce({
762+
appCreate: {
763+
app: {id: '1', key: 'key', activeRoot: {clientCredentials: {secrets: [{key: 'secret'}]}}},
764+
userErrors: [],
765+
},
766+
})
767+
768+
// When
769+
client.token = () => Promise.resolve('token')
770+
await client.createApp(org, {
771+
name: 'app-name',
772+
isLaunchable: false,
773+
})
774+
775+
// Then
776+
expect(vi.mocked(appManagementRequestDoc)).toHaveBeenCalledWith(
777+
expect.objectContaining({
778+
variables: expect.objectContaining({
779+
initialVersion: expect.objectContaining({
780+
source: expect.objectContaining({
781+
modules: expect.not.arrayContaining([expect.objectContaining({type: 'admin'})]),
782+
}),
783+
}),
784+
}),
785+
}),
786+
)
787+
})
788+
655789
test('sets embedded to true in app home module', async () => {
656790
// Given
657791
const client = AppManagementClient.getInstance()

packages/app/src/cli/utilities/developer-platform-client/app-management-client.ts

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,7 @@ import {ListOrganizations} from '../../api/graphql/business-platform-destination
8282
import {AppHomeSpecIdentifier} from '../../models/extensions/specifications/app_config_app_home.js'
8383
import {BrandingSpecIdentifier} from '../../models/extensions/specifications/app_config_branding.js'
8484
import {AppAccessSpecIdentifier} from '../../models/extensions/specifications/app_config_app_access.js'
85+
import {AdminSpecIdentifier} from '../../models/extensions/specifications/admin.js'
8586

8687
import {DevSessionCreate, DevSessionCreateMutation} from '../../api/graphql/app-dev/generated/dev-session-create.js'
8788
import {
@@ -1212,14 +1213,17 @@ function createAppVars(
12121213
apiVersion: string,
12131214
): CreateAppMutationVariables {
12141215
const {isLaunchable, scopesArray, name} = options
1216+
const defaultAppUrl = isLaunchable ? 'https://example.com' : MAGIC_URL
1217+
const defaultRedirectUrl = isLaunchable ? 'https://example.com/api/auth' : MAGIC_REDIRECT_URL
1218+
12151219
const source: AppVersionSource = {
12161220
source: {
12171221
name,
12181222
modules: [
12191223
{
12201224
type: AppHomeSpecIdentifier,
12211225
config: {
1222-
app_url: isLaunchable ? 'https://example.com' : MAGIC_URL,
1226+
app_url: options.applicationUrl ?? defaultAppUrl,
12231227
// Ext-only apps should be embedded = false, however we are hardcoding this to
12241228
// match Partners behaviour for now
12251229
// https://github.com/Shopify/develop-app-inner-loop/issues/2789
@@ -1237,10 +1241,18 @@ function createAppVars(
12371241
{
12381242
type: AppAccessSpecIdentifier,
12391243
config: {
1240-
redirect_url_allowlist: isLaunchable ? ['https://example.com/api/auth'] : [MAGIC_REDIRECT_URL],
1244+
redirect_url_allowlist: options.redirectUrls ?? [defaultRedirectUrl],
12411245
...(scopesArray && {scopes: scopesArray.map((scope) => scope.trim()).join(',')}),
12421246
},
12431247
},
1248+
...(options.staticRoot
1249+
? [
1250+
{
1251+
type: AdminSpecIdentifier,
1252+
config: {admin: {static_root: options.staticRoot}},
1253+
},
1254+
]
1255+
: []),
12441256
],
12451257
},
12461258
}

packages/app/src/cli/utilities/developer-platform-client/partners-client.test.ts

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -152,6 +152,39 @@ describe('createApp', () => {
152152
})
153153
})
154154

155+
test('uses applicationUrl and redirectUrls from options when provided', async () => {
156+
// Given
157+
const partnersClient = PartnersClient.getInstance(testPartnersUserSession)
158+
vi.mocked(appNamePrompt).mockResolvedValue('app-name')
159+
vi.mocked(partnersRequest).mockResolvedValueOnce({appCreate: {app: APP1, userErrors: []}})
160+
const variables = {
161+
org: 1,
162+
title: LOCAL_APP.name,
163+
appUrl: 'https://extensions.shopifycdn.com',
164+
redir: ['https://shopify.dev/apps/default-app-home/api/auth'],
165+
requestedAccessScopes: ['write_products'],
166+
type: 'undecided',
167+
}
168+
169+
// When
170+
await partnersClient.createApp(
171+
{...ORG1, source: OrganizationSource.Partners},
172+
{
173+
name: LOCAL_APP.name,
174+
isLaunchable: false,
175+
scopesArray: ['write_products'],
176+
applicationUrl: 'https://extensions.shopifycdn.com',
177+
redirectUrls: ['https://shopify.dev/apps/default-app-home/api/auth'],
178+
},
179+
)
180+
181+
// Then
182+
expect(partnersRequest).toHaveBeenCalledWith(CreateAppQuery, 'token', variables, undefined, undefined, {
183+
type: 'token_refresh',
184+
handler: expect.any(Function),
185+
})
186+
})
187+
155188
test('throws error if requests has a user error', async () => {
156189
// Given
157190
const partnersClient = PartnersClient.getInstance(testPartnersUserSession)

0 commit comments

Comments
 (0)