diff --git a/packages/app/src/cli/services/app/patch-app-configuration-file.test.ts b/packages/app/src/cli/services/app/patch-app-configuration-file.test.ts new file mode 100644 index 0000000000..87d0eedabf --- /dev/null +++ b/packages/app/src/cli/services/app/patch-app-configuration-file.test.ts @@ -0,0 +1,174 @@ +import {patchAppConfigurationFile} from './patch-app-configuration-file.js' +import {getAppVersionedSchema} from '../../models/app/app.js' +import {loadLocalExtensionsSpecifications} from '../../models/extensions/load-specifications.js' +import {readFile, writeFileSync, inTemporaryDirectory} from '@shopify/cli-kit/node/fs' +import {joinPath} from '@shopify/cli-kit/node/path' +import {describe, expect, test} from 'vitest' + +const defaultToml = `# Learn more about configuring your app at https://shopify.dev/docs/apps/tools/cli/configuration +client_id = "12345" +name = "app1" +embedded = true + +[access_scopes] +# Learn more at https://shopify.dev/docs/apps/tools/cli/configuration#access_scopes +use_legacy_install_flow = true + +[auth] +redirect_urls = [ + "https://example.com/redirect", + "https://example.com/redirect2" +] + +[webhooks] +api_version = "2023-04" +` + +const schema = getAppVersionedSchema(await loadLocalExtensionsSpecifications(), false) + +function writeDefaulToml(tmpDir: string) { + const configPath = joinPath(tmpDir, 'shopify.app.toml') + writeFileSync(configPath, defaultToml) + return configPath +} + +describe('patchAppConfigurationFile', () => { + test('updates existing configuration with new values and adds new top-levelfields', async () => { + await inTemporaryDirectory(async (tmpDir) => { + const configPath = writeDefaulToml(tmpDir) + const patch = { + name: 'Updated App Name', + application_url: 'https://example.com', + access_scopes: { + use_legacy_install_flow: false, + }, + } + + await patchAppConfigurationFile({path: configPath, patch, schema}) + + const updatedTomlFile = await readFile(configPath) + expect(updatedTomlFile) + .toEqual(`# Learn more about configuring your app at https://shopify.dev/docs/apps/tools/cli/configuration + +client_id = "12345" +name = "Updated App Name" +application_url = "https://example.com" +embedded = true + +[access_scopes] +# Learn more at https://shopify.dev/docs/apps/tools/cli/configuration#access_scopes +use_legacy_install_flow = false + +[auth] +redirect_urls = [ + "https://example.com/redirect", + "https://example.com/redirect2" +] + +[webhooks] +api_version = "2023-04" +`) + }) + }) + + test('Adds new table to the toml file', async () => { + await inTemporaryDirectory(async (tmpDir) => { + const configPath = writeDefaulToml(tmpDir) + const patch = { + application_url: 'https://example.com', + build: { + dev_store_url: 'example.myshopify.com', + }, + } + + await patchAppConfigurationFile({path: configPath, patch, schema}) + + const updatedTomlFile = await readFile(configPath) + expect(updatedTomlFile) + .toEqual(`# Learn more about configuring your app at https://shopify.dev/docs/apps/tools/cli/configuration + +client_id = "12345" +name = "app1" +application_url = "https://example.com" +embedded = true + +[build] +dev_store_url = "example.myshopify.com" + +[access_scopes] +# Learn more at https://shopify.dev/docs/apps/tools/cli/configuration#access_scopes +use_legacy_install_flow = true + +[auth] +redirect_urls = [ + "https://example.com/redirect", + "https://example.com/redirect2" +] + +[webhooks] +api_version = "2023-04" +`) + }) + }) + + test('Adds a new field to a toml table, merging with exsisting values', async () => { + await inTemporaryDirectory(async (tmpDir) => { + const configPath = writeDefaulToml(tmpDir) + const patch = { + application_url: 'https://example.com', + access_scopes: { + scopes: 'read_products', + }, + } + + await patchAppConfigurationFile({path: configPath, patch, schema}) + + const updatedTomlFile = await readFile(configPath) + expect(updatedTomlFile) + .toEqual(`# Learn more about configuring your app at https://shopify.dev/docs/apps/tools/cli/configuration + +client_id = "12345" +name = "app1" +application_url = "https://example.com" +embedded = true + +[access_scopes] +# Learn more at https://shopify.dev/docs/apps/tools/cli/configuration#access_scopes +scopes = "read_products" +use_legacy_install_flow = true + +[auth] +redirect_urls = [ + "https://example.com/redirect", + "https://example.com/redirect2" +] + +[webhooks] +api_version = "2023-04" +`) + }) + }) + + test('does not validate the toml if no schema is provided', async () => { + await inTemporaryDirectory(async (tmpDir) => { + const configPath = joinPath(tmpDir, 'shopify.app.toml') + writeFileSync( + configPath, + ` +random_toml_field = "random_value" +`, + ) + const patch = {name: 123} + + await patchAppConfigurationFile({path: configPath, patch, schema: undefined}) + + const updatedTomlFile = await readFile(configPath) + expect(updatedTomlFile) + .toEqual(`# Learn more about configuring your app at https://shopify.dev/docs/apps/tools/cli/configuration + +random_toml_field = "random_value" +name = 123 +`) + }) + }) +}) diff --git a/packages/app/src/cli/services/app/patch-app-configuration-file.ts b/packages/app/src/cli/services/app/patch-app-configuration-file.ts new file mode 100644 index 0000000000..5bb533407b --- /dev/null +++ b/packages/app/src/cli/services/app/patch-app-configuration-file.ts @@ -0,0 +1,35 @@ +import {addDefaultCommentsToToml} from './write-app-configuration-file.js' +import {deepMergeObjects} from '@shopify/cli-kit/common/object' +import {readFile, writeFile} from '@shopify/cli-kit/node/fs' +import {zod} from '@shopify/cli-kit/node/schema' +import {decodeToml, encodeToml} from '@shopify/cli-kit/node/toml' + +export interface PatchTomlOptions { + path: string + patch: {[key: string]: unknown} + schema?: zod.AnyZodObject +} + +/** + * Updates an app/extension configuration file with the given patch. + * + * Only updates the given fields in the patch and leaves the rest of the file unchanged. + * + * @param path - The path to the app/extension configuration file. + * @param patch - The patch to apply to the app/extension configuration file. + * @param schema - The schema to validate the patch against. If not provided, the toml will not be validated. + */ +export async function patchAppConfigurationFile({path, patch, schema}: PatchTomlOptions) { + const tomlContents = await readFile(path) + const configuration = decodeToml(tomlContents) + const updatedConfig = deepMergeObjects(configuration, patch) + + // Re-parse the config with the schema to validate the patch and keep the same order in the file + // Make every field optional to not crash on invalid tomls that are missing fields. + const validSchema = schema ?? zod.object({}).passthrough() + const validatedConfig = validSchema.partial().parse(updatedConfig) + let encodedString = encodeToml(validatedConfig) + + encodedString = addDefaultCommentsToToml(encodedString) + await writeFile(path, encodedString) +} diff --git a/packages/app/src/cli/services/app/write-app-configuration-file.ts b/packages/app/src/cli/services/app/write-app-configuration-file.ts index 5f4cc9c376..7d5b3f848b 100644 --- a/packages/app/src/cli/services/app/write-app-configuration-file.ts +++ b/packages/app/src/cli/services/app/write-app-configuration-file.ts @@ -10,8 +10,6 @@ import {outputDebug} from '@shopify/cli-kit/node/output' // so for now, we manually add comments export async function writeAppConfigurationFile(configuration: CurrentAppConfiguration, schema: zod.ZodTypeAny) { outputDebug(`Writing app configuration to ${configuration.path}`) - const initialComment = `# Learn more about configuring your app at https://shopify.dev/docs/apps/tools/cli/configuration\n` - const scopesComment = `\n# Learn more at https://shopify.dev/docs/apps/tools/cli/configuration#access_scopes` // we need to condense the compliance and non-compliance webhooks again // so compliance topics and topics with the same uri are under @@ -21,18 +19,10 @@ export async function writeAppConfigurationFile(configuration: CurrentAppConfigu const sorted = rewriteConfiguration(schema, condensedWebhooksAppConfiguration) as { [key: string]: string | boolean | object } - const fileSplit = encodeToml(sorted as JsonMapType).split(/(\r\n|\r|\n)/) - fileSplit.unshift('\n') - fileSplit.unshift(initialComment) - - fileSplit.forEach((line, index) => { - if (line === '[access_scopes]') { - fileSplit.splice(index + 1, 0, scopesComment) - } - }) + const encodedString = encodeToml(sorted as JsonMapType) - const file = fileSplit.join('') + const file = addDefaultCommentsToToml(encodedString) writeFileSync(configuration.path, file) } @@ -89,6 +79,23 @@ export const rewriteConfiguration = (schema: T, config return config } +export function addDefaultCommentsToToml(fileString: string) { + const appTomlInitialComment = `# Learn more about configuring your app at https://shopify.dev/docs/apps/tools/cli/configuration\n` + const appTomlScopesComment = `\n# Learn more at https://shopify.dev/docs/apps/tools/cli/configuration#access_scopes` + + const fileSplit = fileString.split(/(\r\n|\r|\n)/) + fileSplit.unshift('\n') + fileSplit.unshift(appTomlInitialComment) + + fileSplit.forEach((line, index) => { + if (line === '[access_scopes]') { + fileSplit.splice(index + 1, 0, appTomlScopesComment) + } + }) + + return fileSplit.join('') +} + /** * When we merge webhooks, we have the privacy and non-privacy compliance subscriptions * separated for matching remote/local config purposes, diff --git a/packages/app/src/cli/services/context.test.ts b/packages/app/src/cli/services/context.test.ts index 76ec015783..67be4886a7 100644 --- a/packages/app/src/cli/services/context.test.ts +++ b/packages/app/src/cli/services/context.test.ts @@ -7,7 +7,7 @@ import {createExtension} from './dev/create-extension.js' import {CachedAppInfo, clearCachedAppInfo, getCachedAppInfo, setCachedAppInfo} from './local-storage.js' import link from './app/config/link.js' import {fetchSpecifications} from './generate/fetch-extension-specifications.js' -import * as writeAppConfigurationFile from './app/write-app-configuration-file.js' +import * as patchAppConfigurationFile from './app/patch-app-configuration-file.js' import {DeployOptions} from './deploy.js' import { MinimalAppIdentifiers, @@ -241,6 +241,9 @@ describe('ensureDevContext', async () => { // Given vi.mocked(selectDeveloperPlatformClient).mockReturnValue(buildDeveloperPlatformClient()) vi.mocked(getCachedAppInfo).mockReturnValue(CACHED1_WITH_CONFIG) + const patchAppConfigurationFileSpy = vi + .spyOn(patchAppConfigurationFile, 'patchAppConfigurationFile') + .mockResolvedValue() vi.mocked(loadAppConfiguration).mockReset() const {schema: configSchema} = await buildVersionedAppSchema() const localApp = { @@ -287,6 +290,7 @@ describe('ensureDevContext', async () => { api_key: APP2.apiKey, partner_id: 1, }) + patchAppConfigurationFileSpy.mockRestore() }) }) @@ -363,6 +367,7 @@ dev_store_url = "domain1" test('loads the correct file when config flag is passed in', async () => { await inTemporaryDirectory(async (tmp) => { // Given + writeFileSync(joinPath(tmp, 'shopify.app.dev.toml'), '') vi.mocked(getCachedAppInfo).mockReturnValue(undefined) vi.mocked(loadAppConfiguration).mockReset() const localApp = { @@ -403,7 +408,10 @@ dev_store_url = "domain1" await inTemporaryDirectory(async (tmp) => { // Given const filePath = joinPath(tmp, 'shopify.app.dev.toml') - writeFileSync(filePath, '') + const tomlContent = `# Learn more about configuring your app at https://shopify.dev/docs/apps/tools/cli/configuration +client_id = "12345" +` + writeFileSync(filePath, tomlContent) vi.mocked(loadAppConfiguration).mockReset() const {schema: configSchema} = await buildVersionedAppSchema() const localApp = { @@ -434,20 +442,10 @@ dev_store_url = "domain1" const content = await readFile(joinPath(tmp, 'shopify.app.dev.toml')) const expectedContent = `# Learn more about configuring your app at https://shopify.dev/docs/apps/tools/cli/configuration -client_id = "key2" -name = "my app" -application_url = "https://myapp.com" -embedded = true +client_id = "12345" [build] dev_store_url = "domain1" - -[access_scopes] -# Learn more at https://shopify.dev/docs/apps/tools/cli/configuration#access_scopes -scopes = "read_products" - -[webhooks] -api_version = "2023-04" ` expect(content).toEqual(expectedContent) }) @@ -456,6 +454,7 @@ api_version = "2023-04" test('shows the correct banner content when running for the first time with linked config file', async () => { await inTemporaryDirectory(async (tmp) => { // Given + writeFileSync(joinPath(tmp, 'shopify.app.toml'), '') vi.mocked(getCachedAppInfo).mockReturnValue(undefined) vi.mocked(loadAppConfiguration).mockReset() const {schema: configSchema} = await buildVersionedAppSchema() @@ -718,6 +717,7 @@ api_version = "2023-04" test('reset triggers link if opted into config in code', async () => { await inTemporaryDirectory(async (tmp) => { // Given + writeFileSync(joinPath(tmp, 'shopify.app.dev.toml'), '') vi.mocked(getCachedAppInfo).mockReturnValueOnce(CACHED1_WITH_CONFIG) const filePath = joinPath(tmp, 'shopify.app.dev.toml') const localApp = { @@ -796,6 +796,7 @@ api_version = "2023-04" test('links app if no app configs exist & cache has a current config file defined', async () => { await inTemporaryDirectory(async (tmp) => { // Given + writeFileSync(joinPath(tmp, 'shopify.app.toml'), '') vi.mocked(getCachedAppInfo).mockReturnValueOnce(CACHED1_WITH_CONFIG) const filePath = joinPath(tmp, 'shopify.app.toml') const {schema: configSchema} = await buildVersionedAppSchema() @@ -846,8 +847,8 @@ describe('ensureDeployContext', () => { vi.mocked(renderConfirmationPrompt).mockResolvedValue(true) vi.mocked(getAppConfigurationFileName).mockReturnValue('shopify.app.toml') - const writeAppConfigurationFileSpy = vi - .spyOn(writeAppConfigurationFile, 'writeAppConfigurationFile') + const patchAppConfigurationFileSpy = vi + .spyOn(patchAppConfigurationFile, 'patchAppConfigurationFile') .mockResolvedValue() const metadataSpyOn = vi.spyOn(metadata, 'addPublicMetadata').mockImplementation(async () => {}) @@ -859,9 +860,14 @@ describe('ensureDeployContext', () => { expect(metadataSpyOn.mock.calls[0]![0]()).toEqual({cmd_deploy_confirm_include_config_used: true}) expect(renderConfirmationPrompt).toHaveBeenCalled() - expect(writeAppConfigurationFileSpy).toHaveBeenCalledWith( - {...app.configuration, build: {include_config_on_deploy: true}}, - app.configSchema, + expect(patchAppConfigurationFileSpy).toHaveBeenCalledWith( + expect.objectContaining({ + path: app.configuration.path, + patch: { + build: {include_config_on_deploy: true}, + }, + schema: expect.any(Object), + }), ) expect(renderInfo).toHaveBeenCalledWith({ body: [ @@ -879,7 +885,7 @@ describe('ensureDeployContext', () => { ], headline: 'Using shopify.app.toml for default values:', }) - writeAppConfigurationFileSpy.mockRestore() + patchAppConfigurationFileSpy.mockRestore() }) test('prompts the user to include the configuration and set it to false when not confirmed if the flag is not present', async () => { @@ -894,8 +900,8 @@ describe('ensureDeployContext', () => { vi.mocked(ensureDeploymentIdsPresence).mockResolvedValue(identifiers) vi.mocked(renderConfirmationPrompt).mockResolvedValue(false) vi.mocked(getAppConfigurationFileName).mockReturnValue('shopify.app.toml') - const writeAppConfigurationFileSpy = vi - .spyOn(writeAppConfigurationFile, 'writeAppConfigurationFile') + const patchAppConfigurationFileSpy = vi + .spyOn(patchAppConfigurationFile, 'patchAppConfigurationFile') .mockResolvedValue() // When @@ -903,9 +909,14 @@ describe('ensureDeployContext', () => { // Then expect(renderConfirmationPrompt).toHaveBeenCalled() - expect(writeAppConfigurationFileSpy).toHaveBeenCalledWith( - {...app.configuration, build: {include_config_on_deploy: false}}, - app.configSchema, + expect(patchAppConfigurationFileSpy).toHaveBeenCalledWith( + expect.objectContaining({ + path: app.configuration.path, + patch: { + build: {include_config_on_deploy: false}, + }, + schema: expect.any(Object), + }), ) expect(renderInfo).toHaveBeenCalledWith({ body: [ @@ -923,7 +934,7 @@ describe('ensureDeployContext', () => { ], headline: 'Using shopify.app.toml for default values:', }) - writeAppConfigurationFileSpy.mockRestore() + patchAppConfigurationFileSpy.mockRestore() }) test('doesnt prompt the user to include the configuration and display the current value if the flag', async () => { @@ -941,8 +952,8 @@ describe('ensureDeployContext', () => { vi.mocked(link).mockResolvedValue((app as any).configuration) // vi.mocked(selectDeveloperPlatformClient).mockReturnValue(testDeveloperPlatformClient) vi.mocked(getAppConfigurationFileName).mockReturnValue('shopify.app.toml') - const writeAppConfigurationFileSpy = vi - .spyOn(writeAppConfigurationFile, 'writeAppConfigurationFile') + const patchAppConfigurationFileSpy = vi + .spyOn(patchAppConfigurationFile, 'patchAppConfigurationFile') .mockResolvedValue() const metadataSpyOn = vi.spyOn(metadata, 'addPublicMetadata').mockImplementation(async () => {}) @@ -956,7 +967,7 @@ describe('ensureDeployContext', () => { expect(metadataSpyOn).not.toHaveBeenCalled() expect(renderConfirmationPrompt).not.toHaveBeenCalled() - expect(writeAppConfigurationFileSpy).not.toHaveBeenCalled() + expect(patchAppConfigurationFileSpy).not.toHaveBeenCalled() expect(renderInfo).toHaveBeenCalledWith({ body: [ { @@ -973,7 +984,7 @@ describe('ensureDeployContext', () => { ], headline: 'Using shopify.app.toml for default values:', }) - writeAppConfigurationFileSpy.mockRestore() + patchAppConfigurationFileSpy.mockRestore() }) test('prompts the user to include the configuration when reset is used if the flag', async () => { @@ -988,8 +999,8 @@ describe('ensureDeployContext', () => { vi.mocked(ensureDeploymentIdsPresence).mockResolvedValue(identifiers) vi.mocked(renderConfirmationPrompt).mockResolvedValue(false) vi.mocked(getAppConfigurationFileName).mockReturnValue('shopify.app.toml') - const writeAppConfigurationFileSpy = vi - .spyOn(writeAppConfigurationFile, 'writeAppConfigurationFile') + const patchAppConfigurationFileSpy = vi + .spyOn(patchAppConfigurationFile, 'patchAppConfigurationFile') .mockResolvedValue() const metadataSpyOn = vi.spyOn(metadata, 'addPublicMetadata').mockImplementation(async () => {}) @@ -1003,10 +1014,16 @@ describe('ensureDeployContext', () => { expect(metadataSpyOn.mock.calls[0]![0]()).toEqual({cmd_deploy_confirm_include_config_used: false}) expect(renderConfirmationPrompt).toHaveBeenCalled() - expect(writeAppConfigurationFileSpy).toHaveBeenCalledWith( - {...app.configuration, build: {include_config_on_deploy: false}}, - app.configSchema, + expect(patchAppConfigurationFileSpy).toHaveBeenCalledWith( + expect.objectContaining({ + path: app.configuration.path, + patch: { + build: {include_config_on_deploy: false}, + }, + schema: expect.any(Object), + }), ) + expect(renderInfo).toHaveBeenCalledWith({ body: [ { @@ -1023,7 +1040,7 @@ describe('ensureDeployContext', () => { ], headline: 'Using shopify.app.toml for default values:', }) - writeAppConfigurationFileSpy.mockRestore() + patchAppConfigurationFileSpy.mockRestore() }) test('doesnt prompt the user to include the configuration when force is used if the flag is not present', async () => { @@ -1038,8 +1055,8 @@ describe('ensureDeployContext', () => { vi.mocked(ensureDeploymentIdsPresence).mockResolvedValue(identifiers) vi.mocked(renderConfirmationPrompt).mockResolvedValue(false) vi.mocked(getAppConfigurationFileName).mockReturnValue('shopify.app.toml') - const writeAppConfigurationFileSpy = vi - .spyOn(writeAppConfigurationFile, 'writeAppConfigurationFile') + const patchAppConfigurationFileSpy = vi + .spyOn(patchAppConfigurationFile, 'patchAppConfigurationFile') .mockResolvedValue() const options = deployOptions(app, false, true) @@ -1050,7 +1067,7 @@ describe('ensureDeployContext', () => { // Then expect(renderConfirmationPrompt).not.toHaveBeenCalled() - expect(writeAppConfigurationFileSpy).not.toHaveBeenCalled() + expect(patchAppConfigurationFileSpy).not.toHaveBeenCalled() expect(renderInfo).toHaveBeenCalledWith({ body: [ { @@ -1067,7 +1084,7 @@ describe('ensureDeployContext', () => { ], headline: 'Using shopify.app.toml for default values:', }) - writeAppConfigurationFileSpy.mockRestore() + patchAppConfigurationFileSpy.mockRestore() }) test('prompt the user to include the configuration when force is used if the flag', async () => { @@ -1082,8 +1099,8 @@ describe('ensureDeployContext', () => { vi.mocked(ensureDeploymentIdsPresence).mockResolvedValue(identifiers) vi.mocked(renderConfirmationPrompt).mockResolvedValue(false) vi.mocked(getAppConfigurationFileName).mockReturnValue('shopify.app.toml') - const writeAppConfigurationFileSpy = vi - .spyOn(writeAppConfigurationFile, 'writeAppConfigurationFile') + const patchAppConfigurationFileSpy = vi + .spyOn(patchAppConfigurationFile, 'patchAppConfigurationFile') .mockResolvedValue() // When @@ -1091,7 +1108,7 @@ describe('ensureDeployContext', () => { // Then expect(renderConfirmationPrompt).not.toHaveBeenCalled() - expect(writeAppConfigurationFileSpy).not.toHaveBeenCalled() + expect(patchAppConfigurationFileSpy).not.toHaveBeenCalled() expect(renderInfo).toHaveBeenCalledWith({ body: [ { @@ -1108,7 +1125,7 @@ describe('ensureDeployContext', () => { ], headline: 'Using shopify.app.toml for default values:', }) - writeAppConfigurationFileSpy.mockRestore() + patchAppConfigurationFileSpy.mockRestore() }) }) diff --git a/packages/app/src/cli/services/context.ts b/packages/app/src/cli/services/context.ts index 3fe5217f9b..d410ae31b1 100644 --- a/packages/app/src/cli/services/context.ts +++ b/packages/app/src/cli/services/context.ts @@ -6,9 +6,9 @@ import {ensureDeploymentIdsPresence} from './context/identifiers.js' import {createExtension} from './dev/create-extension.js' import {CachedAppInfo, clearCachedAppInfo, getCachedAppInfo, setCachedAppInfo} from './local-storage.js' import link from './app/config/link.js' -import {writeAppConfigurationFile} from './app/write-app-configuration-file.js' import {fetchAppRemoteConfiguration} from './app/select-app.js' import {fetchSpecifications} from './generate/fetch-extension-specifications.js' +import {patchAppConfigurationFile} from './app/patch-app-configuration-file.js' import {DeployOptions} from './deploy.js' import {reuseDevConfigPrompt, selectOrganizationPrompt} from '../prompts/dev.js' import { @@ -165,7 +165,9 @@ export async function ensureDevContext(options: DevContextOptions): Promise ({cmd_deploy_confirm_include_config_used: shouldIncludeConfigDeploy})) } diff --git a/packages/app/src/cli/services/dev/urls.test.ts b/packages/app/src/cli/services/dev/urls.test.ts index 562acc805e..b0874807f2 100644 --- a/packages/app/src/cli/services/dev/urls.test.ts +++ b/packages/app/src/cli/services/dev/urls.test.ts @@ -15,7 +15,7 @@ import { } from '../../models/app/app.test-data.js' import {UpdateURLsVariables} from '../../api/graphql/update_urls.js' import {setCachedAppInfo} from '../local-storage.js' -import {writeAppConfigurationFile} from '../app/write-app-configuration-file.js' +import {patchAppConfigurationFile} from '../app/patch-app-configuration-file.js' import {beforeEach, describe, expect, vi, test} from 'vitest' import {AbortError} from '@shopify/cli-kit/node/error' import {checkPortAvailability, getAvailableTCPPort} from '@shopify/cli-kit/node/tcp' @@ -25,7 +25,7 @@ import {renderConfirmationPrompt, renderSelectPrompt} from '@shopify/cli-kit/nod import {terminalSupportsPrompting} from '@shopify/cli-kit/node/system' vi.mock('../local-storage.js') -vi.mock('../app/write-app-configuration-file.js') +vi.mock('../app/patch-app-configuration-file.js') vi.mock('@shopify/cli-kit/node/tcp') vi.mock('@shopify/cli-kit/node/context/spin') vi.mock('@shopify/cli-kit/node/context/local') @@ -91,28 +91,21 @@ describe('updateURLs', () => { await updateURLs(urls, apiKey, testDeveloperPlatformClient(), appWithConfig) // Then - expect(writeAppConfigurationFile).toHaveBeenCalledWith( - { + expect(patchAppConfigurationFile).toHaveBeenCalledWith( + expect.objectContaining({ path: appWithConfig.configuration.path, - access_scopes: { - scopes: 'read_products', + patch: { + application_url: 'https://example.com', + auth: { + redirect_urls: [ + 'https://example.com/auth/callback', + 'https://example.com/auth/shopify/callback', + 'https://example.com/api/auth/callback', + ], + }, }, - application_url: 'https://example.com', - auth: { - redirect_urls: [ - 'https://example.com/auth/callback', - 'https://example.com/auth/shopify/callback', - 'https://example.com/api/auth/callback', - ], - }, - client_id: 'api-key', - embedded: true, - name: 'my app', - webhooks: { - api_version: '2023-04', - }, - }, - appWithConfig.configSchema, + schema: expect.any(Object), + }), ) }) @@ -186,33 +179,26 @@ describe('updateURLs', () => { await updateURLs(urls, apiKey, testDeveloperPlatformClient(), appWithConfig) // Then - expect(writeAppConfigurationFile).toHaveBeenCalledWith( - { + expect(patchAppConfigurationFile).toHaveBeenCalledWith( + expect.objectContaining({ path: appWithConfig.configuration.path, - access_scopes: { - scopes: 'read_products', - }, - application_url: 'https://example.com', - auth: { - redirect_urls: [ - 'https://example.com/auth/callback', - 'https://example.com/auth/shopify/callback', - 'https://example.com/api/auth/callback', - ], - }, - app_proxy: { - url: 'https://example.com', - subpath: 'subpath', - prefix: 'prefix', + patch: { + application_url: 'https://example.com', + auth: { + redirect_urls: [ + 'https://example.com/auth/callback', + 'https://example.com/auth/shopify/callback', + 'https://example.com/api/auth/callback', + ], + }, + app_proxy: { + url: 'https://example.com', + subpath: 'subpath', + prefix: 'prefix', + }, }, - client_id: 'api-key', - embedded: true, - name: 'my app', - webhooks: { - api_version: '2023-04', - }, - }, - appWithConfig.configSchema, + schema: expect.any(Object), + }), ) }) }) @@ -338,7 +324,7 @@ describe('shouldOrPromptUpdateURLs', () => { // Then expect(result).toBe(true) expect(setCachedAppInfo).not.toHaveBeenCalled() - expect(writeAppConfigurationFile).not.toHaveBeenCalled() + expect(patchAppConfigurationFile).not.toHaveBeenCalled() }) test('updates the config file if current config client matches remote', async () => { @@ -358,7 +344,15 @@ describe('shouldOrPromptUpdateURLs', () => { // Then expect(result).toBe(true) expect(setCachedAppInfo).not.toHaveBeenCalled() - expect(writeAppConfigurationFile).toHaveBeenCalledWith(localApp.configuration, localApp.configSchema) + expect(patchAppConfigurationFile).toHaveBeenCalledWith( + expect.objectContaining({ + path: localApp.configuration.path, + patch: { + build: {automatically_update_urls_on_dev: true}, + }, + schema: expect.any(Object), + }), + ) }) }) diff --git a/packages/app/src/cli/services/dev/urls.ts b/packages/app/src/cli/services/dev/urls.ts index 51a2a78361..42d8564b77 100644 --- a/packages/app/src/cli/services/dev/urls.ts +++ b/packages/app/src/cli/services/dev/urls.ts @@ -7,9 +7,9 @@ import { } from '../../models/app/app.js' import {UpdateURLsSchema, UpdateURLsVariables} from '../../api/graphql/update_urls.js' import {setCachedAppInfo} from '../local-storage.js' -import {writeAppConfigurationFile} from '../app/write-app-configuration-file.js' import {AppConfigurationUsedByCli} from '../../models/extensions/specifications/types/app_config.js' import {DeveloperPlatformClient} from '../../utilities/developer-platform-client.js' +import {patchAppConfigurationFile} from '../app/patch-app-configuration-file.js' import {AbortError, BugError} from '@shopify/cli-kit/node/error' import {Config} from '@oclif/core' import {checkPortAvailability, getAvailableTCPPort} from '@shopify/cli-kit/node/tcp' @@ -203,27 +203,23 @@ export async function updateURLs( } if (localApp && isCurrentAppSchema(localApp.configuration) && localApp.configuration.client_id === apiKey) { - let localConfiguration: CurrentAppConfiguration = { - ...localApp.configuration, + const patch = { application_url: urls.applicationUrl, auth: { - ...(localApp.configuration.auth ?? {}), redirect_urls: urls.redirectUrlWhitelist, }, + ...(urls.appProxy + ? { + app_proxy: { + url: urls.appProxy.proxyUrl, + subpath: urls.appProxy.proxySubPath, + prefix: urls.appProxy.proxySubPathPrefix, + }, + } + : {}), } - if (urls.appProxy) { - localConfiguration = { - ...localConfiguration, - app_proxy: { - url: urls.appProxy.proxyUrl, - subpath: urls.appProxy.proxySubPath, - prefix: urls.appProxy.proxySubPathPrefix, - }, - } - } - - await writeAppConfigurationFile(localConfiguration, localApp.configSchema) + await patchAppConfigurationFile({path: localApp.configuration.path, patch, schema: localApp.configSchema}) } } @@ -268,8 +264,9 @@ export async function shouldOrPromptUpdateURLs(options: ShouldOrPromptUpdateURLs ...localConfiguration.build, automatically_update_urls_on_dev: shouldUpdateURLs, } - - await writeAppConfigurationFile(localConfiguration, options.localApp.configSchema) + const patch = {build: {automatically_update_urls_on_dev: shouldUpdateURLs}} + const path = options.localApp.configuration.path + await patchAppConfigurationFile({path, patch, schema: options.localApp.configSchema}) } else { setCachedAppInfo({directory: options.appDirectory, updateURLs: shouldUpdateURLs}) }