From 5b7340fde6b97c96e3f5456abfcc59bda4a5a355 Mon Sep 17 00:00:00 2001 From: Elana Kopelevich Date: Wed, 21 Jan 2026 12:13:02 -0700 Subject: [PATCH 1/2] Asset pipeline flow for hosted static app --- .../models/extensions/extension-instance.ts | 5 + .../models/extensions/load-specifications.ts | 3 + .../cli/models/extensions/specification.ts | 6 +- .../app_config_hosted_app_home.test.ts | 102 ++++++++++++++++++ .../app_config_hosted_app_home.ts | 33 ++++++ .../fetch-extension-specifications.ts | 1 - 6 files changed, 148 insertions(+), 2 deletions(-) create mode 100644 packages/app/src/cli/models/extensions/specifications/app_config_hosted_app_home.test.ts create mode 100644 packages/app/src/cli/models/extensions/specifications/app_config_hosted_app_home.ts diff --git a/packages/app/src/cli/models/extensions/extension-instance.ts b/packages/app/src/cli/models/extensions/extension-instance.ts index 3a37dd9aaf8..170aabb77e5 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 static_app build mode', () => { + expect(spec.buildConfig).toEqual({mode: 'static_app'}) + }) + }) + + describe('identifier', () => { + test('should have correct identifier', () => { + expect(spec.identifier).toBe('hosted_app') + }) + }) +}) 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..c9fc33f5c25 --- /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' + +const hostedAppHomeSpec = createConfigExtensionSpecification({ + identifier: HostedAppHomeSpecIdentifier, + buildConfig: {mode: 'static_app'} 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..f5379ae831e 100644 --- a/packages/app/src/cli/services/generate/fetch-extension-specifications.ts +++ b/packages/app/src/cli/services/generate/fetch-extension-specifications.ts @@ -78,7 +78,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) { From cd9fc6e4b220e8e0a1d3126fc1867c75d227254a Mon Sep 17 00:00:00 2001 From: Elana Kopelevich Date: Tue, 3 Feb 2026 15:28:46 -0700 Subject: [PATCH 2/2] Update hosted app identifier --- .../app/src/cli/models/extensions/extension-instance.ts | 2 +- packages/app/src/cli/models/extensions/specification.ts | 2 +- .../specifications/app_config_hosted_app_home.test.ts | 6 +++--- .../extensions/specifications/app_config_hosted_app_home.ts | 4 ++-- .../cli/services/generate/fetch-extension-specifications.ts | 1 + 5 files changed, 8 insertions(+), 7 deletions(-) diff --git a/packages/app/src/cli/models/extensions/extension-instance.ts b/packages/app/src/cli/models/extensions/extension-instance.ts index 170aabb77e5..60bcb884f6a 100644 --- a/packages/app/src/cli/models/extensions/extension-instance.ts +++ b/packages/app/src/cli/models/extensions/extension-instance.ts @@ -368,7 +368,7 @@ export class ExtensionInstance { }) describe('buildConfig', () => { - test('should have static_app build mode', () => { - expect(spec.buildConfig).toEqual({mode: 'static_app'}) + 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') + 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 index c9fc33f5c25..6b71b710496 100644 --- 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 @@ -12,11 +12,11 @@ const HostedAppHomeTransformConfig: TransformationConfig = { static_root: 'static_root', } -export const HostedAppHomeSpecIdentifier = 'hosted_app' +export const HostedAppHomeSpecIdentifier = 'hosted_app_home' const hostedAppHomeSpec = createConfigExtensionSpecification({ identifier: HostedAppHomeSpecIdentifier, - buildConfig: {mode: 'static_app'} as const, + buildConfig: {mode: 'hosted_app_home'} as const, schema: HostedAppHomeSchema, transformConfig: HostedAppHomeTransformConfig, copyStaticAssets: async (config, directory, outputPath) => { 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 f5379ae831e..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.