diff --git a/.changeset/faster-cli-startup.md b/.changeset/faster-cli-startup.md new file mode 100644 index 00000000000..9a9587aab23 --- /dev/null +++ b/.changeset/faster-cli-startup.md @@ -0,0 +1,5 @@ +--- +'@shopify/cli-kit': patch +--- + +Speed up CLI startup by keeping the terminal UI runtime out of the boot path diff --git a/packages/cli-kit/src/private/node/ui/components/Alert.tsx b/packages/cli-kit/src/private/node/ui/components/Alert.tsx index fc573cd55dc..449fb0ce7cc 100644 --- a/packages/cli-kit/src/private/node/ui/components/Alert.tsx +++ b/packages/cli-kit/src/private/node/ui/components/Alert.tsx @@ -1,10 +1,11 @@ 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 {TokenizedText} from './TokenizedText.js' import {TabularData, TabularDataProps} from './TabularData.js' import {Box, Text} from 'ink' import React, {FunctionComponent} from 'react' +import type {BoldToken, InlineToken, LinkToken, TokenItem} from './token-item.js' export interface CustomSection { title?: string diff --git a/packages/cli-kit/src/private/node/ui/components/DangerousConfirmationPrompt.tsx b/packages/cli-kit/src/private/node/ui/components/DangerousConfirmationPrompt.tsx index 2a31e5756ad..dfeda4abbe5 100644 --- a/packages/cli-kit/src/private/node/ui/components/DangerousConfirmationPrompt.tsx +++ b/packages/cli-kit/src/private/node/ui/components/DangerousConfirmationPrompt.tsx @@ -1,5 +1,5 @@ import {TextInput} from './TextInput.js' -import {InlineToken, TokenItem, TokenizedText} from './TokenizedText.js' +import {TokenizedText} from './TokenizedText.js' import {InfoTable, InfoTableProps} from './Prompts/InfoTable.js' import {handleCtrlC, useComplete} from '../../ui.js' import useLayout from '../hooks/use-layout.js' @@ -11,6 +11,7 @@ import usePrompt, {PromptState} from '../hooks/use-prompt.js' import React, {FunctionComponent, useCallback, useEffect, useState} from 'react' import {Box, useInput, Text} from 'ink' import figures from 'figures' +import type {InlineToken, TokenItem} from './token-item.js' export interface DangerousConfirmationPromptProps { message: string diff --git a/packages/cli-kit/src/private/node/ui/components/List.tsx b/packages/cli-kit/src/private/node/ui/components/List.tsx index 2fa9931e105..904715ca1c5 100644 --- a/packages/cli-kit/src/private/node/ui/components/List.tsx +++ b/packages/cli-kit/src/private/node/ui/components/List.tsx @@ -1,6 +1,7 @@ -import {InlineToken, TokenItem, TokenizedText} from './TokenizedText.js' +import {TokenizedText} from './TokenizedText.js' import {Box, Text, TextProps} from 'ink' import React, {FunctionComponent} from 'react' +import type {InlineToken, TokenItem} from './token-item.js' export interface CustomListItem { type?: string diff --git a/packages/cli-kit/src/private/node/ui/components/Prompts/InfoMessage.tsx b/packages/cli-kit/src/private/node/ui/components/Prompts/InfoMessage.tsx index ba1cf4226d2..e91519818c8 100644 --- a/packages/cli-kit/src/private/node/ui/components/Prompts/InfoMessage.tsx +++ b/packages/cli-kit/src/private/node/ui/components/Prompts/InfoMessage.tsx @@ -1,6 +1,7 @@ -import {InlineToken, LinkToken, TokenItem, TokenizedText, UserInputToken} from '../TokenizedText.js' +import {TokenizedText} from '../TokenizedText.js' import {Box, Text, TextProps} from 'ink' import React, {FunctionComponent} from 'react' +import type {InlineToken, LinkToken, TokenItem, UserInputToken} from '../token-item.js' export interface InfoMessageProps { message: { diff --git a/packages/cli-kit/src/private/node/ui/components/Prompts/InfoTable.tsx b/packages/cli-kit/src/private/node/ui/components/Prompts/InfoTable.tsx index 57d79396625..125c9b46d58 100644 --- a/packages/cli-kit/src/private/node/ui/components/Prompts/InfoTable.tsx +++ b/packages/cli-kit/src/private/node/ui/components/Prompts/InfoTable.tsx @@ -1,8 +1,9 @@ import {CustomListItem, List} from '../List.js' import {capitalize} from '../../../../../public/common/string.js' -import {InlineToken, TokenItem, TokenizedText} from '../TokenizedText.js' +import {TokenizedText} from '../TokenizedText.js' import {Box, Text, TextProps} from 'ink' import React, {FunctionComponent} from 'react' +import type {InlineToken, TokenItem} from '../token-item.js' type Items = (TokenItem | CustomListItem)[] diff --git a/packages/cli-kit/src/private/node/ui/components/Prompts/PromptLayout.tsx b/packages/cli-kit/src/private/node/ui/components/Prompts/PromptLayout.tsx index c7a461681ff..fe035922d80 100644 --- a/packages/cli-kit/src/private/node/ui/components/Prompts/PromptLayout.tsx +++ b/packages/cli-kit/src/private/node/ui/components/Prompts/PromptLayout.tsx @@ -1,6 +1,6 @@ import {InfoTable, InfoTableProps} from './InfoTable.js' import {InfoMessage, InfoMessageProps} from './InfoMessage.js' -import {InlineToken, LinkToken, TokenItem, TokenizedText} from '../TokenizedText.js' +import {TokenizedText} from '../TokenizedText.js' import {messageWithPunctuation} from '../../utilities.js' import {AbortSignal} from '../../../../../public/node/abort.js' import useAbortSignal from '../../hooks/use-abort-signal.js' @@ -9,6 +9,7 @@ import React, {ReactElement, cloneElement, useCallback, useLayoutEffect, useStat import {Box, measureElement, Text, useStdout, DOMElement} from 'ink' import figures from 'figures' +import type {InlineToken, LinkToken, TokenItem} from '../token-item.js' export type Message = TokenItem> diff --git a/packages/cli-kit/src/private/node/ui/components/TabularData.tsx b/packages/cli-kit/src/private/node/ui/components/TabularData.tsx index a228308af54..ecad232db79 100644 --- a/packages/cli-kit/src/private/node/ui/components/TabularData.tsx +++ b/packages/cli-kit/src/private/node/ui/components/TabularData.tsx @@ -1,4 +1,5 @@ -import {InlineToken, TokenizedText, tokenItemToString} from './TokenizedText.js' +import {TokenizedText} from './TokenizedText.js' +import {tokenItemToString, type InlineToken} from './token-item.js' import {unstyled} from '../../../../public/node/output.js' import {Box} from 'ink' import React, {FunctionComponent} from 'react' diff --git a/packages/cli-kit/src/private/node/ui/components/TextPrompt.tsx b/packages/cli-kit/src/private/node/ui/components/TextPrompt.tsx index 6e398a324a9..37e24221c94 100644 --- a/packages/cli-kit/src/private/node/ui/components/TextPrompt.tsx +++ b/packages/cli-kit/src/private/node/ui/components/TextPrompt.tsx @@ -1,4 +1,4 @@ -import {InlineToken, TokenItem, TokenizedText} from './TokenizedText.js' +import {TokenizedText} from './TokenizedText.js' import {TextInput} from './TextInput.js' import {handleCtrlC, useComplete} from '../../ui.js' import useLayout from '../hooks/use-layout.js' @@ -10,6 +10,7 @@ import React, {FunctionComponent, useCallback, useEffect, useState} from 'react' import {Box, useInput, Text} from 'ink' import figures from 'figures' +import type {InlineToken, TokenItem} from './token-item.js' export interface TextPromptProps { message: TokenItem diff --git a/packages/cli-kit/src/private/node/ui/components/TokenizedText.test.tsx b/packages/cli-kit/src/private/node/ui/components/TokenizedText.test.tsx index f81b38161da..562c50fef26 100644 --- a/packages/cli-kit/src/private/node/ui/components/TokenizedText.test.tsx +++ b/packages/cli-kit/src/private/node/ui/components/TokenizedText.test.tsx @@ -1,4 +1,5 @@ -import {tokenItemToString, TokenizedText} from './TokenizedText.js' +import {TokenizedText} from './TokenizedText.js' +import {tokenItemToString} from './token-item.js' import {LinksContext, Link} from '../contexts/LinksContext.js' import {unstyled} from '../../../../public/node/output.js' import {render} from '../../testing/ui.js' diff --git a/packages/cli-kit/src/private/node/ui/components/TokenizedText.tsx b/packages/cli-kit/src/private/node/ui/components/TokenizedText.tsx index 6b3a05562a0..f9bfbaa1a3f 100644 --- a/packages/cli-kit/src/private/node/ui/components/TokenizedText.tsx +++ b/packages/cli-kit/src/private/node/ui/components/TokenizedText.tsx @@ -8,59 +8,7 @@ import {LinksContext} from '../contexts/LinksContext.js' import React, {FunctionComponent, useContext} from 'react' import {Box, Text} from 'ink' -export interface LinkToken { - link: { - label?: string - url: string - } -} - -export interface UserInputToken { - userInput: string -} - -export interface ListToken { - list: { - title?: TokenItem - items: TokenItem[] - ordered?: boolean - } -} - -export interface BoldToken { - bold: string -} - -export type Token = - | string - | { - command: string - } - | LinkToken - | { - char: string - } - | UserInputToken - | { - subdued: string - } - | { - filePath: string - } - | ListToken - | BoldToken - | { - info: string - } - | { - warn: string - } - | { - error: string - } - -export type InlineToken = Exclude -export type TokenItem = T | T[] +import type {ListToken, Token, TokenItem} from './token-item.js' type DisplayType = 'block' | 'inline' interface Block { @@ -75,49 +23,6 @@ function tokenToBlock(token: Token): Block { } } -export function tokenItemToString(token: TokenItem): string { - if (typeof token === 'string') { - return token - } else if ('command' in token) { - return token.command - } else if ('link' in token) { - // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing -- empty label should fall through to url - return token.link.label || token.link.url - } else if ('char' in token) { - return token.char - } else if ('userInput' in token) { - return token.userInput - } else if ('subdued' in token) { - return token.subdued - } else if ('filePath' in token) { - return token.filePath - } else if ('list' in token) { - return token.list.items.map(tokenItemToString).join(' ') - } else if ('bold' in token) { - return token.bold - } else if ('info' in token) { - return token.info - } else if ('warn' in token) { - return token.warn - } else if ('error' in token) { - return token.error - } else { - return token - .map((item, index) => { - if (index !== 0 && !(typeof item !== 'string' && 'char' in item)) { - return ` ${tokenItemToString(item)}` - } else { - return tokenItemToString(item) - } - }) - .join('') - } -} - -export function appendToTokenItem(token: TokenItem, suffix: string): TokenItem { - return Array.isArray(token) ? [...token, {char: suffix}] : [token, {char: suffix}] -} - function splitByDisplayType(acc: Block[][], item: Block) { if (item.display === 'block') { acc.push([item]) diff --git a/packages/cli-kit/src/private/node/ui/components/token-item.ts b/packages/cli-kit/src/private/node/ui/components/token-item.ts new file mode 100644 index 00000000000..2186e160e01 --- /dev/null +++ b/packages/cli-kit/src/private/node/ui/components/token-item.ts @@ -0,0 +1,105 @@ +// Token shapes and the pure helpers that operate on them. +// +// These live apart from `TokenizedText.tsx` on purpose. That file is a React +// component module: importing it pulls in `react` and `ink`, and through Ink, +// `react-reconciler`, `yoga-layout` and its WebAssembly. Non-rendering modules +// such as `output.ts`, `error.ts` and `common/string.ts` need only the types +// and `tokenItemToString`, and importing them from here keeps the terminal UI +// runtime out of the startup path of every command. + +export interface LinkToken { + link: { + label?: string + url: string + } +} + +export interface UserInputToken { + userInput: string +} + +export interface ListToken { + list: { + title?: TokenItem + items: TokenItem[] + ordered?: boolean + } +} + +export interface BoldToken { + bold: string +} + +export type Token = + | string + | { + command: string + } + | LinkToken + | { + char: string + } + | UserInputToken + | { + subdued: string + } + | { + filePath: string + } + | ListToken + | BoldToken + | { + info: string + } + | { + warn: string + } + | { + error: string + } + +export type InlineToken = Exclude +export type TokenItem = T | T[] + +export function tokenItemToString(token: TokenItem): string { + if (typeof token === 'string') { + return token + } else if ('command' in token) { + return token.command + } else if ('link' in token) { + // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing -- empty label should fall through to url + return token.link.label || token.link.url + } else if ('char' in token) { + return token.char + } else if ('userInput' in token) { + return token.userInput + } else if ('subdued' in token) { + return token.subdued + } else if ('filePath' in token) { + return token.filePath + } else if ('list' in token) { + return token.list.items.map(tokenItemToString).join(' ') + } else if ('bold' in token) { + return token.bold + } else if ('info' in token) { + return token.info + } else if ('warn' in token) { + return token.warn + } else if ('error' in token) { + return token.error + } else { + return token + .map((item, index) => { + if (index !== 0 && !(typeof item !== 'string' && 'char' in item)) { + return ` ${tokenItemToString(item)}` + } else { + return tokenItemToString(item) + } + }) + .join('') + } +} + +export function appendToTokenItem(token: TokenItem, suffix: string): TokenItem { + return Array.isArray(token) ? [...token, {char: suffix}] : [token, {char: suffix}] +} diff --git a/packages/cli-kit/src/private/node/ui/utilities.test.ts b/packages/cli-kit/src/private/node/ui/utilities.test.ts index f0ca748548a..369c73d0229 100644 --- a/packages/cli-kit/src/private/node/ui/utilities.test.ts +++ b/packages/cli-kit/src/private/node/ui/utilities.test.ts @@ -1,5 +1,5 @@ import {messageWithPunctuation} from './utilities.js' -import {tokenItemToString} from './components/TokenizedText.js' +import {tokenItemToString} from './components/token-item.js' import {describe, expect, test} from 'vitest' describe('messageWithPunctuation', async () => { diff --git a/packages/cli-kit/src/private/node/ui/utilities.ts b/packages/cli-kit/src/private/node/ui/utilities.ts index a12c1aca290..bd32808e6b8 100644 --- a/packages/cli-kit/src/private/node/ui/utilities.ts +++ b/packages/cli-kit/src/private/node/ui/utilities.ts @@ -1,4 +1,4 @@ -import {appendToTokenItem, TokenItem, tokenItemToString} from './components/TokenizedText.js' +import {appendToTokenItem, tokenItemToString, type TokenItem} from './components/token-item.js' export function messageWithPunctuation(message: TokenItem) { const messageToString = tokenItemToString(message) diff --git a/packages/cli-kit/src/public/common/string.ts b/packages/cli-kit/src/public/common/string.ts index ee19df264a1..4eb2fd3d6f7 100644 --- a/packages/cli-kit/src/public/common/string.ts +++ b/packages/cli-kit/src/public/common/string.ts @@ -1,8 +1,7 @@ import {takeRandomFromArray, uniq} from './array.js' import {unstyled} from '../node/output.js' -import {Token, TokenItem} from '../../private/node/ui/components/TokenizedText.js' - import {camelCase, capitalCase, constantCase, paramCase, snakeCase, pascalCase} from 'change-case' +import type {Token, TokenItem} from '../../private/node/ui/components/token-item.js' const SAFE_RANDOM_BUSINESS_ADJECTIVES = [ 'commercial', diff --git a/packages/cli-kit/src/public/node/error.ts b/packages/cli-kit/src/public/node/error.ts index 9f84774cef9..32e9778e799 100644 --- a/packages/cli-kit/src/public/node/error.ts +++ b/packages/cli-kit/src/public/node/error.ts @@ -1,10 +1,9 @@ import {normalizePath} from './path.js' import {OutputMessage, stringifyMessage, TokenizedString} from './output.js' -import {InlineToken, TokenItem, tokenItemToString} from '../../private/node/ui/components/TokenizedText.js' +import {tokenItemToString, type InlineToken, type TokenItem} from '../../private/node/ui/components/token-item.js' import {hasRateLimitCode} from '../../private/node/analytics/graphql-error-codes.js' import {Errors} from '@oclif/core' -import {ClientError} from 'graphql-request' import type {AlertCustomSection} from './ui.js' @@ -222,21 +221,31 @@ export function shouldReportErrorAsUnexpected(error: unknown): boolean { * (HTTP 429, or a `THROTTLED`/`429` GraphQL code on any error in the response) matches the shape * detected by `errorsIncludeStatus429` in `private/node/api.ts`. * - * Scoped to the external `ClientError` type only — importing the cli-kit `GraphQLClientError` + * Scoped to the external `ClientError` shape only — importing the cli-kit `GraphQLClientError` * wrapper here would create an `error.ts → headers.ts → error.ts` import cycle. * + * Matched structurally rather than with `instanceof ClientError`, because importing the class + * pulls `graphql-request` — and through it `graphql`, `tr46` and `whatwg-url` — into the module + * graph of every command, for one type check on an error path. `ClientError` is the only error + * reaching here that carries both `response` and `request`; the cli-kit wrapper carries + * `statusCode` instead (see `private/node/api/headers.ts`). + * * @param error - Error to be checked. * @returns A boolean indicating if the error is a known expected API error. */ function isExpectedApiError(error: Error): boolean { - if (!(error instanceof ClientError)) { + const candidate = error as Error & { + response?: {status?: number; errors?: unknown} + request?: unknown + } + if (typeof candidate.response !== 'object' || candidate.response === null || candidate.request === undefined) { return false } - const status = error.response?.status + const status = candidate.response.status if (status === 401 || status === 429) { return true } - return hasRateLimitCode(error.response?.errors) + return hasRateLimitCode(candidate.response.errors) } /** diff --git a/packages/cli-kit/src/public/node/output.ts b/packages/cli-kit/src/public/node/output.ts index af883ebd016..3748f64bf20 100644 --- a/packages/cli-kit/src/public/node/output.ts +++ b/packages/cli-kit/src/public/node/output.ts @@ -19,7 +19,7 @@ import { RawContentToken, SubHeadingContentToken, } from '../../private/node/content-tokens.js' -import {tokenItemToString} from '../../private/node/ui/components/TokenizedText.js' +import {tokenItemToString} from '../../private/node/ui/components/token-item.js' import {consoleLog, consoleWarn, output} from '../../private/node/output.js' import stripAnsi from 'strip-ansi' import {Writable} from 'stream' diff --git a/packages/cli-kit/src/public/node/ui.tsx b/packages/cli-kit/src/public/node/ui.tsx index 28e4a1863da..08f15ff7dcb 100644 --- a/packages/cli-kit/src/public/node/ui.tsx +++ b/packages/cli-kit/src/public/node/ui.tsx @@ -12,13 +12,13 @@ import {FatalError} from '../../private/node/ui/components/FatalError.js' import ScalarDict from '../../private/node/ui/components/Table/ScalarDict.js' import {Table, TableColumn, TableProps} from '../../private/node/ui/components/Table/Table.js' import { - Token, tokenItemToString, - InlineToken, - LinkToken, - ListToken, - TokenItem, -} from '../../private/node/ui/components/TokenizedText.js' + type InlineToken, + type LinkToken, + type ListToken, + type Token, + type TokenItem, +} from '../../private/node/ui/components/token-item.js' import { DangerousConfirmationPrompt, DangerousConfirmationPromptProps,