From ba771b86838749f9c99507809b5ed3f0461d5b40 Mon Sep 17 00:00:00 2001 From: Alfonso Noriega Date: Tue, 10 Mar 2026 09:32:41 +0100 Subject: [PATCH] Add client steps implementation to support existing functionalities --- .../models/extensions/extension-instance.ts | 1 + .../build/steps/build-function-step.ts | 12 + .../services/build/steps/build-theme-step.ts | 14 + .../services/build/steps/bundle-theme-step.ts | 30 + .../services/build/steps/bundle-ui-step.ts | 11 + .../build/steps/copy-static-assets-step.ts | 11 + .../build/steps/create-tax-stub-step.ts | 14 + .../build/steps/include-assets-step.test.ts | 606 ++++++++++++++++++ .../build/steps/include_assets_step.ts | 318 +++++++++ .../app/src/cli/services/build/steps/index.ts | 25 +- 10 files changed, 1040 insertions(+), 2 deletions(-) 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-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/include-assets-step.test.ts create mode 100644 packages/app/src/cli/services/build/steps/include_assets_step.ts diff --git a/packages/app/src/cli/models/extensions/extension-instance.ts b/packages/app/src/cli/models/extensions/extension-instance.ts index e79effd8f1..add3423741 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', ] /** 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 0000000000..d1e1f16f6e --- /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 {LifecycleStep, 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: LifecycleStep, 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 0000000000..5c9c65861a --- /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 {LifecycleStep, 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: LifecycleStep, 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 0000000000..f6fb19d573 --- /dev/null +++ b/packages/app/src/cli/services/build/steps/bundle-theme-step.ts @@ -0,0 +1,30 @@ +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 {LifecycleStep, 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: LifecycleStep, + 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 0000000000..0e33925e4c --- /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 {LifecycleStep, 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: LifecycleStep, 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 0000000000..5bad6cea17 --- /dev/null +++ b/packages/app/src/cli/services/build/steps/copy-static-assets-step.ts @@ -0,0 +1,11 @@ +import type {LifecycleStep, 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: LifecycleStep, 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 0000000000..5634f76b86 --- /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 {LifecycleStep, 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: LifecycleStep, 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 0000000000..e2ed2d24e8 --- /dev/null +++ b/packages/app/src/cli/services/build/steps/include-assets-step.test.ts @@ -0,0 +1,606 @@ +import {executeIncludeAssetsStep} from './include_assets_step.js' +import {LifecycleStep, 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: LifecycleStep = { + 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: LifecycleStep = { + 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: LifecycleStep = { + 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: LifecycleStep = { + 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: LifecycleStep = { + 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: LifecycleStep = { + 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: LifecycleStep = { + id: 'copy-static', + name: 'Copy Static', + type: 'include_assets', + config: { + inclusions: [{type: 'configKey', key: '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: LifecycleStep = { + id: 'copy-static', + name: 'Copy Static', + type: 'include_assets', + config: { + inclusions: [{type: 'configKey', key: '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: LifecycleStep = { + id: 'copy-static', + name: 'Copy Static', + type: 'include_assets', + config: { + inclusions: [{type: 'configKey', key: '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: LifecycleStep = { + id: 'copy-static', + name: 'Copy Static', + type: 'include_assets', + config: { + inclusions: [{type: 'configKey', key: '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: LifecycleStep = { + id: 'copy-static', + name: 'Copy Static', + type: 'include_assets', + config: { + inclusions: [{type: 'configKey', key: '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('resolves nested configKey with [] flatten and collects all leaf values', async () => { + // Given — TOML array-of-tables: extensions[].targeting[].tools + const contextWithNestedConfig = { + ...mockContext, + extension: { + ...mockExtension, + configuration: { + extensions: [ + {targeting: [{tools: 'tools-a.js'}, {tools: 'tools-b.js'}]}, + {targeting: [{tools: 'tools-c.js'}]}, + ], + }, + } as unknown as ExtensionInstance, + } + + vi.mocked(fs.fileExists).mockResolvedValue(true) + vi.mocked(fs.copyDirectoryContents).mockResolvedValue() + vi.mocked(fs.glob).mockResolvedValue(['file.js']) + + const step: LifecycleStep = { + id: 'copy-tools', + name: 'Copy Tools', + type: 'include_assets', + config: { + inclusions: [{type: 'configKey', key: 'extensions[].targeting[].tools'}], + }, + } + + // When + await executeIncludeAssetsStep(step, contextWithNestedConfig) + + // Then — all three tools paths resolved and copied + expect(fs.copyDirectoryContents).toHaveBeenCalledWith('/test/extension/tools-a.js', '/test/output') + expect(fs.copyDirectoryContents).toHaveBeenCalledWith('/test/extension/tools-b.js', '/test/output') + expect(fs.copyDirectoryContents).toHaveBeenCalledWith('/test/extension/tools-c.js', '/test/output') + }) + + test('skips silently when [] flatten key resolves to a non-array', async () => { + // Given — targeting is a plain object, not an array + const contextWithBadConfig = { + ...mockContext, + extension: { + ...mockExtension, + configuration: {extensions: {targeting: {tools: 'tools.js'}}}, + } as unknown as ExtensionInstance, + } + + const step: LifecycleStep = { + id: 'copy-tools', + name: 'Copy Tools', + type: 'include_assets', + config: { + inclusions: [{type: 'configKey', key: 'extensions[].targeting[].tools'}], + }, + } + + // When + const result = await executeIncludeAssetsStep(step, contextWithBadConfig) + + // Then — contract violated, skipped silently + expect(result.filesCopied).toBe(0) + expect(fs.copyDirectoryContents).not.toHaveBeenCalled() + }) + + 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: LifecycleStep = { + id: 'copy-mixed', + name: 'Copy Mixed', + type: 'include_assets', + config: { + inclusions: [ + {type: 'configKey', key: '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: LifecycleStep = { + 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: LifecycleStep = { + 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: LifecycleStep = { + 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: LifecycleStep = { + 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: LifecycleStep = { + 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: LifecycleStep = { + 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: LifecycleStep = { + id: 'include-all', + name: 'Include All', + type: 'include_assets', + config: { + inclusions: [ + {type: 'pattern', baseDir: 'assets', include: ['**/*.png', '**/*.svg']}, + {type: 'configKey', key: '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 0000000000..2bf1125178 --- /dev/null +++ b/packages/app/src/cli/services/build/steps/include_assets_step.ts @@ -0,0 +1,318 @@ +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 {LifecycleStep, 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: LifecycleStep, + 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} +} + +/** + * Splits a path into tokens. A token with `flatten: true` (the `[]` suffix) + * signals that an array is expected at that position and the result should be + * flattened one level before continuing. Plain tokens preserve whatever shape + * is already in flight — if the current value is already an array (from a + * prior flatten), the field is plucked from each element automatically. + * + * Examples: + * "tools" → [name:"tools", flatten:false] + * "targeting.tools" → [name:"targeting",...], [name:"tools",...] + * "extensions[].targeting[].schema" → [name:"extensions", flatten:true], ... + */ +function tokenizePath(path: string): {name: string; flatten: boolean}[] { + return path.split('.').map((part) => { + const flatten = part.endsWith('[]') + return {name: flatten ? part.slice(0, -2) : part, flatten} + }) +} + +/** + * Resolves a dot-separated path (with optional `[]` flatten markers) from a + * config object. + * + * - Plain segments (`targeting.tools`): dot-notation access; when the current + * value is already an array (due to a prior flatten), the field is plucked + * from every element automatically. + * - Flatten segments (`extensions[]`): access the field and flatten one level + * of nesting. Returns `undefined` if the value at that point is not an array + * — the `[]` suffix is a contract that an array is expected there. + */ +function getNestedValue(obj: {[key: string]: unknown}, path: string): unknown { + let current: unknown = obj + + for (const {name, flatten} of tokenizePath(path)) { + if (current === null || current === undefined) return undefined + + if (Array.isArray(current)) { + const plucked = current + .map((item) => + item !== null && typeof item === 'object' ? (item as {[key: string]: unknown})[name] : undefined, + ) + .filter((val): val is NonNullable => val !== undefined) + current = plucked.length > 0 ? plucked : undefined + } else if (typeof current === 'object') { + current = (current as {[key: string]: unknown})[name] + } else { + return undefined + } + + if (flatten) { + if (!Array.isArray(current)) return undefined + current = (current as unknown[]).flat(1) + } + } + + return current +} diff --git a/packages/app/src/cli/services/build/steps/index.ts b/packages/app/src/cli/services/build/steps/index.ts index 436bc0b227..d1d773191f 100644 --- a/packages/app/src/cli/services/build/steps/index.ts +++ b/packages/app/src/cli/services/build/steps/index.ts @@ -1,3 +1,10 @@ +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 {LifecycleStep, BuildContext} from '../client-steps.js' /** @@ -9,16 +16,30 @@ import type {LifecycleStep, BuildContext} from '../client-steps.js' * @returns The output from the step execution * @throws Error if the step type is not implemented or unknown */ -export async function executeStepByType(step: LifecycleStep, _context: BuildContext): Promise { +export async function executeStepByType(step: LifecycleStep, context: BuildContext): Promise { switch (step.type) { - // Future step types (not implemented yet): 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':