Skip to content
Open
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
5 changes: 5 additions & 0 deletions .changeset/fix-reset-spurious-toml-prompt.md
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is for the users, I think it's too technical. I'd simplify it to something like Avoid config prompt when using --reset flag

Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@shopify/app': patch
---

Avoid config prompt when using --reset flag
17 changes: 14 additions & 3 deletions packages/app/src/cli/models/app/loader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -246,8 +246,13 @@ export async function loadApp<TModuleSpec extends ExtensionSpecification = Exten
specifications: TModuleSpec[]
remoteFlags?: Flag[]
ignoreUnknownExtensions?: boolean
skipPrompts?: boolean
}): Promise<AppInterface<CurrentAppConfiguration, TModuleSpec>> {
const {project, activeConfig} = await getAppConfigurationContext(options.directory, options.userProvidedConfigName)
const {project, activeConfig} = await getAppConfigurationContext(
options.directory,
options.userProvidedConfigName,
options.skipPrompts ? {skipPrompts: true} : undefined,
)
return loadAppFromContext({
project,
activeConfig,
Expand Down Expand Up @@ -388,11 +393,16 @@ export async function loadOpaqueApp(options: {
configName?: string
specifications: ExtensionSpecification[]
remoteFlags?: Flag[]
skipPrompts?: boolean
}): Promise<OpaqueAppLoadResult> {
// Try to load the app normally first — the loader always collects validation errors,
// so only structural failures (TOML parse, missing files) will throw.
try {
const {project, activeConfig} = await getAppConfigurationContext(options.directory, options.configName)
const {project, activeConfig} = await getAppConfigurationContext(
options.directory,
options.configName,
options.skipPrompts ? {skipPrompts: true} : undefined,
)
const app = await loadAppFromContext({
project,
activeConfig,
Expand Down Expand Up @@ -906,9 +916,10 @@ type ConfigurationLoaderResult<
export async function getAppConfigurationContext(
workingDirectory: string,
userProvidedConfigName?: string,
options?: {skipPrompts?: boolean},
): Promise<{project: Project; activeConfig: ActiveConfig}> {
const project = await Project.load(workingDirectory)
const activeConfig = await selectActiveConfig(project, userProvidedConfigName)
const activeConfig = await selectActiveConfig(project, userProvidedConfigName, options)
return {project, activeConfig}
}

Expand Down
12 changes: 9 additions & 3 deletions packages/app/src/cli/models/project/active-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,15 +53,20 @@ export interface ActiveConfig {
* config's client_id.
* @public
*/
export async function selectActiveConfig(project: Project, userProvidedConfigName?: string): Promise<ActiveConfig> {
export async function selectActiveConfig(
project: Project,
userProvidedConfigName?: string,
options?: {skipPrompts?: boolean},
): 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
const cacheIsStale = Boolean(!configName && cachedConfigPath && !fileExistsSync(cachedConfigPath))

// Handle stale cache: cached config file no longer exists
if (!configName && cachedConfigPath && !fileExistsSync(cachedConfigPath)) {
if (cacheIsStale && !options?.skipPrompts) {
const warningContent = {
headline: `Couldn't find ${cachedConfigName}`,
body: [
Expand All @@ -71,7 +76,8 @@ export async function selectActiveConfig(project: Project, userProvidedConfigNam
configName = await use({directory: project.directory, warningContent, shouldRenderSuccess: false})
}

configName = configName ?? cachedConfigName
// Don't fall back to stale cached name — it points to a non-existent file
configName = configName ?? (cacheIsStale ? undefined : cachedConfigName)

// Determine source after resolution so it reflects the actual selection path
let source: ConfigSource
Expand Down
38 changes: 38 additions & 0 deletions packages/app/src/cli/services/app-context.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -176,6 +176,44 @@ client_id="test-api-key"`
})
})

test('forceRelink skips config selection before link to avoid spurious TOML prompt', async () => {
await inTemporaryDirectory(async (tmp) => {
// Given — no config file on disk, so getAppConfigurationContext would prompt for TOML selection
// if called before link. We verify link is called first (without a prior config load).
const content = `
name = "test-app"
client_id="test-api-key"`
await writeAppConfig(tmp, content)

const getAppConfigSpy = vi.spyOn(loader, 'getAppConfigurationContext')

vi.mocked(link).mockResolvedValue({
remoteApp: mockRemoteApp,
configFileName: 'shopify.app.toml',
configuration: {
client_id: 'test-api-key',
name: 'test-app',
path: normalizePath(joinPath(tmp, 'shopify.app.toml')),
} as any,
})

// When
await linkedAppContext({
directory: tmp,
forceRelink: true,
userProvidedConfigName: undefined,
clientId: undefined,
})

// Then — getAppConfigurationContext should only be called AFTER link, not before
const linkCallOrder = vi.mocked(link).mock.invocationCallOrder[0]!
const configCallOrders = getAppConfigSpy.mock.invocationCallOrder
expect(configCallOrders.every((order) => order > linkCallOrder)).toBe(true)

getAppConfigSpy.mockRestore()
})
})

test('logs metadata', async () => {
await inTemporaryDirectory(async (tmp) => {
// Given
Expand Down
31 changes: 22 additions & 9 deletions packages/app/src/cli/services/app-context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -82,21 +82,34 @@ export async function linkedAppContext({
userProvidedConfigName,
unsafeTolerateErrors = false,
}: LoadedAppContextOptions): Promise<LoadedAppContextOutput> {
let {project, activeConfig} = await getAppConfigurationContext(directory, userProvidedConfigName)
let project: Project
let activeConfig: ActiveConfig
let remoteApp: OrganizationApp | undefined

if (activeConfig.file.errors.length > 0) {
throw new AbortError(activeConfig.file.errors.map((err) => err.message).join('\n'))
}

if (!activeConfig.isLinked || forceRelink) {
const configName = forceRelink ? undefined : basename(activeConfig.file.path)
const result = await link({directory, apiKey: clientId, configName})
if (forceRelink) {
// Skip getAppConfigurationContext() when force-relinking — it may prompt the
// user to select a TOML file that will be immediately discarded by link().
const result = await link({directory, apiKey: clientId})
remoteApp = result.remoteApp
// Re-load project and re-select active config since link may have written new config
const reloaded = await getAppConfigurationContext(directory, result.configFileName)
project = reloaded.project
activeConfig = reloaded.activeConfig
} else {
const loaded = await getAppConfigurationContext(directory, userProvidedConfigName)
project = loaded.project
activeConfig = loaded.activeConfig

if (activeConfig.file.errors.length > 0) {
throw new AbortError(activeConfig.file.errors.map((err) => err.message).join('\n'))
}

if (!activeConfig.isLinked) {
const result = await link({directory, apiKey: clientId, configName: basename(activeConfig.file.path)})
remoteApp = result.remoteApp
const reloaded = await getAppConfigurationContext(directory, result.configFileName)
project = reloaded.project
activeConfig = reloaded.activeConfig
}
}

// Determine the effective client ID
Expand Down
2 changes: 2 additions & 0 deletions packages/app/src/cli/services/app/config/link.ts
Original file line number Diff line number Diff line change
Expand Up @@ -165,6 +165,7 @@ async function getAppCreationDefaultsFromLocalApp(options: LinkOptions): Promise
directory: options.directory,
userProvidedConfigName: options.configName,
remoteFlags: undefined,
skipPrompts: true,
})

return {creationOptions: app.creationDefaultOptions(), appDirectory: app.directory}
Expand Down Expand Up @@ -231,6 +232,7 @@ export async function loadLocalAppOptions(
configName: options.configName,
specifications,
remoteFlags,
skipPrompts: true,
})

switch (result.state) {
Expand Down
Loading