diff --git a/packages/app/src/cli/models/extensions/extension-instance.ts b/packages/app/src/cli/models/extensions/extension-instance.ts index 318617ab2b7..acb1e4e9b27 100644 --- a/packages/app/src/cli/models/extensions/extension-instance.ts +++ b/packages/app/src/cli/models/extensions/extension-instance.ts @@ -2,30 +2,52 @@ import {BaseConfigType, MAX_EXTENSION_HANDLE_LENGTH, MAX_UID_LENGTH} from './sch import {FunctionConfigType} from './specifications/function.js' import {ExtensionFeature, ExtensionSpecification} from './specification.js' import {SingleWebhookSubscriptionType} from './specifications/app_config_webhook_schemas/webhooks_schema.js' -import {ExtensionBuildOptions, bundleFunctionExtension} from '../../services/build/extension.js' -import {bundleThemeExtension} from '../../services/extensions/bundle.js' +import {AppHomeSpecIdentifier} from './specifications/app_config_app_home.js' +import {AppAccessSpecIdentifier} from './specifications/app_config_app_access.js' +import {AppProxySpecIdentifier} from './specifications/app_config_app_proxy.js' +import {BrandingSpecIdentifier} from './specifications/app_config_branding.js' +import {PosSpecIdentifier} from './specifications/app_config_point_of_sale.js' +import {PrivacyComplianceWebhooksSpecIdentifier} from './specifications/app_config_privacy_compliance_webhooks.js' +import {WebhooksSpecIdentifier} from './specifications/app_config_webhook.js' +import {WebhookSubscriptionSpecIdentifier} from './specifications/app_config_webhook_subscription.js' +import {EventsSpecIdentifier} from './specifications/app_config_events.js' +import {HostedAppHomeSpecIdentifier} from './specifications/app_config_hosted_app_home.js' +import { + ExtensionBuildOptions, + buildFunctionExtension, + buildThemeExtension, + buildUIExtension, + bundleFunctionExtension, +} from '../../services/build/extension.js' +import {bundleThemeExtension, copyFilesForExtension} from '../../services/extensions/bundle.js' import {Identifiers} from '../app/identifiers.js' import {DeveloperPlatformClient} from '../../utilities/developer-platform-client.js' -import {AppConfiguration} from '../app/app.js' +import {AppConfigurationWithoutPath} from '../app/app.js' import {ApplicationURLs} from '../../services/dev/urls.js' -import {executeStep, BuildContext} from '../../services/build/client-steps.js' import {ok} from '@shopify/cli-kit/node/result' import {constantize, slugify} from '@shopify/cli-kit/common/string' import {hashString, nonRandomUUID} from '@shopify/cli-kit/node/crypto' import {partnersFqdn} from '@shopify/cli-kit/node/context/fqdn' -import {joinPath, normalizePath, resolvePath, relativePath, basename} from '@shopify/cli-kit/node/path' -import {fileExists, moveFile, glob, copyFile, globSync} from '@shopify/cli-kit/node/fs' +import {joinPath, basename, normalizePath, resolvePath} from '@shopify/cli-kit/node/path' +import {fileExists, touchFile, moveFile, writeFile, glob, copyFile, globSync} from '@shopify/cli-kit/node/fs' import {getPathValue} from '@shopify/cli-kit/common/object' import {outputDebug} from '@shopify/cli-kit/node/output' -import { - extractJSImports, - extractImportPathsRecursively, - clearImportPathsCache, - getImportScanningCacheStats, -} from '@shopify/cli-kit/node/import-extractor' -import {isTruthy} from '@shopify/cli-kit/node/context/utilities' +import {extractJSImports, extractImportPathsRecursively} from '@shopify/cli-kit/node/import-extractor' import {uniq} from '@shopify/cli-kit/common/array' +export const CONFIG_EXTENSION_IDS: string[] = [ + AppAccessSpecIdentifier, + AppHomeSpecIdentifier, + AppProxySpecIdentifier, + BrandingSpecIdentifier, + HostedAppHomeSpecIdentifier, + PosSpecIdentifier, + PrivacyComplianceWebhooksSpecIdentifier, + WebhookSubscriptionSpecIdentifier, + WebhooksSpecIdentifier, + EventsSpecIdentifier, +] + /** * Class that represents an instance of a local extension * Before creating this class we've validated that: @@ -101,7 +123,7 @@ export class ExtensionInstance { - const {clientSteps = []} = this.specification - - const context: BuildContext = { - extension: this, - options, - stepResults: new Map(), - } - - const steps = clientSteps.find((lifecycle) => lifecycle.lifecycle === 'deploy')?.steps ?? [] - - for (const step of steps) { - // eslint-disable-next-line no-await-in-loop - const result = await executeStep(step, context) - context.stepResults.set(step.id, result) - - if (!result.success && !step.continueOnError) { - throw new Error(`Build step "${step.name}" failed: ${result.error?.message}`) - } + const mode = this.specification.buildConfig.mode + + switch (mode) { + case 'theme': + await buildThemeExtension(this, options) + return bundleThemeExtension(this, options) + case 'function': + return buildFunctionExtension(this, options) + case 'ui': + await buildUIExtension(this, options) + // Copy static assets after build completes + return this.copyStaticAssets() + case 'tax_calculation': + await touchFile(this.outputPath) + await writeFile(this.outputPath, '(()=>{})();') + break + case 'copy_files': + return copyFilesForExtension( + this, + options, + this.specification.buildConfig.filePatterns, + this.specification.buildConfig.ignoredFilePatterns, + ) + case 'hosted_app_home': + await this.copyStaticAssets() + break + case 'none': + break } } async buildForBundle(options: ExtensionBuildOptions, bundleDirectory: string, outputId?: string) { this.outputPath = this.getOutputPathForDirectory(bundleDirectory, outputId) + await this.build(options) const bundleInputPath = joinPath(bundleDirectory, this.getOutputFolderId(outputId)) @@ -367,7 +413,8 @@ export class ExtensionInstance { const oldImportPaths = this.cachedImportPaths this.cachedImportPaths = undefined - clearImportPathsCache() this.scanImports() return oldImportPaths !== this.cachedImportPaths } @@ -489,26 +535,13 @@ export class ExtensionInstance { + const imports = this.devSessionDefaultWatchPaths().flatMap((entryFile) => { return extractImportPathsRecursively(entryFile).map((importPath) => normalizePath(resolvePath(importPath))) }) - + // Cache and return unique paths this.cachedImportPaths = uniq(imports) ?? [] - const elapsed = Math.round(performance.now() - startTime) - const cacheStats = getImportScanningCacheStats() - const cacheInfo = cacheStats ? ` (cache: ${cacheStats.directImports} parsed, ${cacheStats.fileExists} stats)` : '' - outputDebug( - `Import scan for "${this.handle}": ${entryFiles.length} entries, ${this.cachedImportPaths.length} files, ${elapsed}ms${cacheInfo}`, - ) + outputDebug(`Found ${this.cachedImportPaths.length} external imports (recursively) for extension ${this.handle}`) return this.cachedImportPaths // eslint-disable-next-line no-catch-all/no-catch-all } catch (error) { @@ -533,8 +566,6 @@ export class ExtensionInstance +export interface TransformationConfig { + [key: string]: string +} export interface CustomTransformationConfig { - forward?: (obj: object, appConfiguration: AppConfiguration, options?: {flags?: Flag[]}) => object + forward?: (obj: object, appConfiguration: AppConfigurationWithoutPath, options?: {flags?: Flag[]}) => object reverse?: (obj: object, options?: {flags?: Flag[]}) => object } type ExtensionExperience = 'extension' | 'configuration' - -export function isAppConfigSpecification(spec: {experience: string}): boolean { - return spec.experience === 'configuration' -} type UidStrategy = 'single' | 'dynamic' | 'uuid' export enum AssetIdentifier { @@ -59,7 +56,6 @@ export interface BuildAsset { type BuildConfig = | {mode: 'ui' | 'theme' | 'function' | 'tax_calculation' | 'none' | 'hosted_app_home'} | {mode: 'copy_files'; filePatterns: string[]; ignoredFilePatterns?: string[]} - /** * Extension specification with all the needed properties and methods to load an extension. */ @@ -73,18 +69,16 @@ export interface ExtensionSpecification) => string getBundleExtensionStdinContent?: (config: TConfiguration) => {main: string; assets?: Asset[]} deployConfig?: ( config: TConfiguration, directory: string, apiKey: string, moduleId?: string, - ) => Promise | undefined> + ) => Promise<{[key: string]: unknown} | undefined> validate?: (config: TConfiguration, configPath: string, directory: string) => Promise> preDeployValidation?: (extension: ExtensionInstance) => Promise buildValidation?: (extension: ExtensionInstance) => Promise @@ -99,7 +93,7 @@ export interface ExtensionSpecification object + transformLocalToRemote?: (localContent: object, appConfiguration: AppConfigurationWithoutPath) => object /** * If required, convert configuration from the platform to the format used locally in the filesystem. @@ -209,7 +203,6 @@ export function createExtensionSpecification) => { - const isConfig = isAppConfigSpecification(merged) - const hasSchema = merged.schema._def.shape !== undefined - // This filters out webhook subscription specifications from contributing to the app configuration schema - const hasSingleUidStrategy = merged.uidStrategy === 'single' - - const canContribute = isConfig && hasSchema && hasSingleUidStrategy - if (!canContribute) { + if (merged.uidStrategy !== 'single') { // no change return appConfigSchema } @@ -258,13 +245,13 @@ export function createExtensionSpecification(spec: { identifier: string schema: ZodSchemaType - clientSteps?: ClientSteps buildConfig?: BuildConfig appModuleFeatures?: (config?: TConfiguration) => ExtensionFeature[] transformConfig: TransformationConfig | CustomTransformationConfig uidStrategy?: UidStrategy getDevSessionUpdateMessages?: (config: TConfiguration) => Promise patchWithAppDevURLs?: (config: TConfiguration, urls: ApplicationURLs) => void + copyStaticAssets?: (config: TConfiguration, directory: string, outputPath: string) => Promise }): ExtensionSpecification { const appModuleFeatures = spec.appModuleFeatures ?? (() => []) return createExtensionSpecification({ @@ -277,34 +264,21 @@ export function createConfigExtensionSpecification( - spec: Pick< - CreateExtensionSpecType, - | 'identifier' - | 'appModuleFeatures' - | 'buildConfig' - | 'uidStrategy' - | 'clientSteps' - | 'experience' - | 'transformRemoteToLocal' - >, + spec: Pick, 'identifier' | 'appModuleFeatures' | 'buildConfig'>, ) { return createExtensionSpecification({ identifier: spec.identifier, schema: zod.any({}) as unknown as ZodSchemaType, appModuleFeatures: spec.appModuleFeatures, - experience: spec.experience, buildConfig: spec.buildConfig ?? {mode: 'none'}, - clientSteps: spec.clientSteps, - uidStrategy: spec.uidStrategy, - transformRemoteToLocal: spec.transformRemoteToLocal, deployConfig: async (config, directory) => { let parsedConfig = configWithoutFirstClassFields(config) if (spec.appModuleFeatures().includes('localization')) { @@ -330,7 +304,7 @@ function resolveReverseAppConfigTransform( transformConfig?: TransformationConfig | CustomTransformationConfig, ) { if (!transformConfig) - return (content: object) => defaultAppConfigReverseTransform(schema, content as Record) + return (content: object) => defaultAppConfigReverseTransform(schema, content as {[key: string]: unknown}) if (Object.keys(transformConfig).includes('reverse')) { return (transformConfig as CustomTransformationConfig).reverse! @@ -398,8 +372,8 @@ function appConfigTransform( * @returns The nested object */ // eslint-disable-next-line @typescript-eslint/no-explicit-any -function defaultAppConfigReverseTransform(schema: zod.ZodType, content: Record) { - return Object.keys(schema._def.shape()).reduce((result: Record, key: string) => { +function defaultAppConfigReverseTransform(schema: zod.ZodType, content: {[key: string]: unknown}) { + return Object.keys(schema._def.shape()).reduce((result: {[key: string]: unknown}, key: string) => { let innerSchema = schema._def.shape()[key] if (innerSchema instanceof zod.ZodOptional) { innerSchema = innerSchema._def.innerType diff --git a/packages/app/src/cli/models/extensions/specifications/app_config_hosted_app_home.test.ts b/packages/app/src/cli/models/extensions/specifications/app_config_hosted_app_home.test.ts new file mode 100644 index 00000000000..c45f98bdf6b --- /dev/null +++ b/packages/app/src/cli/models/extensions/specifications/app_config_hosted_app_home.test.ts @@ -0,0 +1,102 @@ +import spec from './app_config_hosted_app_home.js' +import {placeholderAppConfiguration} from '../../app/app.test-data.js' +import {copyDirectoryContents} from '@shopify/cli-kit/node/fs' +import {describe, expect, test, vi} from 'vitest' + +vi.mock('@shopify/cli-kit/node/fs') + +describe('hosted_app_home', () => { + describe('transform', () => { + test('should return the transformed object with static_root', () => { + const object = { + static_root: 'public', + } + const appConfigSpec = spec + + const result = appConfigSpec.transformLocalToRemote!(object, placeholderAppConfiguration) + + expect(result).toMatchObject({ + static_root: 'public', + }) + }) + + test('should return empty object when static_root is not provided', () => { + const object = {} + const appConfigSpec = spec + + const result = appConfigSpec.transformLocalToRemote!(object, placeholderAppConfiguration) + + expect(result).toMatchObject({}) + }) + }) + + describe('reverseTransform', () => { + test('should return the reversed transformed object with static_root', () => { + const object = { + static_root: 'public', + } + const appConfigSpec = spec + + const result = appConfigSpec.transformRemoteToLocal!(object) + + expect(result).toMatchObject({ + static_root: 'public', + }) + }) + + test('should return empty object when static_root is not provided', () => { + const object = {} + const appConfigSpec = spec + + const result = appConfigSpec.transformRemoteToLocal!(object) + + expect(result).toMatchObject({}) + }) + }) + + describe('copyStaticAssets', () => { + test('should copy static assets from source to output directory', async () => { + vi.mocked(copyDirectoryContents).mockResolvedValue(undefined) + const config = {static_root: 'public'} + const directory = '/app/root' + const outputPath = '/output/dist/bundle.js' + + await spec.copyStaticAssets!(config, directory, outputPath) + + expect(copyDirectoryContents).toHaveBeenCalledWith('/app/root/public', '/output/dist') + }) + + test('should not copy assets when static_root is not provided', async () => { + const config = {} + const directory = '/app/root' + const outputPath = '/output/dist/bundle.js' + + await spec.copyStaticAssets!(config, directory, outputPath) + + expect(copyDirectoryContents).not.toHaveBeenCalled() + }) + + test('should throw error when copy fails', async () => { + vi.mocked(copyDirectoryContents).mockRejectedValue(new Error('Permission denied')) + const config = {static_root: 'public'} + const directory = '/app/root' + const outputPath = '/output/dist/bundle.js' + + await expect(spec.copyStaticAssets!(config, directory, outputPath)).rejects.toThrow( + 'Failed to copy static assets from /app/root/public to /output/dist: Permission denied', + ) + }) + }) + + describe('buildConfig', () => { + test('should have hosted_app_home build mode', () => { + expect(spec.buildConfig).toEqual({mode: 'hosted_app_home'}) + }) + }) + + describe('identifier', () => { + test('should have correct identifier', () => { + expect(spec.identifier).toBe('hosted_app_home') + }) + }) +}) diff --git a/packages/app/src/cli/models/extensions/specifications/app_config_hosted_app_home.ts b/packages/app/src/cli/models/extensions/specifications/app_config_hosted_app_home.ts new file mode 100644 index 00000000000..6b71b710496 --- /dev/null +++ b/packages/app/src/cli/models/extensions/specifications/app_config_hosted_app_home.ts @@ -0,0 +1,33 @@ +import {BaseSchemaWithoutHandle} from '../schemas.js' +import {TransformationConfig, createConfigExtensionSpecification} from '../specification.js' +import {copyDirectoryContents} from '@shopify/cli-kit/node/fs' +import {dirname, joinPath} from '@shopify/cli-kit/node/path' +import {zod} from '@shopify/cli-kit/node/schema' + +const HostedAppHomeSchema = BaseSchemaWithoutHandle.extend({ + static_root: zod.string().optional(), +}) + +const HostedAppHomeTransformConfig: TransformationConfig = { + static_root: 'static_root', +} + +export const HostedAppHomeSpecIdentifier = 'hosted_app_home' + +const hostedAppHomeSpec = createConfigExtensionSpecification({ + identifier: HostedAppHomeSpecIdentifier, + buildConfig: {mode: 'hosted_app_home'} as const, + schema: HostedAppHomeSchema, + transformConfig: HostedAppHomeTransformConfig, + copyStaticAssets: async (config, directory, outputPath) => { + if (!config.static_root) return + const sourceDir = joinPath(directory, config.static_root) + const outputDir = dirname(outputPath) + + return copyDirectoryContents(sourceDir, outputDir).catch((error) => { + throw new Error(`Failed to copy static assets from ${sourceDir} to ${outputDir}: ${error.message}`) + }) + }, +}) + +export default hostedAppHomeSpec 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 a752d56b726..37544a1c672 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 {RemoteSpecification} from '../../api/graphql/extension_specifications.js' +import {FlattenedRemoteSpecification, RemoteSpecification} from '../../api/graphql/extension_specifications.js' import { createContractBasedModuleSpecification, ExtensionSpecification, @@ -10,7 +10,7 @@ import {MinimalAppIdentifiers} from '../../models/organization.js' import {unifiedConfigurationParserFactory} from '../../utilities/json-schema.js' import {getArrayRejectingUndefined} from '@shopify/cli-kit/common/array' import {outputDebug} from '@shopify/cli-kit/node/output' -import {normaliseJsonSchema} from '@shopify/cli-kit/node/json-schema' +import {HandleInvalidAdditionalProperties, normaliseJsonSchema} from '@shopify/cli-kit/node/json-schema' interface FetchSpecificationsOptions { developerPlatformClient: DeveloperPlatformClient @@ -35,24 +35,21 @@ export async function fetchSpecifications({ }: FetchSpecificationsOptions): Promise { const result: RemoteSpecification[] = await developerPlatformClient.specifications(app) - const extensionSpecifications: RemoteSpecification[] = result + const extensionSpecifications: FlattenedRemoteSpecification[] = 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 // Hardcoded value for the post purchase extension because the value is wrong in the API - if (spec.identifier === 'checkout_post_purchase') spec.surface = 'post_purchase' + if (spec.identifier === 'checkout_post_purchase') newSpec.surface = 'post_purchase' - // Hardcoded values for the webhook_subscription extension because the values are wrong in the API - if (spec.identifier === 'webhook_subscription') { - spec.experience = 'configuration' - spec.uidStrategy = 'dynamic' - } - - return spec + return newSpec }) const local = await loadLocalExtensionsSpecifications() @@ -62,26 +59,46 @@ export async function fetchSpecifications({ async function mergeLocalAndRemoteSpecs( local: ExtensionSpecification[], - remote: RemoteSpecification[], + remote: FlattenedRemoteSpecification[], ): 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 const updated = remote.map(async (remoteSpec) => { let localSpec = local.find((local) => local.identifier === remoteSpec.identifier) if (!localSpec && remoteSpec.validationSchema?.jsonSchema) { - localSpec = await createRemoteOnlySpecification(remoteSpec, remoteSpec.validationSchema) + const normalisedSchema = await normaliseJsonSchema(remoteSpec.validationSchema.jsonSchema) + const hasLocalization = normalisedSchema.properties?.localization !== undefined + localSpec = createContractBasedModuleSpecification({ + identifier: remoteSpec.identifier, + appModuleFeatures: () => (hasLocalization ? ['localization'] : []), + }) + localSpec.uidStrategy = remoteSpec.options.uidIsClientProvided ? 'uuid' : 'single' } if (!localSpec) return undefined - const merged = mergeLocalAndRemoteSpec(localSpec, remoteSpec) + const merged = {...localSpec, ...remoteSpec, loadedRemoteSpecs: true} as RemoteAwareExtensionSpecification & + FlattenedRemoteSpecification + + // If configuration is inside an app.toml -- i.e. single UID mode -- we must be able to parse a partial slice. + let handleInvalidAdditionalProperties: HandleInvalidAdditionalProperties + switch (merged.uidStrategy) { + case 'uuid': + handleInvalidAdditionalProperties = 'fail' + break + case 'single': + handleInvalidAdditionalProperties = 'strip' + break + case 'dynamic': + handleInvalidAdditionalProperties = 'fail' + break + } - const parseConfigurationObject = await unifiedConfigurationParserFactory( - merged, - remoteSpec.validationSchema, - merged.experience === 'extension' ? 'fail' : 'strip', - ) + const parseConfigurationObject = await unifiedConfigurationParserFactory(merged, handleInvalidAdditionalProperties) - return {...merged, parseConfigurationObject} + return { + ...merged, + parseConfigurationObject, + } }) const result = getArrayRejectingUndefined(await Promise.all(updated)) @@ -115,36 +132,3 @@ async function mergeLocalAndRemoteSpecs( return result } - -function mergeLocalAndRemoteSpec( - localSpec: ExtensionSpecification, - remoteSpec: RemoteSpecification, -): RemoteAwareExtensionSpecification { - const merged: RemoteAwareExtensionSpecification = { - ...localSpec, - loadedRemoteSpecs: true, - // Remote values have priority over local ones. - externalName: remoteSpec.externalName, - externalIdentifier: remoteSpec.externalIdentifier, - experience: remoteSpec.experience as 'extension' | 'configuration', - registrationLimit: remoteSpec.registrationLimit, - uidStrategy: remoteSpec.uidStrategy, - surface: remoteSpec.surface ?? localSpec.surface, - } - return merged -} - -async function createRemoteOnlySpecification( - remoteSpec: RemoteSpecification, - validationSchema: {jsonSchema: string}, -): Promise { - const normalisedSchema = await normaliseJsonSchema(validationSchema.jsonSchema) - const hasLocalization = normalisedSchema.properties?.localization !== undefined - const localSpec = createContractBasedModuleSpecification({ - identifier: remoteSpec.identifier, - uidStrategy: remoteSpec.uidStrategy, - experience: remoteSpec.experience as 'extension' | 'configuration', - appModuleFeatures: () => (hasLocalization ? ['localization'] : []), - }) - return {...localSpec, loadedRemoteSpecs: true} -}