diff --git a/docs-shopify.dev/commands/interfaces/store-auth.interface.ts b/docs-shopify.dev/commands/interfaces/store-auth.interface.ts index e38a6d147e8..66957c80ac8 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 3bff68a8f95..39550404db6 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/docs-shopify.dev/generated/generated_docs_data.json b/docs-shopify.dev/generated/generated_docs_data.json index 75162409d11..caac62cb2d3 100644 --- a/docs-shopify.dev/generated/generated_docs_data.json +++ b/docs-shopify.dev/generated/generated_docs_data.json @@ -5829,6 +5829,15 @@ "isOptional": true, "environmentValue": "SHOPIFY_FLAG_VERBOSE" }, + { + "filePath": "docs-shopify.dev/commands/interfaces/store-auth.interface.ts", + "syntaxKind": "PropertySignature", + "name": "-j, --json", + "value": "\"\"", + "description": "Output the result as JSON. Automatically disables color output.", + "isOptional": true, + "environmentValue": "SHOPIFY_FLAG_JSON" + }, { "filePath": "docs-shopify.dev/commands/interfaces/store-auth.interface.ts", "syntaxKind": "PropertySignature", @@ -5838,7 +5847,7 @@ "environmentValue": "SHOPIFY_FLAG_STORE" } ], - "value": "export interface storeauth {\n /**\n * Disable color output.\n * @environment SHOPIFY_FLAG_NO_COLOR\n */\n '--no-color'?: ''\n\n /**\n * Comma-separated Admin API scopes to request for the app.\n * @environment SHOPIFY_FLAG_SCOPES\n */\n '--scopes ': string\n\n /**\n * The myshopify.com domain of the store to authenticate against.\n * @environment SHOPIFY_FLAG_STORE\n */\n '-s, --store ': string\n\n /**\n * Increase the verbosity of the output.\n * @environment SHOPIFY_FLAG_VERBOSE\n */\n '--verbose'?: ''\n}" + "value": "export interface storeauth {\n /**\n * Output the result as JSON. Automatically disables color output.\n * @environment SHOPIFY_FLAG_JSON\n */\n '-j, --json'?: ''\n\n /**\n * Disable color output.\n * @environment SHOPIFY_FLAG_NO_COLOR\n */\n '--no-color'?: ''\n\n /**\n * Comma-separated Admin API scopes to request for the app.\n * @environment SHOPIFY_FLAG_SCOPES\n */\n '--scopes ': string\n\n /**\n * The myshopify.com domain of the store to authenticate against.\n * @environment SHOPIFY_FLAG_STORE\n */\n '-s, --store ': string\n\n /**\n * Increase the verbosity of the output.\n * @environment SHOPIFY_FLAG_VERBOSE\n */\n '--verbose'?: ''\n}" } } } @@ -5938,6 +5947,15 @@ "isOptional": true, "environmentValue": "SHOPIFY_FLAG_VERSION" }, + { + "filePath": "docs-shopify.dev/commands/interfaces/store-execute.interface.ts", + "syntaxKind": "PropertySignature", + "name": "-j, --json", + "value": "\"\"", + "description": "Output the result as JSON. Automatically disables color output.", + "isOptional": true, + "environmentValue": "SHOPIFY_FLAG_JSON" + }, { "filePath": "docs-shopify.dev/commands/interfaces/store-execute.interface.ts", "syntaxKind": "PropertySignature", @@ -5965,7 +5983,7 @@ "environmentValue": "SHOPIFY_FLAG_VARIABLES" } ], - "value": "export interface storeexecute {\n /**\n * Allow GraphQL mutations to run against the target store.\n * @environment SHOPIFY_FLAG_ALLOW_MUTATIONS\n */\n '--allow-mutations'?: ''\n\n /**\n * Disable color output.\n * @environment SHOPIFY_FLAG_NO_COLOR\n */\n '--no-color'?: ''\n\n /**\n * The file name where results should be written, instead of STDOUT.\n * @environment SHOPIFY_FLAG_OUTPUT_FILE\n */\n '--output-file '?: string\n\n /**\n * The GraphQL query or mutation, as a string.\n * @environment SHOPIFY_FLAG_QUERY\n */\n '-q, --query '?: string\n\n /**\n * Path to a file containing the GraphQL query or mutation. Can't be used with --query.\n * @environment SHOPIFY_FLAG_QUERY_FILE\n */\n '--query-file '?: string\n\n /**\n * The myshopify.com domain of the store to execute against.\n * @environment SHOPIFY_FLAG_STORE\n */\n '-s, --store ': string\n\n /**\n * Path to a file containing GraphQL variables in JSON format. Can't be used with --variables.\n * @environment SHOPIFY_FLAG_VARIABLE_FILE\n */\n '--variable-file '?: string\n\n /**\n * The values for any GraphQL variables in your query or mutation, in JSON format.\n * @environment SHOPIFY_FLAG_VARIABLES\n */\n '-v, --variables '?: string\n\n /**\n * Increase the verbosity of the output.\n * @environment SHOPIFY_FLAG_VERBOSE\n */\n '--verbose'?: ''\n\n /**\n * The API version to use for the query or mutation. Defaults to the latest stable version.\n * @environment SHOPIFY_FLAG_VERSION\n */\n '--version '?: string\n}" + "value": "export interface storeexecute {\n /**\n * Allow GraphQL mutations to run against the target store.\n * @environment SHOPIFY_FLAG_ALLOW_MUTATIONS\n */\n '--allow-mutations'?: ''\n\n /**\n * Output the result as JSON. Automatically disables color output.\n * @environment SHOPIFY_FLAG_JSON\n */\n '-j, --json'?: ''\n\n /**\n * Disable color output.\n * @environment SHOPIFY_FLAG_NO_COLOR\n */\n '--no-color'?: ''\n\n /**\n * The file name where results should be written, instead of STDOUT.\n * @environment SHOPIFY_FLAG_OUTPUT_FILE\n */\n '--output-file '?: string\n\n /**\n * The GraphQL query or mutation, as a string.\n * @environment SHOPIFY_FLAG_QUERY\n */\n '-q, --query '?: string\n\n /**\n * Path to a file containing the GraphQL query or mutation. Can't be used with --query.\n * @environment SHOPIFY_FLAG_QUERY_FILE\n */\n '--query-file '?: string\n\n /**\n * The myshopify.com domain of the store to execute against.\n * @environment SHOPIFY_FLAG_STORE\n */\n '-s, --store ': string\n\n /**\n * Path to a file containing GraphQL variables in JSON format. Can't be used with --variables.\n * @environment SHOPIFY_FLAG_VARIABLE_FILE\n */\n '--variable-file '?: string\n\n /**\n * The values for any GraphQL variables in your query or mutation, in JSON format.\n * @environment SHOPIFY_FLAG_VARIABLES\n */\n '-v, --variables '?: string\n\n /**\n * Increase the verbosity of the output.\n * @environment SHOPIFY_FLAG_VERBOSE\n */\n '--verbose'?: ''\n\n /**\n * The API version to use for the query or mutation. Defaults to the latest stable version.\n * @environment SHOPIFY_FLAG_VERSION\n */\n '--version '?: string\n}" } } } diff --git a/packages/cli/README.md b/packages/cli/README.md index ce2d7756c14..9a25dfeb007 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -2057,9 +2057,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. @@ -2076,6 +2077,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 execute` @@ -2084,10 +2087,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. @@ -2121,6 +2125,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 b9be73bdcd4..82dbca3b896 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.", "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.", @@ -5803,7 +5813,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": { @@ -5813,6 +5824,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.test.ts b/packages/cli/src/cli/commands/store/auth.test.ts index 21701dff48b..d3b5276b1ba 100644 --- a/packages/cli/src/cli/commands/store/auth.test.ts +++ b/packages/cli/src/cli/commands/store/auth.test.ts @@ -1,8 +1,12 @@ -import {describe, test, expect, vi, beforeEach} from 'vitest' +import {beforeEach, describe, expect, test, vi} from 'vitest' import StoreAuth from './auth.js' import {authenticateStoreWithApp} from '../../services/store/auth/index.js' +import {createStoreAuthPresenter} from '../../services/store/auth/result.js' vi.mock('../../services/store/auth/index.js') +vi.mock('../../services/store/auth/result.js', () => ({ + createStoreAuthPresenter: vi.fn((format: 'text' | 'json') => ({format})), +})) describe('store auth command', () => { beforeEach(() => { @@ -12,15 +16,33 @@ describe('store auth command', () => { 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(createStoreAuthPresenter).toHaveBeenCalledWith('text') + expect(authenticateStoreWithApp).toHaveBeenCalledWith( + { + store: 'shop.myshopify.com', + scopes: 'read_products,write_products', + }, + {presenter: {format: 'text'}}, + ) + }) + + test('passes a json presenter when --json is provided', async () => { + await StoreAuth.run(['--store', 'shop.myshopify.com', '--scopes', 'read_products', '--json']) + + expect(createStoreAuthPresenter).toHaveBeenCalledWith('json') + expect(authenticateStoreWithApp).toHaveBeenCalledWith( + { + store: 'shop.myshopify.com', + scopes: 'read_products', + }, + {presenter: {format: '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.ts b/packages/cli/src/cli/commands/store/auth.ts index 6b46ece1257..a42d4dc327e 100644 --- a/packages/cli/src/cli/commands/store/auth.ts +++ b/packages/cli/src/cli/commands/store/auth.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 {Flags} from '@oclif/core' import {authenticateStoreWithApp} from '../../services/store/auth/index.js' +import {createStoreAuthPresenter} from '../../services/store/auth/result.js' export default class StoreAuth extends Command { static summary = 'Authenticate an app against a store for store commands.' @@ -13,10 +14,14 @@ Re-run this command if the stored token is missing, expires, or no longer has th 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.', @@ -34,9 +39,14 @@ Re-run this command if the stored token is missing, expires, or no longer has th 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/execute.test.ts b/packages/cli/src/cli/commands/store/execute.test.ts index 4d7bd1e2ba7..ffd339ac323 100644 --- a/packages/cli/src/cli/commands/store/execute.test.ts +++ b/packages/cli/src/cli/commands/store/execute.test.ts @@ -1,15 +1,18 @@ -import {describe, test, expect, vi, beforeEach} from 'vitest' +import {beforeEach, describe, expect, test, vi} from 'vitest' import StoreExecute from './execute.js' import {executeStoreOperation} from '../../services/store/execute/index.js' +import {writeOrOutputStoreExecuteResult} from '../../services/store/execute/result.js' vi.mock('../../services/store/execute/index.js') +vi.mock('../../services/store/execute/result.js') 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 () => { + test('passes the inline query through to the service and writes the result', async () => { await StoreExecute.run(['--store', 'shop.myshopify.com', '--query', 'query { shop { name } }']) expect(executeStoreOperation).toHaveBeenCalledWith({ @@ -18,10 +21,14 @@ 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('passes the query file through to the service', async () => { @@ -36,6 +43,16 @@ describe('store execute command', () => { ) }) + test('writes json output when --json is provided', async () => { + await StoreExecute.run(['--store', 'shop.myshopify.com', '--query', 'query { shop { name } }', '--json']) + + expect(writeOrOutputStoreExecuteResult).toHaveBeenCalledWith( + {data: {shop: {name: 'Test shop'}}}, + undefined, + 'json', + ) + }) + test('defines the expected flags', () => { expect(StoreExecute.flags.store).toBeDefined() expect(StoreExecute.flags.query).toBeDefined() @@ -43,5 +60,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 bffc3e21ec4..a184d6d3d8a 100644 --- a/packages/cli/src/cli/commands/store/execute.ts +++ b/packages/cli/src/cli/commands/store/execute.ts @@ -1,9 +1,10 @@ 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 {executeStoreOperation} from '../../services/store/execute/index.js' +import {writeOrOutputStoreExecuteResult} from '../../services/store/execute/result.js' export default class StoreExecute extends Command { static summary = 'Execute GraphQL queries and mutations on a store.' @@ -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/index.test.ts b/packages/cli/src/cli/services/store/auth/index.test.ts index 3d438c29770..2a4323cb4e4 100644 --- a/packages/cli/src/cli/services/store/auth/index.test.ts +++ b/packages/cli/src/cli/services/store/auth/index.test.ts @@ -16,7 +16,7 @@ describe('store auth service', () => { vi.restoreAllMocks() }) - test('authenticateStoreWithApp opens the browser and stores the session with refresh token', async () => { + test('authenticateStoreWithApp opens the browser, stores the session, and returns auth result', async () => { const openURL = vi.fn().mockResolvedValue(true) const presenter = { openingBrowser: vi.fn(), @@ -28,7 +28,7 @@ describe('store auth service', () => { return 'abc123' }) - await authenticateStoreWithApp( + const result = await authenticateStoreWithApp( { store: 'shop.myshopify.com', scopes: 'read_products', @@ -50,7 +50,16 @@ 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(result).toEqual( + expect.objectContaining({ + store: 'shop.myshopify.com', + userId: '42', + scopes: ['read_products'], + hasRefreshToken: true, + associatedUser: expect.objectContaining({email: 'test@example.com'}), + }), + ) + expect(presenter.success).toHaveBeenCalledWith(result) const storedSession = vi.mocked(setStoredStoreAppSession).mock.calls[0]![0] expect(storedSession.store).toBe('shop.myshopify.com') @@ -239,7 +248,7 @@ describe('store auth service', () => { return 'abc123' }) - await authenticateStoreWithApp( + const result = await authenticateStoreWithApp( { store: 'shop.myshopify.com', scopes: 'read_products', @@ -261,7 +270,7 @@ 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(result) }) test('authenticateStoreWithApp rejects when Shopify grants fewer scopes than requested', async () => { diff --git a/packages/cli/src/cli/services/store/auth/index.ts b/packages/cli/src/cli/services/store/auth/index.ts index d96e287f364..eabd3bf3b37 100644 --- a/packages/cli/src/cli/services/store/auth/index.ts +++ b/packages/cli/src/cli/services/store/auth/index.ts @@ -1,6 +1,6 @@ import {normalizeStoreFqdn} from '@shopify/cli-kit/node/context/fqdn' import {AbortError} from '@shopify/cli-kit/node/error' -import {outputCompleted, outputContent, outputDebug, outputInfo, outputToken} from '@shopify/cli-kit/node/output' +import {outputContent, outputDebug, outputToken} from '@shopify/cli-kit/node/output' import {openURL} from '@shopify/cli-kit/node/system' import {STORE_AUTH_APP_CLIENT_ID} from './config.js' import {setStoredStoreAppSession} from './session-store.js' @@ -9,18 +9,13 @@ import {waitForStoreAuthCode} from './callback.js' import {createPkceBootstrap} from './pkce.js' import {mergeRequestedAndStoredScopes, parseStoreAuthScopes, resolveGrantedScopes} from './scopes.js' import {resolveExistingStoreAuthScopes, type ResolvedStoreAuthScopes} from './existing-scopes.js' +import {createStoreAuthPresenter, type StoreAuthPresenter, type StoreAuthResult} from './result.js' interface StoreAuthInput { store: string scopes: string } -interface StoreAuthPresenter { - openingBrowser: () => void - manualAuthUrl: (authorizationUrl: string) => void - success: (store: string, email?: string) => void -} - interface StoreAuthDependencies { openURL: typeof openURL waitForStoreAuthCode: typeof waitForStoreAuthCode @@ -29,39 +24,18 @@ 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 } }'`) - }, -} - const defaultStoreAuthDependencies: StoreAuthDependencies = { openURL, waitForStoreAuthCode, exchangeStoreAuthCodeForToken, resolveExistingScopes: resolveExistingStoreAuthScopes, - presenter: defaultStoreAuthPresenter, + presenter: createStoreAuthPresenter('text'), } export async function authenticateStoreWithApp( input: StoreAuthInput, dependencies: Partial = {}, -): Promise { +): Promise { const resolvedDependencies: StoreAuthDependencies = {...defaultStoreAuthDependencies, ...dependencies} const store = normalizeStoreFqdn(input.store) const requestedScopes = parseStoreAuthScopes(input.scopes) @@ -103,18 +77,16 @@ export async function authenticateStoreWithApp( const now = Date.now() const expiresAt = tokenResponse.expires_in ? new Date(now + tokenResponse.expires_in * 1000).toISOString() : undefined - setStoredStoreAppSession({ + const result: StoreAuthResult = { store, - clientId: STORE_AUTH_APP_CLIENT_ID, userId, - accessToken: tokenResponse.access_token, - refreshToken: tokenResponse.refresh_token, scopes: resolveGrantedScopes(tokenResponse, validationScopes), acquiredAt: new Date(now).toISOString(), expiresAt, refreshTokenExpiresAt: tokenResponse.refresh_token_expires_in ? new Date(now + tokenResponse.refresh_token_expires_in * 1000).toISOString() : undefined, + hasRefreshToken: !!tokenResponse.refresh_token, associatedUser: tokenResponse.associated_user ? { id: tokenResponse.associated_user.id, @@ -124,11 +96,25 @@ export async function authenticateStoreWithApp( accountOwner: tokenResponse.associated_user.account_owner, } : undefined, + } + + setStoredStoreAppSession({ + store, + clientId: STORE_AUTH_APP_CLIENT_ID, + userId, + accessToken: tokenResponse.access_token, + refreshToken: tokenResponse.refresh_token, + scopes: result.scopes, + acquiredAt: result.acquiredAt, + expiresAt, + refreshTokenExpiresAt: result.refreshTokenExpiresAt, + associatedUser: result.associatedUser, }) outputDebug( outputContent`Session persisted for ${outputToken.raw(store)} (user ${outputToken.raw(userId)}, expires ${outputToken.raw(expiresAt ?? 'unknown')})`, ) - resolvedDependencies.presenter.success(store, tokenResponse.associated_user?.email) + resolvedDependencies.presenter.success(result) + return result } diff --git a/packages/cli/src/cli/services/store/auth/result.test.ts b/packages/cli/src/cli/services/store/auth/result.test.ts new file mode 100644 index 00000000000..83c1e9e9280 --- /dev/null +++ b/packages/cli/src/cli/services/store/auth/result.test.ts @@ -0,0 +1,102 @@ +import {afterEach, beforeEach, describe, expect, test, vi} from 'vitest' +import {mockAndCaptureOutput} from '@shopify/cli-kit/node/testing/output' +import {createStoreAuthPresenter} from './result.js' + +function captureStandardStreams() { + const stdout: string[] = [] + const stderr: string[] = [] + + const stdoutSpy = vi.spyOn(process.stdout, 'write').mockImplementation(((chunk: string | Uint8Array) => { + stdout.push(typeof chunk === 'string' ? chunk : Buffer.from(chunk).toString('utf8')) + return true + }) as typeof process.stdout.write) + const stderrSpy = vi.spyOn(process.stderr, 'write').mockImplementation(((chunk: string | Uint8Array) => { + stderr.push(typeof chunk === 'string' ? chunk : Buffer.from(chunk).toString('utf8')) + return true + }) as typeof process.stderr.write) + + return { + stdout: () => stdout.join(''), + stderr: () => stderr.join(''), + restore: () => { + stdoutSpy.mockRestore() + stderrSpy.mockRestore() + }, + } +} + +describe('store auth presenter', () => { + const originalUnitTestEnv = process.env.SHOPIFY_UNIT_TEST + + beforeEach(() => { + mockAndCaptureOutput().clear() + }) + + afterEach(() => { + process.env.SHOPIFY_UNIT_TEST = originalUnitTestEnv + }) + + test('renders human success output in text mode', () => { + const output = mockAndCaptureOutput() + const presenter = createStoreAuthPresenter('text') + + presenter.success({ + store: 'shop.myshopify.com', + userId: '42', + scopes: ['read_products'], + acquiredAt: '2026-04-02T00:00:00.000Z', + hasRefreshToken: true, + associatedUser: {id: 42, email: 'merchant@example.com'}, + }) + + expect(output.completed()).toContain('Logged in.') + expect(output.completed()).toContain('Authenticated as merchant@example.com against shop.myshopify.com.') + expect(output.info()).toContain("shopify store execute --store shop.myshopify.com --query 'query { shop { name id } }'") + expect(output.output()).not.toContain('"store": "shop.myshopify.com"') + }) + + test('writes json success output through the result channel', () => { + const output = mockAndCaptureOutput() + const presenter = createStoreAuthPresenter('json') + + presenter.success({ + store: 'shop.myshopify.com', + userId: '42', + scopes: ['read_products'], + acquiredAt: '2026-04-02T00:00:00.000Z', + hasRefreshToken: true, + associatedUser: {id: 42, email: 'merchant@example.com'}, + }) + + expect(output.output()).toContain('"store": "shop.myshopify.com"') + expect(output.completed()).not.toContain('Authenticated') + expect(output.info()).not.toContain('shopify store execute') + }) + + test('writes browser guidance to stderr and json success to stdout', () => { + process.env.SHOPIFY_UNIT_TEST = 'false' + const streams = captureStandardStreams() + const presenter = createStoreAuthPresenter('json') + + try { + presenter.openingBrowser() + presenter.manualAuthUrl('https://shop.myshopify.com/admin/oauth/authorize?client_id=test') + presenter.success({ + store: 'shop.myshopify.com', + userId: '42', + scopes: ['read_products'], + acquiredAt: '2026-04-02T00:00:00.000Z', + hasRefreshToken: true, + associatedUser: {id: 42, email: 'merchant@example.com'}, + }) + } finally { + streams.restore() + } + + expect(streams.stderr()).toContain('Shopify CLI will open the app authorization page in your browser.') + expect(streams.stderr()).toContain('Browser did not open automatically. Open this URL manually:') + expect(streams.stderr()).toContain('https://shop.myshopify.com/admin/oauth/authorize?client_id=test') + expect(streams.stdout()).toContain('"store": "shop.myshopify.com"') + expect(streams.stdout()).not.toContain('Authenticated') + }) +}) diff --git a/packages/cli/src/cli/services/store/auth/result.ts b/packages/cli/src/cli/services/store/auth/result.ts new file mode 100644 index 00000000000..4b7eec807e2 --- /dev/null +++ b/packages/cli/src/cli/services/store/auth/result.ts @@ -0,0 +1,71 @@ +import {outputCompleted, outputInfo, outputResult, outputToken, outputContent} from '@shopify/cli-kit/node/output' + +export interface StoreAuthResult { + store: string + userId: string + scopes: string[] + acquiredAt: string + expiresAt?: string + refreshTokenExpiresAt?: string + hasRefreshToken: boolean + associatedUser?: { + id: number + email?: string + firstName?: string + lastName?: string + accountOwner?: boolean + } +} + +type StoreAuthOutputFormat = 'text' | 'json' + +export interface StoreAuthPresenter { + openingBrowser: () => void + manualAuthUrl: (authorizationUrl: string) => void + success: (result: StoreAuthResult) => void +} + +function serializeStoreAuthResult(result: StoreAuthResult): string { + return JSON.stringify(result, null, 2) +} + +function buildStoreAuthSuccessText(result: StoreAuthResult): {completed: string[]; info: string[]} { + const displayName = result.associatedUser?.email ? ` as ${result.associatedUser.email}` : '' + + return { + completed: ['Logged in.', `Authenticated${displayName} against ${result.store}.`], + info: ['', 'To verify that authentication worked, run:', `shopify store execute --store ${result.store} --query 'query { shop { name id } }'`], + } +} + +function displayStoreAuthOpeningBrowser(): void { + outputInfo('Shopify CLI will open the app authorization page in your browser.') + outputInfo('') +} + +function displayStoreAuthManualAuthUrl(authorizationUrl: string): void { + outputInfo('Browser did not open automatically. Open this URL manually:') + outputInfo(outputContent`${outputToken.link(authorizationUrl)}`) + outputInfo('') +} + +function displayStoreAuthResult(result: StoreAuthResult, format: StoreAuthOutputFormat = 'text'): void { + if (format === 'json') { + outputResult(serializeStoreAuthResult(result)) + return + } + + const text = buildStoreAuthSuccessText(result) + text.completed.forEach((line) => outputCompleted(line)) + text.info.forEach((line) => outputInfo(line)) +} + +export function createStoreAuthPresenter(format: StoreAuthOutputFormat = 'text'): StoreAuthPresenter { + return { + openingBrowser: displayStoreAuthOpeningBrowser, + manualAuthUrl: displayStoreAuthManualAuthUrl, + success(result: StoreAuthResult) { + displayStoreAuthResult(result, format) + }, + } +} diff --git a/packages/cli/src/cli/services/store/execute/index.test.ts b/packages/cli/src/cli/services/store/execute/index.test.ts index 5c69b6fa0c3..f29b1aed02a 100644 --- a/packages/cli/src/cli/services/store/execute/index.test.ts +++ b/packages/cli/src/cli/services/store/execute/index.test.ts @@ -2,12 +2,10 @@ import {afterEach, beforeEach, describe, expect, test, vi} from 'vitest' import {renderSingleTask} from '@shopify/cli-kit/node/ui' import {executeStoreOperation} from './index.js' import {prepareStoreExecuteRequest} from './request.js' -import {writeOrOutputStoreExecuteResult} from './result.js' import {getStoreGraphQLTarget} from './targets.js' import {mockAndCaptureOutput} from '@shopify/cli-kit/node/testing/output' vi.mock('./request.js') -vi.mock('./result.js') vi.mock('./targets.js') vi.mock('@shopify/cli-kit/node/ui') @@ -17,7 +15,6 @@ describe('executeStoreOperation', () => { parsedOperation: {operationDefinition: {operation: 'query'}}, parsedVariables: {id: 'gid://shopify/Shop/1'}, requestedVersion: '2025-10', - outputFile: '/tmp/result.json', } as any const context = {kind: 'admin-context'} as any const result = {data: {shop: {name: 'Test shop'}}} @@ -39,14 +36,15 @@ describe('executeStoreOperation', () => { mockAndCaptureOutput().clear() }) - test('prepares the request, loads context, executes the target, and writes the result', async () => { - await executeStoreOperation({ - store: 'shop.myshopify.com', - query: 'query { shop { name } }', - variables: '{"id":"gid://shopify/Shop/1"}', - outputFile: '/tmp/result.json', - version: '2025-10', - }) + test('prepares the request, loads context, and returns the execution result', async () => { + await expect( + executeStoreOperation({ + store: 'shop.myshopify.com', + query: 'query { shop { name } }', + variables: '{"id":"gid://shopify/Shop/1"}', + version: '2025-10', + }), + ).resolves.toEqual(result) expect(getStoreGraphQLTarget).toHaveBeenCalledWith('admin') expect(prepareStoreExecuteRequest).toHaveBeenCalledWith({ @@ -54,7 +52,6 @@ describe('executeStoreOperation', () => { queryFile: undefined, variables: '{"id":"gid://shopify/Shop/1"}', variableFile: undefined, - outputFile: '/tmp/result.json', version: '2025-10', allowMutations: undefined, }) @@ -63,7 +60,6 @@ describe('executeStoreOperation', () => { requestedVersion: '2025-10', }) expect(target.execute).toHaveBeenCalledWith({context, request}) - expect(writeOrOutputStoreExecuteResult).toHaveBeenCalledWith(result, '/tmp/result.json') }) test('defaults to the admin target', async () => { diff --git a/packages/cli/src/cli/services/store/execute/index.ts b/packages/cli/src/cli/services/store/execute/index.ts index a25556e2de9..1394a37d262 100644 --- a/packages/cli/src/cli/services/store/execute/index.ts +++ b/packages/cli/src/cli/services/store/execute/index.ts @@ -1,7 +1,6 @@ import {renderSingleTask} from '@shopify/cli-kit/node/ui' import {outputContent} from '@shopify/cli-kit/node/output' import {prepareStoreExecuteRequest} from './request.js' -import {writeOrOutputStoreExecuteResult} from './result.js' import {getStoreGraphQLTarget, type StoreGraphQLApi} from './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,7 +32,5 @@ export async function executeStoreOperation(input: ExecuteStoreOperationInput): renderOptions: {stdout: process.stderr}, }) - const result = await target.execute({context, request}) - - await writeOrOutputStoreExecuteResult(result, request.outputFile) + return await target.execute({context, request}) } 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 df625bb994f..ce995fa9919 100644 --- a/packages/cli/src/cli/services/store/execute/request.test.ts +++ b/packages/cli/src/cli/services/store/execute/request.test.ts @@ -13,16 +13,15 @@ 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', }) + expect(request).not.toHaveProperty('outputFile') }) test('reads the query from a file', async () => { diff --git a/packages/cli/src/cli/services/store/execute/request.ts b/packages/cli/src/cli/services/store/execute/request.ts index 7524b74b87d..2e605392682 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 bbde05a73cf..083e5329019 100644 --- a/packages/cli/src/cli/services/store/execute/result.test.ts +++ b/packages/cli/src/cli/services/store/execute/result.test.ts @@ -1,4 +1,4 @@ -import {beforeEach, describe, expect, test, vi} from 'vitest' +import {afterEach, beforeEach, describe, expect, test, vi} from 'vitest' import {writeFile} from '@shopify/cli-kit/node/fs' import {renderSuccess} from '@shopify/cli-kit/node/ui' import {mockAndCaptureOutput} from '@shopify/cli-kit/node/testing/output' @@ -7,12 +7,41 @@ import {writeOrOutputStoreExecuteResult} from './result.js' vi.mock('@shopify/cli-kit/node/fs') vi.mock('@shopify/cli-kit/node/ui') +function captureStandardStreams() { + const stdout: string[] = [] + const stderr: string[] = [] + + const stdoutSpy = vi.spyOn(process.stdout, 'write').mockImplementation(((chunk: string | Uint8Array) => { + stdout.push(typeof chunk === 'string' ? chunk : Buffer.from(chunk).toString('utf8')) + return true + }) as typeof process.stdout.write) + const stderrSpy = vi.spyOn(process.stderr, 'write').mockImplementation(((chunk: string | Uint8Array) => { + stderr.push(typeof chunk === 'string' ? chunk : Buffer.from(chunk).toString('utf8')) + return true + }) as typeof process.stderr.write) + + return { + stdout: () => stdout.join(''), + stderr: () => stderr.join(''), + restore: () => { + stdoutSpy.mockRestore() + stderrSpy.mockRestore() + }, + } +} + describe('writeOrOutputStoreExecuteResult', () => { + const originalUnitTestEnv = process.env.SHOPIFY_UNIT_TEST + beforeEach(() => { vi.clearAllMocks() mockAndCaptureOutput().clear() }) + afterEach(() => { + process.env.SHOPIFY_UNIT_TEST = originalUnitTestEnv + }) + test('writes results to a file when outputFile is provided', async () => { await writeOrOutputStoreExecuteResult({data: {shop: {name: 'Test shop'}}}, '/tmp/results.json') @@ -29,6 +58,36 @@ describe('writeOrOutputStoreExecuteResult', () => { await writeOrOutputStoreExecuteResult({data: {shop: {name: 'Test shop'}}}) expect(renderSuccess).toHaveBeenCalledWith({headline: 'Operation succeeded.'}) - expect(output.info()).toContain('Test shop') + expect(output.output()).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.output()).toContain('Test shop') + }) + + test('writes json results to stdout without writing to stderr', async () => { + process.env.SHOPIFY_UNIT_TEST = 'false' + const streams = captureStandardStreams() + + try { + await writeOrOutputStoreExecuteResult({data: {shop: {name: 'Test shop'}}}, undefined, 'json') + } finally { + streams.restore() + } + + expect(streams.stdout()).toContain('"name": "Test shop"') + expect(streams.stderr()).toBe('') + }) + + test('suppresses success rendering when writing a file in json mode', async () => { + await writeOrOutputStoreExecuteResult({data: {shop: {name: 'Test shop'}}}, '/tmp/results.json', 'json') + + expect(writeFile).toHaveBeenCalledWith('/tmp/results.json', expect.stringContaining('Test shop')) + expect(renderSuccess).not.toHaveBeenCalled() }) }) diff --git a/packages/cli/src/cli/services/store/execute/result.ts b/packages/cli/src/cli/services/store/execute/result.ts index dd284a5deae..47e71bd678f 100644 --- a/packages/cli/src/cli/services/store/execute/result.ts +++ b/packages/cli/src/cli/services/store/execute/result.ts @@ -2,17 +2,37 @@ 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 { - const resultString = JSON.stringify(result, null, 2) +type StoreExecuteOutputFormat = 'text' | 'json' +function serializeStoreExecuteResult(result: unknown): string { + return JSON.stringify(result, null, 2) +} + +function renderStoreExecuteSuccess(outputFile?: string): void { if (outputFile) { - await writeFile(outputFile, resultString) renderSuccess({ headline: 'Operation succeeded.', body: `Results written to ${outputFile}`, }) - } else { - renderSuccess({headline: 'Operation succeeded.'}) - outputResult(resultString) + return } + + renderSuccess({headline: 'Operation succeeded.'}) +} + +export async function writeOrOutputStoreExecuteResult( + result: unknown, + outputFile?: string, + format: StoreExecuteOutputFormat = 'text', +): Promise { + const serializedResult = serializeStoreExecuteResult(result) + + if (outputFile) { + await writeFile(outputFile, serializedResult) + if (format === 'text') renderStoreExecuteSuccess(outputFile) + return + } + + if (format === 'text') renderStoreExecuteSuccess() + outputResult(serializedResult) }