From 2edfd389369b5cf9cac0ee89c0ff797983ae5d2d Mon Sep 17 00:00:00 2001 From: Ryan Bahan Date: Mon, 16 Mar 2026 14:00:34 -0600 Subject: [PATCH 1/6] Add Project domain model with ActiveConfig and config-selection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduces a new Project domain model that abstracts the filesystem for Shopify app projects. Project discovers all config files, extension files, web files, dotenv files, hidden config, and project metadata in a single scan. - Project: discovers and holds all filesystem state without interpreting it. Supports multi-config projects (shopify.app.*.toml). - ActiveConfig: represents the selected app configuration, derived from Project. Resolves config-specific dotenv and hidden config. - Config selection functions: resolveDotEnv, resolveHiddenConfig, extensionFilesForConfig, webFilesForConfig — pure functions that derive config-specific state from Project. 48 tests covering project discovery, config selection, file filtering, dotenv resolution, hidden config lookup, multi-config scenarios, and integration parity with the existing loader. All new code — zero changes to existing files. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../cli/models/project/active-config.test.ts | 166 +++++++++++ .../src/cli/models/project/active-config.ts | 124 ++++++++ .../models/project/config-selection.test.ts | 196 +++++++++++++ .../cli/models/project/config-selection.ts | 112 ++++++++ .../project/project-integration.test.ts | 271 ++++++++++++++++++ .../src/cli/models/project/project.test.ts | 252 ++++++++++++++++ .../app/src/cli/models/project/project.ts | 240 ++++++++++++++++ 7 files changed, 1361 insertions(+) create mode 100644 packages/app/src/cli/models/project/active-config.test.ts create mode 100644 packages/app/src/cli/models/project/active-config.ts create mode 100644 packages/app/src/cli/models/project/config-selection.test.ts create mode 100644 packages/app/src/cli/models/project/config-selection.ts create mode 100644 packages/app/src/cli/models/project/project-integration.test.ts create mode 100644 packages/app/src/cli/models/project/project.test.ts create mode 100644 packages/app/src/cli/models/project/project.ts 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..1710616881 --- /dev/null +++ b/packages/app/src/cli/models/project/active-config.test.ts @@ -0,0 +1,166 @@ +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() + }) + }) +}) 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..39c6189926 --- /dev/null +++ b/packages/app/src/cli/models/project/active-config.ts @@ -0,0 +1,124 @@ +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' + +/** + * 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 + const source: ConfigSource = configName ? 'flag' : 'cached' + + // 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 + + // 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..3327be572d --- /dev/null +++ b/packages/app/src/cli/models/project/config-selection.test.ts @@ -0,0 +1,196 @@ +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('falls back to default .env 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) + + const dotenv = resolveDotEnv(project, joinPath(dir, 'shopify.app.staging.toml')) + + expect(dotenv).toBeDefined() + expect(dotenv!.variables.KEY).toBe('default') + }) + }) + + 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() + }) + }) +}) + +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..1b5687ef23 --- /dev/null +++ b/packages/app/src/cli/models/project/config-selection.ts @@ -0,0 +1,112 @@ +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' + +/** + * Resolve the config-specific dotenv file for an active config. + * + * shopify.app.toml → .env + * shopify.app.staging.toml → .env.staging + * + * Falls back to the default .env if the config-specific one doesn't exist. + * @public + */ +export function resolveDotEnv(project: Project, activeConfigPath: string): DotEnvFile | undefined { + const shorthand = getAppConfigurationShorthand(activeConfigPath) + if (shorthand) { + const specificName = `${dotEnvFileNames.production}.${shorthand}` + const specific = project.dotenvFiles.get(specificName) + if (specific) return specific + } + 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 = file.path.replace(project.directory, '').replace(/^\//, '') + return relPath.startsWith('extensions/') + }) + } + + // Filter to files that are within the active config's declared directories + const dirPrefixes = (configDirs as string[]).map((dir) => { + // Remove trailing glob (e.g., "custom/*" → "custom/") + return dir.replace(/\*.*$/, '') + }) + + return project.extensionConfigFiles.filter((file) => { + const relPath = file.path.replace(project.directory, '').replace(/^\//, '') + 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(/\*.*$/, '')) + + return project.webConfigFiles.filter((file) => { + const relPath = file.path.replace(project.directory, '').replace(/^\//, '') + 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..de0936ce48 --- /dev/null +++ b/packages/app/src/cli/models/project/project.test.ts @@ -0,0 +1,252 @@ +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('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..0039dab947 --- /dev/null +++ b/packages/app/src/cli/models/project/project.ts @@ -0,0 +1,240 @@ +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 {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 Promise.all(validPaths.map((filePath) => TomlFile.read(filePath))) +} + +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 Promise.all(paths.map((filePath) => TomlFile.read(filePath))) +} + +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 Promise.all(paths.map((filePath) => TomlFile.read(filePath))) +} + +/** 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 {} +} From 8964add7bf61a7c6fa98430474a0803831e7bf79 Mon Sep 17 00:00:00 2001 From: Ryan Bahan Date: Tue, 17 Mar 2026 17:02:10 -0600 Subject: [PATCH 2/6] Address review feedback: fix dotenv fallback, Windows paths, eager parsing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ConfigSource: add 'default' variant so telemetry correctly distinguishes no-flag-no-cache fallback from a cached selection - DotEnv: remove fallback from config-specific to base .env for non-default configs — preserves old semantics where .env.staging missing meant no dotenv, not silent inheritance from .env - Windows paths: replace hardcoded forward-slash string manipulation with relativePath() from cli-kit in extensionFilesForConfig/webFilesForConfig - Glob-to-prefix: add comment documenting the simplification and its limits - Eager parsing: wrap TomlFile.read() in try/catch during discovery so a malformed inactive config or extension TOML is skipped with a debug log instead of blocking the active config from loading Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/app/src/cli/models/app/loader.ts | 2 + .../cli/models/project/active-config.test.ts | 37 +++++++++++++++++ .../src/cli/models/project/active-config.ts | 13 +++++- .../models/project/config-selection.test.ts | 6 +-- .../cli/models/project/config-selection.ts | 21 ++++++---- .../src/cli/models/project/project.test.ts | 41 +++++++++++++++++++ .../app/src/cli/models/project/project.ts | 27 ++++++++++-- 7 files changed, 130 insertions(+), 17 deletions(-) diff --git a/packages/app/src/cli/models/app/loader.ts b/packages/app/src/cli/models/app/loader.ts index c4c06bd9d6..d5c6e015c4 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} diff --git a/packages/app/src/cli/models/project/active-config.test.ts b/packages/app/src/cli/models/project/active-config.test.ts index 1710616881..c50a64d257 100644 --- a/packages/app/src/cli/models/project/active-config.test.ts +++ b/packages/app/src/cli/models/project/active-config.test.ts @@ -163,4 +163,41 @@ describe('selectActiveConfig', () => { 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 index 39c6189926..fa4dcc568c 100644 --- a/packages/app/src/cli/models/project/active-config.ts +++ b/packages/app/src/cli/models/project/active-config.ts @@ -10,7 +10,7 @@ import {fileExistsSync} from '@shopify/cli-kit/node/fs' import {joinPath, basename} from '@shopify/cli-kit/node/path' /** @public */ -export type ConfigSource = 'flag' | 'cached' +export type ConfigSource = 'flag' | 'cached' | 'default' /** * The selected app configuration — one specific TOML from the project's @@ -53,7 +53,6 @@ export interface ActiveConfig { */ export async function selectActiveConfig(project: Project, userProvidedConfigName?: string): Promise { let configName = userProvidedConfigName - const source: ConfigSource = configName ? 'flag' : 'cached' // Check cache for previously selected config const cachedConfigName = getCachedAppInfo(project.directory)?.configFile @@ -72,6 +71,16 @@ export async function selectActiveConfig(project: Project, userProvidedConfigNam 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) diff --git a/packages/app/src/cli/models/project/config-selection.test.ts b/packages/app/src/cli/models/project/config-selection.test.ts index 3327be572d..e52d03074c 100644 --- a/packages/app/src/cli/models/project/config-selection.test.ts +++ b/packages/app/src/cli/models/project/config-selection.test.ts @@ -36,16 +36,16 @@ describe('resolveDotEnv', () => { }) }) - test('falls back to default .env when config-specific dotenv missing', async () => { + 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).toBeDefined() - expect(dotenv!.variables.KEY).toBe('default') + expect(dotenv).toBeUndefined() }) }) diff --git a/packages/app/src/cli/models/project/config-selection.ts b/packages/app/src/cli/models/project/config-selection.ts index 1b5687ef23..075a6ab525 100644 --- a/packages/app/src/cli/models/project/config-selection.ts +++ b/packages/app/src/cli/models/project/config-selection.ts @@ -6,22 +6,23 @@ import {patchAppHiddenConfigFile} from '../../services/app/patch-app-configurati 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 + * shopify.app.staging.toml → .env.staging (no fallback to .env) * - * Falls back to the default .env if the config-specific one doesn't exist. + * 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}` - const specific = project.dotenvFiles.get(specificName) - if (specific) return specific + return project.dotenvFiles.get(specificName) } return project.dotenvFiles.get(dotEnvFileNames.production) } @@ -75,19 +76,21 @@ export function extensionFilesForConfig(project: Project, activeConfig: TomlFile if (!Array.isArray(configDirs) || configDirs.length === 0) { // Default: extensions/* — filter project files by path prefix return project.extensionConfigFiles.filter((file) => { - const relPath = file.path.replace(project.directory, '').replace(/^\//, '') + const relPath = relativePath(project.directory, file.path) return relPath.startsWith('extensions/') }) } - // Filter to files that are within the active config's declared directories + // 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) => { - // Remove trailing glob (e.g., "custom/*" → "custom/") return dir.replace(/\*.*$/, '') }) return project.extensionConfigFiles.filter((file) => { - const relPath = file.path.replace(project.directory, '').replace(/^\//, '') + const relPath = relativePath(project.directory, file.path) return dirPrefixes.some((prefix) => relPath.startsWith(prefix)) }) } @@ -106,7 +109,7 @@ export function webFilesForConfig(project: Project, activeConfig: TomlFile): Tom const dirPrefixes = (configDirs as string[]).map((dir) => dir.replace(/\*.*$/, '')) return project.webConfigFiles.filter((file) => { - const relPath = file.path.replace(project.directory, '').replace(/^\//, '') + const relPath = relativePath(project.directory, file.path) return dirPrefixes.some((prefix) => relPath.startsWith(prefix)) }) } diff --git a/packages/app/src/cli/models/project/project.test.ts b/packages/app/src/cli/models/project/project.test.ts index de0936ce48..77b0c5a864 100644 --- a/packages/app/src/cli/models/project/project.test.ts +++ b/packages/app/src/cli/models/project/project.test.ts @@ -159,6 +159,47 @@ describe('Project', () => { }) }) + 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"') diff --git a/packages/app/src/cli/models/project/project.ts b/packages/app/src/cli/models/project/project.ts index 0039dab947..f9ab17df41 100644 --- a/packages/app/src/cli/models/project/project.ts +++ b/packages/app/src/cli/models/project/project.ts @@ -10,6 +10,7 @@ import { } 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' @@ -177,7 +178,7 @@ 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 Promise.all(validPaths.map((filePath) => TomlFile.read(filePath))) + return readTomlFilesSafe(validPaths) } async function discoverExtensionFiles(directory: string, extensionDirectories?: string[]): Promise { @@ -185,7 +186,7 @@ async function discoverExtensionFiles(directory: string, extensionDirectories?: const patterns = dirs.map((dir) => joinPath(directory, dir, EXTENSION_TOML)) patterns.push(`!${joinPath(directory, NODE_MODULES_EXCLUDE)}`) const paths = await glob(patterns) - return Promise.all(paths.map((filePath) => TomlFile.read(filePath))) + return readTomlFilesSafe(paths) } async function discoverWebFiles(directory: string, webDirectories?: string[]): Promise { @@ -193,7 +194,27 @@ async function discoverWebFiles(directory: string, webDirectories?: string[]): P const patterns = dirs.map((dir) => joinPath(directory, dir, WEB_TOML)) patterns.push(`!${joinPath(directory, NODE_MODULES_EXCLUDE)}`) const paths = await glob(patterns) - return Promise.all(paths.map((filePath) => TomlFile.read(filePath))) + 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 */ From 8cb70ec8b22f98be21284063f5b189f87b24691b Mon Sep 17 00:00:00 2001 From: Ryan Bahan Date: Wed, 18 Mar 2026 08:48:57 -0600 Subject: [PATCH 3/6] Fix configSource in getAppConfigurationState to distinguish default fallback The old code computed configSource before cache resolution, so when neither a flag nor a cached config was set, the default shopify.app.toml fallback was mislabeled as 'cached' instead of 'default'. Move the source determination after resolution to match the three-way logic already used by selectActiveConfig. Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/app/src/cli/models/app/loader.test.ts | 4 ++-- packages/app/src/cli/models/app/loader.ts | 11 ++++++++++- 2 files changed, 12 insertions(+), 3 deletions(-) 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 d5c6e015c4..95e908e240 100644 --- a/packages/app/src/cli/models/app/loader.ts +++ b/packages/app/src/cli/models/app/loader.ts @@ -906,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 @@ -922,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) From a70ecffeb287ac356cf3a743f20e017a166e09b7 Mon Sep 17 00:00:00 2001 From: Ryan Bahan Date: Wed, 18 Mar 2026 08:51:05 -0600 Subject: [PATCH 4/6] Fix dotenv discovery to support hyphenated config names The regex filter in discoverDotEnvFiles used \w+ which excludes hyphens, so .env.my-staging would not be discovered for a shopify.app.my-staging.toml config. Change to [\w-]+ to match the config shorthand naming convention. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../src/cli/models/project/config-selection.test.ts | 12 ++++++++++++ packages/app/src/cli/models/project/project.ts | 2 +- 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/packages/app/src/cli/models/project/config-selection.test.ts b/packages/app/src/cli/models/project/config-selection.test.ts index e52d03074c..8d2f392d73 100644 --- a/packages/app/src/cli/models/project/config-selection.test.ts +++ b/packages/app/src/cli/models/project/config-selection.test.ts @@ -58,6 +58,18 @@ describe('resolveDotEnv', () => { 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', () => { diff --git a/packages/app/src/cli/models/project/project.ts b/packages/app/src/cli/models/project/project.ts index f9ab17df41..7ffbba3bd5 100644 --- a/packages/app/src/cli/models/project/project.ts +++ b/packages/app/src/cli/models/project/project.ts @@ -223,7 +223,7 @@ async function discoverDotEnvFiles(directory: string): Promise { const fileName = basename(filePath) - return fileName === '.env' || /^\.env\.\w+$/.test(fileName) + return fileName === '.env' || /^\.env\.[\w-]+$/.test(fileName) }) const entries = await Promise.all( From 2d7789e369011842145b3b5433b41419f6a846d9 Mon Sep 17 00:00:00 2001 From: Ryan Bahan Date: Wed, 18 Mar 2026 08:52:41 -0600 Subject: [PATCH 5/6] Normalize path separators in extensionFilesForConfig and webFilesForConfig relativePath() returns OS-native separators (backslashes on Windows), so startsWith('extensions/') comparisons would fail. Normalize to forward slashes before prefix matching. Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/app/src/cli/models/project/config-selection.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/app/src/cli/models/project/config-selection.ts b/packages/app/src/cli/models/project/config-selection.ts index 075a6ab525..a633e82488 100644 --- a/packages/app/src/cli/models/project/config-selection.ts +++ b/packages/app/src/cli/models/project/config-selection.ts @@ -76,7 +76,7 @@ export function extensionFilesForConfig(project: Project, activeConfig: TomlFile 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) + const relPath = relativePath(project.directory, file.path).replace(/\\/g, '/') return relPath.startsWith('extensions/') }) } @@ -90,7 +90,7 @@ export function extensionFilesForConfig(project: Project, activeConfig: TomlFile }) return project.extensionConfigFiles.filter((file) => { - const relPath = relativePath(project.directory, file.path) + const relPath = relativePath(project.directory, file.path).replace(/\\/g, '/') return dirPrefixes.some((prefix) => relPath.startsWith(prefix)) }) } @@ -109,7 +109,7 @@ export function webFilesForConfig(project: Project, activeConfig: TomlFile): Tom const dirPrefixes = (configDirs as string[]).map((dir) => dir.replace(/\*.*$/, '')) return project.webConfigFiles.filter((file) => { - const relPath = relativePath(project.directory, file.path) + const relPath = relativePath(project.directory, file.path).replace(/\\/g, '/') return dirPrefixes.some((prefix) => relPath.startsWith(prefix)) }) } From af2a01877a30763a1947e9cd23a2de8439d19f7f Mon Sep 17 00:00:00 2001 From: Ryan Bahan Date: Wed, 18 Mar 2026 08:54:59 -0600 Subject: [PATCH 6/6] Ensure trailing slash on directory prefixes to prevent false prefix matches Without a trailing slash, extension_directories like "ext-a" would also match "ext-a-other/..." paths. Normalize prefixes to always end with '/' before startsWith comparison. Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/app/src/cli/models/project/config-selection.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/app/src/cli/models/project/config-selection.ts b/packages/app/src/cli/models/project/config-selection.ts index a633e82488..dee3504d58 100644 --- a/packages/app/src/cli/models/project/config-selection.ts +++ b/packages/app/src/cli/models/project/config-selection.ts @@ -86,7 +86,7 @@ export function extensionFilesForConfig(project: Project, activeConfig: TomlFile // 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(/\*.*$/, '') + return dir.replace(/\*.*$/, '').replace(/\/?$/, '/') }) return project.extensionConfigFiles.filter((file) => { @@ -106,7 +106,7 @@ export function webFilesForConfig(project: Project, activeConfig: TomlFile): Tom return project.webConfigFiles } - const dirPrefixes = (configDirs as string[]).map((dir) => dir.replace(/\*.*$/, '')) + const dirPrefixes = (configDirs as string[]).map((dir) => dir.replace(/\*.*$/, '').replace(/\/?$/, '/')) return project.webConfigFiles.filter((file) => { const relPath = relativePath(project.directory, file.path).replace(/\\/g, '/')