From 2ca5382be9f9b5719483cf7bc3263b5d370ffaba Mon Sep 17 00:00:00 2001 From: Donald Merand Date: Thu, 10 Sep 2026 16:37:25 -0400 Subject: [PATCH 1/8] Add JSON schema for app versions list Assisted-By: devx/f323f8f7-39c8-42ea-882b-5ae18595fcc9 --- .changeset/app-versions-list-json-schema.md | 5 + .../cli/commands/app/versions/list.test.ts | 128 ++++++++ .../app/src/cli/commands/app/versions/list.ts | 30 +- .../src/cli/services/versions-list.test.ts | 286 ++++++------------ .../app/src/cli/services/versions-list.ts | 152 +++------- .../services/versions-list/presenter.test.ts | 107 +++++++ .../cli/services/versions-list/presenter.ts | 85 ++++++ packages/cli/README.md | 16 + packages/cli/oclif.manifest.json | 2 +- .../rules/json-output-command-exceptions.js | 1 - 10 files changed, 491 insertions(+), 321 deletions(-) create mode 100644 .changeset/app-versions-list-json-schema.md create mode 100644 packages/app/src/cli/commands/app/versions/list.test.ts create mode 100644 packages/app/src/cli/services/versions-list/presenter.test.ts create mode 100644 packages/app/src/cli/services/versions-list/presenter.ts diff --git a/.changeset/app-versions-list-json-schema.md b/.changeset/app-versions-list-json-schema.md new file mode 100644 index 00000000000..a46cee15340 --- /dev/null +++ b/.changeset/app-versions-list-json-schema.md @@ -0,0 +1,5 @@ +--- +'@shopify/app': minor +--- + +Add a JSON output schema for `app versions list`. diff --git a/packages/app/src/cli/commands/app/versions/list.test.ts b/packages/app/src/cli/commands/app/versions/list.test.ts new file mode 100644 index 00000000000..9f05ac3bee7 --- /dev/null +++ b/packages/app/src/cli/commands/app/versions/list.test.ts @@ -0,0 +1,128 @@ +import {testAppLinked, testDeveloperPlatformClient, testOrganizationApp} from '../../../models/app/app.test-data.js' +import {Organization, OrganizationSource} from '../../../models/organization.js' +import {afterEach, describe, expect, test, vi} from 'vitest' + +vi.mock('../../../services/app-context.js') + +function captureStandardStreams() { + const stdout: string[] = [] + const stderr: string[] = [] + + const stdoutSpy = vi.spyOn(process.stdout, 'write').mockImplementation(((chunk: string | Uint8Array) => { + stdout.push(typeof chunk === 'string' ? chunk : Buffer.from(chunk).toString('utf8')) + return true + }) as typeof process.stdout.write) + const stderrSpy = vi.spyOn(process.stderr, 'write').mockImplementation(((chunk: string | Uint8Array) => { + stderr.push(typeof chunk === 'string' ? chunk : Buffer.from(chunk).toString('utf8')) + return true + }) as typeof process.stderr.write) + + return { + stdout: () => stdout.join(''), + stderr: () => stderr.join(''), + restore: () => { + stdoutSpy.mockRestore() + stderrSpy.mockRestore() + }, + } +} + +const originalUnitTestEnvironment = process.env.SHOPIFY_UNIT_TEST + +afterEach(() => { + if (originalUnitTestEnvironment === undefined) { + delete process.env.SHOPIFY_UNIT_TEST + } else { + process.env.SHOPIFY_UNIT_TEST = originalUnitTestEnvironment + } + vi.resetModules() +}) + +describe('app versions list command', () => { + test('writes one JSON document to stdout without text output', async () => { + process.env.SHOPIFY_UNIT_TEST = 'false' + vi.resetModules() + + const organization: Organization = { + id: 'org-id', + businessName: 'name of org 1', + source: OrganizationSource.BusinessPlatform, + } + const app = testAppLinked({}) + const remoteApp = testOrganizationApp({organizationId: organization.id, apiKey: 'api-key'}) + const developerPlatformClient = testDeveloperPlatformClient({ + appVersions: () => + Promise.resolve({ + app: { + id: 'app-id', + title: 'app-title', + organizationId: organization.id, + appVersions: { + nodes: [ + { + message: 'message', + versionTag: 'versionTag', + status: 'active', + createdAt: '2021-01-01', + createdBy: {displayName: 'createdBy'}, + }, + ], + pageInfo: {totalResults: 1}, + }, + }, + }), + }) + const {linkedAppContext} = await import('../../../services/app-context.js') + vi.mocked(linkedAppContext).mockResolvedValue({ + app, + remoteApp, + organization, + developerPlatformClient, + } as unknown as Awaited>) + const {default: VersionsList} = await import('./list.js') + const streams = captureStandardStreams() + + try { + await VersionsList.run(['--json'], import.meta.url) + } finally { + streams.restore() + } + + expect(JSON.parse(streams.stdout())).toEqual([ + { + message: 'message', + versionTag: 'versionTag', + status: 'active', + createdAt: '2021-01-01 00:00:00', + createdBy: 'createdBy', + }, + ]) + expect(streams.stderr()).not.toContain('No app versions found for this app') + expect(streams.stderr()).not.toContain('VERSION') + expect(streams.stderr()).not.toContain('View all') + }) + + test('keeps the existing invalid API key error', async () => { + const app = testAppLinked({}) + const remoteApp = testOrganizationApp({apiKey: 'api-key'}) + const {linkedAppContext} = await import('../../../services/app-context.js') + vi.mocked(linkedAppContext).mockResolvedValue({ + app, + remoteApp, + organization: { + id: 'org-id', + businessName: 'name of org 1', + source: OrganizationSource.BusinessPlatform, + }, + developerPlatformClient: testDeveloperPlatformClient({ + appVersions: () => Promise.resolve({app: null}), + }), + } as unknown as Awaited>) + const {default: VersionsList} = await import('./list.js') + vi.spyOn(VersionsList.prototype, 'catch').mockImplementation(async (error) => { + throw error + }) + + await expect(VersionsList.run(['--json'], import.meta.url)).rejects.toThrow('Invalid API Key: api-key') + }) +}) diff --git a/packages/app/src/cli/commands/app/versions/list.ts b/packages/app/src/cli/commands/app/versions/list.ts index f06f42d2432..ed825db8d20 100644 --- a/packages/app/src/cli/commands/app/versions/list.ts +++ b/packages/app/src/cli/commands/app/versions/list.ts @@ -1,14 +1,21 @@ import {appFlags} from '../../../flags.js' -import versionList from '../../../services/versions-list.js' +import {appVersionsListJsonOutputSchema, getAppVersions} from '../../../services/versions-list.js' +import {renderAppVersionsList} from '../../../services/versions-list/presenter.js' import AppLinkedCommand, {AppLinkedCommandOutput} from '../../../utilities/app-linked-command.js' import {linkedAppContext} from '../../../services/app-context.js' import {globalFlags, jsonFlag} from '@shopify/cli-kit/node/cli' +import {AbortError} from '@shopify/cli-kit/node/error' +import {outputResult} from '@shopify/cli-kit/node/output' export default class VersionsList extends AppLinkedCommand { static summary = 'List deployed versions of your app.' static descriptionWithMarkdown = `Lists the deployed app versions. An app version is a snapshot of your app extensions.` + static get jsonOutputSchema() { + return appVersionsListJsonOutputSchema + } + static description = this.descriptionForHelp() static flags = { @@ -27,13 +34,20 @@ export default class VersionsList extends AppLinkedCommand { userProvidedConfigName: flags.config, }) - await versionList({ - app, - remoteApp, - organization, - developerPlatformClient, - json: flags.json, - }) + const result = await getAppVersions(developerPlatformClient, remoteApp) + if (!result) throw new AbortError(`Invalid API Key: ${remoteApp.apiKey}`) + + if (flags.json) { + outputResult(appVersionsListJsonOutputSchema.encode(result.appVersions)) + } else { + await renderAppVersionsList({ + app, + remoteApp, + organization, + developerPlatformClient, + ...result, + }) + } return {app} } diff --git a/packages/app/src/cli/services/versions-list.test.ts b/packages/app/src/cli/services/versions-list.test.ts index 772e18ef1cd..817dbb70d02 100644 --- a/packages/app/src/cli/services/versions-list.test.ts +++ b/packages/app/src/cli/services/versions-list.test.ts @@ -1,203 +1,79 @@ -import versionList from './versions-list.js' -import {renderCurrentlyUsedConfigInfo} from './context.js' -import {testAppLinked, testDeveloperPlatformClient, testOrganizationApp} from '../models/app/app.test-data.js' -import {Organization, OrganizationSource} from '../models/organization.js' -import {DeveloperPlatformClient} from '../utilities/developer-platform-client.js' +import {appVersionsListJsonOutputSchema, getAppVersions} from './versions-list.js' +import {testDeveloperPlatformClient, testOrganizationApp} from '../models/app/app.test-data.js' import {AppVersionsQuerySchema} from '../api/graphql/get_versions_list.js' -import {afterEach, describe, expect, test, vi} from 'vitest' -import {mockAndCaptureOutput} from '@shopify/cli-kit/node/testing/output' - -vi.mock('../models/app/identifiers.js') -vi.mock('./context.js') - -afterEach(() => { - mockAndCaptureOutput().clear() -}) - -const ORG1: Organization = { - id: 'org-id', - businessName: 'name of org 1', - source: OrganizationSource.BusinessPlatform, -} - -const remoteApp = testOrganizationApp({organizationId: ORG1.id, apiKey: 'api-key', title: 'app-title', id: 'app-id'}) - -function buildDeveloperPlatformClient(): DeveloperPlatformClient { - return testDeveloperPlatformClient({ - orgFromId: (_orgId: string) => Promise.resolve(ORG1), - }) +import {describe, expect, test} from 'vitest' + +const remoteApp = testOrganizationApp({apiKey: 'api-key'}) + +function appVersionsResponse(): AppVersionsQuerySchema { + return { + app: { + id: 'app-id', + title: 'app-title', + organizationId: 'org-id', + appVersions: { + nodes: [ + { + message: 'message', + versionTag: 'versionTag', + status: 'active', + createdAt: '2021-01-01', + createdBy: {displayName: 'createdBy'}, + }, + { + message: null, + versionTag: null, + status: 'released', + createdAt: '2021-01-02', + createdBy: {displayName: null}, + }, + { + status: 'released', + createdAt: '2021-01-03', + }, + ], + pageInfo: {totalResults: 31}, + }, + }, + } } -describe('versions-list', () => { - test('show a message when there are no app versions', async () => { - // Given - const app = testAppLinked({}) - const outputMock = mockAndCaptureOutput() - - // When - await versionList({ - app, - remoteApp, - organization: ORG1, - developerPlatformClient: buildDeveloperPlatformClient(), - json: false, +describe('getAppVersions', () => { + test('returns the existing JSON values and omission behavior as typed data', async () => { + const developerPlatformClient = testDeveloperPlatformClient({ + appVersions: () => Promise.resolve(appVersionsResponse()), }) - // Then - expect(outputMock.info()).toMatchInlineSnapshot(`"No app versions found for this app"`) - }) - - test('show currently used config info', async () => { - // Given - const app = testAppLinked({}) + const result = await getAppVersions(developerPlatformClient, remoteApp) - // When - await versionList({ - app, - remoteApp, - organization: ORG1, - developerPlatformClient: buildDeveloperPlatformClient(), - json: false, - }) - - // Then - expect(renderCurrentlyUsedConfigInfo).toHaveBeenCalledWith({ - org: 'name of org 1', - appName: 'app-title', - configFile: 'shopify.app.toml', - }) - }) - - test('throw error when there is no app', async () => { - // Given - const app = testAppLinked({}) - const developerPlatformClient: DeveloperPlatformClient = testDeveloperPlatformClient({ - appVersions: (_appId) => Promise.resolve({app: null}), - }) - - // When - const output = versionList({ - app, - remoteApp, - json: false, - organization: ORG1, - developerPlatformClient, - }) - - // Then - await expect(output).rejects.toThrow('Invalid API Key: api-key') - }) - - // asserting the exact format of the table is hard to do consistently across different environments - const terminalWidth = process.stdout.columns - - test.skipIf(terminalWidth !== undefined)('render table when there are app versions', async () => { - // Given - const app = testAppLinked({}) - const mockOutput = mockAndCaptureOutput() - const appVersionsResult: AppVersionsQuerySchema = { - app: { - id: 'appId', - title: 'title', - appVersions: { - nodes: [ - { - message: 'message', - versionTag: 'versionTag', - status: 'active', - createdAt: '2021-01-01', - createdBy: {displayName: 'createdBy'}, - }, - { - message: 'message 2', - versionTag: 'versionTag 2', - status: 'released', - createdAt: '2021-01-01', - createdBy: {displayName: 'createdBy 2'}, - }, - { - message: 'long message with more than 15 characters', - versionTag: 'versionTag 3', - status: 'released', - createdAt: '2021-01-01', - createdBy: {displayName: 'createdBy 3'}, - }, - ], - pageInfo: {totalResults: 31}, + expect(result).toEqual({ + appVersions: [ + { + message: 'message', + versionTag: 'versionTag', + status: 'active', + createdAt: '2021-01-01 00:00:00', + createdBy: 'createdBy', }, - organizationId: 'orgId', - }, - } - const developerPlatformClient: DeveloperPlatformClient = testDeveloperPlatformClient({ - appVersions: (_appId) => Promise.resolve(appVersionsResult), - }) - - // When - await versionList({ - app, - remoteApp, - json: false, - developerPlatformClient, - organization: ORG1, - }) - - // Then - expect(mockOutput.info()) - .toMatchInlineSnapshot(`"VERSION STATUS MESSAGE DATE CREATED CREATED BY -──────────── ──────── ───────────── ─────────────────── ─────────── -versionTag ★ active message 2021-01-01 00:00:00 createdBy -versionTag 2 released message 2 2021-01-01 00:00:00 createdBy 2 -versionTag 3 released long messa... 2021-01-01 00:00:00 createdBy 3 - -View all 31 app versions in the Test Dashboard ( https://test.shopify.com/org-id/apps/app-id/versions )"`) - }) - - test('render json when there are app versions', async () => { - // Given - const app = testAppLinked({}) - - const mockOutput = mockAndCaptureOutput() - const appVersionsResult: AppVersionsQuerySchema = { - app: { - id: 'appId', - title: 'title', - appVersions: { - nodes: [ - { - message: 'message', - versionTag: 'versionTag', - status: 'active', - createdAt: '2021-01-01', - createdBy: {displayName: 'createdBy'}, - }, - { - message: 'long message with more than 15 characters', - versionTag: 'versionTag 3', - status: 'released', - createdAt: '2021-01-01', - createdBy: {displayName: 'createdBy 3'}, - }, - ], - pageInfo: {totalResults: 31}, + { + message: '', + versionTag: null, + status: 'released', + createdAt: '2021-01-02 00:00:00', + createdBy: '', }, - organizationId: 'orgId', - }, - } - const developerPlatformClient: DeveloperPlatformClient = testDeveloperPlatformClient({ - appVersions: (_appId) => Promise.resolve(appVersionsResult), - }) - - // When - await versionList({ - app, - remoteApp, - json: true, - developerPlatformClient, - organization: ORG1, + { + message: '', + status: 'released', + createdAt: '2021-01-03 00:00:00', + createdBy: '', + }, + ], + totalResults: 31, }) + if (!result) throw new Error('Expected app versions result') - // Then - expect(mockOutput.info()).toMatchInlineSnapshot(` + expect(appVersionsListJsonOutputSchema.encode(result.appVersions)).toMatchInlineSnapshot(` "[ { "message": "message", @@ -207,13 +83,35 @@ View all 31 app versions in the Test Dashboard ( https://test.shopify.com/org-id "createdBy": "createdBy" }, { - "message": "long message with more than 15 characters", - "versionTag": "versionTag 3", + "message": "", + "versionTag": null, "status": "released", - "createdAt": "2021-01-01 00:00:00", - "createdBy": "createdBy 3" + "createdAt": "2021-01-02 00:00:00", + "createdBy": "" + }, + { + "message": "", + "status": "released", + "createdAt": "2021-01-03 00:00:00", + "createdBy": "" } ]" `) }) + + test('returns undefined when the API response does not contain an app', async () => { + const developerPlatformClient = testDeveloperPlatformClient({ + appVersions: () => Promise.resolve({app: null}), + }) + + await expect(getAppVersions(developerPlatformClient, remoteApp)).resolves.toBeUndefined() + }) + + test('rejects invalid result values', () => { + expect(() => + appVersionsListJsonOutputSchema.validate([ + {message: 'message', versionTag: 'versionTag', status: 'active', createdAt: '2021-01-01', createdBy: 1}, + ]), + ).toThrow() + }) }) diff --git a/packages/app/src/cli/services/versions-list.ts b/packages/app/src/cli/services/versions-list.ts index 6a839b3ee27..deb448c84a3 100644 --- a/packages/app/src/cli/services/versions-list.ts +++ b/packages/app/src/cli/services/versions-list.ts @@ -1,128 +1,46 @@ -import {renderCurrentlyUsedConfigInfo} from './context.js' import {AppVersionsQuerySchema} from '../api/graphql/get_versions_list.js' -import {AppLinkedInterface} from '../models/app/app.js' +import {OrganizationApp} from '../models/organization.js' import {DeveloperPlatformClient} from '../utilities/developer-platform-client.js' -import {Organization, OrganizationApp} from '../models/organization.js' -import colors from '@shopify/cli-kit/node/colors' -import {outputContent, outputInfo, outputResult, outputToken, unstyled} from '@shopify/cli-kit/node/output' import {formatDate} from '@shopify/cli-kit/common/string' -import {AbortError} from '@shopify/cli-kit/node/error' -import {basename} from '@shopify/cli-kit/node/path' -import {renderTable} from '@shopify/cli-kit/node/ui' - -// eslint-disable-next-line @typescript-eslint/consistent-type-definitions -type AppVersionLine = { - createdAt: string - createdBy?: string - message?: string - versionTag?: string | null - status: string +import {defineJsonOutputSchema, type InferJsonOutputSchema} from '@shopify/cli-kit/node/json-output-schema' +import {zod} from '@shopify/cli-kit/node/schema' + +const appVersionJsonOutputSchema = zod.object({ + message: zod.string(), + versionTag: zod.string().nullable().optional(), + status: zod.string(), + createdAt: zod.string(), + createdBy: zod.string(), +}) + +export const appVersionsListJsonOutputSchema = defineJsonOutputSchema({ + name: 'AppVersionsListResult', + schema: zod.array(appVersionJsonOutputSchema), + definitions: {AppVersion: appVersionJsonOutputSchema}, +}) + +export type AppVersionsListResult = InferJsonOutputSchema + +export interface AppVersionsList { + appVersions: AppVersionsListResult + totalResults: number } -const TABLE_FORMATTING_CHARS = 12 - -async function fetchAppVersions( +export async function getAppVersions( developerPlatformClient: DeveloperPlatformClient, app: OrganizationApp, - json: boolean, -): Promise<{ - appVersions: AppVersionLine[] - totalResults: number - app: AppVersionsQuerySchema['app'] -}> { - const res: AppVersionsQuerySchema = await developerPlatformClient.appVersions(app) - if (!res.app) throw new AbortError(`Invalid API Key: ${app.apiKey}`) - - const appVersions = res.app.appVersions.nodes.map((appVersion) => { - const message = appVersion.message ?? '' - return { - ...appVersion, - status: appVersion.status === 'active' && !json ? colors.green(`★ ${appVersion.status}`) : appVersion.status, - createdBy: appVersion.createdBy?.displayName ?? '', - createdAt: formatDate(new Date(appVersion.createdAt)), - message, - } - }) - - if (!json) { - const maxLineLength = (process.stdout.columns ?? 75) - TABLE_FORMATTING_CHARS - let maxMessageLength = maxLineLength - - // Calculate the max allowed length for the message column - appVersions.forEach((appVersion) => { - const combinedLength = - appVersion.message.length + - (appVersion.versionTag?.length ?? 0) + - unstyled(appVersion.status).length + - appVersion.createdAt.length + - appVersion.createdBy.length - if (combinedLength > maxLineLength) { - const combinedWithoutMessageLength = combinedLength - appVersion.message.length - const newMaxLength = Math.max(maxLineLength - combinedWithoutMessageLength, 10) - if (newMaxLength < maxMessageLength) { - maxMessageLength = newMaxLength - } - } - }) - - // Update the message column to fit the max length - appVersions.forEach((appVersion) => { - if (appVersion.message.length > maxMessageLength) { - appVersion.message = `${appVersion.message.slice(0, maxMessageLength - 3)}...` - } - }) - } +): Promise { + const response: AppVersionsQuerySchema = await developerPlatformClient.appVersions(app) + if (!response.app) return undefined return { - appVersions, - totalResults: res.app.appVersions.pageInfo.totalResults, - app: res.app, - } -} - -interface VersionListOptions { - app: AppLinkedInterface - remoteApp: OrganizationApp - organization: Organization - developerPlatformClient: DeveloperPlatformClient - json: boolean -} - -export default async function versionList(options: VersionListOptions) { - const {remoteApp, developerPlatformClient, organization} = options - - const {appVersions, totalResults} = await fetchAppVersions(developerPlatformClient, remoteApp, options.json) - - if (options.json) { - return outputResult(JSON.stringify(appVersions, null, 2)) - } - - renderCurrentlyUsedConfigInfo({ - org: organization.businessName, - appName: remoteApp.title, - configFile: basename(options.app.configPath), - }) - - if (appVersions.length === 0) { - outputInfo('No app versions found for this app') - return + appVersions: response.app.appVersions.nodes.map((appVersion) => ({ + message: appVersion.message ?? '', + versionTag: appVersion.versionTag, + status: appVersion.status, + createdAt: formatDate(new Date(appVersion.createdAt)), + createdBy: appVersion.createdBy?.displayName ?? '', + })), + totalResults: response.app.appVersions.pageInfo.totalResults, } - - renderTable({ - rows: appVersions, - columns: { - versionTag: {header: 'VERSION'}, - status: {header: 'STATUS'}, - message: {header: 'MESSAGE'}, - createdAt: {header: 'DATE CREATED'}, - createdBy: {header: 'CREATED BY'}, - }, - }) - - const link = outputToken.link( - developerPlatformClient.webUiName, - [await developerPlatformClient.appDeepLink(remoteApp), 'versions'].join('/'), - ) - - outputInfo(outputContent`\nView all ${String(totalResults)} app versions in the ${link}`) } diff --git a/packages/app/src/cli/services/versions-list/presenter.test.ts b/packages/app/src/cli/services/versions-list/presenter.test.ts new file mode 100644 index 00000000000..94bd7e2138f --- /dev/null +++ b/packages/app/src/cli/services/versions-list/presenter.test.ts @@ -0,0 +1,107 @@ +import {renderAppVersionsList} from './presenter.js' +import {renderCurrentlyUsedConfigInfo} from '../context.js' +import {testAppLinked, testDeveloperPlatformClient, testOrganizationApp} from '../../models/app/app.test-data.js' +import {Organization, OrganizationSource} from '../../models/organization.js' +import {afterEach, describe, expect, test, vi} from 'vitest' +import {mockAndCaptureOutput} from '@shopify/cli-kit/node/testing/output' + +vi.mock('../context.js') + +afterEach(() => { + mockAndCaptureOutput().clear() +}) + +const organization: Organization = { + id: 'org-id', + businessName: 'name of org 1', + source: OrganizationSource.BusinessPlatform, +} + +const remoteApp = testOrganizationApp({organizationId: organization.id, title: 'app-title', id: 'app-id'}) + +function buildDeveloperPlatformClient() { + return testDeveloperPlatformClient({ + orgFromId: () => Promise.resolve(organization), + }) +} + +describe('renderAppVersionsList', () => { + test('shows a message when there are no app versions', async () => { + const outputMock = mockAndCaptureOutput() + + await renderAppVersionsList({ + app: testAppLinked({}), + appVersions: [], + totalResults: 0, + remoteApp, + organization, + developerPlatformClient: buildDeveloperPlatformClient(), + }) + + expect(outputMock.info()).toMatchInlineSnapshot(`"No app versions found for this app"`) + }) + + test('shows currently used config info', async () => { + await renderAppVersionsList({ + app: testAppLinked({}), + appVersions: [], + totalResults: 0, + remoteApp, + organization, + developerPlatformClient: buildDeveloperPlatformClient(), + }) + + expect(renderCurrentlyUsedConfigInfo).toHaveBeenCalledWith({ + org: 'name of org 1', + appName: 'app-title', + configFile: 'shopify.app.toml', + }) + }) + + const terminalWidth = process.stdout.columns + + test.skipIf(terminalWidth !== undefined)('renders a table and dashboard link when app versions exist', async () => { + const outputMock = mockAndCaptureOutput() + + await renderAppVersionsList({ + app: testAppLinked({}), + appVersions: [ + { + message: 'message', + versionTag: 'versionTag', + status: 'active', + createdAt: '2021-01-01 00:00:00', + createdBy: 'createdBy', + }, + { + message: 'message 2', + versionTag: 'versionTag 2', + status: 'released', + createdAt: '2021-01-01 00:00:00', + createdBy: 'createdBy 2', + }, + { + message: 'long message with more than 15 characters', + versionTag: 'versionTag 3', + status: 'released', + createdAt: '2021-01-01 00:00:00', + createdBy: 'createdBy 3', + }, + ], + totalResults: 31, + remoteApp, + organization, + developerPlatformClient: buildDeveloperPlatformClient(), + }) + + expect(outputMock.info()).toMatchInlineSnapshot( + `"VERSION STATUS MESSAGE DATE CREATED CREATED BY +──────────── ──────── ───────────── ─────────────────── ─────────── +versionTag ★ active message 2021-01-01 00:00:00 createdBy +versionTag 2 released message 2 2021-01-01 00:00:00 createdBy 2 +versionTag 3 released long messa... 2021-01-01 00:00:00 createdBy 3 + +View all 31 app versions in the Test Dashboard ( https://test.shopify.com/org-id/apps/app-id/versions )"`, + ) + }) +}) diff --git a/packages/app/src/cli/services/versions-list/presenter.ts b/packages/app/src/cli/services/versions-list/presenter.ts new file mode 100644 index 00000000000..5e67d5aa2ed --- /dev/null +++ b/packages/app/src/cli/services/versions-list/presenter.ts @@ -0,0 +1,85 @@ +import {renderCurrentlyUsedConfigInfo} from '../context.js' +import {AppVersionsListResult} from '../versions-list.js' +import {AppLinkedInterface} from '../../models/app/app.js' +import {Organization, OrganizationApp} from '../../models/organization.js' +import {DeveloperPlatformClient} from '../../utilities/developer-platform-client.js' +import colors from '@shopify/cli-kit/node/colors' +import {outputContent, outputInfo, outputToken, unstyled} from '@shopify/cli-kit/node/output' +import {basename} from '@shopify/cli-kit/node/path' +import {renderTable} from '@shopify/cli-kit/node/ui' + +const TABLE_FORMATTING_CHARS = 12 + +interface RenderAppVersionsListOptions { + app: AppLinkedInterface + appVersions: AppVersionsListResult + totalResults: number + remoteApp: OrganizationApp + organization: Organization + developerPlatformClient: DeveloperPlatformClient +} + +export async function renderAppVersionsList({ + app, + appVersions: versionResults, + totalResults, + remoteApp, + organization, + developerPlatformClient, +}: RenderAppVersionsListOptions): Promise { + renderCurrentlyUsedConfigInfo({ + org: organization.businessName, + appName: remoteApp.title, + configFile: basename(app.configPath), + }) + + if (versionResults.length === 0) { + outputInfo('No app versions found for this app') + return + } + + const appVersions = versionResults.map((appVersion) => ({ + ...appVersion, + status: appVersion.status === 'active' ? colors.green(`★ ${appVersion.status}`) : appVersion.status, + })) + const maxLineLength = (process.stdout.columns ?? 75) - TABLE_FORMATTING_CHARS + let maxMessageLength = maxLineLength + + appVersions.forEach((appVersion) => { + const combinedLength = + appVersion.message.length + + (appVersion.versionTag?.length ?? 0) + + unstyled(appVersion.status).length + + appVersion.createdAt.length + + appVersion.createdBy.length + if (combinedLength > maxLineLength) { + const combinedWithoutMessageLength = combinedLength - appVersion.message.length + const newMaxLength = Math.max(maxLineLength - combinedWithoutMessageLength, 10) + if (newMaxLength < maxMessageLength) maxMessageLength = newMaxLength + } + }) + + appVersions.forEach((appVersion) => { + if (appVersion.message.length > maxMessageLength) { + appVersion.message = `${appVersion.message.slice(0, maxMessageLength - 3)}...` + } + }) + + renderTable({ + rows: appVersions, + columns: { + versionTag: {header: 'VERSION'}, + status: {header: 'STATUS'}, + message: {header: 'MESSAGE'}, + createdAt: {header: 'DATE CREATED'}, + createdBy: {header: 'CREATED BY'}, + }, + }) + + const link = outputToken.link( + developerPlatformClient.webUiName, + [await developerPlatformClient.appDeepLink(remoteApp), 'versions'].join('/'), + ) + + outputInfo(outputContent`\nView all ${String(totalResults)} app versions in the ${link}`) +} diff --git a/packages/cli/README.md b/packages/cli/README.md index ecea5aaef2e..6bae54d4d13 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -1979,6 +1979,22 @@ DESCRIPTION List deployed versions of your app. Lists the deployed app versions. An app version is a snapshot of your app extensions. + + Output from `--json` conforms to the `AppVersionsListResult` schema. + + Use `--json-schema` to print the schema directly: + + ```ts + type AppVersionsListResult = AppVersion[] + + interface AppVersion { + message: string + versionTag?: string | null + status: string + createdAt: string + createdBy: string + } + ``` ``` ## `shopify app webhook trigger` diff --git a/packages/cli/oclif.manifest.json b/packages/cli/oclif.manifest.json index 3121725eac6..7e0c73bd846 100644 --- a/packages/cli/oclif.manifest.json +++ b/packages/cli/oclif.manifest.json @@ -4374,7 +4374,7 @@ "args": { }, "customPluginName": "@shopify/app", - "description": "Lists the deployed app versions. An app version is a snapshot of your app extensions.", + "description": "Lists the deployed app versions. An app version is a snapshot of your app extensions.\n\nOutput from `--json` conforms to the `AppVersionsListResult` schema.\n\nUse `--json-schema` to print the schema directly:\n\n```ts\ntype AppVersionsListResult = AppVersion[]\n\ninterface AppVersion {\n message: string\n versionTag?: string | null\n status: string\n createdAt: string\n createdBy: string\n}\n```", "descriptionWithMarkdown": "Lists the deployed app versions. An app version is a snapshot of your app extensions.", "flags": { "auth-alias": { 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..0795bc4580f 100644 --- a/packages/eslint-plugin-cli/rules/json-output-command-exceptions.js +++ b/packages/eslint-plugin-cli/rules/json-output-command-exceptions.js @@ -36,7 +36,6 @@ const commandExceptions = [ 'packages/app/src/cli/commands/app/subscription-migrations/schedule.ts', 'packages/app/src/cli/commands/app/subscription-migrations/status.ts', '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', From ffe3b88015639f97ed59df71cdb1472012a8e11f Mon Sep 17 00:00:00 2001 From: Donald Merand Date: Thu, 10 Sep 2026 16:51:08 -0400 Subject: [PATCH 2/8] Preserve app version IDs in JSON output Assisted-By: devx/f323f8f7-39c8-42ea-882b-5ae18595fcc9 --- .../app/src/cli/api/graphql/get_versions_list.ts | 1 + .../src/cli/commands/app/versions/list.test.ts | 2 ++ .../app/src/cli/services/versions-list.test.ts | 15 ++++++++++++--- packages/app/src/cli/services/versions-list.ts | 4 +++- .../cli/services/versions-list/presenter.test.ts | 3 +++ .../src/cli/services/versions-list/presenter.ts | 2 +- packages/cli/README.md | 1 + packages/cli/oclif.manifest.json | 2 +- 8 files changed, 24 insertions(+), 6 deletions(-) diff --git a/packages/app/src/cli/api/graphql/get_versions_list.ts b/packages/app/src/cli/api/graphql/get_versions_list.ts index d01150892c0..e710eeff164 100644 --- a/packages/app/src/cli/api/graphql/get_versions_list.ts +++ b/packages/app/src/cli/api/graphql/get_versions_list.ts @@ -11,6 +11,7 @@ export interface AppVersionsQuerySchema { } message?: string | null status: string + versionId: string versionTag?: string | null }[] pageInfo: { diff --git a/packages/app/src/cli/commands/app/versions/list.test.ts b/packages/app/src/cli/commands/app/versions/list.test.ts index 9f05ac3bee7..ebdea790a13 100644 --- a/packages/app/src/cli/commands/app/versions/list.test.ts +++ b/packages/app/src/cli/commands/app/versions/list.test.ts @@ -62,6 +62,7 @@ describe('app versions list command', () => { { message: 'message', versionTag: 'versionTag', + versionId: 'gid://shopify/Version/1', status: 'active', createdAt: '2021-01-01', createdBy: {displayName: 'createdBy'}, @@ -95,6 +96,7 @@ describe('app versions list command', () => { status: 'active', createdAt: '2021-01-01 00:00:00', createdBy: 'createdBy', + versionId: 'gid://shopify/Version/1', }, ]) expect(streams.stderr()).not.toContain('No app versions found for this app') diff --git a/packages/app/src/cli/services/versions-list.test.ts b/packages/app/src/cli/services/versions-list.test.ts index 817dbb70d02..a0730838e87 100644 --- a/packages/app/src/cli/services/versions-list.test.ts +++ b/packages/app/src/cli/services/versions-list.test.ts @@ -16,6 +16,7 @@ function appVersionsResponse(): AppVersionsQuerySchema { { message: 'message', versionTag: 'versionTag', + versionId: 'gid://shopify/Version/1', status: 'active', createdAt: '2021-01-01', createdBy: {displayName: 'createdBy'}, @@ -23,11 +24,13 @@ function appVersionsResponse(): AppVersionsQuerySchema { { message: null, versionTag: null, + versionId: 'gid://shopify/Version/2', status: 'released', createdAt: '2021-01-02', createdBy: {displayName: null}, }, { + versionId: 'gid://shopify/Version/3', status: 'released', createdAt: '2021-01-03', }, @@ -54,6 +57,7 @@ describe('getAppVersions', () => { status: 'active', createdAt: '2021-01-01 00:00:00', createdBy: 'createdBy', + versionId: 'gid://shopify/Version/1', }, { message: '', @@ -61,12 +65,14 @@ describe('getAppVersions', () => { status: 'released', createdAt: '2021-01-02 00:00:00', createdBy: '', + versionId: 'gid://shopify/Version/2', }, { message: '', status: 'released', createdAt: '2021-01-03 00:00:00', createdBy: '', + versionId: 'gid://shopify/Version/3', }, ], totalResults: 31, @@ -80,20 +86,23 @@ describe('getAppVersions', () => { "versionTag": "versionTag", "status": "active", "createdAt": "2021-01-01 00:00:00", - "createdBy": "createdBy" + "createdBy": "createdBy", + "versionId": "gid://shopify/Version/1" }, { "message": "", "versionTag": null, "status": "released", "createdAt": "2021-01-02 00:00:00", - "createdBy": "" + "createdBy": "", + "versionId": "gid://shopify/Version/2" }, { "message": "", "status": "released", "createdAt": "2021-01-03 00:00:00", - "createdBy": "" + "createdBy": "", + "versionId": "gid://shopify/Version/3" } ]" `) diff --git a/packages/app/src/cli/services/versions-list.ts b/packages/app/src/cli/services/versions-list.ts index deb448c84a3..ec337893a18 100644 --- a/packages/app/src/cli/services/versions-list.ts +++ b/packages/app/src/cli/services/versions-list.ts @@ -11,6 +11,7 @@ const appVersionJsonOutputSchema = zod.object({ status: zod.string(), createdAt: zod.string(), createdBy: zod.string(), + versionId: zod.string(), }) export const appVersionsListJsonOutputSchema = defineJsonOutputSchema({ @@ -21,7 +22,7 @@ export const appVersionsListJsonOutputSchema = defineJsonOutputSchema({ export type AppVersionsListResult = InferJsonOutputSchema -export interface AppVersionsList { +interface AppVersionsList { appVersions: AppVersionsListResult totalResults: number } @@ -40,6 +41,7 @@ export async function getAppVersions( status: appVersion.status, createdAt: formatDate(new Date(appVersion.createdAt)), createdBy: appVersion.createdBy?.displayName ?? '', + versionId: appVersion.versionId, })), totalResults: response.app.appVersions.pageInfo.totalResults, } diff --git a/packages/app/src/cli/services/versions-list/presenter.test.ts b/packages/app/src/cli/services/versions-list/presenter.test.ts index 94bd7e2138f..0a5262d67c8 100644 --- a/packages/app/src/cli/services/versions-list/presenter.test.ts +++ b/packages/app/src/cli/services/versions-list/presenter.test.ts @@ -72,6 +72,7 @@ describe('renderAppVersionsList', () => { status: 'active', createdAt: '2021-01-01 00:00:00', createdBy: 'createdBy', + versionId: 'gid://shopify/Version/1', }, { message: 'message 2', @@ -79,6 +80,7 @@ describe('renderAppVersionsList', () => { status: 'released', createdAt: '2021-01-01 00:00:00', createdBy: 'createdBy 2', + versionId: 'gid://shopify/Version/2', }, { message: 'long message with more than 15 characters', @@ -86,6 +88,7 @@ describe('renderAppVersionsList', () => { status: 'released', createdAt: '2021-01-01 00:00:00', createdBy: 'createdBy 3', + versionId: 'gid://shopify/Version/3', }, ], totalResults: 31, diff --git a/packages/app/src/cli/services/versions-list/presenter.ts b/packages/app/src/cli/services/versions-list/presenter.ts index 5e67d5aa2ed..34cf465c9a2 100644 --- a/packages/app/src/cli/services/versions-list/presenter.ts +++ b/packages/app/src/cli/services/versions-list/presenter.ts @@ -38,7 +38,7 @@ export async function renderAppVersionsList({ return } - const appVersions = versionResults.map((appVersion) => ({ + const appVersions = versionResults.map(({versionId: _, ...appVersion}) => ({ ...appVersion, status: appVersion.status === 'active' ? colors.green(`★ ${appVersion.status}`) : appVersion.status, })) diff --git a/packages/cli/README.md b/packages/cli/README.md index 6bae54d4d13..86ccc96fa59 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -1993,6 +1993,7 @@ DESCRIPTION status: string createdAt: string createdBy: string + versionId: string } ``` ``` diff --git a/packages/cli/oclif.manifest.json b/packages/cli/oclif.manifest.json index 7e0c73bd846..86df001b76d 100644 --- a/packages/cli/oclif.manifest.json +++ b/packages/cli/oclif.manifest.json @@ -4374,7 +4374,7 @@ "args": { }, "customPluginName": "@shopify/app", - "description": "Lists the deployed app versions. An app version is a snapshot of your app extensions.\n\nOutput from `--json` conforms to the `AppVersionsListResult` schema.\n\nUse `--json-schema` to print the schema directly:\n\n```ts\ntype AppVersionsListResult = AppVersion[]\n\ninterface AppVersion {\n message: string\n versionTag?: string | null\n status: string\n createdAt: string\n createdBy: string\n}\n```", + "description": "Lists the deployed app versions. An app version is a snapshot of your app extensions.\n\nOutput from `--json` conforms to the `AppVersionsListResult` schema.\n\nUse `--json-schema` to print the schema directly:\n\n```ts\ntype AppVersionsListResult = AppVersion[]\n\ninterface AppVersion {\n message: string\n versionTag?: string | null\n status: string\n createdAt: string\n createdBy: string\n versionId: string\n}\n```", "descriptionWithMarkdown": "Lists the deployed app versions. An app version is a snapshot of your app extensions.", "flags": { "auth-alias": { From cc89857fc89d03fc94bf285c999d42da53493e3d Mon Sep 17 00:00:00 2001 From: Donald Merand Date: Thu, 10 Sep 2026 17:14:13 -0400 Subject: [PATCH 3/8] Align app versions JSON output boundary Assisted-By: devx/f323f8f7-39c8-42ea-882b-5ae18595fcc9 --- .../cli/commands/app/versions/list.test.ts | 63 +++---- .../app/src/cli/commands/app/versions/list.ts | 14 +- .../services/versions-list/presenter.test.ts | 110 ------------- .../cli/services/versions-list/result.test.ts | 155 ++++++++++++++++++ .../versions-list/{presenter.ts => result.ts} | 30 ++-- 5 files changed, 203 insertions(+), 169 deletions(-) delete mode 100644 packages/app/src/cli/services/versions-list/presenter.test.ts create mode 100644 packages/app/src/cli/services/versions-list/result.test.ts rename packages/app/src/cli/services/versions-list/{presenter.ts => result.ts} (80%) diff --git a/packages/app/src/cli/commands/app/versions/list.test.ts b/packages/app/src/cli/commands/app/versions/list.test.ts index ebdea790a13..53cd035c574 100644 --- a/packages/app/src/cli/commands/app/versions/list.test.ts +++ b/packages/app/src/cli/commands/app/versions/list.test.ts @@ -3,29 +3,7 @@ import {Organization, OrganizationSource} from '../../../models/organization.js' import {afterEach, describe, expect, test, vi} from 'vitest' vi.mock('../../../services/app-context.js') - -function captureStandardStreams() { - const stdout: string[] = [] - const stderr: string[] = [] - - const stdoutSpy = vi.spyOn(process.stdout, 'write').mockImplementation(((chunk: string | Uint8Array) => { - stdout.push(typeof chunk === 'string' ? chunk : Buffer.from(chunk).toString('utf8')) - return true - }) as typeof process.stdout.write) - const stderrSpy = vi.spyOn(process.stderr, 'write').mockImplementation(((chunk: string | Uint8Array) => { - stderr.push(typeof chunk === 'string' ? chunk : Buffer.from(chunk).toString('utf8')) - return true - }) as typeof process.stderr.write) - - return { - stdout: () => stdout.join(''), - stderr: () => stderr.join(''), - restore: () => { - stdoutSpy.mockRestore() - stderrSpy.mockRestore() - }, - } -} +vi.mock('../../../services/versions-list/result.js') const originalUnitTestEnvironment = process.env.SHOPIFY_UNIT_TEST @@ -39,7 +17,7 @@ afterEach(() => { }) describe('app versions list command', () => { - test('writes one JSON document to stdout without text output', async () => { + test('passes the typed result to the JSON output boundary', async () => { process.env.SHOPIFY_UNIT_TEST = 'false' vi.resetModules() @@ -74,6 +52,7 @@ describe('app versions list command', () => { }), }) const {linkedAppContext} = await import('../../../services/app-context.js') + const {renderAppVersionsListResult} = await import('../../../services/versions-list/result.js') vi.mocked(linkedAppContext).mockResolvedValue({ app, remoteApp, @@ -81,27 +60,29 @@ describe('app versions list command', () => { developerPlatformClient, } as unknown as Awaited>) const {default: VersionsList} = await import('./list.js') - const streams = captureStandardStreams() - try { - await VersionsList.run(['--json'], import.meta.url) - } finally { - streams.restore() - } + await VersionsList.run(['--json'], import.meta.url) - expect(JSON.parse(streams.stdout())).toEqual([ + expect(renderAppVersionsListResult).toHaveBeenCalledWith( { - message: 'message', - versionTag: 'versionTag', - status: 'active', - createdAt: '2021-01-01 00:00:00', - createdBy: 'createdBy', - versionId: 'gid://shopify/Version/1', + app, + remoteApp, + organization, + developerPlatformClient, + appVersions: [ + { + message: 'message', + versionTag: 'versionTag', + status: 'active', + createdAt: '2021-01-01 00:00:00', + createdBy: 'createdBy', + versionId: 'gid://shopify/Version/1', + }, + ], + totalResults: 1, }, - ]) - expect(streams.stderr()).not.toContain('No app versions found for this app') - expect(streams.stderr()).not.toContain('VERSION') - expect(streams.stderr()).not.toContain('View all') + 'json', + ) }) test('keeps the existing invalid API key error', async () => { diff --git a/packages/app/src/cli/commands/app/versions/list.ts b/packages/app/src/cli/commands/app/versions/list.ts index ed825db8d20..ce681b9cf7c 100644 --- a/packages/app/src/cli/commands/app/versions/list.ts +++ b/packages/app/src/cli/commands/app/versions/list.ts @@ -1,11 +1,10 @@ import {appFlags} from '../../../flags.js' import {appVersionsListJsonOutputSchema, getAppVersions} from '../../../services/versions-list.js' -import {renderAppVersionsList} from '../../../services/versions-list/presenter.js' +import {renderAppVersionsListResult} from '../../../services/versions-list/result.js' import AppLinkedCommand, {AppLinkedCommandOutput} from '../../../utilities/app-linked-command.js' import {linkedAppContext} from '../../../services/app-context.js' import {globalFlags, jsonFlag} from '@shopify/cli-kit/node/cli' import {AbortError} from '@shopify/cli-kit/node/error' -import {outputResult} from '@shopify/cli-kit/node/output' export default class VersionsList extends AppLinkedCommand { static summary = 'List deployed versions of your app.' @@ -37,17 +36,16 @@ export default class VersionsList extends AppLinkedCommand { const result = await getAppVersions(developerPlatformClient, remoteApp) if (!result) throw new AbortError(`Invalid API Key: ${remoteApp.apiKey}`) - if (flags.json) { - outputResult(appVersionsListJsonOutputSchema.encode(result.appVersions)) - } else { - await renderAppVersionsList({ + await renderAppVersionsListResult( + { app, remoteApp, organization, developerPlatformClient, ...result, - }) - } + }, + flags.json ? 'json' : 'text', + ) return {app} } diff --git a/packages/app/src/cli/services/versions-list/presenter.test.ts b/packages/app/src/cli/services/versions-list/presenter.test.ts deleted file mode 100644 index 0a5262d67c8..00000000000 --- a/packages/app/src/cli/services/versions-list/presenter.test.ts +++ /dev/null @@ -1,110 +0,0 @@ -import {renderAppVersionsList} from './presenter.js' -import {renderCurrentlyUsedConfigInfo} from '../context.js' -import {testAppLinked, testDeveloperPlatformClient, testOrganizationApp} from '../../models/app/app.test-data.js' -import {Organization, OrganizationSource} from '../../models/organization.js' -import {afterEach, describe, expect, test, vi} from 'vitest' -import {mockAndCaptureOutput} from '@shopify/cli-kit/node/testing/output' - -vi.mock('../context.js') - -afterEach(() => { - mockAndCaptureOutput().clear() -}) - -const organization: Organization = { - id: 'org-id', - businessName: 'name of org 1', - source: OrganizationSource.BusinessPlatform, -} - -const remoteApp = testOrganizationApp({organizationId: organization.id, title: 'app-title', id: 'app-id'}) - -function buildDeveloperPlatformClient() { - return testDeveloperPlatformClient({ - orgFromId: () => Promise.resolve(organization), - }) -} - -describe('renderAppVersionsList', () => { - test('shows a message when there are no app versions', async () => { - const outputMock = mockAndCaptureOutput() - - await renderAppVersionsList({ - app: testAppLinked({}), - appVersions: [], - totalResults: 0, - remoteApp, - organization, - developerPlatformClient: buildDeveloperPlatformClient(), - }) - - expect(outputMock.info()).toMatchInlineSnapshot(`"No app versions found for this app"`) - }) - - test('shows currently used config info', async () => { - await renderAppVersionsList({ - app: testAppLinked({}), - appVersions: [], - totalResults: 0, - remoteApp, - organization, - developerPlatformClient: buildDeveloperPlatformClient(), - }) - - expect(renderCurrentlyUsedConfigInfo).toHaveBeenCalledWith({ - org: 'name of org 1', - appName: 'app-title', - configFile: 'shopify.app.toml', - }) - }) - - const terminalWidth = process.stdout.columns - - test.skipIf(terminalWidth !== undefined)('renders a table and dashboard link when app versions exist', async () => { - const outputMock = mockAndCaptureOutput() - - await renderAppVersionsList({ - app: testAppLinked({}), - appVersions: [ - { - message: 'message', - versionTag: 'versionTag', - status: 'active', - createdAt: '2021-01-01 00:00:00', - createdBy: 'createdBy', - versionId: 'gid://shopify/Version/1', - }, - { - message: 'message 2', - versionTag: 'versionTag 2', - status: 'released', - createdAt: '2021-01-01 00:00:00', - createdBy: 'createdBy 2', - versionId: 'gid://shopify/Version/2', - }, - { - message: 'long message with more than 15 characters', - versionTag: 'versionTag 3', - status: 'released', - createdAt: '2021-01-01 00:00:00', - createdBy: 'createdBy 3', - versionId: 'gid://shopify/Version/3', - }, - ], - totalResults: 31, - remoteApp, - organization, - developerPlatformClient: buildDeveloperPlatformClient(), - }) - - expect(outputMock.info()).toMatchInlineSnapshot( - `"VERSION STATUS MESSAGE DATE CREATED CREATED BY -──────────── ──────── ───────────── ─────────────────── ─────────── -versionTag ★ active message 2021-01-01 00:00:00 createdBy -versionTag 2 released message 2 2021-01-01 00:00:00 createdBy 2 -versionTag 3 released long messa... 2021-01-01 00:00:00 createdBy 3 - -View all 31 app versions in the Test Dashboard ( https://test.shopify.com/org-id/apps/app-id/versions )"`, - ) - }) -}) diff --git a/packages/app/src/cli/services/versions-list/result.test.ts b/packages/app/src/cli/services/versions-list/result.test.ts new file mode 100644 index 00000000000..7b0939a9b73 --- /dev/null +++ b/packages/app/src/cli/services/versions-list/result.test.ts @@ -0,0 +1,155 @@ +import {renderAppVersionsListResult} from './result.js' +import {renderCurrentlyUsedConfigInfo} from '../context.js' +import {testAppLinked, testDeveloperPlatformClient, testOrganizationApp} from '../../models/app/app.test-data.js' +import {Organization, OrganizationSource} from '../../models/organization.js' +import {afterEach, describe, expect, test, vi} from 'vitest' +import {mockAndCaptureOutput} from '@shopify/cli-kit/node/testing/output' + +vi.mock('../context.js') + +afterEach(() => { + mockAndCaptureOutput().clear() +}) + +const organization: Organization = { + id: 'org-id', + businessName: 'name of org 1', + source: OrganizationSource.BusinessPlatform, +} + +const remoteApp = testOrganizationApp({organizationId: organization.id, title: 'app-title', id: 'app-id'}) + +function buildDeveloperPlatformClient() { + return testDeveloperPlatformClient({ + orgFromId: () => Promise.resolve(organization), + }) +} + +describe('renderAppVersionsListResult', () => { + test('shows a message when there are no app versions', async () => { + const outputMock = mockAndCaptureOutput() + + await renderAppVersionsListResult( + { + app: testAppLinked({}), + appVersions: [], + totalResults: 0, + remoteApp, + organization, + developerPlatformClient: buildDeveloperPlatformClient(), + }, + 'text', + ) + + expect(outputMock.info()).toMatchInlineSnapshot(`"No app versions found for this app"`) + }) + + test('shows currently used config info', async () => { + await renderAppVersionsListResult( + { + app: testAppLinked({}), + appVersions: [], + totalResults: 0, + remoteApp, + organization, + developerPlatformClient: buildDeveloperPlatformClient(), + }, + 'text', + ) + + expect(renderCurrentlyUsedConfigInfo).toHaveBeenCalledWith({ + org: 'name of org 1', + appName: 'app-title', + configFile: 'shopify.app.toml', + }) + }) + + test('writes the typed JSON result without text presentation', async () => { + const outputMock = mockAndCaptureOutput() + + await renderAppVersionsListResult( + { + app: testAppLinked({}), + appVersions: [ + { + message: 'message', + versionTag: 'versionTag', + status: 'active', + createdAt: '2021-01-01 00:00:00', + createdBy: 'createdBy', + versionId: 'gid://shopify/Version/1', + }, + ], + totalResults: 1, + remoteApp, + organization, + developerPlatformClient: buildDeveloperPlatformClient(), + }, + 'json', + ) + + expect(JSON.parse(outputMock.output())).toEqual([ + { + message: 'message', + versionTag: 'versionTag', + status: 'active', + createdAt: '2021-01-01 00:00:00', + createdBy: 'createdBy', + versionId: 'gid://shopify/Version/1', + }, + ]) + }) + + const terminalWidth = process.stdout.columns + + test.skipIf(terminalWidth !== undefined)('renders a table and dashboard link when app versions exist', async () => { + const outputMock = mockAndCaptureOutput() + + await renderAppVersionsListResult( + { + app: testAppLinked({}), + appVersions: [ + { + message: 'message', + versionTag: 'versionTag', + status: 'active', + createdAt: '2021-01-01 00:00:00', + createdBy: 'createdBy', + versionId: 'gid://shopify/Version/1', + }, + { + message: 'message 2', + versionTag: 'versionTag 2', + status: 'released', + createdAt: '2021-01-01 00:00:00', + createdBy: 'createdBy 2', + versionId: 'gid://shopify/Version/2', + }, + { + message: 'long message with more than 15 characters', + versionTag: 'versionTag 3', + status: 'released', + createdAt: '2021-01-01 00:00:00', + createdBy: 'createdBy 3', + versionId: 'gid://shopify/Version/3', + }, + ], + totalResults: 31, + remoteApp, + organization, + developerPlatformClient: buildDeveloperPlatformClient(), + }, + 'text', + ) + + expect(outputMock.info()).toMatchInlineSnapshot( + `"VERSION STATUS MESSAGE DATE CREATED CREATED BY +──────────── ──────── ───────────── ─────────────────── ─────────── +versionTag ★ active message 2021-01-01 00:00:00 createdBy +versionTag 2 released message 2 2021-01-01 00:00:00 createdBy 2 +versionTag 3 released long messa... 2021-01-01 00:00:00 createdBy 3 + +View all 31 app versions in the Test Dashboard ( https://test.shopify.com/org-id/apps/app-id/versions )"`, + ) + }) +}) diff --git a/packages/app/src/cli/services/versions-list/presenter.ts b/packages/app/src/cli/services/versions-list/result.ts similarity index 80% rename from packages/app/src/cli/services/versions-list/presenter.ts rename to packages/app/src/cli/services/versions-list/result.ts index 34cf465c9a2..b07e5796cad 100644 --- a/packages/app/src/cli/services/versions-list/presenter.ts +++ b/packages/app/src/cli/services/versions-list/result.ts @@ -1,15 +1,17 @@ import {renderCurrentlyUsedConfigInfo} from '../context.js' -import {AppVersionsListResult} from '../versions-list.js' +import {appVersionsListJsonOutputSchema, AppVersionsListResult} from '../versions-list.js' import {AppLinkedInterface} from '../../models/app/app.js' import {Organization, OrganizationApp} from '../../models/organization.js' import {DeveloperPlatformClient} from '../../utilities/developer-platform-client.js' import colors from '@shopify/cli-kit/node/colors' -import {outputContent, outputInfo, outputToken, unstyled} from '@shopify/cli-kit/node/output' +import {outputContent, outputInfo, outputResult, outputToken, unstyled} from '@shopify/cli-kit/node/output' import {basename} from '@shopify/cli-kit/node/path' import {renderTable} from '@shopify/cli-kit/node/ui' const TABLE_FORMATTING_CHARS = 12 +type AppVersionsListOutputFormat = 'text' | 'json' + interface RenderAppVersionsListOptions { app: AppLinkedInterface appVersions: AppVersionsListResult @@ -19,14 +21,22 @@ interface RenderAppVersionsListOptions { developerPlatformClient: DeveloperPlatformClient } -export async function renderAppVersionsList({ - app, - appVersions: versionResults, - totalResults, - remoteApp, - organization, - developerPlatformClient, -}: RenderAppVersionsListOptions): Promise { +export async function renderAppVersionsListResult( + { + app, + appVersions: versionResults, + totalResults, + remoteApp, + organization, + developerPlatformClient, + }: RenderAppVersionsListOptions, + format: AppVersionsListOutputFormat, +): Promise { + if (format === 'json') { + outputResult(appVersionsListJsonOutputSchema.encode(versionResults)) + return + } + renderCurrentlyUsedConfigInfo({ org: organization.businessName, appName: remoteApp.title, From 5dbffe83ef34632ad6a1dee38c8bfa395f6f2b25 Mon Sep 17 00:00:00 2001 From: Donald Merand Date: Mon, 14 Sep 2026 10:57:20 -0400 Subject: [PATCH 4/8] Refresh app versions list JSON schema docs Assisted-By: devx/b173c0b4-2d65-4396-a5eb-d3c4d4c3a4ff --- packages/cli/README.md | 58 +++++++++++++++++++++++++------- packages/cli/oclif.manifest.json | 2 +- 2 files changed, 47 insertions(+), 13 deletions(-) diff --git a/packages/cli/README.md b/packages/cli/README.md index 86ccc96fa59..0bcd33769d9 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -1982,18 +1982,52 @@ DESCRIPTION Output from `--json` conforms to the `AppVersionsListResult` schema. - Use `--json-schema` to print the schema directly: - - ```ts - type AppVersionsListResult = AppVersion[] - - interface AppVersion { - message: string - versionTag?: string | null - status: string - createdAt: string - createdBy: string - versionId: string + Use `--json-schema` to print the result, error, and event schemas. + + ```json + { + "type": "array", + "items": { + "$ref": "#/definitions/AppVersion" + }, + "title": "AppVersionsListResult", + "definitions": { + "AppVersion": { + "type": "object", + "properties": { + "message": { + "type": "string" + }, + "versionTag": { + "type": [ + "string", + "null" + ] + }, + "status": { + "type": "string" + }, + "createdAt": { + "type": "string" + }, + "createdBy": { + "type": "string" + }, + "versionId": { + "type": "string" + } + }, + "required": [ + "message", + "status", + "createdAt", + "createdBy", + "versionId" + ], + "additionalProperties": false + } + }, + "$schema": "http://json-schema.org/draft-07/schema#" } ``` ``` diff --git a/packages/cli/oclif.manifest.json b/packages/cli/oclif.manifest.json index 86df001b76d..22e7a94c391 100644 --- a/packages/cli/oclif.manifest.json +++ b/packages/cli/oclif.manifest.json @@ -4374,7 +4374,7 @@ "args": { }, "customPluginName": "@shopify/app", - "description": "Lists the deployed app versions. An app version is a snapshot of your app extensions.\n\nOutput from `--json` conforms to the `AppVersionsListResult` schema.\n\nUse `--json-schema` to print the schema directly:\n\n```ts\ntype AppVersionsListResult = AppVersion[]\n\ninterface AppVersion {\n message: string\n versionTag?: string | null\n status: string\n createdAt: string\n createdBy: string\n versionId: string\n}\n```", + "description": "Lists the deployed app versions. An app version is a snapshot of your app extensions.\n\nOutput from `--json` conforms to the `AppVersionsListResult` schema.\n\nUse `--json-schema` to print the result, error, and event schemas.\n\n```json\n{\n \"type\": \"array\",\n \"items\": {\n \"$ref\": \"#/definitions/AppVersion\"\n },\n \"title\": \"AppVersionsListResult\",\n \"definitions\": {\n \"AppVersion\": {\n \"type\": \"object\",\n \"properties\": {\n \"message\": {\n \"type\": \"string\"\n },\n \"versionTag\": {\n \"type\": [\n \"string\",\n \"null\"\n ]\n },\n \"status\": {\n \"type\": \"string\"\n },\n \"createdAt\": {\n \"type\": \"string\"\n },\n \"createdBy\": {\n \"type\": \"string\"\n },\n \"versionId\": {\n \"type\": \"string\"\n }\n },\n \"required\": [\n \"message\",\n \"status\",\n \"createdAt\",\n \"createdBy\",\n \"versionId\"\n ],\n \"additionalProperties\": false\n }\n },\n \"$schema\": \"http://json-schema.org/draft-07/schema#\"\n}\n```", "descriptionWithMarkdown": "Lists the deployed app versions. An app version is a snapshot of your app extensions.", "flags": { "auth-alias": { From 66edfe5c7e9ee1fe7caa221e2513d25bcb9ec0f9 Mon Sep 17 00:00:00 2001 From: Donald Merand Date: Tue, 15 Sep 2026 10:06:35 -0400 Subject: [PATCH 5/8] Fix app versions list review feedback Assisted-By: devx/470e4e81-45ae-4aac-95a2-1f5ea6e06b91 --- .../cli/commands/app/versions/list.test.ts | 246 ++++++++++++------ .../app/src/cli/commands/app/versions/list.ts | 5 +- .../src/cli/services/versions-list.test.ts | 10 +- .../app/src/cli/services/versions-list.ts | 27 +- .../src/cli/services/versions-list/result.ts | 2 +- .../src/cli/services/versions-list/types.ts | 19 ++ 6 files changed, 198 insertions(+), 111 deletions(-) create mode 100644 packages/app/src/cli/services/versions-list/types.ts diff --git a/packages/app/src/cli/commands/app/versions/list.test.ts b/packages/app/src/cli/commands/app/versions/list.test.ts index 53cd035c574..304f58b7255 100644 --- a/packages/app/src/cli/commands/app/versions/list.test.ts +++ b/packages/app/src/cli/commands/app/versions/list.test.ts @@ -1,9 +1,17 @@ +import {AppVersionsQuerySchema} from '../../../api/graphql/get_versions_list.js' import {testAppLinked, testDeveloperPlatformClient, testOrganizationApp} from '../../../models/app/app.test-data.js' import {Organization, OrganizationSource} from '../../../models/organization.js' +import {Config} from '@oclif/core' import {afterEach, describe, expect, test, vi} from 'vitest' +import {mockAndCaptureOutput} from '@shopify/cli-kit/node/testing/output' vi.mock('../../../services/app-context.js') -vi.mock('../../../services/versions-list/result.js') + +const organization: Organization = { + id: 'org-id', + businessName: 'name of org 1', + source: OrganizationSource.BusinessPlatform, +} const originalUnitTestEnvironment = process.env.SHOPIFY_UNIT_TEST @@ -13,99 +21,173 @@ afterEach(() => { } else { process.env.SHOPIFY_UNIT_TEST = originalUnitTestEnvironment } + mockAndCaptureOutput().clear() vi.resetModules() }) +// Captures the real standard streams so JSON and text output are proven at the process boundary. +function captureStandardStreams() { + const stdout: string[] = [] + const stderr: string[] = [] + + const stdoutSpy = vi.spyOn(process.stdout, 'write').mockImplementation(((chunk: string | Uint8Array) => { + stdout.push(typeof chunk === 'string' ? chunk : Buffer.from(chunk).toString('utf8')) + return true + }) as typeof process.stdout.write) + const stderrSpy = vi.spyOn(process.stderr, 'write').mockImplementation(((chunk: string | Uint8Array) => { + stderr.push(typeof chunk === 'string' ? chunk : Buffer.from(chunk).toString('utf8')) + return true + }) as typeof process.stderr.write) + + return { + stdout: () => stdout.join(''), + stderr: () => stderr.join(''), + restore: () => { + stdoutSpy.mockRestore() + stderrSpy.mockRestore() + }, + } +} + +type AppVersionsNodes = NonNullable['appVersions']['nodes'] + +function appVersionsResponse(nodes: AppVersionsNodes, totalResults: number): AppVersionsQuerySchema { + return { + app: { + id: 'app-id', + title: 'app-title', + organizationId: organization.id, + appVersions: {nodes, pageInfo: {totalResults}}, + }, + } +} + +async function loadCommand(appVersions: AppVersionsQuerySchema) { + const {linkedAppContext} = await import('../../../services/app-context.js') + const app = testAppLinked({}) + const remoteApp = testOrganizationApp({organizationId: organization.id, apiKey: 'api-key'}) + const developerPlatformClient = testDeveloperPlatformClient({ + appVersions: () => Promise.resolve(appVersions), + }) + vi.mocked(linkedAppContext).mockResolvedValue({ + app, + remoteApp, + organization, + developerPlatformClient, + } as unknown as Awaited>) + const {default: VersionsList} = await import('./list.js') + return VersionsList +} + +// Runs the command body directly so oclif's plugin warnings cannot pollute the stderr proof. +async function runCommand(appVersions: AppVersionsQuerySchema, argv: string[]) { + const VersionsList = await loadCommand(appVersions) + const config = await Config.load() + return new VersionsList(argv, config).run() +} + describe('app versions list command', () => { - test('passes the typed result to the JSON output boundary', async () => { + test('writes the encoded JSON result to stdout with empty stderr', async () => { process.env.SHOPIFY_UNIT_TEST = 'false' vi.resetModules() + const streams = captureStandardStreams() - const organization: Organization = { - id: 'org-id', - businessName: 'name of org 1', - source: OrganizationSource.BusinessPlatform, - } - const app = testAppLinked({}) - const remoteApp = testOrganizationApp({organizationId: organization.id, apiKey: 'api-key'}) - const developerPlatformClient = testDeveloperPlatformClient({ - appVersions: () => - Promise.resolve({ - app: { - id: 'app-id', - title: 'app-title', - organizationId: organization.id, - appVersions: { - nodes: [ - { - message: 'message', - versionTag: 'versionTag', - versionId: 'gid://shopify/Version/1', - status: 'active', - createdAt: '2021-01-01', - createdBy: {displayName: 'createdBy'}, - }, - ], - pageInfo: {totalResults: 1}, + try { + await runCommand( + appVersionsResponse( + [ + { + message: 'message', + versionTag: 'versionTag', + versionId: 'gid://shopify/Version/1', + status: 'active', + createdAt: '2021-01-01', + createdBy: {displayName: 'createdBy'}, }, - }, - }), - }) - const {linkedAppContext} = await import('../../../services/app-context.js') - const {renderAppVersionsListResult} = await import('../../../services/versions-list/result.js') - vi.mocked(linkedAppContext).mockResolvedValue({ - app, - remoteApp, - organization, - developerPlatformClient, - } as unknown as Awaited>) - const {default: VersionsList} = await import('./list.js') - - await VersionsList.run(['--json'], import.meta.url) - - expect(renderAppVersionsListResult).toHaveBeenCalledWith( + { + message: null, + versionTag: null, + versionId: 'gid://shopify/Version/2', + status: 'released', + createdAt: '2021-01-02', + createdBy: {displayName: null}, + }, + ], + 31, + ), + ['--json'], + ) + } finally { + streams.restore() + } + + const expected = [ + { + message: 'message', + versionTag: 'versionTag', + status: 'active', + createdAt: '2021-01-01 00:00:00', + createdBy: 'createdBy', + versionId: 'gid://shopify/Version/1', + }, { - app, - remoteApp, - organization, - developerPlatformClient, - appVersions: [ - { - message: 'message', - versionTag: 'versionTag', - status: 'active', - createdAt: '2021-01-01 00:00:00', - createdBy: 'createdBy', - versionId: 'gid://shopify/Version/1', - }, - ], - totalResults: 1, + message: '', + versionTag: null, + status: 'released', + createdAt: '2021-01-02 00:00:00', + createdBy: '', + versionId: 'gid://shopify/Version/2', }, - 'json', + ] + const {appVersionsListJsonOutputSchema} = await import('../../../services/versions-list/types.js') + + expect(streams.stdout()).toBe(`${appVersionsListJsonOutputSchema.encode(expected)}\n`) + expect(JSON.parse(streams.stdout())).toEqual(expected) + expect(streams.stderr()).toBe('') + }) + + test('writes an empty JSON array to stdout with empty stderr', async () => { + process.env.SHOPIFY_UNIT_TEST = 'false' + vi.resetModules() + const streams = captureStandardStreams() + + try { + await runCommand(appVersionsResponse([], 0), ['--json']) + } finally { + streams.restore() + } + + expect(streams.stdout()).toBe('[]\n') + expect(streams.stderr()).toBe('') + }) + + test('keeps stdout empty and writes config and empty-state guidance to stderr in text mode', async () => { + process.env.SHOPIFY_UNIT_TEST = 'false' + vi.resetModules() + const streams = captureStandardStreams() + + try { + await runCommand(appVersionsResponse([], 0), []) + } finally { + streams.restore() + } + + expect(streams.stdout()).toBe('') + expect(streams.stderr()).toContain('No app versions found for this app') + expect(streams.stderr()).toContain('shopify.app.toml') + }) + + test('reports the factual service error when the API response has no app', async () => { + await expect(runCommand({app: null}, ['--json'])).rejects.toThrow( + 'Shopify did not return app information for API key api-key.', ) }) - test('keeps the existing invalid API key error', async () => { - const app = testAppLinked({}) - const remoteApp = testOrganizationApp({apiKey: 'api-key'}) - const {linkedAppContext} = await import('../../../services/app-context.js') - vi.mocked(linkedAppContext).mockResolvedValue({ - app, - remoteApp, - organization: { - id: 'org-id', - businessName: 'name of org 1', - source: OrganizationSource.BusinessPlatform, - }, - developerPlatformClient: testDeveloperPlatformClient({ - appVersions: () => Promise.resolve({app: null}), - }), - } as unknown as Awaited>) - const {default: VersionsList} = await import('./list.js') - vi.spyOn(VersionsList.prototype, 'catch').mockImplementation(async (error) => { - throw error - }) - - await expect(VersionsList.run(['--json'], import.meta.url)).rejects.toThrow('Invalid API Key: api-key') + test('exposes the result schema for --json-schema and help wiring', async () => { + const VersionsList = await loadCommand(appVersionsResponse([], 0)) + const {appVersionsListJsonOutputSchema} = await import('../../../services/versions-list/types.js') + + expect(VersionsList.jsonOutputSchema).toBe(appVersionsListJsonOutputSchema) + expect(VersionsList.descriptionForHelp()).toContain('`AppVersionsListResult` schema') }) }) diff --git a/packages/app/src/cli/commands/app/versions/list.ts b/packages/app/src/cli/commands/app/versions/list.ts index ce681b9cf7c..5f39b05a436 100644 --- a/packages/app/src/cli/commands/app/versions/list.ts +++ b/packages/app/src/cli/commands/app/versions/list.ts @@ -1,10 +1,10 @@ import {appFlags} from '../../../flags.js' -import {appVersionsListJsonOutputSchema, getAppVersions} from '../../../services/versions-list.js' +import {appVersionsListJsonOutputSchema} from '../../../services/versions-list/types.js' +import {getAppVersions} from '../../../services/versions-list.js' import {renderAppVersionsListResult} from '../../../services/versions-list/result.js' import AppLinkedCommand, {AppLinkedCommandOutput} from '../../../utilities/app-linked-command.js' import {linkedAppContext} from '../../../services/app-context.js' import {globalFlags, jsonFlag} from '@shopify/cli-kit/node/cli' -import {AbortError} from '@shopify/cli-kit/node/error' export default class VersionsList extends AppLinkedCommand { static summary = 'List deployed versions of your app.' @@ -34,7 +34,6 @@ export default class VersionsList extends AppLinkedCommand { }) const result = await getAppVersions(developerPlatformClient, remoteApp) - if (!result) throw new AbortError(`Invalid API Key: ${remoteApp.apiKey}`) await renderAppVersionsListResult( { diff --git a/packages/app/src/cli/services/versions-list.test.ts b/packages/app/src/cli/services/versions-list.test.ts index a0730838e87..97d9249c8f9 100644 --- a/packages/app/src/cli/services/versions-list.test.ts +++ b/packages/app/src/cli/services/versions-list.test.ts @@ -1,4 +1,5 @@ -import {appVersionsListJsonOutputSchema, getAppVersions} from './versions-list.js' +import {getAppVersions} from './versions-list.js' +import {appVersionsListJsonOutputSchema} from './versions-list/types.js' import {testDeveloperPlatformClient, testOrganizationApp} from '../models/app/app.test-data.js' import {AppVersionsQuerySchema} from '../api/graphql/get_versions_list.js' import {describe, expect, test} from 'vitest' @@ -77,7 +78,6 @@ describe('getAppVersions', () => { ], totalResults: 31, }) - if (!result) throw new Error('Expected app versions result') expect(appVersionsListJsonOutputSchema.encode(result.appVersions)).toMatchInlineSnapshot(` "[ @@ -108,12 +108,14 @@ describe('getAppVersions', () => { `) }) - test('returns undefined when the API response does not contain an app', async () => { + test('throws the factual error when the API response does not contain an app', async () => { const developerPlatformClient = testDeveloperPlatformClient({ appVersions: () => Promise.resolve({app: null}), }) - await expect(getAppVersions(developerPlatformClient, remoteApp)).resolves.toBeUndefined() + await expect(getAppVersions(developerPlatformClient, remoteApp)).rejects.toThrow( + 'Shopify did not return app information for API key api-key.', + ) }) test('rejects invalid result values', () => { diff --git a/packages/app/src/cli/services/versions-list.ts b/packages/app/src/cli/services/versions-list.ts index ec337893a18..c3cb2ffcaeb 100644 --- a/packages/app/src/cli/services/versions-list.ts +++ b/packages/app/src/cli/services/versions-list.ts @@ -1,26 +1,9 @@ +import {type AppVersionsListResult} from './versions-list/types.js' import {AppVersionsQuerySchema} from '../api/graphql/get_versions_list.js' import {OrganizationApp} from '../models/organization.js' import {DeveloperPlatformClient} from '../utilities/developer-platform-client.js' import {formatDate} from '@shopify/cli-kit/common/string' -import {defineJsonOutputSchema, type InferJsonOutputSchema} from '@shopify/cli-kit/node/json-output-schema' -import {zod} from '@shopify/cli-kit/node/schema' - -const appVersionJsonOutputSchema = zod.object({ - message: zod.string(), - versionTag: zod.string().nullable().optional(), - status: zod.string(), - createdAt: zod.string(), - createdBy: zod.string(), - versionId: zod.string(), -}) - -export const appVersionsListJsonOutputSchema = defineJsonOutputSchema({ - name: 'AppVersionsListResult', - schema: zod.array(appVersionJsonOutputSchema), - definitions: {AppVersion: appVersionJsonOutputSchema}, -}) - -export type AppVersionsListResult = InferJsonOutputSchema +import {AbortError} from '@shopify/cli-kit/node/error' interface AppVersionsList { appVersions: AppVersionsListResult @@ -30,9 +13,11 @@ interface AppVersionsList { export async function getAppVersions( developerPlatformClient: DeveloperPlatformClient, app: OrganizationApp, -): Promise { +): Promise { const response: AppVersionsQuerySchema = await developerPlatformClient.appVersions(app) - if (!response.app) return undefined + if (!response.app) { + throw new AbortError(`Shopify did not return app information for API key ${app.apiKey}.`) + } return { appVersions: response.app.appVersions.nodes.map((appVersion) => ({ diff --git a/packages/app/src/cli/services/versions-list/result.ts b/packages/app/src/cli/services/versions-list/result.ts index b07e5796cad..4443a7591ab 100644 --- a/packages/app/src/cli/services/versions-list/result.ts +++ b/packages/app/src/cli/services/versions-list/result.ts @@ -1,5 +1,5 @@ +import {appVersionsListJsonOutputSchema, type AppVersionsListResult} from './types.js' import {renderCurrentlyUsedConfigInfo} from '../context.js' -import {appVersionsListJsonOutputSchema, AppVersionsListResult} from '../versions-list.js' import {AppLinkedInterface} from '../../models/app/app.js' import {Organization, OrganizationApp} from '../../models/organization.js' import {DeveloperPlatformClient} from '../../utilities/developer-platform-client.js' diff --git a/packages/app/src/cli/services/versions-list/types.ts b/packages/app/src/cli/services/versions-list/types.ts new file mode 100644 index 00000000000..3f983f82bff --- /dev/null +++ b/packages/app/src/cli/services/versions-list/types.ts @@ -0,0 +1,19 @@ +import {defineJsonOutputSchema, type InferJsonOutputSchema} from '@shopify/cli-kit/node/json-output-schema' +import {zod} from '@shopify/cli-kit/node/schema' + +const appVersionJsonOutputSchema = zod.object({ + message: zod.string(), + versionTag: zod.string().nullable().optional(), + status: zod.string(), + createdAt: zod.string(), + createdBy: zod.string(), + versionId: zod.string(), +}) + +export const appVersionsListJsonOutputSchema = defineJsonOutputSchema({ + name: 'AppVersionsListResult', + schema: zod.array(appVersionJsonOutputSchema), + definitions: {AppVersion: appVersionJsonOutputSchema}, +}) + +export type AppVersionsListResult = InferJsonOutputSchema From 6a912653022833e2069176ed0c4fe2b356b26ed7 Mon Sep 17 00:00:00 2001 From: Donald Merand Date: Tue, 15 Sep 2026 10:27:30 -0400 Subject: [PATCH 6/8] Order app versions JSON members to match GraphQL response Assisted-By: devx/335813ac-6a17-4cff-be30-ab1f14de4932 --- .../cli/commands/app/versions/list.test.ts | 26 ++++++++++++++++--- .../src/cli/services/versions-list.test.ts | 12 ++++----- .../app/src/cli/services/versions-list.ts | 2 +- .../src/cli/services/versions-list/types.ts | 2 +- packages/cli/README.md | 10 +++---- packages/cli/oclif.manifest.json | 2 +- 6 files changed, 36 insertions(+), 18 deletions(-) diff --git a/packages/app/src/cli/commands/app/versions/list.test.ts b/packages/app/src/cli/commands/app/versions/list.test.ts index 304f58b7255..a70656cc1ce 100644 --- a/packages/app/src/cli/commands/app/versions/list.test.ts +++ b/packages/app/src/cli/commands/app/versions/list.test.ts @@ -125,23 +125,41 @@ describe('app versions list command', () => { { message: 'message', versionTag: 'versionTag', + versionId: 'gid://shopify/Version/1', status: 'active', createdAt: '2021-01-01 00:00:00', createdBy: 'createdBy', - versionId: 'gid://shopify/Version/1', }, { message: '', versionTag: null, + versionId: 'gid://shopify/Version/2', status: 'released', createdAt: '2021-01-02 00:00:00', createdBy: '', - versionId: 'gid://shopify/Version/2', }, ] - const {appVersionsListJsonOutputSchema} = await import('../../../services/versions-list/types.js') + const expectedStdout = `[ + { + "message": "message", + "versionTag": "versionTag", + "versionId": "gid://shopify/Version/1", + "status": "active", + "createdAt": "2021-01-01 00:00:00", + "createdBy": "createdBy" + }, + { + "message": "", + "versionTag": null, + "versionId": "gid://shopify/Version/2", + "status": "released", + "createdAt": "2021-01-02 00:00:00", + "createdBy": "" + } +] +` - expect(streams.stdout()).toBe(`${appVersionsListJsonOutputSchema.encode(expected)}\n`) + expect(streams.stdout()).toBe(expectedStdout) expect(JSON.parse(streams.stdout())).toEqual(expected) expect(streams.stderr()).toBe('') }) diff --git a/packages/app/src/cli/services/versions-list.test.ts b/packages/app/src/cli/services/versions-list.test.ts index 97d9249c8f9..b94bfa7bf95 100644 --- a/packages/app/src/cli/services/versions-list.test.ts +++ b/packages/app/src/cli/services/versions-list.test.ts @@ -84,25 +84,25 @@ describe('getAppVersions', () => { { "message": "message", "versionTag": "versionTag", + "versionId": "gid://shopify/Version/1", "status": "active", "createdAt": "2021-01-01 00:00:00", - "createdBy": "createdBy", - "versionId": "gid://shopify/Version/1" + "createdBy": "createdBy" }, { "message": "", "versionTag": null, + "versionId": "gid://shopify/Version/2", "status": "released", "createdAt": "2021-01-02 00:00:00", - "createdBy": "", - "versionId": "gid://shopify/Version/2" + "createdBy": "" }, { "message": "", + "versionId": "gid://shopify/Version/3", "status": "released", "createdAt": "2021-01-03 00:00:00", - "createdBy": "", - "versionId": "gid://shopify/Version/3" + "createdBy": "" } ]" `) diff --git a/packages/app/src/cli/services/versions-list.ts b/packages/app/src/cli/services/versions-list.ts index c3cb2ffcaeb..7a569b77496 100644 --- a/packages/app/src/cli/services/versions-list.ts +++ b/packages/app/src/cli/services/versions-list.ts @@ -23,10 +23,10 @@ export async function getAppVersions( appVersions: response.app.appVersions.nodes.map((appVersion) => ({ message: appVersion.message ?? '', versionTag: appVersion.versionTag, + versionId: appVersion.versionId, status: appVersion.status, createdAt: formatDate(new Date(appVersion.createdAt)), createdBy: appVersion.createdBy?.displayName ?? '', - versionId: appVersion.versionId, })), totalResults: response.app.appVersions.pageInfo.totalResults, } diff --git a/packages/app/src/cli/services/versions-list/types.ts b/packages/app/src/cli/services/versions-list/types.ts index 3f983f82bff..1902e635a09 100644 --- a/packages/app/src/cli/services/versions-list/types.ts +++ b/packages/app/src/cli/services/versions-list/types.ts @@ -4,10 +4,10 @@ import {zod} from '@shopify/cli-kit/node/schema' const appVersionJsonOutputSchema = zod.object({ message: zod.string(), versionTag: zod.string().nullable().optional(), + versionId: zod.string(), status: zod.string(), createdAt: zod.string(), createdBy: zod.string(), - versionId: zod.string(), }) export const appVersionsListJsonOutputSchema = defineJsonOutputSchema({ diff --git a/packages/cli/README.md b/packages/cli/README.md index 0bcd33769d9..386fdb0803f 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -2004,6 +2004,9 @@ DESCRIPTION "null" ] }, + "versionId": { + "type": "string" + }, "status": { "type": "string" }, @@ -2012,17 +2015,14 @@ DESCRIPTION }, "createdBy": { "type": "string" - }, - "versionId": { - "type": "string" } }, "required": [ "message", + "versionId", "status", "createdAt", - "createdBy", - "versionId" + "createdBy" ], "additionalProperties": false } diff --git a/packages/cli/oclif.manifest.json b/packages/cli/oclif.manifest.json index 22e7a94c391..5fa7d253c67 100644 --- a/packages/cli/oclif.manifest.json +++ b/packages/cli/oclif.manifest.json @@ -4374,7 +4374,7 @@ "args": { }, "customPluginName": "@shopify/app", - "description": "Lists the deployed app versions. An app version is a snapshot of your app extensions.\n\nOutput from `--json` conforms to the `AppVersionsListResult` schema.\n\nUse `--json-schema` to print the result, error, and event schemas.\n\n```json\n{\n \"type\": \"array\",\n \"items\": {\n \"$ref\": \"#/definitions/AppVersion\"\n },\n \"title\": \"AppVersionsListResult\",\n \"definitions\": {\n \"AppVersion\": {\n \"type\": \"object\",\n \"properties\": {\n \"message\": {\n \"type\": \"string\"\n },\n \"versionTag\": {\n \"type\": [\n \"string\",\n \"null\"\n ]\n },\n \"status\": {\n \"type\": \"string\"\n },\n \"createdAt\": {\n \"type\": \"string\"\n },\n \"createdBy\": {\n \"type\": \"string\"\n },\n \"versionId\": {\n \"type\": \"string\"\n }\n },\n \"required\": [\n \"message\",\n \"status\",\n \"createdAt\",\n \"createdBy\",\n \"versionId\"\n ],\n \"additionalProperties\": false\n }\n },\n \"$schema\": \"http://json-schema.org/draft-07/schema#\"\n}\n```", + "description": "Lists the deployed app versions. An app version is a snapshot of your app extensions.\n\nOutput from `--json` conforms to the `AppVersionsListResult` schema.\n\nUse `--json-schema` to print the result, error, and event schemas.\n\n```json\n{\n \"type\": \"array\",\n \"items\": {\n \"$ref\": \"#/definitions/AppVersion\"\n },\n \"title\": \"AppVersionsListResult\",\n \"definitions\": {\n \"AppVersion\": {\n \"type\": \"object\",\n \"properties\": {\n \"message\": {\n \"type\": \"string\"\n },\n \"versionTag\": {\n \"type\": [\n \"string\",\n \"null\"\n ]\n },\n \"versionId\": {\n \"type\": \"string\"\n },\n \"status\": {\n \"type\": \"string\"\n },\n \"createdAt\": {\n \"type\": \"string\"\n },\n \"createdBy\": {\n \"type\": \"string\"\n }\n },\n \"required\": [\n \"message\",\n \"versionId\",\n \"status\",\n \"createdAt\",\n \"createdBy\"\n ],\n \"additionalProperties\": false\n }\n },\n \"$schema\": \"http://json-schema.org/draft-07/schema#\"\n}\n```", "descriptionWithMarkdown": "Lists the deployed app versions. An app version is a snapshot of your app extensions.", "flags": { "auth-alias": { From 8d84010ec4dc7556c683de621853b5689217e6fc Mon Sep 17 00:00:00 2001 From: Donald Merand Date: Tue, 15 Sep 2026 10:58:15 -0400 Subject: [PATCH 7/8] Correct app versions JSON member order to production order Assisted-By: devx/40ca2551-19cf-4939-8f27-90c86317525d --- .../cli/commands/app/versions/list.test.ts | 32 +++++++-------- .../src/cli/services/versions-list.test.ts | 40 +++++++++---------- .../app/src/cli/services/versions-list.ts | 8 ++-- .../src/cli/services/versions-list/types.ts | 8 ++-- packages/cli/README.md | 20 +++++----- packages/cli/oclif.manifest.json | 2 +- 6 files changed, 55 insertions(+), 55 deletions(-) diff --git a/packages/app/src/cli/commands/app/versions/list.test.ts b/packages/app/src/cli/commands/app/versions/list.test.ts index a70656cc1ce..3ea72244104 100644 --- a/packages/app/src/cli/commands/app/versions/list.test.ts +++ b/packages/app/src/cli/commands/app/versions/list.test.ts @@ -123,38 +123,38 @@ describe('app versions list command', () => { const expected = [ { - message: 'message', - versionTag: 'versionTag', - versionId: 'gid://shopify/Version/1', - status: 'active', createdAt: '2021-01-01 00:00:00', createdBy: 'createdBy', + versionTag: 'versionTag', + status: 'active', + versionId: 'gid://shopify/Version/1', + message: 'message', }, { - message: '', - versionTag: null, - versionId: 'gid://shopify/Version/2', - status: 'released', createdAt: '2021-01-02 00:00:00', createdBy: '', + versionTag: null, + status: 'released', + versionId: 'gid://shopify/Version/2', + message: '', }, ] const expectedStdout = `[ { - "message": "message", + "createdAt": "2021-01-01 00:00:00", + "createdBy": "createdBy", "versionTag": "versionTag", - "versionId": "gid://shopify/Version/1", "status": "active", - "createdAt": "2021-01-01 00:00:00", - "createdBy": "createdBy" + "versionId": "gid://shopify/Version/1", + "message": "message" }, { - "message": "", + "createdAt": "2021-01-02 00:00:00", + "createdBy": "", "versionTag": null, - "versionId": "gid://shopify/Version/2", "status": "released", - "createdAt": "2021-01-02 00:00:00", - "createdBy": "" + "versionId": "gid://shopify/Version/2", + "message": "" } ] ` diff --git a/packages/app/src/cli/services/versions-list.test.ts b/packages/app/src/cli/services/versions-list.test.ts index b94bfa7bf95..28eaedbbdce 100644 --- a/packages/app/src/cli/services/versions-list.test.ts +++ b/packages/app/src/cli/services/versions-list.test.ts @@ -53,27 +53,27 @@ describe('getAppVersions', () => { expect(result).toEqual({ appVersions: [ { - message: 'message', - versionTag: 'versionTag', - status: 'active', createdAt: '2021-01-01 00:00:00', createdBy: 'createdBy', + versionTag: 'versionTag', + status: 'active', versionId: 'gid://shopify/Version/1', + message: 'message', }, { - message: '', - versionTag: null, - status: 'released', createdAt: '2021-01-02 00:00:00', createdBy: '', + versionTag: null, + status: 'released', versionId: 'gid://shopify/Version/2', + message: '', }, { - message: '', - status: 'released', createdAt: '2021-01-03 00:00:00', createdBy: '', + status: 'released', versionId: 'gid://shopify/Version/3', + message: '', }, ], totalResults: 31, @@ -82,27 +82,27 @@ describe('getAppVersions', () => { expect(appVersionsListJsonOutputSchema.encode(result.appVersions)).toMatchInlineSnapshot(` "[ { - "message": "message", + "createdAt": "2021-01-01 00:00:00", + "createdBy": "createdBy", "versionTag": "versionTag", - "versionId": "gid://shopify/Version/1", "status": "active", - "createdAt": "2021-01-01 00:00:00", - "createdBy": "createdBy" + "versionId": "gid://shopify/Version/1", + "message": "message" }, { - "message": "", + "createdAt": "2021-01-02 00:00:00", + "createdBy": "", "versionTag": null, - "versionId": "gid://shopify/Version/2", "status": "released", - "createdAt": "2021-01-02 00:00:00", - "createdBy": "" + "versionId": "gid://shopify/Version/2", + "message": "" }, { - "message": "", - "versionId": "gid://shopify/Version/3", - "status": "released", "createdAt": "2021-01-03 00:00:00", - "createdBy": "" + "createdBy": "", + "status": "released", + "versionId": "gid://shopify/Version/3", + "message": "" } ]" `) diff --git a/packages/app/src/cli/services/versions-list.ts b/packages/app/src/cli/services/versions-list.ts index 7a569b77496..17936817696 100644 --- a/packages/app/src/cli/services/versions-list.ts +++ b/packages/app/src/cli/services/versions-list.ts @@ -21,12 +21,12 @@ export async function getAppVersions( return { appVersions: response.app.appVersions.nodes.map((appVersion) => ({ - message: appVersion.message ?? '', - versionTag: appVersion.versionTag, - versionId: appVersion.versionId, - status: appVersion.status, createdAt: formatDate(new Date(appVersion.createdAt)), createdBy: appVersion.createdBy?.displayName ?? '', + versionTag: appVersion.versionTag, + status: appVersion.status, + versionId: appVersion.versionId, + message: appVersion.message ?? '', })), totalResults: response.app.appVersions.pageInfo.totalResults, } diff --git a/packages/app/src/cli/services/versions-list/types.ts b/packages/app/src/cli/services/versions-list/types.ts index 1902e635a09..3011c8e8803 100644 --- a/packages/app/src/cli/services/versions-list/types.ts +++ b/packages/app/src/cli/services/versions-list/types.ts @@ -2,12 +2,12 @@ import {defineJsonOutputSchema, type InferJsonOutputSchema} from '@shopify/cli-k import {zod} from '@shopify/cli-kit/node/schema' const appVersionJsonOutputSchema = zod.object({ - message: zod.string(), - versionTag: zod.string().nullable().optional(), - versionId: zod.string(), - status: zod.string(), createdAt: zod.string(), createdBy: zod.string(), + versionTag: zod.string().nullable().optional(), + status: zod.string(), + versionId: zod.string(), + message: zod.string(), }) export const appVersionsListJsonOutputSchema = defineJsonOutputSchema({ diff --git a/packages/cli/README.md b/packages/cli/README.md index 386fdb0803f..4d1f31bebff 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -1995,7 +1995,10 @@ DESCRIPTION "AppVersion": { "type": "object", "properties": { - "message": { + "createdAt": { + "type": "string" + }, + "createdBy": { "type": "string" }, "versionTag": { @@ -2004,25 +2007,22 @@ DESCRIPTION "null" ] }, - "versionId": { - "type": "string" - }, "status": { "type": "string" }, - "createdAt": { + "versionId": { "type": "string" }, - "createdBy": { + "message": { "type": "string" } }, "required": [ - "message", - "versionId", - "status", "createdAt", - "createdBy" + "createdBy", + "status", + "versionId", + "message" ], "additionalProperties": false } diff --git a/packages/cli/oclif.manifest.json b/packages/cli/oclif.manifest.json index 5fa7d253c67..ad6f3bd48da 100644 --- a/packages/cli/oclif.manifest.json +++ b/packages/cli/oclif.manifest.json @@ -4374,7 +4374,7 @@ "args": { }, "customPluginName": "@shopify/app", - "description": "Lists the deployed app versions. An app version is a snapshot of your app extensions.\n\nOutput from `--json` conforms to the `AppVersionsListResult` schema.\n\nUse `--json-schema` to print the result, error, and event schemas.\n\n```json\n{\n \"type\": \"array\",\n \"items\": {\n \"$ref\": \"#/definitions/AppVersion\"\n },\n \"title\": \"AppVersionsListResult\",\n \"definitions\": {\n \"AppVersion\": {\n \"type\": \"object\",\n \"properties\": {\n \"message\": {\n \"type\": \"string\"\n },\n \"versionTag\": {\n \"type\": [\n \"string\",\n \"null\"\n ]\n },\n \"versionId\": {\n \"type\": \"string\"\n },\n \"status\": {\n \"type\": \"string\"\n },\n \"createdAt\": {\n \"type\": \"string\"\n },\n \"createdBy\": {\n \"type\": \"string\"\n }\n },\n \"required\": [\n \"message\",\n \"versionId\",\n \"status\",\n \"createdAt\",\n \"createdBy\"\n ],\n \"additionalProperties\": false\n }\n },\n \"$schema\": \"http://json-schema.org/draft-07/schema#\"\n}\n```", + "description": "Lists the deployed app versions. An app version is a snapshot of your app extensions.\n\nOutput from `--json` conforms to the `AppVersionsListResult` schema.\n\nUse `--json-schema` to print the result, error, and event schemas.\n\n```json\n{\n \"type\": \"array\",\n \"items\": {\n \"$ref\": \"#/definitions/AppVersion\"\n },\n \"title\": \"AppVersionsListResult\",\n \"definitions\": {\n \"AppVersion\": {\n \"type\": \"object\",\n \"properties\": {\n \"createdAt\": {\n \"type\": \"string\"\n },\n \"createdBy\": {\n \"type\": \"string\"\n },\n \"versionTag\": {\n \"type\": [\n \"string\",\n \"null\"\n ]\n },\n \"status\": {\n \"type\": \"string\"\n },\n \"versionId\": {\n \"type\": \"string\"\n },\n \"message\": {\n \"type\": \"string\"\n }\n },\n \"required\": [\n \"createdAt\",\n \"createdBy\",\n \"status\",\n \"versionId\",\n \"message\"\n ],\n \"additionalProperties\": false\n }\n },\n \"$schema\": \"http://json-schema.org/draft-07/schema#\"\n}\n```", "descriptionWithMarkdown": "Lists the deployed app versions. An app version is a snapshot of your app extensions.", "flags": { "auth-alias": { From 28d61f6d58f00bd60ade99677e3150bb1f583f55 Mon Sep 17 00:00:00 2001 From: Donald Merand Date: Tue, 15 Sep 2026 11:15:40 -0400 Subject: [PATCH 8/8] Isolate schema rejection test to createdBy Assisted-By: devx/4dfe3982-1445-4546-94b8-475756b2173c --- packages/app/src/cli/services/versions-list.test.ts | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/packages/app/src/cli/services/versions-list.test.ts b/packages/app/src/cli/services/versions-list.test.ts index 28eaedbbdce..4ea618954da 100644 --- a/packages/app/src/cli/services/versions-list.test.ts +++ b/packages/app/src/cli/services/versions-list.test.ts @@ -121,7 +121,14 @@ describe('getAppVersions', () => { test('rejects invalid result values', () => { expect(() => appVersionsListJsonOutputSchema.validate([ - {message: 'message', versionTag: 'versionTag', status: 'active', createdAt: '2021-01-01', createdBy: 1}, + { + createdAt: '2021-01-01', + createdBy: 1, + versionTag: 'versionTag', + status: 'active', + versionId: 'versionId', + message: 'message', + }, ]), ).toThrow() })