Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/theme-preview-store-auth.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
'@shopify/theme': patch
'@shopify/store': patch
---

Use matching store-auth cache sessions for theme pull and push when no theme password is provided.
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import {loadAdminSessionFromStoreAuth} from './admin-session.js'
import {loadStoredStoreSession} from './session-lifecycle.js'
import {recordStoreFqdnMetadata} from '../attribution.js'
import {setLastSeenUserId} from '@shopify/cli-kit/node/session'
import {describe, expect, test, vi} from 'vitest'

vi.mock('./session-lifecycle.js')
vi.mock('../attribution.js')
vi.mock('@shopify/cli-kit/node/session')

describe('loadAdminSessionFromStoreAuth', () => {
test('returns an Admin session from a matching stored store auth session', async () => {
const storedSession = {
store: 'preview.myshopify.com',
clientId: 'client-id',
userId: 'preview:123',
accessToken: 'shpat_token',
scopes: [],
acquiredAt: '2026-06-08T12:00:00.000Z',
kind: 'preview' as const,
preview: {
shopId: '123',
name: 'Preview Store',
createdAt: '2026-06-08T12:00:00.000Z',
},
}
vi.mocked(loadStoredStoreSession).mockResolvedValue(storedSession)

const got = await loadAdminSessionFromStoreAuth('https://preview.myshopify.com/admin')

expect(loadStoredStoreSession).toHaveBeenCalledWith('preview.myshopify.com')
expect(recordStoreFqdnMetadata).toHaveBeenCalledWith('preview.myshopify.com', true)
expect(setLastSeenUserId).toHaveBeenCalledWith('preview:123')
expect(got).toEqual({
adminSession: {token: 'shpat_token', storeFqdn: 'preview.myshopify.com'},
session: storedSession,
})
})

test('propagates store auth cache errors', async () => {
vi.mocked(loadStoredStoreSession).mockRejectedValue(new Error('missing session'))

await expect(loadAdminSessionFromStoreAuth('preview.myshopify.com')).rejects.toThrow('missing session')
expect(recordStoreFqdnMetadata).not.toHaveBeenCalled()
expect(setLastSeenUserId).not.toHaveBeenCalled()
})
})
23 changes: 23 additions & 0 deletions packages/store/src/cli/services/store/auth/admin-session.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import {loadStoredStoreSession} from './session-lifecycle.js'
import {recordStoreFqdnMetadata} from '../attribution.js'
import {normalizeStoreFqdn} from '@shopify/cli-kit/node/context/fqdn'
import {setLastSeenUserId} from '@shopify/cli-kit/node/session'
import type {AdminSession} from '@shopify/cli-kit/node/session'
import type {StoredStoreAppSession} from './session-store.js'

export async function loadAdminSessionFromStoreAuth(store: string): Promise<{
adminSession: AdminSession
session: StoredStoreAppSession
}> {
const session = await loadStoredStoreSession(normalizeStoreFqdn(store))
await recordStoreFqdnMetadata(session.store, true)
setLastSeenUserId(session.userId)

return {
adminSession: {
token: session.accessToken,
storeFqdn: session.store,
},
session,
}
}
Original file line number Diff line number Diff line change
@@ -1,15 +1,11 @@
import {prepareAdminStoreGraphQLContext} from './admin-context.js'
import {fetchPublicApiVersions} from './admin-transport.js'
import {loadStoredStoreSession} from '../auth/session-lifecycle.js'
import {loadAdminSessionFromStoreAuth} from '../auth/admin-session.js'
import {STORE_AUTH_APP_CLIENT_ID} from '../auth/config.js'
import {recordStoreFqdnMetadata} from '../attribution.js'
import {AbortError} from '@shopify/cli-kit/node/error'
import {setLastSeenUserId} from '@shopify/cli-kit/node/session'
import {beforeEach, describe, expect, test, vi} from 'vitest'

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

