Skip to content
Draft
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
6 changes: 6 additions & 0 deletions .changeset/auto-upgrade-phase1.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
'@shopify/cli-kit': minor
'@shopify/cli': minor
---

Port auto-upgrade POC to main (Phase 1): `shopify upgrade` now prompts to enable automatic upgrades and runs the upgrade. After opting in, the CLI auto-upgrades post-command when a newer version is available. Homebrew detection added to package manager inference.
19 changes: 19 additions & 0 deletions packages/cli-kit/src/private/node/conf-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ export interface ConfSchema {
devSessionStore?: string
currentDevSessionId?: string
cache?: Cache
autoUpgradeEnabled?: boolean
}

let _instance: LocalStorage<ConfSchema> | undefined
Expand Down Expand Up @@ -267,6 +268,24 @@ export async function runWithRateLimit(options: RunWithRateLimitOptions, config
return true
}

/**
* Get auto-upgrade preference.
*
* @returns Whether auto-upgrade is enabled, or undefined if not set.
*/
export function getAutoUpgradeEnabled(config: LocalStorage<ConfSchema> = cliKitStore()): boolean | undefined {
return config.get('autoUpgradeEnabled')
}

/**
* Set auto-upgrade preference.
*
* @param enabled - Whether auto-upgrade should be enabled.
*/
export function setAutoUpgradeEnabled(enabled: boolean, config: LocalStorage<ConfSchema> = cliKitStore()): void {
config.set('autoUpgradeEnabled', enabled)
}

