diff --git a/.changeset/fix-reset-spurious-toml-prompt.md b/.changeset/fix-reset-spurious-toml-prompt.md new file mode 100644 index 00000000000..ac4da26bc9c --- /dev/null +++ b/.changeset/fix-reset-spurious-toml-prompt.md @@ -0,0 +1,5 @@ +--- +'@shopify/app': patch +--- + +Avoid config prompt when using --reset flag diff --git a/packages/app/src/cli/models/app/loader.ts b/packages/app/src/cli/models/app/loader.ts index 2e5aad94508..1d6380929d7 100644 --- a/packages/app/src/cli/models/app/loader.ts +++ b/packages/app/src/cli/models/app/loader.ts @@ -246,8 +246,13 @@ export async function loadApp> { - 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, @@ -388,11 +393,16 @@ export async function loadOpaqueApp(options: { configName?: string specifications: ExtensionSpecification[] remoteFlags?: Flag[] + skipPrompts?: boolean }): Promise { // 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, @@ -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} } diff --git a/packages/app/src/cli/models/project/active-config.ts b/packages/app/src/cli/models/project/active-config.ts index dbf8dd86eeb..5f8ac1eccc2 100644 --- a/packages/app/src/cli/models/project/active-config.ts +++ b/packages/app/src/cli/models/project/active-config.ts @@ -53,15 +53,20 @@ export interface ActiveConfig { * config's client_id. * @public */ -export async function selectActiveConfig(project: Project, userProvidedConfigName?: string): Promise { +export async function selectActiveConfig( + project: Project, + userProvidedConfigName?: string, + options?: {skipPrompts?: boolean}, +): Promise { 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: [ @@ -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 diff --git a/packages/app/src/cli/services/app-context.test.ts b/packages/app/src/cli/services/app-context.test.ts index 460ab4095c8..95fe5c3ea0b 100644 --- a/packages/app/src/cli/services/app-context.test.ts +++ b/packages/app/src/cli/services/app-context.test.ts @@ -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 diff --git a/packages/app/src/cli/services/app-context.ts b/packages/app/src/cli/services/app-context.ts index 8ba969e20f2..8c980f1f309 100644 --- a/packages/app/src/cli/services/app-context.ts +++ b/packages/app/src/cli/services/app-context.ts @@ -82,21 +82,34 @@ export async function linkedAppContext({ userProvidedConfigName, unsafeTolerateErrors = false, }: LoadedAppContextOptions): Promise { - 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 diff --git a/packages/app/src/cli/services/app/config/link.ts b/packages/app/src/cli/services/app/config/link.ts index 5db56b3905c..54900513349 100644 --- a/packages/app/src/cli/services/app/config/link.ts +++ b/packages/app/src/cli/services/app/config/link.ts @@ -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} @@ -231,6 +232,7 @@ export async function loadLocalAppOptions( configName: options.configName, specifications, remoteFlags, + skipPrompts: true, }) switch (result.state) {