Skip to content

Keep the terminal UI runtime out of the CLI startup path - #8238

Merged
isaacroldan merged 54 commits into
mainfrom
river/startup-drop-ink
Aug 6, 2026
Merged

Keep the terminal UI runtime out of the CLI startup path#8238
isaacroldan merged 54 commits into
mainfrom
river/startup-drop-ink

Conversation

@isaacroldan

@isaacroldan isaacroldan commented Aug 3, 2026

Copy link
Copy Markdown
Contributor

Why

Every CLI invocation loads common output code. That code imported a small text-formatting helper from a React and Ink component, so even shopify --version loaded the terminal UI runtime. The error path also loaded graphql-request for one type check.

This work made startup slower before a command could run.

What

  • Move the token types and pure formatting helpers into a dependency-free module.
  • Keep React and Ink imports in rendering modules.
  • Replace the eager ClientError import with a structural check that preserves the existing expected-error cases.

No output changes are intended.

Impact

On clean bundled builds with the Nx cache disabled:

  • shopify --version: 174 ms to 99 ms, with 52% fewer instructions.
  • shopify --help: 50% fewer instructions.
  • JavaScript loaded at startup: 5.31 MB to 4.16 MB.

Testing

  • Type-check, lint, Knip, bundle, unit, and E2E checks pass.
  • Output is unchanged for --version, --help, app --help, and commands.
  • Existing tests use real ClientError instances and pass with the structural check.

Context: https://shopify.slack.com/archives/C0AG0L37Q4C/p1785681072538939

@isaacroldan isaacroldan self-assigned this Aug 3, 2026
@github-actions github-actions Bot added cla-needed Area: @shopify/cli @shopify/cli package issues labels Aug 3, 2026
isaacroldan and others added 2 commits August 3, 2026 17:57
`output.ts` and `error.ts` imported `tokenItemToString` from
`private/node/ui/components/TokenizedText.tsx`, and `error.ts` imported
`ClientError` from graphql-request for a single `instanceof` check.
TokenizedText is a React component module, so those two imports pulled
react-reconciler (341 KB), yoga-layout and its WebAssembly (125 KB), ink
and react into the module graph of every command, plus graphql (254 KB),
tr46 (231 KB) and whatwg-url. Practically everything imports output.ts,
so `shopify --version` was loading a terminal UI renderer and a GraphQL
client to print a version string.

Moves the token types, tokenItemToString and appendToTokenItem into
token-item.ts, which imports nothing, and repoints every import site.
TokenizedText.tsx keeps the component and imports the types. Replaces the
`instanceof ClientError` check with a structural match on the same shape;
ClientError is the only error reaching that path carrying both `response`
and `request`, and the cli-kit wrapper carries `statusCode` instead.

No behaviour change. Measured on the bundled CLI, clean rebuilds on both
sides, cachegrind instruction counts under `node --predictable`:

                              before          after      delta
  shopify --version   1,543,699,375    737,632,410     -52.2%
  shopify --help      1,606,749,593    809,187,564     -49.6%
  --version wall              174 ms          99 ms       -43%
  JS loaded at boot          5.31 MB        4.16 MB       -22%

Output is byte-identical for --version, --help, app --help and commands.

Co-authored-by: Isaac Roldan <isaac.roldan@shopify.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@isaacroldan
isaacroldan force-pushed the river/startup-drop-ink branch from c18b75c to ba02a02 Compare August 3, 2026 15:57

Copy link
Copy Markdown
Contributor

/snapit

@github-actions

github-actions Bot commented Aug 6, 2026

Copy link
Copy Markdown
Contributor

🫰✨ Thanks @gonzaloriestra! Your snapshot has been published to npm.

Test the snapshot by installing your package globally:

pnpm i -g --@shopify:registry=https://registry.npmjs.org @shopify/cli@0.0.0-snapshot-20260806094115

Caution

After installing, validate the version by running shopify version in your terminal.
If the versions don't match, you might have multiple global instances installed.
Use which shopify to find out which one you are running and uninstall it.

