Skip to content

Commit 2477fe9

Browse files
authored
Merge pull request #7151 from Shopify/dlm-store-execute-ux-improvements
Store auth/execute UX improvements
2 parents eb5ae10 + 09f967a commit 2477fe9

File tree

12 files changed

+215
-87
lines changed

12 files changed

+215
-87
lines changed

docs-shopify.dev/commands/store-auth.doc.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,9 @@ import {ReferenceEntityTemplateSchema} from '@shopify/generate-docs'
33

44
const data: ReferenceEntityTemplateSchema = {
55
name: 'store auth',
6-
description: `Starts a PKCE OAuth flow against the specified store and stores an online access token for later use by \`shopify store execute\`.
6+
description: `Authenticates the app against the specified store for store commands and stores an online access token for later reuse.
77
8-
This flow authenticates the app on behalf of the current user. Re-run this command if the stored token is missing, expires, or no longer has the scopes you need.`,
8+
Re-run this command if the stored token is missing, expires, or no longer has the scopes you need.`,
99
overviewPreviewDescription: `Authenticate an app against a store for store commands.`,
1010
type: 'command',
1111
isVisualComponent: false,

docs-shopify.dev/generated/generated_docs_data.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5776,7 +5776,7 @@
57765776
},
57775777
{
57785778
"name": "store auth",
5779-
"description": "Starts a PKCE OAuth flow against the specified store and stores an online access token for later use by `shopify store execute`.\n\nThis flow authenticates the app on behalf of the current user. Re-run this command if the stored token is missing, expires, or no longer has the scopes you need.",
5779+
"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.",
57805780
"overviewPreviewDescription": "Authenticate an app against a store for store commands.",
57815781
"type": "command",
57825782
"isVisualComponent": false,

packages/cli/README.md

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2069,11 +2069,10 @@ FLAGS
20692069
DESCRIPTION
20702070
Authenticate an app against a store for store commands.
20712071
2072-
Starts a PKCE OAuth flow against the specified store and stores an online access token for later use by `shopify store
2073-
execute`.
2072+
Authenticates the app against the specified store for store commands and stores an online access token for later
2073+
reuse.
20742074
2075-
This flow authenticates the app on behalf of the current user. Re-run this command if the stored token is missing,
2076-
expires, or no longer has the scopes you need.
2075+
Re-run this command if the stored token is missing, expires, or no longer has the scopes you need.
20772076
20782077
EXAMPLES
20792078
$ shopify store auth --store shop.myshopify.com --scopes read_products,write_products

packages/cli/oclif.manifest.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5739,8 +5739,8 @@
57395739
],
57405740
"args": {
57415741
},
5742-
"description": "Starts a PKCE OAuth flow against the specified store and stores an online access token for later use by `shopify store execute`.\n\nThis flow authenticates the app on behalf of the current user. Re-run this command if the stored token is missing, expires, or no longer has the scopes you need.",
5743-
"descriptionWithMarkdown": "Starts a PKCE OAuth flow against the specified store and stores an online access token for later use by `shopify store execute`.\n\nThis flow authenticates the app on behalf of the current user. Re-run this command if the stored token is missing, expires, or no longer has the scopes you need.",
5742+
"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.",
5743+
"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.",
57445744
"enableJsonFlag": false,
57455745
"examples": [
57465746
"<%= config.bin %> <%= command.id %> --store shop.myshopify.com --scopes read_products,write_products"

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

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,9 +7,9 @@ import {authenticateStoreWithApp} from '../../services/store/auth.js'
77
export default class StoreAuth extends Command {
88
static summary = 'Authenticate an app against a store for store commands.'
99

10-
static descriptionWithMarkdown = `Starts a PKCE OAuth flow against the specified store and stores an online access token for later use by \`shopify store execute\`.
10+
static descriptionWithMarkdown = `Authenticates the app against the specified store for store commands and stores an online access token for later reuse.
1111
12-
This flow authenticates the app on behalf of the current user. Re-run this command if the stored token is missing, expires, or no longer has the scopes you need.`
12+
Re-run this command if the stored token is missing, expires, or no longer has the scopes you need.`
1313

1414
static description = this.descriptionWithoutMarkdown()
1515

packages/cli/src/cli/services/store/admin-graphql-context.test.ts

Lines changed: 25 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -100,7 +100,11 @@ describe('prepareAdminStoreGraphQLContext', () => {
100100
test('throws when no stored auth exists', async () => {
101101
vi.mocked(getStoredStoreAppSession).mockReturnValue(undefined)
102102

103-
await expect(prepareAdminStoreGraphQLContext({store})).rejects.toThrow('No stored app authentication found')
103+
await expect(prepareAdminStoreGraphQLContext({store})).rejects.toMatchObject({
104+
message: `No stored app authentication found for ${store}.`,
105+
tryMessage: 'To create stored auth for this store, run:',
106+
nextSteps: [[{command: `shopify store auth --store ${store} --scopes <comma-separated-scopes>`}]],
107+
})
104108
})
105109

106110
test('clears stored auth when token refresh fails', async () => {
@@ -111,15 +115,23 @@ describe('prepareAdminStoreGraphQLContext', () => {
111115
text: vi.fn().mockResolvedValue('bad refresh'),
112116
} as any)
113117

114-
await expect(prepareAdminStoreGraphQLContext({store})).rejects.toThrow('Token refresh failed')
118+
await expect(prepareAdminStoreGraphQLContext({store})).rejects.toMatchObject({
119+
message: `Token refresh failed for ${store} (HTTP 401).`,
120+
tryMessage: 'To re-authenticate, run:',
121+
nextSteps: [[{command: `shopify store auth --store ${store} --scopes read_products`}]],
122+
})
115123
expect(clearStoredStoreAppSession).toHaveBeenCalledWith(store, '42')
116124
})
117125

118126
test('throws when an expired session cannot be refreshed because no refresh token is stored', async () => {
119127
vi.mocked(isSessionExpired).mockReturnValue(true)
120128
vi.mocked(getStoredStoreAppSession).mockReturnValue({...storedSession, refreshToken: undefined})
121129

122-
await expect(prepareAdminStoreGraphQLContext({store})).rejects.toThrow('No refresh token stored')
130+
await expect(prepareAdminStoreGraphQLContext({store})).rejects.toMatchObject({
131+
message: `No refresh token stored for ${store}.`,
132+
tryMessage: 'To re-authenticate, run:',
133+
nextSteps: [[{command: `shopify store auth --store ${store} --scopes read_products`}]],
134+
})
123135
expect(clearStoredStoreAppSession).not.toHaveBeenCalled()
124136
})
125137

@@ -130,7 +142,11 @@ describe('prepareAdminStoreGraphQLContext', () => {
130142
text: vi.fn().mockResolvedValue(JSON.stringify({refresh_token: 'fresh-refresh-token'})),
131143
} as any)
132144

133-
await expect(prepareAdminStoreGraphQLContext({store})).rejects.toThrow('Token refresh returned an invalid response')
145+
await expect(prepareAdminStoreGraphQLContext({store})).rejects.toMatchObject({
146+
message: `Token refresh returned an invalid response for ${store}.`,
147+
tryMessage: 'To re-authenticate, run:',
148+
nextSteps: [[{command: `shopify store auth --store ${store} --scopes read_products`}]],
149+
})
134150
expect(clearStoredStoreAppSession).toHaveBeenCalledWith(store, '42')
135151
})
136152

@@ -150,7 +166,11 @@ describe('prepareAdminStoreGraphQLContext', () => {
150166
new AbortError(`Error connecting to your store ${store}: unauthorized 401 {}`),
151167
)
152168

153-
await expect(prepareAdminStoreGraphQLContext({store})).rejects.toThrow('Stored app authentication for')
169+
await expect(prepareAdminStoreGraphQLContext({store})).rejects.toMatchObject({
170+
message: `Stored app authentication for ${store} is no longer valid.`,
171+
tryMessage: 'To re-authenticate, run:',
172+
nextSteps: [[{command: `shopify store auth --store ${store} --scopes read_products`}]],
173+
})
154174
expect(clearStoredStoreAppSession).toHaveBeenCalledWith(store, '42')
155175
})
156176

packages/cli/src/cli/services/store/admin-graphql-context.ts

Lines changed: 12 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import {fetch} from '@shopify/cli-kit/node/http'
44
import {outputContent, outputDebug, outputToken} from '@shopify/cli-kit/node/output'
55
import {AdminSession} from '@shopify/cli-kit/node/session'
66
import {maskToken, STORE_AUTH_APP_CLIENT_ID} from './auth-config.js'
7+
import {createStoredStoreAuthError, reauthenticateStoreAuthError} from './auth-recovery.js'
78
import {
89
clearStoredStoreAppSession,
910
getStoredStoreAppSession,
@@ -20,10 +21,7 @@ export interface AdminStoreGraphQLContext {
2021

2122
async function refreshStoreToken(session: StoredStoreAppSession): Promise<StoredStoreAppSession> {
2223
if (!session.refreshToken) {
23-
throw new AbortError(
24-
`No refresh token stored for ${session.store}.`,
25-
`Run ${outputToken.genericShellCommand(`shopify store auth --store ${session.store} --scopes ${session.scopes.join(',')}`).value} to re-authenticate.`,
26-
)
24+
throw reauthenticateStoreAuthError(`No refresh token stored for ${session.store}.`, session.store, session.scopes.join(','))
2725
}
2826

2927
const endpoint = `https://${session.store}/admin/oauth/access_token`
@@ -49,9 +47,10 @@ async function refreshStoreToken(session: StoredStoreAppSession): Promise<Stored
4947
outputContent`Token refresh failed with HTTP ${outputToken.raw(String(response.status))}: ${outputToken.raw(body.slice(0, 300))}`,
5048
)
5149
clearStoredStoreAppSession(session.store, session.userId)
52-
throw new AbortError(
50+
throw reauthenticateStoreAuthError(
5351
`Token refresh failed for ${session.store} (HTTP ${response.status}).`,
54-
`Run ${outputToken.genericShellCommand(`shopify store auth --store ${session.store} --scopes ${session.scopes.join(',')}`).value} to re-authenticate.`,
52+
session.store,
53+
session.scopes.join(','),
5554
)
5655
}
5756

@@ -65,9 +64,10 @@ async function refreshStoreToken(session: StoredStoreAppSession): Promise<Stored
6564

6665
if (!data.access_token) {
6766
clearStoredStoreAppSession(session.store, session.userId)
68-
throw new AbortError(
67+
throw reauthenticateStoreAuthError(
6968
`Token refresh returned an invalid response for ${session.store}.`,
70-
`Run ${outputToken.genericShellCommand(`shopify store auth --store ${session.store} --scopes ${session.scopes.join(',')}`).value} to re-authenticate.`,
69+
session.store,
70+
session.scopes.join(','),
7171
)
7272
}
7373

@@ -97,10 +97,7 @@ async function loadStoredStoreSession(store: string): Promise<StoredStoreAppSess
9797
let session = getStoredStoreAppSession(store)
9898

9999
if (!session) {
100-
throw new AbortError(
101-
`No stored app authentication found for ${store}.`,
102-
`Run ${outputToken.genericShellCommand(`shopify store auth --store ${store} --scopes <comma-separated-scopes>`).value} to create stored auth for this store.`,
103-
)
100+
throw createStoredStoreAuthError(store)
104101
}
105102

106103
outputDebug(
@@ -133,9 +130,10 @@ async function resolveApiVersion(options: {
133130
/\b(?:401|404)\b/.test(error.message)
134131
) {
135132
clearStoredStoreAppSession(session.store, session.userId)
136-
throw new AbortError(
133+
throw reauthenticateStoreAuthError(
137134
`Stored app authentication for ${session.store} is no longer valid.`,
138-
`Run ${outputToken.genericShellCommand(`shopify store auth --store ${session.store} --scopes ${session.scopes.join(',')}`).value} to re-authenticate.`,
135+
session.store,
136+
session.scopes.join(','),
139137
)
140138
}
141139

packages/cli/src/cli/services/store/admin-graphql-transport.test.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,11 @@ describe('runAdminStoreGraphQLOperation', () => {
5656

5757
await expect(
5858
runAdminStoreGraphQLOperation({store, adminSession, sessionUserId: '42', version: '2025-10', request}),
59-
).rejects.toThrow('Stored app authentication for')
59+
).rejects.toMatchObject({
60+
message: `Stored app authentication for ${store} is no longer valid.`,
61+
tryMessage: 'To re-authenticate, run:',
62+
nextSteps: [[{command: `shopify store auth --store ${store} --scopes <comma-separated-scopes>`}]],
63+
})
6064
expect(clearStoredStoreAppSession).toHaveBeenCalledWith(store, '42')
6165
})
6266

packages/cli/src/cli/services/store/admin-graphql-transport.ts

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,12 @@
11
import {adminUrl} from '@shopify/cli-kit/node/api/admin'
22
import {graphqlRequest} from '@shopify/cli-kit/node/api/graphql'
33
import {AbortError} from '@shopify/cli-kit/node/error'
4-
import {clearStoredStoreAppSession} from './session.js'
5-
import {outputContent, outputToken} from '@shopify/cli-kit/node/output'
4+
import {outputContent} from '@shopify/cli-kit/node/output'
65
import {AdminSession} from '@shopify/cli-kit/node/session'
76
import {renderSingleTask} from '@shopify/cli-kit/node/ui'
7+
import {reauthenticateStoreAuthError} from './auth-recovery.js'
88
import {PreparedStoreExecuteRequest} from './execute-request.js'
9+
import {clearStoredStoreAppSession} from './session.js'
910

1011
function isGraphQLClientError(error: unknown): error is {response: {errors?: unknown; status?: number}} {
1112
if (!error || typeof error !== 'object' || !('response' in error)) return false
@@ -38,9 +39,10 @@ export async function runAdminStoreGraphQLOperation(input: {
3839
} catch (error) {
3940
if (isGraphQLClientError(error) && error.response.status === 401) {
4041
clearStoredStoreAppSession(input.store, input.sessionUserId)
41-
throw new AbortError(
42+
throw reauthenticateStoreAuthError(
4243
`Stored app authentication for ${input.store} is no longer valid.`,
43-
`Run ${outputToken.genericShellCommand(`shopify store auth --store ${input.store} --scopes <comma-separated-scopes>`).value} to re-authenticate.`,
44+
input.store,
45+
'<comma-separated-scopes>',
4446
)
4547
}
4648

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
import {AbortError} from '@shopify/cli-kit/node/error'
2+
3+
function storeAuthCommand(store: string, scopes: string): {command: string} {
4+
return {command: `shopify store auth --store ${store} --scopes ${scopes}`}
5+
}
6+
7+
function storeAuthCommandNextSteps(store: string, scopes: string) {
8+
return [[storeAuthCommand(store, scopes)]]
9+
}
10+
11+
export function createStoredStoreAuthError(store: string): AbortError {
12+
return new AbortError(
13+
`No stored app authentication found for ${store}.`,
14+
'To create stored auth for this store, run:',
15+
storeAuthCommandNextSteps(store, '<comma-separated-scopes>'),
16+
)
17+
}
18+
19+
export function reauthenticateStoreAuthError(message: string, store: string, scopes: string): AbortError {
20+
return new AbortError(message, 'To re-authenticate, run:', storeAuthCommandNextSteps(store, scopes))
21+
}
22+
23+
export function retryStoreAuthWithPermanentDomainError(returnedStore: string): AbortError {
24+
return new AbortError(
25+
'OAuth callback store does not match the requested store.',
26+
`Shopify returned ${returnedStore} during authentication. Re-run using the permanent store domain:`,
27+
storeAuthCommandNextSteps(returnedStore, '<comma-separated-scopes>'),
28+
)
29+
}

0 commit comments

Comments
 (0)