diff --git a/.changeset/resumable-auth-login.md b/.changeset/resumable-auth-login.md new file mode 100644 index 00000000000..049661c2b2f --- /dev/null +++ b/.changeset/resumable-auth-login.md @@ -0,0 +1,6 @@ +--- +'@shopify/cli': minor +'@shopify/cli-kit': minor +--- + +Add resumable non-interactive `shopify auth login` with `--resume` and `--new`. diff --git a/docs-shopify.dev/commands/interfaces/auth-login.interface.ts b/docs-shopify.dev/commands/interfaces/auth-login.interface.ts index d6c9bd440ba..77c0a0d2143 100644 --- a/docs-shopify.dev/commands/interfaces/auth-login.interface.ts +++ b/docs-shopify.dev/commands/interfaces/auth-login.interface.ts @@ -9,4 +9,16 @@ export interface authlogin { * @environment SHOPIFY_FLAG_AUTH_ALIAS */ '--alias '?: string + + /** + * Log in with a new account instead of choosing from existing sessions. + * @environment SHOPIFY_FLAG_AUTH_NEW + */ + '--new'?: '' + + /** + * Resume a pending non-interactive login flow. + * @environment SHOPIFY_FLAG_AUTH_RESUME + */ + '--resume'?: '' } diff --git a/docs-shopify.dev/generated/generated_docs_data_v2.json b/docs-shopify.dev/generated/generated_docs_data_v2.json index ffcffb0094f..811b2c94321 100644 --- a/docs-shopify.dev/generated/generated_docs_data_v2.json +++ b/docs-shopify.dev/generated/generated_docs_data_v2.json @@ -2592,9 +2592,27 @@ "description": "Alias of the session you want to login to.", "isOptional": true, "environmentValue": "SHOPIFY_FLAG_AUTH_ALIAS" + }, + { + "filePath": "docs-shopify.dev/commands/interfaces/auth-login.interface.ts", + "syntaxKind": "PropertySignature", + "name": "--new", + "value": "''", + "description": "Log in with a new account instead of choosing from existing sessions.", + "isOptional": true, + "environmentValue": "SHOPIFY_FLAG_AUTH_NEW" + }, + { + "filePath": "docs-shopify.dev/commands/interfaces/auth-login.interface.ts", + "syntaxKind": "PropertySignature", + "name": "--resume", + "value": "''", + "description": "Resume a pending non-interactive login flow.", + "isOptional": true, + "environmentValue": "SHOPIFY_FLAG_AUTH_RESUME" } ], - "value": "export interface authlogin {\n /**\n * Alias of the session you want to login to.\n * @environment SHOPIFY_FLAG_AUTH_ALIAS\n */\n '--alias '?: string\n}" + "value": "export interface authlogin {\n /**\n * Alias of the session you want to login to.\n * @environment SHOPIFY_FLAG_AUTH_ALIAS\n */\n '--alias '?: string\n\n /**\n * Log in with a new account instead of choosing from existing sessions.\n * @environment SHOPIFY_FLAG_AUTH_NEW\n */\n '--new'?: ''\n\n /**\n * Resume a pending non-interactive login flow.\n * @environment SHOPIFY_FLAG_AUTH_RESUME\n */\n '--resume'?: ''\n}" } }, "authlogout": { diff --git a/docs/README.md b/docs/README.md index 5e6e3c44c9b..f78d128f9c5 100644 --- a/docs/README.md +++ b/docs/README.md @@ -12,6 +12,7 @@ The list below contains valuable resources for people interested in contributing * [Get started](./cli/get-started.md) * [Architecture](./cli/architecture.md) +* [Authentication](./cli/auth.md) * [Conventions](./cli/conventions.md) * [Performance](./cli/performance.md) * [Debugging](./cli/debugging.md) diff --git a/docs/cli/auth.md b/docs/cli/auth.md new file mode 100644 index 00000000000..f487a911f97 --- /dev/null +++ b/docs/cli/auth.md @@ -0,0 +1,48 @@ +# Shopify CLI Authentication + +Shopify CLI authenticates developers with Shopify through a device-code OAuth flow. This works in local terminals, remote development environments, and agent-driven workflows where a browser might not be available to the CLI process. + +## Recommended Flow + +1. Check whether a session is already available with `shopify auth status` or `shopify auth status --json`. +2. If a session is available, continue with the command that needs authentication. +3. If no session is available, run `shopify auth login`. +4. Shopify CLI prints a verification URL and user code, or opens the verification URL in your browser. +5. The user completes login in the browser. +6. Complete the CLI flow: + - In an interactive terminal, keep the command running. It polls and continues automatically after authentication succeeds. + - In a non-TTY environment, run `shopify auth login --resume` after the user authorizes. + +Agents should show the verification URL and user code to the user, ask the user to complete authentication in the browser, and then either wait for the interactive command to finish or run `shopify auth login --resume` for a non-TTY login. + +## Commands + +- `shopify auth status`: Check whether Shopify CLI has a usable Shopify account session. Use `--json` for stable machine-readable output. +- `shopify auth login`: Log in to a Shopify account. In a non-TTY environment, this starts the device-code flow, prints the verification URL and code, stashes the device code, and exits without waiting. +- `shopify auth login --resume`: Resume a pending non-TTY login after the user has authorized in the browser. On success, Shopify CLI exchanges the stashed device code for tokens and stores the session. +- `shopify auth login --new`: Start a new login instead of reusing or choosing from existing sessions. +- `shopify auth logout`: Clear the stored Shopify CLI session. +- Commands that need authentication may start the same login flow automatically when no usable session exists in an interactive terminal. + +## Non-TTY Behavior + +When `shopify auth login` runs without a TTY: + +1. Shopify CLI checks for an existing usable session. +2. If a session exists, Shopify CLI prints the current account and exits without starting a new login. +3. If no session exists, Shopify CLI starts device authorization, prints the verification URL and user code, stores the pending device code, and exits immediately. +4. After the user authorizes in the browser, run `shopify auth login --resume`. + +Use `shopify auth login --new` to skip the existing-session check and start a new device authorization flow. + +## CI + +Do not start browser-based login from CI. Use credentials provided through the supported environment variables for the command you are running. + +## Scopes + +Shopify CLI requests the scopes needed for CLI workflows, including access to Shopify Admin, Partners, Storefront Renderer, Business Platform, and App Management APIs. Individual commands may request additional scopes for the task being performed. + +## Support + +For issues with Shopify CLI authentication, see https://shopify.dev/docs/api/shopify-cli or contact Shopify support at https://help.shopify.com. diff --git a/packages/cli-kit/src/private/node/conf-store.test.ts b/packages/cli-kit/src/private/node/conf-store.test.ts index af8d48b9585..7e05dcbeeee 100644 --- a/packages/cli-kit/src/private/node/conf-store.test.ts +++ b/packages/cli-kit/src/private/node/conf-store.test.ts @@ -13,6 +13,9 @@ import { getCachedPartnerAccountStatus, setCachedPartnerAccountStatus, runWithRateLimit, + clearPendingDeviceAuth, + getPendingDeviceAuth, + setPendingDeviceAuth, } from './conf-store.js' import {isLocalEnvironment} from './context/service.js' import {LocalStorage} from '../../public/node/local-storage.js' @@ -194,6 +197,34 @@ describe('removeCurrentSessionId', () => { }) }) +describe('pending device auth', () => { + test('stores, reads, and clears pending device auth', async () => { + await inTemporaryDirectory(async (cwd) => { + // Given + const config = new LocalStorage({cwd}) + const pendingDeviceAuth = { + deviceCode: 'device-code', + userCode: 'user-code', + verificationUriComplete: 'https://accounts.shopify.com/activate?user_code=user-code', + interval: 5, + expiresAt: 1893456000000, + } + + // When + setPendingDeviceAuth(pendingDeviceAuth, config) + + // Then + expect(getPendingDeviceAuth(config)).toEqual(pendingDeviceAuth) + + // When + clearPendingDeviceAuth(config) + + // Then + expect(getPendingDeviceAuth(config)).toBeUndefined() + }) + }) +}) + describe('session environment isolation', () => { test('getSessions returns production sessions in production', async () => { await inTemporaryDirectory(async (cwd) => { diff --git a/packages/cli-kit/src/private/node/conf-store.ts b/packages/cli-kit/src/private/node/conf-store.ts index 72b583f1f5f..8caf526f47f 100644 --- a/packages/cli-kit/src/private/node/conf-store.ts +++ b/packages/cli-kit/src/private/node/conf-store.ts @@ -26,6 +26,14 @@ interface Cache { [rateLimitKey: RateLimitKey]: CacheValue } +export interface PendingDeviceAuth { + deviceCode: string + userCode: string + verificationUriComplete: string + interval: number + expiresAt: number +} + export interface ConfSchema { sessionStore: string currentSessionId?: string @@ -33,6 +41,7 @@ export interface ConfSchema { currentDevSessionId?: string cache?: Cache autoUpgradeEnabled?: boolean + pendingDeviceAuth?: PendingDeviceAuth } let _instance: LocalStorage | undefined @@ -111,6 +120,31 @@ export function removeCurrentSessionId(config: LocalStorage = cliKit config.delete(currentSessionIdKey()) } +/** + * Get pending device auth state for a resumable non-interactive login flow. + * + * @returns Pending device auth state, if present. + */ +export function getPendingDeviceAuth(config: LocalStorage = cliKitStore()): PendingDeviceAuth | undefined { + return config.get('pendingDeviceAuth') +} + +/** + * Stash pending device auth state for a later `shopify auth login --resume`. + * + * @param auth - Pending device auth state. + */ +export function setPendingDeviceAuth(auth: PendingDeviceAuth, config: LocalStorage = cliKitStore()): void { + config.set('pendingDeviceAuth', auth) +} + +/** + * Clear pending device auth state after completion or expiry. + */ +export function clearPendingDeviceAuth(config: LocalStorage = cliKitStore()): void { + config.delete('pendingDeviceAuth') +} + type CacheValueForKey = NonNullable['value'] /** diff --git a/packages/cli-kit/src/private/node/session.test.ts b/packages/cli-kit/src/private/node/session.test.ts index 8d3593fd1be..6a154e6ebb9 100644 --- a/packages/cli-kit/src/private/node/session.test.ts +++ b/packages/cli-kit/src/private/node/session.test.ts @@ -198,6 +198,22 @@ The CLI is currently unable to prompt for reauthentication.`, await expect(getLastSeenUserIdAfterAuth()).resolves.toBe('unknown') }) + test('throws an authentication required error if the terminal cannot prompt', async () => { + // Given + vi.mocked(validateSession).mockResolvedValueOnce('needs_full_auth') + vi.mocked(fetchSessions).mockResolvedValue(undefined) + vi.mocked(terminalSupportsPrompting).mockReturnValue(false) + + // When + await expect(ensureAuthenticated(defaultApplications)).rejects.toThrow('Authentication required') + + // Then + expect(requestDeviceAuthorization).not.toHaveBeenCalled() + expect(pollForDeviceAuthorization).not.toHaveBeenCalled() + expect(exchangeAccessForApplicationTokens).not.toHaveBeenCalled() + expect(storeSessions).not.toHaveBeenCalled() + }) + test('executes complete auth flow if session is for a different fqdn', async () => { // Given vi.mocked(validateSession).mockResolvedValueOnce('needs_full_auth') diff --git a/packages/cli-kit/src/private/node/session.ts b/packages/cli-kit/src/private/node/session.ts index 3b750d1cdcb..dc62b2af87e 100644 --- a/packages/cli-kit/src/private/node/session.ts +++ b/packages/cli-kit/src/private/node/session.ts @@ -24,6 +24,7 @@ import {AdminSession, logout} from '../../public/node/session.js' import {nonRandomUUID} from '../../public/node/crypto.js' import {isEmpty} from '../../public/common/object.js' import {businessPlatformRequest} from '../../public/node/api/business-platform.js' +import {terminalSupportsPrompting} from '../../public/node/system.js' /** * Fetches the user's email from the Business Platform API @@ -293,19 +294,21 @@ The CLI is currently unable to prompt for reauthentication.`, * @param existingAlias - Optional alias from a previous session to preserve if the email fetch fails. */ async function executeCompleteFlow(applications: OAuthApplications, existingAlias?: string): Promise { - const scopes = getFlattenScopes(applications) - const exchangeScopes = getExchangeScopes(applications) - const store = applications.adminApi?.storeFqdn - if (firstPartyDev()) { - outputDebug(outputContent`Authenticating as Shopify Employee...`) - scopes.push('employee') - } + const scopes = getDeviceAuthScopes(applications) let identityToken: IdentityToken const identityTokenInformation = getIdentityTokenInformation() if (identityTokenInformation) { identityToken = buildIdentityTokenFromEnv(scopes, identityTokenInformation) } else { + if (!terminalSupportsPrompting()) { + throw new AbortError('Authentication required', [ + 'Run', + {command: 'shopify auth login'}, + 'first or use a valid authentication token', + ]) + } + // Request a device code to authorize without a browser redirect. outputDebug(outputContent`Requesting device authorization code...`) const deviceAuth = await requestDeviceAuthorization(scopes) @@ -315,6 +318,28 @@ async function executeCompleteFlow(applications: OAuthApplications, existingAlia identityToken = await pollForDeviceAuthorization(deviceAuth.deviceCode, deviceAuth.interval) } + const session = await completeAuthFlow(identityToken, applications, existingAlias) + outputCompleted(`Logged in.`) + return session +} + +/** + * Given an identity token, exchange it for application tokens and build a complete session. + * Shared between the interactive login flow and the resumable non-interactive flow. + * + * @param identityToken - Identity token returned by the OAuth device code flow. + * @param applications - Applications to exchange access tokens for. + * @param existingAlias - Optional alias from a previous session to preserve if the email fetch fails. + * @returns A complete session with identity and application tokens. + */ +export async function completeAuthFlow( + identityToken: IdentityToken, + applications: OAuthApplications, + existingAlias?: string, +): Promise { + const exchangeScopes = getExchangeScopes(applications) + const store = applications.adminApi?.storeFqdn + // Exchange identity token for application tokens outputDebug(outputContent`CLI token received. Exchanging it for application tokens...`) const result = await exchangeAccessForApplicationTokens(identityToken, exchangeScopes, store) @@ -330,9 +355,6 @@ async function executeCompleteFlow(applications: OAuthApplications, existingAlia }, applications: result, } - - outputCompleted(`Logged in.`) - return session } @@ -419,6 +441,15 @@ function getFlattenScopes(apps: OAuthApplications): string[] { return allDefaultScopes(requestedScopes) } +function getDeviceAuthScopes(applications: OAuthApplications): string[] { + const scopes = getFlattenScopes(applications) + if (firstPartyDev()) { + outputDebug(outputContent`Authenticating as Shopify Employee...`) + scopes.push('employee') + } + return scopes +} + /** * Get the scopes for the given applications. * diff --git a/packages/cli-kit/src/private/node/session/device-authorization.test.ts b/packages/cli-kit/src/private/node/session/device-authorization.test.ts index 32349ba0ee2..899cede0895 100644 --- a/packages/cli-kit/src/private/node/session/device-authorization.test.ts +++ b/packages/cli-kit/src/private/node/session/device-authorization.test.ts @@ -8,10 +8,11 @@ import {IdentityToken} from './schema.js' import {exchangeDeviceCodeForAccessToken} from './exchange.js' import {identityFqdn} from '../../../public/node/context/fqdn.js' import {shopifyFetch} from '../../../public/node/http.js' -import {isTTY} from '../../../public/node/ui.js' +import {isTTY, keypress} from '../../../public/node/ui.js' import {err, ok} from '../../../public/node/result.js' import {AbortError} from '../../../public/node/error.js' -import {isCI} from '../../../public/node/system.js' +import {isCI, openURL} from '../../../public/node/system.js' +import {mockAndCaptureOutput} from '../../../public/node/testing/output.js' import {beforeEach, describe, expect, test, vi} from 'vitest' import {Response} from 'node-fetch' @@ -66,6 +67,26 @@ describe('requestDeviceAuthorization', () => { expect(got).toEqual(dataExpected) }) + test('can request a device auth code without prompting or polling', async () => { + // Given + const outputMock = mockAndCaptureOutput() + const response = new Response(JSON.stringify(data)) + vi.mocked(shopifyFetch).mockResolvedValue(response) + vi.mocked(identityFqdn).mockResolvedValue('fqdn.com') + vi.mocked(clientId).mockReturnValue('clientId') + vi.mocked(isCI).mockReturnValue(true) + + // When + const got = await requestDeviceAuthorization(['scope1', 'scope2'], {noPrompt: true}) + + // Then + expect(got).toEqual(dataExpected) + expect(keypress).not.toHaveBeenCalled() + expect(openURL).not.toHaveBeenCalled() + expect(outputMock.info()).toContain('User verification code: user_code') + expect(outputMock.info()).toContain('verification_uri_complete') + }) + test('when the response is not valid JSON, throw an error with context', async () => { // Given const response = new Response('not valid JSON') diff --git a/packages/cli-kit/src/private/node/session/device-authorization.ts b/packages/cli-kit/src/private/node/session/device-authorization.ts index 14e8e367a9f..f882b574c37 100644 --- a/packages/cli-kit/src/private/node/session/device-authorization.ts +++ b/packages/cli-kit/src/private/node/session/device-authorization.ts @@ -28,9 +28,13 @@ export interface DeviceAuthorizationResponse { * Also returns a `deviceCode` used for polling the token endpoint in the next step. * * @param scopes - The scopes to request + * @param options - Optional settings for presenting the device authorization instructions. * @returns An object with the device authorization response. */ -export async function requestDeviceAuthorization(scopes: string[]): Promise { +export async function requestDeviceAuthorization( + scopes: string[], + {noPrompt = false}: {noPrompt?: boolean} = {}, +): Promise { const fqdn = await identityFqdn() const identityClientId = clientId() const queryParams = {client_id: identityClientId, scope: scopes.join(' ')} @@ -69,32 +73,39 @@ export async function requestDeviceAuthorization(scopes: string[]): Promise { outputInfo(outputContent`👉 Open this link to start the auth process: ${linkToken}`) } - if (isCloudEnvironment() || !isTTY()) { + if (noPrompt) { + outputInfo('\nTo log in to Shopify, open the following URL and enter the verification code.') + outputInfo(outputContent`User verification code: ${jsonResult.user_code}`) cloudMessage() } else { - outputInfo('👉 Press any key to open the login page on your browser') - await keypress() - const opened = await openURL(jsonResult.verification_uri_complete) - if (opened) { - outputInfo(outputContent`Opened link to start the auth process: ${linkToken}`) - } else { + outputInfo('\nTo run this command, log in to Shopify.') + + if (isCI()) { + throw new AbortError( + 'Authorization is required to continue, but the current environment does not support interactive prompts.', + 'To resolve this, specify credentials in your environment, or run the command in an interactive environment such as your local terminal.', + ) + } + + outputInfo(outputContent`User verification code: ${jsonResult.user_code}`) + + if (isCloudEnvironment() || !isTTY()) { cloudMessage() + } else { + outputInfo('👉 Press any key to open the login page on your browser') + await keypress() + const opened = await openURL(jsonResult.verification_uri_complete) + if (opened) { + outputInfo(outputContent`Opened link to start the auth process: ${linkToken}`) + } else { + cloudMessage() + } } } diff --git a/packages/cli-kit/src/public/node/session-auth-status.test.ts b/packages/cli-kit/src/public/node/session-auth-status.test.ts index adc3fd75b3b..17b41722de3 100644 --- a/packages/cli-kit/src/public/node/session-auth-status.test.ts +++ b/packages/cli-kit/src/public/node/session-auth-status.test.ts @@ -97,7 +97,7 @@ describe('getAuthStatus', () => { identityFqdn: 'accounts.shopify.com', agentGuidance: { instruction: - 'No usable Shopify CLI session is available. Run `shopify auth login`, show the verification URL and user code to the user, and keep the command running until authentication completes.', + 'No usable Shopify CLI session is available. Run `shopify auth login`, show the verification URL and user code to the user, and then run `shopify auth login --resume` after the user authorizes.', nextCommand: 'shopify auth login', }, }) diff --git a/packages/cli-kit/src/public/node/session-device-auth.test.ts b/packages/cli-kit/src/public/node/session-device-auth.test.ts new file mode 100644 index 00000000000..a0be7d5ae7f --- /dev/null +++ b/packages/cli-kit/src/public/node/session-device-auth.test.ts @@ -0,0 +1,148 @@ +import {resumeDeviceAuthLogin, startDeviceAuthLogin} from './session.js' +import {identityFqdn} from './context/fqdn.js' +import {err, ok} from './result.js' +import { + clearPendingDeviceAuth, + getPendingDeviceAuth, + setCurrentSessionId, + setPendingDeviceAuth, +} from '../../private/node/conf-store.js' +import {completeAuthFlow} from '../../private/node/session.js' +import {requestDeviceAuthorization} from '../../private/node/session/device-authorization.js' +import {exchangeDeviceCodeForAccessToken} from '../../private/node/session/exchange.js' +import {allDefaultScopes} from '../../private/node/session/scopes.js' +import * as sessionStore from '../../private/node/session/store.js' +import {IdentityToken, Session} from '../../private/node/session/schema.js' + +import {beforeEach, describe, expect, test, vi} from 'vitest' + +vi.mock('./context/fqdn.js') +vi.mock('../../private/node/conf-store.js') +vi.mock('../../private/node/session.js') +vi.mock('../../private/node/session/device-authorization.js') +vi.mock('../../private/node/session/exchange.js') +vi.mock('../../private/node/session/scopes.js') +vi.mock('../../private/node/session/store.js') + +const identityToken: IdentityToken = { + accessToken: 'identity-access-token', + refreshToken: 'identity-refresh-token', + expiresAt: new Date('2030-01-01T00:00:00.000Z'), + scopes: ['openid'], + userId: 'user-id', +} + +const session: Session = { + identity: {...identityToken, alias: 'user@example.com'}, + applications: {}, +} + +const pendingDeviceAuth = { + deviceCode: 'device-code', + userCode: 'ABCD-EFGH', + verificationUriComplete: 'https://accounts.shopify.com/activate?user_code=ABCD-EFGH', + interval: 5, + expiresAt: Date.now() + 60_000, +} + +describe('startDeviceAuthLogin', () => { + beforeEach(() => { + vi.mocked(allDefaultScopes).mockReturnValue(['openid']) + vi.mocked(requestDeviceAuthorization).mockResolvedValue({ + deviceCode: pendingDeviceAuth.deviceCode, + userCode: pendingDeviceAuth.userCode, + verificationUri: 'https://accounts.shopify.com/activate', + verificationUriComplete: pendingDeviceAuth.verificationUriComplete, + interval: pendingDeviceAuth.interval, + expiresIn: 600, + }) + }) + + test('starts device auth and stashes the code for resume', async () => { + // When + const got = await startDeviceAuthLogin() + + // Then + expect(requestDeviceAuthorization).toHaveBeenCalledWith(['openid'], {noPrompt: true}) + expect(setPendingDeviceAuth).toHaveBeenCalledWith({ + deviceCode: pendingDeviceAuth.deviceCode, + userCode: pendingDeviceAuth.userCode, + verificationUriComplete: pendingDeviceAuth.verificationUriComplete, + interval: pendingDeviceAuth.interval, + expiresAt: expect.any(Number), + }) + expect(got).toEqual({ + verificationUriComplete: pendingDeviceAuth.verificationUriComplete, + userCode: pendingDeviceAuth.userCode, + expiresAt: expect.any(String), + }) + }) +}) + +describe('resumeDeviceAuthLogin', () => { + beforeEach(() => { + vi.mocked(identityFqdn).mockResolvedValue('accounts.shopify.com') + vi.mocked(getPendingDeviceAuth).mockReturnValue(pendingDeviceAuth) + vi.mocked(exchangeDeviceCodeForAccessToken).mockResolvedValue(ok(identityToken)) + vi.mocked(completeAuthFlow).mockResolvedValue(session) + vi.mocked(sessionStore.fetch).mockResolvedValue(undefined) + }) + + test('exchanges the stashed device code and stores the session', async () => { + // When + const got = await resumeDeviceAuthLogin() + + // Then + expect(exchangeDeviceCodeForAccessToken).toHaveBeenCalledWith('device-code') + expect(completeAuthFlow).toHaveBeenCalledWith(identityToken, {}) + expect(sessionStore.store).toHaveBeenCalledWith({ + 'accounts.shopify.com': { + 'user-id': session, + }, + }) + expect(setCurrentSessionId).toHaveBeenCalledWith('user-id') + expect(clearPendingDeviceAuth).toHaveBeenCalledOnce() + expect(got).toEqual({status: 'success', alias: 'user@example.com'}) + }) + + test('returns pending while the user has not authorized yet', async () => { + // Given + vi.mocked(exchangeDeviceCodeForAccessToken).mockResolvedValue(err('authorization_pending')) + + // When + const got = await resumeDeviceAuthLogin() + + // Then + expect(got).toEqual({ + status: 'pending', + verificationUriComplete: pendingDeviceAuth.verificationUriComplete, + userCode: pendingDeviceAuth.userCode, + }) + expect(clearPendingDeviceAuth).not.toHaveBeenCalled() + }) + + test('clears the pending state when it has expired', async () => { + // Given + vi.mocked(getPendingDeviceAuth).mockReturnValue({...pendingDeviceAuth, expiresAt: Date.now() - 1000}) + + // When + const got = await resumeDeviceAuthLogin() + + // Then + expect(exchangeDeviceCodeForAccessToken).not.toHaveBeenCalled() + expect(clearPendingDeviceAuth).toHaveBeenCalledOnce() + expect(got.status).toBe('expired') + }) + + test('returns no_pending when there is no stashed code', async () => { + // Given + vi.mocked(getPendingDeviceAuth).mockReturnValue(undefined) + + // When + const got = await resumeDeviceAuthLogin() + + // Then + expect(exchangeDeviceCodeForAccessToken).not.toHaveBeenCalled() + expect(got.status).toBe('no_pending') + }) +}) diff --git a/packages/cli-kit/src/public/node/session-prompt.test.ts b/packages/cli-kit/src/public/node/session-prompt.test.ts index afd1d72aecd..9018f4d02ee 100644 --- a/packages/cli-kit/src/public/node/session-prompt.test.ts +++ b/packages/cli-kit/src/public/node/session-prompt.test.ts @@ -92,6 +92,21 @@ describe('promptSessionSelect', () => { expect(result).toEqual('custom-alias') }) + test('creates a new session directly when forceNewSession is true', async () => { + // Given + vi.mocked(sessionStore.fetch).mockResolvedValue(mockSessions) + + // When + const result = await promptSessionSelect(undefined, {forceNewSession: true}) + + // Then + expect(renderSelectPrompt).not.toHaveBeenCalled() + expect(sessionStore.findSessionByAlias).not.toHaveBeenCalled() + expect(ensureAuthenticatedUser).toHaveBeenCalledWith({}, {forceNewSession: true}) + expect(sessionStore.getSessionAlias).toHaveBeenCalledWith('new-user-id') + expect(result).toEqual('new-alias') + }) + test('shows existing sessions and allows selection', async () => { // Given vi.mocked(sessionStore.fetch).mockResolvedValue(mockSessions) diff --git a/packages/cli-kit/src/public/node/session-prompt.ts b/packages/cli-kit/src/public/node/session-prompt.ts index 278be669125..742f9446493 100644 --- a/packages/cli-kit/src/public/node/session-prompt.ts +++ b/packages/cli-kit/src/public/node/session-prompt.ts @@ -12,6 +12,10 @@ interface SessionChoice { value: string } +interface PromptSessionSelectOptions { + forceNewSession?: boolean +} + /** * Builds the choices array from existing sessions. * @@ -87,9 +91,14 @@ async function getAllChoices(): Promise { * - Otherwise, shows a prompt with all available sessions and the option to log in with a different account. * * @param alias - Optional alias of the account to switch to. + * @param options - Optional prompt behavior. * @returns Promise with the alias of the chosen session. */ -export async function promptSessionSelect(alias?: string): Promise { +export async function promptSessionSelect(alias?: string, options: PromptSessionSelectOptions = {}): Promise { + if (options.forceNewSession) { + return handleNewLogin() + } + if (alias) { const userId = await sessionStore.findSessionByAlias(alias) if (userId) { diff --git a/packages/cli-kit/src/public/node/session.ts b/packages/cli-kit/src/public/node/session.ts index 2d72d59ff85..cc86a725b03 100644 --- a/packages/cli-kit/src/public/node/session.ts +++ b/packages/cli-kit/src/public/node/session.ts @@ -2,13 +2,21 @@ import {shopifyFetch} from './http.js' import {nonRandomUUID} from './crypto.js' import {getAppAutomationToken} from './environment.js' import {identityFqdn} from './context/fqdn.js' +import {firstPartyDev} from './context/local.js' import {AbortError, BugError} from './error.js' import {outputContent, outputToken, outputDebug} from './output.js' -import {getCurrentSessionId} from '../../private/node/conf-store.js' +import { + clearPendingDeviceAuth, + getCurrentSessionId, + getPendingDeviceAuth, + setCurrentSessionId, + setPendingDeviceAuth, +} from '../../private/node/conf-store.js' import * as sessionStore from '../../private/node/session/store.js' import {allDefaultScopes} from '../../private/node/session/scopes.js' import {validateSession} from '../../private/node/session/validate.js' import { + exchangeDeviceCodeForAccessToken, exchangeCustomPartnerToken, exchangeAppAutomationTokenForAppManagementAccessToken, exchangeAppAutomationTokenForBusinessPlatformAccessToken, @@ -17,6 +25,7 @@ import { AdminAPIScope, AppManagementAPIScope, BusinessPlatformScope, + completeAuthFlow, EnsureAuthenticatedAdditionalOptions, PartnersAPIScope, StorefrontRendererScope, @@ -24,6 +33,7 @@ import { setLastSeenAuthMethod, setLastSeenUserIdAfterAuth, } from '../../private/node/session.js' +import {requestDeviceAuthorization} from '../../private/node/session/device-authorization.js' import {isThemeAccessSession} from '../../private/node/api/rest.js' /** @@ -120,7 +130,7 @@ function authStatusGuidance(status: AuthStatusName): AuthStatus['agentGuidance'] return { instruction: - 'No usable Shopify CLI session is available. Run `shopify auth login`, show the verification URL and user code to the user, and keep the command running until authentication completes.', + 'No usable Shopify CLI session is available. Run `shopify auth login`, show the verification URL and user code to the user, and then run `shopify auth login --resume` after the user authorizes.', nextCommand: 'shopify auth login', } } @@ -385,6 +395,95 @@ export function logout(): Promise { return sessionStore.remove() } +export interface StartDeviceAuthLoginResult { + verificationUriComplete: string + userCode: string + expiresAt: string +} + +/** + * Start a resumable device authorization flow for non-interactive `shopify auth login`. + * + * @returns Instructions needed to authorize the device code and resume login. + */ +export async function startDeviceAuthLogin(): Promise { + const scopes = allDefaultScopes() + if (firstPartyDev()) { + scopes.push('employee') + } + const deviceAuth = await requestDeviceAuthorization(scopes, {noPrompt: true}) + const verificationUriComplete = deviceAuth.verificationUriComplete ?? deviceAuth.verificationUri + const expiresAt = Date.now() + deviceAuth.expiresIn * 1000 + + setPendingDeviceAuth({ + deviceCode: deviceAuth.deviceCode, + userCode: deviceAuth.userCode, + verificationUriComplete, + interval: deviceAuth.interval ?? 5, + expiresAt, + }) + + return {verificationUriComplete, userCode: deviceAuth.userCode, expiresAt: new Date(expiresAt).toISOString()} +} + +export type ResumeDeviceAuthLoginResult = + | {status: 'success'; alias: string} + | {status: 'pending'; verificationUriComplete: string; userCode: string} + | {status: 'expired'; message: string} + | {status: 'denied'; message: string} + | {status: 'no_pending'; message: string} + +/** + * Resume a previously started non-interactive device authorization flow. + * + * @returns The result of exchanging the stashed device code. + */ +export async function resumeDeviceAuthLogin(): Promise { + const pending = getPendingDeviceAuth() + + if (!pending) { + return {status: 'no_pending', message: 'No pending login flow. Run `shopify auth login` first.'} + } + + if (Date.now() > pending.expiresAt) { + clearPendingDeviceAuth() + return {status: 'expired', message: 'The login flow has expired. Run `shopify auth login` again.'} + } + + const result = await exchangeDeviceCodeForAccessToken(pending.deviceCode) + + if (result.isErr()) { + const error = result.error + if (error === 'authorization_pending' || error === 'slow_down') { + return { + status: 'pending', + verificationUriComplete: pending.verificationUriComplete, + userCode: pending.userCode, + } + } + if (error === 'expired_token') { + clearPendingDeviceAuth() + return {status: 'expired', message: 'The login flow has expired. Run `shopify auth login` again.'} + } + + clearPendingDeviceAuth() + return {status: 'denied', message: `Authorization failed: ${error}. Run \`shopify auth login\` to try again.`} + } + + const session = await completeAuthFlow(result.value, {}) + const fqdn = await identityFqdn() + const sessions = (await sessionStore.fetch()) ?? {} + const sessionId = session.identity.userId + await sessionStore.store({ + ...sessions, + [fqdn]: {...sessions[fqdn], [sessionId]: session}, + }) + setCurrentSessionId(sessionId) + clearPendingDeviceAuth() + + return {status: 'success', alias: session.identity.alias ?? sessionId} +} + /** * Ensure that we have a valid Admin session for the given store, with access on behalf of the app. * diff --git a/packages/cli/README.md b/packages/cli/README.md index 21dadd72067..4f7060bb8a3 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -1048,17 +1048,39 @@ DESCRIPTION ## `shopify auth login` -Logs you in to your Shopify account. +Log in to a Shopify account. ``` USAGE - $ shopify auth login [--alias ] + $ shopify auth login [--alias ] [--new] [--resume] FLAGS --alias= [env: SHOPIFY_FLAG_AUTH_ALIAS] Alias of the session you want to login to. + --new [env: SHOPIFY_FLAG_AUTH_NEW] Log in with a new account instead of choosing from existing sessions. + --resume [env: SHOPIFY_FLAG_AUTH_RESUME] Resume a pending non-interactive login flow. DESCRIPTION - Logs you in to your Shopify account. + Log in to a Shopify account. + + Logs in to a Shopify account using a browser-based device authentication flow. + + In an interactive terminal, Shopify CLI opens or prints a verification URL and waits for authentication to complete. + + In a non-TTY environment, Shopify CLI first returns the current account if a usable session already exists. If no + session exists, it starts device authorization, prints the verification URL and user code, and exits without waiting. + After the user authorizes in a browser, run `shopify auth login --resume` to exchange the pending device code and + store the session. + + Use `--new` to start a new login instead of reusing an existing session or choosing from saved accounts. + +EXAMPLES + $ shopify auth login + + $ shopify auth login --new + + $ shopify auth login --resume + + $ shopify auth login --alias my-account ``` ## `shopify auth logout` @@ -1092,6 +1114,9 @@ DESCRIPTION Use `--json` for stable machine-readable output. Agents should check this command before starting workflows that need Shopify account authentication. + When this command reports that no usable session exists, run `shopify auth login`. In a non-TTY environment, show the + verification URL and code to the user, then run `shopify auth login --resume` after the user authorizes. + EXAMPLES $ shopify auth status diff --git a/packages/cli/oclif.manifest.json b/packages/cli/oclif.manifest.json index 7f13cde58b9..a09a174ed0c 100644 --- a/packages/cli/oclif.manifest.json +++ b/packages/cli/oclif.manifest.json @@ -3042,8 +3042,15 @@ ], "args": { }, - "description": "Logs you in to your Shopify account.", + "description": "Logs in to a Shopify account using a browser-based device authentication flow.\n\nIn an interactive terminal, Shopify CLI opens or prints a verification URL and waits for authentication to complete.\n\nIn a non-TTY environment, Shopify CLI first returns the current account if a usable session already exists. If no session exists, it starts device authorization, prints the verification URL and user code, and exits without waiting. After the user authorizes in a browser, run `shopify auth login --resume` to exchange the pending device code and store the session.\n\nUse `--new` to start a new login instead of reusing an existing session or choosing from saved accounts.", + "descriptionWithMarkdown": "Logs in to a Shopify account using a browser-based device authentication flow.\n\nIn an interactive terminal, Shopify CLI opens or prints a verification URL and waits for authentication to complete.\n\nIn a non-TTY environment, Shopify CLI first returns the current account if a usable session already exists. If no session exists, it starts device authorization, prints the verification URL and user code, and exits without waiting. After the user authorizes in a browser, run `shopify auth login --resume` to exchange the pending device code and store the session.\n\nUse `--new` to start a new login instead of reusing an existing session or choosing from saved accounts.", "enableJsonFlag": false, + "examples": [ + "<%= config.bin %> <%= command.id %>", + "<%= config.bin %> <%= command.id %> --new", + "<%= config.bin %> <%= command.id %> --resume", + "<%= config.bin %> <%= command.id %> --alias my-account" + ], "flags": { "alias": { "description": "Alias of the session you want to login to.", @@ -3052,6 +3059,20 @@ "multiple": false, "name": "alias", "type": "option" + }, + "new": { + "allowNo": false, + "description": "Log in with a new account instead of choosing from existing sessions.", + "env": "SHOPIFY_FLAG_AUTH_NEW", + "name": "new", + "type": "boolean" + }, + "resume": { + "allowNo": false, + "description": "Resume a pending non-interactive login flow.", + "env": "SHOPIFY_FLAG_AUTH_RESUME", + "name": "resume", + "type": "boolean" } }, "hasDynamicHelp": false, @@ -3061,7 +3082,8 @@ "pluginAlias": "@shopify/cli", "pluginName": "@shopify/cli", "pluginType": "core", - "strict": true + "strict": true, + "summary": "Log in to a Shopify account." }, "auth:logout": { "aliases": [ @@ -3086,8 +3108,8 @@ ], "args": { }, - "description": "Shows whether Shopify CLI has a usable Shopify account session.\n\nUse `--json` for stable machine-readable output. Agents should check this command before starting workflows that need Shopify account authentication.", - "descriptionWithMarkdown": "Shows whether Shopify CLI has a usable Shopify account session.\n\nUse `--json` for stable machine-readable output. Agents should check this command before starting workflows that need Shopify account authentication.", + "description": "Shows whether Shopify CLI has a usable Shopify account session.\n\nUse `--json` for stable machine-readable output. Agents should check this command before starting workflows that need Shopify account authentication.\n\nWhen this command reports that no usable session exists, run `shopify auth login`. In a non-TTY environment, show the verification URL and code to the user, then run `shopify auth login --resume` after the user authorizes.", + "descriptionWithMarkdown": "Shows whether Shopify CLI has a usable Shopify account session.\n\nUse `--json` for stable machine-readable output. Agents should check this command before starting workflows that need Shopify account authentication.\n\nWhen this command reports that no usable session exists, run `shopify auth login`. In a non-TTY environment, show the verification URL and code to the user, then run `shopify auth login --resume` after the user authorizes.", "enableJsonFlag": false, "examples": [ "<%= config.bin %> <%= command.id %>", diff --git a/packages/cli/src/cli/commands/auth/login.test.ts b/packages/cli/src/cli/commands/auth/login.test.ts index 29401713c58..316bc252b41 100644 --- a/packages/cli/src/cli/commands/auth/login.test.ts +++ b/packages/cli/src/cli/commands/auth/login.test.ts @@ -1,11 +1,19 @@ import Login from './login.js' -import {describe, expect, vi, test} from 'vitest' +import {beforeEach, describe, expect, vi, test} from 'vitest' import {promptSessionSelect} from '@shopify/cli-kit/node/session-prompt' +import {getAuthStatus, resumeDeviceAuthLogin, startDeviceAuthLogin} from '@shopify/cli-kit/node/session' import {mockAndCaptureOutput} from '@shopify/cli-kit/node/testing/output' +import {isTTY} from '@shopify/cli-kit/node/ui' vi.mock('@shopify/cli-kit/node/session-prompt') +vi.mock('@shopify/cli-kit/node/session') +vi.mock('@shopify/cli-kit/node/ui') describe('Login command', () => { + beforeEach(() => { + vi.mocked(isTTY).mockReturnValue(true) + }) + test('runs login without alias flag', async () => { // Given const outputMock = mockAndCaptureOutput() @@ -32,6 +40,145 @@ describe('Login command', () => { expect(outputMock.output()).toMatch('Current account: test-account.') }) + test('runs login with new flag', async () => { + // Given + const outputMock = mockAndCaptureOutput() + vi.mocked(promptSessionSelect).mockResolvedValue('new-account') + + // When + await Login.run(['--new']) + + // Then + expect(promptSessionSelect).toHaveBeenCalledWith(undefined, {forceNewSession: true}) + expect(outputMock.output()).toMatch('Current account: new-account.') + }) + + test('starts resumable device auth without polling in non-TTY', async () => { + // Given + const outputMock = mockAndCaptureOutput() + vi.mocked(isTTY).mockReturnValue(false) + vi.mocked(getAuthStatus).mockResolvedValue({ + status: 'not_authenticated', + authenticated: false, + agentGuidance: { + instruction: 'Log in.', + }, + }) + vi.mocked(startDeviceAuthLogin).mockResolvedValue({ + verificationUriComplete: 'https://accounts.shopify.com/activate?user_code=ABCD-EFGH', + userCode: 'ABCD-EFGH', + expiresAt: '2030-01-01T00:00:00.000Z', + }) + + // When + await Login.run([]) + + // Then + expect(startDeviceAuthLogin).toHaveBeenCalledOnce() + expect(promptSessionSelect).not.toHaveBeenCalled() + expect(outputMock.info()).toMatch('shopify auth login --resume') + }) + + test('returns current session in non-TTY when already authenticated', async () => { + // Given + const outputMock = mockAndCaptureOutput() + vi.mocked(isTTY).mockReturnValue(false) + vi.mocked(getAuthStatus).mockResolvedValue({ + status: 'authenticated', + authenticated: true, + account: { + userId: 'user-id', + alias: 'user@example.com', + }, + agentGuidance: { + instruction: 'Continue.', + }, + }) + + // When + await Login.run([]) + + // Then + expect(getAuthStatus).toHaveBeenCalledOnce() + expect(startDeviceAuthLogin).not.toHaveBeenCalled() + expect(promptSessionSelect).not.toHaveBeenCalled() + expect(outputMock.output()).toMatch('Current account: user@example.com.') + }) + + test('--new starts device auth in non-TTY even when already authenticated', async () => { + // Given + const outputMock = mockAndCaptureOutput() + vi.mocked(isTTY).mockReturnValue(false) + vi.mocked(getAuthStatus).mockResolvedValue({ + status: 'authenticated', + authenticated: true, + account: { + userId: 'user-id', + alias: 'user@example.com', + }, + agentGuidance: { + instruction: 'Continue.', + }, + }) + vi.mocked(startDeviceAuthLogin).mockResolvedValue({ + verificationUriComplete: 'https://accounts.shopify.com/activate?user_code=ABCD-EFGH', + userCode: 'ABCD-EFGH', + expiresAt: '2030-01-01T00:00:00.000Z', + }) + + // When + await Login.run(['--new']) + + // Then + expect(getAuthStatus).not.toHaveBeenCalled() + expect(startDeviceAuthLogin).toHaveBeenCalledOnce() + expect(outputMock.info()).toMatch('shopify auth login --resume') + }) + + test('--resume succeeds and outputs current account', async () => { + // Given + const outputMock = mockAndCaptureOutput() + vi.mocked(resumeDeviceAuthLogin).mockResolvedValue({status: 'success', alias: 'user@example.com'}) + + // When + await Login.run(['--resume']) + + // Then + expect(resumeDeviceAuthLogin).toHaveBeenCalledOnce() + expect(startDeviceAuthLogin).not.toHaveBeenCalled() + expect(promptSessionSelect).not.toHaveBeenCalled() + expect(outputMock.output()).toMatch('Current account: user@example.com.') + }) + + test('--resume exits with error when authorization is still pending', async () => { + // Given + vi.mocked(resumeDeviceAuthLogin).mockResolvedValue({ + status: 'pending', + verificationUriComplete: 'https://accounts.shopify.com/activate?user_code=ABCD-EFGH', + userCode: 'ABCD-EFGH', + }) + + // When + await expect(Login.run(['--resume'])).rejects.toThrow('process.exit unexpectedly called with "1"') + + // Then + expect(resumeDeviceAuthLogin).toHaveBeenCalledOnce() + }) + + test('--resume exits with error when no pending auth exists', async () => { + // Given + vi.mocked(resumeDeviceAuthLogin).mockResolvedValue({ + status: 'no_pending', + message: 'No pending login flow. Run `shopify auth login` first.', + }) + + // When + await expect(Login.run(['--resume'])).rejects.toThrow('process.exit unexpectedly called with "1"') + + // Then + expect(resumeDeviceAuthLogin).toHaveBeenCalledOnce() + }) + test('displays flags correctly in help', () => { // When const flags = Login.flags @@ -40,5 +187,11 @@ describe('Login command', () => { expect(flags.alias).toBeDefined() expect(flags.alias.description).toBe('Alias of the session you want to login to.') expect(flags.alias.env).toBe('SHOPIFY_FLAG_AUTH_ALIAS') + expect(flags.resume).toBeDefined() + expect(flags.resume.description).toBe('Resume a pending non-interactive login flow.') + expect(flags.resume.env).toBe('SHOPIFY_FLAG_AUTH_RESUME') + expect(flags.new).toBeDefined() + expect(flags.new.description).toBe('Log in with a new account instead of choosing from existing sessions.') + expect(flags.new.env).toBe('SHOPIFY_FLAG_AUTH_NEW') }) }) diff --git a/packages/cli/src/cli/commands/auth/login.ts b/packages/cli/src/cli/commands/auth/login.ts index 992b311dd26..7637a5aeda6 100644 --- a/packages/cli/src/cli/commands/auth/login.ts +++ b/packages/cli/src/cli/commands/auth/login.ts @@ -1,21 +1,87 @@ import Command from '@shopify/cli-kit/node/base-command' import {promptSessionSelect} from '@shopify/cli-kit/node/session-prompt' +import {getAuthStatus, resumeDeviceAuthLogin, startDeviceAuthLogin} from '@shopify/cli-kit/node/session' import {Flags} from '@oclif/core' -import {outputCompleted} from '@shopify/cli-kit/node/output' +import {AbortError} from '@shopify/cli-kit/node/error' +import {outputCompleted, outputInfo} from '@shopify/cli-kit/node/output' +import {isTTY} from '@shopify/cli-kit/node/ui' export default class Login extends Command { - static description = 'Logs you in to your Shopify account.' + static summary = 'Log in to a Shopify account.' + + static descriptionWithMarkdown = `Logs in to a Shopify account using a browser-based device authentication flow. + +In an interactive terminal, Shopify CLI opens or prints a verification URL and waits for authentication to complete. + +In a non-TTY environment, Shopify CLI first returns the current account if a usable session already exists. If no session exists, it starts device authorization, prints the verification URL and user code, and exits without waiting. After the user authorizes in a browser, run \`shopify auth login --resume\` to exchange the pending device code and store the session. + +Use \`--new\` to start a new login instead of reusing an existing session or choosing from saved accounts.` + + static description = this.descriptionWithoutMarkdown() + + static examples = [ + '<%= config.bin %> <%= command.id %>', + '<%= config.bin %> <%= command.id %> --new', + '<%= config.bin %> <%= command.id %> --resume', + '<%= config.bin %> <%= command.id %> --alias my-account', + ] static flags = { alias: Flags.string({ description: 'Alias of the session you want to login to.', env: 'SHOPIFY_FLAG_AUTH_ALIAS', }), + resume: Flags.boolean({ + description: 'Resume a pending non-interactive login flow.', + default: false, + env: 'SHOPIFY_FLAG_AUTH_RESUME', + }), + new: Flags.boolean({ + description: 'Log in with a new account instead of choosing from existing sessions.', + default: false, + env: 'SHOPIFY_FLAG_AUTH_NEW', + }), } async run(): Promise { const {flags} = await this.parse(Login) - const result = await promptSessionSelect(flags.alias) + + if (flags.resume) { + const result = await resumeDeviceAuthLogin() + switch (result.status) { + case 'success': + outputCompleted(`Current account: ${result.alias}.`) + return + case 'pending': + throw new AbortError( + 'Authorization is still pending.', + `Open ${result.verificationUriComplete} and enter ${result.userCode}, then run \`shopify auth login --resume\` again.`, + ) + case 'expired': + case 'denied': + case 'no_pending': + throw new AbortError(result.message) + } + } + + if (!isTTY()) { + if (!flags.new) { + const authStatus = await getAuthStatus() + if (authStatus.authenticated) { + const account = authStatus.account?.alias ?? authStatus.account?.userId + outputCompleted(`Current account: ${account}.`) + return + } + } + + await startDeviceAuthLogin() + outputInfo('After authorizing, run `shopify auth login --resume` to complete login.') + return + } + + const result = flags.new + ? await promptSessionSelect(flags.alias, {forceNewSession: true}) + : await promptSessionSelect(flags.alias) outputCompleted(`Current account: ${result}.`) } } diff --git a/packages/cli/src/cli/commands/auth/status.ts b/packages/cli/src/cli/commands/auth/status.ts index f3d939f4b5d..636d0a6a123 100644 --- a/packages/cli/src/cli/commands/auth/status.ts +++ b/packages/cli/src/cli/commands/auth/status.ts @@ -7,7 +7,9 @@ export default class Status extends Command { static descriptionWithMarkdown = `Shows whether Shopify CLI has a usable Shopify account session. -Use \`--json\` for stable machine-readable output. Agents should check this command before starting workflows that need Shopify account authentication.` +Use \`--json\` for stable machine-readable output. Agents should check this command before starting workflows that need Shopify account authentication. + +When this command reports that no usable session exists, run \`shopify auth login\`. In a non-TTY environment, show the verification URL and code to the user, then run \`shopify auth login --resume\` after the user authorizes.` static description = this.descriptionWithoutMarkdown()