Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -358,14 +358,14 @@ export class ExtensionInstance<TConfiguration extends BaseConfigType = BaseConfi
this.specification.buildConfig.filePatterns,
this.specification.buildConfig.ignoredFilePatterns,
)
case 'hosted_app_home':
case 'none':
break
}
}

async buildForBundle(options: ExtensionBuildOptions, bundleDirectory: string, outputId?: string) {
this.outputPath = this.getOutputPathForDirectory(bundleDirectory, outputId)

await this.build(options)

const bundleInputPath = joinPath(bundleDirectory, this.getOutputFolderId(outputId))
Expand Down
16 changes: 14 additions & 2 deletions packages/app/src/cli/models/extensions/specification.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import {ZodSchemaType, BaseConfigType, BaseSchema} from './schemas.js'
import {ExtensionInstance} from './extension-instance.js'
import {blocks} from '../../constants.js'
import {ClientSteps} from '../../services/build/client-steps.js'

import {Flag} from '../../utilities/developer-platform-client.js'
import {AppConfiguration} from '../app/app.js'
Expand Down Expand Up @@ -54,8 +55,9 @@ export interface BuildAsset {
}

type BuildConfig =
| {mode: 'ui' | 'theme' | 'function' | 'tax_calculation' | 'none'}
| {mode: 'ui' | 'theme' | 'function' | 'tax_calculation' | 'none' | 'hosted_app_home'}
| {mode: 'copy_files'; filePatterns: string[]; ignoredFilePatterns?: string[]}

/**
* Extension specification with all the needed properties and methods to load an extension.
*/
Expand All @@ -69,6 +71,7 @@ export interface ExtensionSpecification<TConfiguration extends BaseConfigType =
surface: string
registrationLimit: number
experience: ExtensionExperience
clientSteps?: ClientSteps
buildConfig: BuildConfig
dependency?: string
graphQLType?: string
Expand Down Expand Up @@ -204,6 +207,7 @@ export function createExtensionSpecification<TConfiguration extends BaseConfigTy
experience: spec.experience ?? 'extension',
uidStrategy: spec.uidStrategy ?? (spec.experience === 'configuration' ? 'single' : 'uuid'),
getDevSessionUpdateMessages: spec.getDevSessionUpdateMessages,
clientSteps: spec.clientSteps,
buildConfig: spec.buildConfig ?? {mode: 'none'},
}
const merged = {...defaults, ...spec}
Expand Down Expand Up @@ -246,6 +250,8 @@ export function createExtensionSpecification<TConfiguration extends BaseConfigTy
export function createConfigExtensionSpecification<TConfiguration extends BaseConfigType = BaseConfigType>(spec: {
identifier: string
schema: ZodSchemaType<TConfiguration>
clientSteps?: ClientSteps
buildConfig?: BuildConfig
appModuleFeatures?: (config?: TConfiguration) => ExtensionFeature[]
transformConfig: TransformationConfig | CustomTransformationConfig
uidStrategy?: UidStrategy
Expand All @@ -263,18 +269,24 @@ export function createConfigExtensionSpecification<TConfiguration extends BaseCo
transformRemoteToLocal: resolveReverseAppConfigTransform(spec.schema, spec.transformConfig),
experience: 'configuration',
uidStrategy: spec.uidStrategy ?? 'single',
clientSteps: spec.clientSteps,
buildConfig: spec.buildConfig ?? {mode: 'none'},
getDevSessionUpdateMessages: spec.getDevSessionUpdateMessages,
patchWithAppDevURLs: spec.patchWithAppDevURLs,
})
}

