Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/silly-spoons-hammer.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@shopify/cli-kit': patch
---

Ignore duplicated plugins to avoid "multiple tunnel plugins" errors
201 changes: 201 additions & 0 deletions packages/cli-kit/src/public/node/base-command.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -559,3 +559,204 @@ const deleteDefaultEnvironment = async (tmpDir: string): Promise<void> => {
delete clone.environments.default
await writeFile(joinPath(tmpDir, 'shopify.environments.toml'), encodeTOML({environments: clone} as any))
}

describe('removeDuplicatedPlugins', () => {
let capturedPlugins: Map<string, any> | undefined
let outputMock: ReturnType<typeof mockAndCaptureOutput>

class PluginTestCommand extends MockCommand {
async init() {
// Set up test plugins before calling super.init()
const initialPlugins = capturedPlugins
if (initialPlugins) {
this.config.plugins = new Map(initialPlugins)
}

const result = await super.init()

// Capture the plugins after init (which calls removeDuplicatedPlugins)
// eslint-disable-next-line require-atomic-updates
capturedPlugins = new Map(this.config.plugins)

return result
}
}

beforeEach(() => {
capturedPlugins = undefined
outputMock = mockAndCaptureOutput()
outputMock.clear()
})

test('removes @shopify/app plugin when present', async () => {
await inTemporaryDirectory(async (tmpDir) => {
// Given - set up plugins to be injected
const mockPlugin1 = {name: '@shopify/app', version: '1.0.0'} as any
const mockPlugin2 = {name: '@shopify/plugin-ngrok', version: '1.0.0'} as any
const mockPlugin3 = {name: '@shopify/plugin-did-you-mean', version: '1.0.0'} as any

capturedPlugins = new Map([
['@shopify/app', mockPlugin1],
['@shopify/plugin-ngrok', mockPlugin2],
['@shopify/plugin-did-you-mean', mockPlugin3],
])

// When
await PluginTestCommand.run(['--path', tmpDir])

// Then - verify @shopify/app was removed but others remain
expect(capturedPlugins.has('@shopify/app')).toBe(false)
expect(capturedPlugins.has('@shopify/plugin-ngrok')).toBe(true)
expect(capturedPlugins.has('@shopify/plugin-did-you-mean')).toBe(true)
expect(capturedPlugins.size).toBe(2)

// Verify warning was shown
expect(outputMock.output()).toMatch(/Unsupported plugins detected.*@shopify\/app/s)
expect(outputMock.output()).toMatch(/shopify plugins remove @shopify\/app/)
expect(outputMock.output()).not.toMatch(/shopify plugins remove @shopify\/plugin-cloudflare/)
})
})

test('removes @shopify/plugin-cloudflare plugin when present', async () => {
await inTemporaryDirectory(async (tmpDir) => {
// Given - set up plugins to be injected
const mockPlugin1 = {name: '@shopify/plugin-cloudflare', version: '1.0.0'} as any
const mockPlugin2 = {name: '@shopify/plugin-ngrok', version: '1.0.0'} as any
const mockPlugin3 = {name: '@shopify/plugin-did-you-mean', version: '1.0.0'} as any

capturedPlugins = new Map([
['@shopify/plugin-cloudflare', mockPlugin1],
['@shopify/plugin-ngrok', mockPlugin2],
['@shopify/plugin-did-you-mean', mockPlugin3],
])

// When
await PluginTestCommand.run(['--path', tmpDir])

// Then - verify @shopify/plugin-cloudflare was removed but others remain
expect(capturedPlugins.has('@shopify/plugin-cloudflare')).toBe(false)
expect(capturedPlugins.has('@shopify/plugin-ngrok')).toBe(true)
expect(capturedPlugins.has('@shopify/plugin-did-you-mean')).toBe(true)
expect(capturedPlugins.size).toBe(2)

// Verify warning was shown
expect(outputMock.output()).toMatch(/Unsupported plugins detected.*@shopify\/plugin-cloudflare/s)
expect(outputMock.output()).toMatch(/shopify plugins remove @shopify\/plugin-cloudflare/)
expect(outputMock.output()).not.toMatch(/shopify plugins remove @shopify\/app/)
})
})

test('removes both @shopify/app and @shopify/plugin-cloudflare plugins when present', async () => {
await inTemporaryDirectory(async (tmpDir) => {
// Given - set up plugins to be injected
const mockPlugin1 = {name: '@shopify/app', version: '1.0.0'} as any
const mockPlugin2 = {name: '@shopify/plugin-cloudflare', version: '1.0.0'} as any
const mockPlugin3 = {name: '@shopify/plugin-ngrok', version: '1.0.0'} as any
const mockPlugin4 = {name: '@shopify/plugin-did-you-mean', version: '1.0.0'} as any

capturedPlugins = new Map([
['@shopify/app', mockPlugin1],
['@shopify/plugin-cloudflare', mockPlugin2],
['@shopify/plugin-ngrok', mockPlugin3],
['@shopify/plugin-did-you-mean', mockPlugin4],
])

// When
await PluginTestCommand.run(['--path', tmpDir])

// Then - verify both bundled plugins were removed but others remain
expect(capturedPlugins.has('@shopify/app')).toBe(false)
expect(capturedPlugins.has('@shopify/plugin-cloudflare')).toBe(false)
expect(capturedPlugins.has('@shopify/plugin-ngrok')).toBe(true)
expect(capturedPlugins.has('@shopify/plugin-did-you-mean')).toBe(true)
expect(capturedPlugins.size).toBe(2)

// Verify warning was shown with both plugins
expect(outputMock.output()).toMatch(/Unsupported plugins detected.*@shopify\/app.*@shopify\/plugin-cloudflare/s)
expect(outputMock.output()).toMatch(/shopify plugins remove @shopify\/app/)
expect(outputMock.output()).toMatch(/shopify plugins remove @shopify\/plugin-cloudflare/)
})
})

test('does not remove any plugins when bundled plugins are not present', async () => {
await inTemporaryDirectory(async (tmpDir) => {
// Given - set up plugins (none are bundled plugins)
const mockPlugin1 = {name: '@shopify/plugin-ngrok', version: '1.0.0'} as any
const mockPlugin2 = {name: '@shopify/plugin-did-you-mean', version: '1.0.0'} as any
const mockPlugin3 = {name: 'some-other-plugin', version: '1.0.0'} as any

capturedPlugins = new Map([
['@shopify/plugin-ngrok', mockPlugin1],
['@shopify/plugin-did-you-mean', mockPlugin2],
['some-other-plugin', mockPlugin3],
])

// When
await PluginTestCommand.run(['--path', tmpDir])

// Then - verify no plugins were removed
expect(capturedPlugins.size).toBe(3)
expect(capturedPlugins.has('@shopify/plugin-ngrok')).toBe(true)
expect(capturedPlugins.has('@shopify/plugin-did-you-mean')).toBe(true)
expect(capturedPlugins.has('some-other-plugin')).toBe(true)

// Verify no warning was shown
expect(outputMock.output()).toBe('')
})
})

test('handles empty plugin map', async () => {
await inTemporaryDirectory(async (tmpDir) => {
// Given - empty plugins map
capturedPlugins = new Map()

// When
await PluginTestCommand.run(['--path', tmpDir])

// Then - verify map is still empty
expect(capturedPlugins.size).toBe(0)

// Verify no warning was shown
expect(outputMock.output()).toBe('')
})
})

test('preserves plugin metadata when removing bundled plugins', async () => {
await inTemporaryDirectory(async (tmpDir) => {
// Given - set up plugins with more complete metadata
const mockPluginApp = {
name: '@shopify/app',
version: '1.0.0',
type: 'core',
root: '/path/to/app',
} as any
const mockPluginTheme = {
name: '@shopify/plugin-ngrok',
version: '2.0.0',
type: 'user',
root: '/path/to/theme',
} as any

capturedPlugins = new Map([
['@shopify/app', mockPluginApp],
['@shopify/plugin-ngrok', mockPluginTheme],
])

// When
await PluginTestCommand.run(['--path', tmpDir])

// Then - verify @shopify/app was removed but theme plugin remains with all its metadata
expect(capturedPlugins.has('@shopify/app')).toBe(false)
expect(capturedPlugins.has('@shopify/plugin-ngrok')).toBe(true)
const remainingPlugin = capturedPlugins.get('@shopify/plugin-ngrok')
expect(remainingPlugin).toEqual(mockPluginTheme)
expect(remainingPlugin.version).toBe('2.0.0')
expect(remainingPlugin.type).toBe('user')
expect(remainingPlugin.root).toBe('/path/to/theme')

// Verify warning was shown
expect(outputMock.output()).toMatch(/Unsupported plugins detected.*@shopify\/app/s)
expect(outputMock.output()).toMatch(/shopify plugins remove @shopify\/app/)
})
})
})
21 changes: 20 additions & 1 deletion packages/cli-kit/src/public/node/base-command.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import {showNotificationsIfNeeded} from './notifications-system.js'
import {setCurrentCommandId} from './global-context.js'
import {JsonMap} from '../../private/common/json.js'
import {underscore} from '../common/string.js'
import {Command, Errors} from '@oclif/core'
import {Command, Config, Errors} from '@oclif/core'
import {OutputFlags, Input, ParserOutput, FlagInput, OutputArgs} from '@oclif/core/parser'

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

async function removeDuplicatedPlugins(config: Config): Promise<void> {
const plugins = Array.from(config.plugins.values())
const bundlePlugins = ['@shopify/app', '@shopify/plugin-cloudflare']
const pluginsToRemove = plugins.filter((plugin) => bundlePlugins.includes(plugin.name))
if (pluginsToRemove.length > 0) {
const commandsToRun = pluginsToRemove.map((plugin) => ` - shopify plugins remove ${plugin.name}`).join('\n')
renderWarning({
headline: `Unsupported plugins detected: ${pluginsToRemove.map((plugin) => plugin.name).join(', ')}`,
body: [
'They are already included in the CLI and installing them as custom plugins can cause conflicts.',
`You can fix it by running:\n${commandsToRun}`,
],
})
}
const filteredPlugins = plugins.filter((plugin) => !bundlePlugins.includes(plugin.name))
config.plugins = new Map(filteredPlugins.map((plugin) => [plugin.name, plugin]))
}

export default BaseCommand