Skip to content

Commit cd562c1

Browse files
committed
Add store list for organization stores
Add `shopify store list`, listing the stores in the Shopify organizations available to the current CLI account (backed by Business Platform internally). - Discover organizations and query each one's newest stores (a single server-sorted page), in bounded-concurrency batches, then merge and cap to the 250 most recent across organizations; warn on stderr when truncated. - Optional --organization-id to scope to a single organization. - Output {stores, source} as JSON; render Subdomain | Name | Type | Created as a table. - Add shared helpers: cli-kit extractSubdomain + formatShortDate, a store/store-type module (shared with store info), and harden the CLI session read against malformed JSON.
1 parent 747d7f3 commit cd562c1

32 files changed

Lines changed: 1564 additions & 35 deletions

File tree

‎.changeset/store-list-bp-auto.md‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
---
2+
'@shopify/cli': minor
3+
'@shopify/store': minor
4+
'@shopify/organizations': minor
5+
'@shopify/cli-kit': patch
6+
---
7+
8+
Add `shopify store list` to list the stores in the Shopify organizations available to the current CLI account. When some organization lookups fail, the command returns the successful organizations with a warning and failure details for the skipped ones.
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
// This is an autogenerated file. Don't edit this file manually.
2+
/**
3+
* The following flags are available for the `store list` command:
4+
* @publicDocs
5+
*/
6+
export interface storelist {
7+
/**
8+
* Output the result as JSON. Automatically disables color output.
9+
* @environment SHOPIFY_FLAG_JSON
10+
*/
11+
'-j, --json'?: ''
12+
13+
/**
14+
* Disable color output.
15+
* @environment SHOPIFY_FLAG_NO_COLOR
16+
*/
17+
'--no-color'?: ''
18+
19+
/**
20+
* List stores for a single organization, by ID.
21+
* @environment SHOPIFY_FLAG_ORGANIZATION_ID
22+
*/
23+
'--organization-id <value>'?: string
24+
25+
/**
26+
* Increase the verbosity of the output.
27+
* @environment SHOPIFY_FLAG_VERBOSE
28+
*/
29+
'--verbose'?: ''
30+
}

