|
1 | 1 | /** |
2 | | - * Lazy command registry. |
| 2 | + * Manifest-based lazy command loader. |
3 | 3 | * |
4 | | - * Maps each command ID to a dynamic import function that loads ONLY that command's module. |
5 | | - * This avoids importing the entire index.ts (which pulls in `\@shopify/app`, `\@shopify/theme`, |
6 | | - * `\@shopify/cli-hydrogen`, etc.) just to run a single command. |
| 4 | + * Reads the oclif manifest to discover which package owns each command, then |
| 5 | + * derives the entry point from the command ID using a naming convention: |
| 6 | + * dist/cli/commands/\{id with : replaced by /\}.js |
| 7 | + * |
| 8 | + * This lets us dynamically import ONLY the specific command file instead of |
| 9 | + * loading every command from the package index. |
| 10 | + * |
| 11 | + * Commands from external plugins (cli-hydrogen, oclif plugins) fall back to |
| 12 | + * importing the full package. |
7 | 13 | */ |
| 14 | +import {dirname, joinPath, moduleDirectory} from '@shopify/cli-kit/node/path' |
| 15 | +import {readFileSync} from 'fs' |
| 16 | +import {createRequire} from 'module' |
| 17 | +import {pathToFileURL} from 'url' |
8 | 18 |
|
9 | | -// eslint-disable-next-line @typescript-eslint/no-explicit-any |
10 | | -type CommandLoader = () => Promise<any> |
11 | | - |
12 | | -/* eslint-disable @typescript-eslint/naming-convention */ |
13 | | -const cliCommands: Record<string, CommandLoader> = { |
14 | | - version: () => import('./cli/commands/version.js'), |
15 | | - search: () => import('./cli/commands/search.js'), |
16 | | - upgrade: () => import('./cli/commands/upgrade.js'), |
17 | | - 'auth:logout': () => import('./cli/commands/auth/logout.js'), |
18 | | - 'auth:login': () => import('./cli/commands/auth/login.js'), |
19 | | - 'debug:command-flags': () => import('./cli/commands/debug/command-flags.js'), |
20 | | - 'kitchen-sink:async': () => import('./cli/commands/kitchen-sink/async.js'), |
21 | | - 'kitchen-sink:prompts': () => import('./cli/commands/kitchen-sink/prompts.js'), |
22 | | - 'kitchen-sink:static': () => import('./cli/commands/kitchen-sink/static.js'), |
23 | | - 'kitchen-sink': () => import('./cli/commands/kitchen-sink/index.js'), |
24 | | - 'doctor-release': () => import('./cli/commands/doctor-release/doctor-release.js'), |
25 | | - 'doctor-release:theme': () => import('./cli/commands/doctor-release/theme/index.js'), |
26 | | - 'docs:generate': () => import('./cli/commands/docs/generate.js'), |
27 | | - help: () => import('./cli/commands/help.js'), |
28 | | - 'notifications:list': () => import('./cli/commands/notifications/list.js'), |
29 | | - 'notifications:generate': () => import('./cli/commands/notifications/generate.js'), |
30 | | - 'cache:clear': () => import('./cli/commands/cache/clear.js'), |
| 19 | +const require = createRequire(import.meta.url) |
| 20 | + |
| 21 | +interface ManifestCommand { |
| 22 | + customPluginName?: string |
| 23 | + pluginName?: string |
31 | 24 | } |
32 | | -/* eslint-enable @typescript-eslint/naming-convention */ |
33 | | - |
34 | | -const appCommandIds = [ |
35 | | - 'app:build', |
36 | | - 'app:bulk:cancel', |
37 | | - 'app:bulk:status', |
38 | | - 'app:deploy', |
39 | | - 'app:dev', |
40 | | - 'app:dev:clean', |
41 | | - 'app:logs', |
42 | | - 'app:logs:sources', |
43 | | - 'app:import-custom-data-definitions', |
44 | | - 'app:import-extensions', |
45 | | - 'app:info', |
46 | | - 'app:init', |
47 | | - 'app:release', |
48 | | - 'app:config:link', |
49 | | - 'app:config:use', |
50 | | - 'app:config:pull', |
51 | | - 'app:env:pull', |
52 | | - 'app:env:show', |
53 | | - 'app:execute', |
54 | | - 'app:bulk:execute', |
55 | | - 'app:generate:schema', |
56 | | - 'app:function:build', |
57 | | - 'app:function:replay', |
58 | | - 'app:function:run', |
59 | | - 'app:function:info', |
60 | | - 'app:function:schema', |
61 | | - 'app:function:typegen', |
62 | | - 'app:generate:extension', |
63 | | - 'app:versions:list', |
64 | | - 'app:webhook:trigger', |
65 | | - 'webhook:trigger', |
66 | | - 'demo:watcher', |
67 | | - 'organization:list', |
68 | | -] |
69 | 25 |
|
70 | | -// eslint-disable-next-line @typescript-eslint/no-explicit-any |
71 | | -function searchForDefault(module: any): any { |
72 | | - if (module.default?.run) return module.default |
73 | | - for (const value of Object.values(module)) { |
74 | | - // eslint-disable-next-line @typescript-eslint/no-explicit-any |
75 | | - if (typeof (value as any) === 'function' && typeof (value as any).run === 'function') return value |
| 26 | +let cachedCommands: Record<string, ManifestCommand> | undefined |
| 27 | + |
| 28 | +function getManifestCommands(): Record<string, ManifestCommand> { |
| 29 | + if (!cachedCommands) { |
| 30 | + const manifestPath = joinPath(moduleDirectory(import.meta.url), '..', 'oclif.manifest.json') |
| 31 | + cachedCommands = JSON.parse(readFileSync(manifestPath, 'utf8')).commands |
76 | 32 | } |
77 | | - return undefined |
| 33 | + return cachedCommands! |
| 34 | +} |
| 35 | + |
| 36 | +const packageDirCache = new Map<string, string>() |
| 37 | + |
| 38 | +function resolvePackageDir(packageName: string): string { |
| 39 | + let dir = packageDirCache.get(packageName) |
| 40 | + if (!dir) { |
| 41 | + dir = dirname(require.resolve(`${packageName}/package.json`)) |
| 42 | + packageDirCache.set(packageName, dir) |
| 43 | + } |
| 44 | + return dir |
78 | 45 | } |
79 | 46 |
|
| 47 | +const entryPointOverrides: Record<string, string> = { |
| 48 | + 'app:logs:sources': 'dist/cli/commands/app/app-logs/sources.js', |
| 49 | + 'demo:watcher': 'dist/cli/commands/app/demo/watcher.js', |
| 50 | + 'kitchen-sink': 'dist/cli/commands/kitchen-sink/index.js', |
| 51 | + 'doctor-release': 'dist/cli/commands/doctor-release/doctor-release.js', |
| 52 | + 'doctor-release:theme': 'dist/cli/commands/doctor-release/theme/index.js', |
| 53 | +} |
| 54 | + |
| 55 | +function entryPointForCommand(id: string): string { |
| 56 | + return entryPointOverrides[id] ?? `dist/cli/commands/${id.replace(/:/g, '/')}.js` |
| 57 | +} |
| 58 | + |
| 59 | +const packagesWithPerFileLoading = new Set(['@shopify/cli', '@shopify/app', '@shopify/theme']) |
| 60 | + |
80 | 61 | /** |
81 | 62 | * Load a command class by its ID. |
82 | | - * Returns the command class, or undefined if not found. |
| 63 | + * |
| 64 | + * Looks up the command in the oclif manifest to find the owning package, |
| 65 | + * derives the file path from the command ID, and imports only that file. |
| 66 | + * Falls back to importing the full package for external plugins. |
83 | 67 | */ |
84 | 68 | // eslint-disable-next-line @typescript-eslint/no-explicit-any |
85 | 69 | export async function loadCommand(id: string): Promise<any | undefined> { |
86 | | - // Check CLI-local commands first |
87 | | - const cliLoader = cliCommands[id] |
88 | | - if (cliLoader) { |
89 | | - const module = await cliLoader() |
90 | | - return searchForDefault(module) |
91 | | - } |
| 70 | + const commands = getManifestCommands() |
| 71 | + const entry = commands[id] |
| 72 | + if (!entry) return undefined |
92 | 73 |
|
93 | | - // App commands |
94 | | - if (appCommandIds.includes(id)) { |
95 | | - const {commands} = await import('@shopify/app') |
96 | | - return commands[id] |
97 | | - } |
| 74 | + const packageName = entry.customPluginName ?? entry.pluginName |
| 75 | + if (!packageName) return undefined |
98 | 76 |
|
99 | | - // Theme commands |
100 | | - if (id.startsWith('theme:')) { |
101 | | - const themeModule = await import('@shopify/theme') |
102 | | - return (themeModule.default as Record<string, unknown>)?.[id] |
| 77 | + if (packagesWithPerFileLoading.has(packageName)) { |
| 78 | + const packageDir = resolvePackageDir(packageName) |
| 79 | + const entryPoint = entryPointForCommand(id) |
| 80 | + const modulePath = pathToFileURL(joinPath(packageDir, entryPoint)).href |
| 81 | + const module = await import(modulePath) |
| 82 | + return module.default |
103 | 83 | } |
104 | 84 |
|
105 | | - // Hydrogen commands |
106 | | - if (id.startsWith('hydrogen:')) { |
| 85 | + return loadCommandFromPackage(id, packageName) |
| 86 | +} |
| 87 | + |
| 88 | +// eslint-disable-next-line @typescript-eslint/no-explicit-any |
| 89 | +async function loadCommandFromPackage(id: string, packageName: string): Promise<any | undefined> { |
| 90 | + if (packageName === '@shopify/cli-hydrogen') { |
107 | 91 | const {COMMANDS} = await import('@shopify/cli-hydrogen') |
108 | 92 | // eslint-disable-next-line @typescript-eslint/no-explicit-any |
109 | 93 | return (COMMANDS as any)?.[id] |
110 | 94 | } |
111 | 95 |
|
112 | | - // Plugin commands |
113 | | - if (id === 'commands') { |
| 96 | + if (packageName === '@oclif/plugin-commands') { |
114 | 97 | const {commands} = await import('@oclif/plugin-commands') |
115 | 98 | // eslint-disable-next-line @typescript-eslint/no-explicit-any |
116 | 99 | return (commands as any)[id] |
117 | 100 | } |
118 | 101 |
|
119 | | - if (id.startsWith('plugins')) { |
| 102 | + if (packageName === '@oclif/plugin-plugins') { |
120 | 103 | const {commands} = await import('@oclif/plugin-plugins') |
121 | 104 | // eslint-disable-next-line @typescript-eslint/no-explicit-any |
122 | 105 | return (commands as any)[id] |
123 | 106 | } |
124 | 107 |
|
125 | | - if (id.startsWith('config:autocorrect')) { |
| 108 | + if (packageName === '@shopify/plugin-did-you-mean') { |
126 | 109 | const {DidYouMeanCommands} = await import('@shopify/plugin-did-you-mean') |
127 | 110 | // eslint-disable-next-line @typescript-eslint/no-explicit-any |
128 | 111 | return (DidYouMeanCommands as any)[id] |
|
0 commit comments