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
5 changes: 5 additions & 0 deletions .changeset/bright-organizations-list.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@shopify/app': minor
---

Add typed JSON output with organization status, shop count, and URL to `organization list`.
63 changes: 54 additions & 9 deletions packages/app/src/cli/commands/organization/list.test.ts
Original file line number Diff line number Diff line change
@@ -1,31 +1,76 @@
import OrganizationList from './list.js'
import {writeOrganizationListResult} from '../../services/organization/list/result.js'
import {organizationList} from '../../services/organization/list.js'
import {organizationListJsonOutputSchema} from '../../services/organization/list/types.js'
import {NoOrgError} from '../../services/dev/fetch.js'
import {describe, expect, test, vi} from 'vitest'

vi.mock('../../services/organization/list.js')
vi.mock('../../services/organization/list/result.js')

describe('organization list command', () => {
test('calls organizationList service with json: false by default', async () => {
vi.mocked(organizationList).mockResolvedValue()
test('renders text results by default', async () => {
const result = {organizations: []}
vi.mocked(organizationList).mockResolvedValue(result)

await OrganizationList.run([], import.meta.url)

expect(organizationList).toHaveBeenCalledWith({json: false})
expect(organizationList).toHaveBeenCalledWith()
expect(writeOrganizationListResult).toHaveBeenCalledWith(result, 'text')
})

test('calls organizationList service with json: true when --json flag is passed', async () => {
vi.mocked(organizationList).mockResolvedValue()
test('renders JSON results when --json flag is passed', async () => {
const result = {organizations: []}
vi.mocked(organizationList).mockResolvedValue(result)

await OrganizationList.run(['--json'], import.meta.url)

expect(organizationList).toHaveBeenCalledWith({json: true})
expect(organizationList).toHaveBeenCalledWith()
expect(writeOrganizationListResult).toHaveBeenCalledWith(result, 'json')
})

test('calls organizationList service with json: true when -j flag is passed', async () => {
vi.mocked(organizationList).mockResolvedValue()
test('renders JSON results when -j flag is passed', async () => {
const result = {organizations: []}
vi.mocked(organizationList).mockResolvedValue(result)

await OrganizationList.run(['-j'], import.meta.url)

expect(organizationList).toHaveBeenCalledWith({json: true})
expect(organizationList).toHaveBeenCalledWith()
expect(writeOrganizationListResult).toHaveBeenCalledWith(result, 'json')
})

test('renders an empty JSON result when NoOrgError is thrown', async () => {
vi.mocked(organizationList).mockRejectedValue(new NoOrgError({type: 'UserAccount', email: 'test@example.com'}))

await OrganizationList.run(['--json'], import.meta.url)

expect(writeOrganizationListResult).toHaveBeenCalledWith({organizations: []}, 'json')
})

test('passes NoOrgError to the shared error handler in text mode', async () => {
const error = new NoOrgError({type: 'UserAccount', email: 'test@example.com'})
vi.mocked(organizationList).mockRejectedValue(error)
const catchError = vi.spyOn(OrganizationList.prototype, 'catch').mockRejectedValue(error)

await expect(OrganizationList.run([], import.meta.url)).rejects.toThrow(error)

expect(catchError).toHaveBeenCalledWith(error)
expect(writeOrganizationListResult).not.toHaveBeenCalled()
})

test('passes other errors to the shared error handler', async () => {
const error = new Error('request failed')
vi.mocked(organizationList).mockRejectedValue(error)
const catchError = vi.spyOn(OrganizationList.prototype, 'catch').mockRejectedValue(error)

await expect(OrganizationList.run(['--json'], import.meta.url)).rejects.toThrow(error)

expect(catchError).toHaveBeenCalledWith(error)
expect(writeOrganizationListResult).not.toHaveBeenCalled()
})

test('defines the JSON schema and flag', () => {
expect(OrganizationList.flags.json).toBeDefined()
expect(OrganizationList.jsonOutputSchema).toBe(organizationListJsonOutputSchema)
})
})
20 changes: 19 additions & 1 deletion packages/app/src/cli/commands/organization/list.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
import {writeOrganizationListResult} from '../../services/organization/list/result.js'
import {organizationList} from '../../services/organization/list.js'
import {organizationListJsonOutputSchema} from '../../services/organization/list/types.js'
import {NoOrgError} from '../../services/dev/fetch.js'
import {authAliasFlag, globalFlags, jsonFlag} from '@shopify/cli-kit/node/cli'
import BaseCommand from '@shopify/cli-kit/node/base-command'

Expand All @@ -16,8 +19,23 @@ export default class OrganizationList extends BaseCommand {
...jsonFlag,
}

static get jsonOutputSchema() {
return organizationListJsonOutputSchema
}

async run(): Promise<void> {
const {flags} = await this.parse(OrganizationList)
await organizationList({json: flags.json})

try {
const result = await organizationList()
writeOrganizationListResult(result, flags.json ? 'json' : 'text')
} catch (error) {
// JSON output reports no organizations as an empty successful list; text output keeps failing.
if (flags.json && error instanceof NoOrgError) {
writeOrganizationListResult({organizations: []}, 'json')
return
}
throw error
}
}
}
10 changes: 9 additions & 1 deletion packages/app/src/cli/models/app/app.test-data.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import {
OrganizationApp,
MinimalOrganizationApp,
OrganizationSource,
OrganizationWithDetails,
} from '../organization.js'
import {RemoteSpecification} from '../../api/graphql/extension_specifications.js'
import {ExtensionInstance} from '../extensions/extension-instance.js'
Expand Down Expand Up @@ -1256,7 +1257,14 @@ const createSourceScanResponse: SourceScanCreateSchema = {
userErrors: [],
}

const organizationsResponse: Organization[] = [testOrganization()]
const organizationsResponse: OrganizationWithDetails[] = [
{
...testOrganization(),
status: 'ACTIVE',
shopCount: 1,
url: 'https://admin.shopify.com/organization/1',
},
]

const sendSampleWebhookResponse: SendSampleWebhookSchema = {
sendSampleWebhook: {
Expand Down
11 changes: 10 additions & 1 deletion packages/app/src/cli/models/organization.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
import {AppConfigurationUsedByCli} from './extensions/specifications/types/app_config.js'
import {Flag, DeveloperPlatformClient} from '../utilities/developer-platform-client.js'
import {Organization as BaseOrganization} from '@shopify/organizations'
import {
Organization as BaseOrganization,
type OrganizationWithDetails as BaseOrganizationWithDetails,
} from '@shopify/organizations'

export enum OrganizationSource {
Partners = 'Partners',
Expand All @@ -11,6 +14,12 @@ export interface Organization extends BaseOrganization {
source: OrganizationSource
}

/**
* Organization returned by the destinations fetch, including the public fields the API returns
* without an extra request.
*/
export interface OrganizationWithDetails extends Organization, BaseOrganizationWithDetails {}

export interface MinimalAppIdentifiers {
apiKey: string
organizationId: string
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,14 @@ 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},
{
id: '12345',
businessName: 'test',
source: OrganizationSource.BusinessPlatform,
status: 'ACTIVE',
shopCount: 1,
url: 'https://admin.shopify.com/organization/12345',
},
])
})

Expand Down
3 changes: 3 additions & 0 deletions packages/app/src/cli/services/app/env/show.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,9 @@ describe('env show', () => {
businessName: 'test',
source: OrganizationSource.BusinessPlatform,
apps: {nodes: []},
status: 'ACTIVE' as const,
shopCount: 1,
url: 'https://admin.shopify.com/organization/123',
}

vi.mocked(fetchOrganizations).mockResolvedValue([organization])
Expand Down
17 changes: 14 additions & 3 deletions packages/app/src/cli/services/context.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,12 @@ import {CachedAppInfo} from './local-storage.js'
import link from './app/config/link.js'
import {fetchSpecifications} from './generate/fetch-extension-specifications.js'
import {DeployOptions} from './deploy.js'
import {Organization, OrganizationApp, OrganizationSource, OrganizationStore} from '../models/organization.js'
import {
OrganizationApp,
OrganizationSource,
OrganizationStore,
OrganizationWithDetails,
} from '../models/organization.js'
import {getAppIdentifiers} from '../models/app/identifiers.js'
import {
testDeveloperPlatformClient,
Expand Down Expand Up @@ -41,15 +46,21 @@ const APP2 = testOrganizationApp({
apiSecretKeys: [{secret: 'secret2'}],
})

const ORG1: Organization = {
const ORG1: OrganizationWithDetails = {
id: '1',
businessName: 'org1',
source: OrganizationSource.Partners,
status: 'ACTIVE',
shopCount: 1,
url: 'https://admin.shopify.com/organization/1',
}
const ORG2: Organization = {
const ORG2: OrganizationWithDetails = {
id: '2',
businessName: 'org2',
source: OrganizationSource.Partners,
status: 'LOCKED',
shopCount: null,
url: 'https://admin.shopify.com/organization/2',
}

const CACHED1: CachedAppInfo = {appId: 'key1', orgId: '1', storeFqdn: 'domain1', directory: '/cached'}
Expand Down
12 changes: 10 additions & 2 deletions packages/app/src/cli/services/dev/fetch.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
import {fetchOrganizations, fetchStore, NoOrgError, StoreNotFoundError} from './fetch.js'
import {Organization, OrganizationSource, OrganizationStore} from '../../models/organization.js'
import {
Organization,
OrganizationSource,
OrganizationStore,
OrganizationWithDetails,
} from '../../models/organization.js'
import {
testPartnersServiceSession,
testPartnersUserSession,
Expand All @@ -17,10 +22,13 @@ const ORG1: Organization = {
businessName: 'org1',
source: OrganizationSource.Partners,
}
const ORG2: Organization = {
const ORG2: OrganizationWithDetails = {
id: '2',
businessName: 'org2',
source: OrganizationSource.Partners,
status: 'ACTIVE',
shopCount: 1,
url: 'https://admin.shopify.com/organization/2',
}
const STORE1: OrganizationStore = {
shopId: '1',
Expand Down
6 changes: 3 additions & 3 deletions packages/app/src/cli/services/dev/fetch.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import {Organization, OrganizationStore} from '../../models/organization.js'
import {Organization, OrganizationStore, OrganizationWithDetails} from '../../models/organization.js'
import {
DeveloperPlatformClient,
Store,
Expand Down Expand Up @@ -75,9 +75,9 @@ export class NoOrgError extends AbortError {
* If the user doesn't belong to any org, throw an error
* @returns List of organizations
*/
export async function fetchOrganizations(): Promise<Organization[]> {
export async function fetchOrganizations(): Promise<OrganizationWithDetails[]> {
const client = defaultDeveloperPlatformClient()
const organizations: Organization[] = await client.organizations()
const organizations = await client.organizations()

if (organizations.length === 0) {
const session = await client.session()
Expand Down
3 changes: 3 additions & 0 deletions packages/app/src/cli/services/info.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,9 @@ const ORG1 = {
businessName: 'test',
apps: {nodes: []},
source: OrganizationSource.BusinessPlatform,
status: 'ACTIVE' as const,
shopCount: 1,
url: 'https://admin.shopify.com/organization/123',
}

function buildDeveloperPlatformClient(): DeveloperPlatformClient {
Expand Down
Loading
Loading