From 35c2e6a833114312463a48250eda093c5208169c Mon Sep 17 00:00:00 2001 From: Gonzalo Riestra Date: Wed, 11 Feb 2026 11:49:33 +0100 Subject: [PATCH] Auto-upgrade Co-Authored-By: Alfonso Noriega --- .github/workflows/release.yml | 2 +- bin/release | 1 - bin/update-cli-kit-version.js | 7 - docs-shopify.dev/commands/upgrade.doc.ts | 4 +- .../generated/generated_docs_data.json | 4 +- package.json | 2 +- .../src/cli/services/app/config/link.test.ts | 12 +- .../src/cli/services/app/config/use.test.ts | 2 +- .../src/cli/services/dev/graphiql/server.ts | 4 +- .../app/src/cli/services/generate.test.ts | 4 +- packages/app/src/cli/services/info.ts | 4 +- packages/app/src/cli/services/init/init.ts | 1 + .../cli/services/init/template/npm.test.ts | 2 +- .../app/src/cli/services/init/template/npm.ts | 4 +- .../app-management-client.test.ts | 4 +- .../app-management-client.ts | 5 +- .../partners-client.ts | 4 +- .../src/private/node/api/headers.test.ts | 8 +- .../cli-kit/src/private/node/api/headers.ts | 4 +- .../cli-kit/src/private/node/constants.ts | 1 + packages/cli-kit/src/public/common/version.ts | 1 - .../cli-kit/src/public/node/analytics.test.ts | 6 +- packages/cli-kit/src/public/node/analytics.ts | 4 +- .../src/public/node/api/graphql.test.ts | 12 +- .../cli-kit/src/public/node/api/graphql.ts | 4 +- .../src/public/node/base-command.test.ts | 49 ++-- .../cli-kit/src/public/node/context/local.ts | 10 + .../cli-kit/src/public/node/error-handler.ts | 4 +- packages/cli-kit/src/public/node/fs.ts | 19 +- .../src/public/node/hooks/postrun.test.ts | 58 +++++ .../cli-kit/src/public/node/hooks/postrun.ts | 31 ++- .../src/public/node/hooks/prerun.test.ts | 46 +--- .../cli-kit/src/public/node/hooks/prerun.ts | 32 +-- .../cli-kit/src/public/node/is-global.test.ts | 160 ++++++++++++- packages/cli-kit/src/public/node/is-global.ts | 77 ++++++- .../node/multiple-installation-warning.ts | 7 +- .../src/public/node/node-package-manager.ts | 5 +- .../src/public/node/notifications-system.ts | 4 +- packages/cli-kit/src/public/node/output.ts | 1 + .../multiple-installation-warning.test.ts | 24 +- .../cli-kit/src/public/node/upgrade.test.ts | 152 ++++++++++--- packages/cli-kit/src/public/node/upgrade.ts | 211 ++++++++++++++++-- packages/cli-kit/src/public/node/version.ts | 48 +++- packages/cli/README.md | 6 +- packages/cli/oclif.manifest.json | 6 +- packages/cli/src/cli/commands/upgrade.test.ts | 2 +- packages/cli/src/cli/commands/upgrade.ts | 11 +- .../cli/services/commands/notifications.ts | 6 +- .../src/cli/services/commands/version.test.ts | 2 +- .../cli/src/cli/services/commands/version.ts | 4 +- .../cli/utilities/repl/repl-theme-manager.ts | 4 +- .../theme-environment/storefront-utils.ts | 4 +- 52 files changed, 830 insertions(+), 259 deletions(-) delete mode 100755 bin/update-cli-kit-version.js delete mode 100644 packages/cli-kit/src/public/common/version.ts create mode 100644 packages/cli-kit/src/public/node/hooks/postrun.test.ts diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 8ec47ce5fc1..e9ec82bfd01 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -66,7 +66,7 @@ jobs: comment_suffix: " > [!CAUTION] - > After installing, validate the version by running just `shopify` in your terminal. + > After installing, validate the version by running `shopify version` in your terminal. > If the versions don't match, you might have multiple global instances installed. diff --git a/bin/release b/bin/release index 3edb60c71cd..a554e8443e6 100755 --- a/bin/release +++ b/bin/release @@ -16,7 +16,6 @@ fi if [ "$tag" = "nightly" ] || [ "$tag" = "experimental" ]; then echo -e "---\n'"'@shopify/cli'"': patch\n---" > .changeset/force-release.md pnpm changeset version --snapshot $tag - ./bin/update-cli-kit-version.js fi # Bundle the packages diff --git a/bin/update-cli-kit-version.js b/bin/update-cli-kit-version.js deleted file mode 100755 index 2aee5ed690e..00000000000 --- a/bin/update-cli-kit-version.js +++ /dev/null @@ -1,7 +0,0 @@ -#!/usr/bin/env node -import {readFileSync, writeFile, writeFileSync} from 'fs'; - -// Update the cli-kit version in version.ts -const cliKitVersion = JSON.parse(readFileSync('packages/cli-kit/package.json')).version -const content = `export const CLI_KIT_VERSION = '${cliKitVersion}'\n` -writeFileSync('packages/cli-kit/src/public/common/version.ts', content) diff --git a/docs-shopify.dev/commands/upgrade.doc.ts b/docs-shopify.dev/commands/upgrade.doc.ts index c0f307ed08a..2b016df7eaa 100644 --- a/docs-shopify.dev/commands/upgrade.doc.ts +++ b/docs-shopify.dev/commands/upgrade.doc.ts @@ -3,8 +3,8 @@ import {ReferenceEntityTemplateSchema} from '@shopify/generate-docs' const data: ReferenceEntityTemplateSchema = { name: 'upgrade', - description: `Shows details on how to upgrade Shopify CLI.`, - overviewPreviewDescription: `Shows details on how to upgrade Shopify CLI.`, + description: `Upgrades Shopify CLI using your package manager.`, + overviewPreviewDescription: `Upgrades Shopify CLI.`, type: 'command', isVisualComponent: false, defaultExample: { diff --git a/docs-shopify.dev/generated/generated_docs_data.json b/docs-shopify.dev/generated/generated_docs_data.json index 5d51044f3a6..359e7f0a4f5 100644 --- a/docs-shopify.dev/generated/generated_docs_data.json +++ b/docs-shopify.dev/generated/generated_docs_data.json @@ -7815,8 +7815,8 @@ }, { "name": "upgrade", - "description": "Shows details on how to upgrade Shopify CLI.", - "overviewPreviewDescription": "Shows details on how to upgrade Shopify CLI.", + "description": "Upgrades Shopify CLI using your package manager.", + "overviewPreviewDescription": "Upgrades Shopify CLI.", "type": "command", "isVisualComponent": false, "defaultExample": { diff --git a/package.json b/package.json index 1c830c1c088..52be81dd0ad 100644 --- a/package.json +++ b/package.json @@ -8,7 +8,7 @@ "build:affected": "nx affected --target=build", "build": "nx run-many --target=build --all --skip-nx-cache", "bundle-for-release": "nx run-many --target=bundle --all --skip-nx-cache", - "changeset-manifests": "changeset version && pnpm install --no-frozen-lockfile && pnpm refresh-manifests && pnpm refresh-readme && pnpm refresh-code-documentation && bin/update-cli-kit-version.js", + "changeset-manifests": "changeset version && pnpm install --no-frozen-lockfile && pnpm refresh-manifests && pnpm refresh-readme && pnpm refresh-code-documentation", "clean": "nx run-many --target=clean --all --skip-nx-cache && nx reset", "create-app": "nx build create-app && node packages/create-app/bin/dev.js --package-manager npm", "deploy-experimental": "node bin/deploy-experimental.js", diff --git a/packages/app/src/cli/services/app/config/link.test.ts b/packages/app/src/cli/services/app/config/link.test.ts index e0d9ccaa684..8c17a627e1f 100644 --- a/packages/app/src/cli/services/app/config/link.test.ts +++ b/packages/app/src/cli/services/app/config/link.test.ts @@ -260,7 +260,7 @@ embedded = false body: 'Using shopify.app.toml as your default config.', nextSteps: [ [`Make updates to shopify.app.toml in your local project`], - ['To upload your config, run', {command: 'npm run shopify app deploy'}], + ['To upload your config, run', {command: 'shopify app deploy'}], ], reference: [ { @@ -392,7 +392,7 @@ url = "https://api-client-config.com/preferences" body: 'Using shopify.app.toml as your default config.', nextSteps: [ [`Make updates to shopify.app.toml in your local project`], - ['To upload your config, run', {command: 'npm run shopify app deploy'}], + ['To upload your config, run', {command: 'shopify app deploy'}], ], reference: [ { @@ -523,7 +523,7 @@ embedded = false body: 'Using shopify.app.staging.toml as your default config.', nextSteps: [ [`Make updates to shopify.app.staging.toml in your local project`], - ['To upload your config, run', {command: 'yarn shopify app deploy'}], + ['To upload your config, run', {command: 'shopify app deploy'}], ], reference: [ { @@ -695,7 +695,7 @@ embedded = false body: 'Using shopify.app.toml as your default config.', nextSteps: [ [`Make updates to shopify.app.toml in your local project`], - ['To upload your config, run', {command: 'yarn shopify app deploy'}], + ['To upload your config, run', {command: 'shopify app deploy'}], ], reference: [ { @@ -1162,7 +1162,7 @@ embedded = false body: 'Using shopify.app.toml as your default config.', nextSteps: [ [`Make updates to shopify.app.toml in your local project`], - ['To upload your config, run', {command: 'npm run shopify app deploy'}], + ['To upload your config, run', {command: 'shopify app deploy'}], ], reference: [ { @@ -1407,7 +1407,7 @@ embedded = true body: 'Using shopify.app.staging.toml as your default config.', nextSteps: [ [`Make updates to shopify.app.staging.toml in your local project`], - ['To upload your config, run', {command: 'yarn shopify app deploy'}], + ['To upload your config, run', {command: 'shopify app deploy'}], ], reference: [ { diff --git a/packages/app/src/cli/services/app/config/use.test.ts b/packages/app/src/cli/services/app/config/use.test.ts index 6f8418c59b0..f209cf43a0f 100644 --- a/packages/app/src/cli/services/app/config/use.test.ts +++ b/packages/app/src/cli/services/app/config/use.test.ts @@ -43,7 +43,7 @@ describe('use', () => { headline: 'Cleared current configuration.', body: [ 'In order to set a new current configuration, please run', - {command: 'npm run shopify app config use CONFIG_NAME'}, + {command: 'shopify app config use CONFIG_NAME'}, {char: '.'}, ], }) diff --git a/packages/app/src/cli/services/dev/graphiql/server.ts b/packages/app/src/cli/services/dev/graphiql/server.ts index 266d62c8347..9343f202ff1 100644 --- a/packages/app/src/cli/services/dev/graphiql/server.ts +++ b/packages/app/src/cli/services/dev/graphiql/server.ts @@ -4,7 +4,7 @@ import {filterCustomHeaders} from './utilities.js' import express from 'express' import bodyParser from 'body-parser' import {performActionWithRetryAfterRecovery} from '@shopify/cli-kit/common/retry' -import {CLI_KIT_VERSION} from '@shopify/cli-kit/common/version' +import {cliVersion} from '@shopify/cli-kit/node/version' import {AbortError} from '@shopify/cli-kit/node/error' import {adminUrl, supportedApiVersions} from '@shopify/cli-kit/node/api/admin' import {fetch} from '@shopify/cli-kit/node/http' @@ -195,7 +195,7 @@ export function setupGraphiQLServer({ Accept: 'application/json', 'Content-Type': 'application/json', 'X-Shopify-Access-Token': await token(), - 'User-Agent': `ShopifyCLIGraphiQL/${CLI_KIT_VERSION}`, + 'User-Agent': `ShopifyCLIGraphiQL/${cliVersion()}`, } return fetch(graphqlUrl, { diff --git a/packages/app/src/cli/services/generate.test.ts b/packages/app/src/cli/services/generate.test.ts index e00dc5849d1..c62885f989b 100644 --- a/packages/app/src/cli/services/generate.test.ts +++ b/packages/app/src/cli/services/generate.test.ts @@ -70,7 +70,7 @@ describe('generate', () => { │ │ │ Next steps │ │ • To preview this extension along with the rest of the project, run │ - │ \`yarn shopify app dev\` │ + │ \`shopify app dev\` │ │ │ ╰──────────────────────────────────────────────────────────────────────────────╯ " @@ -92,7 +92,7 @@ describe('generate', () => { │ │ │ Next steps │ │ • To preview this extension along with the rest of the project, run │ - │ \`yarn shopify app dev\` │ + │ \`shopify app dev\` │ │ │ ╰──────────────────────────────────────────────────────────────────────────────╯ " diff --git a/packages/app/src/cli/services/info.ts b/packages/app/src/cli/services/info.ts index c6ab37a58f3..b14c74b8b8e 100644 --- a/packages/app/src/cli/services/info.ts +++ b/packages/app/src/cli/services/info.ts @@ -15,7 +15,7 @@ import { stringifyMessage, } from '@shopify/cli-kit/node/output' import {AlertCustomSection, InlineToken} from '@shopify/cli-kit/node/ui' -import {CLI_KIT_VERSION} from '@shopify/cli-kit/common/version' +import {cliVersion} from '@shopify/cli-kit/node/version' export type Format = 'json' | 'text' export interface InfoOptions { @@ -265,7 +265,7 @@ class AppInfo { async systemInfoSection(): Promise { const {platform, arch} = platformAndArch() return this.tableSection('Tooling and System', [ - ['Shopify CLI', CLI_KIT_VERSION], + ['Shopify CLI', cliVersion()], ['Package manager', this.app.packageManager], ['OS', `${platform}-${arch}`], ['Shell', process.env.SHELL ?? 'unknown'], diff --git a/packages/app/src/cli/services/init/init.ts b/packages/app/src/cli/services/init/init.ts index 0dee2acba57..7356800393e 100644 --- a/packages/app/src/cli/services/init/init.ts +++ b/packages/app/src/cli/services/init/init.ts @@ -124,6 +124,7 @@ async function init(options: InitOptions) { await appendFile(joinPath(templateScaffoldDir, '.npmrc'), `auto-install-peers=true\n`) break } + case 'homebrew': case 'unknown': throw new UnknownPackageManagerError() } diff --git a/packages/app/src/cli/services/init/template/npm.test.ts b/packages/app/src/cli/services/init/template/npm.test.ts index b8eeb21770a..a3fc6372ed9 100644 --- a/packages/app/src/cli/services/init/template/npm.test.ts +++ b/packages/app/src/cli/services/init/template/npm.test.ts @@ -7,7 +7,7 @@ import {platform} from 'os' vi.mock('os') vi.mock('@shopify/cli-kit/node/node-package-manager') -vi.mock('@shopify/cli-kit/common/version', () => ({CLI_KIT_VERSION: '1.2.3'})) +vi.mock('@shopify/cli-kit/node/version', () => ({cliVersion: () => '1.2.3'})) describe('updateCLIDependencies', () => { test('updates @shopify/cli and deletes @shopify/app if not using global CLI', async () => { diff --git a/packages/app/src/cli/services/init/template/npm.ts b/packages/app/src/cli/services/init/template/npm.ts index 123cdb1044a..84725407950 100644 --- a/packages/app/src/cli/services/init/template/npm.ts +++ b/packages/app/src/cli/services/init/template/npm.ts @@ -1,7 +1,7 @@ import {PackageManager, installNodeModules, PackageJson} from '@shopify/cli-kit/node/node-package-manager' import {moduleDirectory, normalizePath} from '@shopify/cli-kit/node/path' import {findPathUp} from '@shopify/cli-kit/node/fs' -import {CLI_KIT_VERSION} from '@shopify/cli-kit/common/version' +import {cliVersion} from '@shopify/cli-kit/node/version' import {platform} from 'os' interface UpdateCLIDependenciesOptions { @@ -20,7 +20,7 @@ export async function updateCLIDependencies({ if (useGlobalCLI) { delete packageJSON.dependencies['@shopify/cli'] } else { - packageJSON.dependencies['@shopify/cli'] = CLI_KIT_VERSION + packageJSON.dependencies['@shopify/cli'] = cliVersion() } delete packageJSON.dependencies['@shopify/app'] diff --git a/packages/app/src/cli/utilities/developer-platform-client/app-management-client.test.ts b/packages/app/src/cli/utilities/developer-platform-client/app-management-client.test.ts index aa70c7eece5..38b7d3b2845 100644 --- a/packages/app/src/cli/utilities/developer-platform-client/app-management-client.test.ts +++ b/packages/app/src/cli/utilities/developer-platform-client/app-management-client.test.ts @@ -33,7 +33,7 @@ import {CreateAssetUrl} from '../../api/graphql/app-management/generated/create- import {SourceExtension} from '../../api/graphql/app-management/generated/types.js' import {ListOrganizations} from '../../api/graphql/business-platform-destinations/generated/organizations.js' import {describe, expect, test, vi, beforeEach} from 'vitest' -import {CLI_KIT_VERSION} from '@shopify/cli-kit/common/version' +import {cliVersion} from '@shopify/cli-kit/node/version' import {fetch} from '@shopify/cli-kit/node/http' import { businessPlatformOrganizationsRequest, @@ -69,7 +69,7 @@ const templateDisallowedByMinimumCliVersion: GatedExtensionTemplate = { ...testRemoteExtensionTemplates[2]!, organizationBetaFlags: ['allowedFlag'], // minimum CLI version is higher than the current CLI version - minimumCliVersion: `1${CLI_KIT_VERSION}`, + minimumCliVersion: `1${cliVersion()}`, } const templateDisallowedByDeprecatedFromCliVersion: GatedExtensionTemplate = { ...testRemoteExtensionTemplates[2]!, diff --git a/packages/app/src/cli/utilities/developer-platform-client/app-management-client.ts b/packages/app/src/cli/utilities/developer-platform-client/app-management-client.ts index 410c61f9028..ea54a615b29 100644 --- a/packages/app/src/cli/utilities/developer-platform-client/app-management-client.ts +++ b/packages/app/src/cli/utilities/developer-platform-client/app-management-client.ts @@ -161,7 +161,7 @@ import { businessPlatformRequestDoc, BusinessPlatformRequestOptions, } from '@shopify/cli-kit/node/api/business-platform' -import {CLI_KIT_VERSION} from '@shopify/cli-kit/common/version' +import {cliVersion, isPreReleaseVersion} from '@shopify/cli-kit/node/version' import {versionSatisfies} from '@shopify/cli-kit/node/node-package-manager' import {outputDebug} from '@shopify/cli-kit/node/output' import {developerDashboardFqdn, normalizeStoreFqdn} from '@shopify/cli-kit/node/context/fqdn' @@ -169,7 +169,6 @@ import {TokenItem} from '@shopify/cli-kit/node/ui' import {functionsRequestDoc, FunctionsRequestOptions} from '@shopify/cli-kit/node/api/functions' import {fileExists, readFile} from '@shopify/cli-kit/node/fs' import {JsonMapType} from '@shopify/cli-kit/node/toml' -import {isPreReleaseVersion} from '@shopify/cli-kit/node/version' import {UnauthorizedHandler} from '@shopify/cli-kit/node/api/graphql' import {Variables} from 'graphql-request' import {webhooksRequestDoc, WebhooksRequestOptions} from '@shopify/cli-kit/node/api/webhooks' @@ -1318,7 +1317,7 @@ export async function allowedTemplates( templates: GatedExtensionTemplate[], betaFlagsFetcher: (betaFlags: string[]) => Promise<{[key: string]: boolean}>, expFlagsFetcher: (expFlags: string[]) => Promise<{[key: string]: boolean}>, - version: string = CLI_KIT_VERSION, + version: string = cliVersion(), ): Promise { // Extract both types of flags from templates const allBetaFlags = Array.from(new Set(templates.map((ext) => ext.organizationBetaFlags ?? []).flat())) diff --git a/packages/app/src/cli/utilities/developer-platform-client/partners-client.ts b/packages/app/src/cli/utilities/developer-platform-client/partners-client.ts index 7d61dcc1f1c..2416021a1a7 100644 --- a/packages/app/src/cli/utilities/developer-platform-client/partners-client.ts +++ b/packages/app/src/cli/utilities/developer-platform-client/partners-client.ts @@ -164,7 +164,7 @@ import {ensureAuthenticatedPartners, Session} from '@shopify/cli-kit/node/sessio import {partnersFqdn} from '@shopify/cli-kit/node/context/fqdn' import {TokenItem} from '@shopify/cli-kit/node/ui' import {RequestModeInput, Response, shopifyFetch} from '@shopify/cli-kit/node/http' -import {CLI_KIT_VERSION} from '@shopify/cli-kit/common/version' +import {cliVersion} from '@shopify/cli-kit/node/version' // this is a temporary solution for editions to support https://vault.shopify.io/gsd/projects/31406 // read more here: https://vault.shopify.io/gsd/projects/31406 @@ -702,7 +702,7 @@ export class PartnersClient implements DeveloperPlatformClient { const fetchAppLogs = async ({jwtToken, cursor, filters}: AppLogsOptions): Promise => { const url = await generateFetchAppLogUrl(cursor, filters) - const userAgent = `Shopify CLI; v=${CLI_KIT_VERSION}` + const userAgent = `Shopify CLI; v=${cliVersion()}` const headers = { Authorization: `Bearer ${jwtToken}`, 'User-Agent': userAgent, diff --git a/packages/cli-kit/src/private/node/api/headers.test.ts b/packages/cli-kit/src/private/node/api/headers.test.ts index be8ca5ff495..89c1eff4421 100644 --- a/packages/cli-kit/src/private/node/api/headers.test.ts +++ b/packages/cli-kit/src/private/node/api/headers.test.ts @@ -1,5 +1,5 @@ import {buildHeaders, sanitizedHeadersOutput, GraphQLClientError} from './headers.js' -import {CLI_KIT_VERSION} from '../../../public/common/version.js' +import {cliVersion} from '../../../public/node/version.js' import {randomUUID} from '../../../public/node/crypto.js' import {firstPartyDev, isUnitTest} from '../../../public/node/context/local.js' import {test, vi, expect, describe, beforeEach} from 'vitest' @@ -21,7 +21,7 @@ describe('common API methods', () => { const headers = buildHeaders('my-token') // Then - const version = CLI_KIT_VERSION + const version = cliVersion() expect(headers).toEqual({ 'Content-Type': 'application/json', 'Keep-Alive': 'timeout=30', @@ -41,7 +41,7 @@ describe('common API methods', () => { const headers = buildHeaders('my-token') // Then - const version = CLI_KIT_VERSION + const version = cliVersion() expect(headers).toEqual({ 'Content-Type': 'application/json', 'Keep-Alive': 'timeout=30', @@ -63,7 +63,7 @@ describe('common API methods', () => { const headers = buildHeaders(token) // Then - const version = CLI_KIT_VERSION + const version = cliVersion() expect(headers).toEqual({ 'Content-Type': 'application/json', 'Keep-Alive': 'timeout=30', diff --git a/packages/cli-kit/src/private/node/api/headers.ts b/packages/cli-kit/src/private/node/api/headers.ts index e743b3258c2..b32fd1792db 100644 --- a/packages/cli-kit/src/private/node/api/headers.ts +++ b/packages/cli-kit/src/private/node/api/headers.ts @@ -1,4 +1,4 @@ -import {CLI_KIT_VERSION} from '../../../public/common/version.js' +import {cliVersion} from '../../../public/node/version.js' import {firstPartyDev} from '../../../public/node/context/local.js' import {AbortError} from '../../../public/node/error.js' import https from 'https' @@ -48,7 +48,7 @@ export function sanitizedHeadersOutput(headers: {[key: string]: string}): string } export function buildHeaders(token?: string): {[key: string]: string} { - const userAgent = `Shopify CLI; v=${CLI_KIT_VERSION}` + const userAgent = `Shopify CLI; v=${cliVersion()}` const headers: {[header: string]: string} = { 'User-Agent': userAgent, diff --git a/packages/cli-kit/src/private/node/constants.ts b/packages/cli-kit/src/private/node/constants.ts index 3672b8b4c29..6e7f3891c9e 100644 --- a/packages/cli-kit/src/private/node/constants.ts +++ b/packages/cli-kit/src/private/node/constants.ts @@ -46,6 +46,7 @@ export const environmentVariables = { neverUsePartnersApi: 'SHOPIFY_CLI_NEVER_USE_PARTNERS_API', skipNetworkLevelRetry: 'SHOPIFY_CLI_SKIP_NETWORK_LEVEL_RETRY', maxRequestTimeForNetworkCalls: 'SHOPIFY_CLI_MAX_REQUEST_TIME_FOR_NETWORK_CALLS', + noAutoUpgrade: 'SHOPIFY_CLI_NO_AUTO_UPGRADE', } export const defaultThemeKitAccessDomain = 'theme-kit-access.shopifyapps.com' diff --git a/packages/cli-kit/src/public/common/version.ts b/packages/cli-kit/src/public/common/version.ts deleted file mode 100644 index 165f85429f0..00000000000 --- a/packages/cli-kit/src/public/common/version.ts +++ /dev/null @@ -1 +0,0 @@ -export const CLI_KIT_VERSION = '3.90.0' diff --git a/packages/cli-kit/src/public/node/analytics.test.ts b/packages/cli-kit/src/public/node/analytics.test.ts index 437ee1bb93e..345a9c9794e 100644 --- a/packages/cli-kit/src/public/node/analytics.test.ts +++ b/packages/cli-kit/src/public/node/analytics.test.ts @@ -14,10 +14,10 @@ import {joinPath, dirname} from './path.js' import {publishMonorailEvent} from './monorail.js' import {mockAndCaptureOutput} from './testing/output.js' import {addPublicMetadata} from './metadata.js' +import {cliVersion} from './version.js' import * as store from '../../private/node/analytics/storage.js' import {startAnalytics} from '../../private/node/analytics.js' import {hashString} from '../../public/node/crypto.js' -import {CLI_KIT_VERSION} from '../common/version.js' import {setLastSeenAuthMethod, setLastSeenUserIdAfterAuth} from '../../private/node/session.js' import {test, expect, describe, vi, beforeEach, afterEach, MockedFunction} from 'vitest' @@ -85,7 +85,7 @@ describe('event tracking', () => { } as any await reportAnalyticsEvent({config, exitMode: 'ok'}) // Then - const version = CLI_KIT_VERSION + const version = cliVersion() const expectedPayloadPublic = { command: commandContent.command, cmd_all_alias_used: commandContent.alias, @@ -136,7 +136,7 @@ describe('event tracking', () => { await reportAnalyticsEvent({config, errorMessage: 'Permission denied', exitMode: 'unexpected_error'}) // Then - const version = CLI_KIT_VERSION + const version = cliVersion() const expectedPayloadPublic = { command: commandContent.command, time_start: 1643709599900, diff --git a/packages/cli-kit/src/public/node/analytics.ts b/packages/cli-kit/src/public/node/analytics.ts index 03345a195be..3fb80684100 100644 --- a/packages/cli-kit/src/public/node/analytics.ts +++ b/packages/cli-kit/src/public/node/analytics.ts @@ -2,6 +2,7 @@ import {alwaysLogAnalytics, alwaysLogMetrics, analyticsDisabled, isShopify} from import * as metadata from './metadata.js' import {publishMonorailEvent, MONORAIL_COMMAND_TOPIC} from './monorail.js' import {fanoutHooks} from './plugins.js' +import {cliVersion} from './version.js' import { recordTiming as storageRecordTiming, recordError as storageRecordError, @@ -12,7 +13,6 @@ import { } from '../../private/node/analytics/storage.js' import {outputContent, outputDebug, outputToken} from '../../public/node/output.js' import {getEnvironmentData, getSensitiveEnvironmentData} from '../../private/node/analytics.js' -import {CLI_KIT_VERSION} from '../common/version.js' import {recordMetrics} from '../../private/node/otel-metrics.js' import {runWithRateLimit} from '../../private/node/conf-store.js' import {reportingRateLimit} from '../../private/node/constants.js' @@ -152,7 +152,7 @@ async function buildPayload({config, errorMessage, exitMode}: ReportAnalyticsEve time_end: currentTime, total_time: wallClockElapsed, success: exitMode === 'ok' && errorMessage === undefined, - cli_version: CLI_KIT_VERSION, + cli_version: cliVersion(), ruby_version: '', node_version: process.version.replace('v', ''), is_employee: await isShopify(), diff --git a/packages/cli-kit/src/public/node/api/graphql.test.ts b/packages/cli-kit/src/public/node/api/graphql.test.ts index 619719a26e4..71e61436a0b 100644 --- a/packages/cli-kit/src/public/node/api/graphql.test.ts +++ b/packages/cli-kit/src/public/node/api/graphql.test.ts @@ -8,7 +8,7 @@ import {inTemporaryDirectory} from '../fs.js' import {LocalStorage} from '../local-storage.js' import {ConfSchema, GraphQLRequestKey} from '../../../private/node/conf-store.js' import {nonRandomUUID} from '../crypto.js' -import {CLI_KIT_VERSION} from '../../common/version.js' +import {cliVersion} from '../version.js' import * as system from '../system.js' import {test, vi, describe, expect, beforeEach, beforeAll, afterAll, afterEach} from 'vitest' import {TypedDocumentNode} from '@graphql-typed-document-node/core' @@ -174,7 +174,7 @@ describe('graphqlRequest', () => { } `) - expect(userAgent).toMatch(new RegExp(`Shopify CLI; v=${CLI_KIT_VERSION}`)) + expect(userAgent).toMatch(new RegExp(`Shopify CLI; v=${cliVersion()}`)) }) test('Logs the request ids to metadata and requestIdCollection', async () => { @@ -639,10 +639,10 @@ describe('graphqlRequest with caching', () => { await expect(seventhRes).resolves.toEqual({MutationName: {example: 'new-value'}}) expect(requestCount).toBe(6) - const firstKey: GraphQLRequestKey = `q-${mutationHash}-${firstVariablesHash}-${CLI_KIT_VERSION}-extra` - const withOtherVariablesKey: GraphQLRequestKey = `q-${mutationHash}-${otherVariablesHash}-${CLI_KIT_VERSION}-extra` - const withOtherExtraKey: GraphQLRequestKey = `q-${mutationHash}-${otherVariablesHash}-${CLI_KIT_VERSION}-other-extra` - const noExtraKey: GraphQLRequestKey = `q-${mutationHash}-${otherVariablesHash}-${CLI_KIT_VERSION}-` + const firstKey: GraphQLRequestKey = `q-${mutationHash}-${firstVariablesHash}-${cliVersion()}-extra` + const withOtherVariablesKey: GraphQLRequestKey = `q-${mutationHash}-${otherVariablesHash}-${cliVersion()}-extra` + const withOtherExtraKey: GraphQLRequestKey = `q-${mutationHash}-${otherVariablesHash}-${cliVersion()}-other-extra` + const noExtraKey: GraphQLRequestKey = `q-${mutationHash}-${otherVariablesHash}-${cliVersion()}-` expect(cacheStore.get('cache')![firstKey]).toEqual({ value: '{"MutationName":{"example":"variables"}}', diff --git a/packages/cli-kit/src/public/node/api/graphql.ts b/packages/cli-kit/src/public/node/api/graphql.ts index ad1ca5a28cc..3216152acec 100644 --- a/packages/cli-kit/src/public/node/api/graphql.ts +++ b/packages/cli-kit/src/public/node/api/graphql.ts @@ -13,7 +13,7 @@ import { } from '../../../private/node/conf-store.js' import {LocalStorage} from '../local-storage.js' import {abortSignalFromRequestBehaviour, RequestBehaviour, requestMode, RequestModeInput} from '../http.js' -import {CLI_KIT_VERSION} from '../../common/version.js' +import {cliVersion} from '../version.js' import {sleep} from '../system.js' import {outputContent, outputDebug} from '../output.js' import { @@ -255,7 +255,7 @@ async function performGraphQLRequest(options: PerformGraphQLRequestOpti // The cache key is a combination of the hashed query and variables, with an optional extra key provided by the user. const queryHash = nonRandomUUID(queryAsString) const variablesHash = nonRandomUUID(JSON.stringify(variables ?? {})) - const cacheKey: GraphQLRequestKey = `q-${queryHash}-${variablesHash}-${CLI_KIT_VERSION}-${cacheExtraKey ?? ''}` + const cacheKey: GraphQLRequestKey = `q-${queryHash}-${variablesHash}-${cliVersion()}-${cacheExtraKey ?? ''}` const result = await cacheRetrieveOrRepopulate( cacheKey, diff --git a/packages/cli-kit/src/public/node/base-command.test.ts b/packages/cli-kit/src/public/node/base-command.test.ts index 2ff22d7801c..4eda95f80cd 100644 --- a/packages/cli-kit/src/public/node/base-command.test.ts +++ b/packages/cli-kit/src/public/node/base-command.test.ts @@ -572,13 +572,15 @@ describe('removeDuplicatedPlugins', () => { this.config.plugins = new Map(initialPlugins) } - const result = await super.init() - - // Capture the plugins after init (which calls removeDuplicatedPlugins) - // eslint-disable-next-line require-atomic-updates - capturedPlugins = new Map(this.config.plugins) - - return result + try { + const result = await super.init() + return result + } finally { + // Capture the plugins after init (which calls removeDuplicatedPlugins). + // Use finally to ensure capture even if super.init() throws. + // eslint-disable-next-line require-atomic-updates + capturedPlugins = new Map(this.config.plugins) + } } } @@ -591,9 +593,10 @@ describe('removeDuplicatedPlugins', () => { test('removes @shopify/app plugin when present', async () => { await inTemporaryDirectory(async (tmpDir) => { // Given - set up plugins to be injected - const mockPlugin1 = {name: '@shopify/app', version: '1.0.0'} as any - const mockPlugin2 = {name: '@shopify/plugin-ngrok', version: '1.0.0'} as any - const mockPlugin3 = {name: '@shopify/plugin-did-you-mean', version: '1.0.0'} as any + // root is required because registerCleanBugsnagErrorsFromWithinPlugins calls realpath(plugin.root) + const mockPlugin1 = {name: '@shopify/app', version: '1.0.0', root: tmpDir} as any + const mockPlugin2 = {name: '@shopify/plugin-ngrok', version: '1.0.0', root: tmpDir} as any + const mockPlugin3 = {name: '@shopify/plugin-did-you-mean', version: '1.0.0', root: tmpDir} as any capturedPlugins = new Map([ ['@shopify/app', mockPlugin1], @@ -620,9 +623,9 @@ describe('removeDuplicatedPlugins', () => { test('removes @shopify/plugin-cloudflare plugin when present', async () => { await inTemporaryDirectory(async (tmpDir) => { // Given - set up plugins to be injected - const mockPlugin1 = {name: '@shopify/plugin-cloudflare', version: '1.0.0'} as any - const mockPlugin2 = {name: '@shopify/plugin-ngrok', version: '1.0.0'} as any - const mockPlugin3 = {name: '@shopify/plugin-did-you-mean', version: '1.0.0'} as any + const mockPlugin1 = {name: '@shopify/plugin-cloudflare', version: '1.0.0', root: tmpDir} as any + const mockPlugin2 = {name: '@shopify/plugin-ngrok', version: '1.0.0', root: tmpDir} as any + const mockPlugin3 = {name: '@shopify/plugin-did-you-mean', version: '1.0.0', root: tmpDir} as any capturedPlugins = new Map([ ['@shopify/plugin-cloudflare', mockPlugin1], @@ -649,10 +652,10 @@ describe('removeDuplicatedPlugins', () => { test('removes both @shopify/app and @shopify/plugin-cloudflare plugins when present', async () => { await inTemporaryDirectory(async (tmpDir) => { // Given - set up plugins to be injected - const mockPlugin1 = {name: '@shopify/app', version: '1.0.0'} as any - const mockPlugin2 = {name: '@shopify/plugin-cloudflare', version: '1.0.0'} as any - const mockPlugin3 = {name: '@shopify/plugin-ngrok', version: '1.0.0'} as any - const mockPlugin4 = {name: '@shopify/plugin-did-you-mean', version: '1.0.0'} as any + const mockPlugin1 = {name: '@shopify/app', version: '1.0.0', root: tmpDir} as any + const mockPlugin2 = {name: '@shopify/plugin-cloudflare', version: '1.0.0', root: tmpDir} as any + const mockPlugin3 = {name: '@shopify/plugin-ngrok', version: '1.0.0', root: tmpDir} as any + const mockPlugin4 = {name: '@shopify/plugin-did-you-mean', version: '1.0.0', root: tmpDir} as any capturedPlugins = new Map([ ['@shopify/app', mockPlugin1], @@ -681,9 +684,9 @@ describe('removeDuplicatedPlugins', () => { test('does not remove any plugins when bundled plugins are not present', async () => { await inTemporaryDirectory(async (tmpDir) => { // Given - set up plugins (none are bundled plugins) - const mockPlugin1 = {name: '@shopify/plugin-ngrok', version: '1.0.0'} as any - const mockPlugin2 = {name: '@shopify/plugin-did-you-mean', version: '1.0.0'} as any - const mockPlugin3 = {name: 'some-other-plugin', version: '1.0.0'} as any + const mockPlugin1 = {name: '@shopify/plugin-ngrok', version: '1.0.0', root: tmpDir} as any + const mockPlugin2 = {name: '@shopify/plugin-did-you-mean', version: '1.0.0', root: tmpDir} as any + const mockPlugin3 = {name: 'some-other-plugin', version: '1.0.0', root: tmpDir} as any capturedPlugins = new Map([ ['@shopify/plugin-ngrok', mockPlugin1], @@ -728,13 +731,13 @@ describe('removeDuplicatedPlugins', () => { name: '@shopify/app', version: '1.0.0', type: 'core', - root: '/path/to/app', + root: tmpDir, } as any const mockPluginTheme = { name: '@shopify/plugin-ngrok', version: '2.0.0', type: 'user', - root: '/path/to/theme', + root: tmpDir, } as any capturedPlugins = new Map([ @@ -752,7 +755,7 @@ describe('removeDuplicatedPlugins', () => { expect(remainingPlugin).toEqual(mockPluginTheme) expect(remainingPlugin.version).toBe('2.0.0') expect(remainingPlugin.type).toBe('user') - expect(remainingPlugin.root).toBe('/path/to/theme') + expect(remainingPlugin.root).toBe(tmpDir) // Verify warning was shown expect(outputMock.output()).toMatch(/Unsupported plugins detected.*@shopify\/app/s) diff --git a/packages/cli-kit/src/public/node/context/local.ts b/packages/cli-kit/src/public/node/context/local.ts index 6ab4755a4a5..3dc773be3a9 100644 --- a/packages/cli-kit/src/public/node/context/local.ts +++ b/packages/cli-kit/src/public/node/context/local.ts @@ -292,4 +292,14 @@ export function opentelemetryDomain(env = process.env): string { return isSet(domain) ? domain : 'https://otlp-http-production-cli.shopifysvc.com' } +/** + * Returns true if the CLIshould not automatically upgrade. + * + * @param env - The environment variables from the environment of the current process. + * @returns True if the CLI should not automatically upgrade. + */ +export function noAutoUpgrade(env = process.env): boolean { + return isTruthy(env[environmentVariables.noAutoUpgrade]) +} + export type CIMetadata = Metadata diff --git a/packages/cli-kit/src/public/node/error-handler.ts b/packages/cli-kit/src/public/node/error-handler.ts index 29803ec26ca..fe987e6ee01 100644 --- a/packages/cli-kit/src/public/node/error-handler.ts +++ b/packages/cli-kit/src/public/node/error-handler.ts @@ -10,11 +10,11 @@ import { handler, cleanSingleStackTracePath, } from './error.js' +import {cliVersion} from './version.js' import {isLocalEnvironment} from '../../private/node/context/service.js' import {getEnvironmentData} from '../../private/node/analytics.js' import {outputDebug, outputInfo} from '../../public/node/output.js' import {bugsnagApiKey, reportingRateLimit} from '../../private/node/constants.js' -import {CLI_KIT_VERSION} from '../common/version.js' import {runWithRateLimit} from '../../private/node/conf-store.js' import {getLastSeenUserIdAfterAuth} from '../../private/node/session.js' import {settings, Interfaces} from '@oclif/core' @@ -294,7 +294,7 @@ function initializeBugsnag() { appType: 'node', apiKey: bugsnagApiKey, logger: null, - appVersion: CLI_KIT_VERSION, + appVersion: cliVersion(), autoTrackSessions: false, autoDetectErrors: false, enabledReleaseStages: ['production'], diff --git a/packages/cli-kit/src/public/node/fs.ts b/packages/cli-kit/src/public/node/fs.ts index 09c16a4ce35..b6eafec53f6 100644 --- a/packages/cli-kit/src/public/node/fs.ts +++ b/packages/cli-kit/src/public/node/fs.ts @@ -15,7 +15,7 @@ import { import {temporaryDirectory, temporaryDirectoryTask} from 'tempy' import {sep, join} from 'pathe' -import {findUp as internalFindUp} from 'find-up' +import {findUp as internalFindUp, findUpSync as internalFindUpSync} from 'find-up' import {minimatch} from 'minimatch' import fastGlobLib from 'fast-glob' import { @@ -650,6 +650,23 @@ export async function findPathUp( return got ? normalizePath(got) : undefined } +/** + * Find a file by walking parent directories. + * + * @param matcher - A pattern or an array of patterns to match a file name. + * @param options - Options for the search. + * @returns The first path found that matches or `undefined` if none could be found. + */ +export function findPathUpSync( + matcher: OverloadParameters[0], + options: OverloadParameters[1], +): ReturnType { + // findUp has odd typing + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const got = internalFindUpSync(matcher as any, options) + return got ? normalizePath(got) : undefined +} + export interface MatchGlobOptions { matchBase: boolean noglobstar: boolean diff --git a/packages/cli-kit/src/public/node/hooks/postrun.test.ts b/packages/cli-kit/src/public/node/hooks/postrun.test.ts new file mode 100644 index 00000000000..015b39ebe0e --- /dev/null +++ b/packages/cli-kit/src/public/node/hooks/postrun.test.ts @@ -0,0 +1,58 @@ +import {autoUpgradeIfNeeded} from './postrun.js' +import {mockAndCaptureOutput} from '../testing/output.js' +import {getOutputUpdateCLIReminder, runCLIUpgrade, versionToAutoUpgrade} from '../upgrade.js' +import {describe, expect, test, vi, afterEach} from 'vitest' + +vi.mock('../upgrade.js', async (importOriginal) => { + const actual: any = await importOriginal() + return { + ...actual, + runCLIUpgrade: vi.fn(), + getOutputUpdateCLIReminder: vi.fn(), + versionToAutoUpgrade: vi.fn(), + } +}) + +afterEach(() => { + mockAndCaptureOutput().clear() +}) + +describe('autoUpgradeIfNeeded', () => { + test('runs the upgrade when versionToAutoUpgrade returns a version', async () => { + // Given + vi.mocked(versionToAutoUpgrade).mockReturnValue('3.91.0') + vi.mocked(runCLIUpgrade).mockResolvedValue() + + // When + await autoUpgradeIfNeeded() + + // Then + expect(runCLIUpgrade).toHaveBeenCalled() + }) + + test('falls back to warning when the upgrade fails', async () => { + // Given + const outputMock = mockAndCaptureOutput() + vi.mocked(versionToAutoUpgrade).mockReturnValue('3.91.0') + vi.mocked(runCLIUpgrade).mockRejectedValue(new Error('upgrade failed')) + const installReminder = '💡 Version 3.91.0 available! Run `npm install @shopify/cli@latest`' + vi.mocked(getOutputUpdateCLIReminder).mockReturnValue(installReminder) + + // When + await autoUpgradeIfNeeded() + + // Then + expect(outputMock.warn()).toMatch(installReminder) + }) + + test('does nothing when versionToAutoUpgrade returns undefined', async () => { + // Given + vi.mocked(versionToAutoUpgrade).mockReturnValue(undefined) + + // When + await autoUpgradeIfNeeded() + + // Then + expect(runCLIUpgrade).not.toHaveBeenCalled() + }) +}) diff --git a/packages/cli-kit/src/public/node/hooks/postrun.ts b/packages/cli-kit/src/public/node/hooks/postrun.ts index 6c67eb858df..7398f975a20 100644 --- a/packages/cli-kit/src/public/node/hooks/postrun.ts +++ b/packages/cli-kit/src/public/node/hooks/postrun.ts @@ -1,8 +1,10 @@ import {postrun as deprecationsHook} from './deprecations.js' import {reportAnalyticsEvent} from '../analytics.js' -import {outputDebug} from '../../../public/node/output.js' +import {outputDebug, outputWarn} from '../../../public/node/output.js' +import {getOutputUpdateCLIReminder, runCLIUpgrade, versionToAutoUpgrade} from '../../../public/node/upgrade.js' import BaseCommand from '../base-command.js' import * as metadata from '../../../public/node/metadata.js' +import {cliVersion, isMajorVersionChange} from '../version.js' import {Command, Hook} from '@oclif/core' let postRunHookCompleted = false @@ -25,6 +27,33 @@ export const hook: Hook.Postrun = async ({config, Command}) => { const command = Command.id.replace(/:/g, ' ') outputDebug(`Completed command ${command}`) postRunHookCompleted = true + + if (!command.includes('notifications')) await autoUpgradeIfNeeded() +} + +/** + * Auto-upgrades the CLI after a command completes, if a newer version is available. + * + * @returns Resolves when the upgrade attempt (or fallback warning) is complete. + */ +export async function autoUpgradeIfNeeded(): Promise { + const newerVersion = versionToAutoUpgrade() + if (!newerVersion) return + if (isMajorVersionChange(cliVersion(), newerVersion)) { + return outputWarn(getOutputUpdateCLIReminder(newerVersion)) + } + + try { + await runCLIUpgrade() + // eslint-disable-next-line no-catch-all/no-catch-all + } catch (error) { + const errorMessage = `Auto-upgrade failed: ${error}` + outputDebug(errorMessage) + outputWarn(getOutputUpdateCLIReminder(newerVersion)) + // Report to Observe as a handled error without showing anything extra to the user + const {sendErrorToBugsnag} = await import('../../../public/node/error-handler.js') + await sendErrorToBugsnag(new Error(errorMessage), 'expected_error') + } } /** diff --git a/packages/cli-kit/src/public/node/hooks/prerun.test.ts b/packages/cli-kit/src/public/node/hooks/prerun.test.ts index d242ddacfbc..7a76a822187 100644 --- a/packages/cli-kit/src/public/node/hooks/prerun.test.ts +++ b/packages/cli-kit/src/public/node/hooks/prerun.test.ts @@ -1,47 +1,5 @@ -import {parseCommandContent, warnOnAvailableUpgrade} from './prerun.js' -import {checkForCachedNewVersion, packageManagerFromUserAgent} from '../node-package-manager.js' -import {cacheClear} from '../../../private/node/conf-store.js' -import {mockAndCaptureOutput} from '../testing/output.js' -import {describe, expect, test, vi, afterEach, beforeEach} from 'vitest' - -vi.mock('../node-package-manager') - -beforeEach(() => { - cacheClear() -}) - -afterEach(() => { - mockAndCaptureOutput().clear() - cacheClear() -}) - -describe('warnOnAvailableUpgrade', () => { - test('displays latest version and an install command when a newer exists', async () => { - // Given - const outputMock = mockAndCaptureOutput() - vi.mocked(checkForCachedNewVersion).mockReturnValue('3.0.10') - vi.mocked(packageManagerFromUserAgent).mockReturnValue('npm') - const installReminder = '💡 Version 3.0.10 available! Run `npm install @shopify/cli@latest`' - - // When - await warnOnAvailableUpgrade() - - // Then - expect(outputMock.warn()).toMatch(installReminder) - }) - - test('displays nothing when no newer version exists', async () => { - // Given - const outputMock = mockAndCaptureOutput() - vi.mocked(checkForCachedNewVersion).mockReturnValue(undefined) - - // When - await warnOnAvailableUpgrade() - - // Then - expect(outputMock.warn()).toEqual('') - }) -}) +import {parseCommandContent} from './prerun.js' +import {describe, expect, test} from 'vitest' describe('parseCommandContent', () => { test('when a create command is used should return the correct command content', async () => { diff --git a/packages/cli-kit/src/public/node/hooks/prerun.ts b/packages/cli-kit/src/public/node/hooks/prerun.ts index 298df53a913..b6ea853ba70 100644 --- a/packages/cli-kit/src/public/node/hooks/prerun.ts +++ b/packages/cli-kit/src/public/node/hooks/prerun.ts @@ -1,12 +1,9 @@ -import {CLI_KIT_VERSION} from '../../common/version.js' -import {checkForNewVersion, checkForCachedNewVersion} from '../node-package-manager.js' +import {cliVersion, isPreReleaseVersion} from '../version.js' +import {checkForNewVersion} from '../node-package-manager.js' import {startAnalytics} from '../../../private/node/analytics.js' -import {outputDebug, outputWarn} from '../../../public/node/output.js' -import {getOutputUpdateCLIReminder} from '../../../public/node/upgrade.js' +import {outputDebug} from '../../../public/node/output.js' import Command from '../../../public/node/base-command.js' -import {runAtMinimumInterval} from '../../../private/node/conf-store.js' import {fetchNotificationsInBackground} from '../notifications-system.js' -import {isPreReleaseVersion} from '../version.js' import {Hook} from '@oclif/core' export declare interface CommandContent { @@ -22,7 +19,7 @@ export const hook: Hook.Prerun = async (options) => { pluginAlias: options.Command.plugin?.alias, }) const args = options.argv - await warnOnAvailableUpgrade() + checkForNewVersionInBackground() outputDebug(`Running command ${commandContent.command}`) await startAnalytics({commandContent, args, commandClass: options.Command as unknown as typeof Command}) fetchNotificationsInBackground(options.Command.id) @@ -89,25 +86,14 @@ function findAlias(aliases: string[]) { } /** - * Warns the user if there is a new version of the CLI available + * Triggers a background check for a newer CLI version (non-blocking). + * The result is cached and consumed by the postrun hook for auto-upgrade. */ -export async function warnOnAvailableUpgrade(): Promise { - const cliDependency = '@shopify/cli' - const currentVersion = CLI_KIT_VERSION +export function checkForNewVersionInBackground(): void { + const currentVersion = cliVersion() if (isPreReleaseVersion(currentVersion)) { - // This is a nightly/snapshot/experimental version, so we don't want to check for updates return } - - // Check in the background, once daily // eslint-disable-next-line no-void - void checkForNewVersion(cliDependency, currentVersion, {cacheExpiryInHours: 24}) - - // Warn if we previously found a new version - await runAtMinimumInterval('warn-on-available-upgrade', {days: 1}, async () => { - const newerVersion = checkForCachedNewVersion(cliDependency, currentVersion) - if (newerVersion) { - outputWarn(getOutputUpdateCLIReminder(newerVersion)) - } - }) + void checkForNewVersion('@shopify/cli', currentVersion, {cacheExpiryInHours: 24}) } diff --git a/packages/cli-kit/src/public/node/is-global.test.ts b/packages/cli-kit/src/public/node/is-global.test.ts index 75e6c82e283..dfba905f137 100644 --- a/packages/cli-kit/src/public/node/is-global.test.ts +++ b/packages/cli-kit/src/public/node/is-global.test.ts @@ -1,24 +1,62 @@ import {currentProcessIsGlobal, inferPackageManagerForGlobalCLI, installGlobalCLIPrompt} from './is-global.js' +import {findPathUpSync} from './fs.js' +import {cwd} from './path.js' import {terminalSupportsPrompting} from './system.js' import {renderSelectPrompt} from './ui.js' import {globalCLIVersion} from './version.js' -import * as execa from 'execa' import {beforeEach, describe, expect, test, vi} from 'vitest' +import {realpathSync} from 'fs' vi.mock('./system.js') vi.mock('./ui.js') -vi.mock('execa') vi.mock('which') vi.mock('./version.js') +// Mock fs.js to make findPathUpSync controllable for getProjectDir. +// find-up v6 runs returned paths through locatePathSync which checks file existence, +// so we need to mock findPathUpSync directly rather than globSync. +vi.mock('./fs.js', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + findPathUpSync: vi.fn((...args: Parameters) => actual.findPathUpSync(...args)), + } +}) + +// Mock fs.realpathSync at the module level +// By default, call through to the real implementation for real paths, +// but return the path as-is for fake test paths that don't exist +vi.mock('fs', async (importOriginal) => { + const actual = await importOriginal() + const realRealpathSync = actual.realpathSync + const {existsSync} = actual + return { + ...actual, + realpathSync: vi.fn((path, options) => { + // For real paths, use the actual implementation + // For fake test paths, just return the path as-is + if (existsSync(String(path))) { + return realRealpathSync(path, options) + } + return String(path) + }), + } +}) + const globalNPMPath = '/path/to/global/npm' const globalYarnPath = '/path/to/global/yarn' const globalPNPMPath = '/path/to/global/pnpm' +const globalHomebrewIntel = '/usr/local/Cellar/shopify-cli/3.89.0/bin/shopify' +const globalHomebrewAppleSilicon = '/opt/homebrew/Cellar/shopify-cli/3.89.0/bin/shopify' +const globalHomebrewLinux = '/home/linuxbrew/.linuxbrew/Cellar/shopify-cli/3.89.0/bin/shopify' const unknownGlobalPath = '/path/to/global/unknown' -const localProjectPath = '/path/local' +// Must be within the actual workspace so currentProcessIsGlobal recognizes it as local +const localProjectPath = `${cwd()}/node_modules/.bin/shopify` beforeEach(() => { - ;(vi.mocked(execa.execaSync) as any).mockReturnValue({stdout: localProjectPath}) + // Mock findPathUpSync so getProjectDir returns a shopify.app.toml at the cwd. + // This lets currentProcessIsGlobal compare binary paths against the project root. + vi.mocked(findPathUpSync).mockReturnValue(`${cwd()}/shopify.app.toml`) }) describe('currentProcessIsGlobal', () => { @@ -46,6 +84,11 @@ describe('currentProcessIsGlobal', () => { }) describe('inferPackageManagerForGlobalCLI', () => { + beforeEach(() => { + // Reset mock to default behavior (calls through to real implementation) + vi.mocked(realpathSync).mockClear() + }) + test('returns yarn if yarn is in path', async () => { // Given const argv = ['node', globalYarnPath, 'shopify'] @@ -89,6 +132,115 @@ describe('inferPackageManagerForGlobalCLI', () => { // Then expect(got).toBe('unknown') }) + + test('returns homebrew if SHOPIFY_HOMEBREW_FORMULA is set', async () => { + // Given + const argv = ['node', globalHomebrewAppleSilicon, 'shopify'] + const env = {SHOPIFY_HOMEBREW_FORMULA: 'shopify-cli'} + + // When + const got = inferPackageManagerForGlobalCLI(argv, env) + + // Then + expect(got).toBe('homebrew') + }) + + test('returns homebrew for Intel Mac Cellar path', async () => { + // Given + const argv = ['node', globalHomebrewIntel, 'shopify'] + + // When + const got = inferPackageManagerForGlobalCLI(argv) + + // Then + expect(got).toBe('homebrew') + }) + + test('returns homebrew for Apple Silicon Cellar path', async () => { + // Given + const argv = ['node', globalHomebrewAppleSilicon, 'shopify'] + + // When + const got = inferPackageManagerForGlobalCLI(argv) + + // Then + expect(got).toBe('homebrew') + }) + + test('returns homebrew for Linux Homebrew path', async () => { + // Given + const argv = ['node', globalHomebrewLinux, 'shopify'] + + // When + const got = inferPackageManagerForGlobalCLI(argv) + + // Then + expect(got).toBe('homebrew') + }) + + test('returns homebrew when HOMEBREW_PREFIX matches path', async () => { + // Given + const argv = ['node', '/opt/homebrew/bin/shopify', 'shopify'] + const env = {HOMEBREW_PREFIX: '/opt/homebrew'} + + // When + const got = inferPackageManagerForGlobalCLI(argv, env) + + // Then + expect(got).toBe('homebrew') + }) + + test('resolves symlinks to detect actual package manager (yarn)', async () => { + // Given: A symlink in /opt/homebrew/bin pointing to yarn global + const symlinkPath = '/opt/homebrew/bin/shopify' + const realYarnPath = '/Users/user/.config/yarn/global/node_modules/.bin/shopify' + const argv = ['node', symlinkPath, 'shopify'] + const env = {HOMEBREW_PREFIX: '/opt/homebrew'} + + // Mock realpathSync for this specific test to resolve the symlink + vi.mocked(realpathSync).mockImplementationOnce(() => realYarnPath) + + // When + const got = inferPackageManagerForGlobalCLI(argv, env) + + // Then: Should detect yarn (from real path), not homebrew (from symlink) + expect(got).toBe('yarn') + expect(vi.mocked(realpathSync)).toHaveBeenCalledWith(symlinkPath) + }) + + test('resolves symlinks to detect real homebrew installation', async () => { + // Given: A symlink in /opt/homebrew/bin pointing to a Cellar path (real Homebrew) + const symlinkPath = '/opt/homebrew/bin/shopify' + const realHomebrewPath = '/opt/homebrew/Cellar/shopify-cli/3.89.0/bin/shopify' + const argv = ['node', symlinkPath, 'shopify'] + + // Mock realpathSync for this specific test to resolve the symlink + vi.mocked(realpathSync).mockImplementationOnce(() => realHomebrewPath) + + // When + const got = inferPackageManagerForGlobalCLI(argv) + + // Then: Should still detect homebrew from the real Cellar path + expect(got).toBe('homebrew') + }) + + test('falls back to original path if realpath fails', async () => { + // Given: A path that realpathSync cannot resolve + const nonExistentPath = '/opt/homebrew/bin/shopify' + const argv = ['node', nonExistentPath, 'shopify'] + const env = {HOMEBREW_PREFIX: '/opt/homebrew'} + + // Mock realpathSync for this specific test to throw an error + vi.mocked(realpathSync).mockImplementationOnce(() => { + throw new Error('ENOENT: no such file or directory') + }) + + // When + const got = inferPackageManagerForGlobalCLI(argv, env) + + // Then: Should fall back to checking the original path + expect(got).toBe('homebrew') + }) }) describe('installGlobalCLIPrompt', () => { diff --git a/packages/cli-kit/src/public/node/is-global.ts b/packages/cli-kit/src/public/node/is-global.ts index 5b2bb02ef1e..8827480f509 100644 --- a/packages/cli-kit/src/public/node/is-global.ts +++ b/packages/cli-kit/src/public/node/is-global.ts @@ -1,11 +1,12 @@ import {PackageManager} from './node-package-manager.js' import {outputInfo} from './output.js' -import {cwd, sniffForPath} from './path.js' +import {cwd, dirname, joinPath, sniffForPath} from './path.js' import {exec, terminalSupportsPrompting} from './system.js' import {renderSelectPrompt} from './ui.js' import {globalCLIVersion} from './version.js' import {isUnitTest} from './context/local.js' -import {execaSync} from 'execa' +import {findPathUpSync, globSync} from './fs.js' +import {realpathSync} from 'fs' let _isGlobal: boolean | undefined @@ -23,15 +24,16 @@ export function currentProcessIsGlobal(argv = process.argv): boolean { // Path where the current project is (app/hydrogen) const path = sniffForPath() ?? cwd() - // Closest parent directory to contain a package.json file or node_modules directory - // https://docs.npmjs.com/cli/v8/commands/npm-prefix#description - const npmPrefix = execaSync('npm', ['prefix'], {cwd: path}).stdout.trim() + const projectDir = getProjectDir(path) + if (!projectDir) { + return true + } // From node docs: "The second element [of the array] will be the path to the JavaScript file being executed" const binDir = argv[1] ?? '' - // If binDir starts with npmPrefix, then we are running a local CLI - const isLocal = binDir.startsWith(npmPrefix.trim()) + // If binDir starts with packageJsonPath, then we are running a local CLI + const isLocal = binDir.startsWith(projectDir.trim()) _isGlobal = !isLocal return _isGlobal @@ -82,15 +84,68 @@ export async function installGlobalCLIPrompt(): Promise { + const configPaths = globSync(configFiles.map((file) => joinPath(directory, file))) + return configPaths.length > 0 ? configPaths[0] : undefined + } + try { + const configFile = findPathUpSync(existsConfigFile, { + cwd: directory, + type: 'file', + }) + if (configFile) return dirname(configFile) + // eslint-disable-next-line no-catch-all/no-catch-all + } catch (error) { + return undefined + } +} diff --git a/packages/cli-kit/src/public/node/multiple-installation-warning.ts b/packages/cli-kit/src/public/node/multiple-installation-warning.ts index 4e6e72e345e..4c83d357874 100644 --- a/packages/cli-kit/src/public/node/multiple-installation-warning.ts +++ b/packages/cli-kit/src/public/node/multiple-installation-warning.ts @@ -1,8 +1,7 @@ import {jsonOutputEnabled} from './environment.js' import {currentProcessIsGlobal} from './is-global.js' import {renderInfo} from './ui.js' -import {globalCLIVersion, localCLIVersion} from './version.js' -import {CLI_KIT_VERSION} from '../common/version.js' +import {cliVersion, globalCLIVersion, localCLIVersion} from './version.js' import {runAtMinimumInterval} from '../../private/node/conf-store.js' /** @@ -24,12 +23,12 @@ export async function showMultipleCLIWarningIfNeeded( // If running globally, use the current CLI version, otherwise fetch the global CLI version // Exit early if we can't get the global version - const globalVersion = isGlobal ? CLI_KIT_VERSION : await globalCLIVersion() + const globalVersion = isGlobal ? cliVersion() : await globalCLIVersion() if (!globalVersion) return // If running globally, fetch the local version from npm list, otherwise use current CLI version // Exit early if we can't get the local version - const localVersion = isGlobal ? await localCLIVersion(directory) : CLI_KIT_VERSION + const localVersion = isGlobal ? await localCLIVersion(directory) : cliVersion() if (!localVersion) return const currentInstallation = isGlobal ? 'global installation' : 'local dependency' diff --git a/packages/cli-kit/src/public/node/node-package-manager.ts b/packages/cli-kit/src/public/node/node-package-manager.ts index 5aa52ef72aa..fba6b25b465 100644 --- a/packages/cli-kit/src/public/node/node-package-manager.ts +++ b/packages/cli-kit/src/public/node/node-package-manager.ts @@ -35,6 +35,7 @@ export const lockfilesByManager: {[key in PackageManager]: Lockfile | undefined} npm: npmLockfile, pnpm: pnpmLockfile, bun: bunLockfile, + homebrew: undefined, unknown: undefined, } export type Lockfile = 'yarn.lock' | 'package-lock.json' | 'pnpm-lock.yaml' | 'bun.lockb' @@ -50,7 +51,7 @@ export type DependencyType = 'dev' | 'prod' | 'peer' /** * A union that represents the package managers available. */ -export const packageManager = ['yarn', 'npm', 'pnpm', 'bun', 'unknown'] as const +export const packageManager = ['yarn', 'npm', 'pnpm', 'bun', 'homebrew', 'unknown'] as const export type PackageManager = (typeof packageManager)[number] /** @@ -526,6 +527,8 @@ export async function addNPMDependencies( await installDependencies(options, argumentsToAddDependenciesWithBun(dependenciesWithVersion, options.type)) await installDependencies(options, ['install']) break + case 'homebrew': + throw new AbortError("Homebrew can't be used to install project dependencies. Use npm, yarn, pnpm, or bun.") case 'unknown': throw new UnknownPackageManagerError() } diff --git a/packages/cli-kit/src/public/node/notifications-system.ts b/packages/cli-kit/src/public/node/notifications-system.ts index d9eade1f1cb..29ed5f89ea5 100644 --- a/packages/cli-kit/src/public/node/notifications-system.ts +++ b/packages/cli-kit/src/public/node/notifications-system.ts @@ -8,7 +8,7 @@ import {isTruthy} from './context/utilities.js' import {exec} from './system.js' import {jsonOutputEnabled} from './environment.js' import {fetch} from './http.js' -import {CLI_KIT_VERSION} from '../common/version.js' +import {cliVersion} from './version.js' import {NotificationKey, NotificationsKey, cacheRetrieve, cacheStore} from '../../private/node/conf-store.js' const URL = 'https://cdn.shopify.com/static/cli/notifications.json' @@ -211,7 +211,7 @@ export function filterNotifications( commandId: string, currentSurfaces?: string[], today: Date = new Date(new Date().setUTCHours(0, 0, 0, 0)), - currentVersion: string = CLI_KIT_VERSION, + currentVersion: string = cliVersion(), ): Notification[] { return notifications .filter((notification) => filterByVersion(notification, currentVersion)) diff --git a/packages/cli-kit/src/public/node/output.ts b/packages/cli-kit/src/public/node/output.ts index db95c2ebd14..9bf1043d4ed 100644 --- a/packages/cli-kit/src/public/node/output.ts +++ b/packages/cli-kit/src/public/node/output.ts @@ -124,6 +124,7 @@ export function formatPackageManagerCommand( } return pieces.join(' ') } + case 'homebrew': case 'unknown': { const pieces = [scriptName, ...scriptArgs] return pieces.join(' ') diff --git a/packages/cli-kit/src/public/node/plugins/multiple-installation-warning.test.ts b/packages/cli-kit/src/public/node/plugins/multiple-installation-warning.test.ts index aaa8d3c4e7c..8ba4ed77bd4 100644 --- a/packages/cli-kit/src/public/node/plugins/multiple-installation-warning.test.ts +++ b/packages/cli-kit/src/public/node/plugins/multiple-installation-warning.test.ts @@ -2,8 +2,7 @@ import {clearCache} from '../cli.js' import {currentProcessIsGlobal} from '../is-global.js' import {showMultipleCLIWarningIfNeeded} from '../multiple-installation-warning.js' import {mockAndCaptureOutput} from '../testing/output.js' -import {globalCLIVersion, localCLIVersion} from '../version.js' -import {CLI_KIT_VERSION} from '../../common/version.js' +import {cliVersion, globalCLIVersion, localCLIVersion} from '../version.js' import {describe, beforeEach, test, vi, expect} from 'vitest' vi.mock('../version.js') @@ -12,12 +11,13 @@ vi.mock('../is-global.js') describe('showMultipleCLIWarningIfNeeded', () => { beforeEach(() => { clearCache() + vi.mocked(cliVersion).mockReturnValue('3.80.0') }) test('shows warning if using global CLI but app has local dependency', async () => { // Given vi.mocked(currentProcessIsGlobal).mockReturnValue(true) - vi.mocked(globalCLIVersion).mockResolvedValue(CLI_KIT_VERSION) + vi.mocked(globalCLIVersion).mockResolvedValue(cliVersion()) vi.mocked(localCLIVersion).mockResolvedValue('3.70.0') const mockOutput = mockAndCaptureOutput() @@ -30,7 +30,7 @@ describe('showMultipleCLIWarningIfNeeded', () => { │ │ │ Two Shopify CLI installations found – using global installation │ │ │ - │ A global installation (v${CLI_KIT_VERSION}) and a local dependency (v3.70.0) were │ + │ A global installation (v${cliVersion()}) and a local dependency (v3.70.0) were │ │ detected. │ │ We recommend removing the @shopify/cli and @shopify/app dependencies from │ │ your package.json, unless you want to use different versions across │ @@ -50,11 +50,11 @@ describe('showMultipleCLIWarningIfNeeded', () => { // Given vi.mocked(currentProcessIsGlobal).mockReturnValue(false) vi.mocked(globalCLIVersion).mockResolvedValue('3.70.0') - vi.mocked(localCLIVersion).mockResolvedValue(CLI_KIT_VERSION) + vi.mocked(localCLIVersion).mockResolvedValue(cliVersion()) const mockOutput = mockAndCaptureOutput() // When - await showMultipleCLIWarningIfNeeded('path', {'@shopify/cli': CLI_KIT_VERSION}) + await showMultipleCLIWarningIfNeeded('path', {'@shopify/cli': cliVersion()}) // Then expect(mockOutput.info()).toMatchInlineSnapshot(` @@ -62,7 +62,7 @@ describe('showMultipleCLIWarningIfNeeded', () => { │ │ │ Two Shopify CLI installations found – using local dependency │ │ │ - │ A global installation (v3.70.0) and a local dependency (v${CLI_KIT_VERSION}) were │ + │ A global installation (v3.70.0) and a local dependency (v${cliVersion()}) were │ │ detected. │ │ We recommend removing the @shopify/cli and @shopify/app dependencies from │ │ your package.json, unless you want to use different versions across │ @@ -81,7 +81,7 @@ describe('showMultipleCLIWarningIfNeeded', () => { test('does not show two consecutive warnings', async () => { // Given vi.mocked(currentProcessIsGlobal).mockReturnValue(true) - vi.mocked(globalCLIVersion).mockResolvedValue(CLI_KIT_VERSION) + vi.mocked(globalCLIVersion).mockResolvedValue(cliVersion()) vi.mocked(localCLIVersion).mockResolvedValue('3.70.0') const mockOutput = mockAndCaptureOutput() @@ -95,7 +95,7 @@ describe('showMultipleCLIWarningIfNeeded', () => { │ │ │ Two Shopify CLI installations found – using global installation │ │ │ - │ A global installation (v${CLI_KIT_VERSION}) and a local dependency (v3.70.0) were │ + │ A global installation (v${cliVersion()}) and a local dependency (v3.70.0) were │ │ detected. │ │ We recommend removing the @shopify/cli and @shopify/app dependencies from │ │ your package.json, unless you want to use different versions across │ @@ -113,7 +113,7 @@ describe('showMultipleCLIWarningIfNeeded', () => { test('does not show a warning if there is no local dependency', async () => { // Given vi.mocked(currentProcessIsGlobal).mockReturnValue(true) - vi.mocked(globalCLIVersion).mockResolvedValue(CLI_KIT_VERSION) + vi.mocked(globalCLIVersion).mockResolvedValue(cliVersion()) vi.mocked(localCLIVersion).mockResolvedValue(undefined) const mockOutput = mockAndCaptureOutput() @@ -129,11 +129,11 @@ describe('showMultipleCLIWarningIfNeeded', () => { // Given vi.mocked(currentProcessIsGlobal).mockReturnValue(false) vi.mocked(globalCLIVersion).mockResolvedValue(undefined) - vi.mocked(localCLIVersion).mockResolvedValue(CLI_KIT_VERSION) + vi.mocked(localCLIVersion).mockResolvedValue(cliVersion()) const mockOutput = mockAndCaptureOutput() // When - await showMultipleCLIWarningIfNeeded('path', {'@shopify/cli': CLI_KIT_VERSION}) + await showMultipleCLIWarningIfNeeded('path', {'@shopify/cli': cliVersion()}) // Then expect(mockOutput.warn()).toBe('') diff --git a/packages/cli-kit/src/public/node/upgrade.test.ts b/packages/cli-kit/src/public/node/upgrade.test.ts index 7e77af98534..ae1659f6382 100644 --- a/packages/cli-kit/src/public/node/upgrade.test.ts +++ b/packages/cli-kit/src/public/node/upgrade.test.ts @@ -1,17 +1,33 @@ +import {noAutoUpgrade, isDevelopment} from './context/local.js' import {currentProcessIsGlobal, inferPackageManagerForGlobalCLI} from './is-global.js' -import {packageManagerFromUserAgent} from './node-package-manager.js' -import {cliInstallCommand} from './upgrade.js' -import {vi, describe, test, expect} from 'vitest' +import {checkForCachedNewVersion, packageManagerFromUserAgent, PackageManager} from './node-package-manager.js' +import {exec, isCI} from './system.js' +import {cliInstallCommand, runCLIUpgrade, versionToAutoUpgrade} from './upgrade.js' +import {isPreReleaseVersion} from './version.js' +import {vi, describe, test, expect, beforeEach} from 'vitest' +vi.mock('./context/local.js') vi.mock('./is-global.js') vi.mock('./node-package-manager.js') +vi.mock('./system.js') +vi.mock('./version.js', async (importOriginal) => { + const actual: any = await importOriginal() + return { + ...actual, + isPreReleaseVersion: vi.fn(() => false), + } +}) describe('cliInstallCommand', () => { + beforeEach(() => { + // Mock isDevelopment to return false by default (not in CLI development mode) + vi.mocked(isDevelopment).mockReturnValue(false) + }) + test('says to install globally via npm if the current process is globally installed and no package manager is provided', () => { // Given vi.mocked(currentProcessIsGlobal).mockReturnValue(true) - vi.mocked(packageManagerFromUserAgent).mockReturnValue('unknown') - vi.mocked(inferPackageManagerForGlobalCLI).mockReturnValue('unknown') + vi.mocked(inferPackageManagerForGlobalCLI).mockReturnValue('npm') // When const got = cliInstallCommand() @@ -67,63 +83,133 @@ describe('cliInstallCommand', () => { `) }) - test('says to install locally via npm if the current process is locally installed and no package manager is provided', () => { + test('returns undefined if the current process is locally installed', () => { // Given vi.mocked(currentProcessIsGlobal).mockReturnValue(false) - vi.mocked(packageManagerFromUserAgent).mockReturnValue('unknown') - vi.mocked(inferPackageManagerForGlobalCLI).mockReturnValue('unknown') // When const got = cliInstallCommand() // Then - expect(got).toMatchInlineSnapshot(` - "npm install @shopify/cli@latest" - `) + expect(got).toBeUndefined() }) - test('says to install locally via yarn if the current process is locally installed and yarn is the global package manager', () => { + test('returns undefined if running in development mode (SHOPIFY_ENV=development)', () => { // Given - vi.mocked(currentProcessIsGlobal).mockReturnValue(false) - vi.mocked(packageManagerFromUserAgent).mockReturnValue('unknown') - vi.mocked(inferPackageManagerForGlobalCLI).mockReturnValue('yarn') + vi.mocked(currentProcessIsGlobal).mockReturnValue(true) + vi.mocked(isDevelopment).mockReturnValue(true) + vi.mocked(inferPackageManagerForGlobalCLI).mockReturnValue('npm') // When const got = cliInstallCommand() // Then - expect(got).toMatchInlineSnapshot(` - "yarn add @shopify/cli@latest" - `) + expect(got).toBeUndefined() + }) +}) +describe('runCLIUpgrade', () => { + beforeEach(() => { + // Mock isDevelopment to return false by default (not in CLI development mode) + vi.mocked(isDevelopment).mockReturnValue(false) }) - test('says to install locally via npm if the current process is locally installed and npm is the global package manager', () => { + test('runs the install command via exec for a global npm install', async () => { // Given - vi.mocked(currentProcessIsGlobal).mockReturnValue(false) - vi.mocked(packageManagerFromUserAgent).mockReturnValue('unknown') + vi.mocked(currentProcessIsGlobal).mockReturnValue(true) vi.mocked(inferPackageManagerForGlobalCLI).mockReturnValue('npm') + vi.mocked(exec).mockResolvedValue() // When - const got = cliInstallCommand() + await runCLIUpgrade() // Then - expect(got).toMatchInlineSnapshot(` - "npm install @shopify/cli@latest" - `) + expect(exec).toHaveBeenCalledWith('npm', ['install', '-g', '@shopify/cli@latest'], {stdio: 'inherit'}) }) - test('says to install locally via pnpm if the current process is locally installed and pnpm is the global package manager', () => { + test('runs the install command via exec for a global yarn install', async () => { // Given - vi.mocked(currentProcessIsGlobal).mockReturnValue(false) - vi.mocked(packageManagerFromUserAgent).mockReturnValue('unknown') - vi.mocked(inferPackageManagerForGlobalCLI).mockReturnValue('pnpm') + vi.mocked(currentProcessIsGlobal).mockReturnValue(true) + vi.mocked(inferPackageManagerForGlobalCLI).mockReturnValue('yarn') + vi.mocked(exec).mockResolvedValue() // When - const got = cliInstallCommand() + await runCLIUpgrade() // Then - expect(got).toMatchInlineSnapshot(` - "pnpm add @shopify/cli@latest" - `) + expect(exec).toHaveBeenCalledWith('yarn', ['global', 'add', '@shopify/cli@latest'], {stdio: 'inherit'}) + }) + + test('runs the install command via exec for a global homebrew install', async () => { + // Given + vi.mocked(currentProcessIsGlobal).mockReturnValue(true) + vi.mocked(inferPackageManagerForGlobalCLI).mockReturnValue('homebrew') + vi.mocked(exec).mockResolvedValue() + + // When + await runCLIUpgrade() + + // Then + expect(exec).toHaveBeenCalledWith('brew', ['upgrade', 'shopify-cli'], {stdio: 'inherit'}) + }) + + test('throws an error when cliInstallCommand returns undefined', async () => { + // Given + vi.mocked(currentProcessIsGlobal).mockReturnValue(true) + // Force a falsy return so cliInstallCommand() returns undefined + vi.mocked(inferPackageManagerForGlobalCLI).mockReturnValue('' as unknown as PackageManager) + + // When/Then + await expect(runCLIUpgrade()).rejects.toThrow('Could not determine the package manager') + }) +}) + +describe('versionToAutoUpgrade', () => { + test('returns the newer version for a minor bump outside of CI', () => { + vi.mocked(checkForCachedNewVersion).mockReturnValue('3.91.0') + vi.mocked(isCI).mockReturnValue(false) + vi.mocked(noAutoUpgrade).mockReturnValue(false) + expect(versionToAutoUpgrade()).toBe('3.91.0') + }) + + test('returns the newer version for a patch bump outside of CI', () => { + vi.mocked(checkForCachedNewVersion).mockReturnValue('3.90.1') + vi.mocked(isCI).mockReturnValue(false) + vi.mocked(noAutoUpgrade).mockReturnValue(false) + expect(versionToAutoUpgrade()).toBe('3.90.1') + }) + + test('returns undefined when no cached newer version exists', () => { + vi.mocked(checkForCachedNewVersion).mockReturnValue(undefined) + expect(versionToAutoUpgrade()).toBeUndefined() + }) + + test('returns undefined when running in CI', () => { + vi.mocked(checkForCachedNewVersion).mockReturnValue('3.91.0') + vi.mocked(isCI).mockReturnValue(true) + vi.mocked(noAutoUpgrade).mockReturnValue(false) + expect(versionToAutoUpgrade()).toBeUndefined() + }) + + test('returns undefined when SHOPIFY_CLI_NO_AUTO_UPGRADE is set', () => { + vi.mocked(checkForCachedNewVersion).mockReturnValue('3.91.0') + vi.mocked(isCI).mockReturnValue(false) + vi.mocked(noAutoUpgrade).mockReturnValue(true) + expect(versionToAutoUpgrade()).toBeUndefined() + }) + + test('returns undefined for a major version change', () => { + vi.mocked(checkForCachedNewVersion).mockReturnValue('4.0.0') + vi.mocked(isCI).mockReturnValue(false) + vi.mocked(noAutoUpgrade).mockReturnValue(false) + expect(versionToAutoUpgrade()).toBeUndefined() + }) + + test('returns undefined for a pre-release (nightly/snapshot) version', () => { + vi.mocked(checkForCachedNewVersion).mockReturnValue('3.91.0') + vi.mocked(isCI).mockReturnValue(false) + vi.mocked(noAutoUpgrade).mockReturnValue(false) + vi.mocked(isPreReleaseVersion).mockReturnValue(true) + expect(versionToAutoUpgrade()).toBeUndefined() + vi.mocked(isPreReleaseVersion).mockReturnValue(false) }) }) diff --git a/packages/cli-kit/src/public/node/upgrade.ts b/packages/cli-kit/src/public/node/upgrade.ts index 32c4904134e..c3f4cc7338b 100644 --- a/packages/cli-kit/src/public/node/upgrade.ts +++ b/packages/cli-kit/src/public/node/upgrade.ts @@ -1,6 +1,20 @@ -import {currentProcessIsGlobal, inferPackageManagerForGlobalCLI} from './is-global.js' -import {packageManagerFromUserAgent} from './node-package-manager.js' -import {outputContent, outputToken} from './output.js' +import {isDevelopment, noAutoUpgrade} 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} from './output.js' +import {exec, isCI} from './system.js' +import {cliVersion, isPreReleaseVersion} from './version.js' +import {cwd, moduleDirectory, sniffForPath} from './path.js' +import {SemVer} from 'semver' /** * Utility function for generating an install command for the user to run @@ -8,22 +22,105 @@ import {outputContent, outputToken} from './output.js' * * @returns A string with the command to run. */ -export function cliInstallCommand(): string { +export function cliInstallCommand(): string | undefined { + const packageManager = inferPackageManagerForGlobalCLI() + if (!packageManager) 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` + } +} + +/** + * Runs the CLI upgrade using the appropriate package manager. + * Determines the install command and executes it. + * + * @throws AbortError if the package manager or command cannot be determined. + */ +export async function runCLIUpgrade(): Promise { + // 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() - let packageManager = packageManagerFromUserAgent() - // packageManagerFromUserAgent() will return 'unknown' if it can't determine the package manager - if (packageManager === 'unknown') { - packageManager = inferPackageManagerForGlobalCLI() + + // Don't auto-upgrade for development mode + if (!isGlobal && isDevelopment()) { + outputDebug('Auto-upgrade: Skipping auto-upgrade in development mode.') + return } - // inferPackageManagerForGlobalCLI() will also return 'unknown' if it can't determine the package manager - if (packageManager === 'unknown') packageManager = 'npm' - if (packageManager === 'yarn') { - return `${packageManager} ${isGlobal ? 'global ' : ''}add @shopify/cli@latest` + // 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`Auto-upgrading with: ${outputToken.genericShellCommand(installCommand)}...`) + await exec(command, args, {stdio: 'inherit'}) + } else if (projectDir) { + await upgradeLocalShopify(projectDir, cliVersion()) } else { - const verb = packageManager === 'pnpm' ? 'add' : 'install' - return `${packageManager} ${verb} ${isGlobal ? '-g ' : ''}@shopify/cli@latest` + 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. + * Checks for a cached newer version and skips for CI, pre-release versions, SHOPIFY_CLI_NO_AUTO_UPGRADE, or major version changes. + * + * @returns The version string to upgrade to, or undefined if no upgrade should happen. + */ +export function versionToAutoUpgrade(): string | undefined { + const currentVersion = cliVersion() + const newerVersion = checkForCachedNewVersion('@shopify/cli', currentVersion) + if (process.env.SHOPIFY_CLI_FORCE_AUTO_UPGRADE === '1') { + outputDebug('Auto-upgrade: Forcing auto-upgrade because of SHOPIFY_CLI_FORCE_AUTO_UPGRADE.') + return '3.90.1' + } + if (!newerVersion) { + outputDebug('Auto-upgrade: No newer version available.') + 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 + } + if (noAutoUpgrade()) { + outputDebug('Auto-upgrade: Skipping auto-upgrade because of SHOPIFY_CLI_NO_AUTO_UPGRADE.') + return undefined + } + + // Don't auto-upgrade for major version changes + try { + const currentSemVer = new SemVer(currentVersion) + const newerSemVer = new SemVer(newerVersion) + if (currentSemVer.major !== newerSemVer.major) { + outputDebug('Auto-upgrade: Skipping auto-upgrade because of major version change.') + return undefined + } + // eslint-disable-next-line no-catch-all/no-catch-all + } catch { + // If version parsing fails, skip auto-upgrade + outputDebug('Auto-upgrade: Skipping auto-upgrade because of version parsing failure.') + return undefined + } + + return newerVersion } /** @@ -33,6 +130,88 @@ export function cliInstallCommand(): string { * @returns The message to remind the user to update the CLI. */ export function getOutputUpdateCLIReminder(version: string): string { - return outputContent`💡 Version ${version} available! Run ${outputToken.genericShellCommand(cliInstallCommand())}` - .value + const installCommand = cliInstallCommand() + if (installCommand) { + return outputContent`💡 Version ${version} available! Run ${outputToken.genericShellCommand(installCommand)}`.value + } + return outputContent`💡 Version ${version} available!`.value +} + +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} + + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + let resolvedCLIVersion = allDependencies[await cliDependency()]! + + 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 { + 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 { + return (await packageJsonContents()).name +} + +async function oclifPlugins(): Promise { + return (await packageJsonContents())?.oclif?.plugins || [] +} + +type PackageJsonWithName = Omit & {name: string} +let _packageJsonContents: PackageJsonWithName | undefined + +async function packageJsonContents(): Promise { + 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)}...`, + ) } diff --git a/packages/cli-kit/src/public/node/version.ts b/packages/cli-kit/src/public/node/version.ts index 3c958889471..f93cec00582 100644 --- a/packages/cli-kit/src/public/node/version.ts +++ b/packages/cli-kit/src/public/node/version.ts @@ -1,6 +1,38 @@ +import {findPathUpSync} from './fs.js' +import {moduleDirectory} from './path.js' import {captureOutput} from '../node/system.js' import which from 'which' -import {satisfies} from 'semver' +import {satisfies, SemVer} from 'semver' +import {readFileSync} from 'node:fs' + +let _cliVersion: string | undefined + +/** + * Returns the current CLI version, read from the nearest package.json. + * The result is cached after the first call. + * + * This is a lazy function (rather than a top-level constant) because `findPathUpSync` + * depends on npm packages that may not yet be initialized during ESM module evaluation + * when circular imports are involved. Deferring the call to runtime avoids that issue. + * + * In unbundled (dev) mode this finds cli-kit's own package.json. + * In bundled (global install) mode this finds the CLI's package.json, which shares the same version. + * + * @returns The CLI version string. + */ +export function cliVersion(): string { + if (_cliVersion === undefined) { + const packageJsonPath = findPathUpSync('package.json', {cwd: moduleDirectory(import.meta.url), type: 'file'}) + if (packageJsonPath) { + const pkg = JSON.parse(readFileSync(packageJsonPath, 'utf-8')) + if (pkg.name.startsWith('@shopify/cli') && pkg.version) { + _cliVersion = pkg.version + } + } + _cliVersion ??= '0.0.0' + } + return _cliVersion +} /** * Returns the version of the local dependency of the CLI if it's installed in the provided directory. * @@ -53,3 +85,17 @@ export async function globalCLIVersion(): Promise { export function isPreReleaseVersion(version: string): boolean { return version.startsWith('0.0.0') } + +/** + * Checks if the version is a major version change. + * + * @param currentVersion - The current version. + * @param newerVersion - The newer version. + * @returns True if the version is a major version change. + */ +export function isMajorVersionChange(currentVersion: string, newerVersion: string): boolean { + if (isPreReleaseVersion(currentVersion) || isPreReleaseVersion(newerVersion)) return false + const currentSemVer = new SemVer(currentVersion) + const newerSemVer = new SemVer(newerVersion) + return currentSemVer.major !== newerSemVer.major +} diff --git a/packages/cli/README.md b/packages/cli/README.md index 82416a8fed7..8dc256614ee 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -2742,16 +2742,16 @@ DESCRIPTION ## `shopify upgrade` -Shows details on how to upgrade Shopify CLI. +Upgrades Shopify CLI. ``` USAGE $ shopify upgrade DESCRIPTION - Shows details on how to upgrade Shopify CLI. + Upgrades Shopify CLI. - Shows details on how to upgrade Shopify CLI. + Upgrades Shopify CLI using your package manager. ``` ## `shopify version` diff --git a/packages/cli/oclif.manifest.json b/packages/cli/oclif.manifest.json index 105fd1e3678..5e3d0d2682d 100644 --- a/packages/cli/oclif.manifest.json +++ b/packages/cli/oclif.manifest.json @@ -7770,8 +7770,8 @@ ], "args": { }, - "description": "Shows details on how to upgrade Shopify CLI.", - "descriptionWithMarkdown": "Shows details on how to upgrade Shopify CLI.", + "description": "Upgrades Shopify CLI using your package manager.", + "descriptionWithMarkdown": "Upgrades Shopify CLI using your package manager.", "enableJsonFlag": false, "flags": { }, @@ -7783,7 +7783,7 @@ "pluginName": "@shopify/cli", "pluginType": "core", "strict": true, - "summary": "Shows details on how to upgrade Shopify CLI." + "summary": "Upgrades Shopify CLI." }, "version": { "aliases": [ diff --git a/packages/cli/src/cli/commands/upgrade.test.ts b/packages/cli/src/cli/commands/upgrade.test.ts index e56a55ace44..a6936ee57db 100644 --- a/packages/cli/src/cli/commands/upgrade.test.ts +++ b/packages/cli/src/cli/commands/upgrade.test.ts @@ -1,6 +1,6 @@ import {describe, test, vi, expect} from 'vitest' -vi.mock('../services/upgrade.js') +vi.mock('@shopify/cli-kit/node/upgrade') describe('upgrade command', () => { test('launches service with path', async () => { diff --git a/packages/cli/src/cli/commands/upgrade.ts b/packages/cli/src/cli/commands/upgrade.ts index 6472ea9b6dc..c8e39ffaf2c 100644 --- a/packages/cli/src/cli/commands/upgrade.ts +++ b/packages/cli/src/cli/commands/upgrade.ts @@ -1,17 +1,14 @@ -import {cliInstallCommand} from '@shopify/cli-kit/node/upgrade' import Command from '@shopify/cli-kit/node/base-command' -import {renderInfo} from '@shopify/cli-kit/node/ui' +import {runCLIUpgrade} from '@shopify/cli-kit/node/upgrade' export default class Upgrade extends Command { - static summary = 'Shows details on how to upgrade Shopify CLI.' + static summary = 'Upgrades Shopify CLI.' - static descriptionWithMarkdown = 'Shows details on how to upgrade Shopify CLI.' + static descriptionWithMarkdown = 'Upgrades Shopify CLI using your package manager.' static description = this.descriptionWithoutMarkdown() async run(): Promise { - renderInfo({ - body: [`To upgrade Shopify CLI use your package manager.\n`, `Example:`, {command: cliInstallCommand()}], - }) + await runCLIUpgrade() } } diff --git a/packages/cli/src/cli/services/commands/notifications.ts b/packages/cli/src/cli/services/commands/notifications.ts index aa712a7b466..5a6d8c9d23b 100644 --- a/packages/cli/src/cli/services/commands/notifications.ts +++ b/packages/cli/src/cli/services/commands/notifications.ts @@ -1,4 +1,4 @@ -import {CLI_KIT_VERSION} from '@shopify/cli-kit/common/version' +import {cliVersion} from '@shopify/cli-kit/node/version' import {randomUUID} from '@shopify/cli-kit/node/crypto' import {writeFile} from '@shopify/cli-kit/node/fs' import { @@ -44,12 +44,12 @@ export async function generate() { }) const minVersion = await renderTextPrompt({ message: 'Minimum CLI version (optional)', - initialAnswer: CLI_KIT_VERSION, + initialAnswer: cliVersion(), allowEmpty: true, }) const maxVersion = await renderTextPrompt({ message: 'Maximum CLI version (optional)', - initialAnswer: CLI_KIT_VERSION, + initialAnswer: cliVersion(), allowEmpty: true, }) const minDate = await renderTextPrompt({ diff --git a/packages/cli/src/cli/services/commands/version.test.ts b/packages/cli/src/cli/services/commands/version.test.ts index 4e3680b310e..4c9cecdbe08 100644 --- a/packages/cli/src/cli/services/commands/version.test.ts +++ b/packages/cli/src/cli/services/commands/version.test.ts @@ -3,7 +3,7 @@ import {afterEach, describe, expect, vi, test} from 'vitest' import {mockAndCaptureOutput} from '@shopify/cli-kit/node/testing/output' vi.mock('@shopify/cli-kit/node/node-package-manager') -vi.mock('@shopify/cli-kit/common/version', () => ({CLI_KIT_VERSION: '2.2.2'})) +vi.mock('@shopify/cli-kit/node/version', () => ({cliVersion: () => '2.2.2'})) afterEach(() => { mockAndCaptureOutput().clear() diff --git a/packages/cli/src/cli/services/commands/version.ts b/packages/cli/src/cli/services/commands/version.ts index df92a13da06..bc5a9123397 100644 --- a/packages/cli/src/cli/services/commands/version.ts +++ b/packages/cli/src/cli/services/commands/version.ts @@ -1,6 +1,6 @@ -import {CLI_KIT_VERSION} from '@shopify/cli-kit/common/version' +import {cliVersion} from '@shopify/cli-kit/node/version' import {outputResult} from '@shopify/cli-kit/node/output' export async function versionService(): Promise { - outputResult(CLI_KIT_VERSION) + outputResult(cliVersion()) } diff --git a/packages/theme/src/cli/utilities/repl/repl-theme-manager.ts b/packages/theme/src/cli/utilities/repl/repl-theme-manager.ts index 555e7eaeb3c..e59a71dbacf 100644 --- a/packages/theme/src/cli/utilities/repl/repl-theme-manager.ts +++ b/packages/theme/src/cli/utilities/repl/repl-theme-manager.ts @@ -4,7 +4,7 @@ import {AdminSession} from '@shopify/cli-kit/node/session' import {DEVELOPMENT_THEME_ROLE, Role} from '@shopify/cli-kit/node/themes/utils' import {bulkUploadThemeAssets} from '@shopify/cli-kit/node/themes/api' import {Theme} from '@shopify/cli-kit/node/themes/types' -import {CLI_KIT_VERSION} from '@shopify/cli-kit/common/version' +import {cliVersion} from '@shopify/cli-kit/node/version' export class REPLThemeManager extends ThemeManager { protected context = 'REPL' @@ -24,7 +24,7 @@ export class REPLThemeManager extends ThemeManager { async findOrCreate(): Promise { let theme = await this.fetch() if (!theme) { - const themeName = `Liquid Console (${CLI_KIT_VERSION})` + const themeName = `Liquid Console (${cliVersion()})` theme = await this.create(DEVELOPMENT_THEME_ROLE, themeName) } return theme diff --git a/packages/theme/src/cli/utilities/theme-environment/storefront-utils.ts b/packages/theme/src/cli/utilities/theme-environment/storefront-utils.ts index c18b30a9be2..3ed9144a873 100644 --- a/packages/theme/src/cli/utilities/theme-environment/storefront-utils.ts +++ b/packages/theme/src/cli/utilities/theme-environment/storefront-utils.ts @@ -1,5 +1,5 @@ import {DevServerRenderContext} from './types.js' -import {CLI_KIT_VERSION} from '@shopify/cli-kit/common/version' +import {cliVersion} from '@shopify/cli-kit/node/version' export function storefrontReplaceTemplatesParams(context: DevServerRenderContext): URLSearchParams { /** @@ -23,7 +23,7 @@ export function storefrontReplaceTemplatesParams(context: DevServerRenderContext export function defaultHeaders() { return { - 'User-Agent': `Shopify CLI; v=${CLI_KIT_VERSION}`, + 'User-Agent': `Shopify CLI; v=${cliVersion()}`, } }