diff --git a/packages/app/src/cli/models/app/loader.test.ts b/packages/app/src/cli/models/app/loader.test.ts index 253293fafa..1bbee45a76 100644 --- a/packages/app/src/cli/models/app/loader.test.ts +++ b/packages/app/src/cli/models/app/loader.test.ts @@ -2620,10 +2620,10 @@ describe('load', () => { cmd_app_all_configs_clients: JSON.stringify({'shopify.app.toml': '1234567890'}), cmd_app_linked_config_name: 'shopify.app.toml', cmd_app_linked_config_git_tracked: true, - cmd_app_linked_config_source: 'cached', + cmd_app_linked_config_source: 'default', cmd_app_warning_api_key_deprecation_displayed: false, app_extensions_any: false, - app_extensions_breakdown: {}, + app_extensions_breakdown: JSON.stringify({}), app_extensions_count: 0, app_extensions_custom_layout: false, app_extensions_function_any: false, diff --git a/packages/app/src/cli/models/app/loader.ts b/packages/app/src/cli/models/app/loader.ts index c4c06bd9d6..95e908e240 100644 --- a/packages/app/src/cli/models/app/loader.ts +++ b/packages/app/src/cli/models/app/loader.ts @@ -852,6 +852,8 @@ type LinkedConfigurationSource = | 'flag' // Config file came from the cache (i.e. app use) | 'cached' + // No flag or cache — fell through to the default (shopify.app.toml) + | 'default' type ConfigurationLoadResultMetadata = { allClientIdsByConfigName: {[key: string]: string} @@ -904,7 +906,6 @@ export async function getAppConfigurationState( let configName = userProvidedConfigName const appDirectory = await getAppDirectory(workingDirectory) - const configSource: LinkedConfigurationSource = configName ? 'flag' : 'cached' const cachedCurrentConfigName = getCachedAppInfo(appDirectory)?.configFile const cachedCurrentConfigPath = cachedCurrentConfigName ? joinPath(appDirectory, cachedCurrentConfigName) : null @@ -920,6 +921,16 @@ export async function getAppConfigurationState( 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) diff --git a/packages/app/src/cli/models/project/active-config.test.ts b/packages/app/src/cli/models/project/active-config.test.ts new file mode 100644 index 0000000000..c50a64d257 --- /dev/null +++ b/packages/app/src/cli/models/project/active-config.test.ts @@ -0,0 +1,203 @@ +import {selectActiveConfig} from './active-config.js' +import {Project} from './project.js' +import {describe, expect, test, vi, beforeEach} from 'vitest' +import {inTemporaryDirectory, writeFile, mkdir} from '@shopify/cli-kit/node/fs' +import {joinPath, basename} from '@shopify/cli-kit/node/path' + +vi.mock('../../services/local-storage.js', () => ({ + getCachedAppInfo: vi.fn().mockReturnValue(undefined), + setCachedAppInfo: vi.fn(), + clearCachedAppInfo: vi.fn(), +})) + +vi.mock('../../services/app/config/use.js', () => ({ + default: vi.fn(), +})) + +const {getCachedAppInfo} = await import('../../services/local-storage.js') + +beforeEach(() => { + vi.mocked(getCachedAppInfo).mockReturnValue(undefined) +}) + +describe('selectActiveConfig', () => { + test('selects config by user-provided name (flag)', async () => { + await inTemporaryDirectory(async (dir) => { + await writeFile(joinPath(dir, 'shopify.app.toml'), 'client_id = "default"') + await writeFile(joinPath(dir, 'shopify.app.staging.toml'), 'client_id = "staging"') + const project = await Project.load(dir) + + const config = await selectActiveConfig(project, 'staging') + + expect(basename(config.file.path)).toBe('shopify.app.staging.toml') + expect(config.file.content.client_id).toBe('staging') + expect(config.source).toBe('flag') + expect(config.isLinked).toBe(true) + }) + }) + + test('selects config from cache', async () => { + await inTemporaryDirectory(async (dir) => { + await writeFile(joinPath(dir, 'shopify.app.toml'), 'client_id = "default"') + await writeFile(joinPath(dir, 'shopify.app.production.toml'), 'client_id = "production"') + const project = await Project.load(dir) + + vi.mocked(getCachedAppInfo).mockReturnValue({ + directory: dir, + configFile: 'shopify.app.production.toml', + }) + + const config = await selectActiveConfig(project) + + expect(basename(config.file.path)).toBe('shopify.app.production.toml') + expect(config.file.content.client_id).toBe('production') + expect(config.source).toBe('cached') + }) + }) + + test('falls back to default shopify.app.toml when no flag or cache', async () => { + await inTemporaryDirectory(async (dir) => { + await writeFile(joinPath(dir, 'shopify.app.toml'), 'client_id = "default-id"') + const project = await Project.load(dir) + + const config = await selectActiveConfig(project) + + expect(basename(config.file.path)).toBe('shopify.app.toml') + expect(config.file.content.client_id).toBe('default-id') + }) + }) + + test('detects isLinked from client_id', async () => { + await inTemporaryDirectory(async (dir) => { + await writeFile(joinPath(dir, 'shopify.app.toml'), 'client_id = ""') + const project = await Project.load(dir) + + const config = await selectActiveConfig(project) + + expect(config.isLinked).toBe(false) + }) + }) + + test('detects isLinked when client_id is present', async () => { + await inTemporaryDirectory(async (dir) => { + await writeFile(joinPath(dir, 'shopify.app.toml'), 'client_id = "abc123"') + const project = await Project.load(dir) + + const config = await selectActiveConfig(project) + + expect(config.isLinked).toBe(true) + }) + }) + + test('resolves config-specific dotenv', async () => { + await inTemporaryDirectory(async (dir) => { + await writeFile(joinPath(dir, 'shopify.app.staging.toml'), 'client_id = "staging"') + await writeFile(joinPath(dir, 'shopify.app.toml'), 'client_id = "default"') + await writeFile(joinPath(dir, '.env'), 'KEY=default') + await writeFile(joinPath(dir, '.env.staging'), 'KEY=staging') + const project = await Project.load(dir) + + const config = await selectActiveConfig(project, 'staging') + + expect(config.dotenv).toBeDefined() + expect(config.dotenv!.variables.KEY).toBe('staging') + }) + }) + + test('resolves hidden config for client_id', async () => { + await inTemporaryDirectory(async (dir) => { + await writeFile(joinPath(dir, 'shopify.app.toml'), 'client_id = "abc123"') + await mkdir(joinPath(dir, '.shopify')) + await writeFile( + joinPath(dir, '.shopify', 'project.json'), + JSON.stringify({abc123: {dev_store_url: 'test.myshopify.com'}}), + ) + const project = await Project.load(dir) + + const config = await selectActiveConfig(project) + + expect(config.hiddenConfig).toStrictEqual({dev_store_url: 'test.myshopify.com'}) + }) + }) + + test('returns empty hidden config when no entry for client_id', async () => { + await inTemporaryDirectory(async (dir) => { + await writeFile(joinPath(dir, 'shopify.app.toml'), 'client_id = "abc123"') + const project = await Project.load(dir) + + const config = await selectActiveConfig(project) + + expect(config.hiddenConfig).toStrictEqual({}) + }) + }) + + test('file.path is absolute', async () => { + await inTemporaryDirectory(async (dir) => { + await writeFile(joinPath(dir, 'shopify.app.toml'), 'client_id = "abc"') + const project = await Project.load(dir) + + const config = await selectActiveConfig(project) + + expect(config.file.path).toBe(joinPath(dir, 'shopify.app.toml')) + }) + }) + + test('accepts full filename as config name', async () => { + await inTemporaryDirectory(async (dir) => { + await writeFile(joinPath(dir, 'shopify.app.toml'), 'client_id = "default"') + await writeFile(joinPath(dir, 'shopify.app.staging.toml'), 'client_id = "staging"') + const project = await Project.load(dir) + + const config = await selectActiveConfig(project, 'shopify.app.staging.toml') + + expect(basename(config.file.path)).toBe('shopify.app.staging.toml') + expect(config.file.content.client_id).toBe('staging') + }) + }) + + test('throws when requested config does not exist', async () => { + await inTemporaryDirectory(async (dir) => { + await writeFile(joinPath(dir, 'shopify.app.toml'), 'client_id = "default"') + const project = await Project.load(dir) + + await expect(selectActiveConfig(project, 'nonexistent')).rejects.toThrow() + }) + }) + + test('throws when the only app config is malformed (no valid configs to fall back to)', async () => { + await inTemporaryDirectory(async (dir) => { + // The only config is broken TOML — Project.load skips it and finds 0 valid configs + await writeFile(joinPath(dir, 'shopify.app.toml'), '{{invalid toml') + + await expect(Project.load(dir)).rejects.toThrow(/Could not find/) + }) + }) + + test('surfaces parse error when selecting a broken config while a valid one exists', async () => { + await inTemporaryDirectory(async (dir) => { + // Two configs: one good, one broken. Selecting the broken one by name should + // surface the real parse error via the fallback re-read, not a generic "not found". + await writeFile(joinPath(dir, 'shopify.app.toml'), 'client_id = "good"') + await writeFile(joinPath(dir, 'shopify.app.broken.toml'), '{{invalid toml') + + const project = await Project.load(dir) + + await expect(selectActiveConfig(project, 'shopify.app.broken.toml')).rejects.toThrow() + }) + }) + + test('loads active config even when an unrelated config is malformed', async () => { + await inTemporaryDirectory(async (dir) => { + await writeFile(joinPath(dir, 'shopify.app.toml'), 'client_id = "good"') + await writeFile(joinPath(dir, 'shopify.app.broken.toml'), '{{invalid toml') + + const project = await Project.load(dir) + + // The broken config is skipped, but selecting the good one works fine + const config = await selectActiveConfig(project) + + expect(config.file.content.client_id).toBe('good') + expect(basename(config.file.path)).toBe('shopify.app.toml') + }) + }) +}) diff --git a/packages/app/src/cli/models/project/active-config.ts b/packages/app/src/cli/models/project/active-config.ts new file mode 100644 index 0000000000..fa4dcc568c --- /dev/null +++ b/packages/app/src/cli/models/project/active-config.ts @@ -0,0 +1,133 @@ +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 {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' + +/** @public */ +export type ConfigSource = 'flag' | 'cached' | 'default' + +/** + * The selected app configuration — one specific TOML from the project's + * potentially many app config files, plus config-specific derived state. + * + * A sibling to Project, not a child. Project is the environment; + * ActiveConfig is a selection decision applied to that environment. + * + * path and fileName are derivable from file.path — use the accessors + * if you need them rather than storing redundant data. + * @public + */ +export interface ActiveConfig { + /** The selected app TOML file (from project.appConfigFiles) */ + file: TomlFile + /** How the selection was made */ + source: ConfigSource + /** Whether the config has a non-empty client_id */ + isLinked: boolean + /** Config-specific dotenv (.env.staging or .env) */ + dotenv?: DotEnvFile + /** Hidden config entry for this config's client_id */ + hiddenConfig: AppHiddenConfig +} + +/** + * Select the active app configuration from a project. + * + * Resolution priority: + * 1. userProvidedConfigName (from --config flag) + * 2. Cached selection (from `app config use`) + * 3. Default (shopify.app.toml) + * + * If the cached config file no longer exists on disk, prompts the user + * to select a new one via `app config use`. + * + * Derives config-specific state: dotenv and hidden config for the selected + * config's client_id. + * @public + */ +export async function selectActiveConfig(project: Project, userProvidedConfigName?: string): Promise { + let configName = userProvidedConfigName + + // Check cache for previously selected config + const cachedConfigName = getCachedAppInfo(project.directory)?.configFile + const cachedConfigPath = cachedConfigName ? joinPath(project.directory, cachedConfigName) : null + + // Handle stale cache: cached config file no longer exists + if (!configName && cachedConfigPath && !fileExistsSync(cachedConfigPath)) { + const warningContent = { + headline: `Couldn't find ${cachedConfigName}`, + 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: project.directory, warningContent, shouldRenderSuccess: false}) + } + + configName = configName ?? cachedConfigName + + // Determine source after resolution so it reflects the actual selection path + let source: ConfigSource + if (userProvidedConfigName) { + source = 'flag' + } else if (configName) { + source = 'cached' + } else { + 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 + 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) + } + + 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 !== '' + const dotenv = resolveDotEnv(project, file.path) + const hiddenConfig = await resolveHiddenConfig(project, clientId) + + return { + file, + source, + isLinked, + dotenv, + hiddenConfig, + } +} diff --git a/packages/app/src/cli/models/project/config-selection.test.ts b/packages/app/src/cli/models/project/config-selection.test.ts new file mode 100644 index 0000000000..8d2f392d73 --- /dev/null +++ b/packages/app/src/cli/models/project/config-selection.test.ts @@ -0,0 +1,208 @@ +import {resolveDotEnv, resolveHiddenConfig, extensionFilesForConfig, webFilesForConfig} from './config-selection.js' +import {Project} from './project.js' +import {describe, expect, test} from 'vitest' +import {inTemporaryDirectory, writeFile, mkdir} from '@shopify/cli-kit/node/fs' +import {joinPath} from '@shopify/cli-kit/node/path' + +async function setupProject(dir: string, appToml = 'client_id = "abc"'): Promise { + await writeFile(joinPath(dir, 'shopify.app.toml'), appToml) + return Project.load(dir) +} + +describe('resolveDotEnv', () => { + test('returns default .env for shopify.app.toml', async () => { + await inTemporaryDirectory(async (dir) => { + await writeFile(joinPath(dir, '.env'), 'KEY=default') + const project = await setupProject(dir) + + const dotenv = resolveDotEnv(project, joinPath(dir, 'shopify.app.toml')) + + expect(dotenv).toBeDefined() + expect(dotenv!.variables.KEY).toBe('default') + }) + }) + + test('returns config-specific .env.staging for shopify.app.staging.toml', async () => { + await inTemporaryDirectory(async (dir) => { + await writeFile(joinPath(dir, '.env'), 'KEY=default') + await writeFile(joinPath(dir, '.env.staging'), 'KEY=staging') + await writeFile(joinPath(dir, 'shopify.app.staging.toml'), 'client_id = "staging"') + const project = await setupProject(dir) + + const dotenv = resolveDotEnv(project, joinPath(dir, 'shopify.app.staging.toml')) + + expect(dotenv).toBeDefined() + expect(dotenv!.variables.KEY).toBe('staging') + }) + }) + + test('returns undefined for non-default config when config-specific dotenv missing', async () => { + await inTemporaryDirectory(async (dir) => { + await writeFile(joinPath(dir, '.env'), 'KEY=default') + await writeFile(joinPath(dir, 'shopify.app.staging.toml'), 'client_id = "staging"') + const project = await setupProject(dir) + + // Non-default configs do not fall back to .env — prevents value leakage across configs + const dotenv = resolveDotEnv(project, joinPath(dir, 'shopify.app.staging.toml')) + + expect(dotenv).toBeUndefined() + }) + }) + + test('returns undefined when no dotenv files exist', async () => { + await inTemporaryDirectory(async (dir) => { + const project = await setupProject(dir) + + const dotenv = resolveDotEnv(project, joinPath(dir, 'shopify.app.toml')) + + expect(dotenv).toBeUndefined() + }) + }) + + test('returns config-specific dotenv for hyphenated config name', async () => { + await inTemporaryDirectory(async (dir) => { + await writeFile(joinPath(dir, '.env.my-staging'), 'KEY=my-staging') + await writeFile(joinPath(dir, 'shopify.app.my-staging.toml'), 'client_id = "x"') + const project = await setupProject(dir) + + const dotenv = resolveDotEnv(project, joinPath(dir, 'shopify.app.my-staging.toml')) + + expect(dotenv!.variables.KEY).toBe('my-staging') + }) + }) +}) + +describe('resolveHiddenConfig', () => { + test('returns config for specific client_id', async () => { + await inTemporaryDirectory(async (dir) => { + await mkdir(joinPath(dir, '.shopify')) + await writeFile( + joinPath(dir, '.shopify', 'project.json'), + JSON.stringify({ + abc: {dev_store_url: 'abc.myshopify.com'}, + def: {dev_store_url: 'def.myshopify.com'}, + }), + ) + const project = await setupProject(dir) + + const config = await resolveHiddenConfig(project, 'abc') + expect(config).toStrictEqual({dev_store_url: 'abc.myshopify.com'}) + }) + }) + + test('returns empty for unknown client_id', async () => { + await inTemporaryDirectory(async (dir) => { + await mkdir(joinPath(dir, '.shopify')) + await writeFile( + joinPath(dir, '.shopify', 'project.json'), + JSON.stringify({abc: {dev_store_url: 'abc.myshopify.com'}}), + ) + const project = await setupProject(dir) + + const config = await resolveHiddenConfig(project, 'unknown') + expect(config).toStrictEqual({}) + }) + }) + + test('returns empty for undefined client_id', async () => { + await inTemporaryDirectory(async (dir) => { + const project = await setupProject(dir) + const config = await resolveHiddenConfig(project, undefined) + expect(config).toStrictEqual({}) + }) + }) + + test('handles legacy format with top-level dev_store_url', async () => { + await inTemporaryDirectory(async (dir) => { + await mkdir(joinPath(dir, '.shopify')) + await writeFile( + joinPath(dir, '.shopify', 'project.json'), + JSON.stringify({dev_store_url: 'legacy.myshopify.com'}), + ) + const project = await setupProject(dir) + + const config = await resolveHiddenConfig(project, 'any-client-id') + expect(config).toStrictEqual({dev_store_url: 'legacy.myshopify.com'}) + }) + }) +}) + +describe('extensionFilesForConfig', () => { + test('returns extensions from default directory when no extension_directories set', async () => { + await inTemporaryDirectory(async (dir) => { + await writeFile(joinPath(dir, 'shopify.app.toml'), 'client_id = "abc"') + await mkdir(joinPath(dir, 'extensions', 'my-ext')) + await writeFile(joinPath(dir, 'extensions', 'my-ext', 'shopify.extension.toml'), 'type = "function"') + const project = await Project.load(dir) + const activeConfig = project.appConfigFiles[0]! + + const extFiles = extensionFilesForConfig(project, activeConfig) + + expect(extFiles).toHaveLength(1) + }) + }) + + test('filters to active config extension_directories only', async () => { + await inTemporaryDirectory(async (dir) => { + await writeFile(joinPath(dir, 'shopify.app.toml'), 'client_id = "default"\nextension_directories = ["ext-a/*"]') + await writeFile( + joinPath(dir, 'shopify.app.staging.toml'), + 'client_id = "staging"\nextension_directories = ["ext-b/*"]', + ) + + await mkdir(joinPath(dir, 'ext-a', 'func1')) + await writeFile(joinPath(dir, 'ext-a', 'func1', 'shopify.extension.toml'), 'type = "function"\nname = "func1"') + await mkdir(joinPath(dir, 'ext-b', 'func2')) + await writeFile(joinPath(dir, 'ext-b', 'func2', 'shopify.extension.toml'), 'type = "function"\nname = "func2"') + + const project = await Project.load(dir) + const defaultConfig = project.appConfigByName('shopify.app.toml')! + const stagingConfig = project.appConfigByName('shopify.app.staging.toml')! + + // Default config should only see ext-a + const defaultExts = extensionFilesForConfig(project, defaultConfig) + expect(defaultExts).toHaveLength(1) + expect(defaultExts[0]!.content.name).toBe('func1') + + // Staging config should only see ext-b + const stagingExts = extensionFilesForConfig(project, stagingConfig) + expect(stagingExts).toHaveLength(1) + expect(stagingExts[0]!.content.name).toBe('func2') + }) + }) +}) + +describe('webFilesForConfig', () => { + test('returns all web files when no web_directories set', async () => { + await inTemporaryDirectory(async (dir) => { + await writeFile(joinPath(dir, 'shopify.app.toml'), 'client_id = "abc"') + await mkdir(joinPath(dir, 'web', 'backend')) + await writeFile(joinPath(dir, 'web', 'backend', 'shopify.web.toml'), 'name = "backend"\nroles = ["backend"]') + const project = await Project.load(dir) + const activeConfig = project.appConfigFiles[0]! + + const webFiles = webFilesForConfig(project, activeConfig) + + expect(webFiles).toHaveLength(1) + }) + }) + + test('filters to active config web_directories', async () => { + await inTemporaryDirectory(async (dir) => { + await writeFile(joinPath(dir, 'shopify.app.toml'), 'client_id = "default"\nweb_directories = ["web-a"]') + await writeFile(joinPath(dir, 'shopify.app.staging.toml'), 'client_id = "staging"\nweb_directories = ["web-b"]') + + await mkdir(joinPath(dir, 'web-a')) + await writeFile(joinPath(dir, 'web-a', 'shopify.web.toml'), 'name = "web-a"\nroles = ["backend"]') + await mkdir(joinPath(dir, 'web-b')) + await writeFile(joinPath(dir, 'web-b', 'shopify.web.toml'), 'name = "web-b"\nroles = ["backend"]') + + const project = await Project.load(dir) + const defaultConfig = project.appConfigByName('shopify.app.toml')! + + const webFiles = webFilesForConfig(project, defaultConfig) + expect(webFiles).toHaveLength(1) + expect(webFiles[0]!.content.name).toBe('web-a') + }) + }) +}) diff --git a/packages/app/src/cli/models/project/config-selection.ts b/packages/app/src/cli/models/project/config-selection.ts new file mode 100644 index 0000000000..dee3504d58 --- /dev/null +++ b/packages/app/src/cli/models/project/config-selection.ts @@ -0,0 +1,115 @@ +import {Project} from './project.js' +import {AppHiddenConfig} from '../app/app.js' +import {getAppConfigurationShorthand} from '../app/loader.js' +import {dotEnvFileNames} from '../../constants.js' +import {patchAppHiddenConfigFile} from '../../services/app/patch-app-configuration-file.js' +import {getOrCreateAppConfigHiddenPath} from '../../utilities/app/config/hidden-app-config.js' +import {TomlFile} from '@shopify/cli-kit/node/toml/toml-file' +import {DotEnvFile} from '@shopify/cli-kit/node/dot-env' +import {relativePath} from '@shopify/cli-kit/node/path' + +/** + * Resolve the config-specific dotenv file for an active config. + * + * shopify.app.toml → .env + * shopify.app.staging.toml → .env.staging (no fallback to .env) + * + * Non-default configs only load their config-specific dotenv file. + * This prevents base .env values from leaking into non-default configs. + * @public + */ +export function resolveDotEnv(project: Project, activeConfigPath: string): DotEnvFile | undefined { + const shorthand = getAppConfigurationShorthand(activeConfigPath) + if (shorthand) { + const specificName = `${dotEnvFileNames.production}.${shorthand}` + return project.dotenvFiles.get(specificName) + } + return project.dotenvFiles.get(dotEnvFileNames.production) +} + +/** + * Resolve the hidden config entry for a specific client_id. + * + * The raw .shopify/project.json is keyed by client_id. + * This function looks up the entry for the given client_id, + * handling the legacy format (flat top-level dev_store_url). + * @public + */ +export async function resolveHiddenConfig(project: Project, clientId: string | undefined): Promise { + if (!clientId || typeof clientId !== 'string') return {} + + // Ensure the hidden config directory and file exist (matches old loadHiddenConfig behavior). + // Other code paths (e.g., updateHiddenConfig, store-context) expect this file to be present. + await getOrCreateAppConfigHiddenPath(project.directory) + + const raw = project.hiddenConfigRaw + const entry = raw[clientId] + + if (entry && typeof entry === 'object') { + return entry as AppHiddenConfig + } + + // Legacy migration: top-level dev_store_url string + if (typeof raw.dev_store_url === 'string') { + // Migrate in place + try { + const hiddenConfigPath = await getOrCreateAppConfigHiddenPath(project.directory) + await patchAppHiddenConfigFile(hiddenConfigPath, clientId, {dev_store_url: raw.dev_store_url}) + // eslint-disable-next-line no-catch-all/no-catch-all + } catch { + // Migration failure is not fatal + } + return {dev_store_url: raw.dev_store_url} + } + + return {} +} + +/** + * Filter extension config files to those belonging to the active config's + * extension_directories. If the active config doesn't specify extension_directories, + * uses the default (extensions/*). + * @public + */ +export function extensionFilesForConfig(project: Project, activeConfig: TomlFile): TomlFile[] { + const configDirs = activeConfig.content.extension_directories + if (!Array.isArray(configDirs) || configDirs.length === 0) { + // Default: extensions/* — filter project files by path prefix + return project.extensionConfigFiles.filter((file) => { + const relPath = relativePath(project.directory, file.path).replace(/\\/g, '/') + return relPath.startsWith('extensions/') + }) + } + + // Filter to files within the active config's declared directories. + // Glob patterns are reduced to prefixes (e.g., "custom/*" → "custom/"). + // This is a simplification — complex globs like "foo/*/bar" will over-match. + // In practice, only simple directory patterns are used in app configs. + const dirPrefixes = (configDirs as string[]).map((dir) => { + return dir.replace(/\*.*$/, '').replace(/\/?$/, '/') + }) + + return project.extensionConfigFiles.filter((file) => { + const relPath = relativePath(project.directory, file.path).replace(/\\/g, '/') + return dirPrefixes.some((prefix) => relPath.startsWith(prefix)) + }) +} + +/** + * Filter web config files to those belonging to the active config's + * web_directories. If not specified, returns all web files. + * @public + */ +export function webFilesForConfig(project: Project, activeConfig: TomlFile): TomlFile[] { + const configDirs = activeConfig.content.web_directories + if (!Array.isArray(configDirs) || configDirs.length === 0) { + return project.webConfigFiles + } + + const dirPrefixes = (configDirs as string[]).map((dir) => dir.replace(/\*.*$/, '').replace(/\/?$/, '/')) + + return project.webConfigFiles.filter((file) => { + const relPath = relativePath(project.directory, file.path).replace(/\\/g, '/') + return dirPrefixes.some((prefix) => relPath.startsWith(prefix)) + }) +} diff --git a/packages/app/src/cli/models/project/project-integration.test.ts b/packages/app/src/cli/models/project/project-integration.test.ts new file mode 100644 index 0000000000..f0805cbbc1 --- /dev/null +++ b/packages/app/src/cli/models/project/project-integration.test.ts @@ -0,0 +1,271 @@ +import {Project} from './project.js' +import {resolveDotEnv, resolveHiddenConfig, extensionFilesForConfig, webFilesForConfig} from './config-selection.js' +import {loadApp} from '../app/loader.js' +import {loadLocalExtensionsSpecifications} from '../extensions/load-specifications.js' +import {describe, expect, test} from 'vitest' +import {inTemporaryDirectory, writeFile, mkdir} from '@shopify/cli-kit/node/fs' +import {joinPath} from '@shopify/cli-kit/node/path' + +/** + * Integration tests verifying that Project + config-selection produce + * the same results as the old loader for real app configurations. + */ + +async function setupRealApp(dir: string) { + // App config + await writeFile( + joinPath(dir, 'shopify.app.toml'), + ` +client_id = "test-client-id" +name = "Integration Test App" +application_url = "https://example.com" +embedded = true + +[build] +dev_store_url = "test.myshopify.com" + +[access_scopes] +scopes = "read_products,write_products" + +[auth] +redirect_urls = ["https://example.com/callback"] + +[webhooks] +api_version = "2024-01" + `.trim(), + ) + + // Extension + await mkdir(joinPath(dir, 'extensions', 'my-function')) + await writeFile( + joinPath(dir, 'extensions', 'my-function', 'shopify.extension.toml'), + ` +type = "product_discounts" +name = "My Discount" +handle = "my-discount" + +[build] +command = "cargo build" + `.trim(), + ) + + // Web + await mkdir(joinPath(dir, 'web', 'backend')) + await writeFile( + joinPath(dir, 'web', 'backend', 'shopify.web.toml'), + ` +name = "backend" +roles = ["backend"] + +[commands] +dev = "npm run dev" + `.trim(), + ) + + // Dotenv + await writeFile(joinPath(dir, '.env'), 'SHOPIFY_API_KEY=test-key\nSHOPIFY_API_SECRET=test-secret') + + // Hidden config + await mkdir(joinPath(dir, '.shopify')) + await writeFile( + joinPath(dir, '.shopify', 'project.json'), + JSON.stringify({'test-client-id': {dev_store_url: 'hidden.myshopify.com'}}), + ) + + // package.json (needed by the loader) + await writeFile(joinPath(dir, 'package.json'), JSON.stringify({name: 'test-app', dependencies: {}})) +} + +describe('Project integration', () => { + test('Project discovers the same directory as the old loader', async () => { + await inTemporaryDirectory(async (dir) => { + await setupRealApp(dir) + const specifications = await loadLocalExtensionsSpecifications() + + const project = await Project.load(dir) + const app = await loadApp({ + directory: dir, + userProvidedConfigName: undefined, + specifications, + mode: 'report', + }) + + expect(project.directory).toBe(app.directory) + }) + }) + + test('Project discovers the same extension files as the old loader', async () => { + await inTemporaryDirectory(async (dir) => { + await setupRealApp(dir) + const specifications = await loadLocalExtensionsSpecifications() + + const project = await Project.load(dir) + const app = await loadApp({ + directory: dir, + userProvidedConfigName: undefined, + specifications, + mode: 'report', + }) + + // The app's non-config extensions should match what the project discovered + const appExtensionPaths = app.realExtensions + .filter((ext) => !ext.isAppConfigExtension) + .map((ext) => ext.configurationPath) + .sort() + + const activeConfig = project.appConfigByName('shopify.app.toml')! + const projectExtensionPaths = extensionFilesForConfig(project, activeConfig) + .map((file) => file.path) + .sort() + + expect(projectExtensionPaths).toStrictEqual(appExtensionPaths) + }) + }) + + test('Project discovers the same web files as the old loader', async () => { + await inTemporaryDirectory(async (dir) => { + await setupRealApp(dir) + const specifications = await loadLocalExtensionsSpecifications() + + const project = await Project.load(dir) + const app = await loadApp({ + directory: dir, + userProvidedConfigName: undefined, + specifications, + mode: 'report', + }) + + const appWebDirs = app.webs.map((web) => web.directory).sort() + const activeConfig = project.appConfigByName('shopify.app.toml')! + const projectWebDirs = webFilesForConfig(project, activeConfig) + .map((file) => joinPath(file.path, '..')) + .sort() + + // Both should find the same web directories + expect(projectWebDirs.length).toBe(appWebDirs.length) + }) + }) + + test('resolveDotEnv matches the old loader dotenv', async () => { + await inTemporaryDirectory(async (dir) => { + await setupRealApp(dir) + const specifications = await loadLocalExtensionsSpecifications() + + const project = await Project.load(dir) + const app = await loadApp({ + directory: dir, + userProvidedConfigName: undefined, + specifications, + mode: 'report', + }) + + const configPath = joinPath(dir, 'shopify.app.toml') + const projectDotenv = resolveDotEnv(project, configPath) + + // Both should find the same .env file with the same variables + expect(projectDotenv?.path).toBe(app.dotenv?.path) + expect(projectDotenv?.variables).toStrictEqual(app.dotenv?.variables) + }) + }) + + test('resolveHiddenConfig matches the old loader hidden config', async () => { + await inTemporaryDirectory(async (dir) => { + await setupRealApp(dir) + const specifications = await loadLocalExtensionsSpecifications() + + const project = await Project.load(dir) + const app = await loadApp({ + directory: dir, + userProvidedConfigName: undefined, + specifications, + mode: 'report', + }) + + const projectHiddenConfig = await resolveHiddenConfig(project, 'test-client-id') + + expect(projectHiddenConfig).toStrictEqual(app.hiddenConfig) + }) + }) + + test('Project metadata matches the old loader metadata', async () => { + await inTemporaryDirectory(async (dir) => { + await setupRealApp(dir) + const specifications = await loadLocalExtensionsSpecifications() + + const project = await Project.load(dir) + const app = await loadApp({ + directory: dir, + userProvidedConfigName: undefined, + specifications, + mode: 'report', + }) + + expect(project.packageManager).toBe(app.packageManager) + expect(project.nodeDependencies).toStrictEqual(app.nodeDependencies) + expect(project.usesWorkspaces).toBe(app.usesWorkspaces) + }) + }) + + test('multi-config project discovers all configs', async () => { + await inTemporaryDirectory(async (dir) => { + await setupRealApp(dir) + + // Add a staging config with different extension dirs + await writeFile( + joinPath(dir, 'shopify.app.staging.toml'), + ` +client_id = "staging-client-id" +name = "Staging App" +application_url = "https://staging.example.com" +embedded = true +extension_directories = ["staging-ext/*"] + `.trim(), + ) + + await mkdir(joinPath(dir, 'staging-ext', 'staging-func')) + await writeFile( + joinPath(dir, 'staging-ext', 'staging-func', 'shopify.extension.toml'), + 'type = "function"\nname = "staging-func"\nhandle = "staging-func"', + ) + + const project = await Project.load(dir) + + // Should discover both configs + expect(project.appConfigFiles).toHaveLength(2) + expect(project.appConfigByClientId('test-client-id')).toBeDefined() + expect(project.appConfigByClientId('staging-client-id')).toBeDefined() + + // Should discover extensions from both configs' directories + expect(project.extensionConfigFiles.length).toBeGreaterThanOrEqual(2) + + // Filtering to default config should only get extensions/* + const defaultConfig = project.appConfigByName('shopify.app.toml')! + const defaultExts = extensionFilesForConfig(project, defaultConfig) + expect(defaultExts).toHaveLength(1) + + // Filtering to staging config should only get staging-ext/* + const stagingConfig = project.appConfigByName('shopify.app.staging.toml')! + const stagingExts = extensionFilesForConfig(project, stagingConfig) + expect(stagingExts).toHaveLength(1) + expect(stagingExts[0]!.content.name).toBe('staging-func') + }) + }) + + test('config-specific dotenv resolution works', async () => { + await inTemporaryDirectory(async (dir) => { + await setupRealApp(dir) + await writeFile(joinPath(dir, '.env.staging'), 'STAGING_VAR=staging-value') + await writeFile(joinPath(dir, 'shopify.app.staging.toml'), 'client_id = "staging"') + + const project = await Project.load(dir) + + // Default config gets .env + const defaultDotenv = resolveDotEnv(project, joinPath(dir, 'shopify.app.toml')) + expect(defaultDotenv?.variables.SHOPIFY_API_KEY).toBe('test-key') + + // Staging config gets .env.staging + const stagingDotenv = resolveDotEnv(project, joinPath(dir, 'shopify.app.staging.toml')) + expect(stagingDotenv?.variables.STAGING_VAR).toBe('staging-value') + }) + }) +}) diff --git a/packages/app/src/cli/models/project/project.test.ts b/packages/app/src/cli/models/project/project.test.ts new file mode 100644 index 0000000000..77b0c5a864 --- /dev/null +++ b/packages/app/src/cli/models/project/project.test.ts @@ -0,0 +1,293 @@ +import {Project} from './project.js' +import {describe, expect, test} from 'vitest' +import {inTemporaryDirectory, writeFile, mkdir} from '@shopify/cli-kit/node/fs' +import {joinPath, normalizePath} from '@shopify/cli-kit/node/path' + +async function writeAppToml(directory: string, content: string, name = 'shopify.app.toml'): Promise { + await writeFile(joinPath(directory, name), content) +} + +async function writeExtensionToml(directory: string, extName: string, content: string): Promise { + const extDir = joinPath(directory, 'extensions', extName) + await mkdir(extDir) + await writeFile(joinPath(extDir, 'shopify.extension.toml'), content) +} + +async function writeWebToml(directory: string, webName: string, content: string): Promise { + const webDir = joinPath(directory, 'web', webName) + await mkdir(webDir) + await writeFile(joinPath(webDir, 'shopify.web.toml'), content) +} + +describe('Project', () => { + describe('load', () => { + test('discovers the app config file', async () => { + await inTemporaryDirectory(async (dir) => { + await writeAppToml(dir, 'client_id = "abc123"\nname = "Test App"') + + const project = await Project.load(dir) + + expect(normalizePath(project.directory)).toBe(normalizePath(dir)) + expect(project.appConfigFiles).toHaveLength(1) + expect(project.appConfigFiles[0]!.content.client_id).toBe('abc123') + }) + }) + + test('discovers multiple app config files', async () => { + await inTemporaryDirectory(async (dir) => { + await writeAppToml(dir, 'client_id = "default"') + await writeAppToml(dir, 'client_id = "staging"', 'shopify.app.staging.toml') + await writeAppToml(dir, 'client_id = "production"', 'shopify.app.production.toml') + + const project = await Project.load(dir) + + expect(project.appConfigFiles).toHaveLength(3) + const clientIds = project.appConfigFiles.map((file) => file.content.client_id).sort() + expect(clientIds).toStrictEqual(['default', 'production', 'staging']) + }) + }) + + test('discovers extension config files', async () => { + await inTemporaryDirectory(async (dir) => { + await writeAppToml(dir, 'client_id = "abc"') + await writeExtensionToml(dir, 'my-func', 'type = "function"\nname = "my-func"') + await writeExtensionToml(dir, 'my-ui', 'type = "ui_extension"\nname = "my-ui"') + + const project = await Project.load(dir) + + expect(project.extensionConfigFiles).toHaveLength(2) + }) + }) + + test('discovers web config files', async () => { + await inTemporaryDirectory(async (dir) => { + await writeAppToml(dir, 'client_id = "abc"') + await writeWebToml(dir, 'backend', 'name = "backend"\nroles = ["backend"]') + + const project = await Project.load(dir) + + expect(project.webConfigFiles).toHaveLength(1) + expect(project.webConfigFiles[0]!.content.name).toBe('backend') + }) + }) + + test('discovers all dotenv files', async () => { + await inTemporaryDirectory(async (dir) => { + await writeAppToml(dir, 'client_id = "abc"') + await writeFile(joinPath(dir, '.env'), 'DEFAULT_KEY=default') + await writeFile(joinPath(dir, '.env.staging'), 'STAGING_KEY=staging') + await writeFile(joinPath(dir, '.env.production'), 'PROD_KEY=prod') + + const project = await Project.load(dir) + + expect(project.dotenvFiles.size).toBe(3) + expect(project.dotenvFiles.get('.env')?.variables.DEFAULT_KEY).toBe('default') + expect(project.dotenvFiles.get('.env.staging')?.variables.STAGING_KEY).toBe('staging') + expect(project.dotenvFiles.get('.env.production')?.variables.PROD_KEY).toBe('prod') + }) + }) + + test('loads raw hidden config', async () => { + await inTemporaryDirectory(async (dir) => { + await writeAppToml(dir, 'client_id = "abc"') + await mkdir(joinPath(dir, '.shopify')) + await writeFile( + joinPath(dir, '.shopify', 'project.json'), + JSON.stringify({ + abc: {dev_store_url: 'abc.myshopify.com'}, + def: {dev_store_url: 'def.myshopify.com'}, + }), + ) + + const project = await Project.load(dir) + + expect(project.hiddenConfigRaw.abc).toStrictEqual({dev_store_url: 'abc.myshopify.com'}) + expect(project.hiddenConfigRaw.def).toStrictEqual({dev_store_url: 'def.myshopify.com'}) + }) + }) + + test('returns empty hidden config when file is missing', async () => { + await inTemporaryDirectory(async (dir) => { + await writeAppToml(dir, 'client_id = "abc"') + + const project = await Project.load(dir) + + expect(project.hiddenConfigRaw).toStrictEqual({}) + }) + }) + + test('throws when no app config files found', async () => { + await inTemporaryDirectory(async (dir) => { + await expect(Project.load(dir)).rejects.toThrow() + }) + }) + + test('uses custom extension_directories from app config', async () => { + await inTemporaryDirectory(async (dir) => { + await writeAppToml(dir, 'client_id = "abc"\nextension_directories = ["custom/*"]') + const customDir = joinPath(dir, 'custom', 'my-ext') + await mkdir(customDir) + await writeFile(joinPath(customDir, 'shopify.extension.toml'), 'type = "function"\nname = "custom-ext"') + + const project = await Project.load(dir) + + expect(project.extensionConfigFiles).toHaveLength(1) + expect(project.extensionConfigFiles[0]!.content.name).toBe('custom-ext') + }) + }) + + test('unions extension_directories from all app configs', async () => { + await inTemporaryDirectory(async (dir) => { + await writeAppToml(dir, 'client_id = "default"\nextension_directories = ["ext-a/*"]') + await writeAppToml( + dir, + 'client_id = "staging"\nextension_directories = ["ext-b/*"]', + 'shopify.app.staging.toml', + ) + + const extADir = joinPath(dir, 'ext-a', 'func1') + await mkdir(extADir) + await writeFile(joinPath(extADir, 'shopify.extension.toml'), 'type = "function"\nname = "func1"') + + const extBDir = joinPath(dir, 'ext-b', 'func2') + await mkdir(extBDir) + await writeFile(joinPath(extBDir, 'shopify.extension.toml'), 'type = "function"\nname = "func2"') + + const project = await Project.load(dir) + + expect(project.extensionConfigFiles).toHaveLength(2) + }) + }) + + test('skips malformed inactive app config without blocking active config', async () => { + await inTemporaryDirectory(async (dir) => { + await writeAppToml(dir, 'client_id = "good"') + await writeAppToml(dir, '{{invalid toml content', 'shopify.app.broken.toml') + + const project = await Project.load(dir) + + // The broken config is skipped, but the valid one is loaded + expect(project.appConfigFiles).toHaveLength(1) + expect(project.appConfigFiles[0]!.content.client_id).toBe('good') + }) + }) + + test('skips malformed extension TOML without blocking project load', async () => { + await inTemporaryDirectory(async (dir) => { + await writeAppToml(dir, 'client_id = "abc"') + await writeExtensionToml(dir, 'good-ext', 'type = "function"\nname = "good"') + await writeExtensionToml(dir, 'bad-ext', '{{broken toml') + + const project = await Project.load(dir) + + // Only the valid extension is loaded + expect(project.extensionConfigFiles).toHaveLength(1) + expect(project.extensionConfigFiles[0]!.content.name).toBe('good') + }) + }) + + test('skips malformed web TOML without blocking project load', async () => { + await inTemporaryDirectory(async (dir) => { + await writeAppToml(dir, 'client_id = "abc"') + await writeWebToml(dir, 'good-web', 'name = "good"\nroles = ["backend"]') + await writeWebToml(dir, 'bad-web', '{{broken toml') + + const project = await Project.load(dir) + + // Only the valid web config is loaded + expect(project.webConfigFiles).toHaveLength(1) + expect(project.webConfigFiles[0]!.content.name).toBe('good') + }) + }) + + test('handles missing dotenv gracefully', async () => { + await inTemporaryDirectory(async (dir) => { + await writeAppToml(dir, 'client_id = "abc"') + + const project = await Project.load(dir) + + expect(project.dotenvFiles.size).toBe(0) + }) + }) + + test('handles legacy hidden config format', async () => { + await inTemporaryDirectory(async (dir) => { + await writeAppToml(dir, 'client_id = "abc"') + await mkdir(joinPath(dir, '.shopify')) + await writeFile( + joinPath(dir, '.shopify', 'project.json'), + JSON.stringify({dev_store_url: 'legacy.myshopify.com'}), + ) + + const project = await Project.load(dir) + + expect(project.hiddenConfigRaw.dev_store_url).toBe('legacy.myshopify.com') + }) + }) + }) + + describe('file lookup', () => { + test('appConfigByName finds by filename', async () => { + await inTemporaryDirectory(async (dir) => { + await writeAppToml(dir, 'client_id = "default"') + await writeAppToml(dir, 'client_id = "staging"', 'shopify.app.staging.toml') + + const project = await Project.load(dir) + + const staging = project.appConfigByName('shopify.app.staging.toml') + expect(staging).toBeDefined() + expect(staging!.content.client_id).toBe('staging') + + const missing = project.appConfigByName('shopify.app.missing.toml') + expect(missing).toBeUndefined() + }) + }) + + test('appConfigByClientId finds by client_id', async () => { + await inTemporaryDirectory(async (dir) => { + await writeAppToml(dir, 'client_id = "abc123"') + await writeAppToml(dir, 'client_id = "def456"', 'shopify.app.staging.toml') + + const project = await Project.load(dir) + + const found = project.appConfigByClientId('def456') + expect(found).toBeDefined() + expect(found!.path).toContain('staging') + }) + }) + + test('defaultAppConfig returns shopify.app.toml', async () => { + await inTemporaryDirectory(async (dir) => { + await writeAppToml(dir, 'client_id = "default"') + await writeAppToml(dir, 'client_id = "staging"', 'shopify.app.staging.toml') + + const project = await Project.load(dir) + + expect(project.defaultAppConfig).toBeDefined() + expect(project.defaultAppConfig!.content.client_id).toBe('default') + }) + }) + + test('defaultAppConfig returns undefined when only named configs exist', async () => { + await inTemporaryDirectory(async (dir) => { + await writeAppToml(dir, 'client_id = "staging"', 'shopify.app.staging.toml') + + const project = await Project.load(dir) + + expect(project.defaultAppConfig).toBeUndefined() + }) + }) + }) + + describe('metadata', () => { + test('exposes project directory', async () => { + await inTemporaryDirectory(async (dir) => { + await writeAppToml(dir, 'client_id = "abc"') + + const project = await Project.load(dir) + + expect(normalizePath(project.directory)).toBe(normalizePath(dir)) + }) + }) + }) +}) diff --git a/packages/app/src/cli/models/project/project.ts b/packages/app/src/cli/models/project/project.ts new file mode 100644 index 0000000000..7ffbba3bd5 --- /dev/null +++ b/packages/app/src/cli/models/project/project.ts @@ -0,0 +1,261 @@ +import {configurationFileNames} from '../../constants.js' +import {TomlFile} from '@shopify/cli-kit/node/toml/toml-file' +import {readAndParseDotEnv, DotEnvFile} from '@shopify/cli-kit/node/dot-env' +import {fileExists, glob, findPathUp, readFile} from '@shopify/cli-kit/node/fs' +import { + getDependencies, + getPackageManager, + PackageManager, + usesWorkspaces as detectUsesWorkspaces, +} from '@shopify/cli-kit/node/node-package-manager' +import {joinPath, basename} from '@shopify/cli-kit/node/path' +import {AbortError} from '@shopify/cli-kit/node/error' +import {outputDebug} from '@shopify/cli-kit/node/output' +import {JsonMapType} from '@shopify/cli-kit/node/toml' + +const APP_CONFIG_GLOB = 'shopify.app*.toml' +const APP_CONFIG_REGEX = /^shopify\.app(\.[-\w]+)?\.toml$/ +const EXTENSION_TOML = '*.extension.toml' +const WEB_TOML = 'shopify.web.toml' +const DEFAULT_EXTENSION_DIR = 'extensions/*' +const NODE_MODULES_EXCLUDE = '**/node_modules/**' +const DOTENV_GLOB = '.env*' + +/** + * A Project is the Shopify app as it exists on the filesystem. + * + * It abstracts the OS and location concerns — knows what files exist, + * where they are, and how to read/write them. It does NOT interpret + * config files as modules, select which config is active, or know + * about the platform. + * + * @public + */ +export class Project { + /** + * Discover a project from the filesystem. + * + * Walks up from the given directory to find the app root (the directory + * containing shopify.app*.toml files). Discovers all config files, + * metadata, and dependencies. + * + * Does NOT select which config is active or resolve modules. + */ + static async load(startDirectory: string): Promise { + const directory = await findProjectRoot(startDirectory) + + // Discover all app config files + const appConfigFiles = await discoverAppConfigFiles(directory) + if (appConfigFiles.length === 0) { + throw new AbortError(`Could not find a Shopify app TOML file in ${directory}`) + } + + // Discover extension files from all app configs' extension_directories (union). + // Configs that don't specify extension_directories use the default (extensions/*). + const allExtensionDirs = new Set() + for (const appConfig of appConfigFiles) { + const dirs = appConfig.content.extension_directories + if (Array.isArray(dirs)) { + for (const dir of dirs) allExtensionDirs.add(dir as string) + } else { + allExtensionDirs.add(DEFAULT_EXTENSION_DIR) + } + } + const extensionConfigFiles = await discoverExtensionFiles(directory, [...allExtensionDirs]) + + // Discover web files from all app configs' web_directories (union) + const allWebDirs = new Set() + for (const appConfig of appConfigFiles) { + const dirs = appConfig.content.web_directories + if (Array.isArray(dirs)) { + for (const dir of dirs) allWebDirs.add(dir as string) + } + } + const webConfigFiles = await discoverWebFiles(directory, allWebDirs.size > 0 ? [...allWebDirs] : undefined) + + // Project metadata + const packageJSONPath = joinPath(directory, 'package.json') + const hasPackageJson = await fileExists(packageJSONPath) + const packageManager = hasPackageJson ? await getPackageManager(directory) : 'unknown' + const nodeDependencies = hasPackageJson ? await getDependencies(packageJSONPath) : {} + const usesWorkspaces = hasPackageJson ? await detectUsesWorkspaces(directory) : false + + // Dotenv: discover ALL .env* files in the root + const dotenvFiles = await discoverDotEnvFiles(directory) + + // Hidden config: store the raw .shopify/project.json content + const hiddenConfigRaw = await loadRawHiddenConfig(directory) + + return new Project({ + directory, + packageManager, + nodeDependencies, + usesWorkspaces, + appConfigFiles, + extensionConfigFiles, + webConfigFiles, + dotenvFiles, + hiddenConfigRaw, + }) + } + + readonly directory: string + readonly packageManager: PackageManager + readonly nodeDependencies: Record + readonly usesWorkspaces: boolean + readonly appConfigFiles: TomlFile[] + readonly extensionConfigFiles: TomlFile[] + readonly webConfigFiles: TomlFile[] + + /** All .env* files discovered in the project root, keyed by filename */ + readonly dotenvFiles: Map + + /** Raw .shopify/project.json content — selection logic looks up by client_id */ + readonly hiddenConfigRaw: JsonMapType + + private constructor(options: { + directory: string + packageManager: PackageManager + nodeDependencies: Record + usesWorkspaces: boolean + appConfigFiles: TomlFile[] + extensionConfigFiles: TomlFile[] + webConfigFiles: TomlFile[] + dotenvFiles: Map + hiddenConfigRaw: JsonMapType + }) { + this.directory = options.directory + this.packageManager = options.packageManager + this.nodeDependencies = options.nodeDependencies + this.usesWorkspaces = options.usesWorkspaces + this.appConfigFiles = options.appConfigFiles + this.extensionConfigFiles = options.extensionConfigFiles + this.webConfigFiles = options.webConfigFiles + this.dotenvFiles = options.dotenvFiles + this.hiddenConfigRaw = options.hiddenConfigRaw + } + + // ── File lookup ─────────────────────────────────────────── + + /** Find an app config file by filename (e.g., 'shopify.app.staging.toml') */ + appConfigByName(fileName: string): TomlFile | undefined { + return this.appConfigFiles.find((file) => basename(file.path) === fileName) + } + + /** Find an app config file by client_id */ + appConfigByClientId(clientId: string): TomlFile | undefined { + return this.appConfigFiles.find((file) => file.content.client_id === clientId) + } + + /** The default app config (shopify.app.toml), if it exists */ + get defaultAppConfig(): TomlFile | undefined { + return this.appConfigByName(configurationFileNames.app) + } +} + +// ── Filesystem discovery functions ────────────────────────── + +async function findProjectRoot(startDirectory: string): Promise { + const found = await findPathUp( + async (directory) => { + const matches = await glob(joinPath(directory, APP_CONFIG_GLOB)) + if (matches.length > 0) return directory + }, + { + cwd: startDirectory, + type: 'directory', + }, + ) + if (!found) { + throw new AbortError( + `Could not find a Shopify app configuration file. Looked in ${startDirectory} and parent directories.`, + ) + } + return found +} + +async function discoverAppConfigFiles(directory: string): Promise { + const pattern = joinPath(directory, APP_CONFIG_GLOB) + const paths = await glob(pattern) + const validPaths = paths.filter((filePath) => APP_CONFIG_REGEX.test(basename(filePath))) + return readTomlFilesSafe(validPaths) +} + +async function discoverExtensionFiles(directory: string, extensionDirectories?: string[]): Promise { + const dirs = extensionDirectories ?? [DEFAULT_EXTENSION_DIR] + const patterns = dirs.map((dir) => joinPath(directory, dir, EXTENSION_TOML)) + patterns.push(`!${joinPath(directory, NODE_MODULES_EXCLUDE)}`) + const paths = await glob(patterns) + return readTomlFilesSafe(paths) +} + +async function discoverWebFiles(directory: string, webDirectories?: string[]): Promise { + const dirs = webDirectories ?? ['**'] + const patterns = dirs.map((dir) => joinPath(directory, dir, WEB_TOML)) + patterns.push(`!${joinPath(directory, NODE_MODULES_EXCLUDE)}`) + const paths = await glob(patterns) + return readTomlFilesSafe(paths) +} + +/** + * Read TOML files, skipping any that fail to parse. + * This prevents a malformed inactive config or extension TOML + * from blocking the active config from loading. + */ +async function readTomlFilesSafe(paths: string[]): Promise { + const results = await Promise.all( + paths.map(async (filePath) => { + try { + return await TomlFile.read(filePath) + // eslint-disable-next-line no-catch-all/no-catch-all + } catch { + outputDebug(`Skipping malformed TOML file: ${filePath}`) + return undefined + } + }), + ) + return results.filter((file): file is TomlFile => file !== undefined) +} + +/** Discover all .env* files in the project root */ +async function discoverDotEnvFiles(directory: string): Promise> { + const pattern = joinPath(directory, DOTENV_GLOB) + const paths = await glob(pattern, {dot: true}) + const validPaths = paths.filter((filePath) => { + const fileName = basename(filePath) + return fileName === '.env' || /^\.env\.[\w-]+$/.test(fileName) + }) + + const entries = await Promise.all( + validPaths.map(async (filePath) => { + try { + const dotenv = await readAndParseDotEnv(filePath) + return [basename(filePath), dotenv] as const + // eslint-disable-next-line no-catch-all/no-catch-all + } catch { + return undefined + } + }), + ) + + const result = new Map() + for (const entry of entries) { + if (entry) result.set(entry[0], entry[1]) + } + return result +} + +/** Load the raw .shopify/project.json as JsonMapType */ +async function loadRawHiddenConfig(directory: string): Promise { + const hiddenPath = joinPath(directory, configurationFileNames.hiddenFolder, configurationFileNames.hiddenConfig) + try { + if (await fileExists(hiddenPath)) { + const raw = await readFile(hiddenPath) + return JSON.parse(raw) as JsonMapType + } + // eslint-disable-next-line no-catch-all/no-catch-all + } catch { + // Parse errors are not fatal + } + return {} +}