Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/quiet-upgrades-report.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@shopify/cli': minor
'@shopify/cli-kit': minor
---
Add typed JSON output to `shopify upgrade`.
38 changes: 37 additions & 1 deletion docs-shopify.dev/generated/generated_docs_data_v2.json
Original file line number Diff line number Diff line change
Expand Up @@ -10559,9 +10559,45 @@
"description": "Print the command's JSON schemas.",
"isOptional": true,
"environmentValue": "SHOPIFY_FLAG_JSON_SCHEMA"
},
{
"filePath": "docs-shopify.dev/commands/interfaces/upgrade.interface.ts",
"syntaxKind": "PropertySignature",
"name": "--no-color",
"value": "''",
"description": "Disable color output.",
"isOptional": true,
"environmentValue": "SHOPIFY_FLAG_NO_COLOR"
},
{
"filePath": "docs-shopify.dev/commands/interfaces/upgrade.interface.ts",
"syntaxKind": "PropertySignature",
"name": "--no-input",
"value": "''",
"description": "Disable interactive prompts and browser authentication.",
"isOptional": true,
"environmentValue": "SHOPIFY_FLAG_NO_INPUT"
},
{
"filePath": "docs-shopify.dev/commands/interfaces/upgrade.interface.ts",
"syntaxKind": "PropertySignature",
"name": "--verbose",
"value": "''",
"description": "Increase the verbosity of the output. May include sensitive data.",
"isOptional": true,
"environmentValue": "SHOPIFY_FLAG_VERBOSE"
},
{
"filePath": "docs-shopify.dev/commands/interfaces/upgrade.interface.ts",
"syntaxKind": "PropertySignature",
"name": "-j, --json",
"value": "''",
"description": "Output the result as JSON. Automatically disables color output.",
"isOptional": true,
"environmentValue": "SHOPIFY_FLAG_JSON"
}
],
"value": "export interface upgrade {\n /**\n * Print the command's JSON schemas.\n * @environment SHOPIFY_FLAG_JSON_SCHEMA\n */\n '--json-schema'?: ''\n}"
"value": "export interface upgrade {\n /**\n * Output the result as JSON. Automatically disables color output.\n * @environment SHOPIFY_FLAG_JSON\n */\n '-j, --json'?: ''\n\n /**\n * Print the command's JSON schemas.\n * @environment SHOPIFY_FLAG_JSON_SCHEMA\n */\n '--json-schema'?: ''\n\n /**\n * Disable color output.\n * @environment SHOPIFY_FLAG_NO_COLOR\n */\n '--no-color'?: ''\n\n /**\n * Disable interactive prompts and browser authentication.\n * @environment SHOPIFY_FLAG_NO_INPUT\n */\n '--no-input'?: ''\n\n /**\n * Increase the verbosity of the output. May include sensitive data.\n * @environment SHOPIFY_FLAG_VERBOSE\n */\n '--verbose'?: ''\n}"
}
},
"version": {
Expand Down
120 changes: 117 additions & 3 deletions packages/cli-kit/src/public/node/upgrade.test.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,28 @@
import {isDevelopment, isUnitTest} from './context/local.js'
import {currentProcessIsGlobal, inferPackageManagerForGlobalCLI} from './is-global.js'
import {checkForCachedNewVersion, packageManagerFromUserAgent, PackageManager} from './node-package-manager.js'
import {currentProcessIsGlobal, inferPackageManagerForGlobalCLI, getProjectDir} from './is-global.js'
import {
checkForCachedNewVersion,
checkForNewVersion,
addNPMDependencies,
getPackageManager,
usesWorkspaces,
packageManagerFromUserAgent,
PackageManager,
} from './node-package-manager.js'
import {exec, isCI} from './system.js'
import {
cliInstallCommand,
getOutputUpdateCLIReminder,
hasBlockingAutoUpgradeNotification,
runCLIUpgrade,
upgradeCLI,
versionToAutoUpgrade,
} from './upgrade.js'
import {Notification, fetchNotifications} from './notifications-system.js'
import {globalCLIVersion, isPreReleaseVersion} from './version.js'
import {mockAndCaptureOutput} from './testing/output.js'
import {inTemporaryDirectory, writeFile} from './fs.js'
import {joinPath} from './path.js'
import {getAutoUpgradeEnabled} from '../../private/node/conf-store.js'
import {CLI_KIT_VERSION} from '../common/version.js'
import {SemVer} from 'semver'
Expand All @@ -26,7 +37,15 @@ vi.mock('./notifications-system.js', async (importOriginal) => {
})
vi.mock('./context/local.js')
vi.mock('./is-global.js')
vi.mock('./node-package-manager.js')
vi.mock('./node-package-manager.js', async (importOriginal) => ({
...(await importOriginal<typeof import('./node-package-manager.js')>()),
checkForCachedNewVersion: vi.fn(),
checkForNewVersion: vi.fn(),
addNPMDependencies: vi.fn(),
getPackageManager: vi.fn(),
usesWorkspaces: vi.fn(),
packageManagerFromUserAgent: vi.fn(),
}))
vi.mock('./system.js')
vi.mock('../../private/node/conf-store.js')
vi.mock('./version.js', async (importOriginal) => {
Expand Down Expand Up @@ -403,3 +422,98 @@ describe('hasBlockingAutoUpgradeNotification', () => {
await expect(hasBlockingAutoUpgradeNotification()).resolves.toBe(false)
})
})

describe('upgradeCLI result', () => {
test('returns the verified global version without presenting success', async () => {
vi.mocked(currentProcessIsGlobal).mockReturnValue(true)
vi.mocked(inferPackageManagerForGlobalCLI).mockReturnValue('pnpm')
vi.mocked(globalCLIVersion).mockResolvedValue(CLI_KIT_VERSION)
mockAndCaptureOutput().clear()

await expect(upgradeCLI()).resolves.toEqual({
status: 'success',
changed: false,
scope: 'global',
previousVersion: CLI_KIT_VERSION,
version: CLI_KIT_VERSION,
packageManager: 'pnpm',
})
expect(mockAndCaptureOutput().info()).not.toContain('Shopify CLI upgraded.')
})

test('returns a development skip without installing', async () => {
vi.mocked(isDevelopment).mockReturnValue(true)
vi.mocked(currentProcessIsGlobal).mockReturnValue(true)

await expect(upgradeCLI()).resolves.toEqual({status: 'skipped', reason: 'development', scope: 'global'})
expect(exec).not.toHaveBeenCalled()
})

test('returns an automatic local upgrade skip', async () => {
vi.mocked(currentProcessIsGlobal).mockReturnValue(false)

await expect(upgradeCLI({autoupgrade: true})).resolves.toEqual({
status: 'skipped',
reason: 'local-autoupgrade',
scope: 'local',
})
expect(addNPMDependencies).not.toHaveBeenCalled()
})

test.each([undefined, '999.0.0'])(
'returns local dependency updates with available version %s',
async (availableVersion) => {
await inTemporaryDirectory(async (directory) => {
// In the unbundled source, the upgrade service belongs to cli-kit.
await writeFile(
joinPath(directory, 'package.json'),
JSON.stringify({
dependencies: {'@shopify/cli-kit': '^4.0.0', unrelated: '1.0.0'},
}),
)
vi.mocked(currentProcessIsGlobal).mockReturnValue(false)
vi.mocked(getProjectDir).mockReturnValue(directory)
vi.mocked(checkForNewVersion).mockResolvedValue(availableVersion)
vi.mocked(getPackageManager).mockResolvedValue('npm')
vi.mocked(usesWorkspaces).mockResolvedValue(false)

await expect(upgradeCLI()).resolves.toEqual({
status: 'success',
changed: null,
scope: 'local',
directory,
previousVersion: CLI_KIT_VERSION,
availableVersion: availableVersion ?? null,
packages: ['@shopify/cli-kit'],
})
expect(addNPMDependencies).toHaveBeenCalledExactlyOnceWith([{name: '@shopify/cli-kit', version: 'latest'}], {
directory,
type: 'prod',
packageManager: 'npm',
addToRootDirectory: false,
stdout: process.stdout,
stderr: process.stderr,
})
expect(checkForNewVersion).toHaveBeenCalledWith('@shopify/cli-kit', CLI_KIT_VERSION)
})
},
)

test('skips a local project without a CLI dependency', async () => {
await inTemporaryDirectory(async (directory) => {
await writeFile(joinPath(directory, 'package.json'), '{}')
vi.mocked(currentProcessIsGlobal).mockReturnValue(false)
vi.mocked(getProjectDir).mockReturnValue(directory)

await expect(upgradeCLI()).resolves.toEqual({status: 'skipped', reason: 'dependency-not-found', scope: 'local'})
expect(addNPMDependencies).not.toHaveBeenCalled()
})
})

test('preserves the failure for a missing local project', async () => {
vi.mocked(currentProcessIsGlobal).mockReturnValue(false)
vi.mocked(getProjectDir).mockReturnValue(undefined)

await expect(upgradeCLI()).rejects.toThrow('Could not determine the local project directory')
})
})
82 changes: 58 additions & 24 deletions packages/cli-kit/src/public/node/upgrade.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,14 +12,16 @@ import {
getPackageManager,
} from './node-package-manager.js'
import {outputContent, outputDebug, outputInfo, outputToken, outputWarn} from './output.js'
import {renderSuccess} from './ui.js'
import {presentUpgradeResult} from './upgrade/result.js'
import {execUpgradeCommand, upgradeOutputStreams} from './upgrade/output.js'
import {cwd, moduleDirectory, sniffForPath} from './path.js'
import {exec, isCI} from './system.js'
import {isCI} from './system.js'
import {globalCLIVersion, isPreReleaseVersion} from './version.js'
import {AbortError} from './error.js'
import {getAutoUpgradeEnabled, setAutoUpgradeEnabled, runAtMinimumInterval} from '../../private/node/conf-store.js'
import {CLI_KIT_VERSION} from '../common/version.js'
import {lt as semverLt} from 'semver'
import type {UpgradeResult} from './upgrade/types.js'

export {getAutoUpgradeEnabled, setAutoUpgradeEnabled}

Expand Down Expand Up @@ -64,6 +66,16 @@ export interface RunCLIUpgradeOptions {
* @throws AbortError if the package manager or command cannot be determined.
*/
export async function runCLIUpgrade(options: RunCLIUpgradeOptions = {}): Promise<void> {
presentUpgradeResult(await upgradeCLI(options), 'text')
}

/**
* Upgrades the CLI and returns the outcome independently of final presentation.
*
* @param options - Whether the upgrade was triggered automatically.
* @returns The verified global version, local dependency update, or skip reason.
*/
export async function upgradeCLI(options: RunCLIUpgradeOptions = {}): Promise<UpgradeResult> {
// Path where the current project is (app/hydrogen)
const path = sniffForPath() ?? cwd()
const projectDir = getProjectDir(path)
Expand All @@ -74,21 +86,22 @@ export async function runCLIUpgrade(options: RunCLIUpgradeOptions = {}): Promise
// Don't auto-upgrade for development mode
if (isDevelopment()) {
outputInfo('Skipping upgrade in development mode.')
return
return {status: 'skipped', reason: 'development', scope: isGlobal ? 'global' : 'local'}
}

// 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
return {status: 'skipped', reason: 'local-autoupgrade', scope: 'local'}
}

// Generate the install command for the global CLI and execute it
if (isGlobal) {
const packageManager = inferPackageManagerForGlobalCLI()
const installCommand = cliInstallCommand()
if (!installCommand) {
if (!installCommand || packageManager === 'unknown') {
throw new Error('Could not determine the package manager')
}
const [command, ...args] = installCommand.split(' ')
Expand All @@ -105,7 +118,7 @@ export async function runCLIUpgrade(options: RunCLIUpgradeOptions = {}): Promise
outputContent`${headline}
Now upgrading by running: ${outputToken.genericShellCommand(installCommand)}...`,
)
await exec(command, args, {stdio: 'inherit'})
await execUpgradeCommand(command, args)

// A zero exit code doesn't guarantee the right version landed: the version check above
// queries the public npm registry, while the install goes through whatever registry the
Expand All @@ -126,12 +139,16 @@ export async function runCLIUpgrade(options: RunCLIUpgradeOptions = {}): Promise
'Your package manager may be resolving @shopify/cli from a registry with outdated versions. Check your npm registry configuration and try again.',
)
}
renderSuccess({
headline: 'Shopify CLI upgraded.',
body: `You're now on version ${installedVersion}.`,
})
return {
status: 'success',
changed: installedVersion !== CLI_KIT_VERSION,
scope: 'global',
previousVersion: CLI_KIT_VERSION,
version: installedVersion,
packageManager,
}
} else if (projectDir) {
await upgradeLocalShopify(projectDir, CLI_KIT_VERSION)
return upgradeLocalShopify(projectDir, CLI_KIT_VERSION)
} else {
throw new Error('Could not determine the local project directory')
}
Expand Down Expand Up @@ -238,7 +255,7 @@ export function getOutputUpdateCLIReminder(version: string, isMajor = false): st
return base
}

