Skip to content

Commit a97a9e1

Browse files
authored
Merge pull request #7022 from Shopify/rcb/project-model
Add Project domain model for filesystem abstraction
2 parents 2929234 + af2a018 commit a97a9e1

9 files changed

Lines changed: 1498 additions & 3 deletions

File tree

packages/app/src/cli/models/app/loader.test.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2620,10 +2620,10 @@ describe('load', () => {
26202620
cmd_app_all_configs_clients: JSON.stringify({'shopify.app.toml': '1234567890'}),
26212621
cmd_app_linked_config_name: 'shopify.app.toml',
26222622
cmd_app_linked_config_git_tracked: true,
2623-
cmd_app_linked_config_source: 'cached',
2623+
cmd_app_linked_config_source: 'default',
26242624
cmd_app_warning_api_key_deprecation_displayed: false,
26252625
app_extensions_any: false,
2626-
app_extensions_breakdown: {},
2626+
app_extensions_breakdown: JSON.stringify({}),
26272627
app_extensions_count: 0,
26282628
app_extensions_custom_layout: false,
26292629
app_extensions_function_any: false,

packages/app/src/cli/models/app/loader.ts

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -860,6 +860,8 @@ type LinkedConfigurationSource =
860860
| 'flag'
861861
// Config file came from the cache (i.e. app use)
862862
| 'cached'
863+
// No flag or cache — fell through to the default (shopify.app.toml)
864+
| 'default'
863865

864866
type ConfigurationLoadResultMetadata = {
865867
allClientIdsByConfigName: {[key: string]: string}
@@ -912,7 +914,6 @@ export async function getAppConfigurationState(
912914
let configName = userProvidedConfigName
913915

914916
const appDirectory = await getAppDirectory(workingDirectory)
915-
const configSource: LinkedConfigurationSource = configName ? 'flag' : 'cached'
916917

917918
const cachedCurrentConfigName = getCachedAppInfo(appDirectory)?.configFile
918919
const cachedCurrentConfigPath = cachedCurrentConfigName ? joinPath(appDirectory, cachedCurrentConfigName) : null
@@ -928,6 +929,16 @@ export async function getAppConfigurationState(
928929

929930
configName = configName ?? cachedCurrentConfigName
930931

932+
// Determine source after resolution so it reflects the actual selection path
933+
let configSource: LinkedConfigurationSource
934+
if (userProvidedConfigName) {
935+
configSource = 'flag'
936+
} else if (configName) {
937+
configSource = 'cached'
938+
} else {
939+
configSource = 'default'
940+
}
941+
931942
const {configurationPath, configurationFileName} = await getConfigurationPath(appDirectory, configName)
932943
const file = await loadConfigurationFileContent(configurationPath)
933944

Lines changed: 203 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,203 @@
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, basename} 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+
19+
beforeEach(() => {
20+
vi.mocked(getCachedAppInfo).mockReturnValue(undefined)
21+
})
22+
23+
describe('selectActiveConfig', () => {
24+
test('selects config by user-provided name (flag)', async () => {
25+
await inTemporaryDirectory(async (dir) => {
26+
await writeFile(joinPath(dir, 'shopify.app.toml'), 'client_id = "default"')
27+
await writeFile(joinPath(dir, 'shopify.app.staging.toml'), 'client_id = "staging"')
28+
const project = await Project.load(dir)
29+
30+
const config = await selectActiveConfig(project, 'staging')
31+
32+
expect(basename(config.file.path)).toBe('shopify.app.staging.toml')
33+
expect(config.file.content.client_id).toBe('staging')
34+
expect(config.source).toBe('flag')
35+
expect(config.isLinked).toBe(true)
36+
})
37+
})
38+
39+
test('selects config from cache', async () => {
40+
await inTemporaryDirectory(async (dir) => {
41+
await writeFile(joinPath(dir, 'shopify.app.toml'), 'client_id = "default"')
42+
await writeFile(joinPath(dir, 'shopify.app.production.toml'), 'client_id = "production"')
43+
const project = await Project.load(dir)
44+
45+
vi.mocked(getCachedAppInfo).mockReturnValue({
46+
directory: dir,
47+
configFile: 'shopify.app.production.toml',
48+
})
49+
50+
const config = await selectActiveConfig(project)
51+
52+
expect(basename(config.file.path)).toBe('shopify.app.production.toml')
53+
expect(config.file.content.client_id).toBe('production')
54+
expect(config.source).toBe('cached')
55+
})
56+
})
57+
58+
test('falls back to default shopify.app.toml when no flag or cache', async () => {
59+
await inTemporaryDirectory(async (dir) => {
60+
await writeFile(joinPath(dir, 'shopify.app.toml'), 'client_id = "default-id"')
61+
const project = await Project.load(dir)
62+
63+
const config = await selectActiveConfig(project)
64+
65+
expect(basename(config.file.path)).toBe('shopify.app.toml')
66+
expect(config.file.content.client_id).toBe('default-id')
67+
})
68+
})
69+
70+
test('detects isLinked from client_id', async () => {
71+
await inTemporaryDirectory(async (dir) => {
72+
await writeFile(joinPath(dir, 'shopify.app.toml'), 'client_id = ""')
73+
const project = await Project.load(dir)
74+
75+
const config = await selectActiveConfig(project)
76+
77+
expect(config.isLinked).toBe(false)
78+
})
79+
})
80+
81+
test('detects isLinked when client_id is present', async () => {
82+
await inTemporaryDirectory(async (dir) => {
83+
await writeFile(joinPath(dir, 'shopify.app.toml'), 'client_id = "abc123"')
84+
const project = await Project.load(dir)
85+
86+
const config = await selectActiveConfig(project)
87+
88+
expect(config.isLinked).toBe(true)
89+
})
90+
})
91+
92+
test('resolves config-specific dotenv', async () => {
93+
await inTemporaryDirectory(async (dir) => {
94+
await writeFile(joinPath(dir, 'shopify.app.staging.toml'), 'client_id = "staging"')
95+
await writeFile(joinPath(dir, 'shopify.app.toml'), 'client_id = "default"')
96+
await writeFile(joinPath(dir, '.env'), 'KEY=default')
97+
await writeFile(joinPath(dir, '.env.staging'), 'KEY=staging')
98+
const project = await Project.load(dir)
99+
100+
const config = await selectActiveConfig(project, 'staging')
101+
102+
expect(config.dotenv).toBeDefined()
103+
expect(config.dotenv!.variables.KEY).toBe('staging')
104+
})
105+
})
106+
107+
test('resolves hidden config for client_id', async () => {
108+
await inTemporaryDirectory(async (dir) => {
109+
await writeFile(joinPath(dir, 'shopify.app.toml'), 'client_id = "abc123"')
110+
await mkdir(joinPath(dir, '.shopify'))
111+
await writeFile(
112+
joinPath(dir, '.shopify', 'project.json'),
113+
JSON.stringify({abc123: {dev_store_url: 'test.myshopify.com'}}),
114+
)
115+
const project = await Project.load(dir)
116+
117+
const config = await selectActiveConfig(project)
118+
119+
expect(config.hiddenConfig).toStrictEqual({dev_store_url: 'test.myshopify.com'})
120+
})
121+
})
122+
123+
test('returns empty hidden config when no entry for client_id', async () => {
124+
await inTemporaryDirectory(async (dir) => {
125+
await writeFile(joinPath(dir, 'shopify.app.toml'), 'client_id = "abc123"')
126+
const project = await Project.load(dir)
127+
128+
const config = await selectActiveConfig(project)
129+
130+
expect(config.hiddenConfig).toStrictEqual({})
131+
})
132+
})
133+
134+
test('file.path is absolute', async () => {
135+
await inTemporaryDirectory(async (dir) => {
136+
await writeFile(joinPath(dir, 'shopify.app.toml'), 'client_id = "abc"')
137+
const project = await Project.load(dir)
138+
139+
const config = await selectActiveConfig(project)
140+
141+
expect(config.file.path).toBe(joinPath(dir, 'shopify.app.toml'))
142+
})
143+
})
144+
145+
test('accepts full filename as config name', async () => {
146+
await inTemporaryDirectory(async (dir) => {
147+
await writeFile(joinPath(dir, 'shopify.app.toml'), 'client_id = "default"')
148+
await writeFile(joinPath(dir, 'shopify.app.staging.toml'), 'client_id = "staging"')
149+
const project = await Project.load(dir)
150+
151+
const config = await selectActiveConfig(project, 'shopify.app.staging.toml')
152+
153+
expect(basename(config.file.path)).toBe('shopify.app.staging.toml')
154+
expect(config.file.content.client_id).toBe('staging')
155+
})
156+
})
157+
158+
test('throws when requested config does not exist', async () => {
159+
await inTemporaryDirectory(async (dir) => {
160+
await writeFile(joinPath(dir, 'shopify.app.toml'), 'client_id = "default"')
161+
const project = await Project.load(dir)
162+
163+
await expect(selectActiveConfig(project, 'nonexistent')).rejects.toThrow()
164+
})
165+
})
166+
167+
test('throws when the only app config is malformed (no valid configs to fall back to)', async () => {
168+
await inTemporaryDirectory(async (dir) => {
169+
// The only config is broken TOML — Project.load skips it and finds 0 valid configs
170+
await writeFile(joinPath(dir, 'shopify.app.toml'), '{{invalid toml')
171+
172+
await expect(Project.load(dir)).rejects.toThrow(/Could not find/)
173+
})
174+
})
175+
176+
test('surfaces parse error when selecting a broken config while a valid one exists', async () => {
177+
await inTemporaryDirectory(async (dir) => {
178+
// Two configs: one good, one broken. Selecting the broken one by name should
179+
// surface the real parse error via the fallback re-read, not a generic "not found".
180+
await writeFile(joinPath(dir, 'shopify.app.toml'), 'client_id = "good"')
181+
await writeFile(joinPath(dir, 'shopify.app.broken.toml'), '{{invalid toml')
182+
183+
const project = await Project.load(dir)
184+
185+
await expect(selectActiveConfig(project, 'shopify.app.broken.toml')).rejects.toThrow()
186+
})
187+
})
188+
189+
test('loads active config even when an unrelated config is malformed', async () => {
190+
await inTemporaryDirectory(async (dir) => {
191+
await writeFile(joinPath(dir, 'shopify.app.toml'), 'client_id = "good"')
192+
await writeFile(joinPath(dir, 'shopify.app.broken.toml'), '{{invalid toml')
193+
194+
const project = await Project.load(dir)
195+
196+
// The broken config is skipped, but selecting the good one works fine
197+
const config = await selectActiveConfig(project)
198+
199+
expect(config.file.content.client_id).toBe('good')
200+
expect(basename(config.file.path)).toBe('shopify.app.toml')
201+
})
202+
})
203+
})
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, basename} from '@shopify/cli-kit/node/path'
11+
12+
/** @public */
13+
export type ConfigSource = 'flag' | 'cached' | 'default'
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+
*
22+
* path and fileName are derivable from file.path — use the accessors
23+
* if you need them rather than storing redundant data.
24+
* @public
25+
*/
26+
export interface ActiveConfig {
27+
/** The selected app TOML file (from project.appConfigFiles) */
28+
file: TomlFile
29+
/** How the selection was made */
30+
source: ConfigSource
31+
/** Whether the config has a non-empty client_id */
32+
isLinked: boolean
33+
/** Config-specific dotenv (.env.staging or .env) */
34+
dotenv?: DotEnvFile
35+
/** Hidden config entry for this config's client_id */
36+
hiddenConfig: AppHiddenConfig
37+
}
38+
39+
/**
40+
* Select the active app configuration from a project.
41+
*
42+
* Resolution priority:
43+
* 1. userProvidedConfigName (from --config flag)
44+
* 2. Cached selection (from `app config use`)
45+
* 3. Default (shopify.app.toml)
46+
*
47+
* If the cached config file no longer exists on disk, prompts the user
48+
* to select a new one via `app config use`.
49+
*
50+
* Derives config-specific state: dotenv and hidden config for the selected
51+
* config's client_id.
52+
* @public
53+
*/
54+
export async function selectActiveConfig(project: Project, userProvidedConfigName?: string): Promise<ActiveConfig> {
55+
let configName = userProvidedConfigName
56+
57+
// Check cache for previously selected config
58+
const cachedConfigName = getCachedAppInfo(project.directory)?.configFile
59+
const cachedConfigPath = cachedConfigName ? joinPath(project.directory, cachedConfigName) : null
60+
61+
// Handle stale cache: cached config file no longer exists
62+
if (!configName && cachedConfigPath && !fileExistsSync(cachedConfigPath)) {
63+
const warningContent = {
64+
headline: `Couldn't find ${cachedConfigName}`,
65+
body: [
66+
"If you have multiple config files, select a new one. If you only have one config file, it's been selected as your default.",
67+
],
68+
}
69+
configName = await use({directory: project.directory, warningContent, shouldRenderSuccess: false})
70+
}
71+
72+
configName = configName ?? cachedConfigName
73+
74+
// Determine source after resolution so it reflects the actual selection path
75+
let source: ConfigSource
76+
if (userProvidedConfigName) {
77+
source = 'flag'
78+
} else if (configName) {
79+
source = 'cached'
80+
} else {
81+
source = 'default'
82+
}
83+
84+
// Resolve the config file name and verify it exists
85+
const {configurationPath, configurationFileName} = await getConfigurationPath(project.directory, configName)
86+
87+
// Look up the TomlFile from the project's pre-loaded files
88+
const file = project.appConfigByName(configurationFileName)
89+
if (!file) {
90+
// Fallback: the project didn't discover this file (shouldn't happen, but be safe)
91+
const fallbackFile = await TomlFile.read(configurationPath)
92+
return buildActiveConfig(project, fallbackFile, source)
93+
}
94+
95+
return buildActiveConfig(project, file, source)
96+
}
97+
98+
/**
99+
* Bridge from the new Project/ActiveConfig model to the legacy AppConfigurationState.
100+
*
101+
* This allows callers that still consume AppConfigurationState to work with
102+
* the new selection logic without changes.
103+
* @public
104+
*/
105+
export function toAppConfigurationState(
106+
project: Project,
107+
activeConfig: ActiveConfig,
108+
basicConfiguration: BasicAppConfigurationWithoutModules,
109+
): AppConfigurationState {
110+
return {
111+
appDirectory: project.directory,
112+
configurationPath: activeConfig.file.path,
113+
basicConfiguration,
114+
configSource: activeConfig.source,
115+
configurationFileName: basename(activeConfig.file.path) as AppConfigurationFileName,
116+
isLinked: activeConfig.isLinked,
117+
}
118+
}
119+
120+
async function buildActiveConfig(project: Project, file: TomlFile, source: ConfigSource): Promise<ActiveConfig> {
121+
const clientId = typeof file.content.client_id === 'string' ? file.content.client_id : undefined
122+
const isLinked = Boolean(clientId) && clientId !== ''
123+
const dotenv = resolveDotEnv(project, file.path)
124+
const hiddenConfig = await resolveHiddenConfig(project, clientId)
125+
126+
return {
127+
file,
128+
source,
129+
isLinked,
130+
dotenv,
131+
hiddenConfig,
132+
}
133+
}

0 commit comments

Comments
 (0)