diff --git a/docs-shopify.dev/commands/interfaces/store-auth-logout.interface.ts b/docs-shopify.dev/commands/interfaces/store-auth-logout.interface.ts index 48d229c4f3..4eda6044ff 100644 --- a/docs-shopify.dev/commands/interfaces/store-auth-logout.interface.ts +++ b/docs-shopify.dev/commands/interfaces/store-auth-logout.interface.ts @@ -1,5 +1,11 @@ // This is an autogenerated file. Don't edit this file manually. export interface storeauthlogout { + /** + * Output the result as JSON. Automatically disables color output. + * @environment SHOPIFY_FLAG_JSON + */ + '-j, --json'?: '' + /** * Disable color output. * @environment SHOPIFY_FLAG_NO_COLOR diff --git a/docs-shopify.dev/commands/interfaces/store-auth.interface.ts b/docs-shopify.dev/commands/interfaces/store-auth.interface.ts index e38a6d147e..66957c80ac 100644 --- a/docs-shopify.dev/commands/interfaces/store-auth.interface.ts +++ b/docs-shopify.dev/commands/interfaces/store-auth.interface.ts @@ -1,5 +1,11 @@ // This is an autogenerated file. Don't edit this file manually. export interface storeauth { + /** + * Output the result as JSON. Automatically disables color output. + * @environment SHOPIFY_FLAG_JSON + */ + '-j, --json'?: '' + /** * Disable color output. * @environment SHOPIFY_FLAG_NO_COLOR diff --git a/docs-shopify.dev/commands/interfaces/store-execute.interface.ts b/docs-shopify.dev/commands/interfaces/store-execute.interface.ts index 3bff68a8f9..39550404db 100644 --- a/docs-shopify.dev/commands/interfaces/store-execute.interface.ts +++ b/docs-shopify.dev/commands/interfaces/store-execute.interface.ts @@ -6,6 +6,12 @@ export interface storeexecute { */ '--allow-mutations'?: '' + /** + * Output the result as JSON. Automatically disables color output. + * @environment SHOPIFY_FLAG_JSON + */ + '-j, --json'?: '' + /** * Disable color output. * @environment SHOPIFY_FLAG_NO_COLOR diff --git a/packages/cli/README.md b/packages/cli/README.md index 8cb7531aa4..9ca05f79b7 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -2059,9 +2059,10 @@ Authenticate an app against a store for store commands. ``` USAGE - $ shopify store auth --scopes -s [--no-color] [--verbose] + $ shopify store auth --scopes -s [-j] [--no-color] [--verbose] FLAGS + -j, --json [env: SHOPIFY_FLAG_JSON] Output the result as JSON. Automatically disables color output. -s, --store= (required) [env: SHOPIFY_FLAG_STORE] The myshopify.com domain of the store to authenticate against. --no-color [env: SHOPIFY_FLAG_NO_COLOR] Disable color output. @@ -2084,6 +2085,8 @@ DESCRIPTION EXAMPLES $ shopify store auth --store shop.myshopify.com --scopes read_products,write_products + + $ shopify store auth --store shop.myshopify.com --scopes read_products,write_products --json ``` ## `shopify store auth info` @@ -2118,9 +2121,10 @@ Clear locally stored store auth for a store. ``` USAGE - $ shopify store auth logout -s [--no-color] [--verbose] + $ shopify store auth logout -s [-j] [--no-color] [--verbose] FLAGS + -j, --json [env: SHOPIFY_FLAG_JSON] Output the result as JSON. Automatically disables color output. -s, --store= (required) [env: SHOPIFY_FLAG_STORE] The myshopify.com domain of the store to clear local auth for. --no-color [env: SHOPIFY_FLAG_NO_COLOR] Disable color output. @@ -2135,6 +2139,8 @@ DESCRIPTION EXAMPLES $ shopify store auth logout --store shop.myshopify.com + + $ shopify store auth logout --store shop.myshopify.com --json ``` ## `shopify store execute` @@ -2143,10 +2149,11 @@ Execute GraphQL queries and mutations on a store. ``` USAGE - $ shopify store execute -s [--allow-mutations] [--no-color] [--output-file ] [-q ] + $ shopify store execute -s [--allow-mutations] [-j] [--no-color] [--output-file ] [-q ] [--query-file ] [--variable-file | -v ] [--verbose] [--version ] FLAGS + -j, --json [env: SHOPIFY_FLAG_JSON] Output the result as JSON. Automatically disables color output. -q, --query= [env: SHOPIFY_FLAG_QUERY] The GraphQL query or mutation, as a string. -s, --store= (required) [env: SHOPIFY_FLAG_STORE] The myshopify.com domain of the store to execute against. @@ -2180,6 +2187,8 @@ EXAMPLES $ shopify store execute --store shop.myshopify.com --query-file ./operation.graphql --variables '{"id":"gid://shopify/Product/1"}' $ shopify store execute --store shop.myshopify.com --query "mutation { shop { id } }" --allow-mutations + + $ shopify store execute --store shop.myshopify.com --query "query { shop { name } }" --json ``` ## `shopify theme check` diff --git a/packages/cli/oclif.manifest.json b/packages/cli/oclif.manifest.json index 859510c9ed..5e08dd1a76 100644 --- a/packages/cli/oclif.manifest.json +++ b/packages/cli/oclif.manifest.json @@ -5743,9 +5743,19 @@ "descriptionWithMarkdown": "Authenticates the app against the specified store for store commands and stores an online access token for later reuse.\n\nRe-run this command if the stored token is missing, expires, or no longer has the scopes you need.\n\nTo inspect the locally stored auth state for a store, run [`shopify store auth info`](https://shopify.dev/docs/api/shopify-cli/store/store-auth-info).\n\nTo clear the locally stored auth state for a store, run [`shopify store auth logout`](https://shopify.dev/docs/api/shopify-cli/store/store-auth-logout).", "enableJsonFlag": false, "examples": [ - "<%= config.bin %> <%= command.id %> --store shop.myshopify.com --scopes read_products,write_products" + "<%= config.bin %> <%= command.id %> --store shop.myshopify.com --scopes read_products,write_products", + "<%= config.bin %> <%= command.id %> --store shop.myshopify.com --scopes read_products,write_products --json" ], "flags": { + "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.", @@ -5860,9 +5870,19 @@ "descriptionWithMarkdown": "Clears the locally stored store auth for the specified store on this machine.\n\nThis does not revoke the app or remove granted scopes on Shopify.", "enableJsonFlag": false, "examples": [ - "<%= config.bin %> <%= command.id %> --store shop.myshopify.com" + "<%= config.bin %> <%= command.id %> --store shop.myshopify.com", + "<%= config.bin %> <%= command.id %> --store shop.myshopify.com --json" ], "flags": { + "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.", @@ -5911,7 +5931,8 @@ "examples": [ "<%= config.bin %> <%= command.id %> --store shop.myshopify.com --query \"query { shop { name } }\"", "<%= config.bin %> <%= command.id %> --store shop.myshopify.com --query-file ./operation.graphql --variables '{\"id\":\"gid://shopify/Product/1\"}'", - "<%= config.bin %> <%= command.id %> --store shop.myshopify.com --query \"mutation { shop { id } }\" --allow-mutations" + "<%= config.bin %> <%= command.id %> --store shop.myshopify.com --query \"mutation { shop { id } }\" --allow-mutations", + "<%= config.bin %> <%= command.id %> --store shop.myshopify.com --query \"query { shop { name } }\" --json" ], "flags": { "allow-mutations": { @@ -5921,6 +5942,15 @@ "name": "allow-mutations", "type": "boolean" }, + "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.", diff --git a/packages/cli/src/cli/commands/store/auth/index.test.ts b/packages/cli/src/cli/commands/store/auth/index.test.ts index d048232205..8c25abc82b 100644 --- a/packages/cli/src/cli/commands/store/auth/index.test.ts +++ b/packages/cli/src/cli/commands/store/auth/index.test.ts @@ -1,26 +1,43 @@ import {describe, test, expect, vi, beforeEach} from 'vitest' import StoreAuth from './index.js' -import {authenticateStoreWithApp} from '../../../services/store/auth.js' +import {authenticateStoreWithApp, createStoreAuthPresenter} from '../../../services/store/auth.js' -vi.mock('../../../services/store/auth.js') +vi.mock('../../../services/store/auth.js', () => ({ + authenticateStoreWithApp: vi.fn(), + createStoreAuthPresenter: vi.fn().mockReturnValue('presenter'), +})) describe('store auth command', () => { beforeEach(() => { vi.clearAllMocks() + vi.mocked(createStoreAuthPresenter).mockReturnValue('presenter' as any) }) test('passes parsed flags through to the auth service', async () => { await StoreAuth.run(['--store', 'shop.myshopify.com', '--scopes', 'read_products,write_products']) - expect(authenticateStoreWithApp).toHaveBeenCalledWith({ - store: 'shop.myshopify.com', - scopes: 'read_products,write_products', - }) + expect(authenticateStoreWithApp).toHaveBeenCalledWith( + { + store: 'shop.myshopify.com', + scopes: 'read_products,write_products', + }, + { + presenter: 'presenter', + }, + ) + expect(createStoreAuthPresenter).toHaveBeenCalledWith('text') + }) + + test('supports json output', async () => { + await StoreAuth.run(['--store', 'shop.myshopify.com', '--scopes', 'read_products,write_products', '--json']) + + expect(createStoreAuthPresenter).toHaveBeenCalledWith('json') }) test('defines the expected flags', () => { expect(StoreAuth.flags.store).toBeDefined() expect(StoreAuth.flags.scopes).toBeDefined() + expect(StoreAuth.flags.json).toBeDefined() expect('port' in StoreAuth.flags).toBe(false) expect('client-secret-file' in StoreAuth.flags).toBe(false) }) diff --git a/packages/cli/src/cli/commands/store/auth/index.ts b/packages/cli/src/cli/commands/store/auth/index.ts index 90cb0a3423..fb8de351ed 100644 --- a/packages/cli/src/cli/commands/store/auth/index.ts +++ b/packages/cli/src/cli/commands/store/auth/index.ts @@ -1,8 +1,8 @@ import Command from '@shopify/cli-kit/node/base-command' -import {globalFlags} from '@shopify/cli-kit/node/cli' +import {globalFlags, jsonFlag} from '@shopify/cli-kit/node/cli' import {normalizeStoreFqdn} from '@shopify/cli-kit/node/context/fqdn' import {Flags} from '@oclif/core' -import {authenticateStoreWithApp} from '../../../services/store/auth.js' +import {authenticateStoreWithApp, createStoreAuthPresenter} from '../../../services/store/auth.js' export default class StoreAuth extends Command { static summary = 'Authenticate an app against a store for store commands.' @@ -17,10 +17,14 @@ To clear the locally stored auth state for a store, run [\`shopify store auth lo static description = this.descriptionWithoutMarkdown() - static examples = ['<%= config.bin %> <%= command.id %> --store shop.myshopify.com --scopes read_products,write_products'] + static examples = [ + '<%= config.bin %> <%= command.id %> --store shop.myshopify.com --scopes read_products,write_products', + '<%= config.bin %> <%= command.id %> --store shop.myshopify.com --scopes read_products,write_products --json', + ] static flags = { ...globalFlags, + ...jsonFlag, store: Flags.string({ char: 's', description: 'The myshopify.com domain of the store to authenticate against.', @@ -38,9 +42,14 @@ To clear the locally stored auth state for a store, run [\`shopify store auth lo async run(): Promise { const {flags} = await this.parse(StoreAuth) - await authenticateStoreWithApp({ - store: flags.store, - scopes: flags.scopes, - }) + await authenticateStoreWithApp( + { + store: flags.store, + scopes: flags.scopes, + }, + { + presenter: createStoreAuthPresenter(flags.json ? 'json' : 'text'), + }, + ) } } diff --git a/packages/cli/src/cli/commands/store/auth/logout.test.ts b/packages/cli/src/cli/commands/store/auth/logout.test.ts index 4a69089b9a..01df8acff2 100644 --- a/packages/cli/src/cli/commands/store/auth/logout.test.ts +++ b/packages/cli/src/cli/commands/store/auth/logout.test.ts @@ -17,7 +17,7 @@ describe('store auth logout command', () => { await StoreAuthLogout.run(['--store', 'shop.myshopify.com']) expect(logoutStoreAuth).toHaveBeenCalledWith('shop.myshopify.com') - expect(displayStoreAuthLogout).toHaveBeenCalledWith({store: 'shop.myshopify.com', cleared: true}) + expect(displayStoreAuthLogout).toHaveBeenCalledWith({store: 'shop.myshopify.com', cleared: true}, 'text') }) test('normalizes the store flag before calling the auth logout service', async () => { @@ -26,7 +26,14 @@ describe('store auth logout command', () => { expect(logoutStoreAuth).toHaveBeenCalledWith('shop.myshopify.com') }) + test('supports json output', async () => { + await StoreAuthLogout.run(['--store', 'shop.myshopify.com', '--json']) + + expect(displayStoreAuthLogout).toHaveBeenCalledWith({store: 'shop.myshopify.com', cleared: true}, 'json') + }) + test('defines the expected flags', () => { expect(StoreAuthLogout.flags.store).toBeDefined() + expect(StoreAuthLogout.flags.json).toBeDefined() }) }) diff --git a/packages/cli/src/cli/commands/store/auth/logout.ts b/packages/cli/src/cli/commands/store/auth/logout.ts index 3cb8b862f5..a33e53c44a 100644 --- a/packages/cli/src/cli/commands/store/auth/logout.ts +++ b/packages/cli/src/cli/commands/store/auth/logout.ts @@ -1,5 +1,5 @@ import Command from '@shopify/cli-kit/node/base-command' -import {globalFlags} from '@shopify/cli-kit/node/cli' +import {globalFlags, jsonFlag} from '@shopify/cli-kit/node/cli' import {normalizeStoreFqdn} from '@shopify/cli-kit/node/context/fqdn' import {Flags} from '@oclif/core' import {displayStoreAuthLogout, logoutStoreAuth} from '../../../services/store/auth-logout.js' @@ -13,10 +13,14 @@ This does not revoke the app or remove granted scopes on Shopify.` static description = this.descriptionWithoutMarkdown() - static examples = ['<%= config.bin %> <%= command.id %> --store shop.myshopify.com'] + static examples = [ + '<%= config.bin %> <%= command.id %> --store shop.myshopify.com', + '<%= config.bin %> <%= command.id %> --store shop.myshopify.com --json', + ] static flags = { ...globalFlags, + ...jsonFlag, store: Flags.string({ char: 's', description: 'The myshopify.com domain of the store to clear local auth for.', @@ -30,6 +34,6 @@ This does not revoke the app or remove granted scopes on Shopify.` const {flags} = await this.parse(StoreAuthLogout) const result = logoutStoreAuth(flags.store) - displayStoreAuthLogout(result) + displayStoreAuthLogout(result, flags.json ? 'json' : 'text') } } diff --git a/packages/cli/src/cli/commands/store/execute.test.ts b/packages/cli/src/cli/commands/store/execute.test.ts index e890b5332a..66547d2108 100644 --- a/packages/cli/src/cli/commands/store/execute.test.ts +++ b/packages/cli/src/cli/commands/store/execute.test.ts @@ -1,12 +1,19 @@ import {describe, test, expect, vi, beforeEach} from 'vitest' import StoreExecute from './execute.js' +import {writeOrOutputStoreExecuteResult} from '../../services/store/execute-result.js' import {executeStoreOperation} from '../../services/store/execute.js' -vi.mock('../../services/store/execute.js') +vi.mock('../../services/store/execute-result.js', () => ({ + writeOrOutputStoreExecuteResult: vi.fn(), +})) +vi.mock('../../services/store/execute.js', () => ({ + executeStoreOperation: vi.fn().mockResolvedValue({data: {shop: {name: 'Test shop'}}}), +})) describe('store execute command', () => { beforeEach(() => { vi.clearAllMocks() + vi.mocked(executeStoreOperation).mockResolvedValue({data: {shop: {name: 'Test shop'}}}) }) test('passes the inline query through to the service', async () => { @@ -18,10 +25,21 @@ describe('store execute command', () => { queryFile: undefined, variables: undefined, variableFile: undefined, - outputFile: undefined, version: undefined, allowMutations: false, }) + expect(writeOrOutputStoreExecuteResult).toHaveBeenCalledWith({data: {shop: {name: 'Test shop'}}}, undefined, 'text') + }) + + test('supports json output', async () => { + await StoreExecute.run(['--store', 'shop.myshopify.com', '--query', 'query { shop { name } }', '--json']) + + expect(executeStoreOperation).toHaveBeenCalledWith( + expect.objectContaining({ + store: 'shop.myshopify.com', + }), + ) + expect(writeOrOutputStoreExecuteResult).toHaveBeenCalledWith({data: {shop: {name: 'Test shop'}}}, undefined, 'json') }) test('passes the query file through to the service', async () => { @@ -36,6 +54,16 @@ describe('store execute command', () => { ) }) + test('passes the output file through to the output writer', async () => { + await StoreExecute.run(['--store', 'shop.myshopify.com', '--query', 'query { shop { name } }', '--output-file', './result.json']) + + expect(writeOrOutputStoreExecuteResult).toHaveBeenCalledWith( + {data: {shop: {name: 'Test shop'}}}, + expect.stringMatching(/result\.json$/), + 'text', + ) + }) + test('defines the expected flags', () => { expect(StoreExecute.flags.store).toBeDefined() expect(StoreExecute.flags.query).toBeDefined() @@ -43,5 +71,6 @@ describe('store execute command', () => { expect(StoreExecute.flags.variables).toBeDefined() expect(StoreExecute.flags['variable-file']).toBeDefined() expect(StoreExecute.flags['allow-mutations']).toBeDefined() + expect(StoreExecute.flags.json).toBeDefined() }) }) diff --git a/packages/cli/src/cli/commands/store/execute.ts b/packages/cli/src/cli/commands/store/execute.ts index 661c8af9a2..98770b7ec6 100644 --- a/packages/cli/src/cli/commands/store/execute.ts +++ b/packages/cli/src/cli/commands/store/execute.ts @@ -1,8 +1,9 @@ import Command from '@shopify/cli-kit/node/base-command' -import {globalFlags} from '@shopify/cli-kit/node/cli' +import {globalFlags, jsonFlag} from '@shopify/cli-kit/node/cli' import {normalizeStoreFqdn} from '@shopify/cli-kit/node/context/fqdn' import {resolvePath} from '@shopify/cli-kit/node/path' import {Flags} from '@oclif/core' +import {writeOrOutputStoreExecuteResult} from '../../services/store/execute-result.js' import {executeStoreOperation} from '../../services/store/execute.js' export default class StoreExecute extends Command { @@ -20,10 +21,12 @@ Mutations are disabled by default. Re-run with \`--allow-mutations\` if you inte '<%= config.bin %> <%= command.id %> --store shop.myshopify.com --query "query { shop { name } }"', `<%= config.bin %> <%= command.id %> --store shop.myshopify.com --query-file ./operation.graphql --variables '{"id":"gid://shopify/Product/1"}'`, '<%= config.bin %> <%= command.id %> --store shop.myshopify.com --query "mutation { shop { id } }" --allow-mutations', + '<%= config.bin %> <%= command.id %> --store shop.myshopify.com --query "query { shop { name } }" --json', ] static flags = { ...globalFlags, + ...jsonFlag, query: Flags.string({ char: 'q', description: 'The GraphQL query or mutation, as a string.', @@ -75,15 +78,16 @@ Mutations are disabled by default. Re-run with \`--allow-mutations\` if you inte async run(): Promise { const {flags} = await this.parse(StoreExecute) - await executeStoreOperation({ + const result = await executeStoreOperation({ store: flags.store, query: flags.query, queryFile: flags['query-file'], variables: flags.variables, variableFile: flags['variable-file'], - outputFile: flags['output-file'], version: flags.version, allowMutations: flags['allow-mutations'], }) + + await writeOrOutputStoreExecuteResult(result, flags['output-file'], flags.json ? 'json' : 'text') } } diff --git a/packages/cli/src/cli/services/store/auth-info.test.ts b/packages/cli/src/cli/services/store/auth-info.test.ts index 4543c8aa1d..6d8a734b17 100644 --- a/packages/cli/src/cli/services/store/auth-info.test.ts +++ b/packages/cli/src/cli/services/store/auth-info.test.ts @@ -52,6 +52,28 @@ describe('store auth info service', () => { expect(createStoredStoreAuthError).toHaveBeenCalledWith('shop.myshopify.com') }) + test('normalizes the store before loading stored auth info', () => { + vi.mocked(getStoredStoreAppSession).mockReturnValue({ + store: 'shop.myshopify.com', + clientId: 'client-id', + userId: '42', + accessToken: 'token', + scopes: ['read_products'], + acquiredAt: '2026-04-02T00:00:00.000Z', + } as any) + vi.mocked(isSessionExpired).mockReturnValue(false) + + expect(getStoreAuthInfo('https://shop.myshopify.com/admin')).toEqual({ + store: 'shop.myshopify.com', + userId: '42', + scopes: ['read_products'], + acquiredAt: '2026-04-02T00:00:00.000Z', + hasRefreshToken: false, + isExpired: false, + }) + expect(getStoredStoreAppSession).toHaveBeenCalledWith('shop.myshopify.com') + }) + test('renders text output', () => { displayStoreAuthInfo( { diff --git a/packages/cli/src/cli/services/store/auth-info.ts b/packages/cli/src/cli/services/store/auth-info.ts index 05bbca5e60..18bc2235d4 100644 --- a/packages/cli/src/cli/services/store/auth-info.ts +++ b/packages/cli/src/cli/services/store/auth-info.ts @@ -1,46 +1,21 @@ import {createStoredStoreAuthError} from './auth-recovery.js' -import {getStoredStoreAppSession, isSessionExpired} from './session.js' +import {buildStoreAuthState, type StoreAuthState} from './auth-state.js' +import {getStoredStoreAppSession} from './session.js' +import {normalizeStoreFqdn} from '@shopify/cli-kit/node/context/fqdn' import {outputResult} from '@shopify/cli-kit/node/output' import {renderInfo} from '@shopify/cli-kit/node/ui' type StoreAuthInfoFormat = 'text' | 'json' -interface StoreAuthInfo { - store: string - userId: string - scopes: string[] - acquiredAt: string - expiresAt?: string - refreshTokenExpiresAt?: string - hasRefreshToken: boolean - isExpired: boolean - associatedUser?: { - id: number - email?: string - firstName?: string - lastName?: string - accountOwner?: boolean - } -} +export function getStoreAuthInfo(store: string): StoreAuthState { + const normalizedStore = normalizeStoreFqdn(store) + const session = getStoredStoreAppSession(normalizedStore) + if (!session) throw createStoredStoreAuthError(normalizedStore) -export function getStoreAuthInfo(store: string): StoreAuthInfo { - const session = getStoredStoreAppSession(store) - if (!session) throw createStoredStoreAuthError(store) - - return { - store: session.store, - userId: session.userId, - scopes: session.scopes, - acquiredAt: session.acquiredAt, - expiresAt: session.expiresAt, - refreshTokenExpiresAt: session.refreshTokenExpiresAt, - hasRefreshToken: !!session.refreshToken, - isExpired: isSessionExpired(session), - associatedUser: session.associatedUser, - } + return buildStoreAuthState(session) } -export function displayStoreAuthInfo(info: StoreAuthInfo, format: StoreAuthInfoFormat = 'text'): void { +export function displayStoreAuthInfo(info: StoreAuthState, format: StoreAuthInfoFormat = 'text'): void { if (format === 'json') { outputResult(JSON.stringify(info, null, 2)) return diff --git a/packages/cli/src/cli/services/store/auth-logout.test.ts b/packages/cli/src/cli/services/store/auth-logout.test.ts index 255a7072b4..85ece0466c 100644 --- a/packages/cli/src/cli/services/store/auth-logout.test.ts +++ b/packages/cli/src/cli/services/store/auth-logout.test.ts @@ -1,7 +1,7 @@ import {beforeEach, describe, expect, test, vi} from 'vitest' import {clearStoredStoreAppSession, getStoredStoreAppSession} from './session.js' import {displayStoreAuthLogout, logoutStoreAuth} from './auth-logout.js' -import {outputCompleted, outputInfo} from '@shopify/cli-kit/node/output' +import {outputCompleted, outputInfo, outputResult} from '@shopify/cli-kit/node/output' vi.mock('./session.js') vi.mock('@shopify/cli-kit/node/output') @@ -87,6 +87,21 @@ describe('store auth logout service', () => { expect(outputInfo).toHaveBeenCalledWith('No locally stored store auth found for shop.myshopify.com.') }) + test('renders json output', () => { + displayStoreAuthLogout( + { + store: 'shop.myshopify.com', + cleared: true, + }, + 'json', + ) + + expect(outputResult).toHaveBeenCalledWith(`{ + "store": "shop.myshopify.com", + "cleared": true +}`) + }) + test('clears all locally stored users for the store so logout leaves no active session', () => { vi.mocked(getStoredStoreAppSession).mockReturnValue({ store: 'shop.myshopify.com', diff --git a/packages/cli/src/cli/services/store/auth-logout.ts b/packages/cli/src/cli/services/store/auth-logout.ts index 43bf24c55e..be16ee2d72 100644 --- a/packages/cli/src/cli/services/store/auth-logout.ts +++ b/packages/cli/src/cli/services/store/auth-logout.ts @@ -1,7 +1,9 @@ import {normalizeStoreFqdn} from '@shopify/cli-kit/node/context/fqdn' -import {outputCompleted, outputInfo} from '@shopify/cli-kit/node/output' +import {outputCompleted, outputInfo, outputResult} from '@shopify/cli-kit/node/output' import {clearStoredStoreAppSession, getStoredStoreAppSession} from './session.js' +type StoreAuthLogoutFormat = 'text' | 'json' + interface StoreAuthLogoutResult { store: string cleared: boolean @@ -26,7 +28,12 @@ export function logoutStoreAuth(store: string): StoreAuthLogoutResult { } } -export function displayStoreAuthLogout(result: StoreAuthLogoutResult): void { +export function displayStoreAuthLogout(result: StoreAuthLogoutResult, format: StoreAuthLogoutFormat = 'text'): void { + if (format === 'json') { + outputResult(JSON.stringify(result, null, 2)) + return + } + if (!result.cleared) { outputInfo(`No locally stored store auth found for ${result.store}.`) return diff --git a/packages/cli/src/cli/services/store/auth-state.ts b/packages/cli/src/cli/services/store/auth-state.ts new file mode 100644 index 0000000000..d3ec88d8c6 --- /dev/null +++ b/packages/cli/src/cli/services/store/auth-state.ts @@ -0,0 +1,33 @@ +import {isSessionExpired, type StoredStoreAppSession} from './session.js' + +export interface StoreAuthState { + store: string + userId: string + scopes: string[] + acquiredAt: string + expiresAt?: string + refreshTokenExpiresAt?: string + hasRefreshToken: boolean + isExpired: boolean + associatedUser?: { + id: number + email?: string + firstName?: string + lastName?: string + accountOwner?: boolean + } +} + +export function buildStoreAuthState(session: StoredStoreAppSession): StoreAuthState { + return { + store: session.store, + userId: session.userId, + scopes: session.scopes, + acquiredAt: session.acquiredAt, + expiresAt: session.expiresAt, + refreshTokenExpiresAt: session.refreshTokenExpiresAt, + hasRefreshToken: !!session.refreshToken, + isExpired: isSessionExpired(session), + associatedUser: session.associatedUser, + } +} diff --git a/packages/cli/src/cli/services/store/auth.test.ts b/packages/cli/src/cli/services/store/auth.test.ts index 43d3c493d0..1c63450848 100644 --- a/packages/cli/src/cli/services/store/auth.test.ts +++ b/packages/cli/src/cli/services/store/auth.test.ts @@ -3,6 +3,7 @@ import {describe, test, expect, vi, beforeEach, afterEach} from 'vitest' import { authenticateStoreWithApp, buildStoreAuthUrl, + createStoreAuthPresenter, parseStoreAuthScopes, generateCodeVerifier, computeCodeChallenge, @@ -14,7 +15,7 @@ import {loadStoredStoreSession} from './stored-session.js' import {getStoredStoreAppSession, setStoredStoreAppSession} from './session.js' import {STORE_AUTH_APP_CLIENT_ID} from './auth-config.js' import {fetch} from '@shopify/cli-kit/node/http' -import {outputDebug} from '@shopify/cli-kit/node/output' +import {outputCompleted, outputDebug, outputInfo, outputResult} from '@shopify/cli-kit/node/output' vi.mock('./session.js') vi.mock('./stored-session.js', () => ({loadStoredStoreSession: vi.fn()})) @@ -23,7 +24,10 @@ vi.mock('@shopify/cli-kit/node/output', async () => { const actual = await vi.importActual('@shopify/cli-kit/node/output') return { ...actual, + outputCompleted: vi.fn(), outputDebug: vi.fn(), + outputInfo: vi.fn(), + outputResult: vi.fn(), } }) vi.mock('@shopify/cli-kit/node/system', () => ({openURL: vi.fn().mockResolvedValue(true)})) @@ -444,6 +448,32 @@ describe('store auth service', () => { ).toBe(true) }) + test('createStoreAuthPresenter supports json success output', () => { + const presenter = createStoreAuthPresenter('json') + + presenter.success({ + store: 'shop.myshopify.com', + userId: '42', + scopes: ['read_products'], + acquiredAt: '2026-04-02T00:00:00.000Z', + hasRefreshToken: true, + isExpired: false, + }) + + expect(outputResult).toHaveBeenCalledWith(`{ + "store": "shop.myshopify.com", + "userId": "42", + "scopes": [ + "read_products" + ], + "acquiredAt": "2026-04-02T00:00:00.000Z", + "hasRefreshToken": true, + "isExpired": false +}`) + expect(outputCompleted).not.toHaveBeenCalled() + expect(outputInfo).not.toHaveBeenCalled() + }) + test('authenticateStoreWithApp opens the browser and stores the session with refresh token', async () => { const openURL = vi.fn().mockResolvedValue(true) const presenter = { @@ -478,7 +508,15 @@ describe('store auth service', () => { expect(presenter.openingBrowser).toHaveBeenCalledOnce() expect(openURL).toHaveBeenCalledWith(expect.stringContaining('/admin/oauth/authorize?')) expect(presenter.manualAuthUrl).not.toHaveBeenCalled() - expect(presenter.success).toHaveBeenCalledWith('shop.myshopify.com', 'test@example.com') + expect(presenter.success).toHaveBeenCalledWith( + expect.objectContaining({ + store: 'shop.myshopify.com', + userId: '42', + scopes: ['read_products'], + hasRefreshToken: true, + associatedUser: expect.objectContaining({email: 'test@example.com'}), + }), + ) const storedSession = vi.mocked(setStoredStoreAppSession).mock.calls[0]![0] expect(storedSession.store).toBe('shop.myshopify.com') @@ -711,7 +749,14 @@ describe('store auth service', () => { expect(presenter.manualAuthUrl).toHaveBeenCalledWith( expect.stringContaining('https://shop.myshopify.com/admin/oauth/authorize?'), ) - expect(presenter.success).toHaveBeenCalledWith('shop.myshopify.com', 'test@example.com') + expect(presenter.success).toHaveBeenCalledWith( + expect.objectContaining({ + store: 'shop.myshopify.com', + userId: '42', + scopes: ['read_products'], + associatedUser: expect.objectContaining({email: 'test@example.com'}), + }), + ) }) test('authenticateStoreWithApp rejects when Shopify grants fewer scopes than requested', async () => { diff --git a/packages/cli/src/cli/services/store/auth.ts b/packages/cli/src/cli/services/store/auth.ts index 5bdc26f3fd..116f296f80 100644 --- a/packages/cli/src/cli/services/store/auth.ts +++ b/packages/cli/src/cli/services/store/auth.ts @@ -1,5 +1,6 @@ import {DEFAULT_STORE_AUTH_PORT, STORE_AUTH_APP_CLIENT_ID, STORE_AUTH_CALLBACK_PATH, maskToken, storeAuthRedirectUri} from './auth-config.js' import {retryStoreAuthWithPermanentDomainError} from './auth-recovery.js' +import {buildStoreAuthState, type StoreAuthState} from './auth-state.js' import {getStoredStoreAppSession, setStoredStoreAppSession} from './session.js' import type {StoredStoreAppSession} from './session.js' import {loadStoredStoreSession} from './stored-session.js' @@ -7,7 +8,7 @@ import {normalizeStoreFqdn} from '@shopify/cli-kit/node/context/fqdn' import {randomUUID} from '@shopify/cli-kit/node/crypto' import {AbortError} from '@shopify/cli-kit/node/error' import {fetch} from '@shopify/cli-kit/node/http' -import {outputCompleted, outputContent, outputDebug, outputInfo, outputToken} from '@shopify/cli-kit/node/output' +import {outputCompleted, outputContent, outputDebug, outputInfo, outputResult, outputToken} from '@shopify/cli-kit/node/output' import {openURL} from '@shopify/cli-kit/node/system' import {createHash, randomBytes, timingSafeEqual} from 'crypto' import {createServer} from 'http' @@ -440,10 +441,12 @@ export async function exchangeStoreAuthCodeForToken(options: { return parsed } +type StoreAuthOutputFormat = 'text' | 'json' + interface StoreAuthPresenter { openingBrowser: () => void manualAuthUrl: (authorizationUrl: string) => void - success: (store: string, email?: string) => void + success: (result: StoreAuthState) => void } interface StoreAuthDependencies { @@ -454,32 +457,44 @@ interface StoreAuthDependencies { presenter: StoreAuthPresenter } -const defaultStoreAuthPresenter: StoreAuthPresenter = { - openingBrowser() { - outputInfo('Shopify CLI will open the app authorization page in your browser.') - outputInfo('') - }, - manualAuthUrl(authorizationUrl: string) { - outputInfo('Browser did not open automatically. Open this URL manually:') - outputInfo(outputContent`${outputToken.link(authorizationUrl)}`) - outputInfo('') - }, - success(store: string, email?: string) { - const displayName = email ? ` as ${email}` : '' - - outputCompleted('Logged in.') - outputCompleted(`Authenticated${displayName} against ${store}.`) - outputInfo('') - outputInfo('To verify that authentication worked, run:') - outputInfo(`shopify store execute --store ${store} --query 'query { shop { name id } }'`) - }, +function displayAuthenticatedStore(result: StoreAuthState, format: StoreAuthOutputFormat = 'text'): void { + if (format === 'json') { + outputResult(JSON.stringify(result, null, 2)) + return + } + + const email = result.associatedUser?.email + const displayName = email ? ` as ${email}` : '' + + outputCompleted('Logged in.') + outputCompleted(`Authenticated${displayName} against ${result.store}.`) + outputInfo('') + outputInfo('To verify that authentication worked, run:') + outputInfo(`shopify store execute --store ${result.store} --query 'query { shop { name id } }'`) +} + +export function createStoreAuthPresenter(format: StoreAuthOutputFormat = 'text'): StoreAuthPresenter { + return { + openingBrowser() { + outputInfo('Shopify CLI will open the app authorization page in your browser.') + outputInfo('') + }, + manualAuthUrl(authorizationUrl: string) { + outputInfo('Browser did not open automatically. Open this URL manually:') + outputInfo(outputContent`${outputToken.link(authorizationUrl)}`) + outputInfo('') + }, + success(result: StoreAuthState) { + displayAuthenticatedStore(result, format) + }, + } } const defaultStoreAuthDependencies: StoreAuthDependencies = { openURL, waitForStoreAuthCode, exchangeStoreAuthCodeForToken, - presenter: defaultStoreAuthPresenter, + presenter: createStoreAuthPresenter('text'), } function createPkceBootstrap(options: { @@ -520,11 +535,17 @@ function createPkceBootstrap(options: { export async function authenticateStoreWithApp( input: StoreAuthInput, - dependencies: StoreAuthDependencies = defaultStoreAuthDependencies, -): Promise { + dependencies: Partial = {}, +): Promise { + const resolvedDependencies: StoreAuthDependencies = { + ...defaultStoreAuthDependencies, + ...dependencies, + presenter: dependencies.presenter ?? defaultStoreAuthDependencies.presenter, + } + const store = normalizeStoreFqdn(input.store) const requestedScopes = parseStoreAuthScopes(input.scopes) - const existingScopeResolution = await (dependencies.resolveExistingScopes ?? resolveExistingStoreAuthScopes)(store) + const existingScopeResolution = await (resolvedDependencies.resolveExistingScopes ?? resolveExistingStoreAuthScopes)(store) const scopes = mergeRequestedAndStoredScopes(requestedScopes, existingScopeResolution.scopes) const validationScopes = existingScopeResolution.authoritative ? scopes : requestedScopes @@ -534,16 +555,16 @@ export async function authenticateStoreWithApp( ) } - const bootstrap = createPkceBootstrap({store, scopes, exchangeCodeForToken: dependencies.exchangeStoreAuthCodeForToken}) + const bootstrap = createPkceBootstrap({store, scopes, exchangeCodeForToken: resolvedDependencies.exchangeStoreAuthCodeForToken}) const {authorization: {authorizationUrl}} = bootstrap - dependencies.presenter.openingBrowser() + resolvedDependencies.presenter.openingBrowser() - const code = await dependencies.waitForStoreAuthCode({ + const code = await resolvedDependencies.waitForStoreAuthCode({ ...bootstrap.waitForAuthCodeOptions, onListening: async () => { - const opened = await dependencies.openURL(authorizationUrl) - if (!opened) dependencies.presenter.manualAuthUrl(authorizationUrl) + const opened = await resolvedDependencies.openURL(authorizationUrl) + if (!opened) resolvedDependencies.presenter.manualAuthUrl(authorizationUrl) }, }) const tokenResponse = await bootstrap.exchangeCodeForToken(code) @@ -556,7 +577,7 @@ export async function authenticateStoreWithApp( const now = Date.now() const expiresAt = tokenResponse.expires_in ? new Date(now + tokenResponse.expires_in * 1000).toISOString() : undefined - setStoredStoreAppSession({ + const storedSession: StoredStoreAppSession = { store, clientId: STORE_AUTH_APP_CLIENT_ID, userId, @@ -580,11 +601,15 @@ export async function authenticateStoreWithApp( accountOwner: tokenResponse.associated_user.account_owner, } : undefined, - }) + } + + setStoredStoreAppSession(storedSession) outputDebug( outputContent`Session persisted for ${outputToken.raw(store)} (user ${outputToken.raw(userId)}, expires ${outputToken.raw(expiresAt ?? 'unknown')})`, ) - dependencies.presenter.success(store, tokenResponse.associated_user?.email) + const result = buildStoreAuthState(storedSession) + resolvedDependencies.presenter.success(result) + return result } diff --git a/packages/cli/src/cli/services/store/execute-request.test.ts b/packages/cli/src/cli/services/store/execute-request.test.ts index 7146746bce..2f68faf84b 100644 --- a/packages/cli/src/cli/services/store/execute-request.test.ts +++ b/packages/cli/src/cli/services/store/execute-request.test.ts @@ -13,14 +13,12 @@ describe('prepareStoreExecuteRequest', () => { const request = await prepareStoreExecuteRequest({ query: 'query { shop { name } }', variables: '{"id":"gid://shopify/Shop/1"}', - outputFile: '/tmp/result.json', version: '2025-07', }) expect(request).toMatchObject({ query: 'query { shop { name } }', parsedVariables: {id: 'gid://shopify/Shop/1'}, - outputFile: '/tmp/result.json', requestedVersion: '2025-07', }) }) diff --git a/packages/cli/src/cli/services/store/execute-request.ts b/packages/cli/src/cli/services/store/execute-request.ts index 7524b74b87..2e60539268 100644 --- a/packages/cli/src/cli/services/store/execute-request.ts +++ b/packages/cli/src/cli/services/store/execute-request.ts @@ -11,7 +11,6 @@ export interface PreparedStoreExecuteRequest { query: string parsedOperation: ParsedGraphQLOperation parsedVariables?: {[key: string]: unknown} - outputFile?: string requestedVersion?: string } @@ -132,7 +131,6 @@ export async function prepareStoreExecuteRequest(input: { queryFile?: string variables?: string variableFile?: string - outputFile?: string version?: string allowMutations?: boolean }): Promise { @@ -145,7 +143,6 @@ export async function prepareStoreExecuteRequest(input: { query, parsedOperation, parsedVariables, - outputFile: input.outputFile, requestedVersion: input.version, } } diff --git a/packages/cli/src/cli/services/store/execute-result.test.ts b/packages/cli/src/cli/services/store/execute-result.test.ts index 2a370560ac..393b71d955 100644 --- a/packages/cli/src/cli/services/store/execute-result.test.ts +++ b/packages/cli/src/cli/services/store/execute-result.test.ts @@ -31,4 +31,13 @@ describe('writeOrOutputStoreExecuteResult', () => { expect(renderSuccess).toHaveBeenCalledWith({headline: 'Operation succeeded.'}) expect(output.info()).toContain('Test shop') }) + + test('suppresses success rendering in json mode', async () => { + const output = mockAndCaptureOutput() + + await writeOrOutputStoreExecuteResult({data: {shop: {name: 'Test shop'}}}, undefined, 'json') + + expect(renderSuccess).not.toHaveBeenCalled() + expect(output.info()).toContain('Test shop') + }) }) diff --git a/packages/cli/src/cli/services/store/execute-result.ts b/packages/cli/src/cli/services/store/execute-result.ts index dd284a5dea..9869d77518 100644 --- a/packages/cli/src/cli/services/store/execute-result.ts +++ b/packages/cli/src/cli/services/store/execute-result.ts @@ -2,17 +2,27 @@ import {writeFile} from '@shopify/cli-kit/node/fs' import {outputResult} from '@shopify/cli-kit/node/output' import {renderSuccess} from '@shopify/cli-kit/node/ui' -export async function writeOrOutputStoreExecuteResult(result: unknown, outputFile?: string): Promise { +type StoreExecuteOutputFormat = 'text' | 'json' + +export async function writeOrOutputStoreExecuteResult( + result: unknown, + outputFile?: string, + format: StoreExecuteOutputFormat = 'text', +): Promise { const resultString = JSON.stringify(result, null, 2) if (outputFile) { await writeFile(outputFile, resultString) - renderSuccess({ - headline: 'Operation succeeded.', - body: `Results written to ${outputFile}`, - }) + if (format === 'text') { + renderSuccess({ + headline: 'Operation succeeded.', + body: `Results written to ${outputFile}`, + }) + } } else { - renderSuccess({headline: 'Operation succeeded.'}) + if (format === 'text') { + renderSuccess({headline: 'Operation succeeded.'}) + } outputResult(resultString) } } diff --git a/packages/cli/src/cli/services/store/execute.test.ts b/packages/cli/src/cli/services/store/execute.test.ts index e9ab127693..b0f935e8ac 100644 --- a/packages/cli/src/cli/services/store/execute.test.ts +++ b/packages/cli/src/cli/services/store/execute.test.ts @@ -1,12 +1,11 @@ -import {describe, test, expect, vi, beforeEach, afterEach} from 'vitest' +import {describe, test, expect, vi, beforeEach} from 'vitest' import {executeStoreOperation} from './execute.js' import {getStoredStoreAppSession} from './session.js' import {STORE_AUTH_APP_CLIENT_ID} from './auth-config.js' import {fetchApiVersions, adminUrl} from '@shopify/cli-kit/node/api/admin' import {graphqlRequest} from '@shopify/cli-kit/node/api/graphql' -import {renderSingleTask, renderSuccess} from '@shopify/cli-kit/node/ui' +import {renderSingleTask} from '@shopify/cli-kit/node/ui' import {fileExists, readFile, writeFile} from '@shopify/cli-kit/node/fs' -import {mockAndCaptureOutput} from '@shopify/cli-kit/node/testing/output' vi.mock('./session.js') vi.mock('@shopify/cli-kit/node/api/graphql') @@ -45,15 +44,10 @@ describe('executeStoreOperation', () => { vi.mocked(renderSingleTask).mockImplementation(async ({task}) => task(() => {})) }) - afterEach(() => { - mockAndCaptureOutput().clear() - }) - - test('executes a query successfully', async () => { + test('executes a query successfully and returns the GraphQL result', async () => { vi.mocked(graphqlRequest).mockResolvedValue({data: {shop: {name: 'Test shop'}}}) - const output = mockAndCaptureOutput() - await executeStoreOperation({ + const result = await executeStoreOperation({ store, query: 'query { shop { name } }', }) @@ -73,8 +67,8 @@ describe('executeStoreOperation', () => { variables: undefined, responseOptions: {handleErrors: false}, }) - expect(output.info()).toContain('"name": "Test shop"') - expect(renderSuccess).toHaveBeenCalledWith({headline: 'Operation succeeded.'}) + expect(result).toEqual({data: {shop: {name: 'Test shop'}}}) + expect(writeFile).not.toHaveBeenCalled() }) test('passes parsed variables when provided inline', async () => { @@ -170,20 +164,15 @@ describe('executeStoreOperation', () => { expect(adminUrl).toHaveBeenCalledWith(store, '2025-07', session) }) - test('writes results to a file when outputFile is provided', async () => { + test('does not write output as part of the execution service', async () => { vi.mocked(graphqlRequest).mockResolvedValue({data: {shop: {name: 'Test shop'}}}) await executeStoreOperation({ store, query: 'query { shop { name } }', - outputFile: '/tmp/results.json', }) - expect(writeFile).toHaveBeenCalledWith('/tmp/results.json', expect.stringContaining('Test shop')) - expect(renderSuccess).toHaveBeenCalledWith({ - headline: 'Operation succeeded.', - body: 'Results written to /tmp/results.json', - }) + expect(writeFile).not.toHaveBeenCalled() }) test('throws when stored auth is no longer valid', async () => { diff --git a/packages/cli/src/cli/services/store/execute.ts b/packages/cli/src/cli/services/store/execute.ts index 899bf5a662..f803d5e28c 100644 --- a/packages/cli/src/cli/services/store/execute.ts +++ b/packages/cli/src/cli/services/store/execute.ts @@ -1,7 +1,6 @@ import {renderSingleTask} from '@shopify/cli-kit/node/ui' import {outputContent} from '@shopify/cli-kit/node/output' import {prepareStoreExecuteRequest} from './execute-request.js' -import {writeOrOutputStoreExecuteResult} from './execute-result.js' import {getStoreGraphQLTarget, StoreGraphQLApi} from './graphql-targets.js' interface ExecuteStoreOperationInput { @@ -11,12 +10,11 @@ interface ExecuteStoreOperationInput { queryFile?: string variables?: string variableFile?: string - outputFile?: string version?: string allowMutations?: boolean } -export async function executeStoreOperation(input: ExecuteStoreOperationInput): Promise { +export async function executeStoreOperation(input: ExecuteStoreOperationInput): Promise { const target = getStoreGraphQLTarget(input.api ?? 'admin') const request = await prepareStoreExecuteRequest({ @@ -24,7 +22,6 @@ export async function executeStoreOperation(input: ExecuteStoreOperationInput): queryFile: input.queryFile, variables: input.variables, variableFile: input.variableFile, - outputFile: input.outputFile, version: input.version, allowMutations: input.allowMutations, }) @@ -35,11 +32,9 @@ export async function executeStoreOperation(input: ExecuteStoreOperationInput): renderOptions: {stdout: process.stderr}, }) - const result = await target.execute({ + return await target.execute({ store: input.store, context, request, }) - - await writeOrOutputStoreExecuteResult(result, request.outputFile) }