diff --git a/.changeset/app-config-validate-client-id.md b/.changeset/app-config-validate-client-id.md new file mode 100644 index 00000000000..62cac5d423e --- /dev/null +++ b/.changeset/app-config-validate-client-id.md @@ -0,0 +1,5 @@ +--- +'@shopify/app': patch +--- + +Add a `--client-id` option to `app config validate`. 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..5e5431116df 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,28 @@ 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('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..6ce54e16c08 100644 --- a/packages/app/src/cli/models/project/active-config.ts +++ b/packages/app/src/cli/models/project/active-config.ts @@ -56,9 +56,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 +77,25 @@ export async function selectActiveConfig( configName = await use({directory: project.directory, warningContent, shouldRenderSuccess: false}) } + if (!configName && clientIdConfig) { + return buildActiveConfig(project, clientIdConfig, 'flag') + } + // 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 4000c37764e..ea3711ec7cc 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