Skip to content
Merged
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
8 changes: 8 additions & 0 deletions .changeset/store-list-bp-auto.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
---
'@shopify/cli': minor
'@shopify/store': minor
'@shopify/organizations': minor
'@shopify/cli-kit': minor
---

Add `shopify store list` to list the stores in the Shopify organizations available to the current CLI account.
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -191,6 +191,7 @@ packages/ui-extensions-server-kit/index.*
packages/ui-extensions-server-kit/testing.*
packages/ui-extensions-server-kit/dist
packages/app/src/cli/api/graphql/*/*_schema.graphql
packages/store/src/cli/api/graphql/business-platform-organizations/organizations_schema.graphql
packages/ui-extensions-test-utils/*.d.ts
packages/ui-extensions-test-utils/!typings.d.ts
packages/ui-extensions-test-utils/dist
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import {DeveloperPlatformClient, selectDeveloperPlatformClient} from '../../../u
import {OrganizationApp, OrganizationSource} from '../../../models/organization.js'
import {appNamePrompt, createAsNewAppPrompt} from '../../../prompts/dev.js'
import {selectConfigName} from '../../../prompts/config.js'
import {fetchOrganizations} from '../../dev/fetch.js'
import {selectOrganizationPrompt} from '@shopify/organizations'
import {beforeEach, describe, expect, test, vi} from 'vitest'
import {inTemporaryDirectory, readFile, writeFileSync} from '@shopify/cli-kit/node/fs'
Expand All @@ -21,6 +22,9 @@ vi.mock('../../../models/app/validation/multi-cli-warning.js')
beforeEach(async () => {
// Default mock for selectConfigName - tests that need a specific value can override
vi.mocked(selectConfigName).mockResolvedValue('shopify.app.toml')
vi.mocked(fetchOrganizations).mockResolvedValue([
{id: '12345', businessName: 'test', source: OrganizationSource.BusinessPlatform},
])
})

function buildDeveloperPlatformClient(): DeveloperPlatformClient {
Expand Down
6 changes: 4 additions & 2 deletions packages/app/src/cli/services/context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -320,8 +320,10 @@ export async function fetchOrCreateOrganizationApp(
*/
export async function selectOrg(): Promise<Organization> {
const orgs = await fetchOrganizations()
const org = await selectOrganizationPrompt(orgs)
return org
if (orgs.length === 0) {
throw new AbortError('No organizations found.', 'Make sure you have access to a Shopify organization.')
}
return selectOrganizationPrompt(orgs)
}

interface ReusedValuesOptions {
Expand Down
45 changes: 45 additions & 0 deletions packages/cli-kit/src/private/node/session/store.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,51 @@ describe('session store', () => {
// Then
expect(result).toEqual(mockSessions)
})

test('returns undefined and discards malformed JSON session content', async () => {
// Given
vi.mocked(getSessions).mockReturnValue('{not valid json')

// When
const result = await fetch()

// Then
expect(result).toBeUndefined()
expect(removeSessions).toHaveBeenCalled()
expect(removeCurrentSessionId).toHaveBeenCalled()
})

test('returns undefined and discards schema-invalid session content', async () => {
// Given
vi.mocked(getSessions).mockReturnValue(JSON.stringify({not: 'a valid sessions object'}))
vi.mocked(removeSessions).mockClear()
vi.mocked(removeCurrentSessionId).mockClear()

// When
const result = await fetch()

// Then
expect(result).toBeUndefined()
expect(removeSessions).toHaveBeenCalled()
expect(removeCurrentSessionId).toHaveBeenCalled()
})

test('rethrows non-SyntaxError parse failures without discarding sessions', async () => {
// Given
vi.mocked(getSessions).mockReturnValue(JSON.stringify(mockSessions))
vi.mocked(removeSessions).mockClear()
vi.mocked(removeCurrentSessionId).mockClear()
const parseSpy = vi.spyOn(JSON, 'parse').mockImplementation(() => {
throw new Error('unexpected parse failure')
})

// Then
await expect(fetch()).rejects.toThrow('unexpected parse failure')
expect(removeSessions).not.toHaveBeenCalled()
expect(removeCurrentSessionId).not.toHaveBeenCalled()

parseSpy.mockRestore()
})
})

