Skip to content

Commit 9772ca8

Browse files
Improve agent guidance for store auth
1 parent f07f957 commit 9772ca8

10 files changed

Lines changed: 229 additions & 103 deletions

File tree

docs-shopify.dev/generated/generated_docs_data_v2.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4190,6 +4190,7 @@
41904190
"name": "--scopes <value>",
41914191
"value": "string",
41924192
"description": "Comma-separated Admin API scopes to request for the app.",
4193+
"isOptional": false,
41934194
"environmentValue": "SHOPIFY_FLAG_SCOPES"
41944195
},
41954196
{
@@ -4216,6 +4217,7 @@
42164217
"name": "-s, --store <value>",
42174218
"value": "string",
42184219
"description": "The myshopify.com domain of the store to authenticate against.",
4220+
"isOptional": false,
42194221
"environmentValue": "SHOPIFY_FLAG_STORE"
42204222
}
42214223
],

packages/cli/README.md

Lines changed: 13 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2160,12 +2160,13 @@ USAGE
21602160
$ shopify store auth --scopes <value> -s <value> [-j] [--no-color] [--verbose]
21612161
21622162
FLAGS
2163-
-j, --json [env: SHOPIFY_FLAG_JSON] Output the result as JSON. Automatically disables color output.
2164-
-s, --store=<value> (required) [env: SHOPIFY_FLAG_STORE] The myshopify.com domain of the store to authenticate
2165-
against.
2166-
--no-color [env: SHOPIFY_FLAG_NO_COLOR] Disable color output.
2167-
--scopes=<value> (required) [env: SHOPIFY_FLAG_SCOPES] Comma-separated Admin API scopes to request for the app.
2168-
--verbose [env: SHOPIFY_FLAG_VERBOSE] Increase the verbosity of the output.
2163+
-j, --json [env: SHOPIFY_FLAG_JSON] Output the result as JSON. Automatically disables color output.
2164+
-s, --store=<value> (required) [env: SHOPIFY_FLAG_STORE] The myshopify.com domain of the store to authenticate
2165+
against.
2166+
--no-color [env: SHOPIFY_FLAG_NO_COLOR] Disable color output.
2167+
--scopes=<value> (required) [env: SHOPIFY_FLAG_SCOPES] Comma-separated Admin API scopes to request for the
2168+
app.
2169+
--verbose [env: SHOPIFY_FLAG_VERBOSE] Increase the verbosity of the output.
21692170
21702171
DESCRIPTION
21712172
Authenticate an app against a store for store commands.
@@ -2175,6 +2176,12 @@ DESCRIPTION
21752176
21762177
Re-run this command if the stored token is missing, expires, or no longer has the scopes you need.
21772178
2179+
In an interactive terminal, Shopify CLI opens or prints the authorization URL and waits for authentication to complete.
2180+
Agents should keep the command running until the browser authorization finishes.
2181+
2182+
In a non-TTY environment, Shopify CLI returns the current session if it already has the requested scopes. If no usable
2183+
session exists, it starts the same OAuth flow and waits for authentication to complete.
2184+
21782185
EXAMPLES
21792186
$ shopify store auth --store shop.myshopify.com --scopes read_products,write_products
21802187