export function createContractBasedModuleSpecification<TConfiguration extends BaseConfigType = BaseConfigType>(
spec: Pick<CreateExtensionSpecType<TConfiguration>, 'identifier' | 'appModuleFeatures' | 'buildConfig'>,
spec: Pick<
CreateExtensionSpecType<TConfiguration>,
'identifier' | 'appModuleFeatures' | 'clientSteps' | 'buildConfig'
>,
) {
return createExtensionSpecification({
identifier: spec.identifier,
schema: zod.any({}) as unknown as ZodSchemaType<TConfiguration>,
appModuleFeatures: spec.appModuleFeatures,
clientSteps: spec.clientSteps,
buildConfig: spec.buildConfig ?? {mode: 'none'},
deployConfig: async (config, directory) => {
let parsedConfig = configWithoutFirstClassFields(config)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -77,13 +77,13 @@ function relativeUri(uri?: string, appUrl?: string) {
}

function getCustomersDeletionUri(webhooks: WebhooksConfig) {
return getComplianceUri(webhooks, 'customers/redact') || webhooks?.privacy_compliance?.customer_deletion_url
return getComplianceUri(webhooks, 'customers/redact') ?? webhooks?.privacy_compliance?.customer_deletion_url
}

function getCustomersDataRequestUri(webhooks: WebhooksConfig) {
return getComplianceUri(webhooks, 'customers/data_request') || webhooks?.privacy_compliance?.customer_data_request_url
return getComplianceUri(webhooks, 'customers/data_request') ?? webhooks?.privacy_compliance?.customer_data_request_url
}

function getShopDeletionUri(webhooks: WebhooksConfig) {
return getComplianceUri(webhooks, 'shop/redact') || webhooks?.privacy_compliance?.shop_deletion_url
return getComplianceUri(webhooks, 'shop/redact') ?? webhooks?.privacy_compliance?.shop_deletion_url
}
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@ const paymentExtensionSpec = createExtensionSpecification({
)
case CARD_PRESENT_TARGET:
return cardPresentPaymentsAppExtensionDeployConfig(config as CardPresentPaymentsAppExtensionConfigType)
case undefined:
default:
return {}
}
Expand Down
76 changes: 76 additions & 0 deletions packages/app/src/cli/services/build/client-steps.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
import {executeStep, BuildContext, LifecycleStep} from './client-steps.js'
import * as stepsIndex from './steps/index.js'
import {ExtensionInstance} from '../../models/extensions/extension-instance.js'
import {beforeEach, describe, expect, test, vi} from 'vitest'

vi.mock('./steps/index.js')

describe('executeStep', () => {
let mockContext: BuildContext

beforeEach(() => {
mockContext = {
extension: {
directory: '/test/dir',
outputPath: '/test/output/index.js',
} as ExtensionInstance,
options: {
stdout: {write: vi.fn()} as any,
stderr: {write: vi.fn()} as any,
app: {} as any,
environment: 'production' as const,
},
stepResults: new Map(),
}
})

const step: LifecycleStep = {
id: 'test-step',
name: 'Test Step',
type: 'include_assets',
config: {},
}

describe('success', () => {
test('returns a successful StepResult with output', async () => {
vi.mocked(stepsIndex.executeStepByType).mockResolvedValue({filesCopied: 3})

const result = await executeStep(step, mockContext)

expect(result.id).toBe('test-step')
expect(result.success).toBe(true)
if (result.success) expect(result.output).toEqual({filesCopied: 3})
expect(result.duration).toBeGreaterThanOrEqual(0)
})

test('logs step execution to stdout', async () => {
vi.mocked(stepsIndex.executeStepByType).mockResolvedValue({})

await executeStep(step, mockContext)

expect(mockContext.options.stdout.write).toHaveBeenCalledWith('Executing step: Test Step\n')
})
})

describe('failure', () => {
test('throws a wrapped error when the step fails', async () => {
vi.mocked(stepsIndex.executeStepByType).mockRejectedValue(new Error('something went wrong'))

await expect(executeStep(step, mockContext)).rejects.toThrow(
'Build step "Test Step" failed: something went wrong',
)
})

test('returns a failure result and logs a warning when continueOnError is true', async () => {
vi.mocked(stepsIndex.executeStepByType).mockRejectedValue(new Error('something went wrong'))

const result = await executeStep({...step, continueOnError: true}, mockContext)

expect(result.success).toBe(false)
if (!result.success) expect(result.error?.message).toBe('something went wrong')
expect(mockContext.options.stderr.write).toHaveBeenCalledWith(
'Warning: Step "Test Step" failed but continuing: something went wrong\n',
)
})
})
})
103 changes: 103 additions & 0 deletions packages/app/src/cli/services/build/client-steps.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
import {executeStepByType} from './steps/index.js'
import type {ExtensionInstance} from '../../models/extensions/extension-instance.js'
import type {ExtensionBuildOptions} from './extension.js'

/**
* LifecycleStep represents a single step in the client-side build pipeline.
* Pure configuration object — execution logic is separate (router pattern).
*/
export interface LifecycleStep {
/** Unique identifier, used as the key in the stepResults map */
readonly id: string

/** Human-readable name for logging */
readonly name: string

/** Step type (determines which executor handles it) */
readonly type:
| 'include_assets'
| 'build_theme'
| 'bundle_theme'
| 'bundle_ui'
| 'copy_static_assets'
| 'build_function'
| 'create_tax_stub'

/** Step-specific configuration */
readonly config: {[key: string]: unknown}

/** Whether to continue on error (default: false) */
readonly continueOnError?: boolean
}

/**
* A group of steps scoped to a specific lifecycle phase.
* Allows executing only the steps relevant to a given lifecycle (e.g. 'deploy').
*/
interface ClientLifecycleGroup {
readonly lifecycle: 'deploy'
readonly steps: ReadonlyArray<LifecycleStep>
}

/**
* The full client steps configuration for an extension.
* Replaces the old buildConfig contract.
*/
export type ClientSteps = ReadonlyArray<ClientLifecycleGroup>

/**
* Context passed through the step pipeline.
* Each step can read from and write to the context.
*/
export interface BuildContext {
readonly extension: ExtensionInstance
readonly options: ExtensionBuildOptions
readonly stepResults: Map<string, StepResult>
}

type StepResult = {
readonly id: string
readonly duration: number
} & (
| {
readonly success: false
readonly error: Error
}
| {
readonly success: true
readonly output: never
}
)

/**
* Executes a single client step with error handling.
*/
export async function executeStep(step: LifecycleStep, context: BuildContext): Promise<StepResult> {
const startTime = Date.now()

try {
context.options.stdout.write(`Executing step: ${step.name}\n`)
const output = await executeStepByType(step, context)

return {
id: step.id,
success: true,
duration: Date.now() - startTime,
output: output as never,
}
} catch (error) {
const stepError = error as Error

if (step.continueOnError) {
context.options.stderr.write(`Warning: Step "${step.name}" failed but continuing: ${stepError.message}\n`)
return {
id: step.id,
success: false,
duration: Date.now() - startTime,
error: stepError,
}
}

throw new Error(`Build step "${step.name}" failed: ${stepError.message}`)
}
}
27 changes: 27 additions & 0 deletions packages/app/src/cli/services/build/steps/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import type {LifecycleStep, BuildContext} from '../client-steps.js'

/**
* Routes step execution to the appropriate handler based on step type.
* This implements the Command Pattern router, dispatching to type-specific executors.
*
* @param step - The build step configuration
* @param context - The build context
* @returns The output from the step execution
* @throws Error if the step type is not implemented or unknown
*/
export async function executeStepByType(step: LifecycleStep, _context: BuildContext): Promise<unknown> {
switch (step.type) {
// Future step types (not implemented yet):
case 'include_assets':
case 'build_theme':
case 'bundle_theme':
case 'bundle_ui':
case 'copy_static_assets':
case 'build_function':
case 'create_tax_stub':
throw new Error(`Build step type "${step.type}" is not yet implemented.`)

default:
throw new Error(`Unknown build step type: ${(step as {type: string}).type}`)
}
}
Loading