diff --git a/.changeset/tidy-preview-store-sessions.md b/.changeset/tidy-preview-store-sessions.md new file mode 100644 index 00000000000..d0186b5e3f8 --- /dev/null +++ b/.changeset/tidy-preview-store-sessions.md @@ -0,0 +1,5 @@ +--- +'@shopify/theme': patch +--- + +Fix theme commands on preview stores requiring login. Every theme command reuses a stored preview store session again, not only `theme pull` and `theme push`. diff --git a/packages/theme/src/cli/utilities/theme-command.test.ts b/packages/theme/src/cli/utilities/theme-command.test.ts index 31d8d27605a..6434fb972c7 100644 --- a/packages/theme/src/cli/utilities/theme-command.test.ts +++ b/packages/theme/src/cli/utilities/theme-command.test.ts @@ -329,7 +329,7 @@ describe('ThemeCommand', () => { expect(command.commandCalls[0]).toMatchObject({session: mockSession}) }) - test('ignores the store auth cache when the command does not declare store auth scopes', async () => { + test('ignores a standard store auth cache session when the command does not declare store auth scopes', async () => { vi.mocked(getCurrentStoredStoreAppSession).mockReturnValue({ store: 'test-store.myshopify.com', clientId: 'store-auth-client-id', @@ -338,15 +338,42 @@ describe('ThemeCommand', () => { scopes: ['read_themes', 'write_themes'], acquiredAt: '2026-06-08T11:00:00.000Z', }) + const outputMock = mockAndCaptureOutput() await CommandConfig.load() const command = new TestThemeCommand([], CommandConfig) await command.run() - expect(getCurrentStoredStoreAppSession).not.toHaveBeenCalled() + expect(getCurrentStoredStoreAppSession).toHaveBeenCalledWith('test-store.myshopify.com') expect(ensureAuthenticatedThemes).toHaveBeenCalledWith('test-store.myshopify.com', undefined) expect(command.commandCalls[0]).toMatchObject({session: mockSession}) + expect(outputMock.debug()).toContain( + 'Ignoring stored store auth session for test-store.myshopify.com: it is a standard session and this command only reuses preview store sessions.', + ) + }) + + test('reuses a preview store session when the command does not declare store auth scopes', async () => { + vi.mocked(getCurrentStoredStoreAppSession).mockReturnValue({ + store: 'test-store.myshopify.com', + clientId: 'store-auth-client-id', + userId: 'preview:123', + accessToken: 'shpat_preview_token', + scopes: [], + acquiredAt: '2026-06-08T11:00:00.000Z', + kind: 'preview', + preview: {shopId: '1', name: 'Preview Store', createdAt: '2026-06-08T11:00:00.000Z'}, + }) + + await CommandConfig.load() + const command = new TestThemeCommand([], CommandConfig) + + await command.run() + + expect(ensureAuthenticatedThemes).not.toHaveBeenCalled() + expect(command.commandCalls[0]).toMatchObject({ + session: {token: 'shpat_preview_token', storeFqdn: 'test-store.myshopify.com'}, + }) }) test('treats a matching write scope in the stored session as satisfying a required read scope', async () => { @@ -1129,7 +1156,50 @@ describe('ThemeCommand', () => { expect(ensureAuthenticatedThemes).not.toHaveBeenCalled() }) - test('multiple environment commands ignore the store auth cache when the command does not declare store auth scopes', async () => { + test('multiple environment commands accept a preview store session without declared store auth scopes', 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(listCurrentStoredStoreAppSessions).mockReturnValue([ + { + store: 'store1.myshopify.com', + clientId: 'store-auth-client-id', + userId: 'preview:123', + accessToken: 'shpat_preview_token', + scopes: [], + acquiredAt: '2026-06-08T11:00:00.000Z', + kind: 'preview', + preview: {shopId: '1', name: 'Preview Store', createdAt: '2026-06-08T11:00:00.000Z'}, + }, + ]) + 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(command.commandCalls).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + session: {token: 'shpat_preview_token', storeFqdn: 'store1.myshopify.com'}, + }), + ]), + ) + }) + + test('multiple environment commands ignore a standard store auth cache session when the command does not declare store auth scopes', 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'}) @@ -1153,7 +1223,7 @@ describe('ThemeCommand', () => { await command.run() - expect(listCurrentStoredStoreAppSessions).not.toHaveBeenCalled() + expect(listCurrentStoredStoreAppSessions).toHaveBeenCalledOnce() expect(renderWarning).toHaveBeenCalledWith( expect.objectContaining({ body: ['Missing required flags in environment configuration for preview:', {list: {items: ['password']}}], diff --git a/packages/theme/src/cli/utilities/theme-command.ts b/packages/theme/src/cli/utilities/theme-command.ts index 6c42ad3e558..ef062e15d9c 100644 --- a/packages/theme/src/cli/utilities/theme-command.ts +++ b/packages/theme/src/cli/utilities/theme-command.ts @@ -143,8 +143,11 @@ export default abstract class ThemeCommand extends Command { /** * Admin API scopes that a stored `store auth` session must include for this - * command to reuse it. Commands opt in to reusing store auth sessions by - * returning the scopes they require; the default opts the command out. + * command to reuse it. Commands opt in to reusing standard store auth + * sessions by returning the scopes they require; the default opts the command + * out of standard sessions. Every theme command reuses preview store sessions + * regardless, because the CLI mints those for the store and cannot re-mint + * them. */ protected storeAuthScopes(): string[] | undefined { return undefined @@ -369,9 +372,6 @@ export default abstract class ThemeCommand extends Command { } private async storeAuthSessionForTheme(flags: FlagValues): Promise { - const requiredScopes = this.storeAuthScopes() - if (!requiredScopes) return undefined - const store = typeof flags.store === 'string' ? flags.store : undefined const password = flags.password if (!store || password) return undefined @@ -380,12 +380,11 @@ export default abstract class ThemeCommand extends Command { const storedSession = getCurrentStoredStoreAppSession(storeFqdn) if (!storedSession) return undefined - return this.adminSessionFromStoreAuthSession(storedSession, storeFqdn, requiredScopes) + return this.adminSessionFromStoreAuthSession(storedSession, storeFqdn, this.storeAuthScopes()) } private storeAuthSessionsForTheme(flagsList: FlagValues[]): Map { const requiredScopes = this.storeAuthScopes() - if (!requiredScopes) return new Map() const stores = new Set( flagsList @@ -421,7 +420,7 @@ export default abstract class ThemeCommand extends Command { private adminSessionFromStoreAuthSession( storedSession: StoredStoreAppSession, storeFqdn: string, - requiredScopes: string[], + requiredScopes: string[] | undefined, ): AdminSession | undefined { if (isSessionExpired(storedSession)) { outputDebug( @@ -430,13 +429,23 @@ export default abstract class ThemeCommand extends Command { return undefined } - if (!this.hasRequiredStoreAuthScopes(storedSession.scopes, requiredScopes)) { - outputDebug( - `Ignoring stored store auth session for ${storeFqdn}: it is missing required scopes (has: ${storedSession.scopes.join( - ', ', - )}; needs: ${requiredScopes.join(', ')}).`, - ) - return undefined + const isPreviewSession = storedSession.kind === 'preview' + if (!isPreviewSession) { + if (!requiredScopes) { + outputDebug( + `Ignoring stored store auth session for ${storeFqdn}: it is a standard session and this command only reuses preview store sessions.`, + ) + return undefined + } + + if (!this.hasRequiredStoreAuthScopes(storedSession.scopes, requiredScopes)) { + outputDebug( + `Ignoring stored store auth session for ${storeFqdn}: it is missing required scopes (has: ${storedSession.scopes.join( + ', ', + )}; needs: ${requiredScopes.join(', ')}).`, + ) + return undefined + } } outputDebug(`Using stored store auth session for ${storeFqdn} (scopes: ${storedSession.scopes.join(', ')}).`)