diff --git a/.changeset/moody-beds-sneeze.md b/.changeset/moody-beds-sneeze.md new file mode 100644 index 0000000000..876b06cad6 --- /dev/null +++ b/.changeset/moody-beds-sneeze.md @@ -0,0 +1,6 @@ +--- +'@shopify/cli-kit': patch +'@shopify/app': patch +--- + +Show a warning when there are multiple CLI installations diff --git a/packages/app/src/cli/models/app/loader.test.ts b/packages/app/src/cli/models/app/loader.test.ts index 426c446728..ce1b3092ea 100644 --- a/packages/app/src/cli/models/app/loader.test.ts +++ b/packages/app/src/cli/models/app/loader.test.ts @@ -34,14 +34,19 @@ import {platformAndArch} from '@shopify/cli-kit/node/os' import {outputContent, outputToken} from '@shopify/cli-kit/node/output' import {zod} from '@shopify/cli-kit/node/schema' import {mockAndCaptureOutput} from '@shopify/cli-kit/node/testing/output' -import {currentProcessIsGlobal} from '@shopify/cli-kit/node/is-global' import colors from '@shopify/cli-kit/node/colors' -// eslint-disable-next-line no-restricted-imports -import {resolve} from 'path' + +import {globalCLIVersion, localCLIVersion} from '@shopify/cli-kit/node/version' vi.mock('../../services/local-storage.js') vi.mock('../../services/app/config/use.js') vi.mock('@shopify/cli-kit/node/is-global') +vi.mock('@shopify/cli-kit/node/node-package-manager', async () => ({ + ...((await vi.importActual('@shopify/cli-kit/node/node-package-manager')) as any), + localCLIVersion: vi.fn(), + globalCLIVersion: vi.fn(), +})) +vi.mock('@shopify/cli-kit/node/version') describe('load', () => { let specifications: ExtensionSpecification[] = [] @@ -322,9 +327,9 @@ wrong = "property" test('shows warning if using global CLI but app has local dependency', async () => { // Given - vi.mocked(currentProcessIsGlobal).mockReturnValueOnce(true) + vi.mocked(globalCLIVersion).mockResolvedValue('3.68.0') + vi.mocked(localCLIVersion).mockResolvedValue('3.65.0') const mockOutput = mockAndCaptureOutput() - mockOutput.clear() await writeConfig(appConfiguration, { workspaces: ['packages/*'], name: 'my_app', @@ -339,16 +344,19 @@ wrong = "property" expect(mockOutput.info()).toMatchInlineSnapshot(` "╭─ info ───────────────────────────────────────────────────────────────────────╮ │ │ - │ You are running a global installation of Shopify CLI │ + │ Two Shopify CLI installations found – using local dependency │ │ │ - │ This project has Shopify CLI as a local dependency in package.json. If you │ - │ prefer to use that version, run the command with your package manager │ - │ (e.g. npm run shopify). │ + │ A global installation (v3.68.0) and a local dependency (v3.65.0) were │ + │ detected. │ + │ We recommend removing the @shopify/cli and @shopify/app dependencies from │ + │ your package.json, unless you want to use different versions across │ + │ multiple apps. │ │ │ - │ For more information, see Shopify CLI documentation [1] │ + │ See Shopify CLI documentation. [1] │ │ │ ╰──────────────────────────────────────────────────────────────────────────────╯ - [1] https://shopify.dev/docs/apps/tools/cli + [1] https://shopify.dev/docs/apps/build/cli-for-apps#switch-to-a-global-executab + le-or-local-dependency " `) mockOutput.clear() @@ -356,8 +364,8 @@ wrong = "property" test('doesnt show warning if there is no local dependency', async () => { // Given + vi.mocked(localCLIVersion).mockResolvedValue(undefined) const mockOutput = mockAndCaptureOutput() - mockOutput.clear() await writeConfig(appConfiguration, { workspaces: ['packages/*'], name: 'my_app', @@ -2325,7 +2333,7 @@ wrong = "property" // Then expect(use).toHaveBeenCalledWith({ - directory: normalizePath(resolve(tmpDir)), + directory: normalizePath(tmpDir), shouldRenderSuccess: false, warningContent: { headline: "Couldn't find shopify.app.non-existent.toml", diff --git a/packages/app/src/cli/models/app/loader.ts b/packages/app/src/cli/models/app/loader.ts index c3cbdbe965..12217eee23 100644 --- a/packages/app/src/cli/models/app/loader.ts +++ b/packages/app/src/cli/models/app/loader.ts @@ -49,6 +49,7 @@ import {getArrayRejectingUndefined} from '@shopify/cli-kit/common/array' import {checkIfIgnoredInGitRepository} from '@shopify/cli-kit/node/git' import {renderInfo} from '@shopify/cli-kit/node/ui' import {currentProcessIsGlobal} from '@shopify/cli-kit/node/is-global' +import {globalCLIVersion, localCLIVersion} from '@shopify/cli-kit/node/version' const defaultExtensionDirectory = 'extensions/*' @@ -304,7 +305,7 @@ class AppLoader { const websOfType = webs.filter((web) => web.configuration.roles.includes(webType)) - if (websOfType.length > 1) { + if (websOfType[1]) { this.abortOrReport( outputContent`You can only have one web with the ${outputToken.yellow(webType)} role in your app`, undefined, - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - joinPath(websOfType[1]!.directory, configurationFileNames.web), + + joinPath(websOfType[1].directory, configurationFileNames.web), ) } }) @@ -1048,12 +1056,12 @@ async function getProjectType(webs: Web[]): Promise<'node' | 'php' | 'ruby' | 'f return } else if (backendWebs.length === 0 && frontendWebs.length > 0) { return 'frontend' - } else if (backendWebs.length === 0) { + } else if (!backendWebs[0]) { outputDebug('Unable to decide project type as no web backend') return } - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - const {directory} = backendWebs[0]! + + const {directory} = backendWebs[0] const nodeConfigFile = joinPath(directory, 'package.json') const rubyConfigFile = joinPath(directory, 'Gemfile') diff --git a/packages/cli-kit/src/public/node/is-global.ts b/packages/cli-kit/src/public/node/is-global.ts index e465fe3aee..8e7b8e6f0a 100644 --- a/packages/cli-kit/src/public/node/is-global.ts +++ b/packages/cli-kit/src/public/node/is-global.ts @@ -42,7 +42,8 @@ export function currentProcessIsGlobal(argv = process.argv): boolean { */ export async function isGlobalCLIInstalled(): Promise { try { - const output = await captureOutput('shopify', ['app']) + const env = {...process.env, SHOPIFY_CLI_NO_ANALYTICS: '1'} + const output = await captureOutput('shopify', ['app'], {env}) // Installed if `app dev` is available globally return output.includes('app dev') // eslint-disable-next-line no-catch-all/no-catch-all diff --git a/packages/cli-kit/src/public/node/node-package-manager.test.ts b/packages/cli-kit/src/public/node/node-package-manager.test.ts index 2f8213ecff..d1e7649124 100644 --- a/packages/cli-kit/src/public/node/node-package-manager.test.ts +++ b/packages/cli-kit/src/public/node/node-package-manager.test.ts @@ -29,7 +29,7 @@ import {cacheClear} from '../../private/node/conf-store.js' import latestVersion from 'latest-version' import {vi, describe, test, expect, beforeEach, afterEach} from 'vitest' -vi.mock('../../version.js') +vi.mock('./version.js') vi.mock('./system.js') vi.mock('latest-version') vi.mock('./is-global') diff --git a/packages/cli-kit/src/public/node/version.test.ts b/packages/cli-kit/src/public/node/version.test.ts new file mode 100644 index 0000000000..03dd83760c --- /dev/null +++ b/packages/cli-kit/src/public/node/version.test.ts @@ -0,0 +1,71 @@ +import {localCLIVersion, globalCLIVersion} from './version.js' +import {inTemporaryDirectory} from '../node/fs.js' +import {captureOutput} from '../node/system.js' +import {describe, expect, test, vi} from 'vitest' + +vi.mock('../node/system.js') + +describe('localCLIVersion', () => { + test('returns the version of the local CLI', async () => { + await inTemporaryDirectory(async (tmpDir) => { + // Given + vi.mocked(captureOutput).mockResolvedValueOnce(`folder@ ${tmpDir} +└── @shopify/cli@3.68.0`) + + // When + const got = await localCLIVersion(tmpDir) + + // Then + expect(got).toEqual('3.68.0') + }) + }) + + test('returns undefined when the dependency is not installed', async () => { + await inTemporaryDirectory(async (tmpDir) => { + // Given + vi.mocked(captureOutput).mockResolvedValueOnce(`folder@ ${tmpDir} + └── (empty)`) + + // When + const got = await localCLIVersion(tmpDir) + + // Then + expect(got).toBeUndefined() + }) + }) +}) + +describe('globalCLIVersion', () => { + test('returns the version when a recent CLI is installed globally', async () => { + // Given + vi.mocked(captureOutput).mockImplementationOnce(() => Promise.resolve('3.65.0')) + + // When + const got = await globalCLIVersion() + + // Then + expect(got).toBe('3.65.0') + }) + + test('returns undefined when the global version is older than 3.59', async () => { + // Given + vi.mocked(captureOutput).mockImplementationOnce(() => Promise.resolve('3.55.0')) + + // When + const got = await globalCLIVersion() + + // Then + expect(got).toBeUndefined() + }) + + test('returns undefined when the global version is not installed', async () => { + // Given + vi.mocked(captureOutput).mockImplementationOnce(() => Promise.resolve('command not found: shopify')) + + // When + const got = await globalCLIVersion() + + // Then + expect(got).toBeUndefined() + }) +}) diff --git a/packages/cli-kit/src/public/node/version.ts b/packages/cli-kit/src/public/node/version.ts new file mode 100644 index 0000000000..42894eaee8 --- /dev/null +++ b/packages/cli-kit/src/public/node/version.ts @@ -0,0 +1,37 @@ +import {versionSatisfies} from '../node/node-package-manager.js' +import {captureOutput} from '../node/system.js' + +/** + * Returns the version of the local dependency of the CLI if it's installed in the provided directory. + * + * @param directory - Path of the project to look for the dependency. + * @returns The CLI version or undefined if the dependency is not installed. + */ +export async function localCLIVersion(directory: string): Promise { + try { + const output = await captureOutput('npm', ['list', '@shopify/cli'], {cwd: directory}) + return output.match(/@shopify\/cli@([\w.-]*)/)?.[1] + // eslint-disable-next-line no-catch-all/no-catch-all + } catch { + return undefined + } +} + +/** + * Returns the version of the globally installed CLI, only if it's greater than 3.59.0 (when the global CLI was introduced). + * + * @returns The version of the CLI if it is globally installed or undefined. + */ +export async function globalCLIVersion(): Promise { + try { + const env = {...process.env, SHOPIFY_CLI_NO_ANALYTICS: '1'} + const version = await captureOutput('shopify', ['version'], {env}) + if (versionSatisfies(version, `>=3.59.0`)) { + return version + } + return undefined + // eslint-disable-next-line no-catch-all/no-catch-all + } catch { + return undefined + } +}