Skip to content

Commit 58a57dd

Browse files
Use store auth sessions for theme commands
1 parent 72f8f19 commit 58a57dd

13 files changed

Lines changed: 255 additions & 53 deletions

File tree

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
'@shopify/theme': patch
3+
'@shopify/store': patch
4+
---
5+
6+
Use matching store-auth cache sessions for theme pull and push when no theme password is provided.

packages/e2e/data/snapshots/commands.txt

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -96,7 +96,8 @@
9696
│ ├─ auth
9797
│ ├─ create
9898
│ │ └─ preview
99-
│ └─ execute
99+
│ ├─ execute
100+
│ └─ info
100101
├─ theme
101102
│ ├─ check
102103
│ ├─ console
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
import {loadAdminSessionFromStoreAuth} from './admin-session.js'
2+
import {loadStoredStoreSession} from './session-lifecycle.js'
3+
import {recordStoreFqdnMetadata} from '../attribution.js'
4+
import {setLastSeenUserId} from '@shopify/cli-kit/node/session'
5+
import {describe, expect, test, vi} from 'vitest'
6+
7+
vi.mock('./session-lifecycle.js')
8+
vi.mock('../attribution.js')
9+
vi.mock('@shopify/cli-kit/node/session')
10+
11+
describe('loadAdminSessionFromStoreAuth', () => {
12+
test('returns an Admin session from a matching stored store auth session', async () => {
13+
const storedSession = {
14+
store: 'preview.myshopify.com',
15+
clientId: 'client-id',
16+
userId: 'preview:123',
17+
accessToken: 'shpat_token',
18+
scopes: [],
19+
acquiredAt: '2026-06-08T12:00:00.000Z',
20+
kind: 'preview' as const,
21+
preview: {
22+
shopId: '123',
23+
name: 'Preview Store',
24+
createdAt: '2026-06-08T12:00:00.000Z',
25+
},
26+
}
27+
vi.mocked(loadStoredStoreSession).mockResolvedValue(storedSession)
28+
29+
const got = await loadAdminSessionFromStoreAuth('https://preview.myshopify.com/admin')
30+
31+
expect(loadStoredStoreSession).toHaveBeenCalledWith('preview.myshopify.com')
32+
expect(recordStoreFqdnMetadata).toHaveBeenCalledWith('preview.myshopify.com', true)
33+
expect(setLastSeenUserId).toHaveBeenCalledWith('preview:123')
34+
expect(got).toEqual({
35+
adminSession: {token: 'shpat_token', storeFqdn: 'preview.myshopify.com'},
36+
session: storedSession,
37+
})
38+
})
39+
40+
test('propagates store auth cache errors', async () => {
41+
vi.mocked(loadStoredStoreSession).mockRejectedValue(new Error('missing session'))
42+
43+
await expect(loadAdminSessionFromStoreAuth('preview.myshopify.com')).rejects.toThrow('missing session')
44+
expect(recordStoreFqdnMetadata).not.toHaveBeenCalled()
45+
expect(setLastSeenUserId).not.toHaveBeenCalled()
46+
})
47+
})
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
import {loadStoredStoreSession} from './session-lifecycle.js'
2+
import {recordStoreFqdnMetadata} from '../attribution.js'
3+
import {normalizeStoreFqdn} from '@shopify/cli-kit/node/context/fqdn'
4+
import {setLastSeenUserId} from '@shopify/cli-kit/node/session'
5+
import type {AdminSession} from '@shopify/cli-kit/node/session'
6+
import type {StoredStoreAppSession} from './session-store.js'
7+
8+
export async function loadAdminSessionFromStoreAuth(store: string): Promise<{
9+
adminSession: AdminSession
10+
session: StoredStoreAppSession
11+
}> {
12+
const session = await loadStoredStoreSession(normalizeStoreFqdn(store))
13+
await recordStoreFqdnMetadata(session.store, true)
14+
setLastSeenUserId(session.userId)
15+
16+
return {
17+
adminSession: {
18+
token: session.accessToken,
19+
storeFqdn: session.store,
20+
},
21+
session,
22+
}
23+
}

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

