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 @@ -351,17 +351,17 @@ export class ExtensionInstance<TConfiguration extends BaseConfigType = BaseConfi
return copyFilesForExtension(
this,
options,
this.specification.buildConfig.filePatterns,
this.specification.buildConfig.ignoredFilePatterns,
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 {AppConfigurationWithoutPath} 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
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

is clientSteps meant to eventually replace buildconfig or are we intending to support both?

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',
)
})
})
})
108 changes: 108 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,108 @@
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'
| 'esbuild'
| 'validate'
| 'transform'
| 'custom'

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

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

/**
* A group of steps scoped to a specific lifecycle phase.
* Allows executing only the steps relevant to a given lifecycle (e.g. 'deploy').
*/
interface ClientLifecycleGroup {
readonly lifecycle: 'deploy'
readonly steps: ReadonlyArray<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 {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

i'm unclear on why an additional stateful context is necessary. can we not iterate through the steps just using the data already available and the step i/o?

readonly extension: ExtensionInstance
readonly options: ExtensionBuildOptions
readonly stepResults: Map<string, StepResult>
[key: string]: unknown
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ideally outputs are not unknown if there is a finite amount of valid steps

}

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

/**
* 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,
}

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

BuildContext.stepResults is never written to, despite being part of the contract

BuildContext includes stepResults: Map<string, StepResult> and ClientStep.id is described as the “key in the stepResults map”, but executeStep never stores the produced result into context.stepResults. This breaks intended pipeline behavior (later steps can’t inspect prior outputs/errors) and can cause subtle bugs once steps depend on previous results.

} 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}`)
}
}
31 changes: 31 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,31 @@
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':
case 'esbuild':
case 'validate':
case 'transform':
case 'custom':
throw new Error(`Build step type "${step.type}" is not yet implemented.`)

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Step router makes all step types unusable (always throws)

executeStepByType explicitly throws for every declared step type (including include_assets, bundle_ui, bundle_theme, etc.), so the new ClientSteps pipeline cannot execute any step successfully.

Evidence:

case 'include_assets':
...
  throw new Error(`Build step type "${step.type}" is not yet implemented.`)

Impact:

  • Any runtime path using clientSteps will fail during build/deploy.
  • CI/build pipelines will error; deploys could be blocked broadly once enabled.


default:
throw new Error(`Unknown build step type: ${(step as {type: string}).type}`)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,9 @@ async function mergeLocalAndRemoteSpecs(
const merged = {...localSpec, ...remoteSpec, loadedRemoteSpecs: true} as RemoteAwareExtensionSpecification &
FlattenedRemoteSpecification

// Not all the specs are moved to remote definition yet
merged.clientSteps ??= localSpec.clientSteps ?? []
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this necessary? :thinking_face:
when merging in the previous step, is remoteSpec setting this to undefined? or is it preserving the localSpec value?


// If configuration is inside an app.toml -- i.e. single UID mode -- we must be able to parse a partial slice.
let handleInvalidAdditionalProperties: HandleInvalidAdditionalProperties
switch (merged.uidStrategy) {
Expand Down
Loading