From 91a87a1730845ac0da00b0f6101820824e8cb8cb Mon Sep 17 00:00:00 2001 From: Gonzalo Riestra Date: Wed, 11 Feb 2026 11:49:33 +0100 Subject: [PATCH] Auto-upgrade Co-Authored-By: Alfonso Noriega Co-authored-by: Cursor --- docs-shopify.dev/commands/upgrade.doc.ts | 4 +- .../generated/generated_docs_data.json | 4 +- .../src/cli/services/app/config/link.test.ts | 3 + .../src/cli/services/app/config/use.test.ts | 4 + .../app/src/cli/services/generate.test.ts | 3 + packages/app/src/cli/services/init/init.ts | 1 + .../cli-kit/src/private/node/constants.ts | 1 + .../cli-kit/src/public/node/context/local.ts | 10 + packages/cli-kit/src/public/node/fs.ts | 19 +- .../src/public/node/hooks/postrun.test.ts | 83 +++++++ .../cli-kit/src/public/node/hooks/postrun.ts | 32 ++- .../src/public/node/hooks/prerun.test.ts | 46 +--- .../cli-kit/src/public/node/hooks/prerun.ts | 29 +-- .../cli-kit/src/public/node/is-global.test.ts | 160 ++++++++++++- packages/cli-kit/src/public/node/is-global.ts | 77 ++++++- .../src/public/node/node-package-manager.ts | 5 +- packages/cli-kit/src/public/node/output.ts | 1 + .../cli-kit/src/public/node/upgrade.test.ts | 151 ++++++++++--- packages/cli-kit/src/public/node/upgrade.ts | 196 ++++++++++++++-- packages/cli-kit/src/public/node/version.ts | 17 +- packages/cli/README.md | 6 +- packages/cli/oclif.manifest.json | 6 +- packages/cli/src/cli/commands/upgrade.test.ts | 2 +- packages/cli/src/cli/commands/upgrade.ts | 11 +- packages/cli/src/cli/services/upgrade.test.ts | 211 ------------------ packages/cli/src/cli/services/upgrade.ts | 196 ---------------- 26 files changed, 720 insertions(+), 558 deletions(-) create mode 100644 packages/cli-kit/src/public/node/hooks/postrun.test.ts delete mode 100644 packages/cli/src/cli/services/upgrade.test.ts delete mode 100644 packages/cli/src/cli/services/upgrade.ts diff --git a/docs-shopify.dev/commands/upgrade.doc.ts b/docs-shopify.dev/commands/upgrade.doc.ts index c0f307ed08a..2b016df7eaa 100644 --- a/docs-shopify.dev/commands/upgrade.doc.ts +++ b/docs-shopify.dev/commands/upgrade.doc.ts @@ -3,8 +3,8 @@ import {ReferenceEntityTemplateSchema} from '@shopify/generate-docs' const data: ReferenceEntityTemplateSchema = { name: 'upgrade', - description: `Shows details on how to upgrade Shopify CLI.`, - overviewPreviewDescription: `Shows details on how to upgrade Shopify CLI.`, + description: `Upgrades Shopify CLI using your package manager.`, + overviewPreviewDescription: `Upgrades Shopify CLI.`, type: 'command', isVisualComponent: false, defaultExample: { diff --git a/docs-shopify.dev/generated/generated_docs_data.json b/docs-shopify.dev/generated/generated_docs_data.json index 5d51044f3a6..359e7f0a4f5 100644 --- a/docs-shopify.dev/generated/generated_docs_data.json +++ b/docs-shopify.dev/generated/generated_docs_data.json @@ -7815,8 +7815,8 @@ }, { "name": "upgrade", - "description": "Shows details on how to upgrade Shopify CLI.", - "overviewPreviewDescription": "Shows details on how to upgrade Shopify CLI.", + "description": "Upgrades Shopify CLI using your package manager.", + "overviewPreviewDescription": "Upgrades Shopify CLI.", "type": "command", "isVisualComponent": false, "defaultExample": { diff --git a/packages/app/src/cli/services/app/config/link.test.ts b/packages/app/src/cli/services/app/config/link.test.ts index e0d9ccaa684..8168b4326c2 100644 --- a/packages/app/src/cli/services/app/config/link.test.ts +++ b/packages/app/src/cli/services/app/config/link.test.ts @@ -37,6 +37,9 @@ vi.mock('@shopify/cli-kit/node/ui') vi.mock('../../context/partner-account-info.js') vi.mock('../../context.js') vi.mock('../select-app.js') +vi.mock('@shopify/cli-kit/node/is-global', () => ({ + currentProcessIsGlobal: () => false, +})) const DEFAULT_REMOTE_CONFIGURATION = { name: 'app1', diff --git a/packages/app/src/cli/services/app/config/use.test.ts b/packages/app/src/cli/services/app/config/use.test.ts index 6f8418c59b0..f419880b61c 100644 --- a/packages/app/src/cli/services/app/config/use.test.ts +++ b/packages/app/src/cli/services/app/config/use.test.ts @@ -19,6 +19,9 @@ vi.mock('../../local-storage.js') vi.mock('../../../models/app/loader.js') vi.mock('@shopify/cli-kit/node/ui') vi.mock('../../context.js') +vi.mock('@shopify/cli-kit/node/is-global', () => ({ + currentProcessIsGlobal: () => false, +})) describe('use', () => { test('clears currentConfiguration when reset is true', async () => { @@ -31,6 +34,7 @@ describe('use', () => { developerPlatformClient: testDeveloperPlatformClient(), } writeFileSync(joinPath(tmp, 'package.json'), '{}') + writeFileSync(joinPath(tmp, 'shopify.app.toml'), '') // When await use(options) diff --git a/packages/app/src/cli/services/generate.test.ts b/packages/app/src/cli/services/generate.test.ts index e00dc5849d1..5f3782257dd 100644 --- a/packages/app/src/cli/services/generate.test.ts +++ b/packages/app/src/cli/services/generate.test.ts @@ -38,6 +38,9 @@ vi.mock('../prompts/generate/extension.js') vi.mock('../services/generate/extension.js') vi.mock('../services/context.js') vi.mock('./local-storage.js') +vi.mock('@shopify/cli-kit/node/is-global', () => ({ + currentProcessIsGlobal: () => false, +})) afterEach(() => { mockAndCaptureOutput().clear() diff --git a/packages/app/src/cli/services/init/init.ts b/packages/app/src/cli/services/init/init.ts index 0dee2acba57..7356800393e 100644 --- a/packages/app/src/cli/services/init/init.ts +++ b/packages/app/src/cli/services/init/init.ts @@ -124,6 +124,7 @@ async function init(options: InitOptions) { await appendFile(joinPath(templateScaffoldDir, '.npmrc'), `auto-install-peers=true\n`) break } + case 'homebrew': case 'unknown': throw new UnknownPackageManagerError() } diff --git a/packages/cli-kit/src/private/node/constants.ts b/packages/cli-kit/src/private/node/constants.ts index 3672b8b4c29..6e7f3891c9e 100644 --- a/packages/cli-kit/src/private/node/constants.ts +++ b/packages/cli-kit/src/private/node/constants.ts @@ -46,6 +46,7 @@ export const environmentVariables = { neverUsePartnersApi: 'SHOPIFY_CLI_NEVER_USE_PARTNERS_API', skipNetworkLevelRetry: 'SHOPIFY_CLI_SKIP_NETWORK_LEVEL_RETRY', maxRequestTimeForNetworkCalls: 'SHOPIFY_CLI_MAX_REQUEST_TIME_FOR_NETWORK_CALLS', + noAutoUpgrade: 'SHOPIFY_CLI_NO_AUTO_UPGRADE', } export const defaultThemeKitAccessDomain = 'theme-kit-access.shopifyapps.com' diff --git a/packages/cli-kit/src/public/node/context/local.ts b/packages/cli-kit/src/public/node/context/local.ts index 6ab4755a4a5..3dc773be3a9 100644 --- a/packages/cli-kit/src/public/node/context/local.ts +++ b/packages/cli-kit/src/public/node/context/local.ts @@ -292,4 +292,14 @@ export function opentelemetryDomain(env = process.env): string { return isSet(domain) ? domain : 'https://otlp-http-production-cli.shopifysvc.com' } +/** + * Returns true if the CLIshould not automatically upgrade. + * + * @param env - The environment variables from the environment of the current process. + * @returns True if the CLI should not automatically upgrade. + */ +export function noAutoUpgrade(env = process.env): boolean { + return isTruthy(env[environmentVariables.noAutoUpgrade]) +} + export type CIMetadata = Metadata diff --git a/packages/cli-kit/src/public/node/fs.ts b/packages/cli-kit/src/public/node/fs.ts index 09c16a4ce35..b6eafec53f6 100644 --- a/packages/cli-kit/src/public/node/fs.ts +++ b/packages/cli-kit/src/public/node/fs.ts @@ -15,7 +15,7 @@ import { import {temporaryDirectory, temporaryDirectoryTask} from 'tempy' import {sep, join} from 'pathe' -import {findUp as internalFindUp} from 'find-up' +import {findUp as internalFindUp, findUpSync as internalFindUpSync} from 'find-up' import {minimatch} from 'minimatch' import fastGlobLib from 'fast-glob' import { @@ -650,6 +650,23 @@ export async function findPathUp( return got ? normalizePath(got) : undefined } +/** + * Find a file by walking parent directories. + * + * @param matcher - A pattern or an array of patterns to match a file name. + * @param options - Options for the search. + * @returns The first path found that matches or `undefined` if none could be found. + */ +export function findPathUpSync( + matcher: OverloadParameters[0], + options: OverloadParameters[1], +): ReturnType { + // findUp has odd typing + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const got = internalFindUpSync(matcher as any, options) + return got ? normalizePath(got) : undefined +} + export interface MatchGlobOptions { matchBase: boolean noglobstar: boolean diff --git a/packages/cli-kit/src/public/node/hooks/postrun.test.ts b/packages/cli-kit/src/public/node/hooks/postrun.test.ts new file mode 100644 index 00000000000..0553ecf3031 --- /dev/null +++ b/packages/cli-kit/src/public/node/hooks/postrun.test.ts @@ -0,0 +1,83 @@ +import {autoUpgradeIfNeeded} from './postrun.js' +import {mockAndCaptureOutput} from '../testing/output.js' +import {getOutputUpdateCLIReminder, runCLIUpgrade, versionToAutoUpgrade} from '../upgrade.js' +import {isMajorVersionChange} from '../version.js' +import {describe, expect, test, vi, afterEach} from 'vitest' + +vi.mock('../upgrade.js', async (importOriginal) => { + const actual: any = await importOriginal() + return { + ...actual, + runCLIUpgrade: vi.fn(), + getOutputUpdateCLIReminder: vi.fn(), + versionToAutoUpgrade: vi.fn(), + } +}) + +vi.mock('../version.js', async (importOriginal) => { + const actual: any = await importOriginal() + return { + ...actual, + isMajorVersionChange: vi.fn(), + } +}) + +afterEach(() => { + mockAndCaptureOutput().clear() +}) + +describe('autoUpgradeIfNeeded', () => { + test('runs the upgrade when versionToAutoUpgrade returns a version', async () => { + // Given + vi.mocked(versionToAutoUpgrade).mockReturnValue('3.91.0') + vi.mocked(runCLIUpgrade).mockResolvedValue() + + // When + await autoUpgradeIfNeeded() + + // Then + expect(runCLIUpgrade).toHaveBeenCalled() + }) + + test('falls back to warning when the upgrade fails', async () => { + // Given + const outputMock = mockAndCaptureOutput() + vi.mocked(versionToAutoUpgrade).mockReturnValue('3.91.0') + vi.mocked(runCLIUpgrade).mockRejectedValue(new Error('upgrade failed')) + const installReminder = '💡 Version 3.91.0 available! Run `npm install @shopify/cli@latest`' + vi.mocked(getOutputUpdateCLIReminder).mockReturnValue(installReminder) + + // When + await autoUpgradeIfNeeded() + + // Then + expect(outputMock.warn()).toMatch(installReminder) + }) + + test('does nothing when versionToAutoUpgrade returns undefined', async () => { + // Given + vi.mocked(versionToAutoUpgrade).mockReturnValue(undefined) + + // When + await autoUpgradeIfNeeded() + + // Then + expect(runCLIUpgrade).not.toHaveBeenCalled() + }) + + test('shows warning instead of upgrading for a major version change', async () => { + // Given + const outputMock = mockAndCaptureOutput() + vi.mocked(versionToAutoUpgrade).mockReturnValue('4.0.0') + vi.mocked(isMajorVersionChange).mockReturnValue(true) + const installReminder = '💡 Version 4.0.0 available! Run `npm install @shopify/cli@latest`' + vi.mocked(getOutputUpdateCLIReminder).mockReturnValue(installReminder) + + // When + await autoUpgradeIfNeeded() + + // Then + expect(runCLIUpgrade).not.toHaveBeenCalled() + expect(outputMock.warn()).toMatch(installReminder) + }) +}) diff --git a/packages/cli-kit/src/public/node/hooks/postrun.ts b/packages/cli-kit/src/public/node/hooks/postrun.ts index 6c67eb858df..83598c3ad0f 100644 --- a/packages/cli-kit/src/public/node/hooks/postrun.ts +++ b/packages/cli-kit/src/public/node/hooks/postrun.ts @@ -1,8 +1,11 @@ import {postrun as deprecationsHook} from './deprecations.js' import {reportAnalyticsEvent} from '../analytics.js' -import {outputDebug} from '../../../public/node/output.js' +import {outputDebug, outputWarn} from '../../../public/node/output.js' +import {getOutputUpdateCLIReminder, runCLIUpgrade, versionToAutoUpgrade} from '../../../public/node/upgrade.js' import BaseCommand from '../base-command.js' import * as metadata from '../../../public/node/metadata.js' +import {CLI_KIT_VERSION} from '../../common/version.js' +import {isMajorVersionChange} from '../version.js' import {Command, Hook} from '@oclif/core' let postRunHookCompleted = false @@ -25,6 +28,33 @@ export const hook: Hook.Postrun = async ({config, Command}) => { const command = Command.id.replace(/:/g, ' ') outputDebug(`Completed command ${command}`) postRunHookCompleted = true + + if (!command.includes('notifications')) await autoUpgradeIfNeeded() +} + +/** + * Auto-upgrades the CLI after a command completes, if a newer version is available. + * + * @returns Resolves when the upgrade attempt (or fallback warning) is complete. + */ +export async function autoUpgradeIfNeeded(): Promise { + const newerVersion = versionToAutoUpgrade() + if (!newerVersion) return + if (isMajorVersionChange(CLI_KIT_VERSION, newerVersion)) { + return outputWarn(getOutputUpdateCLIReminder(newerVersion)) + } + + try { + await runCLIUpgrade() + // eslint-disable-next-line no-catch-all/no-catch-all + } catch (error) { + const errorMessage = `Auto-upgrade failed: ${error}` + outputDebug(errorMessage) + outputWarn(getOutputUpdateCLIReminder(newerVersion)) + // Report to Observe as a handled error without showing anything extra to the user + const {sendErrorToBugsnag} = await import('../../../public/node/error-handler.js') + await sendErrorToBugsnag(new Error(errorMessage), 'expected_error') + } } /** diff --git a/packages/cli-kit/src/public/node/hooks/prerun.test.ts b/packages/cli-kit/src/public/node/hooks/prerun.test.ts index d242ddacfbc..7a76a822187 100644 --- a/packages/cli-kit/src/public/node/hooks/prerun.test.ts +++ b/packages/cli-kit/src/public/node/hooks/prerun.test.ts @@ -1,47 +1,5 @@ -import {parseCommandContent, warnOnAvailableUpgrade} from './prerun.js' -import {checkForCachedNewVersion, packageManagerFromUserAgent} from '../node-package-manager.js' -import {cacheClear} from '../../../private/node/conf-store.js' -import {mockAndCaptureOutput} from '../testing/output.js' -import {describe, expect, test, vi, afterEach, beforeEach} from 'vitest' - -vi.mock('../node-package-manager') - -beforeEach(() => { - cacheClear() -}) - -afterEach(() => { - mockAndCaptureOutput().clear() - cacheClear() -}) - -describe('warnOnAvailableUpgrade', () => { - test('displays latest version and an install command when a newer exists', async () => { - // Given - const outputMock = mockAndCaptureOutput() - vi.mocked(checkForCachedNewVersion).mockReturnValue('3.0.10') - vi.mocked(packageManagerFromUserAgent).mockReturnValue('npm') - const installReminder = '💡 Version 3.0.10 available! Run `npm install @shopify/cli@latest`' - - // When - await warnOnAvailableUpgrade() - - // Then - expect(outputMock.warn()).toMatch(installReminder) - }) - - test('displays nothing when no newer version exists', async () => { - // Given - const outputMock = mockAndCaptureOutput() - vi.mocked(checkForCachedNewVersion).mockReturnValue(undefined) - - // When - await warnOnAvailableUpgrade() - - // Then - expect(outputMock.warn()).toEqual('') - }) -}) +import {parseCommandContent} from './prerun.js' +import {describe, expect, test} from 'vitest' describe('parseCommandContent', () => { test('when a create command is used should return the correct command content', async () => { diff --git a/packages/cli-kit/src/public/node/hooks/prerun.ts b/packages/cli-kit/src/public/node/hooks/prerun.ts index 298df53a913..f9eae7b8794 100644 --- a/packages/cli-kit/src/public/node/hooks/prerun.ts +++ b/packages/cli-kit/src/public/node/hooks/prerun.ts @@ -1,12 +1,10 @@ import {CLI_KIT_VERSION} from '../../common/version.js' -import {checkForNewVersion, checkForCachedNewVersion} from '../node-package-manager.js' +import {isPreReleaseVersion} from '../version.js' +import {checkForNewVersion} from '../node-package-manager.js' import {startAnalytics} from '../../../private/node/analytics.js' -import {outputDebug, outputWarn} from '../../../public/node/output.js' -import {getOutputUpdateCLIReminder} from '../../../public/node/upgrade.js' +import {outputDebug} from '../../../public/node/output.js' import Command from '../../../public/node/base-command.js' -import {runAtMinimumInterval} from '../../../private/node/conf-store.js' import {fetchNotificationsInBackground} from '../notifications-system.js' -import {isPreReleaseVersion} from '../version.js' import {Hook} from '@oclif/core' export declare interface CommandContent { @@ -22,7 +20,7 @@ export const hook: Hook.Prerun = async (options) => { pluginAlias: options.Command.plugin?.alias, }) const args = options.argv - await warnOnAvailableUpgrade() + checkForNewVersionInBackground() outputDebug(`Running command ${commandContent.command}`) await startAnalytics({commandContent, args, commandClass: options.Command as unknown as typeof Command}) fetchNotificationsInBackground(options.Command.id) @@ -89,25 +87,14 @@ function findAlias(aliases: string[]) { } /** - * Warns the user if there is a new version of the CLI available + * Triggers a background check for a newer CLI version (non-blocking). + * The result is cached and consumed by the postrun hook for auto-upgrade. */ -export async function warnOnAvailableUpgrade(): Promise { - const cliDependency = '@shopify/cli' +export function checkForNewVersionInBackground(): void { const currentVersion = CLI_KIT_VERSION if (isPreReleaseVersion(currentVersion)) { - // This is a nightly/snapshot/experimental version, so we don't want to check for updates return } - - // Check in the background, once daily // eslint-disable-next-line no-void - void checkForNewVersion(cliDependency, currentVersion, {cacheExpiryInHours: 24}) - - // Warn if we previously found a new version - await runAtMinimumInterval('warn-on-available-upgrade', {days: 1}, async () => { - const newerVersion = checkForCachedNewVersion(cliDependency, currentVersion) - if (newerVersion) { - outputWarn(getOutputUpdateCLIReminder(newerVersion)) - } - }) + void checkForNewVersion('@shopify/cli', currentVersion, {cacheExpiryInHours: 24}) } diff --git a/packages/cli-kit/src/public/node/is-global.test.ts b/packages/cli-kit/src/public/node/is-global.test.ts index 75e6c82e283..dfba905f137 100644 --- a/packages/cli-kit/src/public/node/is-global.test.ts +++ b/packages/cli-kit/src/public/node/is-global.test.ts @@ -1,24 +1,62 @@ import {currentProcessIsGlobal, inferPackageManagerForGlobalCLI, installGlobalCLIPrompt} from './is-global.js' +import {findPathUpSync} from './fs.js' +import {cwd} from './path.js' import {terminalSupportsPrompting} from './system.js' import {renderSelectPrompt} from './ui.js' import {globalCLIVersion} from './version.js' -import * as execa from 'execa' import {beforeEach, describe, expect, test, vi} from 'vitest' +import {realpathSync} from 'fs' vi.mock('./system.js') vi.mock('./ui.js') -vi.mock('execa') vi.mock('which') vi.mock('./version.js') +// Mock fs.js to make findPathUpSync controllable for getProjectDir. +// find-up v6 runs returned paths through locatePathSync which checks file existence, +// so we need to mock findPathUpSync directly rather than globSync. +vi.mock('./fs.js', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + findPathUpSync: vi.fn((...args: Parameters) => actual.findPathUpSync(...args)), + } +}) + +// Mock fs.realpathSync at the module level +// By default, call through to the real implementation for real paths, +// but return the path as-is for fake test paths that don't exist +vi.mock('fs', async (importOriginal) => { + const actual = await importOriginal() + const realRealpathSync = actual.realpathSync + const {existsSync} = actual + return { + ...actual, + realpathSync: vi.fn((path, options) => { + // For real paths, use the actual implementation + // For fake test paths, just return the path as-is + if (existsSync(String(path))) { + return realRealpathSync(path, options) + } + return String(path) + }), + } +}) + const globalNPMPath = '/path/to/global/npm' const globalYarnPath = '/path/to/global/yarn' const globalPNPMPath = '/path/to/global/pnpm' +const globalHomebrewIntel = '/usr/local/Cellar/shopify-cli/3.89.0/bin/shopify' +const globalHomebrewAppleSilicon = '/opt/homebrew/Cellar/shopify-cli/3.89.0/bin/shopify' +const globalHomebrewLinux = '/home/linuxbrew/.linuxbrew/Cellar/shopify-cli/3.89.0/bin/shopify' const unknownGlobalPath = '/path/to/global/unknown' -const localProjectPath = '/path/local' +// Must be within the actual workspace so currentProcessIsGlobal recognizes it as local +const localProjectPath = `${cwd()}/node_modules/.bin/shopify` beforeEach(() => { - ;(vi.mocked(execa.execaSync) as any).mockReturnValue({stdout: localProjectPath}) + // Mock findPathUpSync so getProjectDir returns a shopify.app.toml at the cwd. + // This lets currentProcessIsGlobal compare binary paths against the project root. + vi.mocked(findPathUpSync).mockReturnValue(`${cwd()}/shopify.app.toml`) }) describe('currentProcessIsGlobal', () => { @@ -46,6 +84,11 @@ describe('currentProcessIsGlobal', () => { }) describe('inferPackageManagerForGlobalCLI', () => { + beforeEach(() => { + // Reset mock to default behavior (calls through to real implementation) + vi.mocked(realpathSync).mockClear() + }) + test('returns yarn if yarn is in path', async () => { // Given const argv = ['node', globalYarnPath, 'shopify'] @@ -89,6 +132,115 @@ describe('inferPackageManagerForGlobalCLI', () => { // Then expect(got).toBe('unknown') }) + + test('returns homebrew if SHOPIFY_HOMEBREW_FORMULA is set', async () => { + // Given + const argv = ['node', globalHomebrewAppleSilicon, 'shopify'] + const env = {SHOPIFY_HOMEBREW_FORMULA: 'shopify-cli'} + + // When + const got = inferPackageManagerForGlobalCLI(argv, env) + + // Then + expect(got).toBe('homebrew') + }) + + test('returns homebrew for Intel Mac Cellar path', async () => { + // Given + const argv = ['node', globalHomebrewIntel, 'shopify'] + + // When + const got = inferPackageManagerForGlobalCLI(argv) + + // Then + expect(got).toBe('homebrew') + }) + + test('returns homebrew for Apple Silicon Cellar path', async () => { + // Given + const argv = ['node', globalHomebrewAppleSilicon, 'shopify'] + + // When + const got = inferPackageManagerForGlobalCLI(argv) + + // Then + expect(got).toBe('homebrew') + }) + + test('returns homebrew for Linux Homebrew path', async () => { + // Given + const argv = ['node', globalHomebrewLinux, 'shopify'] + + // When + const got = inferPackageManagerForGlobalCLI(argv) + + // Then + expect(got).toBe('homebrew') + }) + + test('returns homebrew when HOMEBREW_PREFIX matches path', async () => { + // Given + const argv = ['node', '/opt/homebrew/bin/shopify', 'shopify'] + const env = {HOMEBREW_PREFIX: '/opt/homebrew'} + + // When + const got = inferPackageManagerForGlobalCLI(argv, env) + + // Then + expect(got).toBe('homebrew') + }) + + test('resolves symlinks to detect actual package manager (yarn)', async () => { + // Given: A symlink in /opt/homebrew/bin pointing to yarn global + const symlinkPath = '/opt/homebrew/bin/shopify' + const realYarnPath = '/Users/user/.config/yarn/global/node_modules/.bin/shopify' + const argv = ['node', symlinkPath, 'shopify'] + const env = {HOMEBREW_PREFIX: '/opt/homebrew'} + + // Mock realpathSync for this specific test to resolve the symlink + vi.mocked(realpathSync).mockImplementationOnce(() => realYarnPath) + + // When + const got = inferPackageManagerForGlobalCLI(argv, env) + + // Then: Should detect yarn (from real path), not homebrew (from symlink) + expect(got).toBe('yarn') + expect(vi.mocked(realpathSync)).toHaveBeenCalledWith(symlinkPath) + }) + + test('resolves symlinks to detect real homebrew installation', async () => { + // Given: A symlink in /opt/homebrew/bin pointing to a Cellar path (real Homebrew) + const symlinkPath = '/opt/homebrew/bin/shopify' + const realHomebrewPath = '/opt/homebrew/Cellar/shopify-cli/3.89.0/bin/shopify' + const argv = ['node', symlinkPath, 'shopify'] + + // Mock realpathSync for this specific test to resolve the symlink + vi.mocked(realpathSync).mockImplementationOnce(() => realHomebrewPath) + + // When + const got = inferPackageManagerForGlobalCLI(argv) + + // Then: Should still detect homebrew from the real Cellar path + expect(got).toBe('homebrew') + }) + + test('falls back to original path if realpath fails', async () => { + // Given: A path that realpathSync cannot resolve + const nonExistentPath = '/opt/homebrew/bin/shopify' + const argv = ['node', nonExistentPath, 'shopify'] + const env = {HOMEBREW_PREFIX: '/opt/homebrew'} + + // Mock realpathSync for this specific test to throw an error + vi.mocked(realpathSync).mockImplementationOnce(() => { + throw new Error('ENOENT: no such file or directory') + }) + + // When + const got = inferPackageManagerForGlobalCLI(argv, env) + + // Then: Should fall back to checking the original path + expect(got).toBe('homebrew') + }) }) describe('installGlobalCLIPrompt', () => { diff --git a/packages/cli-kit/src/public/node/is-global.ts b/packages/cli-kit/src/public/node/is-global.ts index 5b2bb02ef1e..8827480f509 100644 --- a/packages/cli-kit/src/public/node/is-global.ts +++ b/packages/cli-kit/src/public/node/is-global.ts @@ -1,11 +1,12 @@ import {PackageManager} from './node-package-manager.js' import {outputInfo} from './output.js' -import {cwd, sniffForPath} from './path.js' +import {cwd, dirname, joinPath, sniffForPath} from './path.js' import {exec, terminalSupportsPrompting} from './system.js' import {renderSelectPrompt} from './ui.js' import {globalCLIVersion} from './version.js' import {isUnitTest} from './context/local.js' -import {execaSync} from 'execa' +import {findPathUpSync, globSync} from './fs.js' +import {realpathSync} from 'fs' let _isGlobal: boolean | undefined @@ -23,15 +24,16 @@ export function currentProcessIsGlobal(argv = process.argv): boolean { // Path where the current project is (app/hydrogen) const path = sniffForPath() ?? cwd() - // Closest parent directory to contain a package.json file or node_modules directory - // https://docs.npmjs.com/cli/v8/commands/npm-prefix#description - const npmPrefix = execaSync('npm', ['prefix'], {cwd: path}).stdout.trim() + const projectDir = getProjectDir(path) + if (!projectDir) { + return true + } // From node docs: "The second element [of the array] will be the path to the JavaScript file being executed" const binDir = argv[1] ?? '' - // If binDir starts with npmPrefix, then we are running a local CLI - const isLocal = binDir.startsWith(npmPrefix.trim()) + // If binDir starts with packageJsonPath, then we are running a local CLI + const isLocal = binDir.startsWith(projectDir.trim()) _isGlobal = !isLocal return _isGlobal @@ -82,15 +84,68 @@ export async function installGlobalCLIPrompt(): Promise { + const configPaths = globSync(configFiles.map((file) => joinPath(directory, file))) + return configPaths.length > 0 ? configPaths[0] : undefined + } + try { + const configFile = findPathUpSync(existsConfigFile, { + cwd: directory, + type: 'file', + }) + if (configFile) return dirname(configFile) + // eslint-disable-next-line no-catch-all/no-catch-all + } catch (error) { + return undefined + } +} diff --git a/packages/cli-kit/src/public/node/node-package-manager.ts b/packages/cli-kit/src/public/node/node-package-manager.ts index 5aa52ef72aa..fba6b25b465 100644 --- a/packages/cli-kit/src/public/node/node-package-manager.ts +++ b/packages/cli-kit/src/public/node/node-package-manager.ts @@ -35,6 +35,7 @@ export const lockfilesByManager: {[key in PackageManager]: Lockfile | undefined} npm: npmLockfile, pnpm: pnpmLockfile, bun: bunLockfile, + homebrew: undefined, unknown: undefined, } export type Lockfile = 'yarn.lock' | 'package-lock.json' | 'pnpm-lock.yaml' | 'bun.lockb' @@ -50,7 +51,7 @@ export type DependencyType = 'dev' | 'prod' | 'peer' /** * A union that represents the package managers available. */ -export const packageManager = ['yarn', 'npm', 'pnpm', 'bun', 'unknown'] as const +export const packageManager = ['yarn', 'npm', 'pnpm', 'bun', 'homebrew', 'unknown'] as const export type PackageManager = (typeof packageManager)[number] /** @@ -526,6 +527,8 @@ export async function addNPMDependencies( await installDependencies(options, argumentsToAddDependenciesWithBun(dependenciesWithVersion, options.type)) await installDependencies(options, ['install']) break + case 'homebrew': + throw new AbortError("Homebrew can't be used to install project dependencies. Use npm, yarn, pnpm, or bun.") case 'unknown': throw new UnknownPackageManagerError() } diff --git a/packages/cli-kit/src/public/node/output.ts b/packages/cli-kit/src/public/node/output.ts index db95c2ebd14..9bf1043d4ed 100644 --- a/packages/cli-kit/src/public/node/output.ts +++ b/packages/cli-kit/src/public/node/output.ts @@ -124,6 +124,7 @@ export function formatPackageManagerCommand( } return pieces.join(' ') } + case 'homebrew': case 'unknown': { const pieces = [scriptName, ...scriptArgs] return pieces.join(' ') diff --git a/packages/cli-kit/src/public/node/upgrade.test.ts b/packages/cli-kit/src/public/node/upgrade.test.ts index 7e77af98534..bbb2c17f1bc 100644 --- a/packages/cli-kit/src/public/node/upgrade.test.ts +++ b/packages/cli-kit/src/public/node/upgrade.test.ts @@ -1,17 +1,33 @@ +import {noAutoUpgrade, isDevelopment} from './context/local.js' import {currentProcessIsGlobal, inferPackageManagerForGlobalCLI} from './is-global.js' -import {packageManagerFromUserAgent} from './node-package-manager.js' -import {cliInstallCommand} from './upgrade.js' -import {vi, describe, test, expect} from 'vitest' +import {checkForCachedNewVersion, packageManagerFromUserAgent, PackageManager} from './node-package-manager.js' +import {exec, isCI} from './system.js' +import {cliInstallCommand, runCLIUpgrade, versionToAutoUpgrade} from './upgrade.js' +import {isPreReleaseVersion} from './version.js' +import {vi, describe, test, expect, beforeEach} from 'vitest' +vi.mock('./context/local.js') vi.mock('./is-global.js') vi.mock('./node-package-manager.js') +vi.mock('./system.js') +vi.mock('./version.js', async (importOriginal) => { + const actual: any = await importOriginal() + return { + ...actual, + isPreReleaseVersion: vi.fn(() => false), + } +}) describe('cliInstallCommand', () => { + beforeEach(() => { + // Mock isDevelopment to return false by default (not in CLI development mode) + vi.mocked(isDevelopment).mockReturnValue(false) + }) + test('says to install globally via npm if the current process is globally installed and no package manager is provided', () => { // Given vi.mocked(currentProcessIsGlobal).mockReturnValue(true) - vi.mocked(packageManagerFromUserAgent).mockReturnValue('unknown') - vi.mocked(inferPackageManagerForGlobalCLI).mockReturnValue('unknown') + vi.mocked(inferPackageManagerForGlobalCLI).mockReturnValue('npm') // When const got = cliInstallCommand() @@ -67,63 +83,132 @@ describe('cliInstallCommand', () => { `) }) - test('says to install locally via npm if the current process is locally installed and no package manager is provided', () => { + test('returns undefined if the current process is locally installed', () => { // Given vi.mocked(currentProcessIsGlobal).mockReturnValue(false) - vi.mocked(packageManagerFromUserAgent).mockReturnValue('unknown') - vi.mocked(inferPackageManagerForGlobalCLI).mockReturnValue('unknown') // When const got = cliInstallCommand() // Then - expect(got).toMatchInlineSnapshot(` - "npm install @shopify/cli@latest" - `) + expect(got).toBeUndefined() + }) +}) +describe('runCLIUpgrade', () => { + beforeEach(() => { + // Mock isDevelopment to return false by default (not in CLI development mode) + vi.mocked(isDevelopment).mockReturnValue(false) }) - test('says to install locally via yarn if the current process is locally installed and yarn is the global package manager', () => { + test('runs the install command via exec for a global npm install', async () => { // Given - vi.mocked(currentProcessIsGlobal).mockReturnValue(false) - vi.mocked(packageManagerFromUserAgent).mockReturnValue('unknown') + vi.mocked(currentProcessIsGlobal).mockReturnValue(true) + vi.mocked(inferPackageManagerForGlobalCLI).mockReturnValue('npm') + vi.mocked(exec).mockResolvedValue() + + // When + await runCLIUpgrade() + + // Then + expect(exec).toHaveBeenCalledWith('npm', ['install', '-g', '@shopify/cli@latest'], {stdio: 'inherit'}) + }) + + test('runs the install command via exec for a global yarn install', async () => { + // Given + vi.mocked(currentProcessIsGlobal).mockReturnValue(true) vi.mocked(inferPackageManagerForGlobalCLI).mockReturnValue('yarn') + vi.mocked(exec).mockResolvedValue() // When - const got = cliInstallCommand() + await runCLIUpgrade() // Then - expect(got).toMatchInlineSnapshot(` - "yarn add @shopify/cli@latest" - `) + expect(exec).toHaveBeenCalledWith('yarn', ['global', 'add', '@shopify/cli@latest'], {stdio: 'inherit'}) }) - test('says to install locally via npm if the current process is locally installed and npm is the global package manager', () => { + test('runs the install command via exec for a global homebrew install', async () => { // Given - vi.mocked(currentProcessIsGlobal).mockReturnValue(false) - vi.mocked(packageManagerFromUserAgent).mockReturnValue('unknown') - vi.mocked(inferPackageManagerForGlobalCLI).mockReturnValue('npm') + vi.mocked(currentProcessIsGlobal).mockReturnValue(true) + vi.mocked(inferPackageManagerForGlobalCLI).mockReturnValue('homebrew') + vi.mocked(exec).mockResolvedValue() // When - const got = cliInstallCommand() + await runCLIUpgrade() // Then - expect(got).toMatchInlineSnapshot(` - "npm install @shopify/cli@latest" - `) + expect(exec).toHaveBeenCalledWith('brew', ['upgrade', 'shopify-cli'], {stdio: 'inherit'}) + }) + + test('throws an error when cliInstallCommand returns undefined', async () => { + // Given + vi.mocked(currentProcessIsGlobal).mockReturnValue(true) + // Force a falsy return so cliInstallCommand() returns undefined + vi.mocked(inferPackageManagerForGlobalCLI).mockReturnValue('' as unknown as PackageManager) + + // When/Then + await expect(runCLIUpgrade()).rejects.toThrow('Could not determine the package manager') }) - test('says to install locally via pnpm if the current process is locally installed and pnpm is the global package manager', () => { + test('does nothing when running in development mode for local install (SHOPIFY_ENV=development)', async () => { // Given vi.mocked(currentProcessIsGlobal).mockReturnValue(false) - vi.mocked(packageManagerFromUserAgent).mockReturnValue('unknown') - vi.mocked(inferPackageManagerForGlobalCLI).mockReturnValue('pnpm') + vi.mocked(isDevelopment).mockReturnValue(true) // When - const got = cliInstallCommand() + await runCLIUpgrade() // Then - expect(got).toMatchInlineSnapshot(` - "pnpm add @shopify/cli@latest" - `) + expect(exec).not.toHaveBeenCalled() + }) +}) + +describe('versionToAutoUpgrade', () => { + test('returns the newer version for a minor bump outside of CI', () => { + vi.mocked(checkForCachedNewVersion).mockReturnValue('3.91.0') + vi.mocked(isCI).mockReturnValue(false) + vi.mocked(noAutoUpgrade).mockReturnValue(false) + expect(versionToAutoUpgrade()).toBe('3.91.0') + }) + + test('returns the newer version for a patch bump outside of CI', () => { + vi.mocked(checkForCachedNewVersion).mockReturnValue('3.90.1') + vi.mocked(isCI).mockReturnValue(false) + vi.mocked(noAutoUpgrade).mockReturnValue(false) + expect(versionToAutoUpgrade()).toBe('3.90.1') + }) + + test('returns undefined when no cached newer version exists', () => { + vi.mocked(checkForCachedNewVersion).mockReturnValue(undefined) + expect(versionToAutoUpgrade()).toBeUndefined() + }) + + test('returns undefined when running in CI', () => { + vi.mocked(checkForCachedNewVersion).mockReturnValue('3.91.0') + vi.mocked(isCI).mockReturnValue(true) + vi.mocked(noAutoUpgrade).mockReturnValue(false) + expect(versionToAutoUpgrade()).toBeUndefined() + }) + + test('returns undefined when SHOPIFY_CLI_NO_AUTO_UPGRADE is set', () => { + vi.mocked(checkForCachedNewVersion).mockReturnValue('3.91.0') + vi.mocked(isCI).mockReturnValue(false) + vi.mocked(noAutoUpgrade).mockReturnValue(true) + expect(versionToAutoUpgrade()).toBeUndefined() + }) + + test('returns the newer version for a major version change', () => { + vi.mocked(checkForCachedNewVersion).mockReturnValue('4.0.0') + vi.mocked(isCI).mockReturnValue(false) + vi.mocked(noAutoUpgrade).mockReturnValue(false) + expect(versionToAutoUpgrade()).toEqual('4.0.0') + }) + + test('returns undefined for a pre-release (nightly/snapshot) version', () => { + vi.mocked(checkForCachedNewVersion).mockReturnValue('3.91.0') + vi.mocked(isCI).mockReturnValue(false) + vi.mocked(noAutoUpgrade).mockReturnValue(false) + vi.mocked(isPreReleaseVersion).mockReturnValue(true) + expect(versionToAutoUpgrade()).toBeUndefined() + vi.mocked(isPreReleaseVersion).mockReturnValue(false) }) }) diff --git a/packages/cli-kit/src/public/node/upgrade.ts b/packages/cli-kit/src/public/node/upgrade.ts index 32c4904134e..a2d8e261540 100644 --- a/packages/cli-kit/src/public/node/upgrade.ts +++ b/packages/cli-kit/src/public/node/upgrade.ts @@ -1,6 +1,20 @@ -import {currentProcessIsGlobal, inferPackageManagerForGlobalCLI} from './is-global.js' -import {packageManagerFromUserAgent} from './node-package-manager.js' -import {outputContent, outputToken} from './output.js' +import {isDevelopment, noAutoUpgrade} from './context/local.js' +import {currentProcessIsGlobal, inferPackageManagerForGlobalCLI, getProjectDir} from './is-global.js' +import { + checkForCachedNewVersion, + findUpAndReadPackageJson, + PackageJson, + checkForNewVersion, + DependencyType, + usesWorkspaces, + addNPMDependencies, + getPackageManager, +} from './node-package-manager.js' +import {outputContent, outputDebug, outputInfo, outputToken} from './output.js' +import {cwd, moduleDirectory, sniffForPath} from './path.js' +import {exec, isCI} from './system.js' +import {isPreReleaseVersion} from './version.js' +import {CLI_KIT_VERSION} from '../common/version.js' /** * Utility function for generating an install command for the user to run @@ -8,24 +22,92 @@ import {outputContent, outputToken} from './output.js' * * @returns A string with the command to run. */ -export function cliInstallCommand(): string { +export function cliInstallCommand(): string | undefined { + const packageManager = inferPackageManagerForGlobalCLI() + if (!packageManager) return undefined + + if (packageManager === 'homebrew') { + return 'brew upgrade shopify-cli' + } else if (packageManager === 'yarn') { + return `${packageManager} global add @shopify/cli@latest` + } else { + const verb = packageManager === 'pnpm' ? 'add' : 'install' + return `${packageManager} ${verb} -g @shopify/cli@latest` + } +} + +/** + * Runs the CLI upgrade using the appropriate package manager. + * Determines the install command and executes it. + * + * @throws AbortError if the package manager or command cannot be determined. + */ +export async function runCLIUpgrade(): Promise { + // Path where the current project is (app/hydrogen) + const path = sniffForPath() ?? cwd() + const projectDir = getProjectDir(path) + + // Check if we are running in a global context if not, return const isGlobal = currentProcessIsGlobal() - let packageManager = packageManagerFromUserAgent() - // packageManagerFromUserAgent() will return 'unknown' if it can't determine the package manager - if (packageManager === 'unknown') { - packageManager = inferPackageManagerForGlobalCLI() + + // Don't auto-upgrade for development mode + if (!isGlobal && isDevelopment()) { + outputDebug('Auto-upgrade: Skipping auto-upgrade in development mode.') + return } - // inferPackageManagerForGlobalCLI() will also return 'unknown' if it can't determine the package manager - if (packageManager === 'unknown') packageManager = 'npm' - if (packageManager === 'yarn') { - return `${packageManager} ${isGlobal ? 'global ' : ''}add @shopify/cli@latest` + // Generate the install command for the global CLI and execute it + if (isGlobal) { + const installCommand = cliInstallCommand() + if (!installCommand) { + throw new Error('Could not determine the package manager') + } + const [command, ...args] = installCommand.split(' ') + if (!command) { + throw new Error('Could not determine the command to run') + } + outputInfo(outputContent`Auto-upgrading with: ${outputToken.genericShellCommand(installCommand)}...`) + await exec(command, args, {stdio: 'inherit'}) + } else if (projectDir) { + await upgradeLocalShopify(projectDir, CLI_KIT_VERSION) } else { - const verb = packageManager === 'pnpm' ? 'add' : 'install' - return `${packageManager} ${verb} ${isGlobal ? '-g ' : ''}@shopify/cli@latest` + throw new Error('Could not determine the local project directory') } } +/** + * Returns the version to auto-upgrade to, or undefined if auto-upgrade should be skipped. + * Checks for a cached newer version and skips for CI, pre-release versions, SHOPIFY_CLI_NO_AUTO_UPGRADE, or major version changes. + * + * @returns The version string to upgrade to, or undefined if no upgrade should happen. + */ +export function versionToAutoUpgrade(): string | undefined { + const currentVersion = CLI_KIT_VERSION + const newerVersion = checkForCachedNewVersion('@shopify/cli', currentVersion) + if (!newerVersion) { + outputDebug('Auto-upgrade: No newer version available.') + return undefined + } + if (process.env.SHOPIFY_CLI_FORCE_AUTO_UPGRADE === '1') { + outputDebug('Auto-upgrade: Forcing auto-upgrade because of SHOPIFY_CLI_FORCE_AUTO_UPGRADE.') + return newerVersion + } + if (isCI()) { + outputDebug('Auto-upgrade: Skipping auto-upgrade in CI.') + return undefined + } + if (isPreReleaseVersion(currentVersion)) { + outputDebug('Auto-upgrade: Skipping auto-upgrade for pre-release version.') + return undefined + } + if (noAutoUpgrade()) { + outputDebug('Auto-upgrade: Skipping auto-upgrade because of SHOPIFY_CLI_NO_AUTO_UPGRADE.') + return undefined + } + + return newerVersion +} + /** * Generates a message to remind the user to update the CLI. * @@ -33,6 +115,88 @@ export function cliInstallCommand(): string { * @returns The message to remind the user to update the CLI. */ export function getOutputUpdateCLIReminder(version: string): string { - return outputContent`💡 Version ${version} available! Run ${outputToken.genericShellCommand(cliInstallCommand())}` - .value + const installCommand = cliInstallCommand() + if (installCommand) { + return outputContent`💡 Version ${version} available! Run ${outputToken.genericShellCommand(installCommand)}`.value + } + return outputContent`💡 Version ${version} available!`.value +} + +async function upgradeLocalShopify(projectDir: string, currentVersion: string) { + const packageJson = (await findUpAndReadPackageJson(projectDir)).content + const packageJsonDependencies = packageJson.dependencies ?? {} + const packageJsonDevDependencies = packageJson.devDependencies ?? {} + const allDependencies = {...packageJsonDependencies, ...packageJsonDevDependencies} + + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + let resolvedCLIVersion = allDependencies[await cliDependency()]! + + if (resolvedCLIVersion.slice(0, 1).match(/[\^~]/)) resolvedCLIVersion = currentVersion + const newestCLIVersion = await checkForNewVersion(await cliDependency(), resolvedCLIVersion) + + if (newestCLIVersion) { + outputUpgradeMessage(resolvedCLIVersion, newestCLIVersion) + } else { + outputWontInstallMessage(resolvedCLIVersion) + } + + await installJsonDependencies('prod', packageJsonDependencies, projectDir) + await installJsonDependencies('dev', packageJsonDevDependencies, projectDir) +} + +async function installJsonDependencies( + depsEnv: DependencyType, + deps: {[key: string]: string}, + directory: string, +): Promise { + const packagesToUpdate = [await cliDependency(), ...(await oclifPlugins())] + .filter((pkg: string): boolean => { + const pkgRequirement: string | undefined = deps[pkg] + return Boolean(pkgRequirement) + }) + .map((pkg) => { + return {name: pkg, version: 'latest'} + }) + + const appUsesWorkspaces = await usesWorkspaces(directory) + + if (packagesToUpdate.length > 0) { + await addNPMDependencies(packagesToUpdate, { + packageManager: await getPackageManager(directory), + type: depsEnv, + directory, + stdout: process.stdout, + stderr: process.stderr, + addToRootDirectory: appUsesWorkspaces, + }) + } +} + +async function cliDependency(): Promise { + return (await packageJsonContents()).name +} + +async function oclifPlugins(): Promise { + return (await packageJsonContents())?.oclif?.plugins || [] +} + +type PackageJsonWithName = Omit & {name: string} +let _packageJsonContents: PackageJsonWithName | undefined + +async function packageJsonContents(): Promise { + if (!_packageJsonContents) { + const packageJson = await findUpAndReadPackageJson(moduleDirectory(import.meta.url)) + _packageJsonContents = _packageJsonContents ?? (packageJson.content as PackageJsonWithName) + } + return _packageJsonContents +} + +function outputWontInstallMessage(currentVersion: string): void { + outputInfo(outputContent`You're on the latest version, ${outputToken.yellow(currentVersion)}, no need to upgrade!`) +} + +function outputUpgradeMessage(currentVersion: string, newestVersion: string): void { + outputInfo( + outputContent`Upgrading CLI from ${outputToken.yellow(currentVersion)} to ${outputToken.yellow(newestVersion)}...`, + ) } diff --git a/packages/cli-kit/src/public/node/version.ts b/packages/cli-kit/src/public/node/version.ts index 3c958889471..8454cd83965 100644 --- a/packages/cli-kit/src/public/node/version.ts +++ b/packages/cli-kit/src/public/node/version.ts @@ -1,6 +1,6 @@ import {captureOutput} from '../node/system.js' import which from 'which' -import {satisfies} from 'semver' +import {satisfies, SemVer} from 'semver' /** * Returns the version of the local dependency of the CLI if it's installed in the provided directory. * @@ -53,3 +53,18 @@ export async function globalCLIVersion(): Promise { export function isPreReleaseVersion(version: string): boolean { return version.startsWith('0.0.0') } + +/** + * Checks if there is a major version change between two versions. + * Pre-release versions (0.0.0-*) are treated as not having a major version change. + * + * @param currentVersion - The current version. + * @param newerVersion - The newer version to compare against. + * @returns True if there is a major version change. + */ +export function isMajorVersionChange(currentVersion: string, newerVersion: string): boolean { + if (isPreReleaseVersion(currentVersion) || isPreReleaseVersion(newerVersion)) return false + const currentSemVer = new SemVer(currentVersion) + const newerSemVer = new SemVer(newerVersion) + return currentSemVer.major !== newerSemVer.major +} diff --git a/packages/cli/README.md b/packages/cli/README.md index 82416a8fed7..8dc256614ee 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -2742,16 +2742,16 @@ DESCRIPTION ## `shopify upgrade` -Shows details on how to upgrade Shopify CLI. +Upgrades Shopify CLI. ``` USAGE $ shopify upgrade DESCRIPTION - Shows details on how to upgrade Shopify CLI. + Upgrades Shopify CLI. - Shows details on how to upgrade Shopify CLI. + Upgrades Shopify CLI using your package manager. ``` ## `shopify version` diff --git a/packages/cli/oclif.manifest.json b/packages/cli/oclif.manifest.json index 105fd1e3678..5e3d0d2682d 100644 --- a/packages/cli/oclif.manifest.json +++ b/packages/cli/oclif.manifest.json @@ -7770,8 +7770,8 @@ ], "args": { }, - "description": "Shows details on how to upgrade Shopify CLI.", - "descriptionWithMarkdown": "Shows details on how to upgrade Shopify CLI.", + "description": "Upgrades Shopify CLI using your package manager.", + "descriptionWithMarkdown": "Upgrades Shopify CLI using your package manager.", "enableJsonFlag": false, "flags": { }, @@ -7783,7 +7783,7 @@ "pluginName": "@shopify/cli", "pluginType": "core", "strict": true, - "summary": "Shows details on how to upgrade Shopify CLI." + "summary": "Upgrades Shopify CLI." }, "version": { "aliases": [ diff --git a/packages/cli/src/cli/commands/upgrade.test.ts b/packages/cli/src/cli/commands/upgrade.test.ts index e56a55ace44..a6936ee57db 100644 --- a/packages/cli/src/cli/commands/upgrade.test.ts +++ b/packages/cli/src/cli/commands/upgrade.test.ts @@ -1,6 +1,6 @@ import {describe, test, vi, expect} from 'vitest' -vi.mock('../services/upgrade.js') +vi.mock('@shopify/cli-kit/node/upgrade') describe('upgrade command', () => { test('launches service with path', async () => { diff --git a/packages/cli/src/cli/commands/upgrade.ts b/packages/cli/src/cli/commands/upgrade.ts index 6472ea9b6dc..c8e39ffaf2c 100644 --- a/packages/cli/src/cli/commands/upgrade.ts +++ b/packages/cli/src/cli/commands/upgrade.ts @@ -1,17 +1,14 @@ -import {cliInstallCommand} from '@shopify/cli-kit/node/upgrade' import Command from '@shopify/cli-kit/node/base-command' -import {renderInfo} from '@shopify/cli-kit/node/ui' +import {runCLIUpgrade} from '@shopify/cli-kit/node/upgrade' export default class Upgrade extends Command { - static summary = 'Shows details on how to upgrade Shopify CLI.' + static summary = 'Upgrades Shopify CLI.' - static descriptionWithMarkdown = 'Shows details on how to upgrade Shopify CLI.' + static descriptionWithMarkdown = 'Upgrades Shopify CLI using your package manager.' static description = this.descriptionWithoutMarkdown() async run(): Promise { - renderInfo({ - body: [`To upgrade Shopify CLI use your package manager.\n`, `Example:`, {command: cliInstallCommand()}], - }) + await runCLIUpgrade() } } diff --git a/packages/cli/src/cli/services/upgrade.test.ts b/packages/cli/src/cli/services/upgrade.test.ts deleted file mode 100644 index 08407731792..00000000000 --- a/packages/cli/src/cli/services/upgrade.test.ts +++ /dev/null @@ -1,211 +0,0 @@ -import {upgrade} from './upgrade.js' -import * as upgradeService from './upgrade.js' -import {afterEach, beforeEach, describe, expect, vi, test} from 'vitest' -import {platformAndArch} from '@shopify/cli-kit/node/os' -import * as nodePackageManager from '@shopify/cli-kit/node/node-package-manager' -import {exec, captureOutput} from '@shopify/cli-kit/node/system' -import {inTemporaryDirectory, touchFile, writeFile} from '@shopify/cli-kit/node/fs' -import {joinPath, normalizePath} from '@shopify/cli-kit/node/path' -import {mockAndCaptureOutput} from '@shopify/cli-kit/node/testing/output' -import {AbortError} from '@shopify/cli-kit/node/error' - -const oldCliVersion = '3.0.0' -// just needs to be higher than oldCliVersion for these tests -const currentCliVersion = '3.10.0' - -vi.mock('@shopify/cli-kit/node/os', async () => { - return { - platformAndArch: vi.fn(), - } -}) -vi.mock('@shopify/cli-kit/node/system') - -beforeEach(async () => { - vi.mocked(platformAndArch).mockReturnValue({platform: 'windows', arch: 'amd64'}) -}) -afterEach(() => { - mockAndCaptureOutput().clear() -}) - -describe('upgrade global CLI', () => { - test('does not upgrade globally if the latest version is found', async () => { - await inTemporaryDirectory(async (tmpDir) => { - // Given - const outputMock = mockAndCaptureOutput() - vi.spyOn(nodePackageManager as any, 'checkForNewVersion').mockResolvedValue(undefined) - - // When - await upgrade(tmpDir, currentCliVersion, {env: {}}) - - // Then - expect(outputMock.info()).toMatchInlineSnapshot(` - "You're on the latest version, ${currentCliVersion}, no need to upgrade!" - `) - }) - }) - - test('upgrades globally using npm if the latest version is not found', async () => { - await inTemporaryDirectory(async (tmpDir) => { - // Given - const outputMock = mockAndCaptureOutput() - vi.spyOn(nodePackageManager as any, 'checkForNewVersion').mockResolvedValue(currentCliVersion) - - // When - await upgrade(tmpDir, oldCliVersion, {env: {}}) - - // Then - expect(vi.mocked(exec)).toHaveBeenCalledWith( - 'npm', - ['install', '-g', '@shopify/cli@latest', '@shopify/theme@latest'], - {stdio: 'inherit'}, - ) - expect(outputMock.info()).toMatchInlineSnapshot(` - "Upgrading CLI from ${oldCliVersion} to ${currentCliVersion}...\nAttempting to upgrade via \`npm install -g @shopify/cli@latest @shopify/theme@latest\`..." - `) - expect(outputMock.success()).toMatchInlineSnapshot(` - "Upgraded Shopify CLI to version ${currentCliVersion}" - `) - }) - }) - - const homebrewPackageNames = ['shopify-cli', 'shopify-cli@3'] - homebrewPackageNames.forEach((homebrewPackageName: string) => { - test('upgrades globally using Homebrew if the latest version is not found and the CLI was installed via Homebrew', async () => { - await inTemporaryDirectory(async (tmpDir) => { - // Given - vi.spyOn(nodePackageManager as any, 'checkForNewVersion').mockResolvedValue(currentCliVersion) - - // Then - await expect(async () => { - await upgrade(tmpDir, oldCliVersion, {env: {SHOPIFY_HOMEBREW_FORMULA: homebrewPackageName}}) - }).rejects.toThrowError(AbortError) - }) - }) - }) -}) - -describe('upgrade local CLI', () => { - test('throws an error if a valid app config file is missing', async () => { - await inTemporaryDirectory(async (tmpDir) => { - // Given - await Promise.all([ - writeFile( - joinPath(tmpDir, 'package.json'), - JSON.stringify({dependencies: {'@shopify/cli': currentCliVersion, '@shopify/app': currentCliVersion}}), - ), - touchFile(joinPath(tmpDir, 'shopify.wrongapp.toml')), - ]) - const outputMock = mockAndCaptureOutput() - vi.spyOn(nodePackageManager as any, 'checkForNewVersion').mockResolvedValue(undefined) - - // When // Then - await expect(upgrade(tmpDir, currentCliVersion, {env: {npm_config_user_agent: 'npm'}})).rejects.toBeInstanceOf( - AbortError, - ) - }) - }) - - test('does not upgrade locally if the latest version is found', async () => { - await inTemporaryDirectory(async (tmpDir) => { - // Given - await Promise.all([ - writeFile( - joinPath(tmpDir, 'package.json'), - JSON.stringify({dependencies: {'@shopify/cli': currentCliVersion, '@shopify/app': currentCliVersion}}), - ), - touchFile(joinPath(tmpDir, 'shopify.app.toml')), - ]) - const outputMock = mockAndCaptureOutput() - vi.spyOn(nodePackageManager as any, 'checkForNewVersion').mockResolvedValue(undefined) - - // When - await upgrade(tmpDir, currentCliVersion, {env: {npm_config_user_agent: 'npm'}}) - - // Then - expect(outputMock.info()).toMatchInlineSnapshot(` - "You're on the latest version, ${currentCliVersion}, no need to upgrade!" - `) - }) - }) - - test('upgrades locally if the latest version is not found', async () => { - await inTemporaryDirectory(async (tmpDir) => { - // Given - await Promise.all([ - writeFile( - joinPath(tmpDir, 'package.json'), - JSON.stringify({dependencies: {'@shopify/cli': oldCliVersion, '@shopify/app': oldCliVersion}}), - ), - touchFile(joinPath(tmpDir, 'shopify.app.toml')), - ]) - vi.mocked(captureOutput).mockResolvedValueOnce(tmpDir) - - const outputMock = mockAndCaptureOutput() - vi.spyOn(nodePackageManager as any, 'checkForNewVersion').mockResolvedValueOnce(currentCliVersion) - const addNPMDependenciesMock = vi - .spyOn(nodePackageManager as any, 'addNPMDependencies') - .mockResolvedValue(undefined) - - // When - await upgradeService.upgrade(tmpDir, oldCliVersion, {env: {}}) - - // Then - expect(captureOutput).toHaveBeenCalledWith('npm', ['prefix'], {cwd: normalizePath(tmpDir)}) - expect(outputMock.info()).toMatchInlineSnapshot(` - "Upgrading CLI from ${oldCliVersion} to ${currentCliVersion}..." - `) - expect(addNPMDependenciesMock).toHaveBeenCalledWith([{name: '@shopify/cli', version: 'latest'}], { - packageManager: 'npm', - type: 'prod', - directory: normalizePath(tmpDir), - stdout: process.stdout, - stderr: process.stderr, - addToRootDirectory: false, - }) - expect(outputMock.success()).toMatchInlineSnapshot(` - "Upgraded Shopify CLI to version ${currentCliVersion}" - `) - }) - }) - - test('upgrades locally if CLI is on latest version but APP isnt', async () => { - await inTemporaryDirectory(async (tmpDir) => { - // Given - await Promise.all([ - writeFile( - joinPath(tmpDir, 'package.json'), - JSON.stringify({dependencies: {'@shopify/cli': currentCliVersion, '@shopify/app': oldCliVersion}}), - ), - touchFile(joinPath(tmpDir, 'shopify.app.nondefault.toml')), - ]) - vi.mocked(captureOutput).mockResolvedValueOnce(tmpDir) - - const outputMock = mockAndCaptureOutput() - const checkMock = vi.spyOn(nodePackageManager as any, 'checkForNewVersion') - checkMock.mockResolvedValueOnce(undefined).mockResolvedValueOnce(currentCliVersion) - const addNPMDependenciesMock = vi - .spyOn(nodePackageManager as any, 'addNPMDependencies') - .mockResolvedValue(undefined) - - // When - await upgradeService.upgrade(tmpDir, oldCliVersion, {env: {}}) - - // Then - expect(captureOutput).toHaveBeenCalledWith('npm', ['prefix'], {cwd: normalizePath(tmpDir)}) - expect(outputMock.info()).toMatchInlineSnapshot(` - "Upgrading CLI from ${oldCliVersion} to ${currentCliVersion}..." - `) - expect(addNPMDependenciesMock).toHaveBeenCalledWith([{name: '@shopify/cli', version: 'latest'}], { - packageManager: 'npm', - type: 'prod', - directory: normalizePath(tmpDir), - stdout: process.stdout, - stderr: process.stderr, - addToRootDirectory: false, - }) - expect(outputMock.success()).toMatchInlineSnapshot(` - "Upgraded Shopify CLI to version ${currentCliVersion}" - `) - }) - }) -}) diff --git a/packages/cli/src/cli/services/upgrade.ts b/packages/cli/src/cli/services/upgrade.ts deleted file mode 100644 index 0ae3524bd3d..00000000000 --- a/packages/cli/src/cli/services/upgrade.ts +++ /dev/null @@ -1,196 +0,0 @@ -/* eslint-disable @typescript-eslint/no-invalid-void-type */ -import { - addNPMDependencies, - findUpAndReadPackageJson, - checkForNewVersion, - DependencyType, - getPackageManager, - PackageJson, - usesWorkspaces, -} from '@shopify/cli-kit/node/node-package-manager' -import {exec} from '@shopify/cli-kit/node/system' -import {dirname, joinPath, moduleDirectory} from '@shopify/cli-kit/node/path' -import {findPathUp, glob} from '@shopify/cli-kit/node/fs' -import {AbortError} from '@shopify/cli-kit/node/error' -import {outputContent, outputInfo, outputSuccess, outputToken, outputWarn} from '@shopify/cli-kit/node/output' - -type HomebrewPackageName = 'shopify-cli' | 'shopify-cli@3' - -// Canonical list of oclif plugins that should be installed globally -const globalPlugins = ['@shopify/theme'] - -interface UpgradeOptions { - env: NodeJS.ProcessEnv -} - -export async function upgrade( - directory: string, - currentVersion: string, - {env}: UpgradeOptions = {env: process.env}, -): Promise { - let newestVersion: string | void - - const projectDir = await getProjectDir(directory) - if (projectDir) { - newestVersion = await upgradeLocalShopify(projectDir, currentVersion) - } else if (usingPackageManager({env})) { - throw new AbortError( - outputContent`Couldn't find an app toml file at ${outputToken.path( - directory, - )}, is this a Shopify project directory?`, - ) - } else { - newestVersion = await upgradeGlobalShopify(currentVersion, {env}) - } - - if (newestVersion) { - outputSuccess(`Upgraded Shopify CLI to version ${newestVersion}`) - } -} - -async function getProjectDir(directory: string): Promise { - const configFiles = ['shopify.app{,.*}.toml', 'hydrogen.config.js', 'hydrogen.config.ts'] - const existsConfigFile = async (directory: string) => { - const configPaths = await glob(configFiles.map((file) => joinPath(directory, file))) - return configPaths.length > 0 ? configPaths[0] : undefined - } - const configFile = await findPathUp(existsConfigFile, { - cwd: directory, - type: 'file', - }) - if (configFile) return dirname(configFile) -} - -async function upgradeLocalShopify(projectDir: string, currentVersion: string): Promise { - const packageJson = (await findUpAndReadPackageJson(projectDir)).content - const packageJsonDependencies = packageJson.dependencies || {} - const packageJsonDevDependencies = packageJson.devDependencies || {} - const allDependencies = {...packageJsonDependencies, ...packageJsonDevDependencies} - - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - let resolvedCLIVersion = allDependencies[await cliDependency()]! - const resolvedAppVersion = allDependencies['@shopify/app']?.replace(/[\^~]/, '') - - if (resolvedCLIVersion.slice(0, 1).match(/[\^~]/)) resolvedCLIVersion = currentVersion - const newestCLIVersion = await checkForNewVersion(await cliDependency(), resolvedCLIVersion) - const newestAppVersion = resolvedAppVersion ? await checkForNewVersion('@shopify/app', resolvedAppVersion) : undefined - - if (newestCLIVersion) { - outputUpgradeMessage(resolvedCLIVersion, newestCLIVersion) - } else if (resolvedAppVersion && newestAppVersion) { - outputUpgradeMessage(resolvedAppVersion, newestAppVersion) - } else { - outputWontInstallMessage(resolvedCLIVersion) - return - } - - await installJsonDependencies('prod', packageJsonDependencies, projectDir) - await installJsonDependencies('dev', packageJsonDevDependencies, projectDir) - return newestCLIVersion ?? newestAppVersion -} - -async function upgradeGlobalShopify( - currentVersion: string, - {env}: UpgradeOptions = {env: process.env}, -): Promise { - const newestVersion = await checkForNewVersion(await cliDependency(), currentVersion) - - if (!newestVersion) { - outputWontInstallMessage(currentVersion) - return - } - - outputUpgradeMessage(currentVersion, newestVersion) - - const homebrewPackage = env.SHOPIFY_HOMEBREW_FORMULA as HomebrewPackageName | undefined - try { - if (homebrewPackage) { - throw new AbortError( - outputContent`Upgrade only works for packages managed by a Node package manager (e.g. npm). Run ${outputToken.genericShellCommand( - 'brew upgrade && brew update', - )} instead`, - ) - } else { - await upgradeGlobalViaNpm() - } - } catch (err) { - outputWarn('Upgrade failed!') - throw err - } - return newestVersion -} - -async function upgradeGlobalViaNpm(): Promise { - const command = 'npm' - const args = [ - 'install', - '-g', - `${await cliDependency()}@latest`, - ...globalPlugins.map((plugin) => `${plugin}@latest`), - ] - outputInfo( - outputContent`Attempting to upgrade via ${outputToken.genericShellCommand([command, ...args].join(' '))}...`, - ) - await exec(command, args, {stdio: 'inherit'}) -} - -function outputWontInstallMessage(currentVersion: string): void { - outputInfo(outputContent`You're on the latest version, ${outputToken.yellow(currentVersion)}, no need to upgrade!`) -} - -function outputUpgradeMessage(currentVersion: string, newestVersion: string): void { - outputInfo( - outputContent`Upgrading CLI from ${outputToken.yellow(currentVersion)} to ${outputToken.yellow(newestVersion)}...`, - ) -} - -async function installJsonDependencies( - depsEnv: DependencyType, - deps: {[key: string]: string}, - directory: string, -): Promise { - const packagesToUpdate = [await cliDependency(), ...(await oclifPlugins())] - .filter((pkg: string): boolean => { - const pkgRequirement: string | undefined = deps[pkg] - return Boolean(pkgRequirement) - }) - .map((pkg) => { - return {name: pkg, version: 'latest'} - }) - - const appUsesWorkspaces = await usesWorkspaces(directory) - - if (packagesToUpdate.length > 0) { - await addNPMDependencies(packagesToUpdate, { - packageManager: await getPackageManager(directory), - type: depsEnv, - directory, - stdout: process.stdout, - stderr: process.stderr, - addToRootDirectory: appUsesWorkspaces, - }) - } -} - -async function cliDependency(): Promise { - return (await packageJsonContents()).name -} - -async function oclifPlugins(): Promise { - return (await packageJsonContents())?.oclif?.plugins || [] -} - -type PackageJsonWithName = Omit & {name: string} -let _packageJsonContents: PackageJsonWithName | undefined - -async function packageJsonContents(): Promise { - if (!_packageJsonContents) { - const packageJson = await findUpAndReadPackageJson(moduleDirectory(import.meta.url)) - _packageJsonContents = _packageJsonContents || (packageJson.content as PackageJsonWithName) - } - return _packageJsonContents -} - -function usingPackageManager({env}: UpgradeOptions = {env: process.env}): boolean { - return Boolean(env.npm_config_user_agent) -}