Skip to content

Commit 12b801f

Browse files
Merge pull request #6554 from Shopify/fix-multiple-tunnel-plugins-error
Fix multiple tunnel plugins error
2 parents d82e187 + 29bd2f3 commit 12b801f

File tree

3 files changed

+226
-1
lines changed

3 files changed

+226
-1
lines changed

.changeset/silly-spoons-hammer.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@shopify/cli-kit': patch
3+
---
4+
5+
Ignore duplicated plugins to avoid "multiple tunnel plugins" errors

packages/cli-kit/src/public/node/base-command.test.ts

Lines changed: 201 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -559,3 +559,204 @@ const deleteDefaultEnvironment = async (tmpDir: string): Promise<void> => {
559559
delete clone.environments.default
560560
await writeFile(joinPath(tmpDir, 'shopify.environments.toml'), encodeTOML({environments: clone} as any))
561561
}
562+
563+
describe('removeDuplicatedPlugins', () => {
564+
let capturedPlugins: Map<string, any> | undefined
565+
let outputMock: ReturnType<typeof mockAndCaptureOutput>
566+
567+
class PluginTestCommand extends MockCommand {
568+
async init() {
569+
// Set up test plugins before calling super.init()
570+
const initialPlugins = capturedPlugins
571+
if (initialPlugins) {
572+
this.config.plugins = new Map(initialPlugins)
573+
}
574+
575+
const result = await super.init()
576+
577+
// Capture the plugins after init (which calls removeDuplicatedPlugins)
578+
// eslint-disable-next-line require-atomic-updates
579+
capturedPlugins = new Map(this.config.plugins)
580+
581+
return result
582+
}
583+
}
584+
585+
beforeEach(() => {
586+
capturedPlugins = undefined
587+
outputMock = mockAndCaptureOutput()
588+
outputMock.clear()
589+
})
590+
591+
test('removes @shopify/app plugin when present', async () => {
592+
await inTemporaryDirectory(async (tmpDir) => {
593+
// Given - set up plugins to be injected
594+
const mockPlugin1 = {name: '@shopify/app', version: '1.0.0'} as any
595+
const mockPlugin2 = {name: '@shopify/plugin-ngrok', version: '1.0.0'} as any
596+
const mockPlugin3 = {name: '@shopify/plugin-did-you-mean', version: '1.0.0'} as any
597+
598+
capturedPlugins = new Map([
599+
['@shopify/app', mockPlugin1],
600+
['@shopify/plugin-ngrok', mockPlugin2],
601+
['@shopify/plugin-did-you-mean', mockPlugin3],
602+
])
603+
604+
// When
605+
await PluginTestCommand.run(['--path', tmpDir])
606+
607+
// Then - verify @shopify/app was removed but others remain
608+
expect(capturedPlugins.has('@shopify/app')).toBe(false)
609+
expect(capturedPlugins.has('@shopify/plugin-ngrok')).toBe(true)
610+
expect(capturedPlugins.has('@shopify/plugin-did-you-mean')).toBe(true)
611+
expect(capturedPlugins.size).toBe(2)
612+
613+
// Verify warning was shown
614+
expect(outputMock.output()).toMatch(/Unsupported plugins detected.*@shopify\/app/s)
615+
expect(outputMock.output()).toMatch(/shopify plugins remove @shopify\/app/)
616+
expect(outputMock.output()).not.toMatch(/shopify plugins remove @shopify\/plugin-cloudflare/)
617+
})
618+
})
619+
620+
test('removes @shopify/plugin-cloudflare plugin when present', async () => {
621+
await inTemporaryDirectory(async (tmpDir) => {
622+
// Given - set up plugins to be injected
623+
const mockPlugin1 = {name: '@shopify/plugin-cloudflare', version: '1.0.0'} as any
624+
const mockPlugin2 = {name: '@shopify/plugin-ngrok', version: '1.0.0'} as any
625+
const mockPlugin3 = {name: '@shopify/plugin-did-you-mean', version: '1.0.0'} as any
626+
627+
capturedPlugins = new Map([
628+
['@shopify/plugin-cloudflare', mockPlugin1],
629+
['@shopify/plugin-ngrok', mockPlugin2],
630+
['@shopify/plugin-did-you-mean', mockPlugin3],
631+
])
632+
633+
// When
634+
await PluginTestCommand.run(['--path', tmpDir])
635+
636+
// Then - verify @shopify/plugin-cloudflare was removed but others remain
637+
expect(capturedPlugins.has('@shopify/plugin-cloudflare')).toBe(false)
638+
expect(capturedPlugins.has('@shopify/plugin-ngrok')).toBe(true)
639+
expect(capturedPlugins.has('@shopify/plugin-did-you-mean')).toBe(true)
640+
expect(capturedPlugins.size).toBe(2)
641+
642+
// Verify warning was shown
643+
expect(outputMock.output()).toMatch(/Unsupported plugins detected.*@shopify\/plugin-cloudflare/s)
644+
expect(outputMock.output()).toMatch(/shopify plugins remove @shopify\/plugin-cloudflare/)
645+
expect(outputMock.output()).not.toMatch(/shopify plugins remove @shopify\/app/)
646+
})
647+
})
648+
649+
test('removes both @shopify/app and @shopify/plugin-cloudflare plugins when present', async () => {
650+
await inTemporaryDirectory(async (tmpDir) => {
651+
// Given - set up plugins to be injected
652+
const mockPlugin1 = {name: '@shopify/app', version: '1.0.0'} as any
653+
const mockPlugin2 = {name: '@shopify/plugin-cloudflare', version: '1.0.0'} as any
654+
const mockPlugin3 = {name: '@shopify/plugin-ngrok', version: '1.0.0'} as any
655+
const mockPlugin4 = {name: '@shopify/plugin-did-you-mean', version: '1.0.0'} as any
656+
657+
capturedPlugins = new Map([
658+
['@shopify/app', mockPlugin1],
659+
['@shopify/plugin-cloudflare', mockPlugin2],
660+
['@shopify/plugin-ngrok', mockPlugin3],
661+
['@shopify/plugin-did-you-mean', mockPlugin4],
662+
])
663+
664+
// When
665+
await PluginTestCommand.run(['--path', tmpDir])
666+
667+
// Then - verify both bundled plugins were removed but others remain
668+
expect(capturedPlugins.has('@shopify/app')).toBe(false)
669+
expect(capturedPlugins.has('@shopify/plugin-cloudflare')).toBe(false)
670+
expect(capturedPlugins.has('@shopify/plugin-ngrok')).toBe(true)
671+
expect(capturedPlugins.has('@shopify/plugin-did-you-mean')).toBe(true)
672+
expect(capturedPlugins.size).toBe(2)
673+
674+
// Verify warning was shown with both plugins
675+
expect(outputMock.output()).toMatch(/Unsupported plugins detected.*@shopify\/app.*@shopify\/plugin-cloudflare/s)
676+
expect(outputMock.output()).toMatch(/shopify plugins remove @shopify\/app/)
677+
expect(outputMock.output()).toMatch(/shopify plugins remove @shopify\/plugin-cloudflare/)
678+
})
679+
})
680+
681+
test('does not remove any plugins when bundled plugins are not present', async () => {
682+
await inTemporaryDirectory(async (tmpDir) => {
683+
// Given - set up plugins (none are bundled plugins)
684+
const mockPlugin1 = {name: '@shopify/plugin-ngrok', version: '1.0.0'} as any
685+
const mockPlugin2 = {name: '@shopify/plugin-did-you-mean', version: '1.0.0'} as any
686+
const mockPlugin3 = {name: 'some-other-plugin', version: '1.0.0'} as any
687+
688+
capturedPlugins = new Map([
689+
['@shopify/plugin-ngrok', mockPlugin1],
690+
['@shopify/plugin-did-you-mean', mockPlugin2],
691+
['some-other-plugin', mockPlugin3],
692+
])
693+
694+
// When
695+
await PluginTestCommand.run(['--path', tmpDir])
696+
697+
// Then - verify no plugins were removed
698+
expect(capturedPlugins.size).toBe(3)
699+
expect(capturedPlugins.has('@shopify/plugin-ngrok')).toBe(true)
700+
expect(capturedPlugins.has('@shopify/plugin-did-you-mean')).toBe(true)
701+
expect(capturedPlugins.has('some-other-plugin')).toBe(true)
702+
703+
// Verify no warning was shown
704+
expect(outputMock.output()).toBe('')
705+
})
706+
})
707+
708+
test('handles empty plugin map', async () => {
709+
await inTemporaryDirectory(async (tmpDir) => {
710+
// Given - empty plugins map
711+
capturedPlugins = new Map()
712+
713+
// When
714+
await PluginTestCommand.run(['--path', tmpDir])
715+
716+
// Then - verify map is still empty
717+
expect(capturedPlugins.size).toBe(0)
718+
719+
// Verify no warning was shown
720+
expect(outputMock.output()).toBe('')
721+
})
722+
})
723+
724+
test('preserves plugin metadata when removing bundled plugins', async () => {
725+
await inTemporaryDirectory(async (tmpDir) => {
726+
// Given - set up plugins with more complete metadata
727+
const mockPluginApp = {
728+
name: '@shopify/app',
729+
version: '1.0.0',
730+
type: 'core',
731+
root: '/path/to/app',
732+
} as any
733+
const mockPluginTheme = {
734+
name: '@shopify/plugin-ngrok',
735+
version: '2.0.0',
736+
type: 'user',
737+
root: '/path/to/theme',
738+
} as any
739+
740+
capturedPlugins = new Map([
741+
['@shopify/app', mockPluginApp],
742+
['@shopify/plugin-ngrok', mockPluginTheme],
743+
])
744+
745+
// When
746+
await PluginTestCommand.run(['--path', tmpDir])
747+
748+
// Then - verify @shopify/app was removed but theme plugin remains with all its metadata
749+
expect(capturedPlugins.has('@shopify/app')).toBe(false)
750+
expect(capturedPlugins.has('@shopify/plugin-ngrok')).toBe(true)
751+
const remainingPlugin = capturedPlugins.get('@shopify/plugin-ngrok')
752+
expect(remainingPlugin).toEqual(mockPluginTheme)
753+
expect(remainingPlugin.version).toBe('2.0.0')
754+
expect(remainingPlugin.type).toBe('user')
755+
expect(remainingPlugin.root).toBe('/path/to/theme')
756+
757+
// Verify warning was shown
758+
expect(outputMock.output()).toMatch(/Unsupported plugins detected.*@shopify\/app/s)
759+
expect(outputMock.output()).toMatch(/shopify plugins remove @shopify\/app/)
760+
})
761+
})
762+
})

