From 7f4ec70c9fa676a6bb771ee6c49212be3bc5a3d6 Mon Sep 17 00:00:00 2001 From: Gonzalo Riestra Date: Mon, 25 May 2026 14:22:17 +0200 Subject: [PATCH 1/2] Allow app config validate to target config by client ID --- .changeset/app-config-validate-client-id.md | 5 ++ .../cli/commands/app/config/validate.test.ts | 43 +++++++++++++++++ .../src/cli/commands/app/config/validate.ts | 5 +- packages/app/src/cli/models/app/loader.ts | 2 +- .../cli/models/project/active-config.test.ts | 48 +++++++++++++++++++ .../src/cli/models/project/active-config.ts | 24 +++++++--- packages/app/src/cli/services/app-context.ts | 5 +- .../app/src/cli/services/validate.test.ts | 4 +- packages/app/src/cli/services/validate.ts | 3 +- 9 files changed, 127 insertions(+), 12 deletions(-) create mode 100644 .changeset/app-config-validate-client-id.md diff --git a/.changeset/app-config-validate-client-id.md b/.changeset/app-config-validate-client-id.md new file mode 100644 index 00000000000..5b993973db1 --- /dev/null +++ b/.changeset/app-config-validate-client-id.md @@ -0,0 +1,5 @@ +--- +'@shopify/app': patch +--- + +Allow `app config validate` to target configs by client ID and report the validated configuration. diff --git a/packages/app/src/cli/commands/app/config/validate.test.ts b/packages/app/src/cli/commands/app/config/validate.test.ts index 98fdd12ce00..4dc8933bbe3 100644 --- a/packages/app/src/cli/commands/app/config/validate.test.ts +++ b/packages/app/src/cli/commands/app/config/validate.test.ts @@ -36,6 +36,10 @@ function mockHealthyProject() { } describe('app config validate command', () => { + test('keeps --client-id mutually exclusive with --config', () => { + expect(Validate.flags['client-id']?.exclusive).toEqual(['config']) + }) + test('calls validateApp with json: false by default', async () => { const app = testAppLinked() mockHealthyProject() @@ -72,6 +76,45 @@ describe('app config validate command', () => { await expectValidationMetadataCalls({cmd_app_validate_json: true}) }) + test('skips active config prompts when --client-id is passed', async () => { + const app = testAppLinked() + mockHealthyProject() + vi.mocked(linkedAppContext).mockResolvedValue({app} as Awaited>) + vi.mocked(validateApp).mockResolvedValue() + + await Validate.run(['--client-id', 'api-key'], import.meta.url) + + expect(selectActiveConfig).toHaveBeenCalledWith(expect.anything(), undefined, { + clientId: 'api-key', + skipPrompts: true, + }) + expect(linkedAppContext).toHaveBeenCalledWith({ + directory: expect.any(String), + clientId: 'api-key', + forceRelink: false, + userProvidedConfigName: undefined, + unsafeTolerateErrors: true, + }) + expect(validateApp).toHaveBeenCalledWith(app, {json: false}) + await expectValidationMetadataCalls({cmd_app_validate_json: false}) + }) + + test('keeps active config prompts enabled when --client-id is not passed', async () => { + const app = testAppLinked() + mockHealthyProject() + vi.mocked(linkedAppContext).mockResolvedValue({app} as Awaited>) + vi.mocked(validateApp).mockResolvedValue() + + await Validate.run([], import.meta.url) + + expect(selectActiveConfig).toHaveBeenCalledWith(expect.anything(), undefined, { + clientId: undefined, + skipPrompts: false, + }) + expect(validateApp).toHaveBeenCalledWith(app, {json: false}) + await expectValidationMetadataCalls({cmd_app_validate_json: false}) + }) + test('outputs JSON issues when active config has TOML parse errors', async () => { vi.mocked(Project.load).mockResolvedValue({errors: []} as unknown as Project) vi.mocked(selectActiveConfig).mockResolvedValue({file: new TomlFile('shopify.app.toml', {})} as any) diff --git a/packages/app/src/cli/commands/app/config/validate.ts b/packages/app/src/cli/commands/app/config/validate.ts index 4da9ddc48ba..68f21ded06c 100644 --- a/packages/app/src/cli/commands/app/config/validate.ts +++ b/packages/app/src/cli/commands/app/config/validate.ts @@ -56,7 +56,10 @@ export default class Validate extends AppLinkedCommand { // Stage 2: Select active config and check for TOML parse errors scoped to it let activeConfig try { - activeConfig = await selectActiveConfig(project, flags.config) + activeConfig = await selectActiveConfig(project, flags.config, { + clientId: flags['client-id'], + skipPrompts: Boolean(flags['client-id']), + }) } catch (err) { if (err instanceof AbortError && flags.json) { await recordValidationFailure(1, 1) diff --git a/packages/app/src/cli/models/app/loader.ts b/packages/app/src/cli/models/app/loader.ts index 28bac00d9d4..0f22f92ff97 100644 --- a/packages/app/src/cli/models/app/loader.ts +++ b/packages/app/src/cli/models/app/loader.ts @@ -926,7 +926,7 @@ type ConfigurationLoaderResult< export async function getAppConfigurationContext( workingDirectory: string, userProvidedConfigName?: string, - options?: {skipPrompts?: boolean}, + options?: {clientId?: string; skipPrompts?: boolean}, ): Promise<{project: Project; activeConfig: ActiveConfig}> { const project = await Project.load(workingDirectory) const activeConfig = await selectActiveConfig(project, userProvidedConfigName, options) diff --git a/packages/app/src/cli/models/project/active-config.test.ts b/packages/app/src/cli/models/project/active-config.test.ts index 3f189f4e488..765187a7ac0 100644 --- a/packages/app/src/cli/models/project/active-config.test.ts +++ b/packages/app/src/cli/models/project/active-config.test.ts @@ -55,6 +55,54 @@ describe('selectActiveConfig', () => { }) }) + test('selects config by client ID when cached config is stale and prompts are skipped', async () => { + await inTemporaryDirectory(async (dir) => { + await writeFile(joinPath(dir, 'shopify.app.2.toml'), 'client_id = "matching-client-id"') + await writeFile(joinPath(dir, 'shopify.app.3.toml'), 'client_id = "other-client-id"') + const project = await Project.load(dir) + + vi.mocked(getCachedAppInfo).mockReturnValue({ + directory: dir, + configFile: 'shopify.app.toml', + }) + + const config = await selectActiveConfig(project, undefined, { + clientId: 'matching-client-id', + skipPrompts: true, + }) + + expect(basename(config.file.path)).toBe('shopify.app.2.toml') + expect(config.file.content.client_id).toBe('matching-client-id') + expect(config.source).toBe('flag') + }) + }) + + test('throws when no config matches the client ID', async () => { + await inTemporaryDirectory(async (dir) => { + await writeFile(joinPath(dir, 'shopify.app.toml'), 'client_id = "other-client-id"') + const project = await Project.load(dir) + + await expect(selectActiveConfig(project, undefined, {clientId: 'missing-client-id'})).rejects.toThrow( + "The specified client ID couldn't be found in any TOML file, or the matching TOML file is malformed.", + ) + }) + }) + + test('throws when the config for the client ID is malformed', async () => { + await inTemporaryDirectory(async (dir) => { + await writeFile(joinPath(dir, 'shopify.app.toml'), 'client_id = "other-client-id"') + await writeFile( + joinPath(dir, 'shopify.app.malformed.toml'), + 'client_id = "requested-client-id"\nembedded = invalid', + ) + const project = await Project.load(dir) + + await expect(selectActiveConfig(project, undefined, {clientId: 'requested-client-id'})).rejects.toThrow( + "The specified client ID couldn't be found in any TOML file, or the matching TOML file is malformed.", + ) + }) + }) + test('falls back to default shopify.app.toml when no flag or cache', async () => { await inTemporaryDirectory(async (dir) => { await writeFile(joinPath(dir, 'shopify.app.toml'), 'client_id = "default-id"') diff --git a/packages/app/src/cli/models/project/active-config.ts b/packages/app/src/cli/models/project/active-config.ts index 5f8ac1eccc2..cb17d27ef06 100644 --- a/packages/app/src/cli/models/project/active-config.ts +++ b/packages/app/src/cli/models/project/active-config.ts @@ -43,8 +43,9 @@ export interface ActiveConfig { * * Resolution priority: * 1. userProvidedConfigName (from --config flag) - * 2. Cached selection (from `app config use`) - * 3. Default (shopify.app.toml) + * 2. clientId (from --client-id flag) + * 3. Cached selection (from `app config use`) + * 4. Default (shopify.app.toml) * * If the cached config file no longer exists on disk, prompts the user * to select a new one via `app config use`. @@ -56,9 +57,10 @@ export interface ActiveConfig { export async function selectActiveConfig( project: Project, userProvidedConfigName?: string, - options?: {skipPrompts?: boolean}, + options?: {clientId?: string; skipPrompts?: boolean}, ): Promise { let configName = userProvidedConfigName + const clientIdConfig = options?.clientId ? project.appConfigByClientId(options.clientId) : undefined // Check cache for previously selected config const cachedConfigName = getCachedAppInfo(project.directory)?.configFile @@ -76,21 +78,31 @@ export async function selectActiveConfig( configName = await use({directory: project.directory, warningContent, shouldRenderSuccess: false}) } + if (!configName && clientIdConfig) { + return buildActiveConfig(project, clientIdConfig, 'flag') + } + + if (!configName && options?.clientId) { + throw new AbortError( + "The specified client ID couldn't be found in any TOML file, or the matching TOML file is malformed.", + ) + } + // Don't fall back to stale cached name — it points to a non-existent file - configName = configName ?? (cacheIsStale ? undefined : cachedConfigName) + const resolvedConfigName = configName ?? (cacheIsStale ? undefined : cachedConfigName) // Determine source after resolution so it reflects the actual selection path let source: ConfigSource if (userProvidedConfigName) { source = 'flag' - } else if (configName) { + } else if (resolvedConfigName) { source = 'cached' } else { source = 'default' } // Resolve the config file name and look it up in the project's pre-loaded files - const configurationFileName = getAppConfigurationFileName(configName) + const configurationFileName = getAppConfigurationFileName(resolvedConfigName) const file = project.appConfigByName(configurationFileName) if (!file) { throw new AbortError( diff --git a/packages/app/src/cli/services/app-context.ts b/packages/app/src/cli/services/app-context.ts index 00d37b86fce..cf11d1ff25a 100644 --- a/packages/app/src/cli/services/app-context.ts +++ b/packages/app/src/cli/services/app-context.ts @@ -97,7 +97,10 @@ export async function linkedAppContext({ project = reloaded.project activeConfig = reloaded.activeConfig } else { - const loaded = await getAppConfigurationContext(directory, userProvidedConfigName) + const loaded = await getAppConfigurationContext(directory, userProvidedConfigName, { + clientId, + skipPrompts: Boolean(clientId), + }) project = loaded.project activeConfig = loaded.activeConfig diff --git a/packages/app/src/cli/services/validate.test.ts b/packages/app/src/cli/services/validate.test.ts index c4b8a2a67e4..4a7d0095f29 100644 --- a/packages/app/src/cli/services/validate.test.ts +++ b/packages/app/src/cli/services/validate.test.ts @@ -118,7 +118,7 @@ describe('validateApp', () => { test('renders success when there are no errors', async () => { const app = testAppLinked() await validateApp(app) - expect(renderSuccess).toHaveBeenCalledWith({headline: 'App configuration is valid.'}) + expect(renderSuccess).toHaveBeenCalledWith({headline: "App configuration 'shopify.app.toml' is valid."}) expect(renderError).not.toHaveBeenCalled() expect(outputResult).not.toHaveBeenCalled() await expectLastValidationMetadata({ @@ -197,7 +197,7 @@ describe('validateApp', () => { const app = testAppLinked() app.errors = errors await validateApp(app) - expect(renderSuccess).toHaveBeenCalledWith({headline: 'App configuration is valid.'}) + expect(renderSuccess).toHaveBeenCalledWith({headline: "App configuration 'shopify.app.toml' is valid."}) expect(outputResult).not.toHaveBeenCalled() }) diff --git a/packages/app/src/cli/services/validate.ts b/packages/app/src/cli/services/validate.ts index 7655c369a5b..4aeaaa74d66 100644 --- a/packages/app/src/cli/services/validate.ts +++ b/packages/app/src/cli/services/validate.ts @@ -4,6 +4,7 @@ import metadata from '../metadata.js' import {outputResult} from '@shopify/cli-kit/node/output' import {renderError, renderSuccess} from '@shopify/cli-kit/node/ui' import {AbortSilentError} from '@shopify/cli-kit/node/error' +import {basename} from '@shopify/cli-kit/node/path' interface ValidateAppOptions { json: boolean @@ -30,7 +31,7 @@ export async function validateApp(app: AppLinkedInterface, options: ValidateAppO return } - renderSuccess({headline: 'App configuration is valid.'}) + renderSuccess({headline: `App configuration '${basename(app.configPath)}' is valid.`}) return } From 5b45df267874e1a93106e71c1bb67360f8418416 Mon Sep 17 00:00:00 2001 From: Gonzalo Riestra Date: Wed, 15 Jul 2026 10:42:29 +0200 Subject: [PATCH 2/2] Fix client ID config validation context --- packages/app/src/cli/commands/app/config/validate.test.ts | 5 ++++- packages/app/src/cli/commands/app/config/validate.ts | 3 ++- packages/app/src/cli/models/app/loader.ts | 2 +- packages/app/src/cli/services/app-context.ts | 5 +---- 4 files changed, 8 insertions(+), 7 deletions(-) diff --git a/packages/app/src/cli/commands/app/config/validate.test.ts b/packages/app/src/cli/commands/app/config/validate.test.ts index 4dc8933bbe3..aa7ef84271c 100644 --- a/packages/app/src/cli/commands/app/config/validate.test.ts +++ b/packages/app/src/cli/commands/app/config/validate.test.ts @@ -79,6 +79,9 @@ describe('app config validate command', () => { test('skips active config prompts when --client-id is passed', async () => { const app = testAppLinked() mockHealthyProject() + vi.mocked(selectActiveConfig).mockResolvedValue({ + file: new TomlFile('shopify.app.staging.toml', {}), + } as any) vi.mocked(linkedAppContext).mockResolvedValue({app} as Awaited>) vi.mocked(validateApp).mockResolvedValue() @@ -92,7 +95,7 @@ describe('app config validate command', () => { directory: expect.any(String), clientId: 'api-key', forceRelink: false, - userProvidedConfigName: undefined, + userProvidedConfigName: 'shopify.app.staging.toml', unsafeTolerateErrors: true, }) expect(validateApp).toHaveBeenCalledWith(app, {json: false}) diff --git a/packages/app/src/cli/commands/app/config/validate.ts b/packages/app/src/cli/commands/app/config/validate.ts index 68f21ded06c..d9b90837666 100644 --- a/packages/app/src/cli/commands/app/config/validate.ts +++ b/packages/app/src/cli/commands/app/config/validate.ts @@ -9,6 +9,7 @@ import metadata from '../../../metadata.js' import {globalFlags, jsonFlag} from '@shopify/cli-kit/node/cli' import {AbortError, AbortSilentError} from '@shopify/cli-kit/node/error' import {outputResult, stringifyMessage, unstyled} from '@shopify/cli-kit/node/output' +import {basename} from '@shopify/cli-kit/node/path' import {renderError} from '@shopify/cli-kit/node/ui' async function recordValidationFailure(issueCount: number, fileCount: number) { @@ -93,7 +94,7 @@ export default class Validate extends AppLinkedCommand { directory: flags.path, clientId: flags['client-id'], forceRelink: flags.reset, - userProvidedConfigName: flags.config, + userProvidedConfigName: flags.config ?? (flags['client-id'] ? basename(activeConfig.file.path) : undefined), unsafeTolerateErrors: true, }) app = context.app diff --git a/packages/app/src/cli/models/app/loader.ts b/packages/app/src/cli/models/app/loader.ts index 0f22f92ff97..28bac00d9d4 100644 --- a/packages/app/src/cli/models/app/loader.ts +++ b/packages/app/src/cli/models/app/loader.ts @@ -926,7 +926,7 @@ type ConfigurationLoaderResult< export async function getAppConfigurationContext( workingDirectory: string, userProvidedConfigName?: string, - options?: {clientId?: string; skipPrompts?: boolean}, + options?: {skipPrompts?: boolean}, ): Promise<{project: Project; activeConfig: ActiveConfig}> { const project = await Project.load(workingDirectory) const activeConfig = await selectActiveConfig(project, userProvidedConfigName, options) diff --git a/packages/app/src/cli/services/app-context.ts b/packages/app/src/cli/services/app-context.ts index cf11d1ff25a..00d37b86fce 100644 --- a/packages/app/src/cli/services/app-context.ts +++ b/packages/app/src/cli/services/app-context.ts @@ -97,10 +97,7 @@ export async function linkedAppContext({ project = reloaded.project activeConfig = reloaded.activeConfig } else { - const loaded = await getAppConfigurationContext(directory, userProvidedConfigName, { - clientId, - skipPrompts: Boolean(clientId), - }) + const loaded = await getAppConfigurationContext(directory, userProvidedConfigName) project = loaded.project activeConfig = loaded.activeConfig