diff --git a/.changeset/chilly-glasses-fetch.md b/.changeset/chilly-glasses-fetch.md new file mode 100644 index 0000000000..7e1b85c8f7 --- /dev/null +++ b/.changeset/chilly-glasses-fetch.md @@ -0,0 +1,5 @@ +--- +'@shopify/app': patch +--- + +Fix crash "config2.map is not a function" in `rewriteConfiguration` when writing app configuration with unvalidated data (e.g., from third-party templates without `client_id`) diff --git a/packages/app/src/cli/services/app/__snapshots__/config-pipeline-snapshot.test.ts.snap b/packages/app/src/cli/services/app/__snapshots__/config-pipeline-snapshot.test.ts.snap deleted file mode 100644 index 1c8d26f8c9..0000000000 --- a/packages/app/src/cli/services/app/__snapshots__/config-pipeline-snapshot.test.ts.snap +++ /dev/null @@ -1,365 +0,0 @@ -// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html - -exports[`Config pipeline snapshots > config round-trip (write → read → write) reorders webhook subscriptions 1`] = ` -"# Learn more about configuring your app at https://shopify.dev/docs/apps/tools/cli/configuration - -client_id = "12345" -name = "My Test App" -application_url = "https://myapp.example.com" -embedded = true - -[build] -automatically_update_urls_on_dev = true -dev_store_url = "test-store.myshopify.com" -include_config_on_deploy = true - -[access.admin] -direct_api_mode = "online" -embedded_app_direct_api_access = true - -[access_scopes] -# Learn more at https://shopify.dev/docs/apps/tools/cli/configuration#access_scopes -scopes = "read_products,write_orders" -required_scopes = [ "read_products" ] -optional_scopes = [ "write_orders" ] -use_legacy_install_flow = false - -[auth] -redirect_urls = [ - "https://myapp.example.com/auth/callback", - "https://myapp.example.com/auth/shopify/callback" -] - -[webhooks] -api_version = "2024-01" - - [[webhooks.subscriptions]] - uri = "/webhooks/compliance" - compliance_topics = [ "customers/data_request", "customers/redact" ] - - [[webhooks.subscriptions]] - topics = [ "orders/create" ] - uri = "/webhooks/orders" - - [[webhooks.subscriptions]] - topics = [ "products/create", "products/update" ] - uri = "/webhooks/products" - -[app_proxy] -url = "https://myapp.example.com/proxy" -subpath = "app" -prefix = "apps" - -[pos] -embedded = false - -[app_preferences] -url = "https://myapp.example.com/preferences" -" -`; - -exports[`Config pipeline snapshots > minimal config without webhooks produces stable output 1`] = ` -"# Learn more about configuring your app at https://shopify.dev/docs/apps/tools/cli/configuration - -client_id = "12345" -name = "Minimal App" -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" - -[auth] -redirect_urls = [ "https://example.com/auth/callback" ] -" -`; - -exports[`Config pipeline snapshots > subscription with both topics and compliance_topics on same URI splits after round-trip 1`] = ` -"# Learn more about configuring your app at https://shopify.dev/docs/apps/tools/cli/configuration - -client_id = "12345" -name = "My Test App" -application_url = "https://myapp.example.com" -embedded = true - -[build] -automatically_update_urls_on_dev = true -dev_store_url = "test-store.myshopify.com" -include_config_on_deploy = true - -[access.admin] -direct_api_mode = "online" -embedded_app_direct_api_access = true - -[access_scopes] -# Learn more at https://shopify.dev/docs/apps/tools/cli/configuration#access_scopes -scopes = "read_products,write_orders" -required_scopes = [ "read_products" ] -optional_scopes = [ "write_orders" ] -use_legacy_install_flow = false - -[auth] -redirect_urls = [ - "https://myapp.example.com/auth/callback", - "https://myapp.example.com/auth/shopify/callback" -] - -[webhooks] -api_version = "2024-01" - - [[webhooks.subscriptions]] - topics = [ "orders/create" ] - uri = "/webhooks" - compliance_topics = [ "customers/data_request", "customers/redact" ] - -[app_proxy] -url = "https://myapp.example.com/proxy" -subpath = "app" -prefix = "apps" - -[pos] -embedded = false - -[app_preferences] -url = "https://myapp.example.com/preferences" -" -`; - -exports[`Config pipeline snapshots > subscriptions with same URI but different filters stay separate through round-trip 1`] = ` -"# Learn more about configuring your app at https://shopify.dev/docs/apps/tools/cli/configuration - -client_id = "12345" -name = "My Test App" -application_url = "https://myapp.example.com" -embedded = true - -[build] -automatically_update_urls_on_dev = true -dev_store_url = "test-store.myshopify.com" -include_config_on_deploy = true - -[access.admin] -direct_api_mode = "online" -embedded_app_direct_api_access = true - -[access_scopes] -# Learn more at https://shopify.dev/docs/apps/tools/cli/configuration#access_scopes -scopes = "read_products,write_orders" -required_scopes = [ "read_products" ] -optional_scopes = [ "write_orders" ] -use_legacy_install_flow = false - -[auth] -redirect_urls = [ - "https://myapp.example.com/auth/callback", - "https://myapp.example.com/auth/shopify/callback" -] - -[webhooks] -api_version = "2024-01" - - [[webhooks.subscriptions]] - topics = [ "orders/create" ] - uri = "/webhooks/orders" - filter = "status:paid" - - [[webhooks.subscriptions]] - topics = [ "orders/update" ] - uri = "/webhooks/orders" - filter = "status:pending" - -[app_proxy] -url = "https://myapp.example.com/proxy" -subpath = "app" -prefix = "apps" - -[pos] -embedded = false - -[app_preferences] -url = "https://myapp.example.com/preferences" -" -`; - -exports[`Config pipeline snapshots > subscriptions with same URI but different include_fields stay separate through round-trip 1`] = ` -"# Learn more about configuring your app at https://shopify.dev/docs/apps/tools/cli/configuration - -client_id = "12345" -name = "My Test App" -application_url = "https://myapp.example.com" -embedded = true - -[build] -automatically_update_urls_on_dev = true -dev_store_url = "test-store.myshopify.com" -include_config_on_deploy = true - -[access.admin] -direct_api_mode = "online" -embedded_app_direct_api_access = true - -[access_scopes] -# Learn more at https://shopify.dev/docs/apps/tools/cli/configuration#access_scopes -scopes = "read_products,write_orders" -required_scopes = [ "read_products" ] -optional_scopes = [ "write_orders" ] -use_legacy_install_flow = false - -[auth] -redirect_urls = [ - "https://myapp.example.com/auth/callback", - "https://myapp.example.com/auth/shopify/callback" -] - -[webhooks] -api_version = "2024-01" - - [[webhooks.subscriptions]] - topics = [ "products/create" ] - uri = "/webhooks/products" - include_fields = [ "id", "title" ] - - [[webhooks.subscriptions]] - topics = [ "products/update" ] - uri = "/webhooks/products" - include_fields = [ "id" ] - -[app_proxy] -url = "https://myapp.example.com/proxy" -subpath = "app" -prefix = "apps" - -[pos] -embedded = false - -[app_preferences] -url = "https://myapp.example.com/preferences" -" -`; - -exports[`Config pipeline snapshots > webhook subscriptions with mixed topics and compliance topics produce stable output 1`] = ` -"# Learn more about configuring your app at https://shopify.dev/docs/apps/tools/cli/configuration - -client_id = "12345" -name = "My Test App" -application_url = "https://myapp.example.com" -embedded = true - -[build] -automatically_update_urls_on_dev = true -dev_store_url = "test-store.myshopify.com" -include_config_on_deploy = true - -[access.admin] -direct_api_mode = "online" -embedded_app_direct_api_access = true - -[access_scopes] -# Learn more at https://shopify.dev/docs/apps/tools/cli/configuration#access_scopes -scopes = "read_products,write_orders" -required_scopes = [ "read_products" ] -optional_scopes = [ "write_orders" ] -use_legacy_install_flow = false - -[auth] -redirect_urls = [ - "https://myapp.example.com/auth/callback", - "https://myapp.example.com/auth/shopify/callback" -] - -[webhooks] -api_version = "2024-01" - - [[webhooks.subscriptions]] - topics = [ "orders/create", "orders/updated", "orders/cancelled" ] - uri = "/webhooks/orders" - - [[webhooks.subscriptions]] - topics = [ "products/create" ] - uri = "/webhooks/products" - include_fields = [ "id", "title" ] - - [[webhooks.subscriptions]] - uri = "/webhooks/compliance" - compliance_topics = [ "customers/data_request", "customers/redact", "shop/redact" ] - - [[webhooks.subscriptions]] - topics = [ "app/uninstalled" ] - uri = "/webhooks/app" - -[app_proxy] -url = "https://myapp.example.com/proxy" -subpath = "app" -prefix = "apps" - -[pos] -embedded = false - -[app_preferences] -url = "https://myapp.example.com/preferences" -" -`; - -exports[`Config pipeline snapshots > webhook subscriptions with mixed topics and compliance topics produce stable output 2`] = ` -"# Learn more about configuring your app at https://shopify.dev/docs/apps/tools/cli/configuration - -client_id = "12345" -name = "My Test App" -application_url = "https://myapp.example.com" -embedded = true - -[build] -automatically_update_urls_on_dev = true -dev_store_url = "test-store.myshopify.com" -include_config_on_deploy = true - -[access.admin] -direct_api_mode = "online" -embedded_app_direct_api_access = true - -[access_scopes] -# Learn more at https://shopify.dev/docs/apps/tools/cli/configuration#access_scopes -scopes = "read_products,write_orders" -required_scopes = [ "read_products" ] -optional_scopes = [ "write_orders" ] -use_legacy_install_flow = false - -[auth] -redirect_urls = [ - "https://myapp.example.com/auth/callback", - "https://myapp.example.com/auth/shopify/callback" -] - -[webhooks] -api_version = "2024-01" - - [[webhooks.subscriptions]] - uri = "/webhooks/compliance" - compliance_topics = [ "customers/data_request", "customers/redact", "shop/redact" ] - - [[webhooks.subscriptions]] - topics = [ "app/uninstalled" ] - uri = "/webhooks/app" - - [[webhooks.subscriptions]] - topics = [ "orders/cancelled", "orders/create", "orders/updated" ] - uri = "/webhooks/orders" - - [[webhooks.subscriptions]] - topics = [ "products/create" ] - uri = "/webhooks/products" - include_fields = [ "id", "title" ] - -[app_proxy] -url = "https://myapp.example.com/proxy" -subpath = "app" -prefix = "apps" - -[pos] -embedded = false - -[app_preferences] -url = "https://myapp.example.com/preferences" -" -`; diff --git a/packages/app/src/cli/services/app/config-pipeline-snapshot.test.ts b/packages/app/src/cli/services/app/config-pipeline-snapshot.test.ts index ab03312d32..40c3f45b86 100644 --- a/packages/app/src/cli/services/app/config-pipeline-snapshot.test.ts +++ b/packages/app/src/cli/services/app/config-pipeline-snapshot.test.ts @@ -88,16 +88,16 @@ describe('Config pipeline snapshots', () => { // This test documents the current behavior as a snapshot. await inTemporaryDirectory(async (tmp) => { const filePath = joinPath(tmp, 'shopify.app.toml') - const {schema, configSpecifications: specs} = await buildVersionedAppSchema() + const {configSpecifications: specs} = await buildVersionedAppSchema() // First write - await writeAppConfigurationFile(REALISTIC_CONFIG as CurrentAppConfiguration, schema, filePath) + await writeAppConfigurationFile(REALISTIC_CONFIG as CurrentAppConfiguration, filePath) // Read back through the full parse pipeline (which fires Zod transforms) const parsedConfig = await parseConfigAsCurrentApp(getAppVersionedSchema(specs), filePath) // Second write from the parsed (transformed) config - await writeAppConfigurationFile(parsedConfig, schema, filePath) + await writeAppConfigurationFile(parsedConfig, filePath) const secondWrite = await readFile(filePath) // Snapshot the round-tripped output — it differs from the first write @@ -111,17 +111,17 @@ describe('Config pipeline snapshots', () => { // round-trips should be stable (idempotent). await inTemporaryDirectory(async (tmp) => { const filePath = joinPath(tmp, 'shopify.app.toml') - const {schema, configSpecifications: specs} = await buildVersionedAppSchema() + const {configSpecifications: specs} = await buildVersionedAppSchema() // First write + read + second write (reordering happens here) - await writeAppConfigurationFile(REALISTIC_CONFIG as CurrentAppConfiguration, schema, filePath) + await writeAppConfigurationFile(REALISTIC_CONFIG as CurrentAppConfiguration, filePath) const parsed1 = await parseConfigAsCurrentApp(getAppVersionedSchema(specs), filePath) - await writeAppConfigurationFile(parsed1, schema, filePath) + await writeAppConfigurationFile(parsed1, filePath) const secondWrite = await readFile(filePath) // Third write from re-read — should be identical to second const parsed2 = await parseConfigAsCurrentApp(getAppVersionedSchema(specs), filePath) - await writeAppConfigurationFile(parsed2, schema, filePath) + await writeAppConfigurationFile(parsed2, filePath) const thirdWrite = await readFile(filePath) expect(thirdWrite).toEqual(secondWrite) @@ -131,7 +131,7 @@ describe('Config pipeline snapshots', () => { test('webhook subscriptions with mixed topics and compliance topics produce stable output', async () => { await inTemporaryDirectory(async (tmp) => { const filePath = joinPath(tmp, 'shopify.app.toml') - const {schema, configSpecifications: specs} = await buildVersionedAppSchema() + const {configSpecifications: specs} = await buildVersionedAppSchema() const config = { ...REALISTIC_CONFIG, @@ -160,13 +160,13 @@ describe('Config pipeline snapshots', () => { } // Snapshot the first write - await writeAppConfigurationFile(config as CurrentAppConfiguration, schema, filePath) + await writeAppConfigurationFile(config as CurrentAppConfiguration, filePath) const firstWrite = await readFile(filePath) expect(firstWrite).toMatchSnapshot() // Round-trip to verify reordering behavior on the most complex fixture const parsedConfig = await parseConfigAsCurrentApp(getAppVersionedSchema(specs), filePath) - await writeAppConfigurationFile(parsedConfig, schema, filePath) + await writeAppConfigurationFile(parsedConfig, filePath) const secondWrite = await readFile(filePath) expect(secondWrite).toMatchSnapshot() }) @@ -175,7 +175,7 @@ describe('Config pipeline snapshots', () => { test('config with relative webhook URIs normalizes correctly through round-trip', async () => { await inTemporaryDirectory(async (tmp) => { const filePath = joinPath(tmp, 'shopify.app.toml') - const {schema, configSpecifications: specs} = await buildVersionedAppSchema() + const {configSpecifications: specs} = await buildVersionedAppSchema() const config = { ...REALISTIC_CONFIG, @@ -191,11 +191,11 @@ describe('Config pipeline snapshots', () => { } // Write, read, write - await writeAppConfigurationFile(config as CurrentAppConfiguration, schema, filePath) + await writeAppConfigurationFile(config as CurrentAppConfiguration, filePath) const firstWrite = await readFile(filePath) const parsedConfig = await parseConfigAsCurrentApp(getAppVersionedSchema(specs), filePath) - await writeAppConfigurationFile(parsedConfig, schema, filePath) + await writeAppConfigurationFile(parsedConfig, filePath) const secondWrite = await readFile(filePath) expect(secondWrite).toEqual(firstWrite) @@ -205,7 +205,6 @@ describe('Config pipeline snapshots', () => { test('minimal config without webhooks produces stable output', async () => { await inTemporaryDirectory(async (tmp) => { const filePath = joinPath(tmp, 'shopify.app.toml') - const {schema} = await buildVersionedAppSchema() const config = { client_id: '12345', @@ -220,7 +219,7 @@ describe('Config pipeline snapshots', () => { }, } satisfies CurrentAppConfiguration - await writeAppConfigurationFile(config, schema, filePath) + await writeAppConfigurationFile(config, filePath) const content = await readFile(filePath) expect(content).toMatchSnapshot() }) @@ -229,7 +228,7 @@ describe('Config pipeline snapshots', () => { test('subscriptions with same URI but different filters stay separate through round-trip', async () => { await inTemporaryDirectory(async (tmp) => { const filePath = joinPath(tmp, 'shopify.app.toml') - const {schema, configSpecifications: specs} = await buildVersionedAppSchema() + const {configSpecifications: specs} = await buildVersionedAppSchema() const config = { ...REALISTIC_CONFIG, @@ -242,11 +241,11 @@ describe('Config pipeline snapshots', () => { }, } - await writeAppConfigurationFile(config as CurrentAppConfiguration, schema, filePath) + await writeAppConfigurationFile(config as CurrentAppConfiguration, filePath) const firstWrite = await readFile(filePath) const parsedConfig = await parseConfigAsCurrentApp(getAppVersionedSchema(specs), filePath) - await writeAppConfigurationFile(parsedConfig, schema, filePath) + await writeAppConfigurationFile(parsedConfig, filePath) const secondWrite = await readFile(filePath) expect(firstWrite).toMatchSnapshot() @@ -257,7 +256,7 @@ describe('Config pipeline snapshots', () => { test('subscriptions with same URI but different include_fields stay separate through round-trip', async () => { await inTemporaryDirectory(async (tmp) => { const filePath = joinPath(tmp, 'shopify.app.toml') - const {schema, configSpecifications: specs} = await buildVersionedAppSchema() + const {configSpecifications: specs} = await buildVersionedAppSchema() const config = { ...REALISTIC_CONFIG, @@ -270,11 +269,11 @@ describe('Config pipeline snapshots', () => { }, } - await writeAppConfigurationFile(config as CurrentAppConfiguration, schema, filePath) + await writeAppConfigurationFile(config as CurrentAppConfiguration, filePath) const firstWrite = await readFile(filePath) const parsedConfig = await parseConfigAsCurrentApp(getAppVersionedSchema(specs), filePath) - await writeAppConfigurationFile(parsedConfig, schema, filePath) + await writeAppConfigurationFile(parsedConfig, filePath) const secondWrite = await readFile(filePath) expect(firstWrite).toMatchSnapshot() @@ -285,7 +284,7 @@ describe('Config pipeline snapshots', () => { test('subscription with both topics and compliance_topics on same URI splits after round-trip', async () => { await inTemporaryDirectory(async (tmp) => { const filePath = joinPath(tmp, 'shopify.app.toml') - const {schema, configSpecifications: specs} = await buildVersionedAppSchema() + const {configSpecifications: specs} = await buildVersionedAppSchema() const config = { ...REALISTIC_CONFIG, @@ -301,11 +300,11 @@ describe('Config pipeline snapshots', () => { }, } - await writeAppConfigurationFile(config as CurrentAppConfiguration, schema, filePath) + await writeAppConfigurationFile(config as CurrentAppConfiguration, filePath) const firstWrite = await readFile(filePath) const parsedConfig = await parseConfigAsCurrentApp(getAppVersionedSchema(specs), filePath) - await writeAppConfigurationFile(parsedConfig, schema, filePath) + await writeAppConfigurationFile(parsedConfig, filePath) const secondWrite = await readFile(filePath) // After round-trip, compliance and regular topics should be split into separate subscriptions diff --git a/packages/app/src/cli/services/app/config/link.ts b/packages/app/src/cli/services/app/config/link.ts index 5db56b3905..3301611114 100644 --- a/packages/app/src/cli/services/app/config/link.ts +++ b/packages/app/src/cli/services/app/config/link.ts @@ -2,7 +2,6 @@ import {setCurrentConfigPreference} from './use.js' import { AppConfiguration, CurrentAppConfiguration, - getAppVersionedSchema, CliBuildPreferences, getAppScopes, } from '../../../models/app/app.js' @@ -366,9 +365,7 @@ export async function overwriteLocalConfigFileWithRemoteAppConfiguration(options // eslint-disable-next-line @typescript-eslint/no-explicit-any delete (mergedAppConfiguration as any).scopes - // Always output using the canonical schema - const schema = getAppVersionedSchema(specifications) - await writeAppConfigurationFile(mergedAppConfiguration, schema, configFilePath) + await writeAppConfigurationFile(mergedAppConfiguration, configFilePath) setCurrentConfigPreference(mergedAppConfiguration, {configFileName, directory: appDirectory}) return mergedAppConfiguration diff --git a/packages/app/src/cli/services/app/write-app-configuration-file.test.ts b/packages/app/src/cli/services/app/write-app-configuration-file.test.ts index 685e53f959..0a7af2f9d5 100644 --- a/packages/app/src/cli/services/app/write-app-configuration-file.test.ts +++ b/packages/app/src/cli/services/app/write-app-configuration-file.test.ts @@ -1,5 +1,5 @@ -import {writeAppConfigurationFile} from './write-app-configuration-file.js' -import {DEFAULT_CONFIG, buildVersionedAppSchema} from '../../models/app/app.test-data.js' +import {stripEmptyObjects, writeAppConfigurationFile} from './write-app-configuration-file.js' +import {DEFAULT_CONFIG} from '../../models/app/app.test-data.js' import {CurrentAppConfiguration} from '../../models/app/app.js' import {inTemporaryDirectory, readFile} from '@shopify/cli-kit/node/fs' import {joinPath} from '@shopify/cli-kit/node/path' @@ -45,24 +45,31 @@ describe('writeAppConfigurationFile', () => { await inTemporaryDirectory(async (tmp) => { // Given const filePath = joinPath(tmp, 'shopify.app.toml') - const {schema} = await buildVersionedAppSchema() // When - await writeAppConfigurationFile(FULL_CONFIGURATION, schema, filePath) + await writeAppConfigurationFile(FULL_CONFIGURATION, filePath) // Then const content = await readFile(filePath) const expectedContent = `# Learn more about configuring your app at https://shopify.dev/docs/apps/tools/cli/configuration +application_url = "https://myapp.com/" client_id = "api-key" name = "my app" -application_url = "https://myapp.com/" embedded = true [build] +include_config_on_deploy = true automatically_update_urls_on_dev = true dev_store_url = "example.myshopify.com" -include_config_on_deploy = true + +[webhooks] +api_version = "2023-07" + + [[webhooks.subscriptions]] + topics = [ "products/create" ] + uri = "/webhooks" + compliance_topics = [ "customer_deletion_url", "customer_data_request_url" ] [access_scopes] # Learn more at https://shopify.dev/docs/apps/tools/cli/configuration#access_scopes @@ -75,14 +82,6 @@ redirect_urls = [ "https://example.com/redirect2" ] -[webhooks] -api_version = "2023-07" - - [[webhooks.subscriptions]] - topics = [ "products/create" ] - uri = "/webhooks" - compliance_topics = [ "customer_deletion_url", "customer_data_request_url" ] - [app_proxy] url = "https://example.com/auth/prox" subpath = "asdsa" @@ -102,7 +101,6 @@ url = "https://example.com/prefs" await inTemporaryDirectory(async (tmp) => { // Given const filePath = joinPath(tmp, 'shopify.app.toml') - const {schema} = await buildVersionedAppSchema() // When await writeAppConfigurationFile( @@ -116,7 +114,6 @@ url = "https://example.com/prefs" privacy_compliance: {}, }, } as CurrentAppConfiguration, - schema, filePath, ) @@ -133,7 +130,6 @@ url = "https://example.com/prefs" await inTemporaryDirectory(async (tmp) => { // Given const filePath = joinPath(tmp, 'shopify.app.toml') - const {schema} = await buildVersionedAppSchema() // When await writeAppConfigurationFile( @@ -141,7 +137,6 @@ url = "https://example.com/prefs" ...FULL_CONFIGURATION, auth: {redirect_urls: []}, } as CurrentAppConfiguration, - schema, filePath, ) @@ -150,4 +145,42 @@ url = "https://example.com/prefs" expect(content).toContain('redirect_urls') }) }) + + test('does not crash with type-mismatched config data', async () => { + await inTemporaryDirectory(async (tmp) => { + const filePath = joinPath(tmp, 'shopify.app.toml') + const malformedConfig = { + ...DEFAULT_CONFIG, + auth: {redirect_urls: 'not-an-array'}, + webhooks: {api_version: '2023-07', subscriptions: 'also-not-an-array'}, + } + + await expect( + writeAppConfigurationFile(malformedConfig as unknown as CurrentAppConfiguration, filePath), + ).resolves.not.toThrow() + }) + }) +}) + +describe('stripEmptyObjects', () => { + test('removes empty objects', () => { + expect(stripEmptyObjects({a: 'hello', b: {}})).toEqual({a: 'hello'}) + }) + + test('removes nested empty objects', () => { + expect(stripEmptyObjects({a: {b: {}}})).toEqual({}) + }) + + test('preserves empty arrays', () => { + expect(stripEmptyObjects({a: []})).toEqual({a: []}) + }) + + test('preserves null and undefined', () => { + expect(stripEmptyObjects(null)).toBeNull() + expect(stripEmptyObjects(undefined)).toBeUndefined() + }) + + test('recurses into arrays', () => { + expect(stripEmptyObjects({items: [{a: 1, b: {}}, {c: 2}]})).toEqual({items: [{a: 1}, {c: 2}]}) + }) }) 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 c2f8201240..96069d2876 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 @@ -3,67 +3,41 @@ import {reduceWebhooks} from '../../models/extensions/specifications/transform/a import {removeTrailingSlash} from '../../models/extensions/specifications/validation/common.js' import {TomlFile} from '@shopify/cli-kit/node/toml/toml-file' import {JsonMapType} from '@shopify/cli-kit/node/toml' -import {zod} from '@shopify/cli-kit/node/schema' import {outputDebug} from '@shopify/cli-kit/node/output' -export async function writeAppConfigurationFile( - configuration: CurrentAppConfiguration, - schema: zod.ZodTypeAny, - configPath: string, -) { +export async function writeAppConfigurationFile(configuration: CurrentAppConfiguration, configPath: string) { outputDebug(`Writing app configuration to ${configPath}`) // we need to condense the compliance and non-compliance webhooks again // so compliance topics and topics with the same uri are under // the same [[webhooks.subscriptions]] in the TOML - const condensedWebhooksAppConfiguration = condenseComplianceAndNonComplianceWebhooks(configuration) - - const sorted = rewriteConfiguration(schema, condensedWebhooksAppConfiguration) as JsonMapType + const condensed = condenseComplianceAndNonComplianceWebhooks(configuration) + const cleaned = stripEmptyObjects(condensed) as JsonMapType const file = new TomlFile(configPath, {}) - await file.replace(sorted) + await file.replace(cleaned) await file.transformRaw(addDefaultCommentsToToml) } -export const rewriteConfiguration = (schema: T, config: unknown): unknown => { - if (schema === null || schema === undefined) return null - if (schema instanceof zod.ZodNullable || schema instanceof zod.ZodOptional) - return rewriteConfiguration(schema.unwrap(), config) - if (schema instanceof zod.ZodArray) { - return (config as unknown[]).map((item) => rewriteConfiguration(schema.element, item)) - } - if (schema instanceof zod.ZodEffects) { - return rewriteConfiguration(schema._def.schema, config) - } - if (schema instanceof zod.ZodObject) { - const entries = Object.entries(schema.shape) - const confObj = config as {[key: string]: unknown} - let result: {[key: string]: unknown} = {} - entries.forEach(([key, subSchema]) => { - if (confObj !== undefined && confObj[key] !== undefined) { - let value = rewriteConfiguration(subSchema as T, confObj[key]) - if (!(value instanceof Array) && value instanceof Object && Object.keys(value as object).length === 0) { - value = undefined - } - result = {...result, [key]: value} +/** + * Recursively removes keys whose values are empty objects `{}`. + * Preserves empty arrays, null, undefined, and all other values. + */ +export function stripEmptyObjects(obj: unknown): unknown { + if (obj === null || obj === undefined) return obj + if (Array.isArray(obj)) return obj.map(stripEmptyObjects) + if (typeof obj === 'object') { + const result: {[key: string]: unknown} = {} + for (const [key, value] of Object.entries(obj as Record)) { + const stripped = stripEmptyObjects(value) + if (typeof stripped === 'object' && stripped !== null && !Array.isArray(stripped) && Object.keys(stripped).length === 0) { + continue } - }) - - // if dynamic config was enabled, its possible to have more keys in the file than the schema - const blockedKeys = ['scopes'] - - Object.entries(confObj) - .filter(([key]) => !blockedKeys.includes(key)) - .sort(([key, _value]) => key.localeCompare(key)) - .forEach(([key, value]) => { - if (!entries.map(([key]) => key).includes(key)) { - result = {...result, [key]: value} - } - }) - + result[key] = stripped + } return result } - return config + return obj } function addDefaultCommentsToToml(fileString: string) { diff --git a/packages/app/src/cli/services/context/breakdown-extensions.ts b/packages/app/src/cli/services/context/breakdown-extensions.ts index 9d854e8339..cf415bf349 100644 --- a/packages/app/src/cli/services/context/breakdown-extensions.ts +++ b/packages/app/src/cli/services/context/breakdown-extensions.ts @@ -12,12 +12,11 @@ import { RemoteExtensionRegistrations, } from '../../api/graphql/all_app_extension_registrations.js' import {ExtensionSpecification, isAppConfigSpecification} from '../../models/extensions/specification.js' -import {rewriteConfiguration} from '../app/write-app-configuration-file.js' +import {stripEmptyObjects} from '../app/write-app-configuration-file.js' import {AppConfigurationUsedByCli} from '../../models/extensions/specifications/types/app_config.js' import {removeTrailingSlash} from '../../models/extensions/specifications/validation/common.js' import {throwUidMappingError} from '../../prompts/uid-mapping-error.js' import {deepCompare, deepDifference} from '@shopify/cli-kit/common/object' -import {zod} from '@shopify/cli-kit/node/schema' export interface ConfigExtensionIdentifiersBreakdown { existingFieldNames: string[] @@ -215,7 +214,7 @@ async function resolveRemoteConfigExtensionIdentifiersBreakdown( }) } - const diffFieldNames = buildDiffFieldNames(baselineConfig, remoteConfig, app.configSchema) + const diffFieldNames = buildDiffFieldNames(baselineConfig, remoteConfig) // List of field included in the config except the ones that only affect the CLI and are not pushed to the server // (versioned fields) @@ -251,17 +250,16 @@ async function resolveRemoteConfigExtensionIdentifiersBreakdown( } /** - * Computes the diff between local and remote config (after schema rewriting) and returns + * Computes the diff between local and remote config (after stripping empty objects) and returns * the top-level field names that differ on each side. */ function buildDiffFieldNames( localConfig: CurrentAppConfiguration, remoteConfig: Partial, - schema: zod.ZodTypeAny, ) { const [updated, baseline] = deepDifference( - {...(rewriteConfiguration(schema, localConfig) as object), build: undefined}, - {...(rewriteConfiguration(schema, remoteConfig) as object), build: undefined}, + {...(stripEmptyObjects(localConfig) as object), build: undefined, scopes: undefined}, + {...(stripEmptyObjects(remoteConfig) as object), build: undefined, scopes: undefined}, ) if (deepCompare(updated, baseline)) {