Skip to content
Open
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 @@ -196,6 +196,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 @@ -207,7 +207,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,7 +24,10 @@ 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} {
async function loadTsConfig(
startPath: string,
): Promise<{compilerOptions: ts.CompilerOptions; configPath: string | undefined}> {
const ts = await loadTypeScript()
const configPath = ts.findConfigFile(startPath, ts.sys.fileExists.bind(ts.sys), 'tsconfig.json')
if (!configPath) {
return {compilerOptions: {}, configPath: undefined}
Expand Down Expand Up @@ -65,11 +75,12 @@ async function fallbackResolve(importPath: string, baseDir: string): Promise<str

async function parseAndResolveImports(filePath: string): Promise<string[]> {
try {
const ts = 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
Expand Down
13 changes: 10 additions & 3 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,26 +14,31 @@ 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 {ShopifyConfig} = await import('./custom-oclif-loader.js')
type OclifCore = typeof import('@oclif/core')
const oclifModule = await import('@oclif/core')
// esbuild wraps CJS dynamic imports under .default when bundling as ESM with code splitting
const {Config, run, flush, Errors, settings}: OclifCore =
const {run, flush, Errors, settings}: OclifCore =
(oclifModule as OclifCore & {default?: OclifCore}).default ?? oclifModule

if (isDevelopment()) {
settings.debug = true
}

try {
const config = new Config({root: fileURLToPath(options.moduleURL)})
const config = new ShopifyConfig({root: fileURLToPath(options.moduleURL)})
await config.load()

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

await run(options.argv, config)
await 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 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 @@ -128,9 +128,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 @@ -80,7 +81,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 @@ -91,7 +92,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 @@ -155,6 +156,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()
}
59 changes: 59 additions & 0 deletions packages/cli-kit/src/public/node/custom-oclif-loader.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import {Command, Config} from '@oclif/core'

/**
* 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>

/**
* Subclass of oclif's Config that loads command classes on demand for faster CLI startup.
*/
export class ShopifyConfig extends Config {
private lazyCommandLoader?: LazyCommandLoader

/**
* 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)
}
}
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
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,8 @@ vi.mock('../version.js')
vi.mock('../is-global.js')

describe('showMultipleCLIWarningIfNeeded', () => {
beforeEach(() => {
clearCache()
beforeEach(async () => {
await clearCache()
})

test('shows warning if using global CLI but app has local dependency', async () => {
Expand Down
41 changes: 40 additions & 1 deletion packages/cli/bin/bundle.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
/* eslint-disable @shopify/cli/specific-imports-in-bootstrap-code, @nx/enforce-module-boundaries */
import {createRequire} from 'module'
import {readFileSync} from 'fs'

import {build as esBuild} from 'esbuild'
import {copy} from 'esbuild-plugin-copy'
Expand Down Expand Up @@ -38,9 +39,47 @@ const themeUpdaterDataPath = joinPath(themeUpdaterPath, '..', '..', 'data/*')
const hydrogenPath = dirname(require.resolve('@shopify/cli-hydrogen/package.json'))
const hydrogenAssets = joinPath(hydrogenPath, 'dist/assets/hydrogen/**/*')

const commandEntryPoints = glob.sync('./src/cli/commands/**/*.ts', {
ignore: ['**/*.test.ts', '**/*.d.ts'],
})
const hookEntryPoints = glob.sync('./src/hooks/*.ts', {
ignore: ['**/*.test.ts', '**/*.d.ts'],
})

// Build esbuild entry points for app/theme commands so they get bundled into
// the CLI's own dist/ with all imports resolved. This is needed because
// @shopify/app and @shopify/theme are devDependencies (private packages) and
// won't exist as real node_modules in the published snapshot.
const manifest = JSON.parse(readFileSync(joinPath(process.cwd(), 'oclif.manifest.json'), 'utf8'))
const commandEntryPointOverrides = {
'app:logs:sources': 'cli/commands/app/app-logs/sources',
'demo:watcher': 'cli/commands/app/demo/watcher',
'kitchen-sink': 'cli/commands/kitchen-sink/index',
'doctor-release': 'cli/commands/doctor-release/doctor-release',
'doctor-release:theme': 'cli/commands/doctor-release/theme/index',
}
const externalPackageDirs = {'@shopify/app': '../app/', '@shopify/theme': '../theme/'}

const externalCommandEntryPoints = Object.entries(manifest.commands)
.filter(([, cmd]) => externalPackageDirs[cmd.customPluginName])
.map(([id, cmd]) => {
const out = commandEntryPointOverrides[id] ?? `cli/commands/${id.replace(/:/g, '/')}`
const inPath = externalPackageDirs[cmd.customPluginName] + `src/${out}.ts`
return {in: inPath, out}
})

const toEntry = (f) => ({in: f, out: f.replace('./src/', '').replace('.ts', '')})

esBuild({
bundle: true,
entryPoints: ['./src/index.ts', './src/hooks/prerun.ts', './src/hooks/postrun.ts'],
entryPoints: [
{in: './src/index.ts', out: 'index'},
{in: './src/bootstrap.ts', out: 'bootstrap'},
{in: './src/command-registry.ts', out: 'command-registry'},
...hookEntryPoints.map(toEntry),
...commandEntryPoints.map(toEntry),
...externalCommandEntryPoints,
],
outdir: './dist',
platform: 'node',
format: 'esm',
Expand Down
2 changes: 1 addition & 1 deletion packages/cli/bin/dev.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import runCLI from '../dist/index.js'
const {default: runCLI} = await import('../dist/bootstrap.js')

process.removeAllListeners('warning')

Expand Down
2 changes: 1 addition & 1 deletion packages/cli/bin/run.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
#!/usr/bin/env node

import runCLI from '../dist/index.js'
const {default: runCLI} = await import('../dist/bootstrap.js')

process.removeAllListeners('warning')

Expand Down
Loading
Loading