From 68694825ba012f34dfd85c3220d4aa855ddde762 Mon Sep 17 00:00:00 2001 From: Shaun Stanworth Date: Fri, 9 Jan 2026 15:05:56 +0000 Subject: [PATCH] Support flexible templates for `app init` --- .changeset/lemon-bees-post.md | 5 + packages/app/src/cli/models/app/app.test.ts | 90 ++++ packages/app/src/cli/models/app/app.ts | 31 ++ .../app/src/cli/models/app/loader.test.ts | 388 +++++++++++++++--- packages/app/src/cli/models/app/loader.ts | 147 +++++-- .../services/app/config/link-service.test.ts | 100 +++++ .../src/cli/services/app/config/link.test.ts | 79 ++-- .../app/src/cli/services/app/config/link.ts | 124 +++--- 8 files changed, 809 insertions(+), 155 deletions(-) create mode 100644 .changeset/lemon-bees-post.md diff --git a/.changeset/lemon-bees-post.md b/.changeset/lemon-bees-post.md new file mode 100644 index 00000000000..dffe15ea1d2 --- /dev/null +++ b/.changeset/lemon-bees-post.md @@ -0,0 +1,5 @@ +--- +'@shopify/app': patch +--- + +Support flexible templates in `shopify app init` diff --git a/packages/app/src/cli/models/app/app.test.ts b/packages/app/src/cli/models/app/app.test.ts index d1844e6f050..636a82fb113 100644 --- a/packages/app/src/cli/models/app/app.test.ts +++ b/packages/app/src/cli/models/app/app.test.ts @@ -2,8 +2,10 @@ import { AppSchema, CurrentAppConfiguration, LegacyAppConfiguration, + TemplateConfigSchema, getAppScopes, getAppScopesArray, + getTemplateScopesArray, getUIExtensionRendererVersion, isCurrentAppSchema, isLegacyAppSchema, @@ -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 diff --git a/packages/app/src/cli/models/app/app.ts b/packages/app/src/cli/models/app/app.ts index d1a1e902e82..ead77d10470 100644 --- a/packages/app/src/cli/models/app/app.ts +++ b/packages/app/src/cli/models/app/app.ts @@ -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 + .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 + +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. */ diff --git a/packages/app/src/cli/models/app/loader.test.ts b/packages/app/src/cli/models/app/loader.test.ts index 624a2d02b81..42cf61e9958 100644 --- a/packages/app/src/cli/models/app/loader.test.ts +++ b/packages/app/src/cli/models/app/loader.test.ts @@ -2,6 +2,7 @@ import { getAppConfigurationShorthand, getAppConfigurationFileName, loadApp, + loadOpaqueApp, loadDotEnv, parseConfigurationObject, checkFolderIsValidApp, @@ -3555,20 +3556,17 @@ describe('getAppConfigurationState', () => { }) describe('loadConfigForAppCreation', () => { - test('returns correct configuration for a basic app with no webs', async () => { - // Given + test('extracts top-level scopes from legacy format', async () => { await inTemporaryDirectory(async (tmpDir) => { const config = ` - scopes = "write_products,read_orders" - name = "my-app" +scopes = "write_products,read_orders" +name = "my-app" ` await writeFile(joinPath(tmpDir, 'shopify.app.toml'), config) await writeFile(joinPath(tmpDir, 'package.json'), '{}') - // When const result = await loadConfigForAppCreation(tmpDir, 'my-app') - // Then expect(result).toEqual({ isLaunchable: false, scopesArray: ['read_orders', 'write_products'], @@ -3579,30 +3577,70 @@ describe('loadConfigForAppCreation', () => { }) }) - test('returns correct configuration for an app with a frontend web', async () => { - // Given + test('extracts access_scopes.scopes from current format', async () => { + await inTemporaryDirectory(async (tmpDir) => { + const config = ` +client_id = "12345" +name = "my-app" + +[access_scopes] +scopes = "read_orders,write_products" + ` + await writeFile(joinPath(tmpDir, 'shopify.app.toml'), config) + await writeFile(joinPath(tmpDir, 'package.json'), '{}') + + const result = await loadConfigForAppCreation(tmpDir, 'my-app') + + expect(result).toEqual({ + isLaunchable: false, + scopesArray: ['read_orders', 'write_products'], + name: 'my-app', + directory: tmpDir, + isEmbedded: false, + }) + }) + }) + + test('defaults to empty scopes when scopes field is missing', async () => { await inTemporaryDirectory(async (tmpDir) => { const config = ` - scopes = "write_products" - name = "my-app" +name = "my-app" ` await writeFile(joinPath(tmpDir, 'shopify.app.toml'), config) await writeFile(joinPath(tmpDir, 'package.json'), '{}') + const result = await loadConfigForAppCreation(tmpDir, 'my-app') + + expect(result).toEqual({ + isLaunchable: false, + scopesArray: [], + name: 'my-app', + directory: tmpDir, + isEmbedded: false, + }) + }) + }) + + test('detects launchable app with frontend web', async () => { + await inTemporaryDirectory(async (tmpDir) => { + const config = ` +scopes = "write_products" +name = "my-app" + ` + await writeFile(joinPath(tmpDir, 'shopify.app.toml'), config) + await writeFile(joinPath(tmpDir, 'package.json'), '{}') await writeFile( joinPath(tmpDir, 'shopify.web.toml'), `roles = ["frontend"] name = "web" [commands] -dev = "echo 'Hello, world!'" +dev = "echo 'dev'" `, ) - // When const result = await loadConfigForAppCreation(tmpDir, 'my-app') - // Then expect(result).toEqual({ isLaunchable: true, scopesArray: ['write_products'], @@ -3613,62 +3651,52 @@ dev = "echo 'Hello, world!'" }) }) - test('returns correct configuration for an app with a backend web', async () => { - // Given + test('ignores unknown configuration sections with legacy scopes', async () => { await inTemporaryDirectory(async (tmpDir) => { const config = ` - scopes = "write_products" - name = "my-app" +scopes = "write_products" +name = "my-app" + +[product.metafields.app.example] +type = "single_line_text_field" +name = "Example" ` await writeFile(joinPath(tmpDir, 'shopify.app.toml'), config) await writeFile(joinPath(tmpDir, 'package.json'), '{}') - // Create web directory with backend configuration - const webDir = joinPath(tmpDir, 'web') - await mkdir(webDir) - await writeFile( - joinPath(tmpDir, 'shopify.web.toml'), - `roles = ["backend"] -name = "web" - -[commands] -dev = "echo 'Hello, world!'" - `, - ) - - // When const result = await loadConfigForAppCreation(tmpDir, 'my-app') - // Then expect(result).toEqual({ - isLaunchable: true, + isLaunchable: false, scopesArray: ['write_products'], name: 'my-app', directory: tmpDir, - isEmbedded: true, + isEmbedded: false, }) }) }) - test('returns correct configuration for a connected app', async () => { - // Given + test('ignores unknown configuration sections with access_scopes format', async () => { await inTemporaryDirectory(async (tmpDir) => { const config = ` - client_id = "12345" - name = "my-app" - [access_scopes] - scopes = "read_orders,write_products" +client_id = "12345" +name = "my-app" + +[access_scopes] +scopes = "write_products" + +[product.metafields.app.example] +type = "single_line_text_field" +name = "Example" ` await writeFile(joinPath(tmpDir, 'shopify.app.toml'), config) await writeFile(joinPath(tmpDir, 'package.json'), '{}') - // When const result = await loadConfigForAppCreation(tmpDir, 'my-app') - // Then expect(result).toEqual({ isLaunchable: false, - scopesArray: ['read_orders', 'write_products'], + scopesArray: ['write_products'], name: 'my-app', directory: tmpDir, isEmbedded: false, @@ -3676,23 +3704,28 @@ dev = "echo 'Hello, world!'" }) }) - test('handles empty scopes correctly', async () => { - // Given + test('ignores completely unrecognized configuration sections', async () => { await inTemporaryDirectory(async (tmpDir) => { const config = ` - name = "my-app" - scopes = "" +scopes = "write_products" +name = "my-app" +nonsense_field = "whatever" + +[completely_made_up] +foo = "bar" +baz = 123 + +[another.deeply.nested.thing] +value = true ` await writeFile(joinPath(tmpDir, 'shopify.app.toml'), config) await writeFile(joinPath(tmpDir, 'package.json'), '{}') - // When const result = await loadConfigForAppCreation(tmpDir, 'my-app') - // Then expect(result).toEqual({ isLaunchable: false, - scopesArray: [], + scopesArray: ['write_products'], name: 'my-app', directory: tmpDir, isEmbedded: false, @@ -3832,3 +3865,258 @@ describe('loadHiddenConfig', () => { }) }) }) + +describe('loadOpaqueApp', () => { + let specifications: ExtensionSpecification[] + + beforeAll(async () => { + specifications = await loadLocalExtensionsSpecifications() + }) + + test('returns loaded-app state when app loads successfully', async () => { + await inTemporaryDirectory(async (tmpDir) => { + // Given - a valid linked app configuration + const config = ` +client_id = "12345" +name = "my-app" +application_url = "https://example.com" +embedded = true + +[webhooks] +api_version = "2023-07" + +[auth] +redirect_urls = ["https://example.com/callback"] + ` + await writeFile(joinPath(tmpDir, 'shopify.app.toml'), config) + await writeFile(joinPath(tmpDir, 'package.json'), '{}') + + // When + const result = await loadOpaqueApp({ + directory: tmpDir, + specifications, + mode: 'report', + }) + + // Then + expect(result.state).toBe('loaded-app') + if (result.state === 'loaded-app') { + expect(result.app).toBeDefined() + expect(result.configuration.client_id).toBe('12345') + } + }) + }) + + test('returns loaded-app state for legacy app configuration', async () => { + await inTemporaryDirectory(async (tmpDir) => { + // Given - a legacy (unlinked) app configuration + const config = ` +scopes = "write_products,read_orders" +name = "my-app" + ` + await writeFile(joinPath(tmpDir, 'shopify.app.toml'), config) + await writeFile(joinPath(tmpDir, 'package.json'), '{}') + + // When + const result = await loadOpaqueApp({ + directory: tmpDir, + specifications, + mode: 'report', + }) + + // Then + expect(result.state).toBe('loaded-app') + if (result.state === 'loaded-app') { + expect(result.app).toBeDefined() + } + }) + }) + + test('returns loaded-template state when config has extra keys that fail strict validation', async () => { + await inTemporaryDirectory(async (tmpDir) => { + // Given - a template with metafield configuration that would fail loadApp + const config = ` +scopes = "write_products" +name = "my-app" + +[product.metafields.app.example] +type = "single_line_text_field" +name = "Example" + ` + await writeFile(joinPath(tmpDir, 'shopify.app.toml'), config) + await writeFile(joinPath(tmpDir, 'package.json'), '{}') + + // When + // Strict mode will cause loadApp to fail due to extra config keys + const result = await loadOpaqueApp({ + directory: tmpDir, + specifications, + mode: 'strict', + }) + + // Then + expect(result.state).toBe('loaded-template') + if (result.state === 'loaded-template') { + expect(result.scopes).toBe('write_products') + expect(result.appDirectory).toBe(normalizePath(tmpDir)) + expect(result.rawConfig).toHaveProperty('product') + } + }) + }) + + test('returns loaded-template with access_scopes format', async () => { + await inTemporaryDirectory(async (tmpDir) => { + // Given - a template with access_scopes format and extra config + const config = ` +name = "my-app" + +[access_scopes] +scopes = "read_orders,write_products" + +[completely_unknown_section] +foo = "bar" + ` + await writeFile(joinPath(tmpDir, 'shopify.app.toml'), config) + await writeFile(joinPath(tmpDir, 'package.json'), '{}') + + // When + const result = await loadOpaqueApp({ + directory: tmpDir, + specifications, + mode: 'strict', + }) + + // Then + expect(result.state).toBe('loaded-template') + if (result.state === 'loaded-template') { + expect(result.scopes).toBe('read_orders,write_products') + } + }) + }) + + test('returns error state when config file does not exist', async () => { + await inTemporaryDirectory(async (tmpDir) => { + // Given - no config file + await writeFile(joinPath(tmpDir, 'package.json'), '{}') + + // When + const result = await loadOpaqueApp({ + directory: tmpDir, + specifications, + mode: 'report', + }) + + // Then + expect(result.state).toBe('error') + }) + }) + + test('preserves all raw config keys in loaded-template state for merging', async () => { + await inTemporaryDirectory(async (tmpDir) => { + // Given - a template with various configuration sections + const config = ` +scopes = "write_products" +name = "my-app" + +[metaobjects.app.author] +name = "Author" + +[metaobjects.app.author.fields.name] +type = "single_line_text_field" + ` + await writeFile(joinPath(tmpDir, 'shopify.app.toml'), config) + await writeFile(joinPath(tmpDir, 'package.json'), '{}') + + // When + const result = await loadOpaqueApp({ + directory: tmpDir, + specifications, + mode: 'strict', + }) + + // Then + expect(result.state).toBe('loaded-template') + if (result.state === 'loaded-template') { + expect(result.rawConfig).toHaveProperty('metaobjects') + expect(result.rawConfig).toHaveProperty('name', 'my-app') + expect(result.rawConfig).toHaveProperty('scopes', 'write_products') + } + }) + }) + + test('uses specified configName parameter when loading', async () => { + await inTemporaryDirectory(async (tmpDir) => { + // Given - a custom config file name + const config = ` +scopes = "write_products" +name = "my-app" + ` + await writeFile(joinPath(tmpDir, 'shopify.app.staging.toml'), config) + await writeFile(joinPath(tmpDir, 'package.json'), '{}') + + // When + const result = await loadOpaqueApp({ + directory: tmpDir, + specifications, + configName: 'shopify.app.staging.toml', + mode: 'report', + }) + + // Then + expect(result.state).toBe('loaded-app') + if (result.state === 'loaded-app') { + expect(result.app).toBeDefined() + } + }) + }) + + test('returns package manager in loaded-template state', async () => { + await inTemporaryDirectory(async (tmpDir) => { + // Given - a template with extra config + const config = ` +scopes = "write_products" +name = "my-app" + +[unknown_section] +foo = "bar" + ` + await writeFile(joinPath(tmpDir, 'shopify.app.toml'), config) + await writeFile(joinPath(tmpDir, 'package.json'), '{}') + + // When + const result = await loadOpaqueApp({ + directory: tmpDir, + specifications, + mode: 'strict', + }) + + // Then + expect(result.state).toBe('loaded-template') + if (result.state === 'loaded-template') { + // Package manager is detected from the environment + expect(typeof result.packageManager).toBe('string') + } + }) + }) + + test('defaults to report mode when mode is not specified', async () => { + await inTemporaryDirectory(async (tmpDir) => { + // Given - a valid config + const config = ` +scopes = "write_products" +name = "my-app" + ` + await writeFile(joinPath(tmpDir, 'shopify.app.toml'), config) + await writeFile(joinPath(tmpDir, 'package.json'), '{}') + + // When - mode is not specified + const result = await loadOpaqueApp({ + directory: tmpDir, + specifications, + }) + + // Then - should still work (defaults to report mode) + expect(result.state).toBe('loaded-app') + }) + }) +}) diff --git a/packages/app/src/cli/models/app/loader.ts b/packages/app/src/cli/models/app/loader.ts index 56672c9b25e..d83e575846c 100644 --- a/packages/app/src/cli/models/app/loader.ts +++ b/packages/app/src/cli/models/app/loader.ts @@ -18,6 +18,8 @@ import { AppLinkedInterface, AppHiddenConfig, isLegacyAppSchema, + TemplateConfigSchema, + getTemplateScopesArray, } from './app.js' import {parseHumanReadableError} from './error-parsing.js' import {configurationFileNames, dotEnvFileNames} from '../../constants.js' @@ -42,6 +44,7 @@ import {readAndParseDotEnv, DotEnvFile} from '@shopify/cli-kit/node/dot-env' import { getDependencies, getPackageManager, + PackageManager, usesWorkspaces as appUsesWorkspaces, } from '@shopify/cli-kit/node/node-package-manager' import {resolveFramework} from '@shopify/cli-kit/node/framework' @@ -214,18 +217,17 @@ export async function checkFolderIsValidApp(directory: string) { } export async function loadConfigForAppCreation(directory: string, name: string): Promise { - const state = await getAppConfigurationState(directory) - const config: AppConfiguration = state.state === 'connected-app' ? state.basicConfiguration : state.startingOptions - const loadedConfiguration = await loadAppConfigurationFromState(state, [], []) + const appDirectory = await getAppDirectory(directory) + const {configurationPath} = await getConfigurationPath(appDirectory, undefined) - const loader = new AppLoader({loadedConfiguration}) - const webs = await loader.loadWebs(directory) - - const isLaunchable = webs.webs.some((web) => isWebType(web, WebType.Frontend) || isWebType(web, WebType.Backend)) + // Use permissive schema to allow templates with extra configuration (metafields, metaobjects, etc.) + const config = await parseConfigurationFile(TemplateConfigSchema, configurationPath) + const webs = await loadWebsForAppCreation(appDirectory, config.web_directories) + const isLaunchable = webs.some((web) => isWebType(web, WebType.Frontend) || isWebType(web, WebType.Backend)) return { isLaunchable, - scopesArray: getAppScopesArray(config), + scopesArray: getTemplateScopesArray(config), name, directory, // By default, and ONLY for `app init`, we consider the app as embedded if it is launchable. @@ -233,6 +235,32 @@ export async function loadConfigForAppCreation(directory: string, name: string): } } +async function loadWebsForAppCreation(appDirectory: string, webDirectories?: string[]): Promise { + const webTomlPaths = await findWebConfigPaths(appDirectory, webDirectories) + return Promise.all(webTomlPaths.map((path) => loadSingleWeb(path))) +} + +async function findWebConfigPaths(appDirectory: string, webDirectories?: string[]): Promise { + const defaultWebDirectory = '**' + const webConfigGlobs = [...(webDirectories ?? [defaultWebDirectory])].map((webGlob) => { + return joinPath(appDirectory, webGlob, configurationFileNames.web) + }) + webConfigGlobs.push(`!${joinPath(appDirectory, '**/node_modules/**')}`) + return glob(webConfigGlobs) +} + +async function loadSingleWeb(webConfigPath: string, abortOrReport: AbortOrReport = abort): Promise { + const config = await parseConfigurationFile(WebConfigurationSchema, webConfigPath, abortOrReport) + const roles = new Set('roles' in config ? config.roles : []) + if ('type' in config) roles.add(config.type) + const {type, ...processedWebConfiguration} = {...config, roles: Array.from(roles), type: undefined} + return { + directory: dirname(webConfigPath), + configuration: processedWebConfiguration, + framework: await resolveFramework(dirname(webConfigPath)), + } +} + /** * Load the local app from the given directory and using the provided extensions/functions specifications. * If the App contains extensions not supported by the current specs and mode is strict, it will throw an error. @@ -257,6 +285,83 @@ export async function loadApp { + // Try to load the app normally first + try { + const app = await loadApp({ + directory: options.directory, + userProvidedConfigName: options.configName, + specifications: options.specifications, + remoteFlags: options.remoteFlags, + mode: options.mode ?? 'report', + }) + return {state: 'loaded-app', app, configuration: app.configuration} + // eslint-disable-next-line no-catch-all/no-catch-all + } catch { + // loadApp failed - try loading as raw template config + try { + const appDirectory = await getAppDirectory(options.directory) + const {configurationPath} = await getConfigurationPath(appDirectory, options.configName) + const rawConfig = await loadConfigurationFileContent(configurationPath) + const parsed = TemplateConfigSchema.parse(rawConfig) + const packageManager = await getPackageManager(appDirectory) + + return { + state: 'loaded-template', + rawConfig, + scopes: getTemplateScopesArray(parsed).join(','), + appDirectory, + packageManager, + } + // eslint-disable-next-line no-catch-all/no-catch-all + } catch { + // Both attempts failed + return {state: 'error'} + } + } +} + export async function reloadApp(app: AppLinkedInterface): Promise { const state = await getAppConfigurationState(app.directory, basename(app.configuration.path)) if (state.state !== 'connected-app') { @@ -395,14 +500,8 @@ class AppLoader { - const defaultWebDirectory = '**' - const webConfigGlobs = [...(webDirectories ?? [defaultWebDirectory])].map((webGlob) => { - return joinPath(appDirectory, webGlob, configurationFileNames.web) - }) - webConfigGlobs.push(`!${joinPath(appDirectory, '**/node_modules/**')}`) - const webTomlPaths = await glob(webConfigGlobs) - - const webs = await Promise.all(webTomlPaths.map((path) => this.loadWeb(path))) + const webTomlPaths = await findWebConfigPaths(appDirectory, webDirectories) + const webs = await Promise.all(webTomlPaths.map((path) => loadSingleWeb(path, this.abortOrReport.bind(this)))) this.validateWebs(webs) const webTomlsInStandardLocation = await glob(joinPath(appDirectory, `web/**/${configurationFileNames.web}`)) @@ -446,18 +545,6 @@ class AppLoader { - const config = await this.parseConfigurationFile(WebConfigurationSchema, WebConfigurationFile) - const roles = new Set('roles' in config ? config.roles : []) - if ('type' in config) roles.add(config.type) - const {type, ...processedWebConfiguration} = {...config, roles: Array.from(roles), type: undefined} - return { - directory: dirname(WebConfigurationFile), - configuration: processedWebConfiguration, - framework: await resolveFramework(dirname(WebConfigurationFile)), - } - } - private async createExtensionInstance( type: string, configurationObject: object, @@ -999,7 +1086,7 @@ async function checkIfGitTracked(appDirectory: string, configurationPath: string return isTracked } -async function getConfigurationPath(appDirectory: string, configName: string | undefined) { +export async function getConfigurationPath(appDirectory: string, configName: string | undefined) { const configurationFileName = getAppConfigurationFileName(configName) const configurationPath = joinPath(appDirectory, configurationFileName) @@ -1016,7 +1103,7 @@ async function getConfigurationPath(appDirectory: string, configName: string | u * * @param directory - The current working directory, or the `--path` option */ -async function getAppDirectory(directory: string) { +export async function getAppDirectory(directory: string) { if (!(await fileExists(directory))) { throw new AbortError(outputContent`Couldn't find directory ${outputToken.path(directory)}`) } diff --git a/packages/app/src/cli/services/app/config/link-service.test.ts b/packages/app/src/cli/services/app/config/link-service.test.ts index b718461d2c5..361061d4233 100644 --- a/packages/app/src/cli/services/app/config/link-service.test.ts +++ b/packages/app/src/cli/services/app/config/link-service.test.ts @@ -127,4 +127,104 @@ embedded = false expect(content).toEqual(expectedContent) }) }) + + test('preserves template metafield config when creating new app', async () => { + await inTemporaryDirectory(async (tmp) => { + // Template with metafield configuration that doesn't fit standard schemas + const initialContent = ` +scopes = "write_products" + +[product.metafields.app.demo_info] +type = "single_line_text_field" +name = "Demo Source Info" +description = "Tracks products created by the Shopify app template" + +[webhooks] +api_version = "2025-07" + + [[webhooks.subscriptions]] + uri = "/webhooks/app/uninstalled" + topics = ["app/uninstalled"] + + [[webhooks.subscriptions]] + topics = [ "app/scopes_update" ] + uri = "/webhooks/app/scopes_update" +` + const filePath = joinPath(tmp, 'shopify.app.toml') + writeFileSync(filePath, initialContent) + writeFileSync(joinPath(tmp, 'package.json'), '{}') + + const developerPlatformClient = buildDeveloperPlatformClient() + vi.mocked(selectDeveloperPlatformClient).mockReturnValue(developerPlatformClient) + vi.mocked(createAsNewAppPrompt).mockResolvedValue(true) + vi.mocked(appNamePrompt).mockResolvedValue('My App') + vi.mocked(selectOrganizationPrompt).mockResolvedValue({ + id: '12345', + businessName: 'test', + source: OrganizationSource.BusinessPlatform, + }) + + const options = { + directory: tmp, + developerPlatformClient, + } + await link(options, false) + const content = await readFile(joinPath(tmp, 'shopify.app.toml')) + + // Verify metafield config is preserved + expect(content).toContain('[product.metafields.app.demo_info]') + expect(content).toContain('type = "single_line_text_field"') + expect(content).toContain('name = "Demo Source Info"') + + // Verify webhook subscriptions are preserved + expect(content).toContain('[[webhooks.subscriptions]]') + expect(content).toContain('uri = "/webhooks/app/uninstalled"') + expect(content).toContain('topics = [ "app/uninstalled" ]') + expect(content).toContain('uri = "/webhooks/app/scopes_update"') + }) + }) + + test('preserves template metaobject config when creating new app', async () => { + await inTemporaryDirectory(async (tmp) => { + // Template with metaobject configuration + const initialContent = ` +scopes = "write_products" + +[metaobjects.app.author] +name = "Author" +description = "Content author" + +[metaobjects.app.author.fields.name] +type = "single_line_text_field" +name = "Author Name" +required = true +` + const filePath = joinPath(tmp, 'shopify.app.toml') + writeFileSync(filePath, initialContent) + writeFileSync(joinPath(tmp, 'package.json'), '{}') + + const developerPlatformClient = buildDeveloperPlatformClient() + vi.mocked(selectDeveloperPlatformClient).mockReturnValue(developerPlatformClient) + vi.mocked(createAsNewAppPrompt).mockResolvedValue(true) + vi.mocked(appNamePrompt).mockResolvedValue('My App') + vi.mocked(selectOrganizationPrompt).mockResolvedValue({ + id: '12345', + businessName: 'test', + source: OrganizationSource.BusinessPlatform, + }) + + const options = { + directory: tmp, + developerPlatformClient, + } + await link(options, false) + const content = await readFile(joinPath(tmp, 'shopify.app.toml')) + + // Verify metaobject config is preserved + expect(content).toContain('[metaobjects.app.author]') + expect(content).toContain('name = "Author"') + expect(content).toContain('[metaobjects.app.author.fields.name]') + expect(content).toContain('type = "single_line_text_field"') + }) + }) }) diff --git a/packages/app/src/cli/services/app/config/link.test.ts b/packages/app/src/cli/services/app/config/link.test.ts index 15735c34b2d..e0d9ccaa684 100644 --- a/packages/app/src/cli/services/app/config/link.test.ts +++ b/packages/app/src/cli/services/app/config/link.test.ts @@ -7,7 +7,7 @@ import { testDeveloperPlatformClient, } from '../../../models/app/app.test-data.js' import {selectConfigName} from '../../../prompts/config.js' -import {loadApp} from '../../../models/app/loader.js' +import {loadApp, loadOpaqueApp} from '../../../models/app/loader.js' import {InvalidApiKeyErrorMessage, fetchOrCreateOrganizationApp, appFromIdentifiers} from '../../context.js' import {getCachedCommandInfo} from '../../local-storage.js' import {AppInterface, CurrentAppConfiguration} from '../../../models/app/app.js' @@ -29,6 +29,7 @@ vi.mock('../../../models/app/loader.js', async () => { ...loader, loadApp: vi.fn(), loadAppConfiguration: vi.fn(), + loadOpaqueApp: vi.fn(), } }) vi.mock('../../local-storage') @@ -75,7 +76,7 @@ describe('link', () => { configName: 'Default value', developerPlatformClient, } - vi.mocked(loadApp).mockResolvedValue(await mockApp(tmp)) + await mockLoadOpaqueAppWithApp(tmp) vi.mocked(fetchOrCreateOrganizationApp).mockResolvedValue(mockRemoteApp({developerPlatformClient})) // When @@ -132,7 +133,7 @@ describe('link', () => { client_id = "${remoteApp.apiKey}" ` writeFileSync(filePath, initialContent) - vi.mocked(loadApp).mockResolvedValue(await mockApp(tmp, undefined, [], 'current')) + await mockLoadOpaqueAppWithApp(tmp, undefined, [], 'current') vi.mocked(fetchOrCreateOrganizationApp).mockResolvedValue(remoteApp) // When @@ -194,7 +195,7 @@ embedded = false directory: tmp, developerPlatformClient, } - vi.mocked(loadApp).mockRejectedValue('App not found') + mockLoadOpaqueAppWithError() const apiClientConfiguration = { title: 'new-title', applicationUrl: 'https://api-client-config.com', @@ -312,7 +313,7 @@ embedded = false directory: tmp, developerPlatformClient, } - vi.mocked(loadApp).mockRejectedValue('App not found') + mockLoadOpaqueAppWithError() const apiClientConfiguration = { title: 'new-title', applicationUrl: 'https://api-client-config.com', @@ -466,7 +467,7 @@ url = "https://api-client-config.com/preferences" }, } as CurrentAppConfiguration, } - vi.mocked(loadApp).mockResolvedValue(await mockApp(tmp, localApp, [], 'current')) + await mockLoadOpaqueAppWithApp(tmp, localApp, [], 'current') vi.mocked(fetchOrCreateOrganizationApp).mockResolvedValue( testOrganizationApp({ apiKey: '12345', @@ -584,7 +585,7 @@ embedded = false }, }, } - vi.mocked(loadApp).mockResolvedValue(await mockApp(tmp, localApp, [], 'current')) + await mockLoadOpaqueAppWithApp(tmp, localApp, [], 'current') vi.mocked(fetchOrCreateOrganizationApp).mockResolvedValue( testOrganizationApp({ apiKey: 'different-api-key', @@ -660,7 +661,7 @@ embedded = false directory: tmp, developerPlatformClient, } - vi.mocked(loadApp).mockResolvedValue(await mockApp(tmp)) + await mockLoadOpaqueAppWithApp(tmp) vi.mocked(fetchOrCreateOrganizationApp).mockResolvedValue(mockRemoteApp({developerPlatformClient})) // When @@ -741,7 +742,7 @@ embedded = false directory: tmp, developerPlatformClient, } - vi.mocked(loadApp).mockResolvedValue(await mockApp(tmp)) + await mockLoadOpaqueAppWithApp(tmp) vi.mocked(fetchOrCreateOrganizationApp).mockResolvedValue(mockRemoteApp({developerPlatformClient})) // When @@ -804,7 +805,7 @@ embedded = false apiKey: 'api-key', developerPlatformClient, } - vi.mocked(loadApp).mockResolvedValue(await mockApp(tmp)) + await mockLoadOpaqueAppWithApp(tmp) vi.mocked(selectConfigName).mockResolvedValue('shopify.app.staging.toml') vi.mocked(appFromIdentifiers).mockImplementation(async ({apiKey}: {apiKey: string}) => { return (await developerPlatformClient.appFromIdentifiers(apiKey))! @@ -853,7 +854,7 @@ embedded = false apiKey: 'wrong-api-key', developerPlatformClient, } - vi.mocked(loadApp).mockResolvedValue(await mockApp(tmp)) + await mockLoadOpaqueAppWithApp(tmp) vi.mocked(selectConfigName).mockResolvedValue('shopify.app.staging.toml') // When @@ -884,7 +885,7 @@ embedded = false }, } as CurrentAppConfiguration, } - vi.mocked(loadApp).mockResolvedValue(await mockApp(tmp, localApp)) + await mockLoadOpaqueAppWithApp(tmp, localApp) vi.mocked(fetchOrCreateOrganizationApp).mockResolvedValue( testOrganizationApp({ apiKey: '12345', @@ -955,7 +956,7 @@ embedded = false directory: tmp, developerPlatformClient, } - vi.mocked(loadApp).mockRejectedValue(new Error('Shopify.app.toml not found')) + mockLoadOpaqueAppWithError() vi.mocked(fetchOrCreateOrganizationApp).mockResolvedValue(mockRemoteApp({developerPlatformClient})) // When @@ -1015,7 +1016,7 @@ embedded = false directory: tmp, developerPlatformClient, } - vi.mocked(loadApp).mockResolvedValue(await mockApp(tmp)) + await mockLoadOpaqueAppWithApp(tmp) vi.mocked(fetchOrCreateOrganizationApp).mockResolvedValue(mockRemoteApp({developerPlatformClient})) const remoteConfiguration = { ...DEFAULT_REMOTE_CONFIGURATION, @@ -1106,7 +1107,7 @@ embedded = false developerPlatformClient, } - vi.mocked(loadApp).mockRejectedValue('App not found') + mockLoadOpaqueAppWithError() vi.mocked(fetchOrCreateOrganizationApp).mockResolvedValue(mockRemoteApp({developerPlatformClient})) const remoteConfiguration = { ...DEFAULT_REMOTE_CONFIGURATION, @@ -1246,6 +1247,7 @@ embedded = false developerPlatformClient, } + await mockLoadOpaqueAppWithApp(tmp) vi.mocked(fetchOrCreateOrganizationApp).mockResolvedValue(mockRemoteApp({developerPlatformClient})) const remoteConfiguration = { ...DEFAULT_REMOTE_CONFIGURATION, @@ -1274,6 +1276,7 @@ embedded = false const expectedContent = `# Learn more about configuring your app at https://shopify.dev/docs/apps/tools/cli/configuration client_id = "12345" +extension_directories = [ ] name = "app1" application_url = "https://my-app-url.com" embedded = true @@ -1301,6 +1304,7 @@ embedded = false name: 'app1', application_url: 'https://my-app-url.com', embedded: true, + extension_directories: [], access_scopes: { use_legacy_install_flow: true, }, @@ -1320,7 +1324,6 @@ embedded = false pos: { embedded: false, }, - scopes: undefined, path: expect.stringMatching(/\/shopify.app.toml$/), }) expect(content).toEqual(expectedContent) @@ -1348,7 +1351,7 @@ embedded = false embedded: true, }, } - vi.mocked(loadApp).mockResolvedValue(await mockApp(tmp, localApp, [], 'current')) + await mockLoadOpaqueAppWithApp(tmp, localApp, [], 'current') vi.mocked(fetchOrCreateOrganizationApp).mockResolvedValue( testOrganizationApp({ apiKey: '12345', @@ -1430,7 +1433,7 @@ embedded = true directory: tmp, developerPlatformClient, } - vi.mocked(loadApp).mockResolvedValue(await mockApp(tmp)) + await mockLoadOpaqueAppWithApp(tmp) vi.mocked(fetchOrCreateOrganizationApp).mockResolvedValue({ ...mockRemoteApp(), newApp: true, @@ -1482,7 +1485,7 @@ embedded = false directory: tmp, developerPlatformClient, } - vi.mocked(loadApp).mockResolvedValue(await mockApp(tmp)) + await mockLoadOpaqueAppWithApp(tmp) vi.mocked(fetchOrCreateOrganizationApp).mockResolvedValue({ ...mockRemoteApp(), newApp: true, @@ -1531,7 +1534,7 @@ embedded = false directory: tmp, developerPlatformClient, } - vi.mocked(loadApp).mockResolvedValue(await mockApp(tmp)) + await mockLoadOpaqueAppWithApp(tmp) vi.mocked(fetchOrCreateOrganizationApp).mockResolvedValue(mockRemoteApp({developerPlatformClient})) const remoteConfiguration = { ...DEFAULT_REMOTE_CONFIGURATION, @@ -1579,7 +1582,7 @@ embedded = false directory: tmp, developerPlatformClient, } - vi.mocked(loadApp).mockResolvedValue(await mockApp(tmp)) + await mockLoadOpaqueAppWithApp(tmp) vi.mocked(fetchOrCreateOrganizationApp).mockResolvedValue(mockRemoteApp({developerPlatformClient})) const remoteConfiguration = { ...DEFAULT_REMOTE_CONFIGURATION, @@ -1649,7 +1652,7 @@ embedded = false } as CurrentAppConfiguration, } - vi.mocked(loadApp).mockResolvedValue(await mockApp(tmp, localApp, [], 'current')) + await mockLoadOpaqueAppWithApp(tmp, localApp, [], 'current') vi.mocked(fetchOrCreateOrganizationApp).mockResolvedValue(mockRemoteApp({developerPlatformClient})) const remoteConfiguration = { ...DEFAULT_REMOTE_CONFIGURATION, @@ -1728,7 +1731,7 @@ embedded = false } as CurrentAppConfiguration, } - vi.mocked(loadApp).mockResolvedValue(await mockApp(tmp, localApp, [], 'current')) + await mockLoadOpaqueAppWithApp(tmp, localApp, [], 'current') vi.mocked(fetchOrCreateOrganizationApp).mockResolvedValue(mockRemoteApp({developerPlatformClient})) const remoteConfiguration = { ...DEFAULT_REMOTE_CONFIGURATION, @@ -1807,7 +1810,7 @@ embedded = false } as CurrentAppConfiguration, } - vi.mocked(loadApp).mockResolvedValue(await mockApp(tmp, localApp, [], 'current')) + await mockLoadOpaqueAppWithApp(tmp, localApp, [], 'current') vi.mocked(appFromIdentifiers).mockResolvedValue(mockRemoteApp({developerPlatformClient})) const remoteConfiguration = { ...DEFAULT_REMOTE_CONFIGURATION, @@ -1872,6 +1875,34 @@ async function mockApp( return localApp } +/** + * Helper to mock loadOpaqueApp with a successful app load result. + * Call this instead of mocking loadApp directly, as loadLocalAppOptions now uses loadOpaqueApp. + */ +async function mockLoadOpaqueAppWithApp( + directory: string, + app?: Partial, + flags = [], + schemaType: 'current' | 'legacy' = 'legacy', +) { + const mockedApp = await mockApp(directory, app, flags, schemaType) + vi.mocked(loadOpaqueApp).mockResolvedValue({ + state: 'loaded-app', + app: mockedApp, + configuration: mockedApp.configuration, + }) + // Also mock loadApp for backward compatibility with getAppCreationDefaultsFromLocalApp + vi.mocked(loadApp).mockResolvedValue(mockedApp) +} + +/** + * Helper to mock loadOpaqueApp with an error state (app couldn't be loaded). + */ +function mockLoadOpaqueAppWithError() { + vi.mocked(loadOpaqueApp).mockResolvedValue({state: 'error'}) + vi.mocked(loadApp).mockRejectedValue(new Error('App not found')) +} + function mockRemoteApp(extraRemoteAppFields: Partial = {}) { const remoteApp = testOrganizationApp() remoteApp.apiKey = '12345' diff --git a/packages/app/src/cli/services/app/config/link.ts b/packages/app/src/cli/services/app/config/link.ts index 2bf41763e7c..12997e2d39e 100644 --- a/packages/app/src/cli/services/app/config/link.ts +++ b/packages/app/src/cli/services/app/config/link.ts @@ -6,7 +6,6 @@ import { isCurrentAppSchema, CliBuildPreferences, getAppScopes, - LegacyAppConfiguration, } from '../../../models/app/app.js' import {OrganizationApp} from '../../../models/organization.js' import {selectConfigName} from '../../../prompts/config.js' @@ -15,6 +14,7 @@ import { AppConfigurationStateLinked, getAppConfigurationFileName, loadApp, + loadOpaqueApp, } from '../../../models/app/loader.js' import { fetchOrCreateOrganizationApp, @@ -189,6 +189,11 @@ async function getAppCreationDefaultsFromLocalApp(options: LinkOptions): Promise } } +// Allows both parsed configs and raw templates with extra keys (metafields, etc.) +interface ExistingConfig { + [key: string]: unknown +} + type LocalAppOptions = | { state: 'legacy' @@ -196,7 +201,7 @@ type LocalAppOptions = scopes: string localAppIdMatchedRemote: false existingBuildOptions: undefined - existingConfig: LegacyAppConfiguration + existingConfig: ExistingConfig appDirectory: string packageManager: PackageManager } @@ -206,7 +211,7 @@ type LocalAppOptions = scopes: string localAppIdMatchedRemote: true existingBuildOptions: CliBuildPreferences - existingConfig: CurrentAppConfiguration + existingConfig: ExistingConfig appDirectory: string packageManager: PackageManager } @@ -236,6 +241,9 @@ type LocalAppOptions = * The existing config is only re-used if the app is in the current format and the client_id in the config file * matches that of the selected remote app, or the existing config is in legacy/freshly minted template format. * + * Uses loadOpaqueApp internally to handle templates with extra configuration keys (metafields, metaobjects, etc.) + * that don't fit standard schemas. + * * @param specifications - Module specs to use for loading. These must have come from the platform. * @returns Either a loaded app, or some placeholder data */ @@ -245,63 +253,77 @@ export async function loadLocalAppOptions( remoteFlags: Flag[], remoteAppApiKey: string, ): Promise { - // Though we already loaded the app once, we have to go again now that we have the remote aware specifications in - // place. We didn't have them earlier. - try { - const app = await loadApp({ - specifications, - directory: options.directory, - mode: 'report', - userProvidedConfigName: options.configName, - remoteFlags, - }) - const configuration = app.configuration + const result = await loadOpaqueApp({ + directory: options.directory, + configName: options.configName, + specifications, + remoteFlags, + mode: 'report', + }) - if (!isCurrentAppSchema(configuration)) { + switch (result.state) { + case 'loaded-app': { + const {app, configuration} = result + + if (!isCurrentAppSchema(configuration)) { + return { + state: 'legacy', + configFormat: 'legacy', + scopes: getAppScopes(configuration), + localAppIdMatchedRemote: false, + existingBuildOptions: undefined, + existingConfig: configuration, + appDirectory: app.directory, + packageManager: app.packageManager, + } + } else if (configuration.client_id === remoteAppApiKey || options.isNewApp) { + return { + state: 'reusable-current-app', + configFormat: 'current', + scopes: getAppScopes(configuration), + localAppIdMatchedRemote: true, + existingBuildOptions: configuration.build, + existingConfig: {...configuration}, + appDirectory: app.directory, + packageManager: app.packageManager, + } + } + return { + state: 'unable-to-reuse-current-config', + configFormat: 'current', + scopes: '', + localAppIdMatchedRemote: true, + appDirectory: undefined, + existingBuildOptions: undefined, + existingConfig: undefined, + packageManager: 'npm', + } + } + + case 'loaded-template': + // Template with extra config keys (metafields, etc.) - treat as legacy for linking return { state: 'legacy', configFormat: 'legacy', - scopes: getAppScopes(configuration), + scopes: result.scopes, localAppIdMatchedRemote: false, existingBuildOptions: undefined, - existingConfig: configuration as LegacyAppConfiguration, - appDirectory: app.directory, - packageManager: app.packageManager, + existingConfig: result.rawConfig, + appDirectory: result.appDirectory, + packageManager: result.packageManager, } - } else if (app.configuration.client_id === remoteAppApiKey || options.isNewApp) { + + case 'error': return { - state: 'reusable-current-app', - configFormat: 'current', - scopes: getAppScopes(configuration), - localAppIdMatchedRemote: true, - existingBuildOptions: configuration.build, - existingConfig: configuration, - appDirectory: app.directory, - packageManager: app.packageManager, + state: 'unable-to-load-config', + configFormat: 'legacy', + scopes: '', + localAppIdMatchedRemote: false, + appDirectory: undefined, + existingBuildOptions: undefined, + existingConfig: undefined, + packageManager: 'npm', } - } - return { - state: 'unable-to-reuse-current-config', - configFormat: 'current', - scopes: '', - localAppIdMatchedRemote: true, - appDirectory: undefined, - existingBuildOptions: undefined, - existingConfig: undefined, - packageManager: 'npm', - } - // eslint-disable-next-line no-catch-all/no-catch-all - } catch (error) { - return { - state: 'unable-to-load-config', - configFormat: 'legacy', - scopes: '', - localAppIdMatchedRemote: false, - appDirectory: undefined, - existingBuildOptions: undefined, - existingConfig: undefined, - packageManager: 'npm', - } } }