diff --git a/packages/app/src/cli/commands/app/config/link.ts b/packages/app/src/cli/commands/app/config/link.ts index 91bd549aff0..1783c844eba 100644 --- a/packages/app/src/cli/commands/app/config/link.ts +++ b/packages/app/src/cli/commands/app/config/link.ts @@ -34,7 +34,7 @@ export default class ConfigLink extends AppLinkedCommand { directory: flags.path, clientId: undefined, forceRelink: false, - userProvidedConfigName: result.state.configurationFileName, + userProvidedConfigName: result.configFileName, }) return {app} diff --git a/packages/app/src/cli/models/app/config-file-naming.ts b/packages/app/src/cli/models/app/config-file-naming.ts new file mode 100644 index 00000000000..8d8823631e0 --- /dev/null +++ b/packages/app/src/cli/models/app/config-file-naming.ts @@ -0,0 +1,42 @@ +import {configurationFileNames} from '../../constants.js' +import {slugify} from '@shopify/cli-kit/common/string' +import {basename} from '@shopify/cli-kit/node/path' + +const appConfigurationFileNameRegex = /^shopify\.app(\.[-\w]+)?\.toml$/ +export type AppConfigurationFileName = 'shopify.app.toml' | `shopify.app.${string}.toml` + +/** + * Gets the name of the app configuration file (e.g. `shopify.app.production.toml`) based on a provided config name. + * + * @param configName - Optional config name to base the file name upon + * @returns Either the default app configuration file name (`shopify.app.toml`), the given config name (if it matched the valid format), or `shopify.app..toml` if it was an arbitrary string + */ +export function getAppConfigurationFileName(configName?: string): AppConfigurationFileName { + if (!configName) { + return configurationFileNames.app + } + + if (isValidFormatAppConfigurationFileName(configName)) { + return configName + } else { + return `shopify.app.${slugify(configName)}.toml` + } +} + +/** + * Given a path to an app configuration file, extract the shorthand section from the file name. + * + * This is undefined for `shopify.app.toml` files, or returns e.g. `production` for `shopify.app.production.toml`. + */ +export function getAppConfigurationShorthand(path: string) { + const match = basename(path).match(appConfigurationFileNameRegex) + return match?.[1]?.slice(1) +} + +/** Checks if configName is a valid one (`shopify.app.toml`, or `shopify.app..toml`) */ +export function isValidFormatAppConfigurationFileName(configName: string): configName is AppConfigurationFileName { + if (appConfigurationFileNameRegex.test(configName)) { + return true + } + return false +} diff --git a/packages/app/src/cli/models/app/loader.test.ts b/packages/app/src/cli/models/app/loader.test.ts index 7b44163ed3f..d5c3c321930 100644 --- a/packages/app/src/cli/models/app/loader.test.ts +++ b/packages/app/src/cli/models/app/loader.test.ts @@ -3,17 +3,15 @@ import { getAppConfigurationFileName, loadApp, loadOpaqueApp, - loadDotEnv, parseConfigurationObject, checkFolderIsValidApp, AppLoaderMode, - getAppConfigurationState, + getAppConfigurationContext, loadConfigForAppCreation, reloadApp, - loadHiddenConfig, } from './loader.js' import {parseHumanReadableError} from './error-parsing.js' -import {App, AppConfiguration, AppInterface, AppLinkedInterface, AppSchema, WebConfigurationSchema} from './app.js' +import {App, AppInterface, AppLinkedInterface, AppSchema, WebConfigurationSchema} from './app.js' import {DEFAULT_CONFIG, buildVersionedAppSchema, getWebhookConfig} from './app.test-data.js' import {ExtensionInstance} from '../extensions/extension-instance.js' import {configurationFileNames, blocks} from '../../constants.js' @@ -33,7 +31,7 @@ import { PackageJson, pnpmWorkspaceFile, } from '@shopify/cli-kit/node/node-package-manager' -import {inTemporaryDirectory, moveFile, mkdir, mkTmpDir, rmdir, writeFile, readFile} from '@shopify/cli-kit/node/fs' +import {inTemporaryDirectory, moveFile, mkdir, mkTmpDir, rmdir, writeFile} from '@shopify/cli-kit/node/fs' import {joinPath, dirname, cwd, normalizePath} from '@shopify/cli-kit/node/path' import {platformAndArch} from '@shopify/cli-kit/node/os' import {outputContent, outputToken} from '@shopify/cli-kit/node/output' @@ -256,7 +254,7 @@ describe('load', () => { // When/Then await expect(loadApp({directory: tmp, specifications, userProvidedConfigName: undefined})).rejects.toThrow( - `Couldn't find directory ${tmp}`, + /Could not find a Shopify app configuration file/, ) }) }) @@ -267,7 +265,7 @@ describe('load', () => { // When/Then await expect(loadApp({directory: currentDir, specifications, userProvidedConfigName: undefined})).rejects.toThrow( - `Couldn't find an app toml file at ${currentDir}`, + /Could not find a Shopify app configuration file/, ) }) @@ -485,7 +483,7 @@ describe('load', () => { await makeBlockDir({name: 'my-extension'}) // When - await expect(loadTestingApp()).rejects.toThrow(/Couldn't find an app toml file at/) + await expect(loadTestingApp()).rejects.toThrow(/Could not find a Shopify app configuration file/) }) test('throws an error if the extension configuration file is invalid', async () => { @@ -1058,7 +1056,7 @@ describe('load', () => { await makeBlockDir({name: 'my-functions'}) // When - await expect(loadTestingApp()).rejects.toThrow(/Couldn't find an app toml file at/) + await expect(loadTestingApp()).rejects.toThrow(/Could not find a Shopify app configuration file/) }) test('throws an error if the function configuration file is invalid', async () => { @@ -2813,46 +2811,6 @@ describe('getAppConfigurationShorthand', () => { }) }) -describe('loadDotEnv', () => { - test('it returns undefined if the env is missing', async () => { - await inTemporaryDirectory(async (tmp) => { - // When - const got = await loadDotEnv(tmp, joinPath(tmp, 'shopify.app.toml')) - - // Then - expect(got).toBeUndefined() - }) - }) - - test('it loads from the default env file', async () => { - await inTemporaryDirectory(async (tmp) => { - // Given - await writeFile(joinPath(tmp, '.env'), 'FOO="bar"') - - // When - const got = await loadDotEnv(tmp, joinPath(tmp, 'shopify.app.toml')) - - // Then - expect(got).toBeDefined() - expect(got!.variables.FOO).toEqual('bar') - }) - }) - - test('it loads from the config specific env file', async () => { - await inTemporaryDirectory(async (tmp) => { - // Given - await writeFile(joinPath(tmp, '.env.staging'), 'FOO="bar"') - - // When - const got = await loadDotEnv(tmp, joinPath(tmp, 'shopify.app.staging.toml')) - - // Then - expect(got).toBeDefined() - expect(got!.variables.FOO).toEqual('bar') - }) - }) -}) - describe('checkFolderIsValidApp', () => { test('throws an error if the folder does not contain a shopify.app.toml file', async () => { await inTemporaryDirectory(async (tmp) => { @@ -3484,46 +3442,26 @@ describe('WebhooksSchema', () => { } }) -describe('getAppConfigurationState', () => { +describe('getAppConfigurationContext', () => { test.each([ - [ - `client_id="abcdef"`, - { - basicConfiguration: { - client_id: 'abcdef', - }, - isLinked: true, - }, - ], + [`client_id="abcdef"`, {client_id: 'abcdef'}, true], [ `client_id="abcdef" something_extra="keep"`, - { - basicConfiguration: { - client_id: 'abcdef', - something_extra: 'keep', - }, - isLinked: true, - }, + {client_id: 'abcdef', something_extra: 'keep'}, + true, ], - [ - `client_id=""`, - { - basicConfiguration: { - client_id: '', - }, - isLinked: false, - }, - ], - ])('loads from %s', async (content, resultShouldContain) => { + [`client_id=""`, {client_id: ''}, false], + ])('loads from %s', async (content, expectedContent, expectedIsLinked) => { await inTemporaryDirectory(async (tmpDir) => { const appConfigPath = joinPath(tmpDir, 'shopify.app.toml') const packageJsonPath = joinPath(tmpDir, 'package.json') await writeFile(appConfigPath, content) await writeFile(packageJsonPath, '{}') - const state = await getAppConfigurationState(tmpDir, undefined) - expect(state).toMatchObject(resultShouldContain) + const {activeConfig} = await getAppConfigurationContext(tmpDir, undefined) + expect(activeConfig.file.content).toMatchObject(expectedContent) + expect(activeConfig.isLinked).toBe(expectedIsLinked) }) }) @@ -3535,10 +3473,10 @@ describe('getAppConfigurationState', () => { await writeFile(appConfigPath, content) await writeFile(packageJsonPath, '{}') - const result = await getAppConfigurationState(tmpDir, undefined) + const {activeConfig} = await getAppConfigurationContext(tmpDir, undefined) - expect(result.basicConfiguration.client_id).toBe('') - expect(result.isLinked).toBe(false) + expect(activeConfig.file.content.client_id).toBe('') + expect(activeConfig.isLinked).toBe(false) }) }) }) @@ -3683,117 +3621,6 @@ value = true }) }) -describe('loadHiddenConfig', () => { - test('returns empty object if hidden config file does not exist', async () => { - await inTemporaryDirectory(async (tmpDir) => { - // Given - const configuration = { - client_id: '12345', - } as AppConfiguration - await writeFile(joinPath(tmpDir, '.gitignore'), '') - - // When - const got = await loadHiddenConfig(tmpDir, configuration) - - // Then - expect(got).toEqual({}) - - // Verify empty config file was created - const hiddenConfigPath = joinPath(tmpDir, '.shopify', 'project.json') - const fileContent = await readFile(hiddenConfigPath) - expect(JSON.parse(fileContent)).toEqual({}) - }) - }) - - test('returns config for client_id if hidden config file exists', async () => { - await inTemporaryDirectory(async (tmpDir) => { - // Given - const configuration = { - client_id: '12345', - } as AppConfiguration - const hiddenConfigPath = joinPath(tmpDir, '.shopify', 'project.json') - await mkdir(dirname(hiddenConfigPath)) - await writeFile( - hiddenConfigPath, - JSON.stringify({ - '12345': {someKey: 'someValue'}, - 'other-id': {otherKey: 'otherValue'}, - }), - ) - - // When - const got = await loadHiddenConfig(tmpDir, configuration) - - // Then - expect(got).toEqual({someKey: 'someValue'}) - }) - }) - - test('returns empty object if client_id not found in existing hidden config', async () => { - await inTemporaryDirectory(async (tmpDir) => { - // Given - const configuration = { - client_id: 'not-found', - } as AppConfiguration - const hiddenConfigPath = joinPath(tmpDir, '.shopify', 'project.json') - await mkdir(dirname(hiddenConfigPath)) - await writeFile( - hiddenConfigPath, - JSON.stringify({ - 'other-id': {someKey: 'someValue'}, - }), - ) - - // When - const got = await loadHiddenConfig(tmpDir, configuration) - - // Then - expect(got).toEqual({}) - }) - }) - - test('returns config if hidden config has an old format with just a dev_store_url', async () => { - await inTemporaryDirectory(async (tmpDir) => { - // Given - const configuration = { - client_id: 'not-found', - } as AppConfiguration - const hiddenConfigPath = joinPath(tmpDir, '.shopify', 'project.json') - await mkdir(dirname(hiddenConfigPath)) - await writeFile( - hiddenConfigPath, - JSON.stringify({ - dev_store_url: 'https://dev-store.myshopify.com', - }), - ) - - // When - const got = await loadHiddenConfig(tmpDir, configuration) - - // Then - expect(got).toEqual({dev_store_url: 'https://dev-store.myshopify.com'}) - }) - }) - - test('returns empty object if hidden config file is invalid JSON', async () => { - await inTemporaryDirectory(async (tmpDir) => { - // Given - const configuration = { - client_id: '12345', - } as AppConfiguration - const hiddenConfigPath = joinPath(tmpDir, '.shopify', 'project.json') - await mkdir(dirname(hiddenConfigPath)) - await writeFile(hiddenConfigPath, 'invalid json') - - // When - const got = await loadHiddenConfig(tmpDir, configuration) - - // Then - expect(got).toEqual({}) - }) - }) -}) - describe('loadOpaqueApp', () => { let specifications: ExtensionSpecification[] diff --git a/packages/app/src/cli/models/app/loader.ts b/packages/app/src/cli/models/app/loader.ts index 2521101367c..6ab9533be53 100644 --- a/packages/app/src/cli/models/app/loader.ts +++ b/packages/app/src/cli/models/app/loader.ts @@ -6,58 +6,52 @@ import { WebType, getAppScopesArray, AppConfigurationInterface, - AppConfiguration, CurrentAppConfiguration, getAppVersionedSchema, AppSchema, - BasicAppConfigurationWithoutModules, SchemaForConfig, AppLinkedInterface, - AppHiddenConfig, } from './app.js' import {parseHumanReadableError} from './error-parsing.js' +import { + getAppConfigurationFileName, + getAppConfigurationShorthand, + type AppConfigurationFileName, +} from './config-file-naming.js' import {configurationFileNames, dotEnvFileNames} from '../../constants.js' import metadata from '../../metadata.js' import {ExtensionInstance} from '../extensions/extension-instance.js' import {ExtensionsArraySchema, UnifiedSchema} from '../extensions/schemas.js' -import { - ExtensionSpecification, - RemoteAwareExtensionSpecification, - isAppConfigSpecification, -} from '../extensions/specification.js' -import {getCachedAppInfo} from '../../services/local-storage.js' -import use from '../../services/app/config/use.js' +import {ExtensionSpecification, isAppConfigSpecification} from '../extensions/specification.js' import {CreateAppOptions, Flag} from '../../utilities/developer-platform-client.js' import {findConfigFiles} from '../../prompts/config.js' import {WebhookSubscriptionSpecIdentifier} from '../extensions/specifications/app_config_webhook_subscription.js' import {WebhooksSchema} from '../extensions/specifications/app_config_webhook_schemas/webhooks_schema.js' -import {loadLocalExtensionsSpecifications} from '../extensions/load-specifications.js' -import {patchAppHiddenConfigFile} from '../../services/app/patch-app-configuration-file.js' -import {getOrCreateAppConfigHiddenPath} from '../../utilities/app/config/hidden-app-config.js' import {ApplicationURLs, generateApplicationURLs} from '../../services/dev/urls.js' +import {Project} from '../project/project.js' +import {selectActiveConfig} from '../project/active-config.js' +import { + resolveDotEnv, + resolveHiddenConfig, + extensionFilesForConfig, + webFilesForConfig, +} from '../project/config-selection.js' import {showMultipleCLIWarningIfNeeded} from '@shopify/cli-kit/node/multiple-installation-warning' -import {fileExists, readFile, glob, findPathUp, fileExistsSync} from '@shopify/cli-kit/node/fs' +import {fileExists, readFile, fileExistsSync} from '@shopify/cli-kit/node/fs' import {TomlFile, TomlParseError} from '@shopify/cli-kit/node/toml/toml-file' import {zod} from '@shopify/cli-kit/node/schema' -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 {PackageManager} from '@shopify/cli-kit/node/node-package-manager' import {resolveFramework} from '@shopify/cli-kit/node/framework' import {hashString} from '@shopify/cli-kit/node/crypto' import {JsonMapType} from '@shopify/cli-kit/node/toml' import {joinPath, dirname, basename, relativePath, relativizePath} from '@shopify/cli-kit/node/path' import {AbortError} from '@shopify/cli-kit/node/error' import {outputContent, outputDebug, OutputMessage, outputToken} from '@shopify/cli-kit/node/output' -import {joinWithAnd, slugify} from '@shopify/cli-kit/common/string' +import {joinWithAnd} from '@shopify/cli-kit/common/string' import {getArrayRejectingUndefined} from '@shopify/cli-kit/common/array' import {showNotificationsIfNeeded} from '@shopify/cli-kit/node/notifications-system' import ignore from 'ignore' - -const defaultExtensionDirectory = 'extensions/*' +import type {ActiveConfig} from '../project/active-config.js' /** * The mode in which the app is loaded, this affects how errors are handled: @@ -67,14 +61,25 @@ const defaultExtensionDirectory = 'extensions/*' */ export type AppLoaderMode = 'strict' | 'report' | 'local' +/** + * Narrow runtime state carried forward across app reloads. + * + * Replaces passing the entire previous AppInterface — only genuine runtime + * state (devUUIDs and tunnel URLs) needs to survive a reload. + */ +export interface ReloadState { + /** Extension handle → devUUID, preserved for dev-console stability across reloads */ + extensionDevUUIDs: Map + /** Previous dev tunnel URL, kept stable across reloads */ + previousDevURLs?: ApplicationURLs +} + type AbortOrReport = (errorMessage: OutputMessage, fallback: T, configurationPath: string) => T const abort: AbortOrReport = (errorMessage) => { throw new AbortError(errorMessage) } -const noopAbortOrReport: AbortOrReport = (_errorMessage, fallback, _configurationPath) => fallback - /** * Loads a configuration file, and returns its content as an unvalidated object. */ @@ -196,8 +201,10 @@ interface AppLoaderConstructorArgs< > { mode?: AppLoaderMode loadedConfiguration: ConfigurationLoaderResult - // Used when reloading an app, to avoid some expensive steps during loading. - previousApp?: AppLinkedInterface + // Pre-discovered project data — avoids re-scanning the filesystem for dependencies, package manager, etc. + project: Project + // Narrow runtime state from a previous app load, used during reloads + reloadState?: ReloadState } export async function checkFolderIsValidApp(directory: string) { @@ -209,39 +216,30 @@ export async function checkFolderIsValidApp(directory: string) { } export async function loadConfigForAppCreation(directory: string, name: string): Promise { - const state = await getAppConfigurationState(directory) - const config = state.basicConfiguration - const webs = await loadWebsForAppCreation(state.appDirectory, config.web_directories) + const {project, activeConfig} = await getAppConfigurationContext(directory) + const rawConfig = activeConfig.file.content + const webFiles = webFilesForConfig(project, activeConfig.file) + const webs = await Promise.all(webFiles.map((wf) => loadSingleWeb(wf.path, abort, wf.content))) const isLaunchable = webs.some((web) => isWebType(web, WebType.Frontend) || isWebType(web, WebType.Backend)) - const scopesArray = getAppScopesArray(config as CurrentAppConfiguration) + const scopesArray = getAppScopesArray(rawConfig as CurrentAppConfiguration) return { isLaunchable, scopesArray, name, - directory: state.appDirectory, + directory: project.directory, // By default, and ONLY for `app init`, we consider the app as embedded if it is launchable. isEmbedded: isLaunchable, } } -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) +async function loadSingleWeb( + webConfigPath: string, + abortOrReport: AbortOrReport = abort, + preloadedContent?: JsonMapType, +): Promise { + const config = await parseConfigurationFile(WebConfigurationSchema, webConfigPath, abortOrReport, preloadedContent) 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} @@ -256,22 +254,94 @@ async function loadSingleWeb(webConfigPath: string, abortOrReport: AbortOrReport * 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. */ -export async function loadApp( - options: Omit, 'loadedConfiguration'> & { - directory: string - userProvidedConfigName: string | undefined - specifications: TModuleSpec[] - remoteFlags?: Flag[] - }, -): Promise> { - const specifications = options.specifications +export async function loadApp(options: { + directory: string + userProvidedConfigName: string | undefined + specifications: TModuleSpec[] + remoteFlags?: Flag[] + mode?: AppLoaderMode +}): Promise> { + const {project, activeConfig} = await getAppConfigurationContext(options.directory, options.userProvidedConfigName) + return loadAppFromContext({ + project, + activeConfig, + specifications: options.specifications, + remoteFlags: options.remoteFlags, + mode: options.mode, + }) +} + +/** + * Load an app from a pre-resolved Project and ActiveConfig. + * + * Use this when you already have a Project (e.g. from getAppConfigurationContext) + * instead of re-discovering from directory + configName. + */ +export async function loadAppFromContext(options: { + project: Project + activeConfig: ActiveConfig + specifications: TModuleSpec[] + remoteFlags?: Flag[] + mode?: AppLoaderMode + reloadState?: ReloadState + clientIdOverride?: string +}): Promise> { + const {project, activeConfig, specifications, remoteFlags = [], mode, reloadState, clientIdOverride} = options + + const rawConfig: JsonMapType = {...activeConfig.file.content} + if (clientIdOverride) { + rawConfig.client_id = clientIdOverride + } + delete rawConfig.path + + const appVersionedSchema = getAppVersionedSchema(specifications) + const configSchema = appVersionedSchema as SchemaForConfig + const configurationPath = activeConfig.file.path + const configurationFileName = basename(configurationPath) as AppConfigurationFileName + + const configuration = await parseConfigurationFile(configSchema, configurationPath, abort, rawConfig) + + const allClientIdsByConfigName = getAllLinkedConfigClientIds(project.appConfigFiles, { + [configurationFileName]: configuration.client_id, + }) + + let configurationLoadResultMetadata: ConfigurationLoadResultMetadata = { + usesLinkedConfig: false, + allClientIdsByConfigName, + } + + let gitTracked = false + try { + gitTracked = await checkIfGitTracked(project.directory, configurationPath) + // eslint-disable-next-line no-catch-all/no-catch-all + } catch { + // leave as false + } + + configurationLoadResultMetadata = { + ...configurationLoadResultMetadata, + usesLinkedConfig: true, + name: configurationFileName, + gitTracked, + source: activeConfig.source, + usesCliManagedUrls: configuration.build?.automatically_update_urls_on_dev, + } - const state = await getAppConfigurationState(options.directory, options.userProvidedConfigName) - const loadedConfiguration = await loadAppConfigurationFromState(state, specifications, options.remoteFlags ?? []) + const loadedConfiguration: ConfigurationLoaderResult = { + directory: project.directory, + configPath: configurationPath, + configuration, + configurationLoadResultMetadata, + configSchema, + specifications, + remoteFlags, + } const loader = new AppLoader({ - mode: options.mode, + mode, loadedConfiguration, + project, + reloadState, }) return loader.loaded() } @@ -340,17 +410,16 @@ export async function loadOpaqueApp(options: { } catch { // loadApp failed - try loading as raw template config try { - const appDirectory = await getAppDirectory(options.directory) - const {configurationPath} = await getConfigurationPath(appDirectory, options.configName) + const project = await Project.load(options.directory) + const {configurationPath} = await getConfigurationPath(project.directory, options.configName) const rawConfig = await loadConfigurationFileContent(configurationPath) - const packageManager = await getPackageManager(appDirectory) return { state: 'loaded-template', rawConfig, scopes: extractScopesFromRawConfig(rawConfig), - appDirectory, - packageManager, + appDirectory: project.directory, + packageManager: project.packageManager, } // eslint-disable-next-line no-catch-all/no-catch-all } catch { @@ -361,68 +430,46 @@ export async function loadOpaqueApp(options: { } export async function reloadApp(app: AppLinkedInterface): Promise { - const state = await getAppConfigurationState(app.directory, basename(app.configPath)) - const loadedConfiguration = await loadAppConfigurationFromState(state, app.specifications, app.remoteFlags ?? []) - - const loader = new AppLoader({ - loadedConfiguration, - previousApp: app, - }) - - return loader.loaded() -} - -export async function loadAppUsingConfigurationState( - configState: AppConfigurationState, - { - specifications, - remoteFlags, - mode, - }: { - specifications: RemoteAwareExtensionSpecification[] - remoteFlags?: Flag[] - mode: AppLoaderMode - }, -): Promise> { - const loadedConfiguration = await loadAppConfigurationFromState(configState, specifications, remoteFlags ?? []) - - const loader = new AppLoader({ - mode, - loadedConfiguration, + const {project, activeConfig} = await getAppConfigurationContext(app.directory, basename(app.configPath)) + const reloadState: ReloadState = { + extensionDevUUIDs: new Map(app.allExtensions.map((ext) => [ext.handle, ext.devUUID])), + previousDevURLs: app.devApplicationURLs, + } + return loadAppFromContext({ + project, + activeConfig, + specifications: app.specifications, + remoteFlags: app.remoteFlags ?? [], + reloadState, }) - return loader.loaded() } -type LoadedAppConfigFromConfigState = CurrentAppConfiguration - export function getDotEnvFileName(configurationPath: string) { const configurationShorthand: string | undefined = getAppConfigurationShorthand(configurationPath) return configurationShorthand ? `${dotEnvFileNames.production}.${configurationShorthand}` : dotEnvFileNames.production } -export async function loadDotEnv(appDirectory: string, configurationPath: string): Promise { - let dotEnvFile: DotEnvFile | undefined - const dotEnvPath = joinPath(appDirectory, getDotEnvFileName(configurationPath)) - if (await fileExists(dotEnvPath)) { - dotEnvFile = await readAndParseDotEnv(dotEnvPath) - } - return dotEnvFile -} - class AppLoader { private readonly mode: AppLoaderMode private readonly errors: AppErrors = new AppErrors() private readonly specifications: TModuleSpec[] private readonly remoteFlags: Flag[] private readonly loadedConfiguration: ConfigurationLoaderResult - private readonly previousApp: AppLinkedInterface | undefined + private readonly reloadState: ReloadState | undefined + private readonly project: Project - constructor({mode, loadedConfiguration, previousApp}: AppLoaderConstructorArgs) { + constructor({mode, loadedConfiguration, reloadState, project}: AppLoaderConstructorArgs) { this.mode = mode ?? 'strict' this.specifications = loadedConfiguration.specifications this.remoteFlags = loadedConfiguration.remoteFlags this.loadedConfiguration = loadedConfiguration - this.previousApp = previousApp + this.reloadState = reloadState + this.project = project + } + + private get activeConfigFile(): TomlFile | undefined { + const configPath = this.loadedConfiguration.configPath + return this.project.appConfigFiles.find((file) => file.path === configPath) } async loaded() { @@ -431,24 +478,20 @@ class AppLoader { - const webTomlPaths = await findWebConfigPaths(appDirectory, webDirectories) - const webs = await Promise.all(webTomlPaths.map((path) => loadSingleWeb(path, this.abortOrReport.bind(this)))) + const activeConfig = this.activeConfigFile + const webFiles = activeConfig ? webFilesForConfig(this.project, activeConfig) : this.project.webConfigFiles + const webTomlPaths = webFiles.map((file) => file.path) + const webs = await Promise.all( + webFiles.map((webFile) => loadSingleWeb(webFile.path, this.abortOrReport.bind(this), webFile.content)), + ) this.validateWebs(webs) - const webTomlsInStandardLocation = await glob(joinPath(appDirectory, `web/**/${configurationFileNames.web}`)) - const usedCustomLayout = webDirectories !== undefined || webTomlsInStandardLocation.length !== webTomlPaths.length + const allWebsUnderStandardDir = webTomlPaths.every((webPath) => { + const rel = relativePath(appDirectory, webPath) + return rel.startsWith('web/') + }) + const usedCustomLayout = webDirectories !== undefined || !allWebsUnderStandardDir return {webs, usedCustomLayout} } @@ -565,10 +615,6 @@ class AppLoader { - return extension.handle === configuration.handle - }) - const extensionInstance = new ExtensionInstance({ configuration, configurationPath, @@ -577,9 +623,12 @@ class AppLoader { - return joinPath(appDirectory, extensionPath, '*.extension.toml') - }) - extensionConfigPaths.push(`!${joinPath(appDirectory, '**/node_modules/**')}`) - const configPaths = await glob(extensionConfigPaths) + private async createExtensionInstances(appDirectory: string, _extensionDirectories?: string[]) { + // Use pre-discovered extension files from Project, filtered by active config + const activeConfig = this.activeConfigFile + const extensionFiles = activeConfig + ? extensionFilesForConfig(this.project, activeConfig) + : this.project.extensionConfigFiles - return configPaths.map(async (configurationPath) => { + return extensionFiles.map(async (extensionFile) => { + const configurationPath = extensionFile.path const directory = dirname(configurationPath) - const obj = await loadConfigurationFileContent(configurationPath) + const obj = extensionFile.content const parseResult = ExtensionsArraySchema.safeParse(obj) if (!parseResult.success) { this.abortOrReport( @@ -648,7 +698,12 @@ class AppLoader { const mergedConfig = {...configuration, ...extensionConfig} @@ -823,7 +878,7 @@ class AppLoader { - const specifications = options.specifications ?? (await loadLocalExtensionsSpecifications()) - const state = await getAppConfigurationState(options.directory, options.userProvidedConfigName) - const result = await loadAppConfigurationFromState(state, specifications, options.remoteFlags ?? []) - await logMetadataFromAppLoadingProcess(result.configurationLoadResultMetadata) - return result -} - -interface AppConfigurationLoaderConstructorArgs { - directory: string - userProvidedConfigName: string | undefined - specifications?: ExtensionSpecification[] - remoteFlags?: Flag[] -} - type LinkedConfigurationSource = // Config file was passed via a flag to a command | 'flag' @@ -885,137 +919,24 @@ type ConfigurationLoaderResult< configurationLoadResultMetadata: ConfigurationLoadResultMetadata } -interface AppConfigurationStateBasics { - appDirectory: string - configurationPath: string - configSource: LinkedConfigurationSource - configurationFileName: AppConfigurationFileName -} - -export type AppConfigurationState = AppConfigurationStateBasics & { - basicConfiguration: BasicAppConfigurationWithoutModules - isLinked: boolean -} - /** - * Get the app configuration state from the file system. + * Get the app configuration context from the file system. * - * This takes a shallow look at the app folder, and indicates if the app has been linked or is still in template form. + * Discovers the project and selects the active config. That's it — no parsing + * or intermediate state construction. Callers that need a parsed config should + * use `loadAppFromContext`. * * @param workingDirectory - Typically either the CWD or came from the `--path` argument. The function will find the root folder of the app. * @param userProvidedConfigName - Some commands allow the manual specification of the config name to use. Otherwise, the function may prompt/use the cached preference. - * @returns Detail about the app configuration state. + * @returns The project and active config selection. */ -export async function getAppConfigurationState( +export async function getAppConfigurationContext( workingDirectory: string, userProvidedConfigName?: string, -): Promise { - // partially loads the app config. doesn't actually check for config validity beyond the absolute minimum - let configName = userProvidedConfigName - - const appDirectory = await getAppDirectory(workingDirectory) - - const cachedCurrentConfigName = getCachedAppInfo(appDirectory)?.configFile - const cachedCurrentConfigPath = cachedCurrentConfigName ? joinPath(appDirectory, cachedCurrentConfigName) : null - if (!configName && cachedCurrentConfigPath && !fileExistsSync(cachedCurrentConfigPath)) { - const warningContent = { - headline: `Couldn't find ${cachedCurrentConfigName}`, - body: [ - "If you have multiple config files, select a new one. If you only have one config file, it's been selected as your default.", - ], - } - configName = await use({directory: appDirectory, warningContent, shouldRenderSuccess: false}) - } - - configName = configName ?? cachedCurrentConfigName - - // Determine source after resolution so it reflects the actual selection path - let configSource: LinkedConfigurationSource - if (userProvidedConfigName) { - configSource = 'flag' - } else if (configName) { - configSource = 'cached' - } else { - configSource = 'default' - } - - const {configurationPath, configurationFileName} = await getConfigurationPath(appDirectory, configName) - const file = await loadConfigurationFileContent(configurationPath) - - const parsedConfig = await parseConfigurationFile(AppSchema, configurationPath) - - const isLinked = parsedConfig.client_id !== '' - - return { - appDirectory, - configurationPath, - basicConfiguration: { - ...file, - ...parsedConfig, - }, - configSource, - configurationFileName, - isLinked, - } -} - -/** - * Given app configuration state, load the app configuration. - * - * This is typically called after getting remote-aware extension specifications. The app configuration is validated acordingly. - */ -async function loadAppConfigurationFromState( - configState: AppConfigurationState, - specifications: TModuleSpec[], - remoteFlags: Flag[], -): Promise> { - const file: JsonMapType = { - ...configState.basicConfiguration, - } as JsonMapType - const appVersionedSchema = getAppVersionedSchema(specifications) - const schemaForConfigurationFile = appVersionedSchema as SchemaForConfig - - const configuration = await parseConfigurationFile( - schemaForConfigurationFile, - configState.configurationPath, - abort, - file, - ) - const allClientIdsByConfigName = await getAllLinkedConfigClientIds(configState.appDirectory, { - [configState.configurationFileName]: configuration.client_id, - }) - - let configurationLoadResultMetadata: ConfigurationLoadResultMetadata = { - usesLinkedConfig: false, - allClientIdsByConfigName, - } - - let gitTracked = false - try { - gitTracked = await checkIfGitTracked(configState.appDirectory, configState.configurationPath) - // eslint-disable-next-line no-catch-all/no-catch-all - } catch { - // leave as false - } - - configurationLoadResultMetadata = { - ...configurationLoadResultMetadata, - usesLinkedConfig: true, - name: configState.configurationFileName, - gitTracked, - source: configState.configSource, - usesCliManagedUrls: configuration.build?.automatically_update_urls_on_dev, - } - - return { - directory: configState.appDirectory, - configPath: configState.configurationPath, - configuration, - configurationLoadResultMetadata, - configSchema: schemaForConfigurationFile, - specifications, - remoteFlags, - } +): Promise<{project: Project; activeConfig: ActiveConfig}> { + const project = await Project.load(workingDirectory) + const activeConfig = await selectActiveConfig(project, userProvidedConfigName) + return {project, activeConfig} } async function checkIfGitTracked(appDirectory: string, configurationPath: string) { @@ -1028,7 +949,7 @@ async function checkIfGitTracked(appDirectory: string, configurationPath: string return isTracked } -export async function getConfigurationPath(appDirectory: string, configName: string | undefined) { +async function getConfigurationPath(appDirectory: string, configName: string | undefined) { const configurationFileName = getAppConfigurationFileName(configName) const configurationPath = joinPath(appDirectory, configurationFileName) @@ -1039,108 +960,31 @@ export async function getConfigurationPath(appDirectory: string, configName: str } } -/** - * Sometimes we want to run app commands from a nested folder (for example within an extension). So we need to - * traverse up the filesystem to find the root app directory. - * - * @param directory - The current working directory, or the `--path` option - */ -export async function getAppDirectory(directory: string) { - if (!(await fileExists(directory))) { - throw new AbortError(outputContent`Couldn't find directory ${outputToken.path(directory)}`) - } - - // In order to find the chosen config for the app, we need to find the directory of the app. - // But we can't know the chosen config because the cache key is the directory itself. So we - // look for all possible `shopify.app.*toml` files and stop at the first directory that contains one. - const appDirectory = await findPathUp( - async (directory) => { - const found = await glob(joinPath(directory, appConfigurationFileNameGlob)) - if (found.length > 0) { - return directory - } - }, - { - cwd: directory, - type: 'directory', - }, - ) - - if (appDirectory) { - return appDirectory - } else { - throw new AbortError( - outputContent`Couldn't find an app toml file at ${outputToken.path(directory)}, is this an app directory?`, - ) - } -} - /** * Looks for all likely linked config files in the app folder, parses, and returns a mapping of name to client ID. * * @param prefetchedConfigs - A mapping of config names to client IDs that have already been fetched from the filesystem. */ -async function getAllLinkedConfigClientIds( - appDirectory: string, +function getAllLinkedConfigClientIds( + appConfigFiles: TomlFile[], prefetchedConfigs: {[key: string]: string | number | undefined}, -): Promise<{[key: string]: string}> { - const candidates = await glob(joinPath(appDirectory, appConfigurationFileNameGlob)) - - const entries: [string, string][] = ( - await Promise.all( - candidates.map(async (candidateFile) => { - const configName = basename(candidateFile) - if (prefetchedConfigs[configName] !== undefined && typeof prefetchedConfigs[configName] === 'string') { - return [configName, prefetchedConfigs[configName]] as [string, string] - } - try { - const configuration = await parseConfigurationFile( - // we only care about the client ID, so no need to parse the entire file - zod.object({client_id: zod.string().optional()}), - candidateFile, - // we're not interested in error reporting at all - noopAbortOrReport, - ) - if (configuration.client_id !== undefined) { - return [configName, configuration.client_id] as [string, string] - } - // eslint-disable-next-line no-catch-all/no-catch-all - } catch { - // can ignore errors in parsing - } - }), - ) - ).filter((entry) => entry !== undefined) +): {[key: string]: string} { + const entries: [string, string][] = appConfigFiles + .map((tomlFile) => { + const configName = basename(tomlFile.path) + if (prefetchedConfigs[configName] !== undefined && typeof prefetchedConfigs[configName] === 'string') { + return [configName, prefetchedConfigs[configName]] as [string, string] + } + const clientId = tomlFile.content.client_id + if (typeof clientId === 'string' && clientId !== '') { + return [configName, clientId] as [string, string] + } + return undefined + }) + .filter((entry) => entry !== undefined) return Object.fromEntries(entries) } -export async function loadHiddenConfig( - appDirectory: string, - configuration: AppConfiguration, -): Promise { - if (!configuration.client_id || typeof configuration.client_id !== 'string') return {} - - const hiddenConfigPath = await getOrCreateAppConfigHiddenPath(appDirectory) - - try { - const allConfigs: {[key: string]: AppHiddenConfig} = JSON.parse(await readFile(hiddenConfigPath)) - const currentAppConfig = allConfigs[configuration.client_id] - - if (currentAppConfig) return currentAppConfig - - // Migration from legacy format, can be safely removed in version >=3.77 - const oldConfig = allConfigs.dev_store_url - if (oldConfig !== undefined && typeof oldConfig === 'string') { - await patchAppHiddenConfigFile(hiddenConfigPath, configuration.client_id, {dev_store_url: oldConfig}) - return {dev_store_url: oldConfig} - } - return {} - // eslint-disable-next-line no-catch-all/no-catch-all - } catch { - return {} - } -} - async function getProjectType(webs: Web[]): Promise<'node' | 'php' | 'ruby' | 'frontend' | undefined> { const backendWebs = webs.filter((web) => isWebType(web, WebType.Backend)) const frontendWebs = webs.filter((web) => isWebType(web, WebType.Frontend)) @@ -1283,42 +1127,11 @@ async function logMetadataFromAppLoadingProcess(loadMetadata: ConfigurationLoadR }) } -const appConfigurationFileNameRegex = /^shopify\.app(\.[-\w]+)?\.toml$/ -const appConfigurationFileNameGlob = 'shopify.app*.toml' -export type AppConfigurationFileName = 'shopify.app.toml' | `shopify.app.${string}.toml` - -/** - * Gets the name of the app configuration file (e.g. `shopify.app.production.toml`) based on a provided config name. - * - * @param configName - Optional config name to base the file name upon - * @returns Either the default app configuration file name (`shopify.app.toml`), the given config name (if it matched the valid format), or `shopify.app..toml` if it was an arbitrary string - */ -export function getAppConfigurationFileName(configName?: string): AppConfigurationFileName { - if (!configName) { - return configurationFileNames.app - } - - if (isValidFormatAppConfigurationFileName(configName)) { - return configName - } else { - return `shopify.app.${slugify(configName)}.toml` - } -} - -/** - * Given a path to an app configuration file, extract the shorthand section from the file name. - * - * This is undefined for `shopify.app.toml` files, or returns e.g. `production` for `shopify.app.production.toml`. - */ -export function getAppConfigurationShorthand(path: string) { - const match = basename(path).match(appConfigurationFileNameRegex) - return match?.[1]?.slice(1) -} - -/** Checks if configName is a valid one (`shopify.app.toml`, or `shopify.app..toml`) */ -export function isValidFormatAppConfigurationFileName(configName: string): configName is AppConfigurationFileName { - if (appConfigurationFileNameRegex.test(configName)) { - return true - } - return false -} +// Re-export config file naming utilities from their leaf module. +// These were moved to break the circular dependency: loader ↔ active-config ↔ use ↔ loader. +export { + getAppConfigurationFileName, + getAppConfigurationShorthand, + isValidFormatAppConfigurationFileName, + type AppConfigurationFileName, +} from './config-file-naming.js' diff --git a/packages/app/src/cli/models/project/active-config.ts b/packages/app/src/cli/models/project/active-config.ts index fa4dcc568c2..dbf8dd86eeb 100644 --- a/packages/app/src/cli/models/project/active-config.ts +++ b/packages/app/src/cli/models/project/active-config.ts @@ -1,13 +1,15 @@ import {Project} from './project.js' import {resolveDotEnv, resolveHiddenConfig} from './config-selection.js' -import {AppHiddenConfig, BasicAppConfigurationWithoutModules} from '../app/app.js' -import {AppConfigurationFileName, AppConfigurationState, getConfigurationPath} from '../app/loader.js' +import {AppHiddenConfig} from '../app/app.js' +import {getAppConfigurationFileName} from '../app/config-file-naming.js' import {getCachedAppInfo} from '../../services/local-storage.js' import use from '../../services/app/config/use.js' import {TomlFile} from '@shopify/cli-kit/node/toml/toml-file' import {DotEnvFile} from '@shopify/cli-kit/node/dot-env' import {fileExistsSync} from '@shopify/cli-kit/node/fs' -import {joinPath, basename} from '@shopify/cli-kit/node/path' +import {joinPath} from '@shopify/cli-kit/node/path' +import {AbortError} from '@shopify/cli-kit/node/error' +import {outputContent, outputToken} from '@shopify/cli-kit/node/output' /** @public */ export type ConfigSource = 'flag' | 'cached' | 'default' @@ -81,42 +83,18 @@ export async function selectActiveConfig(project: Project, userProvidedConfigNam source = 'default' } - // Resolve the config file name and verify it exists - const {configurationPath, configurationFileName} = await getConfigurationPath(project.directory, configName) - - // Look up the TomlFile from the project's pre-loaded files + // Resolve the config file name and look it up in the project's pre-loaded files + const configurationFileName = getAppConfigurationFileName(configName) const file = project.appConfigByName(configurationFileName) if (!file) { - // Fallback: the project didn't discover this file (shouldn't happen, but be safe) - const fallbackFile = await TomlFile.read(configurationPath) - return buildActiveConfig(project, fallbackFile, source) + throw new AbortError( + outputContent`Couldn't find ${configurationFileName} in ${outputToken.path(project.directory)}.`, + ) } return buildActiveConfig(project, file, source) } -/** - * Bridge from the new Project/ActiveConfig model to the legacy AppConfigurationState. - * - * This allows callers that still consume AppConfigurationState to work with - * the new selection logic without changes. - * @public - */ -export function toAppConfigurationState( - project: Project, - activeConfig: ActiveConfig, - basicConfiguration: BasicAppConfigurationWithoutModules, -): AppConfigurationState { - return { - appDirectory: project.directory, - configurationPath: activeConfig.file.path, - basicConfiguration, - configSource: activeConfig.source, - configurationFileName: basename(activeConfig.file.path) as AppConfigurationFileName, - isLinked: activeConfig.isLinked, - } -} - async function buildActiveConfig(project: Project, file: TomlFile, source: ConfigSource): Promise { const clientId = typeof file.content.client_id === 'string' ? file.content.client_id : undefined const isLinked = Boolean(clientId) && clientId !== '' diff --git a/packages/app/src/cli/services/app-context.test.ts b/packages/app/src/cli/services/app-context.test.ts index c1bd6d673aa..40b41159915 100644 --- a/packages/app/src/cli/services/app-context.test.ts +++ b/packages/app/src/cli/services/app-context.test.ts @@ -74,6 +74,8 @@ client_id="test-api-key"` developerPlatformClient: expect.any(Object), specifications: [], organization: mockOrganization, + project: expect.any(Object), + activeConfig: expect.any(Object), }) expect(link).not.toHaveBeenCalled() }) @@ -152,22 +154,12 @@ client_id="test-api-key"` vi.mocked(link).mockResolvedValue({ remoteApp: mockRemoteApp, - state: { - appDirectory: tmp, - configurationPath: `${tmp}/shopify.app.toml`, - configSource: 'cached', - configurationFileName: 'shopify.app.toml', - isLinked: true, - basicConfiguration: { - client_id: 'test-api-key', - }, - }, + configFileName: 'shopify.app.toml', configuration: { client_id: 'test-api-key', name: 'test-app', - application_url: 'https://test-app.com', - embedded: false, - }, + path: normalizePath(joinPath(tmp, 'shopify.app.toml')), + } as any, }) // When @@ -218,7 +210,7 @@ client_id="test-api-key"` name = "test-app" client_id="test-api-key"` await writeAppConfig(tmp, content) - const loadSpy = vi.spyOn(loader, 'loadAppUsingConfigurationState') + const loadSpy = vi.spyOn(loader, 'loadAppFromContext') // When await linkedAppContext({ @@ -231,7 +223,7 @@ client_id="test-api-key"` // Then expect(vi.mocked(addUidToTomlsIfNecessary)).not.toHaveBeenCalled() - expect(loadSpy).toHaveBeenCalledWith(expect.any(Object), expect.objectContaining({mode: 'report'})) + expect(loadSpy).toHaveBeenCalledWith(expect.objectContaining({mode: 'report'})) loadSpy.mockRestore() }) }) @@ -243,7 +235,7 @@ client_id="test-api-key"` name = "test-app" client_id="test-api-key"` await writeAppConfig(tmp, content) - const loadSpy = vi.spyOn(loader, 'loadAppUsingConfigurationState') + const loadSpy = vi.spyOn(loader, 'loadAppFromContext') // When await linkedAppContext({ @@ -255,7 +247,7 @@ client_id="test-api-key"` // Then expect(vi.mocked(addUidToTomlsIfNecessary)).toHaveBeenCalled() - expect(loadSpy).toHaveBeenCalledWith(expect.any(Object), expect.objectContaining({mode: 'strict'})) + expect(loadSpy).toHaveBeenCalledWith(expect.objectContaining({mode: 'strict'})) loadSpy.mockRestore() }) }) diff --git a/packages/app/src/cli/services/app-context.ts b/packages/app/src/cli/services/app-context.ts index b33a067c34b..bf9bc4144a3 100644 --- a/packages/app/src/cli/services/app-context.ts +++ b/packages/app/src/cli/services/app-context.ts @@ -7,11 +7,14 @@ import {addUidToTomlsIfNecessary} from './app/add-uid-to-extension-toml.js' import {loadLocalExtensionsSpecifications} from '../models/extensions/load-specifications.js' import {Organization, OrganizationApp, OrganizationSource} from '../models/organization.js' import {DeveloperPlatformClient} from '../utilities/developer-platform-client.js' -import {getAppConfigurationState, loadAppUsingConfigurationState, loadApp} from '../models/app/loader.js' +import {getAppConfigurationContext, loadApp, loadAppFromContext} from '../models/app/loader.js' import {RemoteAwareExtensionSpecification} from '../models/extensions/specification.js' import {AppLinkedInterface, AppInterface} from '../models/app/app.js' +import {Project} from '../models/project/project.js' import metadata from '../metadata.js' import {tryParseInt} from '@shopify/cli-kit/common/string' +import {basename} from '@shopify/cli-kit/node/path' +import type {ActiveConfig} from '../models/project/active-config.js' export interface LoadedAppContextOutput { app: AppLinkedInterface @@ -19,6 +22,8 @@ export interface LoadedAppContextOutput { developerPlatformClient: DeveloperPlatformClient organization: Organization specifications: RemoteAwareExtensionSpecification[] + project: Project + activeConfig: ActiveConfig } /** @@ -68,26 +73,26 @@ export async function linkedAppContext({ userProvidedConfigName, unsafeReportMode = false, }: LoadedAppContextOptions): Promise { - // Get current app configuration state - let configState = await getAppConfigurationState(directory, userProvidedConfigName) + let {project, activeConfig} = await getAppConfigurationContext(directory, userProvidedConfigName) let remoteApp: OrganizationApp | undefined - if (!configState.isLinked || forceRelink) { - const configName = forceRelink ? undefined : configState.configurationFileName + if (!activeConfig.isLinked || forceRelink) { + const configName = forceRelink ? undefined : basename(activeConfig.file.path) const result = await link({directory, apiKey: clientId, configName}) remoteApp = result.remoteApp - configState = result.state + // Re-load project and re-select active config since link may have written new config + const reloaded = await getAppConfigurationContext(directory, result.configFileName) + project = reloaded.project + activeConfig = reloaded.activeConfig } - // If the clientId is provided, update the configuration state with the new clientId - if (clientId && clientId !== configState.basicConfiguration.client_id) { - configState.basicConfiguration.client_id = clientId - } + // Determine the effective client ID + const configClientId = activeConfig.file.content.client_id as string + const effectiveClientId = clientId ?? configClientId // Fetch the remote app, using a different clientID if provided via flag. if (!remoteApp) { - const apiKey = configState.basicConfiguration.client_id - remoteApp = await appFromIdentifiers({apiKey}) + remoteApp = await appFromIdentifiers({apiKey: effectiveClientId}) } const developerPlatformClient = remoteApp.developerPlatformClient @@ -96,11 +101,14 @@ export async function linkedAppContext({ // Fetch the remote app's specifications const specifications = await fetchSpecifications({developerPlatformClient, app: remoteApp}) - // Load the local app using the configuration state and the remote app's specifications - const localApp = await loadAppUsingConfigurationState(configState, { + // Load the local app using the pre-resolved context and the remote app's specifications + const localApp = await loadAppFromContext({ + project, + activeConfig, specifications, remoteFlags: remoteApp.flags, mode: unsafeReportMode ? 'report' : 'strict', + clientIdOverride: clientId && clientId !== configClientId ? clientId : undefined, }) // If the remoteApp is the same as the linked one, update the cached info. @@ -120,7 +128,7 @@ export async function linkedAppContext({ await addUidToTomlsIfNecessary(localApp.allExtensions, developerPlatformClient) } - return {app: localApp, remoteApp, developerPlatformClient, specifications, organization} + return {project, activeConfig, app: localApp, remoteApp, developerPlatformClient, specifications, organization} } async function logMetadata(app: {apiKey: string}, organization: Organization, resetUsed: boolean) { 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 b7fc5a99969..83a2a57d21d 100644 --- a/packages/app/src/cli/services/app/config/link.test.ts +++ b/packages/app/src/cli/services/app/config/link.test.ts @@ -28,7 +28,6 @@ vi.mock('../../../models/app/loader.js', async () => { return { ...loader, loadApp: vi.fn(), - loadAppConfiguration: vi.fn(), loadOpaqueApp: vi.fn(), } }) @@ -82,7 +81,7 @@ describe('link', () => { vi.mocked(fetchOrCreateOrganizationApp).mockResolvedValue(mockRemoteApp({developerPlatformClient})) // When - const {configuration, state, remoteApp} = await link(options) + const {configuration, configFileName, remoteApp} = await link(options) // Then expect(selectConfigName).not.toHaveBeenCalled() @@ -107,14 +106,7 @@ describe('link', () => { }, }) - expect(state).toEqual({ - basicConfiguration: configuration, - appDirectory: options.directory, - configurationPath: expect.stringMatching(/\/shopify.app.default-value.toml$/), - configSource: 'flag', - configurationFileName: 'shopify.app.default-value.toml', - isLinked: true, - }) + expect(configFileName).toBe('shopify.app.default-value.toml') expect(remoteApp).toEqual(mockRemoteApp({developerPlatformClient})) }) @@ -224,7 +216,7 @@ embedded = false }) // When - const {configuration, state} = await link(options) + const {configuration, configFileName} = await link(options) // Then const content = await readFile(joinPath(tmp, 'shopify.app.toml')) @@ -293,14 +285,7 @@ embedded = false }, }) expect(content).toEqual(expectedContent) - expect(state).toEqual({ - basicConfiguration: configuration, - appDirectory: options.directory, - configurationPath: expect.stringMatching(/\/shopify.app.toml$/), - configSource: 'cached', - configurationFileName: 'shopify.app.toml', - isLinked: true, - }) + expect(configFileName).toBe('shopify.app.toml') }) }) diff --git a/packages/app/src/cli/services/app/config/link.ts b/packages/app/src/cli/services/app/config/link.ts index 9060c66c6c9..7d57920934a 100644 --- a/packages/app/src/cli/services/app/config/link.ts +++ b/packages/app/src/cli/services/app/config/link.ts @@ -10,7 +10,6 @@ import {OrganizationApp} from '../../../models/organization.js' import {selectConfigName} from '../../../prompts/config.js' import { AppConfigurationFileName, - AppConfigurationState, getAppConfigurationFileName, loadApp, loadOpaqueApp, @@ -48,9 +47,9 @@ export interface LinkOptions { } interface LinkOutput { - configuration: CurrentAppConfiguration remoteApp: OrganizationApp - state: AppConfigurationState + configFileName: AppConfigurationFileName + configuration: CurrentAppConfiguration } /** * Link a local app configuration file to a remote app on the Shopify platform. @@ -95,16 +94,7 @@ export default async function link(options: LinkOptions, shouldRenderSuccess = t renderSuccessMessage(configFileName, mergedAppConfiguration.name, localAppOptions.packageManager) } - const state: AppConfigurationState = { - basicConfiguration: mergedAppConfiguration, - appDirectory, - configurationPath: joinPath(appDirectory, configFileName), - configSource: options.configName ? 'flag' : 'cached', - configurationFileName: configFileName, - isLinked: mergedAppConfiguration.client_id !== '', - } - - return {configuration: mergedAppConfiguration, remoteApp, state} + return {remoteApp, configFileName, configuration: mergedAppConfiguration} } /** diff --git a/packages/app/src/cli/services/app/config/use.test.ts b/packages/app/src/cli/services/app/config/use.test.ts index 6abc700a42e..3d32faf3e18 100644 --- a/packages/app/src/cli/services/app/config/use.test.ts +++ b/packages/app/src/cli/services/app/config/use.test.ts @@ -1,11 +1,6 @@ import use, {UseOptions} from './use.js' -import { - buildVersionedAppSchema, - testApp, - testAppWithConfig, - testDeveloperPlatformClient, -} from '../../../models/app/app.test-data.js' -import {getAppConfigurationFileName, loadAppConfiguration} from '../../../models/app/loader.js' +import {testApp, testAppWithConfig, testDeveloperPlatformClient} from '../../../models/app/app.test-data.js' +import {getAppConfigurationFileName, getAppConfigurationContext} from '../../../models/app/loader.js' import {clearCurrentConfigFile, setCachedAppInfo} from '../../local-storage.js' import {selectConfigFile} from '../../../prompts/config.js' import {describe, expect, test, vi} from 'vitest' @@ -20,6 +15,21 @@ vi.mock('../../../models/app/loader.js') vi.mock('@shopify/cli-kit/node/ui') vi.mock('../../context.js') +function mockContext(directory: string, configuration: Record) { + vi.mocked(getAppConfigurationContext).mockResolvedValue({ + project: {} as any, + activeConfig: { + file: { + path: joinPath(directory, 'shopify.app.toml'), + content: configuration, + }, + source: 'flag', + isLinked: Boolean(configuration.client_id), + hiddenConfig: {}, + } as any, + }) +} + describe('use', () => { test('clears currentConfiguration when reset is true', async () => { await inTemporaryDirectory(async (tmp) => { @@ -38,7 +48,7 @@ describe('use', () => { // Then expect(clearCurrentConfigFile).toHaveBeenCalledWith(tmp) expect(setCachedAppInfo).not.toHaveBeenCalled() - expect(loadAppConfiguration).not.toHaveBeenCalled() + expect(getAppConfigurationContext).not.toHaveBeenCalled() expect(renderSuccess).toHaveBeenCalledWith({ headline: 'Cleared current configuration.', body: [ @@ -77,18 +87,9 @@ describe('use', () => { } vi.mocked(getAppConfigurationFileName).mockReturnValue('shopify.app.no-id.toml') - const {schema: configSchema} = await buildVersionedAppSchema() const appWithoutClientID = testApp() - // Create a configuration without client_id to test the error case const {client_id: clientId, ...configWithoutClientId} = appWithoutClientID.configuration - vi.mocked(loadAppConfiguration).mockResolvedValue({ - directory: tmp, - configPath: joinPath(tmp, 'shopify.app.no-id.toml'), - configuration: configWithoutClientId as any, - configSchema, - specifications: [], - remoteFlags: [], - }) + mockContext(tmp, configWithoutClientId) // When const result = use(options) @@ -120,7 +121,7 @@ describe('use', () => { "message": "Expected array, received string" } ]'`) - vi.mocked(loadAppConfiguration).mockRejectedValue(error) + vi.mocked(getAppConfigurationContext).mockRejectedValue(error) // When const result = use(options) @@ -149,15 +150,7 @@ describe('use', () => { application_url: 'https://example.com', }, }) - const {schema: configSchema} = await buildVersionedAppSchema() - vi.mocked(loadAppConfiguration).mockResolvedValue({ - directory: tmp, - configPath: joinPath(tmp, 'shopify.app.staging.toml'), - configuration: app.configuration, - configSchema, - specifications: [], - remoteFlags: [], - }) + mockContext(tmp, app.configuration) // When await use(options) @@ -191,15 +184,7 @@ describe('use', () => { webhooks: {api_version: '2023-04'}, }, }) - const {schema: configSchema} = await buildVersionedAppSchema() - vi.mocked(loadAppConfiguration).mockResolvedValue({ - directory: tmp, - configPath: joinPath(tmp, 'shopify.app.local.toml'), - configuration: app.configuration, - configSchema, - specifications: [], - remoteFlags: [], - }) + mockContext(tmp, app.configuration) // When await use(options) @@ -235,15 +220,7 @@ describe('use', () => { await inTemporaryDirectory(async (directory) => { // Given const {configuration} = testApp({}) - const {schema: configSchema} = await buildVersionedAppSchema() - vi.mocked(loadAppConfiguration).mockResolvedValue({ - directory, - configPath: joinPath(directory, 'shopify.app.something.toml'), - configuration, - configSchema, - specifications: [], - remoteFlags: [], - }) + mockContext(directory, configuration) vi.mocked(getAppConfigurationFileName).mockReturnValue('shopify.app.something.toml') createConfigFile(directory, 'shopify.app.something.toml') const options = { @@ -266,15 +243,7 @@ describe('use', () => { await inTemporaryDirectory(async (directory) => { // Given const {configuration} = testApp({}) - const {schema: configSchema} = await buildVersionedAppSchema() - vi.mocked(loadAppConfiguration).mockResolvedValue({ - directory, - configPath: joinPath(directory, 'shopify.app.something.toml'), - configuration, - configSchema, - specifications: [], - remoteFlags: [], - }) + mockContext(directory, configuration) vi.mocked(getAppConfigurationFileName).mockReturnValue('shopify.app.something.toml') createConfigFile(directory, 'shopify.app.something.toml') const options = { diff --git a/packages/app/src/cli/services/app/config/use.ts b/packages/app/src/cli/services/app/config/use.ts index fa0a2ffee55..6e24e5d2dd0 100644 --- a/packages/app/src/cli/services/app/config/use.ts +++ b/packages/app/src/cli/services/app/config/use.ts @@ -1,4 +1,4 @@ -import {getAppConfigurationFileName, loadAppConfiguration} from '../../../models/app/loader.js' +import {getAppConfigurationFileName, getAppConfigurationContext} from '../../../models/app/loader.js' import {clearCurrentConfigFile, setCachedAppInfo} from '../../local-storage.js' import {selectConfigFile} from '../../../prompts/config.js' import {AppConfiguration, CurrentAppConfiguration} from '../../../models/app/app.js' @@ -47,11 +47,8 @@ export default async function use({ const configFileName = (await getConfigFileName(directory, configName)).valueOrAbort() - const {configuration} = await loadAppConfiguration({ - userProvidedConfigName: configFileName, - directory, - }) - setCurrentConfigPreference(configuration, {configFileName, directory}) + const {activeConfig} = await getAppConfigurationContext(directory, configFileName) + setCurrentConfigPreference(activeConfig.file.content as AppConfiguration, {configFileName, directory}) if (shouldRenderSuccess) { renderSuccess({ diff --git a/packages/app/src/cli/services/context.test.ts b/packages/app/src/cli/services/context.test.ts index 5e3a9c9ae06..340d205a472 100644 --- a/packages/app/src/cli/services/context.test.ts +++ b/packages/app/src/cli/services/context.test.ts @@ -18,14 +18,13 @@ import { import {getAppIdentifiers} from '../models/app/identifiers.js' import {selectOrganizationPrompt} from '../prompts/dev.js' import { - DEFAULT_CONFIG, testDeveloperPlatformClient, testAppWithConfig, testOrganizationApp, testThemeExtensions, } from '../models/app/app.test-data.js' import metadata from '../metadata.js' -import {AppConfigurationState, getAppConfigurationFileName, isWebType, loadApp} from '../models/app/loader.js' +import {getAppConfigurationFileName, isWebType, loadApp} from '../models/app/loader.js' import {AppLinkedInterface} from '../models/app/app.js' import * as loadSpecifications from '../models/extensions/load-specifications.js' import { @@ -77,18 +76,6 @@ const STORE1: OrganizationStore = { provisionable: true, } -const state: AppConfigurationState = { - basicConfiguration: { - ...DEFAULT_CONFIG, - client_id: APP2.apiKey, - }, - appDirectory: 'tmp', - configurationPath: 'shopify.app.toml', - configSource: 'flag', - configurationFileName: 'shopify.app.toml', - isLinked: true, -} - const deployOptions = (app: AppLinkedInterface, reset = false, force = false): DeployOptions => { return { app, @@ -172,7 +159,7 @@ beforeEach(async () => { vi.mocked(link).mockResolvedValue({ configuration: testAppWithConfig({config: {client_id: APP2.apiKey}}).configuration, remoteApp: APP2, - state, + configFileName: 'shopify.app.toml', }) // this is needed because using importActual to mock the ui module diff --git a/packages/app/src/cli/services/function/common.test.ts b/packages/app/src/cli/services/function/common.test.ts index 045c1790981..1f0d1805d4a 100644 --- a/packages/app/src/cli/services/function/common.test.ts +++ b/packages/app/src/cli/services/function/common.test.ts @@ -17,6 +17,8 @@ import {renderAutocompletePrompt, renderFatalError} from '@shopify/cli-kit/node/ import {joinPath} from '@shopify/cli-kit/node/path' import {isTerminalInteractive} from '@shopify/cli-kit/node/context/local' import {fileExists} from '@shopify/cli-kit/node/fs' +import type {Project} from '../../models/project/project.js' +import type {ActiveConfig} from '../../models/project/active-config.js' vi.mock('../app-context.js') vi.mock('@shopify/cli-kit/node/ui') @@ -36,6 +38,8 @@ beforeEach(async () => { developerPlatformClient: testDeveloperPlatformClient(), specifications: [], organization: testOrganization(), + project: {} as unknown as Project, + activeConfig: {isLinked: true, hiddenConfig: {}} as unknown as ActiveConfig, }) vi.mocked(renderFatalError).mockReturnValue('') vi.mocked(renderAutocompletePrompt).mockResolvedValue(ourFunction) diff --git a/packages/app/src/cli/services/store-context.test.ts b/packages/app/src/cli/services/store-context.test.ts index e622f2dad67..6b3bd1e0306 100644 --- a/packages/app/src/cli/services/store-context.test.ts +++ b/packages/app/src/cli/services/store-context.test.ts @@ -15,6 +15,8 @@ import {vi, describe, test, expect} from 'vitest' import {hashString} from '@shopify/cli-kit/node/crypto' import {inTemporaryDirectory, mkdir, readFile, writeFile} from '@shopify/cli-kit/node/fs' import {joinPath} from '@shopify/cli-kit/node/path' +import type {Project} from '../models/project/project.js' +import type {ActiveConfig} from '../models/project/active-config.js' vi.mock('./dev/fetch') vi.mock('./dev/select-store') @@ -43,6 +45,8 @@ describe('storeContext', () => { developerPlatformClient: mockDeveloperPlatformClient, remoteApp: testOrganizationApp(), specifications: [], + project: {} as unknown as Project, + activeConfig: {isLinked: true, hiddenConfig: {}} as unknown as ActiveConfig, } test('uses explicitly provided storeFqdn', async () => {