diff --git a/packages/app/src/cli/models/extensions/application-module.ts b/packages/app/src/cli/models/extensions/application-module.ts new file mode 100644 index 00000000000..76917aaba87 --- /dev/null +++ b/packages/app/src/cli/models/extensions/application-module.ts @@ -0,0 +1,665 @@ +import {BaseConfigType, MAX_EXTENSION_HANDLE_LENGTH, MAX_UID_LENGTH} from './schemas.js' +import {FunctionConfigType} from './specifications/function.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 {Identifiers} from '../app/identifiers.js' +import {DeveloperPlatformClient} from '../../utilities/developer-platform-client.js' +import {AppConfiguration} from '../app/app.js' +import {ApplicationURLs} from '../../services/dev/urls.js' +import {executeStep, BuildContext, ClientSteps} from '../../services/build/client-steps.js' +import {RemoteSpecification} from '../../api/graphql/extension_specifications.js' +import {ok, Result} 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 {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 {uniq} from '@shopify/cli-kit/common/array' + +export type ExtensionFeature = + | 'ui_preview' + | 'function' + | 'theme' + | 'cart_url' + | 'esbuild' + | 'single_js_entry_path' + | 'localization' + | 'generates_source_maps' + +export type ExtensionExperience = 'extension' | 'configuration' +export type UidStrategy = 'single' | 'dynamic' | 'uuid' + +export type BuildConfig = + | {mode: 'ui' | 'theme' | 'function' | 'tax_calculation' | 'none' | 'hosted_app_home'} + | {mode: 'copy_files'; filePatterns: string[]; ignoredFilePatterns?: string[]} + +export enum AssetIdentifier { + ShouldRender = 'should_render', + Main = 'main', + Tools = 'tools', + Instructions = 'instructions', +} + +export interface Asset { + identifier: AssetIdentifier + outputFileName: string + content: string +} + +export interface DevSessionWatchConfig { + paths: string[] + ignore?: string[] +} + +/** + * Base class for all application modules (extensions and config modules). + * + * Subclasses override behavior methods (appModuleFeatures, deployConfig, validate, etc.) + * while the base class provides shared infrastructure (build pipeline, file watching, + * handle/uid computation, bundling). + * + * This replaces the old ExtensionInstance + ExtensionSpecification composition pattern + * with a cleaner inheritance model where per-type behavior lives in subclasses. + */ +export abstract class ApplicationModule { + entrySourceFilePath: string + devUUID: string + localIdentifier: string + idEnvironmentVariableName: string + directory: string + configuration: TConfiguration + configurationPath: string + outputPath: string + handle: string + uid: string + readonly remoteSpec: RemoteSpecification + private cachedImportPaths?: string[] + + constructor(options: { + configuration: TConfiguration + configurationPath: string + entryPath?: string + directory: string + remoteSpec: RemoteSpecification + }) { + this.configuration = options.configuration + this.configurationPath = options.configurationPath + this.entrySourceFilePath = options.entryPath ?? '' + this.directory = options.directory + this.remoteSpec = options.remoteSpec + this.handle = this.buildHandle() + this.localIdentifier = this.handle + this.idEnvironmentVariableName = `SHOPIFY_${constantize(this.localIdentifier)}_ID` + this.outputPath = joinPath(this.directory, this.outputRelativePath) + this.uid = this.buildUIDFromStrategy() + this.devUUID = `dev-${this.uid}` + } + + // ------------------------------------------------------------------ + // Abstract: subclasses must define + // ------------------------------------------------------------------ + + abstract appModuleFeatures(): ExtensionFeature[] + + // ------------------------------------------------------------------ + // Identity — defaults delegate to remoteSpec. Subclasses can override. + // SpecificationBackedExtension overrides these to delegate to its + // ExtensionSpecification instead. + // ------------------------------------------------------------------ + + get identifier(): string { + return this.remoteSpec.identifier + } + + get externalIdentifier(): string { + return this.remoteSpec.externalIdentifier + } + + get externalName(): string { + return this.remoteSpec.externalName + } + + get partnersWebIdentifier(): string { + return this.remoteSpec.identifier + } + + get experience(): ExtensionExperience { + return this.remoteSpec.experience as ExtensionExperience + } + + get uidStrategy(): UidStrategy { + return this.remoteSpec.uidStrategy + } + + get surface(): string { + return this.remoteSpec.surface ?? '' + } + + get graphQLType(): string { + return this.identifier.toUpperCase() + } + + get type(): string { + return this.identifier + } + + get humanName(): string { + return this.externalName + } + + get name(): string { + return this.configuration.name ?? this.externalName + } + + get dependency(): string | undefined { + return undefined + } + + get externalType(): string { + return this.externalIdentifier + } + + // ------------------------------------------------------------------ + // Feature queries — derived from appModuleFeatures() + // ------------------------------------------------------------------ + + get features(): ExtensionFeature[] { + return this.appModuleFeatures() + } + + get isPreviewable(): boolean { + return this.features.includes('ui_preview') + } + + get isThemeExtension(): boolean { + return this.features.includes('theme') + } + + get isFunctionExtension(): boolean { + return this.features.includes('function') + } + + get isESBuildExtension(): boolean { + return this.features.includes('esbuild') + } + + get isSourceMapGeneratingExtension(): boolean { + return this.features.includes('generates_source_maps') + } + + get isAppConfigExtension(): boolean { + return this.experience === 'configuration' + } + + get isFlow(): boolean { + return this.identifier.includes('flow') + } + + get isEditorExtensionCollection(): boolean { + return this.identifier === 'editor_extension_collection' + } + + // ------------------------------------------------------------------ + // Build configuration — subclasses override + // ------------------------------------------------------------------ + + get buildConfig(): BuildConfig { + return {mode: 'none'} + } + + get clientSteps(): ClientSteps | undefined { + return undefined + } + + get outputRelativePath(): string { + return '' + } + + get outputFileName(): string { + return basename(this.outputRelativePath) + } + + // ------------------------------------------------------------------ + // UID strategy queries + // ------------------------------------------------------------------ + + get isUUIDStrategyExtension(): boolean { + return this.uidStrategy === 'uuid' + } + + get isSingleStrategyExtension(): boolean { + return this.uidStrategy === 'single' + } + + get isDynamicStrategyExtension(): boolean { + return this.uidStrategy === 'dynamic' + } + + // ------------------------------------------------------------------ + // Display / metrics helpers + // ------------------------------------------------------------------ + + get outputPrefix(): string { + return this.handle + } + + get draftMessages() { + if (this.isAppConfigExtension) return {successMessage: undefined, errorMessage: undefined} + const successMessage = `Draft updated successfully for extension: ${this.localIdentifier}` + const errorMessage = `Error updating extension draft for ${this.localIdentifier}` + return {successMessage, errorMessage} + } + + isSentToMetrics(): boolean { + return !this.isAppConfigExtension + } + + isReturnedAsInfo(): boolean { + return !this.isAppConfigExtension + } + + // ------------------------------------------------------------------ + // Lifecycle hooks — subclasses override as needed + // ------------------------------------------------------------------ + + async deployConfig(_options: ExtensionDeployConfigOptions): Promise<{[key: string]: unknown} | undefined> { + return undefined + } + + validate(): Promise> { + return Promise.resolve(ok(undefined)) + } + + preDeployValidation(): Promise { + return Promise.resolve() + } + + buildValidation(): Promise { + return Promise.resolve() + } + + // ------------------------------------------------------------------ + // UI extension hooks — subclasses override as needed + // ------------------------------------------------------------------ + + getBundleExtensionStdinContent(): {main: string; assets?: Asset[]} { + const relativeImportPath = this.entrySourceFilePath.replace(this.directory, '') + return {main: `import '.${relativeImportPath}';`} + } + + shouldFetchCartUrl(): boolean { + return this.features.includes('cart_url') + } + + hasExtensionPointTarget(_target: string): boolean { + return false + } + + // ------------------------------------------------------------------ + // Config transform hooks — subclasses override as needed + // ------------------------------------------------------------------ + + transformLocalToRemote(_appConfiguration: AppConfiguration): object | undefined { + return undefined + } + + transformRemoteToLocal(_options?: {flags?: unknown[]}): object | undefined { + return undefined + } + + patchWithAppDevURLs(_urls: ApplicationURLs): void {} + + async getDevSessionUpdateMessages(): Promise { + return undefined + } + + async contributeToSharedTypeFile(_typeDefinitionsByFile: Map>): Promise {} + + async copyStaticAssets(_outputPath?: string): Promise {} + + // ------------------------------------------------------------------ + // Watch configuration + // ------------------------------------------------------------------ + + devSessionDefaultWatchPaths(): string[] { + if (this.identifier === 'ui_extension') { + const {main, assets} = this.getBundleExtensionStdinContent() + const mainPaths = extractJSImports(main, this.directory) + const assetPaths = assets?.flatMap((asset) => extractJSImports(asset.content, this.directory)) ?? [] + return mainPaths.concat(...assetPaths) + } + return [this.entrySourceFilePath] + } + + get devSessionWatchConfig(): DevSessionWatchConfig | undefined { + return this.experience === 'configuration' ? {paths: []} : undefined + } + + async watchConfigurationPaths(): Promise { + if (this.isAppConfigExtension) { + return [this.configurationPath] + } + const additionalPaths = [] + if (await fileExists(joinPath(this.directory, 'locales'))) { + additionalPaths.push(joinPath(this.directory, 'locales', '**.json')) + } + additionalPaths.push(joinPath(this.directory, '**.toml')) + return additionalPaths + } + + // ------------------------------------------------------------------ + // Function-specific properties (accessed via config cast) + // ------------------------------------------------------------------ + + get buildCommand(): string | undefined { + const config = this.configuration as unknown as FunctionConfigType + return config.build?.command + } + + get typegenCommand(): string | undefined { + const config = this.configuration as unknown as FunctionConfigType + return config.build?.typegen_command + } + + get inputQueryPath(): string { + return joinPath(this.directory, 'input.graphql') + } + + get isJavaScript(): boolean { + return Boolean(this.entrySourceFilePath.endsWith('.js') || this.entrySourceFilePath.endsWith('.ts')) + } + + // ------------------------------------------------------------------ + // Build pipeline — shared infrastructure, not overridden + // ------------------------------------------------------------------ + + async build(options: ExtensionBuildOptions): Promise { + const {clientSteps = []} = {clientSteps: this.clientSteps} + + const context: BuildContext = { + extension: this as unknown as Parameters[1]['extension'], + 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}`) + } + } + } + + async buildForBundle(options: ExtensionBuildOptions, bundleDirectory: string, outputId?: string): Promise { + this.outputPath = this.getOutputPathForDirectory(bundleDirectory, outputId) + await this.build(options) + + const bundleInputPath = joinPath(bundleDirectory, this.getOutputFolderId(outputId)) + await this.keepBuiltSourcemapsLocally(bundleInputPath) + } + + async copyIntoBundle(options: ExtensionBuildOptions, bundleDirectory: string, extensionUuid?: string): Promise { + const defaultOutputPath = this.outputPath + + this.outputPath = this.getOutputPathForDirectory(bundleDirectory, extensionUuid) + + const buildMode = this.buildConfig.mode + + if (this.isThemeExtension) { + await bundleThemeExtension(this as unknown as Parameters[0], options) + } else if (buildMode !== 'none') { + outputDebug(`Will copy pre-built file from ${defaultOutputPath} to ${this.outputPath}`) + if (await fileExists(defaultOutputPath)) { + await copyFile(defaultOutputPath, this.outputPath) + + if (buildMode === 'function') { + await bundleFunctionExtension(this.outputPath, this.outputPath) + } + } + } + } + + // ------------------------------------------------------------------ + // Output path helpers + // ------------------------------------------------------------------ + + getOutputPathForDirectory(directory: string, outputId?: string): string { + const id = this.getOutputFolderId(outputId) + return joinPath(directory, id, this.outputRelativePath) + } + + getOutputFolderId(outputId?: string): string { + return outputId ?? this.uid + } + + // ------------------------------------------------------------------ + // Targeting / context + // ------------------------------------------------------------------ + + get singleTarget(): string | undefined { + const targets = (getPathValue(this.configuration, 'targeting') as {target: string}[]) ?? [] + if (targets.length !== 1) return undefined + return targets[0]?.target + } + + get contextValue(): string { + let context = this.singleTarget ?? '' + if (this.isFlow) context = this.configuration.handle ?? '' + return context + } + + // ------------------------------------------------------------------ + // Bundle / deploy + // ------------------------------------------------------------------ + + async bundleConfig({ + identifiers, + developerPlatformClient, + apiKey, + appConfiguration, + }: ExtensionBundleConfigOptions): Promise { + const configValue = await this.deployConfig({apiKey, appConfiguration}) + if (!configValue) return undefined + + const result = { + config: JSON.stringify(configValue), + context: this.contextValue, + handle: this.handle, + } + + const uuid = this.isUUIDStrategyExtension + ? identifiers.extensions[this.localIdentifier]! + : identifiers.extensionsNonUuidManaged[this.localIdentifier]! + + return { + ...result, + uid: this.uid, + uuid, + specificationIdentifier: developerPlatformClient.toExtensionGraphQLType(this.graphQLType), + } + } + + async publishURL(options: {orgId: string; appId: string; extensionId?: string}): Promise { + const fqdn = await partnersFqdn() + const parnersPath = this.partnersWebIdentifier + return `https://${fqdn}/${options.orgId}/apps/${options.appId}/extensions/${parnersPath}/${options.extensionId}` + } + + // ------------------------------------------------------------------ + // Source maps + // ------------------------------------------------------------------ + + async keepBuiltSourcemapsLocally(inputPath: string): Promise { + if (!this.isSourceMapGeneratingExtension) return + + const pathsToMove = await glob(`**/${this.handle}.js.map`, { + cwd: inputPath, + absolute: true, + followSymbolicLinks: false, + }) + + const pathToMove = pathsToMove[0] + if (pathToMove === undefined) return + + const outputPathForMap = joinPath(this.directory, relativePath(inputPath, pathToMove)) + await moveFile(pathToMove, outputPathForMap, {overwrite: true}) + outputDebug(`Source map for ${this.localIdentifier} created: ${outputPathForMap}`) + } + + // ------------------------------------------------------------------ + // File watching + // ------------------------------------------------------------------ + + watchedFiles(): string[] { + const watchedFiles: string[] = [] + + const defaultIgnore = [ + '**/node_modules/**', + '**/.git/**', + '**/*.test.*', + '**/dist/**', + '**/*.swp', + '**/generated/**', + '**/.gitignore', + ] + const watchConfig = this.devSessionWatchConfig + + const patterns = watchConfig?.paths ?? ['**/*'] + const ignore = watchConfig?.ignore ?? defaultIgnore + const files = patterns.flatMap((pattern) => + globSync(pattern, { + cwd: this.directory, + absolute: true, + followSymbolicLinks: false, + ignore, + }), + ) + watchedFiles.push(...files.flat()) + + if (!watchConfig) { + const importedFiles = this.scanImports() + watchedFiles.push(...importedFiles) + } + + return [...new Set(watchedFiles.map((file) => normalizePath(file)))] + } + + async rescanImports(): Promise { + const oldImportPaths = this.cachedImportPaths + this.cachedImportPaths = undefined + clearImportPathsCache() + this.scanImports() + return oldImportPaths !== this.cachedImportPaths + } + + // ------------------------------------------------------------------ + // Handle / UID computation + // ------------------------------------------------------------------ + + protected buildHandle(): string { + switch (this.uidStrategy) { + case 'single': + return this.identifier + case 'uuid': + return this.configuration.handle ?? slugify(this.name ?? '') + case 'dynamic': + if ('topic' in this.configuration && 'uri' in this.configuration) { + const subscription = this.configuration as unknown as SingleWebhookSubscriptionType + const handleStr = `${subscription.topic}${subscription.uri}${subscription.filter}` + return hashString(handleStr).substring(0, MAX_EXTENSION_HANDLE_LENGTH) + } + return nonRandomUUID(JSON.stringify(this.configuration)) + default: + return this.identifier + } + } + + protected buildUIDFromStrategy(): string { + switch (this.uidStrategy) { + case 'single': + return this.identifier + case 'uuid': + return this.configuration.uid ?? nonRandomUUID(this.handle) + case 'dynamic': + if ('topic' in this.configuration && 'uri' in this.configuration) { + const subscription = this.configuration as unknown as SingleWebhookSubscriptionType + return `${subscription.topic}::${subscription.filter}::${subscription.uri}`.substring(0, MAX_UID_LENGTH) + } + return nonRandomUUID(JSON.stringify(this.configuration)) + } + } + + private scanImports(): string[] { + if (this.cachedImportPaths !== undefined) { + return this.cachedImportPaths + } + + if (isTruthy(process.env.SHOPIFY_CLI_DISABLE_IMPORT_SCANNING)) { + this.cachedImportPaths = [] + return this.cachedImportPaths + } + + try { + const startTime = performance.now() + const entryFiles = this.devSessionDefaultWatchPaths() + + const imports = entryFiles.flatMap((entryFile) => { + return extractImportPathsRecursively(entryFile).map((importPath) => normalizePath(resolvePath(importPath))) + }) + + 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}`, + ) + return this.cachedImportPaths + // eslint-disable-next-line no-catch-all/no-catch-all + } catch (error) { + outputDebug(`Failed to scan imports for extension ${this.handle}: ${error}`) + this.cachedImportPaths = [] + return this.cachedImportPaths + } + } +} + +// ------------------------------------------------------------------ +// Supporting types +// ------------------------------------------------------------------ + +export interface ExtensionDeployConfigOptions { + apiKey: string + appConfiguration: AppConfiguration +} + +interface ExtensionBundleConfigOptions { + identifiers: Identifiers + developerPlatformClient: DeveloperPlatformClient + apiKey: string + appConfiguration: AppConfiguration +} + +interface BundleConfig { + config: string + context: string + handle: string + uid: string + uuid: string + specificationIdentifier: string +} diff --git a/packages/app/src/cli/models/extensions/extension-instance.ts b/packages/app/src/cli/models/extensions/extension-instance.ts index 11e2f24b976..fa895fbd3c5 100644 --- a/packages/app/src/cli/models/extensions/extension-instance.ts +++ b/packages/app/src/cli/models/extensions/extension-instance.ts @@ -1,586 +1,187 @@ -import {BaseConfigType, MAX_EXTENSION_HANDLE_LENGTH, MAX_UID_LENGTH} from './schemas.js' -import {FunctionConfigType} from './specifications/function.js' -import {DevSessionWatchConfig, 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 {Identifiers} from '../app/identifiers.js' -import {DeveloperPlatformClient} from '../../utilities/developer-platform-client.js' +import {BaseConfigType} from './schemas.js' +import {ApplicationModule, ExtensionDeployConfigOptions} from './application-module.js' +import {joinPath} from '@shopify/cli-kit/node/path' +import {ExtensionFeature, ExtensionSpecification, DevSessionWatchConfig} from './specification.js' +import {Flag} from '../../utilities/developer-platform-client.js' import {AppConfiguration} 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 {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 {uniq} from '@shopify/cli-kit/common/array' +import {ok, Result} from '@shopify/cli-kit/node/result' /** - * Class that represents an instance of a local extension - * Before creating this class we've validated that: - * - There is a spec for this type of extension - * - The Schema for that spec is followed by the extension config toml file - * - We were able to find an entry point file for that extension + * Backward-compatible class that bridges the old ExtensionSpecification-based + * composition pattern with the new ApplicationModule inheritance model. * - * It supports extension points, making this Class compatible with both new ui-extension - * and legacy extension types. Extension points are optional and this class will handle them if present. + * This class extends ApplicationModule and delegates identity/behavior to the + * existing ExtensionSpecification object, preserving the current API surface + * for all 76+ consuming files. * - * This class holds the public interface to interact with extensions + * Once all specs are migrated to ApplicationModule subclasses, this class + * will be removed and consumers will use ApplicationModule directly. */ -export class ExtensionInstance { - entrySourceFilePath: string - devUUID: string - localIdentifier: string - idEnvironmentVariableName: string - directory: string - configuration: TConfiguration - configurationPath: string - outputPath: string - handle: string +export class ExtensionInstance extends ApplicationModule { specification: ExtensionSpecification - uid: string - private cachedImportPaths?: string[] - get graphQLType() { - return (this.specification.graphQLType ?? this.specification.identifier).toUpperCase() - } - - get type(): string { - return this.specification.identifier - } - - get humanName() { - return this.specification.externalName - } - - get name(): string { - return this.configuration.name ?? this.specification.externalName + constructor(options: { + configuration: TConfiguration + configurationPath: string + entryPath?: string + directory: string + specification: ExtensionSpecification + }) { + super({ + ...options, + remoteSpec: { + name: options.specification.externalName, + externalName: options.specification.externalName, + identifier: options.specification.identifier, + gated: false, + externalIdentifier: options.specification.externalIdentifier, + experience: options.specification.experience, + managementExperience: 'cli', + registrationLimit: options.specification.registrationLimit, + uidStrategy: options.specification.uidStrategy, + surface: options.specification.surface, + }, + }) + this.specification = options.specification + // Recompute outputPath now that specification is set, since the + // override of outputRelativePath delegates to specification. + this.outputPath = joinPath(this.directory, this.outputRelativePath) } - get dependency() { - return this.specification.dependency - } + // ------------------------------------------------------------------ + // Identity — delegates to specification for backward compatibility. + // Uses ?. fallback to remoteSpec during construction (before + // this.specification is set by the subclass constructor). + // ------------------------------------------------------------------ - get externalType() { - return this.specification.externalIdentifier + override get identifier(): string { + return this.specification?.identifier ?? this.remoteSpec.identifier } - get surface() { - return this.specification.surface + override get externalIdentifier(): string { + return this.specification?.externalIdentifier ?? this.remoteSpec.externalIdentifier } - get isPreviewable() { - return this.features.includes('ui_preview') + override get externalName(): string { + return this.specification?.externalName ?? this.remoteSpec.externalName } - get isThemeExtension() { - return this.features.includes('theme') + override get partnersWebIdentifier(): string { + return this.specification?.partnersWebIdentifier ?? this.remoteSpec.identifier } - get isFunctionExtension() { - return this.features.includes('function') + override get graphQLType(): string { + if (!this.specification) return this.remoteSpec.identifier.toUpperCase() + return (this.specification.graphQLType ?? this.specification.identifier).toUpperCase() } - get isESBuildExtension() { - return this.features.includes('esbuild') + override get experience() { + return (this.specification?.experience ?? this.remoteSpec.experience) as 'extension' | 'configuration' } - get isSourceMapGeneratingExtension() { - return this.features.includes('generates_source_maps') + override get uidStrategy() { + return this.specification?.uidStrategy ?? this.remoteSpec.uidStrategy } - get isAppConfigExtension() { - return this.specification.experience === 'configuration' + override get surface(): string { + return this.specification?.surface ?? this.remoteSpec.surface ?? '' } - get isFlow() { - return this.specification.identifier.includes('flow') + override get dependency(): string | undefined { + return this.specification?.dependency } - get isEditorExtensionCollection() { - return this.specification.identifier === 'editor_extension_collection' - } + // ------------------------------------------------------------------ + // Behavior — delegates to specification + // ------------------------------------------------------------------ - get features(): ExtensionFeature[] { + appModuleFeatures(): ExtensionFeature[] { return this.specification.appModuleFeatures(this.configuration) } - get outputFileName() { - return basename(this.outputRelativePath) - } - - get outputRelativePath() { - return this.specification.getOutputRelativePath?.(this) ?? '' - } - - constructor(options: { - configuration: TConfiguration - configurationPath: string - entryPath?: string - directory: string - specification: ExtensionSpecification - }) { - this.configuration = options.configuration - this.configurationPath = options.configurationPath - this.entrySourceFilePath = options.entryPath ?? '' - this.directory = options.directory - this.specification = options.specification - this.handle = this.buildHandle() - this.localIdentifier = this.handle - this.idEnvironmentVariableName = `SHOPIFY_${constantize(this.localIdentifier)}_ID` - this.outputPath = joinPath(this.directory, this.outputRelativePath) - this.uid = this.buildUIDFromStrategy() - this.devUUID = `dev-${this.uid}` - } - - get draftMessages() { - if (this.isAppConfigExtension) return {successMessage: undefined, errorMessage: undefined} - const successMessage = `Draft updated successfully for extension: ${this.localIdentifier}` - const errorMessage = `Error updating extension draft for ${this.localIdentifier}` - return {successMessage, errorMessage} + override get buildConfig() { + return this.specification.buildConfig } - get isUUIDStrategyExtension() { - return this.specification.uidStrategy === 'uuid' + override get clientSteps() { + return this.specification.clientSteps } - get isSingleStrategyExtension() { - return this.specification.uidStrategy === 'single' + override get outputRelativePath(): string { + return this.specification?.getOutputRelativePath?.(this) ?? '' } - get isDynamicStrategyExtension() { - return this.specification.uidStrategy === 'dynamic' - } - - get outputPrefix() { - return this.handle - } - - isSentToMetrics() { - return !this.isAppConfigExtension - } - - isReturnedAsInfo() { - return !this.isAppConfigExtension - } - - async deployConfig({ + override async deployConfig({ apiKey, appConfiguration, }: ExtensionDeployConfigOptions): Promise<{[key: string]: unknown} | undefined> { - const deployConfig = await this.specification.deployConfig?.(this.configuration, this.directory, apiKey, undefined) + const deployConfigResult = await this.specification.deployConfig?.(this.configuration, this.directory, apiKey, undefined) const transformedConfig = this.specification.transformLocalToRemote?.(this.configuration, appConfiguration) as | {[key: string]: unknown} | undefined - const resultDeployConfig = deployConfig ?? transformedConfig ?? undefined + const resultDeployConfig = deployConfigResult ?? transformedConfig ?? undefined return resultDeployConfig && Object.keys(resultDeployConfig).length > 0 ? resultDeployConfig : undefined } - validate() { + override validate(): Promise> { if (!this.specification.validate) return Promise.resolve(ok(undefined)) return this.specification.validate(this.configuration, this.configurationPath, this.directory) } - preDeployValidation(): Promise { + override preDeployValidation(): Promise { if (!this.specification.preDeployValidation) return Promise.resolve() return this.specification.preDeployValidation(this) } - buildValidation(): Promise { + override buildValidation(): Promise { if (!this.specification.buildValidation) return Promise.resolve() return this.specification.buildValidation(this) } - async keepBuiltSourcemapsLocally(inputPath: string): Promise { - if (!this.isSourceMapGeneratingExtension) return Promise.resolve() - - const pathsToMove = await glob(`**/${this.handle}.js.map`, { - cwd: inputPath, - absolute: true, - followSymbolicLinks: false, - }) - - const pathToMove = pathsToMove[0] - if (pathToMove === undefined) return Promise.resolve() - - const outputPath = joinPath(this.directory, relativePath(inputPath, pathToMove)) - await moveFile(pathToMove, outputPath, {overwrite: true}) - outputDebug(`Source map for ${this.localIdentifier} created: ${outputPath}`) - } - - async publishURL(options: {orgId: string; appId: string; extensionId?: string}) { - const fqdn = await partnersFqdn() - const parnersPath = this.specification.partnersWebIdentifier - return `https://${fqdn}/${options.orgId}/apps/${options.appId}/extensions/${parnersPath}/${options.extensionId}` - } - - getOutputFolderId(outputId?: string) { - // Ideally we want to return `this.uid` always. To keep supporting Partners API we accept a value to override that. - - return outputId ?? this.uid - } - - // UI Specific properties - getBundleExtensionStdinContent() { + override getBundleExtensionStdinContent() { if (this.specification.getBundleExtensionStdinContent) { return this.specification.getBundleExtensionStdinContent(this.configuration) } - const relativeImportPath = this.entrySourceFilePath.replace(this.directory, '') - return {main: `import '.${relativeImportPath}';`} + return super.getBundleExtensionStdinContent() } - shouldFetchCartUrl(): boolean { - return this.features.includes('cart_url') - } - - hasExtensionPointTarget(target: string): boolean { + override hasExtensionPointTarget(target: string): boolean { return this.specification.hasExtensionPointTarget?.(this.configuration, target) ?? false } - // Functions specific properties - get buildCommand() { - const config = this.configuration as unknown as FunctionConfigType - return config.build?.command - } - - get typegenCommand() { - const config = this.configuration as unknown as FunctionConfigType - return config.build?.typegen_command - } - - /** - * Default entry paths to be watched in a dev session. - * It returns the entry source file path if defined, - * or the list of files generated from the bundle content (UI extensions). - * @returns Array of strings with the paths to be watched - */ - devSessionDefaultWatchPaths(): string[] { - // For UI extensions, use the generated bundle content - if (this.specification.identifier === 'ui_extension') { - const {main, assets} = this.getBundleExtensionStdinContent() - const mainPaths = extractJSImports(main, this.directory) - const assetPaths = assets?.flatMap((asset) => extractJSImports(asset.content, this.directory)) ?? [] - return mainPaths.concat(...assetPaths) - } - - return [this.entrySourceFilePath] - } - - // Custom watch configuration for dev sessions - // Return undefined to watch everything (default for 'extension' experience) - // Return a config with empty paths to watch nothing (default for 'configuration' experience) - get devSessionWatchConfig(): DevSessionWatchConfig | undefined { - if (this.specification.devSessionWatchConfig) { - return this.specification.devSessionWatchConfig(this) - } - - return this.isAppConfigExtension ? {paths: []} : undefined - } - - async watchConfigurationPaths() { - if (this.isAppConfigExtension) { - return [this.configurationPath] - } else { - const additionalPaths = [] - if (await fileExists(joinPath(this.directory, 'locales'))) { - additionalPaths.push(joinPath(this.directory, 'locales', '**.json')) - } - additionalPaths.push(joinPath(this.directory, '**.toml')) - return additionalPaths - } - } - - get inputQueryPath() { - return joinPath(this.directory, 'input.graphql') - } - - get isJavaScript() { - return Boolean(this.entrySourceFilePath.endsWith('.js') || this.entrySourceFilePath.endsWith('.ts')) - } - - async build(options: ExtensionBuildOptions): Promise { - 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) - } + override transformLocalToRemote(appConfiguration: AppConfiguration): object | undefined { + return this.specification.transformLocalToRemote?.(this.configuration, appConfiguration) } - 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)) - await this.keepBuiltSourcemapsLocally(bundleInputPath) + override transformRemoteToLocal(options?: {flags?: Flag[]}): object | undefined { + return this.specification.transformRemoteToLocal?.(this.configuration, options) } - async copyIntoBundle(options: ExtensionBuildOptions, bundleDirectory: string, extensionUuid?: string) { - const defaultOutputPath = this.outputPath - - this.outputPath = this.getOutputPathForDirectory(bundleDirectory, extensionUuid) - - const buildMode = this.specification.buildConfig.mode - - if (this.isThemeExtension) { - await bundleThemeExtension(this, options) - } else if (buildMode !== 'none') { - outputDebug(`Will copy pre-built file from ${defaultOutputPath} to ${this.outputPath}`) - if (await fileExists(defaultOutputPath)) { - await copyFile(defaultOutputPath, this.outputPath) - - if (buildMode === 'function') { - await bundleFunctionExtension(this.outputPath, this.outputPath) - } - } - } - } - - getOutputPathForDirectory(directory: string, outputId?: string) { - const id = this.getOutputFolderId(outputId) - return joinPath(directory, id, this.outputRelativePath) - } - - get singleTarget() { - const targets = (getPathValue(this.configuration, 'targeting') as {target: string}[]) ?? [] - if (targets.length !== 1) return undefined - return targets[0]?.target - } - - get contextValue() { - let context = this.singleTarget ?? '' - if (this.isFlow) context = this.configuration.handle ?? '' - return context - } - - async bundleConfig({ - identifiers, - developerPlatformClient, - apiKey, - appConfiguration, - }: ExtensionBundleConfigOptions): Promise { - const configValue = await this.deployConfig({apiKey, appConfiguration}) - if (!configValue) return undefined - - const result = { - config: JSON.stringify(configValue), - context: this.contextValue, - handle: this.handle, - } - - const uuid = this.isUUIDStrategyExtension - ? identifiers.extensions[this.localIdentifier]! - : identifiers.extensionsNonUuidManaged[this.localIdentifier]! - - return { - ...result, - uid: this.uid, - uuid, - specificationIdentifier: developerPlatformClient.toExtensionGraphQLType(this.graphQLType), - } + override patchWithAppDevURLs(urls: ApplicationURLs): void { + if (!this.specification.patchWithAppDevURLs) return + this.specification.patchWithAppDevURLs(this.configuration, urls) } - async getDevSessionUpdateMessages(): Promise { + override async getDevSessionUpdateMessages(): Promise { if (!this.specification.getDevSessionUpdateMessages) return undefined return this.specification.getDevSessionUpdateMessages(this.configuration) } - /** - * Patches the configuration with the app dev URLs if applicable - * Only for modules that use the app URL in their configuration. - * @param urls - The app dev URLs - */ - patchWithAppDevURLs(urls: ApplicationURLs) { - if (!this.specification.patchWithAppDevURLs) return - this.specification.patchWithAppDevURLs(this.configuration, urls) - } - - async contributeToSharedTypeFile(typeDefinitionsByFile: Map>) { + override async contributeToSharedTypeFile(typeDefinitionsByFile: Map>): Promise { await this.specification.contributeToSharedTypeFile?.(this, typeDefinitionsByFile) } - /** - * Returns all files that need to be watched for this extension - * This includes files in the extension directory (respecting watch paths and gitignore) - * as well as any imported files from outside the extension directory - */ - watchedFiles(): string[] { - const watchedFiles: string[] = [] - - const defaultIgnore = [ - '**/node_modules/**', - '**/.git/**', - '**/*.test.*', - '**/dist/**', - '**/*.swp', - '**/generated/**', - '**/.gitignore', - ] - const watchConfig = this.devSessionWatchConfig - - const patterns = watchConfig?.paths ?? ['**/*'] - const ignore = watchConfig?.ignore ?? defaultIgnore - const files = patterns.flatMap((pattern) => - globSync(pattern, { - cwd: this.directory, - absolute: true, - followSymbolicLinks: false, - ignore, - }), - ) - watchedFiles.push(...files.flat()) - - // Add imported files from outside the extension directory unless custom watch config is defined - if (!watchConfig) { - const importedFiles = this.scanImports() - watchedFiles.push(...importedFiles) - } - - return [...new Set(watchedFiles.map((file) => normalizePath(file)))] - } - - /** - * Copy static assets from the extension directory to the output path - * Used by both dev and deploy builds - */ - async copyStaticAssets(outputPath?: string) { + override async copyStaticAssets(outputPath?: string): Promise { if (this.specification.copyStaticAssets) { return this.specification.copyStaticAssets(this.configuration, this.directory, outputPath ?? this.outputPath) } } - /** - * Rescans imports for this extension and updates the cached import paths - * Returns true if the imports changed - */ - async rescanImports(): Promise { - const oldImportPaths = this.cachedImportPaths - this.cachedImportPaths = undefined - clearImportPathsCache() - this.scanImports() - return oldImportPaths !== this.cachedImportPaths - } - - /** - * Scans for imports in this extension's entry files - * Returns absolute paths of imported files that are outside the extension directory - */ - private scanImports(): string[] { - // Return cached paths if available - if (this.cachedImportPaths !== undefined) { - return this.cachedImportPaths - } - - if (isTruthy(process.env.SHOPIFY_CLI_DISABLE_IMPORT_SCANNING)) { - this.cachedImportPaths = [] - return this.cachedImportPaths - } - - try { - const startTime = performance.now() - const entryFiles = this.devSessionDefaultWatchPaths() - - const imports = entryFiles.flatMap((entryFile) => { - return extractImportPathsRecursively(entryFile).map((importPath) => normalizePath(resolvePath(importPath))) - }) - - 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}`, - ) - return this.cachedImportPaths - // eslint-disable-next-line no-catch-all/no-catch-all - } catch (error) { - outputDebug(`Failed to scan imports for extension ${this.handle}: ${error}`) - this.cachedImportPaths = [] - return this.cachedImportPaths - } - } - - private buildHandle() { - switch (this.specification.uidStrategy) { - case 'single': - return this.specification.identifier - case 'uuid': - return this.configuration.handle ?? slugify(this.name ?? '') - case 'dynamic': - // Hardcoded temporal solution for webhooks - if ('topic' in this.configuration && 'uri' in this.configuration) { - const subscription = this.configuration as unknown as SingleWebhookSubscriptionType - const handle = `${subscription.topic}${subscription.uri}${subscription.filter}` - return hashString(handle).substring(0, MAX_EXTENSION_HANDLE_LENGTH) - } else { - return nonRandomUUID(JSON.stringify(this.configuration)) - } - default: - return this.specification.identifier - } - } - - private buildUIDFromStrategy() { - switch (this.specification.uidStrategy) { - case 'single': - return this.specification.identifier - case 'uuid': - return this.configuration.uid ?? nonRandomUUID(this.handle) - case 'dynamic': - // NOTE: This is a temporary special case for webhook subscriptions. - // We're directly checking for webhook properties and casting the configuration - // instead of using a proper dynamic strategy implementation. - // To remove this special case: - // 1. Implement a proper dynamic UID strategy for webhooks in the server-side specification - // 2. Update the CLI to use that strategy instead of this hardcoded logic - // Related issues: PR #559094 in old Core repo - if ('topic' in this.configuration && 'uri' in this.configuration) { - const subscription = this.configuration as unknown as SingleWebhookSubscriptionType - return `${subscription.topic}::${subscription.filter}::${subscription.uri}`.substring(0, MAX_UID_LENGTH) - } else { - return nonRandomUUID(JSON.stringify(this.configuration)) - } + override get devSessionWatchConfig(): DevSessionWatchConfig | undefined { + if (this.specification.devSessionWatchConfig) { + return this.specification.devSessionWatchConfig(this) } + return super.devSessionWatchConfig } } - -interface ExtensionDeployConfigOptions { - apiKey: string - appConfiguration: AppConfiguration -} - -interface ExtensionBundleConfigOptions { - identifiers: Identifiers - developerPlatformClient: DeveloperPlatformClient - apiKey: string - appConfiguration: AppConfiguration -} - -interface BundleConfig { - config: string - context: string - handle: string - uid: string - uuid: string - specificationIdentifier: string -} diff --git a/packages/app/src/cli/models/extensions/module-descriptor.ts b/packages/app/src/cli/models/extensions/module-descriptor.ts new file mode 100644 index 00000000000..fa36eaf3e3c --- /dev/null +++ b/packages/app/src/cli/models/extensions/module-descriptor.ts @@ -0,0 +1,77 @@ +import {ZodSchemaType, BaseConfigType} from './schemas.js' +import {ApplicationModule, ExtensionExperience, UidStrategy} from './application-module.js' +import {RemoteSpecification} from '../../api/graphql/extension_specifications.js' +import {ParseConfigurationResult} from '@shopify/cli-kit/node/schema' + +/** + * Pre-instantiation metadata for an application module type. + * + * This interface replaces ExtensionSpecification for concerns that must be + * resolved BEFORE creating an ApplicationModule instance: + * - Type identification (identifier lookup) + * - Configuration schema and parsing + * - App configuration schema contribution + * - Instance creation (factory method) + * + * Unlike ExtensionSpecification, this interface has NO instance behavior. + * All instance behavior lives on ApplicationModule subclasses. + * + * Each module type (function, ui_extension, theme, etc.) provides one + * ModuleDescriptor that is registered in the ModuleRegistry. + */ +export interface ModuleDescriptor { + /** Primary type identifier (e.g., 'function', 'ui_extension', 'theme') */ + identifier: string + + /** Additional type strings this descriptor matches (e.g., function subtypes like 'order_discounts') */ + additionalIdentifiers: string[] + + /** External identifier used by the platform API */ + externalIdentifier: string + + /** Human-readable name for display */ + externalName: string + + /** Identifier used in Partners web UI */ + partnersWebIdentifier: string + + /** Surface where the module renders */ + surface: string + + /** Maximum number of instances allowed */ + registrationLimit: number + + /** Whether this is an extension or app configuration module */ + experience: ExtensionExperience + + /** Strategy for generating UIDs (overridden by remote spec) */ + uidStrategy: UidStrategy + + /** Zod schema for validating configuration */ + schema: ZodSchemaType + + /** + * Have this descriptor contribute to the schema used to validate app configuration. + * For descriptors that don't form part of app config, returns the schema unchanged. + */ + contributeToAppConfigurationSchema: (appConfigSchema: ZodSchemaType) => ZodSchemaType + + /** + * Parse a configuration object into a valid configuration for this module type. + * Returns a success/error result with parsed data or validation errors. + */ + parseConfigurationObject: (configurationObject: object) => ParseConfigurationResult + + /** + * Factory: create the correct ApplicationModule subclass for this type. + * The descriptor knows which class to instantiate and passes through + * the construction options. + */ + createModule: (options: { + configuration: TConfiguration + configurationPath: string + entryPath?: string + directory: string + remoteSpec: RemoteSpecification + }) => ApplicationModule +} diff --git a/packages/app/src/cli/models/extensions/module-registry.ts b/packages/app/src/cli/models/extensions/module-registry.ts new file mode 100644 index 00000000000..55eec0e1ee1 --- /dev/null +++ b/packages/app/src/cli/models/extensions/module-registry.ts @@ -0,0 +1,118 @@ +import {ZodSchemaType} from './schemas.js' +import {ModuleDescriptor} from './module-descriptor.js' +import {RemoteSpecification} from '../../api/graphql/extension_specifications.js' +import {outputDebug} from '@shopify/cli-kit/node/output' + +/** + * Registry of all known module descriptors. + * + * Provides the pre-instantiation API needed by the app loader: + * - Type lookup: find the right descriptor for a given type string + * - Schema building: fold all config descriptors into the app config schema + * - Remote merging: apply remote spec overrides to local descriptors + * - Dynamic registration: add contract-based descriptors for remote-only types + * + * Replaces the flat `ExtensionSpecification[]` array that was previously + * threaded through the app loading pipeline. + */ +export class ModuleRegistry { + private descriptors: ModuleDescriptor[] = [] + + register(descriptor: ModuleDescriptor): void { + this.descriptors.push(descriptor) + } + + /** + * Find the descriptor that handles a given type string. + * Matches against identifier, externalIdentifier, and additionalIdentifiers. + */ + findForType(type: string): ModuleDescriptor | undefined { + return this.descriptors.find( + (desc) => + desc.identifier === type || desc.externalIdentifier === type || desc.additionalIdentifiers.includes(type), + ) + } + + /** + * Build the app configuration schema by folding all descriptor contributions. + * Each config descriptor merges its schema into the accumulator. + */ + buildAppConfigurationSchema(baseSchema: ZodSchemaType): ZodSchemaType { + return this.descriptors.reduce>( + (schema, desc) => desc.contributeToAppConfigurationSchema(schema), + baseSchema, + ) + } + + allDescriptors(): ModuleDescriptor[] { + return [...this.descriptors] + } + + configDescriptors(): ModuleDescriptor[] { + return this.descriptors.filter((desc) => desc.experience === 'configuration') + } + + extensionDescriptors(): ModuleDescriptor[] { + return this.descriptors.filter((desc) => desc.experience === 'extension') + } + + /** + * Merge remote spec data into local descriptors. + * Remote values override local for: externalName, externalIdentifier, + * registrationLimit, uidStrategy, surface. + * + * Returns the list of remote specs that had no local descriptor match + * (these may need contract-based descriptors created separately). + */ + mergeRemoteSpecs(remoteSpecs: RemoteSpecification[]): RemoteSpecification[] { + const unmatchedRemote: RemoteSpecification[] = [] + + for (const remoteSpec of remoteSpecs) { + const local = this.descriptors.find((desc) => desc.identifier === remoteSpec.identifier) + if (local) { + local.externalName = remoteSpec.externalName + local.externalIdentifier = remoteSpec.externalIdentifier + local.registrationLimit = remoteSpec.registrationLimit + local.uidStrategy = remoteSpec.uidStrategy + local.surface = remoteSpec.surface ?? local.surface + } else { + unmatchedRemote.push(remoteSpec) + } + } + + const presentLocalMissingRemote = this.descriptors.filter( + (desc) => !remoteSpecs.some((rs) => rs.identifier === desc.identifier), + ) + if (presentLocalMissingRemote.length > 0) { + outputDebug( + `Module descriptors without matching remote spec: ${presentLocalMissingRemote + .map((desc) => desc.identifier) + .sort() + .join(', ')}`, + ) + } + + if (unmatchedRemote.length > 0) { + outputDebug( + `Remote specs without matching local descriptor: ${unmatchedRemote + .map((rs) => rs.identifier) + .sort() + .join(', ')}`, + ) + } + + return unmatchedRemote + } + + /** + * Remove descriptors that have no matching remote spec. + * Used to filter out gated descriptors the user can't access. + */ + retainOnlyMatching(remoteSpecs: RemoteSpecification[]): void { + this.descriptors = this.descriptors.filter((desc) => remoteSpecs.some((rs) => rs.identifier === desc.identifier)) + } + + get size(): number { + return this.descriptors.length + } +} diff --git a/packages/app/src/cli/models/extensions/specification.ts b/packages/app/src/cli/models/extensions/specification.ts index 94907693f10..c6dc903f9cf 100644 --- a/packages/app/src/cli/models/extensions/specification.ts +++ b/packages/app/src/cli/models/extensions/specification.ts @@ -1,8 +1,10 @@ +// Re-export shared types from their canonical location in application-module.ts. +// Consumers currently import these from specification.ts; these re-exports +// preserve backward compatibility during the migration. import {ZodSchemaType, BaseConfigType, BaseSchema} from './schemas.js' import {ExtensionInstance} from './extension-instance.js' import {blocks} from '../../constants.js' import {ClientSteps} from '../../services/build/client-steps.js' - import {Flag} from '../../utilities/developer-platform-client.js' import {AppConfiguration} from '../app/app.js' import {loadLocalesConfig} from '../../utilities/extensions/locales-configuration.js' @@ -12,16 +14,16 @@ import {capitalize} from '@shopify/cli-kit/common/string' import {ParseConfigurationResult, zod} from '@shopify/cli-kit/node/schema' import {getPathValue, setPathValue} from '@shopify/cli-kit/common/object' import {JsonMapType} from '@shopify/cli-kit/node/toml' +import type { + ExtensionFeature, + ExtensionExperience, + UidStrategy, + BuildConfig, + Asset, + DevSessionWatchConfig, +} from './application-module.js' -export type ExtensionFeature = - | 'ui_preview' - | 'function' - | 'theme' - | 'cart_url' - | 'esbuild' - | 'single_js_entry_path' - | 'localization' - | 'generates_source_maps' +export {type ExtensionFeature, AssetIdentifier, type Asset, type DevSessionWatchConfig} from './application-module.js' export type TransformationConfig = Record @@ -30,25 +32,9 @@ export interface CustomTransformationConfig { 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 { - ShouldRender = 'should_render', - Main = 'main', - Tools = 'tools', - Instructions = 'instructions', -} - -export interface Asset { - identifier: AssetIdentifier - outputFileName: string - content: string -} export interface BuildAsset { filepath: string @@ -56,10 +42,6 @@ export interface BuildAsset { static?: boolean } -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. */ @@ -145,13 +127,6 @@ export interface ExtensionSpecification) => DevSessionWatchConfig | undefined } -export interface DevSessionWatchConfig { - /** Absolute paths or globs to watch */ - paths: string[] - /** Glob patterns to ignore. When provided, replaces the default ignore list entirely. */ - ignore?: string[] -} - /** * Extension specification, explicitly marked as having taken remote configuration values into account. */