Skip to content

Commit a2dd71d

Browse files
Add auth status command
Expose a JSON-capable auth status command so agents can check whether Shopify CLI has a usable account session before starting authenticated workflows.
1 parent c422f35 commit a2dd71d

8 files changed

Lines changed: 415 additions & 1 deletion

File tree

Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
import {getAuthStatus} from './session.js'
2+
import {identityFqdn} from './context/fqdn.js'
3+
import {getCurrentSessionId} from '../../private/node/conf-store.js'
4+
import * as sessionStore from '../../private/node/session/store.js'
5+
import {validateSession} from '../../private/node/session/validate.js'
6+
import {Session} from '../../private/node/session/schema.js'
7+
8+
import {beforeEach, describe, expect, test, vi} from 'vitest'
9+
10+
vi.mock('./context/fqdn.js')
11+
vi.mock('../../private/node/conf-store.js')
12+
vi.mock('../../private/node/session/store.js')
13+
vi.mock('../../private/node/session/validate.js')
14+
15+
const expiresAt = new Date('2030-01-01T00:00:00.000Z')
16+
const session: Session = {
17+
identity: {
18+
accessToken: 'identity-token',
19+
refreshToken: 'refresh-token',
20+
expiresAt,
21+
scopes: ['scope'],
22+
userId: 'user-id',
23+
alias: 'user@example.com',
24+
},
25+
applications: {},
26+
}
27+
28+
describe('getAuthStatus', () => {
29+
beforeEach(() => {
30+
vi.mocked(identityFqdn).mockResolvedValue('accounts.shopify.com')
31+
vi.mocked(getCurrentSessionId).mockReturnValue('user-id')
32+
vi.mocked(sessionStore.fetch).mockResolvedValue({
33+
'accounts.shopify.com': {
34+
'user-id': session,
35+
},
36+
})
37+
vi.mocked(validateSession).mockResolvedValue('ok')
38+
})
39+
40+
test('returns authenticated for a valid current session', async () => {
41+
// When
42+
const got = await getAuthStatus()
43+
44+
// Then
45+
expect(got).toEqual({
46+
status: 'authenticated',
47+
authenticated: true,
48+
account: {
49+
userId: 'user-id',
50+
alias: 'user@example.com',
51+
},
52+
identityFqdn: 'accounts.shopify.com',
53+
expiresAt: '2030-01-01T00:00:00.000Z',
54+
agentGuidance: {
55+
instruction: 'A Shopify CLI session is available. Continue with the requested Shopify CLI command.',
56+
},
57+
})
58+
})
59+
60+
test('returns needs_refresh for a refreshable current session', async () => {
61+
// Given
62+
vi.mocked(validateSession).mockResolvedValue('needs_refresh')
63+
64+
// When
65+
const got = await getAuthStatus()
66+
67+
// Then
68+
expect(got.status).toBe('needs_refresh')
69+
expect(got.authenticated).toBe(true)
70+
expect(got.account?.userId).toBe('user-id')
71+
})
72+
73+
test('falls back to the first stored session when no current session is configured', async () => {
74+
// Given
75+
vi.mocked(getCurrentSessionId).mockReturnValue(undefined)
76+
77+
// When
78+
const got = await getAuthStatus()
79+
80+
// Then
81+
expect(got.status).toBe('authenticated')
82+
expect(got.account?.userId).toBe('user-id')
83+
})
84+
85+
test('returns not_authenticated when no session exists', async () => {
86+
// Given
87+
vi.mocked(getCurrentSessionId).mockReturnValue(undefined)
88+
vi.mocked(sessionStore.fetch).mockResolvedValue(undefined)
89+
90+
// When
91+
const got = await getAuthStatus()
92+
93+
// Then
94+
expect(got).toEqual({
95+
status: 'not_authenticated',
96+
authenticated: false,
97+
identityFqdn: 'accounts.shopify.com',
98+
agentGuidance: {
99+
instruction:
100+
'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.',
101+
nextCommand: 'shopify auth login',
102+
},
103+
})
104+
})
105+
106+
test('returns invalid when the current session id is missing from storage', async () => {
107+
// Given
108+
vi.mocked(getCurrentSessionId).mockReturnValue('missing-user-id')
109+
110+
// When
111+
const got = await getAuthStatus()
112+
113+
// Then
114+
expect(got.status).toBe('invalid')
115+
expect(got.authenticated).toBe(false)
116+
})
117+
})