@gonzaloriestra gonzaloriestra left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice! Simple change, good improvement 👌

github-actions Bot and others added 20 commits August 6, 2026 12:24
Redaction is best effort, so the flag that turns on the payload dump should say
so. Regenerates the manifests, README and dev docs that embed the description.
Assisted-By: devx/a6727785-6ba2-4c6b-9612-bcadf6422d8f
Assisted-By: devx/2ae47aa3-adba-4060-adf8-d3b99e6b63f3
Adds integration tests covering edge cases, empty path behavior, and exception/cleanup paths in zip and brotliCompress.
Adds integration tests covering edge cases, empty path behavior, and exception/cleanup paths in zip and brotliCompress. Also synchronizes generated graphql admin types with Shopify upstream schema to resolve CI checks.
Adds integration tests covering edge cases, empty path behavior, and exception/cleanup paths in zip and brotliCompress. Also synchronizes generated graphql admin types with Shopify upstream schema to resolve CI checks.
Add comprehensive unit tests for randomHex, base64URLEncode, sha256, randomBytes, and randomUUID in packages/cli-kit/src/public/node/crypto.test.ts. This closes the test coverage gaps for the crypto utility module without changing any observable behavior.
Improve readability and structure of getOrCreateHiddenShopifyFolder by sequentially checking directory and gitignore existence. This avoids concurrently checking files when the parent directory is missing.
Improve readability and structure of getOrCreateHiddenShopifyFolder by sequentially checking directory and gitignore existence. This avoids concurrently checking files when the parent directory is missing.

Also updated generated admin graphql types and references to resolve pre-existing CI schema generation differences on MetaobjectAdminAccess/MetafieldAdminAccess.
Improve readability and structure of getOrCreateHiddenShopifyFolder by sequentially checking directory and gitignore existence. This avoids concurrently checking files when the parent directory is missing.

Also updated generated admin graphql types to resolve pre-existing CI schema generation differences on MetaobjectAdminAccess.
gonzaloriestra and others added 23 commits August 6, 2026 12:24
Refactors `createGitIgnore` to build the `.gitignore` content using a declarative `.map().join('')` chain instead of an imperative `for...of` loop with string accumulator.
Add comprehensive unit tests for the `isTruthy` utility function in `packages/cli-kit/src/public/node/context/utilities.ts`. These tests cover various truthy patterns ('1', 'true', 'yes') with case-insensitivity, and several falsy scenarios including undefined, empty strings, and '0'.
Refactor `addResolutionOrOverride` to use a more declarative approach
with property key lookup and leverage the `writePackageJSON` helper.
This reduces duplication and improves maintainability.
Add unit tests for getLogsDir, createLogsDir, and writeLog in
packages/cli-kit/src/public/node/logs.ts.
- Use inTemporaryDirectory for real filesystem testing.
- Mock logsFolder for controlled test environment.
Implemented unit tests for the `addCursorAndFiltersToAppLogsUrl` utility function in `packages/cli-kit/src/public/node/api/utilities.test.ts`. The tests cover:
- Base case with only a URL
- Appending a cursor
- Appending status filters
- Appending source filters
- Appending combinations of cursor and multiple filters
- Refactor `outputContent` to use a declarative `.reduce()` approach.
- Refactor `formatSection` to use `.padEnd(35)` for idiomatic string padding.
Refactor `platformAndArch` in `packages/cli-kit/src/public/node/os.ts` to use a constant lookup map instead of an imperative `if/else` block. This improves code readability and maintainability.
Refactor flattenToPatchEntries in toml-file.ts to use a declarative flatMap
approach instead of an imperative for...of loop with entries.push to improve
code idiom and readability.
Refactored `packages/cli/src/cli/services/commands/doc/fetch.test.ts` to replace filesystem mocks with real operations using `inTemporaryDirectory`. Verified that `docFetchService` correctly handles directory creation and file writing by asserting the state of the real filesystem.

