Skip to content

Commit c7fcd8b

Browse files
ryancbahanclaude
andcommitted
Add Project domain model with ActiveConfig and config-selection
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) <noreply@anthropic.com>
1 parent f1bbbcc commit c7fcd8b

7 files changed

Lines changed: 1368 additions & 0 deletions

File tree

Lines changed: 167 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,167 @@
1+
import {selectActiveConfig} from './active-config.js'
2+
import {Project} from './project.js'
3+
import {describe, expect, test, vi, beforeEach} from 'vitest'
4+
import {inTemporaryDirectory, writeFile, mkdir} from '@shopify/cli-kit/node/fs'
5+
import {joinPath} from '@shopify/cli-kit/node/path'
6+
7+
vi.mock('../../services/local-storage.js', () => ({
8+
getCachedAppInfo: vi.fn().mockReturnValue(undefined),
9+
setCachedAppInfo: vi.fn(),
10+
clearCachedAppInfo: vi.fn(),
11+
}))
12+
13+
vi.mock('../../services/app/config/use.js', () => ({
14+
default: vi.fn(),
15+
}))
16+
17+
const {getCachedAppInfo} = await import('../../services/local-storage.js')
18+
const useModule = await import('../../services/app/config/use.js')
19+
20+
beforeEach(() => {
21+
vi.mocked(getCachedAppInfo).mockReturnValue(undefined)
22+
})
23+
24+
describe('selectActiveConfig', () => {
25+
test('selects config by user-provided name (flag)', async () => {
26+
await inTemporaryDirectory(async (dir) => {
27+
await writeFile(joinPath(dir, 'shopify.app.toml'), 'client_id = "default"')
28+
await writeFile(joinPath(dir, 'shopify.app.staging.toml'), 'client_id = "staging"')
29+
const project = await Project.load(dir)
30+
31+
const config = await selectActiveConfig(project, 'staging')
32+
33+
expect(config.fileName).toBe('shopify.app.staging.toml')
34+
expect(config.file.content.client_id).toBe('staging')
35+
expect(config.source).toBe('flag')
36+
expect(config.isLinked).toBe(true)
37+
})
38+
})
39+
40+
test('selects config from cache', async () => {
41+
await inTemporaryDirectory(async (dir) => {
42+
await writeFile(joinPath(dir, 'shopify.app.toml'), 'client_id = "default"')
43+
await writeFile(joinPath(dir, 'shopify.app.production.toml'), 'client_id = "production"')
44+
const project = await Project.load(dir)
45+
46+
vi.mocked(getCachedAppInfo).mockReturnValue({
47+
directory: dir,
48+
configFile: 'shopify.app.production.toml',
49+
})
50+
51+
const config = await selectActiveConfig(project)
52+
53+
expect(config.fileName).toBe('shopify.app.production.toml')
54+
expect(config.file.content.client_id).toBe('production')
55+
expect(config.source).toBe('cached')
56+
})
57+
})
58+
59+
test('falls back to default shopify.app.toml when no flag or cache', async () => {
60+
await inTemporaryDirectory(async (dir) => {
61+
await writeFile(joinPath(dir, 'shopify.app.toml'), 'client_id = "default-id"')
62+
const project = await Project.load(dir)
63+
64+
const config = await selectActiveConfig(project)
65+
66+
expect(config.fileName).toBe('shopify.app.toml')
67+
expect(config.file.content.client_id).toBe('default-id')
68+
})
69+
})
70+
71+
test('detects isLinked from client_id', async () => {
72+
await inTemporaryDirectory(async (dir) => {
73+
await writeFile(joinPath(dir, 'shopify.app.toml'), 'client_id = ""')
74+
const project = await Project.load(dir)
75+
76+
const config = await selectActiveConfig(project)
77+
78+
expect(config.isLinked).toBe(false)
79+
})
80+
})
81+
82+
test('detects isLinked when client_id is present', async () => {
83+
await inTemporaryDirectory(async (dir) => {
84+
await writeFile(joinPath(dir, 'shopify.app.toml'), 'client_id = "abc123"')
85+
const project = await Project.load(dir)
86+
87+
const config = await selectActiveConfig(project)
88+
89+
expect(config.isLinked).toBe(true)
90+
})
91+
})
92+
93+
test('resolves config-specific dotenv', async () => {
94+
await inTemporaryDirectory(async (dir) => {
95+
await writeFile(joinPath(dir, 'shopify.app.staging.toml'), 'client_id = "staging"')
96+
await writeFile(joinPath(dir, 'shopify.app.toml'), 'client_id = "default"')
97+
await writeFile(joinPath(dir, '.env'), 'KEY=default')
98+
await writeFile(joinPath(dir, '.env.staging'), 'KEY=staging')
99+
const project = await Project.load(dir)
100+
101+
const config = await selectActiveConfig(project, 'staging')
102+
103+
expect(config.dotenv).toBeDefined()
104+
expect(config.dotenv!.variables.KEY).toBe('staging')
105+
})
106+
})
107+
108+
test('resolves hidden config for client_id', async () => {
109+
await inTemporaryDirectory(async (dir) => {
110+
await writeFile(joinPath(dir, 'shopify.app.toml'), 'client_id = "abc123"')
111+
await mkdir(joinPath(dir, '.shopify'))
112+
await writeFile(
113+
joinPath(dir, '.shopify', 'project.json'),
114+
JSON.stringify({abc123: {dev_store_url: 'test.myshopify.com'}}),
115+
)
116+
const project = await Project.load(dir)
117+
118+
const config = await selectActiveConfig(project)
119+
120+
expect(config.hiddenConfig).toStrictEqual({dev_store_url: 'test.myshopify.com'})
121+
})
122+
})
123+
124+
test('returns empty hidden config when no entry for client_id', async () => {
125+
await inTemporaryDirectory(async (dir) => {
126+
await writeFile(joinPath(dir, 'shopify.app.toml'), 'client_id = "abc123"')
127+
const project = await Project.load(dir)
128+
129+
const config = await selectActiveConfig(project)
130+
131+
expect(config.hiddenConfig).toStrictEqual({})
132+
})
133+
})
134+
135+
test('path is absolute', async () => {
136+
await inTemporaryDirectory(async (dir) => {
137+
await writeFile(joinPath(dir, 'shopify.app.toml'), 'client_id = "abc"')
138+
const project = await Project.load(dir)
139+
140+
const config = await selectActiveConfig(project)
141+
142+
expect(config.path).toBe(joinPath(dir, 'shopify.app.toml'))
143+
})
144+
})
145+
146+
test('accepts full filename as config name', async () => {
147+
await inTemporaryDirectory(async (dir) => {
148+
await writeFile(joinPath(dir, 'shopify.app.toml'), 'client_id = "default"')
149+
await writeFile(joinPath(dir, 'shopify.app.staging.toml'), 'client_id = "staging"')
150+
const project = await Project.load(dir)
151+
152+
const config = await selectActiveConfig(project, 'shopify.app.staging.toml')
153+
154+
expect(config.fileName).toBe('shopify.app.staging.toml')
155+
expect(config.file.content.client_id).toBe('staging')
156+
})
157+
})
158+
159+
test('throws when requested config does not exist', async () => {
160+
await inTemporaryDirectory(async (dir) => {
161+
await writeFile(joinPath(dir, 'shopify.app.toml'), 'client_id = "default"')
162+
const project = await Project.load(dir)
163+
164+
await expect(selectActiveConfig(project, 'nonexistent')).rejects.toThrow()
165+
})
166+
})
167+
})
Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
1+
import {Project} from './project.js'
2+
import {resolveDotEnv, resolveHiddenConfig} from './config-selection.js'
3+
import {AppHiddenConfig, BasicAppConfigurationWithoutModules} from '../app/app.js'
4+
import {AppConfigurationFileName, AppConfigurationState, getConfigurationPath} from '../app/loader.js'
5+
import {getCachedAppInfo} from '../../services/local-storage.js'
6+
import use from '../../services/app/config/use.js'
7+
import {TomlFile} from '@shopify/cli-kit/node/toml/toml-file'
8+
import {DotEnvFile} from '@shopify/cli-kit/node/dot-env'
9+
import {fileExistsSync} from '@shopify/cli-kit/node/fs'
10+
import {joinPath} from '@shopify/cli-kit/node/path'
11+
12+
/** @public */
13+
export type ConfigSource = 'flag' | 'cached'
14+
15+
/**
16+
* The selected app configuration — one specific TOML from the project's
17+
* potentially many app config files, plus config-specific derived state.
18+
*
19+
* A sibling to Project, not a child. Project is the environment;
20+
* ActiveConfig is a selection decision applied to that environment.
21+
* @public
22+
*/
23+
export interface ActiveConfig {
24+
/** The selected app TOML file (from project.appConfigFiles) */
25+
file: TomlFile
26+
/** Absolute path to the config file */
27+
path: string
28+
/** Filename, e.g., 'shopify.app.staging.toml' */
29+
fileName: string
30+
/** How the selection was made */
31+
source: ConfigSource
32+
/** Whether the config has a non-empty client_id */
33+
isLinked: boolean
34+
/** Config-specific dotenv (.env.staging or .env) */
35+
dotenv?: DotEnvFile
36+
/** Hidden config entry for this config's client_id */
37+
hiddenConfig: AppHiddenConfig
38+
}
39+
40+
/**
41+
* Select the active app configuration from a project.
42+
*
43+
* Resolution priority:
44+
* 1. userProvidedConfigName (from --config flag)
45+
* 2. Cached selection (from `app config use`)
46+
* 3. Default (shopify.app.toml)
47+
*
48+
* If the cached config file no longer exists on disk, prompts the user
49+
* to select a new one via `app config use`.
50+
*
51+
* Derives config-specific state: dotenv and hidden config for the selected
52+
* config's client_id.
53+
* @public
54+
*/
55+
export async function selectActiveConfig(project: Project, userProvidedConfigName?: string): Promise<ActiveConfig> {
56+
let configName = userProvidedConfigName
57+
const source: ConfigSource = configName ? 'flag' : 'cached'
58+
59+
// Check cache for previously selected config
60+
const cachedConfigName = getCachedAppInfo(project.directory)?.configFile
61+
const cachedConfigPath = cachedConfigName ? joinPath(project.directory, cachedConfigName) : null
62+
63+
// Handle stale cache: cached config file no longer exists
64+
if (!configName && cachedConfigPath && !fileExistsSync(cachedConfigPath)) {
65+
const warningContent = {
66+
headline: `Couldn't find ${cachedConfigName}`,
67+
body: [
68+
"If you have multiple config files, select a new one. If you only have one config file, it's been selected as your default.",
69+
],
70+
}
71+
configName = await use({directory: project.directory, warningContent, shouldRenderSuccess: false})
72+
}
73+
74+
configName = configName ?? cachedConfigName
75+
76+
// Resolve the config file name and verify it exists
77+
const {configurationPath, configurationFileName} = await getConfigurationPath(project.directory, configName)
78+
79+
// Look up the TomlFile from the project's pre-loaded files
80+
const file = project.appConfigByName(configurationFileName)
81+
if (!file) {
82+
// Fallback: the project didn't discover this file (shouldn't happen, but be safe)
83+
const fallbackFile = await TomlFile.read(configurationPath)
84+
return buildActiveConfig(project, fallbackFile, configurationPath, configurationFileName, source)
85+
}
86+
87+
return buildActiveConfig(project, file, configurationPath, configurationFileName, source)
88+
}
89+
90+
/**
91+
* Bridge from the new Project/ActiveConfig model to the legacy AppConfigurationState.
92+
*
93+
* This allows callers that still consume AppConfigurationState to work with
94+
* the new selection logic without changes.
95+
* @public
96+
*/
97+
export function toAppConfigurationState(
98+
project: Project,
99+
activeConfig: ActiveConfig,
100+
basicConfiguration: BasicAppConfigurationWithoutModules,
101+
): AppConfigurationState {
102+
return {
103+
appDirectory: project.directory,
104+
configurationPath: activeConfig.path,
105+
basicConfiguration,
106+
configSource: activeConfig.source,
107+
configurationFileName: activeConfig.fileName as AppConfigurationFileName,
108+
isLinked: activeConfig.isLinked,
109+
}
110+
}
111+
112+
async function buildActiveConfig(
113+
project: Project,
114+
file: TomlFile,
115+
configPath: string,
116+
fileName: string,
117+
source: ConfigSource,
118+
): Promise<ActiveConfig> {
119+
const clientId = typeof file.content.client_id === 'string' ? file.content.client_id : undefined
120+
const isLinked = Boolean(clientId) && clientId !== ''
121+
const dotenv = resolveDotEnv(project, configPath)
122+
const hiddenConfig = await resolveHiddenConfig(project, clientId)
123+
124+
return {
125+
file,
126+
path: configPath,
127+
fileName,
128+
source,
129+
isLinked,
130+
dotenv,
131+
hiddenConfig,
132+
}
133+
}

0 commit comments

Comments
 (0)