Skip to content
Merged
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
5 changes: 5 additions & 0 deletions .changeset/lemon-bees-post.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@shopify/app': patch
---

Support flexible templates in `shopify app init`
90 changes: 90 additions & 0 deletions packages/app/src/cli/models/app/app.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,10 @@ import {
AppSchema,
CurrentAppConfiguration,
LegacyAppConfiguration,
TemplateConfigSchema,
getAppScopes,
getAppScopesArray,
getTemplateScopesArray,
getUIExtensionRendererVersion,
isCurrentAppSchema,
isLegacyAppSchema,
Expand Down Expand Up @@ -233,6 +235,94 @@ describe('getAppScopesArray', () => {
})
})

describe('TemplateConfigSchema', () => {
test('parses config with legacy scopes format', () => {
const config = {scopes: 'read_products,write_products'}
const result = TemplateConfigSchema.parse(config)
expect(result.scopes).toEqual('read_products,write_products')
})

test('parses config with access_scopes format', () => {
const config = {access_scopes: {scopes: 'read_products,write_products'}}
const result = TemplateConfigSchema.parse(config)
expect(result.access_scopes?.scopes).toEqual('read_products,write_products')
})

test('preserves extra keys like metafields via passthrough', () => {
const config = {
scopes: 'write_products',
product: {
metafields: {
app: {
demo_info: {
type: 'single_line_text_field',
name: 'Demo Source Info',
},
},
},
},
webhooks: {
api_version: '2025-07',
subscriptions: [{uri: '/webhooks', topics: ['app/uninstalled']}],
},
}
const result = TemplateConfigSchema.parse(config)
expect(result.product).toEqual(config.product)
expect(result.webhooks).toEqual(config.webhooks)
})

test('parses empty config', () => {
const config = {}
const result = TemplateConfigSchema.parse(config)
expect(result).toEqual({})
})
})

describe('getTemplateScopesArray', () => {
test('returns scopes from legacy format', () => {
const config = {scopes: 'read_themes,write_products'}
expect(getTemplateScopesArray(config)).toEqual(['read_themes', 'write_products'])
})

test('returns scopes from access_scopes format', () => {
const config = {access_scopes: {scopes: 'read_themes,write_products'}}
expect(getTemplateScopesArray(config)).toEqual(['read_themes', 'write_products'])
})

test('trims whitespace from scopes and sorts', () => {
const config = {scopes: ' write_products , read_themes '}
expect(getTemplateScopesArray(config)).toEqual(['read_themes', 'write_products'])
})

test('includes empty strings from consecutive commas (caller should handle)', () => {
const config = {scopes: 'read_themes,write_products'}
expect(getTemplateScopesArray(config)).toEqual(['read_themes', 'write_products'])
})

test('returns empty array when no scopes defined', () => {
const config = {}
expect(getTemplateScopesArray(config)).toEqual([])
})

test('returns empty array when scopes is empty string', () => {
const config = {scopes: ''}
expect(getTemplateScopesArray(config)).toEqual([])
})

test('returns empty array when access_scopes.scopes is empty', () => {
const config = {access_scopes: {scopes: ''}}
expect(getTemplateScopesArray(config)).toEqual([])
})

test('prefers legacy scopes over access_scopes when both present', () => {
const config = {
scopes: 'read_themes',
access_scopes: {scopes: 'write_products'},
}
expect(getTemplateScopesArray(config)).toEqual(['read_themes'])
})
})

describe('preDeployValidation', () => {
test('throws an error when app-specific webhooks are used with legacy install flow', async () => {
// Given
Expand Down
31 changes: 31 additions & 0 deletions packages/app/src/cli/models/app/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,37 @@ function fixSingleWildcards(value: string[] | undefined) {
return value?.map((dir) => dir.replace(/([^\*])\*$/, '$1**'))
}

/**
* Schema for loading template config during app init.
* Uses passthrough() to allow any extra keys from full-featured templates
* (e.g., metafields, metaobjects, webhooks) without strict validation.
*/
export const TemplateConfigSchema = zod
Copy link
Contributor

Choose a reason for hiding this comment

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

I see more differences with LegacyAppSchema besides the passthrough (like extension_directories). Is that ok?

.object({
scopes: zod
.string()
.transform((scopes) => normalizeDelimitedString(scopes) ?? '')
.optional(),
access_scopes: zod
.object({
scopes: zod.string().transform((scopes) => normalizeDelimitedString(scopes) ?? ''),
})
.optional(),
web_directories: zod.array(zod.string()).optional(),
})
.passthrough()

export type TemplateConfig = zod.infer<typeof TemplateConfigSchema>

export function getTemplateScopesArray(config: TemplateConfig): string[] {
const scopesString = config.scopes ?? config.access_scopes?.scopes ?? ''
if (scopesString.length === 0) return []
return scopesString
.split(',')
.map((scope) => scope.trim())
.sort()
}

/**
* Schema for a normal, linked app. Properties from modules are not validated.
*/
Expand Down
Loading
Loading