Skip to content

Commit 770bb28

Browse files
committed
Support flexible templates for app init
1 parent 29d44c3 commit 770bb28

8 files changed

Lines changed: 809 additions & 155 deletions

File tree

.changeset/lemon-bees-post.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@shopify/app': patch
3+
---
4+
5+
Support flexible templates in `shopify app init`

packages/app/src/cli/models/app/app.test.ts

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,10 @@ import {
22
AppSchema,
33
CurrentAppConfiguration,
44
LegacyAppConfiguration,
5+
TemplateConfigSchema,
56
getAppScopes,
67
getAppScopesArray,
8+
getTemplateScopesArray,
79
getUIExtensionRendererVersion,
810
isCurrentAppSchema,
911
isLegacyAppSchema,
@@ -233,6 +235,94 @@ describe('getAppScopesArray', () => {
233235
})
234236
})
235237

238+
describe('TemplateConfigSchema', () => {
239+
test('parses config with legacy scopes format', () => {
240+
const config = {scopes: 'read_products,write_products'}
241+
const result = TemplateConfigSchema.parse(config)
242+
expect(result.scopes).toEqual('read_products,write_products')
243+
})
244+
245+
test('parses config with access_scopes format', () => {
246+
const config = {access_scopes: {scopes: 'read_products,write_products'}}
247+
const result = TemplateConfigSchema.parse(config)
248+
expect(result.access_scopes?.scopes).toEqual('read_products,write_products')
249+
})
250+
251+
test('preserves extra keys like metafields via passthrough', () => {
252+
const config = {
253+
scopes: 'write_products',
254+
product: {
255+
metafields: {
256+
app: {
257+
demo_info: {
258+
type: 'single_line_text_field',
259+
name: 'Demo Source Info',
260+
},
261+
},
262+
},
263+
},
264+
webhooks: {
265+
api_version: '2025-07',
266+
subscriptions: [{uri: '/webhooks', topics: ['app/uninstalled']}],
267+
},
268+
}
269+
const result = TemplateConfigSchema.parse(config)
270+
expect(result.product).toEqual(config.product)
271+
expect(result.webhooks).toEqual(config.webhooks)
272+
})
273+
274+
test('parses empty config', () => {
275+
const config = {}
276+
const result = TemplateConfigSchema.parse(config)
277+
expect(result).toEqual({})
278+
})
279+
})
280+
281+
describe('getTemplateScopesArray', () => {
282+
test('returns scopes from legacy format', () => {
283+
const config = {scopes: 'read_themes,write_products'}
284+
expect(getTemplateScopesArray(config)).toEqual(['read_themes', 'write_products'])
285+
})
286+
287+
test('returns scopes from access_scopes format', () => {
288+
const config = {access_scopes: {scopes: 'read_themes,write_products'}}
289+
expect(getTemplateScopesArray(config)).toEqual(['read_themes', 'write_products'])
290+
})
291+
292+
test('trims whitespace from scopes and sorts', () => {
293+
const config = {scopes: ' write_products , read_themes '}
294+
expect(getTemplateScopesArray(config)).toEqual(['read_themes', 'write_products'])
295+
})
296+
297+
test('includes empty strings from consecutive commas (caller should handle)', () => {
298+
const config = {scopes: 'read_themes,write_products'}
299+
expect(getTemplateScopesArray(config)).toEqual(['read_themes', 'write_products'])
300+
})
301+
302+
test('returns empty array when no scopes defined', () => {
303+
const config = {}
304+
expect(getTemplateScopesArray(config)).toEqual([])
305+
})
306+
307+
test('returns empty array when scopes is empty string', () => {
308+
const config = {scopes: ''}
309+
expect(getTemplateScopesArray(config)).toEqual([])
310+
})
311+
312+
test('returns empty array when access_scopes.scopes is empty', () => {
313+
const config = {access_scopes: {scopes: ''}}
314+
expect(getTemplateScopesArray(config)).toEqual([])
315+
})
316+
317+
test('prefers legacy scopes over access_scopes when both present', () => {
318+
const config = {
319+
scopes: 'read_themes',
320+
access_scopes: {scopes: 'write_products'},
321+
}
322+
expect(getTemplateScopesArray(config)).toEqual(['read_themes'])
323+
})
324+
})
325+
236326
describe('preDeployValidation', () => {
237327
test('throws an error when app-specific webhooks are used with legacy install flow', async () => {
238328
// Given

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

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,37 @@ function fixSingleWildcards(value: string[] | undefined) {
7575
return value?.map((dir) => dir.replace(/([^\*])\*$/, '$1**'))
7676
}
7777

78+
/**
79+
* Schema for loading template config during app init.
80+
* Uses passthrough() to allow any extra keys from full-featured templates
81+
* (e.g., metafields, metaobjects, webhooks) without strict validation.
82+
*/
83+
export const TemplateConfigSchema = zod
84+
.object({
85+
scopes: zod
86+
.string()
87+
.transform((scopes) => normalizeDelimitedString(scopes) ?? '')
88+
.optional(),
89+
access_scopes: zod
90+
.object({
91+
scopes: zod.string().transform((scopes) => normalizeDelimitedString(scopes) ?? ''),
92+
})
93+
.optional(),
94+
web_directories: zod.array(zod.string()).optional(),
95+
})
96+
.passthrough()
97+
98+
export type TemplateConfig = zod.infer<typeof TemplateConfigSchema>
99+
100+
export function getTemplateScopesArray(config: TemplateConfig): string[] {
101+
const scopesString = config.scopes ?? config.access_scopes?.scopes ?? ''
102+
if (scopesString.length === 0) return []
103+
return scopesString
104+
.split(',')
105+
.map((scope) => scope.trim())
106+
.sort()
107+
}
108+
78109
/**
79110
* Schema for a normal, linked app. Properties from modules are not validated.
80111
*/

0 commit comments

Comments
 (0)