‎docs-shopify.dev/generated/generated_docs_data_v2.json‎

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4339,6 +4339,53 @@
43394339
"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 <value>': string\n\n /**\n * Increase the verbosity of the output.\n * @environment SHOPIFY_FLAG_VERBOSE\n */\n '--verbose'?: ''\n}"
43404340
}
43414341
},
4342+
"storelist": {
4343+
"docs-shopify.dev/commands/interfaces/store-list.interface.ts": {
4344+
"filePath": "docs-shopify.dev/commands/interfaces/store-list.interface.ts",
4345+
"name": "storelist",
4346+
"description": "The following flags are available for the `store list` command:",
4347+
"isPublicDocs": true,
4348+
"members": [
4349+
{
4350+
"filePath": "docs-shopify.dev/commands/interfaces/store-list.interface.ts",
4351+
"syntaxKind": "PropertySignature",
4352+
"name": "--no-color",
4353+
"value": "''",
4354+
"description": "Disable color output.",
4355+
"isOptional": true,
4356+
"environmentValue": "SHOPIFY_FLAG_NO_COLOR"
4357+
},
4358+
{
4359+
"filePath": "docs-shopify.dev/commands/interfaces/store-list.interface.ts",
4360+
"syntaxKind": "PropertySignature",
4361+
"name": "--organization-id <value>",
4362+
"value": "string",
4363+
"description": "List stores for a single organization, by ID.",
4364+
"isOptional": true,
4365+
"environmentValue": "SHOPIFY_FLAG_ORGANIZATION_ID"
4366+
},
4367+
{
4368+
"filePath": "docs-shopify.dev/commands/interfaces/store-list.interface.ts",
4369+
"syntaxKind": "PropertySignature",
4370+
"name": "--verbose",
4371+
"value": "''",
4372+
"description": "Increase the verbosity of the output.",
4373+
"isOptional": true,
4374+
"environmentValue": "SHOPIFY_FLAG_VERBOSE"
4375+
},
4376+
{
4377+
"filePath": "docs-shopify.dev/commands/interfaces/store-list.interface.ts",
4378+
"syntaxKind": "PropertySignature",
4379+
"name": "-j, --json",
4380+
"value": "''",
4381+
"description": "Output the result as JSON. Automatically disables color output.",
4382+
"isOptional": true,
4383+
"environmentValue": "SHOPIFY_FLAG_JSON"
4384+
}
4385+
],
4386+
"value": "export interface storelist {\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 * List stores for a single organization, by ID.\n * @environment SHOPIFY_FLAG_ORGANIZATION_ID\n */\n '--organization-id <value>'?: string\n\n /**\n * Increase the verbosity of the output.\n * @environment SHOPIFY_FLAG_VERBOSE\n */\n '--verbose'?: ''\n}"
4387+
}
4388+
},
43424389
"themecheck": {
43434390
"docs-shopify.dev/commands/interfaces/theme-check.interface.ts": {
43444391
"filePath": "docs-shopify.dev/commands/interfaces/theme-check.interface.ts",

‎packages/cli-kit/src/private/node/session/store.test.ts‎

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,36 @@ describe('session store', () => {
7171
// Then
7272
expect(result).toEqual(mockSessions)
7373
})
74+
75+
test('returns undefined and discards malformed JSON session content', async () => {
76+
// Given
77+
vi.mocked(getSessions).mockReturnValue('{not valid json')
78+
79+
// When
80+
const result = await fetch()
81+
82+
// Then
83+
expect(result).toBeUndefined()
84+
expect(removeSessions).toHaveBeenCalled()
85+
expect(removeCurrentSessionId).toHaveBeenCalled()
86+
})
87+
88+
test('rethrows non-SyntaxError parse failures without discarding sessions', async () => {
89+
// Given
90+
vi.mocked(getSessions).mockReturnValue(JSON.stringify(mockSessions))
91+
vi.mocked(removeSessions).mockClear()
92+
vi.mocked(removeCurrentSessionId).mockClear()
93+
const parseSpy = vi.spyOn(JSON, 'parse').mockImplementation(() => {
94+
throw new Error('unexpected parse failure')
95+
})
96+
97+
// Then
98+
await expect(fetch()).rejects.toThrow('unexpected parse failure')
99+
expect(removeSessions).not.toHaveBeenCalled()
100+
expect(removeCurrentSessionId).not.toHaveBeenCalled()
101+
102+
parseSpy.mockRestore()
103+
})
74104
})
75105