export function getConfigStoreForPartnerStatus() {
return new LocalStorage<Record<string, {status: true; checkedAt: string}>>({
projectName: 'shopify-cli-kit-partner-status',
Expand Down
12 changes: 11 additions & 1 deletion packages/cli-kit/src/public/node/fs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -652,6 +652,16 @@ export async function findPathUp(
return got ? normalizePath(got) : undefined
}

export function findPathUpSync(
matcher: OverloadParameters<typeof internalFindUp>[0],
options: OverloadParameters<typeof internalFindUp>[1],
): ReturnType<typeof internalFindUpSync> {
// 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
Expand Down
58 changes: 58 additions & 0 deletions packages/cli-kit/src/public/node/hooks/postrun.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import {autoUpgradeIfNeeded} from './postrun.js'
import {versionToAutoUpgrade, runCLIUpgrade, getOutputUpdateCLIReminder} from '../upgrade.js'
import {isMajorVersionChange} from '../version.js'
import {mockAndCaptureOutput} from '../testing/output.js'
import {describe, test, vi, expect, afterEach} from 'vitest'

vi.mock('../upgrade.js')
vi.mock('../version.js')

afterEach(() => {
mockAndCaptureOutput().clear()
})

describe('autoUpgradeIfNeeded', () => {
test('skips when versionToAutoUpgrade returns undefined', async () => {
vi.mocked(versionToAutoUpgrade).mockReturnValue(undefined)

await autoUpgradeIfNeeded()

expect(runCLIUpgrade).not.toHaveBeenCalled()
})

test('shows warning for major version change and does not run upgrade', async () => {
const outputMock = mockAndCaptureOutput()
vi.mocked(versionToAutoUpgrade).mockReturnValue('4.0.0')
vi.mocked(isMajorVersionChange).mockReturnValue(true)
vi.mocked(getOutputUpdateCLIReminder).mockReturnValue('💡 Version 4.0.0 available! Run `brew upgrade shopify-cli`')

await autoUpgradeIfNeeded()

expect(runCLIUpgrade).not.toHaveBeenCalled()
expect(outputMock.warn()).toContain('4.0.0')
})

test('calls runCLIUpgrade for minor/patch upgrade', async () => {
vi.mocked(versionToAutoUpgrade).mockReturnValue('3.100.0')
vi.mocked(isMajorVersionChange).mockReturnValue(false)
vi.mocked(runCLIUpgrade).mockResolvedValue(undefined)

await autoUpgradeIfNeeded()

expect(runCLIUpgrade).toHaveBeenCalledOnce()
})

test('on runCLIUpgrade failure shows warning', async () => {
const outputMock = mockAndCaptureOutput()
vi.mocked(versionToAutoUpgrade).mockReturnValue('3.100.0')
vi.mocked(isMajorVersionChange).mockReturnValue(false)
vi.mocked(runCLIUpgrade).mockRejectedValue(new Error('upgrade failed'))
vi.mocked(getOutputUpdateCLIReminder).mockReturnValue(
'💡 Version 3.100.0 available! Run `npm install -g @shopify/cli@latest`',
)

await autoUpgradeIfNeeded()

expect(outputMock.warn()).toContain('3.100.0')
})
})
33 changes: 32 additions & 1 deletion packages/cli-kit/src/public/node/hooks/postrun.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,11 @@
import {postrun as deprecationsHook} from './deprecations.js'
import {reportAnalyticsEvent} from '../analytics.js'
import {outputDebug} from '../output.js'
import {outputDebug, outputWarn} from '../output.js'
import {getOutputUpdateCLIReminder, runCLIUpgrade, versionToAutoUpgrade} from '../upgrade.js'
import BaseCommand from '../base-command.js'
import * as metadata from '../metadata.js'
import {CLI_KIT_VERSION} from '../../common/version.js'
import {isMajorVersionChange} from '../version.js'

import {Command, Hook} from '@oclif/core'

Expand All @@ -26,6 +29,34 @@ export const hook: Hook.Postrun = async ({config, Command}) => {
const command = Command.id.replace(/:/g, ' ')
outputDebug(`Completed command ${command}`)
postRunHookCompleted = true

if (!command.includes('notifications') && !command.includes('upgrade')) 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<void> {
const newerVersion = versionToAutoUpgrade()
if (!newerVersion) return
if (isMajorVersionChange(CLI_KIT_VERSION, newerVersion)) {
outputWarn(getOutputUpdateCLIReminder(newerVersion))
return
}

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('../error-handler.js')
await sendErrorToBugsnag(new Error(errorMessage), 'expected_error')
}
}

/**
Expand Down
50 changes: 16 additions & 34 deletions packages/cli-kit/src/public/node/hooks/prerun.test.ts
Original file line number Diff line number Diff line change
@@ -1,46 +1,28 @@
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'
import {parseCommandContent, checkForNewVersionInBackground} from './prerun.js'
import {checkForNewVersion} from '../node-package-manager.js'
import {describe, expect, test, vi} 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`'
describe('checkForNewVersionInBackground', () => {
test('calls checkForNewVersion for stable versions', () => {
vi.mocked(checkForNewVersion).mockResolvedValue(undefined)

// When
await warnOnAvailableUpgrade()
checkForNewVersionInBackground()

// Then
expect(outputMock.warn()).toMatch(installReminder)
expect(checkForNewVersion).toHaveBeenCalledWith('@shopify/cli', expect.any(String), {cacheExpiryInHours: 24})
})

test('displays nothing when no newer version exists', async () => {
// Given
const outputMock = mockAndCaptureOutput()
vi.mocked(checkForCachedNewVersion).mockReturnValue(undefined)
test('skips check for pre-release versions', () => {
vi.stubEnv('SHOPIFY_CLI_VERSION', '0.0.0-snapshot-abc')

// When
await warnOnAvailableUpgrade()
// Create a fresh module environment with stubbed version
vi.doMock('../../common/version.js', () => ({CLI_KIT_VERSION: '0.0.0-snapshot-abc'}))

// Then
expect(outputMock.warn()).toEqual('')
checkForNewVersionInBackground()

vi.unstubAllEnvs()
vi.doUnmock('../../common/version.js')
})
})

Expand Down
27 changes: 7 additions & 20 deletions packages/cli-kit/src/public/node/hooks/prerun.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,8 @@
import {CLI_KIT_VERSION} from '../../common/version.js'
import {checkForNewVersion, checkForCachedNewVersion} from '../node-package-manager.js'
import {checkForNewVersion} from '../node-package-manager.js'
import {startAnalytics} from '../../../private/node/analytics.js'
import {outputDebug, outputWarn} from '../output.js'
import {getOutputUpdateCLIReminder} from '../upgrade.js'
import {outputDebug} from '../output.js'
import Command from '../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'
Expand All @@ -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)
Expand Down Expand Up @@ -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<void> {
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})
}
Loading
Loading