diff --git a/eslint.config.js b/eslint.config.js index 2ef521d66a8..2fa0a0b9e37 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -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 diff --git a/package.json b/package.json index 811cb72aa0c..f1389106c03 100644 --- a/package.json +++ b/package.json @@ -211,7 +211,8 @@ "entry": [ "**/{commands,hooks}/**/*.ts!", "**/bin/*.js!", - "**/index.ts!" + "**/index.ts!", + "**/bootstrap.ts!" ], "project": "**/*.ts!", "ignoreDependencies": [ diff --git a/packages/app/package.json b/packages/app/package.json index 3d8d37e1a6b..f4a5a855fd7 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -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": [ diff --git a/packages/app/src/cli/models/extensions/specifications/type-generation.ts b/packages/app/src/cli/models/extensions/specifications/type-generation.ts index 6be61749f49..e519bb0a737 100644 --- a/packages/app/src/cli/models/extensions/specifications/type-generation.ts +++ b/packages/app/src/cli/models/extensions/specifications/type-generation.ts @@ -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 { + // 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) @@ -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} @@ -65,11 +75,12 @@ async function fallbackResolve(importPath: string, baseDir: string): Promise { 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 @@ -173,7 +184,7 @@ interface CreateTypeDefinitionOptions { * Uses the TS compiler API to avoid false positives from comments or string * literals that happen to contain the word "ShopifyGlobal". */ -function targetExportsShopifyGlobal(targetDtsPath: string): boolean { +async function targetExportsShopifyGlobal(targetDtsPath: string): Promise { let content: string try { content = readFileSync(targetDtsPath).toString() @@ -182,6 +193,7 @@ function targetExportsShopifyGlobal(targetDtsPath: string): boolean { return false } + const ts = await loadTypeScript() const sourceFile = ts.createSourceFile(targetDtsPath, content, ts.ScriptTarget.Latest, true, ts.ScriptKind.TS) let found = false @@ -216,34 +228,35 @@ function targetExportsShopifyGlobal(targetDtsPath: string): boolean { * * Returns null if no targets are provided. */ -function buildShopifyType( +async function buildShopifyType( targets: string[], resolvedTargetPaths: Map, toolsTypeDefinition?: string, -): string | null { +): Promise { const toolsSuffix = toolsTypeDefinition ? ' & { tools: ShopifyTools }' : '' - const typeForTarget = (target: string): string => { + const typeForTarget = async (target: string): Promise => { const base = `import('@shopify/ui-extensions/${target}').Api` const dtsPath = resolvedTargetPaths.get(target) - if (dtsPath && targetExportsShopifyGlobal(dtsPath)) { + if (dtsPath && (await targetExportsShopifyGlobal(dtsPath))) { return `${base} & import('@shopify/ui-extensions/${target}').ShopifyGlobal` } return base } if (targets.length === 0) return null - if (targets.length === 1) return `${typeForTarget(targets[0] ?? '')}${toolsSuffix}` - return `(${targets.map(typeForTarget).join(' | ')})${toolsSuffix}` + if (targets.length === 1) return `${await typeForTarget(targets[0] ?? '')}${toolsSuffix}` + const typesForTargets = await Promise.all(targets.map(typeForTarget)) + return `(${typesForTargets.join(' | ')})${toolsSuffix}` } -export function createTypeDefinition({ +export async function createTypeDefinition({ fullPath, typeFilePath, targets, apiVersion, toolsTypeDefinition, -}: CreateTypeDefinitionOptions): string | null { +}: CreateTypeDefinitionOptions): Promise { try { const resolvedTargetPaths = new Map() @@ -266,7 +279,7 @@ export function createTypeDefinition({ const relativePath = relativizePath(fullPath, dirname(typeFilePath)) - const shopifyType = buildShopifyType(targets, resolvedTargetPaths, toolsTypeDefinition) + const shopifyType = await buildShopifyType(targets, resolvedTargetPaths, toolsTypeDefinition) if (!shopifyType) return null const lines = [ diff --git a/packages/app/src/cli/models/extensions/specifications/ui_extension.ts b/packages/app/src/cli/models/extensions/specifications/ui_extension.ts index 815ab533993..085aa919eaf 100644 --- a/packages/app/src/cli/models/extensions/specifications/ui_extension.ts +++ b/packages/app/src/cli/models/extensions/specifications/ui_extension.ts @@ -300,7 +300,7 @@ const uiExtensionSpec = createExtensionSpecification({ ) } } - let typeDefinition = createTypeDefinition({ + let typeDefinition = await createTypeDefinition({ fullPath: filePath, typeFilePath, targets: uniqueTargets, diff --git a/packages/cli-kit/src/public/node/cli-launcher.ts b/packages/cli-kit/src/public/node/cli-launcher.ts index 6d9b6b2725d..48040569a91 100644 --- a/packages/cli-kit/src/public/node/cli-launcher.ts +++ b/packages/cli-kit/src/public/node/cli-launcher.ts @@ -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 } /** @@ -12,12 +14,12 @@ interface Options { * @returns A promise that resolves when the CLI has been launched. */ export async function launchCLI(options: Options): Promise { - 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()) { @@ -25,13 +27,18 @@ export async function launchCLI(options: Options): Promise { } 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) } diff --git a/packages/cli-kit/src/public/node/cli.test.ts b/packages/cli-kit/src/public/node/cli.test.ts index 61f8f7d8ccb..5aa8dc8410b 100644 --- a/packages/cli-kit/src/public/node/cli.test.ts +++ b/packages/cli-kit/src/public/node/cli.test.ts @@ -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() }) diff --git a/packages/cli-kit/src/public/node/cli.ts b/packages/cli-kit/src/public/node/cli.ts index 4adb3268f7a..fd2836734f8 100644 --- a/packages/cli-kit/src/public/node/cli.ts +++ b/packages/cli-kit/src/public/node/cli.ts @@ -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 @@ -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) { @@ -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 = defaultLaunchCli, + launchCLI: (options: {moduleURL: string; lazyCommandLoader?: LazyCommandLoader}) => Promise = defaultLaunchCli, argv: string[] = process.argv, env: NodeJS.ProcessEnv = process.env, versions: NodeJS.ProcessVersions = process.versions, @@ -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( @@ -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 { + const {cacheClear} = await import('../../private/node/conf-store.js') cacheClear() } diff --git a/packages/cli-kit/src/public/node/custom-oclif-loader.ts b/packages/cli-kit/src/public/node/custom-oclif-loader.ts new file mode 100644 index 00000000000..4e33a052e6b --- /dev/null +++ b/packages/cli-kit/src/public/node/custom-oclif-loader.ts @@ -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 + +/** + * 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( + id: string, + argv: string[] = [], + cachedCommand: Command.Loadable | null = null, + ): Promise { + 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(id, argv, cachedCommand) + } +} diff --git a/packages/cli-kit/src/public/node/output.ts b/packages/cli-kit/src/public/node/output.ts index 656809603f6..d1488702da1 100644 --- a/packages/cli-kit/src/public/node/output.ts +++ b/packages/cli-kit/src/public/node/output.ts @@ -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' diff --git a/packages/cli-kit/src/public/node/plugins/multiple-installation-warning.test.ts b/packages/cli-kit/src/public/node/plugins/multiple-installation-warning.test.ts index aaa8d3c4e7c..f20c6cd87ed 100644 --- a/packages/cli-kit/src/public/node/plugins/multiple-installation-warning.test.ts +++ b/packages/cli-kit/src/public/node/plugins/multiple-installation-warning.test.ts @@ -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 () => { diff --git a/packages/cli/bin/bundle.js b/packages/cli/bin/bundle.js index a683ed7dfa5..9a467ebf003 100644 --- a/packages/cli/bin/bundle.js +++ b/packages/cli/bin/bundle.js @@ -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' @@ -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', diff --git a/packages/cli/bin/dev.js b/packages/cli/bin/dev.js index da29908c490..936614ff521 100755 --- a/packages/cli/bin/dev.js +++ b/packages/cli/bin/dev.js @@ -1,4 +1,4 @@ -import runCLI from '../dist/index.js' +const {default: runCLI} = await import('../dist/bootstrap.js') process.removeAllListeners('warning') diff --git a/packages/cli/bin/run.js b/packages/cli/bin/run.js index 6cc0c0bc3cc..7f8f21821c0 100755 --- a/packages/cli/bin/run.js +++ b/packages/cli/bin/run.js @@ -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') diff --git a/packages/cli/package.json b/packages/cli/package.json index d664c7f2861..43ecdd39d03 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -137,44 +137,20 @@ ], "hooks": { "init": [ - { - "target": "./dist/index.js", - "identifier": "AppInitHook" - }, - { - "target": "./dist/index.js", - "identifier": "HydrogenInitHook" - } + "./dist/hooks/app-init.js", + "./dist/hooks/hydrogen-init.js" ], "prerun": "./dist/hooks/prerun.js", "postrun": "./dist/hooks/postrun.js", - "command_not_found": { - "target": "./dist/index.js", - "identifier": "DidYouMeanHook" - }, - "tunnel_start": { - "target": "./dist/index.js", - "identifier": "TunnelStartHook" - }, - "tunnel_provider": { - "target": "./dist/index.js", - "identifier": "TunnelProviderHook" - }, - "update": { - "target": "./dist/index.js", - "identifier": "PluginHook" - }, + "command_not_found": "./dist/hooks/did-you-mean.js", + "tunnel_start": "./dist/hooks/tunnel-start.js", + "tunnel_provider": "./dist/hooks/tunnel-provider.js", + "update": "./dist/hooks/plugin-plugins.js", "sensitive_command_metadata": [ - { - "target": "./dist/index.js", - "identifier": "AppSensitiveMetadataHook" - } + "./dist/hooks/sensitive-metadata.js" ], "public_command_metadata": [ - { - "target": "./dist/index.js", - "identifier": "AppPublicMetadataHook" - } + "./dist/hooks/public-metadata.js" ] } } diff --git a/packages/cli/project.json b/packages/cli/project.json index 624a373411c..188763bf0ee 100644 --- a/packages/cli/project.json +++ b/packages/cli/project.json @@ -25,6 +25,7 @@ "bundle": { "executor": "nx:run-commands", "dependsOn": [ + "build", "cli-kit:generate-version", "app:build", "theme:build" diff --git a/packages/cli/src/bootstrap.ts b/packages/cli/src/bootstrap.ts new file mode 100644 index 00000000000..7e99f28509e --- /dev/null +++ b/packages/cli/src/bootstrap.ts @@ -0,0 +1,69 @@ +/** + * Lightweight CLI bootstrap module. + * + * This file is the entry point for bin/dev.js and bin/run.js. + * It intentionally does NOT import any command modules or heavy packages. + * Commands are loaded lazily by oclif from the manifest + index.ts only when needed. + */ +import {loadCommand} from './command-registry.js' +import {createGlobalProxyAgent} from 'global-agent' +import {runCLI} from '@shopify/cli-kit/node/cli' + +import fs from 'fs' + +// Setup global support for environment variable based proxy configuration. +createGlobalProxyAgent({ + environmentVariableNamespace: 'SHOPIFY_', + forceGlobalAgent: true, + socketConnectionTimeout: 60000, +}) + +// In some cases (for example when we boot the proxy server), when an exception is +// thrown, no 'exit' signal is sent to the process. We don't understand this fully. +// This means that any cleanup code that depends on "process.on('exit', ...)" will +// not be called. The tunnel plugin is an example of that. Here we make sure to print +// the error stack and manually call exit so that the cleanup code is called. This +// makes sure that there are no lingering tunnel processes. +// eslint-disable-next-line @typescript-eslint/no-misused-promises +process.on('uncaughtException', async (err) => { + try { + const {FatalError} = await import('@shopify/cli-kit/node/error') + if (err instanceof FatalError) { + const {renderFatalError} = await import('@shopify/cli-kit/node/ui') + renderFatalError(err) + } else { + fs.writeSync(process.stderr.fd, `${err.stack ?? err.message ?? err}\n`) + } + // eslint-disable-next-line no-catch-all/no-catch-all + } catch { + fs.writeSync(process.stderr.fd, `${err.stack ?? err.message ?? err}\n`) + } + process.exit(1) +}) +const signals = ['SIGINT', 'SIGTERM', 'SIGQUIT'] +signals.forEach((signal) => { + process.on(signal, () => { + process.exit(1) + }) +}) + +// Sometimes we want to specify a precise amount of stdout columns, for example in +// CI or on a cloud environment. +const columns = Number(process.env.SHOPIFY_CLI_COLUMNS) +if (!isNaN(columns)) { + process.stdout.columns = columns +} + +interface RunShopifyCLIOptions { + development: boolean +} + +async function runShopifyCLI({development}: RunShopifyCLIOptions) { + await runCLI({ + moduleURL: import.meta.url, + development, + lazyCommandLoader: loadCommand, + }) +} + +export default runShopifyCLI diff --git a/packages/cli/src/cli/commands/cache/clear.ts b/packages/cli/src/cli/commands/cache/clear.ts index ea901b071c8..a16d57d6b2c 100644 --- a/packages/cli/src/cli/commands/cache/clear.ts +++ b/packages/cli/src/cli/commands/cache/clear.ts @@ -6,6 +6,6 @@ export default class ClearCache extends Command { static hidden = true async run(): Promise { - clearCache() + await clearCache() } } diff --git a/packages/cli/src/command-registry.ts b/packages/cli/src/command-registry.ts new file mode 100644 index 00000000000..2f8180831e6 --- /dev/null +++ b/packages/cli/src/command-registry.ts @@ -0,0 +1,151 @@ +/** + * Manifest-based lazy command loader. + * + * Reads the oclif manifest to discover which package owns each command, then + * derives the entry point from the command ID using a naming convention: + * dist/cli/commands/\{id with : replaced by /\}.js + * + * This lets us dynamically import ONLY the specific command file instead of + * loading every command from the package index. + * + * Commands from external plugins (cli-hydrogen, oclif plugins) fall back to + * importing the full package. + */ +import {dirname, joinPath, moduleDirectory} from '@shopify/cli-kit/node/path' +import {existsSync, readFileSync} from 'fs' +import {fileURLToPath, pathToFileURL} from 'url' + +interface ManifestCommand { + customPluginName?: string + pluginName?: string +} + +let cachedCommands: Record | undefined + +function getManifestCommands(): Record { + if (!cachedCommands) { + const manifestPath = joinPath(moduleDirectory(import.meta.url), '..', 'oclif.manifest.json') + cachedCommands = JSON.parse(readFileSync(manifestPath, 'utf8')).commands + } + return cachedCommands! +} + +const packageDirCache = new Map() + +function resolvePackageDir(packageName: string): string { + let dir = packageDirCache.get(packageName) + if (!dir) { + // Resolve the main entry (respects the "import" condition in exports) + // then walk up to find the package root directory. + dir = dirname(fileURLToPath(import.meta.resolve(packageName))) + while (dir !== dirname(dir)) { + try { + const pkg = JSON.parse(readFileSync(joinPath(dir, 'package.json'), 'utf8')) + if (pkg.name === packageName) break + } catch (error: unknown) { + if (error instanceof SyntaxError) { + dir = dirname(dir) + continue + } + if ((error as NodeJS.ErrnoException).code === 'ENOENT') { + dir = dirname(dir) + continue + } + throw error + } + dir = dirname(dir) + } + packageDirCache.set(packageName, dir) + } + return dir +} + +const entryPointOverrides: Record = { + 'app:logs:sources': 'dist/cli/commands/app/app-logs/sources.js', + 'demo:watcher': 'dist/cli/commands/app/demo/watcher.js', + 'kitchen-sink': 'dist/cli/commands/kitchen-sink/index.js', + 'doctor-release': 'dist/cli/commands/doctor-release/doctor-release.js', + 'doctor-release:theme': 'dist/cli/commands/doctor-release/theme/index.js', +} + +function entryPointForCommand(id: string): string { + return entryPointOverrides[id] ?? `dist/cli/commands/${id.replace(/:/g, '/')}.js` +} + +const packagesWithPerFileLoading = new Set(['@shopify/cli', '@shopify/app', '@shopify/theme']) + +/** + * Load a command class by its ID. + * + * Looks up the command in the oclif manifest to find the owning package, + * derives the file path from the command ID, and imports only that file. + * Falls back to importing the full package for external plugins. + */ +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export async function loadCommand(id: string): Promise { + const commands = getManifestCommands() + const entry = commands[id] + if (!entry) return undefined + + const packageName = entry.customPluginName ?? entry.pluginName + if (!packageName) return undefined + + if (packagesWithPerFileLoading.has(packageName)) { + return loadCommandPerFile(id, packageName) + } + + return loadCommandFromPackage(id, packageName) +} + +const cliRoot = joinPath(moduleDirectory(import.meta.url), '..') + +/** + * Resolve the package directory that contains the command file. + * In bundled builds, esbuild places all command files in the CLI's own dist/, + * so we check there first. In development the file only exists in the owning + * package's dist/, so we fall through to resolvePackageDir. + */ +function resolveCommandRoot(id: string, packageName: string): string { + const entryPoint = entryPointForCommand(id) + const localPath = joinPath(cliRoot, entryPoint) + if (existsSync(localPath)) return cliRoot + return resolvePackageDir(packageName) +} + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +async function loadCommandPerFile(id: string, packageName: string): Promise { + const entryPoint = entryPointForCommand(id) + const root = resolveCommandRoot(id, packageName) + const modulePath = pathToFileURL(joinPath(root, entryPoint)).href + const module = await import(modulePath) + return module.default +} + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +async function loadCommandFromPackage(id: string, packageName: string): Promise { + if (packageName === '@shopify/cli-hydrogen') { + const {COMMANDS} = await import('@shopify/cli-hydrogen') + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return (COMMANDS as any)?.[id] + } + + if (packageName === '@oclif/plugin-commands') { + const {commands} = await import('@oclif/plugin-commands') + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return (commands as any)[id] + } + + if (packageName === '@oclif/plugin-plugins') { + const {commands} = await import('@oclif/plugin-plugins') + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return (commands as any)[id] + } + + if (packageName === '@shopify/plugin-did-you-mean') { + const {DidYouMeanCommands} = await import('@shopify/plugin-did-you-mean') + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return (DidYouMeanCommands as any)[id] + } + + return undefined +} diff --git a/packages/cli/src/hooks/app-init.ts b/packages/cli/src/hooks/app-init.ts new file mode 100644 index 00000000000..9a30373a7a2 --- /dev/null +++ b/packages/cli/src/hooks/app-init.ts @@ -0,0 +1 @@ +export {AppInitHook as default} from '@shopify/app' diff --git a/packages/cli/src/hooks/did-you-mean.ts b/packages/cli/src/hooks/did-you-mean.ts new file mode 100644 index 00000000000..a9fdb81a5be --- /dev/null +++ b/packages/cli/src/hooks/did-you-mean.ts @@ -0,0 +1 @@ +export {DidYouMeanHook as default} from '@shopify/plugin-did-you-mean' diff --git a/packages/cli/src/hooks/hydrogen-init.ts b/packages/cli/src/hooks/hydrogen-init.ts new file mode 100644 index 00000000000..f268e22017a --- /dev/null +++ b/packages/cli/src/hooks/hydrogen-init.ts @@ -0,0 +1,5 @@ +import {HOOKS} from '@shopify/cli-hydrogen' +import type {Hook} from '@oclif/core' + +const hook = HOOKS.init as unknown as Hook<'init'> +export default hook diff --git a/packages/cli/src/hooks/plugin-plugins.ts b/packages/cli/src/hooks/plugin-plugins.ts new file mode 100644 index 00000000000..75f830fa23e --- /dev/null +++ b/packages/cli/src/hooks/plugin-plugins.ts @@ -0,0 +1 @@ +export {hooks as default} from '@oclif/plugin-plugins' diff --git a/packages/cli/src/hooks/public-metadata.ts b/packages/cli/src/hooks/public-metadata.ts new file mode 100644 index 00000000000..ce583513c86 --- /dev/null +++ b/packages/cli/src/hooks/public-metadata.ts @@ -0,0 +1 @@ +export {default} from '@shopify/app/hooks/public-metadata' diff --git a/packages/cli/src/hooks/sensitive-metadata.ts b/packages/cli/src/hooks/sensitive-metadata.ts new file mode 100644 index 00000000000..66841f96487 --- /dev/null +++ b/packages/cli/src/hooks/sensitive-metadata.ts @@ -0,0 +1 @@ +export {default} from '@shopify/app/hooks/sensitive-metadata' diff --git a/packages/cli/src/hooks/tunnel-provider.ts b/packages/cli/src/hooks/tunnel-provider.ts new file mode 100644 index 00000000000..d67aee7193e --- /dev/null +++ b/packages/cli/src/hooks/tunnel-provider.ts @@ -0,0 +1 @@ +export {default} from '@shopify/plugin-cloudflare/hooks/provider' diff --git a/packages/cli/src/hooks/tunnel-start.ts b/packages/cli/src/hooks/tunnel-start.ts new file mode 100644 index 00000000000..57b5610499b --- /dev/null +++ b/packages/cli/src/hooks/tunnel-start.ts @@ -0,0 +1 @@ +export {default} from '@shopify/plugin-cloudflare/hooks/tunnel' diff --git a/packages/plugin-did-you-mean/src/services/conf.ts b/packages/plugin-did-you-mean/src/services/conf.ts index 631db7730f7..dd588e5b6b8 100644 --- a/packages/plugin-did-you-mean/src/services/conf.ts +++ b/packages/plugin-did-you-mean/src/services/conf.ts @@ -9,9 +9,7 @@ export function setAutocorrect(value: boolean, conf: LocalStorage } function getConfig() { - if (!configInstance) { - configInstance = new LocalStorage({projectName: 'did-you-mean'}) - } + configInstance ??= new LocalStorage({projectName: 'did-you-mean'}) return configInstance } diff --git a/packages/theme/src/cli/commands/theme/init.ts b/packages/theme/src/cli/commands/theme/init.ts index c4bb6761f43..d37ff534996 100644 --- a/packages/theme/src/cli/commands/theme/init.ts +++ b/packages/theme/src/cli/commands/theme/init.ts @@ -60,7 +60,7 @@ export default class Init extends ThemeCommand { static multiEnvironmentsFlags: RequiredFlags = null async command(flags: InitFlags, _adminSession: AdminSession, _multiEnvironment: boolean, args: InitArgs) { - const name = args.name || (await this.promptName(flags.path)) + const name = args.name ?? (await this.promptName(flags.path)) const repoUrl = flags['clone-url'] const destination = joinPath(flags.path, name) diff --git a/packages/theme/src/cli/commands/theme/preview.ts b/packages/theme/src/cli/commands/theme/preview.ts index f0cacecfb2f..1fd41aea797 100644 --- a/packages/theme/src/cli/commands/theme/preview.ts +++ b/packages/theme/src/cli/commands/theme/preview.ts @@ -10,8 +10,7 @@ import {InferredFlags} from '@oclif/core/interfaces' type PreviewFlags = InferredFlags export default class Preview extends ThemeCommand { - static summary = - 'Applies JSON overrides to a theme and returns a preview URL.' + static summary = 'Applies JSON overrides to a theme and returns a preview URL.' static descriptionWithMarkdown = `Applies a JSON overrides file to a theme and creates or updates a preview. This lets you quickly preview changes. diff --git a/packages/theme/src/cli/services/check.ts b/packages/theme/src/cli/services/check.ts index 3944b5467eb..090eb9ec370 100644 --- a/packages/theme/src/cli/services/check.ts +++ b/packages/theme/src/cli/services/check.ts @@ -135,12 +135,9 @@ export function sortOffenses(offenses: Offense[]): OffenseMap { const offensesByFile = offenses.reduce((acc: OffenseMap, offense: Offense) => { const {uri} = offense const filePath = pathUtils.fsPath(uri) - if (!acc[filePath]) { - acc[filePath] = [] - } + acc[filePath] ??= [] - // eslint-disable-next-line @typescript-eslint/no-unnecessary-type-assertion - acc[filePath]!.push(offense) + acc[filePath].push(offense) return acc }, {}) diff --git a/packages/theme/src/cli/services/dev.ts b/packages/theme/src/cli/services/dev.ts index 47ad2a174a7..a00c8cf1753 100644 --- a/packages/theme/src/cli/services/dev.ts +++ b/packages/theme/src/cli/services/dev.ts @@ -163,6 +163,8 @@ export function createKeypressHandler( } switch (key.name) { + case undefined: + break case 't': debouncedOpenURL(urls.local, 'localhost') break diff --git a/packages/theme/src/cli/services/info.ts b/packages/theme/src/cli/services/info.ts index e14068f6932..b49c24f93fd 100644 --- a/packages/theme/src/cli/services/info.ts +++ b/packages/theme/src/cli/services/info.ts @@ -103,7 +103,7 @@ async function systemInfoSection(config: {cliVersion: string}): Promise | undefined function themeLocalStorage() { - if (!_themeLocalStorageInstance) { - _themeLocalStorageInstance = new LocalStorage({projectName: 'shopify-cli-theme-conf'}) - } + _themeLocalStorageInstance ??= new LocalStorage({projectName: 'shopify-cli-theme-conf'}) return _themeLocalStorageInstance } function developmentThemeLocalStorage() { - if (!_developmentThemeLocalStorageInstance) { - _developmentThemeLocalStorageInstance = new LocalStorage({ - projectName: 'shopify-cli-development-theme-config', - }) - } + _developmentThemeLocalStorageInstance ??= new LocalStorage({ + projectName: 'shopify-cli-development-theme-config', + }) return _developmentThemeLocalStorageInstance } function replThemeLocalStorage() { - if (!_replThemeLocalStorageInstance) { - _replThemeLocalStorageInstance = new LocalStorage({ - projectName: 'shopify-cli-repl-theme-config', - }) - } + _replThemeLocalStorageInstance ??= new LocalStorage({ + projectName: 'shopify-cli-repl-theme-config', + }) return _replThemeLocalStorageInstance } function themeStorePasswordStorage() { - if (!_themeStorePasswordStorageInstance) { - _themeStorePasswordStorageInstance = new LocalStorage({ - projectName: 'shopify-cli-theme-store-password', - }) - } + _themeStorePasswordStorageInstance ??= new LocalStorage({ + projectName: 'shopify-cli-theme-store-password', + }) return _themeStorePasswordStorageInstance } export function getThemeStore(storage: LocalStorage = themeLocalStorage()) { const context = themeStoreContext.getStore() - return context?.store ? context.store : storage.get('themeStore') + return context?.store ?? storage.get('themeStore') } export function setThemeStore(store: string, storage: LocalStorage = themeLocalStorage()) { diff --git a/packages/theme/src/cli/services/rename.ts b/packages/theme/src/cli/services/rename.ts index 51052756cd7..4d487367552 100644 --- a/packages/theme/src/cli/services/rename.ts +++ b/packages/theme/src/cli/services/rename.ts @@ -14,7 +14,7 @@ export interface RenameOptions { } export async function renameTheme(options: RenameOptions, adminSession: AdminSession) { - const newName = options.name || (await promptThemeName('New name for the theme')) + const newName = options.name ?? (await promptThemeName('New name for the theme')) const theme = await findOrSelectTheme(adminSession, { header: 'Select a theme to rename', diff --git a/packages/theme/src/cli/utilities/asset-checksum.ts b/packages/theme/src/cli/utilities/asset-checksum.ts index 3abef33c68f..fe4cff96eb1 100644 --- a/packages/theme/src/cli/utilities/asset-checksum.ts +++ b/packages/theme/src/cli/utilities/asset-checksum.ts @@ -86,9 +86,7 @@ function md5(content: string | Buffer) { */ export function rejectGeneratedStaticAssets(themeChecksums: Checksum[]) { const liquidAssetKeys = new Set( - themeChecksums - .filter(({key}) => key.startsWith('assets/') && key.endsWith('.liquid')) - .map(({key}) => key), + themeChecksums.filter(({key}) => key.startsWith('assets/') && key.endsWith('.liquid')).map(({key}) => key), ) return themeChecksums.filter(({key}) => { diff --git a/packages/theme/src/cli/utilities/repl/evaluator.test.ts b/packages/theme/src/cli/utilities/repl/evaluator.test.ts index 6eac7e8a1b9..d55a10f7e0e 100644 --- a/packages/theme/src/cli/utilities/repl/evaluator.test.ts +++ b/packages/theme/src/cli/utilities/repl/evaluator.test.ts @@ -225,7 +225,7 @@ function createMockResponse({ status, text: vi.fn().mockResolvedValue(text), headers: { - get: vi.fn((header: string) => headers[header] || null), + get: vi.fn((header: string) => headers[header] ?? null), }, } } diff --git a/packages/theme/src/cli/utilities/repl/evaluator.ts b/packages/theme/src/cli/utilities/repl/evaluator.ts index 1624750715e..643f46c8e9f 100644 --- a/packages/theme/src/cli/utilities/repl/evaluator.ts +++ b/packages/theme/src/cli/utilities/repl/evaluator.ts @@ -18,10 +18,10 @@ export interface EvaluationConfig { export async function evaluate(config: EvaluationConfig): Promise { return ( - (await evalResult(config)) || - (await evalContext(config)) || - (await evalAssignmentContext(config)) || - (await evalSyntaxError(config)) || + (await evalResult(config)) ?? + (await evalContext(config)) ?? + (await evalAssignmentContext(config)) ?? + (await evalSyntaxError(config)) ?? undefined ) } @@ -164,7 +164,7 @@ function isTooManyRequests(response: Response): boolean { function isResourceNotFound(response: Response): boolean { // We don't look for the status code here because the Section Rendering API returns 200 even on unknown paths. - return response.headers.get('server-timing')?.includes('pageType;desc="404"') || false + return response.headers.get('server-timing')?.includes('pageType;desc="404"') ?? false } function expiredSessionError(): never { diff --git a/packages/theme/src/cli/utilities/repl/repl.ts b/packages/theme/src/cli/utilities/repl/repl.ts index 090a48fd9c3..adf44905552 100644 --- a/packages/theme/src/cli/utilities/repl/repl.ts +++ b/packages/theme/src/cli/utilities/repl/repl.ts @@ -49,7 +49,7 @@ export async function handleInput( rl.close() if (error instanceof Error) { - outputDebug(error.stack || 'Error backtrace not found') + outputDebug(error.stack ?? 'Error backtrace not found') throw new AbortError(error.message) } else { throw new AbortError('An unknown error occurred. Please try again.') diff --git a/packages/theme/src/cli/utilities/theme-environment/local-assets.ts b/packages/theme/src/cli/utilities/theme-environment/local-assets.ts index 4fcccf818e2..4b5314b0b2d 100644 --- a/packages/theme/src/cli/utilities/theme-environment/local-assets.ts +++ b/packages/theme/src/cli/utilities/theme-environment/local-assets.ts @@ -109,7 +109,10 @@ function handleCompiledAssetRequest(event: H3Event, ctx: DevServerContext) { return handleBlockScriptsJs(ctx, event, 'snippet') case 'scripts.js': return handleBlockScriptsJs(ctx, event, 'section') + case undefined: + break default: + break } } diff --git a/packages/theme/src/cli/utilities/theme-environment/storefront-password-prompt.ts b/packages/theme/src/cli/utilities/theme-environment/storefront-password-prompt.ts index 7f176157510..aef7bd773e2 100644 --- a/packages/theme/src/cli/utilities/theme-environment/storefront-password-prompt.ts +++ b/packages/theme/src/cli/utilities/theme-environment/storefront-password-prompt.ts @@ -20,8 +20,8 @@ export async function ensureValidPassword(password: string | undefined, store: s } let finalPassword = - password || - getStorefrontPassword() || + password ?? + getStorefrontPassword() ?? (await promptPassword([ 'Enter your', { diff --git a/packages/theme/src/cli/utilities/theme-ext-environment/theme-ext-fs.ts b/packages/theme/src/cli/utilities/theme-ext-environment/theme-ext-fs.ts index bb168f4984f..9672ae52f4f 100644 --- a/packages/theme/src/cli/utilities/theme-ext-environment/theme-ext-fs.ts +++ b/packages/theme/src/cli/utilities/theme-ext-environment/theme-ext-fs.ts @@ -56,7 +56,7 @@ export function mountThemeExtensionFileSystem(root: string): ThemeExtensionFileS unsyncedFileKeys.add(file.key) - return file.value || file.attachment || '' + return file.value ?? file.attachment ?? '' }) sleep(5) diff --git a/packages/theme/src/cli/utilities/theme-previews/preview.test.ts b/packages/theme/src/cli/utilities/theme-previews/preview.test.ts index 33cd2aac764..8e64123fca9 100644 --- a/packages/theme/src/cli/utilities/theme-previews/preview.test.ts +++ b/packages/theme/src/cli/utilities/theme-previews/preview.test.ts @@ -68,9 +68,9 @@ describe('createThemePreview', () => { vi.mocked(shopifyFetch).mockResolvedValue(jsonResponse({}, {status: 422, statusText: 'Unprocessable Entity'})) // When/Then - await expect( - createThemePreview({session, overridesContent: overrides, themeId: expectedThemeId}), - ).rejects.toThrow('Theme preview request failed with status 422: Unprocessable Entity') + await expect(createThemePreview({session, overridesContent: overrides, themeId: expectedThemeId})).rejects.toThrow( + 'Theme preview request failed with status 422: Unprocessable Entity', + ) }) test('throws AbortError when the response body contains an error', async () => { @@ -82,9 +82,9 @@ describe('createThemePreview', () => { ) // When/Then - await expect( - createThemePreview({session, overridesContent: overrides, themeId: expectedThemeId}), - ).rejects.toThrow('Theme preview failed: Invalid template') + await expect(createThemePreview({session, overridesContent: overrides, themeId: expectedThemeId})).rejects.toThrow( + 'Theme preview failed: Invalid template', + ) }) }) diff --git a/packages/theme/src/cli/utilities/theme-selector/filter.ts b/packages/theme/src/cli/utilities/theme-selector/filter.ts index 7300f610977..d7a5b11f6bd 100644 --- a/packages/theme/src/cli/utilities/theme-selector/filter.ts +++ b/packages/theme/src/cli/utilities/theme-selector/filter.ts @@ -3,7 +3,7 @@ import {Theme} from '@shopify/cli-kit/node/themes/types' import {AbortError} from '@shopify/cli-kit/node/error' export function filterThemes(store: string, themes: Theme[], filter: Filter): Theme[] { - return filterByRole(store, themes, filter) || filterByTheme(store, themes, filter) + return filterByRole(store, themes, filter) ?? filterByTheme(store, themes, filter) } function filterByRole(store: string, themes: Theme[], filter: Filter) {