From 5622a43632ca4b6896cd7b0bf13cd11d4c735844 Mon Sep 17 00:00:00 2001 From: Gonzalo Riestra Date: Mon, 31 Aug 2026 13:28:56 +0200 Subject: [PATCH] Add typed JSON output to store authentication --- packages/cli/README.md | 41 ++++++ packages/cli/oclif.manifest.json | 4 +- .../store/src/cli/commands/store/auth.test.ts | 45 ++++--- packages/store/src/cli/commands/store/auth.ts | 21 +-- .../src/cli/commands/store/auth/list.test.ts | 13 +- .../store/src/cli/commands/store/auth/list.ts | 5 + .../cli/commands/store/stripe-auth.test.ts | 43 +++---- .../src/cli/commands/store/stripe-auth.ts | 18 +-- .../store/auth/existing-scopes.test.ts | 8 +- .../src/cli/services/store/auth/index.test.ts | 120 ++---------------- .../src/cli/services/store/auth/index.ts | 17 +-- .../services/store/auth/list-result.test.ts | 42 ++++-- .../cli/services/store/auth/list-result.ts | 35 +---- .../src/cli/services/store/auth/list-types.ts | 18 +++ .../src/cli/services/store/auth/list.test.ts | 32 +++-- .../store/src/cli/services/store/auth/list.ts | 45 +++---- .../cli/services/store/auth/result.test.ts | 93 ++------------ .../src/cli/services/store/auth/result.ts | 55 +------- .../services/store/auth/session-lifecycle.ts | 9 +- .../src/cli/services/store/auth/types.ts | 27 ++++ 20 files changed, 285 insertions(+), 406 deletions(-) create mode 100644 packages/store/src/cli/services/store/auth/list-types.ts create mode 100644 packages/store/src/cli/services/store/auth/types.ts diff --git a/packages/cli/README.md b/packages/cli/README.md index 016e9598836..1a0fe5b7915 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -3744,6 +3744,31 @@ DESCRIPTION Re-run this command if the stored token is missing, expires, or no longer has the scopes you need. + Output from `--json` conforms to the `StoreAuthResult` schema. + + Use `--json-schema` to print the schema directly: + + ```ts + interface StoreAuthResult { + store: string + userId: string + scopes: string[] + acquiredAt: string + expiresAt?: string + refreshTokenExpiresAt?: string + hasRefreshToken: boolean + associatedUser?: StoreAuthAssociatedUser + } + + interface StoreAuthAssociatedUser { + id: number + email?: string + firstName?: string + lastName?: string + accountOwner?: boolean + } + ``` + EXAMPLES $ shopify store auth --store shop.myshopify.com --scopes read_products,write_products @@ -3783,6 +3808,22 @@ DESCRIPTION Use this command to find stores that can be used with store-authenticated commands such as `shopify store execute`. To list stores in a Shopify organization, run `shopify store list`. + Output from `--json` conforms to the `StoreAuthListResult` schema. + + Use `--json-schema` to print the schema directly: + + ```ts + interface StoreAuthListResult { + sessions: StoreAuthListSession[] + message?: string + } + + interface StoreAuthListSession { + subdomain: string + connected: string + } + ``` + EXAMPLES $ shopify store auth list diff --git a/packages/cli/oclif.manifest.json b/packages/cli/oclif.manifest.json index aae3e2b9a94..c6d4485f414 100644 --- a/packages/cli/oclif.manifest.json +++ b/packages/cli/oclif.manifest.json @@ -6919,7 +6919,7 @@ "args": { }, "customPluginName": "@shopify/store", - "description": "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.", + "description": "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\nOutput from `--json` conforms to the `StoreAuthResult` schema.\n\nUse `--json-schema` to print the schema directly:\n\n```ts\ninterface StoreAuthResult {\n store: string\n userId: string\n scopes: string[]\n acquiredAt: string\n expiresAt?: string\n refreshTokenExpiresAt?: string\n hasRefreshToken: boolean\n associatedUser?: StoreAuthAssociatedUser\n}\n\ninterface StoreAuthAssociatedUser {\n id: number\n email?: string\n firstName?: string\n lastName?: string\n accountOwner?: boolean\n}\n```", "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.", "examples": [ "<%= config.bin %> <%= command.id %> --store shop.myshopify.com --scopes read_products,write_products", @@ -6994,7 +6994,7 @@ "args": { }, "customPluginName": "@shopify/store", - "description": "Lists stores authenticated directly on this machine with `shopify store auth`.\n\nUse this command to find stores that can be used with store-authenticated commands such as `shopify store execute`.\nTo list stores in a Shopify organization, run `shopify store list`.", + "description": "Lists stores authenticated directly on this machine with `shopify store auth`.\n\nUse this command to find stores that can be used with store-authenticated commands such as `shopify store execute`.\nTo list stores in a Shopify organization, run `shopify store list`.\n\nOutput from `--json` conforms to the `StoreAuthListResult` schema.\n\nUse `--json-schema` to print the schema directly:\n\n```ts\ninterface StoreAuthListResult {\n sessions: StoreAuthListSession[]\n message?: string\n}\n\ninterface StoreAuthListSession {\n subdomain: string\n connected: string\n}\n```", "descriptionWithMarkdown": "Lists stores authenticated directly on this machine with `shopify store auth`.\n\nUse this command to find stores that can be used with store-authenticated commands such as `shopify store execute`.\nTo list stores in a Shopify organization, run `shopify store list`.", "enableJsonFlag": false, "examples": [ diff --git a/packages/store/src/cli/commands/store/auth.test.ts b/packages/store/src/cli/commands/store/auth.test.ts index 1a6560ee490..f273f39289a 100644 --- a/packages/store/src/cli/commands/store/auth.test.ts +++ b/packages/store/src/cli/commands/store/auth.test.ts @@ -1,39 +1,38 @@ import StoreAuth from './auth.js' import {authenticateStoreWithApp} from '../../services/store/auth/index.js' -import {createStoreAuthPresenter} from '../../services/store/auth/result.js' +import {presentStoreAuthResult} from '../../services/store/auth/result.js' +import {storeAuthJsonOutputSchema} from '../../services/store/auth/types.js' import {describe, expect, test, vi} from 'vitest' vi.mock('../../services/store/auth/index.js') vi.mock('../../services/store/attribution.js') -vi.mock('../../services/store/auth/result.js', () => ({ - createStoreAuthPresenter: vi.fn((format: 'text' | 'json') => ({format})), -})) +vi.mock('../../services/store/auth/result.js') + +const authResult = { + store: 'shop.myshopify.com', + userId: '42', + scopes: ['read_products'], + acquiredAt: '2026-04-02T00:00:00.000Z', + hasRefreshToken: true, +} describe('store auth command', () => { test('passes parsed flags through to the auth service', async () => { + vi.mocked(authenticateStoreWithApp).mockResolvedValue(authResult) await StoreAuth.run(['--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'}}, - ) + expect(authenticateStoreWithApp).toHaveBeenCalledWith({ + store: 'shop.myshopify.com', + scopes: 'read_products,write_products', + }) + expect(presentStoreAuthResult).toHaveBeenCalledWith(authResult, 'text') }) - test('passes a json presenter when --json is provided', async () => { + test('presents JSON when --json is provided', async () => { + vi.mocked(authenticateStoreWithApp).mockResolvedValue(authResult) 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'}}, - ) + expect(presentStoreAuthResult).toHaveBeenCalledWith(authResult, 'json') }) test('defines the expected flags', () => { @@ -44,4 +43,8 @@ describe('store auth command', () => { expect('port' in StoreAuth.flags).toBe(false) expect('client-secret-file' in StoreAuth.flags).toBe(false) }) + + test('exposes the JSON output schema', () => { + expect(StoreAuth.jsonOutputSchema).toBe(storeAuthJsonOutputSchema) + }) }) diff --git a/packages/store/src/cli/commands/store/auth.ts b/packages/store/src/cli/commands/store/auth.ts index e78e594e1d3..60ccc9f77cf 100644 --- a/packages/store/src/cli/commands/store/auth.ts +++ b/packages/store/src/cli/commands/store/auth.ts @@ -1,5 +1,6 @@ import {authenticateStoreWithApp} from '../../services/store/auth/index.js' -import {createStoreAuthPresenter} from '../../services/store/auth/result.js' +import {presentStoreAuthResult} from '../../services/store/auth/result.js' +import {storeAuthJsonOutputSchema} from '../../services/store/auth/types.js' import StoreCommand from '../../utilities/store-command.js' import {storeFlags} from '../../flags.js' import {globalFlags, jsonFlag} from '@shopify/cli-kit/node/cli' @@ -30,17 +31,17 @@ Re-run this command if the stored token is missing, expires, or no longer has th }), } + static get jsonOutputSchema() { + return storeAuthJsonOutputSchema + } + public async run(): Promise { const {flags} = await this.parse(StoreAuth) - await authenticateStoreWithApp( - { - store: flags.store, - scopes: flags.scopes, - }, - { - presenter: createStoreAuthPresenter(flags.json ? 'json' : 'text'), - }, - ) + const result = await authenticateStoreWithApp({ + store: flags.store, + scopes: flags.scopes, + }) + presentStoreAuthResult(result, flags.json ? 'json' : 'text') } } diff --git a/packages/store/src/cli/commands/store/auth/list.test.ts b/packages/store/src/cli/commands/store/auth/list.test.ts index 178122b14a2..fa6e1eaeb61 100644 --- a/packages/store/src/cli/commands/store/auth/list.test.ts +++ b/packages/store/src/cli/commands/store/auth/list.test.ts @@ -1,6 +1,7 @@ import StoreAuthList from './list.js' import {listStoreAuthSessions} from '../../../services/store/auth/list.js' import {writeStoreAuthListResult} from '../../../services/store/auth/list-result.js' +import {storeAuthListJsonOutputSchema} from '../../../services/store/auth/list-types.js' import {describe, expect, test, vi} from 'vitest' vi.mock('../../../services/store/auth/list.js') @@ -8,20 +9,20 @@ vi.mock('../../../services/store/auth/list-result.js') describe('store auth list command', () => { test('lists direct store-auth sessions and writes text output by default', async () => { - vi.mocked(listStoreAuthSessions).mockReturnValue({sessions: []}) + vi.mocked(listStoreAuthSessions).mockReturnValue({sessions: [], message: 'No stores.'}) await StoreAuthList.run([]) expect(listStoreAuthSessions).toHaveBeenCalledWith() - expect(writeStoreAuthListResult).toHaveBeenCalledWith({sessions: []}, 'text') + expect(writeStoreAuthListResult).toHaveBeenCalledWith({sessions: [], message: 'No stores.'}, 'text') }) test('writes json output when requested', async () => { - vi.mocked(listStoreAuthSessions).mockReturnValue({sessions: []}) + vi.mocked(listStoreAuthSessions).mockReturnValue({sessions: [], message: 'No stores.'}) await StoreAuthList.run(['--json']) - expect(writeStoreAuthListResult).toHaveBeenCalledWith({sessions: []}, 'json') + expect(writeStoreAuthListResult).toHaveBeenCalledWith({sessions: [], message: 'No stores.'}, 'json') }) test('does not expose organization or source-selection flags', () => { @@ -29,4 +30,8 @@ describe('store auth list command', () => { expect(StoreAuthList.flags).not.toHaveProperty('organization-id') expect(StoreAuthList.flags).not.toHaveProperty('from') }) + + test('exposes the JSON output schema', () => { + expect(StoreAuthList.jsonOutputSchema).toBe(storeAuthListJsonOutputSchema) + }) }) diff --git a/packages/store/src/cli/commands/store/auth/list.ts b/packages/store/src/cli/commands/store/auth/list.ts index 2243d2d92d6..5a6425d7281 100644 --- a/packages/store/src/cli/commands/store/auth/list.ts +++ b/packages/store/src/cli/commands/store/auth/list.ts @@ -1,5 +1,6 @@ import {listStoreAuthSessions} from '../../../services/store/auth/list.js' import {writeStoreAuthListResult} from '../../../services/store/auth/list-result.js' +import {storeAuthListJsonOutputSchema} from '../../../services/store/auth/list-types.js' import Command from '@shopify/cli-kit/node/base-command' import {globalFlags, jsonFlag} from '@shopify/cli-kit/node/cli' @@ -20,6 +21,10 @@ To list stores in a Shopify organization, run \`shopify store list\`.` ...jsonFlag, } + static get jsonOutputSchema() { + return storeAuthListJsonOutputSchema + } + async run(): Promise { const {flags} = await this.parse(StoreAuthList) const result = listStoreAuthSessions() diff --git a/packages/store/src/cli/commands/store/stripe-auth.test.ts b/packages/store/src/cli/commands/store/stripe-auth.test.ts index 9014cb76758..68918c297ae 100644 --- a/packages/store/src/cli/commands/store/stripe-auth.test.ts +++ b/packages/store/src/cli/commands/store/stripe-auth.test.ts @@ -1,16 +1,23 @@ import StoreStripeAuth from './stripe-auth.js' import {authenticateStoreWithApp} from '../../services/store/auth/index.js' -import {createStoreAuthPresenter} from '../../services/store/auth/result.js' +import {presentStoreAuthResult} from '../../services/store/auth/result.js' import {describe, expect, test, vi} from 'vitest' vi.mock('../../services/store/auth/index.js') vi.mock('../../services/store/attribution.js') -vi.mock('../../services/store/auth/result.js', () => ({ - createStoreAuthPresenter: vi.fn((format: 'text' | 'json') => ({format})), -})) +vi.mock('../../services/store/auth/result.js') + +const authResult = { + store: 'shop.myshopify.com', + userId: '42', + scopes: ['read_products'], + acquiredAt: '2026-04-02T00:00:00.000Z', + hasRefreshToken: true, +} describe('store stripe-auth command', () => { test('passes signup JWT through to the auth service', async () => { + vi.mocked(authenticateStoreWithApp).mockResolvedValue(authResult) await StoreStripeAuth.run([ '--store', 'shop.myshopify.com', @@ -20,18 +27,16 @@ describe('store stripe-auth command', () => { 'signed.signup.jwt', ]) - expect(createStoreAuthPresenter).toHaveBeenCalledWith('text') - expect(authenticateStoreWithApp).toHaveBeenCalledWith( - { - store: 'shop.myshopify.com', - scopes: 'read_products', - signup: 'signed.signup.jwt', - }, - {presenter: {format: 'text'}}, - ) + expect(authenticateStoreWithApp).toHaveBeenCalledWith({ + store: 'shop.myshopify.com', + scopes: 'read_products', + signup: 'signed.signup.jwt', + }) + expect(presentStoreAuthResult).toHaveBeenCalledWith(authResult, 'text') }) - test('passes a json presenter when --json is provided', async () => { + test('presents JSON when --json is provided', async () => { + vi.mocked(authenticateStoreWithApp).mockResolvedValue(authResult) await StoreStripeAuth.run([ '--store', 'shop.myshopify.com', @@ -42,15 +47,7 @@ describe('store stripe-auth command', () => { '--json', ]) - expect(createStoreAuthPresenter).toHaveBeenCalledWith('json') - expect(authenticateStoreWithApp).toHaveBeenCalledWith( - { - store: 'shop.myshopify.com', - scopes: 'read_products', - signup: 'signed.signup.jwt', - }, - {presenter: {format: 'json'}}, - ) + expect(presentStoreAuthResult).toHaveBeenCalledWith(authResult, 'json') }) test('defines the expected flags', () => { diff --git a/packages/store/src/cli/commands/store/stripe-auth.ts b/packages/store/src/cli/commands/store/stripe-auth.ts index 2a17b5ee223..5c4d991bd1e 100644 --- a/packages/store/src/cli/commands/store/stripe-auth.ts +++ b/packages/store/src/cli/commands/store/stripe-auth.ts @@ -1,5 +1,5 @@ import {authenticateStoreWithApp} from '../../services/store/auth/index.js' -import {createStoreAuthPresenter} from '../../services/store/auth/result.js' +import {presentStoreAuthResult} from '../../services/store/auth/result.js' import StoreCommand from '../../utilities/store-command.js' import {storeFlags} from '../../flags.js' import {globalFlags, jsonFlag} from '@shopify/cli-kit/node/cli' @@ -38,15 +38,11 @@ export default class StoreStripeAuth extends StoreCommand { public async run(): Promise { const {flags} = await this.parse(StoreStripeAuth) - await authenticateStoreWithApp( - { - store: flags.store, - scopes: flags.scopes, - signup: flags.signup, - }, - { - presenter: createStoreAuthPresenter(flags.json ? 'json' : 'text'), - }, - ) + const result = await authenticateStoreWithApp({ + store: flags.store, + scopes: flags.scopes, + signup: flags.signup, + }) + presentStoreAuthResult(result, flags.json ? 'json' : 'text') } } diff --git a/packages/store/src/cli/services/store/auth/existing-scopes.test.ts b/packages/store/src/cli/services/store/auth/existing-scopes.test.ts index 7b8aa38ada0..836ecf1958c 100644 --- a/packages/store/src/cli/services/store/auth/existing-scopes.test.ts +++ b/packages/store/src/cli/services/store/auth/existing-scopes.test.ts @@ -2,10 +2,10 @@ import {STORE_AUTH_APP_CLIENT_ID} from './config.js' import {resolveExistingStoreAuthScopes} from './existing-scopes.js' import {loadStoredStoreSession} from './session-lifecycle.js' import {getCurrentStoredStoreAppSession} from '@shopify/cli-kit/node/store-auth-session' -import {afterEach, beforeEach, describe, expect, test, vi} from 'vitest' +import {mockAndCaptureOutput} from '@shopify/cli-kit/node/testing/output' +import {beforeEach, describe, expect, test, vi} from 'vitest' import {adminUrl} from '@shopify/cli-kit/node/api/admin' import {graphqlRequest} from '@shopify/cli-kit/node/api/graphql' -import {mockAndCaptureOutput} from '@shopify/cli-kit/node/testing/output' vi.mock('@shopify/cli-kit/node/store-auth-session') vi.mock('./session-lifecycle.js', () => ({loadStoredStoreSession: vi.fn()})) @@ -25,10 +25,6 @@ describe('resolveExistingStoreAuthScopes', () => { vi.mocked(adminUrl).mockReturnValue('https://shop.myshopify.com/admin/api/unstable/graphql.json') }) - afterEach(() => { - mockAndCaptureOutput().clear() - }) - test('returns no scopes when no stored auth exists', async () => { vi.mocked(getCurrentStoredStoreAppSession).mockReturnValue(undefined) diff --git a/packages/store/src/cli/services/store/auth/index.test.ts b/packages/store/src/cli/services/store/auth/index.test.ts index 709515377e6..c76bbe614c7 100644 --- a/packages/store/src/cli/services/store/auth/index.test.ts +++ b/packages/store/src/cli/services/store/auth/index.test.ts @@ -1,9 +1,11 @@ import {authenticateStoreWithApp} from './index.js' import {STORE_AUTH_APP_CLIENT_ID} from './config.js' +import {storeAuthJsonOutputSchema} from './types.js' import {recordStoreFqdnMetadata} from '../attribution.js' import {setStoredStoreAppSession} from '@shopify/cli-kit/node/store-auth-session' import {setLastSeenUserId} from '@shopify/cli-kit/node/session' -import {describe, expect, test, vi} from 'vitest' +import {mockAndCaptureOutput} from '@shopify/cli-kit/node/testing/output' +import {afterEach, describe, expect, test, vi} from 'vitest' vi.mock('@shopify/cli-kit/node/store-auth-session') vi.mock('../attribution.js') @@ -12,13 +14,13 @@ vi.mock('@shopify/cli-kit/node/system', () => ({openURL: vi.fn().mockResolvedVal vi.mock('@shopify/cli-kit/node/crypto', () => ({randomUUID: vi.fn().mockReturnValue('state-123')})) describe('store auth service', () => { + afterEach(() => { + mockAndCaptureOutput().clear() + }) + test('authenticateStoreWithApp opens the browser, stores the session, and returns auth result', async () => { const openURL = vi.fn().mockResolvedValue(true) - const presenter = { - openingBrowser: vi.fn(), - manualAuthUrl: vi.fn(), - success: vi.fn(), - } + const output = mockAndCaptureOutput() const waitForStoreAuthCodeMock = vi.fn().mockImplementation(async (options) => { await options.onListening?.() return 'abc123' @@ -39,13 +41,10 @@ describe('store auth service', () => { refresh_token: 'refresh-token', associated_user: {id: 42, email: 'test@example.com'}, }), - presenter, }, ) - expect(presenter.openingBrowser).toHaveBeenCalledOnce() expect(openURL).toHaveBeenCalledWith(expect.stringContaining('/admin/oauth/authorize?')) - expect(presenter.manualAuthUrl).not.toHaveBeenCalled() expect(result).toEqual( expect.objectContaining({ store: 'shop.myshopify.com', @@ -55,7 +54,8 @@ describe('store auth service', () => { associatedUser: expect.objectContaining({email: 'test@example.com'}), }), ) - expect(presenter.success).toHaveBeenCalledWith(result) + expect(storeAuthJsonOutputSchema.validate(result)).toEqual(result) + expect(output.info()).toContain('Shopify CLI will open the app authorization page in your browser.') expect(recordStoreFqdnMetadata).toHaveBeenNthCalledWith(1, 'shop.myshopify.com', false) expect(recordStoreFqdnMetadata).toHaveBeenNthCalledWith(2, 'shop.myshopify.com', true) expect(setLastSeenUserId).toHaveBeenCalledWith('42') @@ -79,11 +79,6 @@ describe('store auth service', () => { test('authenticateStoreWithApp includes signup JWT in the authorization URL when provided', async () => { const openURL = vi.fn().mockResolvedValue(true) - const presenter = { - openingBrowser: vi.fn(), - manualAuthUrl: vi.fn(), - success: vi.fn(), - } const waitForStoreAuthCodeMock = vi.fn().mockImplementation(async (options) => { await options.onListening?.() return 'abc123' @@ -105,7 +100,6 @@ describe('store auth service', () => { refresh_token: 'refresh-token', associated_user: {id: 42, email: 'test@example.com'}, }), - presenter, }, ) @@ -115,11 +109,6 @@ describe('store auth service', () => { test('authenticateStoreWithApp uses remote scopes by default when available', async () => { const openURL = vi.fn().mockResolvedValue(true) - const presenter = { - openingBrowser: vi.fn(), - manualAuthUrl: vi.fn(), - success: vi.fn(), - } const waitForStoreAuthCodeMock = vi.fn().mockImplementation(async (options) => { await options.onListening?.() return 'abc123' @@ -140,7 +129,6 @@ describe('store auth service', () => { associated_user: {id: 42, email: 'test@example.com'}, }), resolveExistingScopes: vi.fn().mockResolvedValue({scopes: ['read_customers'], authoritative: true}), - presenter, }, ) @@ -150,11 +138,6 @@ describe('store auth service', () => { test('authenticateStoreWithApp reuses resolved existing scopes when requesting additional access', async () => { const openURL = vi.fn().mockResolvedValue(true) - const presenter = { - openingBrowser: vi.fn(), - manualAuthUrl: vi.fn(), - success: vi.fn(), - } const waitForStoreAuthCodeMock = vi.fn().mockImplementation(async (options) => { await options.onListening?.() return 'abc123' @@ -175,7 +158,6 @@ describe('store auth service', () => { associated_user: {id: 42, email: 'test@example.com'}, }), resolveExistingScopes: vi.fn().mockResolvedValue({scopes: ['read_orders'], authoritative: true}), - presenter, }, ) @@ -191,11 +173,6 @@ describe('store auth service', () => { test('authenticateStoreWithApp does not require non-authoritative cached scopes to still be granted', async () => { const openURL = vi.fn().mockResolvedValue(true) - const presenter = { - openingBrowser: vi.fn(), - manualAuthUrl: vi.fn(), - success: vi.fn(), - } const waitForStoreAuthCodeMock = vi.fn().mockImplementation(async (options) => { await options.onListening?.() return 'abc123' @@ -216,7 +193,6 @@ describe('store auth service', () => { associated_user: {id: 42, email: 'test@example.com'}, }), resolveExistingScopes: vi.fn().mockResolvedValue({scopes: ['read_orders'], authoritative: false}), - presenter, }, ) @@ -232,11 +208,6 @@ describe('store auth service', () => { test('authenticateStoreWithApp avoids requesting redundant read scopes already implied by existing write scopes', async () => { const openURL = vi.fn().mockResolvedValue(true) - const presenter = { - openingBrowser: vi.fn(), - manualAuthUrl: vi.fn(), - success: vi.fn(), - } const waitForStoreAuthCodeMock = vi.fn().mockImplementation(async (options) => { await options.onListening?.() return 'abc123' @@ -257,7 +228,6 @@ describe('store auth service', () => { associated_user: {id: 42, email: 'test@example.com'}, }), resolveExistingScopes: vi.fn().mockResolvedValue({scopes: ['write_products'], authoritative: true}), - presenter, }, ) @@ -273,17 +243,13 @@ describe('store auth service', () => { test('authenticateStoreWithApp shows a manual auth URL when the browser does not open automatically', async () => { const openURL = vi.fn().mockResolvedValue(false) - const presenter = { - openingBrowser: vi.fn(), - manualAuthUrl: vi.fn(), - success: vi.fn(), - } + const output = mockAndCaptureOutput() const waitForStoreAuthCodeMock = vi.fn().mockImplementation(async (options) => { await options.onListening?.() return 'abc123' }) - const result = await authenticateStoreWithApp( + await authenticateStoreWithApp( { store: 'shop.myshopify.com', scopes: 'read_products', @@ -297,15 +263,11 @@ describe('store auth service', () => { expires_in: 86400, associated_user: {id: 42, email: 'test@example.com'}, }), - presenter, }, ) - expect(presenter.openingBrowser).toHaveBeenCalledOnce() - expect(presenter.manualAuthUrl).toHaveBeenCalledWith( - expect.stringContaining('https://shop.myshopify.com/admin/oauth/authorize?'), - ) - expect(presenter.success).toHaveBeenCalledWith(result) + expect(output.info()).toContain('Browser did not open automatically. Open this URL manually:') + expect(output.info()).toContain('https://shop.myshopify.com/admin/oauth/authorize?') }) test('authenticateStoreWithApp records fqdn metadata before resolving existing scopes', async () => { @@ -317,11 +279,6 @@ describe('store auth service', () => { }, { resolveExistingScopes: vi.fn().mockRejectedValue(new Error('scope lookup failed')), - presenter: { - openingBrowser: vi.fn(), - manualAuthUrl: vi.fn(), - success: vi.fn(), - }, }, ), ).rejects.toThrow('scope lookup failed') @@ -345,11 +302,6 @@ describe('store auth service', () => { openURL: vi.fn().mockResolvedValue(true), waitForStoreAuthCode: waitForStoreAuthCodeMock, exchangeStoreAuthCodeForToken: vi.fn(), - presenter: { - openingBrowser: vi.fn(), - manualAuthUrl: vi.fn(), - success: vi.fn(), - }, }, ), ).rejects.toThrow('callback failed') @@ -375,11 +327,6 @@ describe('store auth service', () => { openURL: vi.fn().mockResolvedValue(true), waitForStoreAuthCode: waitForStoreAuthCodeMock, exchangeStoreAuthCodeForToken: vi.fn().mockRejectedValue(new Error('token exchange failed')), - presenter: { - openingBrowser: vi.fn(), - manualAuthUrl: vi.fn(), - success: vi.fn(), - }, }, ), ).rejects.toThrow('token exchange failed') @@ -410,11 +357,6 @@ describe('store auth service', () => { scope: 'read_products', expires_in: 86400, }), - presenter: { - openingBrowser: vi.fn(), - manualAuthUrl: vi.fn(), - success: vi.fn(), - }, }, ), ).rejects.toThrow('Shopify did not return associated user information for the online access token.') @@ -446,11 +388,6 @@ describe('store auth service', () => { expires_in: 86400, associated_user: {id: 42, email: 'test@example.com'}, }), - presenter: { - openingBrowser: vi.fn(), - manualAuthUrl: vi.fn(), - success: vi.fn(), - }, }, ), ).rejects.toMatchObject({ @@ -486,11 +423,6 @@ describe('store auth service', () => { expires_in: 86400, associated_user: {id: 42, email: 'test@example.com'}, }), - presenter: { - openingBrowser: vi.fn(), - manualAuthUrl: vi.fn(), - success: vi.fn(), - }, }, ) @@ -522,11 +454,6 @@ describe('store auth service', () => { expires_in: 86400, associated_user: {id: 42, email: 'test@example.com'}, }), - presenter: { - openingBrowser: vi.fn(), - manualAuthUrl: vi.fn(), - success: vi.fn(), - }, }, ) @@ -559,11 +486,6 @@ describe('store auth service', () => { expires_in: 86400, associated_user: {id: 42, email: 'test@example.com'}, }), - presenter: { - openingBrowser: vi.fn(), - manualAuthUrl: vi.fn(), - success: vi.fn(), - }, }, ), ).rejects.toThrow('Shopify granted fewer scopes than were requested.') @@ -590,11 +512,6 @@ describe('store auth service', () => { expires_in: 86400, associated_user: {id: 42, email: 'test@example.com'}, }), - presenter: { - openingBrowser: vi.fn(), - manualAuthUrl: vi.fn(), - success: vi.fn(), - }, }, ) @@ -626,11 +543,6 @@ describe('store auth service', () => { expires_in: 86400, associated_user: {id: 42, email: 'test@example.com'}, }), - presenter: { - openingBrowser: vi.fn(), - manualAuthUrl: vi.fn(), - success: vi.fn(), - }, }, ) @@ -646,7 +558,6 @@ describe('store auth service', () => { const openURL = vi.fn().mockResolvedValue(true) const waitForStoreAuthCode = vi.fn() const exchangeStoreAuthCodeForToken = vi.fn() - const presenter = {openingBrowser: vi.fn(), manualAuthUrl: vi.fn(), success: vi.fn()} const getCurrentStoredStoreAppSessionMock = vi.fn().mockReturnValue({ store: 'shop.myshopify.com', clientId: STORE_AUTH_APP_CLIENT_ID, @@ -666,7 +577,6 @@ describe('store auth service', () => { waitForStoreAuthCode, exchangeStoreAuthCodeForToken, getCurrentStoredStoreAppSession: getCurrentStoredStoreAppSessionMock, - presenter, }, ), ).rejects.toThrow('`store auth` is unavailable for preview stores.') @@ -675,7 +585,6 @@ describe('store auth service', () => { expect(openURL).not.toHaveBeenCalled() expect(waitForStoreAuthCode).not.toHaveBeenCalled() expect(exchangeStoreAuthCodeForToken).not.toHaveBeenCalled() - expect(presenter.openingBrowser).not.toHaveBeenCalled() expect(setStoredStoreAppSession).not.toHaveBeenCalled() expect(recordStoreFqdnMetadata).not.toHaveBeenCalled() }) @@ -699,7 +608,6 @@ describe('store auth service', () => { waitForStoreAuthCode: vi.fn(), exchangeStoreAuthCodeForToken: vi.fn(), getCurrentStoredStoreAppSession: getCurrentStoredStoreAppSessionMock, - presenter: {openingBrowser: vi.fn(), manualAuthUrl: vi.fn(), success: vi.fn()}, }, ).then( () => { diff --git a/packages/store/src/cli/services/store/auth/index.ts b/packages/store/src/cli/services/store/auth/index.ts index 9a1dc4c9f8e..9919906da08 100644 --- a/packages/store/src/cli/services/store/auth/index.ts +++ b/packages/store/src/cli/services/store/auth/index.ts @@ -4,12 +4,12 @@ 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' +import {type StoreAuthResult} from './types.js' import {recordStoreFqdnMetadata} from '../attribution.js' import {getCurrentStoredStoreAppSession, setStoredStoreAppSession} from '@shopify/cli-kit/node/store-auth-session' import {setLastSeenUserId} from '@shopify/cli-kit/node/session' import {openURL} from '@shopify/cli-kit/node/system' -import {outputContent, outputDebug, outputToken} from '@shopify/cli-kit/node/output' +import {outputContent, outputDebug, outputInfo, outputToken} from '@shopify/cli-kit/node/output' import {AbortError} from '@shopify/cli-kit/node/error' import {normalizeStoreFqdn} from '@shopify/cli-kit/node/context/fqdn' @@ -27,7 +27,6 @@ interface StoreAuthDependencies { exchangeStoreAuthCodeForToken: typeof exchangeStoreAuthCodeForToken resolveExistingScopes: (store: string) => Promise getCurrentStoredStoreAppSession: typeof getCurrentStoredStoreAppSession - presenter: StoreAuthPresenter } const defaultStoreAuthDependencies: StoreAuthDependencies = { @@ -36,7 +35,6 @@ const defaultStoreAuthDependencies: StoreAuthDependencies = { exchangeStoreAuthCodeForToken, resolveExistingScopes: resolveExistingStoreAuthScopes, getCurrentStoredStoreAppSession, - presenter: createStoreAuthPresenter('text'), } export async function authenticateStoreWithApp( @@ -70,13 +68,18 @@ export async function authenticateStoreWithApp( authorization: {authorizationUrl}, } = bootstrap - resolvedDependencies.presenter.openingBrowser() + outputInfo('Shopify CLI will open the app authorization page in your browser.') + outputInfo('') const code = await resolvedDependencies.waitForStoreAuthCode({ ...bootstrap.waitForAuthCodeOptions, onListening: async () => { const opened = await resolvedDependencies.openURL(authorizationUrl) - if (!opened) resolvedDependencies.presenter.manualAuthUrl(authorizationUrl) + if (!opened) { + outputInfo('Browser did not open automatically. Open this URL manually:') + outputInfo(outputContent`${outputToken.link(authorizationUrl)}`) + outputInfo('') + } }, }) const tokenResponse = await bootstrap.exchangeCodeForToken(code) @@ -128,8 +131,6 @@ export async function authenticateStoreWithApp( outputDebug( outputContent`Session persisted for ${outputToken.raw(store)} (user ${outputToken.raw(userId)}, expires ${outputToken.raw(expiresAt ?? 'unknown')})`, ) - - resolvedDependencies.presenter.success(result) return result } diff --git a/packages/store/src/cli/services/store/auth/list-result.test.ts b/packages/store/src/cli/services/store/auth/list-result.test.ts index 1634551a9b3..4790fd60bf6 100644 --- a/packages/store/src/cli/services/store/auth/list-result.test.ts +++ b/packages/store/src/cli/services/store/auth/list-result.test.ts @@ -14,12 +14,8 @@ describe('writeStoreAuthListResult', () => { { sessions: [ { - kind: 'store', - store: 'my-shop.myshopify.com', - userId: '42', - scopes: ['read_products', 'write_products'], - connectedAt: '2026-05-22T00:00:00Z', - associatedUser: {id: 42, email: 'merchant@example.com'}, + subdomain: 'my-shop', + connected: 'May 22, 2026', }, ], }, @@ -39,7 +35,18 @@ describe('writeStoreAuthListResult', () => { test('renders an empty state with auth and organization-list guidance', () => { const output = mockAndCaptureOutput() - writeStoreAuthListResult({sessions: []}, 'text') + writeStoreAuthListResult( + { + sessions: [], + message: [ + 'No stores are authenticated directly with `shopify store auth`.', + '', + 'Run `shopify store auth --store --scopes ` to authenticate a store.', + 'Run `shopify store list` to list stores in a Shopify organization.', + ].join('\n'), + }, + 'text', + ) expect(output.info()).toContain('No stores are authenticated directly with `shopify store auth`.') expect(output.info()).toContain('shopify store auth --store --scopes ') @@ -53,12 +60,8 @@ describe('writeStoreAuthListResult', () => { { sessions: [ { - kind: 'store', - store: 'shop.myshopify.com', - userId: '42', - scopes: ['read_products'], - connectedAt: '2026-05-22T00:00:00Z', - associatedUser: {id: 42, email: 'merchant@example.com'}, + subdomain: 'shop', + connected: 'May 22, 2026', }, ], }, @@ -78,7 +81,18 @@ describe('writeStoreAuthListResult', () => { test('includes empty-state guidance in JSON output when there are no sessions', () => { const output = mockAndCaptureOutput() - writeStoreAuthListResult({sessions: []}, 'json') + writeStoreAuthListResult( + { + sessions: [], + message: [ + 'No stores are authenticated directly with `shopify store auth`.', + '', + 'Run `shopify store auth --store --scopes ` to authenticate a store.', + 'Run `shopify store list` to list stores in a Shopify organization.', + ].join('\n'), + }, + 'json', + ) expect(JSON.parse(output.output())).toEqual({ sessions: [], diff --git a/packages/store/src/cli/services/store/auth/list-result.ts b/packages/store/src/cli/services/store/auth/list-result.ts index e8cba213c5d..a1a7a2420ab 100644 --- a/packages/store/src/cli/services/store/auth/list-result.ts +++ b/packages/store/src/cli/services/store/auth/list-result.ts @@ -1,54 +1,27 @@ -import {type StoreAuthListResult} from './list.js' -import {extractSubdomain, formatShortDate} from '../display.js' +import {storeAuthListJsonOutputSchema, type StoreAuthListResult} from './list-types.js' import {outputInfo, outputResult} from '@shopify/cli-kit/node/output' import {renderTable} from '@shopify/cli-kit/node/ui' export function writeStoreAuthListResult(result: StoreAuthListResult, format: 'text' | 'json'): void { if (format === 'json') { - outputResult(JSON.stringify(toJsonResult(result), null, 2)) + outputResult(storeAuthListJsonOutputSchema.encode(result)) return } renderTextResult(result) } -function toJsonResult(result: StoreAuthListResult): { - sessions: ReturnType[] - message?: string -} { - return { - sessions: result.sessions.map(toDisplaySession), - ...(result.sessions.length === 0 ? {message: emptyStateMessage()} : {}), - } -} - function renderTextResult(result: StoreAuthListResult): void { if (result.sessions.length === 0) { - outputInfo(emptyStateMessage()) + outputInfo(result.message ?? '') return } renderTable({ - rows: result.sessions.map(toDisplaySession), + rows: result.sessions, columns: { subdomain: {header: 'Subdomain'}, connected: {header: 'Connected'}, }, }) } - -function toDisplaySession(session: StoreAuthListResult['sessions'][number]): {subdomain: string; connected: string} { - return { - subdomain: extractSubdomain(session.store) ?? session.store, - connected: formatShortDate(session.connectedAt), - } -} - -function emptyStateMessage(): string { - return [ - 'No stores are authenticated directly with `shopify store auth`.', - '', - 'Run `shopify store auth --store --scopes ` to authenticate a store.', - 'Run `shopify store list` to list stores in a Shopify organization.', - ].join('\n') -} diff --git a/packages/store/src/cli/services/store/auth/list-types.ts b/packages/store/src/cli/services/store/auth/list-types.ts new file mode 100644 index 00000000000..951f14cfa91 --- /dev/null +++ b/packages/store/src/cli/services/store/auth/list-types.ts @@ -0,0 +1,18 @@ +import {defineJsonOutputSchema, type InferJsonOutputSchema} from '@shopify/cli-kit/node/json-output-schema' +import {zod} from '@shopify/cli-kit/node/schema' + +const StoreAuthListSessionSchema = zod.object({ + subdomain: zod.string(), + connected: zod.string(), +}) + +export const storeAuthListJsonOutputSchema = defineJsonOutputSchema({ + name: 'StoreAuthListResult', + schema: zod.object({ + sessions: zod.array(StoreAuthListSessionSchema), + message: zod.string().optional(), + }), + definitions: {StoreAuthListSession: StoreAuthListSessionSchema}, +}) + +export type StoreAuthListResult = InferJsonOutputSchema diff --git a/packages/store/src/cli/services/store/auth/list.test.ts b/packages/store/src/cli/services/store/auth/list.test.ts index 7f61ebcc5e7..cf57b14a17a 100644 --- a/packages/store/src/cli/services/store/auth/list.test.ts +++ b/packages/store/src/cli/services/store/auth/list.test.ts @@ -1,11 +1,12 @@ import {listStoreAuthSessions} from './list.js' +import {storeAuthListJsonOutputSchema} from './list-types.js' import {listStoredStoreAuthSummaries} from './stored-auth.js' import {describe, expect, test, vi} from 'vitest' vi.mock('./stored-auth.js') describe('listStoreAuthSessions', () => { - test('projects stored store auth summaries into typed auth sessions', () => { + test('projects stored store auth summaries into the typed result contract', () => { vi.mocked(listStoredStoreAuthSummaries).mockReturnValue([ { store: 'shop.myshopify.com', @@ -18,19 +19,30 @@ describe('listStoreAuthSessions', () => { }, ]) - expect(listStoreAuthSessions()).toEqual({ + const result = listStoreAuthSessions() + + expect(result).toEqual({ sessions: [ { - kind: 'store', - store: 'shop.myshopify.com', - userId: '42', - scopes: ['read_products'], - connectedAt: '2026-03-27T00:00:00.000Z', - expiresAt: '2026-03-28T00:00:00.000Z', - refreshTokenExpiresAt: '2026-04-28T00:00:00.000Z', - associatedUser: {id: 42, email: 'merchant@example.com'}, + subdomain: 'shop', + connected: 'Mar 27, 2026', }, ], }) + expect(storeAuthListJsonOutputSchema.validate(result)).toEqual(result) + }) + + test('includes the existing guidance when there are no sessions', () => { + vi.mocked(listStoredStoreAuthSummaries).mockReturnValue([]) + + expect(listStoreAuthSessions()).toEqual({ + sessions: [], + message: [ + 'No stores are authenticated directly with `shopify store auth`.', + '', + 'Run `shopify store auth --store --scopes ` to authenticate a store.', + 'Run `shopify store list` to list stores in a Shopify organization.', + ].join('\n'), + }) }) }) diff --git a/packages/store/src/cli/services/store/auth/list.ts b/packages/store/src/cli/services/store/auth/list.ts index 1a28e07d981..450e665e756 100644 --- a/packages/store/src/cli/services/store/auth/list.ts +++ b/packages/store/src/cli/services/store/auth/list.ts @@ -1,31 +1,24 @@ -import {listStoredStoreAuthSummaries, type StoredStoreAuthSummary} from './stored-auth.js' - -export interface StoreAuthListEntry { - kind: 'store' - store: string - userId: string - scopes: string[] - connectedAt: string - expiresAt?: string - refreshTokenExpiresAt?: string - associatedUser?: StoredStoreAuthSummary['associatedUser'] -} - -export interface StoreAuthListResult { - sessions: StoreAuthListEntry[] -} +import {type StoreAuthListResult} from './list-types.js' +import {listStoredStoreAuthSummaries} from './stored-auth.js' +import {extractSubdomain, formatShortDate} from '../display.js' export function listStoreAuthSessions(): StoreAuthListResult { + const sessions = listStoredStoreAuthSummaries().map((summary) => ({ + subdomain: extractSubdomain(summary.store) ?? summary.store, + connected: formatShortDate(summary.acquiredAt), + })) + return { - sessions: listStoredStoreAuthSummaries().map((summary) => ({ - kind: 'store', - store: summary.store, - userId: summary.userId, - scopes: summary.scopes, - connectedAt: summary.acquiredAt, - ...(summary.expiresAt ? {expiresAt: summary.expiresAt} : {}), - ...(summary.refreshTokenExpiresAt ? {refreshTokenExpiresAt: summary.refreshTokenExpiresAt} : {}), - ...(summary.associatedUser ? {associatedUser: summary.associatedUser} : {}), - })), + sessions, + ...(sessions.length === 0 ? {message: emptyStateMessage()} : {}), } } + +function emptyStateMessage(): string { + return [ + 'No stores are authenticated directly with `shopify store auth`.', + '', + 'Run `shopify store auth --store --scopes ` to authenticate a store.', + 'Run `shopify store list` to list stores in a Shopify organization.', + ].join('\n') +} diff --git a/packages/store/src/cli/services/store/auth/result.test.ts b/packages/store/src/cli/services/store/auth/result.test.ts index 06f7ae1fc64..79f11eb28d2 100644 --- a/packages/store/src/cli/services/store/auth/result.test.ts +++ b/packages/store/src/cli/services/store/auth/result.test.ts @@ -1,53 +1,25 @@ -import {createStoreAuthPresenter} from './result.js' -import {afterEach, beforeEach, describe, expect, test, vi} from 'vitest' +import {presentStoreAuthResult} from './result.js' +import {beforeEach, describe, expect, test} from 'vitest' import {mockAndCaptureOutput} from '@shopify/cli-kit/node/testing/output' -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() - }, - } +const result = { + 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'}, } -describe('store auth presenter', () => { - const originalUnitTestEnv = process.env.SHOPIFY_UNIT_TEST - +describe('store auth result presenter', () => { 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'}, - }) + presentStoreAuthResult(result, 'text') expect(output.completed()).toContain('Logged in.') expect(output.completed()).toContain('Authenticated as merchant@example.com against shop.myshopify.com.') @@ -57,50 +29,13 @@ describe('store auth presenter', () => { expect(output.output()).not.toContain('"store": "shop.myshopify.com"') }) - test('writes json success output through the result channel', () => { + test('writes validated JSON 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'}, - }) + presentStoreAuthResult(result, 'json') - expect(output.output()).toContain('"store": "shop.myshopify.com"') + expect(JSON.parse(output.output())).toEqual(result) 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', async () => { - process.env.SHOPIFY_UNIT_TEST = 'false' - vi.resetModules() - const streams = captureStandardStreams() - const {createStoreAuthPresenter} = await import('./result.js') - 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/store/src/cli/services/store/auth/result.ts b/packages/store/src/cli/services/store/auth/result.ts index 58098a7c4f4..83e505e3d1e 100644 --- a/packages/store/src/cli/services/store/auth/result.ts +++ b/packages/store/src/cli/services/store/auth/result.ts @@ -1,34 +1,8 @@ -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 - } -} +import {storeAuthJsonOutputSchema, type StoreAuthResult} from './types.js' +import {outputCompleted, outputInfo, outputResult} from '@shopify/cli-kit/node/output' 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}` : '' @@ -42,20 +16,9 @@ function buildStoreAuthSuccessText(result: StoreAuthResult): {completed: string[ } } -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 { +export function presentStoreAuthResult(result: StoreAuthResult, format: StoreAuthOutputFormat = 'text'): void { if (format === 'json') { - outputResult(serializeStoreAuthResult(result)) + outputResult(storeAuthJsonOutputSchema.encode(result)) return } @@ -63,13 +26,3 @@ function displayStoreAuthResult(result: StoreAuthResult, format: StoreAuthOutput 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/store/src/cli/services/store/auth/session-lifecycle.ts b/packages/store/src/cli/services/store/auth/session-lifecycle.ts index 5cb8c7f090c..136ace60f29 100644 --- a/packages/store/src/cli/services/store/auth/session-lifecycle.ts +++ b/packages/store/src/cli/services/store/auth/session-lifecycle.ts @@ -69,13 +69,14 @@ export async function loadStoredStoreSession(store: string): Promise