Skip to content

Commit 0287a52

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

File tree

16 files changed

+329
-83
lines changed

16 files changed

+329
-83
lines changed

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: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,16 +4,29 @@ 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'
1831
const localProjectPath = '/path/local'
1932

@@ -46,6 +59,12 @@ describe('currentProcessIsGlobal', () => {
4659
})
4760

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

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

0 commit comments

Comments
 (0)