From a6da17061135e4d53bb7bb0588ad5616129eac36 Mon Sep 17 00:00:00 2001 From: Donald Merand Date: Mon, 24 Aug 2026 11:30:24 -0400 Subject: [PATCH] Recover theme commands after preview store claim Assisted-By: devx/6b8a4c45-3042-4ae7-8f77-3e1296bbe6ac --- ...mands-recover-after-preview-store-claim.md | 5 + .../cli-kit/src/public/node/api/admin.test.ts | 26 +++ packages/cli-kit/src/public/node/api/admin.ts | 13 +- .../public/node/store-auth-recovery.test.ts | 144 ++++++++++++ .../src/public/node/store-auth-recovery.ts | 105 +++++++++ .../public/node/store-auth-session.test.ts | 20 ++ .../src/public/node/store-auth-session.ts | 3 +- .../src/public/node/themes/api.test.ts | 25 ++- .../cli-kit/src/public/node/themes/api.ts | 15 +- .../public/node/themes/theme-manager.test.ts | 16 +- .../src/cli/services/store/admin-errors.ts | 16 +- .../store/auth/preview-claim-recovery.test.ts | 17 +- .../cli/services/store/auth/recovery.test.ts | 150 +------------ .../src/cli/services/store/auth/recovery.ts | 52 +---- .../services/store/auth/session-lifecycle.ts | 7 +- .../services/store/execute/admin-transport.ts | 7 +- .../src/cli/services/store/info/index.ts | 16 +- .../src/cli/utilities/theme-command.test.ts | 211 +++++++++++++++++- .../theme/src/cli/utilities/theme-command.ts | 118 +++++++--- 19 files changed, 692 insertions(+), 274 deletions(-) create mode 100644 .changeset/theme-commands-recover-after-preview-store-claim.md create mode 100644 packages/cli-kit/src/public/node/store-auth-recovery.test.ts create mode 100644 packages/cli-kit/src/public/node/store-auth-recovery.ts diff --git a/.changeset/theme-commands-recover-after-preview-store-claim.md b/.changeset/theme-commands-recover-after-preview-store-claim.md new file mode 100644 index 00000000000..b950ce023db --- /dev/null +++ b/.changeset/theme-commands-recover-after-preview-store-claim.md @@ -0,0 +1,5 @@ +--- +'@shopify/theme': patch +--- + +Prompt to re-authenticate when a claimed preview store is used with theme commands. diff --git a/packages/cli-kit/src/public/node/api/admin.test.ts b/packages/cli-kit/src/public/node/api/admin.test.ts index 1a503448dfd..524e88ace4a 100644 --- a/packages/cli-kit/src/public/node/api/admin.test.ts +++ b/packages/cli-kit/src/public/node/api/admin.test.ts @@ -6,6 +6,7 @@ import * as http from '../http.js' import {defaultThemeKitAccessDomain} from '../../../private/node/constants.js' import {test, vi, expect, describe} from 'vitest' +import {ClientError} from 'graphql-request' vi.mock('./graphql.js') vi.mock('../../../private/node/api/headers.js') @@ -114,6 +115,31 @@ describe('admin-graphql-api', () => { }) }) +describe('fetchApiVersions', () => { + test.each([401, 404])('preserves HTTP %i on the thrown error', async (status) => { + vi.mocked(graphqlRequestDoc).mockRejectedValue( + new ClientError({status, data: 'body', errors: []}, {query: 'query'}), + ) + + const error = await admin + .fetchApiVersions({token, storeFqdn: `status-${status}.myshopify.com`}) + .catch((thrown) => thrown) + + expect(error).toBeInstanceOf(admin.AdminApiRequestError) + expect(error).toMatchObject({status}) + expect((error as Error).message).toContain(`Error connecting to your store status-${status}.myshopify.com:`) + }) + + test('keeps the existing access error for HTTP 403', async () => { + vi.mocked(graphqlRequestDoc).mockRejectedValue(new ClientError({status: 403, errors: []}, {query: 'query'})) + + const error = await admin.fetchApiVersions({token, storeFqdn: 'forbidden.myshopify.com'}).catch((thrown) => thrown) + + expect(error).not.toBeInstanceOf(admin.AdminApiRequestError) + expect((error as Error).message).toContain("Looks like you don't have access to this dev store") + }) +}) + describe('admin-rest-api', () => { test('"#restRequest" returns a valid response', async () => { // Given diff --git a/packages/cli-kit/src/public/node/api/admin.ts b/packages/cli-kit/src/public/node/api/admin.ts index f496b9d533b..082dfc986ed 100644 --- a/packages/cli-kit/src/public/node/api/admin.ts +++ b/packages/cli-kit/src/public/node/api/admin.ts @@ -26,6 +26,16 @@ import {TypedDocumentNode} from '@graphql-typed-document-node/core' const LatestApiVersionByFQDN = new Map() +/** Error that preserves an Admin API status for caller-specific recovery. */ +export class AdminApiRequestError extends AbortError { + constructor( + public readonly status: number, + message: string, + ) { + super(message) + } +} + /** * Executes a GraphQL query against the Admin API. * @@ -187,7 +197,8 @@ export async function fetchApiVersions( ) } if (error instanceof ClientError && (error.response.status === 401 || error.response.status === 404)) { - throw new AbortError( + throw new AdminApiRequestError( + error.response.status, `Error connecting to your store ${session.storeFqdn}: ${error.message} ${error.response.status} ${error.response.data}`, ) } diff --git a/packages/cli-kit/src/public/node/store-auth-recovery.test.ts b/packages/cli-kit/src/public/node/store-auth-recovery.test.ts new file mode 100644 index 00000000000..8664f0dc2e2 --- /dev/null +++ b/packages/cli-kit/src/public/node/store-auth-recovery.test.ts @@ -0,0 +1,144 @@ +import { + throwIfStoredStoreAuthIsInvalid, + throwMissingStoredStoreAuthError, + throwReauthenticateStoreAuthError, +} from './store-auth-recovery.js' +import { + getCurrentStoredStoreAppSession, + setStoredStoreAppSession, + type StoreAuthSessionSchema, + type StoredStoreAppSession, +} from './store-auth-session.js' +import {STORE_AUTH_APP_CLIENT_ID} from './constants.js' +import {AdminApiRequestError} from './api/admin.js' +import {AbortError} from './error.js' +import {inTemporaryDirectory} from './fs.js' +import {LocalStorage} from './local-storage.js' +import {describe, expect, test} from 'vitest' + +const SHOP = 'shop.myshopify.com' + +function standardSession(overrides: Partial = {}): StoredStoreAppSession { + return { + store: SHOP, + clientId: STORE_AUTH_APP_CLIENT_ID, + userId: '42', + accessToken: 'token', + scopes: ['read_products', 'write_orders'], + acquiredAt: '2026-03-27T00:00:00.000Z', + ...overrides, + } +} + +function previewSession(overrides: Partial = {}): StoredStoreAppSession { + return { + ...standardSession({ + userId: 'preview:placeholder-uuid', + scopes: ['read_products', 'write_products', 'read_themes'], + }), + kind: 'preview', + preview: { + shopId: '123', + name: 'Lavender Candles', + createdAt: '2026-03-27T00:00:00.000Z', + }, + ...overrides, + } +} + +function captureThrown(run: () => void): AbortError | undefined { + try { + run() + } catch (error) { + if (!(error instanceof AbortError)) throw error + return error + } + return undefined +} + +async function withStoredSession( + session: StoredStoreAppSession, + run: (storage: LocalStorage) => void, +): Promise { + await inTemporaryDirectory((cwd) => { + const storage = new LocalStorage({cwd}) + setStoredStoreAppSession(session, storage) + run(storage) + }) +} + +describe('stored store auth recovery', () => { + test('uses a scopes placeholder when stored authentication is missing', () => { + const error = captureThrown(() => throwMissingStoredStoreAuthError(SHOP)) + + expect(error).toMatchObject({ + message: `No stored app authentication found for ${SHOP}.`, + nextSteps: [ + ['Run', {command: `shopify store auth --store ${SHOP} --scopes `}, 'to authenticate'], + ], + }) + }) + + test('uses the stored scopes for a standard session', () => { + const error = captureThrown(() => throwReauthenticateStoreAuthError('Custom message.', standardSession())) + + expect(error).toMatchObject({ + message: 'Custom message.', + nextSteps: [ + [ + 'Run', + {command: `shopify store auth --store ${SHOP} --scopes read_products,write_orders`}, + 'to re-authenticate', + ], + ], + }) + }) + + test('clears a preview session and reports a likely claim for a typed 401', async () => { + const session = previewSession() + + await withStoredSession(session, (storage) => { + const error = captureThrown(() => + throwIfStoredStoreAuthIsInvalid( + new AdminApiRequestError(401, `Error connecting to your store ${SHOP}: Unauthorized`), + session, + {storage}, + ), + ) + + expect(error).toMatchObject({ + message: `The preview store ${SHOP} has likely been claimed, so its stored authentication is no longer valid.`, + nextSteps: [ + [ + 'Run', + {command: `shopify store auth --store ${SHOP} --scopes `}, + 'to re-authenticate', + ], + ], + }) + expect(getCurrentStoredStoreAppSession(SHOP, storage)).toBeUndefined() + }) + }) + + test('keeps a stored session on a 404 under the default 401-only policy', async () => { + const session = standardSession() + + await withStoredSession(session, (storage) => { + expect(() => throwIfStoredStoreAuthIsInvalid({response: {status: 404}}, session, {storage})).not.toThrow() + expect(getCurrentStoredStoreAppSession(SHOP, storage)).toMatchObject({accessToken: 'token'}) + }) + }) + + test('clears a stored session on a 404 when the caller classifies it explicitly', async () => { + const session = standardSession() + + await withStoredSession(session, (storage) => { + const error = captureThrown(() => + throwIfStoredStoreAuthIsInvalid({response: {status: 404}}, session, {invalidStatuses: [401, 404], storage}), + ) + + expect(error).toMatchObject({message: `Stored app authentication for ${SHOP} is no longer valid.`}) + expect(getCurrentStoredStoreAppSession(SHOP, storage)).toBeUndefined() + }) + }) +}) diff --git a/packages/cli-kit/src/public/node/store-auth-recovery.ts b/packages/cli-kit/src/public/node/store-auth-recovery.ts new file mode 100644 index 00000000000..56d72909b5a --- /dev/null +++ b/packages/cli-kit/src/public/node/store-auth-recovery.ts @@ -0,0 +1,105 @@ +import {clearStoredStoreAppSession} from './store-auth-session.js' +import {AbortError} from './error.js' +import type {LocalStorage} from './local-storage.js' +import type {StoreAuthSessionSchema, StoredStoreAppSession} from './store-auth-session.js' + +const UNKNOWN_SCOPES_PLACEHOLDER = '' +// Only 401 always means rejected credentials. Callers whose 404 also signals a +// removed store or token must opt in with explicit statuses. +const DEFAULT_INVALID_STORE_AUTH_STATUSES: ReadonlyArray = [401] + +function storeAuthCommandNextSteps(store: string, scopes: string, purpose: string) { + return [['Run', {command: `shopify store auth --store ${store} --scopes ${scopes}`}, purpose]] +} + +function reauthScopesFor(session: StoredStoreAppSession): string { + return session.kind === 'preview' ? UNKNOWN_SCOPES_PLACEHOLDER : session.scopes.join(',') +} + +// API errors can store their status on `status`, `statusCode`, or `response.status`. +function httpStatusFromError(error: unknown): number | undefined { + if (!error || typeof error !== 'object') return undefined + + const {status, statusCode, response} = error as {status?: unknown; statusCode?: unknown; response?: unknown} + if (typeof status === 'number') return status + if (typeof statusCode === 'number') return statusCode + if (!response || typeof response !== 'object') return undefined + + const responseStatus = (response as {status?: unknown}).status + return typeof responseStatus === 'number' ? responseStatus : undefined +} + +/** + * Throws an actionable error when no store app authentication is stored. + * + * @param store - The store FQDN that needs authentication. + * @throws AbortError with a `store auth` next step. + */ +export function throwMissingStoredStoreAuthError(store: string): never { + throw new AbortError( + `No stored app authentication found for ${store}.`, + undefined, + storeAuthCommandNextSteps(store, UNKNOWN_SCOPES_PLACEHOLDER, 'to authenticate'), + ) +} + +/** + * Throws an actionable error that directs the user to re-authenticate a stored session. + * + * @param message - The reason that the stored session cannot be used. + * @param session - The stored session to authenticate again. + * @throws AbortError with a `store auth` next step. + */ +export function throwReauthenticateStoreAuthError(message: string, session: StoredStoreAppSession): never { + throw new AbortError( + message, + undefined, + storeAuthCommandNextSteps(session.store, reauthScopesFor(session), 'to re-authenticate'), + ) +} + +/** + * Throws the invalid-session error for a stored store app session. + * + * @param session - The stored session rejected by Shopify. + * @throws AbortError with a `store auth` next step. + */ +export function throwStoredStoreAuthInvalidError(session: StoredStoreAppSession): never { + const message = + session.kind === 'preview' + ? `The preview store ${session.store} has likely been claimed, so its stored authentication is no longer valid.` + : `Stored app authentication for ${session.store} is no longer valid.` + + throwReauthenticateStoreAuthError(message, session) +} + +interface InvalidStoredStoreAuthOptions { + invalidStatuses?: ReadonlyArray + storage?: LocalStorage +} + +/** + * Clears and reports a rejected stored session when its HTTP status is invalid for the caller. + * + * @param error - The rejected API error. + * @param session - The stored session used for the request. + * @param options - Statuses to classify and an optional storage override. + * @throws AbortError with a `store auth` next step when the session is invalid. + */ +export function throwIfStoredStoreAuthIsInvalid( + error: unknown, + session: StoredStoreAppSession, + options: InvalidStoredStoreAuthOptions = {}, +): void { + const invalidStatuses = options.invalidStatuses ?? DEFAULT_INVALID_STORE_AUTH_STATUSES + const status = httpStatusFromError(error) + if (status === undefined || !invalidStatuses.includes(status)) return + + if (options.storage) { + clearStoredStoreAppSession(session.store, session.userId, options.storage) + } else { + clearStoredStoreAppSession(session.store, session.userId) + } + + throwStoredStoreAuthInvalidError(session) +} diff --git a/packages/cli-kit/src/public/node/store-auth-session.test.ts b/packages/cli-kit/src/public/node/store-auth-session.test.ts index afa209a8b01..7109293f91c 100644 --- a/packages/cli-kit/src/public/node/store-auth-session.test.ts +++ b/packages/cli-kit/src/public/node/store-auth-session.test.ts @@ -2,6 +2,7 @@ import { clearStoredStoreAppSession, getCurrentStoredStoreAppSession, getStoreAuthAdminSession, + listCurrentStoredStoreAppSessions, setStoredStoreAppSession, storeAuthSessionKey, type StoredStoreAppSession, @@ -116,6 +117,25 @@ describe('store auth session storage', () => { }) }) + test('lists the current stored session for every store from real local storage', async () => { + await inTemporaryDirectory((cwd) => { + const storage = new LocalStorage>({cwd}) + const previousFirstStoreUser = buildSession({store: 'first.myshopify.com', userId: '42', accessToken: 'token-1'}) + const currentFirstStoreUser = buildSession({store: 'first.myshopify.com', userId: '84', accessToken: 'token-2'}) + const currentSecondStoreUser = buildSession({store: 'second.myshopify.com', userId: '7', accessToken: 'token-3'}) + + setStoredStoreAppSession(previousFirstStoreUser, storage as any) + setStoredStoreAppSession(currentFirstStoreUser, storage as any) + setStoredStoreAppSession(currentSecondStoreUser, storage as any) + storage.set('unrelated-key', {currentUserId: '42', sessionsByUserId: {}}) + + const sessions = listCurrentStoredStoreAppSessions(storage as any) + + expect(sessions).toHaveLength(2) + expect(sessions).toEqual(expect.arrayContaining([currentFirstStoreUser, currentSecondStoreUser])) + }) + }) + test('returns the current user session for a store', () => { const storage = inMemoryStorage() diff --git a/packages/cli-kit/src/public/node/store-auth-session.ts b/packages/cli-kit/src/public/node/store-auth-session.ts index 430d1fc35b7..0c881ceba9e 100644 --- a/packages/cli-kit/src/public/node/store-auth-session.ts +++ b/packages/cli-kit/src/public/node/store-auth-session.ts @@ -65,7 +65,8 @@ interface StoredStoreAppSessionBucket { sessionsByUserId: {[userId: string]: StoredStoreAppSession} } -interface StoreAuthSessionSchema { +/** Store auth sessions, keyed by the store auth storage key. */ +export interface StoreAuthSessionSchema { [key: string]: StoredStoreAppSessionBucket } diff --git a/packages/cli-kit/src/public/node/themes/api.test.ts b/packages/cli-kit/src/public/node/themes/api.test.ts index c6da96b898a..ec2ea2f6e6c 100644 --- a/packages/cli-kit/src/public/node/themes/api.test.ts +++ b/packages/cli-kit/src/public/node/themes/api.test.ts @@ -26,13 +26,18 @@ import {ThemeFilesDelete} from '../../../cli/api/graphql/admin/generated/theme_f import {GetThemes} from '../../../cli/api/graphql/admin/generated/get_themes.js' import {GetTheme} from '../../../cli/api/graphql/admin/generated/get_theme.js' import {FindDevelopmentThemeByName} from '../../../cli/api/graphql/admin/generated/find_development_theme_by_name.js' -import {adminRequestDoc, supportedApiVersions} from '../api/admin.js' +import {AdminApiRequestError, adminRequestDoc, supportedApiVersions} from '../api/admin.js' import {AbortError} from '../error.js' +import {GraphQLClientError} from '../../../private/node/api/headers.js' import {test, vi, expect, describe, beforeEach} from 'vitest' import {ClientError} from 'graphql-request' -vi.mock('../api/admin.js') +vi.mock('../api/admin.js', async (importOriginal) => ({ + ...(await importOriginal()), + adminRequestDoc: vi.fn(), + supportedApiVersions: vi.fn(), +})) vi.mock('@shopify/cli-kit/node/system') vi.stubGlobal('fetch', vi.fn()) @@ -97,6 +102,22 @@ describe('fetchTheme', () => { 'The authenticated account or access token is missing `read_themes` access scope.', ) }) + + test.each([ + new ClientError({status: 401, errors: []}, {query: ''}), + new AdminApiRequestError(401, 'Error connecting to your store my-shop.myshopify.com: Unauthorized'), + new GraphQLClientError('Unauthorized', 401), + ])('propagates HTTP 401 instead of treating the theme as missing', async (error) => { + vi.mocked(adminRequestDoc).mockRejectedValue(error) + + await expect(fetchTheme(123, session)).rejects.toBe(error) + }) + + test('continues to treat a typed HTTP 404 as a missing theme', async () => { + vi.mocked(adminRequestDoc).mockRejectedValue(new AdminApiRequestError(404, 'Not Found')) + + await expect(fetchTheme(123, session)).resolves.toBeUndefined() + }) }) describe('findDevelopmentThemeByName', () => { diff --git a/packages/cli-kit/src/public/node/themes/api.ts b/packages/cli-kit/src/public/node/themes/api.ts index 5bbd328b6aa..7c668b911ce 100644 --- a/packages/cli-kit/src/public/node/themes/api.ts +++ b/packages/cli-kit/src/public/node/themes/api.ts @@ -25,7 +25,8 @@ import {GetTheme} from '../../../cli/api/graphql/admin/generated/get_theme.js' import {FindDevelopmentThemeByName} from '../../../cli/api/graphql/admin/generated/find_development_theme_by_name.js' import {OnlineStorePasswordProtection} from '../../../cli/api/graphql/admin/generated/online_store_password_protection.js' import {RequestModeInput} from '../http.js' -import {adminRequestDoc, type AdminRequestOptions} from '../api/admin.js' +import {AdminApiRequestError, adminRequestDoc, type AdminRequestOptions} from '../api/admin.js' +import {GraphQLClientError} from '../../../private/node/api/headers.js' import {AdminSession} from '../session.js' import {AbortError} from '../error.js' import {outputDebug} from '../output.js' @@ -65,10 +66,12 @@ export async function fetchTheme(id: number, session: AdminSession): Promise( } } +function isUnauthorizedAdminApiError(error: unknown): boolean { + if (error instanceof AdminApiRequestError) return error.status === 401 + if (error instanceof GraphQLClientError) return error.statusCode === 401 + return error instanceof ClientError && error.response.status === 401 +} + function abortIfMissingThemeAccessScope(error: unknown): void { if (!(error instanceof ClientError)) return diff --git a/packages/cli-kit/src/public/node/themes/theme-manager.test.ts b/packages/cli-kit/src/public/node/themes/theme-manager.test.ts index a8b0ef88c19..e12633a35c5 100644 --- a/packages/cli-kit/src/public/node/themes/theme-manager.test.ts +++ b/packages/cli-kit/src/public/node/themes/theme-manager.test.ts @@ -3,6 +3,7 @@ import {Theme} from './types.js' import {fetchTheme, findDevelopmentThemeByName, themeCreate} from './api.js' import {DEVELOPMENT_THEME_ROLE, UNPUBLISHED_THEME_ROLE} from './utils.js' import {BugError} from '../error.js' +import {GraphQLClientError} from '../../../private/node/api/headers.js' import {test, describe, expect, vi, beforeEach} from 'vitest' vi.mock('./api.js') @@ -29,6 +30,10 @@ class TestThemeManager extends ThemeManager { this.themeId = themeId } + storeTheme(themeId: string): void { + this.setTheme(themeId) + } + protected setTheme(themeId: string): void { this.storedThemeId = themeId this.themeId = themeId @@ -151,7 +156,7 @@ describe('ThemeManager', () => { test('removes theme when fetch returns undefined', async () => { // Given - manager.setThemeId('123') + manager.storeTheme('123') vi.mocked(fetchTheme).mockResolvedValue(undefined) // When @@ -162,6 +167,15 @@ describe('ThemeManager', () => { expect(result).toBeUndefined() expect(manager.getStoredThemeId()).toBeUndefined() }) + + test('keeps the stored theme when fetching it fails with HTTP 401', async () => { + manager.storeTheme('123') + const authenticationFailure = new GraphQLClientError('Unauthorized', 401) + vi.mocked(fetchTheme).mockRejectedValue(authenticationFailure) + + await expect(manager.fetch()).rejects.toBe(authenticationFailure) + expect(manager.getStoredThemeId()).toBe('123') + }) }) describe('generateThemeName', () => { diff --git a/packages/store/src/cli/services/store/admin-errors.ts b/packages/store/src/cli/services/store/admin-errors.ts index bc2f224b616..eb776174268 100644 --- a/packages/store/src/cli/services/store/admin-errors.ts +++ b/packages/store/src/cli/services/store/admin-errors.ts @@ -1,7 +1,8 @@ -import {throwStoredAuthInvalidError} from './auth/recovery.js' -import {clearStoredStoreAppSession} from '@shopify/cli-kit/node/store-auth-session' import {AbortError} from '@shopify/cli-kit/node/error' -import type {StoredStoreAppSession} from '@shopify/cli-kit/node/store-auth-session' + +// Store requests target known-good endpoints, so a 404 also signals a removed +// store or token (for example after a preview store is claimed). +export const INVALID_STORED_AUTH_STATUSES: ReadonlyArray = [401, 404] interface GraphQLClientErrorLike { response: {status?: number; errors?: unknown} @@ -54,12 +55,3 @@ export function classifyAdminApiError(error: unknown, storeFqdn: string): AbortE return undefined } - -export function throwIfStoredStoreAuthIsInvalid(error: unknown, session: StoredStoreAppSession): void { - const status = graphQLClientErrorStatus(error) - if (status !== 401 && status !== 404) return - - clearStoredStoreAppSession(session.store, session.userId) - - throwStoredAuthInvalidError(session) -} diff --git a/packages/store/src/cli/services/store/auth/preview-claim-recovery.test.ts b/packages/store/src/cli/services/store/auth/preview-claim-recovery.test.ts index faace03f95b..745d4d195d4 100644 --- a/packages/store/src/cli/services/store/auth/preview-claim-recovery.test.ts +++ b/packages/store/src/cli/services/store/auth/preview-claim-recovery.test.ts @@ -1,14 +1,17 @@ import {authenticateStoreWithApp} from './index.js' import {STORE_AUTH_APP_CLIENT_ID} from './config.js' +import {INVALID_STORED_AUTH_STATUSES} from '../admin-errors.js' +import {throwIfStoredStoreAuthIsInvalid} from '@shopify/cli-kit/node/store-auth-recovery' import { - clearStoredStoreAppSession, getCurrentStoredStoreAppSession, setStoredStoreAppSession, + type StoreAuthSessionSchema, type StoredStoreAppSession, } from '@shopify/cli-kit/node/store-auth-session' import {inTemporaryDirectory} from '@shopify/cli-kit/node/fs' import {LocalStorage} from '@shopify/cli-kit/node/local-storage' import {AbortError} from '@shopify/cli-kit/node/error' +import {AdminApiRequestError} from '@shopify/cli-kit/node/api/admin' import {describe, expect, test, vi} from 'vitest' vi.mock('../attribution.js') @@ -16,10 +19,10 @@ vi.mock('../attribution.js') const SHOP = 'shop.myshopify.com' const SCOPE_RESOLUTION_REACHED = 'Scope resolution reached, so the preview-store guard did not fire.' -type StoreAuthStorage = NonNullable[1]> +type StoreAuthStorage = LocalStorage function createStoreAuthStorage(cwd: string): StoreAuthStorage { - return new LocalStorage({cwd}) + return new LocalStorage({cwd}) } function previewSession(): StoredStoreAppSession { @@ -54,7 +57,13 @@ describe('recovering from a claimed preview store', () => { await expect(runStoreAuth(storage)).rejects.toThrow('`store auth` is unavailable for preview stores.') - clearStoredStoreAppSession(session.store, session.userId, storage) + expect(() => + throwIfStoredStoreAuthIsInvalid( + new AdminApiRequestError(401, `Error connecting to your store ${SHOP}: Unauthorized`), + session, + {invalidStatuses: INVALID_STORED_AUTH_STATUSES, storage}, + ), + ).toThrow(`The preview store ${SHOP} has likely been claimed, so its stored authentication is no longer valid.`) expect(getCurrentStoredStoreAppSession(SHOP, storage)).toBeUndefined() await expect(runStoreAuth(storage)).rejects.toThrow(SCOPE_RESOLUTION_REACHED) diff --git a/packages/store/src/cli/services/store/auth/recovery.test.ts b/packages/store/src/cli/services/store/auth/recovery.test.ts index add964a805e..2ad77e8f908 100644 --- a/packages/store/src/cli/services/store/auth/recovery.test.ts +++ b/packages/store/src/cli/services/store/auth/recovery.test.ts @@ -1,155 +1,9 @@ -import { - throwStoredStoreAuthError, - throwReauthenticateStoreAuthError, - throwStoredAuthInvalidError, - retryStoreAuthWithPermanentDomainError, -} from './recovery.js' -import {STORE_AUTH_APP_CLIENT_ID} from './config.js' +import {retryStoreAuthWithPermanentDomainError} from './recovery.js' import {AbortError} from '@shopify/cli-kit/node/error' import {describe, expect, test} from 'vitest' -import type {StoredStoreAppSession} from '@shopify/cli-kit/node/store-auth-session' - -const SHOP = 'shop.myshopify.com' - -function standardSession(overrides: Partial = {}): StoredStoreAppSession { - return { - store: SHOP, - clientId: STORE_AUTH_APP_CLIENT_ID, - userId: '42', - accessToken: 'token', - scopes: ['read_products', 'write_orders'], - acquiredAt: '2026-03-27T00:00:00.000Z', - ...overrides, - } -} - -function previewSession(overrides: Partial = {}): StoredStoreAppSession { - return { - ...standardSession({ - userId: 'preview:placeholder-uuid', - // The full preapproved catalog is much larger in practice; a couple of entries are enough - // to prove the placeholder is used instead of these. - scopes: ['read_products', 'write_products', 'read_themes'], - }), - kind: 'preview', - preview: { - shopId: '123', - name: 'Lavender Candles', - createdAt: '2026-03-27T00:00:00.000Z', - }, - ...overrides, - } -} - -describe('throwStoredStoreAuthError', () => { - test('reports no stored auth and prompts to authenticate (not re-authenticate) with a scopes placeholder', () => { - let captured: AbortError | undefined - try { - throwStoredStoreAuthError(SHOP) - // eslint-disable-next-line no-catch-all/no-catch-all - } catch (error) { - captured = error as AbortError - } - - expect(captured).toMatchObject({ - message: `No stored app authentication found for ${SHOP}.`, - nextSteps: [ - ['Run', {command: `shopify store auth --store ${SHOP} --scopes `}, 'to authenticate'], - ], - }) - }) -}) - -describe('throwReauthenticateStoreAuthError', () => { - test('suggests the real scopes for a standard session', () => { - let captured: AbortError | undefined - try { - throwReauthenticateStoreAuthError('Custom message.', standardSession()) - // eslint-disable-next-line no-catch-all/no-catch-all - } catch (error) { - captured = error as AbortError - } - - expect(captured).toMatchObject({ - message: 'Custom message.', - nextSteps: [ - [ - 'Run', - {command: `shopify store auth --store ${SHOP} --scopes read_products,write_orders`}, - 'to re-authenticate', - ], - ], - }) - }) - - test('suggests a scopes placeholder for a preview session instead of its preapproved catalog', () => { - let captured: AbortError | undefined - try { - throwReauthenticateStoreAuthError('Custom message.', previewSession()) - // eslint-disable-next-line no-catch-all/no-catch-all - } catch (error) { - captured = error as AbortError - } - - expect(captured).toMatchObject({ - message: 'Custom message.', - nextSteps: [ - [ - 'Run', - {command: `shopify store auth --store ${SHOP} --scopes `}, - 'to re-authenticate', - ], - ], - }) - }) -}) - -describe('throwStoredAuthInvalidError', () => { - test('uses the generic invalid-auth message and real scopes for a standard session', () => { - let captured: AbortError | undefined - try { - throwStoredAuthInvalidError(standardSession()) - // eslint-disable-next-line no-catch-all/no-catch-all - } catch (error) { - captured = error as AbortError - } - - expect(captured).toMatchObject({ - message: `Stored app authentication for ${SHOP} is no longer valid.`, - nextSteps: [ - [ - 'Run', - {command: `shopify store auth --store ${SHOP} --scopes read_products,write_orders`}, - 'to re-authenticate', - ], - ], - }) - }) - - test('flags a likely claim and suggests a scopes placeholder for a preview session', () => { - let captured: AbortError | undefined - try { - throwStoredAuthInvalidError(previewSession()) - // eslint-disable-next-line no-catch-all/no-catch-all - } catch (error) { - captured = error as AbortError - } - - expect(captured).toMatchObject({ - message: `The preview store ${SHOP} has likely been claimed, so its stored authentication is no longer valid.`, - nextSteps: [ - [ - 'Run', - {command: `shopify store auth --store ${SHOP} --scopes `}, - 'to re-authenticate', - ], - ], - }) - }) -}) describe('retryStoreAuthWithPermanentDomainError', () => { - test('returns (rather than throws) an AbortError pointing at the permanent domain with a scopes placeholder', () => { + test('returns an AbortError pointing at the permanent domain with a scopes placeholder', () => { const error = retryStoreAuthWithPermanentDomainError('permanent-shop.myshopify.com') expect(error).toBeInstanceOf(AbortError) diff --git a/packages/store/src/cli/services/store/auth/recovery.ts b/packages/store/src/cli/services/store/auth/recovery.ts index bcf2f95ae4d..d459aefa719 100644 --- a/packages/store/src/cli/services/store/auth/recovery.ts +++ b/packages/store/src/cli/services/store/auth/recovery.ts @@ -1,62 +1,12 @@ import {AbortError} from '@shopify/cli-kit/node/error' -import type {StoredStoreAppSession} from '@shopify/cli-kit/node/store-auth-session' const UNKNOWN_SCOPES_PLACEHOLDER = '' -function storeAuthCommand(store: string, scopes: string): {command: string} { - return {command: `shopify store auth --store ${store} --scopes ${scopes}`} -} - -function storeAuthCommandNextStepsWithUnknownScopes(store: string) { - return [[storeAuthCommand(store, UNKNOWN_SCOPES_PLACEHOLDER)]] -} - -function storeAuthCommandNextStepsWithPurpose(store: string, scopes: string, purpose: string) { - return [['Run', storeAuthCommand(store, scopes), purpose]] -} - -// Preview-store sessions are preapproved for a large, fixed scope catalog (often 30+ scopes). -// Suggesting the user re-request all of them encourages over-scoping, so they get the same -// placeholder as the "no stored auth" case and choose deliberately instead. -function reauthScopesFor(session: StoredStoreAppSession): string { - return session.kind === 'preview' ? UNKNOWN_SCOPES_PLACEHOLDER : session.scopes.join(',') -} - -export function throwStoredStoreAuthError(store: string): never { - throw new AbortError( - `No stored app authentication found for ${store}.`, - undefined, - storeAuthCommandNextStepsWithPurpose(store, UNKNOWN_SCOPES_PLACEHOLDER, 'to authenticate'), - ) -} - -export function throwReauthenticateStoreAuthError(message: string, session: StoredStoreAppSession): never { - throw new AbortError( - message, - undefined, - storeAuthCommandNextStepsWithPurpose(session.store, reauthScopesFor(session), 'to re-authenticate'), - ) -} - -// A preview store's local session has no way to know it was claimed through the browser claim -// flow; a 401/404 the first time the stale session is used again is the only signal. Surfacing -// that possibility is more useful than the generic "no longer valid" message a standard session -// gets, so every call site that detects an invalid stored session (regardless of which API it -// hit) should go through here instead of writing its own message. -export function throwStoredAuthInvalidError(session: StoredStoreAppSession): never { - const message = - session.kind === 'preview' - ? `The preview store ${session.store} has likely been claimed, so its stored authentication is no longer valid.` - : `Stored app authentication for ${session.store} is no longer valid.` - - throwReauthenticateStoreAuthError(message, session) -} - export function retryStoreAuthWithPermanentDomainError(returnedStore: string): AbortError { // eslint-disable-next-line @shopify/cli/no-error-factory-functions return new AbortError( 'OAuth callback store does not match the requested store.', `Shopify returned ${returnedStore} during authentication. Re-run using the permanent store domain:`, - storeAuthCommandNextStepsWithUnknownScopes(returnedStore), + [[{command: `shopify store auth --store ${returnedStore} --scopes ${UNKNOWN_SCOPES_PLACEHOLDER}`}]], ) } diff --git a/packages/store/src/cli/services/store/auth/session-lifecycle.ts b/packages/store/src/cli/services/store/auth/session-lifecycle.ts index 5cb8c7f090c..fa8cf6a5a61 100644 --- a/packages/store/src/cli/services/store/auth/session-lifecycle.ts +++ b/packages/store/src/cli/services/store/auth/session-lifecycle.ts @@ -1,6 +1,9 @@ import {maskToken} from './config.js' -import {throwStoredStoreAuthError, throwReauthenticateStoreAuthError} from './recovery.js' import {refreshStoreAccessToken} from './token-client.js' +import { + throwMissingStoredStoreAuthError, + throwReauthenticateStoreAuthError, +} from '@shopify/cli-kit/node/store-auth-recovery' import { clearStoredStoreAppSession, getCurrentStoredStoreAppSession, @@ -49,7 +52,7 @@ export async function loadStoredStoreSession(store: string): Promise { const original = await importOriginal() return { ...original, + clearStoredStoreAppSession: vi.fn(), getCurrentStoredStoreAppSession: vi.fn(), listCurrentStoredStoreAppSessions: vi.fn(), } @@ -85,6 +89,54 @@ class TestScopedThemeCommand extends TestThemeCommand { } } +class TestFailingThemeCommand extends TestScopedThemeCommand { + failure: Error = new Error('Not configured') + + async command( + flags: any, + session: AdminSession, + multiEnvironment = false, + args?: any, + context?: {stdout?: Writable; stderr?: Writable}, + ): Promise { + await super.command(flags, session, multiEnvironment, args, context) + throw this.failure + } +} + +function adminApiClientError(status: number, message = 'GraphQL Error'): Error { + const error = new Error(message) as Error & {response: {status: number; errors: {message: string}[]}} + error.response = {status, errors: [{message}]} + return error +} + +function graphQLClientError(statusCode: number, message = 'GraphQL Error'): Error { + const error = new Error(message) as Error & {statusCode: number} + error.statusCode = statusCode + return error +} + +function storedStoreAuthSession(overrides: Partial = {}): StoredStoreAppSession { + return { + store: 'test-store.myshopify.com', + clientId: 'store-auth-client-id', + userId: '42', + accessToken: 'shpat_stored_token', + scopes: ['read_themes'], + acquiredAt: '2026-06-08T11:00:00.000Z', + ...overrides, + } +} + +function storedPreviewStoreAuthSession(overrides: Partial = {}): StoredStoreAppSession { + return storedStoreAuthSession({ + userId: 'preview:123', + kind: 'preview', + preview: {shopId: '123', name: 'Lavender Candles', createdAt: '2026-06-08T11:00:00.000Z'}, + ...overrides, + }) +} + class TestThemeCommandWithForce extends TestThemeCommand { static flags = { ...TestThemeCommand.flags, @@ -958,11 +1010,33 @@ describe('ThemeCommand', () => { await command.run() // Then - expect(renderError).toHaveBeenCalledWith( - expect.objectContaining({ - body: ['Environment command-error failed: \n\nMocking a command error'], - }), + expect(renderError).toHaveBeenCalledWith({ + body: ['Environment command-error failed: \n\nMocking a command error'], + }) + }) + + test('keeps a fatal error try message separate from the main message', async () => { + vi.mocked(loadEnvironment).mockResolvedValue({store: 'store.myshopify.com'}) + 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) + } + }) + + await CommandConfig.load() + const command = new TestFailingThemeCommand( + ['--environment', 'broken', '--environment', 'development'], + CommandConfig, ) + command.failure = new AbortError('Theme could not be pushed.', ['Check the', {command: 'theme list'}, 'output.']) + + await command.run() + + expect(renderError).toHaveBeenCalledWith({ + headline: 'Environment broken failed:', + body: ['Theme could not be pushed.', '\n\nCheck the', {command: 'theme list'}, 'output.'], + }) }) test('commands should display an error if the --path flag is used', async () => { @@ -1189,4 +1263,133 @@ describe('ThemeCommand', () => { expect(ensureAuthenticatedThemes).not.toHaveBeenCalled() }) }) + + describe('stored preview store auth recovery', () => { + async function runFailingCommand(argv: string[], failure: Error): Promise { + await CommandConfig.load() + const command = new TestFailingThemeCommand(argv, CommandConfig) + command.failure = failure + + return command.run().then( + () => { + throw new Error('Expected the command to fail') + }, + (error: Error) => error, + ) + } + + test('clears a stored preview session and reports a likely claim on HTTP 401', async () => { + vi.mocked(getCurrentStoredStoreAppSession).mockReturnValue(storedPreviewStoreAuthSession()) + + const error = await runFailingCommand([], adminApiClientError(401)) + + expect(error).toMatchObject({ + message: + 'The preview store test-store.myshopify.com has likely been claimed, so its stored authentication is no longer valid.', + nextSteps: [ + [ + 'Run', + {command: 'shopify store auth --store test-store.myshopify.com --scopes '}, + 'to re-authenticate', + ], + ], + }) + expect(clearStoredStoreAppSession).toHaveBeenCalledWith('test-store.myshopify.com', 'preview:123') + }) + + test('clears a stored preview session when a GraphQL client error has statusCode 401', async () => { + vi.mocked(getCurrentStoredStoreAppSession).mockReturnValue(storedPreviewStoreAuthSession()) + + const error = await runFailingCommand([], graphQLClientError(401)) + + expect(error).toMatchObject({ + message: + 'The preview store test-store.myshopify.com has likely been claimed, so its stored authentication is no longer valid.', + }) + expect(clearStoredStoreAppSession).toHaveBeenCalledWith('test-store.myshopify.com', 'preview:123') + }) + + test('leaves a stored standard session untouched on HTTP 401', async () => { + vi.mocked(getCurrentStoredStoreAppSession).mockReturnValue( + storedStoreAuthSession({ + expiresAt: new Date(Date.now() + 60 * 60 * 1000).toISOString(), + refreshToken: 'refresh-token-that-would-still-work', + }), + ) + const failure = adminApiClientError(401, '[API] Invalid API key or access token') + + const error = await runFailingCommand([], failure) + + expect(error).toBe(failure) + expect(clearStoredStoreAppSession).not.toHaveBeenCalled() + }) + + test.each([404, 500])('leaves a stored preview session untouched on HTTP %i', async (status) => { + vi.mocked(getCurrentStoredStoreAppSession).mockReturnValue(storedPreviewStoreAuthSession()) + const failure = adminApiClientError(status, 'Theme not found') + + const error = await runFailingCommand([], failure) + + expect(error).toBe(failure) + expect(clearStoredStoreAppSession).not.toHaveBeenCalled() + }) + + test('does not classify a 401 from an explicitly supplied password', async () => { + vi.mocked(getCurrentStoredStoreAppSession).mockReturnValue(storedPreviewStoreAuthSession()) + const failure = adminApiClientError(401, '[API] Invalid API key or access token') + + const error = await runFailingCommand(['--password', 'shpat_custom_app_password'], failure) + + expect(error).toBe(failure) + expect(getCurrentStoredStoreAppSession).not.toHaveBeenCalled() + expect(clearStoredStoreAppSession).not.toHaveBeenCalled() + }) + + test('keeps recovery next steps when one environment fails and runs the others', async () => { + vi.mocked(loadEnvironment) + .mockResolvedValueOnce({store: 'claimed.myshopify.com'}) + .mockResolvedValueOnce({store: 'healthy.myshopify.com'}) + vi.mocked(ensureThemeStore).mockImplementation((options: any) => options.store) + vi.mocked(listCurrentStoredStoreAppSessions).mockReturnValue([ + storedPreviewStoreAuthSession({store: 'claimed.myshopify.com'}), + storedPreviewStoreAuthSession({store: 'healthy.myshopify.com'}), + ]) + 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) + } + }) + + await CommandConfig.load() + const command = new TestFailingThemeCommand( + ['--environment', 'claimed', '--environment', 'healthy'], + CommandConfig, + ) + command.failure = adminApiClientError(401) + const originalCommand = command.command.bind(command) + command.command = async (flags, session, multiEnvironment, args, context) => { + if (flags.store !== 'claimed.myshopify.com') { + command.commandCalls.push({flags, session, multiEnvironment: multiEnvironment ?? false, args, context}) + return + } + await originalCommand(flags, session, multiEnvironment, args, context) + } + + await command.run() + + expect(renderError).toHaveBeenCalledWith({ + headline: 'Environment claimed failed:', + body: 'The preview store claimed.myshopify.com has likely been claimed, so its stored authentication is no longer valid.', + nextSteps: [ + [ + 'Run', + {command: 'shopify store auth --store claimed.myshopify.com --scopes '}, + 'to re-authenticate', + ], + ], + }) + expect(command.commandCalls.map(({flags}) => flags.store)).toContain('healthy.myshopify.com') + }) + }) }) diff --git a/packages/theme/src/cli/utilities/theme-command.ts b/packages/theme/src/cli/utilities/theme-command.ts index 6c42ad3e558..0a0724d159c 100644 --- a/packages/theme/src/cli/utilities/theme-command.ts +++ b/packages/theme/src/cli/utilities/theme-command.ts @@ -13,6 +13,7 @@ import { listCurrentStoredStoreAppSessions, type StoredStoreAppSession, } from '@shopify/cli-kit/node/store-auth-session' +import {throwIfStoredStoreAuthIsInvalid} from '@shopify/cli-kit/node/store-auth-recovery' import {loadEnvironment} from '@shopify/cli-kit/node/environments' import { renderWarning, @@ -20,9 +21,10 @@ import { renderConfirmationPrompt, RenderConfirmationPromptOptions, renderError, + type TokenItem, } from '@shopify/cli-kit/node/ui' import {AbortController} from '@shopify/cli-kit/node/abort' -import {AbortError} from '@shopify/cli-kit/node/error' +import {AbortError, FatalError} from '@shopify/cli-kit/node/error' import {recordEvent, compileData} from '@shopify/cli-kit/node/analytics' import {addPublicMetadata, addSensitiveMetadata} from '@shopify/cli-kit/node/metadata' import {outputDebug} from '@shopify/cli-kit/node/output' @@ -33,11 +35,17 @@ import {normalizeStoreFqdn} from '@shopify/cli-kit/node/context/fqdn' import type {Writable} from 'stream' type FlagValues = Record + +interface ThemeSessionContext { + adminSession: AdminSession + storedStoreAppSession?: StoredStoreAppSession +} + interface ValidEnvironment { environment: EnvironmentName flags: FlagValues requiresAuth: boolean - storeAuthSession?: AdminSession + storeAuthSession?: ThemeSessionContext } type EnvironmentName = string /** @@ -56,6 +64,43 @@ type EnvironmentName = string */ export type RequiredFlags = (string | string[])[] | null +// Theme commands use 401 only. A missing `--theme` target can return 404. +const THEME_INVALID_STORE_AUTH_STATUSES = [401] + +function throwIfThemeStoreAuthIsInvalid(error: unknown, sessionContext: ThemeSessionContext | undefined): void { + const storedSession = sessionContext?.storedStoreAppSession + // Theme does not refresh standard sessions. Their 401 may have a usable refresh token. + if (storedSession?.kind !== 'preview') return + + throwIfStoredStoreAuthIsInvalid(error, storedSession, {invalidStatuses: THEME_INVALID_STORE_AUTH_STATUSES}) +} + +function bodyWithSeparateTryMessage(message: string, tryMessage: TokenItem | null): TokenItem { + if (!tryMessage) return message + + const [firstToken, ...remainingTokens] = [tryMessage].flat() + if (firstToken === undefined) return message + + return typeof firstToken === 'string' + ? [message, `\n\n${firstToken}`, ...remainingTokens] + : [message, '\n\n', firstToken, ...remainingTokens] +} + +function renderEnvironmentFailure(environment: EnvironmentName, error: Error): void { + if (!(error instanceof FatalError)) { + renderError({body: [`Environment ${environment} failed: \n\n${error.message}`]}) + return + } + + // Keep FatalError next steps. They provide recovery actions. + renderError({ + headline: `Environment ${environment} failed:`, + body: bodyWithSeparateTryMessage(error.message, error.tryMessage), + ...(error.nextSteps?.length ? {nextSteps: error.nextSteps} : {}), + ...(error.customSections?.length ? {customSections: error.customSections} : {}), + }) +} + export default abstract class ThemeCommand extends Command { static baseFlags = authAliasFlag @@ -99,7 +144,8 @@ export default abstract class ThemeCommand extends Command { throw new AbortError(`Please provide a valid environment.`) } - const session = commandRequiresAuth ? await this.createSession(flags) : undefined + const sessionContext = commandRequiresAuth ? await this.createSession(flags) : undefined + const session = sessionContext?.adminSession const commandName = this.constructor.name.toLowerCase() recordEvent(`theme-command:${commandName}:single-env:authenticated`) @@ -110,6 +156,9 @@ export default abstract class ThemeCommand extends Command { try { await this.command(flags, session, false, args) + } catch (error) { + throwIfThemeStoreAuthIsInvalid(error, sessionContext) + throw error } finally { await this.logAnalyticsData(session) } @@ -206,7 +255,7 @@ export default abstract class ThemeCommand extends Command { const storeAuthSessionsByStore = requiresAuth ? this.storeAuthSessionsForTheme(Array.from(environmentMap.values()).map(({validationFlags}) => validationFlags)) - : new Map() + : new Map() const entriesWithStoreAuthSessions = Array.from(environmentMap.entries()).map( ([environmentName, {flags, validationFlags}]) => ({ @@ -306,13 +355,17 @@ export default abstract class ThemeCommand extends Command { try { const store = flags.store as string await useThemeStoreContext(store, async () => { - const session = requiresAuth ? await this.createSession(flags, storeAuthSession) : undefined + const sessionContext = requiresAuth ? await this.createSession(flags, storeAuthSession) : undefined + const session = sessionContext?.adminSession const commandName = this.constructor.name.toLowerCase() recordEvent(`theme-command:${commandName}:multi-env:authenticated`) try { await this.command(flags, session, true, {}, {stdout, stderr}) + } catch (error) { + throwIfThemeStoreAuthIsInvalid(error, sessionContext) + throw error } finally { await this.logAnalyticsData(session) } @@ -321,8 +374,7 @@ export default abstract class ThemeCommand extends Command { // eslint-disable-next-line no-catch-all/no-catch-all } catch (error) { if (error instanceof Error) { - error.message = `Environment ${environment} failed: \n\n${error.message}` - renderError({body: [error.message]}) + renderEnvironmentFailure(environment, error) } } }, @@ -351,24 +403,21 @@ export default abstract class ThemeCommand extends Command { return groups } - /** - * Create an unauthenticated session object from store and password - * @param flags - The environment flags containing store and password - * @returns The unauthenticated session object - */ - private async createSession(flags: FlagValues, storeAuthSession?: AdminSession) { + private async createSession(flags: FlagValues, storeAuthSession?: ThemeSessionContext): Promise { const store = ensureThemeStore({store: flags.store as string | undefined}) const password = flags.password as string | undefined - const session = password - ? await ensureAuthenticatedThemes(store, password) - : (storeAuthSession ?? - (await this.storeAuthSessionForTheme({store})) ?? - (await ensureAuthenticatedThemes(store, password))) - return session + if (password) { + return {adminSession: await ensureAuthenticatedThemes(store, password)} + } + + const storeAuthContext = storeAuthSession ?? (await this.storeAuthSessionForTheme({store})) + if (storeAuthContext) return storeAuthContext + + return {adminSession: await ensureAuthenticatedThemes(store, password)} } - private async storeAuthSessionForTheme(flags: FlagValues): Promise { + private async storeAuthSessionForTheme(flags: FlagValues): Promise { const requiredScopes = this.storeAuthScopes() if (!requiredScopes) return undefined @@ -380,10 +429,10 @@ export default abstract class ThemeCommand extends Command { const storedSession = getCurrentStoredStoreAppSession(storeFqdn) if (!storedSession) return undefined - return this.adminSessionFromStoreAuthSession(storedSession, storeFqdn, requiredScopes) + return this.themeSessionContextFromStoreAuthSession(storedSession, storeFqdn, requiredScopes) } - private storeAuthSessionsForTheme(flagsList: FlagValues[]): Map { + private storeAuthSessionsForTheme(flagsList: FlagValues[]): Map { const requiredScopes = this.storeAuthScopes() if (!requiredScopes) return new Map() @@ -400,17 +449,17 @@ export default abstract class ThemeCommand extends Command { const storeFqdn = normalizeStoreFqdn(storedSession.store) if (!stores.has(storeFqdn)) return undefined - const session = this.adminSessionFromStoreAuthSession(storedSession, storeFqdn, requiredScopes) - return session ? ([storeFqdn, session] as const) : undefined + const sessionContext = this.themeSessionContextFromStoreAuthSession(storedSession, storeFqdn, requiredScopes) + return sessionContext ? ([storeFqdn, sessionContext] as const) : undefined }) - .filter((entry): entry is readonly [string, AdminSession] => entry !== undefined), + .filter((entry): entry is readonly [string, ThemeSessionContext] => entry !== undefined), ) } private storeAuthSessionFromCache( flags: FlagValues, - storeAuthSessionsByStore: Map, - ): AdminSession | undefined { + storeAuthSessionsByStore: Map, + ): ThemeSessionContext | undefined { const store = typeof flags.store === 'string' ? flags.store : undefined const password = flags.password if (!store || password) return undefined @@ -418,11 +467,11 @@ export default abstract class ThemeCommand extends Command { return storeAuthSessionsByStore.get(normalizeStoreFqdn(store)) } - private adminSessionFromStoreAuthSession( + private themeSessionContextFromStoreAuthSession( storedSession: StoredStoreAppSession, storeFqdn: string, requiredScopes: string[], - ): AdminSession | undefined { + ): ThemeSessionContext | undefined { if (isSessionExpired(storedSession)) { outputDebug( `Ignoring stored store auth session for ${storeFqdn}: it expired at ${storedSession.expiresAt ?? 'unknown'}.`, @@ -444,8 +493,11 @@ export default abstract class ThemeCommand extends Command { setLastSeenUserId(storedSession.userId) return { - token: storedSession.accessToken, - storeFqdn, + adminSession: { + token: storedSession.accessToken, + storeFqdn, + }, + storedStoreAppSession: storedSession, } } @@ -478,7 +530,7 @@ export default abstract class ThemeCommand extends Command { environmentFlags: FlagValues, requiredFlags: Exclude, environmentName: string, - storeAuthSession?: AdminSession, + storeAuthSession?: ThemeSessionContext, ): string[] | true { const missingFlags = requiredFlags .filter((flag) => @@ -501,7 +553,7 @@ export default abstract class ThemeCommand extends Command { return true } - private hasRequiredFlag(environmentFlags: FlagValues, flag: string, storeAuthSession?: AdminSession): boolean { + private hasRequiredFlag(environmentFlags: FlagValues, flag: string, storeAuthSession?: ThemeSessionContext): boolean { if (flag === 'password' && storeAuthSession) return true return Boolean(environmentFlags[flag]) }