From 205db381479b2541ccdbf9e0f7274dde9e678cea Mon Sep 17 00:00:00 2001 From: Alfonso Noriega Date: Mon, 31 Aug 2026 12:21:43 +0200 Subject: [PATCH] Replace any-casts in command-registry with a typed command lookup Type the lazy command loader against @oclif/core's Command class and bridge the external plugin package tables through a single helper, removing the per-branch eslint-disable/any casts. Add unit tests for manifest misses and the full-package fallback path. Assisted-By: devx/c4b514ef-dee6-425a-99d0-2f300e272eb6 --- configurations/vite.config.ts | 1 + packages/cli/src/command-registry.test.ts | 22 ++++++++++++++ packages/cli/src/command-registry.ts | 36 +++++++++++++---------- 3 files changed, 44 insertions(+), 15 deletions(-) create mode 100644 packages/cli/src/command-registry.test.ts diff --git a/configurations/vite.config.ts b/configurations/vite.config.ts index 0a577cbfe58..83014004c0c 100644 --- a/configurations/vite.config.ts +++ b/configurations/vite.config.ts @@ -84,6 +84,7 @@ export const aliases = (packagePath: string) => { }, {find: '@shopify/theme', replacement: path.join(packagePath, '../theme/src/index')}, {find: '@shopify/organizations', replacement: path.join(packagePath, '../organizations/src/index')}, + {find: '@shopify/plugin-did-you-mean', replacement: path.join(packagePath, '../plugin-did-you-mean/src/index')}, {find: '@shopify/store', replacement: path.join(packagePath, '../store/src/index')}, ] } diff --git a/packages/cli/src/command-registry.test.ts b/packages/cli/src/command-registry.test.ts new file mode 100644 index 00000000000..25031cfd749 --- /dev/null +++ b/packages/cli/src/command-registry.test.ts @@ -0,0 +1,22 @@ +import {loadCommand} from './command-registry.js' +import {describe, expect, test} from 'vitest' + +describe('loadCommand', () => { + test('returns undefined for a command id that is not in the manifest', async () => { + await expect(loadCommand('definitely:not:a:command')).resolves.toBeUndefined() + }) + + test('loads an external oclif plugin command from its package command table', async () => { + // `commands` is owned by @oclif/plugin-commands, which is loaded through + // the full-package fallback rather than per-file loading. + const command = await loadCommand('commands') + + expect(command).toBeTypeOf('function') + }) + + test('loads the plugins command from @oclif/plugin-plugins', async () => { + const command = await loadCommand('plugins') + + expect(command).toBeTypeOf('function') + }) +}) diff --git a/packages/cli/src/command-registry.ts b/packages/cli/src/command-registry.ts index 2f8180831e6..0cc0d85b058 100644 --- a/packages/cli/src/command-registry.ts +++ b/packages/cli/src/command-registry.ts @@ -14,6 +14,19 @@ import {dirname, joinPath, moduleDirectory} from '@shopify/cli-kit/node/path' import {existsSync, readFileSync} from 'fs' import {fileURLToPath, pathToFileURL} from 'url' +import type {Command} from '@oclif/core' + +type CommandClass = typeof Command + +/** + * Look up a command class in the command table exported by an external plugin + * package. Each package compiles against its own `@oclif/core`, so its command + * classes are not statically assignable to this workspace's `typeof Command`; + * this helper is the single place where that boundary is bridged. + */ +function commandFromTable(table: unknown, id: string): CommandClass | undefined { + return (table as Record)[id] +} interface ManifestCommand { customPluginName?: string @@ -81,8 +94,7 @@ const packagesWithPerFileLoading = new Set(['@shopify/cli', '@shopify/app', '@sh * 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 { +export async function loadCommand(id: string): Promise { const commands = getManifestCommands() const entry = commands[id] if (!entry) return undefined @@ -112,39 +124,33 @@ function resolveCommandRoot(id: string, packageName: string): string { return resolvePackageDir(packageName) } -// eslint-disable-next-line @typescript-eslint/no-explicit-any -async function loadCommandPerFile(id: string, packageName: string): Promise { +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) + const module: {default?: CommandClass} = await import(modulePath) return module.default } -// eslint-disable-next-line @typescript-eslint/no-explicit-any -async function loadCommandFromPackage(id: string, packageName: string): Promise { +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] + return commandFromTable(COMMANDS, 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] + return commandFromTable(commands, 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] + return commandFromTable(commands, 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 commandFromTable(DidYouMeanCommands, id) } return undefined