diff --git a/.changeset/honest-apricots-fail.md b/.changeset/honest-apricots-fail.md new file mode 100644 index 0000000000..73d2cf6be9 --- /dev/null +++ b/.changeset/honest-apricots-fail.md @@ -0,0 +1,42 @@ +--- +'@shopify/cli-hydrogen': patch +--- + +New `h2 upgrade` command + +We are introducing a new hydrogen cli `upgrade` command that: + +- Makes upgrading hydrogen as easy as typing `h2 upgrade` in your terminal +- Provides a quick summary of the key `features` and `fixes` of any available + hydrogen version(s) +- Generates a `TODO` instructions file detailing all cumulative code changes required + to adopt a given hydrogen version +- Provides a gentle notice during development detailing when a hydrogen is outdated, as well as a quick glance into the number of hydrogen version available + +## Basic use + +```bash +# from the base of the project run +h2 upgrade +``` + +### `--version` flag + +The version flag let's you upgrade to a specific release version without any further +prompts. If an invalid version is provided you will be prompted to choose a hydrogen +version via a CLI prompt + +```bash +h2 upgrade --version 2023.10.0 +``` + +### `--dry-run` flag + +If your are unsure about upgrading or just want to preview the TODO list of +changes to a given hydrogen version you can run + +```bash +h2 upgrade --dry-run + +# this will output a new .md file inside the .hydrogen/ folder for a given upgrade +``` diff --git a/package-lock.json b/package-lock.json index 0d797b9be8..77e3f0c641 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14842,6 +14842,17 @@ "node": "6.* || 8.* || >= 10.*" } }, + "node_modules/get-east-asian-width": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.2.0.tgz", + "integrity": "sha512-2nk+7SIVb14QrgXFHcm84tD4bKQz0RxPuMT8Ag5KPOq7J5fEmAg0UbXdTOSHqNuHSU28k55qnceesxXRZGzKWA==", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/get-func-name": { "version": "2.0.0", "dev": true, @@ -29883,6 +29894,7 @@ "@shopify/mini-oxygen": "^2.2.3", "@shopify/oxygen-cli": "2.6.2", "ansi-escapes": "^6.2.0", + "cli-truncate": "^4.0.0", "diff": "^5.1.0", "fs-extra": "^11.1.0", "get-port": "^7.0.0", @@ -30050,6 +30062,48 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "packages/cli/node_modules/ansi-regex": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.0.1.tgz", + "integrity": "sha512-n5M855fKb2SsfMIiFFoVrABHJC8QtHwVx+mHWP3QcEqBHYienj5dHSgjbxtC0WEZXYt4wcD6zrQElDPhFuZgfA==", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "packages/cli/node_modules/ansi-styles": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.1.tgz", + "integrity": "sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "packages/cli/node_modules/cli-truncate": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/cli-truncate/-/cli-truncate-4.0.0.tgz", + "integrity": "sha512-nPdaFdQ0h/GEigbPClz11D0v/ZJEwxmeVZGeMo3Z5StPtUTkA9o1lD6QwoirYiSDzbcwn2XcjwmCp68W1IS4TA==", + "dependencies": { + "slice-ansi": "^5.0.0", + "string-width": "^7.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "packages/cli/node_modules/emoji-regex": { + "version": "10.3.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.3.0.tgz", + "integrity": "sha512-QpLs9D9v9kArv4lfDEgg1X/gN5XLnf/A6l9cs8SPZLRZR3ZkY9+kwIQTxm+fsSej5UMYGE8fdoaZVIBlqG0XTw==" + }, "packages/cli/node_modules/fs-extra": { "version": "11.1.1", "license": "MIT", @@ -30073,6 +30127,62 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "packages/cli/node_modules/is-fullwidth-code-point": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-4.0.0.tgz", + "integrity": "sha512-O4L094N2/dZ7xqVdrXhh9r1KODPJpFms8B5sGdJLPy664AgvXsreZUyCQQNItZRDlYug4xStLjNp/sz3HvBowQ==", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "packages/cli/node_modules/slice-ansi": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-5.0.0.tgz", + "integrity": "sha512-FC+lgizVPfie0kkhqUScwRu1O/lF6NOgJmlCgK+/LYxDCTk8sGelYaHDhFcDN+Sn3Cv+3VSa4Byeo+IMCzpMgQ==", + "dependencies": { + "ansi-styles": "^6.0.0", + "is-fullwidth-code-point": "^4.0.0" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/slice-ansi?sponsor=1" + } + }, + "packages/cli/node_modules/string-width": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.0.0.tgz", + "integrity": "sha512-GPQHj7row82Hjo9hKZieKcHIhaAIKOJvFSIZXuCU9OASVZrMNUaZuz++SPVrBjnLsnk4k+z9f2EIypgxf2vNFw==", + "dependencies": { + "emoji-regex": "^10.3.0", + "get-east-asian-width": "^1.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "packages/cli/node_modules/strip-ansi": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.0.tgz", + "integrity": "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==", + "dependencies": { + "ansi-regex": "^6.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, "packages/cli/node_modules/type-fest": { "version": "3.13.1", "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-3.13.1.tgz", @@ -35596,6 +35706,7 @@ "@types/tar-fs": "^2.0.1", "@vitest/coverage-v8": "^0.33.0", "ansi-escapes": "^6.2.0", + "cli-truncate": "^4.0.0", "devtools-protocol": "^0.0.1177611", "diff": "^5.1.0", "fast-glob": "^3.2.12", @@ -35671,6 +35782,30 @@ "type-fest": "^3.0.0" } }, + "ansi-regex": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.0.1.tgz", + "integrity": "sha512-n5M855fKb2SsfMIiFFoVrABHJC8QtHwVx+mHWP3QcEqBHYienj5dHSgjbxtC0WEZXYt4wcD6zrQElDPhFuZgfA==" + }, + "ansi-styles": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.1.tgz", + "integrity": "sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==" + }, + "cli-truncate": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/cli-truncate/-/cli-truncate-4.0.0.tgz", + "integrity": "sha512-nPdaFdQ0h/GEigbPClz11D0v/ZJEwxmeVZGeMo3Z5StPtUTkA9o1lD6QwoirYiSDzbcwn2XcjwmCp68W1IS4TA==", + "requires": { + "slice-ansi": "^5.0.0", + "string-width": "^7.0.0" + } + }, + "emoji-regex": { + "version": "10.3.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.3.0.tgz", + "integrity": "sha512-QpLs9D9v9kArv4lfDEgg1X/gN5XLnf/A6l9cs8SPZLRZR3ZkY9+kwIQTxm+fsSej5UMYGE8fdoaZVIBlqG0XTw==" + }, "fs-extra": { "version": "11.1.1", "requires": { @@ -35683,6 +35818,38 @@ "version": "7.0.0", "dev": true }, + "is-fullwidth-code-point": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-4.0.0.tgz", + "integrity": "sha512-O4L094N2/dZ7xqVdrXhh9r1KODPJpFms8B5sGdJLPy664AgvXsreZUyCQQNItZRDlYug4xStLjNp/sz3HvBowQ==" + }, + "slice-ansi": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-5.0.0.tgz", + "integrity": "sha512-FC+lgizVPfie0kkhqUScwRu1O/lF6NOgJmlCgK+/LYxDCTk8sGelYaHDhFcDN+Sn3Cv+3VSa4Byeo+IMCzpMgQ==", + "requires": { + "ansi-styles": "^6.0.0", + "is-fullwidth-code-point": "^4.0.0" + } + }, + "string-width": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.0.0.tgz", + "integrity": "sha512-GPQHj7row82Hjo9hKZieKcHIhaAIKOJvFSIZXuCU9OASVZrMNUaZuz++SPVrBjnLsnk4k+z9f2EIypgxf2vNFw==", + "requires": { + "emoji-regex": "^10.3.0", + "get-east-asian-width": "^1.0.0", + "strip-ansi": "^7.1.0" + } + }, + "strip-ansi": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.0.tgz", + "integrity": "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==", + "requires": { + "ansi-regex": "^6.0.1" + } + }, "type-fest": { "version": "3.13.1", "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-3.13.1.tgz", @@ -40892,6 +41059,11 @@ "get-caller-file": { "version": "2.0.5" }, + "get-east-asian-width": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.2.0.tgz", + "integrity": "sha512-2nk+7SIVb14QrgXFHcm84tD4bKQz0RxPuMT8Ag5KPOq7J5fEmAg0UbXdTOSHqNuHSU28k55qnceesxXRZGzKWA==" + }, "get-func-name": { "version": "2.0.0", "dev": true diff --git a/packages/cli/oclif.manifest.json b/packages/cli/oclif.manifest.json index 3cc3ce33e7..847e202006 100644 --- a/packages/cli/oclif.manifest.json +++ b/packages/cli/oclif.manifest.json @@ -330,6 +330,13 @@ "char": "e", "description": "Specify an environment's branch name when using remote environment variables.", "multiple": false + }, + "disable-version-check": { + "name": "disable-version-check", + "type": "boolean", + "description": "Skip the version check when running `hydrogen dev`", + "required": false, + "allowNo": false } }, "args": {} @@ -647,6 +654,47 @@ }, "args": {} }, + "hydrogen:upgrade": { + "id": "hydrogen:upgrade", + "description": "Upgrade Remix and Hydrogen npm dependencies.", + "strict": true, + "pluginName": "@shopify/cli-hydrogen", + "pluginAlias": "@shopify/cli-hydrogen", + "pluginType": "core", + "aliases": [], + "flags": { + "path": { + "name": "path", + "type": "option", + "description": "The path to the directory of the Hydrogen storefront. The default is the current directory.", + "multiple": false + }, + "version": { + "name": "version", + "type": "option", + "char": "v", + "description": "A target hydrogen version to update to", + "required": false, + "multiple": false + }, + "dry-run": { + "name": "dry-run", + "type": "boolean", + "char": "d", + "description": "Generate a summary and .md file with upgrade instructions without actually upgrading the dependencies", + "required": false, + "allowNo": false + }, + "force": { + "name": "force", + "type": "boolean", + "char": "f", + "description": "Ignore warnings and force the upgrade to the target version", + "allowNo": false + } + }, + "args": {} + }, "hydrogen:debug:cpu": { "id": "hydrogen:debug:cpu", "description": "Builds and profiles the server startup time the app.", diff --git a/packages/cli/package.json b/packages/cli/package.json index ad58bdf044..ca816ed5ad 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -23,11 +23,11 @@ "@types/recursive-readdir": "^2.2.1", "@types/stack-trace": "^0.0.30", "@types/tar-fs": "^2.0.1", - "devtools-protocol": "^0.0.1177611", - "get-port": "^7.0.0", "@vitest/coverage-v8": "^0.33.0", + "devtools-protocol": "^0.0.1177611", "fast-glob": "^3.2.12", "flame-chart-js": "2.3.1", + "get-port": "^7.0.0", "type-fest": "^3.6.0", "vitest": "^0.33.0" }, @@ -40,6 +40,7 @@ "@shopify/mini-oxygen": "^2.2.3", "@shopify/oxygen-cli": "2.6.2", "ansi-escapes": "^6.2.0", + "cli-truncate": "^4.0.0", "diff": "^5.1.0", "fs-extra": "^11.1.0", "get-port": "^7.0.0", diff --git a/packages/cli/src/commands/hydrogen/dev.ts b/packages/cli/src/commands/hydrogen/dev.ts index f4b4e56ca9..65efa5837f 100644 --- a/packages/cli/src/commands/hydrogen/dev.ts +++ b/packages/cli/src/commands/hydrogen/dev.ts @@ -23,7 +23,6 @@ import { import Command from '@shopify/cli-kit/node/base-command'; import {Flags} from '@oclif/core'; import {type MiniOxygen, startMiniOxygen} from '../../lib/mini-oxygen/index.js'; -import {checkHydrogenVersion} from '../../lib/check-version.js'; import {addVirtualRoutes} from '../../lib/virtual-routes.js'; import {spawnCodegenProcess} from '../../lib/codegen.js'; import {getAllEnvironmentVariables} from '../../lib/environment-variables.js'; @@ -31,6 +30,7 @@ import {getConfig} from '../../lib/shopify-config.js'; import {setupLiveReload} from '../../lib/live-reload.js'; import {checkRemixVersions} from '../../lib/remix-version-check.js'; import {getGraphiQLUrl} from '../../lib/graphiql-url.js'; +import {displayDevUpgradeNotice} from './upgrade.js'; import {findPort} from '../../lib/find-port.js'; const LOG_REBUILDING = '🧱 Rebuilding...'; @@ -60,6 +60,11 @@ export default class Dev extends Command { 'inspector-port': commonFlags.inspectorPort, host: deprecated('--host')(), ['env-branch']: commonFlags.envBranch, + ['disable-version-check']: Flags.boolean({ + description: 'Skip the version check when running `hydrogen dev`', + default: false, + required: false, + }), }; async run(): Promise { @@ -82,6 +87,7 @@ type DevOptions = { workerRuntime?: boolean; codegenConfigPath?: string; disableVirtualRoutes?: boolean; + disableVersionCheck?: boolean; envBranch?: string; debug?: boolean; sourcemap?: boolean; @@ -98,6 +104,7 @@ async function runDev({ envBranch, debug = false, sourcemap = true, + disableVersionCheck = false, inspectorPort, }: DevOptions) { if (!process.env.NODE_ENV) process.env.NODE_ENV = 'development'; @@ -107,8 +114,6 @@ async function runDev({ const {root, publicPath, buildPathClient, buildPathWorkerFile} = getProjectPaths(appPath); - const checkingHydrogenVersion = checkHydrogenVersion(root); - const copyingFiles = copyPublicFiles(publicPath, buildPathClient); const reloadConfig = async () => { const config = await getRemixConfig(root); @@ -204,8 +209,10 @@ async function runDev({ } checkRemixVersions(); - const showUpgrade = await checkingHydrogenVersion; - if (showUpgrade) showUpgrade(); + + if (!disableVersionCheck) { + displayDevUpgradeNotice({targetPath: appPath}); + } } const fileWatchCache = createFileWatchCache(); diff --git a/packages/cli/src/commands/hydrogen/upgrade.test.ts b/packages/cli/src/commands/hydrogen/upgrade.test.ts new file mode 100644 index 0000000000..ad0b29f2d2 --- /dev/null +++ b/packages/cli/src/commands/hydrogen/upgrade.test.ts @@ -0,0 +1,940 @@ +import {createRequire} from 'node:module'; +import {fileURLToPath} from 'node:url'; +import {execa} from 'execa'; +import { + describe, + it, + expect, + vi, + beforeEach, + beforeAll, + afterAll, +} from 'vitest'; +import { + inTemporaryDirectory, + writeFile, + fileExists, +} from '@shopify/cli-kit/node/fs'; +import {joinPath} from '@shopify/cli-kit/node/path'; +import { + renderSelectPrompt, + renderConfirmationPrompt, + renderTasks, +} from '@shopify/cli-kit/node/ui'; +import {mockAndCaptureOutput} from '@shopify/cli-kit/node/testing/output'; +import { + buildUpgradeCommandArgs, + displayConfirmation, + getAbsoluteVersion, + getAvailableUpgrades, + getCummulativeRelease, + getHydrogenVersion, + getSelectedRelease, + runUpgrade, + type CumulativeRelease, + type Dependencies, + type Release, + upgradeNodeModules, + getChangelog, +} from './upgrade.js'; +import {type PackageJson} from 'type-fest'; + +vi.mock('../../lib/shell.js'); +vi.mock('@shopify/cli-kit/node/session'); + +vi.mock('@shopify/cli-kit/node/ui', async () => { + const original = await vi.importActual< + typeof import('@shopify/cli-kit/node/ui') + >('@shopify/cli-kit/node/ui'); + + return { + ...original, + renderTasks: vi.fn(() => Promise.resolve()), + renderSelectPrompt: vi.fn(() => Promise.resolve()), + renderConfirmationPrompt: vi.fn(() => Promise.resolve(false)), + }; +}); + +const outputMock = mockAndCaptureOutput(); + +beforeEach(() => { + vi.restoreAllMocks(); + vi.resetModules(); + vi.clearAllMocks(); + outputMock.clear(); +}); + +beforeAll(() => { + process.env.FORCE_CHANGELOG_SOURCE = 'local'; +}); + +afterAll(() => { + delete process.env.FORCE_CHANGELOG_SOURCE; +}); + +function createOutdatedSkeletonPackageJson() { + const require = createRequire(import.meta.url); + const packageJson = require(fileURLToPath( + new URL('../../../../../templates/skeleton/package.json', import.meta.url), + )) as PackageJson; + + if (!packageJson) throw new Error('Could not parse package.json'); + if (!packageJson?.dependencies) + throw new Error('Could not parse package.json dependencies'); + if (!packageJson?.devDependencies) + throw new Error('Could not parse package.json devDependencies'); + + // bump the versions to be outdated + packageJson.dependencies['@shopify/hydrogen'] = '^2023.1.6'; + packageJson.dependencies['@remix-run/react'] = '1.12.0'; + packageJson.devDependencies['@shopify/cli-hydrogen'] = '^4.0.8'; + packageJson.devDependencies['@shopify/remix-oxygen'] = '^1.0.3'; + packageJson.devDependencies['@remix-run/dev'] = '1.12.0'; + packageJson.devDependencies['typescript'] = '^4.9.5'; + + return packageJson; +} + +/** + * Creates a temporary directory with a git repo and a package.json + */ +async function inTemporaryHydrogenRepo( + cb: (tmpDir: string) => Promise, + { + cleanGitRepo, + packageJson, + }: { + cleanGitRepo?: boolean; + packageJson?: null | Record; + } = { + cleanGitRepo: true, + packageJson: null, + }, +) { + return inTemporaryDirectory(async (tmpDir) => { + // init the git repo + await execa('git', ['init'], {cwd: tmpDir}); + + if (packageJson) { + const packageJsonPath = joinPath(tmpDir, 'package.json'); + await writeFile(packageJsonPath, JSON.stringify(packageJson)); + expect(await fileExists(packageJsonPath)).toBeTruthy(); + } + + // expect to be a git repo + expect(await fileExists(joinPath(tmpDir, '/.git/config'))).toBeTruthy(); + + if (cleanGitRepo) { + await execa('git', ['add', 'package.json'], {cwd: tmpDir}); + await execa('git', ['commit', '-m', 'initial commit'], {cwd: tmpDir}); + } + + await cb(tmpDir); + }); +} + +describe('upgrade', async () => { + // Create an outdated skeleton package.json for all tests + const OUTDATED_HYDROGEN_PACKAGE_JSON = createOutdatedSkeletonPackageJson(); + + describe('checkIsGitRepo', () => { + it('renders an error message when not in a git repo', async () => { + await inTemporaryDirectory(async (appPath) => { + await expect(runUpgrade({dryRun: false, appPath})).rejects.toThrowError( + 'git repository', + ); + }); + }); + }); + + describe('checkDirtyGitBranch', () => { + it('renders error message if the target git repo is dirty', async () => { + await inTemporaryHydrogenRepo( + async (appPath) => { + await expect( + runUpgrade({dryRun: false, appPath}), + ).rejects.toThrowError('clean git'); + }, + {cleanGitRepo: false, packageJson: OUTDATED_HYDROGEN_PACKAGE_JSON}, + ); + }); + }); + + describe('getHydrogenVersion', () => { + it('throws if no package.json is found', async () => { + await inTemporaryHydrogenRepo( + async (appPath) => { + await expect( + runUpgrade({dryRun: false, appPath}), + ).rejects.toThrowError('valid package.json'); + }, + {packageJson: null}, + ); + }); + + it('throws if no hydrogen version is found in package.json', async () => { + await inTemporaryHydrogenRepo( + async (appPath) => { + await expect( + runUpgrade({dryRun: false, appPath}), + ).rejects.toThrowError('version in package.json'); + }, + { + cleanGitRepo: true, + packageJson: { + name: 'hello-world', + dependencies: {}, + }, + }, + ); + }); + + it('returns the current hydrogen version from the package.json', async () => { + await inTemporaryHydrogenRepo( + async (appPath) => { + const hydrogen = await getHydrogenVersion({appPath}); + + expect(hydrogen).toBeDefined(); + expect(hydrogen.currentVersion).toMatch('^2023.1.6'); + expect(hydrogen.currentDependencies).toMatchObject({ + ...OUTDATED_HYDROGEN_PACKAGE_JSON.dependencies, + ...OUTDATED_HYDROGEN_PACKAGE_JSON.devDependencies, + }); + }, + { + cleanGitRepo: true, + packageJson: OUTDATED_HYDROGEN_PACKAGE_JSON, + }, + ); + }); + }); + + // TODO: finish this test once merged and published so that package.json is accessible + describe.skip('fetchChangelog', () => { + it('fetches the latest changelog from the hydrogen repo', async () => {}); + + it('renders an error message if the changelog could not be fetched', async () => {}); + }); + + describe('getAvailableUpgrades', async () => { + it('renders "already in the latest version" success message if no upgrades are available', async () => { + const {releases} = await getChangelog(); + + await inTemporaryHydrogenRepo( + async (appPath) => { + await runUpgrade({dryRun: false, appPath}); + expect(outputMock.info()).toMatch( + / success.+ latest Hydrogen version/is, + ); + }, + { + cleanGitRepo: true, + packageJson: { + dependencies: { + // @ts-expect-error - we know this release version exists + '@shopify/hydrogen': releases[0].version, + }, + }, + }, + ); + }); + + it('returns available upgrades and uniqueAvailableUpgrades if they exist', async () => { + const {releases} = await getChangelog(); + + await inTemporaryHydrogenRepo( + async (appPath) => { + const current = await getHydrogenVersion({appPath}); + const availableUpgrades = getAvailableUpgrades({ + releases, + ...current, + }); + + const uniqueAvailableUpgrades = releases + .slice(0, 2) + .reduce((acc, release) => { + // @ts-ignore + if (acc[release.version]) return acc; + return { + ...acc, + [release.version]: release, + }; + }, {}); + + expect(availableUpgrades).toMatchObject({ + availableUpgrades: releases.slice(0, 2), + uniqueAvailableUpgrades, + }); + }, + { + cleanGitRepo: true, + packageJson: { + dependencies: { + // @ts-ignore + '@shopify/hydrogen': releases[2].version, + }, + }, + }, + ); + }); + }); + + describe('getSelectedRelease', () => { + it('prioritizes a passed target --version over a select prompt if available', async () => { + await inTemporaryHydrogenRepo( + async (appPath) => { + const {releases} = await getChangelog(); + const current = await getHydrogenVersion({appPath}); + + expect(current?.currentVersion).toBeDefined(); + + const {availableUpgrades} = getAvailableUpgrades({ + ...current, + releases, + }); + + await expect( + getSelectedRelease({ + availableUpgrades, + // @ts-ignore - we know this release version exists + currentVersion: current.currentVersion, + // OUTDATED_HYDROGEN_PACKAGE_JSON.dependencies['@shopify/hydrogen'], + targetVersion: '2023.7.10', + }), + ).resolves.toMatchObject({ + version: '2023.7.10', + }); + + expect(renderSelectPrompt).not.toHaveBeenCalled(); + }, + { + cleanGitRepo: true, + packageJson: OUTDATED_HYDROGEN_PACKAGE_JSON, + }, + ); + }); + + it('prompts if a passed target --version is not a valid upgradable version', async () => { + await inTemporaryHydrogenRepo( + async (appPath) => { + const {releases} = await getChangelog(); + const current = await getHydrogenVersion({appPath}); + + expect(current?.currentVersion).toBeDefined(); + + const {availableUpgrades} = getAvailableUpgrades({ + ...current, + releases, + }); + + // Choose latest release + vi.mocked(renderSelectPrompt).mockImplementationOnce( + ({choices}) => Promise.resolve(choices[0]?.value!), + ); + + await expect( + getSelectedRelease({ + availableUpgrades, + currentVersion: current!.currentVersion!, + targetVersion: '2023.1.5', // fails because this version is in the past + }), + ).resolves.toMatchObject(availableUpgrades[0]!); + + expect(renderSelectPrompt).toHaveBeenCalled(); + }, + { + cleanGitRepo: true, + packageJson: OUTDATED_HYDROGEN_PACKAGE_JSON, + }, + ); + }); + + it('prompts to select a release if no target --version is passed', async () => { + await inTemporaryHydrogenRepo( + async (appPath) => { + const {releases} = await getChangelog(); + const previousRelease = releases[1]; + const latestRelease = releases[0]; + const current = await getHydrogenVersion({appPath}); + + expect(current?.currentVersion).toBeDefined(); + + const {availableUpgrades} = getAvailableUpgrades({ + ...current, + releases, + }); + + // Choose latest release + vi.mocked(renderSelectPrompt).mockImplementationOnce( + ({choices}) => Promise.resolve(choices[0]?.value!), + ); + + await expect( + getSelectedRelease({ + availableUpgrades, + currentVersion: previousRelease!.version, + }), + ).resolves.toMatchObject(availableUpgrades[0]!); + + expect(renderSelectPrompt).toHaveBeenCalledWith({ + message: expect.stringContaining(previousRelease!.version), + choices: expect.arrayContaining([ + { + label: expect.stringContaining(latestRelease!.version), + value: latestRelease, + }, + ]), + defaultValue: latestRelease, + }); + }, + { + cleanGitRepo: true, + packageJson: OUTDATED_HYDROGEN_PACKAGE_JSON, + }, + ); + }); + }); + + describe('getCummulativeRelease', () => { + it('returns the correct fixes and features for a release range thats outdated', async () => { + await inTemporaryHydrogenRepo( + async (appPath) => { + const {releases} = await getChangelog(); + const current = await getHydrogenVersion({appPath}); + + expect(current?.currentVersion).toBeDefined(); + + const {availableUpgrades} = getAvailableUpgrades({ + ...current, + releases, + }); + + // 2023.4.1 + const selectedRelease = releases.find( + (release) => release.version === '2023.4.1', + ); + + // testing from 2023.1.6 (outdated) to 2023.4.1 + const {features, fixes} = getCummulativeRelease({ + availableUpgrades, + ...current, + // @ts-ignore - we know this release version exists + selectedRelease, + }); + + expect(features).toMatchObject(CUMMLATIVE_RELEASE.features); + expect(fixes).toMatchObject(CUMMLATIVE_RELEASE.fixes); + }, + { + cleanGitRepo: true, + packageJson: OUTDATED_HYDROGEN_PACKAGE_JSON, + }, + ); + }); + }); + + describe('displayConfirmation', () => { + it('renders a confirmation prompt to continue or return to the previous menu', async () => { + await inTemporaryHydrogenRepo( + async () => { + const {releases} = await getChangelog(); + + // 2023.10.0 + const selectedRelease = releases.find( + (release) => release.version === '2023.10.0', + ) as (typeof releases)[0]; + + await expect( + displayConfirmation({ + cumulativeRelease: CUMMLATIVE_RELEASE, + selectedRelease, + }), + ).resolves.toEqual(false); + + const info = outputMock.info(); + expect(info).toMatch('Included in this upgrade'); + + [...CUMMLATIVE_RELEASE.features, ...CUMMLATIVE_RELEASE.fixes].forEach( + (feat) => + // Cut the string to avoid matching the banner + expect(info).toMatch(feat.title.slice(0, 15)), + ); + + expect(renderConfirmationPrompt).toHaveBeenCalledWith({ + message: `Are you sure you want to upgrade to ${selectedRelease.version}?`, + cancellationMessage: `No, choose another version`, + defaultValue: true, + }); + }, + { + cleanGitRepo: true, + packageJson: OUTDATED_HYDROGEN_PACKAGE_JSON, + }, + ); + }); + }); + + describe('upgradeNodeModules', () => { + it('runs the upgrade command task', async () => { + await inTemporaryHydrogenRepo( + async (appPath) => { + const {releases} = await getChangelog(); + + const selectedRelease = releases.find( + (release) => release.version === '2023.10.0', + ) as (typeof releases)[0]; + + const currentDependencies = { + ...OUTDATED_HYDROGEN_PACKAGE_JSON.dependencies, + ...OUTDATED_HYDROGEN_PACKAGE_JSON.devDependencies, + } as Dependencies; + + await upgradeNodeModules({ + appPath, + selectedRelease, + currentDependencies, + }); + + expect(renderTasks).toHaveBeenCalled(); + }, + { + cleanGitRepo: true, + packageJson: OUTDATED_HYDROGEN_PACKAGE_JSON, + }, + ); + }); + + it('builds the upgrade command args', async () => { + const {releases} = await getChangelog(); + + const selectedRelease = releases.find( + (release) => release.version === '2023.10.0', + ) as (typeof releases)[0]; + + const currentDependencies = { + ...OUTDATED_HYDROGEN_PACKAGE_JSON.dependencies, + ...OUTDATED_HYDROGEN_PACKAGE_JSON.devDependencies, + } as Dependencies; + + const result: string[] = [ + '@shopify/hydrogen@2023.10.0', + '@shopify/cli-hydrogen@6.0.0', + '@shopify/remix-oxygen@2.0.0', + '@remix-run/react@2.1.0', + '@remix-run/dev@2.1.0', + 'typescript@5.2.2', + ]; + + const args = buildUpgradeCommandArgs({ + selectedRelease, + currentDependencies, + }); + + expect(args).toEqual(expect.arrayContaining(result)); + }); + + it('upgrades and syncs up all available Remix deps if they are out-of-date', async () => { + const {releases} = await getChangelog(); + + const selectedRelease = releases.find( + (release) => release.version === '2023.10.0', + ) as (typeof releases)[0]; + + const currentDependencies = { + ...OUTDATED_HYDROGEN_PACKAGE_JSON.dependencies, + '@remix-run/react': '1.3.0', + ...OUTDATED_HYDROGEN_PACKAGE_JSON.devDependencies, + '@remix-run/dev': '1.2.0', + '@remix-run/css-bundle': '1.7.0', + } as Dependencies; + + const result: string[] = [ + '@shopify/cli-hydrogen@6.0.0', + '@shopify/hydrogen@2023.10.0', + '@shopify/remix-oxygen@2.0.0', + 'typescript@5.2.2', + '@remix-run/react@2.1.0', + '@remix-run/server-runtime@2.1.0', + '@remix-run/dev@2.1.0', + '@remix-run/eslint-config@2.1.0', + '@remix-run/css-bundle@2.1.0', + ]; + + const args = buildUpgradeCommandArgs({ + selectedRelease, + currentDependencies, + }); + + expect(args).toEqual(result); + }); + + it('upgrades all available Remix deps if they are out-of-date', async () => { + const {releases} = await getChangelog(); + + const selectedRelease = releases.find( + (release) => release.version === '2023.10.0', + ) as (typeof releases)[0]; + + const currentDependencies = { + ...OUTDATED_HYDROGEN_PACKAGE_JSON.dependencies, + '@remix-run/react': '1.8.0', + ...OUTDATED_HYDROGEN_PACKAGE_JSON.devDependencies, + '@remix-run/dev': '1.8.0', + '@remix-run/css-bundle': '1.8.0', + } as Dependencies; + + const result: string[] = [ + '@shopify/cli-hydrogen@6.0.0', + '@shopify/hydrogen@2023.10.0', + '@shopify/remix-oxygen@2.0.0', + 'typescript@5.2.2', + '@remix-run/react@2.1.0', + '@remix-run/server-runtime@2.1.0', + '@remix-run/dev@2.1.0', + '@remix-run/eslint-config@2.1.0', + '@remix-run/css-bundle@2.1.0', + ]; + + const args = buildUpgradeCommandArgs({ + selectedRelease, + currentDependencies, + }); + + expect(args).toEqual(result); + }); + + it('does not upgrade Remix deps if they are more up-to-date', async () => { + const {releases} = await getChangelog(); + + const selectedRelease = releases.find( + (release) => release.version === '2023.10.0', + ) as (typeof releases)[0]; + + const currentDependencies = { + ...OUTDATED_HYDROGEN_PACKAGE_JSON.dependencies, + '@remix-run/react': '2.2.0', + ...OUTDATED_HYDROGEN_PACKAGE_JSON.devDependencies, + '@remix-run/dev': '2.2.0', + } as Dependencies; + + const result: string[] = [ + '@shopify/cli-hydrogen@6.0.0', + '@shopify/hydrogen@2023.10.0', + '@shopify/remix-oxygen@2.0.0', + 'typescript@5.2.2', + ]; + + const args = buildUpgradeCommandArgs({ + selectedRelease, + currentDependencies, + }); + + expect(args).toEqual(result); + }); + + it('does not install an optional dependency that was not installed', async () => { + const {releases} = await getChangelog(); + + const selectedRelease = Object.create( + // @ts-ignore - we know this release version exists + releases.find((release) => release.version === '2023.10.0'), + ) as (typeof releases)[0]; + + // simulate a missing optional dependency + selectedRelease.dependenciesMeta = { + typescript: { + required: false, + }, + }; + + const currentDependencies = { + ...OUTDATED_HYDROGEN_PACKAGE_JSON.dependencies, + '@remix-run/react': '2.1.0', + ...OUTDATED_HYDROGEN_PACKAGE_JSON.devDependencies, + '@remix-run/dev': '2.1.0', + } as Dependencies; + + // simulate a missing required dependency + delete currentDependencies['typescript']; + + const result: string[] = [ + '@shopify/cli-hydrogen@6.0.0', + '@shopify/hydrogen@2023.10.0', + '@shopify/remix-oxygen@2.0.0', + ]; + + const args = buildUpgradeCommandArgs({ + selectedRelease, + currentDependencies, + }); + + expect(args).toEqual(result); + }); + + it('adds a required dependency that was not installed', async () => { + const {releases} = await getChangelog(); + + // has typescript as a required dependency + const selectedRelease = releases.find( + (release) => release.version === '2023.10.0', + ) as (typeof releases)[0]; + + const currentDependencies = { + ...OUTDATED_HYDROGEN_PACKAGE_JSON.dependencies, + '@remix-run/react': '2.1.0', + ...OUTDATED_HYDROGEN_PACKAGE_JSON.devDependencies, + '@remix-run/dev': '2.1.0', + } as Dependencies; + + // simulate a missing required dependency + delete currentDependencies['typescript']; + + const result: string[] = [ + '@shopify/cli-hydrogen@6.0.0', + '@shopify/hydrogen@2023.10.0', + '@shopify/remix-oxygen@2.0.0', + `typescript@${getAbsoluteVersion( + // @ts-ignore - we know this release version exists + selectedRelease.devDependencies.typescript, + )}`, + ]; + + const args = buildUpgradeCommandArgs({ + selectedRelease, + currentDependencies, + }); + + expect(args).toEqual(expect.arrayContaining(result)); + }); + + it('does not upgrade a required dependency that is further up-to-date', async () => { + const {releases} = await getChangelog(); + + const selectedRelease = releases.find( + (release) => release.version === '2023.10.0', + ) as (typeof releases)[0]; + + const currentDependencies = { + ...OUTDATED_HYDROGEN_PACKAGE_JSON.dependencies, + '@remix-run/react': '2.1.0', + ...OUTDATED_HYDROGEN_PACKAGE_JSON.devDependencies, + '@remix-run/dev': '2.1.0', + typescript: '5.3.0', // more up-to-date than that of 2023.10.0 + } as Dependencies; + + const result: string[] = [ + '@shopify/cli-hydrogen@6.0.0', + '@shopify/hydrogen@2023.10.0', + '@shopify/remix-oxygen@2.0.0', + ]; + + const args = buildUpgradeCommandArgs({ + selectedRelease, + currentDependencies, + }); + + expect(args).toEqual(expect.arrayContaining(result)); + }); + + it('does not upgrade @next dependencies', async () => { + const {releases} = await getChangelog(); + + const selectedRelease = releases.find( + (release) => release.version === '2023.10.0', + ) as (typeof releases)[0]; + + const currentDependencies = { + ...OUTDATED_HYDROGEN_PACKAGE_JSON.dependencies, + '@remix-run/react': '2.1.0', + ...OUTDATED_HYDROGEN_PACKAGE_JSON.devDependencies, + '@remix-run/dev': '2.1.0', + '@shopify/hydrogen': 'next', + } as Dependencies; + + const result: string[] = [ + '@shopify/cli-hydrogen@6.0.0', + '@shopify/remix-oxygen@2.0.0', + `typescript@${getAbsoluteVersion( + // @ts-ignore - we know this release version exists + selectedRelease.devDependencies.typescript, + )}`, + ]; + + const args = buildUpgradeCommandArgs({ + selectedRelease, + currentDependencies, + }); + + expect(args).toEqual(result); + }); + }); +}); + +// cummlative result when upgrading from 2023.1.6 (outdated) to 2023.4.1 +const CUMMLATIVE_RELEASE = { + fixes: [ + { + title: 'Add a default Powered-By: Shopify-Hydrogen header', + pr: 'https://github.com/Shopify/hydrogen/pull/872', + id: '872', + steps: [ + { + title: + ' It can be disabled by passing poweredByHeader: false in the configuration object of createRequestHandler', + code: 'YGBgdHMKaW1wb3J0IHtjcmVhdGVSZXF1ZXN0SGFuZGxlcn0gZnJvbSAnQHNob3BpZnkvcmVtaXgtb3h5Z2VuJzsKCmV4cG9ydCBkZWZhdWx0IHsKICBhc3luYyBmZXRjaChyZXF1ZXN0KSB7CiAgICAvLyAuLi4KICAgIGNvbnN0IGhhbmRsZVJlcXVlc3QgPSBjcmVhdGVSZXF1ZXN0SGFuZGxlcih7CiAgICAgIC8vIC4uLiBvdGhlciBwcm9wZXJ0aWVzIGluY2x1ZGVkCiAgICAgIHBvd2VyZWRCeUhlYWRlcjogZmFsc2UsCiAgICB9KTsKICAgIC8vIC4uLgogIH0sCn07CmBgYA', + file: 'server.ts', + }, + ], + }, + { + title: 'Updated CLI prompts', + pr: 'https://github.com/Shopify/hydrogen/pull/733', + id: '733', + steps: [ + { + title: 'Update package.json', + code: 'YGBgZGlmZgoiZGVwZW5kZW5jaWVzIjogewotICAiQHNob3BpZnkvY2xpIjogIjMueC54IiwKKyAgIkBzaG9waWZ5L2NsaSI6ICIzLjQ1LjAiLAp9CmBgYA==', + file: 'package.json', + }, + ], + }, + { + title: + 'Added support for the Remix future flags v2_meta, v2_errorBoundary and v2_routeConvention to the generate command', + pr: 'https://github.com/Shopify/hydrogen/pull/756', + id: '756', + }, + { + title: 'Update virtual route to use Remix V2 route name conventions', + pr: 'https://github.com/Shopify/hydrogen/pull/792', + id: '792', + }, + { + title: 'Update internal Remix dependencies to 1.15.0', + pr: 'https://github.com/Shopify/hydrogen/pull/728', + id: '728', + docs: 'https://github.com/remix-run/remix/releases/tag/remix%401.15.0', + }, + { + title: 'Improve type safety in SEO data generators', + pr: 'https://github.com/Shopify/hydrogen/pull/763', + id: '763', + }, + { + title: 'Stop hydrating with requestIdleCallback', + pr: 'https://github.com/Shopify/hydrogen/pull/667', + id: '667', + }, + { + title: 'Fix active cart session event in Live View', + pr: 'https://github.com/Shopify/hydrogen/pull/614', + id: '614', + steps: [ + { + title: + 'Introducing getStorefrontHeaders that collects the required Shopify headers for making a Storefront API call.', + code: 'YGBgdHMKKyBpbXBvcnQge2dldFN0b3JlZnJvbnRIZWFkZXJzfSBmcm9tICdAc2hvcGlmeS9yZW1peC1veHlnZW4nOwppbXBvcnQge2NyZWF0ZVN0b3JlZnJvbnRDbGllbnQsIHN0b3JlZnJvbnRSZWRpcmVjdH0gZnJvbSAnQHNob3BpZnkvaHlkcm9nZW4nOwoKZXhwb3J0IGRlZmF1bHQgewogIGFzeW5jIGZldGNoKAogICAgcmVxdWVzdDogUmVxdWVzdCwKICAgIGVudjogRW52LAogICAgZXhlY3V0aW9uQ29udGV4dDogRXhlY3V0aW9uQ29udGV4dCwKICApOiBQcm9taXNlPFJlc3BvbnNlPiB7CgogICAgY29uc3Qge3N0b3JlZnJvbnR9ID0gY3JlYXRlU3RvcmVmcm9udENsaWVudCh7CiAgICAgIGNhY2hlLAogICAgICB3YWl0VW50aWwsCi0gICAgIGJ1eWVySXA6IGdldEJ1eWVySXAocmVxdWVzdCksCiAgICAgIGkxOG46IHtsYW5ndWFnZTogJ0VOJywgY291bnRyeTogJ1VTJ30sCiAgICAgIHB1YmxpY1N0b3JlZnJvbnRUb2tlbjogZW52LlBVQkxJQ19TVE9SRUZST05UX0FQSV9UT0tFTiwKICAgICAgcHJpdmF0ZVN0b3JlZnJvbnRUb2tlbjogZW52LlBSSVZBVEVfU1RPUkVGUk9OVF9BUElfVE9LRU4sCiAgICAgIHN0b3JlRG9tYWluOiBgaHR0cHM6Ly8ke2Vudi5QVUJMSUNfU1RPUkVfRE9NQUlOfWAsCiAgICAgIHN0b3JlZnJvbnRBcGlWZXJzaW9uOiBlbnYuUFVCTElDX1NUT1JFRlJPTlRfQVBJX1ZFUlNJT04gfHwgJzIwMjMtMDEnLAogICAgICBzdG9yZWZyb250SWQ6IGVudi5QVUJMSUNfU1RPUkVGUk9OVF9JRCwKLSAgICAgcmVxdWVzdEdyb3VwSWQ6IHJlcXVlc3QuaGVhZGVycy5nZXQoJ3JlcXVlc3QtaWQnKSwKKyAgICAgc3RvcmVmcm9udEhlYWRlcnM6IGdldFN0b3JlZnJvbnRIZWFkZXJzKHJlcXVlc3QpLAogICAgfSk7CmBgYA==', + file: 'server.ts', + }, + ], + }, + ], + features: [ + { + title: + 'Add command to pull environment variables from a Hydrogen storefront', + pr: 'https://github.com/Shopify/hydrogen/pull/809', + id: '809', + }, + { + title: + 'New --debug flag for the dev command that attaches a Node inspector to the development server', + pr: 'https://github.com/Shopify/hydrogen/pull/869', + id: '869', + }, + { + title: + 'Add new commands for merchants to be able to list and link Hydrogen storefronts', + pr: 'https://github.com/Shopify/hydrogen/pull/784', + id: '784', + }, + { + title: 'Added parseGid() utility', + pr: 'https://github.com/Shopify/hydrogen/pull/845', + id: '845', + steps: [ + { + title: 'Example usage', + code: 'YGBgdHMKaW1wb3J0IHtwYXJzZUdpZH0gZnJvbSAnQHNob3BpZnkvaHlkcm9nZW4tcmVhY3QnOwoKY29uc3Qge2lkLCByZXNvdXJjZX0gPSBwYXJzZUdpZCgnZ2lkOi8vc2hvcGlmeS9PcmRlci8xMjMnKTsKCmNvbnNvbGUubG9nKGlkKTsgLy8gMTIzCmNvbnNvbGUubG9nKHJlc291cmNlKTsgLy8gT3JkZXIKYGBg', + }, + ], + }, + { + title: + 'Added a new shortcut command that creates a global h2 alias for the Hydrogen CLI', + pr: 'https://github.com/Shopify/hydrogen/pull/679', + id: '679', + steps: [ + { + title: 'Create the h2 alias', + code: 'YGBgYmFzaApucHggc2hvcGlmeSBoeWRyb2dlbiBzaG9ydGN1dApgYGA=', + }, + { + title: 'After that, you can run commands using the new alias:', + code: 'YGBgYmFzaApoMiBnZW5lcmF0ZSByb3V0ZSBob21lCmgyIGcgciBob21lICMgU2FtZSBhcyB0aGUgYWJvdmUKaDIgY2hlY2sgcm91dGVzCmBgYA==', + }, + ], + }, + { + title: 'Add an experimental createWithCache_unstable', + info: 'This utility creates a function similar to useQuery from Hydrogen v1. Use this utility to query third-party APIs and apply custom cache options', + pr: 'https://github.com/Shopify/hydrogen/pull/600', + id: '600', + steps: [ + { + title: 'To setup the utility, update your server.ts', + file: 'server.ts', + code: 'YGBgdHMKaW1wb3J0IHsKICBjcmVhdGVTdG9yZWZyb250Q2xpZW50LAogIGNyZWF0ZVdpdGhDYWNoZV91bnN0YWJsZSwKICBDYWNoZUxvbmcsCn0gZnJvbSAnQHNob3BpZnkvaHlkcm9nZW4nOwoKLy8gLi4uCgogIGNvbnN0IGNhY2hlID0gYXdhaXQgY2FjaGVzLm9wZW4oJ2h5ZHJvZ2VuJyk7CiAgY29uc3Qgd2l0aENhY2hlID0gY3JlYXRlV2l0aENhY2hlX3Vuc3RhYmxlKHtjYWNoZSwgd2FpdFVudGlsfSk7CgogIC8vIENyZWF0ZSBjdXN0b20gdXRpbGl0aWVzIHRvIHF1ZXJ5IHRoaXJkLXBhcnR5IEFQSXM6CiAgY29uc3QgZmV0Y2hNeUNNUyA9IChxdWVyeSkgPT4gewogICAgLy8gUHJlZml4IHRoZSBjYWNoZSBrZXkgYW5kIG1ha2UgaXQgdW5pcXVlIGJhc2VkIG9uIGFyZ3VtZW50cy4KICAgIHJldHVybiB3aXRoQ2FjaGUoWydteS1jbXMnLCBxdWVyeV0sIENhY2hlTG9uZygpLCAoKSA9PiB7CiAgICAgIGNvbnN0IGNtc0RhdGEgPSBhd2FpdCAoYXdhaXQgZmV0Y2goJ215LWNtcy5jb20vYXBpJywgewogICAgICAgIG1ldGhvZDogJ1BPU1QnLAogICAgICAgIGJvZHk6IHF1ZXJ5CiAgICAgIH0pKS5qc29uKCk7CgogICAgICBjb25zdCBuZXh0UGFnZSA9IChhd2FpdCBmZXRjaCgnbXktY21zLmNvbS9hcGknLCB7CiAgICAgICAgbWV0aG9kOiAnUE9TVCcsCiAgICAgICAgYm9keTogY21zRGF0YTEubmV4dFBhZ2VRdWVyeSwKICAgICAgfSkpLmpzb24oKTsKCiAgICAgIHJldHVybiB7Li4uY21zRGF0YSwgbmV4dFBhZ2V9CiAgICB9KTsKICB9OwoKICBjb25zdCBoYW5kbGVSZXF1ZXN0ID0gY3JlYXRlUmVxdWVzdEhhbmRsZXIoewogICAgYnVpbGQ6IHJlbWl4QnVpbGQsCiAgICBtb2RlOiBwcm9jZXNzLmVudi5OT0RFX0VOViwKICAgIGdldExvYWRDb250ZXh0OiAoKSA9PiAoewogICAgICBzZXNzaW9uLAogICAgICB3YWl0VW50aWwsCiAgICAgIHN0b3JlZnJvbnQsCiAgICAgIGVudiwKICAgICAgZmV0Y2hNeUNNUywKICAgIH0pLAogIH0pOwpgYGA=', + }, + ], + }, + { + title: 'Update Remix to 1.14.0', + pr: 'https://github.com/Shopify/hydrogen/pull/599', + id: '599', + }, + { + title: 'Added Cache-Control defaults to all the demo store routes', + pr: 'https://github.com/Shopify/hydrogen/pull/599', + id: '599', + }, + { + title: 'Added new loader API for setting SEO tags within route module', + pr: 'https://github.com/Shopify/hydrogen/pull/591', + id: '591', + }, + { + title: 'ShopPayButton component now can receive a storeDomain', + pr: 'https://github.com/Shopify/hydrogen/pull/645', + id: '645', + }, + { + title: + 'Added robots option to SEO config that allows users granular control over the robots meta tag.', + pr: 'https://github.com/Shopify/hydrogen/pull/572', + id: '572', + steps: [ + { + title: 'Example usage', + code: 'YGBgdHMKZXhwb3J0IGhhbmRsZSA9IHsKICBzZW86IHsKICAgIHJvYm90czogewogICAgICBub0luZGV4OiBmYWxzZSwKICAgICAgbm9Gb2xsb3c6IGZhbHNlLAogICAgfQogIH0KfQpgYGA=', + file: 'All files that use SEO config', + }, + ], + }, + { + title: 'Added decoding prop to the SpreadMedia component', + pr: 'https://github.com/Shopify/hydrogen/pull/642', + id: '642', + }, + ], +} as CumulativeRelease; diff --git a/packages/cli/src/commands/hydrogen/upgrade.ts b/packages/cli/src/commands/hydrogen/upgrade.ts new file mode 100644 index 0000000000..951c206478 --- /dev/null +++ b/packages/cli/src/commands/hydrogen/upgrade.ts @@ -0,0 +1,1117 @@ +import path from 'node:path'; +import {fileURLToPath} from 'node:url'; +import {createRequire} from 'node:module'; +import semver from 'semver'; +import cliTruncate from 'cli-truncate'; +import {Flags} from '@oclif/core'; +import {isClean, ensureInsideGitDirectory} from '@shopify/cli-kit/node/git'; +import Command from '@shopify/cli-kit/node/base-command'; +import { + renderConfirmationPrompt, + renderInfo, + renderSelectPrompt, + renderSuccess, + renderTasks, +} from '@shopify/cli-kit/node/ui'; +import { + fileExists, + isDirectory, + mkdir, + removeFile, + touchFile, + writeFile, + readFile, +} from '@shopify/cli-kit/node/fs'; +import { + getDependencies, + installNodeModules, + getPackageManager, +} from '@shopify/cli-kit/node/node-package-manager'; +import {AbortError} from '@shopify/cli-kit/node/error'; +import {PackageJson} from 'type-fest'; +import {commonFlags, flagsToCamelObject} from '../../lib/flags.js'; +import {getProjectPaths} from '../../lib/remix-config.js'; + +export type Dependencies = Record; + +export type Choice = { + label: string; + value: T; + key?: string; + group?: string; + helperText?: string; +}; + +export type SupportedPackage = + | '@shopify/hydrogen' + | '@shopify/cli-hydrogen' + | '@shopify/remix-oxygen'; + +export type PackageToUpgrade = { + version: string; + name: SupportedPackage; + type: 'dependency' | 'devDependency'; +}; + +type Step = { + code: string; + file?: string; + info?: string; + reel?: string; + title: string; +}; + +export type ReleaseItem = { + breaking?: boolean; + docs?: string; + id: string | null; + info?: string; + pr: `https://${string}` | null; + steps?: Array; + title: string; +}; + +export type Release = { + commit: `https://${string}`; + date: string; + dependencies: Record; + devDependencies: Record; + dependenciesMeta?: Record; + features: Array; + fixes: Array; + hash: string; + pr: `https://${string}`; + title: string; + version: string; +}; + +export type ChangeLog = { + url: string; + releases: Array; + version: string; +}; + +export type CumulativeRelease = { + features: Array; + fixes: Array; +}; + +const INSTRUCTIONS_FOLDER = '.hydrogen'; + +export default class Upgrade extends Command { + static description = 'Upgrade Remix and Hydrogen npm dependencies.'; + + static flags = { + path: commonFlags.path, + version: Flags.string({ + description: 'A target hydrogen version to update to', + required: false, + char: 'v', + }), + ['dry-run']: Flags.boolean({ + description: + 'Generate a summary and .md file with upgrade instructions without actually upgrading the dependencies', + required: false, + default: false, + char: 'd', + }), + force: Flags.boolean({ + description: + 'Ignore warnings and force the upgrade to the target version', + env: 'SHOPIFY_HYDROGEN_FLAG_FORCE', + char: 'f', + }), + }; + + async run(): Promise { + const {flags} = await this.parse(Upgrade); + + await runUpgrade({ + ...flagsToCamelObject(flags), + appPath: flags.path ? path.resolve(flags.path) : process.cwd(), + }); + } +} + +let CACHED_CHANGELOG: ChangeLog | null = null; + +type UpgradeOptions = { + appPath: string; + dryRun: boolean; + version?: string; + force?: boolean; +}; + +export async function runUpgrade({ + appPath, + dryRun, + version: targetVersion, + force, +}: UpgradeOptions) { + if (!force) { + await checkIsGitRepo(appPath); + + await checkDirtyGitBranch(appPath); + } + + const {currentVersion, currentDependencies} = await getHydrogenVersion({ + appPath, + }); + + const changelog = await getChangelog(); + + const {availableUpgrades} = getAvailableUpgrades({ + releases: changelog.releases, + currentVersion, + currentDependencies, + }); + + if (!availableUpgrades?.length) { + renderSuccess({ + headline: `You are on the latest Hydrogen version: ${getAbsoluteVersion( + currentVersion, + )}`, + }); + + return; + } + + let confirmed = false; + let selectedRelease: Release | undefined = undefined; + let cumulativeRelease: CumulativeRelease | undefined = undefined; + + do { + // Prompt the user to select a version from the list of available upgrades + selectedRelease = await getSelectedRelease({ + currentVersion, + targetVersion, + availableUpgrades, + }); + + // Get an aggregate list of features and fixes included in the upgrade versions range + cumulativeRelease = getCummulativeRelease({ + availableUpgrades, + currentVersion, + currentDependencies, + selectedRelease, + }); + + confirmed = + dryRun || + (await displayConfirmation({ + cumulativeRelease, + selectedRelease, + })); + } while (!confirmed); + + // Generate a markdown file with upgrade instructions + const instrunctionsFilePathPromise = generateUpgradeInstructionsFile({ + appPath, + cumulativeRelease, + currentVersion, + dryRun, + selectedRelease, + }); + + if (!dryRun) { + await upgradeNodeModules({appPath, selectedRelease, currentDependencies}); + await validateUpgrade({ + appPath, + selectedRelease, + }); + } + + const instrunctionsFilePath = await instrunctionsFilePathPromise; + + if (dryRun) { + await displayDryRunSummary({ + instrunctionsFilePath, + selectedRelease, + }); + } else { + // Display a summary of the upgrade and next steps + await displayUpgradeSummary({ + currentVersion, + instrunctionsFilePath, + selectedRelease, + }); + } +} + +/** + * Checks if the target folder is a git repo and throws an error if it's not + */ +function checkIsGitRepo(appPath: string) { + return ensureInsideGitDirectory(appPath).catch(() => { + throw new AbortError( + 'The upgrade command can only be run on a git repository', + `Please run the command inside a git repository or run 'git init' to create one`, + ); + }); +} + +/** + * Checks if the current git branch is clean and throws an error if it's not + */ +async function checkDirtyGitBranch(appPath: string) { + // TODO: remove type cast when we upgrade cli-kit to a version that includes https://github.com/Shopify/cli/pull/3022 + const cleanBranch = (await isClean(appPath)) as unknown as () => boolean; + + if (!cleanBranch()) { + throw new AbortError( + 'The upgrade command can only be run on a clean git branch', + 'Please commit your changes or re-run the command on a clean branch', + ); + } +} + +/** + * Gets the current @shopify/hydrogen version from the app's package.json + */ +export async function getHydrogenVersion({appPath}: {appPath: string}) { + const {root} = getProjectPaths(appPath); + const packageJsonPath = path.join(root, 'package.json'); + + let packageJson: PackageJson | undefined; + + try { + packageJson = JSON.parse(await readFile(packageJsonPath)) as PackageJson; + } catch { + throw new AbortError( + 'Could not find a valid package.json', + 'Please make sure you are running the command in a npm project', + ); + } + + const currentDependencies = { + ...(packageJson.dependencies ?? {}), + ...(packageJson.devDependencies ?? {}), + } as Dependencies; + + const currentVersion = currentDependencies['@shopify/hydrogen']; + + if (!currentVersion) { + throw new AbortError( + 'Could not find a valid Hydrogen version in package.json', + 'Please make sure you are running the command in a Hydrogen project', + ); + } + + return {currentVersion, currentDependencies}; +} + +/** + * Fetches the changelog.json file from the Hydrogen repo + */ +export async function getChangelog(): Promise { + if (CACHED_CHANGELOG) return CACHED_CHANGELOG; + + // For local testing + if ( + process.env.FORCE_CHANGELOG_SOURCE === 'local' || + (process.env.FORCE_CHANGELOG_SOURCE !== 'remote' && !!process.env.LOCAL_ENV) + ) { + const require = createRequire(import.meta.url); + return require(fileURLToPath( + new URL('../../../../../docs/changelog.json', import.meta.url), + )) as ChangeLog; + } + + try { + const response = await fetch('https://hydrogen.shopify.dev/changelog.json'); + + if (!response.ok) { + throw new Error('Failed to fetch changelog.json'); + } + + const json = await response.json(); + + if ('releases' in json && 'url' in json) { + CACHED_CHANGELOG = json; + return CACHED_CHANGELOG; + } + } catch {} + + throw new AbortError( + 'Failed to fetch changelog', + 'Ensure you have internet connection and try again', + ); +} + +export function hasOutdatedDependencies({ + release, + currentDependencies, +}: { + release: Release; + currentDependencies: Dependencies; +}) { + return Object.entries(release.dependencies).some(([name, version]) => { + const currentDependencyVersion = currentDependencies?.[name]; + if (!currentDependencyVersion) return false; + const isDependencyOutdated = semver.gt( + getAbsoluteVersion(version), + getAbsoluteVersion(currentDependencyVersion), + ); + return isDependencyOutdated; + }); +} + +export function isUpgradeableRelease({ + currentDependencies, + currentPinnedVersion, + release, +}: { + currentDependencies?: Dependencies; + currentPinnedVersion: string; + release: Release; +}) { + if (!currentDependencies) return false; + + const isHydrogenOutdated = semver.gt(release.version, currentPinnedVersion); + + if (isHydrogenOutdated) return true; + + // check if any of the other dependencies of the selected release are outdated + const isCurrentHydrogen = + getAbsoluteVersion(release.version) === currentPinnedVersion; + + if (!isCurrentHydrogen) return false; + + return hasOutdatedDependencies({release, currentDependencies}); +} + +/** + * Gets the list of available upgrades based on the current version + */ +export function getAvailableUpgrades({ + releases, + currentVersion, + currentDependencies, +}: { + releases: ChangeLog['releases']; + currentVersion: string; + currentDependencies?: Dependencies; +}) { + const currentPinnedVersion = getAbsoluteVersion(currentVersion); + let currentMajorVersion = ''; + + const availableUpgrades = releases.filter((release) => { + const isUpgradeable = isUpgradeableRelease({ + release, + currentPinnedVersion, + currentDependencies, + }); + + if (!isUpgradeable) return false; + + if (currentMajorVersion !== release.version) { + currentMajorVersion = release.version; + return true; + } + + return false; + }) as Array; + + const uniqueAvailableUpgrades = availableUpgrades.reduce((acc, release) => { + if (acc[release.version]) return acc; + acc[release.version] = release; + return acc; + }, {} as Record); + + return {availableUpgrades, uniqueAvailableUpgrades}; +} + +/** + * Gets the selected release based on the --version flag or the user's prompt selection + */ +export async function getSelectedRelease({ + targetVersion, + availableUpgrades, + currentVersion, +}: { + targetVersion?: string; + availableUpgrades: Array; + currentVersion: string; +}) { + const targetRelease = targetVersion + ? availableUpgrades.find( + (release) => + getAbsoluteVersion(release.version) === + getAbsoluteVersion(targetVersion), + ) + : undefined; + + return ( + targetRelease ?? promptUpgradeOptions(currentVersion, availableUpgrades) + ); +} + +/** + * Gets an aggregate list of features and fixes included in the upgrade versions range + */ +export function getCummulativeRelease({ + availableUpgrades, + selectedRelease, + currentVersion, + currentDependencies, +}: { + availableUpgrades: Array; + selectedRelease: Release; + currentVersion: string; + currentDependencies?: Dependencies; +}): CumulativeRelease { + const currentPinnedVersion = getAbsoluteVersion(currentVersion); + + if (!availableUpgrades?.length) { + return {features: [], fixes: []}; + } + + const upgradingReleases = availableUpgrades.filter((release) => { + const isHydrogenUpgrade = + semver.gt(release.version, currentPinnedVersion) && + semver.lte(release.version, selectedRelease.version); + + if (isHydrogenUpgrade) return true; + + const isSameHydrogenVersion = + getAbsoluteVersion(release.version) === currentPinnedVersion; + + if (!isSameHydrogenVersion || !currentDependencies) return false; + + return hasOutdatedDependencies({release, currentDependencies}); + }); + + return upgradingReleases.reduce( + (acc, release) => { + acc.features = [...acc.features, ...release.features]; + acc.fixes = [...acc.fixes, ...release.fixes]; + return acc; + }, + {features: [], fixes: []} as CumulativeRelease, + ); +} + +/** + * Displays a confirmation prompt to the user with a list of features and fixes + * included in the upgrade versions range. The user can also return to the + * version selection prompt if they want to choose a different version. + **/ +export function displayConfirmation({ + cumulativeRelease, + selectedRelease, +}: { + cumulativeRelease: CumulativeRelease; + selectedRelease: Release; +}) { + const {features, fixes} = cumulativeRelease; + if (features.length || fixes.length) { + renderInfo({ + headline: `Included in this upgrade:`, + //@ts-ignore we know that filter(Boolean) will always return an array + customSections: [ + features.length && { + title: 'Features', + body: [ + { + list: { + items: features.map((item) => item.title), + }, + }, + ], + }, + fixes.length && { + title: 'Fixes', + body: [ + { + list: { + items: fixes.map((item) => item.title), + }, + }, + ], + }, + ].filter(Boolean), + }); + } + + return renderConfirmationPrompt({ + message: `Are you sure you want to upgrade to ${selectedRelease.version}?`, + cancellationMessage: `No, choose another version`, + defaultValue: true, + }); +} + +function isRemixDependency([name]: [string, string]) { + if (name.includes('@remix-run')) { + return true; + } + return false; +} + +/** + * Checks if a dependency should be included in the upgrade command + */ +function maybeIncludeDependency({ + currentDependencies, + dependency: [name, version], + selectedRelease, +}: { + dependency: [string, string]; + currentDependencies: Dependencies; + selectedRelease: Release; +}) { + const existingDependencyVersion = currentDependencies[name]; + + const isRemixPackage = isRemixDependency([name, version]); + + // Remix dependencies are handled later + if (isRemixPackage) return false; + + const isNextVersion = existingDependencyVersion === 'next'; + + if (isNextVersion) return false; + + // Handle required/conditional dependenciesMeta deps + const depMeta = selectedRelease.dependenciesMeta?.[name]; + + if (!depMeta) return true; + + const isRequired = Boolean( + selectedRelease.dependenciesMeta?.[name]?.required, + ); + + if (!isRequired) return false; + + // Dep meta is required... + if (!existingDependencyVersion) return true; + + const isOlderVersion = semver.lt( + getAbsoluteVersion(existingDependencyVersion), + getAbsoluteVersion(version), + ); + + if (isOlderVersion) return true; + + return false; +} + +/** + * Builds the arguments for the `npm|yarn|pnpm install` command + */ +export function buildUpgradeCommandArgs({ + selectedRelease, + currentDependencies, +}: { + selectedRelease: Release; + currentDependencies: Dependencies; +}) { + const args: string[] = []; + + // upgrade dependencies + for (const dependency of Object.entries(selectedRelease.dependencies)) { + const shouldUpgradeDep = maybeIncludeDependency({ + currentDependencies, + dependency, + selectedRelease, + }); + if (!shouldUpgradeDep) continue; + args.push(`${dependency[0]}@${getAbsoluteVersion(dependency[1])}`); + } + + // upgrade devDependencies + for (const dependency of Object.entries(selectedRelease.devDependencies)) { + const shouldUpgradeDep = maybeIncludeDependency({ + currentDependencies, + dependency, + selectedRelease, + }); + if (!shouldUpgradeDep) continue; + args.push(`${dependency[0]}@${getAbsoluteVersion(dependency[1])}`); + } + + // Maybe upgrade Remix dependencies + const currentRemix = + Object.entries(currentDependencies).find(isRemixDependency); + const selectedRemix = Object.entries(selectedRelease.dependencies).find( + isRemixDependency, + ); + + if (currentRemix && selectedRemix) { + const shouldUpgradeRemix = semver.lt( + getAbsoluteVersion(currentRemix[1]), + getAbsoluteVersion(selectedRemix[1]), + ); + + if (shouldUpgradeRemix) { + args.push( + ...appendRemixDependencies({currentDependencies, selectedRemix}), + ); + } + } + + return args; +} + +/** + * Installs the new Hydrogen dependencies + */ +export async function upgradeNodeModules({ + appPath, + selectedRelease, + currentDependencies, +}: { + appPath: string; + selectedRelease: Release; + currentDependencies: Dependencies; +}) { + await renderTasks( + [ + { + title: `Upgrading dependencies`, + task: async () => { + await installNodeModules({ + directory: appPath, + packageManager: await getPackageManager(appPath), + args: buildUpgradeCommandArgs({ + selectedRelease, + currentDependencies, + }), + }); + }, + }, + ], + {}, + ); +} + +/** + * Appends the current @remix-run dependencies to the upgrade command + */ +function appendRemixDependencies({ + currentDependencies, + selectedRemix, +}: { + currentDependencies: Dependencies; + selectedRemix: [string, string]; +}) { + const command: string[] = []; + for (const [name, version] of Object.entries(currentDependencies)) { + const isRemixPackage = isRemixDependency([name, version]); + if (!isRemixPackage) { + continue; + } + command.push(`${name}@${getAbsoluteVersion(selectedRemix[1])}`); + } + return command; +} + +/** + * Gets the absolute version from a pinned or unpinned version + */ +export function getAbsoluteVersion(version: string) { + const result = semver.minVersion(version); + if (!result) { + throw new AbortError(`Invalid version: ${version}`); + } + return result.version; +} + +/** + * Prompts the user to select a version from the list of available upgrades + */ +async function promptUpgradeOptions( + currentVersion: string, + availableUpgrades: Release[], +) { + if (!availableUpgrades?.length) { + throw new AbortError('No upgrade options available'); + } + + // Build the list of upgrade options to display to the user + const choices = availableUpgrades.map((release, index) => { + const {version, title} = release; + + const tag = + index === 0 + ? '(latest)' + : semver.patch(version) === 0 + ? '(major)' + : getAbsoluteVersion(currentVersion) === getAbsoluteVersion(version) + ? '(outdated)' + : ''; + + // TODO: add group sorting function to cli-kit select prompt + // so that we can group by major version + const majorVersion = `${semver.major(version)}.${semver.minor(version)}`; + + return { + // group: majorVersion, + label: `${version} ${tag} - ${cliTruncate(title, 54)}`, + value: release, + } as Choice; + }); + + return renderSelectPrompt({ + message: `Available Hydrogen versions (current: ${currentVersion})`, + choices: choices, + defaultValue: choices[0]?.value, // Latest version + }); +} + +async function displayDryRunSummary({ + selectedRelease, + instrunctionsFilePath, +}: { + selectedRelease: Release; + instrunctionsFilePath?: string; +}) { + let nextSteps = []; + + if (typeof instrunctionsFilePath === 'string') { + let instructions = `Preview the upgrade instructions at:\nfile://${instrunctionsFilePath}`; + nextSteps.push(instructions); + } + + const releaseNotesUrl = `https://hydrogen.shopify.dev/releases/${selectedRelease.version}`; + + nextSteps.push(`Release notes:\n${releaseNotesUrl}`); + + return renderSuccess({ + headline: + 'This was a dry run. So, nothing was changed in your Hydrogen configuration.', + // @ts-ignore we know that filter(Boolean) will always return an array + customSections: [ + { + title: 'What’s next?', + body: [ + { + list: { + items: nextSteps, + }, + }, + ], + }, + ].filter(Boolean), + }); +} + +/** + * Displays a summary of the upgrade and next steps + */ +async function displayUpgradeSummary({ + currentVersion, + selectedRelease, + instrunctionsFilePath, +}: { + currentVersion: string; + selectedRelease: Release; + instrunctionsFilePath?: string; +}) { + const updatedDependenciesList = [ + ...Object.entries(selectedRelease.dependencies || {}).map( + ([name, version]) => `${name}@${version}`, + ), + ...Object.entries(selectedRelease.devDependencies || {}).map( + ([name, version]) => `${name}@${version}`, + ), + ]; + + let nextSteps = []; + + if (typeof instrunctionsFilePath === 'string') { + let instructions = `Upgrade instructions created at:\nfile://${instrunctionsFilePath}`; + nextSteps.push(instructions); + } + + const releaseNotesUrl = `https://hydrogen.shopify.dev/releases/${selectedRelease.version}`; + + nextSteps.push(`Release notes:\n${releaseNotesUrl}`); + + const currentPinnedVersion = getAbsoluteVersion(currentVersion); + const selectedPinnedVersion = getAbsoluteVersion(selectedRelease.version); + + const upgradedDependenciesOnly = + currentPinnedVersion === selectedPinnedVersion; + + const fromToMsg = `${currentPinnedVersion} → ${selectedPinnedVersion}`; + + const headline = upgradedDependenciesOnly + ? `You've have upgraded Hydrogen ${selectedPinnedVersion} dependencies` + : `You've have upgraded from ${fromToMsg}`; + + return renderSuccess({ + headline, + // @ts-ignore we know that filter(Boolean) will always return an array + customSections: [ + { + title: 'Updated dependencies', + body: [ + { + list: { + items: updatedDependenciesList, + }, + }, + ], + }, + { + title: 'What’s next?', + body: [ + { + list: { + items: nextSteps, + }, + }, + ], + }, + ].filter(Boolean), + }); +} + +/** + * Validate if a h2 upgrade was successful by comparing the previous and current + * @shopify/hydrogen versions + */ +async function validateUpgrade({ + appPath, + selectedRelease, +}: { + appPath: string; + selectedRelease: Release; +}) { + const dependencies = await getDependencies( + path.join(appPath, 'package.json'), + ); + + const updatedVersion = dependencies['@shopify/hydrogen']; + + if (!updatedVersion) { + throw new AbortError('Hydrogen version not found in package.json'); + } + + const updatedPinnedVersion = getAbsoluteVersion(updatedVersion); + + if (updatedPinnedVersion !== selectedRelease.version) { + throw new AbortError( + `Failed to upgrade to Hydrogen version ${selectedRelease.version}`, + `You are still on version ${updatedPinnedVersion}`, + ); + } +} + +/** + * Generates markdown for a release item + */ +function generateStepMd(item: ReleaseItem) { + const {steps} = item; + const heading = `### ${item.title} [#${item.id}](${item.pr})\n`; + const body = steps + ?.map((step, stepIndex) => { + const pr = item.pr ? `[#${item.id}](${item.pr})\n` : ''; + const multiStep = steps.length > 1; + const title = multiStep + ? `#### Step: ${stepIndex + 1}. ${step.title} ${pr}\n` + : `#### ${step.title.trim()}\n`; + const info = step.info ? `> ${step.info}\n` : ''; + const code = step.code ? `${Buffer.from(step.code, 'base64')}\n` : ''; + const docs = item.docs ? `[docs](${item.docs})\n` : ''; + return `${title}${info}${docs}${pr}${code}`; + }) + .join('\n'); + return `${heading}\n${body}`; +} + +/** + * Generates a markdown file with upgrade instructions + */ +async function generateUpgradeInstructionsFile({ + appPath, + cumulativeRelease, + currentVersion, + dryRun, + selectedRelease, +}: { + appPath: string; + cumulativeRelease: CumulativeRelease; + currentVersion: string; + dryRun: boolean; + selectedRelease: Release; +}) { + let filename = ''; + + const {featuresMd, breakingChangesMd} = cumulativeRelease.features + .filter((feature) => feature.steps) + .reduce( + (acc, feature) => { + if (feature.breaking) { + acc.breakingChangesMd.push(generateStepMd(feature)); + } else { + acc.featuresMd.push(generateStepMd(feature)); + } + return acc; + }, + {featuresMd: [], breakingChangesMd: []} as { + featuresMd: string[]; + breakingChangesMd: string[]; + }, + ); + + const fixesMd = cumulativeRelease.fixes + .filter((fixes) => fixes.steps) + .map(generateStepMd); + + if (!featuresMd.length && !fixesMd.length) { + renderInfo({ + headline: `No upgrade instructions generated`, + body: `There are no additional upgrade instructions for this version.`, + }); + return; + } + + const absoluteFrom = getAbsoluteVersion(currentVersion); + const absoluteTo = getAbsoluteVersion(selectedRelease.version); + filename = `${ + dryRun ? 'preview-' : '' + }upgrade-${absoluteFrom}-to-${absoluteTo}.md`; + + const instructionsFolderPath = path.join(appPath, INSTRUCTIONS_FOLDER); + + const h1 = `# Hydrogen upgrade guide: ${absoluteFrom} to ${absoluteTo}`; + + let md = `${h1}\n\n----\n`; + + if (breakingChangesMd.length) { + md += `\n## Breaking changes\n\n${breakingChangesMd.join('\n')}\n----\n`; + } + + if (featuresMd.length) { + md += `\n## Features\n\n${featuresMd.join('\n')}\n----\n`; + } + + if (fixesMd.length) { + md += `\n${featuresMd.length ? '----\n\n' : ''}## Fixes\n\n${fixesMd.join( + '\n', + )}`; + } + + const filePath = path.join(instructionsFolderPath, filename); + + try { + await isDirectory(instructionsFolderPath); + } catch (error) { + await mkdir(instructionsFolderPath); + } + + if (!(await fileExists(filePath))) { + await touchFile(filePath); + } else { + const overwriteMdFile = await renderConfirmationPrompt({ + message: `A previous upgrade instructions file already exists for this version.\nDo you want to overwrite it?`, + defaultValue: false, + }); + + if (overwriteMdFile) { + await removeFile(`${filePath}.old`); + } else { + return; + } + await touchFile(filePath); + } + + await writeFile(filePath, md); + + return `${INSTRUCTIONS_FOLDER}/${filename}`; +} + +/** + * Displays a notice to the user if there are new Hydrogen versions available + * This function is executed by the `dev` command + */ +export async function displayDevUpgradeNotice({ + targetPath, +}: { + targetPath?: string; +}) { + const appPath = targetPath ? path.resolve(targetPath) : process.cwd(); + const {currentVersion} = await getHydrogenVersion({appPath}); + + const changelog = await getChangelog(); + + const {availableUpgrades, uniqueAvailableUpgrades} = getAvailableUpgrades({ + releases: changelog.releases, + currentVersion, + }); + + if (availableUpgrades.length === 0 || !availableUpgrades[0]?.version) { + // Using latest version already or changelog fetch errored + return; + } + + const pinnedLatestVersion = getAbsoluteVersion(availableUpgrades[0].version); + const pinnedCurrentVersion = getAbsoluteVersion(currentVersion); + const currentReleaseIndex = changelog.releases.findIndex((release) => { + const pinnedReleaseVersion = getAbsoluteVersion(release.version); + return pinnedReleaseVersion === pinnedCurrentVersion; + }); + + const uniqueNextReleases = changelog.releases + .slice(0, currentReleaseIndex) + .reverse() + .reduce((acc, release) => { + if (acc[release.version]) return acc; + acc[release.version] = release; + return acc; + }, {} as Record); + + const nextReleases = Object.keys(uniqueNextReleases).length + ? Object.entries(uniqueNextReleases) + .map(([version, release]) => { + return `${version} - ${release.title}`; + }) + .slice(0, 5) + : []; + + let headline = + Object.keys(uniqueAvailableUpgrades).length > 1 + ? `There are ${ + Object.keys(uniqueAvailableUpgrades).length + } new @shopify/hydrogen versions available.` + : `There's a new @shopify/hydrogen version available.`; + + renderInfo({ + headline, + body: [`Current: ${currentVersion} | Latest: ${pinnedLatestVersion}`], + //@ts-ignore will always be an array + customSections: nextReleases.length + ? [ + { + title: `The next ${nextReleases.length} version(s) include`, + body: [ + { + list: { + items: [ + ...nextReleases, + availableUpgrades.length > 5 && `...more`, + ] + .flat() + .filter(Boolean), + }, + }, + ].filter(Boolean), + }, + { + title: 'Next steps', + body: [ + { + list: { + items: [ + `Run \`h2 upgrade\` or \`h2 upgrade --version XXXX.X.XX\``, + , + `Read release notes at https://hydrogen.shopify.dev/releases`, + ], + }, + }, + ], + }, + ] + : [], + }); +}