From b8672695c5c7066581acb96b5f9c577cf75d3dc9 Mon Sep 17 00:00:00 2001 From: Donald Merand Date: Wed, 9 Sep 2026 16:47:47 -0400 Subject: [PATCH] Add JSON schema to organization list Assisted-By: devx/4b57fdd2-57c6-4b77-8fa7-0f44b976b0ae --- .../cli/commands/organization/list.test.ts | 83 ++++++++++++++++--- .../app/src/cli/commands/organization/list.ts | 37 ++++++++- .../cli/services/organization/list.test.ts | 63 ++++++-------- .../app/src/cli/services/organization/list.ts | 68 +++++---------- packages/cli/README.md | 16 ++++ packages/cli/oclif.manifest.json | 2 +- .../rules/json-output-legacy-command-paths.js | 1 - 7 files changed, 173 insertions(+), 97 deletions(-) diff --git a/packages/app/src/cli/commands/organization/list.test.ts b/packages/app/src/cli/commands/organization/list.test.ts index a4d866ce44d..d6089e1c3fa 100644 --- a/packages/app/src/cli/commands/organization/list.test.ts +++ b/packages/app/src/cli/commands/organization/list.test.ts @@ -1,31 +1,92 @@ import OrganizationList from './list.js' -import {organizationList} from '../../services/organization/list.js' +import { + organizationList, + organizationListJsonOutputSchema, + type OrganizationListResult, +} from '../../services/organization/list.js' +import {NoOrgError} from '../../services/dev/fetch.js' +import {outputResult} from '@shopify/cli-kit/node/output' +import {renderTable} from '@shopify/cli-kit/node/ui' import {describe, expect, test, vi} from 'vitest' -vi.mock('../../services/organization/list.js') +vi.mock('../../services/organization/list.js', async (importOriginal) => { + const actual = await importOriginal() + return {...actual, organizationList: vi.fn()} +}) +vi.mock('@shopify/cli-kit/node/output', async (importOriginal) => { + const actual = await importOriginal() + return {...actual, outputResult: vi.fn()} +}) +vi.mock('@shopify/cli-kit/node/ui') + +const RESULT: OrganizationListResult = { + organizations: [ + {id: '123', gid: 'gid://organization/Organization/123', name: 'Test Organization'}, + {id: '456', gid: 'gid://organization/Organization/456', name: 'Another Organization'}, + ], +} describe('organization list command', () => { - test('calls organizationList service with json: false by default', async () => { - vi.mocked(organizationList).mockResolvedValue() + test('exposes the organization list JSON schema', () => { + expect(OrganizationList.jsonOutputSchema).toBe(organizationListJsonOutputSchema) + }) + + test('renders the organization table by default', async () => { + vi.mocked(organizationList).mockResolvedValue(RESULT) await OrganizationList.run([], import.meta.url) - expect(organizationList).toHaveBeenCalledWith({json: false}) + expect(organizationList).toHaveBeenCalledWith() + expect(renderTable).toHaveBeenCalledWith({ + rows: [ + {id: '123', name: 'Test Organization'}, + {id: '456', name: 'Another Organization'}, + ], + columns: { + id: {header: 'ID'}, + name: {header: 'NAME'}, + }, + }) + expect(outputResult).not.toHaveBeenCalled() }) - test('calls organizationList service with json: true when --json flag is passed', async () => { - vi.mocked(organizationList).mockResolvedValue() + test('outputs the encoded JSON document when --json is passed', async () => { + vi.mocked(organizationList).mockResolvedValue(RESULT) await OrganizationList.run(['--json'], import.meta.url) - expect(organizationList).toHaveBeenCalledWith({json: true}) + expect(organizationList).toHaveBeenCalledWith() + expect(outputResult).toHaveBeenCalledWith(organizationListJsonOutputSchema.encode(RESULT)) + expect(renderTable).not.toHaveBeenCalled() }) - test('calls organizationList service with json: true when -j flag is passed', async () => { - vi.mocked(organizationList).mockResolvedValue() + test('outputs the encoded JSON document when -j is passed', async () => { + vi.mocked(organizationList).mockResolvedValue(RESULT) await OrganizationList.run(['-j'], import.meta.url) - expect(organizationList).toHaveBeenCalledWith({json: true}) + expect(outputResult).toHaveBeenCalledWith(organizationListJsonOutputSchema.encode(RESULT)) + }) + + test('returns an empty JSON array when NoOrgError is thrown in JSON mode', async () => { + vi.mocked(organizationList).mockRejectedValue(new NoOrgError({type: 'UserAccount', email: 'test@example.com'})) + + await OrganizationList.run(['--json'], import.meta.url) + + expect(outputResult).toHaveBeenCalledWith(organizationListJsonOutputSchema.encode({organizations: []})) + }) + + test('uses standard error handling for NoOrgError in table mode', async () => { + vi.mocked(organizationList).mockRejectedValue(new NoOrgError({type: 'UserAccount', email: 'test@example.com'})) + const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + + try { + await expect(OrganizationList.run([], import.meta.url)).rejects.toThrow( + 'process.exit unexpectedly called with "1"', + ) + expect(outputResult).not.toHaveBeenCalled() + } finally { + consoleErrorSpy.mockRestore() + } }) }) diff --git a/packages/app/src/cli/commands/organization/list.ts b/packages/app/src/cli/commands/organization/list.ts index 1e5f71003bf..7cb7884c375 100644 --- a/packages/app/src/cli/commands/organization/list.ts +++ b/packages/app/src/cli/commands/organization/list.ts @@ -1,6 +1,13 @@ -import {organizationList} from '../../services/organization/list.js' +import { + organizationList, + organizationListJsonOutputSchema, + type OrganizationListResult, +} from '../../services/organization/list.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' +import {outputResult} from '@shopify/cli-kit/node/output' +import {renderTable} from '@shopify/cli-kit/node/ui' export default class OrganizationList extends BaseCommand { static baseFlags = {...BaseCommand.baseFlags, ...authAliasFlag} @@ -16,8 +23,34 @@ 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}) + + let result: OrganizationListResult + try { + result = await organizationList() + } catch (error) { + if (flags.json && error instanceof NoOrgError) { + outputResult(organizationListJsonOutputSchema.encode({organizations: []})) + return + } + throw error + } + + if (flags.json) { + outputResult(organizationListJsonOutputSchema.encode(result)) + } else { + 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.test.ts b/packages/app/src/cli/services/organization/list.test.ts index 5960ba5bf4b..27d05bb1c16 100644 --- a/packages/app/src/cli/services/organization/list.test.ts +++ b/packages/app/src/cli/services/organization/list.test.ts @@ -1,12 +1,9 @@ -import {organizationList} from './list.js' +import {organizationList, organizationListJsonOutputSchema} from './list.js' import {fetchOrganizations, NoOrgError} from '../dev/fetch.js' import {Organization, OrganizationSource} 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 = { id: '123', @@ -21,31 +18,10 @@ const ORG2: Organization = { } describe('organizationList', () => { - test('renders table with organization id and name', async () => { + test('returns organizations with id, gid, and name', async () => { vi.mocked(fetchOrganizations).mockResolvedValue([ORG1, ORG2]) - await organizationList({json: false}) - - 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({ + await expect(organizationList()).resolves.toEqual({ organizations: [ {id: '123', gid: 'gid://organization/Organization/123', name: 'Test Organization'}, {id: '456', gid: 'gid://organization/Organization/456', name: 'Another Organization'}, @@ -53,21 +29,34 @@ describe('organizationList', () => { }) }) - test('returns empty JSON array when NoOrgError thrown in JSON mode', async () => { - const mockOutput = mockAndCaptureOutput() - mockOutput.clear() - const error = new NoOrgError({type: 'UserAccount', email: 'test@example.com'}) - vi.mocked(fetchOrganizations).mockRejectedValue(error) - - await organizationList({json: true}) + test('encodes the public JSON document and excludes source', async () => { + vi.mocked(fetchOrganizations).mockResolvedValue([ORG1, ORG2]) - expect(JSON.parse(mockOutput.output())).toEqual({organizations: []}) + const result = await organizationList() + + expect(organizationListJsonOutputSchema.encode(result)).toBe(`{ + "organizations": [ + { + "id": "123", + "gid": "gid://organization/Organization/123", + "name": "Test Organization" + }, + { + "id": "456", + "gid": "gid://organization/Organization/456", + "name": "Another Organization" + } + ] +}`) + expect(() => + organizationListJsonOutputSchema.validate({organizations: [{id: '123', gid: 'gid', name: 1}]}), + ).toThrow() }) - test('propagates NoOrgError in table mode', async () => { + test('propagates NoOrgError', async () => { const error = new NoOrgError({type: 'UserAccount', email: 'test@example.com'}) vi.mocked(fetchOrganizations).mockRejectedValue(error) - await expect(organizationList({json: false})).rejects.toThrow(NoOrgError) + await expect(organizationList()).rejects.toThrow(NoOrgError) }) }) diff --git a/packages/app/src/cli/services/organization/list.ts b/packages/app/src/cli/services/organization/list.ts index 11147d918dc..3d93d1d7b0c 100644 --- a/packages/app/src/cli/services/organization/list.ts +++ b/packages/app/src/cli/services/organization/list.ts @@ -1,52 +1,30 @@ -import {fetchOrganizations, NoOrgError} from '../dev/fetch.js' -import {Organization} from '../../models/organization.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' +import {defineJsonOutputSchema, type InferJsonOutputSchema} from '@shopify/cli-kit/node/json-output-schema' +import {zod} from '@shopify/cli-kit/node/schema' -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 - } +const OrganizationSchema = zod.object({ + id: zod.string(), + gid: zod.string(), + name: zod.string(), +}) - 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 - } +export const organizationListJsonOutputSchema = defineJsonOutputSchema({ + name: 'OrganizationListResult', + schema: zod.object({organizations: zod.array(OrganizationSchema)}), + definitions: {Organization: OrganizationSchema}, +}) - renderOrganizationsTable(organizations) -} +export type OrganizationListResult = InferJsonOutputSchema -function renderOrganizationsTable(organizations: Organization[]): void { - const rows = organizations.map((org) => ({ - id: org.id, - name: org.businessName, - })) +export async function organizationList(): Promise { + const organizations = await fetchOrganizations() - renderTable({ - rows, - columns: { - id: {header: 'ID'}, - name: {header: 'NAME'}, - }, - }) + return { + organizations: organizations.map((organization) => ({ + id: organization.id, + gid: organizationGidForBP(organization.id), + name: organization.businessName, + })), + } } diff --git a/packages/cli/README.md b/packages/cli/README.md index 1d2b03449ad..7a744159bcd 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -3397,6 +3397,22 @@ 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 schema directly: + + ```ts + interface OrganizationListResult { + organizations: Organization[] + } + + interface Organization { + id: string + gid: string + name: string + } + ``` ``` ## `shopify plugins add PLUGIN` diff --git a/packages/cli/oclif.manifest.json b/packages/cli/oclif.manifest.json index 66513bcce84..601532c9d43 100644 --- a/packages/cli/oclif.manifest.json +++ b/packages/cli/oclif.manifest.json @@ -6461,7 +6461,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 schema directly:\n\n```ts\ninterface OrganizationListResult {\n organizations: Organization[]\n}\n\ninterface Organization {\n id: string\n gid: string\n name: string\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-legacy-command-paths.js b/packages/eslint-plugin-cli/rules/json-output-legacy-command-paths.js index 38ea9951b99..9670a5b1080 100644 --- a/packages/eslint-plugin-cli/rules/json-output-legacy-command-paths.js +++ b/packages/eslint-plugin-cli/rules/json-output-legacy-command-paths.js @@ -33,7 +33,6 @@ const legacyCommandPaths = [ 'packages/app/src/cli/commands/app/release.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',