Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions packages/app/src/cli/models/app/loader.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
13 changes: 12 additions & 1 deletion packages/app/src/cli/models/app/loader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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}
Expand Down Expand Up @@ -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
Expand All @@ -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)

Expand Down
203 changes: 203 additions & 0 deletions packages/app/src/cli/models/project/active-config.test.ts
Original file line number Diff line number Diff line change
@@ -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')
})
})
})
133 changes: 133 additions & 0 deletions packages/app/src/cli/models/project/active-config.ts
Original file line number Diff line number Diff line change
@@ -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<ActiveConfig> {
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<ActiveConfig> {
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,
}
}
Loading
Loading