Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
33 commits
Select commit Hold shift + click to select a range
d5f1166
autoresearch: setup experiment for CLI startup optimization
gonzaloriestra Mar 13, 2026
14dccc5
Baseline: shopify version takes ~1840ms. Import alone is ~1494ms.\n\n…
gonzaloriestra Mar 13, 2026
f2553ed
Lazy command loading: separate bootstrap.ts, extract token-utils, mov…
gonzaloriestra Mar 13, 2026
9c778d9
Defer conf-store import, inline app-init hook with lazy LocalStorage,…
gonzaloriestra Mar 13, 2026
d8849b4
Add postrun hook back (was missing - analytics broken). Lazy postrun …
gonzaloriestra Mar 13, 2026
11d9824
Corrected baseline measurement (original code, warmed). 600ms wall ti…
gonzaloriestra Mar 13, 2026
4de4996
Defer error-handler import to catch block. Saves ~380ms cold load on …
gonzaloriestra Mar 13, 2026
2f37d17
Lazy prerun hook with parallel imports (Promise.all). Defers node-pac…
gonzaloriestra Mar 13, 2026
a8332af
Lazy import of latest-version in node-package-manager.ts. Saves ~113m…
gonzaloriestra Mar 13, 2026
3eeaa69
Baseline for help command benchmark (bundled). Includes all prior opt…
gonzaloriestra Mar 13, 2026
f08630d
Run prerun hook in parallel with command execution. Prerun loads anal…
gonzaloriestra Mar 13, 2026
0034985
Fire-and-forget postrun + process.exit(0) after command. Eliminates w…
gonzaloriestra Mar 13, 2026
fb7b6b5
Externalize TypeScript compiler from bundle. Oclif bundled the entire…
gonzaloriestra Mar 13, 2026
c9ad535
Skip prerun/postrun hooks for help and version commands (no analytics…
gonzaloriestra Mar 13, 2026
64e2376
Skip init hooks for help/version via runHook override. Avoids app-ini…
gonzaloriestra Mar 13, 2026
c65b999
Fast path for help and version: read manifest + package.json directly…
gonzaloriestra Mar 13, 2026
c79522c
Reverted help/version-specific fast paths and hook skipping. Keeping …
gonzaloriestra Mar 13, 2026
7ffee97
Fire-and-forget init hooks for ALL commands via runHook override. Ini…
gonzaloriestra Mar 13, 2026
4896a55
Replace cli-kit wrappers (fs.js, path.js, execa, context/local.js) wi…
gonzaloriestra Mar 13, 2026
14dfe21
Inline isDevelopment check in cli-launcher.ts to avoid importing cont…
gonzaloriestra Mar 13, 2026
048ef72
Defer prerun+postrun hooks to AFTER command execution (fire-and-forge…
gonzaloriestra Mar 13, 2026
4653f22
Enable full minification (whitespace + identifiers) in esbuild bundle…
gonzaloriestra Mar 13, 2026
71678d7
Enable V8 compile cache via module.enableCompileCache() in dev.js/run…
gonzaloriestra Mar 13, 2026
a32d5d1
Lazy imports in base-command: defer ui.js (600KB React/Ink), error-ha…
gonzaloriestra Mar 13, 2026
6e4db4f
Custom lightweight dispatcher bypassing oclif.run() for known command…
gonzaloriestra Mar 13, 2026
3b7c644
Inline terminalSupportsPrompting (avoids system.js→execa chain), lazy…
gonzaloriestra Mar 13, 2026
d174cb7
Skip async exitIfOldNodeVersion call for Node >= 18 (inline fast chec…
gonzaloriestra Mar 13, 2026
14a7982
Static import ShopifyConfig in cli-launcher (instead of dynamic). Eli…
gonzaloriestra Mar 13, 2026
a9e7a3d
Replace dynamic import('@oclif/core') for debug settings with static …
gonzaloriestra Mar 13, 2026
cc7e844
Type-only imports in output.ts: PackageManager and TokenItem. Prevent…
gonzaloriestra Mar 13, 2026
efdc375
Type-only imports in base-command.ts: JsonMap, OutputFlags, Input, Pa…
gonzaloriestra Mar 13, 2026
62c6045
Update ideas file with final optimization state and lessons learned
gonzaloriestra Mar 13, 2026
0b00a10
Remove temp files
gonzaloriestra Mar 16, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions eslint.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -192,6 +192,17 @@ const config = [
'@shopify/strict-component-boundaries': 'off',
},
},

// The cli package uses a lazy command-loading pattern (command-registry.ts) that
// dynamically imports libraries at runtime. NX detects these dynamic imports and
// flags every static import of the same library elsewhere in the package. Since
// the command files themselves are lazy-loaded, their static imports are fine.
{
files: ['packages/cli/src/**/*.ts'],
rules: {
'@nx/enforce-module-boundaries': 'off',
},
},
]

export default config
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -210,7 +210,8 @@
"entry": [
"**/{commands,hooks}/**/*.ts!",
"**/bin/*.js!",
"**/index.ts!"
"**/index.ts!",
"**/bootstrap.ts!"
],
"project": "**/*.ts!",
"ignoreDependencies": [
Expand Down
12 changes: 12 additions & 0 deletions packages/app/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,18 @@
"./node/plugins/*": {
"import": "./dist/cli/public/plugins/*.js",
"require": "./dist/cli/public/plugins/*.d.ts"
},
"./hooks/init": {
"import": "./dist/cli/hooks/clear_command_cache.js",
"types": "./dist/cli/hooks/clear_command_cache.d.ts"
},
"./hooks/public-metadata": {
"import": "./dist/cli/hooks/public_metadata.js",
"types": "./dist/cli/hooks/public_metadata.d.ts"
},
"./hooks/sensitive-metadata": {
"import": "./dist/cli/hooks/sensitive_metadata.js",
"types": "./dist/cli/hooks/sensitive_metadata.d.ts"
}
},
"files": [
Expand Down
Original file line number Diff line number Diff line change
@@ -1,11 +1,18 @@
import {fileExists, findPathUp, readFileSync} from '@shopify/cli-kit/node/fs'
import {dirname, joinPath, relativizePath, resolvePath} from '@shopify/cli-kit/node/path'
import {AbortError} from '@shopify/cli-kit/node/error'
import ts from 'typescript'
import {compile} from 'json-schema-to-typescript'
import {pascalize} from '@shopify/cli-kit/common/string'
import {zod} from '@shopify/cli-kit/node/schema'
import {createRequire} from 'module'
import type ts from 'typescript'

async function loadTypeScript(): Promise<typeof ts> {
// typescript is CJS; dynamic import wraps it as { default: ... }
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const mod: any = await import('typescript')
return mod.default ?? mod
}

const require = createRequire(import.meta.url)

Expand All @@ -17,18 +24,21 @@ export function parseApiVersion(apiVersion: string): {year: number; month: numbe
return {year: parseInt(year, 10), month: parseInt(month, 10)}
}

function loadTsConfig(startPath: string): {compilerOptions: ts.CompilerOptions; configPath: string | undefined} {
const configPath = ts.findConfigFile(startPath, ts.sys.fileExists.bind(ts.sys), 'tsconfig.json')
async function loadTsConfig(
startPath: string,
): Promise<{compilerOptions: ts.CompilerOptions; configPath: string | undefined}> {
const tsModule = await loadTypeScript()
const configPath = tsModule.findConfigFile(startPath, tsModule.sys.fileExists.bind(tsModule.sys), 'tsconfig.json')
if (!configPath) {
return {compilerOptions: {}, configPath: undefined}
}

const configFile = ts.readConfigFile(configPath, ts.sys.readFile.bind(ts.sys))
const configFile = tsModule.readConfigFile(configPath, tsModule.sys.readFile.bind(tsModule.sys))
if (configFile.error) {
return {compilerOptions: {}, configPath}
}

const parsedConfig = ts.parseJsonConfigFileContent(configFile.config, ts.sys, dirname(configPath))
const parsedConfig = tsModule.parseJsonConfigFileContent(configFile.config, tsModule.sys, dirname(configPath))

return {compilerOptions: parsedConfig.options, configPath}
}
Expand Down Expand Up @@ -65,60 +75,64 @@ async function fallbackResolve(importPath: string, baseDir: string): Promise<str

async function parseAndResolveImports(filePath: string): Promise<string[]> {
try {
const tsModule = await loadTypeScript()
const content = readFileSync(filePath).toString()
const resolvedPaths: string[] = []

// Load TypeScript configuration once
const {compilerOptions} = loadTsConfig(filePath)
const {compilerOptions} = await loadTsConfig(filePath)

// Determine script kind based on file extension
let scriptKind = ts.ScriptKind.JSX
let scriptKind = tsModule.ScriptKind.JSX
if (filePath.endsWith('.ts')) {
scriptKind = ts.ScriptKind.TS
scriptKind = tsModule.ScriptKind.TS
} else if (filePath.endsWith('.tsx')) {
scriptKind = ts.ScriptKind.TSX
scriptKind = tsModule.ScriptKind.TSX
}

const sourceFile = ts.createSourceFile(filePath, content, ts.ScriptTarget.Latest, true, scriptKind)
const sourceFile = tsModule.createSourceFile(filePath, content, tsModule.ScriptTarget.Latest, true, scriptKind)

const processedImports = new Set<string>()
const importPaths: string[] = []

const visit = (node: ts.Node): void => {
if (ts.isImportDeclaration(node) && node.moduleSpecifier && ts.isStringLiteral(node.moduleSpecifier)) {
if (
tsModule.isImportDeclaration(node) &&
node.moduleSpecifier &&
tsModule.isStringLiteral(node.moduleSpecifier)
) {
importPaths.push(node.moduleSpecifier.text)
} else if (ts.isCallExpression(node) && node.expression.kind === ts.SyntaxKind.ImportKeyword) {
} else if (tsModule.isCallExpression(node) && node.expression.kind === tsModule.SyntaxKind.ImportKeyword) {
const firstArg = node.arguments[0]
if (firstArg && ts.isStringLiteral(firstArg)) {
if (firstArg && tsModule.isStringLiteral(firstArg)) {
importPaths.push(firstArg.text)
}
} else if (ts.isExportDeclaration(node) && node.moduleSpecifier && ts.isStringLiteral(node.moduleSpecifier)) {
} else if (
tsModule.isExportDeclaration(node) &&
node.moduleSpecifier &&
tsModule.isStringLiteral(node.moduleSpecifier)
) {
importPaths.push(node.moduleSpecifier.text)
}

ts.forEachChild(node, visit)
tsModule.forEachChild(node, visit)
}

visit(sourceFile)

for (const importPath of importPaths) {
// Skip if already processed
if (!importPath || processedImports.has(importPath)) {
continue
}

processedImports.add(importPath)

// Use TypeScript's module resolution to resolve potential "paths" configurations
const resolvedModule = ts.resolveModuleName(importPath, filePath, compilerOptions, ts.sys)
const resolvedModule = tsModule.resolveModuleName(importPath, filePath, compilerOptions, tsModule.sys)
if (resolvedModule.resolvedModule?.resolvedFileName) {
const resolvedPath = resolvedModule.resolvedModule.resolvedFileName

if (!resolvedPath.includes('node_modules')) {
resolvedPaths.push(resolvedPath)
}
} else {
// Fallback to manual resolution for edge cases
// eslint-disable-next-line no-await-in-loop
const fallbackPath = await fallbackResolve(importPath, dirname(filePath))
if (fallbackPath) {
Expand All @@ -129,7 +143,6 @@ async function parseAndResolveImports(filePath: string): Promise<string[]> {

return resolvedPaths
} catch (error) {
// Re-throw AbortError as-is, wrap other errors
if (error instanceof AbortError) {
throw error
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,60 +6,10 @@ import {FilePath} from './FilePath.js'
import {Subdued} from './Subdued.js'
import React, {FunctionComponent} from 'react'
import {Box, Text} from 'ink'
import type {Token, ListToken, TokenItem} from './token-utils.js'

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 type {LinkToken, UserInputToken, ListToken, BoldToken, Token, InlineToken, TokenItem} from './token-utils.js'
export {tokenItemToString} from './token-utils.js'

type DisplayType = 'block' | 'inline'
interface Block {
Expand All @@ -74,48 +24,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) {
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])
Expand Down
101 changes: 101 additions & 0 deletions packages/cli-kit/src/private/node/ui/components/token-utils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
/**
* Lightweight token types and string utilities.
* This module does NOT import React or Ink — it can be loaded cheaply.
* The React component (TokenizedText) remains in TokenizedText.tsx and re-exports from here.
*/

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 function tokenItemToString(token: TokenItem): string {
if (typeof token === 'string') {
return token
} else if ('command' in token) {
return token.command
} else if ('link' in token) {
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}]
}
Loading
Loading