beforeEach(() => {
vi.mocked(loadStoredStoreSession).mockResolvedValue(storedSession)
vi.mocked(loadAdminSessionFromStoreAuth).mockResolvedValue({
adminSession: {token: storedSession.accessToken, storeFqdn: storedSession.store},
session: storedSession,
})
vi.mocked(fetchPublicApiVersions).mockResolvedValue([
{handle: '2025-10', supported: true},
{handle: '2025-07', supported: true},
Expand All @@ -40,10 +39,7 @@ describe('prepareAdminStoreGraphQLContext', () => {
test('returns the stored admin session, version, and full auth session', async () => {
const result = await prepareAdminStoreGraphQLContext({store})

expect(loadStoredStoreSession).toHaveBeenCalledWith(store)
expect(recordStoreFqdnMetadata).toHaveBeenCalledOnce()
expect(recordStoreFqdnMetadata).toHaveBeenCalledWith(store, true)
expect(setLastSeenUserId).toHaveBeenCalledWith('42')
expect(loadAdminSessionFromStoreAuth).toHaveBeenCalledWith(store)
expect(fetchPublicApiVersions).toHaveBeenCalledWith({
adminSession: {token: 'token', storeFqdn: store},
session: storedSession,
Expand All @@ -62,7 +58,10 @@ describe('prepareAdminStoreGraphQLContext', () => {
refreshToken: 'fresh-refresh-token',
expiresAt: '2026-04-03T00:00:00.000Z',
}
vi.mocked(loadStoredStoreSession).mockResolvedValue(refreshedSession)
vi.mocked(loadAdminSessionFromStoreAuth).mockResolvedValue({
adminSession: {token: refreshedSession.accessToken, storeFqdn: refreshedSession.store},
session: refreshedSession,
})

const result = await prepareAdminStoreGraphQLContext({store})

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

expect(loadStoredStoreSession).toHaveBeenCalledWith(store)
expect(recordStoreFqdnMetadata).toHaveBeenCalledOnce()
expect(recordStoreFqdnMetadata).toHaveBeenCalledWith(store, true)
expect(loadAdminSessionFromStoreAuth).toHaveBeenCalledWith(store)
expect(result).toEqual({
adminSession: {token: 'token', storeFqdn: store},
version: 'unstable',
Expand All @@ -98,32 +95,21 @@ describe('prepareAdminStoreGraphQLContext', () => {
await expect(prepareAdminStoreGraphQLContext({store, userSpecifiedVersion: '1999-01'})).rejects.toThrow(
'Invalid API version',
)
expect(recordStoreFqdnMetadata).toHaveBeenCalledOnce()
expect(recordStoreFqdnMetadata).toHaveBeenCalledWith(store, true)
expect(loadAdminSessionFromStoreAuth).toHaveBeenCalledWith(store)
})

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

await expect(prepareAdminStoreGraphQLContext({store})).rejects.toThrow('missing stored auth')
expect(recordStoreFqdnMetadata).not.toHaveBeenCalled()
expect(loadAdminSessionFromStoreAuth).toHaveBeenCalledWith(store)
expect(fetchPublicApiVersions).not.toHaveBeenCalled()
})

test('re-records fqdn metadata when the stored session store differs from the requested store', async () => {
vi.mocked(loadStoredStoreSession).mockResolvedValue({...storedSession, store: 'permanent-shop.myshopify.com'})

await prepareAdminStoreGraphQLContext({store})

expect(recordStoreFqdnMetadata).toHaveBeenCalledOnce()
expect(recordStoreFqdnMetadata).toHaveBeenCalledWith('permanent-shop.myshopify.com', true)
})

test('rethrows whatever the transport raises (errors are owned by the transport)', async () => {
vi.mocked(fetchPublicApiVersions).mockRejectedValue(new AbortError('upstream exploded'))

await expect(prepareAdminStoreGraphQLContext({store})).rejects.toThrow('upstream exploded')
expect(recordStoreFqdnMetadata).toHaveBeenCalledOnce()
expect(recordStoreFqdnMetadata).toHaveBeenCalledWith(store, true)
expect(loadAdminSessionFromStoreAuth).toHaveBeenCalledWith(store)
})
})
12 changes: 2 additions & 10 deletions packages/store/src/cli/services/store/execute/admin-context.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,6 @@
import {fetchPublicApiVersions} from './admin-transport.js'
import {loadStoredStoreSession} from '../auth/session-lifecycle.js'
import {recordStoreFqdnMetadata} from '../attribution.js'
import {loadAdminSessionFromStoreAuth} from '../auth/admin-session.js'
import {AbortError} from '@shopify/cli-kit/node/error'
import {setLastSeenUserId} from '@shopify/cli-kit/node/session'
import type {AdminSession} from '@shopify/cli-kit/node/session'
import type {StoredStoreAppSession} from '../auth/session-store.js'

Expand Down Expand Up @@ -38,13 +36,7 @@ export async function prepareAdminStoreGraphQLContext(input: {
store: string
userSpecifiedVersion?: string
}): Promise<AdminStoreGraphQLContext> {
const session = await loadStoredStoreSession(input.store)
await recordStoreFqdnMetadata(session.store, true)
setLastSeenUserId(session.userId)
const adminSession = {
token: session.accessToken,
storeFqdn: session.store,
}
const {adminSession, session} = await loadAdminSessionFromStoreAuth(input.store)
const version = await resolveApiVersion({session, adminSession, userSpecifiedVersion: input.userSpecifiedVersion})

return {adminSession, version, session}
Expand Down
2 changes: 2 additions & 0 deletions packages/store/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ import StoreCreatePreview from './cli/commands/store/create/preview.js'
import StoreExecute from './cli/commands/store/execute.js'
import StoreInfo from './cli/commands/store/info.js'

export {loadAdminSessionFromStoreAuth} from './cli/services/store/auth/admin-session.js'

const COMMANDS = {
'store:auth': StoreAuth,
'store:create:preview': StoreCreatePreview,
Expand Down
1 change: 1 addition & 0 deletions packages/theme/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@
"dependencies": {
"@oclif/core": "4.11.4",
"@shopify/cli-kit": "4.1.0",
"@shopify/store": "4.1.0",
"@shopify/theme-check-node": "3.26.1",
"@shopify/theme-language-server-node": "2.21.3",
"chokidar": "3.6.0",
Expand Down
98 changes: 98 additions & 0 deletions packages/theme/src/cli/utilities/theme-command.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import {ensureThemeStore} from './theme-store.js'
import {describe, vi, expect, test, beforeEach} from 'vitest'
import {Config, Flags} from '@oclif/core'
import {AdminSession, ensureAuthenticatedThemes} from '@shopify/cli-kit/node/session'
import {loadAdminSessionFromStoreAuth} from '@shopify/store'
import {loadEnvironment} from '@shopify/cli-kit/node/environments'
import {fileExistsSync} from '@shopify/cli-kit/node/fs'
import {AbortError} from '@shopify/cli-kit/node/error'
Expand All @@ -14,6 +15,7 @@ import {hashString} from '@shopify/cli-kit/node/crypto'
import type {Writable} from 'stream'

vi.mock('@shopify/cli-kit/node/session')
vi.mock('@shopify/store', () => ({loadAdminSessionFromStoreAuth: vi.fn()}))
vi.mock('@shopify/cli-kit/node/environments')
vi.mock('@shopify/cli-kit/node/ui')
vi.mock('@shopify/cli-kit/node/metadata', () => ({
Expand Down Expand Up @@ -180,6 +182,9 @@ describe('ThemeCommand', () => {
}
vi.mocked(ensureThemeStore).mockReturnValue('test-store.myshopify.com')
vi.mocked(ensureAuthenticatedThemes).mockResolvedValue(mockSession)
vi.mocked(loadAdminSessionFromStoreAuth).mockRejectedValue(
new AbortError('No stored app authentication found for test-store.myshopify.com.'),
)
vi.mocked(fileExistsSync).mockReturnValue(true)
})

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

test('uses a matching store auth cache session when no password is provided', async () => {
const storeAuthSession = {token: 'shpat_preview_token', storeFqdn: 'test-store.myshopify.com'}
vi.mocked(loadAdminSessionFromStoreAuth).mockResolvedValue({adminSession: storeAuthSession, session: {} as any})

await CommandConfig.load()
const command = new TestThemeCommand([], CommandConfig)

await command.run()

expect(loadAdminSessionFromStoreAuth).toHaveBeenCalledWith('test-store.myshopify.com')
expect(ensureAuthenticatedThemes).not.toHaveBeenCalled()
expect(command.commandCalls[0]).toMatchObject({session: storeAuthSession})
})

test('uses the password flag instead of a matching store auth cache session', async () => {
const storeAuthSession = {token: 'shpat_preview_token', storeFqdn: 'test-store.myshopify.com'}
vi.mocked(loadAdminSessionFromStoreAuth).mockResolvedValue({adminSession: storeAuthSession, session: {} as any})

await CommandConfig.load()
const command = new TestThemeCommand(['--password', 'shptka_password'], CommandConfig)

await command.run()

expect(loadAdminSessionFromStoreAuth).not.toHaveBeenCalled()
expect(ensureAuthenticatedThemes).toHaveBeenCalledWith('test-store.myshopify.com', 'shptka_password')
expect(command.commandCalls[0]).toMatchObject({session: mockSession})
})

test('falls back to theme authentication when no matching store auth cache session exists', async () => {
await CommandConfig.load()
const command = new TestThemeCommand([], CommandConfig)

await command.run()

expect(loadAdminSessionFromStoreAuth).toHaveBeenCalledWith('test-store.myshopify.com')
expect(ensureAuthenticatedThemes).toHaveBeenCalledWith('test-store.myshopify.com', undefined)
expect(command.commandCalls[0]).toMatchObject({session: mockSession})
})

test('single environment provided but not found in TOML - throws AbortError', async () => {
// Given
vi.mocked(loadEnvironment).mockResolvedValue(undefined)
Expand Down Expand Up @@ -839,6 +883,60 @@ describe('ThemeCommand', () => {
expect(liveEnvFlags?.['no-color']).toEqual(true)
})

test('multiple environment commands accept missing password when a store auth cache session exists', async () => {
const storeAuthSession = {token: 'shpat_preview_token', storeFqdn: 'store1.myshopify.com'}
vi.mocked(loadEnvironment)
.mockResolvedValueOnce({store: 'store1.myshopify.com', path: '/home/path/to/theme1'})
.mockResolvedValueOnce({store: 'store2.myshopify.com', password: 'password2', path: '/home/path/to/theme2'})
vi.mocked(loadAdminSessionFromStoreAuth).mockResolvedValue({adminSession: storeAuthSession, session: {} as any})
vi.mocked(renderConfirmationPrompt).mockResolvedValue(true)
vi.mocked(renderConcurrent).mockImplementation(async ({processes}) => {
for (const process of processes) {
// eslint-disable-next-line no-await-in-loop
await process.action({} as Writable, {} as Writable, {} as any)
}
})
vi.mocked(ensureThemeStore).mockImplementation((options: any) => options.store)

await CommandConfig.load()
const command = new TestThemeCommandWithPathFlag(
['--environment', 'preview', '--environment', 'another-preview'],
CommandConfig,
)

await command.run()

expect(renderWarning).not.toHaveBeenCalled()
expect(loadAdminSessionFromStoreAuth).toHaveBeenCalledWith('store1.myshopify.com')
expect(ensureAuthenticatedThemes).toHaveBeenCalledWith('store2.myshopify.com', 'password2')
expect(command.commandCalls).toEqual(
expect.arrayContaining([expect.objectContaining({session: storeAuthSession})]),
)
})

test('multiple environment commands still require password when no store auth cache session exists', async () => {
vi.mocked(loadEnvironment)
.mockResolvedValueOnce({store: 'store1.myshopify.com', path: '/home/path/to/theme1'})
.mockResolvedValueOnce({store: 'store2.myshopify.com', password: 'password2', path: '/home/path/to/theme2'})
vi.mocked(renderConcurrent).mockResolvedValue(undefined)

await CommandConfig.load()
const command = new TestThemeCommandWithPathFlag(
['--environment', 'preview', '--environment', 'another-preview'],
CommandConfig,
)

await command.run()

expect(renderWarning).toHaveBeenCalledWith(
expect.objectContaining({
body: ['Missing required flags in environment configuration for preview:', {list: {items: ['password']}}],
}),
)
expect(renderConcurrent).not.toHaveBeenCalled()
expect(ensureAuthenticatedThemes).not.toHaveBeenCalled()
})

test('commands will only create a session object if the password flag is supported', async () => {
// Given
vi.mocked(loadEnvironment)
Expand Down
Loading
Loading