From 355155560f27df73a3c452a548704932989ca5b5 Mon Sep 17 00:00:00 2001 From: Gasser Aly Date: Fri, 8 May 2026 12:46:14 -0400 Subject: [PATCH] feat: flow ai cli commands --- .../cli-kit/src/private/node/session.test.ts | 57 +++++++ packages/cli-kit/src/private/node/session.ts | 51 ++++-- .../cli-kit/src/public/node/session.test.ts | 24 +++ packages/cli-kit/src/public/node/session.ts | 27 ++++ packages/cli/oclif.manifest.json | 91 ++++++++++- .../src/cli/commands/flow/tool/call.test.ts | 64 ++++++++ .../store/src/cli/commands/flow/tool/call.ts | 71 +++++++++ .../src/cli/services/flow/tool-call.test.ts | 150 ++++++++++++++++++ .../store/src/cli/services/flow/tool-call.ts | 127 +++++++++++++++ packages/store/src/index.ts | 2 + 10 files changed, 650 insertions(+), 14 deletions(-) create mode 100644 packages/store/src/cli/commands/flow/tool/call.test.ts create mode 100644 packages/store/src/cli/commands/flow/tool/call.ts create mode 100644 packages/store/src/cli/services/flow/tool-call.test.ts create mode 100644 packages/store/src/cli/services/flow/tool-call.ts diff --git a/packages/cli-kit/src/private/node/session.test.ts b/packages/cli-kit/src/private/node/session.test.ts index 8d3593fd1be..c089ee8e14e 100644 --- a/packages/cli-kit/src/private/node/session.test.ts +++ b/packages/cli-kit/src/private/node/session.test.ts @@ -313,6 +313,46 @@ describe('when existing session is valid', () => { expect(fetchSessions).toHaveBeenCalledOnce() }) + test('returns identity token when identity auth is requested', async () => { + // Given + vi.mocked(validateSession).mockResolvedValueOnce('ok') + vi.mocked(fetchSessions).mockResolvedValue(validSessions) + const identityScope = 'https://api.shopify.com/auth/example.scope' + + // When + const got = await ensureAuthenticated({ + identityApi: {scopes: [identityScope]}, + }) + + // Then + expect(got).toEqual({identity: 'access_token', userId}) + expect(validateSession).toHaveBeenCalledWith( + [identityScope], + {identityApi: {scopes: [identityScope]}}, + validSessions[fqdn]![userId], + ) + }) + + test('does not exchange application tokens for identity-only auth', async () => { + // Given + vi.mocked(validateSession).mockResolvedValueOnce('needs_full_auth') + vi.mocked(fetchSessions).mockResolvedValue(undefined) + const identityScope = 'https://api.shopify.com/auth/example.scope' + + // When + const got = await ensureAuthenticated({ + identityApi: {scopes: [identityScope]}, + }) + + // Then + expect(exchangeAccessForApplicationTokens).not.toHaveBeenCalled() + expect(businessPlatformRequest).not.toHaveBeenCalled() + expect(got).toEqual({identity: 'access_token', userId}) + + const storedSession = vi.mocked(storeSessions).mock.calls[0]![0] + expect(storedSession[fqdn]![userId]!.applications).toEqual({}) + }) + test('overwrites partners token if provided with a custom CLI token', async () => { // Given vi.mocked(validateSession).mockResolvedValueOnce('ok') @@ -371,6 +411,23 @@ describe('when existing session is expired', () => { expect(fetchSessions).toHaveBeenCalledOnce() }) + test('does not exchange application tokens when refreshing identity-only auth', async () => { + // Given + vi.mocked(validateSession).mockResolvedValueOnce('needs_refresh') + vi.mocked(fetchSessions).mockResolvedValue(validSessions) + const identityScope = 'https://api.shopify.com/auth/example.scope' + + // When + const got = await ensureAuthenticated({ + identityApi: {scopes: [identityScope]}, + }) + + // Then + expect(refreshAccessToken).toHaveBeenCalled() + expect(exchangeAccessForApplicationTokens).not.toHaveBeenCalled() + expect(got).toEqual({identity: 'access_token', userId}) + }) + test('attempts to refresh the token and executes a complete flow if identity returns an invalid grant error', async () => { // Given const tokenResponseError = new InvalidGrantError() diff --git a/packages/cli-kit/src/private/node/session.ts b/packages/cli-kit/src/private/node/session.ts index 3b750d1cdcb..e58192af4c0 100644 --- a/packages/cli-kit/src/private/node/session.ts +++ b/packages/cli-kit/src/private/node/session.ts @@ -9,7 +9,7 @@ import { InvalidGrantError, InvalidRequestError, } from './session/exchange.js' -import {IdentityToken, Session, Sessions} from './session/schema.js' +import {ApplicationToken, IdentityToken, Session, Sessions} from './session/schema.js' import * as sessionStore from './session/store.js' import {pollForDeviceAuthorization, requestDeviceAuthorization} from './session/device-authorization.js' import {isThemeAccessSession} from './api/rest.js' @@ -92,6 +92,12 @@ interface BusinessPlatformAPIOAuthOptions { scopes: BusinessPlatformScope[] } +export type IdentityScope = string +interface IdentityOAuthOptions { + /** List of Identity scopes to request permissions for. */ + scopes: IdentityScope[] +} + /** * It represents the authentication requirements and * is the input necessary to trigger the authentication @@ -103,6 +109,7 @@ export interface OAuthApplications { partnersApi?: PartnersAPIOAuthOptions businessPlatformApi?: BusinessPlatformAPIOAuthOptions appManagementApi?: AppManagementAPIOauthOptions + identityApi?: IdentityOAuthOptions } export interface OAuthSession { @@ -111,6 +118,7 @@ export interface OAuthSession { storefront?: string businessPlatform?: string appManagement?: string + identity?: string userId: string } @@ -294,7 +302,6 @@ The CLI is currently unable to prompt for reauthentication.`, */ async function executeCompleteFlow(applications: OAuthApplications, existingAlias?: string): Promise { const scopes = getFlattenScopes(applications) - const exchangeScopes = getExchangeScopes(applications) const store = applications.adminApi?.storeFqdn if (firstPartyDev()) { outputDebug(outputContent`Authenticating as Shopify Employee...`) @@ -315,9 +322,12 @@ async function executeCompleteFlow(applications: OAuthApplications, existingAlia identityToken = await pollForDeviceAuthorization(deviceAuth.deviceCode, deviceAuth.interval) } - // Exchange identity token for application tokens - outputDebug(outputContent`CLI token received. Exchanging it for application tokens...`) - const result = await exchangeAccessForApplicationTokens(identityToken, exchangeScopes, store) + let result: Record = {} + if (requiresApplicationTokenExchange(applications)) { + // Exchange identity token for application tokens + outputDebug(outputContent`CLI token received. Exchanging it for application tokens...`) + result = await exchangeAccessForApplicationTokens(identityToken, getExchangeScopes(applications), store) + } // Preserve existing alias if available, otherwise try fetching email const businessPlatformToken = result[applicationId('business-platform')]?.accessToken @@ -344,13 +354,13 @@ async function executeCompleteFlow(applications: OAuthApplications, existingAlia async function refreshTokens(session: Session, applications: OAuthApplications): Promise { // Refresh Identity Token const identityToken = await refreshAccessToken(session.identity) - // Exchange new identity token for application tokens - const exchangeScopes = getExchangeScopes(applications) - const applicationTokens = await exchangeAccessForApplicationTokens( - identityToken, - exchangeScopes, - applications.adminApi?.storeFqdn, - ) + const applicationTokens = requiresApplicationTokenExchange(applications) + ? await exchangeAccessForApplicationTokens( + identityToken, + getExchangeScopes(applications), + applications.adminApi?.storeFqdn, + ) + : {} return { identity: {...identityToken, alias: session.identity.alias}, @@ -358,6 +368,16 @@ async function refreshTokens(session: Session, applications: OAuthApplications): } } +function requiresApplicationTokenExchange(apps: OAuthApplications): boolean { + return [ + apps.adminApi, + apps.storefrontRendererApi, + apps.partnersApi, + apps.businessPlatformApi, + apps.appManagementApi, + ].some((app) => app !== undefined) +} + /** * Get the application tokens for a given session. * @@ -399,6 +419,10 @@ async function tokensFor(applications: OAuthApplications, session: Session): Pro tokens.appManagement = session.applications[appId]?.accessToken } + if (applications.identityApi) { + tokens.identity = session.identity.accessToken + } + return tokens } @@ -415,7 +439,8 @@ function getFlattenScopes(apps: OAuthApplications): string[] { const storefront = apps.storefrontRendererApi?.scopes ?? [] const businessPlatform = apps.businessPlatformApi?.scopes ?? [] const appManagement = apps.appManagementApi?.scopes ?? [] - const requestedScopes = [...admin, ...partner, ...storefront, ...businessPlatform, ...appManagement] + const identity = apps.identityApi?.scopes ?? [] + const requestedScopes = [...admin, ...partner, ...storefront, ...businessPlatform, ...appManagement, ...identity] return allDefaultScopes(requestedScopes) } diff --git a/packages/cli-kit/src/public/node/session.test.ts b/packages/cli-kit/src/public/node/session.test.ts index 810f9acc1c1..bd3091654e0 100644 --- a/packages/cli-kit/src/public/node/session.test.ts +++ b/packages/cli-kit/src/public/node/session.test.ts @@ -3,6 +3,7 @@ import { ensureAuthenticatedAdminAsApp, ensureAuthenticatedAppManagementAndBusinessPlatform, ensureAuthenticatedBusinessPlatform, + ensureAuthenticatedIdentity, ensureAuthenticatedPartners, ensureAuthenticatedStorefront, ensureAuthenticatedThemes, @@ -42,6 +43,29 @@ describe('store command analytics session helpers', () => { }) }) +describe('ensureAuthenticatedIdentity', () => { + test('returns the identity token when success', async () => { + vi.mocked(ensureAuthenticated).mockResolvedValueOnce({identity: 'identity_token', userId: '1234-5678'}) + + const got = await ensureAuthenticatedIdentity(['https://api.shopify.com/auth/example.scope']) + + expect(got).toEqual({token: 'identity_token', userId: '1234-5678'}) + expect(ensureAuthenticated).toHaveBeenCalledWith( + {identityApi: {scopes: ['https://api.shopify.com/auth/example.scope']}}, + process.env, + {}, + ) + }) + + test('throws error if there is no identity token', async () => { + vi.mocked(ensureAuthenticated).mockResolvedValueOnce({userId: '1234-5678'}) + + const got = ensureAuthenticatedIdentity(['https://api.shopify.com/auth/example.scope']) + + await expect(got).rejects.toThrow(`No identity token`) + }) +}) + describe('ensureAuthenticatedStorefront', () => { test('returns only storefront token if success', async () => { // Given diff --git a/packages/cli-kit/src/public/node/session.ts b/packages/cli-kit/src/public/node/session.ts index 1ebffdeaf47..f846569c329 100644 --- a/packages/cli-kit/src/public/node/session.ts +++ b/packages/cli-kit/src/public/node/session.ts @@ -14,6 +14,7 @@ import { AppManagementAPIScope, BusinessPlatformScope, EnsureAuthenticatedAdditionalOptions, + IdentityScope, PartnersAPIScope, StorefrontRendererScope, ensureAuthenticated, @@ -101,6 +102,32 @@ export async function ensureAuthenticatedUser( return {userId: tokens.userId} } +/** + * Ensure that we have a valid Identity access token with the requested scopes. + * + * Use this when a first-party Shopify service validates the CLI caller directly + * with Identity rather than with an exchanged application token. + * + * @param scopes - Identity scopes to authenticate with. + * @param env - Optional environment variables to use. + * @param options - Optional extra options to use. + * @returns The Identity access token and user ID. + */ +export async function ensureAuthenticatedIdentity( + scopes: IdentityScope[] = [], + env = process.env, + options: EnsureAuthenticatedAdditionalOptions = {}, +): Promise<{token: string; userId: string}> { + outputDebug(outputContent`Ensuring that the user is authenticated with Identity scopes: +${outputToken.json(scopes)} +`) + const tokens = await ensureAuthenticated({identityApi: {scopes}}, env, options) + if (!tokens.identity) { + throw new BugError('No identity token found after ensuring authenticated') + } + return {token: tokens.identity, userId: tokens.userId} +} + /** * Ensure that we have a valid session to access the Partners API. * If SHOPIFY_CLI_PARTNERS_TOKEN exists, that token will be used to obtain a valid Partners Token diff --git a/packages/cli/oclif.manifest.json b/packages/cli/oclif.manifest.json index b44ecc21b27..3645582c108 100644 --- a/packages/cli/oclif.manifest.json +++ b/packages/cli/oclif.manifest.json @@ -3659,6 +3659,95 @@ "pluginType": "core", "strict": true }, + "flow:tool:call": { + "aliases": [ + ], + "args": { + "tool": { + "description": "The Flow tool name to call.", + "name": "tool", + "required": true + } + }, + "customPluginName": "@shopify/store", + "description": "Calls a Shopify Flow tool for the specified store.\n\nThe CLI owns authentication and backend routing. Agents and scripts should use this command rather than calling Flow or Sidekick endpoints directly.", + "descriptionWithMarkdown": "Calls a Shopify Flow tool for the specified store.\n\nThe CLI owns authentication and backend routing. Agents and scripts should use this command rather than calling Flow or Sidekick endpoints directly.", + "examples": [ + "<%= config.bin %> <%= command.id %> flow_app_agent_template_search --store shop.myshopify.com --arguments '{\"search_queries\":[\"fraud prevention\"]}' --json", + "<%= config.bin %> <%= command.id %> flow_app_agent_create_or_update_workflow_from_json --store shop.myshopify.com --arguments-file ./workflow.json --json" + ], + "flags": { + "arguments": { + "description": "The JSON object arguments for the tool.", + "env": "SHOPIFY_FLAG_FLOW_TOOL_ARGUMENTS", + "hasDynamicHelp": false, + "multiple": false, + "name": "arguments", + "type": "option" + }, + "arguments-file": { + "description": "Path to a file containing the tool arguments as JSON. Can't be used with --arguments.", + "env": "SHOPIFY_FLAG_FLOW_TOOL_ARGUMENTS_FILE", + "hasDynamicHelp": false, + "multiple": false, + "name": "arguments-file", + "type": "option" + }, + "endpoint": { + "description": "Override the Flow tool gateway endpoint. Intended for local development.", + "env": "SHOPIFY_FLOW_TOOL_CALL_ENDPOINT", + "hasDynamicHelp": false, + "hidden": true, + "multiple": false, + "name": "endpoint", + "type": "option" + }, + "json": { + "allowNo": false, + "char": "j", + "description": "Output the result as JSON. Automatically disables color output.", + "env": "SHOPIFY_FLAG_JSON", + "hidden": false, + "name": "json", + "type": "boolean" + }, + "no-color": { + "allowNo": false, + "description": "Disable color output.", + "env": "SHOPIFY_FLAG_NO_COLOR", + "hidden": false, + "name": "no-color", + "type": "boolean" + }, + "store": { + "char": "s", + "description": "The myshopify.com domain of the store to execute against.", + "env": "SHOPIFY_FLAG_STORE", + "hasDynamicHelp": false, + "multiple": false, + "name": "store", + "required": true, + "type": "option" + }, + "verbose": { + "allowNo": false, + "description": "Increase the verbosity of the output.", + "env": "SHOPIFY_FLAG_VERBOSE", + "hidden": false, + "name": "verbose", + "type": "boolean" + } + }, + "hasDynamicHelp": false, + "hiddenAliases": [ + ], + "id": "flow:tool:call", + "pluginAlias": "@shopify/cli", + "pluginName": "@shopify/cli", + "pluginType": "core", + "strict": true, + "summary": "Call a Shopify Flow tool." + }, "help": { "aliases": [ ], @@ -8461,4 +8550,4 @@ } }, "version": "3.94.0" -} \ No newline at end of file +} diff --git a/packages/store/src/cli/commands/flow/tool/call.test.ts b/packages/store/src/cli/commands/flow/tool/call.test.ts new file mode 100644 index 00000000000..e503248a5c9 --- /dev/null +++ b/packages/store/src/cli/commands/flow/tool/call.test.ts @@ -0,0 +1,64 @@ +import FlowToolCall from './call.js' +import {callFlowTool} from '../../../services/flow/tool-call.js' +import {outputResult} from '@shopify/cli-kit/node/output' +import {beforeEach, describe, expect, test, vi} from 'vitest' + +vi.mock('../../../services/flow/tool-call.js') +vi.mock('@shopify/cli-kit/node/output') +vi.mock('../../../services/store/metrics.js') + +describe('flow tool call command', () => { + beforeEach(() => { + vi.mocked(callFlowTool).mockResolvedValue({isError: false, content: []}) + }) + + test('passes inline arguments through to the service and writes json output', async () => { + await FlowToolCall.run([ + 'flow_app_agent_template_search', + '--store', + 'shop.myshopify.com', + '--arguments', + '{"search_queries":["fraud prevention"]}', + '--json', + ]) + + expect(callFlowTool).toHaveBeenCalledWith({ + tool: 'flow_app_agent_template_search', + store: 'shop.myshopify.com', + arguments: '{"search_queries":["fraud prevention"]}', + argumentsFile: undefined, + endpoint: undefined, + }) + expect(outputResult).toHaveBeenCalledWith(JSON.stringify({isError: false, content: []}, null, 2)) + }) + + test('passes arguments file and endpoint through to the service', async () => { + await FlowToolCall.run([ + 'flow_app_agent_create_or_update_workflow_from_json', + '--store', + 'shop.myshopify.com', + '--arguments-file', + './workflow.json', + '--endpoint', + 'http://localhost:3000/flow/tools/call', + ]) + + expect(callFlowTool).toHaveBeenCalledWith( + expect.objectContaining({ + tool: 'flow_app_agent_create_or_update_workflow_from_json', + store: 'shop.myshopify.com', + arguments: undefined, + argumentsFile: expect.stringMatching(/workflow\.json$/), + endpoint: 'http://localhost:3000/flow/tools/call', + }), + ) + }) + + test('defines the expected flags', () => { + expect(FlowToolCall.flags.store).toBeDefined() + expect(FlowToolCall.flags.arguments).toBeDefined() + expect(FlowToolCall.flags['arguments-file']).toBeDefined() + expect(FlowToolCall.flags.endpoint).toBeDefined() + expect(FlowToolCall.flags.json).toBeDefined() + }) +}) diff --git a/packages/store/src/cli/commands/flow/tool/call.ts b/packages/store/src/cli/commands/flow/tool/call.ts new file mode 100644 index 00000000000..7d50ccc3225 --- /dev/null +++ b/packages/store/src/cli/commands/flow/tool/call.ts @@ -0,0 +1,71 @@ +import {callFlowTool} from '../../../services/flow/tool-call.js' +import StoreCommand from '../../../utilities/store-command.js' +import {globalFlags, jsonFlag} from '@shopify/cli-kit/node/cli' +import {normalizeStoreFqdn} from '@shopify/cli-kit/node/context/fqdn' +import {outputResult} from '@shopify/cli-kit/node/output' +import {resolvePath} from '@shopify/cli-kit/node/path' +import {Args, Flags} from '@oclif/core' + +export default class FlowToolCall extends StoreCommand { + static summary = 'Call a Shopify Flow tool.' + + static descriptionWithMarkdown = `Calls a Shopify Flow tool for the specified store. + +The CLI owns authentication and backend routing. Agents and scripts should use this command rather than calling Flow or Sidekick endpoints directly.` + + static description = this.descriptionWithoutMarkdown() + + static examples = [ + '<%= config.bin %> <%= command.id %> flow_app_agent_template_search --store shop.myshopify.com --arguments \'{"search_queries":["fraud prevention"]}\' --json', + '<%= config.bin %> <%= command.id %> flow_app_agent_create_or_update_workflow_from_json --store shop.myshopify.com --arguments-file ./workflow.json --json', + ] + + static args = { + tool: Args.string({ + description: 'The Flow tool name to call.', + required: true, + }), + } + + static flags = { + ...globalFlags, + ...jsonFlag, + store: Flags.string({ + char: 's', + description: 'The myshopify.com domain of the store to execute against.', + env: 'SHOPIFY_FLAG_STORE', + parse: async (input) => normalizeStoreFqdn(input), + required: true, + }), + arguments: Flags.string({ + description: 'The JSON object arguments for the tool.', + env: 'SHOPIFY_FLAG_FLOW_TOOL_ARGUMENTS', + exactlyOne: ['arguments', 'arguments-file'], + }), + 'arguments-file': Flags.string({ + description: "Path to a file containing the tool arguments as JSON. Can't be used with --arguments.", + env: 'SHOPIFY_FLAG_FLOW_TOOL_ARGUMENTS_FILE', + parse: async (input) => resolvePath(input), + exactlyOne: ['arguments', 'arguments-file'], + }), + endpoint: Flags.string({ + description: 'Override the Flow tool gateway endpoint. Intended for local development.', + env: 'SHOPIFY_FLOW_TOOL_CALL_ENDPOINT', + hidden: true, + }), + } + + public async run(): Promise { + const {args, flags} = await this.parse(FlowToolCall) + + const result = await callFlowTool({ + tool: args.tool, + store: flags.store, + arguments: flags.arguments, + argumentsFile: flags['arguments-file'], + endpoint: flags.endpoint, + }) + + outputResult(JSON.stringify(result, null, 2)) + } +} diff --git a/packages/store/src/cli/services/flow/tool-call.test.ts b/packages/store/src/cli/services/flow/tool-call.test.ts new file mode 100644 index 00000000000..9bcf7e053c1 --- /dev/null +++ b/packages/store/src/cli/services/flow/tool-call.test.ts @@ -0,0 +1,150 @@ +import {callFlowTool} from './tool-call.js' +import {shopifyFetch} from '@shopify/cli-kit/node/http' +import {ensureAuthenticatedIdentity} from '@shopify/cli-kit/node/session' +import {afterEach, beforeEach, describe, expect, test, vi} from 'vitest' + +vi.mock('@shopify/cli-kit/node/session') +vi.mock('@shopify/cli-kit/node/http') + +describe('flow tool call service', () => { + const originalServiceEnv = process.env.SHOPIFY_SERVICE_ENV + const originalEndpoint = process.env.SHOPIFY_FLOW_TOOL_CALL_ENDPOINT + + beforeEach(() => { + delete process.env.SHOPIFY_SERVICE_ENV + delete process.env.SHOPIFY_FLOW_TOOL_CALL_ENDPOINT + vi.clearAllMocks() + vi.mocked(ensureAuthenticatedIdentity).mockResolvedValue({token: 'identity-token', userId: 'user-id'}) + vi.mocked(shopifyFetch).mockResolvedValue( + new Response(JSON.stringify({isError: false, content: []}), { + status: 200, + headers: {'Content-Type': 'application/json'}, + }), + ) + }) + + afterEach(() => { + if (originalServiceEnv === undefined) { + delete process.env.SHOPIFY_SERVICE_ENV + } else { + process.env.SHOPIFY_SERVICE_ENV = originalServiceEnv + } + + if (originalEndpoint === undefined) { + delete process.env.SHOPIFY_FLOW_TOOL_CALL_ENDPOINT + } else { + process.env.SHOPIFY_FLOW_TOOL_CALL_ENDPOINT = originalEndpoint + } + }) + + test('authenticates with the CLI identity session and calls the explicit endpoint', async () => { + const result = await callFlowTool({ + tool: 'flow_app_agent_template_search', + store: 'shop.myshopify.com', + arguments: '{"search_queries":["fraud prevention"]}', + endpoint: 'https://sidekick.example/flow/tools/call', + }) + + expect(ensureAuthenticatedIdentity).toHaveBeenCalledWith(['https://api.shopify.com/auth/flow.workflows.manage']) + expect(shopifyFetch).toHaveBeenCalledWith( + 'https://sidekick.example/flow/tools/call', + expect.objectContaining({ + method: 'POST', + headers: { + Authorization: 'Bearer identity-token', + 'Content-Type': 'application/json', + 'X-Shopify-Shop-Domain': 'shop.myshopify.com', + 'X-Shopify-User-Id': 'user-id', + }, + body: JSON.stringify({ + tool: 'flow_app_agent_template_search', + arguments: {search_queries: ['fraud prevention']}, + }), + }), + 'slow-request', + ) + expect(result).toEqual({isError: false, content: []}) + }) + + test('routes Flow-owned tools directly to local Flow in local development', async () => { + process.env.SHOPIFY_SERVICE_ENV = 'local' + + await callFlowTool({ + tool: 'flow_app_agent_template_search', + store: 'shop1.my.shop.dev', + arguments: '{"search_queries":["fraud prevention"]}', + }) + + expect(shopifyFetch).toHaveBeenCalledWith( + 'https://flow.shop.dev/flow-core/tool_call', + expect.anything(), + 'slow-request', + ) + }) + + test('routes SK-native tools to local agent-server in local development', async () => { + process.env.SHOPIFY_SERVICE_ENV = 'local' + + await callFlowTool({ + tool: 'flow_app_agent_search_shop_resource', + store: 'shop1.my.shop.dev', + arguments: '{"query":"products"}', + }) + + expect(shopifyFetch).toHaveBeenCalledWith( + 'https://agent-server.shop.dev/flow/tools/call', + expect.anything(), + 'slow-request', + ) + }) + + test('requests Admin GraphQL scope for SK-native tools', async () => { + await callFlowTool({ + tool: 'flow_app_agent_search_shop_resource', + store: 'shop.myshopify.com', + arguments: '{"resource_type":"PRODUCT","query":"shirt"}', + }) + + expect(ensureAuthenticatedIdentity).toHaveBeenCalledWith(['https://api.shopify.com/auth/shop.admin.graphql']) + expect(shopifyFetch).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ + headers: { + Authorization: 'Bearer identity-token', + 'Content-Type': 'application/json', + 'X-Shopify-Shop-Domain': 'shop.myshopify.com', + 'X-Shopify-User-Id': 'user-id', + }, + }), + 'slow-request', + ) + }) + + test('rejects non-object arguments json', async () => { + await expect( + callFlowTool({ + tool: 'flow_app_agent_template_search', + store: 'shop.myshopify.com', + arguments: '[]', + }), + ).rejects.toThrow('Flow tool arguments must be a JSON object.') + }) + + test('raises a useful error for gateway failures', async () => { + vi.mocked(shopifyFetch).mockResolvedValue( + new Response(JSON.stringify({error: 'not allowed'}), { + status: 403, + statusText: 'Forbidden', + headers: {'Content-Type': 'application/json'}, + }), + ) + + await expect( + callFlowTool({ + tool: 'flow_app_agent_template_search', + store: 'shop.myshopify.com', + arguments: '{"search_queries":["fraud prevention"]}', + }), + ).rejects.toThrow('Flow tool gateway request failed with HTTP 403.') + }) +}) diff --git a/packages/store/src/cli/services/flow/tool-call.ts b/packages/store/src/cli/services/flow/tool-call.ts new file mode 100644 index 00000000000..c35fbe6a899 --- /dev/null +++ b/packages/store/src/cli/services/flow/tool-call.ts @@ -0,0 +1,127 @@ +import {AbortError} from '@shopify/cli-kit/node/error' +import {fileExists, readFile} from '@shopify/cli-kit/node/fs' +import {shopifyFetch, type Response} from '@shopify/cli-kit/node/http' +import {outputContent, outputToken} from '@shopify/cli-kit/node/output' +import {ensureAuthenticatedIdentity} from '@shopify/cli-kit/node/session' + +const DEFAULT_FLOW_TOOL_CALL_ENDPOINT = 'https://flow.shopifycloud.com/flow-core/tool_call' +const DEFAULT_LOCAL_FLOW_TOOL_CALL_ENDPOINT = 'https://flow.shop.dev/flow-core/tool_call' +const DEFAULT_SIDEKICK_FLOW_TOOL_CALL_ENDPOINT = 'https://sidekick.shopify.ai/flow/tools/call' +const DEFAULT_LOCAL_SIDEKICK_FLOW_TOOL_CALL_ENDPOINT = 'https://agent-server.shop.dev/flow/tools/call' +const FLOW_WORKFLOWS_MANAGE_SCOPE = 'https://api.shopify.com/auth/flow.workflows.manage' +const SHOP_ADMIN_GRAPHQL_SCOPE = 'https://api.shopify.com/auth/shop.admin.graphql' + +const FLOW_OWNED_TOOLS = new Set([ + 'flow_app_agent_create_or_update_workflow_from_json', + 'flow_app_agent_environment_paths_search', + 'flow_app_agent_object_type_definition_search', + 'flow_app_agent_shopifyql_query_fields', + 'flow_app_agent_task_configuration', + 'flow_app_agent_task_search', + 'flow_app_agent_template_search', + 'flow_app_agent_workflow_lookup', +]) + +export interface FlowToolCallInput { + tool: string + store: string + arguments?: string + argumentsFile?: string + endpoint?: string +} + +async function parseArguments( + input: Pick, +): Promise> { + let rawArguments: string + + if (input.arguments !== undefined) { + rawArguments = input.arguments + } else if (input.argumentsFile) { + if (!(await fileExists(input.argumentsFile))) { + throw new AbortError( + outputContent`Arguments file not found at ${outputToken.path( + input.argumentsFile, + )}. Please check the path and try again.`, + ) + } + rawArguments = await readFile(input.argumentsFile, {encoding: 'utf8'}) + } else { + rawArguments = '{}' + } + + let parsed: unknown + try { + parsed = JSON.parse(rawArguments) + } catch (error) { + const errorMessage = error instanceof Error ? error.message : 'Unknown error' + throw new AbortError( + outputContent`Invalid JSON in Flow tool arguments: ${errorMessage}`, + 'Please provide a valid JSON object.', + ) + } + + if (!parsed || Array.isArray(parsed) || typeof parsed !== 'object') { + throw new AbortError('Flow tool arguments must be a JSON object.') + } + + return parsed as Record +} + +function endpointFor(input: FlowToolCallInput): string { + if (input.endpoint) return input.endpoint + if (process.env.SHOPIFY_FLOW_TOOL_CALL_ENDPOINT) return process.env.SHOPIFY_FLOW_TOOL_CALL_ENDPOINT + + const local = process.env.SHOPIFY_SERVICE_ENV === 'local' + if (FLOW_OWNED_TOOLS.has(input.tool)) { + return local ? DEFAULT_LOCAL_FLOW_TOOL_CALL_ENDPOINT : DEFAULT_FLOW_TOOL_CALL_ENDPOINT + } + + return local ? DEFAULT_LOCAL_SIDEKICK_FLOW_TOOL_CALL_ENDPOINT : DEFAULT_SIDEKICK_FLOW_TOOL_CALL_ENDPOINT +} + +async function parseResponse(response: Response): Promise { + const text = await response.text() + + if (!text.trim()) return {} + + try { + return JSON.parse(text) + } catch { + return {raw: text} + } +} + +export async function callFlowTool(input: FlowToolCallInput): Promise { + const parsedArguments = await parseArguments(input) + const flowOwnedTool = FLOW_OWNED_TOOLS.has(input.tool) + const auth = await ensureAuthenticatedIdentity([flowOwnedTool ? FLOW_WORKFLOWS_MANAGE_SCOPE : SHOP_ADMIN_GRAPHQL_SCOPE]) + const endpoint = endpointFor(input) + const headers: Record = { + Authorization: `Bearer ${auth.token}`, + 'Content-Type': 'application/json', + 'X-Shopify-Shop-Domain': input.store, + 'X-Shopify-User-Id': auth.userId, + } + + const response = await shopifyFetch( + endpoint, + { + method: 'POST', + headers, + body: JSON.stringify({ + tool: input.tool, + arguments: parsedArguments, + }), + }, + 'slow-request', + ) + + const body = await parseResponse(response) + + if (!response.ok) { + throw new AbortError(`Flow tool gateway request failed with HTTP ${response.status}.`, JSON.stringify(body, null, 2)) + } + + return body +} diff --git a/packages/store/src/index.ts b/packages/store/src/index.ts index 73e67d78154..a8827034ecc 100644 --- a/packages/store/src/index.ts +++ b/packages/store/src/index.ts @@ -1,7 +1,9 @@ +import FlowToolCall from './cli/commands/flow/tool/call.js' import StoreAuth from './cli/commands/store/auth.js' import StoreExecute from './cli/commands/store/execute.js' const COMMANDS = { + 'flow:tool:call': FlowToolCall, 'store:auth': StoreAuth, 'store:execute': StoreExecute, }