Skip to content

Recover on theme commands after a preview store has been claimed - #8190

Draft
amcaplan wants to merge 3 commits into
mainfrom
theme-commands-after-claim
Draft

Recover on theme commands after a preview store has been claimed#8190
amcaplan wants to merge 3 commits into
mainfrom
theme-commands-after-claim

Conversation

@amcaplan

Copy link
Copy Markdown
Contributor

WHY are these changes introduced?

PR #7986 made shopify store info and store execute fail actionably when run against a preview store that has since been claimed through the browser claim flow — the stored session stays kind: 'preview' forever, the CLI has no local signal of the claim, and the stale Admin token starts returning 401. Theme commands never got that treatment: they reported an opaque failure, or worse, "Development theme #X could not be found" for what was actually an authentication problem.

WHAT is this pull request doing?

Ports the same recovery to theme commands, and fixes two things that stopped the recovery from working at all.

  • The advice Recover on store info after a preview store has been claimed #7986 prints could never be followed. throwIfPreviewStore aborts store auth for any store that still has a stored preview session, and the recovery deliberately retained that session — so the command every one of these errors points at refused to run, with no reset flag to escape. store info was the last path still broken, because its preview branch returns before any Admin call and never reached the shared helper. Invalid preview sessions are now cleared, which is what lets the suggested command start. This reverses a deliberate choice from Recover on store info after a preview store has been claimed #7986; the trade-off is that preview credentials are write-once, so clearing is irreversible — but a preview token never expires, so a 401 on one means it stopped being accepted outright and nothing usable is discarded.
  • Theme commands can't safely condemn a standard session. store reaches a 401 only after loadStoredStoreSession has already refreshed an expired token, so there the status is definitive. Theme uses the stored token as-is, so an ordinary expired-but-refreshable session 401s for a reason re-authenticating wouldn't fix — and clearing it would destroy a usable refresh token. Theme therefore classifies preview sessions only and never clears.

Also included: the Admin transport stops destroying the HTTP status (AdminApiRequestError), fetchTheme stops reporting a 401 as a missing theme, and multi-environment failures stop dropping the nextSteps that make these errors actionable.

Known follow-up (not in this PR): theme commands still read stored tokens with no expiry check or refresh, unlike store's loadStoredStoreSession. That gap is why theme classifies preview-only. Exposing a refresh-aware lifecycle at the cli-kit layer would let theme handle standard sessions properly too.

How to test your changes?

packages/store/src/cli/services/store/auth/preview-claim-recovery.test.ts is the key one — it composes both halves against real LocalStorage in a temp directory: store auth refuses to start, the 401 is classified and the session cleared, then store auth gets past the guard.

Full suite green: 266 files / 2764 passed / 2 skipped, plus type-check, lint and knip.

Post-release steps

None.

Checklist

  • I've considered possible cross-platform impacts (Mac, Linux, Windows)
  • I've considered possible documentation changes
  • I've considered analytics changes to measure impact
  • The change is user-facing — I've identified the correct bump type (patch for bug fixes · minor for new features · major for breaking changes) and added a changeset with pnpm changeset add

🤖 Generated with Claude Code

amcaplan and others added 3 commits July 28, 2026 21:26
`fetchApiVersions` wrapped every failed version-discovery request in a
plain `AbortError`, destroying the HTTP status, and `fetchTheme` mapped
any GraphQL failure to `undefined` - which callers read as "this theme is
gone". A 401 says nothing about whether a theme exists, so
`ThemeManager.fetch` would delete the cached development theme id and
report "Development theme #X could not be found" for what was really an
authentication failure.

Add `AdminApiRequestError`, which carries the status alongside the
existing message, and narrow `fetchTheme` so an unauthorized request
propagates instead of being flattened.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The recovery helpers lived in `packages/store`, which `packages/theme`
cannot import. Move them into a new public cli-kit module so both
packages share one source of truth for the message a rejected stored
session produces.

Clearing behaviour is inverted on the way: an invalid preview session is
now cleared rather than retained. The retained record was what made the
recovery unreachable - `throwIfPreviewStore` refuses to start
`store auth` for any store that still has a stored preview session, so
the `store auth` next step these errors print could never run, and there
is no reset flag to escape it. `store info` reaches the same conclusion
through its own preview branch, which returns before any Admin call and
never touched the shared helper, so it gets the same rule.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Theme commands flattened a stored `store auth` session to
`{token, storeFqdn}` before running, so a rejected request had no way to
tell that the token came from a preview store whose claim the CLI has no
local signal for. Thread the stored session through as
`ThemeSessionContext` and classify at both the single- and
multi-environment boundaries.

Only preview sessions are classified, and only on a 401. Theme commands
use the stored access token as-is, with no expiry check and no refresh,
so an ordinary expired-but-refreshable standard session would 401 here
for a reason re-authenticating wouldn't fix. A 404 is left alone because
theme commands accept `--theme <id>`, where a genuine not-found must not
be reported as a claimed store.