packages/cli/oclif.manifest.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5730,8 +5730,8 @@
57305730
"args": {
57315731
},
57325732
"customPluginName": "@shopify/store",
5733-
"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.",
5734-
"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.",
5733+
"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.",
5734+
"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.",
57355735
"examples": [
57365736
"<%= config.bin %> <%= command.id %> --store shop.myshopify.com --scopes read_products,write_products",
57375737
"<%= config.bin %> <%= command.id %> --store shop.myshopify.com --scopes read_products,write_products --json"

packages/store/src/cli/commands/store/auth.test.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,10 @@ describe('store auth command', () => {
4040
expect(StoreAuth.flags.store).toBeDefined()
4141
expect(StoreAuth.flags.scopes).toBeDefined()
4242
expect(StoreAuth.flags.json).toBeDefined()
43+
expect(StoreAuth.flags.store.required).toBe(true)
44+
expect(StoreAuth.flags.scopes.required).toBe(true)
45+
expect('resume' in StoreAuth.flags).toBe(false)
46+
expect('callback-url' in StoreAuth.flags).toBe(false)
4347
expect('port' in StoreAuth.flags).toBe(false)
4448
expect('client-secret-file' in StoreAuth.flags).toBe(false)
4549
})

packages/store/src/cli/commands/store/auth.ts

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,11 @@ export default class StoreAuth extends StoreCommand {
1010

1111
static descriptionWithMarkdown = `Authenticates the app against the specified store for store commands and stores an online access token for later reuse.
1212
13-
Re-run this command if the stored token is missing, expires, or no longer has the scopes you need.`
13+
Re-run this command if the stored token is missing, expires, or no longer has the scopes you need.
14+
15+
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.
16+
17+
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.`
1418

1519
static description = this.descriptionWithoutMarkdown()
1620

@@ -38,14 +42,15 @@ Re-run this command if the stored token is missing, expires, or no longer has th
3842

3943
public async run(): Promise<void> {
4044
const {flags} = await this.parse(StoreAuth)
45+
const presenter = createStoreAuthPresenter(flags.json ? 'json' : 'text')
4146

4247
await authenticateStoreWithApp(
4348
{
4449
store: flags.store,
4550
scopes: flags.scopes,
4651
},
4752
{
48-
presenter: createStoreAuthPresenter(flags.json ? 'json' : 'text'),
53+
presenter,
4954
},
5055
)
5156
}

packages/store/src/cli/services/store/auth/index.test.ts

Lines changed: 99 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,27 @@
11
import {authenticateStoreWithApp} from './index.js'
2-
import {setStoredStoreAppSession} from './session-store.js'
2+
import {getCurrentStoredStoreAppSession, setStoredStoreAppSession} from './session-store.js'
33
import {STORE_AUTH_APP_CLIENT_ID} from './config.js'
44
import {recordStoreFqdnMetadata} from '../attribution.js'
55
import {setLastSeenUserId} from '@shopify/cli-kit/node/session'
6-
import {describe, expect, test, vi} from 'vitest'
6+
import {randomUUID} from '@shopify/cli-kit/node/crypto'
7+
import {terminalSupportsPrompting} from '@shopify/cli-kit/node/system'
8+
import {beforeEach, describe, expect, test, vi} from 'vitest'
79

810
vi.mock('./session-store.js')
911
vi.mock('../attribution.js')
1012
vi.mock('@shopify/cli-kit/node/session')
11-
vi.mock('@shopify/cli-kit/node/system', () => ({openURL: vi.fn().mockResolvedValue(true)}))
13+
vi.mock('@shopify/cli-kit/node/system', () => ({
14+
openURL: vi.fn().mockResolvedValue(true),
15+
terminalSupportsPrompting: vi.fn().mockReturnValue(true),
16+
}))
1217
vi.mock('@shopify/cli-kit/node/crypto', () => ({randomUUID: vi.fn().mockReturnValue('state-123')}))
1318

1419
describe('store auth service', () => {
20+
beforeEach(() => {
21+
vi.mocked(randomUUID).mockReturnValue('state-123')
22+
vi.mocked(terminalSupportsPrompting).mockReturnValue(true)
23+
})
24+
1525
test('authenticateStoreWithApp opens the browser, stores the session, and returns auth result', async () => {
1626
const openURL = vi.fn().mockResolvedValue(true)
1727
const presenter = {
@@ -77,6 +87,92 @@ describe('store auth service', () => {
7787
})
7888
})
7989

90+
test('authenticateStoreWithApp keeps waiting for auth when the terminal cannot prompt', async () => {
91+
const openURL = vi.fn().mockResolvedValue(false)
92+
const presenter = {
93+
openingBrowser: vi.fn(),
94+
manualAuthUrl: vi.fn(),
95+
success: vi.fn(),
96+
}
97+
const waitForStoreAuthCodeMock = vi.fn().mockImplementation(async (options) => {
98+
await options.onListening?.()
99+
return 'abc123'
100+
})
101+
102+
const result = await authenticateStoreWithApp(
103+
{
104+
store: 'shop.myshopify.com',
105+
scopes: 'read_products',
106+
},
107+
{
108+
openURL,
109+
presenter,
110+
terminalSupportsPrompting: vi.fn().mockReturnValue(false),
111+
waitForStoreAuthCode: waitForStoreAuthCodeMock,
112+
exchangeStoreAuthCodeForToken: vi.fn().mockResolvedValue({
113+
access_token: 'token',
114+
scope: 'read_products',
115+
expires_in: 86400,
116+
associated_user: {id: 42, email: 'test@example.com'},
117+
}),
118+
},
119+
)
120+
121+
expect(result).toEqual(
122+
expect.objectContaining({
123+
store: 'shop.myshopify.com',
124+
userId: '42',
125+
scopes: ['read_products'],
126+
}),
127+
)
128+
expect(presenter.openingBrowser).toHaveBeenCalledOnce()
129+
expect(presenter.manualAuthUrl).toHaveBeenCalledWith(
130+
expect.stringContaining('https://shop.myshopify.com/admin/oauth/authorize?'),
131+
)
132+
expect(presenter.success).toHaveBeenCalledWith(result)
133+
})
134+
135+
test('authenticateStoreWithApp returns existing session without auth when non-TTY scopes are already granted', async () => {
136+
const presenter = {
137+
openingBrowser: vi.fn(),
138+
manualAuthUrl: vi.fn(),
139+
success: vi.fn(),
140+
}
141+
vi.mocked(getCurrentStoredStoreAppSession).mockReturnValue({
142+
store: 'shop.myshopify.com',
143+
clientId: STORE_AUTH_APP_CLIENT_ID,
144+
userId: '42',
145+
accessToken: 'token',
146+
scopes: ['read_products'],
147+
acquiredAt: '2026-03-27T00:00:00.000Z',
148+
associatedUser: {id: 42, email: 'test@example.com'},
149+
})
150+
151+
const result = await authenticateStoreWithApp(
152+
{
153+
store: 'shop.myshopify.com',
154+
scopes: 'read_products',
155+
},
156+
{
157+
presenter,
158+
resolveExistingScopes: vi.fn().mockResolvedValue({scopes: ['read_products'], authoritative: true}),
159+
terminalSupportsPrompting: vi.fn().mockReturnValue(false),
160+
waitForStoreAuthCode: vi.fn(),
161+
exchangeStoreAuthCodeForToken: vi.fn(),
162+
},
163+
)
164+
165+
expect(result).toEqual(
166+
expect.objectContaining({
167+
store: 'shop.myshopify.com',
168+
userId: '42',
169+
scopes: ['read_products'],
170+
associatedUser: expect.objectContaining({email: 'test@example.com'}),
171+
}),
172+
)
173+
expect(presenter.success).toHaveBeenCalledWith(result)
174+
})
175+
80176
test('authenticateStoreWithApp uses remote scopes by default when available', async () => {
81177
const openURL = vi.fn().mockResolvedValue(true)
82178
const presenter = {

packages/store/src/cli/services/store/auth/index.ts

Lines changed: 80 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,15 @@
11
import {STORE_AUTH_APP_CLIENT_ID} from './config.js'
2-
import {setStoredStoreAppSession} from './session-store.js'
2+
import {setStoredStoreAppSession, type StoredStoreAppSession} from './session-store.js'
33
import {exchangeStoreAuthCodeForToken} from './token-client.js'
44
import {waitForStoreAuthCode} from './callback.js'
55
import {createPkceBootstrap} from './pkce.js'
66
import {mergeRequestedAndStoredScopes, parseStoreAuthScopes, resolveGrantedScopes} from './scopes.js'
77
import {resolveExistingStoreAuthScopes, type ResolvedStoreAuthScopes} from './existing-scopes.js'
8+
import {loadStoredStoreSession} from './session-lifecycle.js'
89
import {createStoreAuthPresenter, type StoreAuthPresenter, type StoreAuthResult} from './result.js'
910
import {recordStoreFqdnMetadata} from '../attribution.js'
1011
import {setLastSeenUserId} from '@shopify/cli-kit/node/session'
11-
import {openURL} from '@shopify/cli-kit/node/system'
12+
import {openURL, terminalSupportsPrompting} from '@shopify/cli-kit/node/system'
1213
import {outputContent, outputDebug, outputToken} from '@shopify/cli-kit/node/output'
1314
import {AbortError} from '@shopify/cli-kit/node/error'
1415
import {normalizeStoreFqdn} from '@shopify/cli-kit/node/context/fqdn'
@@ -24,6 +25,7 @@ interface StoreAuthDependencies {
2425
exchangeStoreAuthCodeForToken: typeof exchangeStoreAuthCodeForToken
2526
resolveExistingScopes: (store: string) => Promise<ResolvedStoreAuthScopes>
2627
presenter: StoreAuthPresenter
28+
terminalSupportsPrompting: typeof terminalSupportsPrompting
2729
}
2830

2931
const defaultStoreAuthDependencies: StoreAuthDependencies = {
@@ -32,45 +34,31 @@ const defaultStoreAuthDependencies: StoreAuthDependencies = {
3234
exchangeStoreAuthCodeForToken,
3335
resolveExistingScopes: resolveExistingStoreAuthScopes,
3436
presenter: createStoreAuthPresenter('text'),
37+
terminalSupportsPrompting,
3538
}
3639

37-
export async function authenticateStoreWithApp(
38-
input: StoreAuthInput,
39-
dependencies: Partial<StoreAuthDependencies> = {},
40-
): Promise<StoreAuthResult> {
41-
const resolvedDependencies: StoreAuthDependencies = {...defaultStoreAuthDependencies, ...dependencies}
42-
const store = normalizeStoreFqdn(input.store)
43-
await recordStoreFqdnMetadata(store, false)
44-
const requestedScopes = parseStoreAuthScopes(input.scopes)
45-
const existingScopeResolution = await resolvedDependencies.resolveExistingScopes(store)
46-
const scopes = mergeRequestedAndStoredScopes(requestedScopes, existingScopeResolution.scopes)
47-
const validationScopes = existingScopeResolution.authoritative ? scopes : requestedScopes
48-
49-
if (existingScopeResolution.scopes.length > 0) {
50-
outputDebug(
51-
outputContent`Merged requested scopes ${outputToken.raw(requestedScopes.join(','))} with existing scopes ${outputToken.raw(existingScopeResolution.scopes.join(','))} for ${outputToken.raw(store)}`,
52-
)
53-
}
54-
55-
const bootstrap = createPkceBootstrap({
56-
store,
40+
function storedSessionToStoreAuthResult(
41+
session: StoredStoreAppSession,
42+
scopes: string[],
43+
acquiredAt = session.acquiredAt,
44+
): StoreAuthResult {
45+
return {
46+
store: session.store,
47+
userId: session.userId,
5748
scopes,
58-
exchangeCodeForToken: resolvedDependencies.exchangeStoreAuthCodeForToken,
59-
})
60-
const {
61-
authorization: {authorizationUrl},
62-
} = bootstrap
63-
64-
resolvedDependencies.presenter.openingBrowser()
49+
acquiredAt,
50+
expiresAt: session.expiresAt,
51+
refreshTokenExpiresAt: session.refreshTokenExpiresAt,
52+
hasRefreshToken: Boolean(session.refreshToken),
53+
associatedUser: session.associatedUser,
54+
}
55+
}
6556

66-
const code = await resolvedDependencies.waitForStoreAuthCode({
67-
...bootstrap.waitForAuthCodeOptions,
68-
onListening: async () => {
69-
const opened = await resolvedDependencies.openURL(authorizationUrl)
70-
if (!opened) resolvedDependencies.presenter.manualAuthUrl(authorizationUrl)
71-
},
72-
})
73-
const tokenResponse = await bootstrap.exchangeCodeForToken(code)
57+
async function persistStoreAuthToken(
58+
tokenResponse: Awaited<ReturnType<typeof exchangeStoreAuthCodeForToken>>,
59+
store: string,
60+
validationScopes: string[],
61+
): Promise<StoreAuthResult> {
7462
await recordStoreFqdnMetadata(store, true)
7563

7664
const userId = tokenResponse.associated_user?.id?.toString()
@@ -81,7 +69,6 @@ export async function authenticateStoreWithApp(
8169

8270
const now = Date.now()
8371
const expiresAt = tokenResponse.expires_in ? new Date(now + tokenResponse.expires_in * 1000).toISOString() : undefined
84-
8572
const result: StoreAuthResult = {
8673
store,
8774
userId,
@@ -120,6 +107,61 @@ export async function authenticateStoreWithApp(
120107
outputContent`Session persisted for ${outputToken.raw(store)} (user ${outputToken.raw(userId)}, expires ${outputToken.raw(expiresAt ?? 'unknown')})`,
121108
)
122109

110+
return result
111+
}
112+
113+
export async function authenticateStoreWithApp(
114+
input: StoreAuthInput,
115+
dependencies: Partial<StoreAuthDependencies> = {},
116+
): Promise<StoreAuthResult> {
117+
const resolvedDependencies: StoreAuthDependencies = {...defaultStoreAuthDependencies, ...dependencies}
118+
const store = normalizeStoreFqdn(input.store)
119+
await recordStoreFqdnMetadata(store, false)
120+
const requestedScopes = parseStoreAuthScopes(input.scopes)
121+
const existingScopeResolution = await resolvedDependencies.resolveExistingScopes(store)
122+
const scopes = mergeRequestedAndStoredScopes(requestedScopes, existingScopeResolution.scopes)
123+
const validationScopes = existingScopeResolution.authoritative ? scopes : requestedScopes
124+
125+
if (existingScopeResolution.scopes.length > 0) {
126+
outputDebug(
127+
outputContent`Merged requested scopes ${outputToken.raw(requestedScopes.join(','))} with existing scopes ${outputToken.raw(existingScopeResolution.scopes.join(','))} for ${outputToken.raw(store)}`,
128+
)
129+
}
130+
131+
const bootstrap = createPkceBootstrap({
132+
store,
133+
scopes,
134+
exchangeCodeForToken: resolvedDependencies.exchangeStoreAuthCodeForToken,
135+
})
136+
const {
137+
authorization: {authorizationUrl},
138+
} = bootstrap
139+
140+
if (!resolvedDependencies.terminalSupportsPrompting()) {
141+
const existingMergedScopes = mergeRequestedAndStoredScopes(requestedScopes, existingScopeResolution.scopes)
142+
if (
143+
existingScopeResolution.authoritative &&
144+
existingMergedScopes.length === existingScopeResolution.scopes.length &&
145+
existingMergedScopes.every((scope) => existingScopeResolution.scopes.includes(scope))
146+
) {
147+
const session = await loadStoredStoreSession(store)
148+
const result = storedSessionToStoreAuthResult(session, existingScopeResolution.scopes)
149+
resolvedDependencies.presenter.success(result)
150+
return result
151+
}
152+
}
153+
154+
resolvedDependencies.presenter.openingBrowser()
155+
156+
const code = await resolvedDependencies.waitForStoreAuthCode({
157+
...bootstrap.waitForAuthCodeOptions,
158+
onListening: async () => {
159+
const opened = await resolvedDependencies.openURL(authorizationUrl)
160+
if (!opened) resolvedDependencies.presenter.manualAuthUrl(authorizationUrl)
161+
},
162+
})
163+
const result = await persistStoreAuthToken(await bootstrap.exchangeCodeForToken(code), store, validationScopes)
164+
123165
resolvedDependencies.presenter.success(result)
124166
return result
125167
}

0 commit comments

Comments
 (0)