From e70a5256a51cd8db5600e8e5f6d7c3ddbbe0e70c Mon Sep 17 00:00:00 2001 From: Ariel Caplan Date: Tue, 9 Jun 2026 21:23:14 +0300 Subject: [PATCH 1/4] Add the store info service layer Introduce the data-access and composition layer behind `shopify store info`: the Business Platform destinations lookup (with owning-org resolution), the Organizations shop fetch, plan-handle mapping, shared types, and the getStoreInfo orchestrator. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../cli/services/store/info/destinations.ts | 107 +++++++++++++++++ .../src/cli/services/store/info/index.ts | 108 ++++++++++++++++++ .../services/store/info/organization-shop.ts | 57 +++++++++ .../store/src/cli/services/store/info/plan.ts | 24 ++++ .../src/cli/services/store/info/types.ts | 49 ++++++++ 5 files changed, 345 insertions(+) create mode 100644 packages/store/src/cli/services/store/info/destinations.ts create mode 100644 packages/store/src/cli/services/store/info/index.ts create mode 100644 packages/store/src/cli/services/store/info/organization-shop.ts create mode 100644 packages/store/src/cli/services/store/info/plan.ts create mode 100644 packages/store/src/cli/services/store/info/types.ts diff --git a/packages/store/src/cli/services/store/info/destinations.ts b/packages/store/src/cli/services/store/info/destinations.ts new file mode 100644 index 0000000000..baecfe820a --- /dev/null +++ b/packages/store/src/cli/services/store/info/destinations.ts @@ -0,0 +1,107 @@ +import {StoreInfoDestinations} from '../../../api/graphql/business-platform-destinations/generated/store-info-destinations.js' +import {StoreInfoOwningOrg} from '../../../api/graphql/business-platform-destinations/generated/store-info-owning-org.js' +import {AbortError} from '@shopify/cli-kit/node/error' +import {businessPlatformRequestDoc} from '@shopify/cli-kit/node/api/business-platform' +import {ensureAuthenticatedBusinessPlatform} from '@shopify/cli-kit/node/session' +import {numericIdFromEncodedGid} from '@shopify/cli-kit/common/gid' +import {extractHost} from '@shopify/cli-kit/common/url' +import {outputDebug} from '@shopify/cli-kit/node/output' +import type { + StoreInfoDestinationsQuery, + StoreInfoDestinationsQueryVariables, +} from '../../../api/graphql/business-platform-destinations/generated/store-info-destinations.js' +import type { + StoreInfoOwningOrgQuery, + StoreInfoOwningOrgQueryVariables, +} from '../../../api/graphql/business-platform-destinations/generated/store-info-owning-org.js' +import type {DestinationsContext, OwningOrgInternal} from './types.js' + +type DestinationNodeFromQuery = NonNullable< + StoreInfoDestinationsQuery['currentUserAccount'] +>['destinations']['nodes'][number] + +interface FetchDestinationsContextOptions { + store: string + token?: string +} + +export async function fetchDestinationsContext(options: FetchDestinationsContextOptions): Promise { + const token = options.token ?? (await ensureAuthenticatedBusinessPlatform()) + const unauthorizedHandler = { + type: 'token_refresh' as const, + handler: async () => { + const newToken = await ensureAuthenticatedBusinessPlatform() + return {token: newToken} + }, + } + + // `options.store` is already a normalized FQDN; extractHost canonicalizes it (lowercased, + // scheme/path stripped) so it lines up with the hosts BP returns. + const targetHost = extractHost(options.store) ?? options.store.toLowerCase() + + // BP's destinations.search matches against handle/name, so search by the store's subdomain + // handle (the first DNS label) rather than the full FQDN to widen the hit rate. Taking the + // first label works across environments (myshopify.com, shopify.io, *.shop.dev); a fixed + // `.myshopify.com` strip would leave local-dev FQDNs untouched. + const subdomain = targetHost.split('.')[0] ?? targetHost + + const response = await businessPlatformRequestDoc({ + query: StoreInfoDestinations, + token, + variables: {search: subdomain}, + unauthorizedHandler, + }) + + const nodes = response.currentUserAccount?.destinations.nodes ?? [] + const matchedNode = nodes.find((node) => matchesStore(node, targetHost)) + + if (!matchedNode) { + throw new AbortError( + `Couldn't find a store with domain ${options.store} for the current account.`, + 'Verify the domain (must be the canonical `myshopify.com` FQDN) and that you are signed in to an account with access to the store. Inactive shops are not searchable.', + ) + } + + const owningOrg = await fetchOwningOrg(String(matchedNode.publicId), token, unauthorizedHandler) + + return {...(owningOrg ? {owningOrg} : {})} +} + +async function fetchOwningOrg( + destinationPublicId: string, + token: string, + unauthorizedHandler: {type: 'token_refresh'; handler: () => Promise<{token: string}>}, +): Promise { + try { + const orgResponse = await businessPlatformRequestDoc({ + query: StoreInfoOwningOrg, + token, + variables: {destinationPublicId}, + unauthorizedHandler, + }) + const org = orgResponse.currentUserAccount?.organizationForDestination + if (!org) { + outputDebug(`No owning organization returned for destination ${destinationPublicId}.`) + return undefined + } + const decodedId = org.id ? numericIdFromEncodedGid(org.id) : undefined + return {name: org.name, ...(decodedId ? {id: decodedId} : {})} + // eslint-disable-next-line no-catch-all/no-catch-all + } catch (error) { + outputDebug(`Failed to resolve owning organization: ${errorMessage(error)}`) + return undefined + } +} + +function matchesStore(node: DestinationNodeFromQuery, targetHost: string): boolean { + // BP returns URL strings (sometimes with scheme, sometimes bare) in primaryDomain/webUrl; + // extract the hostname and compare against the already-canonicalized target host, so both + // sides are in the same form regardless of suffix. We match on these rather than handle/name + // because those are unreliable (often null or an abbreviation rather than the subdomain). + return [node.primaryDomain, node.webUrl].some((value) => extractHost(value) === targetHost) +} + +function errorMessage(error: unknown): string { + if (error instanceof Error) return error.message + return String(error) +} diff --git a/packages/store/src/cli/services/store/info/index.ts b/packages/store/src/cli/services/store/info/index.ts new file mode 100644 index 0000000000..9514bf62b2 --- /dev/null +++ b/packages/store/src/cli/services/store/info/index.ts @@ -0,0 +1,108 @@ +import {fetchDestinationsContext} from './destinations.js' +import {fetchOrganizationShop} from './organization-shop.js' +import {mapPlanToPublicHandle} from './plan.js' +import {AbortError} from '@shopify/cli-kit/node/error' +import {compact} from '@shopify/cli-kit/common/object' +import {extractMyshopifyHandle} from '@shopify/cli-kit/common/url' +import {outputDebug} from '@shopify/cli-kit/node/output' +import type {Store} from '../../../api/graphql/business-platform-organizations/generated/types.js' +import type {DestinationsContext, OrganizationShopFields, StoreInfoResult, StoreInfoStoreOwner} from './types.js' + +interface GetStoreInfoOptions { + store?: string +} + +export async function getStoreInfo(options: GetStoreInfoOptions): Promise { + const store = options.store + if (!store) { + throw new AbortError( + 'No store specified.', + 'Pass the `myshopify.com` domain via the `--store` flag, e.g. `shopify store info --store shop.myshopify.com`.', + ) + } + + const destinationsCtx = await fetchDestinationsContext({store}) + const orgShop = await safeFetchOrganizationShop(destinationsCtx, store) + + return buildResult({store, destinationsCtx, orgShop}) +} + +async function safeFetchOrganizationShop( + ctx: DestinationsContext, + store: string, +): Promise { + if (!ctx.owningOrg?.id) { + // Without an org id we can't address the BP Organizations API, so the shop-level fields + // (id, owner, type, feature preview) are unavailable. The destination already gives us a + // usable baseline (display name, admin URL). + outputDebug('Owning organization id is unknown; skipping BP Organizations shop lookup.') + return undefined + } + try { + return await fetchOrganizationShop({store, organizationId: ctx.owningOrg.id}) + // eslint-disable-next-line no-catch-all/no-catch-all + } catch (error) { + outputDebug(`BP Organizations shop lookup failed: ${error instanceof Error ? error.message : String(error)}`) + return undefined + } +} + +interface BuildResultArgs { + store: string + destinationsCtx: DestinationsContext + orgShop: OrganizationShopFields | undefined +} + +function buildResult(args: BuildResultArgs): StoreInfoResult { + const {store, destinationsCtx, orgShop} = args + + const fields: Partial = { + id: buildShopGid(orgShop?.shopifyShopId), + displayName: orgShop?.name, + organizationId: destinationsCtx.owningOrg?.id, + organizationName: destinationsCtx.owningOrg?.name, + storeOwner: buildStoreOwner(orgShop), + type: mapStoreType(orgShop?.storeType), + plan: mapPlanToPublicHandle(orgShop?.planName), + featurePreview: orgShop?.developerPreviewHandle, + adminUrl: buildAdminUrl(extractMyshopifyHandle(store)), + } + + return {...compact(fields), subdomain: store} as StoreInfoResult +} + +// The BP `ShopifyShopID` scalar is the bare numeric id; the admin GID is derived locally. +function buildShopGid(shopifyShopId: string | undefined): string | undefined { + if (!shopifyShopId) return undefined + return `gid://shopify/Shop/${shopifyShopId}` +} + +function buildStoreOwner(orgShop: OrganizationShopFields | undefined): StoreInfoStoreOwner | undefined { + if (!orgShop) return undefined + const owner = compact({name: orgShop.ownerName, email: orgShop.ownerEmail}) as StoreInfoStoreOwner + return Object.keys(owner).length > 0 ? owner : undefined +} + +function buildAdminUrl(handle: string | undefined): string | undefined { + if (!handle) return undefined + return `https://admin.shopify.com/store/${encodeURIComponent(handle)}` +} + +// The public store-type handle surfaced as `type` for every member of the BP `Store` enum. +// Declared as a fully-keyed record so adding a value to the enum fails type-checking here until +// it's given an explicit handle, rather than silently falling back to a lowercased raw value. +const storeTypeHandles: {[key in Store]: string} = { + APP_DEVELOPMENT: 'dev', + CLIENT_TRANSFER: 'client_transfer', + COLLABORATOR: 'collaborator', + DEVELOPMENT: 'dev', + DEVELOPMENT_SUPERSET: 'dev', + PRODUCTION: 'production', +} + +// Returns undefined for an unrecognized value (e.g. a newer enum member than the generated types +// know about) so the field is omitted rather than shown as a guessed handle. +function mapStoreType(storeType: Store | undefined): string | undefined { + if (!storeType) return undefined + return storeTypeHandles[storeType] +} diff --git a/packages/store/src/cli/services/store/info/organization-shop.ts b/packages/store/src/cli/services/store/info/organization-shop.ts new file mode 100644 index 0000000000..3d35dc646b --- /dev/null +++ b/packages/store/src/cli/services/store/info/organization-shop.ts @@ -0,0 +1,57 @@ +import {StoreInfoShop} from '../../../api/graphql/business-platform-organizations/generated/store-info-shop.js' +import {BugError} from '@shopify/cli-kit/node/error' +import {businessPlatformOrganizationsRequestDoc} from '@shopify/cli-kit/node/api/business-platform' +import {ensureAuthenticatedBusinessPlatform} from '@shopify/cli-kit/node/session' +import {extractHost} from '@shopify/cli-kit/common/url' +import type { + StoreInfoShopQuery, + StoreInfoShopQueryVariables, +} from '../../../api/graphql/business-platform-organizations/generated/store-info-shop.js' +import type {OrganizationShopFields} from './types.js' + +interface FetchOrganizationShopOptions { + store: string + organizationId: string + token?: string +} + +export async function fetchOrganizationShop(options: FetchOrganizationShopOptions): Promise { + const token = options.token ?? (await ensureAuthenticatedBusinessPlatform()) + const unauthorizedHandler = { + type: 'token_refresh' as const, + handler: async () => { + const newToken = await ensureAuthenticatedBusinessPlatform() + return {token: newToken} + }, + } + + const response = await businessPlatformOrganizationsRequestDoc({ + query: StoreInfoShop, + token, + organizationId: options.organizationId, + variables: {search: options.store}, + unauthorizedHandler, + }) + + const edges = response.organization?.accessibleShops?.edges ?? [] + const lowerStore = options.store.toLowerCase() + const matched = edges.map((edge) => edge.node).find((node) => extractHost(node.primaryDomain) === lowerStore) + + if (!matched) { + throw new BugError( + `Couldn't find shop ${options.store} inside organization ${options.organizationId}.`, + 'The shop matched a global lookup but is not listed under its parent organization. This usually means the search index is stale; try again in a moment.', + ) + } + + return { + shopifyShopId: matched.shopifyShopId ?? undefined, + name: matched.name, + primaryDomain: matched.primaryDomain ?? undefined, + storeType: matched.storeType ?? undefined, + developerPreviewHandle: matched.developerPreviewHandle ?? undefined, + planName: matched.planName ?? undefined, + ownerName: matched.ownerDetails?.fullName ?? undefined, + ownerEmail: matched.ownerDetails?.email ?? undefined, + } +} diff --git a/packages/store/src/cli/services/store/info/plan.ts b/packages/store/src/cli/services/store/info/plan.ts new file mode 100644 index 0000000000..d9d3727c4f --- /dev/null +++ b/packages/store/src/cli/services/store/info/plan.ts @@ -0,0 +1,24 @@ +/** + * Maps a raw BP plan name (`Shop.planName`) to the public plan handle surfaced by `store info`. + * + * The raw names BP returns are Shopify-internal and intentionally differ from the marketing + * names (e.g. `professional` is Grow, `unlimited` is Advanced). The mapping is assumed stable + * and 1:1, so it's hardcoded here rather than fetched. Both the internal name and the public + * handle are accepted as keys because the exact form BP returns isn't pinned down by the schema. + * + * Anything not in this table is treated as unrecognized and omitted from the output. + */ +const PLAN_HANDLES: {[planName: string]: string} = { + basic: 'basic', + professional: 'grow', + grow: 'grow', + unlimited: 'advanced', + advanced: 'advanced', + shopify_plus: 'plus', + plus: 'plus', +} + +export function mapPlanToPublicHandle(planName: string | undefined): string | undefined { + if (!planName) return undefined + return PLAN_HANDLES[planName.toLowerCase()] +} diff --git a/packages/store/src/cli/services/store/info/types.ts b/packages/store/src/cli/services/store/info/types.ts new file mode 100644 index 0000000000..bc0fa77451 --- /dev/null +++ b/packages/store/src/cli/services/store/info/types.ts @@ -0,0 +1,49 @@ +import type {Store} from '../../../api/graphql/business-platform-organizations/generated/types.js' + +export interface StoreInfoStoreOwner { + name?: string + email?: string +} + +/** + * Internal-only org reference used to drive the BP Organizations request and to + * populate `organizationId` / `organizationName`. + */ +export interface OwningOrgInternal { + name: string + id?: string +} + +export interface StoreInfoResult { + id?: string + displayName?: string + subdomain: string + organizationId?: string + organizationName?: string + storeOwner?: StoreInfoStoreOwner + type?: string + // Public plan handle (basic | grow | advanced | plus), mapped from the raw BP plan name. + // Unrecognized plans are omitted. See `plan.ts`. + plan?: string + featurePreview?: string + adminUrl?: string +} + +/** + * Result of the BP Destinations lookup. The destination itself carries no fields we surface; + * its only job is to prove the store exists/is accessible and to resolve the owning org. + */ +export interface DestinationsContext { + owningOrg?: OwningOrgInternal +} + +export interface OrganizationShopFields { + shopifyShopId?: string + name?: string + primaryDomain?: string + storeType?: Store + developerPreviewHandle?: string + planName?: string + ownerName?: string + ownerEmail?: string +} From eca96d86354bf8ffa82cdd4c4084481d976691c5 Mon Sep 17 00:00:00 2001 From: Ariel Caplan Date: Tue, 9 Jun 2026 21:23:15 +0300 Subject: [PATCH 2/4] Add the `shopify store info` command and result rendering Wire up the StoreInfo command on the shared `--store` flag and render the composed result as human-readable text or `--json`. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/store/src/cli/commands/store/info.ts | 34 ++++++++++++++++ .../src/cli/services/store/info/result.ts | 40 +++++++++++++++++++ packages/store/src/index.ts | 2 + 3 files changed, 76 insertions(+) create mode 100644 packages/store/src/cli/commands/store/info.ts create mode 100644 packages/store/src/cli/services/store/info/result.ts diff --git a/packages/store/src/cli/commands/store/info.ts b/packages/store/src/cli/commands/store/info.ts new file mode 100644 index 0000000000..00957423f8 --- /dev/null +++ b/packages/store/src/cli/commands/store/info.ts @@ -0,0 +1,34 @@ +import {getStoreInfo} from '../../services/store/info/index.js' +import {renderStoreInfoResult} from '../../services/store/info/result.js' +import StoreCommand from '../../utilities/store-command.js' +import {storeFlags} from '../../flags.js' +import {globalFlags, jsonFlag} from '@shopify/cli-kit/node/cli' + +export default class StoreInfo extends StoreCommand { + static summary = 'Surface metadata about a Shopify store.' + + static descriptionWithMarkdown = `Returns metadata about a store you have access to: id, display name, subdomain, organization, store owner, type, plan, feature preview, and admin URL. + +Use \`--json\` for machine-readable output.` + + static description = this.descriptionWithoutMarkdown() + + static examples = [ + '<%= config.bin %> <%= command.id %> --store shop.myshopify.com', + '<%= config.bin %> <%= command.id %> --store shop.myshopify.com --json', + ] + + static flags = { + ...globalFlags, + ...jsonFlag, + store: storeFlags.store, + } + + public async run(): Promise { + const {flags} = await this.parse(StoreInfo) + + const result = await getStoreInfo({store: flags.store}) + + renderStoreInfoResult(result, flags.json ? 'json' : 'text') + } +} diff --git a/packages/store/src/cli/services/store/info/result.ts b/packages/store/src/cli/services/store/info/result.ts new file mode 100644 index 0000000000..b07b6a23f4 --- /dev/null +++ b/packages/store/src/cli/services/store/info/result.ts @@ -0,0 +1,40 @@ +import {outputResult} from '@shopify/cli-kit/node/output' +import {renderInfo} from '@shopify/cli-kit/node/ui' +import {capitalizeWords} from '@shopify/cli-kit/common/string' +import type {StoreInfoResult, StoreInfoStoreOwner} from './types.js' + +type StoreInfoOutputFormat = 'text' | 'json' + +export function renderStoreInfoResult(result: StoreInfoResult, format: StoreInfoOutputFormat): void { + if (format === 'json') { + outputResult(JSON.stringify(result, null, 2)) + return + } + renderInfo({ + customSections: [{title: 'Store details', body: {list: {items: storeDetailItems(result)}}}], + }) +} + +function storeDetailItems(result: StoreInfoResult): string[] { + const items: string[] = [] + push(items, 'ID', result.id) + push(items, 'Display Name', result.displayName) + push(items, 'Subdomain', result.subdomain) + push(items, 'Organization', result.organizationName) + push(items, 'Store owner', formatOwner(result.storeOwner)) + push(items, 'Type', result.type ? capitalizeWords(result.type) : undefined) + push(items, 'Plan', result.plan ? capitalizeWords(result.plan) : undefined) + push(items, 'Feature Preview', result.featurePreview) + push(items, 'Admin URL', result.adminUrl) + return items +} + +function formatOwner(owner: StoreInfoStoreOwner | undefined): string | undefined { + if (!owner) return undefined + if (owner.name && owner.email) return `${owner.name} (${owner.email})` + return owner.name ?? owner.email +} + +function push(items: string[], label: string, value: string | undefined): void { + if (value) items.push(`${label}: ${value}`) +} diff --git a/packages/store/src/index.ts b/packages/store/src/index.ts index 73e67d7815..98924c2328 100644 --- a/packages/store/src/index.ts +++ b/packages/store/src/index.ts @@ -1,9 +1,11 @@ import StoreAuth from './cli/commands/store/auth.js' import StoreExecute from './cli/commands/store/execute.js' +import StoreInfo from './cli/commands/store/info.js' const COMMANDS = { 'store:auth': StoreAuth, 'store:execute': StoreExecute, + 'store:info': StoreInfo, } export default COMMANDS From ecec15fc34ad026c54beccb8ef02a156667e7d39 Mon Sep 17 00:00:00 2001 From: Ariel Caplan Date: Tue, 9 Jun 2026 21:23:15 +0300 Subject: [PATCH 3/4] Add store info tests Cover the service layer, command wiring, and result rendering. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../store/src/cli/commands/store/info.test.ts | 40 +++++ .../services/store/info/destinations.test.ts | 149 ++++++++++++++++++ .../src/cli/services/store/info/index.test.ts | 143 +++++++++++++++++ .../store/info/organization-shop.test.ts | 81 ++++++++++ .../src/cli/services/store/info/plan.test.ts | 33 ++++ .../cli/services/store/info/result.test.ts | 114 ++++++++++++++ 6 files changed, 560 insertions(+) create mode 100644 packages/store/src/cli/commands/store/info.test.ts create mode 100644 packages/store/src/cli/services/store/info/destinations.test.ts create mode 100644 packages/store/src/cli/services/store/info/index.test.ts create mode 100644 packages/store/src/cli/services/store/info/organization-shop.test.ts create mode 100644 packages/store/src/cli/services/store/info/plan.test.ts create mode 100644 packages/store/src/cli/services/store/info/result.test.ts diff --git a/packages/store/src/cli/commands/store/info.test.ts b/packages/store/src/cli/commands/store/info.test.ts new file mode 100644 index 0000000000..f1f43169ae --- /dev/null +++ b/packages/store/src/cli/commands/store/info.test.ts @@ -0,0 +1,40 @@ +import StoreInfo from './info.js' +import {getStoreInfo} from '../../services/store/info/index.js' +import {renderStoreInfoResult} from '../../services/store/info/result.js' +import {beforeEach, describe, expect, test, vi} from 'vitest' + +vi.mock('../../services/store/info/index.js') +vi.mock('../../services/store/info/result.js') +vi.mock('../../services/store/attribution.js') + +describe('store info command', () => { + beforeEach(() => { + vi.mocked(getStoreInfo).mockResolvedValue({ + subdomain: 'shop.myshopify.com', + displayName: 'My Shop', + }) + }) + + test('passes the store flag through to the service', async () => { + await StoreInfo.run(['--store', 'shop.myshopify.com']) + + expect(getStoreInfo).toHaveBeenCalledWith({ + store: 'shop.myshopify.com', + }) + expect(renderStoreInfoResult).toHaveBeenCalledWith( + expect.objectContaining({subdomain: 'shop.myshopify.com'}), + 'text', + ) + }) + + test('renders json format when --json flag is set', async () => { + await StoreInfo.run(['--store', 'shop.myshopify.com', '--json']) + + expect(renderStoreInfoResult).toHaveBeenCalledWith(expect.anything(), 'json') + }) + + test('defines the expected flags', () => { + expect(StoreInfo.flags.store).toBeDefined() + expect(StoreInfo.flags.json).toBeDefined() + }) +}) diff --git a/packages/store/src/cli/services/store/info/destinations.test.ts b/packages/store/src/cli/services/store/info/destinations.test.ts new file mode 100644 index 0000000000..3de471ba1e --- /dev/null +++ b/packages/store/src/cli/services/store/info/destinations.test.ts @@ -0,0 +1,149 @@ +import {fetchDestinationsContext} from './destinations.js' +import {businessPlatformRequestDoc} from '@shopify/cli-kit/node/api/business-platform' +import {ensureAuthenticatedBusinessPlatform} from '@shopify/cli-kit/node/session' +import {AbortError} from '@shopify/cli-kit/node/error' +import {describe, test, expect, vi, beforeEach} from 'vitest' + +vi.mock('@shopify/cli-kit/node/api/business-platform') +vi.mock('@shopify/cli-kit/node/session') + +const SHOP = 'shop.myshopify.com' + +function destinationNode(overrides: Record = {}) { + return { + publicId: 'dest-public-1', + primaryDomain: `https://${SHOP}`, + webUrl: `https://${SHOP}/admin`, + ...overrides, + } +} + +describe('fetchDestinationsContext', () => { + beforeEach(() => { + vi.mocked(ensureAuthenticatedBusinessPlatform).mockResolvedValue('bp-token') + }) + + test('throws AbortError when no destination matches the domain', async () => { + vi.mocked(businessPlatformRequestDoc).mockResolvedValueOnce({ + currentUserAccount: {destinations: {nodes: []}}, + } as never) + + const err = await fetchDestinationsContext({store: SHOP}).catch((error: unknown) => error) + expect(err).toBeInstanceOf(AbortError) + expect((err as AbortError).message).toContain(SHOP) + }) + + test('throws AbortError when domain match is missing from results', async () => { + vi.mocked(businessPlatformRequestDoc).mockResolvedValueOnce({ + currentUserAccount: { + destinations: { + nodes: [ + destinationNode({ + primaryDomain: 'https://other.myshopify.com', + webUrl: 'https://other.myshopify.com/admin', + }), + ], + }, + }, + } as never) + + const err = await fetchDestinationsContext({store: SHOP}).catch((error: unknown) => error) + expect(err).toBeInstanceOf(AbortError) + expect((err as AbortError).message).toContain(SHOP) + }) + + test('searches BP with the subdomain rather than the full FQDN', async () => { + vi.mocked(businessPlatformRequestDoc) + .mockResolvedValueOnce({ + currentUserAccount: {destinations: {nodes: [destinationNode()]}}, + } as never) + .mockResolvedValueOnce({ + currentUserAccount: {organizationForDestination: {id: 'gid', name: 'Org'}}, + } as never) + + await fetchDestinationsContext({store: SHOP}) + + expect(vi.mocked(businessPlatformRequestDoc).mock.calls[0]?.[0].variables).toEqual({search: 'shop'}) + }) + + test('extracts the subdomain handle for non-myshopify FQDNs (local dev)', async () => { + const devStore = 'my-dev-store.shop.dev' + vi.mocked(businessPlatformRequestDoc) + .mockResolvedValueOnce({ + currentUserAccount: { + destinations: { + nodes: [destinationNode({primaryDomain: `https://${devStore}`, webUrl: `https://${devStore}/admin`})], + }, + }, + } as never) + .mockResolvedValueOnce({ + currentUserAccount: {organizationForDestination: {id: 'gid', name: 'Org'}}, + } as never) + + await fetchDestinationsContext({store: devStore}) + + expect(vi.mocked(businessPlatformRequestDoc).mock.calls[0]?.[0].variables).toEqual({search: 'my-dev-store'}) + }) + + test('resolves the owning org via the matched destination publicId', async () => { + vi.mocked(businessPlatformRequestDoc) + .mockResolvedValueOnce({ + currentUserAccount: {destinations: {nodes: [destinationNode()]}}, + } as never) + .mockResolvedValueOnce({ + currentUserAccount: { + organizationForDestination: { + id: Buffer.from('gid://organization/Organization/123').toString('base64'), + name: 'Acme Org', + }, + }, + } as never) + + const ctx = await fetchDestinationsContext({store: SHOP}) + + expect(vi.mocked(businessPlatformRequestDoc).mock.calls[1]?.[0].variables).toEqual({ + destinationPublicId: 'dest-public-1', + }) + expect(ctx.owningOrg).toEqual({name: 'Acme Org', id: '123'}) + }) + + test('leaves owning org undefined when the org request throws', async () => { + vi.mocked(businessPlatformRequestDoc) + .mockResolvedValueOnce({ + currentUserAccount: {destinations: {nodes: [destinationNode()]}}, + } as never) + .mockRejectedValueOnce(new Error('boom')) + + const ctx = await fetchDestinationsContext({store: SHOP}) + + expect(ctx.owningOrg).toBeUndefined() + }) + + test('leaves owning org undefined when the org is missing from the response', async () => { + vi.mocked(businessPlatformRequestDoc) + .mockResolvedValueOnce({ + currentUserAccount: {destinations: {nodes: [destinationNode()]}}, + } as never) + .mockResolvedValueOnce({ + currentUserAccount: {organizationForDestination: null}, + } as never) + + const ctx = await fetchDestinationsContext({store: SHOP}) + + expect(ctx.owningOrg).toBeUndefined() + }) + + test('uses a provided token without re-authenticating', async () => { + vi.mocked(businessPlatformRequestDoc).mockResolvedValueOnce({ + currentUserAccount: {destinations: {nodes: [destinationNode()]}}, + } as never) + vi.mocked(businessPlatformRequestDoc).mockResolvedValueOnce({ + currentUserAccount: {organizationForDestination: {id: 'gid', name: 'O'}}, + } as never) + + await fetchDestinationsContext({store: SHOP, token: 'preset'}) + + expect(ensureAuthenticatedBusinessPlatform).not.toHaveBeenCalled() + expect(vi.mocked(businessPlatformRequestDoc).mock.calls[0]?.[0].token).toBe('preset') + }) +}) diff --git a/packages/store/src/cli/services/store/info/index.test.ts b/packages/store/src/cli/services/store/info/index.test.ts new file mode 100644 index 0000000000..8b72733d98 --- /dev/null +++ b/packages/store/src/cli/services/store/info/index.test.ts @@ -0,0 +1,143 @@ +import {getStoreInfo} from './index.js' +import {fetchDestinationsContext} from './destinations.js' +import {fetchOrganizationShop} from './organization-shop.js' +import {AbortError} from '@shopify/cli-kit/node/error' +import {describe, test, expect, vi} from 'vitest' +import type {OrganizationShopFields} from './types.js' +import type {Store} from '../../../api/graphql/business-platform-organizations/generated/types.js' + +vi.mock('./destinations.js') +vi.mock('./organization-shop.js') + +const SHOP = 'shop.myshopify.com' + +function orgShop(overrides: Partial = {}): OrganizationShopFields { + return { + shopifyShopId: '72193245184', + name: 'My Shop (Org)', + primaryDomain: `https://${SHOP}`, + storeType: 'PRODUCTION', + developerPreviewHandle: 'extended_variants', + planName: 'professional', + ownerName: 'Jane Doe', + ownerEmail: 'jane@acme.com', + ...overrides, + } +} + +describe('getStoreInfo', () => { + test('throws AbortError when no store is provided', async () => { + const err = await getStoreInfo({}).catch((error: unknown) => error) + expect(err).toBeInstanceOf(AbortError) + expect((err as AbortError).message).toContain('No store') + }) + + test('composes fields from destinations + org-shop', async () => { + vi.mocked(fetchDestinationsContext).mockResolvedValueOnce({ + owningOrg: {name: 'Acme Holdings', id: '149572536'}, + }) + vi.mocked(fetchOrganizationShop).mockResolvedValueOnce(orgShop()) + + const result = await getStoreInfo({store: SHOP}) + + expect(result).toEqual({ + id: 'gid://shopify/Shop/72193245184', + displayName: 'My Shop (Org)', + subdomain: SHOP, + organizationId: '149572536', + organizationName: 'Acme Holdings', + storeOwner: {name: 'Jane Doe', email: 'jane@acme.com'}, + type: 'production', + plan: 'grow', + featurePreview: 'extended_variants', + adminUrl: 'https://admin.shopify.com/store/shop', + }) + }) + + test('derives the admin URL from the myshopify subdomain', async () => { + vi.mocked(fetchDestinationsContext).mockResolvedValueOnce({owningOrg: {name: 'Acme', id: '42'}}) + vi.mocked(fetchOrganizationShop).mockResolvedValueOnce(orgShop()) + + const result = await getStoreInfo({store: 'acme-widgets.myshopify.com'}) + + expect(result.adminUrl).toBe('https://admin.shopify.com/store/acme-widgets') + }) + + test('maps the raw plan name to a public handle', async () => { + vi.mocked(fetchDestinationsContext).mockResolvedValueOnce({owningOrg: {name: 'Acme', id: '42'}}) + vi.mocked(fetchOrganizationShop).mockResolvedValueOnce(orgShop({planName: 'shopify_plus'})) + + const result = await getStoreInfo({store: SHOP}) + + expect(result.plan).toBe('plus') + }) + + test('omits the plan when the raw plan name is unrecognized', async () => { + vi.mocked(fetchDestinationsContext).mockResolvedValueOnce({owningOrg: {name: 'Acme', id: '42'}}) + vi.mocked(fetchOrganizationShop).mockResolvedValueOnce(orgShop({planName: 'development_legacy'})) + + const result = await getStoreInfo({store: SHOP}) + + expect(result.plan).toBeUndefined() + }) + + test.each<[Store, string]>([ + ['APP_DEVELOPMENT', 'dev'], + ['DEVELOPMENT', 'dev'], + ['DEVELOPMENT_SUPERSET', 'dev'], + ['PRODUCTION', 'production'], + ['CLIENT_TRANSFER', 'client_transfer'], + ['COLLABORATOR', 'collaborator'], + ])('maps the %s store type to the `%s` handle', async (storeType, expected) => { + vi.mocked(fetchDestinationsContext).mockResolvedValueOnce({owningOrg: {name: 'Acme', id: '42'}}) + vi.mocked(fetchOrganizationShop).mockResolvedValueOnce(orgShop({storeType})) + + const result = await getStoreInfo({store: SHOP}) + + expect(result.type).toBe(expected) + }) + + test('omits the type for an unrecognized store type', async () => { + vi.mocked(fetchDestinationsContext).mockResolvedValueOnce({owningOrg: {name: 'Acme', id: '42'}}) + vi.mocked(fetchOrganizationShop).mockResolvedValueOnce(orgShop({storeType: 'MANAGED_MARKETS' as Store})) + + const result = await getStoreInfo({store: SHOP}) + + expect(result.type).toBeUndefined() + }) + + test('omits storeOwner when neither name nor email is present', async () => { + vi.mocked(fetchDestinationsContext).mockResolvedValueOnce({owningOrg: {name: 'Acme', id: '42'}}) + vi.mocked(fetchOrganizationShop).mockResolvedValueOnce(orgShop({ownerName: undefined, ownerEmail: undefined})) + + const result = await getStoreInfo({store: SHOP}) + + expect(result.storeOwner).toBeUndefined() + }) + + test('omits org-sourced fields when the owning org is unknown', async () => { + vi.mocked(fetchDestinationsContext).mockResolvedValueOnce({}) + + const result = await getStoreInfo({store: SHOP}) + + expect(fetchOrganizationShop).not.toHaveBeenCalled() + expect(result).toEqual({ + subdomain: SHOP, + adminUrl: 'https://admin.shopify.com/store/shop', + }) + }) + + test('omits org-sourced fields without throwing when the org-shop lookup fails', async () => { + vi.mocked(fetchDestinationsContext).mockResolvedValueOnce({owningOrg: {name: 'Acme', id: '42'}}) + vi.mocked(fetchOrganizationShop).mockRejectedValueOnce(new Error('5xx')) + + const result = await getStoreInfo({store: SHOP}) + + expect(result).toEqual({ + subdomain: SHOP, + organizationId: '42', + organizationName: 'Acme', + adminUrl: 'https://admin.shopify.com/store/shop', + }) + }) +}) diff --git a/packages/store/src/cli/services/store/info/organization-shop.test.ts b/packages/store/src/cli/services/store/info/organization-shop.test.ts new file mode 100644 index 0000000000..3d04008572 --- /dev/null +++ b/packages/store/src/cli/services/store/info/organization-shop.test.ts @@ -0,0 +1,81 @@ +import {fetchOrganizationShop} from './organization-shop.js' +import {businessPlatformOrganizationsRequestDoc} from '@shopify/cli-kit/node/api/business-platform' +import {ensureAuthenticatedBusinessPlatform} from '@shopify/cli-kit/node/session' +import {BugError} from '@shopify/cli-kit/node/error' +import {describe, test, expect, vi, beforeEach} from 'vitest' + +vi.mock('@shopify/cli-kit/node/api/business-platform') +vi.mock('@shopify/cli-kit/node/session') + +const SHOP = 'shop.myshopify.com' +const ORG_ID = '123' + +function shopNode(overrides: Record = {}) { + return { + shopifyShopId: '72193245184', + name: 'My Shop', + primaryDomain: `https://${SHOP}`, + storeType: 'PRODUCTION', + developerPreviewHandle: 'extended_variants', + planName: 'professional', + ownerDetails: {fullName: 'Jane Doe', email: 'jane@acme.com'}, + ...overrides, + } +} + +describe('fetchOrganizationShop', () => { + beforeEach(() => { + vi.mocked(ensureAuthenticatedBusinessPlatform).mockResolvedValue('bp-token') + }) + + test('returns the matched shop node', async () => { + vi.mocked(businessPlatformOrganizationsRequestDoc).mockResolvedValueOnce({ + organization: { + id: 'gid', + name: 'Acme', + accessibleShops: {edges: [{node: shopNode()}]}, + }, + } as never) + + const shop = await fetchOrganizationShop({store: SHOP, organizationId: ORG_ID}) + expect(shop.name).toBe('My Shop') + expect(shop.primaryDomain).toBe(`https://${SHOP}`) + expect(shop.shopifyShopId).toBe('72193245184') + expect(shop.storeType).toBe('PRODUCTION') + expect(shop.developerPreviewHandle).toBe('extended_variants') + expect(shop.planName).toBe('professional') + expect(shop.ownerName).toBe('Jane Doe') + expect(shop.ownerEmail).toBe('jane@acme.com') + }) + + test('throws BugError when no shop matches the domain', async () => { + vi.mocked(businessPlatformOrganizationsRequestDoc).mockResolvedValueOnce({ + organization: { + id: 'gid', + name: 'Acme', + accessibleShops: {edges: [{node: shopNode({primaryDomain: 'https://other.myshopify.com'})}]}, + }, + } as never) + + await expect(fetchOrganizationShop({store: SHOP, organizationId: ORG_ID})).rejects.toBeInstanceOf(BugError) + }) + + test('passes organizationId and search variable to the request', async () => { + vi.mocked(businessPlatformOrganizationsRequestDoc).mockResolvedValueOnce({ + organization: {id: 'gid', name: 'Acme', accessibleShops: {edges: [{node: shopNode()}]}}, + } as never) + + await fetchOrganizationShop({store: SHOP, organizationId: ORG_ID, token: 'preset'}) + + const call = vi.mocked(businessPlatformOrganizationsRequestDoc).mock.calls[0]?.[0] + expect(call?.organizationId).toBe(ORG_ID) + expect(call?.variables).toEqual({search: SHOP}) + expect(call?.token).toBe('preset') + expect(ensureAuthenticatedBusinessPlatform).not.toHaveBeenCalled() + }) + + test('throws when organization is missing', async () => { + vi.mocked(businessPlatformOrganizationsRequestDoc).mockResolvedValueOnce({organization: null} as never) + await expect(fetchOrganizationShop({store: SHOP, organizationId: ORG_ID})).rejects.toBeInstanceOf(BugError) + }) +}) diff --git a/packages/store/src/cli/services/store/info/plan.test.ts b/packages/store/src/cli/services/store/info/plan.test.ts new file mode 100644 index 0000000000..e893b93b2e --- /dev/null +++ b/packages/store/src/cli/services/store/info/plan.test.ts @@ -0,0 +1,33 @@ +import {mapPlanToPublicHandle} from './plan.js' +import {describe, test, expect} from 'vitest' + +describe('mapPlanToPublicHandle', () => { + test('maps internal plan names to public handles', () => { + expect(mapPlanToPublicHandle('basic')).toBe('basic') + expect(mapPlanToPublicHandle('professional')).toBe('grow') + expect(mapPlanToPublicHandle('unlimited')).toBe('advanced') + expect(mapPlanToPublicHandle('shopify_plus')).toBe('plus') + }) + + test('accepts the public handles themselves', () => { + expect(mapPlanToPublicHandle('grow')).toBe('grow') + expect(mapPlanToPublicHandle('advanced')).toBe('advanced') + expect(mapPlanToPublicHandle('plus')).toBe('plus') + }) + + test('is case-insensitive', () => { + expect(mapPlanToPublicHandle('Professional')).toBe('grow') + expect(mapPlanToPublicHandle('SHOPIFY_PLUS')).toBe('plus') + }) + + test('returns undefined for unrecognized plans', () => { + expect(mapPlanToPublicHandle('staff')).toBeUndefined() + expect(mapPlanToPublicHandle('development_legacy')).toBeUndefined() + expect(mapPlanToPublicHandle('some_new_plan')).toBeUndefined() + }) + + test('returns undefined when no plan is provided', () => { + expect(mapPlanToPublicHandle(undefined)).toBeUndefined() + expect(mapPlanToPublicHandle('')).toBeUndefined() + }) +}) diff --git a/packages/store/src/cli/services/store/info/result.test.ts b/packages/store/src/cli/services/store/info/result.test.ts new file mode 100644 index 0000000000..82327445d5 --- /dev/null +++ b/packages/store/src/cli/services/store/info/result.test.ts @@ -0,0 +1,114 @@ +import {renderStoreInfoResult} from './result.js' +import {outputResult} from '@shopify/cli-kit/node/output' +import {renderInfo} from '@shopify/cli-kit/node/ui' +import {describe, test, expect, vi} from 'vitest' +import type {StoreInfoResult} from './types.js' + +vi.mock('@shopify/cli-kit/node/output') +vi.mock('@shopify/cli-kit/node/ui') + +function baseResult(overrides: Partial = {}): StoreInfoResult { + return { + subdomain: 'shop.myshopify.com', + displayName: 'My Shop', + ...overrides, + } +} + +function storeDetailItems(): string[] { + const opts = vi.mocked(renderInfo).mock.calls[0]?.[0] as { + customSections: {title: string; body: {list: {items: string[]}}}[] + } + const section = opts.customSections.find((sec) => sec.title === 'Store details') + return section?.body.list.items ?? [] +} + +describe('renderStoreInfoResult', () => { + test('emits the doc-shaped JSON via outputResult when format is json', () => { + renderStoreInfoResult( + baseResult({ + id: 'gid://shopify/Shop/72193245184', + organizationId: '149572536', + organizationName: 'Acme Holdings', + storeOwner: {name: 'Jane Doe', email: 'jane@acme.com'}, + type: 'dev', + plan: 'grow', + featurePreview: 'extended_variants', + adminUrl: 'https://admin.shopify.com/store/acme-widgets', + }), + 'json', + ) + + expect(outputResult).toHaveBeenCalledOnce() + const payload = vi.mocked(outputResult).mock.calls[0]?.[0] as string + expect(JSON.parse(payload)).toEqual({ + id: 'gid://shopify/Shop/72193245184', + displayName: 'My Shop', + subdomain: 'shop.myshopify.com', + organizationId: '149572536', + organizationName: 'Acme Holdings', + storeOwner: {name: 'Jane Doe', email: 'jane@acme.com'}, + type: 'dev', + plan: 'grow', + featurePreview: 'extended_variants', + adminUrl: 'https://admin.shopify.com/store/acme-widgets', + }) + expect(renderInfo).not.toHaveBeenCalled() + }) + + test('renders a Store details section in text format', () => { + renderStoreInfoResult(baseResult(), 'text') + + expect(renderInfo).toHaveBeenCalledOnce() + const opts = vi.mocked(renderInfo).mock.calls[0]?.[0] as {customSections: {title: string}[]} + expect(opts.customSections.map((sec) => sec.title)).toEqual(['Store details']) + }) + + test('capitalizes the type and includes the doc fields', () => { + renderStoreInfoResult( + baseResult({ + id: 'gid://shopify/Shop/1', + organizationName: 'Acme Holdings', + type: 'dev', + plan: 'grow', + featurePreview: 'extended_variants', + adminUrl: 'https://admin.shopify.com/store/acme-widgets', + }), + 'text', + ) + + const items = storeDetailItems() + expect(items).toContain('ID: gid://shopify/Shop/1') + expect(items).toContain('Display Name: My Shop') + expect(items).toContain('Subdomain: shop.myshopify.com') + expect(items).toContain('Organization: Acme Holdings') + expect(items).toContain('Type: Dev') + expect(items).toContain('Plan: Grow') + expect(items).toContain('Feature Preview: extended_variants') + expect(items).toContain('Admin URL: https://admin.shopify.com/store/acme-widgets') + }) + + test('formats the store owner as "name (email)" when both are present', () => { + renderStoreInfoResult(baseResult({storeOwner: {name: 'Jane Doe', email: 'jane@acme.com'}}), 'text') + expect(storeDetailItems()).toContain('Store owner: Jane Doe (jane@acme.com)') + }) + + test('falls back to the available store owner field when only one is present', () => { + renderStoreInfoResult(baseResult({storeOwner: {name: 'Jane Doe'}}), 'text') + expect(storeDetailItems()).toContain('Store owner: Jane Doe') + }) + + test('falls back to the email when the store owner has no name', () => { + renderStoreInfoResult(baseResult({storeOwner: {email: 'jane@acme.com'}}), 'text') + expect(storeDetailItems()).toContain('Store owner: jane@acme.com') + }) + + test('omits fields that are not present', () => { + renderStoreInfoResult(baseResult(), 'text') + const items = storeDetailItems() + expect(items.some((item) => item.startsWith('Feature Preview'))).toBe(false) + expect(items.some((item) => item.startsWith('Store owner'))).toBe(false) + expect(items.some((item) => item.startsWith('Type'))).toBe(false) + expect(items.some((item) => item.startsWith('Plan'))).toBe(false) + }) +}) From 43520bc269cbe6a1d43dd65655856d0fce9f07ad Mon Sep 17 00:00:00 2001 From: Ariel Caplan Date: Tue, 9 Jun 2026 21:23:15 +0300 Subject: [PATCH 4/4] Add store info changeset and regenerate docs Add the changeset and regenerate the command reference: README, oclif manifest, and shopify.dev docs. Co-Authored-By: Claude Opus 4.8 (1M context) --- .changeset/store-info-command.md | 5 ++ .../interfaces/store-info.interface.ts | 30 ++++++++++ .../generated/generated_docs_data_v2.json | 46 +++++++++++++++ packages/cli/README.md | 29 +++++++++ packages/cli/oclif.manifest.json | 59 +++++++++++++++++++ 5 files changed, 169 insertions(+) create mode 100644 .changeset/store-info-command.md create mode 100644 docs-shopify.dev/commands/interfaces/store-info.interface.ts diff --git a/.changeset/store-info-command.md b/.changeset/store-info-command.md new file mode 100644 index 0000000000..22931602b9 --- /dev/null +++ b/.changeset/store-info-command.md @@ -0,0 +1,5 @@ +--- +'@shopify/cli': minor +--- + +Add `shopify store info --store ` command to display metadata about a store. Supports `--json` for machine-readable output. diff --git a/docs-shopify.dev/commands/interfaces/store-info.interface.ts b/docs-shopify.dev/commands/interfaces/store-info.interface.ts new file mode 100644 index 0000000000..01c264dca6 --- /dev/null +++ b/docs-shopify.dev/commands/interfaces/store-info.interface.ts @@ -0,0 +1,30 @@ +// This is an autogenerated file. Don't edit this file manually. +/** + * The following flags are available for the `store info` command: + * @publicDocs + */ +export interface storeinfo { + /** + * Output the result as JSON. Automatically disables color output. + * @environment SHOPIFY_FLAG_JSON + */ + '-j, --json'?: '' + + /** + * Disable color output. + * @environment SHOPIFY_FLAG_NO_COLOR + */ + '--no-color'?: '' + + /** + * The myshopify.com domain of the store. + * @environment SHOPIFY_FLAG_STORE + */ + '-s, --store ': string + + /** + * Increase the verbosity of the output. + * @environment SHOPIFY_FLAG_VERBOSE + */ + '--verbose'?: '' +} diff --git a/docs-shopify.dev/generated/generated_docs_data_v2.json b/docs-shopify.dev/generated/generated_docs_data_v2.json index 613051d8d7..3c9205db03 100644 --- a/docs-shopify.dev/generated/generated_docs_data_v2.json +++ b/docs-shopify.dev/generated/generated_docs_data_v2.json @@ -4293,6 +4293,52 @@ "value": "export interface storeexecute {\n /**\n * Allow GraphQL mutations to run against the target store.\n * @environment SHOPIFY_FLAG_ALLOW_MUTATIONS\n */\n '--allow-mutations'?: ''\n\n /**\n * Output the result as JSON. Automatically disables color output.\n * @environment SHOPIFY_FLAG_JSON\n */\n '-j, --json'?: ''\n\n /**\n * Disable color output.\n * @environment SHOPIFY_FLAG_NO_COLOR\n */\n '--no-color'?: ''\n\n /**\n * The file name where results should be written, instead of STDOUT.\n * @environment SHOPIFY_FLAG_OUTPUT_FILE\n */\n '--output-file '?: string\n\n /**\n * The GraphQL query or mutation, as a string.\n * @environment SHOPIFY_FLAG_QUERY\n */\n '-q, --query '?: string\n\n /**\n * Path to a file containing the GraphQL query or mutation. Can't be used with --query.\n * @environment SHOPIFY_FLAG_QUERY_FILE\n */\n '--query-file '?: string\n\n /**\n * The myshopify.com domain of the store.\n * @environment SHOPIFY_FLAG_STORE\n */\n '-s, --store ': string\n\n /**\n * Path to a file containing GraphQL variables in JSON format. Can't be used with --variables.\n * @environment SHOPIFY_FLAG_VARIABLE_FILE\n */\n '--variable-file '?: string\n\n /**\n * The values for any GraphQL variables in your query or mutation, in JSON format.\n * @environment SHOPIFY_FLAG_VARIABLES\n */\n '-v, --variables '?: string\n\n /**\n * Increase the verbosity of the output.\n * @environment SHOPIFY_FLAG_VERBOSE\n */\n '--verbose'?: ''\n\n /**\n * The API version to use for the query or mutation. Defaults to the latest stable version.\n * @environment SHOPIFY_FLAG_VERSION\n */\n '--version '?: string\n}" } }, + "storeinfo": { + "docs-shopify.dev/commands/interfaces/store-info.interface.ts": { + "filePath": "docs-shopify.dev/commands/interfaces/store-info.interface.ts", + "name": "storeinfo", + "description": "The following flags are available for the `store info` command:", + "isPublicDocs": true, + "members": [ + { + "filePath": "docs-shopify.dev/commands/interfaces/store-info.interface.ts", + "syntaxKind": "PropertySignature", + "name": "--no-color", + "value": "''", + "description": "Disable color output.", + "isOptional": true, + "environmentValue": "SHOPIFY_FLAG_NO_COLOR" + }, + { + "filePath": "docs-shopify.dev/commands/interfaces/store-info.interface.ts", + "syntaxKind": "PropertySignature", + "name": "--verbose", + "value": "''", + "description": "Increase the verbosity of the output.", + "isOptional": true, + "environmentValue": "SHOPIFY_FLAG_VERBOSE" + }, + { + "filePath": "docs-shopify.dev/commands/interfaces/store-info.interface.ts", + "syntaxKind": "PropertySignature", + "name": "-j, --json", + "value": "''", + "description": "Output the result as JSON. Automatically disables color output.", + "isOptional": true, + "environmentValue": "SHOPIFY_FLAG_JSON" + }, + { + "filePath": "docs-shopify.dev/commands/interfaces/store-info.interface.ts", + "syntaxKind": "PropertySignature", + "name": "-s, --store ", + "value": "string", + "description": "The myshopify.com domain of the store.", + "environmentValue": "SHOPIFY_FLAG_STORE" + } + ], + "value": "export interface storeinfo {\n /**\n * Output the result as JSON. Automatically disables color output.\n * @environment SHOPIFY_FLAG_JSON\n */\n '-j, --json'?: ''\n\n /**\n * Disable color output.\n * @environment SHOPIFY_FLAG_NO_COLOR\n */\n '--no-color'?: ''\n\n /**\n * The myshopify.com domain of the store.\n * @environment SHOPIFY_FLAG_STORE\n */\n '-s, --store ': string\n\n /**\n * Increase the verbosity of the output.\n * @environment SHOPIFY_FLAG_VERBOSE\n */\n '--verbose'?: ''\n}" + } + }, "themecheck": { "docs-shopify.dev/commands/interfaces/theme-check.interface.ts": { "filePath": "docs-shopify.dev/commands/interfaces/theme-check.interface.ts", diff --git a/packages/cli/README.md b/packages/cli/README.md index e485350506..02da54b2d3 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -78,6 +78,7 @@ * [`shopify search [query]`](#shopify-search-query) * [`shopify store auth`](#shopify-store-auth) * [`shopify store execute`](#shopify-store-execute) +* [`shopify store info`](#shopify-store-info) * [`shopify theme check`](#shopify-theme-check) * [`shopify theme console`](#shopify-theme-console) * [`shopify theme delete`](#shopify-theme-delete) @@ -2177,6 +2178,34 @@ EXAMPLES $ shopify store execute --store shop.myshopify.com --query "query { shop { name } }" --json ``` +## `shopify store info` + +Surface metadata about a Shopify store. + +``` +USAGE + $ shopify store info -s [-j] [--no-color] [--verbose] + +FLAGS + -j, --json [env: SHOPIFY_FLAG_JSON] Output the result as JSON. Automatically disables color output. + -s, --store= (required) [env: SHOPIFY_FLAG_STORE] The myshopify.com domain of the store. + --no-color [env: SHOPIFY_FLAG_NO_COLOR] Disable color output. + --verbose [env: SHOPIFY_FLAG_VERBOSE] Increase the verbosity of the output. + +DESCRIPTION + Surface metadata about a Shopify store. + + Returns metadata about a store you have access to: id, display name, subdomain, organization, store owner, type, plan, + feature preview, and admin URL. + + Use `--json` for machine-readable output. + +EXAMPLES + $ shopify store info --store shop.myshopify.com + + $ shopify store info --store shop.myshopify.com --json +``` + ## `shopify theme check` Validate the theme. diff --git a/packages/cli/oclif.manifest.json b/packages/cli/oclif.manifest.json index 48d3bc25cd..a43dda537c 100644 --- a/packages/cli/oclif.manifest.json +++ b/packages/cli/oclif.manifest.json @@ -5862,6 +5862,65 @@ "strict": true, "summary": "Execute GraphQL queries and mutations on a store." }, + "store:info": { + "aliases": [ + ], + "args": { + }, + "customPluginName": "@shopify/store", + "description": "Returns metadata about a store you have access to: id, display name, subdomain, organization, store owner, type, plan, feature preview, and admin URL.\n\nUse `--json` for machine-readable output.", + "descriptionWithMarkdown": "Returns metadata about a store you have access to: id, display name, subdomain, organization, store owner, type, plan, feature preview, and admin URL.\n\nUse `--json` for machine-readable output.", + "examples": [ + "<%= config.bin %> <%= command.id %> --store shop.myshopify.com", + "<%= config.bin %> <%= command.id %> --store shop.myshopify.com --json" + ], + "flags": { + "json": { + "allowNo": false, + "char": "j", + "description": "Output the result as JSON. Automatically disables color output.", + "env": "SHOPIFY_FLAG_JSON", + "hidden": false, + "name": "json", + "type": "boolean" + }, + "no-color": { + "allowNo": false, + "description": "Disable color output.", + "env": "SHOPIFY_FLAG_NO_COLOR", + "hidden": false, + "name": "no-color", + "type": "boolean" + }, + "store": { + "char": "s", + "description": "The myshopify.com domain of the store.", + "env": "SHOPIFY_FLAG_STORE", + "hasDynamicHelp": false, + "multiple": false, + "name": "store", + "required": true, + "type": "option" + }, + "verbose": { + "allowNo": false, + "description": "Increase the verbosity of the output.", + "env": "SHOPIFY_FLAG_VERBOSE", + "hidden": false, + "name": "verbose", + "type": "boolean" + } + }, + "hasDynamicHelp": false, + "hiddenAliases": [ + ], + "id": "store:info", + "pluginAlias": "@shopify/cli", + "pluginName": "@shopify/cli", + "pluginType": "core", + "strict": true, + "summary": "Surface metadata about a Shopify store." + }, "theme:check": { "aliases": [ ],