Multi-environment failures previously prefixed the environment onto
`error.message`, which silently dropped a `FatalError`'s `tryMessage`,
`nextSteps` and custom sections - the parts that make the new error
actionable. Render the environment as a headline and forward the rest.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@github-actions github-actions Bot added the Area: @shopify/cli @shopify/cli package issues label Jul 28, 2026
@github-actions

Copy link
Copy Markdown
Contributor

Differences in type declarations

We detected differences in the type declarations generated by Typescript for this branch compared to the baseline ('main' branch). Please, review them to ensure they are backward-compatible. Here are some important things to keep in mind:

  • Some seemingly private modules might be re-exported through public modules.
  • If the branch is behind main you might see odd diffs, rebase main into this branch.

New type declarations

packages/cli-kit/dist/public/node/store-auth-recovery.d.ts
import type { LocalStorage } from './local-storage.js';
import type { StoreAuthSessionSchema, StoredStoreAppSession } from './store-auth-session.js';
/**
 * Throw an actionable error reporting that no store-app authentication is stored for a store.
 *
 * @param store - The store FQDN the caller tried to use.
 * @throws AbortError pointing at `shopify store auth`.
 */
export declare function throwMissingStoredStoreAuthError(store: string): never;
/**
 * Throw a caller-supplied message alongside the next step for re-authenticating a stored session.
 *
 * @param message - The message describing why the stored session can no longer be used.
 * @param session - The stored store auth session that needs re-authenticating.
 * @throws AbortError pointing at `shopify store auth`.
 */
export declare function throwReauthenticateStoreAuthError(message: string, session: StoredStoreAppSession): never;
/**
 * Throw an actionable error reporting that a stored store-app session is no longer valid.
 *
 * A preview store's local session has no way to know it was claimed through the browser claim
 * flow; a rejected request 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.
 *
 * @param session - The stored store auth session that is no longer valid.
 * @throws AbortError pointing at `shopify store auth`.
 */
export declare function throwStoredStoreAuthInvalidError(session: StoredStoreAppSession): never;
interface InvalidStoredStoreAuthOptions {
    /** HTTP statuses that mean the stored session is invalid. Defaults to 401 and 404. */
    invalidStatuses?: ReadonlyArray<number>;
    /** Storage override for tests. */
    storage?: LocalStorage<StoreAuthSessionSchema>;
}
/**
 * Convert a failed request into an actionable re-authentication error when its HTTP status means
 * the stored store-app session behind it stopped being accepted.
 *
 * The status is read from either shape a rejected Admin API request arrives in: carried directly on
 * the error (cli-kit's typed transport errors) or nested under `response` (graphql-request's
 * `ClientError`). When it matches, the stored session is cleared from local storage and an
 * `AbortError` pointing at `shopify store auth` is thrown. An error carrying any other status - or
 * no status at all - is left untouched for the caller to classify.
 *
 * @param error - The error thrown by the request made with the stored session.
 * @param session - The stored store auth session the request was made with.
 * @param options - Statuses to classify as invalid, and a storage override for tests.
 * @throws AbortError pointing at `shopify store auth` when the stored session is invalid.
 */
export declare function throwIfStoredStoreAuthIsInvalid(error: unknown, session: StoredStoreAppSession, options?: InvalidStoredStoreAuthOptions): void;
export {};

Existing type declarations

packages/cli-kit/dist/public/node/store-auth-session.d.ts
@@ -55,7 +55,11 @@ interface StoredStoreAppSessionBucket {
         [userId: string]: StoredStoreAppSession;
     };
 }
-interface StoreAuthSessionSchema {
+/**
+ * Local-storage layout for store-auth sessions, keyed by {@link storeAuthSessionKey}. Exported so
+ * that sibling modules taking a storage override can type it the same way this module does.
+ */
+export interface StoreAuthSessionSchema {
     [key: string]: StoredStoreAppSessionBucket;
 }
 /**
packages/cli-kit/dist/public/node/api/admin.d.ts
@@ -1,8 +1,22 @@
 import { GraphQLResponseOptions, GraphQLVariables } from './graphql.js';
 import { AdminSession } from '../session.js';
+import { AbortError } from '../error.js';
 import { RequestModeInput } from '../http.js';
 import { Variables } from 'graphql-request';
 import { TypedDocumentNode } from '@graphql-typed-document-node/core';
+/**
+ * Thrown when the Admin API rejects a request with a status a caller may need to act on. It carries
+ * the HTTP status so callers can classify the failure - for instance a stored `shopify store auth`
+ * session that stopped being accepted once its preview store was claimed - rather than parsing the
+ * rendered message. It renders exactly like any other abort error.
+ */
+export declare class AdminApiRequestError extends AbortError {
+    /**
+     * HTTP status the Admin API responded with.
+     */
+    readonly status: number;
+    constructor(status: number, message: string);
+}
 /**
  * Executes a GraphQL query against the Admin API.
  *

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Area: @shopify/cli @shopify/cli package issues

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant