From 2ca5382be9f9b5719483cf7bc3263b5d370ffaba Mon Sep 17 00:00:00 2001 From: Donald Merand Date: Thu, 10 Sep 2026 16:37:25 -0400 Subject: [PATCH 1/3] 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/3] 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/3] 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,