From 2830f1b34594a08d6d9acbbe3b1aaa1a26952804 Mon Sep 17 00:00:00 2001 From: Alfonso Noriega Date: Tue, 10 Feb 2026 17:00:26 +0100 Subject: [PATCH] Abstract build steps to externalize the build configuration --- .../extensions/extension-instance.test.ts | 4 +- .../models/extensions/extension-instance.ts | 58 +-- .../cli/models/extensions/specification.ts | 9 +- .../app_config_hosted_app_home.test.ts | 65 ++- .../app_config_hosted_app_home.ts | 29 +- .../extensions/specifications/channel.test.ts | 156 ++++++ .../extensions/specifications/channel.ts | 16 +- .../specifications/checkout_post_purchase.ts | 9 +- .../specifications/checkout_ui_extension.ts | 9 +- .../specifications/flow_template.test.ts | 152 ++++++ .../specifications/flow_template.ts | 19 +- .../extensions/specifications/function.ts | 6 +- .../specifications/function_build.test.ts | 64 +++ .../specifications/pos_ui_extension.ts | 9 +- .../specifications/product_subscription.ts | 9 +- .../specifications/tax_calculation.ts | 6 +- .../tax_calculation_build.test.ts | 67 +++ .../extensions/specifications/theme.test.ts | 138 ++++++ .../models/extensions/specifications/theme.ts | 19 +- .../extensions/specifications/ui_extension.ts | 9 +- .../specifications/ui_extension_build.test.ts | 90 ++++ .../specifications/web_pixel_extension.ts | 9 +- .../build/build-steps.integration.test.ts | 209 ++++++++ .../cli/services/build/build-steps.test.ts | 414 ++++++++++++++++ .../app/src/cli/services/build/build-steps.ts | 288 +++++++++++ .../build/steps/build-function-step.ts | 12 + .../services/build/steps/build-theme-step.ts | 14 + .../services/build/steps/bundle-theme-step.ts | 27 + .../services/build/steps/bundle-ui-step.ts | 11 + .../build/steps/copy-files-step.test.ts | 468 ++++++++++++++++++ .../services/build/steps/copy-files-step.ts | 244 +++++++++ .../build/steps/copy-static-assets-step.ts | 11 + .../build/steps/create-tax-stub-step.ts | 14 + .../app/src/cli/services/build/steps/index.ts | 54 ++ 34 files changed, 2620 insertions(+), 98 deletions(-) create mode 100644 packages/app/src/cli/models/extensions/specifications/channel.test.ts create mode 100644 packages/app/src/cli/models/extensions/specifications/flow_template.test.ts create mode 100644 packages/app/src/cli/models/extensions/specifications/function_build.test.ts create mode 100644 packages/app/src/cli/models/extensions/specifications/tax_calculation_build.test.ts create mode 100644 packages/app/src/cli/models/extensions/specifications/theme.test.ts create mode 100644 packages/app/src/cli/models/extensions/specifications/ui_extension_build.test.ts create mode 100644 packages/app/src/cli/services/build/build-steps.integration.test.ts create mode 100644 packages/app/src/cli/services/build/build-steps.test.ts create mode 100644 packages/app/src/cli/services/build/build-steps.ts create mode 100644 packages/app/src/cli/services/build/steps/build-function-step.ts create mode 100644 packages/app/src/cli/services/build/steps/build-theme-step.ts create mode 100644 packages/app/src/cli/services/build/steps/bundle-theme-step.ts create mode 100644 packages/app/src/cli/services/build/steps/bundle-ui-step.ts create mode 100644 packages/app/src/cli/services/build/steps/copy-files-step.test.ts create mode 100644 packages/app/src/cli/services/build/steps/copy-files-step.ts create mode 100644 packages/app/src/cli/services/build/steps/copy-static-assets-step.ts create mode 100644 packages/app/src/cli/services/build/steps/create-tax-stub-step.ts create mode 100644 packages/app/src/cli/services/build/steps/index.ts diff --git a/packages/app/src/cli/models/extensions/extension-instance.test.ts b/packages/app/src/cli/models/extensions/extension-instance.test.ts index 2adc27a7d1b..d96ccdeb662 100644 --- a/packages/app/src/cli/models/extensions/extension-instance.test.ts +++ b/packages/app/src/cli/models/extensions/extension-instance.test.ts @@ -148,8 +148,8 @@ describe('build', async () => { // Given const extensionInstance = await testTaxCalculationExtension(tmpDir) const options: ExtensionBuildOptions = { - stdout: new Writable(), - stderr: new Writable(), + stdout: new Writable({write(chunk, enc, cb) { cb() }}), + stderr: new Writable({write(chunk, enc, cb) { cb() }}), app: testApp(), environment: 'production', } diff --git a/packages/app/src/cli/models/extensions/extension-instance.ts b/packages/app/src/cli/models/extensions/extension-instance.ts index 932033f3154..4e6a55a8b6f 100644 --- a/packages/app/src/cli/models/extensions/extension-instance.ts +++ b/packages/app/src/cli/models/extensions/extension-instance.ts @@ -14,14 +14,9 @@ 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 {ExtensionBuildOptions, bundleFunctionExtension} from '../../services/build/extension.js' +import {bundleThemeExtension} from '../../services/extensions/bundle.js' +import {BuildContext, executeStep} from '../../services/build/build-steps.js' import {Identifiers} from '../app/identifiers.js' import {DeveloperPlatformClient} from '../../utilities/developer-platform-client.js' import {AppConfigurationWithoutPath} from '../app/app.js' @@ -31,7 +26,7 @@ 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, basename, normalizePath, resolvePath} from '@shopify/cli-kit/node/path' -import {fileExists, touchFile, moveFile, writeFile, glob, copyFile, globSync} from '@shopify/cli-kit/node/fs' +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} from '@shopify/cli-kit/node/import-extractor' @@ -347,34 +342,25 @@ export class ExtensionInstance { - const mode = this.specification.buildConfig.mode + const {buildConfig} = this.specification + + const context: BuildContext = { + extension: this, + options, + stepResults: new Map(), + signal: options.signal, + } - 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 + const {steps = [], stopOnError = true} = buildConfig + + 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 && stopOnError && !step.continueOnError) { + throw new Error(`Build step "${step.displayName}" failed: ${result.error?.message}`) + } } } diff --git a/packages/app/src/cli/models/extensions/specification.ts b/packages/app/src/cli/models/extensions/specification.ts index 9c3b5625b90..16793b2d6f7 100644 --- a/packages/app/src/cli/models/extensions/specification.ts +++ b/packages/app/src/cli/models/extensions/specification.ts @@ -2,6 +2,7 @@ import {ZodSchemaType, BaseConfigType, BaseSchema} from './schemas.js' import {ExtensionInstance} from './extension-instance.js' import {blocks} from '../../constants.js' +import {BuildStep} from '../../services/build/build-steps.js' import {Flag} from '../../utilities/developer-platform-client.js' import {AppConfigurationWithoutPath} from '../app/app.js' @@ -54,9 +55,11 @@ export interface BuildAsset { static?: boolean } -type BuildConfig = - | {mode: 'ui' | 'theme' | 'function' | 'tax_calculation' | 'none' | 'hosted_app_home'} - | {mode: 'copy_files'; filePatterns: string[]; ignoredFilePatterns?: string[]} +interface BuildConfig { + mode: 'none' | 'ui' | 'theme' | 'function' | 'tax_calculation' | 'copy_files' + steps?: ReadonlyArray + stopOnError?: boolean +} /** * Extension specification with all the needed properties and methods to load an extension. */ 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 index c45f98bdf6b..bb798939cca 100644 --- 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 @@ -1,9 +1,6 @@ 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') +import {describe, expect, test} from 'vitest' describe('hosted_app_home', () => { describe('transform', () => { @@ -54,43 +51,43 @@ describe('hosted_app_home', () => { }) }) - 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') + describe('buildConfig', () => { + test('should use copy_files mode', () => { + expect(spec.buildConfig.mode).toBe('copy_files') }) - 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) + test('should have copy-static-assets step with tomlKey entry', () => { + if (spec.buildConfig.mode === 'none') { + throw new Error('Expected build_steps mode') + } - expect(copyDirectoryContents).not.toHaveBeenCalled() + expect(spec.buildConfig.steps).toHaveLength(1) + expect(spec.buildConfig.steps![0]).toMatchObject({ + id: 'copy-static-assets', + displayName: 'Copy Static Assets', + type: 'copy_files', + config: { + strategy: 'files', + definition: {files: [{tomlKey: 'static_root'}]}, + }, + }) + expect(spec.buildConfig.stopOnError).toBe(true) }) - 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' + test('config should be serializable to JSON', () => { + if (spec.buildConfig.mode === 'none') { + throw new Error('Expected build_steps mode') + } - await expect(spec.copyStaticAssets!(config, directory, outputPath)).rejects.toThrow( - 'Failed to copy static assets from /app/root/public to /output/dist: Permission denied', - ) - }) - }) + const serialized = JSON.stringify(spec.buildConfig) + expect(serialized).toBeDefined() - describe('buildConfig', () => { - test('should have hosted_app_home build mode', () => { - expect(spec.buildConfig).toEqual({mode: 'hosted_app_home'}) + const deserialized = JSON.parse(serialized) + expect(deserialized.steps).toHaveLength(1) + expect(deserialized.steps[0].config).toEqual({ + strategy: 'files', + definition: {files: [{tomlKey: 'static_root'}]}, + }) }) }) 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 6b71b710496..41a82d889af 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 @@ -1,7 +1,5 @@ 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({ @@ -16,18 +14,25 @@ export const HostedAppHomeSpecIdentifier = 'hosted_app_home' const hostedAppHomeSpec = createConfigExtensionSpecification({ identifier: HostedAppHomeSpecIdentifier, - buildConfig: {mode: 'hosted_app_home'} as const, + buildConfig: { + mode: 'copy_files', + steps: [ + { + id: 'copy-static-assets', + displayName: 'Copy Static Assets', + type: 'copy_files', + config: { + strategy: 'files', + definition: { + files: [{tomlKey: 'static_root'}], + }, + }, + }, + ], + stopOnError: true, + }, 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/models/extensions/specifications/channel.test.ts b/packages/app/src/cli/models/extensions/specifications/channel.test.ts new file mode 100644 index 00000000000..203a5048095 --- /dev/null +++ b/packages/app/src/cli/models/extensions/specifications/channel.test.ts @@ -0,0 +1,156 @@ +import spec from './channel.js' +import {ExtensionInstance} from '../extension-instance.js' +import {ExtensionBuildOptions} from '../../../services/build/extension.js' +import {describe, expect, test} from 'vitest' +import {inTemporaryDirectory, writeFile, fileExists, mkdir} from '@shopify/cli-kit/node/fs' +import {joinPath} from '@shopify/cli-kit/node/path' +import {Writable} from 'stream' + +const SUBDIRECTORY = 'specifications' + +describe('channel_config', () => { + describe('buildConfig', () => { + test('uses build_steps mode', () => { + expect(spec.buildConfig.mode).toBe('copy_files') + }) + + test('has a single copy-files step scoped to the specifications subdirectory', () => { + if (spec.buildConfig.mode === 'none') throw new Error('Expected build_steps mode') + + expect(spec.buildConfig.steps).toHaveLength(1) + expect(spec.buildConfig.steps![0]).toMatchObject({ + id: 'copy-files', + type: 'copy_files', + config: { + strategy: 'pattern', + definition: {source: '.'}, + }, + }) + + const {patterns} = (spec.buildConfig.steps![0]!.config as {definition: {patterns: string[]}}).definition + + expect(patterns).toEqual( + expect.arrayContaining([ + `${SUBDIRECTORY}/**/*.json`, + `${SUBDIRECTORY}/**/*.toml`, + `${SUBDIRECTORY}/**/*.yaml`, + `${SUBDIRECTORY}/**/*.yml`, + `${SUBDIRECTORY}/**/*.svg`, + ]), + ) + }) + + test('config is serializable to JSON', () => { + if (spec.buildConfig.mode === 'none') throw new Error('Expected build_steps mode') + + const serialized = JSON.stringify(spec.buildConfig) + const deserialized = JSON.parse(serialized) + + expect(deserialized.steps).toHaveLength(1) + expect(deserialized.steps[0].config.strategy).toBe('pattern') + }) + }) + + describe('build integration', () => { + test('copies specification files to output, preserving subdirectory structure', async () => { + await inTemporaryDirectory(async (tmpDir) => { + // Given + const extensionDir = joinPath(tmpDir, 'extension') + const specsDir = joinPath(extensionDir, SUBDIRECTORY) + const outputDir = joinPath(tmpDir, 'output') + + await mkdir(specsDir) + await mkdir(outputDir) + + await writeFile(joinPath(specsDir, 'product.json'), '{}') + await writeFile(joinPath(specsDir, 'order.toml'), '[spec]') + await writeFile(joinPath(specsDir, 'logo.svg'), '') + // Root-level files should NOT be copied + await writeFile(joinPath(extensionDir, 'README.md'), '# readme') + await writeFile(joinPath(extensionDir, 'index.js'), 'ignored') + + const extension = new ExtensionInstance({ + configuration: {name: 'my-channel', type: 'channel'}, + configurationPath: '', + directory: extensionDir, + specification: spec, + }) + extension.outputPath = outputDir + + const buildOptions: ExtensionBuildOptions = { + stdout: new Writable({ + write(chunk, enc, cb) { + cb() + }, + }), + stderr: new Writable({ + write(chunk, enc, cb) { + cb() + }, + }), + app: {} as any, + environment: 'production', + } + + // When + await extension.build(buildOptions) + + // Then — specification files copied with path preserved + await expect(fileExists(joinPath(outputDir, SUBDIRECTORY, 'product.json'))).resolves.toBe(true) + await expect(fileExists(joinPath(outputDir, SUBDIRECTORY, 'order.toml'))).resolves.toBe(true) + await expect(fileExists(joinPath(outputDir, SUBDIRECTORY, 'logo.svg'))).resolves.toBe(true) + + // Root-level files not in specifications/ are not copied + await expect(fileExists(joinPath(outputDir, 'README.md'))).resolves.toBe(false) + await expect(fileExists(joinPath(outputDir, 'index.js'))).resolves.toBe(false) + }) + }) + + test('does not copy files with non-matching extensions inside specifications/', async () => { + await inTemporaryDirectory(async (tmpDir) => { + // Given + const extensionDir = joinPath(tmpDir, 'extension') + const specsDir = joinPath(extensionDir, SUBDIRECTORY) + const outputDir = joinPath(tmpDir, 'output') + + await mkdir(specsDir) + await mkdir(outputDir) + + await writeFile(joinPath(specsDir, 'spec.json'), '{}') + await writeFile(joinPath(specsDir, 'ignored.ts'), 'const x = 1') + await writeFile(joinPath(specsDir, 'ignored.js'), 'const x = 1') + + const extension = new ExtensionInstance({ + configuration: {name: 'my-channel', type: 'channel'}, + configurationPath: '', + directory: extensionDir, + specification: spec, + }) + extension.outputPath = outputDir + + const buildOptions: ExtensionBuildOptions = { + stdout: new Writable({ + write(chunk, enc, cb) { + cb() + }, + }), + stderr: new Writable({ + write(chunk, enc, cb) { + cb() + }, + }), + app: {} as any, + environment: 'production', + } + + // When + await extension.build(buildOptions) + + // Then + await expect(fileExists(joinPath(outputDir, SUBDIRECTORY, 'spec.json'))).resolves.toBe(true) + await expect(fileExists(joinPath(outputDir, SUBDIRECTORY, 'ignored.ts'))).resolves.toBe(false) + await expect(fileExists(joinPath(outputDir, SUBDIRECTORY, 'ignored.js'))).resolves.toBe(false) + }) + }) + }) +}) diff --git a/packages/app/src/cli/models/extensions/specifications/channel.ts b/packages/app/src/cli/models/extensions/specifications/channel.ts index 44ac2c2150d..8ec23458243 100644 --- a/packages/app/src/cli/models/extensions/specifications/channel.ts +++ b/packages/app/src/cli/models/extensions/specifications/channel.ts @@ -8,7 +8,21 @@ const channelSpecificationSpec = createContractBasedModuleSpecification({ identifier: 'channel_config', buildConfig: { mode: 'copy_files', - filePatterns: FILE_EXTENSIONS.map((ext) => joinPath(SUBDIRECTORY_NAME, '**', `*.${ext}`)), + steps: [ + { + id: 'copy-files', + displayName: 'Copy Files', + type: 'copy_files', + config: { + strategy: 'pattern', + definition: { + source: '.', + patterns: FILE_EXTENSIONS.map((ext) => joinPath(SUBDIRECTORY_NAME, '**', `*.${ext}`)), + }, + }, + }, + ], + stopOnError: true, }, appModuleFeatures: () => [], }) diff --git a/packages/app/src/cli/models/extensions/specifications/checkout_post_purchase.ts b/packages/app/src/cli/models/extensions/specifications/checkout_post_purchase.ts index 616adf80b73..4ec2cb3be3a 100644 --- a/packages/app/src/cli/models/extensions/specifications/checkout_post_purchase.ts +++ b/packages/app/src/cli/models/extensions/specifications/checkout_post_purchase.ts @@ -14,7 +14,14 @@ const checkoutPostPurchaseSpec = createExtensionSpecification({ partnersWebIdentifier: 'post_purchase', schema: CheckoutPostPurchaseSchema, appModuleFeatures: (_) => ['ui_preview', 'cart_url', 'esbuild', 'single_js_entry_path'], - buildConfig: {mode: 'ui'}, + buildConfig: { + mode: 'ui', + steps: [ + {id: 'bundle-ui', displayName: 'Bundle UI Extension', type: 'bundle_ui', config: {}}, + {id: 'copy-static-assets', displayName: 'Copy Static Assets', type: 'copy_static_assets', config: {}}, + ], + stopOnError: true, + }, deployConfig: async (config, _) => { return {metafields: config.metafields ?? []} }, diff --git a/packages/app/src/cli/models/extensions/specifications/checkout_ui_extension.ts b/packages/app/src/cli/models/extensions/specifications/checkout_ui_extension.ts index f08dfd97c40..57435674be8 100644 --- a/packages/app/src/cli/models/extensions/specifications/checkout_ui_extension.ts +++ b/packages/app/src/cli/models/extensions/specifications/checkout_ui_extension.ts @@ -21,7 +21,14 @@ const checkoutSpec = createExtensionSpecification({ dependency, schema: CheckoutSchema, appModuleFeatures: (_) => ['ui_preview', 'cart_url', 'esbuild', 'single_js_entry_path', 'generates_source_maps'], - buildConfig: {mode: 'ui'}, + buildConfig: { + mode: 'ui', + steps: [ + {id: 'bundle-ui', displayName: 'Bundle UI Extension', type: 'bundle_ui', config: {}}, + {id: 'copy-static-assets', displayName: 'Copy Static Assets', type: 'copy_static_assets', config: {}}, + ], + stopOnError: true, + }, deployConfig: async (config, directory) => { return { extension_points: config.extension_points, diff --git a/packages/app/src/cli/models/extensions/specifications/flow_template.test.ts b/packages/app/src/cli/models/extensions/specifications/flow_template.test.ts new file mode 100644 index 00000000000..59de1a53e8b --- /dev/null +++ b/packages/app/src/cli/models/extensions/specifications/flow_template.test.ts @@ -0,0 +1,152 @@ +import spec from './flow_template.js' +import {ExtensionInstance} from '../extension-instance.js' +import {ExtensionBuildOptions} from '../../../services/build/extension.js' +import {describe, expect, test} from 'vitest' +import {inTemporaryDirectory, writeFile, fileExists, mkdir} from '@shopify/cli-kit/node/fs' +import {joinPath} from '@shopify/cli-kit/node/path' +import {Writable} from 'stream' + +describe('flow_template', () => { + describe('buildConfig', () => { + test('uses build_steps mode', () => { + expect(spec.buildConfig.mode).toBe('copy_files') + }) + + test('has a single copy-files step', () => { + if (spec.buildConfig.mode === 'none') throw new Error('Expected build_steps mode') + + expect(spec.buildConfig.steps).toHaveLength(1) + expect(spec.buildConfig.steps![0]).toMatchObject({ + id: 'copy-files', + type: 'copy_files', + config: { + strategy: 'pattern', + definition: { + source: '.', + patterns: expect.arrayContaining(['**/*.flow', '**/*.json', '**/*.toml']), + }, + }, + }) + }) + + test('only copies flow, json, and toml files — not js or ts files', () => { + if (spec.buildConfig.mode === 'none') throw new Error('Expected build_steps mode') + + const {definition} = spec.buildConfig.steps[0]!.config as { + definition: {patterns: string[]} + } + + expect(definition.patterns).toContain('**/*.flow') + expect(definition.patterns).toContain('**/*.json') + expect(definition.patterns).toContain('**/*.toml') + expect(definition.patterns).not.toContain('**/*.js') + expect(definition.patterns).not.toContain('**/*.ts') + }) + + test('config is serializable to JSON', () => { + if (spec.buildConfig.mode === 'none') throw new Error('Expected build_steps mode') + + const serialized = JSON.stringify(spec.buildConfig) + const deserialized = JSON.parse(serialized) + + expect(deserialized.steps).toHaveLength(1) + expect(deserialized.steps[0].config.strategy).toBe('pattern') + }) + }) + + describe('build integration', () => { + test('copies flow, json, and toml files to output directory', async () => { + await inTemporaryDirectory(async (tmpDir) => { + // Given + const extensionDir = joinPath(tmpDir, 'extension') + const outputDir = joinPath(tmpDir, 'output') + + await mkdir(extensionDir) + await mkdir(outputDir) + + await writeFile(joinPath(extensionDir, 'template.flow'), 'flow-content') + await writeFile(joinPath(extensionDir, 'config.json'), '{}') + await writeFile(joinPath(extensionDir, 'shopify.app.toml'), '[extension]') + await writeFile(joinPath(extensionDir, 'index.js'), 'console.log("ignored")') + await writeFile(joinPath(extensionDir, 'index.ts'), 'const x = 1') + + const extension = new ExtensionInstance({ + configuration: {name: 'my-flow-template', type: 'flow_template'}, + configurationPath: '', + directory: extensionDir, + specification: spec, + }) + extension.outputPath = outputDir + + const buildOptions: ExtensionBuildOptions = { + stdout: new Writable({ + write(chunk, enc, cb) { + cb() + }, + }), + stderr: new Writable({ + write(chunk, enc, cb) { + cb() + }, + }), + app: {} as any, + environment: 'production', + } + + // When + await extension.build(buildOptions) + + // Then — only matching extensions are copied + await expect(fileExists(joinPath(outputDir, 'template.flow'))).resolves.toBe(true) + await expect(fileExists(joinPath(outputDir, 'config.json'))).resolves.toBe(true) + await expect(fileExists(joinPath(outputDir, 'shopify.app.toml'))).resolves.toBe(true) + await expect(fileExists(joinPath(outputDir, 'index.js'))).resolves.toBe(false) + await expect(fileExists(joinPath(outputDir, 'index.ts'))).resolves.toBe(false) + }) + }) + + test('preserves subdirectory structure when copying', async () => { + await inTemporaryDirectory(async (tmpDir) => { + // Given + const extensionDir = joinPath(tmpDir, 'extension') + const outputDir = joinPath(tmpDir, 'output') + const subDir = joinPath(extensionDir, 'sub') + + await mkdir(extensionDir) + await mkdir(subDir) + await mkdir(outputDir) + + await writeFile(joinPath(subDir, 'nested.flow'), 'nested-flow-content') + + const extension = new ExtensionInstance({ + configuration: {name: 'my-flow-template', type: 'flow_template'}, + configurationPath: '', + directory: extensionDir, + specification: spec, + }) + extension.outputPath = outputDir + + const buildOptions: ExtensionBuildOptions = { + stdout: new Writable({ + write(chunk, enc, cb) { + cb() + }, + }), + stderr: new Writable({ + write(chunk, enc, cb) { + cb() + }, + }), + app: {} as any, + environment: 'production', + } + + // When + await extension.build(buildOptions) + + // Then — subdirectory structure is preserved + await expect(fileExists(joinPath(outputDir, 'sub', 'nested.flow'))).resolves.toBe(true) + }) + }) + }) +}) diff --git a/packages/app/src/cli/models/extensions/specifications/flow_template.ts b/packages/app/src/cli/models/extensions/specifications/flow_template.ts index 19841b9a4ad..9096eeb1687 100644 --- a/packages/app/src/cli/models/extensions/specifications/flow_template.ts +++ b/packages/app/src/cli/models/extensions/specifications/flow_template.ts @@ -49,7 +49,24 @@ const flowTemplateSpec = createExtensionSpecification({ identifier: 'flow_template', schema: FlowTemplateExtensionSchema, appModuleFeatures: (_) => ['ui_preview'], - buildConfig: {mode: 'copy_files', filePatterns: ['*.flow', '*.json', '*.toml']}, + buildConfig: { + mode: 'copy_files', + steps: [ + { + id: 'copy-files', + displayName: 'Copy Files', + type: 'copy_files', + config: { + strategy: 'pattern', + definition: { + source: '.', + patterns: ['**/*.flow', '**/*.json', '**/*.toml'], + }, + }, + }, + ], + stopOnError: true, + }, deployConfig: async (config, extensionPath) => { return { template_handle: config.handle, diff --git a/packages/app/src/cli/models/extensions/specifications/function.ts b/packages/app/src/cli/models/extensions/specifications/function.ts index 4687dc44558..6031056f485 100644 --- a/packages/app/src/cli/models/extensions/specifications/function.ts +++ b/packages/app/src/cli/models/extensions/specifications/function.ts @@ -83,7 +83,11 @@ const functionSpec = createExtensionSpecification({ ], schema: FunctionExtensionSchema, appModuleFeatures: (_) => ['function'], - buildConfig: {mode: 'function'}, + buildConfig: { + mode: 'function', + steps: [{id: 'build-function', displayName: 'Build Function', type: 'build_function', config: {}}], + stopOnError: true, + }, deployConfig: async (config, directory, apiKey) => { let inputQuery: string | undefined const moduleId = randomUUID() diff --git a/packages/app/src/cli/models/extensions/specifications/function_build.test.ts b/packages/app/src/cli/models/extensions/specifications/function_build.test.ts new file mode 100644 index 00000000000..6bb7fe1052a --- /dev/null +++ b/packages/app/src/cli/models/extensions/specifications/function_build.test.ts @@ -0,0 +1,64 @@ +import functionSpec from './function.js' +import {ExtensionInstance} from '../extension-instance.js' +import {describe, expect, test, vi} from 'vitest' +import {Writable} from 'stream' + +vi.mock('../../../services/build/extension.js', async (importOriginal) => { + const original = await importOriginal() + return {...original, buildFunctionExtension: vi.fn().mockResolvedValue(undefined)} +}) + +describe('function buildConfig', () => { + test('uses build_steps mode', () => { + expect(functionSpec.buildConfig.mode).toBe('function') + }) + + test('has a single build-function step', () => { + if (functionSpec.buildConfig.mode === 'none') throw new Error('Expected build_steps mode') + + const {steps} = functionSpec.buildConfig + + expect(steps).toHaveLength(1) + expect(steps[0]).toMatchObject({id: 'build-function', type: 'build_function'}) + }) + + test('config is serializable to JSON', () => { + if (functionSpec.buildConfig.mode === 'none') throw new Error('Expected build_steps mode') + + const serialized = JSON.stringify(functionSpec.buildConfig) + const deserialized = JSON.parse(serialized) + + expect(deserialized.steps).toHaveLength(1) + expect(deserialized.steps[0].type).toBe('build_function') + }) + + test('build_function step invokes buildFunctionExtension', async () => { + const {buildFunctionExtension} = await import('../../../services/build/extension.js') + + const extension = new ExtensionInstance({ + configuration: {name: 'my-function', type: 'product_discounts', api_version: '2022-07'}, + configurationPath: '', + directory: '/tmp/func', + specification: functionSpec, + }) + + const buildOptions = { + stdout: new Writable({ + write(chunk, enc, cb) { + cb() + }, + }), + stderr: new Writable({ + write(chunk, enc, cb) { + cb() + }, + }), + app: {} as any, + environment: 'production' as const, + } + + await extension.build(buildOptions) + + expect(buildFunctionExtension).toHaveBeenCalledWith(extension, buildOptions) + }) +}) diff --git a/packages/app/src/cli/models/extensions/specifications/pos_ui_extension.ts b/packages/app/src/cli/models/extensions/specifications/pos_ui_extension.ts index 33962306a24..232931263bf 100644 --- a/packages/app/src/cli/models/extensions/specifications/pos_ui_extension.ts +++ b/packages/app/src/cli/models/extensions/specifications/pos_ui_extension.ts @@ -11,7 +11,14 @@ const posUISpec = createExtensionSpecification({ dependency, schema: BaseSchema.extend({name: zod.string()}), appModuleFeatures: (_) => ['ui_preview', 'esbuild', 'single_js_entry_path'], - buildConfig: {mode: 'ui'}, + buildConfig: { + mode: 'ui', + steps: [ + {id: 'bundle-ui', displayName: 'Bundle UI Extension', type: 'bundle_ui', config: {}}, + {id: 'copy-static-assets', displayName: 'Copy Static Assets', type: 'copy_static_assets', config: {}}, + ], + stopOnError: true, + }, deployConfig: async (config, directory) => { const result = await getDependencyVersion(dependency, directory) if (result === 'not_found') throw new BugError(`Dependency ${dependency} not found`) diff --git a/packages/app/src/cli/models/extensions/specifications/product_subscription.ts b/packages/app/src/cli/models/extensions/specifications/product_subscription.ts index ba807e409f6..058fdfe517a 100644 --- a/packages/app/src/cli/models/extensions/specifications/product_subscription.ts +++ b/packages/app/src/cli/models/extensions/specifications/product_subscription.ts @@ -12,7 +12,14 @@ const productSubscriptionSpec = createExtensionSpecification({ graphQLType: 'subscription_management', schema: BaseSchema, appModuleFeatures: (_) => ['ui_preview', 'esbuild', 'single_js_entry_path'], - buildConfig: {mode: 'ui'}, + buildConfig: { + mode: 'ui', + steps: [ + {id: 'bundle-ui', displayName: 'Bundle UI Extension', type: 'bundle_ui', config: {}}, + {id: 'copy-static-assets', displayName: 'Copy Static Assets', type: 'copy_static_assets', config: {}}, + ], + stopOnError: true, + }, deployConfig: async (_, directory) => { const result = await getDependencyVersion(dependency, directory) if (result === 'not_found') throw new BugError(`Dependency ${dependency} not found`) diff --git a/packages/app/src/cli/models/extensions/specifications/tax_calculation.ts b/packages/app/src/cli/models/extensions/specifications/tax_calculation.ts index 1e97e577bb6..e3b970244d2 100644 --- a/packages/app/src/cli/models/extensions/specifications/tax_calculation.ts +++ b/packages/app/src/cli/models/extensions/specifications/tax_calculation.ts @@ -28,7 +28,11 @@ const spec = createExtensionSpecification({ identifier: 'tax_calculation', schema: TaxCalculationsSchema, appModuleFeatures: (_) => [], - buildConfig: {mode: 'tax_calculation'}, + buildConfig: { + mode: 'tax_calculation', + steps: [{id: 'create-tax-stub', displayName: 'Create Tax Stub', type: 'create_tax_stub', config: {}}], + stopOnError: true, + }, deployConfig: async (config, _) => { return { production_api_base_url: config.production_api_base_url, diff --git a/packages/app/src/cli/models/extensions/specifications/tax_calculation_build.test.ts b/packages/app/src/cli/models/extensions/specifications/tax_calculation_build.test.ts new file mode 100644 index 00000000000..83df3c7bbdc --- /dev/null +++ b/packages/app/src/cli/models/extensions/specifications/tax_calculation_build.test.ts @@ -0,0 +1,67 @@ +import taxCalculationSpec from './tax_calculation.js' +import {ExtensionInstance} from '../extension-instance.js' +import {ExtensionBuildOptions} from '../../../services/build/extension.js' +import {describe, expect, test} from 'vitest' +import {inTemporaryDirectory, readFile} from '@shopify/cli-kit/node/fs' +import {Writable} from 'stream' + +describe('tax_calculation buildConfig', () => { + test('uses build_steps mode', () => { + expect(taxCalculationSpec.buildConfig.mode).toBe('tax_calculation') + }) + + test('has a single create-tax-stub step', () => { + if (taxCalculationSpec.buildConfig.mode === 'none') throw new Error('Expected build_steps mode') + + const {steps} = taxCalculationSpec.buildConfig + + expect(steps).toHaveLength(1) + expect(steps[0]).toMatchObject({id: 'create-tax-stub', type: 'create_tax_stub'}) + }) + + test('config is serializable to JSON', () => { + if (taxCalculationSpec.buildConfig.mode === 'none') throw new Error('Expected build_steps mode') + + const serialized = JSON.stringify(taxCalculationSpec.buildConfig) + const deserialized = JSON.parse(serialized) + + expect(deserialized.steps).toHaveLength(1) + expect(deserialized.steps[0].type).toBe('create_tax_stub') + }) + + describe('build integration', () => { + test('creates the stub JS file at outputPath', async () => { + await inTemporaryDirectory(async (tmpDir) => { + // Given + const extension = new ExtensionInstance({ + configuration: {name: 'tax-calc', type: 'tax_calculation'}, + configurationPath: '', + directory: tmpDir, + specification: taxCalculationSpec, + }) + + const buildOptions: ExtensionBuildOptions = { + stdout: new Writable({ + write(chunk, enc, cb) { + cb() + }, + }), + stderr: new Writable({ + write(chunk, enc, cb) { + cb() + }, + }), + app: {} as any, + environment: 'production', + } + + // When + await extension.build(buildOptions) + + // Then + const content = await readFile(extension.outputPath) + expect(content).toBe('(()=>{})();') + }) + }) + }) +}) diff --git a/packages/app/src/cli/models/extensions/specifications/theme.test.ts b/packages/app/src/cli/models/extensions/specifications/theme.test.ts new file mode 100644 index 00000000000..3259ee5413d --- /dev/null +++ b/packages/app/src/cli/models/extensions/specifications/theme.test.ts @@ -0,0 +1,138 @@ +import spec from './theme.js' +import {ExtensionInstance} from '../extension-instance.js' +import {ExtensionBuildOptions} from '../../../services/build/extension.js' +import {describe, expect, test, vi} from 'vitest' +import {inTemporaryDirectory, writeFile, fileExists, mkdir} from '@shopify/cli-kit/node/fs' +import {joinPath} from '@shopify/cli-kit/node/path' +import {Writable} from 'stream' + +vi.mock('../../../services/build/theme-check.js', () => ({ + runThemeCheck: vi.fn().mockResolvedValue(''), +})) + +describe('theme', () => { + describe('buildConfig', () => { + test('uses build_steps mode', () => { + expect(spec.buildConfig.mode).toBe('theme') + }) + + test('has two steps: build-theme and bundle-theme', () => { + if (spec.buildConfig.mode === 'none') throw new Error('Expected build_steps mode') + + const {steps} = spec.buildConfig + + expect(steps).toHaveLength(2) + expect(steps[0]).toMatchObject({id: 'build-theme', type: 'build_theme'}) + expect(steps[1]).toMatchObject({id: 'bundle-theme', type: 'bundle_theme'}) + }) + + test('config is serializable to JSON', () => { + if (spec.buildConfig.mode === 'none') throw new Error('Expected build_steps mode') + + const serialized = JSON.stringify(spec.buildConfig) + const deserialized = JSON.parse(serialized) + + expect(deserialized.steps).toHaveLength(2) + expect(deserialized.steps[0].id).toBe('build-theme') + expect(deserialized.steps[1].id).toBe('bundle-theme') + }) + }) + + describe('build integration', () => { + test('bundles theme files to output directory preserving subdirectory structure', async () => { + await inTemporaryDirectory(async (tmpDir) => { + // Given + const extensionDir = joinPath(tmpDir, 'extension') + const outputDir = joinPath(tmpDir, 'output') + const blocksDir = joinPath(extensionDir, 'blocks') + const assetsDir = joinPath(extensionDir, 'assets') + + await mkdir(extensionDir) + await mkdir(outputDir) + await mkdir(blocksDir) + await mkdir(assetsDir) + + await writeFile(joinPath(blocksDir, 'main.liquid'), '{% block %}{% endblock %}') + await writeFile(joinPath(assetsDir, 'style.css'), 'body {}') + + const extension = new ExtensionInstance({ + configuration: {name: 'theme-extension', type: 'theme'}, + configurationPath: '', + directory: extensionDir, + specification: spec, + }) + extension.outputPath = outputDir + + const buildOptions: ExtensionBuildOptions = { + stdout: new Writable({ + write(chunk, enc, cb) { + cb() + }, + }), + stderr: new Writable({ + write(chunk, enc, cb) { + cb() + }, + }), + app: {} as any, + environment: 'production', + } + + // When + await extension.build(buildOptions) + + // Then — theme files are copied with directory structure preserved + await expect(fileExists(joinPath(outputDir, 'blocks', 'main.liquid'))).resolves.toBe(true) + await expect(fileExists(joinPath(outputDir, 'assets', 'style.css'))).resolves.toBe(true) + }) + }) + + test('does not copy ignored files (e.g. .DS_Store, .gitkeep)', async () => { + await inTemporaryDirectory(async (tmpDir) => { + // Given + const extensionDir = joinPath(tmpDir, 'extension') + const outputDir = joinPath(tmpDir, 'output') + const blocksDir = joinPath(extensionDir, 'blocks') + + await mkdir(extensionDir) + await mkdir(outputDir) + await mkdir(blocksDir) + + await writeFile(joinPath(blocksDir, 'main.liquid'), '{% block %}{% endblock %}') + await writeFile(joinPath(blocksDir, '.DS_Store'), 'ignored') + await writeFile(joinPath(blocksDir, '.gitkeep'), '') + + const extension = new ExtensionInstance({ + configuration: {name: 'theme-extension', type: 'theme'}, + configurationPath: '', + directory: extensionDir, + specification: spec, + }) + extension.outputPath = outputDir + + const buildOptions: ExtensionBuildOptions = { + stdout: new Writable({ + write(chunk, enc, cb) { + cb() + }, + }), + stderr: new Writable({ + write(chunk, enc, cb) { + cb() + }, + }), + app: {} as any, + environment: 'production', + } + + // When + await extension.build(buildOptions) + + // Then — liquid files are copied, ignored files are not + await expect(fileExists(joinPath(outputDir, 'blocks', 'main.liquid'))).resolves.toBe(true) + await expect(fileExists(joinPath(outputDir, 'blocks', '.DS_Store'))).resolves.toBe(false) + await expect(fileExists(joinPath(outputDir, 'blocks', '.gitkeep'))).resolves.toBe(false) + }) + }) + }) +}) diff --git a/packages/app/src/cli/models/extensions/specifications/theme.ts b/packages/app/src/cli/models/extensions/specifications/theme.ts index 6debcc8135e..1a6a2e8aefb 100644 --- a/packages/app/src/cli/models/extensions/specifications/theme.ts +++ b/packages/app/src/cli/models/extensions/specifications/theme.ts @@ -12,7 +12,24 @@ const themeSpec = createExtensionSpecification({ schema: BaseSchema, partnersWebIdentifier: 'theme_app_extension', graphQLType: 'theme_app_extension', - buildConfig: {mode: 'theme'}, + buildConfig: { + mode: 'theme', + steps: [ + { + id: 'build-theme', + displayName: 'Build Theme Extension', + type: 'build_theme', + config: {}, + }, + { + id: 'bundle-theme', + displayName: 'Bundle Theme Extension', + type: 'bundle_theme', + config: {}, + }, + ], + stopOnError: true, + }, appModuleFeatures: (_) => { return ['theme'] }, diff --git a/packages/app/src/cli/models/extensions/specifications/ui_extension.ts b/packages/app/src/cli/models/extensions/specifications/ui_extension.ts index f3e04cbeab9..38451da2348 100644 --- a/packages/app/src/cli/models/extensions/specifications/ui_extension.ts +++ b/packages/app/src/cli/models/extensions/specifications/ui_extension.ts @@ -101,7 +101,14 @@ const uiExtensionSpec = createExtensionSpecification({ identifier: 'ui_extension', dependency, schema: UIExtensionSchema, - buildConfig: {mode: 'ui'}, + buildConfig: { + mode: 'ui', + steps: [ + {id: 'bundle-ui', displayName: 'Bundle UI Extension', type: 'bundle_ui', config: {}}, + {id: 'copy-static-assets', displayName: 'Copy Static Assets', type: 'copy_static_assets', config: {}}, + ], + stopOnError: true, + }, appModuleFeatures: (config) => { const basic: ExtensionFeature[] = ['ui_preview', 'esbuild', 'generates_source_maps'] const needsCart = diff --git a/packages/app/src/cli/models/extensions/specifications/ui_extension_build.test.ts b/packages/app/src/cli/models/extensions/specifications/ui_extension_build.test.ts new file mode 100644 index 00000000000..928f033a5bb --- /dev/null +++ b/packages/app/src/cli/models/extensions/specifications/ui_extension_build.test.ts @@ -0,0 +1,90 @@ +import uiExtensionSpec from './ui_extension.js' +import checkoutPostPurchaseSpec from './checkout_post_purchase.js' +import checkoutUiExtensionSpec from './checkout_ui_extension.js' +import posUiExtensionSpec from './pos_ui_extension.js' +import productSubscriptionSpec from './product_subscription.js' +import webPixelExtensionSpec from './web_pixel_extension.js' +import {ExtensionInstance} from '../extension-instance.js' +import {ExtensionBuildOptions} from '../../../services/build/extension.js' +import {describe, expect, test, vi} from 'vitest' +import {Writable} from 'stream' + +vi.mock('../../../services/build/extension.js', async (importOriginal) => { + const original = await importOriginal() + return {...original, buildUIExtension: vi.fn().mockResolvedValue(undefined)} +}) + +const UI_SPECS = [ + {name: 'ui_extension', spec: uiExtensionSpec}, + {name: 'checkout_post_purchase', spec: checkoutPostPurchaseSpec}, + {name: 'checkout_ui_extension', spec: checkoutUiExtensionSpec}, + {name: 'pos_ui_extension', spec: posUiExtensionSpec}, + {name: 'product_subscription', spec: productSubscriptionSpec}, + {name: 'web_pixel_extension', spec: webPixelExtensionSpec}, +] + +describe('UI extension build configs', () => { + for (const {name, spec} of UI_SPECS) { + describe(name, () => { + test('uses build_steps mode', () => { + expect(spec.buildConfig.mode).toBe('ui') + }) + + test('has bundle-ui and copy-static-assets steps', () => { + if (spec.buildConfig.mode === 'none') throw new Error('Expected build_steps mode') + + const {steps} = spec.buildConfig + + expect(steps).toHaveLength(2) + expect(steps[0]).toMatchObject({id: 'bundle-ui', type: 'bundle_ui'}) + expect(steps[1]).toMatchObject({id: 'copy-static-assets', type: 'copy_static_assets'}) + }) + + test('config is serializable to JSON', () => { + if (spec.buildConfig.mode === 'none') throw new Error('Expected build_steps mode') + + const serialized = JSON.stringify(spec.buildConfig) + const deserialized = JSON.parse(serialized) + + expect(deserialized.steps).toHaveLength(2) + expect(deserialized.steps[0].type).toBe('bundle_ui') + expect(deserialized.steps[1].type).toBe('copy_static_assets') + }) + }) + } + + describe('bundle-ui step invokes buildUIExtension', () => { + test('calls buildUIExtension with extension and options', async () => { + const {buildUIExtension} = await import('../../../services/build/extension.js') + + const extension = new ExtensionInstance({ + configuration: {name: 'ui-ext', type: 'product_subscription', metafields: []}, + configurationPath: '', + directory: '/tmp/ext', + specification: uiExtensionSpec, + }) + + const copyStaticAssetsSpy = vi.spyOn(extension, 'copyStaticAssets').mockResolvedValue(undefined) + + const buildOptions: ExtensionBuildOptions = { + stdout: new Writable({ + write(chunk, enc, cb) { + cb() + }, + }), + stderr: new Writable({ + write(chunk, enc, cb) { + cb() + }, + }), + app: {} as any, + environment: 'production', + } + + await extension.build(buildOptions) + + expect(buildUIExtension).toHaveBeenCalledWith(extension, buildOptions) + expect(copyStaticAssetsSpy).toHaveBeenCalled() + }) + }) +}) diff --git a/packages/app/src/cli/models/extensions/specifications/web_pixel_extension.ts b/packages/app/src/cli/models/extensions/specifications/web_pixel_extension.ts index 298a18d876b..c9622ae5fff 100644 --- a/packages/app/src/cli/models/extensions/specifications/web_pixel_extension.ts +++ b/packages/app/src/cli/models/extensions/specifications/web_pixel_extension.ts @@ -31,7 +31,14 @@ const webPixelSpec = createExtensionSpecification({ partnersWebIdentifier: 'web_pixel', schema: WebPixelSchema, appModuleFeatures: (_) => ['esbuild', 'single_js_entry_path'], - buildConfig: {mode: 'ui'}, + buildConfig: { + mode: 'ui', + steps: [ + {id: 'bundle-ui', displayName: 'Bundle UI Extension', type: 'bundle_ui', config: {}}, + {id: 'copy-static-assets', displayName: 'Copy Static Assets', type: 'copy_static_assets', config: {}}, + ], + stopOnError: true, + }, deployConfig: async (config, _) => { return { runtime_context: config.runtime_context, diff --git a/packages/app/src/cli/services/build/build-steps.integration.test.ts b/packages/app/src/cli/services/build/build-steps.integration.test.ts new file mode 100644 index 00000000000..d1b752ec78c --- /dev/null +++ b/packages/app/src/cli/services/build/build-steps.integration.test.ts @@ -0,0 +1,209 @@ +import {ExtensionBuildOptions} from './extension.js' +import {executeBuildSteps, BuildStepsConfig} from './build-steps.js' +import {ExtensionInstance} from '../../models/extensions/extension-instance.js' +import {describe, expect, test} from 'vitest' +import {inTemporaryDirectory, writeFile, readFile, mkdir, fileExists} from '@shopify/cli-kit/node/fs' +import {joinPath} from '@shopify/cli-kit/node/path' +import {Writable} from 'stream' + +describe('build_steps integration', () => { + test('executes copy_files step and copies files to output', async () => { + await inTemporaryDirectory(async (tmpDir) => { + // Setup: Create extension directory with assets + const extensionDir = joinPath(tmpDir, 'extension') + const assetsDir = joinPath(extensionDir, 'assets') + const outputDir = joinPath(tmpDir, 'output') + + await mkdir(extensionDir) + await mkdir(assetsDir) + await mkdir(outputDir) + + // Create test files + await writeFile(joinPath(assetsDir, 'logo.png'), 'fake-png-data') + await writeFile(joinPath(assetsDir, 'style.css'), 'body { color: red; }') + + // Create mock extension + const mockExtension = { + directory: extensionDir, + outputPath: joinPath(outputDir, 'extension.js'), + } as ExtensionInstance + + // Create build steps config + const stepsConfig: BuildStepsConfig = { + steps: [ + { + id: 'copy-assets', + displayName: 'Copy Assets', + type: 'copy_files', + config: { + strategy: 'pattern', + definition: { + source: 'assets', + patterns: ['**/*'], + }, + }, + }, + ], + } + + const buildOptions: ExtensionBuildOptions = { + stdout: new Writable({ + write(chunk, encoding, callback) { + callback() + }, + }), + stderr: new Writable({ + write(chunk, encoding, callback) { + callback() + }, + }), + app: {} as any, + environment: 'production', + } + + // Execute: Call executeBuildSteps directly + await executeBuildSteps(mockExtension, stepsConfig, buildOptions) + + // Verify: Files were copied to output directory + const logoExists = await fileExists(joinPath(outputDir, 'logo.png')) + const styleExists = await fileExists(joinPath(outputDir, 'style.css')) + + expect(logoExists).toBe(true) + expect(styleExists).toBe(true) + + const logoContent = await readFile(joinPath(outputDir, 'logo.png')) + const styleContent = await readFile(joinPath(outputDir, 'style.css')) + + expect(logoContent).toBe('fake-png-data') + expect(styleContent).toBe('body { color: red; }') + }) + }) + + test('executes multiple steps in sequence', async () => { + await inTemporaryDirectory(async (tmpDir) => { + // Setup: Create extension with two asset directories + const extensionDir = joinPath(tmpDir, 'extension') + const imagesDir = joinPath(extensionDir, 'images') + const stylesDir = joinPath(extensionDir, 'styles') + const outputDir = joinPath(tmpDir, 'output') + + await mkdir(extensionDir) + await mkdir(imagesDir) + await mkdir(stylesDir) + await mkdir(outputDir) + + await writeFile(joinPath(imagesDir, 'logo.png'), 'logo-data') + await writeFile(joinPath(stylesDir, 'main.css'), 'css-data') + + const mockExtension = { + directory: extensionDir, + outputPath: joinPath(outputDir, 'extension.js'), + } as ExtensionInstance + + const stepsConfig: BuildStepsConfig = { + steps: [ + { + id: 'copy-images', + displayName: 'Copy Images', + type: 'copy_files', + config: { + strategy: 'pattern', + definition: { + source: 'images', + patterns: ['**/*'], + destination: 'assets/images', + }, + }, + }, + { + id: 'copy-styles', + displayName: 'Copy Styles', + type: 'copy_files', + config: { + strategy: 'pattern', + definition: { + source: 'styles', + patterns: ['**/*'], + destination: 'assets/styles', + }, + }, + }, + ], + } + + const buildOptions: ExtensionBuildOptions = { + stdout: new Writable({ + write(chunk, encoding, callback) { + callback() + }, + }), + stderr: new Writable({ + write(chunk, encoding, callback) { + callback() + }, + }), + app: {} as any, + environment: 'production', + } + + // Execute + await executeBuildSteps(mockExtension, stepsConfig, buildOptions) + + // Verify: Files from both steps were copied to correct destinations + const logoExists = await fileExists(joinPath(outputDir, 'assets/images/logo.png')) + const styleExists = await fileExists(joinPath(outputDir, 'assets/styles/main.css')) + + expect(logoExists).toBe(true) + expect(styleExists).toBe(true) + }) + }) + + test('silently skips tomlKeys step when TOML key is absent from extension config', async () => { + await inTemporaryDirectory(async (tmpDir) => { + const extensionDir = joinPath(tmpDir, 'extension') + const outputDir = joinPath(tmpDir, 'output') + + await mkdir(extensionDir) + await mkdir(outputDir) + + // Extension has no configuration — static_root key is absent + const mockExtension = { + directory: extensionDir, + outputPath: joinPath(outputDir, 'extension.js'), + configuration: {}, + } as unknown as ExtensionInstance + + const stepsConfig: BuildStepsConfig = { + steps: [ + { + id: 'copy-static', + displayName: 'Copy Static Assets', + type: 'copy_files', + config: { + strategy: 'files', + definition: {files: [{tomlKey: 'static_root'}]}, + }, + }, + ], + } + + const buildOptions: ExtensionBuildOptions = { + stdout: new Writable({ + write(chunk, encoding, callback) { + callback() + }, + }), + stderr: new Writable({ + write(chunk, encoding, callback) { + callback() + }, + }), + app: {} as any, + environment: 'production', + } + + // Should not throw — absent tomlKeys are silently skipped + await expect(executeBuildSteps(mockExtension, stepsConfig, buildOptions)).resolves.not.toThrow() + }) + }) +}) diff --git a/packages/app/src/cli/services/build/build-steps.test.ts b/packages/app/src/cli/services/build/build-steps.test.ts new file mode 100644 index 00000000000..84a83227e4a --- /dev/null +++ b/packages/app/src/cli/services/build/build-steps.test.ts @@ -0,0 +1,414 @@ +import {executeBuildSteps, BuildStep, BuildContext, BuildStepsConfig, resolveConfigurableValue} from './build-steps.js' +import * as stepsIndex from './steps/index.js' +import {ExtensionInstance} from '../../models/extensions/extension-instance.js' +import {beforeEach, describe, expect, test, vi} from 'vitest' + +vi.mock('./steps/index.js') + +describe('executeBuildSteps', () => { + let mockExtension: ExtensionInstance + let mockStdout: {write: ReturnType} + let mockStderr: {write: ReturnType} + let mockOptions: any + + beforeEach(() => { + mockStdout = {write: vi.fn()} + mockStderr = {write: vi.fn()} + mockOptions = { + stdout: mockStdout, + stderr: mockStderr, + app: {} as any, + environment: 'production' as const, + } + mockExtension = { + directory: '/test/dir', + outputPath: '/test/output/index.js', + } as ExtensionInstance + }) + + describe('execution', () => { + test('executes steps in order and passes context', async () => { + // Given + const executionOrder: string[] = [] + + vi.mocked(stepsIndex.executeStepByType).mockImplementation(async (step: BuildStep) => { + executionOrder.push(step.id) + return {success: true} + }) + + const stepsConfig: BuildStepsConfig = { + steps: [ + { + id: 'step1', + displayName: 'Step 1', + type: 'copy_files', + config: {}, + }, + { + id: 'step2', + displayName: 'Step 2', + type: 'copy_files', + config: {}, + }, + { + id: 'step3', + displayName: 'Step 3', + type: 'copy_files', + config: {}, + }, + ], + } + + // When + await executeBuildSteps(mockExtension, stepsConfig, mockOptions) + + // Then + expect(executionOrder).toEqual(['step1', 'step2', 'step3']) + expect(stepsIndex.executeStepByType).toHaveBeenCalledTimes(3) + }) + + test('stops on first error when stopOnError is true', async () => { + // Given + vi.mocked(stepsIndex.executeStepByType) + .mockResolvedValueOnce({success: true}) + .mockRejectedValueOnce(new Error('Step 2 failed')) + + const stepsConfig: BuildStepsConfig = { + steps: [ + { + id: 'step1', + displayName: 'Step 1', + type: 'copy_files', + config: {}, + }, + { + id: 'step2', + displayName: 'Step 2', + type: 'copy_files', + config: {}, + }, + { + id: 'step3', + displayName: 'Step 3', + type: 'copy_files', + config: {}, + }, + ], + stopOnError: true, + } + + // When/Then + await expect(executeBuildSteps(mockExtension, stepsConfig, mockOptions)).rejects.toThrow('Step 2 failed') + + // Only first two steps should be called + expect(stepsIndex.executeStepByType).toHaveBeenCalledTimes(2) + }) + + test('continues on error when stopOnError is false', async () => { + // Given + vi.mocked(stepsIndex.executeStepByType) + .mockResolvedValueOnce({success: true}) + .mockRejectedValueOnce(new Error('Step 2 failed')) + .mockResolvedValueOnce({success: true}) + + const stepsConfig: BuildStepsConfig = { + steps: [ + { + id: 'step1', + displayName: 'Step 1', + type: 'copy_files', + config: {}, + continueOnError: false, + }, + { + id: 'step2', + displayName: 'Step 2', + type: 'copy_files', + config: {}, + continueOnError: true, + }, + { + id: 'step3', + displayName: 'Step 3', + type: 'copy_files', + config: {}, + }, + ], + stopOnError: true, + } + + // When + await executeBuildSteps(mockExtension, stepsConfig, mockOptions) + + // Then + expect(stepsIndex.executeStepByType).toHaveBeenCalledTimes(3) + expect(mockStderr.write).toHaveBeenCalledWith( + expect.stringContaining('Warning: Step "Step 2" failed but continuing'), + ) + }) + }) + + describe('step results tracking', () => { + test('tracks results from completed steps', async () => { + // Given + vi.mocked(stepsIndex.executeStepByType) + .mockResolvedValueOnce({filesCopied: 5}) + .mockResolvedValueOnce({filesCopied: 10}) + + const stepsConfig: BuildStepsConfig = { + steps: [ + { + id: 'step1', + displayName: 'Step 1', + type: 'copy_files', + config: {}, + }, + { + id: 'step2', + displayName: 'Step 2', + type: 'copy_files', + config: {}, + }, + ], + } + + // When + await executeBuildSteps(mockExtension, stepsConfig, mockOptions) + + // Then + // We can't directly inspect the context, but we can verify the steps were executed + expect(stepsIndex.executeStepByType).toHaveBeenCalledTimes(2) + expect(stepsIndex.executeStepByType).toHaveBeenNthCalledWith( + 1, + stepsConfig.steps[0], + expect.objectContaining({ + extension: mockExtension, + options: mockOptions, + }), + ) + }) + + test('tracks error results when continueOnError is true', async () => { + // Given + vi.mocked(stepsIndex.executeStepByType) + .mockResolvedValueOnce({success: true}) + .mockRejectedValueOnce(new Error('Step 2 error')) + .mockResolvedValueOnce({success: true}) + + const stepsConfig: BuildStepsConfig = { + steps: [ + { + id: 'step1', + displayName: 'Step 1', + type: 'copy_files', + config: {}, + }, + { + id: 'step2', + displayName: 'Step 2', + type: 'copy_files', + config: {}, + continueOnError: true, + }, + { + id: 'step3', + displayName: 'Step 3', + type: 'copy_files', + config: {}, + }, + ], + } + + // When + await executeBuildSteps(mockExtension, stepsConfig, mockOptions) + + // Then + expect(stepsIndex.executeStepByType).toHaveBeenCalledTimes(3) + expect(mockStderr.write).toHaveBeenCalledWith(expect.stringContaining('Step 2 error')) + }) + }) + + describe('logging', () => { + test('logs step execution', async () => { + // Given + vi.mocked(stepsIndex.executeStepByType).mockResolvedValue({success: true}) + + const stepsConfig: BuildStepsConfig = { + steps: [ + { + id: 'test-step', + displayName: 'Test Step', + type: 'copy_files', + config: {}, + }, + ], + } + + // When + await executeBuildSteps(mockExtension, stepsConfig, mockOptions) + + // Then + expect(mockStdout.write).toHaveBeenCalledWith('Executing step: Test Step\n') + }) + + test('logs warnings for failed steps with continueOnError', async () => { + // Given + vi.mocked(stepsIndex.executeStepByType).mockRejectedValue(new Error('Test error')) + + const stepsConfig: BuildStepsConfig = { + steps: [ + { + id: 'test-step', + displayName: 'Test Step', + type: 'copy_files', + config: {}, + continueOnError: true, + }, + ], + } + + // When + await executeBuildSteps(mockExtension, stepsConfig, mockOptions) + + // Then + expect(mockStderr.write).toHaveBeenCalledWith('Warning: Step "Test Step" failed but continuing: Test error\n') + }) + }) +}) + +describe('resolveConfigurableValue', () => { + let mockContext: BuildContext + + beforeEach(() => { + mockContext = { + extension: { + configuration: { + static_root: 'public', + nested: { + field: 'nested-value', + }, + }, + directory: '/test/dir', + }, + options: { + stdout: {write: vi.fn()}, + stderr: {write: vi.fn()}, + app: {} as any, + environment: 'production', + }, + stepResults: new Map(), + } as unknown as BuildContext + }) + + test('returns literal value as-is', () => { + const result = resolveConfigurableValue('literal-value', mockContext) + expect(result).toBe('literal-value') + }) + + test('returns literal number as-is', () => { + const result = resolveConfigurableValue(42, mockContext) + expect(result).toBe(42) + }) + + test('returns literal boolean as-is', () => { + const result = resolveConfigurableValue(true, mockContext) + expect(result).toBe(true) + }) + + test('returns literal array as-is', () => { + const arr = ['a', 'b', 'c'] + const result = resolveConfigurableValue(arr, mockContext) + expect(result).toBe(arr) + }) + + test('resolves configPath reference', () => { + const result = resolveConfigurableValue({configPath: 'static_root'}, mockContext) + expect(result).toBe('public') + }) + + test('resolves nested configPath reference', () => { + const result = resolveConfigurableValue({configPath: 'nested.field'}, mockContext) + expect(result).toBe('nested-value') + }) + + test('returns undefined for missing configPath', () => { + const result = resolveConfigurableValue({configPath: 'nonexistent'}, mockContext) + expect(result).toBeUndefined() + }) + + test('returns undefined for deeply nested missing configPath', () => { + const result = resolveConfigurableValue({configPath: 'nested.missing.field'}, mockContext) + expect(result).toBeUndefined() + }) + + test('resolves envVar reference', () => { + process.env.TEST_VAR = 'test-value' + const result = resolveConfigurableValue({envVar: 'TEST_VAR'}, mockContext) + expect(result).toBe('test-value') + delete process.env.TEST_VAR + }) + + test('returns undefined for missing envVar', () => { + const result = resolveConfigurableValue({envVar: 'NONEXISTENT_VAR'}, mockContext) + expect(result).toBeUndefined() + }) + + test('returns undefined when value is undefined', () => { + const result = resolveConfigurableValue(undefined, mockContext) + expect(result).toBeUndefined() + }) + + test('returns undefined when value is null', () => { + const result = resolveConfigurableValue(null as any, mockContext) + expect(result).toBeUndefined() + }) + + test('plucks a field from a TOML array of tables', () => { + const context = { + ...mockContext, + extension: { + ...mockContext.extension, + configuration: { + targeting: [{tools: 'my-tools1.js'}, {tools: 'my-tools2.js'}], + }, + }, + } as unknown as BuildContext + + const result = resolveConfigurableValue({configPath: 'targeting.tools'}, context) + expect(result).toEqual(['my-tools1.js', 'my-tools2.js']) + }) + + test('plucks a field from a deeply nested TOML array of tables', () => { + const context = { + ...mockContext, + extension: { + ...mockContext.extension, + configuration: { + extensions: { + targeting: { + intents: [{schema: './email-schema.json'}, {schema: './sms-schema.json'}], + }, + }, + }, + }, + } as unknown as BuildContext + + const result = resolveConfigurableValue({configPath: 'extensions.targeting.intents.schema'}, context) + expect(result).toEqual(['./email-schema.json', './sms-schema.json']) + }) + + test('returns undefined when array of tables has no matching field', () => { + const context = { + ...mockContext, + extension: { + ...mockContext.extension, + configuration: { + targeting: [{tools: 'my-tools1.js'}, {tools: 'my-tools2.js'}], + }, + }, + } as unknown as BuildContext + + const result = resolveConfigurableValue({configPath: 'targeting.nonexistent'}, context) + expect(result).toBeUndefined() + }) +}) diff --git a/packages/app/src/cli/services/build/build-steps.ts b/packages/app/src/cli/services/build/build-steps.ts new file mode 100644 index 00000000000..d356194bd10 --- /dev/null +++ b/packages/app/src/cli/services/build/build-steps.ts @@ -0,0 +1,288 @@ +import {executeStepByType} from './steps/index.js' +import {AbortSignal} from '@shopify/cli-kit/node/abort' +import type {ExtensionInstance} from '../../models/extensions/extension-instance.js' +import type {ExtensionBuildOptions} from './extension.js' + +/** + * BuildStep represents a single build command configuration. + * Inspired by the existing Task pattern in: + * /packages/cli-kit/src/private/node/ui/components/Tasks.tsx + * + * Key differences from Task: + * - Not coupled to UI rendering + * - Pure configuration object (execution logic is separate) + * - Router pattern dispatches to type-specific executors + */ +export interface BuildStep { + /** Unique identifier for this step (e.g., 'copy_files', 'build') */ + readonly id: string + + /** Display name for logging */ + readonly displayName: string + + /** Optional description */ + readonly description?: string + + /** Step type (determines which executor handles it) */ + readonly type: + | 'copy_files' + | 'build_theme' + | 'bundle_theme' + | 'bundle_ui' + | 'copy_static_assets' + | 'build_function' + | 'create_tax_stub' + | 'esbuild' + | 'validate' + | 'transform' + | 'custom' + + /** Step-specific configuration */ + readonly config: {[key: string]: unknown} + + /** + * Whether to continue on error (default: false) + */ + readonly continueOnError?: boolean +} + +/** + * BuildContext is passed through the pipeline (similar to Task). + * Each step can read from and write to the context. + * + * Key design: Immutable configuration, mutable context + */ +export interface BuildContext { + /** The extension being built */ + readonly extension: ExtensionInstance + + /** Build options (stdout, stderr, etc.) */ + readonly options: ExtensionBuildOptions + + /** Results from previous steps (for step dependencies) */ + readonly stepResults: Map + + /** Abort signal for cancellation */ + readonly signal?: AbortSignal + + /** Custom data that steps can write to (extensible) */ + [key: string]: unknown +} + +/** + * Result of a step execution + */ +export interface StepResult { + readonly stepId: string + readonly displayName: string + readonly success: boolean + readonly duration: number + readonly output?: unknown + readonly error?: Error +} + +/** + * Reference to a configuration value. + * Used to dynamically resolve values from the extension's configuration at build time. + */ +export interface ConfigReference { + /** Path to the config value (e.g., 'static_root' or 'nested.field') */ + configPath: string +} + +/** + * Reference to an environment variable. + * Used to dynamically resolve values from environment variables at build time. + */ +export interface EnvReference { + /** Name of the environment variable */ + envVar: string +} + +/** + * A value that can be either: + * - A literal value (T) + * - A reference to a config field -configPath: string + * - A reference to an environment variable - envVar: string + * + * This allows build step configurations to be static (serializable to JSON) + * while still supporting dynamic values resolved at build time. + * + * Example: + * ```typescript + * // Literal value + * source: 'public' + * + * // Reference to config + * source: {configPath: 'static_root'} + * + * // Reference to env var + * source: {envVar: 'BUILD_DIR'} + * ``` + */ +export type ConfigurableValue = T | ConfigReference | EnvReference + +/** + * Checks if a ConfigurableValue is a reference (ConfigReference or EnvReference). + * + * @param value - The value to check + * @returns true if the value is a reference object + */ +export function isReference(value: unknown): value is ConfigReference | EnvReference { + return typeof value === 'object' && value !== null && ('configPath' in value || 'envVar' in value) +} + +/** + * Resolves a ConfigurableValue to its actual value. + * If the value is a reference (configPath or envVar), it will be resolved from the context. + * Otherwise, the literal value is returned as-is. + * + * @param value - The configurable value to resolve + * @param context - The build context containing extension config and options + * @returns The resolved value, or undefined if the reference cannot be resolved + */ +export function resolveConfigurableValue( + value: ConfigurableValue | undefined, + context: BuildContext, +): T | undefined { + if (!value) { + return undefined + } + + // Check if it's a config reference + if (typeof value === 'object' && value !== null && 'configPath' in value) { + const configRef = value + return getNestedValue(context.extension.configuration, configRef.configPath) as T | undefined + } + + // Check if it's an env var reference + if (typeof value === 'object' && value !== null && 'envVar' in value) { + const envRef = value + return process.env[envRef.envVar] as T | undefined + } + + // It's a literal value + return value as T +} + +/** + * Helper function to get a nested value from an object using a dot-separated path. + * @param obj - The object to get the nested value from + * @param path - The dot-separated path to the nested value + * @returns The nested value, or undefined if the path is not found + */ +function getNestedValue(obj: {[key: string]: unknown}, path: string): unknown { + const parts = path.split('.') + let current: unknown = obj + + for (const part of parts) { + if (current === null || current === undefined) { + return undefined + } + + // Array pluck: when current is an array, map the next key across all elements. + // Handles TOML array-of-tables at any depth, e.g.: + // [[extensions.targeting.intents]] + // schema = "./email-schema.json" + // → configPath 'extensions.targeting.intents.schema' → ["./email-schema.json"] + if (Array.isArray(current)) { + const plucked = current + .map((item) => { + if (typeof item === 'object' && item !== null && part in (item as object)) { + return (item as {[key: string]: unknown})[part] + } + return undefined + }) + .filter((item): item is NonNullable => item !== undefined) + current = plucked.length > 0 ? plucked : undefined + continue + } + + if (typeof current === 'object' && part in current) { + current = (current as {[key: string]: unknown})[part] + } else { + return undefined + } + } + + return current +} + +/** + * BuildStepsConfig defines the pipeline configuration. + */ +export interface BuildStepsConfig { + /** Array of steps to execute in order */ + readonly steps: ReadonlyArray + + /** Whether to stop on first error (default: true) */ + readonly stopOnError?: boolean +} + +/** + * Executes a build steps pipeline for an extension. + * + * @param extension - The extension instance to build + * @param stepsConfig - Configuration defining the build steps + * @param options - Build options (stdout, stderr, etc.) + */ +export async function executeBuildSteps( + extension: ExtensionInstance, + stepsConfig: BuildStepsConfig, + options: ExtensionBuildOptions, +): Promise { + const context: BuildContext = { + extension, + options, + stepResults: new Map(), + signal: options.signal, + } + + const {steps, stopOnError = true} = stepsConfig + + 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 && stopOnError && !step.continueOnError) { + throw new Error(`Build step "${step.displayName}" failed: ${result.error?.message}`) + } + } +} + +/** + * Executes a single build step with error handling and skip logic. + */ +export async function executeStep(step: BuildStep, context: BuildContext): Promise { + const startTime = Date.now() + + try { + // Execute the step using type-specific executor + context.options.stdout.write(`Executing step: ${step.displayName}\n`) + const output = await executeStepByType(step, context) + + return { + stepId: step.id, + displayName: step.displayName, + success: true, + duration: Date.now() - startTime, + output, + } + } catch (error) { + const stepError = error as Error + + if (step.continueOnError) { + context.options.stderr.write(`Warning: Step "${step.displayName}" failed but continuing: ${stepError.message}\n`) + return { + stepId: step.id, + displayName: step.displayName, + success: false, + duration: Date.now() - startTime, + error: stepError, + } + } + + throw new Error(`Build step "${step.displayName}" failed: ${stepError.message}`) + } +} diff --git a/packages/app/src/cli/services/build/steps/build-function-step.ts b/packages/app/src/cli/services/build/steps/build-function-step.ts new file mode 100644 index 00000000000..d76af40da9f --- /dev/null +++ b/packages/app/src/cli/services/build/steps/build-function-step.ts @@ -0,0 +1,12 @@ +import {buildFunctionExtension} from '../extension.js' +import type {BuildStep, BuildContext} from '../build-steps.js' + +/** + * Executes a build_function build step. + * + * Compiles the function extension (JavaScript or other language) to WASM, + * applying wasm-opt and trampoline as configured. + */ +export async function executeBuildFunctionStep(_step: BuildStep, context: BuildContext): Promise { + return buildFunctionExtension(context.extension, context.options) +} diff --git a/packages/app/src/cli/services/build/steps/build-theme-step.ts b/packages/app/src/cli/services/build/steps/build-theme-step.ts new file mode 100644 index 00000000000..cd16d229376 --- /dev/null +++ b/packages/app/src/cli/services/build/steps/build-theme-step.ts @@ -0,0 +1,14 @@ +import {runThemeCheck} from '../theme-check.js' +import type {BuildStep, BuildContext} from '../build-steps.js' + +/** + * Executes a build_theme build step. + * + * Runs theme check on the extension directory and writes any offenses to stdout. + */ +export async function executeBuildThemeStep(_step: BuildStep, context: BuildContext): Promise { + const {extension, options} = context + options.stdout.write(`Running theme check on your Theme app extension...`) + const offenses = await runThemeCheck(extension.directory) + if (offenses) options.stdout.write(offenses) +} diff --git a/packages/app/src/cli/services/build/steps/bundle-theme-step.ts b/packages/app/src/cli/services/build/steps/bundle-theme-step.ts new file mode 100644 index 00000000000..fa6e1b32db5 --- /dev/null +++ b/packages/app/src/cli/services/build/steps/bundle-theme-step.ts @@ -0,0 +1,27 @@ +import {themeExtensionFiles} from '../../../utilities/extensions/theme.js' +import {copyFile} from '@shopify/cli-kit/node/fs' +import {relativePath, joinPath} from '@shopify/cli-kit/node/path' +import type {BuildStep, BuildContext} from '../build-steps.js' + +/** + * Executes a bundle_theme build step. + * + * Copies theme extension files to the output directory, preserving relative paths. + * Respects the extension's .shopifyignore file and the standard ignore patterns. + */ +export async function executeBundleThemeStep(_step: BuildStep, context: BuildContext): Promise<{filesCopied: number}> { + const {extension, options} = context + options.stdout.write(`Bundling theme extension ${extension.localIdentifier}...`) + const files = await themeExtensionFiles(extension) + + await Promise.all( + files.map(async (filepath) => { + const relativePathName = relativePath(extension.directory, filepath) + const outputFile = joinPath(extension.outputPath, relativePathName) + if (filepath === outputFile) return + await copyFile(filepath, outputFile) + }), + ) + + return {filesCopied: files.length} +} diff --git a/packages/app/src/cli/services/build/steps/bundle-ui-step.ts b/packages/app/src/cli/services/build/steps/bundle-ui-step.ts new file mode 100644 index 00000000000..9b157722219 --- /dev/null +++ b/packages/app/src/cli/services/build/steps/bundle-ui-step.ts @@ -0,0 +1,11 @@ +import {buildUIExtension} from '../extension.js' +import type {BuildStep, BuildContext} from '../build-steps.js' + +/** + * Executes a bundle_ui build step. + * + * Bundles the UI extension using esbuild, writing output to extension.outputPath. + */ +export async function executeBundleUIStep(_step: BuildStep, context: BuildContext): Promise { + return buildUIExtension(context.extension, context.options) +} diff --git a/packages/app/src/cli/services/build/steps/copy-files-step.test.ts b/packages/app/src/cli/services/build/steps/copy-files-step.test.ts new file mode 100644 index 00000000000..97f42f3a6c8 --- /dev/null +++ b/packages/app/src/cli/services/build/steps/copy-files-step.test.ts @@ -0,0 +1,468 @@ +import {executeCopyFilesStep} from './copy-files-step.js' +import {BuildStep, BuildContext} from '../build-steps.js' +import {ExtensionInstance} from '../../../models/extensions/extension-instance.js' +import {describe, expect, test, vi, beforeEach} from 'vitest' +import * as fs from '@shopify/cli-kit/node/fs' + +vi.mock('@shopify/cli-kit/node/fs') + +describe('executeCopyFilesStep', () => { + let mockExtension: ExtensionInstance + let mockContext: BuildContext + let mockStdout: any + + beforeEach(() => { + mockStdout = {write: vi.fn()} + mockExtension = { + directory: '/test/extension', + outputPath: '/test/output/extension.js', + } as ExtensionInstance + + mockContext = { + extension: mockExtension, + options: { + stdout: mockStdout, + stderr: {write: vi.fn()}, + app: {} as any, + environment: 'production', + }, + stepResults: new Map(), + } + }) + + describe('files strategy — explicit file list', () => { + test('copies directory contents to output root when no destination', async () => { + // Given + vi.mocked(fs.fileExists).mockResolvedValue(true) + vi.mocked(fs.copyDirectoryContents).mockResolvedValue() + vi.mocked(fs.glob).mockResolvedValue(['index.html', 'assets/logo.png']) + + const step: BuildStep = { + id: 'copy-dist', + displayName: 'Copy Dist', + type: 'copy_files', + config: { + strategy: 'files', + definition: { + files: [{source: 'dist'}], + }, + }, + } + + // When + const result = await executeCopyFilesStep(step, mockContext) + + // Then + expect(fs.copyDirectoryContents).toHaveBeenCalledWith('/test/extension/dist', '/test/output') + expect(result.filesCopied).toBe(2) + expect(mockStdout.write).toHaveBeenCalledWith(expect.stringContaining('Copied contents of dist to output root')) + }) + + test('throws when source directory does not exist', async () => { + // Given + vi.mocked(fs.fileExists).mockResolvedValue(false) + + const step: BuildStep = { + id: 'copy-dist', + displayName: 'Copy Dist', + type: 'copy_files', + config: { + strategy: 'files', + definition: { + files: [{source: 'dist'}], + }, + }, + } + + // When/Then + await expect(executeCopyFilesStep(step, mockContext)).rejects.toThrow('Source does not exist') + }) + + test('copies file to explicit destination path', async () => { + // Given + vi.mocked(fs.fileExists).mockResolvedValue(true) + vi.mocked(fs.copyFile).mockResolvedValue() + vi.mocked(fs.mkdir).mockResolvedValue() + + const step: BuildStep = { + id: 'copy-icon', + displayName: 'Copy Icon', + type: 'copy_files', + config: { + strategy: 'files', + definition: { + files: [{source: 'src/icon.png', destination: 'assets/icon.png'}], + }, + }, + } + + // When + const result = await executeCopyFilesStep(step, mockContext) + + // Then + expect(fs.copyFile).toHaveBeenCalledWith('/test/extension/src/icon.png', '/test/output/assets/icon.png') + expect(result.filesCopied).toBe(1) + expect(mockStdout.write).toHaveBeenCalledWith('Copied src/icon.png to assets/icon.png\n') + }) + + test('throws when source file does not exist (with destination)', async () => { + // Given + vi.mocked(fs.fileExists).mockResolvedValue(false) + + const step: BuildStep = { + id: 'copy-icon', + displayName: 'Copy Icon', + type: 'copy_files', + config: { + strategy: 'files', + definition: { + files: [{source: 'src/missing.png', destination: 'assets/missing.png'}], + }, + }, + } + + // When/Then + await expect(executeCopyFilesStep(step, mockContext)).rejects.toThrow('Source does not exist') + }) + + test('handles mixed entries: directory-to-root and explicit file', async () => { + // Given + vi.mocked(fs.fileExists).mockResolvedValue(true) + vi.mocked(fs.copyDirectoryContents).mockResolvedValue() + vi.mocked(fs.copyFile).mockResolvedValue() + vi.mocked(fs.mkdir).mockResolvedValue() + vi.mocked(fs.glob).mockResolvedValue(['index.html']) + + const step: BuildStep = { + id: 'copy-mixed', + displayName: 'Copy Mixed', + type: 'copy_files', + config: { + strategy: 'files', + definition: { + files: [{source: 'dist'}, {source: 'src/icon.png', destination: 'assets/icon.png'}], + }, + }, + } + + // When + const result = await executeCopyFilesStep(step, mockContext) + + // Then + expect(fs.copyDirectoryContents).toHaveBeenCalledWith('/test/extension/dist', '/test/output') + expect(fs.copyFile).toHaveBeenCalledWith('/test/extension/src/icon.png', '/test/output/assets/icon.png') + expect(result.filesCopied).toBe(2) + }) + }) + + describe('files strategy — tomlKey entries', () => { + test('copies directory contents for resolved tomlKey', async () => { + // Given + const contextWithConfig = { + ...mockContext, + extension: { + ...mockExtension, + configuration: {static_root: 'public'}, + } as unknown as ExtensionInstance, + } + + vi.mocked(fs.fileExists).mockResolvedValue(true) + vi.mocked(fs.copyDirectoryContents).mockResolvedValue() + vi.mocked(fs.glob).mockResolvedValue(['index.html', 'logo.png']) + + const step: BuildStep = { + id: 'copy-static', + displayName: 'Copy Static', + type: 'copy_files', + config: { + strategy: 'files', + definition: {files: [{tomlKey: 'static_root'}]}, + }, + } + + // When + const result = await executeCopyFilesStep(step, contextWithConfig) + + // Then + expect(fs.copyDirectoryContents).toHaveBeenCalledWith('/test/extension/public', '/test/output') + expect(result.filesCopied).toBe(2) + }) + + test('skips silently when tomlKey is absent from config', async () => { + // Given — configuration has no static_root + const contextWithoutConfig = { + ...mockContext, + extension: { + ...mockExtension, + configuration: {}, + } as unknown as ExtensionInstance, + } + + const step: BuildStep = { + id: 'copy-static', + displayName: 'Copy Static', + type: 'copy_files', + config: { + strategy: 'files', + definition: {files: [{tomlKey: 'static_root'}]}, + }, + } + + // When + const result = await executeCopyFilesStep(step, contextWithoutConfig) + + // Then — no error, no copies + expect(result.filesCopied).toBe(0) + expect(fs.copyDirectoryContents).not.toHaveBeenCalled() + expect(mockStdout.write).toHaveBeenCalledWith(expect.stringContaining("No value for tomlKey 'static_root'")) + }) + + test('skips path that does not exist on disk but logs a warning', async () => { + // Given + const contextWithConfig = { + ...mockContext, + extension: { + ...mockExtension, + configuration: {static_root: 'nonexistent'}, + } as unknown as ExtensionInstance, + } + + vi.mocked(fs.fileExists).mockResolvedValue(false) + + const step: BuildStep = { + id: 'copy-static', + displayName: 'Copy Static', + type: 'copy_files', + config: { + strategy: 'files', + definition: {files: [{tomlKey: 'static_root'}]}, + }, + } + + // When + const result = await executeCopyFilesStep(step, contextWithConfig) + + // Then — no error, logged warning + expect(result.filesCopied).toBe(0) + expect(mockStdout.write).toHaveBeenCalledWith( + expect.stringContaining("Warning: path 'nonexistent' does not exist"), + ) + }) + + test('resolves TOML array field and copies each path', async () => { + // Given — static_root is an array + const contextWithArrayConfig = { + ...mockContext, + extension: { + ...mockExtension, + configuration: {static_root: ['public', 'assets']}, + } as unknown as ExtensionInstance, + } + + vi.mocked(fs.fileExists).mockResolvedValue(true) + vi.mocked(fs.copyDirectoryContents).mockResolvedValue() + vi.mocked(fs.glob).mockResolvedValue(['file.html']) + + const step: BuildStep = { + id: 'copy-static', + displayName: 'Copy Static', + type: 'copy_files', + config: { + strategy: 'files', + definition: {files: [{tomlKey: 'static_root'}]}, + }, + } + + // When + await executeCopyFilesStep(step, contextWithArrayConfig) + + // Then — both paths copied + expect(fs.copyDirectoryContents).toHaveBeenCalledWith('/test/extension/public', '/test/output') + expect(fs.copyDirectoryContents).toHaveBeenCalledWith('/test/extension/assets', '/test/output') + }) + + test('handles mixed source and tomlKey entries in a single step', async () => { + // Given + const contextWithConfig = { + ...mockContext, + extension: { + ...mockExtension, + configuration: {static_root: 'public'}, + } as unknown as ExtensionInstance, + } + + vi.mocked(fs.fileExists).mockResolvedValue(true) + vi.mocked(fs.copyDirectoryContents).mockResolvedValue() + vi.mocked(fs.copyFile).mockResolvedValue() + vi.mocked(fs.mkdir).mockResolvedValue() + vi.mocked(fs.glob).mockResolvedValue(['index.html']) + + const step: BuildStep = { + id: 'copy-mixed', + displayName: 'Copy Mixed', + type: 'copy_files', + config: { + strategy: 'files', + definition: { + files: [{tomlKey: 'static_root'}, {source: 'src/icon.png', destination: 'assets/icon.png'}], + }, + }, + } + + // When + const result = await executeCopyFilesStep(step, contextWithConfig) + + // Then + expect(fs.copyDirectoryContents).toHaveBeenCalledWith('/test/extension/public', '/test/output') + expect(fs.copyFile).toHaveBeenCalledWith('/test/extension/src/icon.png', '/test/output/assets/icon.png') + expect(result.filesCopied).toBe(2) + }) + }) + + describe('pattern strategy', () => { + test('copies files matching patterns', async () => { + // Given + vi.mocked(fs.glob).mockResolvedValue(['/test/extension/public/logo.png', '/test/extension/public/style.css']) + vi.mocked(fs.copyFile).mockResolvedValue() + vi.mocked(fs.mkdir).mockResolvedValue() + + const step: BuildStep = { + id: 'copy-public', + displayName: 'Copy Public', + type: 'copy_files', + config: { + strategy: 'pattern', + definition: { + source: 'public', + patterns: ['**/*'], + }, + }, + } + + // When + const result = await executeCopyFilesStep(step, mockContext) + + // Then + expect(result.filesCopied).toBe(2) + expect(fs.copyFile).toHaveBeenCalledTimes(2) + }) + + test('respects ignore patterns', async () => { + // Given + vi.mocked(fs.glob).mockResolvedValue(['/test/extension/public/style.css']) + vi.mocked(fs.copyFile).mockResolvedValue() + vi.mocked(fs.mkdir).mockResolvedValue() + + const step: BuildStep = { + id: 'copy-public', + displayName: 'Copy Public', + type: 'copy_files', + config: { + strategy: 'pattern', + definition: { + source: 'public', + ignore: ['**/*.png'], + }, + }, + } + + // When + await executeCopyFilesStep(step, mockContext) + + // Then + expect(fs.glob).toHaveBeenCalledWith(expect.any(Array), expect.objectContaining({ignore: ['**/*.png']})) + }) + + test('copies to destination subdirectory when specified', async () => { + // Given + vi.mocked(fs.glob).mockResolvedValue(['/test/extension/public/logo.png']) + vi.mocked(fs.copyFile).mockResolvedValue() + vi.mocked(fs.mkdir).mockResolvedValue() + + const step: BuildStep = { + id: 'copy-public', + displayName: 'Copy Public', + type: 'copy_files', + config: { + strategy: 'pattern', + definition: { + source: 'public', + destination: 'static', + }, + }, + } + + // When + await executeCopyFilesStep(step, mockContext) + + // Then + expect(fs.glob).toHaveBeenCalledWith(expect.any(Array), expect.objectContaining({cwd: '/test/extension/public'})) + expect(fs.copyFile).toHaveBeenCalledWith('/test/extension/public/logo.png', '/test/output/static/logo.png') + }) + + test('flattens files when preserveStructure is false', async () => { + // Given + vi.mocked(fs.glob).mockResolvedValue(['/test/extension/src/components/Button.tsx']) + vi.mocked(fs.copyFile).mockResolvedValue() + vi.mocked(fs.mkdir).mockResolvedValue() + + const step: BuildStep = { + id: 'copy-src', + displayName: 'Copy Source', + type: 'copy_files', + config: { + strategy: 'pattern', + definition: { + source: 'src', + preserveStructure: false, + }, + }, + } + + // When + await executeCopyFilesStep(step, mockContext) + + // Then — filename only, no subdirectory + expect(fs.copyFile).toHaveBeenCalledWith('/test/extension/src/components/Button.tsx', '/test/output/Button.tsx') + }) + + test('returns zero and warns when no files match', async () => { + // Given + vi.mocked(fs.glob).mockResolvedValue([]) + vi.mocked(fs.mkdir).mockResolvedValue() + + const step: BuildStep = { + id: 'copy-public', + displayName: 'Copy Public', + type: 'copy_files', + config: { + strategy: 'pattern', + definition: {source: 'public'}, + }, + } + + // When + const result = await executeCopyFilesStep(step, mockContext) + + // Then + expect(result.filesCopied).toBe(0) + expect(mockStdout.write).toHaveBeenCalledWith(expect.stringContaining('No files matched patterns')) + }) + + test('throws when source is missing', async () => { + // Given — no source provided + const step: BuildStep = { + id: 'copy-build', + displayName: 'Copy Build', + type: 'copy_files', + config: { + strategy: 'pattern', + definition: {}, + }, + } + + // When/Then + await expect(executeCopyFilesStep(step, mockContext)).rejects.toThrow('Build step "Copy Build" requires a source') + }) + }) +}) diff --git a/packages/app/src/cli/services/build/steps/copy-files-step.ts b/packages/app/src/cli/services/build/steps/copy-files-step.ts new file mode 100644 index 00000000000..929fce5930c --- /dev/null +++ b/packages/app/src/cli/services/build/steps/copy-files-step.ts @@ -0,0 +1,244 @@ +import {resolveConfigurableValue} from '../build-steps.js' +import {joinPath, dirname, extname, relativePath, basename} from '@shopify/cli-kit/node/path' +import {glob, copyFile, copyDirectoryContents, fileExists, mkdir} from '@shopify/cli-kit/node/fs' +import {z} from 'zod' +import type {BuildStep, BuildContext} from '../build-steps.js' + +/** + * Zod schema for a configurable value (literal or reference). + * Accepts either a literal value of type T, or a config/env reference object. + */ +const configurableValueSchema = (literalSchema: T) => + z.union([literalSchema, z.object({configPath: z.string()}), z.object({envVar: z.string()})]) + +/** + * Files strategy definition. + * + * Each entry in `files` is one of: + * - `{source}` only: copy the directory's contents into the output root. + * - `{source, destination}`: copy the file to an explicit destination path. + * - `{tomlKey}`: resolve a path from the extension's TOML config and copy its + * directory contents into the output root. Silently skipped when the key is absent. + */ +const FilesDefinitionSchema = z.object({ + files: z.array( + z.union([z.object({source: z.string(), destination: z.string().optional()}), z.object({tomlKey: z.string()})]), + ), +}) + +/** + * Pattern strategy definition. + * + * Selects files from a single source directory using glob patterns. + */ +const PatternDefinitionSchema = z.object({ + source: z.string().optional(), + patterns: configurableValueSchema(z.array(z.string())).optional(), + ignore: configurableValueSchema(z.array(z.string())).optional(), + destination: z.string().optional(), + preserveStructure: z.boolean().default(true), +}) + +/** + * Configuration schema for copy_files step. + * Discriminated by strategy; definition shape is tied to the chosen strategy. + */ +const CopyFilesConfigSchema = z.discriminatedUnion('strategy', [ + z.object({ + strategy: z.literal('files'), + definition: FilesDefinitionSchema, + }), + z.object({ + strategy: z.literal('pattern'), + definition: PatternDefinitionSchema, + }), +]) + +/** + * Executes a copy_files build step. + * + * Supports two strategies: + * + * 1. **'files' strategy**: each entry in `definition.files` is either: + * - `{source}` — copy directory contents into the output root. + * - `{source, destination}` — copy a file to an explicit destination path. + * - `{tomlKey}` — resolve a path from the extension's TOML config and copy + * its directory contents into the output root; silently skipped if absent. + * + * 2. **'pattern' strategy**: glob-based file selection from a single source directory. + */ +export async function executeCopyFilesStep(step: BuildStep, context: BuildContext): Promise<{filesCopied: number}> { + const config = CopyFilesConfigSchema.parse(step.config) + const {extension, options} = context + // When outputPath is a file (e.g. index.js, index.wasm), the output directory is its + // parent. When outputPath has no extension, it IS the output directory (copy_files mode + // extensions where outputPath points to a bundle directory, not a single file). + const outputDir = extname(extension.outputPath) ? dirname(extension.outputPath) : extension.outputPath + + switch (config.strategy) { + case 'files': { + return copyFilesList(config.definition.files, extension.directory, outputDir, context, options) + } + case 'pattern': { + const {definition} = config + + if (!definition.source) { + throw new Error(`Build step "${step.displayName}" requires a source`) + } + + const sourceDir = joinPath(extension.directory, definition.source) + const resolvedPatterns = resolveConfigurableValue(definition.patterns, context) ?? ['**/*'] + const resolvedIgnore = resolveConfigurableValue(definition.ignore, context) ?? [] + const destinationDir = definition.destination ? joinPath(outputDir, definition.destination) : outputDir + + return copyByPattern( + sourceDir, + destinationDir, + resolvedPatterns, + resolvedIgnore, + definition.preserveStructure, + options, + ) + } + } +} + +/** + * Files strategy — processes a mixed list of `source` and `tomlKey` entries. + */ +async function copyFilesList( + files: ({source: string; destination?: string} | {tomlKey: string})[], + baseDir: string, + outputDir: string, + context: BuildContext, + options: {stdout: NodeJS.WritableStream}, +): Promise<{filesCopied: number}> { + const counts = await Promise.all( + files.map(async (entry) => { + if ('tomlKey' in entry) { + return copyTomlKeyEntry(entry.tomlKey, baseDir, outputDir, context, options) + } + return copySourceEntry(entry.source, entry.destination, baseDir, outputDir, options) + }), + ) + return {filesCopied: counts.reduce((sum, count) => sum + count, 0)} +} + +/** + * Handles a `{source}` or `{source, destination}` files entry. + * + * - No `destination`: copy directory contents into the output root. + * - With `destination`: copy the file to the explicit destination path. + */ +async function copySourceEntry( + source: string, + destination: string | undefined, + baseDir: string, + outputDir: string, + options: {stdout: NodeJS.WritableStream}, +): Promise { + const sourcePath = joinPath(baseDir, source) + const exists = await fileExists(sourcePath) + if (!exists) { + throw new Error(`Source does not exist: ${sourcePath}`) + } + + if (destination !== undefined) { + const destPath = joinPath(outputDir, destination) + await mkdir(dirname(destPath)) + await copyFile(sourcePath, destPath) + options.stdout.write(`Copied ${source} to ${destination}\n`) + return 1 + } + + await copyDirectoryContents(sourcePath, outputDir) + const copied = await glob(['**/*'], {cwd: outputDir, absolute: false}) + options.stdout.write(`Copied contents of ${source} to output root\n`) + return copied.length +} + +/** + * Handles a `{tomlKey}` files entry. + * + * Resolves the key from the extension's TOML config. String values and string + * arrays are each used as source paths. Unresolved keys and missing paths are + * skipped silently with a log message. + */ +async function copyTomlKeyEntry( + key: string, + baseDir: string, + outputDir: string, + context: BuildContext, + options: {stdout: NodeJS.WritableStream}, +): Promise { + const value = resolveConfigurableValue({configPath: key}, context) + let paths: string[] + if (typeof value === 'string') { + paths = [value] + } else if (Array.isArray(value)) { + paths = value.filter((item): item is string => typeof item === 'string') + } else { + paths = [] + } + + if (paths.length === 0) { + options.stdout.write(`No value for tomlKey '${key}', skipping\n`) + return 0 + } + + const counts = await Promise.all( + paths.map(async (sourcePath) => { + const fullPath = joinPath(baseDir, sourcePath) + const exists = await fileExists(fullPath) + if (!exists) { + options.stdout.write(`Warning: path '${sourcePath}' does not exist, skipping\n`) + return 0 + } + await copyDirectoryContents(fullPath, outputDir) + const copied = await glob(['**/*'], {cwd: outputDir, absolute: false}) + options.stdout.write(`Copied contents of '${sourcePath}' to output root\n`) + return copied.length + }), + ) + return counts.reduce((sum, count) => sum + count, 0) +} + +/** + * Pattern strategy: glob-based file selection. + */ +async function copyByPattern( + sourceDir: string, + outputDir: string, + patterns: string[], + ignore: string[], + preserveStructure: boolean, + options: {stdout: NodeJS.WritableStream}, +): Promise<{filesCopied: number}> { + const files = await glob(patterns, { + absolute: true, + cwd: sourceDir, + ignore, + }) + + if (files.length === 0) { + options.stdout.write(`Warning: No files matched patterns in ${sourceDir}\n`) + return {filesCopied: 0} + } + + await mkdir(outputDir) + + await Promise.all( + files.map(async (filepath) => { + const relPath = preserveStructure ? relativePath(sourceDir, filepath) : basename(filepath) + const destPath = joinPath(outputDir, relPath) + + if (filepath === destPath) return + + await mkdir(dirname(destPath)) + await copyFile(filepath, destPath) + }), + ) + + options.stdout.write(`Copied ${files.length} file(s) from ${sourceDir} to ${outputDir}\n`) + return {filesCopied: files.length} +} diff --git a/packages/app/src/cli/services/build/steps/copy-static-assets-step.ts b/packages/app/src/cli/services/build/steps/copy-static-assets-step.ts new file mode 100644 index 00000000000..02221ef441e --- /dev/null +++ b/packages/app/src/cli/services/build/steps/copy-static-assets-step.ts @@ -0,0 +1,11 @@ +import type {BuildStep, BuildContext} from '../build-steps.js' + +/** + * Executes a copy_static_assets build step. + * + * Copies static assets defined in the extension's build_manifest to the output directory. + * This is a no-op for extensions that do not define static assets. + */ +export async function executeCopyStaticAssetsStep(_step: BuildStep, context: BuildContext): Promise { + return context.extension.copyStaticAssets() +} diff --git a/packages/app/src/cli/services/build/steps/create-tax-stub-step.ts b/packages/app/src/cli/services/build/steps/create-tax-stub-step.ts new file mode 100644 index 00000000000..50bcc09bc12 --- /dev/null +++ b/packages/app/src/cli/services/build/steps/create-tax-stub-step.ts @@ -0,0 +1,14 @@ +import {touchFile, writeFile} from '@shopify/cli-kit/node/fs' +import type {BuildStep, BuildContext} from '../build-steps.js' + +/** + * Executes a create_tax_stub build step. + * + * Creates a minimal JavaScript stub file at the extension's output path, + * satisfying the tax calculation extension bundle format. + */ +export async function executeCreateTaxStubStep(_step: BuildStep, context: BuildContext): Promise { + const {extension} = context + await touchFile(extension.outputPath) + await writeFile(extension.outputPath, '(()=>{})();') +} diff --git a/packages/app/src/cli/services/build/steps/index.ts b/packages/app/src/cli/services/build/steps/index.ts new file mode 100644 index 00000000000..c68c796e58b --- /dev/null +++ b/packages/app/src/cli/services/build/steps/index.ts @@ -0,0 +1,54 @@ +import {executeCopyFilesStep} from './copy-files-step.js' +import {executeBuildThemeStep} from './build-theme-step.js' +import {executeBundleThemeStep} from './bundle-theme-step.js' +import {executeBundleUIStep} from './bundle-ui-step.js' +import {executeCopyStaticAssetsStep} from './copy-static-assets-step.js' +import {executeBuildFunctionStep} from './build-function-step.js' +import {executeCreateTaxStubStep} from './create-tax-stub-step.js' +import type {BuildStep, BuildContext} from '../build-steps.js' + +/** + * Routes step execution to the appropriate handler based on step type. + * This implements the Command Pattern router, dispatching to type-specific executors. + * + * @param step - The build step configuration + * @param context - The build context + * @returns The output from the step execution + * @throws Error if the step type is not implemented or unknown + */ +export async function executeStepByType(step: BuildStep, context: BuildContext): Promise { + switch (step.type) { + case 'copy_files': + return executeCopyFilesStep(step, context) + + case 'build_theme': + return executeBuildThemeStep(step, context) + + case 'bundle_theme': + return executeBundleThemeStep(step, context) + + case 'bundle_ui': + return executeBundleUIStep(step, context) + + case 'copy_static_assets': + return executeCopyStaticAssetsStep(step, context) + + case 'build_function': + return executeBuildFunctionStep(step, context) + + case 'create_tax_stub': + return executeCreateTaxStubStep(step, context) + + // Future step types (not implemented yet): + case 'esbuild': + case 'validate': + case 'transform': + case 'custom': + throw new Error( + `Build step type "${step.type}" is not yet implemented. Only "copy_files" is currently supported.`, + ) + + default: + throw new Error(`Unknown build step type: ${(step as {type: string}).type}`) + } +}