Fix custom-oclif-loader.ts hydrogen loader strategy for hydrogen-cli local development#6827
Fix custom-oclif-loader.ts hydrogen loader strategy for hydrogen-cli local development#6827
Conversation
This comment has been minimized.
This comment has been minimized.
Coverage report
Test suite run success3789 tests passing in 1453 suites. Report generated by 🧪jest coverage report action from 72d3411 |
|
Changes make sense, thank you for the pairing @andguy95. We'll want to make sure @Shopify/app-inner-loop takes a peek as they have historically made the changes to the file. |
| import {isDevelopment} from './context/local.js' | ||
| import {execaSync} from 'execa' | ||
| import {Command, Config} from '@oclif/core' | ||
| import {Config} from '@oclif/core' |
There was a problem hiding this comment.
(threading)
I think we should add tests here too. Otherwise we aren't fixing the true root cause, which is regressions in this file can break this functionality for the Hydrogen CLI, and no tests or CI checks will fail as a result.
I think we should add at minimum:
- A test verifying external hydrogen commands replace bundled ones after load()
- A test verifying non-dev mode does NOT trigger replacement
- A test that would catch future oclif API changes
| import {isDevelopment} from './context/local.js' | ||
| import {execaSync} from 'execa' | ||
| import {Command, Config} from '@oclif/core' | ||
| import {Config} from '@oclif/core' |
There was a problem hiding this comment.
(threading)
The PR only replaces commands by command.id. If hydrogen commands have aliases registered in _commands, those alias entries still point to bundled versions. There are currently no hydrogen command aliases (checked all 26 commands in oclif.manifest.json), so this is not a bug today but it's a latent issue.
I would recommend adding a comment documenting this limitation, or handling aliases proactively, but there might be a better way to do this overall that avoids this issue:
oclif's own insertLegacyPlugins method uses a delete-then-loadCommands pattern. I think using this.loadCommands(externalHydrogenPlugin) after deleting bundled commands would be a bit cleaner since it handles aliases and permutations through oclif's own code, though both options would involve the ts-expect-error from accessing internal properties
There was a problem hiding this comment.
Updated the logic to match the delete and load pattern found in the insert legacy plugin. This should also account for the aliasing!
| for (const command of externalHydrogenPlugin.commands) { | ||
| if (!command.id.startsWith('hydrogen')) continue | ||
| internalCommands.delete(command.id) | ||
| const allAliases = [...(command.aliases ?? []), ...((command as {hiddenAliases?: string[]}).hiddenAliases ?? [])] |
There was a problem hiding this comment.
Here is the code where we delete the aliased commands so we don't have a stale/cached version if aliased
| // if oclif removes or renames the private APIs that ShopifyConfig depends on. | ||
| // The mock above intentionally replaces oclif for logic isolation; this block | ||
| // provides the missing contract check against the installed package version. | ||
| describe('oclif API contract', () => { |
There was a problem hiding this comment.
Added test to check for possible regression:
loadCommandsneeds to be on the config_commandsneeds to be on the config- This will also be caught in the error check in the actual loader
| }) | ||
|
|
||
| describe('load()', () => { | ||
| test('does not replace commands when not in dev mode', async () => { |
There was a problem hiding this comment.
A test verifying non-dev mode does NOT trigger replacement
| expect(asMock(config).loadCommandsCalls).toHaveLength(0) | ||
| }) | ||
|
|
||
| test('removes bundled hydrogen commands and reloads from external plugin', async () => { |
There was a problem hiding this comment.
A test verifying external hydrogen commands replace bundled ones after load()
kdaviduik
left a comment
There was a problem hiding this comment.
Needs a changeset but other than that LGTM :)
note about this, it seems to be a false alarm as apparently:
- customPriority had zero callers anywhere in the codebase
- customPriority was already non-functional since oclif v3.83.0
- The sole consumer (cli-launcher.ts) already calls config.load() — no caller changes needed
Differences in type declarationsWe detected differences in the type declarations generated by Typescript for this branch compared to the baseline ('main' branch). Please, review them to ensure they are backward-compatible. Here are some important things to keep in mind:
New type declarationsWe found no new type declarations in this PR Existing type declarationspackages/cli-kit/dist/public/node/custom-oclif-loader.d.ts@@ -1,6 +1,6 @@
-import { Command, Config } from '@oclif/core';
+import { Config } from '@oclif/core';
import { Options } from '@oclif/core/interfaces';
export declare class ShopifyConfig extends Config {
constructor(options: Options);
- customPriority(commands: Command.Loadable[]): Command.Loadable | undefined;
+ load(): Promise<void>;
}
\ No newline at end of file
|
|
🤖 Code Review · #projects-dev-ai for questions ✅ Complete - No issues 📋 History✅ No issues |
| for (const command of externalHydrogenPlugin.commands) { | ||
| if (!command.id.startsWith('hydrogen')) continue | ||
| internalCommands.delete(command.id) | ||
| const allAliases = [...(command.aliases ?? []), ...((command as {hiddenAliases?: string[]}).hiddenAliases ?? [])] |
There was a problem hiding this comment.
non-blocking: hiddenAliases is an unguarded third private API dependency
The type assertion to {hiddenAliases?: string[]} accesses an undocumented oclif property. The contract tests verify _commands and loadCommands on the real Config but do NOT verify hiddenAliases on command objects. If oclif renames or removes hiddenAliases, the ?? [] fallback silently produces an empty array and hidden aliases for bundled commands remain in _commands, causing the bundled version to win when invoked via a hidden alias.
I think adding a third contract test would close this gap:
test('Command objects still expose hiddenAliases', async () => {
const {Config: RealConfig} = await vi.importActual<typeof import('@oclif/core')>('@oclif/core')
const config = new (RealConfig as new (options: {root: string}) => OclifConfig)({
root: fileURLToPath(new URL('.', import.meta.url)),
})
await config.load()
const someCommand = Array.from(config.commands)[0]
if (someCommand) {
expect('hiddenAliases' in someCommand).toBe(true)
}
})I'd also suggest extracting the inline type cast to a named interface for readability:
interface OclifCommand {
id: string
aliases?: string[]
hiddenAliases?: string[]
}| '@shopify/cli-kit': patch | ||
| --- | ||
|
|
||
| Fix hydrogen local dev plugin loader to correctly replace bundled hydrogen commands with external plugin commands by switching to a post-load strategy |
There was a problem hiding this comment.
blocking: @shopify/cli-kit exports custom-oclif-loader.js as a public entry point. The type diff shows customPriority was removed from the public declaration and load() was added. While customPriority was effectively dead code in oclif v4 (the framework no longer called it), it was part of the public type declaration.
The changeset is marked as patch, which I think is appropriate if there are no external consumers, but I think the changeset body should also mention the customPriority removal for transparency, even if it's dead code - consumers upgrading @shopify/cli-kit should be aware.
| return -1 | ||
| } | ||
| if (typeof (this as unknown as {[key: string]: unknown})._commands === 'undefined') { | ||
| throw new Error('ShopifyConfig: oclif internals changed. _commands is no longer available.') |
There was a problem hiding this comment.
non-blocking: I think we should also check instanceof Map here
The typeof check catches _commands being removed, but if oclif changes _commands from Map to a plain object or other collection type, the code would crash at .delete(). An instanceof Map check should throw (same pattern as this guard) rather than silently skipping:
// After the typeof check, add:
const internalCommands = this._commands
if (!(internalCommands instanceof Map)) {
throw new Error('ShopifyConfig: oclif internals changed. _commands is no longer a Map.')
}| // Force OCLIF to ignore manifests so commands are loaded dynamically | ||
| // to be replaced later | ||
| options.ignoreManifest = true |
There was a problem hiding this comment.
non-blocking: I think this comment could be more precise about the tradeoff - this applies to ALL plugins, not just hydrogen:
| // Force OCLIF to ignore manifests so commands are loaded dynamically | |
| // to be replaced later | |
| options.ignoreManifest = true | |
| // Skip the oclif manifest cache so external plugin commands are loaded | |
| // from disk rather than from a potentially stale manifest. This has a | |
| // startup performance cost, but only applies during development. | |
| options.ignoreManifest = true |
| } | ||
| // Force OCLIF to ignore manifests so commands are loaded dynamically | ||
| // to be replaced later | ||
| options.ignoreManifest = true |
There was a problem hiding this comment.
This is dangerous change, ignoring the manifest will make the CLI slower at startup on every command, because it needs to discover every command again on each run. Please do a comparison to check how speed is affected with and without this 🙏.
When is this line code executed? will it affect most users or just internal hydrogen development?
There was a problem hiding this comment.
@isaacroldan Thank you, this is a good callout. I will test. This ideally should be executed in development mode but only when in the Hydrogen Monorepo.
In this instance we could probably move the hydrogen monorepo check where the isDevelopment check is to ensure this ignoreManifest only runs in the expected context.
|
Is bringing |
|
/snapit |
|
🫰✨ Thanks @isaacroldan! Your snapshot has been published to npm. Test the snapshot by installing your package globally: npm i -g --@shopify:registry=https://registry.npmjs.org @shopify/cli@0.0.0-snapshot-20260227125139Caution After installing, validate the version by running |
WHY are these changes introduced?
During local development of the
@shopify/cliin theShopify/hydrogenyou are no longer able to see your changes when running thenpx shopify hydrogencommands because thecustom-ocliff-loader.tsno longer loads the localShopify/hydrgencli in theShopify/hydrogenmonorepo.These versions below are good:
In OCLIF v4,
determinePriorityis a standalone utility function (not an instance method), so we can't override it to customize command priority incustom-ocliff-loader.ts.WHAT is this pull request doing?
Change the
custom-ocliff-loader.tsto manually replace bundled hydrogen commands with external ones after loading completes if in dev mode.How to test your changes?
dev.tsinhydrogen/packages/cli/src/commands/hydrogen/dev.tsrun()cliproject runnpm run buildtemplate/skeletonproject runnpx shopify hydrogen devshopify-devpointing to a locally built version of theShopiy/clialias shopify-dev='/Users/$USER/bin/shopify'Measuring impact
How do we know this change was effective? Please choose one:
Checklist