diff --git a/packages/app/src/cli/services/app/config/link-service.test.ts b/packages/app/src/cli/services/app/config/link-service.test.ts index 124ee465d0..a1a415c29d 100644 --- a/packages/app/src/cli/services/app/config/link-service.test.ts +++ b/packages/app/src/cli/services/app/config/link-service.test.ts @@ -8,6 +8,7 @@ import {selectOrganizationPrompt} from '@shopify/organizations' import {beforeEach, describe, expect, test, vi} from 'vitest' import {inTemporaryDirectory, readFile, writeFileSync} from '@shopify/cli-kit/node/fs' import {joinPath} from '@shopify/cli-kit/node/path' +import {terminalSupportsPrompting} from '@shopify/cli-kit/node/system' vi.mock('./use.js') vi.mock('../../../prompts/dev.js') @@ -15,10 +16,12 @@ vi.mock('@shopify/organizations') vi.mock('../../../prompts/config.js') vi.mock('../../local-storage') vi.mock('@shopify/cli-kit/node/ui') +vi.mock('@shopify/cli-kit/node/system') vi.mock('../../dev/fetch.js') vi.mock('../../../utilities/developer-platform-client.js') vi.mock('../../../models/app/validation/multi-cli-warning.js') beforeEach(async () => { + vi.mocked(terminalSupportsPrompting).mockReturnValue(true) // Default mock for selectConfigName - tests that need a specific value can override vi.mocked(selectConfigName).mockResolvedValue('shopify.app.toml') }) diff --git a/packages/app/src/cli/services/app/config/link.test.ts b/packages/app/src/cli/services/app/config/link.test.ts index fd47e8ee1a..501851c343 100644 --- a/packages/app/src/cli/services/app/config/link.test.ts +++ b/packages/app/src/cli/services/app/config/link.test.ts @@ -20,6 +20,7 @@ import {joinPath} from '@shopify/cli-kit/node/path' import {renderSuccess} from '@shopify/cli-kit/node/ui' import {outputContent} from '@shopify/cli-kit/node/output' import {setPathValue} from '@shopify/cli-kit/common/object' +import {terminalSupportsPrompting} from '@shopify/cli-kit/node/system' vi.mock('./use.js') vi.mock('../../../prompts/config.js') @@ -36,6 +37,7 @@ vi.mock('../../../models/app/loader.js', async () => { }) vi.mock('../../local-storage') vi.mock('@shopify/cli-kit/node/ui') +vi.mock('@shopify/cli-kit/node/system') vi.mock('../../context/partner-account-info.js') vi.mock('../../context.js') vi.mock('../select-app.js') @@ -66,6 +68,7 @@ function buildDeveloperPlatformClient(extraFields: Partial { vi.mocked(fetchAppRemoteConfiguration).mockResolvedValue(DEFAULT_REMOTE_CONFIGURATION) + vi.mocked(terminalSupportsPrompting).mockReturnValue(true) // Default mock for selectConfigName - tests that need a specific value can override vi.mocked(selectConfigName).mockResolvedValue('shopify.app.toml') }) @@ -115,6 +118,28 @@ describe('link', () => { }) }) + test('throws in non-TTY when the remote app would require prompting', async () => { + await inTemporaryDirectory(async (tmp) => { + // Given + vi.mocked(terminalSupportsPrompting).mockReturnValue(false) + const developerPlatformClient = buildDeveloperPlatformClient() + const options: LinkOptions = { + directory: tmp, + developerPlatformClient, + } + await mockLoadOpaqueAppWithApp(tmp) + + // When + const result = link(options) + + // Then + await expect(result).rejects.toThrow(/app config link requires additional flags/) + await expect(result).rejects.toThrow(/--client-id/) + expect(fetchOrCreateOrganizationApp).not.toHaveBeenCalled() + expect(selectConfigName).not.toHaveBeenCalled() + }) + }) + test('does not ask for a name when a file name is provided as a flag', async () => { await inTemporaryDirectory(async (tmp) => { // Given @@ -877,6 +902,31 @@ describe('link', () => { }) }) + test('throws in non-TTY when the config file name would require prompting', async () => { + await inTemporaryDirectory(async (tmp) => { + // Given + vi.mocked(terminalSupportsPrompting).mockReturnValue(false) + const developerPlatformClient = buildDeveloperPlatformClient() + const remoteApp = mockRemoteApp({developerPlatformClient, apiKey: 'new-api-key'}) + const options: LinkOptions = { + directory: tmp, + apiKey: remoteApp.apiKey, + developerPlatformClient, + } + writeFileSync(joinPath(tmp, 'shopify.app.toml'), 'client_id = "existing-api-key"') + await mockLoadOpaqueAppWithApp(tmp) + vi.mocked(appFromIdentifiers).mockResolvedValue(remoteApp) + + // When + const result = link(options) + + // Then + await expect(result).rejects.toThrow(/app config link requires additional flags/) + await expect(result).rejects.toThrow(/--file-name/) + expect(selectConfigName).not.toHaveBeenCalled() + }) + }) + test('uses scopes on platform if defined', async () => { await inTemporaryDirectory(async (tmp) => { // Given diff --git a/packages/app/src/cli/services/app/config/link.ts b/packages/app/src/cli/services/app/config/link.ts index 362f911bbe..f72bcbcd1b 100644 --- a/packages/app/src/cli/services/app/config/link.ts +++ b/packages/app/src/cli/services/app/config/link.ts @@ -30,6 +30,7 @@ import {fileExists} from '@shopify/cli-kit/node/fs' import {joinPath} from '@shopify/cli-kit/node/path' import {AbortError} from '@shopify/cli-kit/node/error' import {PackageManager} from '@shopify/cli-kit/node/node-package-manager' +import {terminalSupportsPrompting} from '@shopify/cli-kit/node/system' export interface LinkOptions { directory: string @@ -94,6 +95,16 @@ export default async function link(options: LinkOptions, shouldRenderSuccess = t return {remoteApp, configFileName, configuration: mergedAppConfiguration} } +function abortIfLinkPromptCannotRun(missingFlags: string[]) { + if (terminalSupportsPrompting() || missingFlags.length === 0) return + + const flags = missingFlags.map((flag) => `--${flag}`).join(' ') + throw new AbortError( + `app config link requires additional flags in non-interactive terminal environments. Run shopify app config link ${flags} with the required values.`, + `Run shopify app config link ${flags} with the required values, or run the command in an interactive terminal.`, + ) +} + /** * Choose or create an app on the platform to link to. * @@ -125,6 +136,7 @@ async function selectOrCreateRemoteAppToLinkTo(options: LinkOptions): Promise<{ } } + abortIfLinkPromptCannotRun(['client-id']) const remoteApp = await fetchOrCreateOrganizationApp({...creationOptions, directory: appDirectory}) const developerPlatformClient = remoteApp.developerPlatformClient @@ -322,6 +334,7 @@ async function loadConfigurationFileName( // If no TOML files exist at all, use the default filename without prompting if (isEmpty(existingTomls)) return 'shopify.app.toml' + abortIfLinkPromptCannotRun(['file-name']) return selectConfigName(localAppInfo.appDirectory ?? options.directory, remoteApp.title) }