diff --git a/.changeset/app-versions-list-json-schema.md b/.changeset/app-versions-list-json-schema.md new file mode 100644 index 00000000000..f6aa8961dbd --- /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` and clarify its missing-app error. 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..9178562f310 --- /dev/null +++ b/packages/app/src/cli/commands/app/versions/list.test.ts @@ -0,0 +1,216 @@ +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' +// eslint-disable-next-line n/prefer-global/console +import {Console} from 'node:console' + +vi.mock('../../../services/app-context.js') + +const organization: Organization = { + id: 'org-id', + businessName: 'name of org 1', + source: OrganizationSource.BusinessPlatform, +} + +const originalUnitTestEnvironment = process.env.SHOPIFY_UNIT_TEST + +afterEach(() => { + if (originalUnitTestEnvironment === undefined) { + delete process.env.SHOPIFY_UNIT_TEST + } 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) + // Vitest intercepts console.warn; use Node's console to exercise the captured streams. + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(new Console(process.stdout, process.stderr).warn) + + return { + stdout: () => stdout.join(''), + stderr: () => stderr.join(''), + restore: () => { + warnSpy.mockRestore() + 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('writes the encoded JSON result to stdout with empty stderr', async () => { + process.env.SHOPIFY_UNIT_TEST = 'false' + vi.resetModules() + const streams = captureStandardStreams() + + try { + await runCommand( + appVersionsResponse( + [ + { + 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}, + }, + ], + 31, + ), + ['--json'], + ) + } finally { + streams.restore() + } + + const expected = [ + { + createdAt: '2021-01-01 00:00:00', + createdBy: 'createdBy', + versionTag: 'versionTag', + status: 'active', + versionId: 'gid://shopify/Version/1', + message: 'message', + }, + { + createdAt: '2021-01-02 00:00:00', + createdBy: '', + versionTag: null, + status: 'released', + versionId: 'gid://shopify/Version/2', + message: '', + }, + ] + const expectedStdout = `[ + { + "createdAt": "2021-01-01 00:00:00", + "createdBy": "createdBy", + "versionTag": "versionTag", + "status": "active", + "versionId": "gid://shopify/Version/1", + "message": "message" + }, + { + "createdAt": "2021-01-02 00:00:00", + "createdBy": "", + "versionTag": null, + "status": "released", + "versionId": "gid://shopify/Version/2", + "message": "" + } +] +` + + expect(streams.stdout()).toBe(expectedStdout) + 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('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 f06f42d2432..5f39b05a436 100644 --- a/packages/app/src/cli/commands/app/versions/list.ts +++ b/packages/app/src/cli/commands/app/versions/list.ts @@ -1,5 +1,7 @@ import {appFlags} from '../../../flags.js' -import versionList 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' @@ -9,6 +11,10 @@ export default class VersionsList extends AppLinkedCommand { 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,18 @@ export default class VersionsList extends AppLinkedCommand { userProvidedConfigName: flags.config, }) - await versionList({ - app, - remoteApp, - organization, - developerPlatformClient, - json: flags.json, - }) + const result = await getAppVersions(developerPlatformClient, remoteApp) + + 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..4ea618954da 100644 --- a/packages/app/src/cli/services/versions-list.test.ts +++ b/packages/app/src/cli/services/versions-list.test.ts @@ -1,219 +1,135 @@ -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 {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 {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({}) - - // When - await versionList({ - app, - remoteApp, - organization: ORG1, - developerPlatformClient: buildDeveloperPlatformClient(), - json: false, - }) + const result = await getAppVersions(developerPlatformClient, remoteApp) - // 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: [ + { + createdAt: '2021-01-01 00:00:00', + createdBy: 'createdBy', + versionTag: 'versionTag', + status: 'active', + versionId: 'gid://shopify/Version/1', + message: 'message', }, - 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}, + { + createdAt: '2021-01-02 00:00:00', + createdBy: '', + versionTag: null, + status: 'released', + versionId: 'gid://shopify/Version/2', + message: '', }, - organizationId: 'orgId', - }, - } - const developerPlatformClient: DeveloperPlatformClient = testDeveloperPlatformClient({ - appVersions: (_appId) => Promise.resolve(appVersionsResult), - }) - - // When - await versionList({ - app, - remoteApp, - json: true, - developerPlatformClient, - organization: ORG1, + { + createdAt: '2021-01-03 00:00:00', + createdBy: '', + status: 'released', + versionId: 'gid://shopify/Version/3', + message: '', + }, + ], + totalResults: 31, }) - // Then - expect(mockOutput.info()).toMatchInlineSnapshot(` + expect(appVersionsListJsonOutputSchema.encode(result.appVersions)).toMatchInlineSnapshot(` "[ { - "message": "message", + "createdAt": "2021-01-01 00:00:00", + "createdBy": "createdBy", "versionTag": "versionTag", "status": "active", - "createdAt": "2021-01-01 00:00:00", - "createdBy": "createdBy" + "versionId": "gid://shopify/Version/1", + "message": "message" }, { - "message": "long message with more than 15 characters", - "versionTag": "versionTag 3", + "createdAt": "2021-01-02 00:00:00", + "createdBy": "", + "versionTag": null, "status": "released", - "createdAt": "2021-01-01 00:00:00", - "createdBy": "createdBy 3" + "versionId": "gid://shopify/Version/2", + "message": "" + }, + { + "createdAt": "2021-01-03 00:00:00", + "createdBy": "", + "status": "released", + "versionId": "gid://shopify/Version/3", + "message": "" } ]" `) }) + + 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)).rejects.toThrow( + 'Shopify did not return app information for API key api-key.', + ) + }) + + test('rejects invalid result values', () => { + expect(() => + appVersionsListJsonOutputSchema.validate([ + { + createdAt: '2021-01-01', + createdBy: 1, + versionTag: 'versionTag', + status: 'active', + versionId: 'versionId', + message: 'message', + }, + ]), + ).toThrow() + }) }) diff --git a/packages/app/src/cli/services/versions-list.ts b/packages/app/src/cli/services/versions-list.ts index 6a839b3ee27..17936817696 100644 --- a/packages/app/src/cli/services/versions-list.ts +++ b/packages/app/src/cli/services/versions-list.ts @@ -1,128 +1,33 @@ -import {renderCurrentlyUsedConfigInfo} from './context.js' +import {type AppVersionsListResult} from './versions-list/types.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 +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) { + throw new AbortError(`Shopify did not return app information for API key ${app.apiKey}.`) } 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) => ({ + 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, } - - 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..4443a7591ab --- /dev/null +++ b/packages/app/src/cli/services/versions-list/result.ts @@ -0,0 +1,95 @@ +import {appVersionsListJsonOutputSchema, type AppVersionsListResult} from './types.js' +import {renderCurrentlyUsedConfigInfo} from '../context.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/app/src/cli/services/versions-list/types.ts b/packages/app/src/cli/services/versions-list/types.ts new file mode 100644 index 00000000000..3011c8e8803 --- /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({ + createdAt: zod.string(), + createdBy: zod.string(), + versionTag: zod.string().nullable().optional(), + status: zod.string(), + versionId: zod.string(), + message: zod.string(), +}) + +export const appVersionsListJsonOutputSchema = defineJsonOutputSchema({ + name: 'AppVersionsListResult', + schema: zod.array(appVersionJsonOutputSchema), + definitions: {AppVersion: appVersionJsonOutputSchema}, +}) + +export type AppVersionsListResult = InferJsonOutputSchema diff --git a/packages/app/src/cli/utilities/developer-platform-client/app-management-client.test.ts b/packages/app/src/cli/utilities/developer-platform-client/app-management-client.test.ts index 3eea71b1ac9..b22a7eb26b1 100644 --- a/packages/app/src/cli/utilities/developer-platform-client/app-management-client.test.ts +++ b/packages/app/src/cli/utilities/developer-platform-client/app-management-client.test.ts @@ -1335,6 +1335,26 @@ describe('deploy', () => { }) }) +describe('appVersions', () => { + test('preserves a missing app in the API response instead of dereferencing it', async () => { + // Given + const client = AppManagementClient.getInstance() + client.token = () => Promise.resolve('token') + vi.mocked(appManagementRequestDoc).mockResolvedValueOnce({app: null}) + + // When + const result: AppVersionsQuerySchema = await client.appVersions({ + apiKey: 'api-key', + organizationId: 'gid://shopify/Organization/123', + id: 'gid://shopify/App/123', + title: 'Test App', + }) + + // Then + expect(result).toEqual({app: null}) + }) +}) + describe('AppManagementClient', () => { describe('generateSignedUploadUrl', () => { test('passes Brotli format for uploads and scopes the cache key to the app and command run', async () => { diff --git a/packages/app/src/cli/utilities/developer-platform-client/app-management-client.ts b/packages/app/src/cli/utilities/developer-platform-client/app-management-client.ts index ec44f4ded5b..05ef012b4a4 100644 --- a/packages/app/src/cli/utilities/developer-platform-client/app-management-client.ts +++ b/packages/app/src/cli/utilities/developer-platform-client/app-management-client.ts @@ -630,6 +630,9 @@ export class AppManagementClient implements DeveloperPlatformClient { const query = AppVersions const variables = {appId: id} const result = await this.appManagementRequest({query, variables}) + if (!result.app) { + return {app: null} + } return { app: { id: result.app.id, diff --git a/packages/cli/README.md b/packages/cli/README.md index ecea5aaef2e..4d1f31bebff 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -1979,6 +1979,57 @@ 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 result, error, and event schemas. + + ```json + { + "type": "array", + "items": { + "$ref": "#/definitions/AppVersion" + }, + "title": "AppVersionsListResult", + "definitions": { + "AppVersion": { + "type": "object", + "properties": { + "createdAt": { + "type": "string" + }, + "createdBy": { + "type": "string" + }, + "versionTag": { + "type": [ + "string", + "null" + ] + }, + "status": { + "type": "string" + }, + "versionId": { + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "createdAt", + "createdBy", + "status", + "versionId", + "message" + ], + "additionalProperties": false + } + }, + "$schema": "http://json-schema.org/draft-07/schema#" + } + ``` ``` ## `shopify app webhook trigger` diff --git a/packages/cli/oclif.manifest.json b/packages/cli/oclif.manifest.json index 3121725eac6..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.", + "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": { 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',