Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/store-list-json-result-contract.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@shopify/store': minor
---

Document and validate the `store list --json` result contract.
26 changes: 26 additions & 0 deletions packages/cli/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -3897,6 +3897,32 @@ DESCRIPTION

Run `shopify organization list` to find organization IDs.

With `--json`, the command returns `StoreListResult`, described by these TypeScript types:

```ts
interface StoreListResult {
stores: StoreListEntry[]
organization?: StoreListOrganization
notice?: string
truncated?: boolean
}

interface StoreListEntry {
id?: string
store: string
createdAt: string
organizationId: string
organizationName: string
name?: string
type?: string
}

interface StoreListOrganization {
id: string
name: string
}
```

EXAMPLES
$ shopify store list

Expand Down
4 changes: 2 additions & 2 deletions packages/cli/oclif.manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -7193,8 +7193,8 @@
"args": {
},
"customPluginName": "@shopify/store",
"description": "Lists stores in a Shopify organization available to the current CLI account.\n\nWhen more than one organization is available, the command prompts you to pick one unless you provide `--organization-id`. In that case, `--organization-id` is required in non-interactive environments.\n\nRun `<%= config.bin %> organization list` to find organization IDs.",
"descriptionWithMarkdown": "Lists stores in a Shopify organization available to the current CLI account.\n\nWhen more than one organization is available, the command prompts you to pick one unless you provide `--organization-id`. In that case, `--organization-id` is required in non-interactive environments.\n\nRun `<%= config.bin %> organization list` to find organization IDs.",
"description": "Lists stores in a Shopify organization available to the current CLI account.\n\nWhen more than one organization is available, the command prompts you to pick one unless you provide `--organization-id`. In that case, `--organization-id` is required in non-interactive environments.\n\nRun `<%= config.bin %> organization list` to find organization IDs.\n\nWith `--json`, the command returns `StoreListResult`, described by these TypeScript types:\n\n```ts\ninterface StoreListResult {\n stores: StoreListEntry[]\n organization?: StoreListOrganization\n notice?: string\n truncated?: boolean\n}\n\ninterface StoreListEntry {\n id?: string\n store: string\n createdAt: string\n organizationId: string\n organizationName: string\n name?: string\n type?: string\n}\n\ninterface StoreListOrganization {\n id: string\n name: string\n}\n```",
"descriptionWithMarkdown": "Lists stores in a Shopify organization available to the current CLI account.\n\nWhen more than one organization is available, the command prompts you to pick one unless you provide `--organization-id`. In that case, `--organization-id` is required in non-interactive environments.\n\nRun `<%= config.bin %> organization list` to find organization IDs.\n\nWith `--json`, the command returns `StoreListResult`, described by these TypeScript types:\n\n```ts\ninterface StoreListResult {\n stores: StoreListEntry[]\n organization?: StoreListOrganization\n notice?: string\n truncated?: boolean\n}\n\ninterface StoreListEntry {\n id?: string\n store: string\n createdAt: string\n organizationId: string\n organizationName: string\n name?: string\n type?: string\n}\n\ninterface StoreListOrganization {\n id: string\n name: string\n}\n```",
"examples": [
"<%= config.bin %> <%= command.id %>",
"<%= config.bin %> <%= command.id %> --organization-id 1234567",
Expand Down
17 changes: 11 additions & 6 deletions packages/store/src/cli/commands/store/list.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import StoreList from './list.js'
import {listStores} from '../../services/store/list.js'
import {writeStoreListResult} from '../../services/store/list/result.js'
import {presentStoreListResult} from '../../services/store/list/result.js'
import {describe, expect, test, vi} from 'vitest'

vi.mock('../../services/store/list.js')
Expand All @@ -9,34 +9,39 @@ vi.mock('../../services/store/attribution.js')

describe('store list command', () => {
test('runs the list service and writes text output by default', async () => {
vi.mocked(listStores).mockResolvedValue({stores: [], source: 'organization'})
vi.mocked(listStores).mockResolvedValue({stores: []})

await StoreList.run([])

expect(listStores).toHaveBeenCalledWith({organizationId: undefined})
expect(writeStoreListResult).toHaveBeenCalledWith({stores: [], source: 'organization'}, 'text')
expect(presentStoreListResult).toHaveBeenCalledWith({stores: []}, 'text')
})

test('passes the organization id through to the list service', async () => {
vi.mocked(listStores).mockResolvedValue({stores: [], source: 'organization'})
vi.mocked(listStores).mockResolvedValue({stores: []})

await StoreList.run(['--organization-id', '1234567'])

expect(listStores).toHaveBeenCalledWith({organizationId: 1234567})
})

test('writes json output when requested', async () => {
vi.mocked(listStores).mockResolvedValue({stores: [], source: 'organization'})
vi.mocked(listStores).mockResolvedValue({stores: []})

await StoreList.run(['--json'])

expect(listStores).toHaveBeenCalledWith({organizationId: undefined})
expect(writeStoreListResult).toHaveBeenCalledWith({stores: [], source: 'organization'}, 'json')
expect(presentStoreListResult).toHaveBeenCalledWith({stores: []}, 'json')
})

test('defines the expected flags', () => {
expect(StoreList.flags.json).toBeDefined()
expect(StoreList.flags['organization-id']).toBeDefined()
expect(StoreList.flags).not.toHaveProperty('from')
})

test('documents the JSON output schema', () => {
expect(StoreList.description).toContain('interface StoreListResult')
expect(StoreList.description).toContain('stores: StoreListEntry[]')
})
})
9 changes: 7 additions & 2 deletions packages/store/src/cli/commands/store/list.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import {listStores} from '../../services/store/list.js'
import {writeStoreListResult} from '../../services/store/list/result.js'
import {presentStoreListResult} from '../../services/store/list/result.js'
import {storeListJsonOutputSchema} from '../../services/store/list/types.js'
import {storeFlags} from '../../flags.js'
import StoreCommand from '../../utilities/store-command.js'
import {globalFlags, jsonFlag} from '@shopify/cli-kit/node/cli'
Expand All @@ -8,6 +9,10 @@ import {Flags} from '@oclif/core'
export default class StoreList extends StoreCommand {
static summary = 'List stores in a Shopify organization.'

static get jsonOutputSchema() {
return storeListJsonOutputSchema
}

static descriptionWithMarkdown = `Lists stores in a Shopify organization available to the current CLI account.

When more than one organization is available, the command prompts you to pick one unless you provide \`--organization-id\`. In that case, \`--organization-id\` is required in non-interactive environments.
Expand Down Expand Up @@ -35,6 +40,6 @@ Run \`<%= config.bin %> organization list\` to find organization IDs.`
const {flags} = await this.parse(StoreList)
const result = await listStores({organizationId: flags['organization-id']})

writeStoreListResult(result, flags.json ? 'json' : 'text')
presentStoreListResult(result, flags.json ? 'json' : 'text')
}
}
4 changes: 1 addition & 3 deletions packages/store/src/cli/services/store/list.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,6 @@ describe('listStores', () => {
expect(renderAutocompletePrompt).not.toHaveBeenCalled()
expect(result).toEqual({
stores: [orgEntry],
source: 'organization',
organization: {id: '1234', name: 'Acme'},
})
})
Expand Down Expand Up @@ -107,7 +106,6 @@ describe('listStores', () => {

expect(result).toEqual({
stores: [],
source: 'organization',
notice: "Couldn't resolve a Shopify account for the current CLI session.",
})
})
Expand All @@ -117,7 +115,7 @@ describe('listStores', () => {

const result = await listStores()

expect(result).toEqual({stores: [], source: 'organization'})
expect(result).toEqual({stores: []})
})