- Replaced `vi.mock('@shopify/cli-kit/node/fs')` with real fs utilities.
- Updated 'writes the document to the output path instead of stdout' to use `inTemporaryDirectory`.
- Complied with `vitest/prefer-expect-resolves` ESLint rule.
…tion

Refactor `argumentsToAddDependenciesWithNPM`, `argumentsToAddDependenciesWithYarn`,
`argumentsToAddDependenciesWithPNPM`, and `argumentsToAddDependenciesWithBun`
to use declarative lookup maps and spread operators. This improves readability
and maintainability by replacing imperative switch statements and array mutations
with more idiomatic TypeScript patterns.
…anged

Every app automation token exchange failure collapsed into a single fixed
message, and the OAuth `error`/`error_description` returned by Identity were
discarded before reaching it, so a revoked token, an expired token and an
unreachable Identity were indistinguishable. Verbose output didn't help either:
it printed the response headers for that call and no status or body.

Keep the existing sentence as the headline and append the upstream reason when
there is one, and log the status plus the OAuth error fields of a failed token
request under `--verbose`. Only failed responses are logged, and only the
`error`/`error_description` fields, since a successful body carries the access
token.
The fields of a failed token response aren't guaranteed: the responder
isn't always Identity, since proxies and gateways can answer with
arbitrary JSON. A body without an `error` field crashed the error
constructor before any message was shown, and `error_description` flowed
into the terminal unbounded.

Guard both fields once, where the response is parsed: `error` falls back
to `unknown_error`, and `error_description` is flattened to a single line
and capped at 200 characters. Every consumer downstream now gets the
shape the type declares.

Also widen the changeset wording: the shared error handler enriches
interactive token errors too, not only automation-token ones.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Map any code outside IdentityDeviceError to unknown_failure at the
exchange boundary instead of casting, and add a default branch to the
poll switch so an unexpected code rejects rather than leaving the
promise unsettled. Also apply one shared flatten-and-truncate rule to
both the Identity description and the appended Reason message.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Review direction from the proposal thread: every token failure resolves
to the same user action, so the default error stays generic and the
reason lives in the debug log only.

The debug line in tokenRequest covers OAuth rejections. The automation
catch now logs the caught error too, which covers failures that produce
no OAuth body, such as the authentication service being unreachable.
The enrichment of InvalidGrantError, InvalidRequestError and the
fall-through AbortError is reverted: session.ts only checks the class,
so those messages carried the detail into default output for nothing.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Assisted-By: devx/2ae47aa3-adba-4060-adf8-d3b99e6b63f3
@github-actions

github-actions Bot commented Aug 6, 2026

Copy link
Copy Markdown
Contributor

Differences in type declarations

We detected differences in the type declarations generated by Typescript for this branch compared to the baseline ('main' branch). Please, review them to ensure they are backward-compatible. Here are some important things to keep in mind:

  • Some seemingly private modules might be re-exported through public modules.
  • If the branch is behind main you might see odd diffs, rebase main into this branch.

New type declarations

