diff --git a/.changeset/bright-organizations-list.md b/.changeset/bright-organizations-list.md new file mode 100644 index 00000000000..91b743516df --- /dev/null +++ b/.changeset/bright-organizations-list.md @@ -0,0 +1,5 @@ +--- +'@shopify/app': minor +--- + +Add typed JSON output with organization status, shop count, and URL to `organization list`. diff --git a/packages/app/src/cli/commands/organization/list.test.ts b/packages/app/src/cli/commands/organization/list.test.ts index a4d866ce44d..527acf03893 100644 --- a/packages/app/src/cli/commands/organization/list.test.ts +++ b/packages/app/src/cli/commands/organization/list.test.ts @@ -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) }) }) diff --git a/packages/app/src/cli/commands/organization/list.ts b/packages/app/src/cli/commands/organization/list.ts index ea87a89701b..4fb68679f87 100644 --- a/packages/app/src/cli/commands/organization/list.ts +++ b/packages/app/src/cli/commands/organization/list.ts @@ -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' @@ -16,8 +19,23 @@ export default class OrganizationList extends BaseCommand { ...jsonFlag, } + static get jsonOutputSchema() { + return organizationListJsonOutputSchema + } + async run(): Promise { 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 + } } } diff --git a/packages/app/src/cli/models/app/app.test-data.ts b/packages/app/src/cli/models/app/app.test-data.ts index ee16ab80c39..638f5042b8a 100644 --- a/packages/app/src/cli/models/app/app.test-data.ts +++ b/packages/app/src/cli/models/app/app.test-data.ts @@ -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' @@ -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: { diff --git a/packages/app/src/cli/models/organization.ts b/packages/app/src/cli/models/organization.ts index 0ad5058bf34..5f5b2172243 100644 --- a/packages/app/src/cli/models/organization.ts +++ b/packages/app/src/cli/models/organization.ts @@ -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', @@ -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 diff --git a/packages/app/src/cli/services/app/config/link-service.test.ts b/packages/app/src/cli/services/app/config/link-service.test.ts index 47d4db12f0f..018f2d4f75d 100644 --- a/packages/app/src/cli/services/app/config/link-service.test.ts +++ b/packages/app/src/cli/services/app/config/link-service.test.ts @@ -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', + }, ]) }) diff --git a/packages/app/src/cli/services/app/env/show.test.ts b/packages/app/src/cli/services/app/env/show.test.ts index cd3e039e7c9..229d583cf54 100644 --- a/packages/app/src/cli/services/app/env/show.test.ts +++ b/packages/app/src/cli/services/app/env/show.test.ts @@ -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]) diff --git a/packages/app/src/cli/services/context.test.ts b/packages/app/src/cli/services/context.test.ts index 252ea3f06cf..4d0dc3c66f1 100644 --- a/packages/app/src/cli/services/context.test.ts +++ b/packages/app/src/cli/services/context.test.ts @@ -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, @@ -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'} diff --git a/packages/app/src/cli/services/dev/fetch.test.ts b/packages/app/src/cli/services/dev/fetch.test.ts index 9ff8d5f7f13..8e05551b0f1 100644 --- a/packages/app/src/cli/services/dev/fetch.test.ts +++ b/packages/app/src/cli/services/dev/fetch.test.ts @@ -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, @@ -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', diff --git a/packages/app/src/cli/services/dev/fetch.ts b/packages/app/src/cli/services/dev/fetch.ts index 9206eb942bf..d8426e4d3b4 100644 --- a/packages/app/src/cli/services/dev/fetch.ts +++ b/packages/app/src/cli/services/dev/fetch.ts @@ -1,4 +1,4 @@ -import {Organization, OrganizationStore} from '../../models/organization.js' +import {Organization, OrganizationStore, OrganizationWithDetails} from '../../models/organization.js' import { DeveloperPlatformClient, Store, @@ -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 { +export async function fetchOrganizations(): Promise { const client = defaultDeveloperPlatformClient() - const organizations: Organization[] = await client.organizations() + const organizations = await client.organizations() if (organizations.length === 0) { const session = await client.session() diff --git a/packages/app/src/cli/services/info.test.ts b/packages/app/src/cli/services/info.test.ts index 354741b7e77..2fbfaa88369 100644 --- a/packages/app/src/cli/services/info.test.ts +++ b/packages/app/src/cli/services/info.test.ts @@ -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 { diff --git a/packages/app/src/cli/services/organization/list.test.ts b/packages/app/src/cli/services/organization/list.test.ts index 5960ba5bf4b..c4f892d107c 100644 --- a/packages/app/src/cli/services/organization/list.test.ts +++ b/packages/app/src/cli/services/organization/list.test.ts @@ -1,73 +1,112 @@ import {organizationList} from './list.js' +import {organizationListJsonOutputSchema} from './list/types.js' import {fetchOrganizations, NoOrgError} from '../dev/fetch.js' -import {Organization, OrganizationSource} from '../../models/organization.js' +import {OrganizationSource, OrganizationWithDetails} from '../../models/organization.js' import {describe, expect, test, vi} from 'vitest' -import {mockAndCaptureOutput} from '@shopify/cli-kit/node/testing/output' -import {renderTable} from '@shopify/cli-kit/node/ui' vi.mock('../dev/fetch.js') -vi.mock('@shopify/cli-kit/node/ui') -const ORG1: Organization = { +const ORG1: OrganizationWithDetails = { id: '123', businessName: 'Test Organization', source: OrganizationSource.Partners, + status: 'ACTIVE', + shopCount: 3, + url: 'https://admin.shopify.com/organization/123', } -const ORG2: Organization = { +const ORG2: OrganizationWithDetails = { id: '456', businessName: 'Another Organization', source: OrganizationSource.BusinessPlatform, + status: 'LOCKED', + shopCount: null, + url: 'https://admin.shopify.com/organization/456', } describe('organizationList', () => { - test('renders table with organization id and name', async () => { + test('returns organizations with id, gid, name, status, shop count, and url (excludes source)', async () => { vi.mocked(fetchOrganizations).mockResolvedValue([ORG1, ORG2]) - await organizationList({json: false}) + const result = await organizationList() - expect(renderTable).toHaveBeenCalledWith({ - rows: [ - {id: '123', name: 'Test Organization'}, - {id: '456', name: 'Another Organization'}, - ], - columns: { - id: {header: 'ID'}, - name: {header: 'NAME'}, - }, - }) - }) - - test('outputs JSON with id, gid, and name (excludes source)', async () => { - const mockOutput = mockAndCaptureOutput() - mockOutput.clear() - vi.mocked(fetchOrganizations).mockResolvedValue([ORG1, ORG2]) - - await organizationList({json: true}) - - expect(JSON.parse(mockOutput.output())).toEqual({ + expect(result).toEqual({ organizations: [ - {id: '123', gid: 'gid://organization/Organization/123', name: 'Test Organization'}, - {id: '456', gid: 'gid://organization/Organization/456', name: 'Another Organization'}, + { + id: '123', + gid: 'gid://organization/Organization/123', + name: 'Test Organization', + status: 'ACTIVE', + shopCount: 3, + url: 'https://admin.shopify.com/organization/123', + }, + { + id: '456', + gid: 'gid://organization/Organization/456', + name: 'Another Organization', + status: 'LOCKED', + shopCount: null, + url: 'https://admin.shopify.com/organization/456', + }, ], }) }) - test('returns empty JSON array when NoOrgError thrown in JSON mode', async () => { - const mockOutput = mockAndCaptureOutput() - mockOutput.clear() + test('propagates NoOrgError', async () => { const error = new NoOrgError({type: 'UserAccount', email: 'test@example.com'}) vi.mocked(fetchOrganizations).mockRejectedValue(error) - await organizationList({json: true}) + await expect(organizationList()).rejects.toThrow(error) + }) +}) - expect(JSON.parse(mockOutput.output())).toEqual({organizations: []}) +describe('organizationListJsonOutputSchema', () => { + test('encodes the public result in a stable field order', () => { + expect( + organizationListJsonOutputSchema.encode({ + organizations: [ + { + id: '123', + gid: 'gid://organization/Organization/123', + name: 'Test Organization', + status: 'ACTIVE', + shopCount: null, + url: 'https://admin.shopify.com/organization/123', + }, + ], + }), + ).toBe(`{ + "organizations": [ + { + "id": "123", + "gid": "gid://organization/Organization/123", + "name": "Test Organization", + "status": "ACTIVE", + "shopCount": null, + "url": "https://admin.shopify.com/organization/123" + } + ] +}`) }) - test('propagates NoOrgError in table mode', async () => { - const error = new NoOrgError({type: 'UserAccount', email: 'test@example.com'}) - vi.mocked(fetchOrganizations).mockRejectedValue(error) + test('rejects an unknown status value', () => { + expect(() => + organizationListJsonOutputSchema.validate({ + organizations: [ + { + id: '123', + gid: 'gid://organization/Organization/123', + name: 'Test Organization', + status: 'SUSPENDED', + shopCount: 3, + url: 'https://admin.shopify.com/organization/123', + }, + ], + }), + ).toThrow() + }) - await expect(organizationList({json: false})).rejects.toThrow(NoOrgError) + test('rejects invalid public results', () => { + expect(() => organizationListJsonOutputSchema.validate({organizations: [{id: 123}]})).toThrow() }) }) diff --git a/packages/app/src/cli/services/organization/list.ts b/packages/app/src/cli/services/organization/list.ts index 11147d918dc..40415f276b2 100644 --- a/packages/app/src/cli/services/organization/list.ts +++ b/packages/app/src/cli/services/organization/list.ts @@ -1,52 +1,18 @@ -import {fetchOrganizations, NoOrgError} from '../dev/fetch.js' -import {Organization} from '../../models/organization.js' +import {type OrganizationListResult} from './list/types.js' +import {fetchOrganizations} from '../dev/fetch.js' import {organizationGidForBP} from '../../utilities/developer-platform-client/app-management-client.js' -import {outputResult} from '@shopify/cli-kit/node/output' -import {renderTable} from '@shopify/cli-kit/node/ui' -interface OrganizationListOptions { - json: boolean -} - -export async function organizationList(options: OrganizationListOptions): Promise { - let organizations: Organization[] - try { - organizations = await fetchOrganizations() - } catch (error) { - // In JSON mode, return empty array for CI/agents instead of throwing - if (options.json && error instanceof NoOrgError) { - outputResult(JSON.stringify({organizations: []}, null, 2)) - return - } - throw error - } +export async function organizationList(): Promise { + const organizations = await fetchOrganizations() - if (options.json) { - const jsonOutput = { - organizations: organizations.map((org) => ({ - id: org.id, - gid: organizationGidForBP(org.id), - name: org.businessName, - })), - } - outputResult(JSON.stringify(jsonOutput, null, 2)) - return + return { + organizations: organizations.map((organization) => ({ + id: organization.id, + gid: organizationGidForBP(organization.id), + name: organization.businessName, + status: organization.status, + shopCount: organization.shopCount, + url: organization.url, + })), } - - renderOrganizationsTable(organizations) -} - -function renderOrganizationsTable(organizations: Organization[]): void { - const rows = organizations.map((org) => ({ - id: org.id, - name: org.businessName, - })) - - renderTable({ - rows, - columns: { - id: {header: 'ID'}, - name: {header: 'NAME'}, - }, - }) } diff --git a/packages/app/src/cli/services/organization/list/result.test.ts b/packages/app/src/cli/services/organization/list/result.test.ts new file mode 100644 index 00000000000..8e5497f11c9 --- /dev/null +++ b/packages/app/src/cli/services/organization/list/result.test.ts @@ -0,0 +1,70 @@ +import {writeOrganizationListResult} from './result.js' +import {renderTable} from '@shopify/cli-kit/node/ui' +import {describe, expect, test, vi} from 'vitest' + +vi.mock('@shopify/cli-kit/node/ui') +vi.mock('@shopify/cli-kit/node/context/local', async (importOriginal) => ({ + ...(await importOriginal()), + isUnitTest: () => false, +})) + +const ORGANIZATION_1 = { + id: '123', + gid: 'gid://organization/Organization/123', + name: 'Test Organization', + status: 'ACTIVE' as const, + shopCount: 3, + url: 'https://admin.shopify.com/organization/123', +} + +const ORGANIZATION_2 = { + id: '456', + gid: 'gid://organization/Organization/456', + name: 'Another Organization', + status: 'LOCKED' as const, + shopCount: null, + url: 'https://admin.shopify.com/organization/456', +} + +describe('writeOrganizationListResult', () => { + test('renders a table with organization id and name in text format', () => { + writeOrganizationListResult({organizations: [ORGANIZATION_1, ORGANIZATION_2]}, 'text') + + expect(renderTable).toHaveBeenCalledWith({ + rows: [ + {id: '123', name: 'Test Organization'}, + {id: '456', name: 'Another Organization'}, + ], + columns: { + id: {header: 'ID'}, + name: {header: 'NAME'}, + }, + }) + }) + + test('writes the exact JSON document to stdout and nothing to stderr', () => { + const stdout = vi.spyOn(process.stdout, 'write').mockImplementation(() => true) + const stderr = vi.spyOn(process.stderr, 'write').mockImplementation(() => true) + + writeOrganizationListResult({organizations: [ORGANIZATION_1]}, 'json') + + const stdoutContent = stdout.mock.calls.map(([content]) => String(content)).join('') + + const expectedJson = `{ + "organizations": [ + { + "id": "123", + "gid": "gid://organization/Organization/123", + "name": "Test Organization", + "status": "ACTIVE", + "shopCount": 3, + "url": "https://admin.shopify.com/organization/123" + } + ] +}` + + expect(stdout).toHaveBeenCalledOnce() + expect(stdoutContent).toBe(`${expectedJson}\n`) + expect(stderr).not.toHaveBeenCalled() + }) +}) diff --git a/packages/app/src/cli/services/organization/list/result.ts b/packages/app/src/cli/services/organization/list/result.ts new file mode 100644 index 00000000000..6d408fb5d33 --- /dev/null +++ b/packages/app/src/cli/services/organization/list/result.ts @@ -0,0 +1,21 @@ +import {organizationListJsonOutputSchema, type OrganizationListResult} from './types.js' +import {outputResult} from '@shopify/cli-kit/node/output' +import {renderTable} from '@shopify/cli-kit/node/ui' + +export function writeOrganizationListResult(result: OrganizationListResult, format: 'json' | 'text'): void { + if (format === 'json') { + outputResult(organizationListJsonOutputSchema.encode(result)) + return + } + + renderTable({ + rows: result.organizations.map((organization) => ({ + id: organization.id, + name: organization.name, + })), + columns: { + id: {header: 'ID'}, + name: {header: 'NAME'}, + }, + }) +} diff --git a/packages/app/src/cli/services/organization/list/types.ts b/packages/app/src/cli/services/organization/list/types.ts new file mode 100644 index 00000000000..0c9e36ade47 --- /dev/null +++ b/packages/app/src/cli/services/organization/list/types.ts @@ -0,0 +1,22 @@ +import {defineJsonOutputSchema, type InferJsonOutputSchema} from '@shopify/cli-kit/node/json-output-schema' +import {organizationStatusValues} from '@shopify/organizations' +import {zod} from '@shopify/cli-kit/node/schema' + +const OrganizationListEntrySchema = zod + .object({ + id: zod.string(), + gid: zod.string(), + name: zod.string(), + status: zod.enum(organizationStatusValues), + shopCount: zod.number().nullable(), + url: zod.string(), + }) + .strict() + +export const organizationListJsonOutputSchema = defineJsonOutputSchema({ + name: 'OrganizationListResult', + schema: zod.object({organizations: zod.array(OrganizationListEntrySchema)}).strict(), + definitions: {OrganizationListEntry: OrganizationListEntrySchema}, +}) + +export type OrganizationListResult = InferJsonOutputSchema diff --git a/packages/app/src/cli/utilities/developer-platform-client.ts b/packages/app/src/cli/utilities/developer-platform-client.ts index 3e378c3d9c5..448d0284b4b 100644 --- a/packages/app/src/cli/utilities/developer-platform-client.ts +++ b/packages/app/src/cli/utilities/developer-platform-client.ts @@ -6,6 +6,7 @@ import { OrganizationApp, OrganizationSource, OrganizationStore, + OrganizationWithDetails, } from '../models/organization.js' import {AllAppExtensionRegistrationsQuerySchema} from '../api/graphql/all_app_extension_registrations.js' import {AppDeploySchema, AppDeployVariables} from '../api/graphql/app_deploy.js' @@ -215,7 +216,7 @@ export interface DeveloperPlatformClient { unsafeRefreshToken: () => Promise accountInfo: () => Promise appFromIdentifiers: (apiKey: string) => Promise - organizations: () => Promise + organizations: () => Promise orgFromId: (orgId: string) => Promise orgAndApps: (orgId: string) => Promise> appsForOrg: (orgId: string, term?: string) => Promise> diff --git a/packages/app/src/cli/utilities/developer-platform-client/app-management-client.test.ts b/packages/app/src/cli/utilities/developer-platform-client/app-management-client.test.ts index 3eea71b1ac9..634cd7a2d48 100644 --- a/packages/app/src/cli/utilities/developer-platform-client/app-management-client.test.ts +++ b/packages/app/src/cli/utilities/developer-platform-client/app-management-client.test.ts @@ -2238,9 +2238,27 @@ describe('organizations', () => { // Given const client = AppManagementClient.getInstance() vi.mocked(fetchOrganizations).mockResolvedValueOnce([ - {id: '1', businessName: 'Org One'}, - {id: '2', businessName: 'Org Two'}, - {id: '3', businessName: 'Org Three'}, + { + id: '1', + businessName: 'Org One', + status: 'ACTIVE', + shopCount: 1, + url: 'https://admin.shopify.com/organization/1', + }, + { + id: '2', + businessName: 'Org Two', + status: 'ACTIVE', + shopCount: null, + url: 'https://admin.shopify.com/organization/2', + }, + { + id: '3', + businessName: 'Org Three', + status: 'LOCKED', + shopCount: 3, + url: 'https://admin.shopify.com/organization/3', + }, ]) // When @@ -2248,9 +2266,30 @@ describe('organizations', () => { // Then expect(result).toEqual([ - {id: '1', businessName: 'Org One', source: 'BusinessPlatform'}, - {id: '2', businessName: 'Org Two', source: 'BusinessPlatform'}, - {id: '3', businessName: 'Org Three', source: 'BusinessPlatform'}, + { + id: '1', + businessName: 'Org One', + source: 'BusinessPlatform', + status: 'ACTIVE', + shopCount: 1, + url: 'https://admin.shopify.com/organization/1', + }, + { + id: '2', + businessName: 'Org Two', + source: 'BusinessPlatform', + status: 'ACTIVE', + shopCount: null, + url: 'https://admin.shopify.com/organization/2', + }, + { + id: '3', + businessName: 'Org Three', + source: 'BusinessPlatform', + status: 'LOCKED', + shopCount: 3, + url: 'https://admin.shopify.com/organization/3', + }, ]) }) 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 ec44f4ded5b..1dd4bce1f6b 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 @@ -41,6 +41,7 @@ import { OrganizationApp, OrganizationSource, OrganizationStore, + OrganizationWithDetails, } from '../../models/organization.js' import { AllAppExtensionRegistrationsQuerySchema, @@ -367,7 +368,7 @@ export class AppManagementClient implements DeveloperPlatformClient { } } - async organizations(): Promise { + async organizations(): Promise { const orgs = await fetchOrganizations() return orgs.map((org) => ({ ...org, diff --git a/packages/cli/README.md b/packages/cli/README.md index ecea5aaef2e..517cb09488d 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -3417,6 +3417,71 @@ DESCRIPTION List Shopify organizations you have access to. Lists the Shopify organizations that you have access to, along with their organization IDs. + + Output from `--json` conforms to the `OrganizationListResult` schema. + + Use `--json-schema` to print the result, error, and event schemas. + + ```json + { + "type": "object", + "properties": { + "organizations": { + "type": "array", + "items": { + "$ref": "#/definitions/OrganizationListEntry" + } + } + }, + "required": [ + "organizations" + ], + "additionalProperties": false, + "title": "OrganizationListResult", + "definitions": { + "OrganizationListEntry": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "gid": { + "type": "string" + }, + "name": { + "type": "string" + }, + "status": { + "type": "string", + "enum": [ + "ACTIVE", + "LOCKED" + ] + }, + "shopCount": { + "type": [ + "number", + "null" + ] + }, + "url": { + "type": "string" + } + }, + "required": [ + "id", + "gid", + "name", + "status", + "shopCount", + "url" + ], + "additionalProperties": false + } + }, + "$schema": "http://json-schema.org/draft-07/schema#" + } + ``` ``` ## `shopify plugins add PLUGIN` diff --git a/packages/cli/oclif.manifest.json b/packages/cli/oclif.manifest.json index 3121725eac6..6a4badf2ae7 100644 --- a/packages/cli/oclif.manifest.json +++ b/packages/cli/oclif.manifest.json @@ -7419,7 +7419,7 @@ "args": { }, "customPluginName": "@shopify/app", - "description": "Lists the Shopify organizations that you have access to, along with their organization IDs.", + "description": "Lists the Shopify organizations that you have access to, along with their organization IDs.\n\nOutput from `--json` conforms to the `OrganizationListResult` schema.\n\nUse `--json-schema` to print the result, error, and event schemas.\n\n```json\n{\n \"type\": \"object\",\n \"properties\": {\n \"organizations\": {\n \"type\": \"array\",\n \"items\": {\n \"$ref\": \"#/definitions/OrganizationListEntry\"\n }\n }\n },\n \"required\": [\n \"organizations\"\n ],\n \"additionalProperties\": false,\n \"title\": \"OrganizationListResult\",\n \"definitions\": {\n \"OrganizationListEntry\": {\n \"type\": \"object\",\n \"properties\": {\n \"id\": {\n \"type\": \"string\"\n },\n \"gid\": {\n \"type\": \"string\"\n },\n \"name\": {\n \"type\": \"string\"\n },\n \"status\": {\n \"type\": \"string\",\n \"enum\": [\n \"ACTIVE\",\n \"LOCKED\"\n ]\n },\n \"shopCount\": {\n \"type\": [\n \"number\",\n \"null\"\n ]\n },\n \"url\": {\n \"type\": \"string\"\n }\n },\n \"required\": [\n \"id\",\n \"gid\",\n \"name\",\n \"status\",\n \"shopCount\",\n \"url\"\n ],\n \"additionalProperties\": false\n }\n },\n \"$schema\": \"http://json-schema.org/draft-07/schema#\"\n}\n```", "descriptionWithMarkdown": "Lists the Shopify organizations that you have access to, along with their organization IDs.", "enableJsonFlag": false, "flags": { diff --git a/packages/eslint-plugin-cli/rules/json-output-command-exceptions.js b/packages/eslint-plugin-cli/rules/json-output-command-exceptions.js index 9eb6f761bf7..46b063ed9f7 100644 --- a/packages/eslint-plugin-cli/rules/json-output-command-exceptions.js +++ b/packages/eslint-plugin-cli/rules/json-output-command-exceptions.js @@ -38,7 +38,6 @@ const commandExceptions = [ 'packages/app/src/cli/commands/app/subscription-migrations/unschedule.ts', 'packages/app/src/cli/commands/app/versions/list.ts', 'packages/app/src/cli/commands/app/webhook/trigger.ts', - 'packages/app/src/cli/commands/organization/list.ts', 'packages/cli/src/cli/commands/auth/login.ts', 'packages/cli/src/cli/commands/auth/logout.ts', 'packages/cli/src/cli/commands/cache/clear.ts', diff --git a/packages/organizations/src/cli/api/graphql/business-platform-destinations/generated/organizations.ts b/packages/organizations/src/cli/api/graphql/business-platform-destinations/generated/organizations.ts index fffb8744b01..9ae1a34bc56 100644 --- a/packages/organizations/src/cli/api/graphql/business-platform-destinations/generated/organizations.ts +++ b/packages/organizations/src/cli/api/graphql/business-platform-destinations/generated/organizations.ts @@ -8,7 +8,9 @@ export type ListOrganizationsQueryVariables = Types.Exact<{[key: string]: never} export type ListOrganizationsQuery = { currentUserAccount?: { uuid: string - organizationsWithAccessToDestination: {nodes: {id: string; name: string}[]} + organizationsWithAccessToDestination: { + nodes: {id: string; name: string; status: Types.OrganizationStatus; shopCount?: number | null; url: string}[] + } } | null } @@ -50,6 +52,9 @@ export const ListOrganizations = { selections: [ {kind: 'Field', name: {kind: 'Name', value: 'id'}}, {kind: 'Field', name: {kind: 'Name', value: 'name'}}, + {kind: 'Field', name: {kind: 'Name', value: 'status'}}, + {kind: 'Field', name: {kind: 'Name', value: 'shopCount'}}, + {kind: 'Field', name: {kind: 'Name', value: 'url'}}, {kind: 'Field', name: {kind: 'Name', value: '__typename'}}, ], }, diff --git a/packages/organizations/src/cli/api/graphql/business-platform-destinations/generated/types.d.ts b/packages/organizations/src/cli/api/graphql/business-platform-destinations/generated/types.d.ts index 9fed0e6e078..6b86e920a05 100644 --- a/packages/organizations/src/cli/api/graphql/business-platform-destinations/generated/types.d.ts +++ b/packages/organizations/src/cli/api/graphql/business-platform-destinations/generated/types.d.ts @@ -22,3 +22,9 @@ export type Scalars = { /** The ID for a Organization. */ OrganizationID: { input: any; output: any; } }; + +export type OrganizationStatus = + /** Organization is active. */ + | 'ACTIVE' + /** Organization is locked. */ + | 'LOCKED'; diff --git a/packages/organizations/src/cli/api/graphql/business-platform-destinations/queries/organizations.graphql b/packages/organizations/src/cli/api/graphql/business-platform-destinations/queries/organizations.graphql index ca78dbdda2e..ecdc85fe4de 100644 --- a/packages/organizations/src/cli/api/graphql/business-platform-destinations/queries/organizations.graphql +++ b/packages/organizations/src/cli/api/graphql/business-platform-destinations/queries/organizations.graphql @@ -5,6 +5,9 @@ query ListOrganizations { nodes { id name + status + shopCount + url } } } diff --git a/packages/organizations/src/cli/models/organization.ts b/packages/organizations/src/cli/models/organization.ts index b4efab626c5..f10e4f30550 100644 --- a/packages/organizations/src/cli/models/organization.ts +++ b/packages/organizations/src/cli/models/organization.ts @@ -1,4 +1,22 @@ +/** + * Organization status values, kept in sync with the `OrganizationStatus` GraphQL enum. + * The `fetchOrganizations` mapping fails type-check if the API adds a new value. + */ +export const organizationStatusValues = ['ACTIVE', 'LOCKED'] as const + +export type OrganizationStatus = (typeof organizationStatusValues)[number] + export interface Organization { id: string businessName: string } + +/** + * Organization with every field the destinations query returns without an extra request. + * Only the destinations fetch produces this shape, so other `Organization` consumers stay unchanged. + */ +export interface OrganizationWithDetails extends Organization { + status: OrganizationStatus + shopCount: number | null + url: string +} diff --git a/packages/organizations/src/cli/services/fetch.test.ts b/packages/organizations/src/cli/services/fetch.test.ts index 4a86d7012e6..7961c9ff4df 100644 --- a/packages/organizations/src/cli/services/fetch.test.ts +++ b/packages/organizations/src/cli/services/fetch.test.ts @@ -9,18 +9,30 @@ vi.mock('@shopify/cli-kit/node/session') const ENCODED_GID_1 = Buffer.from('gid://organization/Organization/1234').toString('base64') const ENCODED_GID_2 = Buffer.from('gid://organization/Organization/5678').toString('base64') +const NODE_1 = { + id: ENCODED_GID_1, + name: 'My Org', + status: 'ACTIVE' as const, + shopCount: 3, + url: 'https://admin.shopify.com/organization/1234', +} +const NODE_2 = { + id: ENCODED_GID_2, + name: 'Other Org', + status: 'LOCKED' as const, + shopCount: null, + url: 'https://admin.shopify.com/organization/5678', +} + describe('fetchOrganizations', () => { - test('returns organizations with decoded numeric IDs', async () => { + test('returns organizations with decoded numeric IDs and public destination fields', 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'}, - {id: ENCODED_GID_2, name: 'Other Org'}, - ], + nodes: [NODE_1, NODE_2], }, }, }) @@ -28,8 +40,20 @@ describe('fetchOrganizations', () => { const orgs = await fetchOrganizations() expect(orgs).toEqual([ - {id: '1234', businessName: 'My Org'}, - {id: '5678', businessName: 'Other Org'}, + { + id: '1234', + businessName: 'My Org', + status: 'ACTIVE', + shopCount: 3, + url: 'https://admin.shopify.com/organization/1234', + }, + { + id: '5678', + businessName: 'Other Org', + status: 'LOCKED', + shopCount: null, + url: 'https://admin.shopify.com/organization/5678', + }, ]) }) @@ -66,7 +90,7 @@ describe('fetchOrganizations', () => { uuid: 'user-uuid', email: 'merchant@example.com', organizationsWithAccessToDestination: { - nodes: [{id: ENCODED_GID_1, name: 'My Org'}], + nodes: [NODE_1], }, }, }) @@ -92,7 +116,7 @@ describe('fetchOrganizationsWithAccessInfo', () => { uuid: 'user-uuid', email: 'merchant@example.com', organizationsWithAccessToDestination: { - nodes: [{id: ENCODED_GID_1, name: 'My Org'}], + nodes: [NODE_1], }, }, }) @@ -110,7 +134,7 @@ describe('fetchOrganizationsWithAccessInfo', () => { uuid: 'user-uuid', email: 'merchant@example.com', organizationsWithAccessToDestination: { - nodes: [{id: ENCODED_GID_1, name: 'My Org'}], + nodes: [NODE_1], }, }, }) @@ -132,7 +156,7 @@ describe('fetchOrganizationsWithAccessInfo', () => { uuid: 'user-uuid', email: 'merchant@example.com', organizationsWithAccessToDestination: { - nodes: [{id: ENCODED_GID_1, name: 'My Org'}], + nodes: [NODE_1], }, }, }) @@ -151,7 +175,7 @@ describe('fetchOrganizationsWithAccessInfo', () => { uuid: 'user-uuid', email: 'merchant@example.com', organizationsWithAccessToDestination: { - nodes: [{id: ENCODED_GID_1, name: 'My Org'}], + nodes: [NODE_1], }, }, }) @@ -159,7 +183,15 @@ describe('fetchOrganizationsWithAccessInfo', () => { const result = await fetchOrganizationsWithAccessInfo() expect(result).toEqual({ - organizations: [{id: '1234', businessName: 'My Org'}], + organizations: [ + { + id: '1234', + businessName: 'My Org', + status: 'ACTIVE', + shopCount: 3, + url: 'https://admin.shopify.com/organization/1234', + }, + ], currentUserResolved: true, }) }) diff --git a/packages/organizations/src/cli/services/fetch.ts b/packages/organizations/src/cli/services/fetch.ts index a174465c21c..00bfed6fb9d 100644 --- a/packages/organizations/src/cli/services/fetch.ts +++ b/packages/organizations/src/cli/services/fetch.ts @@ -1,5 +1,5 @@ import {ListOrganizations} from '../api/graphql/business-platform-destinations/generated/organizations.js' -import {Organization} from '../models/organization.js' +import {Organization, OrganizationWithDetails} 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' @@ -10,14 +10,23 @@ interface FetchOrganizationsWithAccessInfoResult { currentUserResolved: boolean } -export async function fetchOrganizations(): Promise { - const result = await fetchOrganizationsWithAccessInfo() +interface FetchOrganizationsWithDetailsResult { + organizations: OrganizationWithDetails[] + currentUserResolved: boolean +} + +export async function fetchOrganizations(): Promise { + const result = await fetchOrganizationsWithDetails() return result.organizations } export async function fetchOrganizationsWithAccessInfo( token?: string, ): Promise { + return fetchOrganizationsWithDetails(token) +} + +async function fetchOrganizationsWithDetails(token?: string): Promise { const resolvedToken = token ?? (await ensureAuthenticatedBusinessPlatform()) const unauthorizedHandler = { type: 'token_refresh' as const, @@ -42,7 +51,13 @@ export async function fetchOrganizationsWithAccessInfo( if (id === undefined) { throw new AbortError(`Failed to decode organization ID from: ${org.id}`) } - return {id, businessName: org.name} + return { + id, + businessName: org.name, + status: org.status, + shopCount: org.shopCount ?? null, + url: org.url, + } }) return { diff --git a/packages/organizations/src/cli/services/select.test.ts b/packages/organizations/src/cli/services/select.test.ts index dd6efdfd87c..6f0fdef40f1 100644 --- a/packages/organizations/src/cli/services/select.test.ts +++ b/packages/organizations/src/cli/services/select.test.ts @@ -6,10 +6,23 @@ import {describe, expect, test, vi} from 'vitest' vi.mock('./fetch.js') vi.mock('../prompts/organization.js') -const ORGS = [ - {id: '1234', businessName: 'My Org'}, - {id: '5678', businessName: 'Other Org'}, -] +const ORG_1 = { + id: '1234', + businessName: 'My Org', + status: 'ACTIVE' as const, + shopCount: 1, + url: 'https://admin.shopify.com/organization/1234', +} + +const ORG_2 = { + id: '5678', + businessName: 'Other Org', + status: 'LOCKED' as const, + shopCount: null, + url: 'https://admin.shopify.com/organization/5678', +} + +const ORGS = [ORG_1, ORG_2] describe('selectOrg', () => { test('returns org matching flag ID', async () => { @@ -17,7 +30,7 @@ describe('selectOrg', () => { const result = await selectOrg('5678') - expect(result).toEqual({id: '5678', businessName: 'Other Org'}) + expect(result).toEqual(ORG_2) expect(selectOrganizationPrompt).not.toHaveBeenCalled() }) @@ -33,7 +46,7 @@ describe('selectOrg', () => { const result = await selectOrg() - expect(result).toEqual({id: '1234', businessName: 'My Org'}) + expect(result).toEqual(ORG_1) expect(selectOrganizationPrompt).toHaveBeenCalledWith(ORGS) }) @@ -43,7 +56,7 @@ describe('selectOrg', () => { const result = await selectOrg(undefined) - expect(result).toEqual({id: '5678', businessName: 'Other Org'}) + expect(result).toEqual(ORG_2) expect(selectOrganizationPrompt).toHaveBeenCalledWith(ORGS) }) diff --git a/packages/organizations/src/index.ts b/packages/organizations/src/index.ts index 4d6a46a5f57..fd48a1732ae 100644 --- a/packages/organizations/src/index.ts +++ b/packages/organizations/src/index.ts @@ -1,7 +1,8 @@ export {fetchOrganizations, fetchOrganizationsWithAccessInfo} from './cli/services/fetch.js' export {selectOrg} from './cli/services/select.js' export {selectOrganizationPrompt} from './cli/prompts/organization.js' -export type {Organization} from './cli/models/organization.js' +export type {Organization, OrganizationWithDetails, OrganizationStatus} from './cli/models/organization.js' +export {organizationStatusValues} from './cli/models/organization.js' export {businessPlatformTokenRefreshHandler} from './cli/services/business-platform.js' export {createDevStore, devStorePlanHandles} from './cli/services/dev/create-dev-store.js' export type {CreateDevStoreOptions, DevStorePlan} from './cli/services/dev/create-dev-store.js'