diff --git a/packages/app/src/cli/models/extensions/extension-instance.ts b/packages/app/src/cli/models/extensions/extension-instance.ts index ebc742ad74..05371c5d53 100644 --- a/packages/app/src/cli/models/extensions/extension-instance.ts +++ b/packages/app/src/cli/models/extensions/extension-instance.ts @@ -46,7 +46,6 @@ export const CONFIG_EXTENSION_IDS: string[] = [ WebhookSubscriptionSpecIdentifier, WebhooksSpecIdentifier, EventsSpecIdentifier, - // Hardcoded identifiers that don't exist locally. 'data', '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..1c166f0a1f --- /dev/null +++ b/packages/app/src/cli/services/build/steps/include-assets-step.test.ts @@ -0,0 +1,665 @@ +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 and preserveStructure is false', async () => { + // Given + vi.mocked(fs.fileExists).mockResolvedValue(true) + vi.mocked(fs.isDirectory).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: false}], + }, + } + + // 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.isDirectory).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.isDirectory).mockResolvedValueOnce(true).mockResolvedValueOnce(false) + 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/dist') + expect(fs.copyFile).toHaveBeenCalledWith('/test/extension/src/icon.png', '/test/output/assets/icon.png') + expect(result.filesCopied).toBe(2) + }) + + test('copies a file to output root when source is a file and no destination is given', async () => { + // Given + vi.mocked(fs.fileExists).mockResolvedValue(true) + vi.mocked(fs.isDirectory).mockResolvedValue(false) + vi.mocked(fs.copyFile).mockResolvedValue() + vi.mocked(fs.mkdir).mockResolvedValue() + + const step: LifecycleStep = { + id: 'copy-readme', + name: 'Copy README', + type: 'include_assets', + config: { + inclusions: [{type: 'static', source: 'README.md'}], + }, + } + + // When + const result = await executeIncludeAssetsStep(step, mockContext) + + // Then + expect(fs.copyFile).toHaveBeenCalledWith('/test/extension/README.md', '/test/output/README.md') + expect(result.filesCopied).toBe(1) + expect(mockStdout.write).toHaveBeenCalledWith(expect.stringContaining('Copied README.md to README.md')) + }) + + test('copies a directory to explicit destination path', async () => { + // Given + vi.mocked(fs.fileExists).mockResolvedValue(true) + vi.mocked(fs.isDirectory).mockResolvedValue(true) + vi.mocked(fs.copyDirectoryContents).mockResolvedValue() + vi.mocked(fs.glob).mockResolvedValue(['a.js', 'b.js']) + + const step: LifecycleStep = { + id: 'copy-dist', + name: 'Copy Dist', + type: 'include_assets', + config: { + inclusions: [{type: 'static', source: 'dist', destination: 'assets/dist'}], + }, + } + + // When + const result = await executeIncludeAssetsStep(step, mockContext) + + // Then + expect(fs.copyDirectoryContents).toHaveBeenCalledWith('/test/extension/dist', '/test/output/assets/dist') + expect(result.filesCopied).toBe(2) + expect(mockStdout.write).toHaveBeenCalledWith(expect.stringContaining('Copied dist to assets/dist')) + }) + }) + + 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.isDirectory).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.isDirectory).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.isDirectory).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.isDirectory).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.isDirectory).mockImplementation((path) => Promise.resolve(!String(path).endsWith('.png'))) + 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.isDirectory).mockImplementation((path) => Promise.resolve(!String(path).endsWith('.json'))) + 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..2703be0cb5 --- /dev/null +++ b/packages/app/src/cli/services/build/steps/include-assets-step.ts @@ -0,0 +1,138 @@ +import {copyByPattern} from './include-assets/copy-by-pattern.js' +import {copySourceEntry} from './include-assets/copy-source-entry.js' +import {copyConfigKeyEntry} from './include-assets/copy-config-key-entry.js' +import {joinPath, dirname, extname, sanitizeRelativePath} from '@shopify/cli-kit/node/path' +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(true), +}) + +/** + * 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 + * (`'static'`, `'configKey'`, 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: 'static'` — copy a file or directory into the output. + * - `type: 'configKey'` — resolve a path from the extension's + * config and copy 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) => { + const warn = (msg: string) => options.stdout.write(msg) + const sanitizedDest = entry.destination !== undefined ? sanitizeRelativePath(entry.destination, warn) : undefined + + if (entry.type === 'pattern') { + const sourceDir = entry.baseDir ? joinPath(extension.directory, entry.baseDir) : extension.directory + const destinationDir = sanitizedDest ? joinPath(outputDir, sanitizedDest) : outputDir + return copyByPattern( + { + sourceDir, + outputDir: destinationDir, + patterns: entry.include, + ignore: entry.ignore ?? [], + preserveStructure: entry.preserveStructure, + }, + options, + ) + } + + if (entry.type === 'configKey') { + return copyConfigKeyEntry( + { + key: entry.key, + baseDir: extension.directory, + outputDir, + context, + preserveStructure: entry.preserveStructure, + destination: sanitizedDest, + }, + options, + ) + } + + if (entry.type === 'static') { + return copySourceEntry( + { + source: entry.source, + destination: sanitizedDest, + baseDir: extension.directory, + outputDir, + preserveStructure: entry.preserveStructure, + }, + options, + ) + } + }), + ) + + return {filesCopied: counts.reduce((sum, count) => sum + (count ?? 0), 0)} +} diff --git a/packages/app/src/cli/services/build/steps/include-assets/copy-by-pattern.test.ts b/packages/app/src/cli/services/build/steps/include-assets/copy-by-pattern.test.ts new file mode 100644 index 0000000000..9e4cb821b3 --- /dev/null +++ b/packages/app/src/cli/services/build/steps/include-assets/copy-by-pattern.test.ts @@ -0,0 +1,204 @@ +import {copyByPattern} from './copy-by-pattern.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('copyByPattern', () => { + let mockStdout: any + + beforeEach(() => { + mockStdout = {write: vi.fn()} + }) + + test('copies matched files preserving relative paths when preserveStructure is true', async () => { + // Given + vi.mocked(fs.glob).mockResolvedValue(['/src/components/Button.tsx', '/src/utils/helpers.ts']) + vi.mocked(fs.mkdir).mockResolvedValue() + vi.mocked(fs.copyFile).mockResolvedValue() + + // When + const result = await copyByPattern( + { + sourceDir: '/src', + outputDir: '/out', + patterns: ['**/*.ts', '**/*.tsx'], + ignore: [], + preserveStructure: true, + }, + {stdout: mockStdout}, + ) + + // Then + expect(fs.copyFile).toHaveBeenCalledWith('/src/components/Button.tsx', '/out/components/Button.tsx') + expect(fs.copyFile).toHaveBeenCalledWith('/src/utils/helpers.ts', '/out/utils/helpers.ts') + expect(result).toBe(2) + expect(mockStdout.write).toHaveBeenCalledWith(expect.stringContaining('Copied 2 file(s)')) + }) + + test('flattens files to basename when preserveStructure is false', async () => { + // Given + vi.mocked(fs.glob).mockResolvedValue(['/src/components/Button.tsx', '/src/utils/helper.ts']) + vi.mocked(fs.mkdir).mockResolvedValue() + vi.mocked(fs.copyFile).mockResolvedValue() + + // When + const result = await copyByPattern( + { + sourceDir: '/src', + outputDir: '/out', + patterns: ['**/*'], + ignore: [], + preserveStructure: false, + }, + {stdout: mockStdout}, + ) + + // Then + expect(fs.copyFile).toHaveBeenCalledWith('/src/components/Button.tsx', '/out/Button.tsx') + expect(fs.copyFile).toHaveBeenCalledWith('/src/utils/helper.ts', '/out/helper.ts') + expect(result).toBe(2) + }) + + test('warns and lets last-in-array win when flattening produces basename collision', async () => { + // Given — two files with the same basename in different directories + vi.mocked(fs.glob).mockResolvedValue(['/src/a/index.js', '/src/b/index.js']) + vi.mocked(fs.mkdir).mockResolvedValue() + vi.mocked(fs.copyFile).mockResolvedValue() + + // When + const result = await copyByPattern( + { + sourceDir: '/src', + outputDir: '/out', + patterns: ['**/index.js'], + ignore: [], + preserveStructure: false, + }, + {stdout: mockStdout}, + ) + + // Then — collision warning emitted, only the last one is copied + expect(mockStdout.write).toHaveBeenCalledWith(expect.stringContaining('filename collision detected')) + expect(fs.copyFile).toHaveBeenCalledTimes(1) + expect(fs.copyFile).toHaveBeenCalledWith('/src/b/index.js', '/out/index.js') + expect(result).toBe(1) + }) + + test('warns and returns 0 when no files match patterns', async () => { + // Given + vi.mocked(fs.glob).mockResolvedValue([]) + + // When + const result = await copyByPattern( + { + sourceDir: '/src', + outputDir: '/out', + patterns: ['**/*.png'], + ignore: [], + preserveStructure: true, + }, + {stdout: mockStdout}, + ) + + // Then + expect(result).toBe(0) + expect(fs.mkdir).not.toHaveBeenCalled() + expect(fs.copyFile).not.toHaveBeenCalled() + expect(mockStdout.write).toHaveBeenCalledWith(expect.stringContaining('No files matched patterns')) + }) + + test('skips file and warns when resolved destination escapes the output directory', async () => { + // Given — sourceDir is /out/sub, so a file from /out/sub/../../evil resolves outside /out/sub + // Simulate by providing a glob result whose relative path traverses upward + vi.mocked(fs.glob).mockResolvedValue(['/out/sub/../../evil.js']) + vi.mocked(fs.mkdir).mockResolvedValue() + vi.mocked(fs.copyFile).mockResolvedValue() + + // When + const result = await copyByPattern( + { + sourceDir: '/out/sub', + outputDir: '/out/sub', + patterns: ['**/*'], + ignore: [], + preserveStructure: true, + }, + {stdout: mockStdout}, + ) + + // Then + expect(mockStdout.write).toHaveBeenCalledWith(expect.stringContaining('skipping')) + expect(fs.copyFile).not.toHaveBeenCalled() + expect(result).toBe(0) + }) + + test('returns 0 without copying when filepath equals computed destPath', async () => { + // Given — file already lives at the exact destination path + vi.mocked(fs.glob).mockResolvedValue(['/out/logo.png']) + vi.mocked(fs.mkdir).mockResolvedValue() + vi.mocked(fs.copyFile).mockResolvedValue() + + // When — sourceDir is /out so basename resolves to /out/logo.png == filepath + const result = await copyByPattern( + { + sourceDir: '/out', + outputDir: '/out', + patterns: ['*.png'], + ignore: [], + preserveStructure: false, + }, + {stdout: mockStdout}, + ) + + // Then + expect(fs.copyFile).not.toHaveBeenCalled() + expect(result).toBe(0) + expect(mockStdout.write).toHaveBeenCalledWith(expect.stringContaining('Copied 0 file(s)')) + }) + + test('calls mkdir(outputDir) before copying when files are matched', async () => { + // Given + vi.mocked(fs.glob).mockResolvedValue(['/src/app.js']) + vi.mocked(fs.mkdir).mockResolvedValue() + vi.mocked(fs.copyFile).mockResolvedValue() + + // When + await copyByPattern( + { + sourceDir: '/src', + outputDir: '/out/dist', + patterns: ['*.js'], + ignore: [], + preserveStructure: false, + }, + {stdout: mockStdout}, + ) + + // Then — outputDir created before copying + expect(fs.mkdir).toHaveBeenCalledWith('/out/dist') + }) + + test('passes ignore patterns to glob', async () => { + // Given + vi.mocked(fs.glob).mockResolvedValue([]) + + // When + await copyByPattern( + { + sourceDir: '/src', + outputDir: '/out', + patterns: ['**/*'], + ignore: ['**/*.test.ts', 'node_modules/**'], + preserveStructure: true, + }, + {stdout: mockStdout}, + ) + + // Then + expect(fs.glob).toHaveBeenCalledWith( + ['**/*'], + expect.objectContaining({ignore: ['**/*.test.ts', 'node_modules/**']}), + ) + }) +}) diff --git a/packages/app/src/cli/services/build/steps/include-assets/copy-by-pattern.ts b/packages/app/src/cli/services/build/steps/include-assets/copy-by-pattern.ts new file mode 100644 index 0000000000..b0110d6aa5 --- /dev/null +++ b/packages/app/src/cli/services/build/steps/include-assets/copy-by-pattern.ts @@ -0,0 +1,82 @@ +import {joinPath, dirname, relativePath, basename} from '@shopify/cli-kit/node/path' +import {glob, copyFile, mkdir} from '@shopify/cli-kit/node/fs' + +/** + * Pattern strategy: glob-based file selection. + */ +export async function copyByPattern( + config: { + sourceDir: string + outputDir: string + patterns: string[] + ignore: string[] + preserveStructure: boolean + }, + options: {stdout: NodeJS.WritableStream}, +): Promise { + const {sourceDir, outputDir, patterns, ignore, preserveStructure} = config + 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 0 + } + + await mkdir(outputDir) + + const duplicates = new Set() + if (!preserveStructure) { + const basenames = files.map((fp) => basename(fp)) + const seen = new Set() + for (const name of basenames) { + if (seen.has(name)) { + duplicates.add(name) + } else { + seen.add(name) + } + } + if (duplicates.size > 0) { + const colliding = files.filter((fp) => duplicates.has(basename(fp))) + options.stdout.write( + `Warning: filename collision detected when flattening — the following files share a basename and will overwrite each other: ${colliding.join(', ')}\n`, + ) + } + } + + // When flattening and collisions exist, deduplicate so last-in-array deterministically wins + const filesToCopy = + !preserveStructure && duplicates.size > 0 + ? files.filter((fp, idx) => { + const name = basename(fp) + if (!duplicates.has(name)) return true + const lastIdx = files.reduce((last, file, ii) => (basename(file) === name ? ii : last), -1) + return lastIdx === idx + }) + : files + + const copyResults = await Promise.all( + filesToCopy.map(async (filepath): Promise => { + const relPath = preserveStructure ? relativePath(sourceDir, filepath) : basename(filepath) + const destPath = joinPath(outputDir, relPath) + + if (relativePath(outputDir, destPath).startsWith('..')) { + options.stdout.write(`Warning: skipping '${filepath}' - resolved destination is outside the output directory\n`) + return 0 + } + + if (filepath === destPath) return 0 + + await mkdir(dirname(destPath)) + await copyFile(filepath, destPath) + return 1 + }), + ) + + const copiedCount = copyResults.reduce((sum, count) => sum + count, 0) + options.stdout.write(`Copied ${copiedCount} file(s) from ${sourceDir} to ${outputDir}\n`) + return copiedCount +} diff --git a/packages/app/src/cli/services/build/steps/include-assets/copy-config-key-entry.test.ts b/packages/app/src/cli/services/build/steps/include-assets/copy-config-key-entry.test.ts new file mode 100644 index 0000000000..0850be617c --- /dev/null +++ b/packages/app/src/cli/services/build/steps/include-assets/copy-config-key-entry.test.ts @@ -0,0 +1,205 @@ +import {copyConfigKeyEntry} from './copy-config-key-entry.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') + +const makeContext = (configuration: Record) => ({ + extension: {configuration} as any, + options: {} as any, + stepResults: new Map(), +}) + +describe('copyConfigKeyEntry', () => { + let mockStdout: any + + beforeEach(() => { + mockStdout = {write: vi.fn()} + }) + + test('merges directory contents into output root by default (preserveStructure false)', async () => { + // Given + const context = makeContext({static_root: 'public'}) + vi.mocked(fs.fileExists).mockResolvedValue(true) + vi.mocked(fs.isDirectory).mockResolvedValue(true) + vi.mocked(fs.copyDirectoryContents).mockResolvedValue() + vi.mocked(fs.glob).mockResolvedValue(['index.html', 'logo.png']) + + // When + const result = await copyConfigKeyEntry( + {key: 'static_root', baseDir: '/ext', outputDir: '/out', context, preserveStructure: false}, + {stdout: mockStdout}, + ) + + // Then + expect(fs.copyDirectoryContents).toHaveBeenCalledWith('/ext/public', '/out') + expect(result).toBe(2) + expect(mockStdout.write).toHaveBeenCalledWith(expect.stringContaining('Copied contents of')) + }) + + test('places directory under its own name when preserveStructure is true', async () => { + // Given + const context = makeContext({theme_root: 'theme'}) + vi.mocked(fs.fileExists).mockResolvedValue(true) + vi.mocked(fs.isDirectory).mockResolvedValue(true) + vi.mocked(fs.copyDirectoryContents).mockResolvedValue() + vi.mocked(fs.glob).mockResolvedValue(['style.css', 'layout.liquid']) + + // When + const result = await copyConfigKeyEntry( + {key: 'theme_root', baseDir: '/ext', outputDir: '/out', context, preserveStructure: true}, + {stdout: mockStdout}, + ) + + // Then + expect(fs.copyDirectoryContents).toHaveBeenCalledWith('/ext/theme', '/out/theme') + expect(result).toBe(2) + expect(mockStdout.write).toHaveBeenCalledWith(expect.stringContaining("Copied 'theme' to theme")) + }) + + test('copies a file source to outputDir/basename', async () => { + // Given + const context = makeContext({schema_path: 'src/schema.json'}) + vi.mocked(fs.fileExists).mockResolvedValue(true) + vi.mocked(fs.isDirectory).mockResolvedValue(false) + vi.mocked(fs.mkdir).mockResolvedValue() + vi.mocked(fs.copyFile).mockResolvedValue() + + // When + const result = await copyConfigKeyEntry( + {key: 'schema_path', baseDir: '/ext', outputDir: '/out', context, preserveStructure: false}, + {stdout: mockStdout}, + ) + + // Then + expect(fs.copyFile).toHaveBeenCalledWith('/ext/src/schema.json', '/out/schema.json') + expect(result).toBe(1) + expect(mockStdout.write).toHaveBeenCalledWith(expect.stringContaining("Copied 'src/schema.json' to schema.json")) + }) + + test('skips with log message when configKey is absent from configuration', async () => { + // Given + const context = makeContext({}) + + // When + const result = await copyConfigKeyEntry( + {key: 'static_root', baseDir: '/ext', outputDir: '/out', context, preserveStructure: false}, + {stdout: mockStdout}, + ) + + // Then + expect(result).toBe(0) + expect(fs.fileExists).not.toHaveBeenCalled() + expect(mockStdout.write).toHaveBeenCalledWith("No value for configKey 'static_root', skipping\n") + }) + + test('skips with warning when path resolved from config does not exist on disk', async () => { + // Given + const context = makeContext({assets_dir: 'nonexistent'}) + vi.mocked(fs.fileExists).mockResolvedValue(false) + + // When + const result = await copyConfigKeyEntry( + {key: 'assets_dir', baseDir: '/ext', outputDir: '/out', context, preserveStructure: false}, + {stdout: mockStdout}, + ) + + // Then + expect(result).toBe(0) + expect(fs.copyDirectoryContents).not.toHaveBeenCalled() + expect(mockStdout.write).toHaveBeenCalledWith(expect.stringContaining("Warning: path 'nonexistent' does not exist")) + }) + + test('resolves array config value and copies each path, summing results', async () => { + // Given + const context = makeContext({roots: ['public', 'assets']}) + vi.mocked(fs.fileExists).mockResolvedValue(true) + vi.mocked(fs.isDirectory).mockResolvedValue(true) + vi.mocked(fs.copyDirectoryContents).mockResolvedValue() + // Each directory listing returns 2 files + vi.mocked(fs.glob).mockResolvedValue(['a.html', 'b.html']) + + // When + const result = await copyConfigKeyEntry( + {key: 'roots', baseDir: '/ext', outputDir: '/out', context, preserveStructure: false}, + {stdout: mockStdout}, + ) + + // Then + expect(fs.copyDirectoryContents).toHaveBeenCalledWith('/ext/public', '/out') + expect(fs.copyDirectoryContents).toHaveBeenCalledWith('/ext/assets', '/out') + expect(result).toBe(4) + }) + + test('prefixes outputDir with destination when destination param is provided', async () => { + // Given + const context = makeContext({icons_dir: 'icons'}) + vi.mocked(fs.fileExists).mockResolvedValue(true) + vi.mocked(fs.isDirectory).mockResolvedValue(true) + vi.mocked(fs.copyDirectoryContents).mockResolvedValue() + vi.mocked(fs.glob).mockResolvedValue(['icon.svg']) + + // When + await copyConfigKeyEntry( + { + key: 'icons_dir', + baseDir: '/ext', + outputDir: '/out', + context, + preserveStructure: false, + destination: 'static/icons', + }, + {stdout: mockStdout}, + ) + + // Then — effectiveOutputDir is /out/static/icons + expect(fs.copyDirectoryContents).toHaveBeenCalledWith('/ext/icons', '/out/static/icons') + }) + + test('resolves nested [] flatten path and collects all leaf values', async () => { + // Given — extensions[].targeting[].schema pattern + const context = makeContext({ + extensions: [ + {targeting: [{schema: 'schema-a.json'}, {schema: 'schema-b.json'}]}, + {targeting: [{schema: 'schema-c.json'}]}, + ], + }) + vi.mocked(fs.fileExists).mockResolvedValue(true) + vi.mocked(fs.isDirectory).mockResolvedValue(false) + vi.mocked(fs.mkdir).mockResolvedValue() + vi.mocked(fs.copyFile).mockResolvedValue() + + // When + const result = await copyConfigKeyEntry( + {key: 'extensions[].targeting[].schema', baseDir: '/ext', outputDir: '/out', context, preserveStructure: false}, + {stdout: mockStdout}, + ) + + // Then — all three schemas copied + expect(fs.copyFile).toHaveBeenCalledWith('/ext/schema-a.json', '/out/schema-a.json') + expect(fs.copyFile).toHaveBeenCalledWith('/ext/schema-b.json', '/out/schema-b.json') + expect(fs.copyFile).toHaveBeenCalledWith('/ext/schema-c.json', '/out/schema-c.json') + expect(result).toBe(3) + }) + + test('skips with no-value log when [] flatten resolves to a non-array (contract violated)', async () => { + // Given — extensions is a plain object, not an array, so [] contract fails + const context = makeContext({ + extensions: {targeting: {schema: 'schema.json'}}, + }) + + // When + const result = await copyConfigKeyEntry( + {key: 'extensions[].targeting[].schema', baseDir: '/ext', outputDir: '/out', context, preserveStructure: false}, + {stdout: mockStdout}, + ) + + // Then — getNestedValue returns undefined, treated as absent key + expect(result).toBe(0) + expect(fs.copyDirectoryContents).not.toHaveBeenCalled() + expect(fs.copyFile).not.toHaveBeenCalled() + expect(mockStdout.write).toHaveBeenCalledWith( + expect.stringContaining("No value for configKey 'extensions[].targeting[].schema'"), + ) + }) +}) diff --git a/packages/app/src/cli/services/build/steps/include-assets/copy-config-key-entry.ts b/packages/app/src/cli/services/build/steps/include-assets/copy-config-key-entry.ts new file mode 100644 index 0000000000..41880a179c --- /dev/null +++ b/packages/app/src/cli/services/build/steps/include-assets/copy-config-key-entry.ts @@ -0,0 +1,126 @@ +import {joinPath, basename} from '@shopify/cli-kit/node/path' +import {glob, copyFile, copyDirectoryContents, fileExists, mkdir, isDirectory} from '@shopify/cli-kit/node/fs' +import type {BuildContext} from '../../client-steps.js' + +/** + * 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`. + */ +export async function copyConfigKeyEntry( + config: { + key: string + baseDir: string + outputDir: string + context: BuildContext + preserveStructure: boolean + destination?: string + }, + options: {stdout: NodeJS.WritableStream}, +): Promise { + const {key, baseDir, outputDir, context, preserveStructure, destination} = config + 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 + } + if (!(await isDirectory(fullPath))) { + const destPath = joinPath(effectiveOutputDir, basename(fullPath)) + await mkdir(effectiveOutputDir) + await copyFile(fullPath, destPath) + options.stdout.write(`Copied '${sourcePath}' to ${basename(fullPath)}\n`) + return 1 + } + 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) +} + +/** + * 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/include-assets/copy-source-entry.test.ts b/packages/app/src/cli/services/build/steps/include-assets/copy-source-entry.test.ts new file mode 100644 index 0000000000..3164f5bbf7 --- /dev/null +++ b/packages/app/src/cli/services/build/steps/include-assets/copy-source-entry.test.ts @@ -0,0 +1,174 @@ +import {copySourceEntry} from './copy-source-entry.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('copySourceEntry', () => { + let mockStdout: any + + beforeEach(() => { + mockStdout = {write: vi.fn()} + }) + + test('throws when source path does not exist', async () => { + // Given + vi.mocked(fs.fileExists).mockResolvedValue(false) + + // When / Then + await expect( + copySourceEntry( + { + source: 'missing/file.js', + destination: undefined, + baseDir: '/ext', + outputDir: '/out', + preserveStructure: false, + }, + {stdout: mockStdout}, + ), + ).rejects.toThrow('Source does not exist: /ext/missing/file.js') + }) + + test('copies file to explicit destination path (preserveStructure ignored)', async () => { + // Given + vi.mocked(fs.fileExists).mockResolvedValue(true) + vi.mocked(fs.isDirectory).mockResolvedValue(false) + vi.mocked(fs.mkdir).mockResolvedValue() + vi.mocked(fs.copyFile).mockResolvedValue() + + // When + const result = await copySourceEntry( + { + source: 'src/icon.png', + destination: 'assets/icon.png', + baseDir: '/ext', + outputDir: '/out', + preserveStructure: false, + }, + {stdout: mockStdout}, + ) + + // Then + expect(fs.copyFile).toHaveBeenCalledWith('/ext/src/icon.png', '/out/assets/icon.png') + expect(result).toBe(1) + expect(mockStdout.write).toHaveBeenCalledWith('Copied src/icon.png to assets/icon.png\n') + }) + + test('copies directory under its own name when no destination and preserveStructure is true', async () => { + // Given + vi.mocked(fs.fileExists).mockResolvedValue(true) + vi.mocked(fs.isDirectory).mockResolvedValue(true) + vi.mocked(fs.copyDirectoryContents).mockResolvedValue() + vi.mocked(fs.glob).mockResolvedValue(['index.html', 'logo.png']) + + // When + const result = await copySourceEntry( + {source: 'dist', destination: undefined, baseDir: '/ext', outputDir: '/out', preserveStructure: true}, + {stdout: mockStdout}, + ) + + // Then + expect(fs.copyDirectoryContents).toHaveBeenCalledWith('/ext/dist', '/out/dist') + expect(result).toBe(2) + expect(mockStdout.write).toHaveBeenCalledWith('Copied dist to dist\n') + }) + + test('merges directory contents into output root when no destination and preserveStructure is false', async () => { + // Given + vi.mocked(fs.fileExists).mockResolvedValue(true) + vi.mocked(fs.isDirectory).mockResolvedValue(true) + vi.mocked(fs.copyDirectoryContents).mockResolvedValue() + vi.mocked(fs.glob).mockResolvedValue(['a.js', 'b.js', 'c.js']) + + // When + const result = await copySourceEntry( + {source: 'build', destination: undefined, baseDir: '/ext', outputDir: '/out', preserveStructure: false}, + {stdout: mockStdout}, + ) + + // Then + expect(fs.copyDirectoryContents).toHaveBeenCalledWith('/ext/build', '/out') + expect(result).toBe(3) + expect(mockStdout.write).toHaveBeenCalledWith('Copied contents of build to output root\n') + }) + + test('copies file to basename in outputDir when source is a file and no destination given', async () => { + // Given + vi.mocked(fs.fileExists).mockResolvedValue(true) + vi.mocked(fs.isDirectory).mockResolvedValue(false) + vi.mocked(fs.mkdir).mockResolvedValue() + vi.mocked(fs.copyFile).mockResolvedValue() + + // When + const result = await copySourceEntry( + {source: 'README.md', destination: undefined, baseDir: '/ext', outputDir: '/out', preserveStructure: true}, + {stdout: mockStdout}, + ) + + // Then + expect(fs.copyFile).toHaveBeenCalledWith('/ext/README.md', '/out/README.md') + expect(result).toBe(1) + expect(mockStdout.write).toHaveBeenCalledWith('Copied README.md to README.md\n') + }) + + test('copies directory to explicit destination path, ignoring preserveStructure', async () => { + // Given + vi.mocked(fs.fileExists).mockResolvedValue(true) + vi.mocked(fs.isDirectory).mockResolvedValue(true) + vi.mocked(fs.copyDirectoryContents).mockResolvedValue() + vi.mocked(fs.glob).mockResolvedValue(['x.js']) + + // When + const result = await copySourceEntry( + {source: 'dist', destination: 'vendor/dist', baseDir: '/ext', outputDir: '/out', preserveStructure: false}, + {stdout: mockStdout}, + ) + + // Then + expect(fs.copyDirectoryContents).toHaveBeenCalledWith('/ext/dist', '/out/vendor/dist') + expect(result).toBe(1) + expect(mockStdout.write).toHaveBeenCalledWith('Copied dist to vendor/dist\n') + }) + + test('returns count of files discovered in destination directory after directory copy', async () => { + // Given + vi.mocked(fs.fileExists).mockResolvedValue(true) + vi.mocked(fs.isDirectory).mockResolvedValue(true) + vi.mocked(fs.copyDirectoryContents).mockResolvedValue() + // Simulate 5 files inside the copied directory + vi.mocked(fs.glob).mockResolvedValue(['a.js', 'b.js', 'c.js', 'd.js', 'e.js']) + + // When + const result = await copySourceEntry( + {source: 'theme', destination: undefined, baseDir: '/ext', outputDir: '/out', preserveStructure: false}, + {stdout: mockStdout}, + ) + + // Then — count comes from glob on destPath, not a constant + expect(result).toBe(5) + }) + + test('creates parent directories before copying a file', async () => { + // Given + vi.mocked(fs.fileExists).mockResolvedValue(true) + vi.mocked(fs.isDirectory).mockResolvedValue(false) + vi.mocked(fs.mkdir).mockResolvedValue() + vi.mocked(fs.copyFile).mockResolvedValue() + + // When + await copySourceEntry( + { + source: 'src/deep/icon.png', + destination: 'assets/icons/icon.png', + baseDir: '/ext', + outputDir: '/out', + preserveStructure: false, + }, + {stdout: mockStdout}, + ) + + // Then — parent of destination path created + expect(fs.mkdir).toHaveBeenCalledWith('/out/assets/icons') + }) +}) diff --git a/packages/app/src/cli/services/build/steps/include-assets/copy-source-entry.ts b/packages/app/src/cli/services/build/steps/include-assets/copy-source-entry.ts new file mode 100644 index 0000000000..5514cd564e --- /dev/null +++ b/packages/app/src/cli/services/build/steps/include-assets/copy-source-entry.ts @@ -0,0 +1,57 @@ +import {joinPath, dirname, basename} from '@shopify/cli-kit/node/path' +import {glob, copyFile, copyDirectoryContents, fileExists, mkdir, isDirectory} from '@shopify/cli-kit/node/fs' + +/** + * 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 to that exact path (`preserveStructure` is ignored). + */ +export async function copySourceEntry( + config: { + source: string + destination: string | undefined + baseDir: string + outputDir: string + preserveStructure: boolean + }, + options: {stdout: NodeJS.WritableStream}, +): Promise { + const {source, destination, baseDir, outputDir, preserveStructure} = config + const sourcePath = joinPath(baseDir, source) + if (!(await fileExists(sourcePath))) { + throw new Error(`Source does not exist: ${sourcePath}`) + } + + const sourceIsDir = await isDirectory(sourcePath) + + // Resolve destination path and log message up front, then dispatch on file vs directory. + let destPath: string + let logMsg: string + if (destination !== undefined) { + destPath = joinPath(outputDir, destination) + logMsg = `Copied ${source} to ${destination}\n` + } else if (sourceIsDir && preserveStructure) { + destPath = joinPath(outputDir, basename(sourcePath)) + logMsg = `Copied ${source} to ${basename(sourcePath)}\n` + } else if (sourceIsDir) { + destPath = outputDir + logMsg = `Copied contents of ${source} to output root\n` + } else { + destPath = joinPath(outputDir, basename(sourcePath)) + logMsg = `Copied ${source} to ${basename(sourcePath)}\n` + } + + if (sourceIsDir) { + await copyDirectoryContents(sourcePath, destPath) + const copied = await glob(['**/*'], {cwd: destPath, absolute: false}) + options.stdout.write(logMsg) + return copied.length + } + + await mkdir(dirname(destPath)) + await copyFile(sourcePath, destPath) + options.stdout.write(logMsg) + return 1 +} diff --git a/packages/app/src/cli/services/build/steps/index.ts b/packages/app/src/cli/services/build/steps/index.ts index 846a4d4147..6ca10e6e04 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,18 +16,28 @@ 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': - case 'create_tax_stub': - throw new Error(`Build step type "${step.type}" is not yet implemented.`) + return executeBuildFunctionStep(step, context) + case 'create_tax_stub': + return executeCreateTaxStubStep(step, context) default: throw new Error(`Unknown build step type: ${(step as {type: string}).type}`) } diff --git a/packages/cli-kit/src/public/node/path.ts b/packages/cli-kit/src/public/node/path.ts index a234622f4f..b1691d637e 100644 --- a/packages/cli-kit/src/public/node/path.ts +++ b/packages/cli-kit/src/public/node/path.ts @@ -191,3 +191,32 @@ export function sniffForPath(argv = process.argv): string | undefined { export function sniffForJson(argv = process.argv): boolean { return argv.includes('--json') || argv.includes('-j') } + +/** + * Removes any `..` traversal segments from a relative path and calls `warn` + * if any were stripped. Normal `..` that cancel out within the path (e.g. + * `foo/../bar` → `bar`) are collapsed but never allowed to escape the root. + * Both `/` and `\` are treated as separators for cross-platform safety. + * + * @param input - The relative path to sanitize. + * @param warn - Called with a human-readable warning when traversal segments are removed. + * @returns The sanitized path (may be an empty string if all segments were traversal). + */ +export function sanitizeRelativePath(input: string, warn: (msg: string) => void): string { + const segments = input.replace(/\\/g, '/').split('/') + const stack: string[] = [] + let stripped = false + for (const seg of segments) { + if (seg === '..') { + stripped = true + stack.pop() + } else if (seg !== '.') { + stack.push(seg) + } + } + const result = stack.join('/') + if (stripped) { + warn(`Warning: path '${input}' contains '..' traversal — sanitized to '${result || '.'}'\n`) + } + return result +}