From 726d67ccedbe9fdd1d1bc4e8613bf42ead7cd03f Mon Sep 17 00:00:00 2001 From: Donald Merand Date: Thu, 10 Sep 2026 22:01:03 -0400 Subject: [PATCH] Add typed JSON output to organization list Assisted-By: devx/54e07dfa-7888-4c74-92b5-c10e1b9be5ed --- .changeset/bright-organizations-list.md | 5 ++ .../cli/commands/organization/list.test.ts | 63 ++++++++++++++++--- .../app/src/cli/commands/organization/list.ts | 19 +++++- .../cli/services/organization/list.test.ts | 60 ++++++++---------- .../app/src/cli/services/organization/list.ts | 57 +++-------------- .../services/organization/list/result.test.ts | 54 ++++++++++++++++ .../cli/services/organization/list/result.ts | 21 +++++++ .../cli/services/organization/list/types.ts | 18 ++++++ packages/cli/README.md | 16 +++++ packages/cli/oclif.manifest.json | 2 +- .../rules/json-output-command-exceptions.js | 1 - 11 files changed, 222 insertions(+), 94 deletions(-) create mode 100644 .changeset/bright-organizations-list.md create mode 100644 packages/app/src/cli/services/organization/list/result.test.ts create mode 100644 packages/app/src/cli/services/organization/list/result.ts create mode 100644 packages/app/src/cli/services/organization/list/types.ts diff --git a/.changeset/bright-organizations-list.md b/.changeset/bright-organizations-list.md new file mode 100644 index 00000000000..68373e2f788 --- /dev/null +++ b/.changeset/bright-organizations-list.md @@ -0,0 +1,5 @@ +--- +'@shopify/app': minor +--- + +Add typed JSON output for `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..ed9c50cb136 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,22 @@ 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) { + if (flags.json && error instanceof NoOrgError) { + writeOrganizationListResult({organizations: []}, 'json') + return + } + throw error + } } } diff --git a/packages/app/src/cli/services/organization/list.test.ts b/packages/app/src/cli/services/organization/list.test.ts index 5960ba5bf4b..be956e82d35 100644 --- a/packages/app/src/cli/services/organization/list.test.ts +++ b/packages/app/src/cli/services/organization/list.test.ts @@ -1,12 +1,10 @@ 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 {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 +19,12 @@ const ORG2: Organization = { } describe('organizationList', () => { - test('renders table with organization id and name', async () => { + test('returns organizations with id, gid, and name (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'}, @@ -53,21 +32,32 @@ describe('organizationList', () => { }) }) - 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}) - - expect(JSON.parse(mockOutput.output())).toEqual({organizations: []}) + await expect(organizationList()).rejects.toThrow(error) }) +}) - test('propagates NoOrgError in table mode', async () => { - const error = new NoOrgError({type: 'UserAccount', email: 'test@example.com'}) - vi.mocked(fetchOrganizations).mockRejectedValue(error) +describe('organizationListJsonOutputSchema', () => { + test('encodes the public result', () => { + expect( + organizationListJsonOutputSchema.encode({ + organizations: [{id: '123', gid: 'gid://organization/Organization/123', name: 'Test Organization'}], + }), + ).toBe(`{ + "organizations": [ + { + "id": "123", + "gid": "gid://organization/Organization/123", + "name": "Test Organization" + } + ] +}`) + }) - 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..549c52ca1c0 100644 --- a/packages/app/src/cli/services/organization/list.ts +++ b/packages/app/src/cli/services/organization/list.ts @@ -1,52 +1,15 @@ -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, + })), } - - 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..d982ef3ea97 --- /dev/null +++ b/packages/app/src/cli/services/organization/list/result.test.ts @@ -0,0 +1,54 @@ +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, +})) + +describe('writeOrganizationListResult', () => { + test('renders a table with organization id and name in text format', () => { + writeOrganizationListResult( + { + organizations: [ + {id: '123', gid: 'gid://organization/Organization/123', name: 'Test Organization'}, + {id: '456', gid: 'gid://organization/Organization/456', name: 'Another Organization'}, + ], + }, + 'text', + ) + + expect(renderTable).toHaveBeenCalledWith({ + rows: [ + {id: '123', name: 'Test Organization'}, + {id: '456', name: 'Another Organization'}, + ], + columns: { + id: {header: 'ID'}, + name: {header: 'NAME'}, + }, + }) + }) + + test('writes one 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: [{id: '123', gid: 'gid://organization/Organization/123', name: 'Test Organization'}], + }, + 'json', + ) + + const stdoutContent = stdout.mock.calls.map(([content]) => String(content)).join('') + + expect(stdout).toHaveBeenCalledOnce() + expect(JSON.parse(stdoutContent)).toEqual({ + organizations: [{id: '123', gid: 'gid://organization/Organization/123', name: 'Test Organization'}], + }) + 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..ab6c8c34aa2 --- /dev/null +++ b/packages/app/src/cli/services/organization/list/types.ts @@ -0,0 +1,18 @@ +import {defineJsonOutputSchema, type InferJsonOutputSchema} from '@shopify/cli-kit/node/json-output-schema' +import {zod} from '@shopify/cli-kit/node/schema' + +const OrganizationListEntrySchema = zod + .object({ + id: zod.string(), + gid: zod.string(), + name: 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/cli/README.md b/packages/cli/README.md index ecea5aaef2e..f0c9d4045cf 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -3417,6 +3417,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: OrganizationListEntry[] + } + + interface OrganizationListEntry { + 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 3121725eac6..3e03fdc8846 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 schema directly:\n\n```ts\ninterface OrganizationListResult {\n organizations: OrganizationListEntry[]\n}\n\ninterface OrganizationListEntry {\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-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',