76106
describe('remove', () => {

‎packages/cli-kit/src/private/node/session/store.ts‎

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,16 @@ export async function fetch(): Promise<Sessions | undefined> {
2323
if (!content) {
2424
return undefined
2525
}
26-
const contentJson = JSON.parse(content)
26+
27+
let contentJson: unknown
28+
try {
29+
contentJson = JSON.parse(content)
30+
} catch (error) {
31+
if (!(error instanceof SyntaxError)) throw error
32+
await remove()
33+
return undefined
34+
}
35+
2736
const parsedSessions = await SessionsSchema.safeParseAsync(contentJson)
2837
if (parsedSessions.success) {
2938
return parsedSessions.data

‎packages/cli-kit/src/public/common/string.test.ts‎

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import {
22
formatDate,
33
formatLocalDate,
4+
formatShortDate,
45
getRandomName,
56
joinWithAnd,
67
linesToColumns,
@@ -108,6 +109,24 @@ describe('formatDate', () => {
108109
})
109110
})
110111

112+
describe('formatShortDate', () => {
113+
test('formats an ISO timestamp as MMM DD, YYYY in UTC', () => {
114+
expect(formatShortDate('2026-05-22T16:26:20Z')).toBe('May 22, 2026')
115+
})
116+
117+
test('zero-pads the day and uses UTC', () => {
118+
expect(formatShortDate('2026-01-05T23:59:59Z')).toBe('Jan 05, 2026')
119+
})
120+
121+
test('accepts a Date instance', () => {
122+
expect(formatShortDate(new Date('2025-12-15T12:00:00Z'))).toBe('Dec 15, 2025')
123+
})
124+
125+
test('returns an empty string for an unparseable value', () => {
126+
expect(formatShortDate('not-a-date')).toBe('')
127+
})
128+
})
129+
111130
describe('formatLocalDate', () => {
112131
test('formats an ISO date string to local date string', () => {
113132
// Given

‎packages/cli-kit/src/public/common/string.ts‎

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -375,6 +375,24 @@ export function formatLocalDate(dateString: string): string {
375375
return formatDate(localDate)
376376
}
377377

378+
const SHORT_DATE_MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']
379+
380+
/**
381+
* Formats a date as a short calendar date like "May 22, 2026" (`MMM DD, YYYY`) in UTC.
382+
*
383+
* UTC keeps the output deterministic regardless of the machine timezone. Returns an empty string
384+
* when the value cannot be parsed into a valid date.
385+
*
386+
* @param value - A Date, epoch milliseconds, or a date string such as an ISO 8601 timestamp.
387+
* @returns The formatted date, or an empty string when the value is invalid.
388+
*/
389+
export function formatShortDate(value: Date | number | string): string {
390+
const date = value instanceof Date ? value : new Date(value)
391+
if (Number.isNaN(date.getTime())) return ''
392+
393+
return `${SHORT_DATE_MONTHS[date.getUTCMonth()]} ${String(date.getUTCDate()).padStart(2, '0')}, ${date.getUTCFullYear()}`
394+
}
395+
378396
/**
379397
* Given a list of items, it returns a string with the items joined by commas and the last item joined by "and".
380398
* All items are wrapped in double quotes.

‎packages/cli-kit/src/public/common/url.test.ts‎

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import {extractHost, extractMyshopifyHandle, isValidURL, safeParseURL} from './url.js'
1+
import {extractHost, extractMyshopifyHandle, extractSubdomain, isValidURL, safeParseURL} from './url.js'
22
import {describe, expect, test} from 'vitest'
33

44
describe('isValidURL', () => {
@@ -67,6 +67,11 @@ describe('extractHost', () => {
6767
expect(extractHost('shop.myshopify.com/admin')).toBe('shop.myshopify.com')
6868
})
6969

70+
test('returns the host for a bare host with a port (opaque URL guard)', () => {
71+
expect(extractHost('my-shop.shop.dev:9292/admin')).toBe('my-shop.shop.dev')
72+
expect(extractHost('localhost:3000')).toBe('localhost')
73+
})
74+
7075
test('returns undefined for null/undefined/empty input', () => {
7176
expect(extractHost(null)).toBeUndefined()
7277
expect(extractHost(undefined)).toBeUndefined()
@@ -88,3 +93,23 @@ describe('extractMyshopifyHandle', () => {
8893
expect(extractMyshopifyHandle(undefined)).toBeUndefined()
8994
})
9095
})
96+
97+
describe('extractSubdomain', () => {
98+
test('extracts the first label from a myshopify.com host', () => {
99+
expect(extractSubdomain('https://my-shop.myshopify.com')).toBe('my-shop')
100+
})
101+
102+
test('extracts the first label from non-myshopify hosts (local dev)', () => {
103+
expect(extractSubdomain('my-shop.my.shop.dev')).toBe('my-shop')
104+
expect(extractSubdomain('https://acme.shop.dev/admin')).toBe('acme')
105+
})
106+
107+
test('extracts the first label from a bare host with a port', () => {
108+
expect(extractSubdomain('my-shop.shop.dev:9292/admin')).toBe('my-shop')
109+
})
110+
111+
test('returns undefined for null/undefined input', () => {
112+
expect(extractSubdomain(null)).toBeUndefined()
113+
expect(extractSubdomain(undefined)).toBeUndefined()
114+
})
115+
})

‎packages/cli-kit/src/public/common/url.ts‎

Lines changed: 25 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -40,9 +40,17 @@ export function safeParseURL(url: string): URL | undefined {
4040
export function extractHost(value: string | null | undefined): string | undefined {
4141
if (!value) return undefined
4242
const lowered = value.toLowerCase()
43-
const parsed = safeParseURL(lowered)
44-
if (parsed) return parsed.hostname
45-
return lowered.replace(/^https?:\/\//, '').split('/')[0]
43+
// A bare `host:port` (e.g. `my-shop.shop.dev:9292`) parses as an opaque URL whose hostname is
44+
// empty (the host is read as the scheme), so try parsing with an explicit scheme as well and only
45+
// accept a non-empty hostname.
46+
for (const candidate of [lowered, `https://${lowered}`]) {
47+
const hostname = safeParseURL(candidate)?.hostname
48+
if (hostname) return hostname
49+
}
50+
// Never return an empty string: callers using `extractHost(value) ?? value` must keep the input.
51+
const fallback = lowered.replace(/^https?:\/\//, '').split('/')[0]
52+
if (fallback) return fallback
53+
return undefined
4654
}
4755

4856
/**
@@ -57,3 +65,17 @@ export function extractMyshopifyHandle(value: string | null | undefined): string
5765
const match = host.match(/^([^.]+)\.myshopify\.com$/)
5866
return match ? match[1] : undefined
5967
}
68+
69+
/**
70+
* Extracts the leading subdomain label from a URL or host, across environments — e.g.
71+
* `acme.myshopify.com`, `acme.shop.dev`, or local development hosts — rather than assuming a
72+
* `*.myshopify.com` domain.
73+
*
74+
* @param value - A URL or host string, possibly null/undefined.
75+
* @returns The first DNS label, or undefined when no host can be extracted.
76+
*/
77+
export function extractSubdomain(value: string | null | undefined): string | undefined {
78+
const host = extractHost(value)
79+
if (!host) return undefined
80+
return host.split('.')[0]
81+
}

‎packages/cli/README.md‎

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,7 @@
7979
* [`shopify store auth`](#shopify-store-auth)
8080
* [`shopify store execute`](#shopify-store-execute)
8181
* [`shopify store info`](#shopify-store-info)
82+
* [`shopify store list`](#shopify-store-list)
8283
* [`shopify theme check`](#shopify-theme-check)
8384
* [`shopify theme console`](#shopify-theme-console)
8485
* [`shopify theme delete`](#shopify-theme-delete)
@@ -2206,6 +2207,41 @@ EXAMPLES
22062207
$ shopify store info --store shop.myshopify.com --json
22072208
```
22082209

2210+
## `shopify store list`
2211+
2212+
List stores in a Shopify organization.
2213+
2214+
```
2215+
USAGE
2216+
$ shopify store list [-j] [--no-color] [--organization-id <value>] [--verbose]
2217+
2218+
FLAGS
2219+
-j, --json [env: SHOPIFY_FLAG_JSON] Output the result as JSON. Automatically disables color
2220+
output.
2221+
--no-color [env: SHOPIFY_FLAG_NO_COLOR] Disable color output.
2222+
--organization-id=<value> [env: SHOPIFY_FLAG_ORGANIZATION_ID] Filters the store list by organization. If omitted
2223+
and you belong to more than one organization, you will be prompted to choose one.
2224+
--verbose [env: SHOPIFY_FLAG_VERBOSE] Increase the verbosity of the output.
2225+
2226+
DESCRIPTION
2227+
List stores in a Shopify organization.
2228+
2229+
Lists stores in a Shopify organization available to the current CLI account.
2230+
2231+
When more than one organization is available, the command prompts you to pick one unless you provide
2232+
`--organization-id`.
2233+
In non-interactive environments, provide `--organization-id`.
2234+
2235+
Run `shopify organization list` to find organization IDs.
2236+
2237+
EXAMPLES
2238+
$ shopify store list
2239+
2240+
$ shopify store list --organization-id 1234567
2241+
2242+
$ shopify store list --json
2243+
```
2244+
22092245
## `shopify theme check`
22102246

22112247
Validate the theme.

0 commit comments

Comments
 (0)