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(', ')