Skip to content

Commit

Permalink
Rework app info to use components
Browse files Browse the repository at this point in the history
  • Loading branch information
amcaplan committed Jan 8, 2025
1 parent 3a87534 commit 4d43e17
Show file tree
Hide file tree
Showing 5 changed files with 179 additions and 99 deletions.
20 changes: 12 additions & 8 deletions packages/app/src/cli/commands/app/info.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {linkedAppContext} from '../../services/app-context.js'
import {Flags} from '@oclif/core'
import {globalFlags, jsonFlag} from '@shopify/cli-kit/node/cli'
import {outputInfo} from '@shopify/cli-kit/node/output'
import {renderInfo} from '@shopify/cli-kit/node/ui'

export default class AppInfo extends AppCommand {
static summary = 'Print basic information about your app and extensions.'
Expand Down Expand Up @@ -40,14 +41,17 @@ export default class AppInfo extends AppCommand {
userProvidedConfigName: flags.config,
unsafeReportMode: true,
})
outputInfo(
await info(app, remoteApp, {
format: (flags.json ? 'json' : 'text') as Format,
webEnv: flags['web-env'],
configName: flags.config,
developerPlatformClient,
}),
)
const results = await info(app, remoteApp, {
format: (flags.json ? 'json' : 'text') as Format,
webEnv: flags['web-env'],
configName: flags.config,
developerPlatformClient,
})
if (typeof results === 'string' || 'value' in results) {
outputInfo(results)
} else {
renderInfo({customSections: results})
}
if (app.errors) process.exit(2)

return {app}
Expand Down
207 changes: 119 additions & 88 deletions packages/app/src/cli/services/info.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,19 @@ import {configurationFileNames} from '../constants.js'
import {ExtensionInstance} from '../models/extensions/extension-instance.js'
import {OrganizationApp} from '../models/organization.js'
import {platformAndArch} from '@shopify/cli-kit/node/os'
import {linesToColumns} from '@shopify/cli-kit/common/string'
import {basename, relativePath} from '@shopify/cli-kit/node/path'
import {OutputMessage, outputContent, outputToken, formatSection, stringifyMessage} from '@shopify/cli-kit/node/output'
import {
OutputMessage,
formatPackageManagerCommand,
outputContent,
outputToken,
stringifyMessage,
} from '@shopify/cli-kit/node/output'
import {InlineToken, renderInfo} from '@shopify/cli-kit/node/ui'
import {CLI_KIT_VERSION} from '@shopify/cli-kit/common/version'

type CustomSection = Exclude<Parameters<typeof renderInfo>[0]['customSections'], undefined>[number]