Lines changed: 17 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,11 @@
11
import {prepareAdminStoreGraphQLContext} from './admin-context.js'
22
import {fetchPublicApiVersions} from './admin-transport.js'
3-
import {loadStoredStoreSession} from '../auth/session-lifecycle.js'
3+
import {loadAdminSessionFromStoreAuth} from '../auth/admin-session.js'
44
import {STORE_AUTH_APP_CLIENT_ID} from '../auth/config.js'
5-
import {recordStoreFqdnMetadata} from '../attribution.js'
65
import {AbortError} from '@shopify/cli-kit/node/error'
7-
import {setLastSeenUserId} from '@shopify/cli-kit/node/session'
86
import {beforeEach, describe, expect, test, vi} from 'vitest'
97

10-
vi.mock('../auth/session-lifecycle.js', () => ({loadStoredStoreSession: vi.fn()}))
11-
vi.mock('../attribution.js')
12-
vi.mock('@shopify/cli-kit/node/session')
8+
vi.mock('../auth/admin-session.js')
139
vi.mock('./admin-transport.js', () => ({
1410
fetchPublicApiVersions: vi.fn(),
1511
// runAdminStoreGraphQLOperation isn't exercised here, but we re-export it for type completeness.
@@ -29,7 +25,10 @@ describe('prepareAdminStoreGraphQLContext', () => {
2925
}
3026

3127
beforeEach(() => {
32-
vi.mocked(loadStoredStoreSession).mockResolvedValue(storedSession)
28+
vi.mocked(loadAdminSessionFromStoreAuth).mockResolvedValue({
29+
adminSession: {token: storedSession.accessToken, storeFqdn: storedSession.store},
30+
session: storedSession,
31+
})
3332
vi.mocked(fetchPublicApiVersions).mockResolvedValue([
3433
{handle: '2025-10', supported: true},
3534
{handle: '2025-07', supported: true},
@@ -40,10 +39,7 @@ describe('prepareAdminStoreGraphQLContext', () => {
4039
test('returns the stored admin session, version, and full auth session', async () => {
4140
const result = await prepareAdminStoreGraphQLContext({store})
4241

43-
expect(loadStoredStoreSession).toHaveBeenCalledWith(store)
44-
expect(recordStoreFqdnMetadata).toHaveBeenCalledOnce()
45-
expect(recordStoreFqdnMetadata).toHaveBeenCalledWith(store, true)
46-
expect(setLastSeenUserId).toHaveBeenCalledWith('42')
42+
expect(loadAdminSessionFromStoreAuth).toHaveBeenCalledWith(store)
4743
expect(fetchPublicApiVersions).toHaveBeenCalledWith({
4844
adminSession: {token: 'token', storeFqdn: store},
4945
session: storedSession,
@@ -62,7 +58,10 @@ describe('prepareAdminStoreGraphQLContext', () => {
6258
refreshToken: 'fresh-refresh-token',
6359
expiresAt: '2026-04-03T00:00:00.000Z',
6460
}
65-
vi.mocked(loadStoredStoreSession).mockResolvedValue(refreshedSession)
61+
vi.mocked(loadAdminSessionFromStoreAuth).mockResolvedValue({
62+
adminSession: {token: refreshedSession.accessToken, storeFqdn: refreshedSession.store},
63+
session: refreshedSession,
64+
})
6665

6766
const result = await prepareAdminStoreGraphQLContext({store})
6867

@@ -83,9 +82,7 @@ describe('prepareAdminStoreGraphQLContext', () => {
8382
test('allows unstable without consulting the transport, but still loads the stored session', async () => {
8483
const result = await prepareAdminStoreGraphQLContext({store, userSpecifiedVersion: 'unstable'})
8584

86-
expect(loadStoredStoreSession).toHaveBeenCalledWith(store)
87-
expect(recordStoreFqdnMetadata).toHaveBeenCalledOnce()
88-
expect(recordStoreFqdnMetadata).toHaveBeenCalledWith(store, true)
85+
expect(loadAdminSessionFromStoreAuth).toHaveBeenCalledWith(store)
8986
expect(result).toEqual({
9087
adminSession: {token: 'token', storeFqdn: store},
9188
version: 'unstable',
@@ -98,32 +95,21 @@ describe('prepareAdminStoreGraphQLContext', () => {
9895
await expect(prepareAdminStoreGraphQLContext({store, userSpecifiedVersion: '1999-01'})).rejects.toThrow(
9996
'Invalid API version',
10097
)
101-
expect(recordStoreFqdnMetadata).toHaveBeenCalledOnce()
102-
expect(recordStoreFqdnMetadata).toHaveBeenCalledWith(store, true)
98+
expect(loadAdminSessionFromStoreAuth).toHaveBeenCalledWith(store)
10399
})
104100

105-
test('does not record validated store metadata when loading stored auth fails', async () => {
106-
vi.mocked(loadStoredStoreSession).mockRejectedValue(new AbortError('missing stored auth'))
101+
test('does not resolve API versions when loading stored auth fails', async () => {
102+
vi.mocked(loadAdminSessionFromStoreAuth).mockRejectedValue(new AbortError('missing stored auth'))
107103

108104
await expect(prepareAdminStoreGraphQLContext({store})).rejects.toThrow('missing stored auth')
109-
expect(recordStoreFqdnMetadata).not.toHaveBeenCalled()
105+
expect(loadAdminSessionFromStoreAuth).toHaveBeenCalledWith(store)
110106
expect(fetchPublicApiVersions).not.toHaveBeenCalled()
111107
})
112108

113-
test('re-records fqdn metadata when the stored session store differs from the requested store', async () => {
114-
vi.mocked(loadStoredStoreSession).mockResolvedValue({...storedSession, store: 'permanent-shop.myshopify.com'})
115-
116-
await prepareAdminStoreGraphQLContext({store})
117-
118-
expect(recordStoreFqdnMetadata).toHaveBeenCalledOnce()
119-
expect(recordStoreFqdnMetadata).toHaveBeenCalledWith('permanent-shop.myshopify.com', true)
120-
})
121-
122109
test('rethrows whatever the transport raises (errors are owned by the transport)', async () => {
123110
vi.mocked(fetchPublicApiVersions).mockRejectedValue(new AbortError('upstream exploded'))
124111

125112
await expect(prepareAdminStoreGraphQLContext({store})).rejects.toThrow('upstream exploded')
126-
expect(recordStoreFqdnMetadata).toHaveBeenCalledOnce()
127-
expect(recordStoreFqdnMetadata).toHaveBeenCalledWith(store, true)
113+
expect(loadAdminSessionFromStoreAuth).toHaveBeenCalledWith(store)
128114
})
129115
})

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

Lines changed: 2 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,6 @@
11
import {fetchPublicApiVersions} from './admin-transport.js'
2-
import {loadStoredStoreSession} from '../auth/session-lifecycle.js'
3-
import {recordStoreFqdnMetadata} from '../attribution.js'
2+
import {loadAdminSessionFromStoreAuth} from '../auth/admin-session.js'
43
import {AbortError} from '@shopify/cli-kit/node/error'
5-
import {setLastSeenUserId} from '@shopify/cli-kit/node/session'
64
import type {AdminSession} from '@shopify/cli-kit/node/session'
75
import type {StoredStoreAppSession} from '../auth/session-store.js'
86

@@ -38,13 +36,7 @@ export async function prepareAdminStoreGraphQLContext(input: {
3836
store: string
3937
userSpecifiedVersion?: string
4038
}): Promise<AdminStoreGraphQLContext> {
41-
const session = await loadStoredStoreSession(input.store)
42-
await recordStoreFqdnMetadata(session.store, true)
43-
setLastSeenUserId(session.userId)
44-
const adminSession = {
45-
token: session.accessToken,
46-
storeFqdn: session.store,
47-
}
39+
const {adminSession, session} = await loadAdminSessionFromStoreAuth(input.store)
4840
const version = await resolveApiVersion({session, adminSession, userSpecifiedVersion: input.userSpecifiedVersion})
4941

5042
return {adminSession, version, session}

packages/store/src/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@ import StoreCreatePreview from './cli/commands/store/create/preview.js'
33
import StoreExecute from './cli/commands/store/execute.js'
44
import StoreInfo from './cli/commands/store/info.js'
55

6+
export {loadAdminSessionFromStoreAuth} from './cli/services/store/auth/admin-session.js'
7+
68
const COMMANDS = {
79
'store:auth': StoreAuth,
810
'store:create:preview': StoreCreatePreview,

packages/theme/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@
4242
"dependencies": {
4343
"@oclif/core": "4.11.4",
4444
"@shopify/cli-kit": "4.1.0",
45+
"@shopify/store": "4.1.0",
4546
"@shopify/theme-check-node": "3.26.1",
4647
"@shopify/theme-language-server-node": "2.21.3",
4748
"chokidar": "3.6.0",

packages/theme/src/cli/utilities/theme-command.test.ts

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import {ensureThemeStore} from './theme-store.js'
33
import {describe, vi, expect, test, beforeEach} from 'vitest'
44
import {Config, Flags} from '@oclif/core'
55
import {AdminSession, ensureAuthenticatedThemes} from '@shopify/cli-kit/node/session'
6+
import {loadAdminSessionFromStoreAuth} from '@shopify/store'
67
import {loadEnvironment} from '@shopify/cli-kit/node/environments'
78
import {fileExistsSync} from '@shopify/cli-kit/node/fs'
89
import {AbortError} from '@shopify/cli-kit/node/error'
@@ -14,6 +15,7 @@ import {hashString} from '@shopify/cli-kit/node/crypto'
1415
import type {Writable} from 'stream'
1516

1617
vi.mock('@shopify/cli-kit/node/session')
18+
vi.mock('@shopify/store', () => ({loadAdminSessionFromStoreAuth: vi.fn()}))
1719
vi.mock('@shopify/cli-kit/node/environments')
1820
vi.mock('@shopify/cli-kit/node/ui')
1921
vi.mock('@shopify/cli-kit/node/metadata', () => ({
@@ -180,6 +182,9 @@ describe('ThemeCommand', () => {
180182
}
181183
vi.mocked(ensureThemeStore).mockReturnValue('test-store.myshopify.com')
182184
vi.mocked(ensureAuthenticatedThemes).mockResolvedValue(mockSession)
185+
vi.mocked(loadAdminSessionFromStoreAuth).mockRejectedValue(
186+
new AbortError('No stored app authentication found for test-store.myshopify.com.'),
187+
)
183188
vi.mocked(fileExistsSync).mockReturnValue(true)
184189
})
185190

@@ -244,6 +249,45 @@ describe('ThemeCommand', () => {
244249
expect(sensitiveMetadata).toContainEqual({store_fqdn: mockSession.storeFqdn})
245250
})
246251

252+
test('uses a matching store auth cache session when no password is provided', async () => {
253+
const storeAuthSession = {token: 'shpat_preview_token', storeFqdn: 'test-store.myshopify.com'}
254+
vi.mocked(loadAdminSessionFromStoreAuth).mockResolvedValue({adminSession: storeAuthSession, session: {} as any})
255+
256+
await CommandConfig.load()
257+
const command = new TestThemeCommand([], CommandConfig)
258+
259+
await command.run()
260+
261+
expect(loadAdminSessionFromStoreAuth).toHaveBeenCalledWith('test-store.myshopify.com')
262+
expect(ensureAuthenticatedThemes).not.toHaveBeenCalled()
263+
expect(command.commandCalls[0]).toMatchObject({session: storeAuthSession})
264+
})
265+
266+
test('uses the password flag instead of a matching store auth cache session', async () => {
267+
const storeAuthSession = {token: 'shpat_preview_token', storeFqdn: 'test-store.myshopify.com'}
268+
vi.mocked(loadAdminSessionFromStoreAuth).mockResolvedValue({adminSession: storeAuthSession, session: {} as any})
269+
270+
await CommandConfig.load()
271+
const command = new TestThemeCommand(['--password', 'shptka_password'], CommandConfig)
272+
273+
await command.run()
274+
275+
expect(loadAdminSessionFromStoreAuth).not.toHaveBeenCalled()
276+
expect(ensureAuthenticatedThemes).toHaveBeenCalledWith('test-store.myshopify.com', 'shptka_password')
277+
expect(command.commandCalls[0]).toMatchObject({session: mockSession})
278+
})
279+
280+
test('falls back to theme authentication when no matching store auth cache session exists', async () => {
281+
await CommandConfig.load()
282+
const command = new TestThemeCommand([], CommandConfig)
283+
284+
await command.run()
285+
286+
expect(loadAdminSessionFromStoreAuth).toHaveBeenCalledWith('test-store.myshopify.com')
287+
expect(ensureAuthenticatedThemes).toHaveBeenCalledWith('test-store.myshopify.com', undefined)
288+
expect(command.commandCalls[0]).toMatchObject({session: mockSession})
289+
})
290+
247291
test('single environment provided but not found in TOML - throws AbortError', async () => {
248292
// Given
249293
vi.mocked(loadEnvironment).mockResolvedValue(undefined)
@@ -839,6 +883,60 @@ describe('ThemeCommand', () => {
839883
expect(liveEnvFlags?.['no-color']).toEqual(true)
840884
})
841885

886+
test('multiple environment commands accept missing password when a store auth cache session exists', async () => {
887+
const storeAuthSession = {token: 'shpat_preview_token', storeFqdn: 'store1.myshopify.com'}
888+
vi.mocked(loadEnvironment)
889+
.mockResolvedValueOnce({store: 'store1.myshopify.com', path: '/home/path/to/theme1'})
890+
.mockResolvedValueOnce({store: 'store2.myshopify.com', password: 'password2', path: '/home/path/to/theme2'})
891+
vi.mocked(loadAdminSessionFromStoreAuth).mockResolvedValue({adminSession: storeAuthSession, session: {} as any})
892+
vi.mocked(renderConfirmationPrompt).mockResolvedValue(true)
893+
vi.mocked(renderConcurrent).mockImplementation(async ({processes}) => {
894+
for (const process of processes) {
895+
// eslint-disable-next-line no-await-in-loop
896+
await process.action({} as Writable, {} as Writable, {} as any)
897+
}
898+
})
899+
vi.mocked(ensureThemeStore).mockImplementation((options: any) => options.store)
900+
901+
await CommandConfig.load()
902+
const command = new TestThemeCommandWithPathFlag(
903+
['--environment', 'preview', '--environment', 'another-preview'],
904+
CommandConfig,
905+
)
906+
907+
await command.run()
908+
909+
expect(renderWarning).not.toHaveBeenCalled()
910+
expect(loadAdminSessionFromStoreAuth).toHaveBeenCalledWith('store1.myshopify.com')
911+
expect(ensureAuthenticatedThemes).toHaveBeenCalledWith('store2.myshopify.com', 'password2')
912+
expect(command.commandCalls).toEqual(
913+
expect.arrayContaining([expect.objectContaining({session: storeAuthSession})]),
914+
)
915+
})
916+
917+
test('multiple environment commands still require password when no store auth cache session exists', async () => {
918+
vi.mocked(loadEnvironment)
919+
.mockResolvedValueOnce({store: 'store1.myshopify.com', path: '/home/path/to/theme1'})
920+
.mockResolvedValueOnce({store: 'store2.myshopify.com', password: 'password2', path: '/home/path/to/theme2'})
921+
vi.mocked(renderConcurrent).mockResolvedValue(undefined)
922+
923+
await CommandConfig.load()
924+
const command = new TestThemeCommandWithPathFlag(
925+
['--environment', 'preview', '--environment', 'another-preview'],
926+
CommandConfig,
927+
)
928+
929+
await command.run()
930+
931+
expect(renderWarning).toHaveBeenCalledWith(
932+
expect.objectContaining({
933+
body: ['Missing required flags in environment configuration for preview:', {list: {items: ['password']}}],
934+
}),
935+
)
936+
expect(renderConcurrent).not.toHaveBeenCalled()
937+
expect(ensureAuthenticatedThemes).not.toHaveBeenCalled()
938+
})
939+
842940
test('commands will only create a session object if the password flag is supported', async () => {
843941
// Given
844942
vi.mocked(loadEnvironment)

0 commit comments

Comments
 (0)