diff --git a/docs-shopify.dev/commands/store-auth.doc.ts b/docs-shopify.dev/commands/store-auth.doc.ts index 15438784f2d..8f06c2b8c9e 100644 --- a/docs-shopify.dev/commands/store-auth.doc.ts +++ b/docs-shopify.dev/commands/store-auth.doc.ts @@ -3,9 +3,9 @@ import {ReferenceEntityTemplateSchema} from '@shopify/generate-docs' const data: ReferenceEntityTemplateSchema = { name: 'store auth', - description: `Starts a PKCE OAuth flow against the specified store and stores an online access token for later use by \`shopify store execute\`. + description: `Authenticates the app against the specified store for store commands and stores an online access token for later reuse. -This flow authenticates the app on behalf of the current user. Re-run this command if the stored token is missing, expires, or no longer has the scopes you need.`, +Re-run this command if the stored token is missing, expires, or no longer has the scopes you need.`, overviewPreviewDescription: `Authenticate an app against a store for store commands.`, type: 'command', isVisualComponent: false, diff --git a/docs-shopify.dev/generated/generated_docs_data.json b/docs-shopify.dev/generated/generated_docs_data.json index 2209c497d21..231885f03fa 100644 --- a/docs-shopify.dev/generated/generated_docs_data.json +++ b/docs-shopify.dev/generated/generated_docs_data.json @@ -5776,7 +5776,7 @@ }, { "name": "store auth", - "description": "Starts a PKCE OAuth flow against the specified store and stores an online access token for later use by `shopify store execute`.\n\nThis flow authenticates the app on behalf of the current user. Re-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.", "overviewPreviewDescription": "Authenticate an app against a store for store commands.", "type": "command", "isVisualComponent": false, diff --git a/packages/cli/README.md b/packages/cli/README.md index ce8543ddce3..ce2d7756c14 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -2069,11 +2069,10 @@ FLAGS DESCRIPTION Authenticate an app against a store for store commands. - Starts a PKCE OAuth flow against the specified store and stores an online access token for later use by `shopify store - execute`. + Authenticates the app against the specified store for store commands and stores an online access token for later + reuse. - This flow authenticates the app on behalf of the current user. Re-run this command if the stored token is missing, - expires, or no longer has the scopes you need. + Re-run this command if the stored token is missing, expires, or no longer has the scopes you need. EXAMPLES $ shopify store auth --store shop.myshopify.com --scopes read_products,write_products diff --git a/packages/cli/oclif.manifest.json b/packages/cli/oclif.manifest.json index 1cc281c086f..8377879e8fe 100644 --- a/packages/cli/oclif.manifest.json +++ b/packages/cli/oclif.manifest.json @@ -5739,8 +5739,8 @@ ], "args": { }, - "description": "Starts a PKCE OAuth flow against the specified store and stores an online access token for later use by `shopify store execute`.\n\nThis flow authenticates the app on behalf of the current user. Re-run this command if the stored token is missing, expires, or no longer has the scopes you need.", - "descriptionWithMarkdown": "Starts a PKCE OAuth flow against the specified store and stores an online access token for later use by `shopify store execute`.\n\nThis flow authenticates the app on behalf of the current user. Re-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.", + "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" diff --git a/packages/cli/src/cli/commands/store/auth.ts b/packages/cli/src/cli/commands/store/auth.ts index b55a405b4eb..1fe48e3f3dc 100644 --- a/packages/cli/src/cli/commands/store/auth.ts +++ b/packages/cli/src/cli/commands/store/auth.ts @@ -7,9 +7,9 @@ import {authenticateStoreWithApp} from '../../services/store/auth.js' export default class StoreAuth extends Command { static summary = 'Authenticate an app against a store for store commands.' - static descriptionWithMarkdown = `Starts a PKCE OAuth flow against the specified store and stores an online access token for later use by \`shopify store execute\`. + static descriptionWithMarkdown = `Authenticates the app against the specified store for store commands and stores an online access token for later reuse. -This flow authenticates the app on behalf of the current user. Re-run this command if the stored token is missing, expires, or no longer has the scopes you need.` +Re-run this command if the stored token is missing, expires, or no longer has the scopes you need.` static description = this.descriptionWithoutMarkdown() diff --git a/packages/cli/src/cli/services/store/admin-graphql-context.test.ts b/packages/cli/src/cli/services/store/admin-graphql-context.test.ts index fb7288e3ac2..137c3f2761f 100644 --- a/packages/cli/src/cli/services/store/admin-graphql-context.test.ts +++ b/packages/cli/src/cli/services/store/admin-graphql-context.test.ts @@ -100,7 +100,11 @@ describe('prepareAdminStoreGraphQLContext', () => { test('throws when no stored auth exists', async () => { vi.mocked(getStoredStoreAppSession).mockReturnValue(undefined) - await expect(prepareAdminStoreGraphQLContext({store})).rejects.toThrow('No stored app authentication found') + await expect(prepareAdminStoreGraphQLContext({store})).rejects.toMatchObject({ + message: `No stored app authentication found for ${store}.`, + tryMessage: 'To create stored auth for this store, run:', + nextSteps: [[{command: `shopify store auth --store ${store} --scopes `}]], + }) }) test('clears stored auth when token refresh fails', async () => { @@ -111,7 +115,11 @@ describe('prepareAdminStoreGraphQLContext', () => { text: vi.fn().mockResolvedValue('bad refresh'), } as any) - await expect(prepareAdminStoreGraphQLContext({store})).rejects.toThrow('Token refresh failed') + await expect(prepareAdminStoreGraphQLContext({store})).rejects.toMatchObject({ + message: `Token refresh failed for ${store} (HTTP 401).`, + tryMessage: 'To re-authenticate, run:', + nextSteps: [[{command: `shopify store auth --store ${store} --scopes read_products`}]], + }) expect(clearStoredStoreAppSession).toHaveBeenCalledWith(store, '42') }) @@ -119,7 +127,11 @@ describe('prepareAdminStoreGraphQLContext', () => { vi.mocked(isSessionExpired).mockReturnValue(true) vi.mocked(getStoredStoreAppSession).mockReturnValue({...storedSession, refreshToken: undefined}) - await expect(prepareAdminStoreGraphQLContext({store})).rejects.toThrow('No refresh token stored') + await expect(prepareAdminStoreGraphQLContext({store})).rejects.toMatchObject({ + message: `No refresh token stored for ${store}.`, + tryMessage: 'To re-authenticate, run:', + nextSteps: [[{command: `shopify store auth --store ${store} --scopes read_products`}]], + }) expect(clearStoredStoreAppSession).not.toHaveBeenCalled() }) @@ -130,7 +142,11 @@ describe('prepareAdminStoreGraphQLContext', () => { text: vi.fn().mockResolvedValue(JSON.stringify({refresh_token: 'fresh-refresh-token'})), } as any) - await expect(prepareAdminStoreGraphQLContext({store})).rejects.toThrow('Token refresh returned an invalid response') + await expect(prepareAdminStoreGraphQLContext({store})).rejects.toMatchObject({ + message: `Token refresh returned an invalid response for ${store}.`, + tryMessage: 'To re-authenticate, run:', + nextSteps: [[{command: `shopify store auth --store ${store} --scopes read_products`}]], + }) expect(clearStoredStoreAppSession).toHaveBeenCalledWith(store, '42') }) @@ -150,7 +166,11 @@ describe('prepareAdminStoreGraphQLContext', () => { new AbortError(`Error connecting to your store ${store}: unauthorized 401 {}`), ) - await expect(prepareAdminStoreGraphQLContext({store})).rejects.toThrow('Stored app authentication for') + await expect(prepareAdminStoreGraphQLContext({store})).rejects.toMatchObject({ + message: `Stored app authentication for ${store} is no longer valid.`, + tryMessage: 'To re-authenticate, run:', + nextSteps: [[{command: `shopify store auth --store ${store} --scopes read_products`}]], + }) expect(clearStoredStoreAppSession).toHaveBeenCalledWith(store, '42') }) diff --git a/packages/cli/src/cli/services/store/admin-graphql-context.ts b/packages/cli/src/cli/services/store/admin-graphql-context.ts index 15784617c14..f014e5b9e9e 100644 --- a/packages/cli/src/cli/services/store/admin-graphql-context.ts +++ b/packages/cli/src/cli/services/store/admin-graphql-context.ts @@ -4,6 +4,7 @@ import {fetch} from '@shopify/cli-kit/node/http' import {outputContent, outputDebug, outputToken} from '@shopify/cli-kit/node/output' import {AdminSession} from '@shopify/cli-kit/node/session' import {maskToken, STORE_AUTH_APP_CLIENT_ID} from './auth-config.js' +import {createStoredStoreAuthError, reauthenticateStoreAuthError} from './auth-recovery.js' import { clearStoredStoreAppSession, getStoredStoreAppSession, @@ -20,10 +21,7 @@ export interface AdminStoreGraphQLContext { async function refreshStoreToken(session: StoredStoreAppSession): Promise { if (!session.refreshToken) { - throw new AbortError( - `No refresh token stored for ${session.store}.`, - `Run ${outputToken.genericShellCommand(`shopify store auth --store ${session.store} --scopes ${session.scopes.join(',')}`).value} to re-authenticate.`, - ) + throw reauthenticateStoreAuthError(`No refresh token stored for ${session.store}.`, session.store, session.scopes.join(',')) } const endpoint = `https://${session.store}/admin/oauth/access_token` @@ -49,9 +47,10 @@ async function refreshStoreToken(session: StoredStoreAppSession): Promise`).value} to create stored auth for this store.`, - ) + throw createStoredStoreAuthError(store) } outputDebug( @@ -133,9 +130,10 @@ async function resolveApiVersion(options: { /\b(?:401|404)\b/.test(error.message) ) { clearStoredStoreAppSession(session.store, session.userId) - throw new AbortError( + throw reauthenticateStoreAuthError( `Stored app authentication for ${session.store} is no longer valid.`, - `Run ${outputToken.genericShellCommand(`shopify store auth --store ${session.store} --scopes ${session.scopes.join(',')}`).value} to re-authenticate.`, + session.store, + session.scopes.join(','), ) } diff --git a/packages/cli/src/cli/services/store/admin-graphql-transport.test.ts b/packages/cli/src/cli/services/store/admin-graphql-transport.test.ts index fa196baf79e..a0d03fc1051 100644 --- a/packages/cli/src/cli/services/store/admin-graphql-transport.test.ts +++ b/packages/cli/src/cli/services/store/admin-graphql-transport.test.ts @@ -56,7 +56,11 @@ describe('runAdminStoreGraphQLOperation', () => { await expect( runAdminStoreGraphQLOperation({store, adminSession, sessionUserId: '42', version: '2025-10', request}), - ).rejects.toThrow('Stored app authentication for') + ).rejects.toMatchObject({ + message: `Stored app authentication for ${store} is no longer valid.`, + tryMessage: 'To re-authenticate, run:', + nextSteps: [[{command: `shopify store auth --store ${store} --scopes `}]], + }) expect(clearStoredStoreAppSession).toHaveBeenCalledWith(store, '42') }) diff --git a/packages/cli/src/cli/services/store/admin-graphql-transport.ts b/packages/cli/src/cli/services/store/admin-graphql-transport.ts index 52ffcf67321..1a104ee160e 100644 --- a/packages/cli/src/cli/services/store/admin-graphql-transport.ts +++ b/packages/cli/src/cli/services/store/admin-graphql-transport.ts @@ -1,11 +1,12 @@ import {adminUrl} from '@shopify/cli-kit/node/api/admin' import {graphqlRequest} from '@shopify/cli-kit/node/api/graphql' import {AbortError} from '@shopify/cli-kit/node/error' -import {clearStoredStoreAppSession} from './session.js' -import {outputContent, outputToken} from '@shopify/cli-kit/node/output' +import {outputContent} from '@shopify/cli-kit/node/output' import {AdminSession} from '@shopify/cli-kit/node/session' import {renderSingleTask} from '@shopify/cli-kit/node/ui' +import {reauthenticateStoreAuthError} from './auth-recovery.js' import {PreparedStoreExecuteRequest} from './execute-request.js' +import {clearStoredStoreAppSession} from './session.js' function isGraphQLClientError(error: unknown): error is {response: {errors?: unknown; status?: number}} { if (!error || typeof error !== 'object' || !('response' in error)) return false @@ -38,9 +39,10 @@ export async function runAdminStoreGraphQLOperation(input: { } catch (error) { if (isGraphQLClientError(error) && error.response.status === 401) { clearStoredStoreAppSession(input.store, input.sessionUserId) - throw new AbortError( + throw reauthenticateStoreAuthError( `Stored app authentication for ${input.store} is no longer valid.`, - `Run ${outputToken.genericShellCommand(`shopify store auth --store ${input.store} --scopes `).value} to re-authenticate.`, + input.store, + '', ) } diff --git a/packages/cli/src/cli/services/store/auth-recovery.ts b/packages/cli/src/cli/services/store/auth-recovery.ts new file mode 100644 index 00000000000..2308f3faead --- /dev/null +++ b/packages/cli/src/cli/services/store/auth-recovery.ts @@ -0,0 +1,29 @@ +import {AbortError} from '@shopify/cli-kit/node/error' + +function storeAuthCommand(store: string, scopes: string): {command: string} { + return {command: `shopify store auth --store ${store} --scopes ${scopes}`} +} + +function storeAuthCommandNextSteps(store: string, scopes: string) { + return [[storeAuthCommand(store, scopes)]] +} + +export function createStoredStoreAuthError(store: string): AbortError { + return new AbortError( + `No stored app authentication found for ${store}.`, + 'To create stored auth for this store, run:', + storeAuthCommandNextSteps(store, ''), + ) +} + +export function reauthenticateStoreAuthError(message: string, store: string, scopes: string): AbortError { + return new AbortError(message, 'To re-authenticate, run:', storeAuthCommandNextSteps(store, scopes)) +} + +export function retryStoreAuthWithPermanentDomainError(returnedStore: string): AbortError { + return new AbortError( + 'OAuth callback store does not match the requested store.', + `Shopify returned ${returnedStore} during authentication. Re-run using the permanent store domain:`, + storeAuthCommandNextSteps(returnedStore, ''), + ) +} diff --git a/packages/cli/src/cli/services/store/auth.test.ts b/packages/cli/src/cli/services/store/auth.test.ts index 708416cc7e6..0ae4fe7c94e 100644 --- a/packages/cli/src/cli/services/store/auth.test.ts +++ b/packages/cli/src/cli/services/store/auth.test.ts @@ -174,8 +174,8 @@ describe('store auth service', () => { }), ).rejects.toMatchObject({ message: 'OAuth callback store does not match the requested store.', - tryMessage: - 'Shopify returned other-shop.myshopify.com during authentication. Re-run shopify store auth --store other-shop.myshopify.com --scopes using the permanent store domain instead of an alias or vanity domain.', + tryMessage: 'Shopify returned other-shop.myshopify.com during authentication. Re-run using the permanent store domain:', + nextSteps: [[{command: 'shopify store auth --store other-shop.myshopify.com --scopes '}]], }) }) @@ -303,7 +303,11 @@ describe('store auth service', () => { test('authenticateStoreWithApp opens the browser and stores the session with refresh token', async () => { const openURL = vi.fn().mockResolvedValue(true) - const renderSuccess = vi.fn() + const presenter = { + openingBrowser: vi.fn(), + manualAuthUrl: vi.fn(), + success: vi.fn(), + } const waitForStoreAuthCodeMock = vi.fn().mockImplementation(async (options) => { await options.onListening?.() return 'abc123' @@ -324,16 +328,14 @@ describe('store auth service', () => { refresh_token: 'refresh-token', associated_user: {id: 42, email: 'test@example.com'}, }), - renderInfo: vi.fn(), - renderSuccess, + presenter, }, ) + expect(presenter.openingBrowser).toHaveBeenCalledOnce() expect(openURL).toHaveBeenCalledWith(expect.stringContaining('/admin/oauth/authorize?')) - expect(renderSuccess).toHaveBeenCalledWith({ - headline: 'Store authentication succeeded.', - body: 'Authenticated as test@example.com against shop.myshopify.com.', - }) + expect(presenter.manualAuthUrl).not.toHaveBeenCalled() + expect(presenter.success).toHaveBeenCalledWith('shop.myshopify.com', 'test@example.com') const storedSession = vi.mocked(setStoredStoreAppSession).mock.calls[0]![0] expect(storedSession.store).toBe('shop.myshopify.com') @@ -352,6 +354,43 @@ 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 waitForStoreAuthCodeMock = vi.fn().mockImplementation(async (options) => { + await options.onListening?.() + return 'abc123' + }) + + await authenticateStoreWithApp( + { + store: 'shop.myshopify.com', + scopes: 'read_products', + }, + { + openURL, + waitForStoreAuthCode: waitForStoreAuthCodeMock, + exchangeStoreAuthCodeForToken: vi.fn().mockResolvedValue({ + access_token: 'token', + scope: 'read_products', + 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('shop.myshopify.com', 'test@example.com') + }) + test('authenticateStoreWithApp rejects when Shopify grants fewer scopes than requested', async () => { const waitForStoreAuthCodeMock = vi.fn().mockImplementation(async (options) => { await options.onListening?.() @@ -373,11 +412,22 @@ describe('store auth service', () => { expires_in: 86400, associated_user: {id: 42, email: 'test@example.com'}, }), - renderInfo: vi.fn(), - renderSuccess: vi.fn(), + presenter: { + openingBrowser: vi.fn(), + manualAuthUrl: vi.fn(), + success: vi.fn(), + }, }, ), - ).rejects.toThrow('Shopify granted fewer scopes than were requested.') + ).rejects.toMatchObject({ + message: 'Shopify granted fewer scopes than were requested.', + tryMessage: 'Missing scopes: write_products.', + nextSteps: [ + 'Update the app or store installation scopes.', + 'See https://shopify.dev/app/scopes', + 'Re-run shopify store auth.', + ], + }) expect(setStoredStoreAppSession).not.toHaveBeenCalled() }) @@ -402,8 +452,11 @@ describe('store auth service', () => { expires_in: 86400, associated_user: {id: 42, email: 'test@example.com'}, }), - renderInfo: vi.fn(), - renderSuccess: vi.fn(), + presenter: { + openingBrowser: vi.fn(), + manualAuthUrl: vi.fn(), + success: vi.fn(), + }, }, ) @@ -436,8 +489,11 @@ describe('store auth service', () => { expires_in: 86400, associated_user: {id: 42, email: 'test@example.com'}, }), - renderInfo: vi.fn(), - renderSuccess: vi.fn(), + presenter: { + openingBrowser: vi.fn(), + manualAuthUrl: vi.fn(), + success: vi.fn(), + }, }, ), ).rejects.toThrow('Shopify granted fewer scopes than were requested.') @@ -464,8 +520,11 @@ describe('store auth service', () => { expires_in: 86400, associated_user: {id: 42, email: 'test@example.com'}, }), - renderInfo: vi.fn(), - renderSuccess: vi.fn(), + presenter: { + openingBrowser: vi.fn(), + manualAuthUrl: vi.fn(), + success: vi.fn(), + }, }, ) @@ -497,8 +556,11 @@ describe('store auth service', () => { expires_in: 86400, associated_user: {id: 42, email: 'test@example.com'}, }), - renderInfo: vi.fn(), - renderSuccess: vi.fn(), + presenter: { + openingBrowser: vi.fn(), + manualAuthUrl: vi.fn(), + success: vi.fn(), + }, }, ) diff --git a/packages/cli/src/cli/services/store/auth.ts b/packages/cli/src/cli/services/store/auth.ts index e6bdeb8234f..0b38fc842d3 100644 --- a/packages/cli/src/cli/services/store/auth.ts +++ b/packages/cli/src/cli/services/store/auth.ts @@ -1,12 +1,12 @@ import {DEFAULT_STORE_AUTH_PORT, STORE_AUTH_APP_CLIENT_ID, STORE_AUTH_CALLBACK_PATH, maskToken, storeAuthRedirectUri} from './auth-config.js' +import {retryStoreAuthWithPermanentDomainError} from './auth-recovery.js' import {setStoredStoreAppSession} from './session.js' import {normalizeStoreFqdn} from '@shopify/cli-kit/node/context/fqdn' import {randomUUID} from '@shopify/cli-kit/node/crypto' import {AbortError} from '@shopify/cli-kit/node/error' import {fetch} from '@shopify/cli-kit/node/http' -import {outputContent, outputDebug, outputInfo, outputToken} from '@shopify/cli-kit/node/output' +import {outputCompleted, outputContent, outputDebug, outputInfo, outputToken} from '@shopify/cli-kit/node/output' import {openURL} from '@shopify/cli-kit/node/system' -import {renderInfo, renderSuccess} from '@shopify/cli-kit/node/ui' import {createHash, randomBytes, timingSafeEqual} from 'crypto' import {createServer} from 'http' @@ -106,7 +106,12 @@ function resolveGrantedScopes(tokenResponse: StoreTokenResponse, requestedScopes if (missingScopes.length > 0) { throw new AbortError( 'Shopify granted fewer scopes than were requested.', - `Missing scopes: ${missingScopes.join(', ')}. Update the app or store installation scopes, then re-run shopify store auth.`, + `Missing scopes: ${missingScopes.join(', ')}.`, + [ + 'Update the app or store installation scopes.', + 'See https://shopify.dev/app/scopes', + 'Re-run shopify store auth.', + ], ) } @@ -190,12 +195,14 @@ export async function waitForStoreAuthCode({ const {searchParams} = requestUrl - const fail = (message: string, tryMessage?: string) => { + const fail = (error: AbortError | string, tryMessage?: string) => { + const abortError = typeof error === 'string' ? new AbortError(error, tryMessage) : error + res.statusCode = 400 res.setHeader('Content-Type', 'text/html') res.setHeader('Connection', 'close') - res.once('finish', () => settleWithError(new AbortError(message, tryMessage))) - res.end(renderAuthCallbackPage('Authentication failed', message)) + res.once('finish', () => settleWithError(abortError)) + res.end(renderAuthCallbackPage('Authentication failed', abortError.message)) } const returnedStore = searchParams.get('shop') @@ -208,10 +215,7 @@ export async function waitForStoreAuthCode({ const normalizedReturnedStore = normalizeStoreFqdn(returnedStore) if (normalizedReturnedStore !== normalizedStore) { - fail( - 'OAuth callback store does not match the requested store.', - `Shopify returned ${normalizedReturnedStore} during authentication. Re-run ${outputToken.genericShellCommand(`shopify store auth --store ${normalizedReturnedStore} --scopes `).value} using the permanent store domain instead of an alias or vanity domain.`, - ) + fail(retryStoreAuthWithPermanentDomainError(normalizedReturnedStore)) return } @@ -349,20 +353,45 @@ export async function exchangeStoreAuthCodeForToken(options: { return parsed } +interface StoreAuthPresenter { + openingBrowser: () => void + manualAuthUrl: (authorizationUrl: string) => void + success: (store: string, email?: string) => void +} + interface StoreAuthDependencies { openURL: typeof openURL waitForStoreAuthCode: typeof waitForStoreAuthCode exchangeStoreAuthCodeForToken: typeof exchangeStoreAuthCodeForToken - renderInfo: typeof renderInfo - renderSuccess: typeof renderSuccess + 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, - renderInfo, - renderSuccess, + presenter: defaultStoreAuthPresenter, } function createPkceBootstrap( @@ -410,20 +439,13 @@ export async function authenticateStoreWithApp( authorization: {store, scopes, redirectUri, authorizationUrl}, } = bootstrap - dependencies.renderInfo({ - headline: 'Authenticate the app against your store.', - body: `Shopify CLI will open the app authorization page in your browser.`, - }) + dependencies.presenter.openingBrowser() const code = await dependencies.waitForStoreAuthCode({ ...bootstrap.waitForAuthCodeOptions, onListening: async () => { const opened = await dependencies.openURL(authorizationUrl) - if (!opened) { - outputInfo('Browser did not open automatically. Open this URL manually:') - outputInfo(authorizationUrl) - outputInfo('') - } + if (!opened) dependencies.presenter.manualAuthUrl(authorizationUrl) }, }) const tokenResponse = await bootstrap.exchangeCodeForToken(code) @@ -466,13 +488,5 @@ export async function authenticateStoreWithApp( outputContent`Session persisted for ${outputToken.raw(store)} (user ${outputToken.raw(userId)}, expires ${outputToken.raw(expiresAt ?? 'unknown')})`, ) - const email = tokenResponse.associated_user?.email - const displayName = email ? ` as ${email}` : '' - - dependencies.renderSuccess({ - headline: 'Store authentication succeeded.', - body: `Authenticated${displayName} against ${store}.`, - }) - - outputInfo(`Next step: shopify store execute --store ${store} --query 'query { shop { name id } }'`) + dependencies.presenter.success(store, tokenResponse.associated_user?.email) }