diff --git a/.changeset/store-info-command.md b/.changeset/store-info-command.md new file mode 100644 index 00000000000..f9fdfd5e1a1 --- /dev/null +++ b/.changeset/store-info-command.md @@ -0,0 +1,5 @@ +--- +'@shopify/cli': minor +--- + +Add `shopify store info --store ` command. Supports `--json`. Additional fields are included automatically when you have run `store auth` for the store. diff --git a/.gitignore b/.gitignore index ecfacadde7d..893d5f0a5dd 100644 --- a/.gitignore +++ b/.gitignore @@ -202,6 +202,7 @@ packages/ui-extensions-dev-console/css-transform.js packages/ui-extensions-dev-console/dist packages/cli-kit/src/cli/api/graphql/*/*_schema.graphql packages/organizations/src/cli/api/graphql/*/*_schema.graphql +packages/store/src/cli/api/graphql/*/*_schema.graphql .claude/settings.local.json diff --git a/bin/get-graphql-schemas.js b/bin/get-graphql-schemas.js index b5a076dbd94..89fb15d0aa7 100755 --- a/bin/get-graphql-schemas.js +++ b/bin/get-graphql-schemas.js @@ -51,13 +51,17 @@ const schemas = [ localPaths: [ './packages/app/src/cli/api/graphql/business-platform-destinations/destinations_schema.graphql', './packages/organizations/src/cli/api/graphql/business-platform-destinations/destinations_schema.graphql', + './packages/store/src/cli/api/graphql/business-platform-destinations/destinations_schema.graphql', ], }, { owner: 'shop', repo: 'world', pathToFile: 'areas/platforms/organizations/db/graphql/organizations_schema.graphql', - localPaths: ['./packages/app/src/cli/api/graphql/business-platform-organizations/organizations_schema.graphql'], + localPaths: [ + './packages/app/src/cli/api/graphql/business-platform-organizations/organizations_schema.graphql', + './packages/store/src/cli/api/graphql/business-platform-organizations/organizations_schema.graphql', + ], }, { owner: 'shop', @@ -79,6 +83,7 @@ const schemas = [ './packages/cli-kit/src/cli/api/graphql/admin/admin_schema.graphql', './packages/app/src/cli/api/graphql/bulk-operations/admin_schema.graphql', './packages/app/src/cli/api/graphql/admin/admin_schema.graphql', + './packages/store/src/cli/api/graphql/admin/admin_schema.graphql', ], usesLfs: true, }, diff --git a/docs-shopify.dev/commands/interfaces/store-auth.interface.ts b/docs-shopify.dev/commands/interfaces/store-auth.interface.ts index 926550e5104..16f479cc306 100644 --- a/docs-shopify.dev/commands/interfaces/store-auth.interface.ts +++ b/docs-shopify.dev/commands/interfaces/store-auth.interface.ts @@ -23,7 +23,7 @@ export interface storeauth { '--scopes ': string /** - * The myshopify.com domain of the store to authenticate against. + * The myshopify.com domain of the store. * @environment SHOPIFY_FLAG_STORE */ '-s, --store ': string diff --git a/docs-shopify.dev/commands/interfaces/store-execute.interface.ts b/docs-shopify.dev/commands/interfaces/store-execute.interface.ts index be6a7e3c915..5897fffbb58 100644 --- a/docs-shopify.dev/commands/interfaces/store-execute.interface.ts +++ b/docs-shopify.dev/commands/interfaces/store-execute.interface.ts @@ -41,7 +41,7 @@ export interface storeexecute { '--query-file '?: string /** - * The myshopify.com domain of the store to execute against. + * The myshopify.com domain of the store. * @environment SHOPIFY_FLAG_STORE */ '-s, --store ': string 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 00000000000..01c264dca63 --- /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 4965084de82..16f31211e65 100644 --- a/docs-shopify.dev/generated/generated_docs_data_v2.json +++ b/docs-shopify.dev/generated/generated_docs_data_v2.json @@ -4177,11 +4177,11 @@ "syntaxKind": "PropertySignature", "name": "-s, --store ", "value": "string", - "description": "The myshopify.com domain of the store to authenticate against.", + "description": "The myshopify.com domain of the store.", "environmentValue": "SHOPIFY_FLAG_STORE" } ], - "value": "export interface storeauth {\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 * Comma-separated Admin API scopes to request for the app.\n * @environment SHOPIFY_FLAG_SCOPES\n */\n '--scopes ': string\n\n /**\n * The myshopify.com domain of the store to authenticate against.\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}" + "value": "export interface storeauth {\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 * Comma-separated Admin API scopes to request for the app.\n * @environment SHOPIFY_FLAG_SCOPES\n */\n '--scopes ': string\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}" } }, "storeexecute": { @@ -4277,7 +4277,7 @@ "syntaxKind": "PropertySignature", "name": "-s, --store ", "value": "string", - "description": "The myshopify.com domain of the store to execute against.", + "description": "The myshopify.com domain of the store.", "environmentValue": "SHOPIFY_FLAG_STORE" }, { @@ -4290,7 +4290,53 @@ "environmentValue": "SHOPIFY_FLAG_VARIABLES" } ], - "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 to execute against.\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}" + "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": { diff --git a/graphql.config.ts b/graphql.config.ts index bc1e9bf2229..e392b65f60a 100644 --- a/graphql.config.ts +++ b/graphql.config.ts @@ -86,5 +86,8 @@ export default { functions: projectFactory('functions', 'functions_cli_schema.graphql', 'app'), adminAsApp: projectFactory('admin', 'admin_schema.graphql'), organizationsDestinations: projectFactory('business-platform-destinations', 'destinations_schema.graphql', 'organizations'), + storeBusinessPlatformDestinations: projectFactory('business-platform-destinations', 'destinations_schema.graphql', 'store'), + storeBusinessPlatformOrganizations: projectFactory('business-platform-organizations', 'organizations_schema.graphql', 'store'), + storeAdmin: projectFactory('admin', 'admin_schema.graphql', 'store'), }, } diff --git a/package.json b/package.json index 20bed9b068a..6716f373697 100644 --- a/package.json +++ b/package.json @@ -227,6 +227,24 @@ ] } }, + "packages/store": { + "entry": [ + "**/{commands,hooks}/**/*.ts!", + "**/index.ts!" + ], + "project": "**/*.ts!", + "ignore": [ + "**/graphql/**/generated/*.ts" + ], + "ignoreDependencies": [ + "@graphql-typed-document-node/core" + ], + "vite": { + "config": [ + "vite.config.ts" + ] + } + }, "packages/cli": { "entry": [ "**/{commands,hooks}/**/*.ts!", diff --git a/packages/app/src/cli/utilities/developer-platform-client/app-management-client.ts b/packages/app/src/cli/utilities/developer-platform-client/app-management-client.ts index 5d8ac4d1c8f..94badf04945 100644 --- a/packages/app/src/cli/utilities/developer-platform-client/app-management-client.ts +++ b/packages/app/src/cli/utilities/developer-platform-client/app-management-client.ts @@ -159,6 +159,7 @@ import { BusinessPlatformRequestOptions, } from '@shopify/cli-kit/node/api/business-platform' import {CLI_KIT_VERSION} from '@shopify/cli-kit/common/version' +import {encodeGid, numericIdFromEncodedGid, numericIdFromGid} from '@shopify/cli-kit/common/gid' import {versionSatisfies} from '@shopify/cli-kit/node/node-package-manager' import {outputDebug} from '@shopify/cli-kit/node/output' import {developerDashboardFqdn, normalizeStoreFqdn} from '@shopify/cli-kit/node/context/fqdn' @@ -1266,7 +1267,7 @@ export function organizationGidForBP(id: string): string { // 1234 => gid://organization/Organization/1234 => base64 export function encodedGidFromOrganizationIdForBP(id: string): string { - return Buffer.from(organizationGidForBP(id)).toString('base64') + return encodeGid(organizationGidForBP(id)) } // App Managament uses a different GID format than Business Platform for organizationId. @@ -1277,20 +1278,26 @@ function gidFromOrganizationIdForShopify(id: string): string { // 1234 => gid://organization/ShopifyShop/1234 => base64 export function encodedGidFromShopId(id: string): string { - const gid = `gid://organization/ShopifyShop/${id}` - return Buffer.from(gid).toString('base64') + return encodeGid(`gid://organization/ShopifyShop/${id}`) } // base64 => gid://organization/Organization/1234 => 1234 function idFromEncodedGid(gid: string): string { - const decodedGid = Buffer.from(gid, 'base64').toString('ascii') - return numberFromGid(decodedGid).toString() + const numeric = numericIdFromEncodedGid(gid) + if (numeric === undefined) { + throw new Error(`Invalid encoded GID: ${gid}`) + } + return numeric } // gid://organization/Organization/1234 => 1234 function numberFromGid(gid: string): number { if (gid.startsWith('gid://')) { - return Number(gid.match(/^gid.*\/(\d+)$/)![1]) + const numeric = numericIdFromGid(gid) + if (numeric === undefined) { + throw new Error(`Invalid GID: ${gid}`) + } + return Number(numeric) } return Number(gid) } diff --git a/packages/cli-kit/src/public/common/gid.test.ts b/packages/cli-kit/src/public/common/gid.test.ts new file mode 100644 index 00000000000..3a58f7ae6e6 --- /dev/null +++ b/packages/cli-kit/src/public/common/gid.test.ts @@ -0,0 +1,39 @@ +import {encodeGid, numericIdFromEncodedGid, numericIdFromGid} from './gid.js' +import {describe, expect, test} from 'vitest' + +describe('numericIdFromGid', () => { + test('extracts the trailing numeric id from a plain gid', () => { + expect(numericIdFromGid('gid://shopify/Product/1234')).toBe('1234') + expect(numericIdFromGid('gid://organization/Organization/9876')).toBe('9876') + }) + + test('returns undefined when there is no trailing /', () => { + expect(numericIdFromGid('gid://shopify/Product/ABC')).toBeUndefined() + expect(numericIdFromGid('not-a-gid')).toBeUndefined() + expect(numericIdFromGid('1234')).toBeUndefined() + }) +}) + +describe('numericIdFromEncodedGid', () => { + test('extracts the trailing numeric id from a base64-encoded gid', () => { + const gid = Buffer.from('gid://organization/Organization/1234').toString('base64') + expect(numericIdFromEncodedGid(gid)).toBe('1234') + }) + + test('returns undefined when the decoded string does not end with /', () => { + expect(numericIdFromEncodedGid(Buffer.from('not-a-gid').toString('base64'))).toBeUndefined() + expect(numericIdFromEncodedGid('!!!')).toBeUndefined() + }) +}) + +describe('encodeGid', () => { + test('base64-encodes a plain gid', () => { + expect(encodeGid('gid://organization/Organization/1234')).toBe( + Buffer.from('gid://organization/Organization/1234').toString('base64'), + ) + }) + + test('round-trips with numericIdFromEncodedGid', () => { + expect(numericIdFromEncodedGid(encodeGid('gid://shopify/Product/42'))).toBe('42') + }) +}) diff --git a/packages/cli-kit/src/public/common/gid.ts b/packages/cli-kit/src/public/common/gid.ts new file mode 100644 index 00000000000..8d69694db6e --- /dev/null +++ b/packages/cli-kit/src/public/common/gid.ts @@ -0,0 +1,33 @@ +/** + * Extracts the trailing numeric id from a plain GraphQL global id like + * `gid://shopify/Product/123`. + * + * @param gid - A plain GraphQL global id string. + * @returns The trailing numeric id, or undefined when the string does not end with `/`. + */ +export function numericIdFromGid(gid: string): string | undefined { + const match = gid.match(/\/(\d+)$/) + return match ? match[1] : undefined +} + +/** + * Decodes a base64-encoded GraphQL global id (for example, the form + * Business Platform APIs return) and returns the trailing numeric id. + * + * @param gid - A base64-encoded GraphQL global id. + * @returns The trailing numeric id, or undefined when the decoded string does not end with `/`. + */ +export function numericIdFromEncodedGid(gid: string): string | undefined { + return numericIdFromGid(Buffer.from(gid, 'base64').toString('ascii')) +} + +/** + * Encodes a plain GraphQL global id (`gid://...`) as base64, which is the + * form some Business Platform endpoints require. + * + * @param gid - A plain GraphQL global id string to encode. + * @returns The base64-encoded gid. + */ +export function encodeGid(gid: string): string { + return Buffer.from(gid).toString('base64') +} diff --git a/packages/cli-kit/src/public/common/url.test.ts b/packages/cli-kit/src/public/common/url.test.ts index c3dea50d3ec..ed72607d172 100644 --- a/packages/cli-kit/src/public/common/url.test.ts +++ b/packages/cli-kit/src/public/common/url.test.ts @@ -1,4 +1,4 @@ -import {isValidURL, safeParseURL} from './url.js' +import {extractHost, extractMyshopifyHandle, isValidURL, safeParseURL} from './url.js' import {describe, expect, test} from 'vitest' describe('isValidURL', () => { @@ -57,3 +57,34 @@ describe('safeParseURL', () => { expect(result).toBeUndefined() }) }) + +describe('extractHost', () => { + test('returns the hostname for a full URL', () => { + expect(extractHost('https://Shop.MyShopify.com/admin')).toBe('shop.myshopify.com') + }) + + test('strips the scheme and path for a bare host string', () => { + expect(extractHost('shop.myshopify.com/admin')).toBe('shop.myshopify.com') + }) + + test('returns undefined for null/undefined/empty input', () => { + expect(extractHost(null)).toBeUndefined() + expect(extractHost(undefined)).toBeUndefined() + expect(extractHost('')).toBeUndefined() + }) +}) + +describe('extractMyshopifyHandle', () => { + test('extracts the subdomain from a myshopify.com URL', () => { + expect(extractMyshopifyHandle('https://my-shop.myshopify.com')).toBe('my-shop') + }) + + test('returns undefined when the host is not a myshopify.com domain', () => { + expect(extractMyshopifyHandle('https://example.com')).toBeUndefined() + }) + + test('returns undefined for null/undefined input', () => { + expect(extractMyshopifyHandle(null)).toBeUndefined() + expect(extractMyshopifyHandle(undefined)).toBeUndefined() + }) +}) diff --git a/packages/cli-kit/src/public/common/url.ts b/packages/cli-kit/src/public/common/url.ts index 6a6086efe86..12b3316b324 100644 --- a/packages/cli-kit/src/public/common/url.ts +++ b/packages/cli-kit/src/public/common/url.ts @@ -28,3 +28,32 @@ export function safeParseURL(url: string): URL | undefined { return undefined } } + +/** + * Extracts the lowercased hostname from a URL-shaped string. Tolerates + * bare hosts (without a scheme) and inputs that come back from APIs as + * either `https://shop.myshopify.com` or `shop.myshopify.com`. + * + * @param value - A URL or bare host string, possibly null/undefined. + * @returns The lowercased hostname, or undefined when the input is empty. + */ +export function extractHost(value: string | null | undefined): string | undefined { + if (!value) return undefined + const lowered = value.toLowerCase() + const parsed = safeParseURL(lowered) + if (parsed) return parsed.hostname + return lowered.replace(/^https?:\/\//, '').split('/')[0] +} + +/** + * Extracts the subdomain handle from a `*.myshopify.com` URL or host. + * + * @param value - A URL or host string, possibly null/undefined. + * @returns The myshopify subdomain handle, or undefined when the input isn't a `*.myshopify.com` URL. + */ +export function extractMyshopifyHandle(value: string | null | undefined): string | undefined { + const host = extractHost(value) + if (!host) return undefined + const match = host.match(/^([^.]+)\.myshopify\.com$/) + return match ? match[1] : undefined +} diff --git a/packages/cli/README.md b/packages/cli/README.md index d96a305ea8a..5b01dc5e865 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) @@ -2110,8 +2111,7 @@ USAGE 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 to authenticate - against. + -s, --store= (required) [env: SHOPIFY_FLAG_STORE] The myshopify.com domain of the store. --no-color [env: SHOPIFY_FLAG_NO_COLOR] Disable color output. --scopes= (required) [env: SHOPIFY_FLAG_SCOPES] Comma-separated Admin API scopes to request for the app. --verbose [env: SHOPIFY_FLAG_VERBOSE] Increase the verbosity of the output. @@ -2142,8 +2142,7 @@ USAGE FLAGS -j, --json [env: SHOPIFY_FLAG_JSON] Output the result as JSON. Automatically disables color output. -q, --query= [env: SHOPIFY_FLAG_QUERY] The GraphQL query or mutation, as a string. - -s, --store= (required) [env: SHOPIFY_FLAG_STORE] The myshopify.com domain of the store to execute - against. + -s, --store= (required) [env: SHOPIFY_FLAG_STORE] The myshopify.com domain of the store. -v, --variables= [env: SHOPIFY_FLAG_VARIABLES] The values for any GraphQL variables in your query or mutation, in JSON format. --allow-mutations [env: SHOPIFY_FLAG_ALLOW_MUTATIONS] Allow GraphQL mutations to run against the target @@ -2178,6 +2177,37 @@ 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: domain, name, type, status, URLs, plan, billing currency, and more. + + If you have run `store auth` for the store, additional fields are included: shop owner, timezone, setup status, and + Plus subscription. + + Use `--json` for machine-readable output. When an individual field can't be fetched, the rest of the output is still + returned and the missing fields are listed with a reason. + +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 b73dd5a6473..b0678c3b754 100644 --- a/packages/cli/oclif.manifest.json +++ b/packages/cli/oclif.manifest.json @@ -5710,7 +5710,7 @@ }, "store": { "char": "s", - "description": "The myshopify.com domain of the store to authenticate against.", + "description": "The myshopify.com domain of the store.", "env": "SHOPIFY_FLAG_STORE", "hasDynamicHelp": false, "multiple": false, @@ -5804,7 +5804,7 @@ }, "store": { "char": "s", - "description": "The myshopify.com domain of the store to execute against.", + "description": "The myshopify.com domain of the store.", "env": "SHOPIFY_FLAG_STORE", "hasDynamicHelp": false, "multiple": false, @@ -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: domain, name, type, status, URLs, plan, billing currency, and more.\n\nIf you have run `store auth` for the store, additional fields are included: shop owner, timezone, setup status, and Plus subscription.\n\nUse `--json` for machine-readable output. When an individual field can't be fetched, the rest of the output is still returned and the missing fields are listed with a reason.", + "descriptionWithMarkdown": "Returns metadata about a store you have access to: domain, name, type, status, URLs, plan, billing currency, and more.\n\nIf you have run `store auth` for the store, additional fields are included: shop owner, timezone, setup status, and Plus subscription.\n\nUse `--json` for machine-readable output. When an individual field can't be fetched, the rest of the output is still returned and the missing fields are listed with a reason.", + "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": [ ], diff --git a/packages/organizations/src/cli/services/fetch.ts b/packages/organizations/src/cli/services/fetch.ts index 0d35fb8f899..cab34b004ba 100644 --- a/packages/organizations/src/cli/services/fetch.ts +++ b/packages/organizations/src/cli/services/fetch.ts @@ -3,6 +3,7 @@ import {Organization} from '../models/organization.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 {numericIdFromEncodedGid} from '@shopify/cli-kit/common/gid' export async function fetchOrganizations(): Promise { const token = await ensureAuthenticatedBusinessPlatform() @@ -25,17 +26,11 @@ export async function fetchOrganizations(): Promise { } const orgs = result.currentUserAccount.organizationsWithAccessToDestination.nodes - return orgs.map((org) => ({ - id: idFromEncodedGid(org.id), - businessName: org.name, - })) -} - -function idFromEncodedGid(gid: string): string { - const decodedGid = Buffer.from(gid, 'base64').toString('ascii') - const match = decodedGid.match(/\/(\d+)$/) - if (!match) { - throw new AbortError(`Failed to decode organization ID from: ${gid}`) - } - return match[1]! + return orgs.map((org) => { + const id = numericIdFromEncodedGid(org.id) + if (!id) { + throw new AbortError(`Failed to decode organization ID from: ${org.id}`) + } + return {id, businessName: org.name} + }) } diff --git a/packages/store/package.json b/packages/store/package.json index b1843e78742..d03fb78cf10 100644 --- a/packages/store/package.json +++ b/packages/store/package.json @@ -39,6 +39,7 @@ ] }, "dependencies": { + "@graphql-typed-document-node/core": "3.2.0", "@oclif/core": "4.5.3", "@shopify/cli-kit": "4.1.0" }, diff --git a/packages/store/project.json b/packages/store/project.json index ade6270f79e..931a6db8478 100644 --- a/packages/store/project.json +++ b/packages/store/project.json @@ -41,6 +41,86 @@ "command": "pnpm tsc --noEmit", "cwd": "packages/store" } + }, + "graphql-codegen": { + "executor": "nx:noop", + "dependsOn": ["graphql-codegen:formatting"] + }, + "graphql-codegen:formatting": { + "executor": "nx:run-commands", + "dependsOn": ["graphql-codegen:postfix"], + "inputs": [{"dependentTasksOutputFiles": "**/*.ts"}], + "outputs": [ + "{projectRoot}/src/cli/api/graphql/business-platform-destinations/generated/**/*.ts", + "{projectRoot}/src/cli/api/graphql/business-platform-organizations/generated/**/*.ts", + "{projectRoot}/src/cli/api/graphql/admin/generated/**/*.ts" + ], + "options": { + "commands": [ + "pnpm eslint 'src/cli/api/graphql/business-platform-destinations/generated/**/*.{ts,tsx}' --fix", + "pnpm eslint 'src/cli/api/graphql/business-platform-organizations/generated/**/*.{ts,tsx}' --fix", + "pnpm eslint 'src/cli/api/graphql/admin/generated/**/*.{ts,tsx}' --fix" + ], + "cwd": "packages/store" + } + }, + "graphql-codegen:generate:business-platform-destinations": { + "executor": "nx:run-commands", + "inputs": [ + "{workspaceRoot}/graphql.config.ts", + "{projectRoot}/src/cli/api/graphql/business-platform-destinations/**/*.graphql" + ], + "outputs": ["{projectRoot}/src/cli/api/graphql/business-platform-destinations/generated/**/*.ts"], + "options": { + "commands": ["pnpm exec graphql-codegen --project=storeBusinessPlatformDestinations"], + "cwd": "{workspaceRoot}" + } + }, + "graphql-codegen:generate:business-platform-organizations": { + "executor": "nx:run-commands", + "inputs": [ + "{workspaceRoot}/graphql.config.ts", + "{projectRoot}/src/cli/api/graphql/business-platform-organizations/**/*.graphql" + ], + "outputs": ["{projectRoot}/src/cli/api/graphql/business-platform-organizations/generated/**/*.ts"], + "options": { + "commands": ["pnpm exec graphql-codegen --project=storeBusinessPlatformOrganizations"], + "cwd": "{workspaceRoot}" + } + }, + "graphql-codegen:generate:admin": { + "executor": "nx:run-commands", + "inputs": [ + "{workspaceRoot}/graphql.config.ts", + "{projectRoot}/src/cli/api/graphql/admin/**/*.graphql" + ], + "outputs": ["{projectRoot}/src/cli/api/graphql/admin/generated/**/*.ts"], + "options": { + "commands": ["pnpm exec graphql-codegen --project=storeAdmin"], + "cwd": "{workspaceRoot}" + } + }, + "graphql-codegen:postfix": { + "executor": "nx:run-commands", + "dependsOn": [ + "graphql-codegen:generate:business-platform-destinations", + "graphql-codegen:generate:business-platform-organizations", + "graphql-codegen:generate:admin" + ], + "inputs": [{"dependentTasksOutputFiles": "**/*.ts"}], + "outputs": [ + "{projectRoot}/src/cli/api/graphql/business-platform-destinations/generated/**/*.ts", + "{projectRoot}/src/cli/api/graphql/business-platform-organizations/generated/**/*.ts", + "{projectRoot}/src/cli/api/graphql/admin/generated/**/*.ts" + ], + "options": { + "commands": [ + "find ./packages/store/src/cli/api/graphql/business-platform-destinations/generated/ -type f -name '*.ts' -exec sh -c 'sed -i \"\" \"s|import \\* as Types from '\\''./types'\\'';|import \\* as Types from '\\''./types.js'\\'';|g; s|export const \\([A-Za-z0-9_]*\\)Document =|export const \\1 =|g\" \"$0\"' {} \\;", + "find ./packages/store/src/cli/api/graphql/business-platform-organizations/generated/ -type f -name '*.ts' -exec sh -c 'sed -i \"\" \"s|import \\* as Types from '\\''./types'\\'';|import \\* as Types from '\\''./types.js'\\'';|g; s|export const \\([A-Za-z0-9_]*\\)Document =|export const \\1 =|g\" \"$0\"' {} \\;", + "find ./packages/store/src/cli/api/graphql/admin/generated/ -type f -name '*.ts' -exec sh -c 'sed -i \"\" \"s|import \\* as Types from '\\''./types'\\'';|import \\* as Types from '\\''./types.js'\\'';|g; s|export const \\([A-Za-z0-9_]*\\)Document =|export const \\1 =|g\" \"$0\"' {} \\;" + ], + "cwd": "{workspaceRoot}" + } } } } diff --git a/packages/store/src/cli/api/graphql/admin/generated/store-info-admin-shop.ts b/packages/store/src/cli/api/graphql/admin/generated/store-info-admin-shop.ts new file mode 100644 index 00000000000..11c50cca2df --- /dev/null +++ b/packages/store/src/cli/api/graphql/admin/generated/store-info-admin-shop.ts @@ -0,0 +1,50 @@ +/* eslint-disable @typescript-eslint/consistent-type-definitions */ +import * as Types from './types.js' + +import {TypedDocumentNode as DocumentNode} from '@graphql-typed-document-node/core' + +export type StoreInfoAdminShopQueryVariables = Types.Exact<{[key: string]: never}> + +export type StoreInfoAdminShopQuery = { + shop: {shopOwnerName: string; ianaTimezone: string; setupRequired: boolean; features: {shopifyPlus: boolean}} +} + +export const StoreInfoAdminShop = { + kind: 'Document', + definitions: [ + { + kind: 'OperationDefinition', + operation: 'query', + name: {kind: 'Name', value: 'StoreInfoAdminShop'}, + selectionSet: { + kind: 'SelectionSet', + selections: [ + { + kind: 'Field', + name: {kind: 'Name', value: 'shop'}, + selectionSet: { + kind: 'SelectionSet', + selections: [ + {kind: 'Field', name: {kind: 'Name', value: 'shopOwnerName'}}, + {kind: 'Field', name: {kind: 'Name', value: 'ianaTimezone'}}, + {kind: 'Field', name: {kind: 'Name', value: 'setupRequired'}}, + { + kind: 'Field', + name: {kind: 'Name', value: 'features'}, + selectionSet: { + kind: 'SelectionSet', + selections: [ + {kind: 'Field', name: {kind: 'Name', value: 'shopifyPlus'}}, + {kind: 'Field', name: {kind: 'Name', value: '__typename'}}, + ], + }, + }, + {kind: 'Field', name: {kind: 'Name', value: '__typename'}}, + ], + }, + }, + ], + }, + }, + ], +} as unknown as DocumentNode diff --git a/packages/store/src/cli/api/graphql/admin/generated/types.d.ts b/packages/store/src/cli/api/graphql/admin/generated/types.d.ts new file mode 100644 index 00000000000..8df6b6878c3 --- /dev/null +++ b/packages/store/src/cli/api/graphql/admin/generated/types.d.ts @@ -0,0 +1,118 @@ +/* eslint-disable @typescript-eslint/consistent-type-definitions, @typescript-eslint/naming-convention, @typescript-eslint/no-explicit-any, tsdoc/syntax, @typescript-eslint/no-duplicate-type-constituents, @typescript-eslint/no-redundant-type-constituents, @nx/enforce-module-boundaries */ +import {JsonMapType} from '@shopify/cli-kit/node/toml' +export type Maybe = T | null; +export type InputMaybe = Maybe; +export type Exact = { [K in keyof T]: T[K] }; +export type MakeOptional = Omit & { [SubKey in K]?: Maybe }; +export type MakeMaybe = Omit & { [SubKey in K]: Maybe }; +export type MakeEmpty = { [_ in K]?: never }; +export type Incremental = T | { [P in keyof T]?: P extends ' $fragmentName' | '__typename' ? T[P] : never }; +/** All built-in and custom scalars, mapped to their actual values */ +export type Scalars = { + ID: { input: string; output: string; } + String: { input: string; output: string; } + Boolean: { input: boolean; output: boolean; } + Int: { input: number; output: number; } + Float: { input: number; output: number; } + /** + * An Amazon Web Services Amazon Resource Name (ARN), including the Region and account ID. + * For more information, refer to [Amazon Resource Names](https://docs.aws.amazon.com/general/latest/gr/aws-arns-and-namespaces.html). + */ + ARN: { input: any; output: any; } + /** + * Represents non-fractional signed whole numeric values. Since the value may + * exceed the size of a 32-bit integer, it's encoded as a string. + */ + BigInt: { input: any; output: any; } + /** + * A string containing a hexadecimal representation of a color. + * + * For example, "#6A8D48". + */ + Color: { input: any; output: any; } + /** + * Represents an [ISO 8601](https://en.wikipedia.org/wiki/ISO_8601)-encoded date string. + * For example, September 7, 2019 is represented as `"2019-07-16"`. + */ + Date: { input: any; output: any; } + /** + * Represents an [ISO 8601](https://en.wikipedia.org/wiki/ISO_8601)-encoded date and time string. + * For example, 3:50 pm on September 7, 2019 in the time zone of UTC (Coordinated Universal Time) is + * represented as `"2019-09-07T15:50:00Z`". + */ + DateTime: { input: any; output: any; } + /** + * A signed decimal number, which supports arbitrary precision and is serialized as a string. + * + * Example values: `"29.99"`, `"29.999"`. + */ + Decimal: { input: any; output: any; } + /** + * A string containing a strict subset of HTML code. Non-allowed tags will be stripped out. + * Allowed tags: + * * `a` (allowed attributes: `href`, `target`) + * * `b` + * * `br` + * * `em` + * * `i` + * * `strong` + * * `u` + * Use [HTML](https://shopify.dev/api/admin-graphql/latest/scalars/HTML) instead if you need to + * include other HTML tags. + * + * Example value: `"Your current domain is example.myshopify.com."` + */ + FormattedString: { input: any; output: any; } + /** + * A string containing HTML code. Refer to the [HTML spec](https://html.spec.whatwg.org/#elements-3) for a + * complete list of HTML elements. + * + * Example value: `"

Grey cotton knit sweater.

"` + */ + HTML: { input: any; output: any; } + /** + * A [JSON](https://www.json.org/json-en.html) object. + * + * Example value: + * `{ + * "product": { + * "id": "gid://shopify/Product/1346443542550", + * "title": "White T-shirt", + * "options": [{ + * "name": "Size", + * "values": ["M", "L"] + * }] + * } + * }` + */ + JSON: { input: JsonMapType | string; output: JsonMapType; } + /** A monetary value string without a currency symbol or code. Example value: `"100.57"`. */ + Money: { input: any; output: any; } + /** + * Represents a unique identifier in the Storefront API. A `StorefrontID` value can + * be used wherever an ID is expected in the Storefront API. + * + * Example value: `"Z2lkOi8vc2hvcGlmeS9Qcm9kdWN0LzEwMDc5Nzg1MTAw"`. + */ + StorefrontID: { input: any; output: any; } + /** + * Represents an [RFC 3986](https://datatracker.ietf.org/doc/html/rfc3986) and + * [RFC 3987](https://datatracker.ietf.org/doc/html/rfc3987)-compliant URI string. + * + * For example, `"https://example.myshopify.com"` is a valid URL. It includes a scheme (`https`) and a host + * (`example.myshopify.com`). + */ + URL: { input: string; output: string; } + /** + * An unsigned 64-bit integer. Represents whole numeric values between 0 and 2^64 - 1 encoded as a string of base-10 digits. + * + * Example value: `"50"`. + */ + UnsignedInt64: { input: any; output: any; } + /** + * Time between UTC time and a location's observed time, in the format `"+HH:MM"` or `"-HH:MM"`. + * + * Example value: `"-07:00"`. + */ + UtcOffset: { input: any; output: any; } +}; diff --git a/packages/store/src/cli/api/graphql/admin/queries/store-info-admin-shop.graphql b/packages/store/src/cli/api/graphql/admin/queries/store-info-admin-shop.graphql new file mode 100644 index 00000000000..068bceb73dc --- /dev/null +++ b/packages/store/src/cli/api/graphql/admin/queries/store-info-admin-shop.graphql @@ -0,0 +1,10 @@ +query StoreInfoAdminShop { + shop { + shopOwnerName + ianaTimezone + setupRequired + features { + shopifyPlus + } + } +} diff --git a/packages/store/src/cli/api/graphql/business-platform-destinations/generated/store-info-destinations.ts b/packages/store/src/cli/api/graphql/business-platform-destinations/generated/store-info-destinations.ts new file mode 100644 index 00000000000..27fd4e37f73 --- /dev/null +++ b/packages/store/src/cli/api/graphql/business-platform-destinations/generated/store-info-destinations.ts @@ -0,0 +1,105 @@ +/* eslint-disable @typescript-eslint/consistent-type-definitions */ +import * as Types from './types.js' + +import {TypedDocumentNode as DocumentNode} from '@graphql-typed-document-node/core' + +export type StoreInfoDestinationsQueryVariables = Types.Exact<{ + search: Types.Scalars['String']['input'] +}> + +export type StoreInfoDestinationsQuery = { + currentUserAccount?: { + destinations: { + nodes: { + id: unknown + publicId: unknown + name: string + handle?: string | null + shortName?: string | null + primaryDomain?: string | null + webUrl: string + status: Types.DestinationStatus + accountStatus?: Types.AccountStatus | null + isAppDevelopment?: boolean | null + lastAccess?: unknown | null + }[] + } + } | null +} + +export const StoreInfoDestinations = { + kind: 'Document', + definitions: [ + { + kind: 'OperationDefinition', + operation: 'query', + name: {kind: 'Name', value: 'StoreInfoDestinations'}, + variableDefinitions: [ + { + kind: 'VariableDefinition', + variable: {kind: 'Variable', name: {kind: 'Name', value: 'search'}}, + type: {kind: 'NonNullType', type: {kind: 'NamedType', name: {kind: 'Name', value: 'String'}}}, + }, + ], + selectionSet: { + kind: 'SelectionSet', + selections: [ + { + kind: 'Field', + name: {kind: 'Name', value: 'currentUserAccount'}, + selectionSet: { + kind: 'SelectionSet', + selections: [ + { + kind: 'Field', + name: {kind: 'Name', value: 'destinations'}, + arguments: [ + { + kind: 'Argument', + name: {kind: 'Name', value: 'search'}, + value: {kind: 'Variable', name: {kind: 'Name', value: 'search'}}, + }, + { + kind: 'Argument', + name: {kind: 'Name', value: 'shopsOnly'}, + value: {kind: 'BooleanValue', value: true}, + }, + {kind: 'Argument', name: {kind: 'Name', value: 'first'}, value: {kind: 'IntValue', value: '25'}}, + ], + selectionSet: { + kind: 'SelectionSet', + selections: [ + { + kind: 'Field', + name: {kind: 'Name', value: 'nodes'}, + selectionSet: { + kind: 'SelectionSet', + selections: [ + {kind: 'Field', name: {kind: 'Name', value: 'id'}}, + {kind: 'Field', name: {kind: 'Name', value: 'publicId'}}, + {kind: 'Field', name: {kind: 'Name', value: 'name'}}, + {kind: 'Field', name: {kind: 'Name', value: 'handle'}}, + {kind: 'Field', name: {kind: 'Name', value: 'shortName'}}, + {kind: 'Field', name: {kind: 'Name', value: 'primaryDomain'}}, + {kind: 'Field', name: {kind: 'Name', value: 'webUrl'}}, + {kind: 'Field', name: {kind: 'Name', value: 'status'}}, + {kind: 'Field', name: {kind: 'Name', value: 'accountStatus'}}, + {kind: 'Field', name: {kind: 'Name', value: 'isAppDevelopment'}}, + {kind: 'Field', name: {kind: 'Name', value: 'lastAccess'}}, + {kind: 'Field', name: {kind: 'Name', value: '__typename'}}, + ], + }, + }, + {kind: 'Field', name: {kind: 'Name', value: '__typename'}}, + ], + }, + }, + {kind: 'Field', name: {kind: 'Name', value: '__typename'}}, + ], + }, + }, + ], + }, + }, + ], +} as unknown as DocumentNode diff --git a/packages/store/src/cli/api/graphql/business-platform-destinations/generated/store-info-owning-org.ts b/packages/store/src/cli/api/graphql/business-platform-destinations/generated/store-info-owning-org.ts new file mode 100644 index 00000000000..4c00ecaac07 --- /dev/null +++ b/packages/store/src/cli/api/graphql/business-platform-destinations/generated/store-info-owning-org.ts @@ -0,0 +1,64 @@ +/* eslint-disable @typescript-eslint/consistent-type-definitions */ +import * as Types from './types.js' + +import {TypedDocumentNode as DocumentNode} from '@graphql-typed-document-node/core' + +export type StoreInfoOwningOrgQueryVariables = Types.Exact<{ + destinationPublicId: Types.Scalars['DestinationPublicID']['input'] +}> + +export type StoreInfoOwningOrgQuery = { + currentUserAccount?: {organizationForDestination?: {id: string; name: string} | null} | null +} + +export const StoreInfoOwningOrg = { + kind: 'Document', + definitions: [ + { + kind: 'OperationDefinition', + operation: 'query', + name: {kind: 'Name', value: 'StoreInfoOwningOrg'}, + variableDefinitions: [ + { + kind: 'VariableDefinition', + variable: {kind: 'Variable', name: {kind: 'Name', value: 'destinationPublicId'}}, + type: {kind: 'NonNullType', type: {kind: 'NamedType', name: {kind: 'Name', value: 'DestinationPublicID'}}}, + }, + ], + selectionSet: { + kind: 'SelectionSet', + selections: [ + { + kind: 'Field', + name: {kind: 'Name', value: 'currentUserAccount'}, + selectionSet: { + kind: 'SelectionSet', + selections: [ + { + kind: 'Field', + name: {kind: 'Name', value: 'organizationForDestination'}, + arguments: [ + { + kind: 'Argument', + name: {kind: 'Name', value: 'destinationPublicId'}, + value: {kind: 'Variable', name: {kind: 'Name', value: 'destinationPublicId'}}, + }, + ], + selectionSet: { + kind: 'SelectionSet', + selections: [ + {kind: 'Field', name: {kind: 'Name', value: 'id'}}, + {kind: 'Field', name: {kind: 'Name', value: 'name'}}, + {kind: 'Field', name: {kind: 'Name', value: '__typename'}}, + ], + }, + }, + {kind: 'Field', name: {kind: 'Name', value: '__typename'}}, + ], + }, + }, + ], + }, + }, + ], +} as unknown as DocumentNode diff --git a/packages/store/src/cli/api/graphql/business-platform-destinations/generated/types.d.ts b/packages/store/src/cli/api/graphql/business-platform-destinations/generated/types.d.ts new file mode 100644 index 00000000000..e739028cbe6 --- /dev/null +++ b/packages/store/src/cli/api/graphql/business-platform-destinations/generated/types.d.ts @@ -0,0 +1,36 @@ +/* eslint-disable @typescript-eslint/consistent-type-definitions, @typescript-eslint/naming-convention, @typescript-eslint/no-explicit-any, tsdoc/syntax, @typescript-eslint/no-duplicate-type-constituents, @typescript-eslint/no-redundant-type-constituents, @nx/enforce-module-boundaries */ +import {JsonMapType} from '@shopify/cli-kit/node/toml' +export type Maybe = T | null; +export type InputMaybe = Maybe; +export type Exact = { [K in keyof T]: T[K] }; +export type MakeOptional = Omit & { [SubKey in K]?: Maybe }; +export type MakeMaybe = Omit & { [SubKey in K]: Maybe }; +export type MakeEmpty = { [_ in K]?: never }; +export type Incremental = T | { [P in keyof T]?: P extends ' $fragmentName' | '__typename' ? T[P] : never }; +/** All built-in and custom scalars, mapped to their actual values */ +export type Scalars = { + ID: { input: string; output: string; } + String: { input: string; output: string; } + Boolean: { input: boolean; output: boolean; } + Int: { input: number; output: number; } + Float: { input: number; output: number; } + DestinationID: { input: any; output: any; } + DestinationPublicID: { input: any; output: any; } + GlobalID: { input: string; output: string; } + /** An ISO 8601-encoded datetime */ + ISO8601DateTime: { input: any; output: any; } + /** The ID for a Organization. */ + OrganizationID: { input: any; output: any; } +}; + +export type AccountStatus = + /** Account is active on the destination */ + | 'ACTIVE' + /** Account is pending on the destination */ + | 'PENDING' + /** Account is suspended on the destination */ + | 'SUSPENDED'; + +export type DestinationStatus = + | 'ACTIVE' + | 'INACTIVE'; diff --git a/packages/store/src/cli/api/graphql/business-platform-destinations/queries/store-info-destinations.graphql b/packages/store/src/cli/api/graphql/business-platform-destinations/queries/store-info-destinations.graphql new file mode 100644 index 00000000000..ccd0d3272d3 --- /dev/null +++ b/packages/store/src/cli/api/graphql/business-platform-destinations/queries/store-info-destinations.graphql @@ -0,0 +1,19 @@ +query StoreInfoDestinations($search: String!) { + currentUserAccount { + destinations(search: $search, shopsOnly: true, first: 25) { + nodes { + id + publicId + name + handle + shortName + primaryDomain + webUrl + status + accountStatus + isAppDevelopment + lastAccess + } + } + } +} diff --git a/packages/store/src/cli/api/graphql/business-platform-destinations/queries/store-info-owning-org.graphql b/packages/store/src/cli/api/graphql/business-platform-destinations/queries/store-info-owning-org.graphql new file mode 100644 index 00000000000..21343979d24 --- /dev/null +++ b/packages/store/src/cli/api/graphql/business-platform-destinations/queries/store-info-owning-org.graphql @@ -0,0 +1,8 @@ +query StoreInfoOwningOrg($destinationPublicId: DestinationPublicID!) { + currentUserAccount { + organizationForDestination(destinationPublicId: $destinationPublicId) { + id + name + } + } +} diff --git a/packages/store/src/cli/api/graphql/business-platform-organizations/generated/store-info-shop.ts b/packages/store/src/cli/api/graphql/business-platform-organizations/generated/store-info-shop.ts new file mode 100644 index 00000000000..c988a60fda3 --- /dev/null +++ b/packages/store/src/cli/api/graphql/business-platform-organizations/generated/store-info-shop.ts @@ -0,0 +1,112 @@ +/* eslint-disable @typescript-eslint/consistent-type-definitions */ +import * as Types from './types.js' + +import {TypedDocumentNode as DocumentNode} from '@graphql-typed-document-node/core' + +export type StoreInfoShopQueryVariables = Types.Exact<{ + search?: Types.InputMaybe +}> + +export type StoreInfoShopQuery = { + organization?: { + id: string + name: string + accessibleShops?: { + edges: { + node: { + name: string + primaryDomain?: string | null + storeType?: Types.Store | null + status: Types.ShopPropertyStatus + planName?: string | null + planVariantName?: string | null + billingCurrency?: string | null + createdAt: unknown + shortName?: string | null + } + }[] + } | null + } | null +} + +export const StoreInfoShop = { + kind: 'Document', + definitions: [ + { + kind: 'OperationDefinition', + operation: 'query', + name: {kind: 'Name', value: 'StoreInfoShop'}, + variableDefinitions: [ + { + kind: 'VariableDefinition', + variable: {kind: 'Variable', name: {kind: 'Name', value: 'search'}}, + type: {kind: 'NamedType', name: {kind: 'Name', value: 'String'}}, + }, + ], + selectionSet: { + kind: 'SelectionSet', + selections: [ + { + kind: 'Field', + name: {kind: 'Name', value: 'organization'}, + selectionSet: { + kind: 'SelectionSet', + selections: [ + {kind: 'Field', name: {kind: 'Name', value: 'id'}}, + {kind: 'Field', name: {kind: 'Name', value: 'name'}}, + { + kind: 'Field', + name: {kind: 'Name', value: 'accessibleShops'}, + arguments: [ + {kind: 'Argument', name: {kind: 'Name', value: 'first'}, value: {kind: 'IntValue', value: '5'}}, + { + kind: 'Argument', + name: {kind: 'Name', value: 'search'}, + value: {kind: 'Variable', name: {kind: 'Name', value: 'search'}}, + }, + ], + selectionSet: { + kind: 'SelectionSet', + selections: [ + { + kind: 'Field', + name: {kind: 'Name', value: 'edges'}, + selectionSet: { + kind: 'SelectionSet', + selections: [ + { + kind: 'Field', + name: {kind: 'Name', value: 'node'}, + selectionSet: { + kind: 'SelectionSet', + selections: [ + {kind: 'Field', name: {kind: 'Name', value: 'name'}}, + {kind: 'Field', name: {kind: 'Name', value: 'primaryDomain'}}, + {kind: 'Field', name: {kind: 'Name', value: 'storeType'}}, + {kind: 'Field', name: {kind: 'Name', value: 'status'}}, + {kind: 'Field', name: {kind: 'Name', value: 'planName'}}, + {kind: 'Field', name: {kind: 'Name', value: 'planVariantName'}}, + {kind: 'Field', name: {kind: 'Name', value: 'billingCurrency'}}, + {kind: 'Field', name: {kind: 'Name', value: 'createdAt'}}, + {kind: 'Field', name: {kind: 'Name', value: 'shortName'}}, + {kind: 'Field', name: {kind: 'Name', value: '__typename'}}, + ], + }, + }, + {kind: 'Field', name: {kind: 'Name', value: '__typename'}}, + ], + }, + }, + {kind: 'Field', name: {kind: 'Name', value: '__typename'}}, + ], + }, + }, + {kind: 'Field', name: {kind: 'Name', value: '__typename'}}, + ], + }, + }, + ], + }, + }, + ], +} as unknown as DocumentNode diff --git a/packages/store/src/cli/api/graphql/business-platform-organizations/generated/types.d.ts b/packages/store/src/cli/api/graphql/business-platform-organizations/generated/types.d.ts new file mode 100644 index 00000000000..968d9305931 --- /dev/null +++ b/packages/store/src/cli/api/graphql/business-platform-organizations/generated/types.d.ts @@ -0,0 +1,95 @@ +/* eslint-disable @typescript-eslint/consistent-type-definitions, @typescript-eslint/naming-convention, @typescript-eslint/no-explicit-any, tsdoc/syntax, @typescript-eslint/no-duplicate-type-constituents, @typescript-eslint/no-redundant-type-constituents, @nx/enforce-module-boundaries */ +import {JsonMapType} from '@shopify/cli-kit/node/toml' +export type Maybe = T | null; +export type InputMaybe = Maybe; +export type Exact = { [K in keyof T]: T[K] }; +export type MakeOptional = Omit & { [SubKey in K]?: Maybe }; +export type MakeMaybe = Omit & { [SubKey in K]: Maybe }; +export type MakeEmpty = { [_ in K]?: never }; +export type Incremental = T | { [P in keyof T]?: P extends ' $fragmentName' | '__typename' ? T[P] : never }; +/** All built-in and custom scalars, mapped to their actual values */ +export type Scalars = { + ID: { input: string; output: string; } + String: { input: string; output: string; } + Boolean: { input: boolean; output: boolean; } + Int: { input: number; output: number; } + Float: { input: number; output: number; } + AccessRoleAssignee: { input: any; output: any; } + /** The ID for a AccessRole. */ + AccessRoleID: { input: any; output: any; } + AccessRoleRecordId: { input: any; output: any; } + /** The ID for a ActionAudit. */ + ActionAuditID: { input: any; output: any; } + /** The ID for a Address. */ + AddressID: { input: any; output: any; } + /** The ID for a Attestation. */ + AttestationID: { input: any; output: any; } + /** The ID for a BulkDataOperation. */ + BulkDataOperationID: { input: any; output: any; } + /** The ID for a BusinessUser. */ + BusinessUserID: { input: any; output: any; } + /** The ID for a BusinessUsersImport. */ + BusinessUsersImportID: { input: any; output: any; } + /** A signed decimal number, which supports arbitrary precision and is serialized as a string. */ + Decimal: { input: any; output: any; } + /** The ID for a DocumentAttachment. */ + DocumentAttachmentID: { input: any; output: any; } + /** The ID for a EntitySupportingDocument. */ + EntitySupportingDocumentID: { input: any; output: any; } + GlobalID: { input: string; output: string; } + /** The ID for a GovernmentIdentifier. */ + GovernmentIdentifierID: { input: any; output: any; } + /** The ID for a Group. */ + GroupID: { input: any; output: any; } + /** An ISO 8601-encoded date */ + ISO8601Date: { input: any; output: any; } + /** An ISO 8601-encoded datetime */ + ISO8601DateTime: { input: any; output: any; } + /** Represents untyped JSON */ + JSON: { input: JsonMapType | string; output: JsonMapType; } + /** The ID for a LegalEntity. */ + LegalEntityID: { input: any; output: any; } + /** The ID for a OrganizationDomain. */ + OrganizationDomainID: { input: any; output: any; } + /** The ID for a Organization. */ + OrganizationID: { input: any; output: any; } + /** The ID for a OrganizationUser. */ + OrganizationUserID: { input: any; output: any; } + /** The ID for a PersonAlias. */ + PersonAliasID: { input: any; output: any; } + /** The ID for a Person. */ + PersonID: { input: any; output: any; } + /** The ID for a Principal. */ + PrincipalID: { input: any; output: any; } + /** The ID for a Property. */ + PropertyID: { input: any; output: any; } + PropertyId: { input: string; output: string; } + PropertyPublicID: { input: string; output: string; } + /** The ID for a PropertyTransferRequest. */ + PropertyTransferRequestID: { input: any; output: any; } + /** The ID for a Role. */ + RoleID: { input: any; output: any; } + /** The ID for a Shop. */ + ShopID: { input: any; output: any; } + /** The ID for a ShopifyShop. */ + ShopifyShopID: { input: any; output: any; } + /** The ID for a StoreAdditionRequest. */ + StoreAdditionRequestID: { input: any; output: any; } + SupportedEntityId: { input: any; output: any; } + /** The ID for a SupportingDocument. */ + SupportingDocumentID: { input: any; output: any; } + /** An RFC 3986 and RFC 3987 compliant URI string. */ + URL: { input: string; output: string; } +}; + +export type ShopPropertyStatus = + | 'ACTIVE' + | 'INACTIVE'; + +export type Store = + | 'APP_DEVELOPMENT' + | 'CLIENT_TRANSFER' + | 'COLLABORATOR' + | 'DEVELOPMENT' + | 'DEVELOPMENT_SUPERSET' + | 'PRODUCTION'; diff --git a/packages/store/src/cli/api/graphql/business-platform-organizations/queries/store-info-shop.graphql b/packages/store/src/cli/api/graphql/business-platform-organizations/queries/store-info-shop.graphql new file mode 100644 index 00000000000..81406630e25 --- /dev/null +++ b/packages/store/src/cli/api/graphql/business-platform-organizations/queries/store-info-shop.graphql @@ -0,0 +1,21 @@ +query StoreInfoShop($search: String) { + organization { + id + name + accessibleShops(first: 5, search: $search) { + edges { + node { + name + primaryDomain + storeType + status + planName + planVariantName + billingCurrency + createdAt + shortName + } + } + } + } +} diff --git a/packages/store/src/cli/commands/store/auth.ts b/packages/store/src/cli/commands/store/auth.ts index 9a7c0eb2fdf..e78e594e1d3 100644 --- a/packages/store/src/cli/commands/store/auth.ts +++ b/packages/store/src/cli/commands/store/auth.ts @@ -1,8 +1,8 @@ import {authenticateStoreWithApp} from '../../services/store/auth/index.js' import {createStoreAuthPresenter} from '../../services/store/auth/result.js' import StoreCommand from '../../utilities/store-command.js' +import {storeFlags} from '../../flags.js' import {globalFlags, jsonFlag} from '@shopify/cli-kit/node/cli' -import {normalizeStoreFqdn} from '@shopify/cli-kit/node/context/fqdn' import {Flags} from '@oclif/core' export default class StoreAuth extends StoreCommand { @@ -22,13 +22,7 @@ Re-run this command if the stored token is missing, expires, or no longer has th static flags = { ...globalFlags, ...jsonFlag, - store: Flags.string({ - char: 's', - description: 'The myshopify.com domain of the store to authenticate against.', - env: 'SHOPIFY_FLAG_STORE', - parse: async (input) => normalizeStoreFqdn(input), - required: true, - }), + store: storeFlags.store, scopes: Flags.string({ description: 'Comma-separated Admin API scopes to request for the app.', env: 'SHOPIFY_FLAG_SCOPES', diff --git a/packages/store/src/cli/commands/store/execute.ts b/packages/store/src/cli/commands/store/execute.ts index e3f0bd3fc3b..f1648d232d4 100644 --- a/packages/store/src/cli/commands/store/execute.ts +++ b/packages/store/src/cli/commands/store/execute.ts @@ -1,8 +1,8 @@ import {executeStoreOperation} from '../../services/store/execute/index.js' import {writeOrOutputStoreExecuteResult} from '../../services/store/execute/result.js' import StoreCommand from '../../utilities/store-command.js' +import {storeFlags} from '../../flags.js' import {globalFlags, jsonFlag} from '@shopify/cli-kit/node/cli' -import {normalizeStoreFqdn} from '@shopify/cli-kit/node/context/fqdn' import {resolvePath} from '@shopify/cli-kit/node/path' import {Flags} from '@oclif/core' @@ -52,13 +52,7 @@ Mutations are disabled by default. Re-run with \`--allow-mutations\` if you inte parse: async (input) => resolvePath(input), exclusive: ['variables'], }), - store: Flags.string({ - char: 's', - description: 'The myshopify.com domain of the store to execute against.', - env: 'SHOPIFY_FLAG_STORE', - parse: async (input) => normalizeStoreFqdn(input), - required: true, - }), + store: storeFlags.store, version: Flags.string({ description: 'The API version to use for the query or mutation. Defaults to the latest stable version.', env: 'SHOPIFY_FLAG_VERSION', 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 00000000000..6ae96627e4e --- /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({ + shop_domain: 'shop.myshopify.com', + auth_status: {authed: false, source: 'store-auth'}, + }) + }) + + 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({shop_domain: '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/commands/store/info.ts b/packages/store/src/cli/commands/store/info.ts new file mode 100644 index 00000000000..edd45ab5e2d --- /dev/null +++ b/packages/store/src/cli/commands/store/info.ts @@ -0,0 +1,36 @@ +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: domain, name, type, status, URLs, plan, billing currency, and more. + +If you have run \`store auth\` for the store, additional fields are included: shop owner, timezone, setup status, and Plus subscription. + +Use \`--json\` for machine-readable output. When an individual field can't be fetched, the rest of the output is still returned and the missing fields are listed with a reason.` + + 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/flags.ts b/packages/store/src/cli/flags.ts new file mode 100644 index 00000000000..541d7ecda73 --- /dev/null +++ b/packages/store/src/cli/flags.ts @@ -0,0 +1,12 @@ +import {normalizeStoreFqdn} from '@shopify/cli-kit/node/context/fqdn' +import {Flags} from '@oclif/core' + +export const storeFlags = { + store: Flags.string({ + char: 's', + description: 'The myshopify.com domain of the store.', + env: 'SHOPIFY_FLAG_STORE', + parse: async (input) => normalizeStoreFqdn(input), + required: true, + }), +} diff --git a/packages/store/src/cli/services/store/info/admin-shop.test.ts b/packages/store/src/cli/services/store/info/admin-shop.test.ts new file mode 100644 index 00000000000..47eb1ac252e --- /dev/null +++ b/packages/store/src/cli/services/store/info/admin-shop.test.ts @@ -0,0 +1,90 @@ +import {fetchAdminShop} from './admin-shop.js' +import {prepareAdminStoreGraphQLContext} from '../execute/admin-context.js' +import {adminRequestDoc} from '@shopify/cli-kit/node/api/admin' +import {AbortError} from '@shopify/cli-kit/node/error' +import {describe, test, expect, vi, beforeEach} from 'vitest' + +vi.mock('../execute/admin-context.js') +vi.mock('@shopify/cli-kit/node/api/admin') + +const SHOP = 'shop.myshopify.com' + +function adminContext() { + return { + adminSession: {storeFqdn: SHOP, token: 'admin-tok'}, + version: '2026-01', + session: {} as never, + } +} + +describe('fetchAdminShop', () => { + beforeEach(() => { + vi.resetAllMocks() + }) + + test('returns skipped with a hint when no stored auth exists', async () => { + vi.mocked(prepareAdminStoreGraphQLContext).mockRejectedValueOnce( + new AbortError('Run `shopify store auth` to authenticate before executing operations against this store.'), + ) + + const result = await fetchAdminShop(SHOP) + + expect(result.skipped).toBe(true) + if (result.skipped) { + expect(result.reason).toContain('store auth') + expect(result.reason).toContain(SHOP) + } + }) + + test('returns mapped shop fields on success', async () => { + vi.mocked(prepareAdminStoreGraphQLContext).mockResolvedValueOnce(adminContext() as never) + vi.mocked(adminRequestDoc).mockResolvedValueOnce({ + shop: { + shopOwnerName: 'Alice', + ianaTimezone: 'America/New_York', + setupRequired: false, + features: {shopifyPlus: true}, + }, + } as never) + + const result = await fetchAdminShop(SHOP) + expect(result.skipped).toBe(false) + if (!result.skipped) { + expect(result.shop).toEqual({ + shopOwnerName: 'Alice', + ianaTimezone: 'America/New_York', + setupRequired: false, + shopifyPlus: true, + }) + } + }) + + test('omits shopifyPlus when the features object is absent', async () => { + vi.mocked(prepareAdminStoreGraphQLContext).mockResolvedValueOnce(adminContext() as never) + vi.mocked(adminRequestDoc).mockResolvedValueOnce({ + shop: { + shopOwnerName: 'Bob', + ianaTimezone: null, + setupRequired: null, + features: null, + }, + } as never) + + const result = await fetchAdminShop(SHOP) + expect(result.skipped).toBe(false) + if (!result.skipped) { + expect(result.shop).toEqual({shopOwnerName: 'Bob'}) + } + }) + + test('returns skipped with the error message when the Admin request fails', async () => { + vi.mocked(prepareAdminStoreGraphQLContext).mockResolvedValueOnce(adminContext() as never) + vi.mocked(adminRequestDoc).mockRejectedValueOnce(new Error('network down')) + + const result = await fetchAdminShop(SHOP) + expect(result.skipped).toBe(true) + if (result.skipped) { + expect(result.reason).toContain('network down') + } + }) +}) diff --git a/packages/store/src/cli/services/store/info/admin-shop.ts b/packages/store/src/cli/services/store/info/admin-shop.ts new file mode 100644 index 00000000000..f55a3efdf68 --- /dev/null +++ b/packages/store/src/cli/services/store/info/admin-shop.ts @@ -0,0 +1,71 @@ +import {StoreInfoAdminShop} from '../../../api/graphql/admin/generated/store-info-admin-shop.js' +import {prepareAdminStoreGraphQLContext} from '../execute/admin-context.js' +import {adminRequestDoc} from '@shopify/cli-kit/node/api/admin' +import {AbortError} from '@shopify/cli-kit/node/error' +import {compact} from '@shopify/cli-kit/common/object' +import type {AdminShopFetchOutcome, AdminShopFields} from './types.js' +import type { + StoreInfoAdminShopQuery, + StoreInfoAdminShopQueryVariables, +} from '../../../api/graphql/admin/generated/store-info-admin-shop.js' + +export async function fetchAdminShop(store: string): Promise { + let context + try { + context = await prepareAdminStoreGraphQLContext({store}) + } catch (error) { + return { + skipped: true, + reason: skipReasonForContextError(error, store), + } + } + + try { + const response = await adminRequestDoc({ + query: StoreInfoAdminShop, + session: context.adminSession, + version: context.version, + variables: {}, + responseOptions: {handleErrors: false}, + }) + + return { + skipped: false, + shop: mapAdminShop(response.shop), + } + // eslint-disable-next-line no-catch-all/no-catch-all + } catch (error) { + return { + skipped: true, + reason: `Admin API request failed: ${errorMessage(error)}`, + } + } +} + +function mapAdminShop(shop: StoreInfoAdminShopQuery['shop']): AdminShopFields { + return compact({ + shopOwnerName: shop.shopOwnerName, + ianaTimezone: shop.ianaTimezone, + setupRequired: shop.setupRequired, + shopifyPlus: shop.features?.shopifyPlus, + }) as AdminShopFields +} + +function skipReasonForContextError(error: unknown, store: string): string { + if (error instanceof AbortError) { + // Most common: no stored session for this shop. Distinguish from other AbortErrors + // (e.g. refresh failure) by surfacing the underlying message verbatim, with a hint + // to run `store auth` when the message indicates a missing/expired session. + const message = error.message + if (/store auth|not authenticated|no stored|reauthenticate/i.test(message)) { + return `No \`store auth\` for ${store} — run \`shopify store auth --store ${store}\` to enable these fields.` + } + return message + } + return `Could not prepare Admin context for ${store}: ${errorMessage(error)}` +} + +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/auth-status.test.ts b/packages/store/src/cli/services/store/info/auth-status.test.ts new file mode 100644 index 00000000000..81d3cd583d1 --- /dev/null +++ b/packages/store/src/cli/services/store/info/auth-status.test.ts @@ -0,0 +1,54 @@ +import {readAuthStatus} from './auth-status.js' +import {getCurrentStoredStoreAppSession} from '../auth/session-store.js' +import {describe, test, expect, vi, beforeEach} from 'vitest' + +vi.mock('../auth/session-store.js') + +describe('readAuthStatus', () => { + beforeEach(() => { + vi.resetAllMocks() + }) + + test('returns authed=false when no session is stored', () => { + vi.mocked(getCurrentStoredStoreAppSession).mockReturnValue(undefined) + + expect(readAuthStatus('shop.myshopify.com')).toEqual({ + authed: false, + source: 'store-auth', + }) + }) + + test('returns authed=true with expires_at when session has an expiry', () => { + vi.mocked(getCurrentStoredStoreAppSession).mockReturnValue({ + store: 'shop.myshopify.com', + clientId: 'client-id', + userId: '42', + accessToken: 'tok', + scopes: ['read_products'], + acquiredAt: '2026-05-01T00:00:00.000Z', + expiresAt: '2026-06-01T00:00:00.000Z', + }) + + expect(readAuthStatus('shop.myshopify.com')).toEqual({ + authed: true, + source: 'store-auth', + expires_at: '2026-06-01T00:00:00.000Z', + }) + }) + + test('omits expires_at when the session has no expiry', () => { + vi.mocked(getCurrentStoredStoreAppSession).mockReturnValue({ + store: 'shop.myshopify.com', + clientId: 'client-id', + userId: '42', + accessToken: 'tok', + scopes: ['read_products'], + acquiredAt: '2026-05-01T00:00:00.000Z', + }) + + expect(readAuthStatus('shop.myshopify.com')).toEqual({ + authed: true, + source: 'store-auth', + }) + }) +}) diff --git a/packages/store/src/cli/services/store/info/auth-status.ts b/packages/store/src/cli/services/store/info/auth-status.ts new file mode 100644 index 00000000000..3920dca4ed1 --- /dev/null +++ b/packages/store/src/cli/services/store/info/auth-status.ts @@ -0,0 +1,14 @@ +import {getCurrentStoredStoreAppSession} from '../auth/session-store.js' +import type {StoreInfoAuthStatus} from './types.js' + +export function readAuthStatus(store: string): StoreInfoAuthStatus { + const session = getCurrentStoredStoreAppSession(store) + if (!session) { + return {authed: false, source: 'store-auth'} + } + return { + authed: true, + source: 'store-auth', + ...(session.expiresAt ? {expires_at: session.expiresAt} : {}), + } +} 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 00000000000..764385f1603 --- /dev/null +++ b/packages/store/src/cli/services/store/info/destinations.test.ts @@ -0,0 +1,169 @@ +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 { + id: 'gid://destination/Destination/1', + publicId: 'dest-public-1', + name: 'Shop', + handle: 'shop', + shortName: 'shop', + primaryDomain: `https://${SHOP}`, + webUrl: `https://${SHOP}/admin`, + status: 'ACTIVE', + accountStatus: 'ACTIVE', + isAppDevelopment: false, + lastAccess: '2026-05-20T00:00:00.000Z', + ...overrides, + } +} + +describe('fetchDestinationsContext', () => { + beforeEach(() => { + vi.resetAllMocks() + 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((e: unknown) => e) + 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((e: unknown) => e) + expect(err).toBeInstanceOf(AbortError) + expect((err as AbortError).message).toContain(SHOP) + }) + + test('derives canonical myshopify handle from primaryDomain when BP returns handle: null', async () => { + vi.mocked(businessPlatformRequestDoc) + .mockResolvedValueOnce({ + currentUserAccount: { + destinations: { + nodes: [ + destinationNode({ + handle: null, + shortName: 'ACT', + primaryDomain: `https://${SHOP}`, + webUrl: `https://${SHOP}/admin`, + }), + ], + }, + }, + } as never) + .mockResolvedValueOnce({ + currentUserAccount: {organizationForDestination: {id: 'gid', name: 'Org'}}, + } as never) + + const ctx = await fetchDestinationsContext({store: SHOP}) + + expect(ctx.destination.handle).toBe('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('returns destination + owning org on success', 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(ctx.destination.primaryDomain).toBe(`https://${SHOP}`) + expect(ctx.owningOrg).toEqual({name: 'Acme Org', id: '123'}) + expect(ctx.owningOrgError).toBeUndefined() + }) + + test('records owning_org field error 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.destination.primaryDomain).toBe(`https://${SHOP}`) + expect(ctx.owningOrg).toBeUndefined() + expect(ctx.owningOrgError).toEqual({ + source: 'bp_destinations', + reason: 'Request failed: boom', + }) + }) + + test('records owning_org field error when the org is missing from 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() + expect(ctx.owningOrgError?.source).toBe('bp_destinations') + }) + + 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/destinations.ts b/packages/store/src/cli/services/store/info/destinations.ts new file mode 100644 index 00000000000..efe79dff165 --- /dev/null +++ b/packages/store/src/cli/services/store/info/destinations.ts @@ -0,0 +1,118 @@ +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, extractMyshopifyHandle} from '@shopify/cli-kit/common/url' +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 {DestinationNode, DestinationsContext, OwningOrgInternal, StoreInfoFieldError} 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} + }, + } + + // BP's destinations.search matches against handle/name; using the subdomain widens the hit + // rate vs. passing the full FQDN. + const lowerStore = options.store.toLowerCase() + const subdomain = lowerStore.replace(/\.myshopify\.com$/, '') + + const response = await businessPlatformRequestDoc({ + query: StoreInfoDestinations, + token, + variables: {search: subdomain}, + unauthorizedHandler, + }) + + const nodes = response.currentUserAccount?.destinations.nodes ?? [] + const matchedNode = nodes.find((node) => matchesStore(node, lowerStore)) + + 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.', + ) + } + + // BP returns `handle: null` and a non-subdomain `shortName` for many shops; derive the + // canonical myshopify handle from the URL fields so admin_url construction works. + const canonicalHandle = extractMyshopifyHandle(matchedNode.primaryDomain) ?? extractMyshopifyHandle(matchedNode.webUrl) + const matched: DestinationNode = { + ...matchedNode, + id: String(matchedNode.id), + publicId: String(matchedNode.publicId), + lastAccess: matchedNode.lastAccess == null ? null : String(matchedNode.lastAccess), + handle: canonicalHandle ?? matchedNode.handle ?? null, + } + + let owningOrg: OwningOrgInternal | undefined + let owningOrgError: StoreInfoFieldError | undefined + + try { + const orgResponse = await businessPlatformRequestDoc({ + query: StoreInfoOwningOrg, + token, + variables: {destinationPublicId: matched.publicId}, + unauthorizedHandler, + }) + const org = orgResponse.currentUserAccount?.organizationForDestination + if (org) { + const decodedId = org.id ? numericIdFromEncodedGid(org.id) : undefined + owningOrg = { + name: org.name, + ...(decodedId ? {id: decodedId} : {}), + } + } else { + owningOrgError = { + source: 'bp_destinations', + reason: 'organizationForDestination returned no organization for this destination.', + } + } + // eslint-disable-next-line no-catch-all/no-catch-all + } catch (error) { + owningOrgError = { + source: 'bp_destinations', + reason: `Request failed: ${errorMessage(error)}`, + } + } + + return { + destination: matched, + ...(owningOrg ? {owningOrg} : {}), + ...(owningOrgError ? {owningOrgError} : {}), + } +} + +function matchesStore(node: DestinationNodeFromQuery, lowerStore: string): boolean { + // BP returns URL strings (sometimes with scheme, sometimes bare) in primaryDomain/webUrl; + // extract the hostname and compare. handle/shortName are unreliable (often null or an + // abbreviation rather than the myshopify subdomain). + return [node.primaryDomain, node.webUrl].some((value) => extractHost(value) === lowerStore) +} + +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.test.ts b/packages/store/src/cli/services/store/info/index.test.ts new file mode 100644 index 00000000000..f4ea8b23438 --- /dev/null +++ b/packages/store/src/cli/services/store/info/index.test.ts @@ -0,0 +1,202 @@ +import {getStoreInfo} from './index.js' +import {fetchDestinationsContext} from './destinations.js' +import {fetchOrganizationShop} from './organization-shop.js' +import {fetchAdminShop} from './admin-shop.js' +import {readAuthStatus} from './auth-status.js' +import type {DestinationNode} from './types.js' +import {AbortError} from '@shopify/cli-kit/node/error' +import {describe, test, expect, vi, beforeEach} from 'vitest' + +vi.mock('./destinations.js') +vi.mock('./organization-shop.js') +vi.mock('./admin-shop.js') +vi.mock('./auth-status.js') + +const SHOP = 'shop.myshopify.com' + +function destination(overrides: Partial = {}): DestinationNode { + return { + id: 'gid-dest', + publicId: 'pub-1', + name: 'My Shop', + handle: 'my-shop', + shortName: 'my-shop', + primaryDomain: `https://${SHOP}`, + webUrl: `https://${SHOP}/admin`, + status: 'ACTIVE', + accountStatus: 'ACTIVE', + isAppDevelopment: false, + lastAccess: '2026-05-20T00:00:00.000Z', + ...overrides, + } +} + +function orgShop(overrides: Record = {}) { + return { + name: 'My Shop (Org)', + primaryDomain: `https://${SHOP}`, + storeType: 'PRODUCTION', + status: 'active', + planName: 'Basic', + planVariantName: 'monthly', + billingCurrency: 'USD', + createdAt: '2025-01-01T00:00:00.000Z', + shortName: 'my-shop', + ...overrides, + } +} + +describe('getStoreInfo', () => { + beforeEach(() => { + vi.resetAllMocks() + vi.mocked(readAuthStatus).mockReturnValue({authed: false, source: 'store-auth'}) + }) + + test('throws AbortError when no store is provided', async () => { + const err = await getStoreInfo({}).catch((e: unknown) => e) + expect(err).toBeInstanceOf(AbortError) + expect((err as AbortError).message).toContain('No store') + }) + + test('composes Tier 1 + Tier 2 fields from destinations + org-shop', async () => { + vi.mocked(fetchDestinationsContext).mockResolvedValueOnce({ + destination: destination(), + owningOrg: {name: 'Acme', id: '42'}, + }) + vi.mocked(fetchOrganizationShop).mockResolvedValueOnce(orgShop()) + + const result = await getStoreInfo({store: SHOP}) + + expect(result.shop_domain).toBe(SHOP) + expect(result.display_name).toBe('My Shop (Org)') + expect(result.store_type).toBe('PRODUCTION') + expect(result.status).toBe('active') + expect(result.primary_url).toBe(`https://${SHOP}`) + expect(result.admin_url).toBe('https://admin.shopify.com/store/my-shop') + expect(result.owning_org).toEqual({name: 'Acme'}) + expect(result.plan).toEqual({name: 'Basic', variant: 'monthly'}) + expect(result.billing_currency).toBe('USD') + expect(result.last_access).toBe('2026-05-20T00:00:00.000Z') + expect(result.auth_status).toEqual({authed: false, source: 'store-auth'}) + expect(result._field_errors).toBeUndefined() + expect(fetchAdminShop).not.toHaveBeenCalled() + }) + + test('records _field_errors for Tier 2 fields when org-shop fails', async () => { + vi.mocked(fetchDestinationsContext).mockResolvedValueOnce({ + destination: destination(), + owningOrg: {name: 'Acme', id: '42'}, + }) + vi.mocked(fetchOrganizationShop).mockRejectedValueOnce(new Error('5xx')) + + const result = await getStoreInfo({store: SHOP}) + + expect(result.plan).toBeUndefined() + expect(result.billing_currency).toBeUndefined() + expect(result._field_errors?.plan?.source).toBe('bp_organizations') + expect(result._field_errors?.plan?.reason).toContain('5xx') + expect(result._field_errors?.billing_currency).toBeDefined() + }) + + test('records _field_errors when owning org id is unknown (skips org-shop)', async () => { + vi.mocked(fetchDestinationsContext).mockResolvedValueOnce({ + destination: destination(), + owningOrgError: {source: 'bp_destinations', reason: 'no match'}, + }) + + const result = await getStoreInfo({store: SHOP}) + + expect(fetchOrganizationShop).not.toHaveBeenCalled() + expect(result._field_errors?.owning_org?.reason).toBe('no match') + expect(result._field_errors?.plan?.reason).toBe('no match') + }) + + test('when not authed, silently omits Admin-sourced fields and does not record field errors', async () => { + vi.mocked(fetchDestinationsContext).mockResolvedValueOnce({ + destination: destination(), + owningOrg: {name: 'Acme', id: '42'}, + }) + vi.mocked(fetchOrganizationShop).mockResolvedValueOnce(orgShop()) + + const result = await getStoreInfo({store: SHOP}) + + expect(fetchAdminShop).not.toHaveBeenCalled() + expect(result.shop_owner).toBeUndefined() + expect(result.timezone).toBeUndefined() + expect(result.setup_required).toBeUndefined() + expect(result.plus).toBeUndefined() + expect(result._field_errors).toBeUndefined() + }) + + test('when authed, includes Admin-sourced fields from the admin response', async () => { + vi.mocked(readAuthStatus).mockReturnValue({ + authed: true, + source: 'store-auth', + expires_at: '2026-12-01T00:00:00.000Z', + }) + vi.mocked(fetchDestinationsContext).mockResolvedValueOnce({ + destination: destination(), + owningOrg: {name: 'Acme', id: '42'}, + }) + vi.mocked(fetchOrganizationShop).mockResolvedValueOnce(orgShop()) + vi.mocked(fetchAdminShop).mockResolvedValueOnce({ + skipped: false, + shop: { + shopOwnerName: 'Alice', + ianaTimezone: 'America/New_York', + setupRequired: false, + shopifyPlus: true, + }, + }) + + const result = await getStoreInfo({store: SHOP}) + + expect(result.shop_owner).toEqual({name: 'Alice'}) + expect(result.timezone).toBe('America/New_York') + expect(result.setup_required).toBe(false) + expect(result.plus).toBe(true) + expect(result._field_errors).toBeUndefined() + }) + + test('when authed and admin skipped, records admin-source field errors', async () => { + vi.mocked(readAuthStatus).mockReturnValue({authed: true, source: 'store-auth'}) + vi.mocked(fetchDestinationsContext).mockResolvedValueOnce({ + destination: destination(), + owningOrg: {name: 'Acme', id: '42'}, + }) + vi.mocked(fetchOrganizationShop).mockResolvedValueOnce(orgShop()) + vi.mocked(fetchAdminShop).mockResolvedValueOnce({skipped: true, reason: 'Admin 5xx'}) + + const result = await getStoreInfo({store: SHOP}) + + expect(result._field_errors?.shop_owner?.source).toBe('admin') + expect(result._field_errors?.shop_owner?.reason).toBe('Admin 5xx') + expect(result._field_errors?.timezone?.reason).toBe('Admin 5xx') + }) + + test('falls back to destination fields when org-shop is unavailable', async () => { + vi.mocked(fetchDestinationsContext).mockResolvedValueOnce({ + destination: destination({isAppDevelopment: true}), + owningOrgError: {source: 'bp_destinations', reason: 'no match'}, + }) + + const result = await getStoreInfo({store: SHOP}) + + expect(result.display_name).toBe('My Shop') + expect(result.store_type).toBe('DEVELOPMENT') + expect(result.status).toBe('ACTIVE') + expect(result.primary_url).toBe(`https://${SHOP}`) + }) + + test('builds admin_url from shortName when handle is missing', async () => { + vi.mocked(fetchDestinationsContext).mockResolvedValueOnce({ + destination: destination({handle: null, shortName: 'fallback-handle'}), + owningOrg: {name: 'Acme', id: '42'}, + }) + vi.mocked(fetchOrganizationShop).mockResolvedValueOnce(orgShop()) + + const result = await getStoreInfo({store: SHOP}) + + expect(result.admin_url).toBe('https://admin.shopify.com/store/fallback-handle') + }) +}) 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 00000000000..766f889b5f5 --- /dev/null +++ b/packages/store/src/cli/services/store/info/index.ts @@ -0,0 +1,173 @@ +import {readAuthStatus} from './auth-status.js' +import {fetchDestinationsContext} from './destinations.js' +import {fetchOrganizationShop} from './organization-shop.js' +import {fetchAdminShop} from './admin-shop.js' +import type { + AdminShopFetchOutcome, + DestinationsContext, + OrganizationShopFields, + StoreInfoAuthStatus, + StoreInfoFieldError, + StoreInfoPlan, + StoreInfoResult, +} from './types.js' +import {AbortError, FatalError} from '@shopify/cli-kit/node/error' +import {compact} from '@shopify/cli-kit/common/object' + +interface GetStoreInfoOptions { + store?: string +} + +const TIER_3_FIELDS = ['shop_owner', 'timezone', 'setup_required', 'plus'] as const + +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 fieldErrors: Record = {} + + const destinationsCtx = await fetchDestinationsContext({store}) + + const auth = readAuthStatus(store) + + const [orgShopOutcome, adminOutcome] = await Promise.all([ + safeFetchOrganizationShop(destinationsCtx, store, fieldErrors), + auth.authed ? fetchAdminShop(store) : Promise.resolve(null), + ]) + + const result = buildResult({ + store, + destinationsCtx, + orgShop: orgShopOutcome, + admin: adminOutcome, + auth, + fieldErrors, + }) + + return result +} + +async function safeFetchOrganizationShop( + ctx: DestinationsContext, + store: string, + fieldErrors: Record, +): Promise { + if (!ctx.owningOrg?.id) { + // Without an org id we can't address the BP Organizations API. Surface the reason on + // every Tier-2 field that depends on it so the caller sees why each is missing. + const reason = ctx.owningOrgError?.reason ?? 'Owning organization id is unknown.' + for (const field of TIER_2_ORG_FIELDS) { + fieldErrors[field] = {source: 'bp_organizations', reason} + } + return undefined + } + try { + return await fetchOrganizationShop({store, organizationId: ctx.owningOrg.id}) + // eslint-disable-next-line no-catch-all/no-catch-all + } catch (error) { + const reason = `Request failed: ${buildErrorReason(error)}` + for (const field of TIER_2_ORG_FIELDS) { + fieldErrors[field] = {source: 'bp_organizations', reason} + } + return undefined + } +} + +const TIER_2_ORG_FIELDS = ['plan', 'billing_currency', 'created_at'] as const + +interface BuildResultArgs { + store: string + destinationsCtx: DestinationsContext + orgShop: OrganizationShopFields | undefined + admin: AdminShopFetchOutcome | null + auth: StoreInfoAuthStatus + fieldErrors: Record +} + +function buildResult(args: BuildResultArgs): StoreInfoResult { + const {store, destinationsCtx, orgShop, admin, auth, fieldErrors} = args + const destination = destinationsCtx.destination + + if (destinationsCtx.owningOrgError) { + fieldErrors.owning_org = destinationsCtx.owningOrgError + } + + const baseFields: Partial = { + shop_domain: store, + display_name: orgShop?.name ?? destination.name, + store_type: orgShop?.storeType ?? (destination.isAppDevelopment ? 'DEVELOPMENT' : undefined), + status: orgShop?.status ?? destination.status, + primary_url: orgShop?.primaryDomain ?? destination.primaryDomain ?? undefined, + admin_url: buildAdminUrl(destination.handle ?? destination.shortName ?? undefined), + owning_org: destinationsCtx.owningOrg ? {name: destinationsCtx.owningOrg.name} : undefined, + plan: orgShop ? buildPlan(orgShop) : undefined, + billing_currency: orgShop?.billingCurrency, + created_at: orgShop?.createdAt, + last_access: destination.lastAccess ?? undefined, + } + + const result = {...compact(baseFields), auth_status: auth} as StoreInfoResult + + applyAdminFields(result, admin, fieldErrors) + + if (Object.keys(fieldErrors).length > 0) { + result._field_errors = fieldErrors + } + + return result +} + +function applyAdminFields( + result: StoreInfoResult, + admin: AdminShopFetchOutcome | null, + fieldErrors: Record, +): void { + // Not authed: silently omit Admin-sourced fields. `auth_status: not authenticated` + // already tells the caller why; we don't want to clutter the output with field errors + // for data the user never asked for. + if (!admin) return + + if (admin.skipped) { + // Authed (we attempted the fetch) but Admin returned an error. This is a real failure + // the caller should know about. + for (const field of TIER_3_FIELDS) { + fieldErrors[field] = {source: 'admin', reason: admin.reason} + } + return + } + + const shop = admin.shop + Object.assign( + result, + compact({ + shop_owner: shop.shopOwnerName ? {name: shop.shopOwnerName} : undefined, + timezone: shop.ianaTimezone, + setup_required: shop.setupRequired, + plus: shop.shopifyPlus, + }), + ) +} + +function buildAdminUrl(handle: string | undefined): string | undefined { + if (!handle) return undefined + return `https://admin.shopify.com/store/${encodeURIComponent(handle)}` +} + +function buildPlan(shop: OrganizationShopFields): StoreInfoPlan | undefined { + const plan = compact({name: shop.planName, variant: shop.planVariantName}) as StoreInfoPlan + return Object.keys(plan).length > 0 ? plan : undefined +} + +function buildErrorReason(error: unknown): string { + if (error instanceof FatalError && error.tryMessage) { + const tryMsg = typeof error.tryMessage === 'string' ? error.tryMessage : String(error.tryMessage) + return `${error.message} (${tryMsg})` + } + if (error instanceof Error) return error.message + return String(error) +} 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 00000000000..be9cca49493 --- /dev/null +++ b/packages/store/src/cli/services/store/info/organization-shop.test.ts @@ -0,0 +1,78 @@ +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 { + name: 'My Shop', + primaryDomain: `https://${SHOP}`, + storeType: 'PRODUCTION', + status: 'active', + planName: 'Basic', + planVariantName: 'monthly', + billingCurrency: 'USD', + createdAt: '2025-01-01T00:00:00.000Z', + shortName: 'my-shop', + ...overrides, + } +} + +describe('fetchOrganizationShop', () => { + beforeEach(() => { + vi.resetAllMocks() + 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}`) + }) + + 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/organization-shop.ts b/packages/store/src/cli/services/store/info/organization-shop.ts new file mode 100644 index 00000000000..8031c15fcad --- /dev/null +++ b/packages/store/src/cli/services/store/info/organization-shop.ts @@ -0,0 +1,60 @@ +import {StoreInfoShop} from '../../../api/graphql/business-platform-organizations/generated/store-info-shop.js' +import type { + StoreInfoShopQuery, + StoreInfoShopQueryVariables, +} 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 {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 { + name: matched.name, + primaryDomain: matched.primaryDomain ?? undefined, + storeType: matched.storeType ?? undefined, + status: matched.status, + planName: matched.planName ?? undefined, + planVariantName: matched.planVariantName ?? undefined, + billingCurrency: matched.billingCurrency ?? undefined, + createdAt: matched.createdAt == null ? undefined : String(matched.createdAt), + shortName: matched.shortName ?? undefined, + } +} 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 00000000000..5cb1dc10041 --- /dev/null +++ b/packages/store/src/cli/services/store/info/result.test.ts @@ -0,0 +1,129 @@ +import {renderStoreInfoResult} from './result.js' +import type {StoreInfoResult} from './types.js' +import {outputResult} from '@shopify/cli-kit/node/output' +import {renderInfo} from '@shopify/cli-kit/node/ui' +import {describe, test, expect, vi, beforeEach} from 'vitest' + +vi.mock('@shopify/cli-kit/node/output') +vi.mock('@shopify/cli-kit/node/ui') + +function baseResult(overrides: Partial = {}): StoreInfoResult { + return { + shop_domain: 'shop.myshopify.com', + display_name: 'My Shop', + auth_status: {authed: false, source: 'store-auth'}, + ...overrides, + } +} + +describe('renderStoreInfoResult', () => { + beforeEach(() => { + vi.resetAllMocks() + }) + + test('emits JSON via outputResult when format is json', () => { + renderStoreInfoResult( + baseResult({ + billing_currency: 'USD', + _field_errors: {shop_owner: {source: 'cli', reason: 'not authed'}}, + }), + 'json', + ) + expect(outputResult).toHaveBeenCalledOnce() + const payload = vi.mocked(outputResult).mock.calls[0]?.[0] as string + expect(JSON.parse(payload)).toEqual({ + shop_domain: 'shop.myshopify.com', + display_name: 'My Shop', + auth_status: {authed: false, source: 'store-auth'}, + billing_currency: 'USD', + _field_errors: {shop_owner: {source: 'cli', reason: 'not authed'}}, + }) + expect(renderInfo).not.toHaveBeenCalled() + }) + + test('emits complete JSON shape when Tier 3 fields are populated', () => { + renderStoreInfoResult( + baseResult({ + store_type: 'PRODUCTION', + status: 'active', + primary_url: 'https://shop.myshopify.com', + admin_url: 'https://admin.shopify.com/store/my-shop', + owning_org: {name: 'Acme'}, + plan: {name: 'Basic', variant: 'monthly'}, + billing_currency: 'USD', + created_at: '2025-01-01T00:00:00.000Z', + last_access: '2026-05-20T00:00:00.000Z', + shop_owner: {name: 'Alice'}, + timezone: 'America/New_York', + plus: true, + setup_required: false, + auth_status: {authed: true, source: 'store-auth', expires_at: '2026-12-01T00:00:00.000Z'}, + }), + 'json', + ) + const payload = vi.mocked(outputResult).mock.calls[0]?.[0] as string + expect(JSON.parse(payload)).toEqual({ + shop_domain: 'shop.myshopify.com', + display_name: 'My Shop', + store_type: 'PRODUCTION', + status: 'active', + primary_url: 'https://shop.myshopify.com', + admin_url: 'https://admin.shopify.com/store/my-shop', + owning_org: {name: 'Acme'}, + auth_status: {authed: true, source: 'store-auth', expires_at: '2026-12-01T00:00:00.000Z'}, + plan: {name: 'Basic', variant: 'monthly'}, + billing_currency: 'USD', + created_at: '2025-01-01T00:00:00.000Z', + last_access: '2026-05-20T00:00:00.000Z', + shop_owner: {name: 'Alice'}, + timezone: 'America/New_York', + plus: true, + setup_required: false, + }) + }) + + test('text format includes Store and Access sections by default', () => { + renderStoreInfoResult(baseResult(), 'text') + expect(renderInfo).toHaveBeenCalledOnce() + const opts = vi.mocked(renderInfo).mock.calls[0]?.[0] as {customSections: {title: string}[]} + const titles = opts.customSections.map((s) => s.title) + expect(titles).toContain('Store') + expect(titles).toContain('Access') + }) + + test('text format includes Plan section when plan data is present', () => { + renderStoreInfoResult( + baseResult({plan: {name: 'Basic'}, billing_currency: 'USD'}), + 'text', + ) + const opts = vi.mocked(renderInfo).mock.calls[0]?.[0] as {customSections: {title: string}[]} + expect(opts.customSections.map((s) => s.title)).toContain('Plan') + }) + + test('text format includes Activity section when activity data is present', () => { + renderStoreInfoResult( + baseResult({created_at: '2025-01-01T00:00:00.000Z', last_access: '2026-05-20T00:00:00.000Z'}), + 'text', + ) + const opts = vi.mocked(renderInfo).mock.calls[0]?.[0] as {customSections: {title: string}[]} + expect(opts.customSections.map((s) => s.title)).toContain('Activity') + }) + + test('timezone is rendered in the Store section', () => { + renderStoreInfoResult(baseResult({timezone: 'America/New_York'}), 'text') + const opts = vi.mocked(renderInfo).mock.calls[0]?.[0] as { + customSections: {title: string; body: {list: {items: string[]}}}[] + } + const store = opts.customSections.find((s) => s.title === 'Store') + expect(store?.body.list.items.some((item) => item.includes('Timezone: America/New_York'))).toBe(true) + }) + + test('text format adds Missing or partial fields section when _field_errors is non-empty', () => { + renderStoreInfoResult( + baseResult({_field_errors: {plan: {source: 'bp_organizations', reason: '5xx'}}}), + 'text', + ) + const opts = vi.mocked(renderInfo).mock.calls[0]?.[0] as {customSections: {title: string}[]} + expect(opts.customSections.map((s) => s.title)).toContain('Missing or partial fields') + }) +}) 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 00000000000..cd7128e87f9 --- /dev/null +++ b/packages/store/src/cli/services/store/info/result.ts @@ -0,0 +1,118 @@ +import type {StoreInfoFieldError, StoreInfoResult} from './types.js' +import {outputResult} from '@shopify/cli-kit/node/output' +import {renderInfo} from '@shopify/cli-kit/node/ui' +import {capitalizeWords, formatDate} from '@shopify/cli-kit/common/string' +import type {AlertCustomSection} from '@shopify/cli-kit/node/ui' + +type StoreInfoOutputFormat = 'text' | 'json' + +const DATE_FIELDS = new Set(['created_at', 'last_access']) + +export function renderStoreInfoResult(result: StoreInfoResult, format: StoreInfoOutputFormat): void { + if (format === 'json') { + outputResult(JSON.stringify(result, null, 2)) + return + } + renderInfo({ + headline: `Store info: ${result.shop_domain}`, + customSections: buildTextSections(result), + }) +} + +function buildTextSections(result: StoreInfoResult): AlertCustomSection[] { + const sections: AlertCustomSection[] = [] + + for (const {title, items} of [ + {title: 'Store', items: storeItems(result)}, + {title: 'Access', items: accessItems(result)}, + {title: 'Plan', items: planItems(result)}, + {title: 'Activity', items: activityItems(result)}, + ]) { + if (items.length > 0) sections.push({title, body: {list: {items}}}) + } + + if (result._field_errors && Object.keys(result._field_errors).length > 0) { + sections.push({ + title: 'Missing or partial fields', + body: {list: {items: fieldErrorItems(result._field_errors)}}, + }) + } + + return sections +} + +function storeItems(result: StoreInfoResult): string[] { + const items: string[] = [] + items.push(line('shop_domain', result.shop_domain)) + pushIfPresent(items, 'display_name', result.display_name) + pushIfPresent(items, 'store_type', result.store_type) + pushIfPresent(items, 'status', result.status) + if (result.setup_required != null) items.push(line('setup_required', formatValue(result.setup_required))) + if (result.owning_org) items.push(line('owning_org', result.owning_org.name)) + if (result.shop_owner?.name) items.push(line('shop_owner', result.shop_owner.name)) + pushIfPresent(items, 'timezone', result.timezone) + return items +} + +function accessItems(result: StoreInfoResult): string[] { + const items: string[] = [] + pushIfPresent(items, 'primary_url', result.primary_url) + pushIfPresent(items, 'admin_url', result.admin_url) + items.push(line('auth_status', formatAuthStatus(result.auth_status))) + return items +} + +function planItems(result: StoreInfoResult): string[] { + const items: string[] = [] + if (result.plan) { + const planText = [result.plan.name, result.plan.variant] + .filter((value): value is string => Boolean(value)) + .map(capitalizeWords) + .join(' / ') + if (planText) items.push(line('plan', planText)) + } + pushIfPresent(items, 'billing_currency', result.billing_currency) + if (result.plus != null) items.push(line('plus', formatValue(result.plus))) + return items +} + +function activityItems(result: StoreInfoResult): string[] { + const items: string[] = [] + pushIfPresent(items, 'created_at', result.created_at) + pushIfPresent(items, 'last_access', result.last_access) + return items +} + +function fieldErrorItems(errors: Record): string[] { + return Object.entries(errors).map(([field, err]) => `${capitalizeWords(field)} [${err.source}]: ${err.reason}`) +} + +function pushIfPresent(items: string[], key: string, value: string | undefined): void { + if (value) items.push(line(key, formatValue(value, key))) +} + +function line(key: string, value: string): string { + return `${capitalizeWords(key)}: ${value}` +} + +function formatValue(value: unknown, key?: string): string { + if (typeof value === 'boolean') return value ? 'Yes' : 'No' + if (typeof value === 'string') { + if (key && DATE_FIELDS.has(key)) return formatUtcDate(value) + // GraphQL enum values like APP_DEVELOPMENT → "App Development". Skip short all-caps + // tokens (USD, ID) that are conventionally codes, not enums. + if (/^[A-Z][A-Z0-9_]*$/.test(value) && (value.includes('_') || value.length > 4)) { + return capitalizeWords(value) + } + } + return String(value) +} + +function formatAuthStatus(auth: StoreInfoResult['auth_status']): string { + if (!auth.authed) return 'not authenticated' + return auth.expires_at ? `authenticated (expires ${formatUtcDate(auth.expires_at)})` : 'authenticated' +} + +function formatUtcDate(value: string): string { + return `${formatDate(new Date(value))} UTC` +} 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 00000000000..446bcc4be35 --- /dev/null +++ b/packages/store/src/cli/services/store/info/types.ts @@ -0,0 +1,101 @@ +type StoreInfoFieldErrorSource = 'bp_destinations' | 'bp_organizations' | 'admin' | 'cli' + +export interface StoreInfoFieldError { + source: StoreInfoFieldErrorSource + reason: string +} + +interface StoreInfoOwningOrg { + name: string +} + +/** + * Internal-only org reference used to drive the BP Organizations request. + * We don't surface the organization id in `store info` output. + */ +export interface OwningOrgInternal { + name: string + id?: string +} + +interface StoreInfoShopOwner { + name?: string +} + +export interface StoreInfoPlan { + name?: string + variant?: string +} + +export interface StoreInfoAuthStatus { + authed: boolean + source: 'store-auth' + expires_at?: string +} + +export interface StoreInfoResult { + shop_domain: string + display_name?: string + store_type?: string + status?: string + primary_url?: string + admin_url?: string + owning_org?: StoreInfoOwningOrg + auth_status: StoreInfoAuthStatus + + plan?: StoreInfoPlan + billing_currency?: string + created_at?: string + last_access?: string + + // Sourced from Admin API; only populated when the shop has been authed via `store auth`. + shop_owner?: StoreInfoShopOwner + timezone?: string + setup_required?: boolean + plus?: boolean + + _field_errors?: Record +} + +export interface DestinationNode { + id: string + publicId: string + name: string + handle?: string | null + shortName?: string | null + primaryDomain?: string | null + webUrl: string + status: 'ACTIVE' | 'INACTIVE' + accountStatus?: string | null + isAppDevelopment?: boolean | null + lastAccess?: string | null +} + +export interface DestinationsContext { + destination: DestinationNode + owningOrg?: OwningOrgInternal + owningOrgError?: StoreInfoFieldError +} + +export interface OrganizationShopFields { + name?: string + primaryDomain?: string + storeType?: string + status?: string + planName?: string + planVariantName?: string + billingCurrency?: string + createdAt?: string + shortName?: string +} + +export type AdminShopFetchOutcome = + | {skipped: true; reason: string} + | {skipped: false; shop: AdminShopFields} + +export interface AdminShopFields { + shopOwnerName?: string + ianaTimezone?: string + setupRequired?: boolean + shopifyPlus?: boolean +} diff --git a/packages/store/src/index.ts b/packages/store/src/index.ts index 73e67d78154..98924c2328f 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 diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f6bc9736c72..3dde2272813 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -645,6 +645,9 @@ importers: packages/store: dependencies: + '@graphql-typed-document-node/core': + specifier: 3.2.0 + version: 3.2.0(graphql@16.10.0) '@oclif/core': specifier: 4.5.3 version: 4.5.3