packages/cli-kit/src/public/node/base-command.ts

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ import {showNotificationsIfNeeded} from './notifications-system.js'
1212
import {setCurrentCommandId} from './global-context.js'
1313
import {JsonMap} from '../../private/common/json.js'
1414
import {underscore} from '../common/string.js'
15-
import {Command, Errors} from '@oclif/core'
15+
import {Command, Config, Errors} from '@oclif/core'
1616
import {OutputFlags, Input, ParserOutput, FlagInput, OutputArgs} from '@oclif/core/parser'
1717

1818
// eslint-disable-next-line @typescript-eslint/no-explicit-any
@@ -56,6 +56,7 @@ abstract class BaseCommand extends Command {
5656
// This function runs just prior to `run`
5757
await registerCleanBugsnagErrorsFromWithinPlugins(this.config)
5858
}
59+
await removeDuplicatedPlugins(this.config)
5960
this.showNpmFlagWarning()
6061
await showNotificationsIfNeeded()
6162
return super.init()
@@ -336,4 +337,22 @@ function commandSupportsFlag(flags: FlagInput | undefined, flagName: string): bo
336337
return Boolean(flags) && Object.prototype.hasOwnProperty.call(flags, flagName)
337338
}
338339

340+
async function removeDuplicatedPlugins(config: Config): Promise<void> {
341+
const plugins = Array.from(config.plugins.values())
342+
const bundlePlugins = ['@shopify/app', '@shopify/plugin-cloudflare']
343+
const pluginsToRemove = plugins.filter((plugin) => bundlePlugins.includes(plugin.name))
344+
if (pluginsToRemove.length > 0) {
345+
const commandsToRun = pluginsToRemove.map((plugin) => ` - shopify plugins remove ${plugin.name}`).join('\n')
346+
renderWarning({
347+
headline: `Unsupported plugins detected: ${pluginsToRemove.map((plugin) => plugin.name).join(', ')}`,
348+
body: [
349+
'They are already included in the CLI and installing them as custom plugins can cause conflicts.',
350+
`You can fix it by running:\n${commandsToRun}`,
351+
],
352+
})
353+
}
354+
const filteredPlugins = plugins.filter((plugin) => !bundlePlugins.includes(plugin.name))
355+
config.plugins = new Map(filteredPlugins.map((plugin) => [plugin.name, plugin]))
356+
}
357+
339358
export default BaseCommand

0 commit comments

Comments
 (0)