Skip to content

Commit b107811

Browse files
Merge pull request #7104 from Shopify/03-25-phase1_port_auto_upgrade_poc_to_main
[Phase 1] Port and productionize auto-upgrade POC to main
2 parents afcef6c + 2834b7a commit b107811

22 files changed

Lines changed: 814 additions & 154 deletions

File tree

docs-shopify.dev/commands/upgrade.doc.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,8 @@ import {ReferenceEntityTemplateSchema} from '@shopify/generate-docs'
33

44
const data: ReferenceEntityTemplateSchema = {
55
name: 'upgrade',
6-
description: `Shows details on how to upgrade Shopify CLI.`,
7-
overviewPreviewDescription: `Shows details on how to upgrade Shopify CLI.`,
6+
description: `Upgrades Shopify CLI using your package manager.`,
7+
overviewPreviewDescription: `Upgrades Shopify CLI.`,
88
type: 'command',
99
isVisualComponent: false,
1010
defaultExample: {

docs-shopify.dev/generated/generated_docs_data.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8125,8 +8125,8 @@
81258125
},
81268126
{
81278127
"name": "upgrade",
8128-
"description": "Shows details on how to upgrade Shopify CLI.",
8129-
"overviewPreviewDescription": "Shows details on how to upgrade Shopify CLI.",
8128+
"description": "Upgrades Shopify CLI using your package manager.",
8129+
"overviewPreviewDescription": "Upgrades Shopify CLI.",
81308130
"type": "command",
81318131
"isVisualComponent": false,
81328132
"defaultExample": {

packages/app/src/cli/services/app/config/link.test.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,9 @@ import {setPathValue} from '@shopify/cli-kit/common/object'
2323

2424
vi.mock('./use.js')
2525
vi.mock('../../../prompts/config.js')
26+
vi.mock('@shopify/cli-kit/node/is-global', () => ({
27+
currentProcessIsGlobal: () => false,
28+
}))
2629
vi.mock('../../../models/app/loader.js', async () => {
2730
const loader: any = await vi.importActual('../../../models/app/loader.js')
2831
return {

packages/app/src/cli/services/app/config/use.test.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,9 @@ vi.mock('../../local-storage.js')
1414
vi.mock('../../../models/app/loader.js')
1515
vi.mock('@shopify/cli-kit/node/ui')
1616
vi.mock('../../context.js')
17+
vi.mock('@shopify/cli-kit/node/is-global', () => ({
18+
currentProcessIsGlobal: () => false,
19+
}))
1720

1821
function mockContext(directory: string, configuration: Record<string, unknown>) {
1922
vi.mocked(getAppConfigurationContext).mockResolvedValue({
@@ -41,6 +44,7 @@ describe('use', () => {
4144
developerPlatformClient: testDeveloperPlatformClient(),
4245
}
4346
writeFileSync(joinPath(tmp, 'package.json'), '{}')
47+
writeFileSync(joinPath(tmp, 'shopify.app.toml'), '')
4448

4549
// When
4650
await use(options)

packages/app/src/cli/services/generate.test.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,9 @@ vi.mock('../prompts/generate/extension.js')
3939
vi.mock('../services/generate/extension.js')
4040
vi.mock('../services/context.js')
4141
vi.mock('./local-storage.js')
42+
vi.mock('@shopify/cli-kit/node/is-global', () => ({
43+
currentProcessIsGlobal: () => false,
44+
}))
4245

4346
afterEach(() => {
4447
mockAndCaptureOutput().clear()

packages/app/src/cli/services/init/init.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -124,6 +124,7 @@ async function init(options: InitOptions) {
124124
await appendFile(joinPath(templateScaffoldDir, '.npmrc'), `auto-install-peers=true\n`)
125125
break
126126
}
127+
case 'homebrew':
127128
case 'unknown':
128129
throw new UnknownPackageManagerError()
129130
}

packages/cli-kit/src/private/node/conf-store.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ export interface ConfSchema {
3232
devSessionStore?: string
3333
currentDevSessionId?: string
3434
cache?: Cache
35+
autoUpgradeEnabled?: boolean
3536
}
3637

3738
let _instance: LocalStorage<ConfSchema> | undefined
@@ -265,6 +266,24 @@ export async function runWithRateLimit(options: RunWithRateLimitOptions, config
265266
return true
266267
}
267268

269+
/**
270+
* Get auto-upgrade preference.
271+
*
272+
* @returns Whether auto-upgrade is enabled, or undefined if never set.
273+
*/
274+
export function getAutoUpgradeEnabled(config: LocalStorage<ConfSchema> = cliKitStore()): boolean | undefined {
275+
return config.get('autoUpgradeEnabled')
276+
}
277+
278+
/**
279+
* Set auto-upgrade preference.
280+
*
281+
* @param enabled - Whether auto-upgrade should be enabled.
282+
*/
283+
export function setAutoUpgradeEnabled(enabled: boolean, config: LocalStorage<ConfSchema> = cliKitStore()): void {
284+
config.set('autoUpgradeEnabled', enabled)
285+
}
286+
268287
export function getConfigStoreForPartnerStatus() {
269288
return new LocalStorage<Record<string, {status: true; checkedAt: string}>>({
270289
projectName: 'shopify-cli-kit-partner-status',

packages/cli-kit/src/public/node/fs.ts

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ import {
1515

1616
import {temporaryDirectory, temporaryDirectoryTask} from 'tempy'
1717
import {sep, join} from 'pathe'
18-
import {findUp as internalFindUp} from 'find-up'
18+
import {findUp as internalFindUp, findUpSync as internalFindUpSync} from 'find-up'
1919
import {minimatch} from 'minimatch'
2020
import fastGlobLib from 'fast-glob'
2121
import {
@@ -652,6 +652,23 @@ export async function findPathUp(
652652
return got ? normalizePath(got) : undefined
653653
}
654654

655+
/**
656+
* Find a file by walking parent directories.
657+
*
658+
* @param matcher - A pattern or an array of patterns to match a file name.
659+
* @param options - Options for the search.
660+
* @returns The first path found that matches or `undefined` if none could be found.
661+
*/
662+
export function findPathUpSync(
663+
matcher: OverloadParameters<typeof internalFindUp>[0],
664+
options: OverloadParameters<typeof internalFindUp>[1],
665+
): ReturnType<typeof internalFindUpSync> {
666+
// findUp has odd typing
667+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
668+
const got = internalFindUpSync(matcher as any, options)
669+
return got ? normalizePath(got) : undefined
670+
}
671+
655672
export interface MatchGlobOptions {
656673
matchBase: boolean
657674
noglobstar: boolean
Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
import {autoUpgradeIfNeeded} from './postrun.js'
2+
import {mockAndCaptureOutput} from '../testing/output.js'
3+
import {getOutputUpdateCLIReminder, runCLIUpgrade, versionToAutoUpgrade} from '../upgrade.js'
4+
import {isMajorVersionChange} from '../version.js'
5+
import {describe, expect, test, vi, afterEach} from 'vitest'
6+
7+
vi.mock('../upgrade.js', async (importOriginal) => {
8+
const actual: any = await importOriginal()
9+
return {
10+
...actual,
11+
runCLIUpgrade: vi.fn(),
12+
getOutputUpdateCLIReminder: vi.fn(),
13+
versionToAutoUpgrade: vi.fn(),
14+
}
15+
})
16+
17+
vi.mock('../version.js', async (importOriginal) => {
18+
const actual: any = await importOriginal()
19+
return {
20+
...actual,
21+
isMajorVersionChange: vi.fn(),
22+
}
23+
})
24+
25+
// Always execute the task so the rate limit doesn't interfere with tests
26+
vi.mock('../../../private/node/conf-store.js', async (importOriginal) => {
27+
const actual: any = await importOriginal()
28+
return {
29+
...actual,
30+
runAtMinimumInterval: vi.fn(async (_key: string, _interval: object, task: () => Promise<void>) => {
31+
await task()
32+
return true
33+
}),
34+
}
35+
})
36+
37+
afterEach(() => {
38+
mockAndCaptureOutput().clear()
39+
})
40+
41+
describe('autoUpgradeIfNeeded', () => {
42+
test('runs the upgrade when versionToAutoUpgrade returns a version', async () => {
43+
// Given
44+
vi.mocked(versionToAutoUpgrade).mockReturnValue('3.91.0')
45+
vi.mocked(runCLIUpgrade).mockResolvedValue()
46+
47+
// When
48+
await autoUpgradeIfNeeded()
49+
50+
// Then
51+
expect(runCLIUpgrade).toHaveBeenCalled()
52+
})
53+
54+
test('falls back to warning when the upgrade fails', async () => {
55+
// Given
56+
const outputMock = mockAndCaptureOutput()
57+
vi.mocked(versionToAutoUpgrade).mockReturnValue('3.91.0')
58+
vi.mocked(runCLIUpgrade).mockRejectedValue(new Error('upgrade failed'))
59+
const installReminder = '💡 Version 3.91.0 available! Run `npm install @shopify/cli@latest`'
60+
vi.mocked(getOutputUpdateCLIReminder).mockReturnValue(installReminder)
61+
62+
// When
63+
await autoUpgradeIfNeeded()
64+
65+
// Then
66+
expect(outputMock.warn()).toMatch(installReminder)
67+
})
68+
69+
test('does nothing when versionToAutoUpgrade returns undefined', async () => {
70+
// Given
71+
vi.mocked(versionToAutoUpgrade).mockReturnValue(undefined)
72+
73+
// When
74+
await autoUpgradeIfNeeded()
75+
76+
// Then
77+
expect(runCLIUpgrade).not.toHaveBeenCalled()
78+
})
79+
80+
test('shows warning instead of upgrading for a major version change', async () => {
81+
// Given
82+
const outputMock = mockAndCaptureOutput()
83+
vi.mocked(versionToAutoUpgrade).mockReturnValue('4.0.0')
84+
vi.mocked(isMajorVersionChange).mockReturnValue(true)
85+
const installReminder = '💡 Version 4.0.0 available! Run `npm install @shopify/cli@latest`'
86+
vi.mocked(getOutputUpdateCLIReminder).mockReturnValue(installReminder)
87+
88+
// When
89+
await autoUpgradeIfNeeded()
90+
91+
// Then
92+
expect(runCLIUpgrade).not.toHaveBeenCalled()
93+
expect(outputMock.warn()).toMatch(installReminder)
94+
})
95+
})

packages/cli-kit/src/public/node/hooks/postrun.ts

Lines changed: 64 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,13 @@
11
import {postrun as deprecationsHook} from './deprecations.js'
22
import {reportAnalyticsEvent} from '../analytics.js'
3-
import {outputDebug} from '../output.js'
3+
import {outputDebug, outputWarn} from '../output.js'
4+
import {getOutputUpdateCLIReminder, runCLIUpgrade, versionToAutoUpgrade, warnIfUpgradeAvailable} from '../upgrade.js'
5+
import {inferPackageManagerForGlobalCLI} from '../is-global.js'
46
import BaseCommand from '../base-command.js'
57
import * as metadata from '../metadata.js'
6-
8+
import {runAtMinimumInterval} from '../../../private/node/conf-store.js'
9+
import {CLI_KIT_VERSION} from '../../common/version.js'
10+
import {isMajorVersionChange} from '../version.js'
711
import {Command, Hook} from '@oclif/core'
812

913
let postRunHookCompleted = false
@@ -26,6 +30,64 @@ export const hook: Hook.Postrun = async ({config, Command}) => {
2630
const command = Command.id.replace(/:/g, ' ')
2731
outputDebug(`Completed command ${command}`)
2832
postRunHookCompleted = true
33+
34+
if (!Command.id?.includes('upgrade') && !Command.id?.startsWith('notifications')) {
35+
try {
36+
await autoUpgradeIfNeeded()
37+
// eslint-disable-next-line no-catch-all/no-catch-all
38+
} catch (error) {
39+
outputDebug(`Auto-upgrade check failed: ${error}`)
40+
}
41+
}
42+
}
43+
44+
/**
45+
* Auto-upgrades the CLI after a command completes, if a newer version is available.
46+
* The entire flow is rate-limited to once per day unless forced via SHOPIFY_CLI_FORCE_AUTO_UPGRADE.
47+
*
48+
* @returns Resolves when the upgrade attempt (or fallback warning) is complete.
49+
*/
50+
export async function autoUpgradeIfNeeded(): Promise<void> {
51+
const newerVersion = versionToAutoUpgrade()
52+
if (!newerVersion) {
53+
await warnIfUpgradeAvailable()
54+
return
55+
}
56+
57+
const forced = process.env.SHOPIFY_CLI_FORCE_AUTO_UPGRADE === '1'
58+
59+
// SHOPIFY_CLI_FORCE_AUTO_UPGRADE bypasses the daily rate limit so tests and intentional upgrades always run.
60+
if (forced) {
61+
await performAutoUpgrade(newerVersion)
62+
} else {
63+
// Rate-limit the entire upgrade flow to once per day to avoid repeated attempts and major-version warnings.
64+
await runAtMinimumInterval('auto-upgrade', {days: 1}, async () => {
65+
await performAutoUpgrade(newerVersion)
66+
})
67+
}
68+
}
69+
70+
async function performAutoUpgrade(newerVersion: string): Promise<void> {
71+
if (isMajorVersionChange(CLI_KIT_VERSION, newerVersion)) {
72+
return outputWarn(getOutputUpdateCLIReminder(newerVersion))
73+
}
74+
75+
try {
76+
await runCLIUpgrade()
77+
// eslint-disable-next-line no-catch-all/no-catch-all
78+
} catch (error) {
79+
const errorMessage = `Auto-upgrade failed: ${error}`
80+
outputDebug(errorMessage)
81+
outputWarn(getOutputUpdateCLIReminder(newerVersion))
82+
// Report to Observe as a handled error without showing anything extra to the user
83+
const {sendErrorToBugsnag} = await import('../error-handler.js')
84+
const enrichedError = Object.assign(new Error(errorMessage), {
85+
packageManager: inferPackageManagerForGlobalCLI(),
86+
platform: process.platform,
87+
cliVersion: CLI_KIT_VERSION,
88+
})
89+
await sendErrorToBugsnag(enrichedError, 'expected_error')
90+
}
2991
}
3092

3193
/**

0 commit comments

Comments
 (0)