Skip to content

Commit 312b035

Browse files
Auto-upgrade
Co-Authored-By: Alfonso Noriega <alfonso.noriega.meneses@gmail.com>
1 parent c1334f6 commit 312b035

18 files changed

Lines changed: 411 additions & 87 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
@@ -7815,8 +7815,8 @@
78157815
},
78167816
{
78177817
"name": "upgrade",
7818-
"description": "Shows details on how to upgrade Shopify CLI.",
7819-
"overviewPreviewDescription": "Shows details on how to upgrade Shopify CLI.",
7818+
"description": "Upgrades Shopify CLI using your package manager.",
7819+
"overviewPreviewDescription": "Upgrades Shopify CLI.",
78207820
"type": "command",
78217821
"isVisualComponent": false,
78227822
"defaultExample": {

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/constants.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@ export const environmentVariables = {
4646
neverUsePartnersApi: 'SHOPIFY_CLI_NEVER_USE_PARTNERS_API',
4747
skipNetworkLevelRetry: 'SHOPIFY_CLI_SKIP_NETWORK_LEVEL_RETRY',
4848
maxRequestTimeForNetworkCalls: 'SHOPIFY_CLI_MAX_REQUEST_TIME_FOR_NETWORK_CALLS',
49+
noAutoUpgrade: 'SHOPIFY_CLI_NO_AUTO_UPGRADE',
4950
}
5051

5152
export const defaultThemeKitAccessDomain = 'theme-kit-access.shopifyapps.com'

packages/cli-kit/src/public/node/context/local.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -292,4 +292,14 @@ export function opentelemetryDomain(env = process.env): string {
292292
return isSet(domain) ? domain : 'https://otlp-http-production-cli.shopifysvc.com'
293293
}
294294

295+
/**
296+
* Returns true if the CLIshould not automatically upgrade.
297+
*
298+
* @param env - The environment variables from the environment of the current process.
299+
* @returns True if the CLI should not automatically upgrade.
300+
*/
301+
export function noAutoUpgrade(env = process.env): boolean {
302+
return isTruthy(env[environmentVariables.noAutoUpgrade])
303+
}
304+
295305
export type CIMetadata = Metadata

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 {
@@ -650,6 +650,23 @@ export async function findPathUp(
650650
return got ? normalizePath(got) : undefined
651651
}
652652

653+
/**
654+
* Find a file by walking parent directories.
655+
*
656+
* @param matcher - A pattern or an array of patterns to match a file name.
657+
* @param options - Options for the search.
658+
* @returns The first path found that matches or `undefined` if none could be found.
659+
*/
660+
export function findPathUpSync(
661+
matcher: OverloadParameters<typeof internalFindUp>[0],
662+
options: OverloadParameters<typeof internalFindUp>[1],
663+
): ReturnType<typeof internalFindUpSync> {
664+
// findUp has odd typing
665+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
666+
const got = internalFindUpSync(matcher as any, options)
667+
return got ? normalizePath(got) : undefined
668+
}
669+
653670
export interface MatchGlobOptions {
654671
matchBase: boolean
655672
noglobstar: boolean

packages/cli-kit/src/public/node/hooks/prerun.test.ts

Lines changed: 45 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,20 @@
11
import {parseCommandContent, warnOnAvailableUpgrade} from './prerun.js'
2-
import {checkForCachedNewVersion, packageManagerFromUserAgent} from '../node-package-manager.js'
2+
import {checkForCachedNewVersion} from '../node-package-manager.js'
33
import {cacheClear} from '../../../private/node/conf-store.js'
44
import {mockAndCaptureOutput} from '../testing/output.js'
5+
import {getOutputUpdateCLIReminder, runCLIUpgrade, shouldAutoUpgrade} from '../upgrade.js'
56
import {describe, expect, test, vi, afterEach, beforeEach} from 'vitest'
67

78
vi.mock('../node-package-manager')
9+
vi.mock('../upgrade.js', async (importOriginal) => {
10+
const actual: any = await importOriginal()
11+
return {
12+
...actual,
13+
runCLIUpgrade: vi.fn(),
14+
getOutputUpdateCLIReminder: vi.fn(),
15+
shouldAutoUpgrade: vi.fn(),
16+
}
17+
})
818

919
beforeEach(() => {
1020
cacheClear()
@@ -16,12 +26,27 @@ afterEach(() => {
1626
})
1727

1828
describe('warnOnAvailableUpgrade', () => {
19-
test('displays latest version and an install command when a newer exists', async () => {
29+
test('runs the upgrade when a newer version exists and auto-upgrade is allowed', async () => {
30+
// Given
31+
vi.mocked(checkForCachedNewVersion).mockReturnValue('3.0.10')
32+
vi.mocked(shouldAutoUpgrade).mockReturnValue(true)
33+
vi.mocked(runCLIUpgrade).mockResolvedValue()
34+
35+
// When
36+
await warnOnAvailableUpgrade()
37+
38+
// Then
39+
expect(runCLIUpgrade).toHaveBeenCalled()
40+
})
41+
42+
test('falls back to warning when the upgrade fails', async () => {
2043
// Given
2144
const outputMock = mockAndCaptureOutput()
2245
vi.mocked(checkForCachedNewVersion).mockReturnValue('3.0.10')
23-
vi.mocked(packageManagerFromUserAgent).mockReturnValue('npm')
46+
vi.mocked(shouldAutoUpgrade).mockReturnValue(true)
47+
vi.mocked(runCLIUpgrade).mockRejectedValue(new Error('upgrade failed'))
2448
const installReminder = '💡 Version 3.0.10 available! Run `npm install @shopify/cli@latest`'
49+
vi.mocked(getOutputUpdateCLIReminder).mockReturnValue(installReminder)
2550

2651
// When
2752
await warnOnAvailableUpgrade()
@@ -30,16 +55,31 @@ describe('warnOnAvailableUpgrade', () => {
3055
expect(outputMock.warn()).toMatch(installReminder)
3156
})
3257

33-
test('displays nothing when no newer version exists', async () => {
58+
test('shows a warning instead of upgrading when auto-upgrade is not allowed', async () => {
3459
// Given
3560
const outputMock = mockAndCaptureOutput()
61+
vi.mocked(checkForCachedNewVersion).mockReturnValue('4.0.0')
62+
vi.mocked(shouldAutoUpgrade).mockReturnValue(false)
63+
const installReminder = '💡 Version 4.0.0 available! Run `npm install @shopify/cli@latest`'
64+
vi.mocked(getOutputUpdateCLIReminder).mockReturnValue(installReminder)
65+
66+
// When
67+
await warnOnAvailableUpgrade()
68+
69+
// Then
70+
expect(runCLIUpgrade).not.toHaveBeenCalled()
71+
expect(outputMock.warn()).toMatch(installReminder)
72+
})
73+
74+
test('does nothing when no newer version exists', async () => {
75+
// Given
3676
vi.mocked(checkForCachedNewVersion).mockReturnValue(undefined)
3777

3878
// When
3979
await warnOnAvailableUpgrade()
4080

4181
// Then
42-
expect(outputMock.warn()).toEqual('')
82+
expect(runCLIUpgrade).not.toHaveBeenCalled()
4383
})
4484
})
4585

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

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import {CLI_KIT_VERSION} from '../../common/version.js'
22
import {checkForNewVersion, checkForCachedNewVersion} from '../node-package-manager.js'
33
import {startAnalytics} from '../../../private/node/analytics.js'
44
import {outputDebug, outputWarn} from '../../../public/node/output.js'
5-
import {getOutputUpdateCLIReminder} from '../../../public/node/upgrade.js'
5+
import {getOutputUpdateCLIReminder, runCLIUpgrade, shouldAutoUpgrade} from '../../../public/node/upgrade.js'
66
import Command from '../../../public/node/base-command.js'
77
import {runAtMinimumInterval} from '../../../private/node/conf-store.js'
88
import {fetchNotificationsInBackground} from '../notifications-system.js'
@@ -103,11 +103,21 @@ export async function warnOnAvailableUpgrade(): Promise<void> {
103103
// eslint-disable-next-line no-void
104104
void checkForNewVersion(cliDependency, currentVersion, {cacheExpiryInHours: 24})
105105

106-
// Warn if we previously found a new version
106+
// Auto-upgrade if we previously found a new version
107107
await runAtMinimumInterval('warn-on-available-upgrade', {days: 1}, async () => {
108108
const newerVersion = checkForCachedNewVersion(cliDependency, currentVersion)
109109
if (newerVersion) {
110-
outputWarn(getOutputUpdateCLIReminder(newerVersion))
110+
if (shouldAutoUpgrade(currentVersion, newerVersion)) {
111+
try {
112+
await runCLIUpgrade()
113+
// eslint-disable-next-line no-catch-all/no-catch-all
114+
} catch (error) {
115+
outputDebug(`Auto-upgrade failed: ${error}`)
116+
outputWarn(getOutputUpdateCLIReminder(newerVersion))
117+
}
118+
} else {
119+
outputWarn(getOutputUpdateCLIReminder(newerVersion))
120+
}
111121
}
112122
})
113123
}

packages/cli-kit/src/public/node/is-global.test.ts

Lines changed: 131 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,36 @@
11
import {currentProcessIsGlobal, inferPackageManagerForGlobalCLI, installGlobalCLIPrompt} from './is-global.js'
2+
import {cwd} from './path.js'
23
import {terminalSupportsPrompting} from './system.js'
34
import {renderSelectPrompt} from './ui.js'
45
import {globalCLIVersion} from './version.js'
56
import * as execa from 'execa'
67
import {beforeEach, describe, expect, test, vi} from 'vitest'
8+
import {realpathSync} from 'fs'
79

810
vi.mock('./system.js')
911
vi.mock('./ui.js')
1012
vi.mock('execa')
1113
vi.mock('which')
1214
vi.mock('./version.js')
1315

16+
// Mock fs.realpathSync at the module level
17+
vi.mock('fs', async (importOriginal) => {
18+
const actual = await importOriginal<typeof import('fs')>()
19+
return {
20+
...actual,
21+
realpathSync: vi.fn((path: string) => path),
22+
}
23+
})
24+
1425
const globalNPMPath = '/path/to/global/npm'
1526
const globalYarnPath = '/path/to/global/yarn'
1627
const globalPNPMPath = '/path/to/global/pnpm'
28+
const globalHomebrewIntel = '/usr/local/Cellar/shopify-cli/3.89.0/bin/shopify'
29+
const globalHomebrewAppleSilicon = '/opt/homebrew/Cellar/shopify-cli/3.89.0/bin/shopify'
30+
const globalHomebrewLinux = '/home/linuxbrew/.linuxbrew/Cellar/shopify-cli/3.89.0/bin/shopify'
1731
const unknownGlobalPath = '/path/to/global/unknown'
18-
const localProjectPath = '/path/local'
32+
// Must be within the actual workspace so currentProcessIsGlobal recognizes it as local
33+
const localProjectPath = `${cwd()}/node_modules/.bin/shopify`
1934

2035
beforeEach(() => {
2136
;(vi.mocked(execa.execaSync) as any).mockReturnValue({stdout: localProjectPath})
@@ -46,6 +61,12 @@ describe('currentProcessIsGlobal', () => {
4661
})
4762

4863
describe('inferPackageManagerForGlobalCLI', () => {
64+
beforeEach(() => {
65+
// Reset mock to return the input path by default (no symlink resolution)
66+
vi.mocked(realpathSync).mockClear()
67+
vi.mocked(realpathSync).mockImplementation((path) => String(path))
68+
})
69+
4970
test('returns yarn if yarn is in path', async () => {
5071
// Given
5172
const argv = ['node', globalYarnPath, 'shopify']
@@ -89,6 +110,115 @@ describe('inferPackageManagerForGlobalCLI', () => {
89110
// Then
90111
expect(got).toBe('unknown')
91112
})
113+
114+
test('returns homebrew if SHOPIFY_HOMEBREW_FORMULA is set', async () => {
115+
// Given
116+
const argv = ['node', globalHomebrewAppleSilicon, 'shopify']
117+
const env = {SHOPIFY_HOMEBREW_FORMULA: 'shopify-cli'}
118+
119+
// When
120+
const got = inferPackageManagerForGlobalCLI(argv, env)
121+
122+
// Then
123+
expect(got).toBe('homebrew')
124+
})
125+
126+
test('returns homebrew for Intel Mac Cellar path', async () => {
127+
// Given
128+
const argv = ['node', globalHomebrewIntel, 'shopify']
129+
130+
// When
131+
const got = inferPackageManagerForGlobalCLI(argv)
132+
133+
// Then
134+
expect(got).toBe('homebrew')
135+
})
136+
137+
test('returns homebrew for Apple Silicon Cellar path', async () => {
138+
// Given
139+
const argv = ['node', globalHomebrewAppleSilicon, 'shopify']
140+
141+
// When
142+
const got = inferPackageManagerForGlobalCLI(argv)
143+
144+
// Then
145+
expect(got).toBe('homebrew')
146+
})
147+
148+
test('returns homebrew for Linux Homebrew path', async () => {
149+
// Given
150+
const argv = ['node', globalHomebrewLinux, 'shopify']
151+
152+
// When
153+
const got = inferPackageManagerForGlobalCLI(argv)
154+
155+
// Then
156+
expect(got).toBe('homebrew')
157+
})
158+
159+
test('returns homebrew when HOMEBREW_PREFIX matches path', async () => {
160+
// Given
161+
const argv = ['node', '/opt/homebrew/bin/shopify', 'shopify']
162+
const env = {HOMEBREW_PREFIX: '/opt/homebrew'}
163+
164+
// When
165+
const got = inferPackageManagerForGlobalCLI(argv, env)
166+
167+
// Then
168+
expect(got).toBe('homebrew')
169+
})
170+
171+
test('resolves symlinks to detect actual package manager (yarn)', async () => {
172+
// Given: A symlink in /opt/homebrew/bin pointing to yarn global
173+
const symlinkPath = '/opt/homebrew/bin/shopify'
174+
const realYarnPath = '/Users/user/.config/yarn/global/node_modules/.bin/shopify'
175+
const argv = ['node', symlinkPath, 'shopify']
176+
const env = {HOMEBREW_PREFIX: '/opt/homebrew'}
177+
178+
// Mock realpathSync to resolve the symlink
179+
vi.mocked(realpathSync).mockReturnValueOnce(realYarnPath)
180+
181+
// When
182+
const got = inferPackageManagerForGlobalCLI(argv, env)
183+
184+
// Then: Should detect yarn (from real path), not homebrew (from symlink)
185+
expect(got).toBe('yarn')
186+
expect(vi.mocked(realpathSync)).toHaveBeenCalledWith(symlinkPath)
187+
})
188+
189+
test('resolves symlinks to detect real homebrew installation', async () => {
190+
// Given: A symlink in /opt/homebrew/bin pointing to a Cellar path (real Homebrew)
191+
const symlinkPath = '/opt/homebrew/bin/shopify'
192+
const realHomebrewPath = '/opt/homebrew/Cellar/shopify-cli/3.89.0/bin/shopify'
193+
const argv = ['node', symlinkPath, 'shopify']
194+
195+
// Mock realpathSync to resolve the symlink
196+
vi.mocked(realpathSync).mockReturnValueOnce(realHomebrewPath)
197+
198+
// When
199+
const got = inferPackageManagerForGlobalCLI(argv)
200+
201+
// Then: Should still detect homebrew from the real Cellar path
202+
expect(got).toBe('homebrew')
203+
})
204+
205+
test('falls back to original path if realpath fails', async () => {
206+
// Given: A path that realpathSync cannot resolve
207+
const nonExistentPath = '/opt/homebrew/bin/shopify'
208+
const argv = ['node', nonExistentPath, 'shopify']
209+
const env = {HOMEBREW_PREFIX: '/opt/homebrew'}
210+
211+
// Mock realpathSync to throw an error
212+
vi.mocked(realpathSync).mockImplementationOnce(() => {
213+
throw new Error('ENOENT: no such file or directory')
214+
})
215+
216+
// When
217+
const got = inferPackageManagerForGlobalCLI(argv, env)
218+
219+
// Then: Should fall back to checking the original path
220+
expect(got).toBe('homebrew')
221+
})
92222
})
93223

94224
describe('installGlobalCLIPrompt', () => {

0 commit comments

Comments
 (0)