Skip to content

Commit 3c71159

Browse files
shazronclaudegithub-actions[bot]
authored
fix: normalize oclif v2 plugin hooks for v4 compatibility (#922)
* fix: normalize oclif v2 plugin hooks for v4 compatibility When the global aio-cli (oclif v2) runs commands from this plugin (oclif v4), v4's Config.load re-uses the v2 Plugin objects as-is. Those objects store hooks as plain string arrays, but v4's runHook expects {identifier, target} objects — causing hook.target to be undefined and crashing path.extname() with ERR_INVALID_ARG_TYPE. Normalize all plugin hooks to v4 format once in BaseCommand.init() so every command that calls this.config.runHook works correctly. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * test: add coverage for oclif v2 hook normalization in BaseCommand.init Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix: normalize oclif v2 plugin hooks for v4 compatibility When the global aio-cli (oclif v2) runs commands from this plugin (oclif v4), v4's Config.load re-uses the v2 Plugin objects as-is. Those objects store hooks as plain string arrays, but v4's runHook expects {identifier, target} objects — causing hook.target to be undefined and crashing path.extname() with ERR_INVALID_ARG_TYPE. Normalize all plugin hooks to v4 format in BaseCommand.init(), but only when string hooks are actually present to avoid unnecessary mutation of already-normalized plugin objects. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix: normalize oclif v2 plugin hooks for v4 compatibility When the global aio-cli (oclif v2) runs commands from this plugin (oclif v4), v4's Config.load re-uses the v2 Plugin objects as-is. Those objects store hooks as plain string arrays, but v4's runHook expects {identifier, target} objects — causing hook.target to be undefined and crashing path.extname() with ERR_INVALID_ARG_TYPE. Normalize all plugin hooks to v4 format in BaseCommand.init(), but only when string hooks are actually present to avoid unnecessary mutation of already-normalized plugin objects. Uses getPluginsList() if available, falling back to this.config.plugins Map. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix lint issues * Update src/BaseCommand.js Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> * Revert "Update src/BaseCommand.js" This reverts commit cab6c55. * fix: normalize oclif v2 plugin hooks for v4 compatibility When the global aio-cli (oclif v2) runs commands from this plugin (oclif v4), v4's Config.load re-uses the v2 Plugin objects as-is. Those objects store hooks as plain string arrays, but v4's runHook expects {identifier, target} objects — causing hook.target to be undefined and crashing path.extname() with ERR_INVALID_ARG_TYPE. Normalize all plugin hooks to v4 format in BaseCommand.init(), but only when string hooks are actually present to avoid unnecessary mutation of already-normalized plugin objects. Uses getPluginsList() if available, falling back to this.config.plugins Map. Wraps assignment in try/catch to handle frozen plugin hook objects. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com> Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
1 parent 7939e90 commit 3c71159

3 files changed

Lines changed: 101 additions & 0 deletions

File tree

src/BaseCommand.js

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,31 @@ class BaseCommand extends Command {
4040

4141
async init () {
4242
await super.init()
43+
// Normalize hooks from plugins loaded by oclif v2 into this v4 Config.
44+
// oclif v2 stores hooks as string arrays; v4 expects {identifier, target} objects.
45+
// Only mutate when string hooks are present; guard against frozen plugin references.
46+
const pluginList = typeof this.config.getPluginsList === 'function'
47+
? this.config.getPluginsList()
48+
: [...(this.config.plugins?.values() ?? [])]
49+
for (const plugin of pluginList) {
50+
if (!plugin.hooks) {
51+
continue
52+
}
53+
for (const [event, hooks] of Object.entries(plugin.hooks)) {
54+
const hooksArr = Array.isArray(hooks) ? hooks : [hooks]
55+
if (!hooksArr.some(h => typeof h === 'string')) {
56+
continue
57+
}
58+
try {
59+
plugin.hooks[event] = hooksArr.map(h =>
60+
typeof h === 'string' ? { identifier: 'default', target: h } : h
61+
)
62+
} catch {
63+
// plugin.hooks is frozen or sealed; skip normalization for this event
64+
}
65+
}
66+
}
67+
4368
// setup a prompt that outputs to stderr
4469
this.prompt = inquirer.createPromptModule({ output: process.stderr })
4570

test/BaseCommand.test.js

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -288,6 +288,81 @@ test('init', async () => {
288288
expect(inquirer.createPromptModule).toHaveBeenCalledWith({ output: process.stderr })
289289
})
290290

291+
test('init normalizes oclif v2 string hooks to v4 object format', async () => {
292+
const cmd = new TheCommand([])
293+
const plugin = {
294+
hooks: {
295+
'pre-deploy-event-reg': ['./src/hooks/pre-deploy-event-reg.js'],
296+
'post-deploy-event-reg': './src/hooks/post-deploy-event-reg.js',
297+
'already-v4': [{ identifier: 'default', target: './src/hooks/foo.js' }],
298+
mixed: ['./src/hooks/string.js', { identifier: 'named', target: './src/hooks/obj.js' }]
299+
}
300+
}
301+
cmd.config = global.createOclifMockConfig({
302+
getPluginsList: jest.fn().mockReturnValue([plugin])
303+
})
304+
await cmd.init()
305+
expect(plugin.hooks['pre-deploy-event-reg']).toEqual([{ identifier: 'default', target: './src/hooks/pre-deploy-event-reg.js' }])
306+
expect(plugin.hooks['post-deploy-event-reg']).toEqual([{ identifier: 'default', target: './src/hooks/post-deploy-event-reg.js' }])
307+
expect(plugin.hooks['already-v4']).toEqual([{ identifier: 'default', target: './src/hooks/foo.js' }])
308+
expect(plugin.hooks['mixed']).toEqual([
309+
{ identifier: 'default', target: './src/hooks/string.js' },
310+
{ identifier: 'named', target: './src/hooks/obj.js' }
311+
])
312+
})
313+
314+
test('init skips plugins with no hooks', async () => {
315+
const cmd = new TheCommand([])
316+
const plugin = { name: 'no-hooks-plugin' }
317+
cmd.config = global.createOclifMockConfig({
318+
getPluginsList: jest.fn().mockReturnValue([plugin])
319+
})
320+
await expect(cmd.init()).resolves.not.toThrow()
321+
})
322+
323+
test('init does not mutate hooks already in v4 format', async () => {
324+
const cmd = new TheCommand([])
325+
const original = [{ identifier: 'default', target: './src/hooks/foo.js' }]
326+
const plugin = { hooks: { 'some-event': original } }
327+
cmd.config = global.createOclifMockConfig({
328+
getPluginsList: jest.fn().mockReturnValue([plugin])
329+
})
330+
await cmd.init()
331+
expect(plugin.hooks['some-event']).toBe(original) // same reference, not replaced
332+
})
333+
334+
test('init falls back to this.config.plugins Map when getPluginsList is unavailable', async () => {
335+
const cmd = new TheCommand([])
336+
const plugin = { hooks: { 'pre-deploy-event-reg': ['./src/hooks/hook.js'] } }
337+
const mockConfig = global.createOclifMockConfig({
338+
plugins: new Map([['test-plugin', plugin]])
339+
})
340+
delete mockConfig.getPluginsList
341+
cmd.config = mockConfig
342+
await cmd.init()
343+
expect(plugin.hooks['pre-deploy-event-reg']).toEqual([{ identifier: 'default', target: './src/hooks/hook.js' }])
344+
})
345+
346+
test('init handles config with neither getPluginsList nor plugins without throwing', async () => {
347+
const cmd = new TheCommand([])
348+
const mockConfig = global.createOclifMockConfig()
349+
delete mockConfig.getPluginsList
350+
delete mockConfig.plugins
351+
cmd.config = mockConfig
352+
await expect(cmd.init()).resolves.not.toThrow()
353+
})
354+
355+
test('init skips normalization gracefully when plugin hooks object is frozen', async () => {
356+
const cmd = new TheCommand([])
357+
const plugin = { hooks: Object.freeze({ 'pre-deploy-event-reg': ['./src/hooks/hook.js'] }) }
358+
cmd.config = global.createOclifMockConfig({
359+
getPluginsList: jest.fn().mockReturnValue([plugin])
360+
})
361+
await expect(cmd.init()).resolves.not.toThrow()
362+
// hooks remain as-is since the frozen object blocked the assignment
363+
expect(plugin.hooks['pre-deploy-event-reg']).toEqual(['./src/hooks/hook.js'])
364+
})
365+
291366
test('catch', async () => {
292367
const cmd = new TheCommand([])
293368
cmd.config = global.createOclifMockConfig()

test/jest.setup.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,7 @@ global.createOclifMockConfig = (overrides = {}) => ({
5353
runHook: jest.fn().mockResolvedValue({ successes: [] }),
5454
runCommand: jest.fn(),
5555
findCommand: jest.fn(),
56+
getPluginsList: jest.fn().mockReturnValue([]),
5657
...overrides
5758
})
5859

0 commit comments

Comments
 (0)