diff --git a/packages/app/src/cli/models/extensions/extension-instance.ts b/packages/app/src/cli/models/extensions/extension-instance.ts index 1732badeae3..fe311f33422 100644 --- a/packages/app/src/cli/models/extensions/extension-instance.ts +++ b/packages/app/src/cli/models/extensions/extension-instance.ts @@ -44,6 +44,7 @@ export const CONFIG_EXTENSION_IDS: string[] = [ WebhookSubscriptionSpecIdentifier, WebhooksSpecIdentifier, EventsSpecIdentifier, + 'admin', ] /** @@ -368,8 +369,8 @@ export class ExtensionInstance(spec: { identifier: string schema: ZodSchemaType + clientSteps?: ClientSteps + buildConfig?: BuildConfig appModuleFeatures?: (config?: TConfiguration) => ExtensionFeature[] transformConfig: TransformationConfig | CustomTransformationConfig uidStrategy?: UidStrategy @@ -262,18 +272,24 @@ export function createConfigExtensionSpecification( - spec: Pick, 'identifier' | 'appModuleFeatures' | 'buildConfig'>, + spec: Pick< + CreateExtensionSpecType, + 'identifier' | 'appModuleFeatures' | 'clientSteps' | 'buildConfig' + >, ) { return createExtensionSpecification({ identifier: spec.identifier, schema: zod.any({}) as unknown as ZodSchemaType, appModuleFeatures: spec.appModuleFeatures, + clientSteps: spec.clientSteps, buildConfig: spec.buildConfig ?? {mode: 'none'}, deployConfig: async (config, directory) => { let parsedConfig = configWithoutFirstClassFields(config) diff --git a/packages/app/src/cli/services/build/client-steps.integration.test.ts b/packages/app/src/cli/services/build/client-steps.integration.test.ts new file mode 100644 index 00000000000..f901fae1d1d --- /dev/null +++ b/packages/app/src/cli/services/build/client-steps.integration.test.ts @@ -0,0 +1,162 @@ +import {ExtensionBuildOptions} from './extension.js' +import {executeStep, BuildContext} from './client-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' + +function buildOptions(): ExtensionBuildOptions { + return { + stdout: new Writable({ + write(chunk, encoding, callback) { + callback() + }, + }), + stderr: new Writable({ + write(chunk, encoding, callback) { + callback() + }, + }), + app: {} as any, + environment: 'production', + } +} + +describe('client_steps integration', () => { + test('executes include_assets 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; }') + + const mockExtension = { + directory: extensionDir, + outputPath: joinPath(outputDir, 'extension.js'), + } as ExtensionInstance + + const context: BuildContext = {extension: mockExtension, options: buildOptions(), stepResults: new Map()} + + await executeStep( + { + id: 'copy-assets', + name: 'Copy Assets', + type: 'include_assets', + config: { + inclusions: [{type: 'pattern', baseDir: 'assets', include: ['**/*']}], + }, + }, + context, + ) + + // 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 context: BuildContext = {extension: mockExtension, options: buildOptions(), stepResults: new Map()} + + await executeStep( + { + id: 'copy-images', + name: 'Copy Images', + type: 'include_assets', + config: { + inclusions: [{type: 'pattern', baseDir: 'images', include: ['**/*'], destination: 'assets/images'}], + }, + }, + context, + ) + await executeStep( + { + id: 'copy-styles', + name: 'Copy Styles', + type: 'include_assets', + config: { + inclusions: [{type: 'pattern', baseDir: 'styles', include: ['**/*'], destination: 'assets/styles'}], + }, + }, + context, + ) + + // 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 configKey step when config 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 context: BuildContext = {extension: mockExtension, options: buildOptions(), stepResults: new Map()} + + // Should not throw — absent configKey values are silently skipped + await expect( + executeStep( + { + id: 'copy-static-assets', + name: 'Copy Static Assets', + type: 'include_assets', + config: {inclusions: [{type: 'configKey', configKey: 'static_root'}]}, + }, + context, + ), + ).resolves.not.toThrow() + }) + }) +}) diff --git a/packages/app/src/cli/services/build/client-steps.test.ts b/packages/app/src/cli/services/build/client-steps.test.ts new file mode 100644 index 00000000000..493f0157b7c --- /dev/null +++ b/packages/app/src/cli/services/build/client-steps.test.ts @@ -0,0 +1,76 @@ +import {executeStep, ClientStep, BuildContext} from './client-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('executeStep', () => { + let mockContext: BuildContext + + beforeEach(() => { + mockContext = { + extension: { + directory: '/test/dir', + outputPath: '/test/output/index.js', + } as ExtensionInstance, + options: { + stdout: {write: vi.fn()} as any, + stderr: {write: vi.fn()} as any, + app: {} as any, + environment: 'production' as const, + }, + stepResults: new Map(), + } + }) + + const step: ClientStep = { + id: 'test-step', + name: 'Test Step', + type: 'include_assets', + config: {}, + } + + describe('success', () => { + test('returns a successful StepResult with output', async () => { + vi.mocked(stepsIndex.executeStepByType).mockResolvedValue({filesCopied: 3}) + + const result = await executeStep(step, mockContext) + + expect(result.id).toBe('test-step') + expect(result.success).toBe(true) + expect(result.output).toEqual({filesCopied: 3}) + expect(result.duration).toBeGreaterThanOrEqual(0) + }) + + test('logs step execution to stdout', async () => { + vi.mocked(stepsIndex.executeStepByType).mockResolvedValue({}) + + await executeStep(step, mockContext) + + expect(mockContext.options.stdout.write).toHaveBeenCalledWith('Executing step: Test Step\n') + }) + }) + + describe('failure', () => { + test('throws a wrapped error when the step fails', async () => { + vi.mocked(stepsIndex.executeStepByType).mockRejectedValue(new Error('something went wrong')) + + await expect(executeStep(step, mockContext)).rejects.toThrow( + 'Build step "Test Step" failed: something went wrong', + ) + }) + + test('returns a failure result and logs a warning when continueOnError is true', async () => { + vi.mocked(stepsIndex.executeStepByType).mockRejectedValue(new Error('something went wrong')) + + const result = await executeStep({...step, continueOnError: true}, mockContext) + + expect(result.success).toBe(false) + expect(result.error?.message).toBe('something went wrong') + expect(mockContext.options.stderr.write).toHaveBeenCalledWith( + 'Warning: Step "Test Step" failed but continuing: something went wrong\n', + ) + }) + }) +}) diff --git a/packages/app/src/cli/services/build/client-steps.ts b/packages/app/src/cli/services/build/client-steps.ts new file mode 100644 index 00000000000..7d61e5a1304 --- /dev/null +++ b/packages/app/src/cli/services/build/client-steps.ts @@ -0,0 +1,102 @@ +import {executeStepByType} from './steps/index.js' +import type {ExtensionInstance} from '../../models/extensions/extension-instance.js' +import type {ExtensionBuildOptions} from './extension.js' + +/** + * ClientStep represents a single step in the client-side build pipeline. + * Pure configuration object — execution logic is separate (router pattern). + */ +export interface ClientStep { + /** Unique identifier, used as the key in the stepResults map */ + readonly id: string + + /** Human-readable name for logging */ + readonly name: string + + /** Step type (determines which executor handles it) */ + readonly type: + | 'include_assets' + | '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 +} + +/** + * A group of steps scoped to a specific lifecycle phase. + * Allows executing only the steps relevant to a given lifecycle (e.g. 'deploy'). + */ +interface ClientLifecycleGroup { + readonly lifecycle: 'deploy' + readonly steps: ReadonlyArray +} + +/** + * The full client steps configuration for an extension. + * Replaces the old buildConfig contract. + */ +export type ClientSteps = ReadonlyArray + +/** + * Context passed through the step pipeline. + * Each step can read from and write to the context. + */ +export interface BuildContext { + readonly extension: ExtensionInstance + readonly options: ExtensionBuildOptions + readonly stepResults: Map + [key: string]: unknown +} + +interface StepResult { + readonly id: string + readonly success: boolean + readonly duration: number + readonly output?: unknown + readonly error?: Error +} + +/** + * Executes a single client step with error handling. + */ +export async function executeStep(step: ClientStep, context: BuildContext): Promise { + const startTime = Date.now() + + try { + context.options.stdout.write(`Executing step: ${step.name}\n`) + const output = await executeStepByType(step, context) + + return { + id: step.id, + success: true, + duration: Date.now() - startTime, + output, + } + } catch (error) { + const stepError = error as Error + + if (step.continueOnError) { + context.options.stderr.write(`Warning: Step "${step.name}" failed but continuing: ${stepError.message}\n`) + return { + id: step.id, + success: false, + duration: Date.now() - startTime, + error: stepError, + } + } + + throw new Error(`Build step "${step.name}" 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..a0106c1bf05 --- /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 {ClientStep, BuildContext} from '../client-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: ClientStep, 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..314787210d0 --- /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 {ClientStep, BuildContext} from '../client-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: ClientStep, 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..4c49af9c03f --- /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 {ClientStep, BuildContext} from '../client-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: ClientStep, 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..050119b62f1 --- /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 {ClientStep, BuildContext} from '../client-steps.js' + +/** + * Executes a bundle_ui build step. + * + * Bundles the UI extension using esbuild, writing output to extension.outputPath. + */ +export async function executeBundleUIStep(_step: ClientStep, context: BuildContext): Promise { + return buildUIExtension(context.extension, context.options) +} 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..5f9e0e9e4b9 --- /dev/null +++ b/packages/app/src/cli/services/build/steps/copy-static-assets-step.ts @@ -0,0 +1,11 @@ +import type {ClientStep, BuildContext} from '../client-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: ClientStep, 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..2bc6538cb02 --- /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 {ClientStep, BuildContext} from '../client-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: ClientStep, context: BuildContext): Promise { + const {extension} = context + await touchFile(extension.outputPath) + await writeFile(extension.outputPath, '(()=>{})();') +} diff --git a/packages/app/src/cli/services/build/steps/include-assets-step.test.ts b/packages/app/src/cli/services/build/steps/include-assets-step.test.ts new file mode 100644 index 00000000000..97394aaaf4c --- /dev/null +++ b/packages/app/src/cli/services/build/steps/include-assets-step.test.ts @@ -0,0 +1,542 @@ +import {executeIncludeAssetsStep} from './include_assets_step.js' +import {ClientStep, BuildContext} from '../client-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('executeIncludeAssetsStep', () => { + let mockExtension: ExtensionInstance + let mockContext: BuildContext + let mockStdout: any + let mockStderr: any + + beforeEach(() => { + mockStdout = {write: vi.fn()} + mockStderr = {write: vi.fn()} + mockExtension = { + directory: '/test/extension', + outputPath: '/test/output/extension.js', + } as ExtensionInstance + + mockContext = { + extension: mockExtension, + options: { + stdout: mockStdout, + stderr: mockStderr, + app: {} as any, + environment: 'production', + }, + stepResults: new Map(), + } + }) + + describe('static entries', () => { + test('copies directory contents to output root when no destination (preserveStructure defaults false)', 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: ClientStep = { + id: 'copy-dist', + name: 'Copy Dist', + type: 'include_assets', + config: { + inclusions: [{type: 'static', source: 'dist'}], + }, + } + + // When + const result = await executeIncludeAssetsStep(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('preserves directory name when preserveStructure is true', 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: ClientStep = { + id: 'copy-dist', + name: 'Copy Dist', + type: 'include_assets', + config: { + inclusions: [{type: 'static', source: 'dist', preserveStructure: true}], + }, + } + + // When + const result = await executeIncludeAssetsStep(step, mockContext) + + // Then — directory is placed under its own name, not merged into output root + expect(fs.copyDirectoryContents).toHaveBeenCalledWith('/test/extension/dist', '/test/output/dist') + expect(result.filesCopied).toBe(2) + expect(mockStdout.write).toHaveBeenCalledWith(expect.stringContaining('Copied dist to dist')) + }) + + test('throws when source directory does not exist', async () => { + // Given + vi.mocked(fs.fileExists).mockResolvedValue(false) + + const step: ClientStep = { + id: 'copy-dist', + name: 'Copy Dist', + type: 'include_assets', + config: { + inclusions: [{type: 'static', source: 'dist'}], + }, + } + + // When/Then + await expect(executeIncludeAssetsStep(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: ClientStep = { + id: 'copy-icon', + name: 'Copy Icon', + type: 'include_assets', + config: { + inclusions: [{type: 'static', source: 'src/icon.png', destination: 'assets/icon.png'}], + }, + } + + // When + const result = await executeIncludeAssetsStep(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: ClientStep = { + id: 'copy-icon', + name: 'Copy Icon', + type: 'include_assets', + config: { + inclusions: [{type: 'static', source: 'src/missing.png', destination: 'assets/missing.png'}], + }, + } + + // When/Then + await expect(executeIncludeAssetsStep(step, mockContext)).rejects.toThrow('Source does not exist') + }) + + test('handles multiple static entries in inclusions', 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: ClientStep = { + id: 'copy-mixed', + name: 'Copy Mixed', + type: 'include_assets', + config: { + inclusions: [ + {type: 'static', source: 'dist'}, + {type: 'static', source: 'src/icon.png', destination: 'assets/icon.png'}, + ], + }, + } + + // When + const result = await executeIncludeAssetsStep(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('configKey entries', () => { + test('copies directory contents for resolved configKey', 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: ClientStep = { + id: 'copy-static', + name: 'Copy Static', + type: 'include_assets', + config: { + inclusions: [{type: 'configKey', configKey: 'static_root'}], + }, + } + + // When + const result = await executeIncludeAssetsStep(step, contextWithConfig) + + // Then + expect(fs.copyDirectoryContents).toHaveBeenCalledWith('/test/extension/public', '/test/output') + expect(result.filesCopied).toBe(2) + }) + + test('preserves directory name for configKey when preserveStructure is true', 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: ClientStep = { + id: 'copy-static', + name: 'Copy Static', + type: 'include_assets', + config: { + inclusions: [{type: 'configKey', configKey: 'static_root', preserveStructure: true}], + }, + } + + // When + const result = await executeIncludeAssetsStep(step, contextWithConfig) + + // Then — directory is placed under its own name + expect(fs.copyDirectoryContents).toHaveBeenCalledWith('/test/extension/public', '/test/output/public') + expect(result.filesCopied).toBe(2) + }) + + test('skips silently when configKey is absent from config', async () => { + // Given — configuration has no static_root + const contextWithoutConfig = { + ...mockContext, + extension: { + ...mockExtension, + configuration: {}, + } as unknown as ExtensionInstance, + } + + const step: ClientStep = { + id: 'copy-static', + name: 'Copy Static', + type: 'include_assets', + config: { + inclusions: [{type: 'configKey', configKey: 'static_root'}], + }, + } + + // When + const result = await executeIncludeAssetsStep(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 configKey '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: ClientStep = { + id: 'copy-static', + name: 'Copy Static', + type: 'include_assets', + config: { + inclusions: [{type: 'configKey', configKey: 'static_root'}], + }, + } + + // When + const result = await executeIncludeAssetsStep(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 array config value 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: ClientStep = { + id: 'copy-static', + name: 'Copy Static', + type: 'include_assets', + config: { + inclusions: [{type: 'configKey', configKey: 'static_root'}], + }, + } + + // When + await executeIncludeAssetsStep(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 configKey and source entries in inclusions', 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: ClientStep = { + id: 'copy-mixed', + name: 'Copy Mixed', + type: 'include_assets', + config: { + inclusions: [ + {type: 'configKey', configKey: 'static_root'}, + {type: 'static', source: 'src/icon.png', destination: 'assets/icon.png'}, + ], + }, + } + + // When + const result = await executeIncludeAssetsStep(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 entries', () => { + test('copies files matching include 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: ClientStep = { + id: 'copy-public', + name: 'Copy Public', + type: 'include_assets', + config: { + inclusions: [{type: 'pattern', baseDir: 'public', include: ['**/*']}], + }, + } + + // When + const result = await executeIncludeAssetsStep(step, mockContext) + + // Then + expect(result.filesCopied).toBe(2) + expect(fs.copyFile).toHaveBeenCalledTimes(2) + }) + + test('uses extension directory as source when source is omitted', async () => { + // Given + vi.mocked(fs.glob).mockResolvedValue(['/test/extension/index.js', '/test/extension/manifest.json']) + vi.mocked(fs.copyFile).mockResolvedValue() + vi.mocked(fs.mkdir).mockResolvedValue() + + const step: ClientStep = { + id: 'copy-root', + name: 'Copy Root', + type: 'include_assets', + config: { + inclusions: [{type: 'pattern', include: ['*.js', '*.json']}], + }, + } + + // When + await executeIncludeAssetsStep(step, mockContext) + + // Then — glob is called with extension.directory as cwd + expect(fs.glob).toHaveBeenCalledWith(expect.any(Array), expect.objectContaining({cwd: '/test/extension'})) + }) + + 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: ClientStep = { + id: 'copy-public', + name: 'Copy Public', + type: 'include_assets', + config: { + inclusions: [{type: 'pattern', baseDir: 'public', ignore: ['**/*.png']}], + }, + } + + // When + await executeIncludeAssetsStep(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: ClientStep = { + id: 'copy-public', + name: 'Copy Public', + type: 'include_assets', + config: { + inclusions: [{type: 'pattern', baseDir: 'public', destination: 'static'}], + }, + } + + // When + await executeIncludeAssetsStep(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: ClientStep = { + id: 'copy-source', + name: 'Copy Source', + type: 'include_assets', + config: { + inclusions: [{type: 'pattern', baseDir: 'src', preserveStructure: false}], + }, + } + + // When + await executeIncludeAssetsStep(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: ClientStep = { + id: 'copy-public', + name: 'Copy Public', + type: 'include_assets', + config: { + inclusions: [{type: 'pattern', baseDir: 'public'}], + }, + } + + // When + const result = await executeIncludeAssetsStep(step, mockContext) + + // Then + expect(result.filesCopied).toBe(0) + expect(mockStdout.write).toHaveBeenCalledWith(expect.stringContaining('No files matched patterns')) + }) + }) + + describe('mixed inclusions', () => { + test('executes all entry types in parallel and aggregates filesCopied count', async () => { + // Given + const contextWithConfig = { + ...mockContext, + extension: { + ...mockExtension, + configuration: {theme_root: 'theme'}, + } 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() + // glob: first call for pattern entry, second for configKey dir listing + vi.mocked(fs.glob) + .mockResolvedValueOnce(['/test/extension/assets/logo.png', '/test/extension/assets/icon.svg']) + .mockResolvedValueOnce(['index.html', 'style.css']) + + const step: ClientStep = { + id: 'include-all', + name: 'Include All', + type: 'include_assets', + config: { + inclusions: [ + {type: 'pattern', baseDir: 'assets', include: ['**/*.png', '**/*.svg']}, + {type: 'configKey', configKey: 'theme_root'}, + {type: 'static', source: 'src/manifest.json', destination: 'manifest.json'}, + ], + }, + } + + // When + const result = await executeIncludeAssetsStep(step, contextWithConfig) + + // Then + // 5 = 2 pattern + 2 configKey dir contents + 1 explicit file + expect(result.filesCopied).toBe(5) + expect(fs.copyFile).toHaveBeenCalledWith('/test/extension/src/manifest.json', '/test/output/manifest.json') + expect(fs.copyDirectoryContents).toHaveBeenCalledWith('/test/extension/theme', '/test/output') + }) + }) +}) diff --git a/packages/app/src/cli/services/build/steps/include_assets_step.ts b/packages/app/src/cli/services/build/steps/include_assets_step.ts new file mode 100644 index 00000000000..cd37c930a58 --- /dev/null +++ b/packages/app/src/cli/services/build/steps/include_assets_step.ts @@ -0,0 +1,296 @@ +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 {ClientStep, BuildContext} from '../client-steps.js' + +/** + * Pattern inclusion entry. + * + * Selects files from a source directory using glob patterns. `source` defaults + * to the extension root when omitted. `include` defaults to `['**\/*']`. + * `preserveStructure` defaults to `true` (relative paths preserved). + */ +const PatternEntrySchema = z.object({ + type: z.literal('pattern'), + baseDir: z.string().optional(), + include: z.array(z.string()).default(['**/*']), + ignore: z.array(z.string()).optional(), + destination: z.string().optional(), + preserveStructure: z.boolean().default(true), +}) + +/** + * Static inclusion entry — explicit source path. + * + * - With `destination`: copies the file/directory to that exact path. + * - Without `destination`, `preserveStructure` false (default): merges + * directory contents into the output root. + * - Without `destination`, `preserveStructure` true: places the directory + * under its own name in the output. + */ +const StaticEntrySchema = z.object({ + type: z.literal('static'), + source: z.string(), + destination: z.string().optional(), + preserveStructure: z.boolean().default(false), +}) + +/** + * ConfigKey inclusion entry — config key resolution. + * + * Resolves a path (or array of paths) from the extension configuration and + * copies the directory contents into the output. Silently skipped when the + * key is absent. Respects `preserveStructure` and `destination` the same way + * as the static entry. + */ +const ConfigKeyEntrySchema = z.object({ + type: z.literal('configKey'), + key: z.string(), + destination: z.string().optional(), + preserveStructure: z.boolean().default(false), +}) + +const InclusionEntrySchema = z.discriminatedUnion('type', [PatternEntrySchema, StaticEntrySchema, ConfigKeyEntrySchema]) + +/** + * Configuration schema for include_assets step. + * + * `inclusions` is a flat array of entries, each with a `type` discriminant + * (`'files'` or `'pattern'`). All entries are processed in parallel. + */ +const IncludeAssetsConfigSchema = z.object({ + inclusions: z.array(InclusionEntrySchema), +}) + +/** + * Executes an include_assets build step. + * + * Iterates over `config.inclusions` and dispatches each entry by type: + * + * - `type: 'files'` with `source` — copy a file or directory into the output. + * - `type: 'files'` with `configKey` — resolve a path from the extension's + * config and copy its directory into the output; silently skipped if absent. + * - `type: 'pattern'` — glob-based file selection from a source directory + * (defaults to extension root when `source` is omitted). + */ +export async function executeIncludeAssetsStep( + step: ClientStep, + context: BuildContext, +): Promise<{filesCopied: number}> { + const config = IncludeAssetsConfigSchema.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. + const outputDir = extname(extension.outputPath) ? dirname(extension.outputPath) : extension.outputPath + + const counts = await Promise.all( + config.inclusions.map(async (entry) => { + if (entry.type === 'pattern') { + const sourceDir = entry.baseDir ? joinPath(extension.directory, entry.baseDir) : extension.directory + const destinationDir = entry.destination ? joinPath(outputDir, entry.destination) : outputDir + const result = await copyByPattern( + sourceDir, + destinationDir, + entry.include, + entry.ignore ?? [], + entry.preserveStructure, + options, + ) + return result.filesCopied + } + + if (entry.type === 'configKey') { + return copyConfigKeyEntry( + entry.key, + extension.directory, + outputDir, + context, + options, + entry.preserveStructure, + entry.destination, + ) + } + + return copySourceEntry( + entry.source, + entry.destination, + extension.directory, + outputDir, + options, + entry.preserveStructure, + ) + }), + ) + + return {filesCopied: counts.reduce((sum, count) => sum + count, 0)} +} + +/** + * Handles a `{source}` or `{source, destination}` files entry. + * + * - No `destination`, `preserveStructure` false: copy directory contents into the output root. + * - No `destination`, `preserveStructure` true: copy the directory under its own name in the output. + * - With `destination`: copy the file to the explicit destination path (`preserveStructure` is ignored). + */ +async function copySourceEntry( + source: string, + destination: string | undefined, + baseDir: string, + outputDir: string, + options: {stdout: NodeJS.WritableStream}, + preserveStructure: boolean, +): 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 + } + + const destDir = preserveStructure ? joinPath(outputDir, basename(sourcePath)) : outputDir + await copyDirectoryContents(sourcePath, destDir) + const copied = await glob(['**/*'], {cwd: destDir, absolute: false}) + const msg = preserveStructure + ? `Copied ${source} to ${basename(sourcePath)}\n` + : `Copied contents of ${source} to output root\n` + options.stdout.write(msg) + return copied.length +} + +/** + * Handles a `{configKey}` files entry. + * + * Resolves the key from the extension's config. String values and string + * arrays are each used as source paths. Unresolved keys and missing paths are + * skipped silently with a log message. When `destination` is given, the + * resolved directory is placed under `outputDir/destination`. + */ +async function copyConfigKeyEntry( + key: string, + baseDir: string, + outputDir: string, + context: BuildContext, + options: {stdout: NodeJS.WritableStream}, + preserveStructure: boolean, + destination?: string, +): Promise { + const value = getNestedValue(context.extension.configuration, key) + 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 configKey '${key}', skipping\n`) + return 0 + } + + const effectiveOutputDir = destination ? joinPath(outputDir, destination) : outputDir + + 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 + } + const destDir = preserveStructure ? joinPath(effectiveOutputDir, basename(fullPath)) : effectiveOutputDir + await copyDirectoryContents(fullPath, destDir) + const copied = await glob(['**/*'], {cwd: destDir, absolute: false}) + const msg = preserveStructure + ? `Copied '${sourcePath}' to ${basename(fullPath)}\n` + : `Copied contents of '${sourcePath}' to output root\n` + options.stdout.write(msg) + 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} +} + +/** + * Resolves a dot-separated path from a config object. + * Handles TOML array-of-tables by plucking the next key across all elements. + */ +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 + } + + 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 +} 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..54a6cd6d626 --- /dev/null +++ b/packages/app/src/cli/services/build/steps/index.ts @@ -0,0 +1,52 @@ +import {executeIncludeAssetsStep} from './include_assets_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 {ClientStep, BuildContext} from '../client-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: ClientStep, context: BuildContext): Promise { + switch (step.type) { + case 'include_assets': + return executeIncludeAssetsStep(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.`) + + default: + throw new Error(`Unknown build step type: ${(step as {type: string}).type}`) + } +} diff --git a/packages/app/src/cli/services/generate/fetch-extension-specifications.ts b/packages/app/src/cli/services/generate/fetch-extension-specifications.ts index 37544a1c672..4aa743ea03a 100644 --- a/packages/app/src/cli/services/generate/fetch-extension-specifications.ts +++ b/packages/app/src/cli/services/generate/fetch-extension-specifications.ts @@ -79,6 +79,9 @@ async function mergeLocalAndRemoteSpecs( const merged = {...localSpec, ...remoteSpec, loadedRemoteSpecs: true} as RemoteAwareExtensionSpecification & FlattenedRemoteSpecification + // Not all the specs are moved to remote definition yet + merged.clientSteps ??= localSpec.clientSteps ?? [] + // If configuration is inside an app.toml -- i.e. single UID mode -- we must be able to parse a partial slice. let handleInvalidAdditionalProperties: HandleInvalidAdditionalProperties switch (merged.uidStrategy) {