test('propagates store listing failures', async () => {
Expand Down
8 changes: 3 additions & 5 deletions packages/store/src/cli/services/store/list.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import {listBusinessPlatformStores} from './list/bp-source.js'
import {STORE_LIST_LIMIT} from './list/constants.js'
import {type ListStoresResult, type StoreListEntry, type StoreListOrganization} from './list/types.js'
import {type StoreListEntry, type StoreListOrganization, type StoreListResult} from './list/types.js'
import {AbortError} from '@shopify/cli-kit/node/error'
import {ensureAuthenticatedBusinessPlatform} from '@shopify/cli-kit/node/session'
import {isTTY, renderAutocompletePrompt} from '@shopify/cli-kit/node/ui'
Expand All @@ -10,20 +10,19 @@ interface ListStoresOptions {
organizationId?: number
}

export async function listStores(options: ListStoresOptions = {}): Promise<ListStoresResult> {
export async function listStores(options: ListStoresOptions = {}): Promise<StoreListResult> {
const token = await ensureAuthenticatedBusinessPlatform()
const organizationsResult = await fetchOrganizationsWithAccessInfo(token)

if (!organizationsResult.currentUserResolved) {
return {
stores: [],
source: 'organization',
notice: "Couldn't resolve a Shopify account for the current CLI session.",
}
}

if (organizationsResult.organizations.length === 0) {
return {stores: [], source: 'organization'}
return {stores: []}
}

if (!options.organizationId && organizationsResult.organizations.length > 1 && !isTTY()) {
Expand All @@ -43,7 +42,6 @@ export async function listStores(options: ListStoresOptions = {}): Promise<ListS

return {
stores,
source: 'organization',
organization: storeListOrganization(selectedOrganization),
...(truncated ? {truncated: true} : {}),
}
Expand Down
49 changes: 49 additions & 0 deletions packages/store/src/cli/services/store/list/codec.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import {encodeStoreListJson} from './codec.js'
import {describe, expect, test} from 'vitest'

describe('store:list codec', () => {
test('preserves the current JSON wire document', () => {
const result = {
stores: [
{
id: 'gid://shopify/Shop/1',
store: 'shop.myshopify.com',
createdAt: '2026-05-22T00:00:00Z',
organizationId: '1234',
organizationName: 'Acme',
name: 'My Shop',
type: 'dev',
},
],
organization: {id: '1234', name: 'Acme'},
notice: 'A notice',
truncated: true,
}

expect(encodeStoreListJson(result)).toBe(`{
"stores": [
{
"id": "gid://shopify/Shop/1",
"store": "shop.myshopify.com",
"createdAt": "2026-05-22T00:00:00Z",
"organizationId": "1234",
"organizationName": "Acme",
"name": "My Shop",
"type": "dev"
}
],
"organization": {
"id": "1234",
"name": "Acme"
},
"notice": "A notice",
"truncated": true
}`)
})

test('omits optional fields when execution data does not provide them', () => {
expect(encodeStoreListJson({stores: []})).toBe(`{
"stores": []
}`)
})
})
6 changes: 6 additions & 0 deletions packages/store/src/cli/services/store/list/codec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import {storeListJsonOutputSchema, type StoreListResult} from './types.js'

/** Encode the store:list document without selecting an output channel. */
export function encodeStoreListJson(result: StoreListResult): string {
return JSON.stringify(storeListJsonOutputSchema.schema.parse(result), null, 2)
}
28 changes: 11 additions & 17 deletions packages/store/src/cli/services/store/list/result.test.ts
Original file line number Diff line number Diff line change
@@ -1,20 +1,19 @@
import {writeStoreListResult} from './result.js'
import {presentStoreListResult} from './result.js'
import {beforeEach, describe, expect, test} from 'vitest'
import {mockAndCaptureOutput} from '@shopify/cli-kit/node/testing/output'

const organization = {id: '1234', name: 'Acme'}

describe('writeStoreListResult', () => {
describe('presentStoreListResult', () => {
beforeEach(() => {
mockAndCaptureOutput().clear()
})

test('renders organization context and rows with subdomain, name, type, and created date', () => {
const output = mockAndCaptureOutput()

writeStoreListResult(
presentStoreListResult(
{
source: 'organization',
organization,
stores: [
{
Expand Down Expand Up @@ -44,9 +43,8 @@ describe('writeStoreListResult', () => {
test('renders the subdomain handle for non-myshopify hosts (local dev)', () => {
const output = mockAndCaptureOutput()

writeStoreListResult(
presentStoreListResult(
{
source: 'organization',
organization,
stores: [
{
Expand All @@ -68,9 +66,8 @@ describe('writeStoreListResult', () => {
test('writes the unresolved-session notice to stderr and the empty state to stdout', () => {
const output = mockAndCaptureOutput()

writeStoreListResult(
presentStoreListResult(
{
source: 'organization',
stores: [],
notice: "Couldn't resolve a Shopify account for the current CLI session.",
},
Expand All @@ -85,15 +82,15 @@ describe('writeStoreListResult', () => {
test('renders the selected organization empty state', () => {
const output = mockAndCaptureOutput()

writeStoreListResult({source: 'organization', organization, stores: []}, 'text')
presentStoreListResult({organization, stores: []}, 'text')

expect(output.info()).toContain('No stores found in Acme.')
})

test('renders the fallback organization empty state when no organization is selected', () => {
const output = mockAndCaptureOutput()

writeStoreListResult({source: 'organization', stores: []}, 'text')
presentStoreListResult({stores: []}, 'text')

expect(output.info()).toContain('No stores found in your Shopify organization.')
expect(output.info()).toContain('shopify store auth list')
Expand All @@ -102,9 +99,8 @@ describe('writeStoreListResult', () => {
test('emits a {stores, organization} JSON document on stdout', () => {
const output = mockAndCaptureOutput()

writeStoreListResult(
presentStoreListResult(
{
source: 'organization',
organization,
stores: [
{
Expand Down Expand Up @@ -140,9 +136,8 @@ describe('writeStoreListResult', () => {
test('includes unresolved-session notices in JSON output', () => {
const output = mockAndCaptureOutput()

writeStoreListResult(
presentStoreListResult(
{
source: 'organization',
stores: [],
notice: "Couldn't resolve a Shopify account for the current CLI session.",
},
Expand All @@ -158,7 +153,6 @@ describe('writeStoreListResult', () => {

test('warns on stderr when the listing was truncated, in both text and json', () => {
const result = {
source: 'organization' as const,
organization,
stores: [
{
Expand All @@ -172,11 +166,11 @@ describe('writeStoreListResult', () => {
}

const textOutput = mockAndCaptureOutput()
writeStoreListResult(result, 'text')
presentStoreListResult(result, 'text')
expect(textOutput.warn()).toContain('Showing the 250 most recent stores in Acme. More stores exist')

const jsonOutput = mockAndCaptureOutput()
writeStoreListResult(result, 'json')
presentStoreListResult(result, 'json')
expect(jsonOutput.warn()).toContain('Showing the 250 most recent stores in Acme. More stores exist')
// The structured truncation flag is part of the JSON document on stdout (prose stays on stderr).
expect(jsonOutput.output()).toContain('"truncated": true')
Expand Down
Loading
Loading