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/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 new file mode 100644 index 00000000000..53cd035c574 --- /dev/null +++ b/packages/app/src/cli/commands/app/versions/list.test.ts @@ -0,0 +1,111 @@ +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') +vi.mock('../../../services/versions-list/result.js') + +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('passes the typed result to the JSON output boundary', 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', + versionId: 'gid://shopify/Version/1', + status: 'active', + createdAt: '2021-01-01', + createdBy: {displayName: 'createdBy'}, + }, + ], + pageInfo: {totalResults: 1}, + }, + }, + }), + }) + 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( + { + 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, + }, + 'json', + ) + }) + + 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..ce681b9cf7c 100644 --- a/packages/app/src/cli/commands/app/versions/list.ts +++ b/packages/app/src/cli/commands/app/versions/list.ts @@ -1,14 +1,20 @@ import {appFlags} from '../../../flags.js' -import versionList from '../../../services/versions-list.js' +import {appVersionsListJsonOutputSchema, 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.' 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 +33,19 @@ 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}`) + + await renderAppVersionsListResult( + { + app, + remoteApp, + organization, + developerPlatformClient, + ...result, + }, + flags.json ? 'json' : 'text', + ) 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..a0730838e87 100644 --- a/packages/app/src/cli/services/versions-list.test.ts +++ b/packages/app/src/cli/services/versions-list.test.ts @@ -1,219 +1,126 @@ -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', + versionId: 'gid://shopify/Version/1', + status: 'active', + createdAt: '2021-01-01', + createdBy: {displayName: 'createdBy'}, + }, + { + 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', + }, + ], + 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', + versionId: 'gid://shopify/Version/1', }, - 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: '', + versionId: 'gid://shopify/Version/2', }, - 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: '', + versionId: 'gid://shopify/Version/3', + }, + ], + totalResults: 31, }) + if (!result) throw new Error('Expected app versions result') - // Then - expect(mockOutput.info()).toMatchInlineSnapshot(` + expect(appVersionsListJsonOutputSchema.encode(result.appVersions)).toMatchInlineSnapshot(` "[ { "message": "message", "versionTag": "versionTag", "status": "active", "createdAt": "2021-01-01 00:00:00", - "createdBy": "createdBy" + "createdBy": "createdBy", + "versionId": "gid://shopify/Version/1" }, { - "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": "", + "versionId": "gid://shopify/Version/2" + }, + { + "message": "", + "status": "released", + "createdAt": "2021-01-03 00:00:00", + "createdBy": "", + "versionId": "gid://shopify/Version/3" } ]" `) }) + + 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..ec337893a18 100644 --- a/packages/app/src/cli/services/versions-list.ts +++ b/packages/app/src/cli/services/versions-list.ts @@ -1,128 +1,48 @@ -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(), + versionId: zod.string(), +}) + +export const appVersionsListJsonOutputSchema = defineJsonOutputSchema({ + name: 'AppVersionsListResult', + schema: zod.array(appVersionJsonOutputSchema), + definitions: {AppVersion: appVersionJsonOutputSchema}, +}) + +export type AppVersionsListResult = InferJsonOutputSchema + +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 ?? '', + versionId: appVersion.versionId, + })), + 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/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/result.ts b/packages/app/src/cli/services/versions-list/result.ts new file mode 100644 index 00000000000..b07e5796cad --- /dev/null +++ b/packages/app/src/cli/services/versions-list/result.ts @@ -0,0 +1,95 @@ +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' +import colors from '@shopify/cli-kit/node/colors' +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 + totalResults: number + remoteApp: OrganizationApp + organization: Organization + developerPlatformClient: DeveloperPlatformClient +} + +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, + configFile: basename(app.configPath), + }) + + if (versionResults.length === 0) { + outputInfo('No app versions found for this app') + return + } + + const appVersions = versionResults.map(({versionId: _, ...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..86ccc96fa59 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -1979,6 +1979,23 @@ 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 + versionId: string + } + ``` ``` ## `shopify app webhook trigger` diff --git a/packages/cli/oclif.manifest.json b/packages/cli/oclif.manifest.json index 3121725eac6..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.", + "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": { 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',