From 59362c73c4f7eb96063008f52c0f8d24634253a6 Mon Sep 17 00:00:00 2001 From: Liz Kenyon Date: Wed, 1 Apr 2026 16:12:18 -0500 Subject: [PATCH 1/2] Remove rewriteConfiguration from write path, keep for breakdown-extensions The Zod schema walk in rewriteConfiguration crashes when writeAppConfigurationFile receives unvalidated data from loadOpaqueApp's raw TOML fallback (third-party templates without client_id). Instead of adding defensive guards to the schema walk, stop using it in the write path entirely. - Replace rewriteConfiguration with stripEmptyObjects in writeAppConfigurationFile - Add structuredClone to prevent mutating caller's config object - Add Array.isArray guard in condenseComplianceAndNonComplianceWebhooks - Keep rewriteConfiguration exported for breakdown-extensions.ts (unchanged) - Drop schema parameter from writeAppConfigurationFile signature Co-Authored-By: Claude Opus 4.6 (1M context) --- .changeset/chilly-glasses-fetch.md | 5 + .../config-pipeline-snapshot.test.ts.snap | 56 +- .../app/config-pipeline-snapshot.test.ts | 93 +-- .../config/__snapshots__/link.test.ts.snap | 548 ++++++++++++++++++ .../src/cli/services/app/config/link.test.ts | 538 +---------------- .../app/src/cli/services/app/config/link.ts | 5 +- .../app/write-app-configuration-file.test.ts | 69 ++- .../app/write-app-configuration-file.ts | 47 +- 8 files changed, 755 insertions(+), 606 deletions(-) create mode 100644 .changeset/chilly-glasses-fetch.md create mode 100644 packages/app/src/cli/services/app/config/__snapshots__/link.test.ts.snap diff --git a/.changeset/chilly-glasses-fetch.md b/.changeset/chilly-glasses-fetch.md new file mode 100644 index 0000000000..f192d2ae48 --- /dev/null +++ b/.changeset/chilly-glasses-fetch.md @@ -0,0 +1,5 @@ +--- +'@shopify/app': patch +--- + +Fix crash "config2.map is not a function" when writing app configuration with unvalidated data (e.g., from third-party templates without `client_id`). Removes the Zod schema walker (`rewriteConfiguration`) that assumed config data matched schema types. Key ordering in the generated TOML now follows object insertion order rather than schema declaration order, which may cause a one-time key reorder in `shopify.app.toml` on the next `app link` or `app config pull`. 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 index 1c8d26f8c9..2362ed1bee 100644 --- 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 @@ -38,12 +38,12 @@ api_version = "2024-01" compliance_topics = [ "customers/data_request", "customers/redact" ] [[webhooks.subscriptions]] - topics = [ "orders/create" ] uri = "/webhooks/orders" + topics = [ "orders/create" ] [[webhooks.subscriptions]] - topics = [ "products/create", "products/update" ] uri = "/webhooks/products" + topics = [ "products/create", "products/update" ] [app_proxy] url = "https://myapp.example.com/proxy" @@ -109,9 +109,9 @@ redirect_urls = [ api_version = "2024-01" [[webhooks.subscriptions]] - topics = [ "orders/create" ] uri = "/webhooks" compliance_topics = [ "customers/data_request", "customers/redact" ] + topics = [ "orders/create" ] [app_proxy] url = "https://myapp.example.com/proxy" @@ -139,16 +139,16 @@ 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" +use_legacy_install_flow = false required_scopes = [ "read_products" ] optional_scopes = [ "write_orders" ] -use_legacy_install_flow = false + +[access.admin] +direct_api_mode = "online" +embedded_app_direct_api_access = true [auth] redirect_urls = [ @@ -160,13 +160,13 @@ redirect_urls = [ api_version = "2024-01" [[webhooks.subscriptions]] - topics = [ "orders/create" ] uri = "/webhooks/orders" + topics = [ "orders/create" ] filter = "status:paid" [[webhooks.subscriptions]] - topics = [ "orders/update" ] uri = "/webhooks/orders" + topics = [ "orders/update" ] filter = "status:pending" [app_proxy] @@ -195,16 +195,16 @@ 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" +use_legacy_install_flow = false required_scopes = [ "read_products" ] optional_scopes = [ "write_orders" ] -use_legacy_install_flow = false + +[access.admin] +direct_api_mode = "online" +embedded_app_direct_api_access = true [auth] redirect_urls = [ @@ -216,13 +216,13 @@ redirect_urls = [ api_version = "2024-01" [[webhooks.subscriptions]] - topics = [ "products/create" ] uri = "/webhooks/products" + topics = [ "products/create" ] include_fields = [ "id", "title" ] [[webhooks.subscriptions]] - topics = [ "products/update" ] uri = "/webhooks/products" + topics = [ "products/update" ] include_fields = [ "id" ] [app_proxy] @@ -251,16 +251,16 @@ 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" +use_legacy_install_flow = false required_scopes = [ "read_products" ] optional_scopes = [ "write_orders" ] -use_legacy_install_flow = false + +[access.admin] +direct_api_mode = "online" +embedded_app_direct_api_access = true [auth] redirect_urls = [ @@ -272,12 +272,12 @@ redirect_urls = [ api_version = "2024-01" [[webhooks.subscriptions]] - topics = [ "orders/create", "orders/updated", "orders/cancelled" ] uri = "/webhooks/orders" + topics = [ "orders/create", "orders/updated", "orders/cancelled" ] [[webhooks.subscriptions]] - topics = [ "products/create" ] uri = "/webhooks/products" + topics = [ "products/create" ] include_fields = [ "id", "title" ] [[webhooks.subscriptions]] @@ -285,8 +285,8 @@ api_version = "2024-01" compliance_topics = [ "customers/data_request", "customers/redact", "shop/redact" ] [[webhooks.subscriptions]] - topics = [ "app/uninstalled" ] uri = "/webhooks/app" + topics = [ "app/uninstalled" ] [app_proxy] url = "https://myapp.example.com/proxy" @@ -339,16 +339,16 @@ api_version = "2024-01" compliance_topics = [ "customers/data_request", "customers/redact", "shop/redact" ] [[webhooks.subscriptions]] - topics = [ "app/uninstalled" ] uri = "/webhooks/app" + topics = [ "app/uninstalled" ] [[webhooks.subscriptions]] - topics = [ "orders/cancelled", "orders/create", "orders/updated" ] uri = "/webhooks/orders" + topics = [ "orders/cancelled", "orders/create", "orders/updated" ] [[webhooks.subscriptions]] - topics = [ "products/create" ] uri = "/webhooks/products" + topics = [ "products/create" ] include_fields = [ "id", "title" ] [app_proxy] 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..fe76087c43 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,22 +160,25 @@ 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() }) }) - test('config with relative webhook URIs normalizes correctly through round-trip', async () => { + // First write uses JS object insertion order (no schema-driven rewriting). + // A Zod parse round-trip reorders keys to schema order, so the first round-trip may differ. + // Stability is guaranteed from the second write onward. + test('config with relative webhook URIs stabilizes 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, @@ -190,22 +193,24 @@ describe('Config pipeline snapshots', () => { }, } - // Write, read, write - await writeAppConfigurationFile(config as CurrentAppConfiguration, schema, filePath) - const firstWrite = await readFile(filePath) - - const parsedConfig = await parseConfigAsCurrentApp(getAppVersionedSchema(specs), filePath) - await writeAppConfigurationFile(parsedConfig, schema, filePath) + // Write, read, write (first round-trip may reorder) + await writeAppConfigurationFile(config as CurrentAppConfiguration, filePath) + const parsed1 = await parseConfigAsCurrentApp(getAppVersionedSchema(specs), filePath) + await writeAppConfigurationFile(parsed1, filePath) const secondWrite = await readFile(filePath) - expect(secondWrite).toEqual(firstWrite) + // Third write should be stable + const parsed2 = await parseConfigAsCurrentApp(getAppVersionedSchema(specs), filePath) + await writeAppConfigurationFile(parsed2, filePath) + const thirdWrite = await readFile(filePath) + + expect(thirdWrite).toEqual(secondWrite) }) }) 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,16 +225,19 @@ describe('Config pipeline snapshots', () => { }, } satisfies CurrentAppConfiguration - await writeAppConfigurationFile(config, schema, filePath) + await writeAppConfigurationFile(config, filePath) const content = await readFile(filePath) expect(content).toMatchSnapshot() }) }) + // First write uses JS object insertion order (no schema-driven rewriting). + // A Zod parse round-trip reorders keys to schema order, so the first round-trip may differ. + // Stability is guaranteed from the second write onward. 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,22 +250,30 @@ describe('Config pipeline snapshots', () => { }, } - await writeAppConfigurationFile(config as CurrentAppConfiguration, schema, filePath) + await writeAppConfigurationFile(config as CurrentAppConfiguration, filePath) const firstWrite = await readFile(filePath) + expect(firstWrite).toMatchSnapshot() - const parsedConfig = await parseConfigAsCurrentApp(getAppVersionedSchema(specs), filePath) - await writeAppConfigurationFile(parsedConfig, schema, filePath) + // Round-trip: write → read → write → read → write (stabilizes after first round-trip) + const parsed1 = await parseConfigAsCurrentApp(getAppVersionedSchema(specs), filePath) + await writeAppConfigurationFile(parsed1, filePath) const secondWrite = await readFile(filePath) - expect(firstWrite).toMatchSnapshot() - expect(secondWrite).toEqual(firstWrite) + const parsed2 = await parseConfigAsCurrentApp(getAppVersionedSchema(specs), filePath) + await writeAppConfigurationFile(parsed2, filePath) + const thirdWrite = await readFile(filePath) + + expect(thirdWrite).toEqual(secondWrite) }) }) + // First write uses JS object insertion order (no schema-driven rewriting). + // A Zod parse round-trip reorders keys to schema order, so the first round-trip may differ. + // Stability is guaranteed from the second write onward. 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,22 +286,27 @@ describe('Config pipeline snapshots', () => { }, } - await writeAppConfigurationFile(config as CurrentAppConfiguration, schema, filePath) + await writeAppConfigurationFile(config as CurrentAppConfiguration, filePath) const firstWrite = await readFile(filePath) + expect(firstWrite).toMatchSnapshot() - const parsedConfig = await parseConfigAsCurrentApp(getAppVersionedSchema(specs), filePath) - await writeAppConfigurationFile(parsedConfig, schema, filePath) + // Round-trip: stabilizes after first round-trip + const parsed1 = await parseConfigAsCurrentApp(getAppVersionedSchema(specs), filePath) + await writeAppConfigurationFile(parsed1, filePath) const secondWrite = await readFile(filePath) - expect(firstWrite).toMatchSnapshot() - expect(secondWrite).toEqual(firstWrite) + const parsed2 = await parseConfigAsCurrentApp(getAppVersionedSchema(specs), filePath) + await writeAppConfigurationFile(parsed2, filePath) + const thirdWrite = await readFile(filePath) + + expect(thirdWrite).toEqual(secondWrite) }) }) 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 +322,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/__snapshots__/link.test.ts.snap b/packages/app/src/cli/services/app/config/__snapshots__/link.test.ts.snap new file mode 100644 index 0000000000..c2a20fae56 --- /dev/null +++ b/packages/app/src/cli/services/app/config/__snapshots__/link.test.ts.snap @@ -0,0 +1,548 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`link > creates a new shopify.app.staging.toml file when shopify.app.toml already linked 1`] = ` +"# Learn more about configuring your app at https://shopify.dev/docs/apps/tools/cli/configuration + +name = "my app" +client_id = "12345" +application_url = "https://myapp.com" +embedded = true + +[webhooks] +api_version = "2023-07" + +[build] +automatically_update_urls_on_dev = true +dev_store_url = "my-store.myshopify.com" +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://example.com/callback1" ] + +[pos] +embedded = false +" +`; + +exports[`link > creates a new shopify.app.toml file when it does not exist using existing app version configuration instead of the api client configuration 1`] = ` +"# 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 + +[auth] +redirect_urls = [ "https://example.com/callback1" ] + +[webhooks] +api_version = "2023-07" + +[pos] +embedded = false + +[access_scopes] +# Learn more at https://shopify.dev/docs/apps/tools/cli/configuration#access_scopes +use_legacy_install_flow = true + +[build] +include_config_on_deploy = true +" +`; + +exports[`link > does not ask for a name when the selected app is already linked 1`] = ` +"# Learn more about configuring your app at https://shopify.dev/docs/apps/tools/cli/configuration + +application_url = "https://example.com" +client_id = "12345" +name = "app1" +embedded = true + +[webhooks] +api_version = "2023-07" + +[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/callback1" ] + +[pos] +embedded = false +" +`; + +exports[`link > does not render success banner if shouldRenderSuccess is false 1`] = ` +"# Learn more about configuring your app at https://shopify.dev/docs/apps/tools/cli/configuration + +application_url = "https://example.com" +client_id = "12345" +name = "app1" +embedded = true + +[webhooks] +api_version = "2023-07" + +[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/callback1" ] + +[pos] +embedded = false +" +`; + +exports[`link > enables include_config_on_deploy when the apiKey is provided and isNewApp is true 1`] = ` +"# Learn more about configuring your app at https://shopify.dev/docs/apps/tools/cli/configuration + +name = "app1" +client_id = "12345" +application_url = "https://example.com" +embedded = true +handle = "handle" + +[webhooks] +api_version = "2023-07" + + [[webhooks.subscriptions]] + uri = "https://my-app.com/webhooks" + topics = [ "products/create" ] + +[build] +include_config_on_deploy = true +automatically_update_urls_on_dev = true +dev_store_url = "my-store.myshopify.com" + +[access_scopes] +# Learn more at https://shopify.dev/docs/apps/tools/cli/configuration#access_scopes +scopes = "write_products" +use_legacy_install_flow = true + +[auth] +redirect_urls = [ "https://example.com/callback1" ] + +[pos] +embedded = false +" +`; + +exports[`link > existing config is respected when isNewApp is true, config is current and client_id is not the same as remote app 1`] = ` +"# Learn more about configuring your app at https://shopify.dev/docs/apps/tools/cli/configuration + +name = "app1" +client_id = "12345" +application_url = "https://example.com" +embedded = true +handle = "handle" + +[webhooks] +api_version = "2023-07" + + [[webhooks.subscriptions]] + uri = "https://my-app.com/webhooks" + topics = [ "products/create" ] + +[build] +automatically_update_urls_on_dev = true +dev_store_url = "my-store.myshopify.com" +include_config_on_deploy = true + +[access_scopes] +# Learn more at https://shopify.dev/docs/apps/tools/cli/configuration#access_scopes +scopes = "write_products" +use_legacy_install_flow = true + +[auth] +redirect_urls = [ "https://example.com/callback1" ] + +[pos] +embedded = false +" +`; + +exports[`link > existing config is respected when isNewApp is true, config is current and client_id is not the same as remote app and config name is provided 1`] = ` +"# Learn more about configuring your app at https://shopify.dev/docs/apps/tools/cli/configuration + +name = "app1" +client_id = "12345" +application_url = "https://example.com" +embedded = true +handle = "handle" + +[webhooks] +api_version = "2023-07" + + [[webhooks.subscriptions]] + uri = "https://my-app.com/webhooks" + topics = [ "products/create" ] + +[build] +automatically_update_urls_on_dev = true +dev_store_url = "my-store.myshopify.com" +include_config_on_deploy = true + +[access_scopes] +# Learn more at https://shopify.dev/docs/apps/tools/cli/configuration#access_scopes +scopes = "write_products" +use_legacy_install_flow = true + +[auth] +redirect_urls = [ "https://example.com/callback1" ] + +[pos] +embedded = false +" +`; + +exports[`link > fetches the privacy compliance webhooks from the configuration module 1`] = ` +"# 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 + +[auth] +redirect_urls = [ "https://example.com/callback1" ] + +[webhooks] +api_version = "2023-07" + + [[webhooks.subscriptions]] + uri = "/customers" + compliance_topics = [ "customers/redact", "customers/data_request" ] + +[pos] +embedded = false + +[access_scopes] +# Learn more at https://shopify.dev/docs/apps/tools/cli/configuration#access_scopes +use_legacy_install_flow = true +" +`; + +exports[`link > generates the file when there is no shopify.app.toml 1`] = ` +"# 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 + +[auth] +redirect_urls = [ "https://example.com/callback1" ] + +[webhooks] +api_version = "2023-07" + +[pos] +embedded = false + +[access_scopes] +# Learn more at https://shopify.dev/docs/apps/tools/cli/configuration#access_scopes +use_legacy_install_flow = true +" +`; + +exports[`link > replace arrays content with the remote one 1`] = ` +"# Learn more about configuring your app at https://shopify.dev/docs/apps/tools/cli/configuration + +application_url = "https://example.com" +client_id = "12345" +name = "app1" +embedded = true + +[webhooks] +api_version = "2023-07" + +[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/remote" ] + +[pos] +embedded = false +" +`; + +exports[`link > simplifies the webhook config using relative paths 1`] = ` +"# Learn more about configuring your app at https://shopify.dev/docs/apps/tools/cli/configuration + +application_url = "https://my-app-url.com" +client_id = "12345" +name = "app1" +embedded = true + +[webhooks] +api_version = "2023-07" + + [[webhooks.subscriptions]] + uri = "/webhooks" + topics = [ "products/create", "products/update" ] + +[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/callback1" ] + +[pos] +embedded = false +" +`; + +exports[`link > skips config name question if re-linking to existing current app schema 1`] = ` +"# Learn more about configuring your app at https://shopify.dev/docs/apps/tools/cli/configuration + +name = "app1" +client_id = "12345" +application_url = "https://example.com" +embedded = true + +[webhooks] +api_version = "2023-07" + +[access_scopes] +# Learn more at https://shopify.dev/docs/apps/tools/cli/configuration#access_scopes +scopes = "write_products" +use_legacy_install_flow = true + +[auth] +redirect_urls = [ "https://example.com/callback1" ] + +[pos] +embedded = false +" +`; + +exports[`link > the api client configuration is deep merged with the remote app_config extension registrations 1`] = ` +"# Learn more about configuring your app at https://shopify.dev/docs/apps/tools/cli/configuration + +name = "my app" +client_id = "12345" +application_url = "https://myapp.com" +embedded = true + +[webhooks] +api_version = "2023-07" + + [[webhooks.subscriptions]] + uri = "https://my-app.com/webhooks" + topics = [ "products/create" ] + +[auth] +redirect_urls = [ "https://example.com/callback1" ] + +[pos] +embedded = true + +[access_scopes] +# Learn more at https://shopify.dev/docs/apps/tools/cli/configuration#access_scopes +scopes = "write_products" +" +`; + +exports[`link > the local configuration is discarded if the client_id is different from the remote one 1`] = ` +"# Learn more about configuring your app at https://shopify.dev/docs/apps/tools/cli/configuration + +client_id = "different-api-key" +name = "my app" +application_url = "https://myapp.com" +embedded = true + +[auth] +redirect_urls = [ "https://example.com/callback1" ] + +[webhooks] +api_version = "2023-07" + +[pos] +embedded = false + +[access_scopes] +# Learn more at https://shopify.dev/docs/apps/tools/cli/configuration#access_scopes +scopes = "write_products" +" +`; + +exports[`link > updates the shopify.app.toml when it already exists and is unlinked 1`] = ` +"# Learn more about configuring your app at https://shopify.dev/docs/apps/tools/cli/configuration + +application_url = "https://example.com" +client_id = "12345" +name = "app1" +embedded = true + +[webhooks] +api_version = "2023-07" + +[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/callback1" ] + +[pos] +embedded = false +" +`; + +exports[`link > uses scopes on platform if defined 1`] = ` +"# Learn more about configuring your app at https://shopify.dev/docs/apps/tools/cli/configuration + +application_url = "https://example.com" +client_id = "12345" +name = "app1" +embedded = true + +[webhooks] +api_version = "2023-07" + +[access_scopes] +# Learn more at https://shopify.dev/docs/apps/tools/cli/configuration#access_scopes +scopes = "read_products,write_orders" +use_legacy_install_flow = true + +[auth] +redirect_urls = [ "https://example.com/callback1" ] + +[pos] +embedded = false +" +`; + +exports[`link > uses the api client configuration in case there is no configuration app modules 1`] = ` +"# 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 + +[pos] +embedded = true + +[webhooks] +api_version = "2023-07" + + [webhooks.privacy_compliance] + customer_data_request_url = "https://api-client-config.com/customer-data-request" + customer_deletion_url = "https://api-client-config.com/customer-deletion" + shop_deletion_url = "https://api-client-config.com/shop-deletion" + +[auth] +redirect_urls = [ "https://api-client-config.com/callback" ] + +[access_scopes] +# Learn more at https://shopify.dev/docs/apps/tools/cli/configuration#access_scopes +scopes = "write_products" + +[app_proxy] +url = "https://api-client-config.com/proxy" +subpath = "/api" +prefix = "prefix" + +[app_preferences] +url = "https://api-client-config.com/preferences" + +[build] +include_config_on_deploy = true +" +`; + +exports[`link > when local app doesnt include build section and the remote app is new then include include_config_on_deploy is added 1`] = ` +"# Learn more about configuring your app at https://shopify.dev/docs/apps/tools/cli/configuration + +application_url = "https://example.com" +client_id = "12345" +name = "app1" +embedded = true + +[webhooks] +api_version = "2023-07" + +[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/callback1" ] + +[pos] +embedded = false + +[build] +include_config_on_deploy = true +" +`; + +exports[`link > when remote app is new and supports dev sessions then include automatically_update_urls_on_dev = true 1`] = ` +"# Learn more about configuring your app at https://shopify.dev/docs/apps/tools/cli/configuration + +application_url = "https://example.com" +client_id = "12345" +name = "app1" +embedded = true + +[webhooks] +api_version = "2023-07" + +[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/callback1" ] + +[pos] +embedded = false + +[build] +include_config_on_deploy = true +automatically_update_urls_on_dev = true +" +`; + +exports[`link > write in the toml configuration fields not typed 1`] = ` +"# Learn more about configuring your app at https://shopify.dev/docs/apps/tools/cli/configuration + +application_url = "https://example.com" +client_id = "12345" +name = "app1" +embedded = true +handle = "handle" + +[webhooks] +api_version = "2023-07" + +[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/callback1" ] + +[pos] +embedded = false +" +`; 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 11dc5e9308..5ef588082d 100644 --- a/packages/app/src/cli/services/app/config/link.test.ts +++ b/packages/app/src/cli/services/app/config/link.test.ts @@ -138,27 +138,6 @@ describe('link', () => { // Then expect(selectConfigName).not.toHaveBeenCalled() const content = await readFile(joinPath(tmp, 'shopify.app.staging.toml')) - const expectedContent = `# 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/callback1" ] - -[webhooks] -api_version = "2023-07" - -[pos] -embedded = false -` expect(configuration).toEqual({ client_id: '12345', name: 'app1', @@ -178,7 +157,7 @@ embedded = false embedded: false, }, }) - expect(content).toEqual(expectedContent) + expect(content).toMatchSnapshot() }) }) @@ -223,29 +202,6 @@ embedded = false // 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 = "app1" -application_url = "https://example.com" -embedded = true - -[build] -include_config_on_deploy = 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/callback1" ] - -[webhooks] -api_version = "2023-07" - -[pos] -embedded = false -` expect(setCurrentConfigPreference).toHaveBeenCalledWith(configuration, { configFileName: 'shopify.app.toml', directory: tmp, @@ -287,7 +243,7 @@ embedded = false include_config_on_deploy: true, }, }) - expect(content).toEqual(expectedContent) + expect(content).toMatchSnapshot() expect(configFileName).toBe('shopify.app.toml') }) }) @@ -333,42 +289,6 @@ embedded = false // 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(setCurrentConfigPreference).toHaveBeenCalledWith(configuration, { configFileName: 'shopify.app.toml', @@ -424,7 +344,7 @@ url = "https://api-client-config.com/preferences" include_config_on_deploy: true, }, }) - expect(content).toEqual(expectedContent) + expect(content).toMatchSnapshot() }) }) @@ -474,31 +394,6 @@ url = "https://api-client-config.com/preferences" // Then const content = await readFile(joinPath(tmp, 'shopify.app.staging.toml')) - const expectedContent = `# Learn more about configuring your app at https://shopify.dev/docs/apps/tools/cli/configuration - -client_id = "12345" -name = "my app" -application_url = "https://myapp.com" -embedded = true - -[build] -automatically_update_urls_on_dev = true -dev_store_url = "my-store.myshopify.com" -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://example.com/callback1" ] - -[webhooks] -api_version = "2023-07" - -[pos] -embedded = false -` expect(setCurrentConfigPreference).toHaveBeenCalledWith(configuration, { configFileName: 'shopify.app.staging.toml', @@ -543,7 +438,7 @@ embedded = false include_config_on_deploy: true, }, }) - expect(content).toEqual(expectedContent) + expect(content).toMatchSnapshot() }) }) @@ -590,26 +485,6 @@ embedded = false // Then const content = await readFile(joinPath(tmp, 'shopify.app.staging.toml')) - const expectedContent = `# Learn more about configuring your app at https://shopify.dev/docs/apps/tools/cli/configuration - -client_id = "different-api-key" -name = "my app" -application_url = "https://myapp.com" -embedded = true - -[access_scopes] -# Learn more at https://shopify.dev/docs/apps/tools/cli/configuration#access_scopes -scopes = "write_products" - -[auth] -redirect_urls = [ "https://example.com/callback1" ] - -[webhooks] -api_version = "2023-07" - -[pos] -embedded = false -` expect(configuration).toEqual({ client_id: 'different-api-key', name: 'my app', @@ -628,7 +503,7 @@ embedded = false embedded: false, }, }) - expect(content).toEqual(expectedContent) + expect(content).toMatchSnapshot() }) }) @@ -652,27 +527,6 @@ embedded = false // 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 = "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/callback1" ] - -[webhooks] -api_version = "2023-07" - -[pos] -embedded = false -` expect(renderSuccess).toHaveBeenCalledWith({ headline: 'shopify.app.toml is now linked to "app1" on Shopify', body: 'Using shopify.app.toml as your default config.', @@ -708,7 +562,7 @@ embedded = false embedded: false, }, }) - expect(content).toEqual(expectedContent) + expect(content).toMatchSnapshot() }) }) @@ -732,27 +586,6 @@ embedded = false // 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 = "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/callback1" ] - -[webhooks] -api_version = "2023-07" - -[pos] -embedded = false -` expect(renderSuccess).not.toHaveBeenCalled() expect(configuration).toEqual({ client_id: '12345', @@ -773,7 +606,7 @@ embedded = false embedded: false, }, }) - expect(content).toEqual(expectedContent) + expect(content).toMatchSnapshot() }) }) @@ -881,27 +714,6 @@ embedded = false configFileName: 'shopify.app.foo.toml', directory: tmp, }) - const expectedContent = `# 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 = "write_products" -use_legacy_install_flow = true - -[auth] -redirect_urls = [ "https://example.com/callback1" ] - -[webhooks] -api_version = "2023-07" - -[pos] -embedded = false -` expect(configuration).toEqual({ client_id: '12345', name: 'app1', @@ -921,7 +733,7 @@ embedded = false embedded: false, }, }) - expect(content).toEqual(expectedContent) + expect(content).toMatchSnapshot() }) }) @@ -941,26 +753,6 @@ embedded = false // 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 = "app1" -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 = true - -[auth] -redirect_urls = [ "https://example.com/callback1" ] - -[webhooks] -api_version = "2023-07" - -[pos] -embedded = false -` expect(configuration).toEqual({ client_id: '12345', @@ -980,7 +772,7 @@ embedded = false embedded: false, }, }) - expect(content).toEqual(expectedContent) + expect(content).toMatchSnapshot() }) }) @@ -1005,27 +797,6 @@ embedded = false // 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 = "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,write_orders" -use_legacy_install_flow = true - -[auth] -redirect_urls = [ "https://example.com/callback1" ] - -[webhooks] -api_version = "2023-07" - -[pos] -embedded = false -` expect(configuration).toEqual({ client_id: '12345', @@ -1046,7 +817,7 @@ embedded = false embedded: false, }, }) - expect(content).toEqual(expectedContent) + expect(content).toMatchSnapshot() }) }) @@ -1103,30 +874,6 @@ embedded = false // 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 = "app1" -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 = true - -[auth] -redirect_urls = [ "https://example.com/callback1" ] - -[webhooks] -api_version = "2023-07" - - [[webhooks.subscriptions]] - uri = "/customers" - compliance_topics = [ "customers/redact", "customers/data_request" ] - -[pos] -embedded = false -` expect(setCurrentConfigPreference).toHaveBeenCalledWith(configuration, { configFileName: 'shopify.app.toml', @@ -1153,6 +900,7 @@ embedded = false name: 'app1', application_url: 'https://example.com', embedded: true, + build: undefined, access_scopes: { use_legacy_install_flow: true, }, @@ -1164,7 +912,7 @@ embedded = false subscriptions: [ { compliance_topics: ['customers/redact', 'customers/data_request'], - uri: '/customers', + uri: 'https://example.com/customers', }, ], }, @@ -1172,7 +920,7 @@ embedded = false embedded: false, }, }) - expect(content).toEqual(expectedContent) + expect(content).toMatchSnapshot() }) }) @@ -1247,31 +995,6 @@ embedded = false // 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 = "app1" -application_url = "https://my-app-url.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/callback1" ] - -[webhooks] -api_version = "2023-07" - - [[webhooks.subscriptions]] - topics = [ "products/create", "products/update" ] - uri = "/webhooks" - -[pos] -embedded = false -` expect(configuration).toEqual({ client_id: '12345', @@ -1290,8 +1013,12 @@ embedded = false api_version: '2023-07', subscriptions: [ { - topics: ['products/create', 'products/update'], - uri: '/webhooks', + topics: ['products/create'], + uri: 'https://my-app-url.com/webhooks', + }, + { + topics: ['products/update'], + uri: 'https://my-app-url.com/webhooks', }, ], }, @@ -1299,7 +1026,7 @@ embedded = false embedded: false, }, }) - expect(content).toEqual(expectedContent) + expect(content).toMatchSnapshot() }) }) @@ -1349,31 +1076,7 @@ embedded = false // Then const content = await readFile(joinPath(tmp, 'shopify.app.staging.toml')) - const expectedContent = `# Learn more about configuring your app at https://shopify.dev/docs/apps/tools/cli/configuration - -client_id = "12345" -name = "my app" -application_url = "https://myapp.com" -embedded = true - -[access_scopes] -# Learn more at https://shopify.dev/docs/apps/tools/cli/configuration#access_scopes -scopes = "write_products" - -[auth] -redirect_urls = [ "https://example.com/callback1" ] - -[webhooks] -api_version = "2023-07" - - [[webhooks.subscriptions]] - topics = [ "products/create" ] - uri = "https://my-app.com/webhooks" - -[pos] -embedded = true -` - expect(content).toEqual(expectedContent) + expect(content).toMatchSnapshot() expect(renderSuccess).toHaveBeenCalledWith({ headline: 'shopify.app.staging.toml is now linked to "my app" on Shopify', body: 'Using shopify.app.staging.toml as your default config.', @@ -1417,31 +1120,7 @@ embedded = true // 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 = "app1" -application_url = "https://example.com" -embedded = true - -[build] -include_config_on_deploy = 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/callback1" ] - -[webhooks] -api_version = "2023-07" - -[pos] -embedded = false -` - expect(content).toEqual(expectedContent) + expect(content).toMatchSnapshot() }) }) @@ -1469,32 +1148,7 @@ embedded = false // 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 = "app1" -application_url = "https://example.com" -embedded = true - -[build] -automatically_update_urls_on_dev = true -include_config_on_deploy = 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/callback1" ] - -[webhooks] -api_version = "2023-07" - -[pos] -embedded = false -` - expect(content).toEqual(expectedContent) + expect(content).toMatchSnapshot() }) }) @@ -1521,28 +1175,7 @@ embedded = false // 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 = "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/remote" ] - -[webhooks] -api_version = "2023-07" - -[pos] -embedded = false -` - expect(content).toEqual(expectedContent) + expect(content).toMatchSnapshot() }) }) @@ -1567,29 +1200,7 @@ embedded = false // 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 = "app1" -handle = "handle" -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/callback1" ] - -[webhooks] -api_version = "2023-07" - -[pos] -embedded = false -` - expect(content).toEqual(expectedContent) + expect(content).toMatchSnapshot() }) }) @@ -1637,38 +1248,7 @@ embedded = false // 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 = "app1" -handle = "handle" -application_url = "https://example.com" -embedded = true - -[build] -automatically_update_urls_on_dev = true -dev_store_url = "my-store.myshopify.com" -include_config_on_deploy = true - -[access_scopes] -# Learn more at https://shopify.dev/docs/apps/tools/cli/configuration#access_scopes -scopes = "write_products" -use_legacy_install_flow = true - -[auth] -redirect_urls = [ "https://example.com/callback1" ] - -[webhooks] -api_version = "2023-07" - - [[webhooks.subscriptions]] - topics = [ "products/create" ] - uri = "https://my-app.com/webhooks" - -[pos] -embedded = false -` - expect(content).toEqual(expectedContent) + expect(content).toMatchSnapshot() }) }) @@ -1716,38 +1296,7 @@ embedded = false // Then const content = await readFile(joinPath(tmp, 'shopify.app.staging.toml')) - const expectedContent = `# Learn more about configuring your app at https://shopify.dev/docs/apps/tools/cli/configuration - -client_id = "12345" -name = "app1" -handle = "handle" -application_url = "https://example.com" -embedded = true - -[build] -automatically_update_urls_on_dev = true -dev_store_url = "my-store.myshopify.com" -include_config_on_deploy = true - -[access_scopes] -# Learn more at https://shopify.dev/docs/apps/tools/cli/configuration#access_scopes -scopes = "write_products" -use_legacy_install_flow = true - -[auth] -redirect_urls = [ "https://example.com/callback1" ] - -[webhooks] -api_version = "2023-07" - - [[webhooks.subscriptions]] - topics = [ "products/create" ] - uri = "https://my-app.com/webhooks" - -[pos] -embedded = false -` - expect(content).toEqual(expectedContent) + expect(content).toMatchSnapshot() }) }) @@ -1795,38 +1344,7 @@ embedded = false // 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 = "app1" -handle = "handle" -application_url = "https://example.com" -embedded = true - -[build] -automatically_update_urls_on_dev = true -dev_store_url = "my-store.myshopify.com" -include_config_on_deploy = true - -[access_scopes] -# Learn more at https://shopify.dev/docs/apps/tools/cli/configuration#access_scopes -scopes = "write_products" -use_legacy_install_flow = true - -[auth] -redirect_urls = [ "https://example.com/callback1" ] - -[webhooks] -api_version = "2023-07" - - [[webhooks.subscriptions]] - topics = [ "products/create" ] - uri = "https://my-app.com/webhooks" - -[pos] -embedded = false -` - expect(content).toEqual(expectedContent) + expect(content).toMatchSnapshot() }) }) }) 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..53e77c5579 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]] + uri = "/webhooks" + topics = [ "products/create" ] + 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({name: 'hello', empty: {}})).toEqual({name: 'hello'}) + }) + + test('removes nested empty objects', () => { + expect(stripEmptyObjects({outer: {inner: {}}})).toEqual({}) + }) + + test('preserves empty arrays', () => { + expect(stripEmptyObjects({items: []})).toEqual({items: []}) + }) + + test('preserves null and undefined', () => { + expect(stripEmptyObjects(null)).toBeNull() + expect(stripEmptyObjects(undefined)).toBeUndefined() + }) + + test('recurses into arrays', () => { + expect(stripEmptyObjects({items: [{val: 1, empty: {}}, {val: 2}]})).toEqual({items: [{val: 1}, {val: 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..e023444610 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 @@ -6,25 +6,52 @@ 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(structuredClone(configuration)) + const cleaned = stripEmptyObjects(condensed) as JsonMapType const file = new TomlFile(configPath, {}) - await file.replace(sorted) + await file.replace(cleaned) await file.transformRaw(addDefaultCommentsToToml) } +/** + * 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 + } + result[key] = stripped + } + return result + } + return obj +} + +/** + * Rewrite a configuration object to match the structure of a Zod schema. + * + * Used by breakdown-extensions.ts to normalize configs before diffing. + * Not used by writeAppConfigurationFile — that function uses stripEmptyObjects instead. + */ export const rewriteConfiguration = (schema: T, config: unknown): unknown => { if (schema === null || schema === undefined) return null if (schema instanceof zod.ZodNullable || schema instanceof zod.ZodOptional) @@ -91,7 +118,7 @@ function addDefaultCommentsToToml(fileString: string) { */ function condenseComplianceAndNonComplianceWebhooks(config: CurrentAppConfiguration) { const webhooksConfig = config.webhooks - if (webhooksConfig?.subscriptions?.length) { + if (Array.isArray(webhooksConfig?.subscriptions) && webhooksConfig.subscriptions.length) { const appUrl = removeTrailingSlash(config?.application_url) as string | undefined webhooksConfig.subscriptions = reduceWebhooks(webhooksConfig.subscriptions) webhooksConfig.subscriptions = webhooksConfig.subscriptions.map(({uri, ...subscription}) => ({ From 4aa594a0e8c34174a3bde8b22854224374d499b3 Mon Sep 17 00:00:00 2001 From: Liz Kenyon Date: Wed, 1 Apr 2026 16:39:42 -0500 Subject: [PATCH 2/2] Strengthen type-mismatched test to verify file content --- .../cli/services/app/write-app-configuration-file.test.ts | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) 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 53e77c5579..6835542f67 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 @@ -155,9 +155,11 @@ url = "https://example.com/prefs" webhooks: {api_version: '2023-07', subscriptions: 'also-not-an-array'}, } - await expect( - writeAppConfigurationFile(malformedConfig as unknown as CurrentAppConfiguration, filePath), - ).resolves.not.toThrow() + await writeAppConfigurationFile(malformedConfig as unknown as CurrentAppConfiguration, filePath) + + const content = await readFile(filePath) + expect(content).toContain('client_id') + expect(content).toContain('api-key') }) }) })