packages/cli-kit/src/public/node/session.ts

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,13 @@
11
import {shopifyFetch} from './http.js'
22
import {nonRandomUUID} from './crypto.js'
33
import {getAppAutomationToken} from './environment.js'
4+
import {identityFqdn} from './context/fqdn.js'
45
import {AbortError, BugError} from './error.js'
56
import {outputContent, outputToken, outputDebug} from './output.js'
7+
import {getCurrentSessionId} from '../../private/node/conf-store.js'
68
import * as sessionStore from '../../private/node/session/store.js'
9+
import {allDefaultScopes} from '../../private/node/session/scopes.js'
10+
import {validateSession} from '../../private/node/session/validate.js'
711
import {
812
exchangeCustomPartnerToken,
913
exchangeAppAutomationTokenForAppManagementAccessToken,
@@ -65,6 +69,23 @@ interface UnknownAccountInfo {
6569
type: 'UnknownAccount'
6670
}
6771

72+
export type AuthStatusName = 'authenticated' | 'needs_refresh' | 'not_authenticated' | 'invalid'
73+
74+
export interface AuthStatus {
75+
status: AuthStatusName
76+
authenticated: boolean
77+
account?: {
78+
userId: string
79+
alias?: string
80+
}
81+
identityFqdn?: string
82+
expiresAt?: string
83+
agentGuidance: {
84+
instruction: string
85+
nextCommand?: string
86+
}
87+
}
88+
6889
/**
6990
* Type guard to check if an account is a UserAccount.
7091
*
@@ -85,6 +106,72 @@ export function isServiceAccount(account: AccountInfo): account is ServiceAccoun
85106
return account.type === 'ServiceAccount'
86107
}
87108

109+
function authStatusGuidance(status: AuthStatusName): AuthStatus['agentGuidance'] {
110+
if (status === 'authenticated') {
111+
return {instruction: 'A Shopify CLI session is available. Continue with the requested Shopify CLI command.'}
112+
}
113+
114+
if (status === 'needs_refresh') {
115+
return {
116+
instruction:
117+
'A Shopify CLI session is available, but it may refresh before the next command. Continue with the requested Shopify CLI command.',
118+
}
119+
}
120+
121+
return {
122+
instruction:
123+
'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.',
124+
nextCommand: 'shopify auth login',
125+
}
126+
}
127+
128+
/**
129+
* Returns the current Shopify CLI authentication status without starting a login flow.
130+
*
131+
* @returns The current authentication status.
132+
*/
133+
export async function getAuthStatus(): Promise<AuthStatus> {
134+
const fqdn = await identityFqdn()
135+
const sessions = await sessionStore.fetch()
136+
const fqdnSessions = sessions?.[fqdn] ?? {}
137+
const currentSessionId = getCurrentSessionId()
138+
const sessionId = currentSessionId ?? Object.keys(fqdnSessions)[0]
139+
140+
if (!sessionId) {
141+
return {
142+
status: 'not_authenticated',
143+
authenticated: false,
144+
identityFqdn: fqdn,
145+
agentGuidance: authStatusGuidance('not_authenticated'),
146+
}
147+
}
148+
149+
const session = fqdnSessions[sessionId]
150+
if (!session) {
151+
return {
152+
status: 'invalid',
153+
authenticated: false,
154+
identityFqdn: fqdn,
155+
agentGuidance: authStatusGuidance('invalid'),
156+
}
157+
}
158+
159+
const validationResult = await validateSession(allDefaultScopes(), {}, session)
160+
const status = validationResult === 'ok' ? 'authenticated' : validationResult === 'needs_refresh' ? 'needs_refresh' : 'invalid'
161+
162+
return {
163+
status,
164+
authenticated: status !== 'invalid',
165+
account: {
166+
userId: session.identity.userId,
167+
alias: session.identity.alias,
168+
},
169+
identityFqdn: fqdn,
170+
expiresAt: session.identity.expiresAt.toISOString(),
171+
agentGuidance: authStatusGuidance(status),
172+
}
173+
}
174+
88175
/**
89176
* Ensure that we have a valid session with no particular scopes.
90177
*

packages/cli/README.md

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@
3232
* [`shopify app webhook trigger`](#shopify-app-webhook-trigger)
3333
* [`shopify auth login`](#shopify-auth-login)
3434
* [`shopify auth logout`](#shopify-auth-logout)
35+
* [`shopify auth status`](#shopify-auth-status)
3536
* [`shopify commands`](#shopify-commands)
3637
* [`shopify config autocorrect off`](#shopify-config-autocorrect-off)
3738
* [`shopify config autocorrect on`](#shopify-config-autocorrect-on)
@@ -1072,6 +1073,31 @@ DESCRIPTION
10721073
Logs you out of the Shopify account or Partner account and store.
10731074
```
10741075

1076+
## `shopify auth status`
1077+
1078+
Show Shopify account authentication status.
1079+
1080+
```
1081+
USAGE
1082+
$ shopify auth status [-j]
1083+
1084+
FLAGS
1085+
-j, --json [env: SHOPIFY_FLAG_JSON] Output the result as JSON. Automatically disables color output.
1086+
1087+
DESCRIPTION
1088+
Show Shopify account authentication status.
1089+
1090+
Shows whether Shopify CLI has a usable Shopify account session.
1091+
1092+
Use `--json` for stable machine-readable output. Agents should check this command before starting workflows that need
1093+
Shopify account authentication.
1094+
1095+
EXAMPLES
1096+
$ shopify auth status
1097+
1098+
$ shopify auth status --json
1099+
```
1100+
10751101
## `shopify commands`
10761102

10771103
List all shopify commands.

packages/cli/oclif.manifest.json

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3081,6 +3081,39 @@
30813081
"pluginType": "core",
30823082
"strict": true
30833083
},
3084+
"auth:status": {
3085+
"aliases": [
3086+
],
3087+
"args": {
3088+
},
3089+
"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.",
3090+
"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.",
3091+
"enableJsonFlag": false,
3092+
"examples": [
3093+
"<%= config.bin %> <%= command.id %>",
3094+
"<%= config.bin %> <%= command.id %> --json"
3095+
],
3096+
"flags": {
3097+
"json": {
3098+
"allowNo": false,
3099+
"char": "j",
3100+
"description": "Output the result as JSON. Automatically disables color output.",
3101+
"env": "SHOPIFY_FLAG_JSON",
3102+
"hidden": false,
3103+
"name": "json",
3104+
"type": "boolean"
3105+
}
3106+
},
3107+
"hasDynamicHelp": false,
3108+
"hiddenAliases": [
3109+
],
3110+
"id": "auth:status",
3111+
"pluginAlias": "@shopify/cli",
3112+
"pluginName": "@shopify/cli",
3113+
"pluginType": "core",
3114+
"strict": true,
3115+
"summary": "Show Shopify account authentication status."
3116+
},
30843117
"cache:clear": {
30853118
"aliases": [
30863119
],
Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
import Status from './status.js'
2+
import {getAuthStatus} from '@shopify/cli-kit/node/session'
3+
import {mockAndCaptureOutput} from '@shopify/cli-kit/node/testing/output'
4+
5+
import {afterEach, beforeEach, describe, expect, test, vi} from 'vitest'
6+
7+
vi.mock('@shopify/cli-kit/node/session')
8+
9+
describe('auth status command', () => {
10+
beforeEach(() => {
11+
mockAndCaptureOutput().clear()
12+
vi.clearAllMocks()
13+
process.exitCode = undefined
14+
})
15+
16+
afterEach(() => {
17+
mockAndCaptureOutput().clear()
18+
process.exitCode = undefined
19+
})
20+
21+
test('prints authenticated status as text', async () => {
22+
// Given
23+
const outputMock = mockAndCaptureOutput()
24+
vi.mocked(getAuthStatus).mockResolvedValue({
25+
status: 'authenticated',
26+
authenticated: true,
27+
account: {userId: 'user-id', alias: 'user@example.com'},
28+
identityFqdn: 'accounts.shopify.com',
29+
expiresAt: '2030-01-01T00:00:00.000Z',
30+
agentGuidance: {
31+
instruction: 'A Shopify CLI session is available. Continue with the requested Shopify CLI command.',
32+
},
33+
})
34+
35+
// When
36+
await Status.run([])
37+
38+
// Then
39+
expect(outputMock.info()).toBe('Logged in as user@example.com.')
40+
expect(process.exitCode).toBeUndefined()
41+
})
42+
43+
test('prints status as JSON', async () => {
44+
// Given
45+
const outputMock = mockAndCaptureOutput()
46+
const status = {
47+
status: 'not_authenticated' as const,
48+
authenticated: false,
49+
identityFqdn: 'accounts.shopify.com',
50+
agentGuidance: {
51+
instruction:
52+
'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.',
53+
nextCommand: 'shopify auth login',
54+
},
55+
}
56+
vi.mocked(getAuthStatus).mockResolvedValue(status)
57+
58+
// When
59+
await Status.run(['--json'])
60+
61+
// Then
62+
expect(JSON.parse(outputMock.output())).toEqual(status)
63+
expect(process.exitCode).toBe(1)
64+
})
65+
66+
test('sets a failing exit code when not authenticated', async () => {
67+
// Given
68+
const outputMock = mockAndCaptureOutput()
69+
vi.mocked(getAuthStatus).mockResolvedValue({
70+
status: 'not_authenticated',
71+
authenticated: false,
72+
identityFqdn: 'accounts.shopify.com',
73+
agentGuidance: {
74+
instruction:
75+
'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.',
76+
nextCommand: 'shopify auth login',
77+
},
78+
})
79+
80+
// When
81+
await Status.run([])
82+
83+
// Then
84+
expect(outputMock.info()).toBe('Not logged in. Run `shopify auth login`.')
85+
expect(process.exitCode).toBe(1)
86+
})
87+
})

0 commit comments

Comments
 (0)