diff --git a/.changeset/clever-auth-status.md b/.changeset/clever-auth-status.md new file mode 100644 index 00000000000..213d790a46f --- /dev/null +++ b/.changeset/clever-auth-status.md @@ -0,0 +1,6 @@ +--- +'@shopify/cli': minor +'@shopify/cli-kit': minor +--- + +Add `shopify auth status` with JSON output for checking Shopify CLI account authentication. diff --git a/docs-shopify.dev/commands/interfaces/auth-status.interface.ts b/docs-shopify.dev/commands/interfaces/auth-status.interface.ts new file mode 100644 index 00000000000..1ad894810ed --- /dev/null +++ b/docs-shopify.dev/commands/interfaces/auth-status.interface.ts @@ -0,0 +1,12 @@ +// This is an autogenerated file. Don't edit this file manually. +/** + * The following flags are available for the `auth status` command: + * @publicDocs + */ +export interface authstatus { + /** + * Output the result as JSON. Automatically disables color output. + * @environment SHOPIFY_FLAG_JSON + */ + '-j, --json'?: '' +} diff --git a/docs-shopify.dev/generated/generated_docs_data_v2.json b/docs-shopify.dev/generated/generated_docs_data_v2.json index 4965084de82..ffcffb0094f 100644 --- a/docs-shopify.dev/generated/generated_docs_data_v2.json +++ b/docs-shopify.dev/generated/generated_docs_data_v2.json @@ -2607,6 +2607,26 @@ "value": "export interface authlogout {\n\n}" } }, + "authstatus": { + "docs-shopify.dev/commands/interfaces/auth-status.interface.ts": { + "filePath": "docs-shopify.dev/commands/interfaces/auth-status.interface.ts", + "name": "authstatus", + "description": "The following flags are available for the `auth status` command:", + "isPublicDocs": true, + "members": [ + { + "filePath": "docs-shopify.dev/commands/interfaces/auth-status.interface.ts", + "syntaxKind": "PropertySignature", + "name": "-j, --json", + "value": "''", + "description": "Output the result as JSON. Automatically disables color output.", + "isOptional": true, + "environmentValue": "SHOPIFY_FLAG_JSON" + } + ], + "value": "export interface authstatus {\n /**\n * Output the result as JSON. Automatically disables color output.\n * @environment SHOPIFY_FLAG_JSON\n */\n '-j, --json'?: ''\n}" + } + }, "commands": { "docs-shopify.dev/commands/interfaces/commands.interface.ts": { "filePath": "docs-shopify.dev/commands/interfaces/commands.interface.ts", 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 new file mode 100644 index 00000000000..adc3fd75b3b --- /dev/null +++ b/packages/cli-kit/src/public/node/session-auth-status.test.ts @@ -0,0 +1,117 @@ +import {getAuthStatus} from './session.js' +import {identityFqdn} from './context/fqdn.js' +import {getCurrentSessionId} from '../../private/node/conf-store.js' +import * as sessionStore from '../../private/node/session/store.js' +import {validateSession} from '../../private/node/session/validate.js' +import {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/store.js') +vi.mock('../../private/node/session/validate.js') + +const expiresAt = new Date('2030-01-01T00:00:00.000Z') +const session: Session = { + identity: { + accessToken: 'identity-token', + refreshToken: 'refresh-token', + expiresAt, + scopes: ['scope'], + userId: 'user-id', + alias: 'user@example.com', + }, + applications: {}, +} + +describe('getAuthStatus', () => { + beforeEach(() => { + vi.mocked(identityFqdn).mockResolvedValue('accounts.shopify.com') + vi.mocked(getCurrentSessionId).mockReturnValue('user-id') + vi.mocked(sessionStore.fetch).mockResolvedValue({ + 'accounts.shopify.com': { + 'user-id': session, + }, + }) + vi.mocked(validateSession).mockResolvedValue('ok') + }) + + test('returns authenticated for a valid current session', async () => { + // When + const got = await getAuthStatus() + + // Then + expect(got).toEqual({ + status: 'authenticated', + authenticated: true, + account: { + userId: 'user-id', + alias: 'user@example.com', + }, + identityFqdn: 'accounts.shopify.com', + expiresAt: '2030-01-01T00:00:00.000Z', + agentGuidance: { + instruction: 'A Shopify CLI session is available. Continue with the requested Shopify CLI command.', + }, + }) + }) + + test('returns needs_refresh for a refreshable current session', async () => { + // Given + vi.mocked(validateSession).mockResolvedValue('needs_refresh') + + // When + const got = await getAuthStatus() + + // Then + expect(got.status).toBe('needs_refresh') + expect(got.authenticated).toBe(true) + expect(got.account?.userId).toBe('user-id') + }) + + test('falls back to the first stored session when no current session is configured', async () => { + // Given + vi.mocked(getCurrentSessionId).mockReturnValue(undefined) + + // When + const got = await getAuthStatus() + + // Then + expect(got.status).toBe('authenticated') + expect(got.account?.userId).toBe('user-id') + }) + + test('returns not_authenticated when no session exists', async () => { + // Given + vi.mocked(getCurrentSessionId).mockReturnValue(undefined) + vi.mocked(sessionStore.fetch).mockResolvedValue(undefined) + + // When + const got = await getAuthStatus() + + // Then + expect(got).toEqual({ + status: 'not_authenticated', + authenticated: false, + 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.', + nextCommand: 'shopify auth login', + }, + }) + }) + + test('returns invalid when the current session id is missing from storage', async () => { + // Given + vi.mocked(getCurrentSessionId).mockReturnValue('missing-user-id') + + // When + const got = await getAuthStatus() + + // Then + expect(got.status).toBe('invalid') + expect(got.authenticated).toBe(false) + }) +}) diff --git a/packages/cli-kit/src/public/node/session.ts b/packages/cli-kit/src/public/node/session.ts index 1ebffdeaf47..2d72d59ff85 100644 --- a/packages/cli-kit/src/public/node/session.ts +++ b/packages/cli-kit/src/public/node/session.ts @@ -1,9 +1,13 @@ import {shopifyFetch} from './http.js' import {nonRandomUUID} from './crypto.js' import {getAppAutomationToken} from './environment.js' +import {identityFqdn} from './context/fqdn.js' import {AbortError, BugError} from './error.js' import {outputContent, outputToken, outputDebug} from './output.js' +import {getCurrentSessionId} 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 { exchangeCustomPartnerToken, exchangeAppAutomationTokenForAppManagementAccessToken, @@ -65,6 +69,23 @@ interface UnknownAccountInfo { type: 'UnknownAccount' } +export type AuthStatusName = 'authenticated' | 'needs_refresh' | 'not_authenticated' | 'invalid' + +export interface AuthStatus { + status: AuthStatusName + authenticated: boolean + account?: { + userId: string + alias?: string + } + identityFqdn?: string + expiresAt?: string + agentGuidance: { + instruction: string + nextCommand?: string + } +} + /** * Type guard to check if an account is a UserAccount. * @@ -85,6 +106,78 @@ export function isServiceAccount(account: AccountInfo): account is ServiceAccoun return account.type === 'ServiceAccount' } +function authStatusGuidance(status: AuthStatusName): AuthStatus['agentGuidance'] { + if (status === 'authenticated') { + return {instruction: 'A Shopify CLI session is available. Continue with the requested Shopify CLI command.'} + } + + if (status === 'needs_refresh') { + return { + instruction: + 'A Shopify CLI session is available, but it may refresh before the next command. Continue with the requested Shopify CLI command.', + } + } + + 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.', + nextCommand: 'shopify auth login', + } +} + +function validationResultToAuthStatus(validationResult: Awaited>): AuthStatusName { + if (validationResult === 'ok') return 'authenticated' + if (validationResult === 'needs_refresh') return 'needs_refresh' + return 'invalid' +} + +/** + * Returns the current Shopify CLI authentication status without starting a login flow. + * + * @returns The current authentication status. + */ +export async function getAuthStatus(): Promise { + const fqdn = await identityFqdn() + const sessions = await sessionStore.fetch() + const fqdnSessions = sessions?.[fqdn] ?? {} + const currentSessionId = getCurrentSessionId() + const sessionId = currentSessionId ?? Object.keys(fqdnSessions)[0] + + if (!sessionId) { + return { + status: 'not_authenticated', + authenticated: false, + identityFqdn: fqdn, + agentGuidance: authStatusGuidance('not_authenticated'), + } + } + + const session = fqdnSessions[sessionId] + if (!session) { + return { + status: 'invalid', + authenticated: false, + identityFqdn: fqdn, + agentGuidance: authStatusGuidance('invalid'), + } + } + + const validationResult = await validateSession(allDefaultScopes(), {}, session) + const status = validationResultToAuthStatus(validationResult) + + return { + status, + authenticated: status !== 'invalid', + account: { + userId: session.identity.userId, + alias: session.identity.alias, + }, + identityFqdn: fqdn, + expiresAt: session.identity.expiresAt.toISOString(), + agentGuidance: authStatusGuidance(status), + } +} + /** * Ensure that we have a valid session with no particular scopes. * diff --git a/packages/cli/README.md b/packages/cli/README.md index d96a305ea8a..21dadd72067 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -32,6 +32,7 @@ * [`shopify app webhook trigger`](#shopify-app-webhook-trigger) * [`shopify auth login`](#shopify-auth-login) * [`shopify auth logout`](#shopify-auth-logout) +* [`shopify auth status`](#shopify-auth-status) * [`shopify commands`](#shopify-commands) * [`shopify config autocorrect off`](#shopify-config-autocorrect-off) * [`shopify config autocorrect on`](#shopify-config-autocorrect-on) @@ -1072,6 +1073,31 @@ DESCRIPTION Logs you out of the Shopify account or Partner account and store. ``` +## `shopify auth status` + +Show Shopify account authentication status. + +``` +USAGE + $ shopify auth status [-j] + +FLAGS + -j, --json [env: SHOPIFY_FLAG_JSON] Output the result as JSON. Automatically disables color output. + +DESCRIPTION + Show Shopify account authentication status. + + 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. + +EXAMPLES + $ shopify auth status + + $ shopify auth status --json +``` + ## `shopify commands` List all shopify commands. diff --git a/packages/cli/oclif.manifest.json b/packages/cli/oclif.manifest.json index 046ac204728..7f13cde58b9 100644 --- a/packages/cli/oclif.manifest.json +++ b/packages/cli/oclif.manifest.json @@ -3081,6 +3081,39 @@ "pluginType": "core", "strict": true }, + "auth:status": { + "aliases": [ + ], + "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.", + "enableJsonFlag": false, + "examples": [ + "<%= config.bin %> <%= command.id %>", + "<%= config.bin %> <%= command.id %> --json" + ], + "flags": { + "json": { + "allowNo": false, + "char": "j", + "description": "Output the result as JSON. Automatically disables color output.", + "env": "SHOPIFY_FLAG_JSON", + "hidden": false, + "name": "json", + "type": "boolean" + } + }, + "hasDynamicHelp": false, + "hiddenAliases": [ + ], + "id": "auth:status", + "pluginAlias": "@shopify/cli", + "pluginName": "@shopify/cli", + "pluginType": "core", + "strict": true, + "summary": "Show Shopify account authentication status." + }, "cache:clear": { "aliases": [ ], diff --git a/packages/cli/src/cli/commands/auth/status.test.ts b/packages/cli/src/cli/commands/auth/status.test.ts new file mode 100644 index 00000000000..5a8d21de7fc --- /dev/null +++ b/packages/cli/src/cli/commands/auth/status.test.ts @@ -0,0 +1,24 @@ +import Status from './status.js' +import {authStatusService} from '../../services/commands/auth-status.js' + +import {describe, expect, test, vi} from 'vitest' + +vi.mock('../../services/commands/auth-status.js') + +describe('auth status command', () => { + test('checks auth status as text by default', async () => { + // When + await Status.run([]) + + // Then + expect(authStatusService).toHaveBeenCalledWith(false) + }) + + test('checks auth status as JSON', async () => { + // When + await Status.run(['--json']) + + // Then + expect(authStatusService).toHaveBeenCalledWith(true) + }) +}) diff --git a/packages/cli/src/cli/commands/auth/status.ts b/packages/cli/src/cli/commands/auth/status.ts new file mode 100644 index 00000000000..f3d939f4b5d --- /dev/null +++ b/packages/cli/src/cli/commands/auth/status.ts @@ -0,0 +1,24 @@ +import {authStatusService} from '../../services/commands/auth-status.js' +import Command from '@shopify/cli-kit/node/base-command' +import {jsonFlag} from '@shopify/cli-kit/node/cli' + +export default class Status extends Command { + static summary = 'Show Shopify account authentication status.' + + 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.` + + static description = this.descriptionWithoutMarkdown() + + static examples = ['<%= config.bin %> <%= command.id %>', '<%= config.bin %> <%= command.id %> --json'] + + static flags = { + ...jsonFlag, + } + + async run(): Promise { + const {flags} = await this.parse(Status) + await authStatusService(flags.json) + } +} diff --git a/packages/cli/src/cli/services/commands/auth-status.test.ts b/packages/cli/src/cli/services/commands/auth-status.test.ts new file mode 100644 index 00000000000..3871900461e --- /dev/null +++ b/packages/cli/src/cli/services/commands/auth-status.test.ts @@ -0,0 +1,86 @@ +import {authStatusService} from './auth-status.js' +import {getAuthStatus} from '@shopify/cli-kit/node/session' +import {mockAndCaptureOutput} from '@shopify/cli-kit/node/testing/output' + +import {afterEach, beforeEach, describe, expect, test, vi} from 'vitest' + +vi.mock('@shopify/cli-kit/node/session') + +describe('authStatusService', () => { + beforeEach(() => { + mockAndCaptureOutput().clear() + process.exitCode = undefined + }) + + afterEach(() => { + mockAndCaptureOutput().clear() + process.exitCode = undefined + }) + + test('prints authenticated status as text', async () => { + // Given + const outputMock = mockAndCaptureOutput() + vi.mocked(getAuthStatus).mockResolvedValue({ + status: 'authenticated', + authenticated: true, + account: {userId: 'user-id', alias: 'user@example.com'}, + identityFqdn: 'accounts.shopify.com', + expiresAt: '2030-01-01T00:00:00.000Z', + agentGuidance: { + instruction: 'A Shopify CLI session is available. Continue with the requested Shopify CLI command.', + }, + }) + + // When + await authStatusService(false) + + // Then + expect(outputMock.info()).toBe('Logged in as user@example.com.') + expect(process.exitCode).toBeUndefined() + }) + + test('prints status as JSON', async () => { + // Given + const outputMock = mockAndCaptureOutput() + const status = { + status: 'not_authenticated' as const, + authenticated: false, + 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.', + nextCommand: 'shopify auth login', + }, + } + vi.mocked(getAuthStatus).mockResolvedValue(status) + + // When + await authStatusService(true) + + // Then + expect(JSON.parse(outputMock.output())).toEqual(status) + expect(process.exitCode).toBe(1) + }) + + test('sets a failing exit code when not authenticated', async () => { + // Given + const outputMock = mockAndCaptureOutput() + vi.mocked(getAuthStatus).mockResolvedValue({ + status: 'not_authenticated', + authenticated: false, + 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.', + nextCommand: 'shopify auth login', + }, + }) + + // When + await authStatusService(false) + + // Then + expect(outputMock.info()).toBe('Not logged in. Run `shopify auth login`.') + expect(process.exitCode).toBe(1) + }) +}) diff --git a/packages/cli/src/cli/services/commands/auth-status.ts b/packages/cli/src/cli/services/commands/auth-status.ts new file mode 100644 index 00000000000..177f6b922c1 --- /dev/null +++ b/packages/cli/src/cli/services/commands/auth-status.ts @@ -0,0 +1,42 @@ +import {outputInfo, outputResult} from '@shopify/cli-kit/node/output' +import {AuthStatus, getAuthStatus} from '@shopify/cli-kit/node/session' + +function serializeAuthStatus(status: AuthStatus): string { + return JSON.stringify(status, null, 2) +} + +function displayAuthStatus(status: AuthStatus): void { + switch (status.status) { + case 'authenticated': { + const account = status.account?.alias ?? status.account?.userId + outputInfo(`Logged in as ${account}.`) + return + } + case 'needs_refresh': { + const account = status.account?.alias ?? status.account?.userId + outputInfo(`Logged in as ${account}, but the session may refresh before use.`) + return + } + case 'not_authenticated': { + outputInfo('Not logged in. Run `shopify auth login`.') + return + } + case 'invalid': { + outputInfo('The saved Shopify CLI session is invalid. Run `shopify auth login`.') + } + } +} + +export async function authStatusService(json: boolean): Promise { + const status = await getAuthStatus() + + if (json) { + outputResult(serializeAuthStatus(status)) + } else { + displayAuthStatus(status) + } + + if (!status.authenticated) { + process.exitCode = 1 + } +} diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index ff506419c63..d124fa728d5 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -3,6 +3,7 @@ import Search from './cli/commands/search.js' import Upgrade from './cli/commands/upgrade.js' import Logout from './cli/commands/auth/logout.js' import Login from './cli/commands/auth/login.js' +import Status from './cli/commands/auth/status.js' import CommandFlags from './cli/commands/debug/command-flags.js' import KitchenSinkAsync from './cli/commands/kitchen-sink/async.js' import KitchenSinkPrompts from './cli/commands/kitchen-sink/prompts.js' @@ -150,6 +151,7 @@ export const COMMANDS: any = { help: HelpCommand, 'auth:logout': Logout, 'auth:login': Login, + 'auth:status': Status, 'debug:command-flags': CommandFlags, 'kitchen-sink': KitchenSink, 'kitchen-sink:async': KitchenSinkAsync, diff --git a/packages/e2e/data/snapshots/commands.txt b/packages/e2e/data/snapshots/commands.txt index 9e9afb6c946..ccd79e9a7f2 100644 --- a/packages/e2e/data/snapshots/commands.txt +++ b/packages/e2e/data/snapshots/commands.txt @@ -38,7 +38,8 @@ │ └─ trigger ├─ auth │ ├─ login -│ └─ logout +│ ├─ logout +│ └─ status ├─ commands ├─ config │ ├─ autocorrect