Skip to content

Commit 12ce355

Browse files
Improve agent guidance for store auth
1 parent f07f957 commit 12ce355

12 files changed

Lines changed: 240 additions & 111 deletions

File tree

.changeset/store-auth-resume.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
'@shopify/store': minor
3+
'@shopify/cli': minor
4+
---
5+
6+
Add resumable non-interactive `shopify store auth`.

docs-shopify.dev/commands/interfaces/store-auth.interface.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,13 +20,13 @@ export interface storeauth {
2020
* Comma-separated Admin API scopes to request for the app.
2121
* @environment SHOPIFY_FLAG_SCOPES
2222
*/
23-
'--scopes <value>': string
23+
'--scopes <value>'?: string
2424

2525
/**
2626
* The myshopify.com domain of the store to authenticate against.
2727
* @environment SHOPIFY_FLAG_STORE
2828
*/
29-
'-s, --store <value>': string
29+
'-s, --store <value>'?: string
3030

3131
/**
3232
* Increase the verbosity of the output.

docs-shopify.dev/generated/generated_docs_data_v2.json

Lines changed: 3 additions & 1 deletion
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": true,
41934194
"environmentValue": "SHOPIFY_FLAG_SCOPES"
41944195
},
41954196
{
@@ -4216,10 +4217,11 @@
42164217
"name": "-s, --store <value>",
42174218
"value": "string",
42184219
"description": "The myshopify.com domain of the store to authenticate against.",
4220+
"isOptional": true,
42194221
"environmentValue": "SHOPIFY_FLAG_STORE"
42204222
}
42214223
],
4222-
"value": "export interface storeauth {\n /**\n * Output the result as JSON. Automatically disables color output.\n * @environment SHOPIFY_FLAG_JSON\n */\n '-j, --json'?: ''\n\n /**\n * Disable color output.\n * @environment SHOPIFY_FLAG_NO_COLOR\n */\n '--no-color'?: ''\n\n /**\n * Comma-separated Admin API scopes to request for the app.\n * @environment SHOPIFY_FLAG_SCOPES\n */\n '--scopes <value>': string\n\n /**\n * The myshopify.com domain of the store to authenticate against.\n * @environment SHOPIFY_FLAG_STORE\n */\n '-s, --store <value>': string\n\n /**\n * Increase the verbosity of the output.\n * @environment SHOPIFY_FLAG_VERBOSE\n */\n '--verbose'?: ''\n}"
4224+
"value": "export interface storeauth {\n /**\n * Output the result as JSON. Automatically disables color output.\n * @environment SHOPIFY_FLAG_JSON\n */\n '-j, --json'?: ''\n\n /**\n * Disable color output.\n * @environment SHOPIFY_FLAG_NO_COLOR\n */\n '--no-color'?: ''\n\n /**\n * Comma-separated Admin API scopes to request for the app.\n * @environment SHOPIFY_FLAG_SCOPES\n */\n '--scopes <value>'?: string\n\n /**\n * The myshopify.com domain of the store to authenticate against.\n * @environment SHOPIFY_FLAG_STORE\n */\n '-s, --store <value>'?: string\n\n /**\n * Increase the verbosity of the output.\n * @environment SHOPIFY_FLAG_VERBOSE\n */\n '--verbose'?: ''\n}"
42234225
}
42244226
},
42254227
"storeexecute": {

packages/cli/README.md

Lines changed: 12 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -2157,15 +2157,14 @@ Authenticate an app against a store for store commands.
21572157

21582158
```
21592159
USAGE
2160-
$ shopify store auth --scopes <value> -s <value> [-j] [--no-color] [--verbose]
2160+
$ shopify store auth [-j] [--no-color] [--scopes <value>] [-s <value>] [--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> [env: SHOPIFY_FLAG_STORE] The myshopify.com domain of the store to authenticate against.
2165+
--no-color [env: SHOPIFY_FLAG_NO_COLOR] Disable color output.
2166+
--scopes=<value> [env: SHOPIFY_FLAG_SCOPES] Comma-separated Admin API scopes to request for the app.
2167+
--verbose [env: SHOPIFY_FLAG_VERBOSE] Increase the verbosity of the output.
21692168
21702169
DESCRIPTION
21712170
Authenticate an app against a store for store commands.
@@ -2175,6 +2174,12 @@ DESCRIPTION
21752174
21762175
Re-run this command if the stored token is missing, expires, or no longer has the scopes you need.
21772176
2177+
In an interactive terminal, Shopify CLI opens or prints the authorization URL and waits for authentication to complete.
2178+
Agents should keep the command running until the browser authorization finishes.
2179+
2180+
In a non-TTY environment, Shopify CLI returns the current session if it already has the requested scopes. If no usable
2181+
session exists, it starts the same OAuth flow and waits for authentication to complete.
2182+
21782183
EXAMPLES
21792184
$ shopify store auth --store shop.myshopify.com --scopes read_products,write_products
21802185

packages/cli/oclif.manifest.json

Lines changed: 2 additions & 4 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"
@@ -5760,7 +5760,6 @@
57605760
"hasDynamicHelp": false,
57615761
"multiple": false,
57625762
"name": "scopes",
5763-
"required": true,
57645763
"type": "option"
57655764
},
57665765
"store": {
@@ -5770,7 +5769,6 @@
57705769
"hasDynamicHelp": false,
57715770
"multiple": false,
57725771
"name": "store",
5773-
"required": true,
57745772
"type": "option"
57755773
},
57765774
"verbose": {

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,8 @@ describe('store auth command', () => {
4040
expect(StoreAuth.flags.store).toBeDefined()
4141
expect(StoreAuth.flags.scopes).toBeDefined()
4242
expect(StoreAuth.flags.json).toBeDefined()
43+
expect('resume' in StoreAuth.flags).toBe(false)
44+
expect('callback-url' in StoreAuth.flags).toBe(false)
4345
expect('port' in StoreAuth.flags).toBe(false)
4446
expect('client-secret-file' in StoreAuth.flags).toBe(false)
4547
})

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

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import {authenticateStoreWithApp} from '../../services/store/auth/index.js'
22
import {createStoreAuthPresenter} from '../../services/store/auth/result.js'
33
import StoreCommand from '../../utilities/store-command.js'
4+
import {AbortError} from '@shopify/cli-kit/node/error'
45
import {globalFlags, jsonFlag} from '@shopify/cli-kit/node/cli'
56
import {normalizeStoreFqdn} from '@shopify/cli-kit/node/context/fqdn'
67
import {Flags} from '@oclif/core'
@@ -10,7 +11,11 @@ export default class StoreAuth extends StoreCommand {
1011

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

1520
static description = this.descriptionWithoutMarkdown()
1621

@@ -27,25 +32,28 @@ Re-run this command if the stored token is missing, expires, or no longer has th
2732
description: 'The myshopify.com domain of the store to authenticate against.',
2833
env: 'SHOPIFY_FLAG_STORE',
2934
parse: async (input) => normalizeStoreFqdn(input),
30-
required: true,
3135
}),
3236
scopes: Flags.string({
3337
description: 'Comma-separated Admin API scopes to request for the app.',
3438
env: 'SHOPIFY_FLAG_SCOPES',
35-
required: true,
3639
}),
3740
}
3841

3942
public async run(): Promise<void> {
4043
const {flags} = await this.parse(StoreAuth)
44+
const presenter = createStoreAuthPresenter(flags.json ? 'json' : 'text')
45+
46+
if (!flags.store || !flags.scopes) {
47+
throw new AbortError('Missing required flags.', 'Pass --store and --scopes.')
48+
}
4149

4250
await authenticateStoreWithApp(
4351
{
4452
store: flags.store,
4553
scopes: flags.scopes,
4654
},
4755
{
48-
presenter: createStoreAuthPresenter(flags.json ? 'json' : 'text'),
56+
presenter,
4957
},
5058
)
5159
}

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 = {

0 commit comments

Comments
 (0)