diff --git a/.changeset/grumpy-ravens-arrive.md b/.changeset/grumpy-ravens-arrive.md new file mode 100644 index 0000000000..746384494a --- /dev/null +++ b/.changeset/grumpy-ravens-arrive.md @@ -0,0 +1,5 @@ +--- +'@shopify/app': patch +--- + +Link uses api client config as fallback when the app doesnt have an active app version or the existing one doesn't include any configuration app module diff --git a/packages/app/src/cli/api/graphql/app_active_version.ts b/packages/app/src/cli/api/graphql/app_active_version.ts index 21feddadff..801f36cfa9 100644 --- a/packages/app/src/cli/api/graphql/app_active_version.ts +++ b/packages/app/src/cli/api/graphql/app_active_version.ts @@ -48,7 +48,7 @@ export interface AppModuleVersion { export interface ActiveAppVersionQuerySchema { app: { - activeAppVersion: { + activeAppVersion?: { appModuleVersions: AppModuleVersion[] } } diff --git a/packages/app/src/cli/api/graphql/create_app.ts b/packages/app/src/cli/api/graphql/create_app.ts index e74ec33173..577368b7b8 100644 --- a/packages/app/src/cli/api/graphql/create_app.ts +++ b/packages/app/src/cli/api/graphql/create_app.ts @@ -29,6 +29,23 @@ export const CreateAppQuery = gql` } appType grantedScopes + applicationUrl + redirectUrlWhitelist + requestedAccessScopes + webhookApiVersion + embedded + posEmbedded + preferencesUrl + gdprWebhooks { + customerDeletionUrl + customerDataRequestUrl + shopDeletionUrl + } + appProxy { + subPath + subPathPrefix + url + } disabledFlags } userErrors { @@ -63,6 +80,20 @@ export interface CreateAppQuerySchema { applicationUrl: string redirectUrlWhitelist: string[] requestedAccessScopes?: string[] + webhookApiVersion: string + embedded: boolean + posEmbedded?: boolean + preferencesUrl?: string + gdprWebhooks?: { + customerDeletionUrl?: string + customerDataRequestUrl?: string + shopDeletionUrl?: string + } + appProxy?: { + subPath: string + subPathPrefix: string + url: string + } disabledFlags: string[] } userErrors: { diff --git a/packages/app/src/cli/api/graphql/find_app.ts b/packages/app/src/cli/api/graphql/find_app.ts index c03bb01290..9a083dfdbd 100644 --- a/packages/app/src/cli/api/graphql/find_app.ts +++ b/packages/app/src/cli/api/graphql/find_app.ts @@ -12,6 +12,23 @@ export const FindAppQuery = gql` } appType grantedScopes + applicationUrl + redirectUrlWhitelist + requestedAccessScopes + webhookApiVersion + embedded + posEmbedded + preferencesUrl + gdprWebhooks { + customerDeletionUrl + customerDataRequestUrl + shopDeletionUrl + } + appProxy { + subPath + subPathPrefix + url + } developmentStorePreviewEnabled disabledFlags } @@ -29,6 +46,23 @@ export interface FindAppQuerySchema { }[] appType: string grantedScopes: string[] + applicationUrl: string + redirectUrlWhitelist: string[] + requestedAccessScopes?: string[] + webhookApiVersion: string + embedded: boolean + posEmbedded?: boolean + preferencesUrl?: string + gdprWebhooks?: { + customerDeletionUrl?: string + customerDataRequestUrl?: string + shopDeletionUrl?: string + } + appProxy?: { + subPath: string + subPathPrefix: string + url: string + } developmentStorePreviewEnabled: boolean disabledFlags: string[] } diff --git a/packages/app/src/cli/models/organization.ts b/packages/app/src/cli/models/organization.ts index 0259c578fe..b7191b5574 100644 --- a/packages/app/src/cli/models/organization.ts +++ b/packages/app/src/cli/models/organization.ts @@ -25,6 +25,23 @@ export type OrganizationApp = MinimalOrganizationApp & { newApp?: boolean grantedScopes: string[] developmentStorePreviewEnabled?: boolean + applicationUrl?: string + redirectUrlWhitelist?: string[] + requestedAccessScopes?: string[] + webhookApiVersion?: string + embedded?: boolean + posEmbedded?: boolean + preferencesUrl?: string + gdprWebhooks?: { + customerDeletionUrl?: string + customerDataRequestUrl?: string + shopDeletionUrl?: string + } + appProxy?: { + subPath: string + subPathPrefix: string + url: string + } configuration?: SpecsAppConfiguration flags: Flag[] } diff --git a/packages/app/src/cli/services/app/config/link.test.ts b/packages/app/src/cli/services/app/config/link.test.ts index c4ec3348f2..adf66ec82f 100644 --- a/packages/app/src/cli/services/app/config/link.test.ts +++ b/packages/app/src/cli/services/app/config/link.test.ts @@ -84,7 +84,7 @@ describe('link', () => { }) }) - test('creates a new shopify.app.toml file when it does not exist', async () => { + test('creates a new shopify.app.toml file when it does not exist using existing app version configuration instead of the api client configuration', async () => { await inTemporaryDirectory(async (tmp) => { // Given const options: LinkOptions = { @@ -92,7 +92,30 @@ describe('link', () => { developerPlatformClient: buildDeveloperPlatformClient(), } vi.mocked(loadApp).mockRejectedValue('App not found') - vi.mocked(fetchOrCreateOrganizationApp).mockResolvedValue({...mockRemoteApp(), newApp: true}) + const apiClientConfiguration = { + title: 'new-title', + applicationUrl: 'https://api-client-config.com', + redirectUrlWhitelist: ['https://api-client-config.com/callback'], + requestedAccessScopes: ['write_products'], + webhookApiVersion: '2023-07', + embedded: false, + posEmbedded: true, + preferencesUrl: 'https://api-client-config.com/preferences', + gdprWebhooks: { + customerDeletionUrl: 'https://api-client-config.com/customer-deletion', + customerDataRequestUrl: 'https://api-client-config.com/customer-data-request', + shopDeletionUrl: 'https://api-client-config.com/shop-deletion', + }, + appProxy: { + subPath: '/api', + subPathPrefix: 'prefix', + url: 'https://api-client-config.com/proxy', + }, + } + vi.mocked(fetchOrCreateOrganizationApp).mockResolvedValue({ + ...mockRemoteApp(apiClientConfiguration), + newApp: true, + }) // When await link(options) @@ -143,6 +166,102 @@ embedded = false }) }) + test('uses the api client configuration in case there is no configuration app modules', async () => { + await inTemporaryDirectory(async (tmp) => { + // Given + const options: LinkOptions = { + directory: tmp, + developerPlatformClient: buildDeveloperPlatformClient(), + } + vi.mocked(loadApp).mockRejectedValue('App not found') + const apiClientConfiguration = { + title: 'new-title', + applicationUrl: 'https://api-client-config.com', + redirectUrlWhitelist: ['https://api-client-config.com/callback'], + requestedAccessScopes: ['write_products'], + webhookApiVersion: '2023-07', + embedded: false, + posEmbedded: true, + preferencesUrl: 'https://api-client-config.com/preferences', + gdprWebhooks: { + customerDeletionUrl: 'https://api-client-config.com/customer-deletion', + customerDataRequestUrl: 'https://api-client-config.com/customer-data-request', + shopDeletionUrl: 'https://api-client-config.com/shop-deletion', + }, + appProxy: { + subPath: '/api', + subPathPrefix: 'prefix', + url: 'https://api-client-config.com/proxy', + }, + } + vi.mocked(fetchOrCreateOrganizationApp).mockResolvedValue({ + ...mockRemoteApp(apiClientConfiguration), + newApp: true, + }) + vi.mocked(fetchAppRemoteConfiguration).mockResolvedValue(undefined) + + // When + await link(options) + + // Then + const content = await readFile(joinPath(tmp, 'shopify.app.toml')) + const expectedContent = `# Learn more about configuring your app at https://shopify.dev/docs/apps/tools/cli/configuration + +client_id = "12345" +name = "new-title" +application_url = "https://api-client-config.com" +embedded = false + +[build] +include_config_on_deploy = true + +[access_scopes] +# Learn more at https://shopify.dev/docs/apps/tools/cli/configuration#access_scopes +scopes = "write_products" + +[auth] +redirect_urls = [ "https://api-client-config.com/callback" ] + +[webhooks] +api_version = "2023-07" + + [webhooks.privacy_compliance] + customer_deletion_url = "https://api-client-config.com/customer-deletion" + customer_data_request_url = "https://api-client-config.com/customer-data-request" + shop_deletion_url = "https://api-client-config.com/shop-deletion" + +[app_proxy] +url = "https://api-client-config.com/proxy" +subpath = "/api" +prefix = "prefix" + +[pos] +embedded = true + +[app_preferences] +url = "https://api-client-config.com/preferences" +` + expect(content).toEqual(expectedContent) + expect(saveCurrentConfig).toHaveBeenCalledWith({configFileName: 'shopify.app.toml', directory: tmp}) + expect(renderSuccess).toHaveBeenCalledWith({ + headline: 'shopify.app.toml is now linked to "new-title" on Shopify', + body: 'Using shopify.app.toml as your default config.', + nextSteps: [ + [`Make updates to shopify.app.toml in your local project`], + ['To upload your config, run', {command: 'npm run shopify app deploy'}], + ], + reference: [ + { + link: { + label: 'App configuration', + url: 'https://shopify.dev/docs/apps/tools/cli/configuration', + }, + }, + ], + }) + }) + }) + test('creates a new shopify.app.staging.toml file when shopify.app.toml already linked', async () => { await inTemporaryDirectory(async (tmp) => { // Given @@ -909,8 +1028,8 @@ async function mockApp( return localApp } -function mockRemoteApp() { +function mockRemoteApp(extraRemoteAppFields: Partial = {}) { const remoteApp = testOrganizationApp() remoteApp.apiKey = '12345' - return remoteApp + return {...remoteApp, ...extraRemoteAppFields} } diff --git a/packages/app/src/cli/services/app/config/link.ts b/packages/app/src/cli/services/app/config/link.ts index 56e499b9f1..1af09036e8 100644 --- a/packages/app/src/cli/services/app/config/link.ts +++ b/packages/app/src/cli/services/app/config/link.ts @@ -3,6 +3,7 @@ import { AppConfiguration, AppInterface, EmptyApp, + getAppScopes, isCurrentAppSchema, isLegacyAppSchema, } from '../../../models/app/app.js' @@ -24,6 +25,7 @@ import {loadLocalExtensionsSpecifications} from '../../../models/extensions/load import {selectDeveloperPlatformClient, DeveloperPlatformClient} from '../../../utilities/developer-platform-client.js' import {fetchAppRemoteConfiguration} from '../select-app.js' import {fetchSpecifications} from '../../generate/fetch-extension-specifications.js' +import {SpecsAppConfiguration} from '../../../models/extensions/specifications/types/app_config.js' import {renderSuccess} from '@shopify/cli-kit/node/ui' import {AbortError} from '@shopify/cli-kit/node/error' import {formatPackageManagerCommand} from '@shopify/cli-kit/node/output' @@ -47,12 +49,13 @@ export default async function link(options: LinkOptions, shouldRenderSuccess = t await logMetadataForLoadedContext(remoteApp) let configuration = addLocalAppConfig(localApp.configuration, remoteApp, configFilePath) - const remoteAppConfiguration = await fetchAppRemoteConfiguration( - remoteApp, - developerPlatformClient, - localApp.specifications ?? [], - localApp.remoteFlags, - ) + const remoteAppConfiguration = + (await fetchAppRemoteConfiguration( + remoteApp, + developerPlatformClient, + localApp.specifications ?? [], + localApp.remoteFlags, + )) ?? buildRemoteApiClientConfiguration(configuration, remoteApp) const replaceLocalArrayStrategy = (_destinationArray: unknown[], sourceArray: unknown[]) => sourceArray configuration = deepMergeObjects( configuration, @@ -206,3 +209,107 @@ function renderSuccessMessage(configFileName: string, appName: string, localApp: ], }) } + +function buildRemoteApiClientConfiguration( + appConfiguration: AppConfiguration, + remoteApp: OrganizationApp, +): SpecsAppConfiguration { + return { + ...addBrandingConfig(remoteApp), + ...addPosConfig(remoteApp), + ...addRemoteAppWebhooksConfig(remoteApp), + ...addRemoteAppAccessConfig(appConfiguration, remoteApp), + ...addRemoteAppProxyConfig(remoteApp), + ...addRemoteAppHomeConfig(remoteApp), + } +} + +function addRemoteAppHomeConfig(remoteApp: OrganizationApp) { + const homeConfig = { + application_url: remoteApp.applicationUrl?.replace(/\/$/, '') || '', + embedded: remoteApp.embedded === undefined ? true : remoteApp.embedded, + } + return remoteApp.preferencesUrl + ? { + ...homeConfig, + app_preferences: { + url: remoteApp.preferencesUrl, + }, + } + : {...homeConfig} +} + +function addRemoteAppProxyConfig(remoteApp: OrganizationApp) { + return remoteApp.appProxy?.url + ? { + app_proxy: { + url: remoteApp.appProxy.url, + subpath: remoteApp.appProxy.subPath, + prefix: remoteApp.appProxy.subPathPrefix, + }, + } + : {} +} + +function addRemoteAppWebhooksConfig(remoteApp: OrganizationApp) { + const hasAnyPrivacyWebhook = + remoteApp.gdprWebhooks?.customerDataRequestUrl || + remoteApp.gdprWebhooks?.customerDeletionUrl || + remoteApp.gdprWebhooks?.shopDeletionUrl + + const privacyComplianceContent = { + privacy_compliance: { + customer_data_request_url: remoteApp.gdprWebhooks?.customerDataRequestUrl, + customer_deletion_url: remoteApp.gdprWebhooks?.customerDeletionUrl, + shop_deletion_url: remoteApp.gdprWebhooks?.shopDeletionUrl, + }, + } + + return { + webhooks: { + api_version: remoteApp.webhookApiVersion || '2023-07', + ...(hasAnyPrivacyWebhook ? privacyComplianceContent : {}), + }, + } +} + +function addRemoteAppAccessConfig(appConfiguration: AppConfiguration, remoteApp: OrganizationApp) { + let accessScopesContent = {} + // if we have upstream scopes, use them + if (remoteApp.requestedAccessScopes) { + accessScopesContent = { + scopes: remoteApp.requestedAccessScopes.join(','), + } + // if we can't find scopes or have to fall back, omit setting a scope and set legacy to true + } else if (getAppScopes(appConfiguration) === '') { + accessScopesContent = { + use_legacy_install_flow: true, + } + // if we have scopes locally and not upstream, preserve them but don't push them upstream (legacy is true) + } else { + accessScopesContent = { + scopes: getAppScopes(appConfiguration), + use_legacy_install_flow: true, + } + } + return { + auth: { + redirect_urls: remoteApp.redirectUrlWhitelist ?? [], + }, + access_scopes: accessScopesContent, + } +} + +function addPosConfig(remoteApp: OrganizationApp) { + return { + pos: { + embedded: remoteApp.posEmbedded || false, + }, + } +} + +function addBrandingConfig(remoteApp: OrganizationApp) { + return { + name: remoteApp.title, + } +} diff --git a/packages/app/src/cli/services/app/select-app.test.ts b/packages/app/src/cli/services/app/select-app.test.ts index e4d91f0aee..d87a82a74d 100644 --- a/packages/app/src/cli/services/app/select-app.test.ts +++ b/packages/app/src/cli/services/app/select-app.test.ts @@ -135,4 +135,22 @@ describe('fetchAppRemoteConfiguration', () => { }, }) }) + + test('when no configuration modules are present undefined is returned ', async () => { + // Given + const developerPlatformClient: DeveloperPlatformClient = testDeveloperPlatformClient({ + activeAppVersion: (_app: MinimalAppIdentifiers) => Promise.resolve(undefined), + }) + + // When + const result = await fetchAppRemoteConfiguration( + minimalOrganizationApp, + developerPlatformClient, + await configurationSpecifications(), + [], + ) + + // Then + expect(result).toBeUndefined() + }) }) diff --git a/packages/app/src/cli/services/app/select-app.ts b/packages/app/src/cli/services/app/select-app.ts index 22f43354e2..71d0afd818 100644 --- a/packages/app/src/cli/services/app/select-app.ts +++ b/packages/app/src/cli/services/app/select-app.ts @@ -29,6 +29,7 @@ export async function fetchAppRemoteConfiguration( const activeAppVersion = await developerPlatformClient.activeAppVersion(remoteApp) const appModuleVersionsConfig = activeAppVersion?.appModuleVersions.filter((module) => module.specification?.experience === 'configuration') || [] + if (appModuleVersionsConfig.length === 0) return undefined const remoteConfiguration = remoteAppConfigurationExtensionContent( appModuleVersionsConfig, specifications, diff --git a/packages/app/src/cli/services/context/breakdown-extensions.test.ts b/packages/app/src/cli/services/context/breakdown-extensions.test.ts index 22826d0da4..57d4b2b546 100644 --- a/packages/app/src/cli/services/context/breakdown-extensions.test.ts +++ b/packages/app/src/cli/services/context/breakdown-extensions.test.ts @@ -1283,6 +1283,45 @@ describe('configExtensionsIdentifiersBreakdown', () => { }) }) }) + describe('deploy with release using local configuration when there is no remote app version', () => { + test('all local configuration will be returned in the new list', async () => { + // Given + const configuration = { + path: 'shopify.app.development.toml', + name: 'my app', + client_id: '12345', + application_url: 'https://myapp.com', + embedded: true, + webhooks: { + api_version: '2023-04', + }, + build: { + include_config_on_deploy: true, + }, + } + + const developerPlatformClient: DeveloperPlatformClient = testDeveloperPlatformClient({ + activeAppVersion: (_app: MinimalAppIdentifiers) => Promise.resolve(undefined), + }) + + // When + const result = await configExtensionsIdentifiersBreakdown({ + developerPlatformClient, + apiKey: 'apiKey', + remoteApp: testOrganizationApp(), + localApp: await LOCAL_APP([], configuration), + release: true, + }) + + // Then + expect(result).toEqual({ + existingFieldNames: [], + existingUpdatedFieldNames: [], + newFieldNames: ['name', 'application_url', 'embedded', 'webhooks'], + deletedFieldNames: [], + }) + }) + }) describe('deploy not including the configuration app modules', () => { test('when the include_config_on_deploy is not true the configuration breakdown info is not returned', async () => { // Given diff --git a/packages/app/src/cli/services/context/breakdown-extensions.ts b/packages/app/src/cli/services/context/breakdown-extensions.ts index 677df5db40..f128f5a64d 100644 --- a/packages/app/src/cli/services/context/breakdown-extensions.ts +++ b/packages/app/src/cli/services/context/breakdown-extensions.ts @@ -45,13 +45,14 @@ export async function extensionsIdentifiersDeployBreakdown(options: EnsureDeploy const extensionsToConfirm = await ensureExtensionsIds(options, remoteExtensionsRegistrations.app) let extensionIdentifiersBreakdown = loadLocalExtensionsIdentifiersBreakdown(extensionsToConfirm) if (options.release) { - extensionIdentifiersBreakdown = await resolveRemoteExtensionIdentifiersBreakdown( - options.developerPlatformClient, - options.remoteApp, - extensionsToConfirm.validMatches, - extensionsToConfirm.extensionsToCreate, - extensionsToConfirm.dashboardOnlyExtensions, - ) + extensionIdentifiersBreakdown = + (await resolveRemoteExtensionIdentifiersBreakdown( + options.developerPlatformClient, + options.remoteApp, + extensionsToConfirm.validMatches, + extensionsToConfirm.extensionsToCreate, + extensionsToConfirm.dashboardOnlyExtensions, + )) ?? extensionIdentifiersBreakdown } return { extensionIdentifiersBreakdown, @@ -125,12 +126,13 @@ async function resolveRemoteConfigExtensionIdentifiersBreakdown( app: AppInterface, versionAppModules?: AppModuleVersion[], ) { - const remoteConfig = await fetchAppRemoteConfiguration( - remoteApp, - developerPlatformClient, - app.specifications ?? [], - app.remoteFlags, - ) + const remoteConfig = + (await fetchAppRemoteConfiguration( + remoteApp, + developerPlatformClient, + app.specifications ?? [], + app.remoteFlags, + )) ?? {} const baselineConfig = versionAppModules ? remoteAppConfigurationExtensionContent(versionAppModules, app.specifications ?? [], app.remoteFlags) : app.configuration @@ -259,8 +261,9 @@ async function resolveRemoteExtensionIdentifiersBreakdown( localRegistration: IdentifiersExtensions, toCreate: LocalSource[], dashboardOnly: RemoteSource[], -): Promise { +): Promise { const activeAppVersion = await developerPlatformClient.activeAppVersion(remoteApp) + if (!activeAppVersion) return const extensionIdentifiersBreakdown = loadExtensionsIdentifiersBreakdown( activeAppVersion, diff --git a/packages/app/src/cli/utilities/developer-platform-client.ts b/packages/app/src/cli/utilities/developer-platform-client.ts index b577184c4b..d6db17c492 100644 --- a/packages/app/src/cli/utilities/developer-platform-client.ts +++ b/packages/app/src/cli/utilities/developer-platform-client.ts @@ -106,7 +106,7 @@ export interface DeveloperPlatformClient { storeByDomain: (orgId: string, shopDomain: string) => Promise appExtensionRegistrations: (app: MinimalAppIdentifiers) => Promise appVersions: (appId: string) => Promise - activeAppVersion: (app: MinimalAppIdentifiers) => Promise + activeAppVersion: (app: MinimalAppIdentifiers) => Promise appVersionByTag: (input: AppVersionByTagVariables) => Promise appVersionsDiff: (input: AppVersionsDiffVariables) => Promise functionUploadUrl: () => Promise 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 417dec93b4..ab9210240f 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 @@ -299,10 +299,11 @@ export class PartnersClient implements DeveloperPlatformClient { return this.request(AppVersionsDiffQuery, input) } - async activeAppVersion({apiKey}: MinimalAppIdentifiers): Promise { + async activeAppVersion({apiKey}: MinimalAppIdentifiers): Promise { const variables: ActiveAppVersionQueryVariables = {apiKey} const result = await this.request(ActiveAppVersionQuery, variables) const version = result.app.activeAppVersion + if (!version) return return { ...version, appModuleVersions: version.appModuleVersions.map((mod) => { diff --git a/packages/app/src/cli/utilities/developer-platform-client/shopify-developers-client.ts b/packages/app/src/cli/utilities/developer-platform-client/shopify-developers-client.ts index 8cd61d4a6f..d25b1ee051 100644 --- a/packages/app/src/cli/utilities/developer-platform-client/shopify-developers-client.ts +++ b/packages/app/src/cli/utilities/developer-platform-client/shopify-developers-client.ts @@ -120,8 +120,8 @@ export class ShopifyDevelopersClient implements DeveloperPlatformClient { async appFromId(appIdentifiers: MinimalAppIdentifiers): Promise { const {app} = await this.fetchApp(appIdentifiers) const {modules} = app.activeRelease.version - const brandingModule = modules.find((mod) => mod.specification.identifier === 'branding')! - const appAccessModule = modules.find((mod) => mod.specification.identifier === 'app_access')! + const brandingModule = modules.find((mod) => mod.specification.externalIdentifier === 'branding')! + const appAccessModule = modules.find((mod) => mod.specification.externalIdentifier === 'app_access')! return { id: app.id, title: brandingModule.config.name as string,