export type Format = 'json' | 'text'
export interface InfoOptions {
format: Format
Expand All @@ -19,16 +27,12 @@ export interface InfoOptions {
webEnv: boolean
developerPlatformClient: DeveloperPlatformClient
}
interface Configurable {
type: string
externalType: string
}

export async function info(
app: AppLinkedInterface,
remoteApp: OrganizationApp,
options: InfoOptions,
): Promise<OutputMessage> {
): Promise<OutputMessage | CustomSection[]> {
if (options.webEnv) {
return infoWeb(app, remoteApp, options)
} else {
Expand All @@ -48,7 +52,7 @@ async function infoApp(
app: AppLinkedInterface,
remoteApp: OrganizationApp,
options: InfoOptions,
): Promise<OutputMessage> {
): Promise<OutputMessage | CustomSection[]> {
if (options.format === 'json') {
const extensionsInfo = withPurgedSchemas(app.allExtensions.filter((ext) => ext.isReturnedAsInfo()))
let appWithSupportedExtensions = {
Expand Down Expand Up @@ -117,139 +121,154 @@ class AppInfo {
this.options = options
}

async output(): Promise<string> {
const sections: [string, string][] = [
await this.devConfigsSection(),
async output(): Promise<CustomSection[]> {
return [
...(await this.devConfigsSection()),
this.projectSettingsSection(),
await this.appComponentsSection(),
...(await this.appComponentsSection()),
await this.systemInfoSection(),
]
return sections.map((sectionContents: [string, string]) => formatSection(...sectionContents)).join('\n\n')
}

async devConfigsSection(): Promise<[string, string]> {
const title = `Current app configuration`
const postscript = outputContent`💡 To change these, run ${outputToken.packagejsonScript(
this.app.packageManager,
'dev',
'--reset',
)}`.value

async devConfigsSection(): Promise<CustomSection[]> {
let updateUrls = NOT_CONFIGURED_TEXT
if (this.app.configuration.build?.automatically_update_urls_on_dev !== undefined) {
updateUrls = this.app.configuration.build.automatically_update_urls_on_dev ? 'Yes' : 'No'
}

let partnersAccountInfo = ['Partners account', 'unknown']
let partnersAccountInfo: [string, string] = ['Partners account', 'unknown']
const retrievedAccountInfo = await this.options.developerPlatformClient.accountInfo()
if (isServiceAccount(retrievedAccountInfo)) {
partnersAccountInfo = ['Service account', retrievedAccountInfo.orgName]
} else if (isUserAccount(retrievedAccountInfo)) {
partnersAccountInfo = ['Partners account', retrievedAccountInfo.email]
}

const lines = [
['Configuration file', basename(this.app.configuration.path) || configurationFileNames.app],
['App name', this.remoteApp.title || NOT_CONFIGURED_TEXT],
['Client ID', this.remoteApp.apiKey || NOT_CONFIGURED_TEXT],
['Access scopes', getAppScopes(this.app.configuration)],
['Dev store', this.app.configuration.build?.dev_store_url || NOT_CONFIGURED_TEXT],
['Update URLs', updateUrls],
partnersAccountInfo,
return [
this.tableSection(
'Current app configuration',
[
['Configuration file', basename(this.app.configuration.path) || configurationFileNames.app],
['App name', this.remoteApp.title || NOT_CONFIGURED_TEXT],
['Client ID', this.remoteApp.apiKey || NOT_CONFIGURED_TEXT],
['Access scopes', getAppScopes(this.app.configuration)],
['Dev store', this.app.configuration.build?.dev_store_url || NOT_CONFIGURED_TEXT],

Check warning on line 155 in packages/app/src/cli/services/info.ts

View workflow job for this annotation

GitHub Actions / ESLint Report Analysis

packages/app/src/cli/services/info.ts#L155

[@typescript-eslint/prefer-nullish-coalescing] Prefer using nullish coalescing operator (`??`) instead of a logical or (`||`), as it is a safer operator.
['Update URLs', updateUrls],
partnersAccountInfo,
],
{isFirstItem: true},
),
{
body: [
'💡 To change these, run',
{command: formatPackageManagerCommand(this.app.packageManager, 'dev', '--reset')},
],
},
]
return [title, `${linesToColumns(lines)}\n\n${postscript}`]
}

projectSettingsSection(): [string, string] {
const title = 'Your Project'
const lines = [['Root location', this.app.directory]]
return [title, linesToColumns(lines)]
projectSettingsSection(): CustomSection {
return this.tableSection('Your Project', [['Root location', {filePath: this.app.directory}]])
}

async appComponentsSection(): Promise<[string, string]> {
const title = 'Directory Components'

let body = this.webComponentsSection()

function augmentWithExtensions<TExtension extends Configurable>(
extensions: TExtension[],
outputFormatter: (extension: TExtension) => string,
) {
const types = new Set(extensions.map((ext) => ext.type))
types.forEach((extensionType: string) => {
const relevantExtensions = extensions.filter((extension: TExtension) => extension.type === extensionType)
if (relevantExtensions[0]) {
body += `\n\n${outputContent`${outputToken.subheading(relevantExtensions[0].externalType)}`.value}`
relevantExtensions.forEach((extension: TExtension) => {
body += outputFormatter(extension)
})
}
})
}
async appComponentsSection(): Promise<CustomSection[]> {
const webComponentsSection = this.webComponentsSection()

const supportedExtensions = this.app.allExtensions.filter((ext) => ext.isReturnedAsInfo())
augmentWithExtensions(supportedExtensions, this.extensionSubSection.bind(this))
const extensionsSections = this.extensionsSections(supportedExtensions)

let errorsSection: CustomSection | undefined
if (this.app.errors?.isEmpty() === false) {
body += `\n\n${outputContent`${outputToken.subheading('Extensions with errors')}`.value}`
supportedExtensions.forEach((extension) => {
body += this.invalidExtensionSubSection(extension)
})
errorsSection = this.tableSection(
'Extensions with errors',
(
supportedExtensions
.map((extension) => this.invalidExtensionSubSection(extension))
.filter((data) => typeof data !== 'undefined') as [string, InlineToken][][]
).flat(),
)
}
return [title, body]

return [
{
title: '\nDirectory components'.toUpperCase(),
body: '',
},
...(webComponentsSection ? [webComponentsSection] : []),
...extensionsSections,
...(errorsSection ? [errorsSection] : []),
]
}

webComponentsSection(): string {
webComponentsSection(): CustomSection | undefined {
const errors: OutputMessage[] = []
const subtitle = outputContent`${outputToken.subheading('web')}`.value
const toplevel = ['📂 web', '']
const sublevels: [string, string][] = []
const sublevels: [string, InlineToken][] = []
if (!this.app.webs[0]) return
this.app.webs.forEach((web) => {
if (web.configuration) {
if (web.configuration.name) {
const {name, roles} = web.configuration
sublevels.push([` 📂 ${name} (${roles.join(',')})`, relativePath(this.app.directory, web.directory)])
sublevels.push([
` 📂 ${name} (${roles.join(',')})`,
{filePath: relativePath(this.app.directory, web.directory)},
])
} else {
web.configuration.roles.forEach((role) => {
sublevels.push([` 📂 ${role}`, relativePath(this.app.directory, web.directory)])
sublevels.push([` 📂 ${role}`, {filePath: relativePath(this.app.directory, web.directory)}])
})
}
} else {
sublevels.push([` 📂 ${UNKNOWN_TEXT}`, relativePath(this.app.directory, web.directory)])
sublevels.push([` 📂 ${UNKNOWN_TEXT}`, {filePath: relativePath(this.app.directory, web.directory)}])
}
if (this.app.errors) {
const error = this.app.errors.getError(`${web.directory}/${configurationFileNames.web}`)
if (error) errors.push(error)
}
})
let errorContent = `\n${errors.map((error) => this.formattedError(error)).join('\n')}`
if (errorContent.trim() === '') errorContent = ''

return `${subtitle}\n${linesToColumns([toplevel, ...sublevels])}${errorContent}`
return this.subtableSection('web', [
['📂 web', ''],
...sublevels,
...errors.map((error): [string, InlineToken] => ['', {error: this.formattedError(error)}]),
])
}

extensionSubSection(extension: ExtensionInstance): string {
extensionsSections(extensions: ExtensionInstance[]): CustomSection[] {
const types = Array.from(new Set(extensions.map((ext) => ext.type)))
return types
.map((extensionType: string): CustomSection | undefined => {
const relevantExtensions = extensions.filter((extension: ExtensionInstance) => extension.type === extensionType)
if (relevantExtensions[0]) {
return this.subtableSection(
relevantExtensions[0].externalType,
relevantExtensions.map((ext) => this.extensionSubSection(ext)).flat(),
)
}
})
.filter((section: CustomSection | undefined) => section !== undefined) as CustomSection[]
}

extensionSubSection(extension: ExtensionInstance): [string, InlineToken][] {
const config = extension.configuration
const details = [
[`📂 ${extension.handle}`, relativePath(this.app.directory, extension.directory)],
[' config file', relativePath(extension.directory, extension.configurationPath)],
const details: [string, InlineToken][] = [
[`📂 ${extension.handle}`, {filePath: relativePath(this.app.directory, extension.directory)}],
[' config file', {filePath: relativePath(extension.directory, extension.configurationPath)}],
]
if (config && config.metafields?.length) {
details.push([' metafields', `${config.metafields.length}`])
}

return `\n${linesToColumns(details)}`
return details
}

invalidExtensionSubSection(extension: ExtensionInstance): string {
invalidExtensionSubSection(extension: ExtensionInstance): [string, InlineToken][] | undefined {
const error = this.app.errors?.getError(extension.configurationPath)
if (!error) return ''
const details = [
[`📂 ${extension.handle}`, relativePath(this.app.directory, extension.directory)],
[' config file', relativePath(extension.directory, extension.configurationPath)],
if (!error) return
return [
[`📂 ${extension.handle}`, {filePath: relativePath(this.app.directory, extension.directory)}],
[' config file', {filePath: relativePath(extension.directory, extension.configurationPath)}],
[' message', {error: this.formattedError(error)}],
]
const formattedError = this.formattedError(error)
return `\n${linesToColumns(details)}\n${formattedError}`
}

formattedError(str: OutputMessage): string {
Expand All @@ -258,16 +277,28 @@ class AppInfo {
return outputContent`${outputToken.errorText(errorLines.join('\n'))}`.value
}

async systemInfoSection(): Promise<[string, string]> {
const title = 'Tooling and System'
async systemInfoSection(): Promise<CustomSection> {
const {platform, arch} = platformAndArch()
const lines: string[][] = [
return this.tableSection('Tooling and System', [
['Shopify CLI', CLI_KIT_VERSION],
['Package manager', this.app.packageManager],
['OS', `${platform}-${arch}`],
['Shell', process.env.SHELL || 'unknown'],

Check warning on line 286 in packages/app/src/cli/services/info.ts

View workflow job for this annotation

GitHub Actions / ESLint Report Analysis

packages/app/src/cli/services/info.ts#L286

[@typescript-eslint/prefer-nullish-coalescing] Prefer using nullish coalescing operator (`??`) instead of a logical or (`||`), as it is a safer operator.
['Node version', process.version],
]
return [title, linesToColumns(lines)]
])
}

tableSection(title: string, rows: [string, InlineToken][], {isFirstItem = false} = {}): CustomSection {
return {
title: `${isFirstItem ? '' : '\n'}${title.toUpperCase()}\n`,
body: {tabularData: rows, firstColumnSubdued: true},
}
}

subtableSection(title: string, rows: [string, InlineToken][]): CustomSection {
return {
title,
body: {tabularData: rows, firstColumnSubdued: true},
}
}
}
9 changes: 7 additions & 2 deletions packages/cli-kit/src/private/node/ui/components/Alert.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,13 @@ import {Banner, BannerType} from './Banner.js'
import {Link} from './Link.js'
import {List} from './List.js'
import {BoldToken, InlineToken, LinkToken, TokenItem, TokenizedText} from './TokenizedText.js'
import {TabularData, TabularDataProps} from './TabularData.js'
import {Box, Text} from 'ink'
import React, {FunctionComponent} from 'react'

export interface CustomSection {
title?: string
body: TokenItem
body: TabularDataProps | TokenItem
}

export interface AlertProps {
Expand Down Expand Up @@ -57,7 +58,11 @@ const Alert: FunctionComponent<AlertProps> = ({
{customSections.map((section, index) => (
<Box key={index} flexDirection="column">
{section.title ? <Text bold>{section.title}</Text> : null}
<TokenizedText item={section.body} />
{typeof section.body === 'object' && 'tabularData' in section.body ? (
<TabularData {...section.body} />
) : (
<TokenizedText item={section.body} />
)}
</Box>
))}
</Box>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import {Banner} from './Banner.js'
import {TokenizedText} from './TokenizedText.js'
import {Command} from './Command.js'
import {List} from './List.js'
import {TabularData} from './TabularData.js'
import {BugError, cleanSingleStackTracePath, ExternalError, FatalError as Fatal} from '../../../../public/node/error.js'
import {Box, Text} from 'ink'
import React, {FunctionComponent} from 'react'
Expand Down Expand Up @@ -58,7 +59,11 @@ const FatalError: FunctionComponent<FatalErrorProps> = ({error}) => {
{error.customSections.map((section, index) => (
<Box key={index} flexDirection="column">
{section.title ? <Text bold>{section.title}</Text> : null}
<TokenizedText item={section.body} />
{typeof section.body === 'object' && 'tabularData' in section.body ? (
<TabularData {...section.body} />
) : (
<TokenizedText item={section.body} />
)}
</Box>
))}
</Box>
Expand Down
Loading

0 comments on commit 4d43e17

Please sign in to comment.