-
Notifications
You must be signed in to change notification settings - Fork 266
Expand file tree
/
Copy pathupgrade.ts
More file actions
285 lines (254 loc) · 10.9 KB
/
Copy pathupgrade.ts
File metadata and controls
285 lines (254 loc) · 10.9 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
import {fetchNotifications, filterNotifications} from './notifications-system.js'
import {isDevelopment} from './context/local.js'
import {currentProcessIsGlobal, inferPackageManagerForGlobalCLI, getProjectDir} from './is-global.js'
import {
checkForCachedNewVersion,
findUpAndReadPackageJson,
PackageJson,
checkForNewVersion,
DependencyType,
usesWorkspaces,
addNPMDependencies,
getPackageManager,
} from './node-package-manager.js'
import {outputContent, outputDebug, outputInfo, outputToken, outputWarn} from './output.js'
import {cwd, moduleDirectory, sniffForPath} from './path.js'
import {exec, isCI} from './system.js'
import {isPreReleaseVersion} from './version.js'
import {getAutoUpgradeEnabled, setAutoUpgradeEnabled, runAtMinimumInterval} from '../../private/node/conf-store.js'
import {CLI_KIT_VERSION} from '../common/version.js'
export {getAutoUpgradeEnabled, setAutoUpgradeEnabled}
/**
* Utility function for generating an install command for the user to run
* to install an updated version of Shopify CLI.
*
* @returns A string with the command to run, or undefined if the package manager cannot be determined.
*/
export function cliInstallCommand(): string | undefined {
const packageManager = inferPackageManagerForGlobalCLI()
if (!packageManager || packageManager === 'unknown') return undefined
if (packageManager === 'homebrew') {
return 'brew upgrade shopify-cli'
} else if (packageManager === 'yarn') {
return `${packageManager} global add @shopify/cli@latest`
} else {
const verb = packageManager === 'pnpm' ? 'add' : 'install'
return `${packageManager} ${verb} -g @shopify/cli@latest`
}
}
/**
* Options for {@link runCLIUpgrade}.
*/
export interface RunCLIUpgradeOptions {
/**
* `true` when the upgrade is being triggered by the automatic postrun hook.
* In that case we skip project-local upgrades — those should only happen when the
* user explicitly runs `shopify upgrade` so we don't surprise them by mutating
* their `package.json` / lockfile in the background.
*/
autoupgrade?: boolean
}
/**
* Runs the CLI upgrade using the appropriate package manager.
* Determines the install command and executes it.
*
* @param options - See {@link RunCLIUpgradeOptions}.
* @throws AbortError if the package manager or command cannot be determined.
*/
export async function runCLIUpgrade(options: RunCLIUpgradeOptions = {}): Promise<void> {
// Path where the current project is (app/hydrogen)
const path = sniffForPath() ?? cwd()
const projectDir = getProjectDir(path)
// Check if we are running in a global context if not, return
const isGlobal = currentProcessIsGlobal()
// Don't auto-upgrade for development mode
if (isDevelopment()) {
outputInfo('Skipping upgrade in development mode.')
return
}
// When triggered by the automatic postrun hook, skip project-local upgrades.
// Bumping `package.json` / lockfile silently in the background would surprise users
// and produce noisy diffs; explicit `shopify upgrade` invocations still upgrade the
// local project.
if (options.autoupgrade && !isGlobal) {
return
}
// Generate the install command for the global CLI and execute it
if (isGlobal) {
const installCommand = cliInstallCommand()
if (!installCommand) {
throw new Error('Could not determine the package manager')
}
const [command, ...args] = installCommand.split(' ')
if (!command) {
throw new Error('Could not determine the command to run')
}
outputInfo(outputContent`Upgrading Shopify CLI by running: ${outputToken.genericShellCommand(installCommand)}...`)
await exec(command, args, {stdio: 'inherit'})
} else if (projectDir) {
await upgradeLocalShopify(projectDir, CLI_KIT_VERSION)
} else {
throw new Error('Could not determine the local project directory')
}
}
/**
* Returns the version to auto-upgrade to, or undefined if auto-upgrade should be skipped.
* Auto-upgrade is enabled by default and can be disabled via `setAutoUpgradeEnabled(false)`.
* Also skips for CI, pre-release versions, or when no newer version is available.
*
* @returns The version string to upgrade to, or undefined if no upgrade should happen.
*/
export function versionToAutoUpgrade(): string | undefined {
const currentVersion = CLI_KIT_VERSION
const newerVersion = checkForCachedNewVersion('@shopify/cli', currentVersion)
if (!newerVersion) {
outputDebug('Auto-upgrade: No newer version available.')
return undefined
}
if (process.env.SHOPIFY_CLI_FORCE_AUTO_UPGRADE === '1') {
outputDebug('Auto-upgrade: Forcing auto-upgrade because of SHOPIFY_CLI_FORCE_AUTO_UPGRADE.')
return newerVersion
}
if (!getAutoUpgradeEnabled()) {
outputDebug('Auto-upgrade: Skipping because auto-upgrade is not enabled.')
return undefined
}
if (isCI()) {
outputDebug('Auto-upgrade: Skipping auto-upgrade in CI.')
return undefined
}
if (isPreReleaseVersion(currentVersion)) {
outputDebug('Auto-upgrade: Skipping auto-upgrade for pre-release version.')
return undefined
}
return newerVersion
}
/**
* Checks the freshly fetched notifications feed for a kill-switch notification that
* disables auto-upgrade. A blocking notification is one with `surface: "autoupgrade"`,
* `type: "error"`, and matching version/date ranges for the current CLI.
*
* Fails open: any error fetching or parsing the feed results in `false`, so a broken
* notifications endpoint never prevents users from auto-upgrading. Intentionally silent
* (no logs) — this is invoked on the auto-upgrade hot path.
*
* @returns `true` when an active blocking notification is found, `false` otherwise.
*/
export async function hasBlockingAutoUpgradeNotification(): Promise<boolean> {
try {
const {notifications} = await fetchNotifications()
// Reuse the standard notifications filtering for version/date/surface. The empty
// commandId disables the command filter; ['autoupgrade'] scopes to our surface.
// The frequency filter is a no-op here because we never render, so nothing gets
// written to the lastShown cache for these notifications.
const matching = filterNotifications(notifications, '', ['autoupgrade'])
return matching.some((notification) => notification.type === 'error')
// eslint-disable-next-line no-catch-all/no-catch-all
} catch {
return false
}
}
/**
* Shows a daily upgrade-available warning for users who have not enabled auto-upgrade.
* Skipped in CI and for pre-release versions. When auto-upgrade is enabled this is a no-op
* because the postrun hook will handle the upgrade directly.
*/
export async function warnIfUpgradeAvailable(): Promise<void> {
if (isCI() || isPreReleaseVersion(CLI_KIT_VERSION) || getAutoUpgradeEnabled()) return
const newerVersion = checkForCachedNewVersion('@shopify/cli', CLI_KIT_VERSION)
if (!newerVersion) return
await runAtMinimumInterval('warn-on-available-upgrade', {days: 1}, async () => {
outputWarn(getOutputUpdateCLIReminder(newerVersion))
})
}
/**
* Generates a message to remind the user to update the CLI.
* For major version bumps, appends a link to the GitHub release notes so users
* can review breaking changes before deciding to upgrade.
*
* @param version - The version to update to.
* @param isMajor - Whether the version bump is a major version change.
* @returns The message to remind the user to update the CLI.
*/
export function getOutputUpdateCLIReminder(version: string, isMajor = false): string {
const installCommand = cliInstallCommand()
const base = installCommand
? outputContent`💡 Version ${version} available! Run ${outputToken.genericShellCommand(installCommand)}`.value
: outputContent`💡 Version ${version} available!`.value
if (isMajor) {
const releaseUrl = `https://github.com/Shopify/cli/releases/tag/${version}`
const majorNotice =
outputContent`⚠️ This is a major version — review breaking changes before upgrading:\n ${outputToken.link(releaseUrl, releaseUrl)}`
.value
return `${base}\n\n${majorNotice}`
}
return base
}
async function upgradeLocalShopify(projectDir: string, currentVersion: string) {
const packageJson = (await findUpAndReadPackageJson(projectDir)).content
const packageJsonDependencies = packageJson.dependencies ?? {}
const packageJsonDevDependencies = packageJson.devDependencies ?? {}
const allDependencies = {...packageJsonDependencies, ...packageJsonDevDependencies}
let resolvedCLIVersion = allDependencies[await cliDependency()]
if (!resolvedCLIVersion) {
outputDebug('Auto-upgrade: CLI dependency not found in project dependencies, skipping local upgrade.')
return
}
if (resolvedCLIVersion.slice(0, 1).match(/[\^~]/)) resolvedCLIVersion = currentVersion
const newestCLIVersion = await checkForNewVersion(await cliDependency(), resolvedCLIVersion)
if (newestCLIVersion) {
outputUpgradeMessage(resolvedCLIVersion, newestCLIVersion)
} else {
outputWontInstallMessage(resolvedCLIVersion)
}
await installJsonDependencies('prod', packageJsonDependencies, projectDir)
await installJsonDependencies('dev', packageJsonDevDependencies, projectDir)
}
async function installJsonDependencies(
depsEnv: DependencyType,
deps: {[key: string]: string},
directory: string,
): Promise<void> {
const packagesToUpdate = [await cliDependency(), ...(await oclifPlugins())]
.filter((pkg: string): boolean => {
const pkgRequirement: string | undefined = deps[pkg]
return Boolean(pkgRequirement)
})
.map((pkg) => {
return {name: pkg, version: 'latest'}
})
const appUsesWorkspaces = await usesWorkspaces(directory)
if (packagesToUpdate.length > 0) {
await addNPMDependencies(packagesToUpdate, {
packageManager: await getPackageManager(directory),
type: depsEnv,
directory,
stdout: process.stdout,
stderr: process.stderr,
addToRootDirectory: appUsesWorkspaces,
})
}
}
async function cliDependency(): Promise<string> {
return (await packageJsonContents()).name
}
async function oclifPlugins(): Promise<string[]> {
return (await packageJsonContents())?.oclif?.plugins ?? []
}
type PackageJsonWithName = Omit<PackageJson, 'name'> & {name: string}
let _packageJsonContents: PackageJsonWithName | undefined
async function packageJsonContents(): Promise<PackageJsonWithName> {
if (!_packageJsonContents) {
const packageJson = await findUpAndReadPackageJson(moduleDirectory(import.meta.url))
_packageJsonContents = _packageJsonContents ?? (packageJson.content as PackageJsonWithName)
}
return _packageJsonContents
}
function outputWontInstallMessage(currentVersion: string): void {
outputInfo(outputContent`You're on the latest version, ${outputToken.yellow(currentVersion)}, no need to upgrade!`)
}
function outputUpgradeMessage(currentVersion: string, newestVersion: string): void {
outputInfo(
outputContent`Upgrading CLI from ${outputToken.yellow(currentVersion)} to ${outputToken.yellow(newestVersion)}...`,
)
}