packages/cli-kit/dist/private/node/ui/components/token-item.d.ts
export interface LinkToken {
    link: {
        label?: string;
        url: string;
    };
}
export interface UserInputToken {
    userInput: string;
}
export interface ListToken {
    list: {
        title?: TokenItem<InlineToken>;
        items: TokenItem<InlineToken>[];
        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<Token, ListToken>;
export type TokenItem<T extends Token = Token> = T | T[];
export declare function tokenItemToString(token: TokenItem): string;
export declare function appendToTokenItem(token: TokenItem, suffix: string): TokenItem;

Existing type declarations

packages/cli-kit/dist/public/common/string.d.ts
@@ -1,4 +1,4 @@
-import { Token, TokenItem } from '../../private/node/ui/components/TokenizedText.js';
+import type { Token, TokenItem } from '../../private/node/ui/components/token-item.js';
 export type RandomNameFamily = 'business' | 'creative';
 /**
  * Generates a random name by combining an adjective and noun.
packages/cli-kit/dist/public/node/error.d.ts
@@ -1,5 +1,5 @@
 import { OutputMessage } from './output.js';
-import { InlineToken, TokenItem } from '../../private/node/ui/components/TokenizedText.js';
+import { type InlineToken, type TokenItem } from '../../private/node/ui/components/token-item.js';
 import type { AlertCustomSection } from './ui.js';
 export { ExtendableError } from 'ts-error';
 export declare enum FatalErrorType {
packages/cli-kit/dist/public/node/ui.d.ts
@@ -6,7 +6,7 @@ import { AlertOptions } from '../../private/node/ui/alert.js';
 import { CustomSection } from '../../private/node/ui/components/Alert.js';
 import ScalarDict from '../../private/node/ui/components/Table/ScalarDict.js';
 import { TableColumn, TableProps } from '../../private/node/ui/components/Table/Table.js';
-import { Token, InlineToken, LinkToken, ListToken, TokenItem } from '../../private/node/ui/components/TokenizedText.js';
+import { type InlineToken, type LinkToken, type ListToken, type Token, type TokenItem } from '../../private/node/ui/components/token-item.js';
 import { DangerousConfirmationPromptProps } from '../../private/node/ui/components/DangerousConfirmationPrompt.js';
 import { SelectPromptProps } from '../../private/node/ui/components/SelectPrompt.js';
 import { Task } from '../../private/node/ui/components/Tasks.js';
packages/cli-kit/dist/private/node/ui/utilities.d.ts
@@ -1,16 +1,16 @@
-import { TokenItem } from './components/TokenizedText.js';
-export declare function messageWithPunctuation(message: TokenItem): string | {
+import { type TokenItem } from './components/token-item.js';
+export declare function messageWithPunctuation(message: TokenItem): string | import("./components/token-item.js").LinkToken | import("./components/token-item.js").UserInputToken | import("./components/token-item.js").ListToken | {
     command: string;
-} | import("./components/TokenizedText.js").LinkToken | {
+} | {
     char: string;
-} | import("./components/TokenizedText.js").UserInputToken | {
+} | {
     subdued: string;
 } | {
     filePath: string;
-} | import("./components/TokenizedText.js").ListToken | import("./components/TokenizedText.js").BoldToken | {
+} | import("./components/token-item.js").BoldToken | {
     info: string;
 } | {
     warn: string;
 } | {
     error: string;
-} | import("./components/TokenizedText.js").Token[];
\ No newline at end of file
+} | import("./components/token-item.js").Token[];
\ No newline at end of file
packages/cli-kit/dist/private/node/ui/components/Alert.d.ts
@@ -1,7 +1,7 @@
 import { BannerType } from './Banner.js';
-import { BoldToken, InlineToken, LinkToken, TokenItem } from './TokenizedText.js';
 import { TabularDataProps } from './TabularData.js';
 import { FunctionComponent } from 'react';
+import type { BoldToken, InlineToken, LinkToken, TokenItem } from './token-item.js';
 export interface CustomSection {
     title?: string;
     body: TabularDataProps | TokenItem;
packages/cli-kit/dist/private/node/ui/components/DangerousConfirmationPrompt.d.ts
@@ -1,7 +1,7 @@
-import { InlineToken, TokenItem } from './TokenizedText.js';
 import { InfoTableProps } from './Prompts/InfoTable.js';
 import { AbortSignal } from '../../../../public/node/abort.js';
 import { FunctionComponent } from 'react';
+import type { InlineToken, TokenItem } from './token-item.js';
 export interface DangerousConfirmationPromptProps {
     message: string;
     confirmation: string;
packages/cli-kit/dist/private/node/ui/components/List.d.ts
@@ -1,6 +1,6 @@
-import { InlineToken, TokenItem } from './TokenizedText.js';
 import { TextProps } from 'ink';
 import { FunctionComponent } from 'react';
+import type { InlineToken, TokenItem } from './token-item.js';
 export interface CustomListItem {
     type?: string;
     item: TokenItem<InlineToken>;
packages/cli-kit/dist/private/node/ui/components/TabularData.d.ts
@@ -1,4 +1,4 @@
-import { InlineToken } from './TokenizedText.js';
+import { type InlineToken } from './token-item.js';
 import { FunctionComponent } from 'react';
 export interface TabularDataProps {
     tabularData: InlineToken[][];
packages/cli-kit/dist/private/node/ui/components/TextPrompt.d.ts
@@ -1,6 +1,6 @@
-import { InlineToken, TokenItem } from './TokenizedText.js';
 import { AbortSignal } from '../../../../public/node/abort.js';
 import { FunctionComponent } from 'react';
+import type { InlineToken, TokenItem } from './token-item.js';
 export interface TextPromptProps {
     message: TokenItem;
     onSubmit: (value: string) => void;
packages/cli-kit/dist/private/node/ui/components/TokenizedText.d.ts
@@ -1,42 +1,5 @@
 import { FunctionComponent } from 'react';
-export interface LinkToken {
-    link: {
-        label?: string;
-        url: string;
-    };
-}
-export interface UserInputToken {
-    userInput: string;
-}
-export interface ListToken {
-    list: {
-        title?: TokenItem<InlineToken>;
-        items: TokenItem<InlineToken>[];
-        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<Token, ListToken>;
-export type TokenItem<T extends Token = Token> = T | T[];
-export declare function tokenItemToString(token: TokenItem): string;
-export declare function appendToTokenItem(token: TokenItem, suffix: string): TokenItem;
+import type { TokenItem } from './token-item.js';
 interface TokenizedTextProps {
     item: TokenItem;
 }
packages/cli-kit/dist/private/node/ui/components/Prompts/InfoMessage.d.ts
@@ -1,6 +1,6 @@
-import { InlineToken, LinkToken, TokenItem, UserInputToken } from '../TokenizedText.js';
 import { TextProps } from 'ink';
 import { FunctionComponent } from 'react';
+import type { InlineToken, LinkToken, TokenItem, UserInputToken } from '../token-item.js';
 export interface InfoMessageProps {
     message: {
         title: {
packages/cli-kit/dist/private/node/ui/components/Prompts/InfoTable.d.ts
@@ -1,7 +1,7 @@
 import { CustomListItem } from '../List.js';
-import { InlineToken, TokenItem } from '../TokenizedText.js';
 import { TextProps } from 'ink';
 import { FunctionComponent } from 'react';
+import type { InlineToken, TokenItem } from '../token-item.js';
 type Items = (TokenItem<InlineToken> | CustomListItem)[];
 export interface InfoTableSection {
     color?: TextProps['color'];
packages/cli-kit/dist/private/node/ui/components/Prompts/PromptLayout.d.ts
@@ -1,9 +1,9 @@
 import { InfoTableProps } from './InfoTable.js';
 import { InfoMessageProps } from './InfoMessage.js';
-import { InlineToken, LinkToken, TokenItem } from '../TokenizedText.js';
 import { AbortSignal } from '../../../../../public/node/abort.js';
 import { PromptState } from '../../hooks/use-prompt.js';
 import { ReactElement } from 'react';
+import type { InlineToken, LinkToken, TokenItem } from '../token-item.js';
 export type Message = TokenItem<Exclude<InlineToken, LinkToken>>;
 interface PromptLayoutProps {
     message: Message;

@isaacroldan
isaacroldan marked this pull request as ready for review August 6, 2026 10:46
@isaacroldan
isaacroldan requested a review from a team as a code owner August 6, 2026 10:46
@isaacroldan
isaacroldan added this pull request to the merge queue Aug 6, 2026
Merged via the queue into main with commit e966c47 Aug 6, 2026
29 checks passed
@isaacroldan
isaacroldan deleted the river/startup-drop-ink branch August 6, 2026 10:53
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Area: @shopify/cli @shopify/cli package issues

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants