Skip to content

Commit 3ac919f

Browse files
Abstract build steps to externalize the build configuration
1 parent c4c3353 commit 3ac919f

20 files changed

+806
-52
lines changed

packages/app/src/cli/models/extensions/extension-instance.test.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -148,8 +148,8 @@ describe('build', async () => {
148148
// Given
149149
const extensionInstance = await testTaxCalculationExtension(tmpDir)
150150
const options: ExtensionBuildOptions = {
151-
stdout: new Writable(),
152-
stderr: new Writable(),
151+
stdout: new Writable({write(chunk, enc, cb) { cb() }}),
152+
stderr: new Writable({write(chunk, enc, cb) { cb() }}),
153153
app: testApp(),
154154
environment: 'production',
155155
}

packages/app/src/cli/models/extensions/extension-instance.ts

Lines changed: 22 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -14,14 +14,9 @@ import {WebhooksSpecIdentifier} from './specifications/app_config_webhook.js'
1414
import {WebhookSubscriptionSpecIdentifier} from './specifications/app_config_webhook_subscription.js'
1515
import {EventsSpecIdentifier} from './specifications/app_config_events.js'
1616
import {HostedAppHomeSpecIdentifier} from './specifications/app_config_hosted_app_home.js'
17-
import {
18-
ExtensionBuildOptions,
19-
buildFunctionExtension,
20-
buildThemeExtension,
21-
buildUIExtension,
22-
bundleFunctionExtension,
23-
} from '../../services/build/extension.js'
24-
import {bundleThemeExtension, copyFilesForExtension} from '../../services/extensions/bundle.js'
17+
import {ExtensionBuildOptions, bundleFunctionExtension} from '../../services/build/extension.js'
18+
import {bundleThemeExtension} from '../../services/extensions/bundle.js'
19+
import {BuildContext, executeStep} from '../../services/build/build-steps.js'
2520
import {Identifiers} from '../app/identifiers.js'
2621
import {DeveloperPlatformClient} from '../../utilities/developer-platform-client.js'
2722
import {AppConfigurationWithoutPath} from '../app/app.js'
@@ -31,7 +26,7 @@ import {constantize, slugify} from '@shopify/cli-kit/common/string'
3126
import {hashString, nonRandomUUID} from '@shopify/cli-kit/node/crypto'
3227
import {partnersFqdn} from '@shopify/cli-kit/node/context/fqdn'
3328
import {joinPath, basename, normalizePath, resolvePath} from '@shopify/cli-kit/node/path'
34-
import {fileExists, touchFile, moveFile, writeFile, glob, copyFile, globSync} from '@shopify/cli-kit/node/fs'
29+
import {fileExists, moveFile, glob, copyFile, globSync} from '@shopify/cli-kit/node/fs'
3530
import {getPathValue} from '@shopify/cli-kit/common/object'
3631
import {outputDebug} from '@shopify/cli-kit/node/output'
3732
import {extractJSImports, extractImportPathsRecursively} from '@shopify/cli-kit/node/import-extractor'
@@ -347,34 +342,25 @@ export class ExtensionInstance<TConfiguration extends BaseConfigType = BaseConfi
347342
}
348343

349344
async build(options: ExtensionBuildOptions): Promise<void> {
350-
const mode = this.specification.buildConfig.mode
345+
const {buildConfig} = this.specification
346+
347+
const context: BuildContext = {
348+
extension: this,
349+
options,
350+
stepResults: new Map(),
351+
signal: options.signal,
352+
}
351353

352-
switch (mode) {
353-
case 'theme':
354-
await buildThemeExtension(this, options)
355-
return bundleThemeExtension(this, options)
356-
case 'function':
357-
return buildFunctionExtension(this, options)
358-
case 'ui':
359-
await buildUIExtension(this, options)
360-
// Copy static assets after build completes
361-
return this.copyStaticAssets()
362-
case 'tax_calculation':
363-
await touchFile(this.outputPath)
364-
await writeFile(this.outputPath, '(()=>{})();')
365-
break
366-
case 'copy_files':
367-
return copyFilesForExtension(
368-
this,
369-
options,
370-
this.specification.buildConfig.filePatterns,
371-
this.specification.buildConfig.ignoredFilePatterns,
372-
)
373-
case 'hosted_app_home':
374-
await this.copyStaticAssets()
375-
break
376-
case 'none':
377-
break
354+
const {steps = [], stopOnError = true} = buildConfig
355+
356+
for (const step of steps) {
357+
// eslint-disable-next-line no-await-in-loop
358+
const result = await executeStep(step, context)
359+
context.stepResults.set(step.id, result)
360+
361+
if (!result.success && stopOnError && !step.continueOnError) {
362+
throw new Error(`Build step "${step.displayName}" failed: ${result.error?.message}`)
363+
}
378364
}
379365
}
380366

packages/app/src/cli/models/extensions/specification.ts

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
import {ZodSchemaType, BaseConfigType, BaseSchema} from './schemas.js'
33
import {ExtensionInstance} from './extension-instance.js'
44
import {blocks} from '../../constants.js'
5+
import {BuildStep} from '../../services/build/build-steps.js'
56

67
import {Flag} from '../../utilities/developer-platform-client.js'
78
import {AppConfigurationWithoutPath} from '../app/app.js'
@@ -54,9 +55,11 @@ export interface BuildAsset {
5455
static?: boolean
5556
}
5657

57-
type BuildConfig =
58-
| {mode: 'ui' | 'theme' | 'function' | 'tax_calculation' | 'none' | 'hosted_app_home'}
59-
| {mode: 'copy_files'; filePatterns: string[]; ignoredFilePatterns?: string[]}
58+
interface BuildConfig {
59+
mode: 'none' | 'ui' | 'theme' | 'function' | 'tax_calculation' | 'copy_files'
60+
steps?: ReadonlyArray<BuildStep>
61+
stopOnError?: boolean
62+
}
6063
/**
6164
* Extension specification with all the needed properties and methods to load an extension.
6265
*/
Lines changed: 156 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,156 @@
1+
import spec from './channel.js'
2+
import {ExtensionInstance} from '../extension-instance.js'
3+
import {ExtensionBuildOptions} from '../../../services/build/extension.js'
4+
import {describe, expect, test} from 'vitest'
5+
import {inTemporaryDirectory, writeFile, fileExists, mkdir} from '@shopify/cli-kit/node/fs'
6+
import {joinPath} from '@shopify/cli-kit/node/path'
7+
import {Writable} from 'stream'
8+
9+
const SUBDIRECTORY = 'specifications'
10+
11+
describe('channel_config', () => {
12+
describe('buildConfig', () => {
13+
test('uses build_steps mode', () => {
14+
expect(spec.buildConfig.mode).toBe('copy_files')
15+
})
16+
17+
test('has a single copy-files step scoped to the specifications subdirectory', () => {
18+
if (spec.buildConfig.mode === 'none') throw new Error('Expected build_steps mode')
19+
20+
expect(spec.buildConfig.steps).toHaveLength(1)
21+
expect(spec.buildConfig.steps![0]).toMatchObject({
22+
id: 'copy-files',
23+
type: 'copy_files',
24+
config: {
25+
strategy: 'pattern',
26+
definition: {source: '.'},
27+
},
28+
})
29+
30+
const {patterns} = (spec.buildConfig.steps![0]!.config as {definition: {patterns: string[]}}).definition
31+
32+
expect(patterns).toEqual(
33+
expect.arrayContaining([
34+
`${SUBDIRECTORY}/**/*.json`,
35+
`${SUBDIRECTORY}/**/*.toml`,
36+
`${SUBDIRECTORY}/**/*.yaml`,
37+
`${SUBDIRECTORY}/**/*.yml`,
38+
`${SUBDIRECTORY}/**/*.svg`,
39+
]),
40+
)
41+
})
42+
43+
test('config is serializable to JSON', () => {
44+
if (spec.buildConfig.mode === 'none') throw new Error('Expected build_steps mode')
45+
46+
const serialized = JSON.stringify(spec.buildConfig)
47+
const deserialized = JSON.parse(serialized)
48+
49+
expect(deserialized.steps).toHaveLength(1)
50+
expect(deserialized.steps[0].config.strategy).toBe('pattern')
51+
})
52+
})
53+
54+
describe('build integration', () => {
55+
test('copies specification files to output, preserving subdirectory structure', async () => {
56+
await inTemporaryDirectory(async (tmpDir) => {
57+
// Given
58+
const extensionDir = joinPath(tmpDir, 'extension')
59+
const specsDir = joinPath(extensionDir, SUBDIRECTORY)
60+
const outputDir = joinPath(tmpDir, 'output')
61+
62+
await mkdir(specsDir)
63+
await mkdir(outputDir)
64+
65+
await writeFile(joinPath(specsDir, 'product.json'), '{}')
66+
await writeFile(joinPath(specsDir, 'order.toml'), '[spec]')
67+
await writeFile(joinPath(specsDir, 'logo.svg'), '<svg/>')
68+
// Root-level files should NOT be copied
69+
await writeFile(joinPath(extensionDir, 'README.md'), '# readme')
70+
await writeFile(joinPath(extensionDir, 'index.js'), 'ignored')
71+
72+
const extension = new ExtensionInstance({
73+
configuration: {name: 'my-channel', type: 'channel'},
74+
configurationPath: '',
75+
directory: extensionDir,
76+
specification: spec,
77+
})
78+
extension.outputPath = outputDir
79+
80+
const buildOptions: ExtensionBuildOptions = {
81+
stdout: new Writable({
82+
write(chunk, enc, cb) {
83+
cb()
84+
},
85+
}),
86+
stderr: new Writable({
87+
write(chunk, enc, cb) {
88+
cb()
89+
},
90+
}),
91+
app: {} as any,
92+
environment: 'production',
93+
}
94+
95+
// When
96+
await extension.build(buildOptions)
97+
98+
// Then — specification files copied with path preserved
99+
await expect(fileExists(joinPath(outputDir, SUBDIRECTORY, 'product.json'))).resolves.toBe(true)
100+
await expect(fileExists(joinPath(outputDir, SUBDIRECTORY, 'order.toml'))).resolves.toBe(true)
101+
await expect(fileExists(joinPath(outputDir, SUBDIRECTORY, 'logo.svg'))).resolves.toBe(true)
102+
103+
// Root-level files not in specifications/ are not copied
104+
await expect(fileExists(joinPath(outputDir, 'README.md'))).resolves.toBe(false)
105+
await expect(fileExists(joinPath(outputDir, 'index.js'))).resolves.toBe(false)
106+
})
107+
})
108+
109+
test('does not copy files with non-matching extensions inside specifications/', async () => {
110+
await inTemporaryDirectory(async (tmpDir) => {
111+
// Given
112+
const extensionDir = joinPath(tmpDir, 'extension')
113+
const specsDir = joinPath(extensionDir, SUBDIRECTORY)
114+
const outputDir = joinPath(tmpDir, 'output')
115+
116+
await mkdir(specsDir)
117+
await mkdir(outputDir)
118+
119+
await writeFile(joinPath(specsDir, 'spec.json'), '{}')
120+
await writeFile(joinPath(specsDir, 'ignored.ts'), 'const x = 1')
121+
await writeFile(joinPath(specsDir, 'ignored.js'), 'const x = 1')
122+
123+
const extension = new ExtensionInstance({
124+
configuration: {name: 'my-channel', type: 'channel'},
125+
configurationPath: '',
126+
directory: extensionDir,
127+
specification: spec,
128+
})
129+
extension.outputPath = outputDir
130+
131+
const buildOptions: ExtensionBuildOptions = {
132+
stdout: new Writable({
133+
write(chunk, enc, cb) {
134+
cb()
135+
},
136+
}),
137+
stderr: new Writable({
138+
write(chunk, enc, cb) {
139+
cb()
140+
},
141+
}),
142+
app: {} as any,
143+
environment: 'production',
144+
}
145+
146+
// When
147+
await extension.build(buildOptions)
148+
149+
// Then
150+
await expect(fileExists(joinPath(outputDir, SUBDIRECTORY, 'spec.json'))).resolves.toBe(true)
151+
await expect(fileExists(joinPath(outputDir, SUBDIRECTORY, 'ignored.ts'))).resolves.toBe(false)
152+
await expect(fileExists(joinPath(outputDir, SUBDIRECTORY, 'ignored.js'))).resolves.toBe(false)
153+
})
154+
})
155+
})
156+
})

packages/app/src/cli/models/extensions/specifications/channel.ts

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,21 @@ const channelSpecificationSpec = createContractBasedModuleSpecification({
88
identifier: 'channel_config',
99
buildConfig: {
1010
mode: 'copy_files',
11-
filePatterns: FILE_EXTENSIONS.map((ext) => joinPath(SUBDIRECTORY_NAME, '**', `*.${ext}`)),
11+
steps: [
12+
{
13+
id: 'copy-files',
14+
displayName: 'Copy Files',
15+
type: 'copy_files',
16+
config: {
17+
strategy: 'pattern',
18+
definition: {
19+
source: '.',
20+
patterns: FILE_EXTENSIONS.map((ext) => joinPath(SUBDIRECTORY_NAME, '**', `*.${ext}`)),
21+
},
22+
},
23+
},
24+
],
25+
stopOnError: true,
1226
},
1327
appModuleFeatures: () => [],
1428
})

packages/app/src/cli/models/extensions/specifications/checkout_post_purchase.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,14 @@ const checkoutPostPurchaseSpec = createExtensionSpecification({
1414
partnersWebIdentifier: 'post_purchase',
1515
schema: CheckoutPostPurchaseSchema,
1616
appModuleFeatures: (_) => ['ui_preview', 'cart_url', 'esbuild', 'single_js_entry_path'],
17-
buildConfig: {mode: 'ui'},
17+
buildConfig: {
18+
mode: 'ui',
19+
steps: [
20+
{id: 'bundle-ui', displayName: 'Bundle UI Extension', type: 'bundle_ui', config: {}},
21+
{id: 'copy-static-assets', displayName: 'Copy Static Assets', type: 'copy_static_assets', config: {}},
22+
],
23+
stopOnError: true,
24+
},
1825
deployConfig: async (config, _) => {
1926
return {metafields: config.metafields ?? []}
2027
},

packages/app/src/cli/models/extensions/specifications/checkout_ui_extension.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,14 @@ const checkoutSpec = createExtensionSpecification({
2121
dependency,
2222
schema: CheckoutSchema,
2323
appModuleFeatures: (_) => ['ui_preview', 'cart_url', 'esbuild', 'single_js_entry_path', 'generates_source_maps'],
24-
buildConfig: {mode: 'ui'},
24+
buildConfig: {
25+
mode: 'ui',
26+
steps: [
27+
{id: 'bundle-ui', displayName: 'Bundle UI Extension', type: 'bundle_ui', config: {}},
28+
{id: 'copy-static-assets', displayName: 'Copy Static Assets', type: 'copy_static_assets', config: {}},
29+
],
30+
stopOnError: true,
31+
},
2532
deployConfig: async (config, directory) => {
2633
return {
2734
extension_points: config.extension_points,

0 commit comments

Comments
 (0)