diff --git a/packages/app/src/cli/models/extensions/extension-instance.ts b/packages/app/src/cli/models/extensions/extension-instance.ts index 3a37dd9aaf8..60bcb884f6a 100644 --- a/packages/app/src/cli/models/extensions/extension-instance.ts +++ b/packages/app/src/cli/models/extensions/extension-instance.ts @@ -12,6 +12,7 @@ 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 {HostedAppHomeSpecIdentifier} from './specifications/app_config_hosted_app_home.js' import { ExtensionBuildOptions, buildFunctionExtension, @@ -40,6 +41,7 @@ export const CONFIG_EXTENSION_IDS: string[] = [ AppHomeSpecIdentifier, AppProxySpecIdentifier, BrandingSpecIdentifier, + HostedAppHomeSpecIdentifier, PosSpecIdentifier, PrivacyComplianceWebhooksSpecIdentifier, WebhookSubscriptionSpecIdentifier, @@ -366,6 +368,9 @@ export class ExtensionInstance(spec: { identifier: string schema: ZodSchemaType + 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({ @@ -256,8 +258,10 @@ export function createConfigExtensionSpecification { + 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 37544a1c672..c66f37372f7 100644 --- a/packages/app/src/cli/services/generate/fetch-extension-specifications.ts +++ b/packages/app/src/cli/services/generate/fetch-extension-specifications.ts @@ -38,6 +38,7 @@ export async function fetchSpecifications({ const extensionSpecifications: FlattenedRemoteSpecification[] = result .filter((specification) => ['extension', 'configuration'].includes(specification.experience)) .map((spec) => { + console.log('specification', spec.identifier) 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. @@ -78,7 +79,6 @@ async function mergeLocalAndRemoteSpecs( 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) {