Skip to content

Commit 085b95c

Browse files
Auto-upgrade
Co-Authored-By: Alfonso Noriega <alfonso.noriega.meneses@gmail.com>
1 parent 97da1b4 commit 085b95c

File tree

18 files changed

+335
-88
lines changed

18 files changed

+335
-88
lines changed

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: 27 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,19 @@
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} 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+
}
16+
})
817

918
beforeEach(() => {
1019
cacheClear()
@@ -16,12 +25,25 @@ afterEach(() => {
1625
})
1726

1827
describe('warnOnAvailableUpgrade', () => {
19-
test('displays latest version and an install command when a newer exists', async () => {
28+
test('runs the upgrade when a newer version exists', async () => {
29+
// Given
30+
vi.mocked(checkForCachedNewVersion).mockReturnValue('3.0.10')
31+
vi.mocked(runCLIUpgrade).mockResolvedValue()
32+
33+
// When
34+
await warnOnAvailableUpgrade()
35+
36+
// Then
37+
expect(runCLIUpgrade).toHaveBeenCalled()
38+
})
39+
40+
test('falls back to warning when the upgrade fails', async () => {
2041
// Given
2142
const outputMock = mockAndCaptureOutput()
2243
vi.mocked(checkForCachedNewVersion).mockReturnValue('3.0.10')
23-
vi.mocked(packageManagerFromUserAgent).mockReturnValue('npm')
44+
vi.mocked(runCLIUpgrade).mockRejectedValue(new Error('upgrade failed'))
2445
const installReminder = '💡 Version 3.0.10 available! Run `npm install @shopify/cli@latest`'
46+
vi.mocked(getOutputUpdateCLIReminder).mockReturnValue(installReminder)
2547

2648
// When
2749
await warnOnAvailableUpgrade()
@@ -30,16 +52,15 @@ describe('warnOnAvailableUpgrade', () => {
3052
expect(outputMock.warn()).toMatch(installReminder)
3153
})
3254

33-
test('displays nothing when no newer version exists', async () => {
55+
test('does nothing when no newer version exists', async () => {
3456
// Given
35-
const outputMock = mockAndCaptureOutput()
3657
vi.mocked(checkForCachedNewVersion).mockReturnValue(undefined)
3758

3859
// When
3960
await warnOnAvailableUpgrade()
4061

4162
// Then
42-
expect(outputMock.warn()).toEqual('')
63+
expect(runCLIUpgrade).not.toHaveBeenCalled()
4364
})
4465
})
4566

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

Lines changed: 9 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} 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,17 @@ 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+
try {
111+
await runCLIUpgrade()
112+
// eslint-disable-next-line no-catch-all/no-catch-all
113+
} catch (error) {
114+
outputDebug(`Auto-upgrade failed: ${error}`)
115+
outputWarn(getOutputUpdateCLIReminder(newerVersion))
116+
}
111117
}
112118
})
113119
}

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

Lines changed: 130 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,18 +4,32 @@ import {renderSelectPrompt} from './ui.js'
44
import {globalCLIVersion} from './version.js'
55
import * as execa from 'execa'
66
import {beforeEach, describe, expect, test, vi} from 'vitest'
7+
import {realpathSync} from 'fs'
78

89
vi.mock('./system.js')
910
vi.mock('./ui.js')
1011
vi.mock('execa')
1112
vi.mock('which')
1213
vi.mock('./version.js')
1314

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

2034
beforeEach(() => {
2135
;(vi.mocked(execa.execaSync) as any).mockReturnValue({stdout: localProjectPath})
@@ -46,6 +60,12 @@ describe('currentProcessIsGlobal', () => {
4660
})
4761

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

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

0 commit comments

Comments
 (0)