Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
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 @@ -211,7 +211,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
9 changes: 7 additions & 2 deletions packages/cli-kit/src/public/node/cli-launcher.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
import {fileURLToPath} from 'node:url'
import type {LazyCommandLoader} from './custom-oclif-loader.js'

interface Options {
moduleURL: string
argv?: string[]
lazyCommandLoader?: LazyCommandLoader
}

/**
Expand All @@ -12,7 +14,6 @@ interface Options {
* @returns A promise that resolves when the CLI has been launched.
*/
export async function launchCLI(options: Options): Promise<void> {
const {errorHandler} = await import('./error-handler.js')
const {isDevelopment} = await import('./context/local.js')
const oclif = await import('@oclif/core')
const {ShopifyConfig} = await import('./custom-oclif-loader.js')
Expand All @@ -22,14 +23,18 @@ export async function launchCLI(options: Options): Promise<void> {
}

try {
// Use a custom OCLIF config to customize the behavior of the CLI
const config = new ShopifyConfig({root: fileURLToPath(options.moduleURL)})
await config.load()

if (options.lazyCommandLoader) {
config.setLazyCommandLoader(options.lazyCommandLoader)
}

await oclif.default.run(options.argv, config)
await oclif.default.flush()
// eslint-disable-next-line no-catch-all/no-catch-all
} catch (error) {
const {errorHandler} = await import('./error-handler.js')
await errorHandler(error as Error)
return oclif.default.Errors.handle(error as Error)
}
Expand Down
4 changes: 2 additions & 2 deletions packages/cli-kit/src/public/node/cli.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -107,9 +107,9 @@ describe('cli', () => {
})

describe('clearCache', () => {
test('clears the cache', () => {
test('clears the cache', async () => {
const spy = vi.spyOn(confStore, 'cacheClear')
clearCache()
await clearCache()
expect(spy).toHaveBeenCalled()
spy.mockRestore()
})
Expand Down
12 changes: 7 additions & 5 deletions packages/cli-kit/src/public/node/cli.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,8 @@
import {isTruthy} from './context/utilities.js'
import {launchCLI as defaultLaunchCli} from './cli-launcher.js'
import {cacheClear} from '../../private/node/conf-store.js'
import {environmentVariables} from '../../private/node/constants.js'

import {Flags} from '@oclif/core'
import type {LazyCommandLoader} from './custom-oclif-loader.js'

/**
* IMPORTANT NOTE: Imports in this module are dynamic to ensure that "setupEnvironmentVariables" can dynamically
Expand All @@ -14,6 +13,8 @@ interface RunCLIOptions {
/** The value of import.meta.url of the CLI executable module */
moduleURL: string
development: boolean
/** Optional lazy command loader for on-demand command loading */
lazyCommandLoader?: LazyCommandLoader
}

async function exitIfOldNodeVersion(versions: NodeJS.ProcessVersions = process.versions) {
Expand Down Expand Up @@ -77,7 +78,7 @@ function forceNoColor(argv: string[] = process.argv, env: NodeJS.ProcessEnv = pr
*/
export async function runCLI(
options: RunCLIOptions & {runInCreateMode?: boolean},
launchCLI: (options: {moduleURL: string}) => Promise<void> = defaultLaunchCli,
launchCLI: (options: {moduleURL: string; lazyCommandLoader?: LazyCommandLoader}) => Promise<void> = defaultLaunchCli,
argv: string[] = process.argv,
env: NodeJS.ProcessEnv = process.env,
versions: NodeJS.ProcessVersions = process.versions,
Expand All @@ -88,7 +89,7 @@ export async function runCLI(
}
forceNoColor(argv, env)
await exitIfOldNodeVersion(versions)
return launchCLI({moduleURL: options.moduleURL})
return launchCLI({moduleURL: options.moduleURL, lazyCommandLoader: options.lazyCommandLoader})
}

async function addInitToArgvWhenRunningCreateCLI(
Expand Down Expand Up @@ -152,6 +153,7 @@ export const jsonFlag = {
/**
* Clear the CLI cache, used to store some API responses and handle notifications status
*/
export function clearCache(): void {
export async function clearCache(): Promise<void> {
const {cacheClear} = await import('../../private/node/conf-store.js')
cacheClear()
}
61 changes: 54 additions & 7 deletions packages/cli-kit/src/public/node/custom-oclif-loader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,19 @@ import {execaSync} from 'execa'
import {Command, Config} from '@oclif/core'
import {Options} from '@oclif/core/interfaces'

/**
* Optional lazy command loader function.
* If set, ShopifyConfig will use it to load individual commands on demand
* instead of importing the entire COMMANDS module (which triggers loading all packages).
*/
export type LazyCommandLoader = (id: string) => Promise<typeof Command | undefined>

export class ShopifyConfig extends Config {
private lazyCommandLoader?: LazyCommandLoader

constructor(options: Options) {
if (isDevelopment()) {
// eslint-disable-next-line @shopify/cli/no-process-cwd
const currentPath = cwd()

let path = sniffForPath() ?? currentPath
Expand Down Expand Up @@ -37,6 +47,50 @@ export class ShopifyConfig extends Config {
}
}

/**
* Set a lazy command loader that will be used to load individual command classes on demand,
* bypassing the default oclif behavior of importing the entire COMMANDS module.
*
* @param loader - The lazy command loader function.
*/
setLazyCommandLoader(loader: LazyCommandLoader): void {
this.lazyCommandLoader = loader
}

/**
* Override runCommand to use lazy loading when available.
* Instead of calling cmd.load() which triggers loading ALL commands via index.js,
* we directly import only the needed command module.
*
* @param id - The command ID to run.
* @param argv - The arguments to pass to the command.
* @param cachedCommand - An optional cached command loadable.
* @returns The command result.
*/
async runCommand<T = unknown>(
id: string,
argv: string[] = [],
cachedCommand: Command.Loadable | null = null,
): Promise<T> {
if (this.lazyCommandLoader) {
const cmd = cachedCommand ?? this.findCommand(id)
if (cmd) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const commandClass = (await this.lazyCommandLoader(id)) as any
if (commandClass) {
commandClass.id = id
// eslint-disable-next-line @typescript-eslint/no-explicit-any
commandClass.plugin = cmd.plugin ?? (this as any).rootPlugin
await this.runHook('prerun', {argv, Command: commandClass})
const result = (await commandClass.run(argv, this)) as T
await this.runHook('postrun', {argv, Command: commandClass, result})
return result
}
}
}
return super.runCommand<T>(id, argv, cachedCommand)
}

customPriority(commands: Command.Loadable[]): Command.Loadable | undefined {
const oclifPlugins = this.pjson.oclif.plugins ?? []
const commandPlugins = commands.sort((aCommand, bCommand) => {
Expand All @@ -49,38 +103,31 @@ export class ShopifyConfig extends Config {

// If there is an external cli-hydrogen plugin, its commands should take priority over bundled ('core') commands
if (aCommand.pluginType === 'core' && bCommand.pluginAlias === '@shopify/cli-hydrogen') {
// If b is hydrogen and a is core sort b first
return 1
}

if (aCommand.pluginAlias === '@shopify/cli-hydrogen' && bCommand.pluginType === 'core') {
// If a is hydrogen and b is core sort a first
return -1
}

// All other cases are the default implementation from the private `determinePriority` method
// When both plugin types are 'core' plugins sort based on index
if (aCommand.pluginType === 'core' && bCommand.pluginType === 'core') {
// If b appears first in the pjson.plugins sort it first
return aIndex - bIndex
}

// if b is a core plugin and a is not sort b first
if (bCommand.pluginType === 'core' && aCommand.pluginType !== 'core') {
return 1
}

// if a is a core plugin and b is not sort a first
if (aCommand.pluginType === 'core' && bCommand.pluginType !== 'core') {
return -1
}

// if a is a jit plugin and b is not sort b first
if (aCommand.pluginType === 'jit' && bCommand.pluginType !== 'jit') {
return 1
}

// if b is a jit plugin and a is not sort a first
if (bCommand.pluginType === 'jit' && aCommand.pluginType !== 'jit') {
return -1
}
Expand Down
2 changes: 0 additions & 2 deletions packages/cli-kit/src/public/node/output.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,9 +21,7 @@ import {
} from '../../private/node/content-tokens.js'
import {tokenItemToString} from '../../private/node/ui/components/TokenizedText.js'
import {consoleLog, consoleWarn, output} from '../../private/node/output.js'

import stripAnsi from 'strip-ansi'

import {Writable} from 'stream'

import type {Change} from 'diff'
Expand Down
Loading
Loading