describe('remove', () => {
Expand Down
11 changes: 10 additions & 1 deletion packages/cli-kit/src/private/node/session/store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,16 @@ export async function fetch(): Promise<Sessions | undefined> {
if (!content) {
return undefined
}
const contentJson = JSON.parse(content)

let contentJson: unknown
try {
contentJson = JSON.parse(content)
} catch (error) {
if (!(error instanceof SyntaxError)) throw error
await remove()
return undefined
}

const parsedSessions = await SessionsSchema.safeParseAsync(contentJson)
if (parsedSessions.success) {
return parsedSessions.data
Expand Down
5 changes: 5 additions & 0 deletions packages/cli-kit/src/public/common/url.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,11 @@ describe('extractHost', () => {
expect(extractHost('shop.myshopify.com/admin')).toBe('shop.myshopify.com')
})

test('returns the host for a bare host with a port (opaque URL guard)', () => {
expect(extractHost('my-shop.shop.dev:9292/admin')).toBe('my-shop.shop.dev')
expect(extractHost('localhost:3000')).toBe('localhost')
})

test('returns undefined for null/undefined/empty input', () => {
expect(extractHost(null)).toBeUndefined()
expect(extractHost(undefined)).toBeUndefined()
Expand Down
14 changes: 11 additions & 3 deletions packages/cli-kit/src/public/common/url.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,9 +40,17 @@ export function safeParseURL(url: string): URL | undefined {
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]
// A bare `host:port` (e.g. `my-shop.shop.dev:9292`) parses as an opaque URL whose hostname is
// empty (the host is read as the scheme), so try parsing with an explicit scheme as well and only
// accept a non-empty hostname.
for (const candidate of [lowered, `https://${lowered}`]) {
const hostname = safeParseURL(candidate)?.hostname
if (hostname) return hostname
}
// Never return an empty string: callers using `extractHost(value) ?? value` must keep the input.
const fallback = lowered.replace(/^https?:\/\//, '').split('/')[0]
if (fallback) return fallback
return undefined
}

/**
Expand Down
61 changes: 60 additions & 1 deletion packages/cli/oclif.manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -5785,7 +5785,7 @@
"type": "boolean"
},
"organization-id": {
"description": "The organization to create the store in (numeric ID). Auto-selects if you belong to a single org.",
"description": "The numeric organization ID. Auto-selects if you belong to a single organization.",
"env": "SHOPIFY_FLAG_ORGANIZATION_ID",
"hasDynamicHelp": false,
"multiple": false,
Expand Down Expand Up @@ -6065,6 +6065,65 @@
"strict": true,
"summary": "Surface metadata about a Shopify store."
},
"store:list": {
"aliases": [
],
"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`.\nIn non-interactive environments, `--organization-id` is required.\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`.\nIn non-interactive environments, `--organization-id` is required.\n\nRun `<%= config.bin %> organization list` to find organization IDs.",
"examples": [
"<%= config.bin %> <%= command.id %>",
"<%= config.bin %> <%= command.id %> --organization-id 1234567",
"<%= config.bin %> <%= command.id %> --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"
},
"organization-id": {
"description": "The numeric organization ID. Auto-selects if you belong to a single organization.",
"env": "SHOPIFY_FLAG_ORGANIZATION_ID",
"hasDynamicHelp": false,
"multiple": false,
"name": "organization-id",
"type": "option"
},
"verbose": {
"allowNo": false,
"description": "Increase the verbosity of the output.",
"env": "SHOPIFY_FLAG_VERBOSE",
"hidden": false,
"name": "verbose",
"type": "boolean"
}
},
"hasDynamicHelp": false,
"hidden": true,
"hiddenAliases": [
],
"id": "store:list",
"pluginAlias": "@shopify/cli",
"pluginName": "@shopify/cli",
"pluginType": "core",
"strict": true,
"summary": "List stores in a Shopify organization."
},
"theme:check": {
"aliases": [
],
Expand Down
100 changes: 99 additions & 1 deletion packages/organizations/src/cli/services/fetch.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import {fetchOrganizations} from './fetch.js'
import {fetchOrganizations, fetchOrganizationsWithAccessInfo} from './fetch.js'
import {describe, expect, test, vi} from 'vitest'
import {businessPlatformRequestDoc} from '@shopify/cli-kit/node/api/business-platform'
import {ensureAuthenticatedBusinessPlatform} from '@shopify/cli-kit/node/session'
Expand All @@ -15,6 +15,7 @@ describe('fetchOrganizations', () => {
vi.mocked(businessPlatformRequestDoc).mockResolvedValue({
currentUserAccount: {
uuid: 'user-uuid',
email: 'merchant@example.com',
organizationsWithAccessToDestination: {
nodes: [
{id: ENCODED_GID_1, name: 'My Org'},
Expand Down Expand Up @@ -47,6 +48,7 @@ describe('fetchOrganizations', () => {
vi.mocked(businessPlatformRequestDoc).mockResolvedValue({
currentUserAccount: {
uuid: 'user-uuid',
email: 'merchant@example.com',
organizationsWithAccessToDestination: {
nodes: [],
},
Expand All @@ -62,6 +64,7 @@ describe('fetchOrganizations', () => {
vi.mocked(businessPlatformRequestDoc).mockResolvedValue({
currentUserAccount: {
uuid: 'user-uuid',
email: 'merchant@example.com',
organizationsWithAccessToDestination: {
nodes: [{id: ENCODED_GID_1, name: 'My Org'}],
},
Expand All @@ -80,3 +83,98 @@ describe('fetchOrganizations', () => {
)
})
})

describe('fetchOrganizationsWithAccessInfo', () => {
test('uses a provided token without re-authenticating', async () => {
vi.mocked(ensureAuthenticatedBusinessPlatform).mockClear()
vi.mocked(businessPlatformRequestDoc).mockResolvedValue({
currentUserAccount: {
uuid: 'user-uuid',
email: 'merchant@example.com',
organizationsWithAccessToDestination: {
nodes: [{id: ENCODED_GID_1, name: 'My Org'}],
},
},
})

await fetchOrganizationsWithAccessInfo('pre-resolved-token')

expect(ensureAuthenticatedBusinessPlatform).not.toHaveBeenCalled()
expect(businessPlatformRequestDoc).toHaveBeenCalledWith(expect.objectContaining({token: 'pre-resolved-token'}))
})

test('refreshes a provided token on unauthorized without authenticating before the first request', async () => {
vi.mocked(ensureAuthenticatedBusinessPlatform).mockResolvedValue('refreshed-token')
vi.mocked(businessPlatformRequestDoc).mockResolvedValue({
currentUserAccount: {
uuid: 'user-uuid',
email: 'merchant@example.com',
organizationsWithAccessToDestination: {
nodes: [{id: ENCODED_GID_1, name: 'My Org'}],
},
},
})

await fetchOrganizationsWithAccessInfo('pre-resolved-token')

const requestOptions = vi.mocked(businessPlatformRequestDoc).mock.calls[0]?.[0] as any
expect(ensureAuthenticatedBusinessPlatform).not.toHaveBeenCalled()
await expect(requestOptions.unauthorizedHandler.handler()).resolves.toEqual({token: 'refreshed-token'})
expect(ensureAuthenticatedBusinessPlatform).toHaveBeenCalledOnce()
})

test('refreshes ambient local auth on unauthorized when no token is provided', async () => {
vi.mocked(ensureAuthenticatedBusinessPlatform)
.mockResolvedValueOnce('initial-token')
.mockResolvedValueOnce('refreshed-token')
vi.mocked(businessPlatformRequestDoc).mockResolvedValue({
currentUserAccount: {
uuid: 'user-uuid',
email: 'merchant@example.com',
organizationsWithAccessToDestination: {
nodes: [{id: ENCODED_GID_1, name: 'My Org'}],
},
},
})

await fetchOrganizationsWithAccessInfo()

const requestOptions = vi.mocked(businessPlatformRequestDoc).mock.calls[0]?.[0] as any
await expect(requestOptions.unauthorizedHandler.handler()).resolves.toEqual({token: 'refreshed-token'})
expect(businessPlatformRequestDoc).toHaveBeenCalledWith(expect.objectContaining({token: 'initial-token'}))
})

test('returns organizations plus current-user metadata when the session resolves to a user', async () => {
vi.mocked(ensureAuthenticatedBusinessPlatform).mockResolvedValue('test-token')
vi.mocked(businessPlatformRequestDoc).mockResolvedValue({
currentUserAccount: {
uuid: 'user-uuid',
email: 'merchant@example.com',
organizationsWithAccessToDestination: {
nodes: [{id: ENCODED_GID_1, name: 'My Org'}],
},
},
})

const result = await fetchOrganizationsWithAccessInfo()

expect(result).toEqual({
organizations: [{id: '1234', businessName: 'My Org'}],
currentUserResolved: true,
})
})

test('returns unresolved current-user metadata when BP cannot resolve currentUserAccount', async () => {
vi.mocked(ensureAuthenticatedBusinessPlatform).mockResolvedValue('test-token')
vi.mocked(businessPlatformRequestDoc).mockResolvedValue({
currentUserAccount: null,
})

const result = await fetchOrganizationsWithAccessInfo()

expect(result).toEqual({
organizations: [],
currentUserResolved: false,
})
})
})
Loading
Loading