From 12ce35542a2e93cec576801e7bb03bec0406f1a7 Mon Sep 17 00:00:00 2001 From: Gonzalo Riestra Date: Wed, 27 May 2026 16:02:57 +0200 Subject: [PATCH] Improve agent guidance for store auth --- .changeset/store-auth-resume.md | 6 + .../interfaces/store-auth.interface.ts | 4 +- .../generated/generated_docs_data_v2.json | 4 +- packages/cli/README.md | 19 +-- packages/cli/oclif.manifest.json | 6 +- .../store/src/cli/commands/store/auth.test.ts | 2 + packages/store/src/cli/commands/store/auth.ts | 16 ++- .../src/cli/services/store/auth/index.test.ts | 102 ++++++++++++++- .../src/cli/services/store/auth/index.ts | 118 ++++++++++++------ .../cli/services/store/auth/result.test.ts | 71 +++-------- .../src/cli/services/store/auth/result.ts | 2 + .../services/store/auth/session-store.test.ts | 1 + 12 files changed, 240 insertions(+), 111 deletions(-) create mode 100644 .changeset/store-auth-resume.md diff --git a/.changeset/store-auth-resume.md b/.changeset/store-auth-resume.md new file mode 100644 index 00000000000..e5fd90fbe80 --- /dev/null +++ b/.changeset/store-auth-resume.md @@ -0,0 +1,6 @@ +--- +'@shopify/store': minor +'@shopify/cli': minor +--- + +Add resumable non-interactive `shopify store auth`. diff --git a/docs-shopify.dev/commands/interfaces/store-auth.interface.ts b/docs-shopify.dev/commands/interfaces/store-auth.interface.ts index 926550e5104..0aa6876a325 100644 --- a/docs-shopify.dev/commands/interfaces/store-auth.interface.ts +++ b/docs-shopify.dev/commands/interfaces/store-auth.interface.ts @@ -20,13 +20,13 @@ export interface storeauth { * Comma-separated Admin API scopes to request for the app. * @environment SHOPIFY_FLAG_SCOPES */ - '--scopes ': string + '--scopes '?: string /** * The myshopify.com domain of the store to authenticate against. * @environment SHOPIFY_FLAG_STORE */ - '-s, --store ': string + '-s, --store '?: string /** * Increase the verbosity of the output. diff --git a/docs-shopify.dev/generated/generated_docs_data_v2.json b/docs-shopify.dev/generated/generated_docs_data_v2.json index 811b2c94321..01cfd8e7655 100644 --- a/docs-shopify.dev/generated/generated_docs_data_v2.json +++ b/docs-shopify.dev/generated/generated_docs_data_v2.json @@ -4190,6 +4190,7 @@ "name": "--scopes ", "value": "string", "description": "Comma-separated Admin API scopes to request for the app.", + "isOptional": true, "environmentValue": "SHOPIFY_FLAG_SCOPES" }, { @@ -4216,10 +4217,11 @@ "name": "-s, --store ", "value": "string", "description": "The myshopify.com domain of the store to authenticate against.", + "isOptional": true, "environmentValue": "SHOPIFY_FLAG_STORE" } ], - "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}" + "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}" } }, "storeexecute": { diff --git a/packages/cli/README.md b/packages/cli/README.md index 4f7060bb8a3..ff5dc6a765c 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -2157,15 +2157,14 @@ Authenticate an app against a store for store commands. ``` USAGE - $ shopify store auth --scopes -s [-j] [--no-color] [--verbose] + $ shopify store auth [-j] [--no-color] [--scopes ] [-s ] [--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. - --scopes= (required) [env: SHOPIFY_FLAG_SCOPES] Comma-separated Admin API scopes to request for the app. - --verbose [env: SHOPIFY_FLAG_VERBOSE] Increase the verbosity of the output. + -j, --json [env: SHOPIFY_FLAG_JSON] Output the result as JSON. Automatically disables color output. + -s, --store= [env: SHOPIFY_FLAG_STORE] The myshopify.com domain of the store to authenticate against. + --no-color [env: SHOPIFY_FLAG_NO_COLOR] Disable color output. + --scopes= [env: SHOPIFY_FLAG_SCOPES] Comma-separated Admin API scopes to request for the app. + --verbose [env: SHOPIFY_FLAG_VERBOSE] Increase the verbosity of the output. DESCRIPTION Authenticate an app against a store for store commands. @@ -2175,6 +2174,12 @@ DESCRIPTION Re-run this command if the stored token is missing, expires, or no longer has the scopes you need. + In an interactive terminal, Shopify CLI opens or prints the authorization URL and waits for authentication to complete. + Agents should keep the command running until the browser authorization finishes. + + In a non-TTY environment, Shopify CLI returns the current session if it already has the requested scopes. If no usable + session exists, it starts the same OAuth flow and waits for authentication to complete. + 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 a09a174ed0c..9c8c1525aa5 100644 --- a/packages/cli/oclif.manifest.json +++ b/packages/cli/oclif.manifest.json @@ -5730,8 +5730,8 @@ "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.", - "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.", + "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\nIn an interactive terminal, Shopify CLI opens or prints the authorization URL and waits for authentication to complete. Agents should keep the command running until the browser authorization finishes.\n\nIn a non-TTY environment, Shopify CLI returns the current session if it already has the requested scopes. If no usable session exists, it starts the same OAuth flow and waits for authentication to complete.", + "descriptionWithMarkdown": "Authenticates the app against the specified store for store commands and stores an online access token for later reuse.\n\nRe-run this command if the stored token is missing, expires, or no longer has the scopes you need.\n\nIn an interactive terminal, Shopify CLI opens or prints the authorization URL and waits for authentication to complete. Agents should keep the command running until the browser authorization finishes.\n\nIn a non-TTY environment, Shopify CLI returns the current session if it already has the requested scopes. If no usable session exists, it starts the same OAuth flow and waits for authentication to complete.", "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" @@ -5760,7 +5760,6 @@ "hasDynamicHelp": false, "multiple": false, "name": "scopes", - "required": true, "type": "option" }, "store": { @@ -5770,7 +5769,6 @@ "hasDynamicHelp": false, "multiple": false, "name": "store", - "required": true, "type": "option" }, "verbose": { diff --git a/packages/store/src/cli/commands/store/auth.test.ts b/packages/store/src/cli/commands/store/auth.test.ts index 2b3e0efa856..d1b4f5034ab 100644 --- a/packages/store/src/cli/commands/store/auth.test.ts +++ b/packages/store/src/cli/commands/store/auth.test.ts @@ -40,6 +40,8 @@ describe('store auth command', () => { expect(StoreAuth.flags.store).toBeDefined() expect(StoreAuth.flags.scopes).toBeDefined() expect(StoreAuth.flags.json).toBeDefined() + expect('resume' in StoreAuth.flags).toBe(false) + expect('callback-url' in StoreAuth.flags).toBe(false) expect('port' in StoreAuth.flags).toBe(false) expect('client-secret-file' in StoreAuth.flags).toBe(false) }) diff --git a/packages/store/src/cli/commands/store/auth.ts b/packages/store/src/cli/commands/store/auth.ts index 9a7c0eb2fdf..d3a1618980f 100644 --- a/packages/store/src/cli/commands/store/auth.ts +++ b/packages/store/src/cli/commands/store/auth.ts @@ -1,6 +1,7 @@ import {authenticateStoreWithApp} from '../../services/store/auth/index.js' import {createStoreAuthPresenter} from '../../services/store/auth/result.js' import StoreCommand from '../../utilities/store-command.js' +import {AbortError} from '@shopify/cli-kit/node/error' import {globalFlags, jsonFlag} from '@shopify/cli-kit/node/cli' import {normalizeStoreFqdn} from '@shopify/cli-kit/node/context/fqdn' import {Flags} from '@oclif/core' @@ -10,7 +11,11 @@ export default class StoreAuth extends StoreCommand { static descriptionWithMarkdown = `Authenticates the app against the specified store for store commands and stores an online access token for later reuse. -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. + +In an interactive terminal, Shopify CLI opens or prints the authorization URL and waits for authentication to complete. Agents should keep the command running until the browser authorization finishes. + +In a non-TTY environment, Shopify CLI returns the current session if it already has the requested scopes. If no usable session exists, it starts the same OAuth flow and waits for authentication to complete.` static description = this.descriptionWithoutMarkdown() @@ -27,17 +32,20 @@ Re-run this command if the stored token is missing, expires, or no longer has th description: 'The myshopify.com domain of the store to authenticate against.', env: 'SHOPIFY_FLAG_STORE', parse: async (input) => normalizeStoreFqdn(input), - required: true, }), scopes: Flags.string({ description: 'Comma-separated Admin API scopes to request for the app.', env: 'SHOPIFY_FLAG_SCOPES', - required: true, }), } public async run(): Promise { const {flags} = await this.parse(StoreAuth) + const presenter = createStoreAuthPresenter(flags.json ? 'json' : 'text') + + if (!flags.store || !flags.scopes) { + throw new AbortError('Missing required flags.', 'Pass --store and --scopes.') + } await authenticateStoreWithApp( { @@ -45,7 +53,7 @@ Re-run this command if the stored token is missing, expires, or no longer has th scopes: flags.scopes, }, { - presenter: createStoreAuthPresenter(flags.json ? 'json' : 'text'), + presenter, }, ) } 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 a02c10ce5ac..8a0cf653d2f 100644 --- a/packages/store/src/cli/services/store/auth/index.test.ts +++ b/packages/store/src/cli/services/store/auth/index.test.ts @@ -1,17 +1,27 @@ import {authenticateStoreWithApp} from './index.js' -import {setStoredStoreAppSession} from './session-store.js' +import {getCurrentStoredStoreAppSession, setStoredStoreAppSession} from './session-store.js' import {STORE_AUTH_APP_CLIENT_ID} from './config.js' import {recordStoreFqdnMetadata} from '../attribution.js' import {setLastSeenUserId} from '@shopify/cli-kit/node/session' -import {describe, expect, test, vi} from 'vitest' +import {randomUUID} from '@shopify/cli-kit/node/crypto' +import {terminalSupportsPrompting} from '@shopify/cli-kit/node/system' +import {beforeEach, describe, expect, test, vi} from 'vitest' vi.mock('./session-store.js') vi.mock('../attribution.js') vi.mock('@shopify/cli-kit/node/session') -vi.mock('@shopify/cli-kit/node/system', () => ({openURL: vi.fn().mockResolvedValue(true)})) +vi.mock('@shopify/cli-kit/node/system', () => ({ + openURL: vi.fn().mockResolvedValue(true), + terminalSupportsPrompting: vi.fn().mockReturnValue(true), +})) vi.mock('@shopify/cli-kit/node/crypto', () => ({randomUUID: vi.fn().mockReturnValue('state-123')})) describe('store auth service', () => { + beforeEach(() => { + vi.mocked(randomUUID).mockReturnValue('state-123') + vi.mocked(terminalSupportsPrompting).mockReturnValue(true) + }) + test('authenticateStoreWithApp opens the browser, stores the session, and returns auth result', async () => { const openURL = vi.fn().mockResolvedValue(true) const presenter = { @@ -77,6 +87,92 @@ describe('store auth service', () => { }) }) + test('authenticateStoreWithApp keeps waiting for auth when the terminal cannot prompt', 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' + }) + + const result = await authenticateStoreWithApp( + { + store: 'shop.myshopify.com', + scopes: 'read_products', + }, + { + openURL, + presenter, + terminalSupportsPrompting: vi.fn().mockReturnValue(false), + waitForStoreAuthCode: waitForStoreAuthCodeMock, + exchangeStoreAuthCodeForToken: vi.fn().mockResolvedValue({ + access_token: 'token', + scope: 'read_products', + expires_in: 86400, + associated_user: {id: 42, email: 'test@example.com'}, + }), + }, + ) + + expect(result).toEqual( + expect.objectContaining({ + store: 'shop.myshopify.com', + userId: '42', + scopes: ['read_products'], + }), + ) + expect(presenter.openingBrowser).toHaveBeenCalledOnce() + expect(presenter.manualAuthUrl).toHaveBeenCalledWith( + expect.stringContaining('https://shop.myshopify.com/admin/oauth/authorize?'), + ) + expect(presenter.success).toHaveBeenCalledWith(result) + }) + + test('authenticateStoreWithApp returns existing session without auth when non-TTY scopes are already granted', async () => { + const presenter = { + openingBrowser: vi.fn(), + manualAuthUrl: vi.fn(), + success: vi.fn(), + } + vi.mocked(getCurrentStoredStoreAppSession).mockReturnValue({ + store: 'shop.myshopify.com', + clientId: STORE_AUTH_APP_CLIENT_ID, + userId: '42', + accessToken: 'token', + scopes: ['read_products'], + acquiredAt: '2026-03-27T00:00:00.000Z', + associatedUser: {id: 42, email: 'test@example.com'}, + }) + + const result = await authenticateStoreWithApp( + { + store: 'shop.myshopify.com', + scopes: 'read_products', + }, + { + presenter, + resolveExistingScopes: vi.fn().mockResolvedValue({scopes: ['read_products'], authoritative: true}), + terminalSupportsPrompting: vi.fn().mockReturnValue(false), + waitForStoreAuthCode: vi.fn(), + exchangeStoreAuthCodeForToken: vi.fn(), + }, + ) + + expect(result).toEqual( + expect.objectContaining({ + store: 'shop.myshopify.com', + userId: '42', + scopes: ['read_products'], + associatedUser: expect.objectContaining({email: 'test@example.com'}), + }), + ) + expect(presenter.success).toHaveBeenCalledWith(result) + }) + test('authenticateStoreWithApp uses remote scopes by default when available', async () => { const openURL = vi.fn().mockResolvedValue(true) const presenter = { diff --git a/packages/store/src/cli/services/store/auth/index.ts b/packages/store/src/cli/services/store/auth/index.ts index 8856623f96c..01546bb6372 100644 --- a/packages/store/src/cli/services/store/auth/index.ts +++ b/packages/store/src/cli/services/store/auth/index.ts @@ -1,14 +1,15 @@ import {STORE_AUTH_APP_CLIENT_ID} from './config.js' -import {setStoredStoreAppSession} from './session-store.js' +import {setStoredStoreAppSession, type StoredStoreAppSession} from './session-store.js' import {exchangeStoreAuthCodeForToken} from './token-client.js' 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 {loadStoredStoreSession} from './session-lifecycle.js' import {createStoreAuthPresenter, type StoreAuthPresenter, type StoreAuthResult} from './result.js' import {recordStoreFqdnMetadata} from '../attribution.js' import {setLastSeenUserId} from '@shopify/cli-kit/node/session' -import {openURL} from '@shopify/cli-kit/node/system' +import {openURL, terminalSupportsPrompting} from '@shopify/cli-kit/node/system' import {outputContent, outputDebug, outputToken} from '@shopify/cli-kit/node/output' import {AbortError} from '@shopify/cli-kit/node/error' import {normalizeStoreFqdn} from '@shopify/cli-kit/node/context/fqdn' @@ -24,6 +25,7 @@ interface StoreAuthDependencies { exchangeStoreAuthCodeForToken: typeof exchangeStoreAuthCodeForToken resolveExistingScopes: (store: string) => Promise presenter: StoreAuthPresenter + terminalSupportsPrompting: typeof terminalSupportsPrompting } const defaultStoreAuthDependencies: StoreAuthDependencies = { @@ -32,45 +34,31 @@ const defaultStoreAuthDependencies: StoreAuthDependencies = { exchangeStoreAuthCodeForToken, resolveExistingScopes: resolveExistingStoreAuthScopes, presenter: createStoreAuthPresenter('text'), + terminalSupportsPrompting, } -export async function authenticateStoreWithApp( - input: StoreAuthInput, - dependencies: Partial = {}, -): Promise { - const resolvedDependencies: StoreAuthDependencies = {...defaultStoreAuthDependencies, ...dependencies} - const store = normalizeStoreFqdn(input.store) - await recordStoreFqdnMetadata(store, false) - const requestedScopes = parseStoreAuthScopes(input.scopes) - const existingScopeResolution = await resolvedDependencies.resolveExistingScopes(store) - const scopes = mergeRequestedAndStoredScopes(requestedScopes, existingScopeResolution.scopes) - const validationScopes = existingScopeResolution.authoritative ? scopes : requestedScopes - - if (existingScopeResolution.scopes.length > 0) { - outputDebug( - outputContent`Merged requested scopes ${outputToken.raw(requestedScopes.join(','))} with existing scopes ${outputToken.raw(existingScopeResolution.scopes.join(','))} for ${outputToken.raw(store)}`, - ) - } - - const bootstrap = createPkceBootstrap({ - store, +function storedSessionToStoreAuthResult( + session: StoredStoreAppSession, + scopes: string[], + acquiredAt = session.acquiredAt, +): StoreAuthResult { + return { + store: session.store, + userId: session.userId, scopes, - exchangeCodeForToken: resolvedDependencies.exchangeStoreAuthCodeForToken, - }) - const { - authorization: {authorizationUrl}, - } = bootstrap - - resolvedDependencies.presenter.openingBrowser() + acquiredAt, + expiresAt: session.expiresAt, + refreshTokenExpiresAt: session.refreshTokenExpiresAt, + hasRefreshToken: Boolean(session.refreshToken), + associatedUser: session.associatedUser, + } +} - const code = await resolvedDependencies.waitForStoreAuthCode({ - ...bootstrap.waitForAuthCodeOptions, - onListening: async () => { - const opened = await resolvedDependencies.openURL(authorizationUrl) - if (!opened) resolvedDependencies.presenter.manualAuthUrl(authorizationUrl) - }, - }) - const tokenResponse = await bootstrap.exchangeCodeForToken(code) +async function persistStoreAuthToken( + tokenResponse: Awaited>, + store: string, + validationScopes: string[], +): Promise { await recordStoreFqdnMetadata(store, true) const userId = tokenResponse.associated_user?.id?.toString() @@ -81,7 +69,6 @@ export async function authenticateStoreWithApp( const now = Date.now() const expiresAt = tokenResponse.expires_in ? new Date(now + tokenResponse.expires_in * 1000).toISOString() : undefined - const result: StoreAuthResult = { store, userId, @@ -120,6 +107,61 @@ export async function authenticateStoreWithApp( outputContent`Session persisted for ${outputToken.raw(store)} (user ${outputToken.raw(userId)}, expires ${outputToken.raw(expiresAt ?? 'unknown')})`, ) + return result +} + +export async function authenticateStoreWithApp( + input: StoreAuthInput, + dependencies: Partial = {}, +): Promise { + const resolvedDependencies: StoreAuthDependencies = {...defaultStoreAuthDependencies, ...dependencies} + const store = normalizeStoreFqdn(input.store) + await recordStoreFqdnMetadata(store, false) + const requestedScopes = parseStoreAuthScopes(input.scopes) + const existingScopeResolution = await resolvedDependencies.resolveExistingScopes(store) + const scopes = mergeRequestedAndStoredScopes(requestedScopes, existingScopeResolution.scopes) + const validationScopes = existingScopeResolution.authoritative ? scopes : requestedScopes + + if (existingScopeResolution.scopes.length > 0) { + outputDebug( + outputContent`Merged requested scopes ${outputToken.raw(requestedScopes.join(','))} with existing scopes ${outputToken.raw(existingScopeResolution.scopes.join(','))} for ${outputToken.raw(store)}`, + ) + } + + const bootstrap = createPkceBootstrap({ + store, + scopes, + exchangeCodeForToken: resolvedDependencies.exchangeStoreAuthCodeForToken, + }) + const { + authorization: {authorizationUrl}, + } = bootstrap + + if (!resolvedDependencies.terminalSupportsPrompting()) { + const existingMergedScopes = mergeRequestedAndStoredScopes(requestedScopes, existingScopeResolution.scopes) + if ( + existingScopeResolution.authoritative && + existingMergedScopes.length === existingScopeResolution.scopes.length && + existingMergedScopes.every((scope) => existingScopeResolution.scopes.includes(scope)) + ) { + const session = await loadStoredStoreSession(store) + const result = storedSessionToStoreAuthResult(session, existingScopeResolution.scopes) + resolvedDependencies.presenter.success(result) + return result + } + } + + resolvedDependencies.presenter.openingBrowser() + + const code = await resolvedDependencies.waitForStoreAuthCode({ + ...bootstrap.waitForAuthCodeOptions, + onListening: async () => { + const opened = await resolvedDependencies.openURL(authorizationUrl) + if (!opened) resolvedDependencies.presenter.manualAuthUrl(authorizationUrl) + }, + }) + const result = await persistStoreAuthToken(await bootstrap.exchangeCodeForToken(code), store, validationScopes) + resolvedDependencies.presenter.success(result) return result } 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 29e11516d71..4f21a052373 100644 --- a/packages/store/src/cli/services/store/auth/result.test.ts +++ b/packages/store/src/cli/services/store/auth/result.test.ts @@ -1,41 +1,12 @@ import {createStoreAuthPresenter} from './result.js' -import {afterEach, beforeEach, describe, expect, test, vi} from 'vitest' +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() - }, - } -} - 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') @@ -75,30 +46,26 @@ describe('store auth presenter', () => { 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() + test('writes browser guidance and json success output', () => { + const output = mockAndCaptureOutput() 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() - } + 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'}, + }) - 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') + expect(output.info()).toContain('Shopify CLI will open the app authorization page in your browser.') + expect(output.info()).toContain('Keep this command running until authentication completes in the browser.') + expect(output.info()).toContain('Browser did not open automatically. Open this URL manually:') + expect(output.info()).toContain('https://shop.myshopify.com/admin/oauth/authorize?client_id=test') + expect(output.output()).toContain('"store": "shop.myshopify.com"') + expect(output.output()).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..50831e3e093 100644 --- a/packages/store/src/cli/services/store/auth/result.ts +++ b/packages/store/src/cli/services/store/auth/result.ts @@ -44,12 +44,14 @@ function buildStoreAuthSuccessText(result: StoreAuthResult): {completed: string[ function displayStoreAuthOpeningBrowser(): void { outputInfo('Shopify CLI will open the app authorization page in your browser.') + outputInfo('Keep this command running until authentication completes in the browser.') outputInfo('') } function displayStoreAuthManualAuthUrl(authorizationUrl: string): void { outputInfo('Browser did not open automatically. Open this URL manually:') outputInfo(outputContent`${outputToken.link(authorizationUrl)}`) + outputInfo('Keep this command running until authentication completes in the browser.') outputInfo('') } diff --git a/packages/store/src/cli/services/store/auth/session-store.test.ts b/packages/store/src/cli/services/store/auth/session-store.test.ts index 523060f5975..a7e29f46021 100644 --- a/packages/store/src/cli/services/store/auth/session-store.test.ts +++ b/packages/store/src/cli/services/store/auth/session-store.test.ts @@ -166,4 +166,5 @@ describe('store session storage', () => { expect(() => clearStoredStoreAppSession('shop.myshopify.com', '42', storage as any)).not.toThrow() expect(storage.get(storeAuthSessionKey('shop.myshopify.com'))).toBeUndefined() }) + })