async function upgradeLocalShopify(projectDir: string, currentVersion: string) {
async function upgradeLocalShopify(projectDir: string, currentVersion: string): Promise<UpgradeResult> {
const packageJson = (await findUpAndReadPackageJson(projectDir)).content
const packageJsonDependencies = packageJson.dependencies ?? {}
const packageJsonDevDependencies = packageJson.devDependencies ?? {}
Expand All @@ -247,7 +264,7 @@ async function upgradeLocalShopify(projectDir: string, currentVersion: string) {
let resolvedCLIVersion = allDependencies[await cliDependency()]
if (!resolvedCLIVersion) {
outputDebug('Auto-upgrade: CLI dependency not found in project dependencies, skipping local upgrade.')
return
return {status: 'skipped', reason: 'dependency-not-found', scope: 'local'}
}

if (resolvedCLIVersion.slice(0, 1).match(/[\^~]/)) resolvedCLIVersion = currentVersion
Expand All @@ -259,15 +276,25 @@ async function upgradeLocalShopify(projectDir: string, currentVersion: string) {
outputWontInstallMessage(resolvedCLIVersion)
}

await installJsonDependencies('prod', packageJsonDependencies, projectDir)
await installJsonDependencies('dev', packageJsonDevDependencies, projectDir)
const dependencies = await installJsonDependencies('prod', packageJsonDependencies, projectDir)
const devDependencies = await installJsonDependencies('dev', packageJsonDevDependencies, projectDir)
// Local installs are not verified, so the registry version is not an installed-version claim.
return {
status: 'success',
changed: null,
scope: 'local',
directory: projectDir,
previousVersion: resolvedCLIVersion,
availableVersion: newestCLIVersion ?? null,
packages: [...new Set([...dependencies, ...devDependencies])],
}
}

async function installJsonDependencies(
depsEnv: DependencyType,
deps: {[key: string]: string},
directory: string,
): Promise<void> {
): Promise<string[]> {
const packagesToUpdate = [await cliDependency(), ...(await oclifPlugins())]
.filter((pkg: string): boolean => {
const pkgRequirement: string | undefined = deps[pkg]
Expand All @@ -280,15 +307,22 @@ async function installJsonDependencies(
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,
})
const packageManager = await getPackageManager(directory)
const streams = upgradeOutputStreams()
try {
await addNPMDependencies(packagesToUpdate, {
packageManager,
type: depsEnv,
directory,
...streams,
addToRootDirectory: appUsesWorkspaces,
})
} finally {
if (streams.stdout !== process.stdout) streams.stdout.end()
if (streams.stderr !== process.stderr) streams.stderr.end()
}
}
return packagesToUpdate.map(({name}) => name)
}

async function cliDependency(): Promise<string> {
Expand Down
Loading
Loading