diff --git a/packages/store/src/cli/services/store/execute/admin-context.test.ts b/packages/store/src/cli/services/store/execute/admin-context.test.ts index f19fafb461..5fa4241820 100644 --- a/packages/store/src/cli/services/store/execute/admin-context.test.ts +++ b/packages/store/src/cli/services/store/execute/admin-context.test.ts @@ -1,22 +1,16 @@ import {prepareAdminStoreGraphQLContext} from './admin-context.js' -import {clearStoredStoreAppSession} from '../auth/session-store.js' +import {fetchPublicApiVersions} from './admin-transport.js' import {loadStoredStoreSession} from '../auth/session-lifecycle.js' import {STORE_AUTH_APP_CLIENT_ID} from '../auth/config.js' import {AbortError} from '@shopify/cli-kit/node/error' -import {fetchApiVersions} from '@shopify/cli-kit/node/api/admin' import {beforeEach, describe, expect, test, vi} from 'vitest' -vi.mock('../auth/session-store.js') vi.mock('../auth/session-lifecycle.js', () => ({loadStoredStoreSession: vi.fn()})) -vi.mock('@shopify/cli-kit/node/api/admin', async () => { - const actual = await vi.importActual( - '@shopify/cli-kit/node/api/admin', - ) - return { - ...actual, - fetchApiVersions: vi.fn(), - } -}) +vi.mock('./admin-transport.js', () => ({ + fetchPublicApiVersions: vi.fn(), + // runAdminStoreGraphQLOperation isn't exercised here, but we re-export it for type completeness. + runAdminStoreGraphQLOperation: vi.fn(), +})) describe('prepareAdminStoreGraphQLContext', () => { const store = 'shop.myshopify.com' @@ -32,26 +26,23 @@ describe('prepareAdminStoreGraphQLContext', () => { beforeEach(() => { vi.mocked(loadStoredStoreSession).mockResolvedValue(storedSession) - vi.mocked(fetchApiVersions).mockResolvedValue([ + vi.mocked(fetchPublicApiVersions).mockResolvedValue([ {handle: '2025-10', supported: true}, {handle: '2025-07', supported: true}, {handle: 'unstable', supported: false}, - ] as any) + ]) }) test('returns the stored admin session, version, and full auth session', async () => { const result = await prepareAdminStoreGraphQLContext({store}) expect(loadStoredStoreSession).toHaveBeenCalledWith(store) - expect(fetchApiVersions).toHaveBeenCalledWith({ - token: 'token', - storeFqdn: store, + expect(fetchPublicApiVersions).toHaveBeenCalledWith({ + adminSession: {token: 'token', storeFqdn: store}, + session: storedSession, }) expect(result).toEqual({ - adminSession: { - token: 'token', - storeFqdn: store, - }, + adminSession: {token: 'token', storeFqdn: store}, version: '2025-10', session: storedSession, }) @@ -68,9 +59,9 @@ describe('prepareAdminStoreGraphQLContext', () => { const result = await prepareAdminStoreGraphQLContext({store}) - expect(fetchApiVersions).toHaveBeenCalledWith({ - token: 'fresh-token', - storeFqdn: store, + expect(fetchPublicApiVersions).toHaveBeenCalledWith({ + adminSession: {token: 'fresh-token', storeFqdn: store}, + session: refreshedSession, }) expect(result.adminSession.token).toBe('fresh-token') expect(result.session).toEqual(refreshedSession) @@ -82,31 +73,16 @@ describe('prepareAdminStoreGraphQLContext', () => { expect(result.version).toBe('2025-07') }) - test('allows unstable without validating against fetched versions', async () => { + test('allows unstable without consulting the transport, but still loads the stored session', async () => { const result = await prepareAdminStoreGraphQLContext({store, userSpecifiedVersion: 'unstable'}) - expect(result.version).toBe('unstable') - expect(fetchApiVersions).not.toHaveBeenCalled() - }) - - test('clears the current stored auth and prompts re-auth with real scopes when API version lookup gets invalid auth', async () => { - vi.mocked(fetchApiVersions).mockRejectedValue( - new AbortError(`Error connecting to your store ${store}: unauthorized 401 {}`), - ) - - await expect(prepareAdminStoreGraphQLContext({store})).rejects.toMatchObject({ - message: `Stored app authentication for ${store} is no longer valid.`, - tryMessage: 'To re-authenticate, run:', - nextSteps: [[{command: `shopify store auth --store ${store} --scopes read_products,write_orders`}]], + expect(loadStoredStoreSession).toHaveBeenCalledWith(store) + expect(result).toEqual({ + adminSession: {token: 'token', storeFqdn: store}, + version: 'unstable', + session: storedSession, }) - expect(clearStoredStoreAppSession).toHaveBeenCalledWith(store, '42') - }) - - test('rethrows unrelated API version lookup failures', async () => { - vi.mocked(fetchApiVersions).mockRejectedValue(new AbortError('upstream exploded')) - - await expect(prepareAdminStoreGraphQLContext({store})).rejects.toThrow('upstream exploded') - expect(clearStoredStoreAppSession).not.toHaveBeenCalled() + expect(fetchPublicApiVersions).not.toHaveBeenCalled() }) test('throws when the requested API version is invalid', async () => { @@ -114,4 +90,10 @@ describe('prepareAdminStoreGraphQLContext', () => { 'Invalid API version', ) }) + + 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') + }) }) diff --git a/packages/store/src/cli/services/store/execute/admin-context.ts b/packages/store/src/cli/services/store/execute/admin-context.ts index 8347db49d0..095c08520f 100644 --- a/packages/store/src/cli/services/store/execute/admin-context.ts +++ b/packages/store/src/cli/services/store/execute/admin-context.ts @@ -1,7 +1,5 @@ -import {throwReauthenticateStoreAuthError} from '../auth/recovery.js' -import {clearStoredStoreAppSession} from '../auth/session-store.js' +import {fetchPublicApiVersions} from './admin-transport.js' import {loadStoredStoreSession} from '../auth/session-lifecycle.js' -import {fetchApiVersions} from '@shopify/cli-kit/node/api/admin' import {AbortError} from '@shopify/cli-kit/node/error' import type {AdminSession} from '@shopify/cli-kit/node/session' import type {StoredStoreAppSession} from '../auth/session-store.js' @@ -21,25 +19,7 @@ async function resolveApiVersion(options: { if (userSpecifiedVersion === 'unstable') return userSpecifiedVersion - let availableVersions - try { - availableVersions = await fetchApiVersions(adminSession) - } catch (error) { - if ( - error instanceof AbortError && - error.message.includes(`Error connecting to your store ${adminSession.storeFqdn}:`) && - /\b(?:401|404)\b/.test(error.message) - ) { - clearStoredStoreAppSession(session.store, session.userId) - throwReauthenticateStoreAuthError( - `Stored app authentication for ${session.store} is no longer valid.`, - session.store, - session.scopes.join(','), - ) - } - - throw error - } + const availableVersions = await fetchPublicApiVersions({adminSession, session}) if (!userSpecifiedVersion) { const supportedVersions = availableVersions.filter((version) => version.supported).map((version) => version.handle) diff --git a/packages/store/src/cli/services/store/execute/admin-transport.test.ts b/packages/store/src/cli/services/store/execute/admin-transport.test.ts index b0bec8f5a9..50f968e811 100644 --- a/packages/store/src/cli/services/store/execute/admin-transport.test.ts +++ b/packages/store/src/cli/services/store/execute/admin-transport.test.ts @@ -1,10 +1,15 @@ import {prepareStoreExecuteRequest} from './request.js' -import {runAdminStoreGraphQLOperation} from './admin-transport.js' +import { + ABORTED_FETCH_MESSAGE_FRAGMENTS, + fetchPublicApiVersions, + runAdminStoreGraphQLOperation, +} from './admin-transport.js' import {clearStoredStoreAppSession} from '../auth/session-store.js' import {STORE_AUTH_APP_CLIENT_ID} from '../auth/config.js' import {beforeEach, describe, expect, test, vi} from 'vitest' import {adminUrl} from '@shopify/cli-kit/node/api/admin' import {graphqlRequest} from '@shopify/cli-kit/node/api/graphql' +import {AbortError, BugError} from '@shopify/cli-kit/node/error' import {renderSingleTask} from '@shopify/cli-kit/node/ui' vi.mock('../auth/session-store.js') @@ -20,6 +25,14 @@ vi.mock('@shopify/cli-kit/node/api/admin', async () => { } }) +// Structural fake of graphql-request's `ClientError` — the trap matches on shape, not on +// the imported class, so we don't pull `graphql-request` into `@shopify/store`. +function makeClientErrorLike(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 +} + describe('runAdminStoreGraphQLOperation', () => { const store = 'shop.myshopify.com' const context = { @@ -36,7 +49,9 @@ describe('runAdminStoreGraphQLOperation', () => { } beforeEach(() => { - vi.mocked(adminUrl).mockReturnValue('https://shop.myshopify.com/admin/api/2025-10/graphql.json') + // Echo the inputs back into the URL so tests can verify the right store/version were + // passed in (a constant return value would mask `adminUrl(wrongStore, wrongVersion)`). + vi.mocked(adminUrl).mockImplementation((store, version) => `https://${store}/admin/api/${version}/graphql.json`) vi.mocked(renderSingleTask).mockImplementation(async ({task}) => task(() => {})) }) @@ -47,10 +62,11 @@ describe('runAdminStoreGraphQLOperation', () => { const result = await runAdminStoreGraphQLOperation({context, request}) expect(result).toEqual({data: {shop: {name: 'Test shop'}}}) + expect(adminUrl).toHaveBeenCalledWith(store, '2025-10', context.adminSession) expect(graphqlRequest).toHaveBeenCalledWith({ query: 'query { shop { name } }', api: 'Admin', - url: 'https://shop.myshopify.com/admin/api/2025-10/graphql.json', + url: `https://${store}/admin/api/2025-10/graphql.json`, token: 'token', variables: undefined, responseOptions: {handleErrors: false}, @@ -69,6 +85,16 @@ describe('runAdminStoreGraphQLOperation', () => { expect(clearStoredStoreAppSession).toHaveBeenCalledWith(store, '42') }) + test('also clears stored auth on a 401 ClientError-shaped rejection', async () => { + vi.mocked(graphqlRequest).mockRejectedValue(makeClientErrorLike(401, 'Unauthorized')) + const request = await prepareStoreExecuteRequest({query: 'query { shop { name } }'}) + + await expect(runAdminStoreGraphQLOperation({context, request})).rejects.toMatchObject({ + message: `Stored app authentication for ${store} is no longer valid.`, + }) + expect(clearStoredStoreAppSession).toHaveBeenCalledWith(store, '42') + }) + test('throws a GraphQL operation error when errors are returned', async () => { vi.mocked(graphqlRequest).mockRejectedValue({response: {errors: [{message: 'Field does not exist'}]}}) const request = await prepareStoreExecuteRequest({query: 'query { nope }'}) @@ -76,10 +102,184 @@ describe('runAdminStoreGraphQLOperation', () => { await expect(runAdminStoreGraphQLOperation({context, request})).rejects.toThrow('GraphQL operation failed.') }) + test('maps a 402 ClientError to a store-unavailable AbortError even when the response also carries `errors`', async () => { + // Branch-ordering regression check: a 402 response that also carries GraphQL `errors` + // must surface as the store-unavailable AbortError, not the generic "GraphQL operation + // failed" branch. + vi.mocked(graphqlRequest).mockRejectedValue(makeClientErrorLike(402, 'Unavailable Shop')) + const request = await prepareStoreExecuteRequest({query: 'query { shop { name } }'}) + + let captured: AbortError | undefined + await runAdminStoreGraphQLOperation({context, request}).catch((error) => { + captured = error as AbortError + }) + + expect(captured).toBeInstanceOf(AbortError) + expect(captured?.message).toBe(`The store ${store} is currently unavailable.`) + expect(captured?.message).not.toContain('GraphQL operation failed.') + }) + test('rethrows non-GraphQL errors', async () => { vi.mocked(graphqlRequest).mockRejectedValue(new Error('boom')) const request = await prepareStoreExecuteRequest({query: 'query { shop { name } }'}) await expect(runAdminStoreGraphQLOperation({context, request})).rejects.toThrow('boom') }) + + // A user cancellation or CLI-side fetch timeout during the execute phase must surface as + // a user-facing AbortError, not be mistaken for an auth failure or wrapped as a bug. + // Driven off the production constant so adding a new abort-message fragment auto-extends + // coverage here. + test.each(ABORTED_FETCH_MESSAGE_FRAGMENTS)( + 'maps user-aborted fetches with message %j to an AbortError, not a CLI bug', + async (fragment) => { + vi.mocked(graphqlRequest).mockRejectedValue(new Error(fragment)) + const request = await prepareStoreExecuteRequest({query: 'query { shop { name } }'}) + + let captured: AbortError | undefined + await runAdminStoreGraphQLOperation({context, request}).catch((error) => { + captured = error as AbortError + }) + + expect(captured).toBeInstanceOf(AbortError) + expect(captured).not.toBeInstanceOf(BugError) + expect(captured?.message).toBe(`Request to ${store} was aborted before it completed.`) + expect(clearStoredStoreAppSession).not.toHaveBeenCalled() + }, + ) + + test('maps user-aborted fetches (name=AbortError) to an AbortError, not a CLI bug', async () => { + const abort = new Error('aborted') + abort.name = 'AbortError' + vi.mocked(graphqlRequest).mockRejectedValue(abort) + const request = await prepareStoreExecuteRequest({query: 'query { shop { name } }'}) + + let captured: AbortError | undefined + await runAdminStoreGraphQLOperation({context, request}).catch((error) => { + captured = error as AbortError + }) + + expect(captured).toBeInstanceOf(AbortError) + expect(captured).not.toBeInstanceOf(BugError) + expect(clearStoredStoreAppSession).not.toHaveBeenCalled() + }) +}) + +describe('fetchPublicApiVersions', () => { + const store = 'shop.myshopify.com' + const session = { + store, + clientId: STORE_AUTH_APP_CLIENT_ID, + userId: '42', + accessToken: 'token', + refreshToken: 'refresh-token', + scopes: ['read_products', 'write_orders'], + acquiredAt: '2026-03-27T00:00:00.000Z', + } + const adminSession = {token: 'token', storeFqdn: store} + + beforeEach(() => { + vi.mocked(adminUrl).mockImplementation((s, version) => `https://${s}/admin/api/${version}/graphql.json`) + }) + + test('issues the publicApiVersions query against the unstable Admin endpoint', async () => { + vi.mocked(graphqlRequest).mockResolvedValue({ + publicApiVersions: [ + {handle: '2025-10', supported: true}, + {handle: '2025-07', supported: true}, + ], + }) + + const result = await fetchPublicApiVersions({adminSession, session}) + + expect(result).toEqual([ + {handle: '2025-10', supported: true}, + {handle: '2025-07', supported: true}, + ]) + expect(adminUrl).toHaveBeenCalledWith(store, 'unstable', adminSession) + expect(graphqlRequest).toHaveBeenCalledWith( + expect.objectContaining({ + api: 'Admin', + token: 'token', + url: `https://${store}/admin/api/unstable/graphql.json`, + responseOptions: {handleErrors: false}, + }), + ) + }) + + test('clears stored auth and prompts re-auth when the version request returns 401', async () => { + vi.mocked(graphqlRequest).mockRejectedValue(makeClientErrorLike(401, 'Unauthorized')) + + await expect(fetchPublicApiVersions({adminSession, session})).rejects.toMatchObject({ + message: `Stored app authentication for ${store} is no longer valid.`, + tryMessage: 'To re-authenticate, run:', + nextSteps: [[{command: `shopify store auth --store ${store} --scopes read_products,write_orders`}]], + }) + expect(clearStoredStoreAppSession).toHaveBeenCalledWith(store, '42') + }) + + test('also handles 404 as a stored-auth-no-longer-valid signal', async () => { + vi.mocked(graphqlRequest).mockRejectedValue(makeClientErrorLike(404, 'Not Found')) + + await expect(fetchPublicApiVersions({adminSession, session})).rejects.toMatchObject({ + message: `Stored app authentication for ${store} is no longer valid.`, + }) + expect(clearStoredStoreAppSession).toHaveBeenCalledWith(store, '42') + }) + + test('maps 402 Unavailable Shop to an AbortError without clearing stored auth', async () => { + vi.mocked(graphqlRequest).mockRejectedValue(makeClientErrorLike(402, 'Unavailable Shop')) + + let captured: AbortError | undefined + await fetchPublicApiVersions({adminSession, session}).catch((error) => { + captured = error as AbortError + }) + + expect(captured).toBeInstanceOf(AbortError) + expect(captured).not.toBeInstanceOf(BugError) + expect(captured?.message).toBe(`The store ${store} is currently unavailable.`) + expect(String((captured as unknown as {tryMessage?: string})?.tryMessage ?? '')).toContain( + 'Check the store in the Shopify admin', + ) + expect(clearStoredStoreAppSession).not.toHaveBeenCalled() + }) + + test.each(ABORTED_FETCH_MESSAGE_FRAGMENTS)( + 'maps user-aborted fetches with message %j to an AbortError without clearing stored auth', + async (fragment) => { + vi.mocked(graphqlRequest).mockRejectedValue(new Error(fragment)) + + let captured: AbortError | undefined + await fetchPublicApiVersions({adminSession, session}).catch((error) => { + captured = error as AbortError + }) + + expect(captured).toBeInstanceOf(AbortError) + expect(captured).not.toBeInstanceOf(BugError) + expect(captured?.message).toBe(`Request to ${store} was aborted before it completed.`) + expect(clearStoredStoreAppSession).not.toHaveBeenCalled() + }, + ) + + test('maps user-aborted fetches (name=AbortError) to an AbortError without clearing stored auth', async () => { + const abort = new Error('aborted') + abort.name = 'AbortError' + vi.mocked(graphqlRequest).mockRejectedValue(abort) + + let captured: AbortError | undefined + await fetchPublicApiVersions({adminSession, session}).catch((error) => { + captured = error as AbortError + }) + + expect(captured).toBeInstanceOf(AbortError) + expect(captured).not.toBeInstanceOf(BugError) + expect(clearStoredStoreAppSession).not.toHaveBeenCalled() + }) + + test('rethrows unrelated errors', async () => { + vi.mocked(graphqlRequest).mockRejectedValue(new Error('upstream exploded')) + + await expect(fetchPublicApiVersions({adminSession, session})).rejects.toThrow('upstream exploded') + expect(clearStoredStoreAppSession).not.toHaveBeenCalled() + }) }) diff --git a/packages/store/src/cli/services/store/execute/admin-transport.ts b/packages/store/src/cli/services/store/execute/admin-transport.ts index d780fc1f17..70347e5135 100644 --- a/packages/store/src/cli/services/store/execute/admin-transport.ts +++ b/packages/store/src/cli/services/store/execute/admin-transport.ts @@ -5,13 +5,59 @@ import {graphqlRequest} from '@shopify/cli-kit/node/api/graphql' import {AbortError} from '@shopify/cli-kit/node/error' import {outputContent} from '@shopify/cli-kit/node/output' import {renderSingleTask} from '@shopify/cli-kit/node/ui' +import type {AdminSession} from '@shopify/cli-kit/node/session' import type {PreparedStoreExecuteRequest} from './request.js' import type {AdminStoreGraphQLContext} from './admin-context.js' +import type {StoredStoreAppSession} from '../auth/session-store.js' -function isGraphQLClientError(error: unknown): error is {response: {errors?: unknown; status?: number}} { - if (!error || typeof error !== 'object' || !('response' in error)) return false - const response = (error as {response?: unknown}).response - return Boolean(response) && typeof response === 'object' +interface ApiVersion { + handle: string + supported: boolean +} + +const PUBLIC_API_VERSIONS_QUERY = ` + query StoreExecutePublicApiVersions { + publicApiVersions { + handle + supported + } + } +` + +/** + * Runs the version-discovery GraphQL query against the Admin API. Errors are classified + * the same way as the execute-phase request: 401/404 trigger a stored-auth re-auth flow, + * 402 / fetch-aborts surface as user-facing `AbortError`s. + */ +export async function fetchPublicApiVersions(input: { + adminSession: AdminSession + session: StoredStoreAppSession +}): Promise { + try { + const response = await graphqlRequest<{publicApiVersions: ApiVersion[]}>({ + query: PUBLIC_API_VERSIONS_QUERY, + api: 'Admin', + url: adminUrl(input.adminSession.storeFqdn, 'unstable', input.adminSession), + token: input.adminSession.token, + responseOptions: {handleErrors: false}, + }) + return response.publicApiVersions + } catch (error) { + const status = graphQLClientErrorStatus(error) + if (status === 401 || status === 404) { + clearStoredStoreAppSession(input.session.store, input.session.userId) + throwReauthenticateStoreAuthError( + `Stored app authentication for ${input.session.store} is no longer valid.`, + input.session.store, + input.session.scopes.join(','), + ) + } + + const classified = classifyAdminApiError(error, input.adminSession.storeFqdn) + if (classified) throw classified + + throw error + } } export async function runAdminStoreGraphQLOperation(input: { @@ -34,7 +80,7 @@ export async function runAdminStoreGraphQLOperation(input: { renderOptions: {stdout: process.stderr}, }) } catch (error) { - if (isGraphQLClientError(error) && error.response.status === 401) { + if (isGraphQLClientErrorLike(error) && error.response.status === 401) { clearStoredStoreAppSession(input.context.session.store, input.context.session.userId) throwReauthenticateStoreAuthError( `Stored app authentication for ${input.context.session.store} is no longer valid.`, @@ -43,10 +89,79 @@ export async function runAdminStoreGraphQLOperation(input: { ) } - if (isGraphQLClientError(error) && error.response.errors) { + // Status-specific classification (e.g. 402 store-unavailable) must run before the + // generic GraphQL-errors branch, otherwise a 402 response that also carries + // `errors: [...]` would be misreported as "GraphQL operation failed". + const classified = classifyAdminApiError(error, input.context.adminSession.storeFqdn) + if (classified) throw classified + + if (isGraphQLClientErrorLike(error) && error.response.errors) { throw new AbortError('GraphQL operation failed.', JSON.stringify({errors: error.response.errors}, null, 2)) } throw error } } + +// ---------- error classification ---------- +// +// Both Admin GraphQL calls above see the same raw error shapes from `graphqlRequest`: +// +// - graphql-request `ClientError` (matched structurally on `{response: {status}}` so +// `@shopify/store` doesn't need to depend on `graphql-request` at runtime), +// - fetch-aborted `Error`s (semantic `name === 'AbortError'`, with a message-string +// fallback for older transports). +// +// `classifyAdminApiError` covers the shapes both phases agree are user-facing rather +// than CLI bugs. Phase-specific recovery (clearing stored auth on 401/404 etc.) stays +// at the call site. + +interface GraphQLClientErrorLike { + response: {status?: number; errors?: unknown} +} + +function isGraphQLClientErrorLike(error: unknown): error is GraphQLClientErrorLike { + if (!error || typeof error !== 'object' || !('response' in error)) return false + const response = (error as {response?: unknown}).response + return Boolean(response) && typeof response === 'object' +} + +function graphQLClientErrorStatus(error: unknown): number | undefined { + if (!isGraphQLClientErrorLike(error)) return undefined + const status = error.response.status + return typeof status === 'number' ? status : undefined +} + +// Lower-cased substrings that Node's fetch/undici implementations use to signal an +// aborted request. cli-kit's lower-level transport (`isTransientNetworkError` in +// packages/cli-kit/src/private/node/api.ts) already recognizes 'the operation was +// aborted'; we cover that shape and add 'the user aborted a request' (the literal +// node-fetch surfaces) so a fetch that bubbles past cli-kit's retry layer is still +// classified correctly. Exported so tests reference the same source of truth as +// production. +export const ABORTED_FETCH_MESSAGE_FRAGMENTS = ['the user aborted a request', 'the operation was aborted'] as const + +function isUserAbortedFetchError(error: unknown): boolean { + if (!(error instanceof Error)) return false + if (error.name === 'AbortError') return true + const message = error.message.toLowerCase() + return ABORTED_FETCH_MESSAGE_FRAGMENTS.some((fragment) => message.includes(fragment)) +} + +function classifyAdminApiError(error: unknown, storeFqdn: string): AbortError | undefined { + // 402 Payment Required: the shop is frozen / on hold / otherwise unavailable. Store-state + // issue, not a CLI bug. + if (graphQLClientErrorStatus(error) === 402) { + return new AbortError( + `The store ${storeFqdn} is currently unavailable.`, + 'Check the store in the Shopify admin and try again once it is reactivated.', + ) + } + + // User-aborted fetches (Ctrl-C, CLI-side fetch timeouts) are user-driven, not CLI bugs. + if (isUserAbortedFetchError(error)) { + return new AbortError(`Request to ${storeFqdn} was aborted before it completed.`) + } + + return undefined +}