diff --git a/packages/app/src/cli/api/graphql/extension_specifications.ts b/packages/app/src/cli/api/graphql/extension_specifications.ts index c427072796..24d3582aba 100644 --- a/packages/app/src/cli/api/graphql/extension_specifications.ts +++ b/packages/app/src/cli/api/graphql/extension_specifications.ts @@ -30,7 +30,11 @@ export interface ExtensionSpecificationsQueryVariables { apiKey: string } -export interface RemoteSpecification { +/** + * Raw shape returned by the GraphQL extensionSpecifications query (Partners API). + * Has nested `options` and `features` matching the query structure. + */ +export interface RawRemoteSpecification { name: string externalName: string identifier: string @@ -40,8 +44,6 @@ export interface RemoteSpecification { options: { managementExperience: 'cli' | 'custom' | 'dashboard' registrationLimit: number - uidIsClientProvided: boolean - uidStrategy?: 'single' | 'dynamic' | 'uuid' } features?: { argo?: { @@ -53,11 +55,32 @@ export interface RemoteSpecification { } | null } -export interface FlattenedRemoteSpecification extends RemoteSpecification { - surface?: string +/** + * Flattened remote specification used throughout the CLI. + * Options are flattened to top-level fields to align with ExtensionSpecification. + */ +export interface RemoteSpecification { + name: string + externalName: string + identifier: string + gated: boolean + externalIdentifier: string + experience: 'extension' | 'configuration' | 'deprecated' + managementExperience: 'cli' | 'custom' | 'dashboard' registrationLimit: number + uidIsClientProvided: boolean + uidStrategy?: 'single' | 'dynamic' | 'uuid' + surface?: string + features?: { + argo?: { + surface: string + } + } + validationSchema?: { + jsonSchema: string + } | null } export interface ExtensionSpecificationsQuerySchema { - extensionSpecifications: RemoteSpecification[] + extensionSpecifications: RawRemoteSpecification[] } diff --git a/packages/app/src/cli/models/app/app.test-data.ts b/packages/app/src/cli/models/app/app.test-data.ts index f60e603a1f..8d0eb89c91 100644 --- a/packages/app/src/cli/models/app/app.test-data.ts +++ b/packages/app/src/cli/models/app/app.test-data.ts @@ -694,11 +694,9 @@ const testRemoteSpecifications: RemoteSpecification[] = [ externalIdentifier: 'checkout_post_purchase_external', gated: false, experience: 'extension', - options: { - managementExperience: 'cli', - registrationLimit: 1, - uidIsClientProvided: true, - }, + managementExperience: 'cli', + registrationLimit: 1, + uidIsClientProvided: true, features: { argo: { surface: 'checkout', @@ -712,11 +710,9 @@ const testRemoteSpecifications: RemoteSpecification[] = [ externalIdentifier: 'theme_external', gated: false, experience: 'extension', - options: { - managementExperience: 'cli', - registrationLimit: 1, - uidIsClientProvided: true, - }, + managementExperience: 'cli', + registrationLimit: 1, + uidIsClientProvided: true, }, { name: 'Product Subscription', @@ -725,11 +721,9 @@ const testRemoteSpecifications: RemoteSpecification[] = [ externalIdentifier: 'product_subscription_external', gated: false, experience: 'extension', - options: { - managementExperience: 'cli', - registrationLimit: 1, - uidIsClientProvided: true, - }, + managementExperience: 'cli', + registrationLimit: 1, + uidIsClientProvided: true, features: { argo: { surface: 'admin', @@ -743,11 +737,9 @@ const testRemoteSpecifications: RemoteSpecification[] = [ externalIdentifier: 'ui_extension_external', gated: false, experience: 'extension', - options: { - managementExperience: 'cli', - registrationLimit: 50, - uidIsClientProvided: true, - }, + managementExperience: 'cli', + registrationLimit: 50, + uidIsClientProvided: true, features: { argo: { surface: 'all', @@ -761,11 +753,9 @@ const testRemoteSpecifications: RemoteSpecification[] = [ externalIdentifier: 'checkout_ui_extension_external', gated: false, experience: 'extension', - options: { - managementExperience: 'cli', - registrationLimit: 5, - uidIsClientProvided: true, - }, + managementExperience: 'cli', + registrationLimit: 5, + uidIsClientProvided: true, features: { argo: { surface: 'checkout', @@ -781,11 +771,9 @@ const testRemoteSpecifications: RemoteSpecification[] = [ externalIdentifier: 'product_subscription_external', gated: false, experience: 'extension', - options: { - managementExperience: 'cli', - registrationLimit: 1, - uidIsClientProvided: true, - }, + managementExperience: 'cli', + registrationLimit: 1, + uidIsClientProvided: true, features: { argo: { surface: 'admin', @@ -799,11 +787,9 @@ const testRemoteSpecifications: RemoteSpecification[] = [ externalIdentifier: 'marketing_activity_extension_external', gated: false, experience: 'extension', - options: { - managementExperience: 'dashboard', - registrationLimit: 100, - uidIsClientProvided: true, - }, + managementExperience: 'dashboard', + registrationLimit: 100, + uidIsClientProvided: true, }, { name: 'function', @@ -812,11 +798,9 @@ const testRemoteSpecifications: RemoteSpecification[] = [ externalIdentifier: 'function', gated: false, experience: 'extension', - options: { - managementExperience: 'cli', - registrationLimit: 1, - uidIsClientProvided: true, - }, + managementExperience: 'cli', + registrationLimit: 1, + uidIsClientProvided: true, features: { argo: { surface: 'checkout', @@ -830,11 +814,9 @@ const testRemoteSpecifications: RemoteSpecification[] = [ externalIdentifier: 'editor_extension_collection_external', gated: false, experience: 'extension', - options: { - managementExperience: 'cli', - registrationLimit: 100, - uidIsClientProvided: true, - }, + managementExperience: 'cli', + registrationLimit: 100, + uidIsClientProvided: true, }, { name: 'Flow Action', @@ -843,11 +825,9 @@ const testRemoteSpecifications: RemoteSpecification[] = [ externalIdentifier: 'flow_action', gated: true, experience: 'extension', - options: { - managementExperience: 'cli', - registrationLimit: 100, - uidIsClientProvided: true, - }, + managementExperience: 'cli', + registrationLimit: 100, + uidIsClientProvided: true, }, { name: 'Flow Template', @@ -856,11 +836,9 @@ const testRemoteSpecifications: RemoteSpecification[] = [ identifier: 'flow_template', gated: true, experience: 'extension', - options: { - managementExperience: 'cli', - registrationLimit: 300, - uidIsClientProvided: true, - }, + managementExperience: 'cli', + registrationLimit: 300, + uidIsClientProvided: true, }, { name: 'Flow Trigger', @@ -869,11 +847,9 @@ const testRemoteSpecifications: RemoteSpecification[] = [ identifier: 'flow_trigger', gated: true, experience: 'extension', - options: { - managementExperience: 'cli', - registrationLimit: 100, - uidIsClientProvided: true, - }, + managementExperience: 'cli', + registrationLimit: 100, + uidIsClientProvided: true, }, { name: 'POS UI Extension', @@ -882,11 +858,9 @@ const testRemoteSpecifications: RemoteSpecification[] = [ identifier: 'pos_ui_extension', gated: false, experience: 'extension', - options: { - managementExperience: 'cli', - registrationLimit: 50, - uidIsClientProvided: true, - }, + managementExperience: 'cli', + registrationLimit: 50, + uidIsClientProvided: true, }, { name: 'Web Pixel Extension', @@ -895,11 +869,9 @@ const testRemoteSpecifications: RemoteSpecification[] = [ identifier: 'web_pixel_extension', gated: false, experience: 'extension', - options: { - managementExperience: 'cli', - registrationLimit: 1, - uidIsClientProvided: true, - }, + managementExperience: 'cli', + registrationLimit: 1, + uidIsClientProvided: true, }, { name: 'Branding', @@ -908,11 +880,9 @@ const testRemoteSpecifications: RemoteSpecification[] = [ identifier: 'branding', gated: false, experience: 'configuration', - options: { - managementExperience: 'cli', - registrationLimit: 1, - uidIsClientProvided: false, - }, + managementExperience: 'cli', + registrationLimit: 1, + uidIsClientProvided: false, }, { name: 'App access', @@ -921,11 +891,9 @@ const testRemoteSpecifications: RemoteSpecification[] = [ identifier: 'app_access', gated: false, experience: 'configuration', - options: { - managementExperience: 'cli', - registrationLimit: 1, - uidIsClientProvided: false, - }, + managementExperience: 'cli', + registrationLimit: 1, + uidIsClientProvided: false, }, { name: 'Webhooks', @@ -934,11 +902,9 @@ const testRemoteSpecifications: RemoteSpecification[] = [ identifier: 'webhooks', gated: false, experience: 'configuration', - options: { - managementExperience: 'cli', - registrationLimit: 1, - uidIsClientProvided: false, - }, + managementExperience: 'cli', + registrationLimit: 1, + uidIsClientProvided: false, }, { name: 'Privacy Compliance Webhooks', @@ -947,11 +913,9 @@ const testRemoteSpecifications: RemoteSpecification[] = [ identifier: 'privacy_compliance_webhooks', gated: false, experience: 'configuration', - options: { - managementExperience: 'cli', - registrationLimit: 1, - uidIsClientProvided: false, - }, + managementExperience: 'cli', + registrationLimit: 1, + uidIsClientProvided: false, }, { name: 'App Proxy', @@ -960,11 +924,9 @@ const testRemoteSpecifications: RemoteSpecification[] = [ identifier: 'app_proxy', gated: false, experience: 'configuration', - options: { - managementExperience: 'cli', - registrationLimit: 1, - uidIsClientProvided: false, - }, + managementExperience: 'cli', + registrationLimit: 1, + uidIsClientProvided: false, }, { name: 'Point Of Sale Configuration', @@ -973,11 +935,9 @@ const testRemoteSpecifications: RemoteSpecification[] = [ identifier: 'point_of_sale', gated: false, experience: 'configuration', - options: { - managementExperience: 'cli', - registrationLimit: 1, - uidIsClientProvided: false, - }, + managementExperience: 'cli', + registrationLimit: 1, + uidIsClientProvided: false, }, { name: 'App Home', @@ -986,11 +946,9 @@ const testRemoteSpecifications: RemoteSpecification[] = [ identifier: 'app_home', gated: false, experience: 'configuration', - options: { - managementExperience: 'cli', - registrationLimit: 1, - uidIsClientProvided: false, - }, + managementExperience: 'cli', + registrationLimit: 1, + uidIsClientProvided: false, }, { name: 'Remote Extension Without Schema and Without local spec', @@ -999,11 +957,9 @@ const testRemoteSpecifications: RemoteSpecification[] = [ externalIdentifier: 'remote_only_extension_without_schema_external', gated: false, experience: 'extension', - options: { - managementExperience: 'cli', - registrationLimit: 1, - uidIsClientProvided: true, - }, + managementExperience: 'cli', + registrationLimit: 1, + uidIsClientProvided: true, }, { name: 'Remote Extension With Schema, Without local spec, without localization', @@ -1012,11 +968,9 @@ const testRemoteSpecifications: RemoteSpecification[] = [ externalIdentifier: 'remote_only_extension_schema_external', gated: false, experience: 'extension', - options: { - managementExperience: 'cli', - registrationLimit: 1, - uidIsClientProvided: true, - }, + managementExperience: 'cli', + registrationLimit: 1, + uidIsClientProvided: true, validationSchema: { jsonSchema: '{"$schema":"http://json-schema.org/draft-07/schema#","type":"object","additionalProperties":false,"properties":{"pattern":{"type":"string"},"name":{"type":"string"}},"required":["pattern"]}', @@ -1029,11 +983,9 @@ const testRemoteSpecifications: RemoteSpecification[] = [ externalIdentifier: 'remote_only_extension_schema_with_localization_external', gated: false, experience: 'extension', - options: { - managementExperience: 'cli', - registrationLimit: 1, - uidIsClientProvided: true, - }, + managementExperience: 'cli', + registrationLimit: 1, + uidIsClientProvided: true, validationSchema: { jsonSchema: '{"$schema":"http://json-schema.org/draft-07/schema#","type":"object","additionalProperties":false,"properties":{"pattern":{"type":"string"},"name":{"type":"string"},"localization":{"type":"object","properties":{"marketing_channel":{"type":"string"}},"required":["marketing_channel"]}},"required":["pattern","localization"]}', @@ -1046,11 +998,9 @@ const testRemoteSpecifications: RemoteSpecification[] = [ externalIdentifier: 'remote_only_extension_schema_config_style_external', gated: false, experience: 'configuration', - options: { - managementExperience: 'cli', - registrationLimit: 1, - uidIsClientProvided: false, - }, + managementExperience: 'cli', + registrationLimit: 1, + uidIsClientProvided: false, validationSchema: { jsonSchema: '{"$schema":"http://json-schema.org/draft-07/schema#","type":"object","additionalProperties":false,"properties":{"pattern":{"type":"string"},"name":{"type":"string"}},"required":["pattern"]}', diff --git a/packages/app/src/cli/models/extensions/specification.ts b/packages/app/src/cli/models/extensions/specification.ts index a4564c6889..930117cbf4 100644 --- a/packages/app/src/cli/models/extensions/specification.ts +++ b/packages/app/src/cli/models/extensions/specification.ts @@ -142,10 +142,14 @@ export interface ExtensionSpecification = ExtensionSpecification & { loadedRemoteSpecs: true + validationSchema?: { + jsonSchema: string + } | null } /** diff --git a/packages/app/src/cli/services/generate/fetch-extension-specifications.ts b/packages/app/src/cli/services/generate/fetch-extension-specifications.ts index ea09719743..d8c550eb60 100644 --- a/packages/app/src/cli/services/generate/fetch-extension-specifications.ts +++ b/packages/app/src/cli/services/generate/fetch-extension-specifications.ts @@ -1,5 +1,5 @@ import {loadLocalExtensionsSpecifications} from '../../models/extensions/load-specifications.js' -import {FlattenedRemoteSpecification, RemoteSpecification} from '../../api/graphql/extension_specifications.js' +import {RemoteSpecification} from '../../api/graphql/extension_specifications.js' import { createContractBasedModuleSpecification, ExtensionSpecification, @@ -35,25 +35,23 @@ export async function fetchSpecifications({ }: FetchSpecificationsOptions): Promise { const result: RemoteSpecification[] = await developerPlatformClient.specifications(app) - const extensionSpecifications: FlattenedRemoteSpecification[] = result + const extensionSpecifications: RemoteSpecification[] = result .filter((specification) => ['extension', 'configuration'].includes(specification.experience)) .map((spec) => { - const newSpec = spec as FlattenedRemoteSpecification // WORKAROUND: The identifiers in the API are different for these extensions to the ones the CLI // has been using so far. This is a workaround to keep the CLI working until the API is updated. if (spec.identifier === 'theme_app_extension') spec.identifier = 'theme' if (spec.identifier === 'subscription_management') spec.identifier = 'product_subscription' - newSpec.registrationLimit = spec.options.registrationLimit - newSpec.surface = spec.features?.argo?.surface + spec.surface = spec.features?.argo?.surface // Hardcoded value for the post purchase extension because the value is wrong in the API - if (spec.identifier === 'checkout_post_purchase') newSpec.surface = 'post_purchase' + if (spec.identifier === 'checkout_post_purchase') spec.surface = 'post_purchase' // Hardcoded value for the webhook_subscription extension because the value is wrong in the API if (spec.identifier === 'webhook_subscription') spec.experience = 'configuration' - return newSpec + return spec }) const local = await loadLocalExtensionsSpecifications() @@ -63,7 +61,7 @@ export async function fetchSpecifications({ async function mergeLocalAndRemoteSpecs( local: ExtensionSpecification[], - remote: FlattenedRemoteSpecification[], + remote: RemoteSpecification[], ): Promise { // Iterate over the remote specs and merge them with the local ones // If the local spec is missing, and the remote one has a validation schema, create a new local spec using contracts @@ -74,24 +72,23 @@ async function mergeLocalAndRemoteSpecs( const hasLocalization = normalisedSchema.properties?.localization !== undefined localSpec = createContractBasedModuleSpecification({ identifier: remoteSpec.identifier, - uidStrategy: remoteSpec.options.uidStrategy, + uidStrategy: remoteSpec.uidStrategy, appModuleFeatures: () => (hasLocalization ? ['localization'] : []), }) // Seed uidStrategy for contract specs using uidIsClientProvided as fallback (Partners API path). // This will be overridden below if the backend provides a typename-derived value. localSpec.uidStrategy = - remoteSpec.options.uidStrategy ?? (remoteSpec.options.uidIsClientProvided ? 'uuid' : 'single') + remoteSpec.uidStrategy ?? (remoteSpec.uidIsClientProvided ? 'uuid' : 'single') } if (!localSpec) return undefined - const merged = {...localSpec, ...remoteSpec, loadedRemoteSpecs: true} as RemoteAwareExtensionSpecification & - FlattenedRemoteSpecification + const merged = {...localSpec, ...remoteSpec, loadedRemoteSpecs: true} as RemoteAwareExtensionSpecification // Always prefer the backend-derived uidStrategy (from __typename) when available. // This correctly overrides the local spec's default (e.g. channel_config defaults to 'uuid' // locally but the backend defines it as 'single'). // Falls back to the local spec value for the Partners API path (no __typename available). - merged.uidStrategy = merged.options.uidStrategy ?? localSpec.uidStrategy ?? 'single' + merged.uidStrategy = remoteSpec.uidStrategy ?? localSpec.uidStrategy ?? 'single' // If configuration is inside an app.toml -- i.e. single UID mode -- we must be able to parse a partial slice. // DEPRECATED: not all single specs are config specs. diff --git a/packages/app/src/cli/utilities/developer-platform-client/app-management-client.ts b/packages/app/src/cli/utilities/developer-platform-client/app-management-client.ts index 9ca082bd7f..09888fb123 100644 --- a/packages/app/src/cli/utilities/developer-platform-client/app-management-client.ts +++ b/packages/app/src/cli/utilities/developer-platform-client/app-management-client.ts @@ -459,12 +459,10 @@ export class AppManagementClient implements DeveloperPlatformClient { identifier: spec.identifier, externalIdentifier: spec.externalIdentifier, gated: false, - options: { - managementExperience: 'cli', - registrationLimit: spec.uidStrategy.appModuleLimit, - uidIsClientProvided: spec.uidStrategy.isClientProvided, - uidStrategy: uidStrategyFromTypename(spec.uidStrategy.__typename), - }, + managementExperience: 'cli', + registrationLimit: spec.uidStrategy.appModuleLimit, + uidIsClientProvided: spec.uidStrategy.isClientProvided, + uidStrategy: uidStrategyFromTypename(spec.uidStrategy.__typename), experience: normalizeExperience(spec.experience), validationSchema: spec.validationSchema, }), diff --git a/packages/app/src/cli/utilities/developer-platform-client/partners-client.ts b/packages/app/src/cli/utilities/developer-platform-client/partners-client.ts index c1ecc08c36..71379dd514 100644 --- a/packages/app/src/cli/utilities/developer-platform-client/partners-client.ts +++ b/packages/app/src/cli/utilities/developer-platform-client/partners-client.ts @@ -359,10 +359,9 @@ export class PartnersClient implements DeveloperPlatformClient { // Partners client does not support isClientProvided. Safe to assume that all modules are extension-style. return result.extensionSpecifications.map((spec) => ({ ...spec, - options: { - ...spec.options, - uidIsClientProvided: true, - }, + managementExperience: spec.options.managementExperience, + registrationLimit: spec.options.registrationLimit, + uidIsClientProvided: true, })) } diff --git a/packages/app/src/cli/utilities/json-schema.ts b/packages/app/src/cli/utilities/json-schema.ts index 55855eddd4..8477740a3d 100644 --- a/packages/app/src/cli/utilities/json-schema.ts +++ b/packages/app/src/cli/utilities/json-schema.ts @@ -1,4 +1,3 @@ -import {FlattenedRemoteSpecification} from '../api/graphql/extension_specifications.js' import {BaseConfigType} from '../models/extensions/schemas.js' import {RemoteAwareExtensionSpecification} from '../models/extensions/specification.js' import {ParseConfigurationResult} from '@shopify/cli-kit/node/schema' @@ -32,7 +31,7 @@ const JsonSchemaBaseProperties = { * @returns A function that can parse a configuration object */ export async function unifiedConfigurationParserFactory( - merged: RemoteAwareExtensionSpecification & FlattenedRemoteSpecification, + merged: RemoteAwareExtensionSpecification, handleInvalidAdditionalProperties: HandleInvalidAdditionalProperties = 'strip', ) { const contractJsonSchema = merged.validationSchema?.jsonSchema