From e2656ff0a7ee1f02f2613a859ddcb4d96d27e85d Mon Sep 17 00:00:00 2001 From: Markus Renken Date: Wed, 22 Apr 2026 16:14:34 +0200 Subject: [PATCH 1/2] feat(cli): add non-interactive flag to hydrogen upgrade command --- .changeset/upgrade-non-interactive.md | 5 + packages/cli/oclif.manifest.json | 11 ++ .../src/commands/hydrogen/upgrade-e2e.test.ts | 3 +- .../cli/src/commands/hydrogen/upgrade.test.ts | 170 ++++++++++++++++++ packages/cli/src/commands/hydrogen/upgrade.ts | 64 ++++++- 5 files changed, 245 insertions(+), 8 deletions(-) create mode 100644 .changeset/upgrade-non-interactive.md diff --git a/.changeset/upgrade-non-interactive.md b/.changeset/upgrade-non-interactive.md new file mode 100644 index 0000000000..a98d003990 --- /dev/null +++ b/.changeset/upgrade-non-interactive.md @@ -0,0 +1,5 @@ +--- +'@shopify/cli-hydrogen': patch +--- + +Add `--non-interactive` flag (alias `-y`/`--yes`, env `SHOPIFY_HYDROGEN_FLAG_NON_INTERACTIVE`) to `shopify hydrogen upgrade` so it can run in CI and other non-TTY environments. Requires `--version` to be set, auto-accepts the confirmation prompt, and auto-overwrites any existing upgrade instructions file. When run without a TTY and without this flag, the resulting error now points to it. diff --git a/packages/cli/oclif.manifest.json b/packages/cli/oclif.manifest.json index ef365057fc..fc3a65e62f 100644 --- a/packages/cli/oclif.manifest.json +++ b/packages/cli/oclif.manifest.json @@ -1655,6 +1655,17 @@ "name": "force", "allowNo": false, "type": "boolean" + }, + "non-interactive": { + "aliases": [ + "yes" + ], + "char": "y", + "description": "Run without any interactive prompts. Requires --version. Auto-accepts the upgrade confirmation and overwrites any existing upgrade instructions file.", + "env": "SHOPIFY_HYDROGEN_FLAG_NON_INTERACTIVE", + "name": "non-interactive", + "allowNo": false, + "type": "boolean" } }, "hasDynamicHelp": false, diff --git a/packages/cli/src/commands/hydrogen/upgrade-e2e.test.ts b/packages/cli/src/commands/hydrogen/upgrade-e2e.test.ts index 7f1c6981c0..72540bb309 100644 --- a/packages/cli/src/commands/hydrogen/upgrade-e2e.test.ts +++ b/packages/cli/src/commands/hydrogen/upgrade-e2e.test.ts @@ -15,7 +15,7 @@ * UPGRADE_TEST_TO= - Test to specific version (default: latest) * UPGRADE_TEST_LAST_N= - Test last N versions * FORCE_CHANGELOG_SOURCE=local - Read changelog from local file (set by tests) - * SHOPIFY_HYDROGEN_FLAG_FORCE=1 - Skip interactive prompts (set by tests) + * SHOPIFY_HYDROGEN_FLAG_FORCE=1 - Skip dirty git branch check (set by tests) * CI=1 - Enable CI mode (set by tests) * * Key design decisions: @@ -72,6 +72,7 @@ vi.mock('@shopify/cli-kit/node/ui', async () => { return { ...original, + isTTY: vi.fn(() => true), renderTasks: vi.fn(async (tasks) => { for (const task of tasks) { if (task.task && typeof task.task === 'function') { diff --git a/packages/cli/src/commands/hydrogen/upgrade.test.ts b/packages/cli/src/commands/hydrogen/upgrade.test.ts index c363874696..2b4abddb5c 100644 --- a/packages/cli/src/commands/hydrogen/upgrade.test.ts +++ b/packages/cli/src/commands/hydrogen/upgrade.test.ts @@ -5,6 +5,7 @@ import {describe, it, expect, vi, beforeEach} from 'vitest'; import {writeFile, fileExists} from '@shopify/cli-kit/node/fs'; import {joinPath} from '@shopify/cli-kit/node/path'; import { + isTTY, renderSelectPrompt, renderConfirmationPrompt, renderTasks, @@ -48,6 +49,7 @@ vi.mock('@shopify/cli-kit/node/ui', async () => { return { ...original, + isTTY: vi.fn(() => true), renderTasks: vi.fn(() => Promise.resolve()), renderSelectPrompt: vi.fn(() => Promise.resolve()), renderConfirmationPrompt: vi.fn(() => Promise.resolve(false)), @@ -2001,6 +2003,174 @@ describe('upgrade', async () => { expect(args).toEqual(result); }); }); + + describe('non-interactive mode', () => { + it('throws when --non-interactive is set without --version', async () => { + await inTemporaryHydrogenRepo( + async (appPath) => { + await expect( + runUpgrade({appPath, nonInteractive: true}), + ).rejects.toThrowError('--non-interactive flag requires --version'); + }, + { + cleanGitRepo: true, + packageJson: OUTDATED_HYDROGEN_PACKAGE_JSON, + }, + ); + }); + + it('throws with a hint pointing at --non-interactive when stdin is not a TTY', async () => { + vi.mocked(isTTY).mockReturnValueOnce(false); + + await inTemporaryHydrogenRepo( + async (appPath) => { + await expect(runUpgrade({appPath})).rejects.toThrowError( + /requires an interactive terminal/, + ); + }, + { + cleanGitRepo: true, + packageJson: OUTDATED_HYDROGEN_PACKAGE_JSON, + }, + ); + }); + + it('bypasses the TTY check when --non-interactive is set', async () => { + vi.mocked(isTTY).mockReturnValue(false); + + await inTemporaryHydrogenRepo( + async (appPath) => { + // Without --version this should fail for the preconditions reason, + // not for the TTY reason — proving the TTY gate didn't trigger. + await expect( + runUpgrade({appPath, nonInteractive: true}), + ).rejects.toThrowError('--non-interactive flag requires --version'); + }, + { + cleanGitRepo: true, + packageJson: OUTDATED_HYDROGEN_PACKAGE_JSON, + }, + ); + }); + + it('throws from getSelectedRelease when --version is not a known upgrade target', async () => { + await inTemporaryHydrogenRepo( + async (appPath) => { + const {releases} = await getChangelog(); + const current = await getHydrogenVersion({appPath}); + const {availableUpgrades} = getAvailableUpgrades({ + ...current, + releases, + }); + + await expect( + getSelectedRelease({ + availableUpgrades, + currentVersion: current.currentVersion, + currentDependencies: current.currentDependencies, + targetVersion: '9999.99.99', + nonInteractive: true, + }), + ).rejects.toThrowError( + /9999\.99\.99 is not an available Hydrogen upgrade target/, + ); + + expect(renderSelectPrompt).not.toHaveBeenCalled(); + }, + { + cleanGitRepo: true, + packageJson: OUTDATED_HYDROGEN_PACKAGE_JSON, + }, + ); + }); + + it('auto-confirms displayConfirmation when nonInteractive is set', async () => { + const selectedRelease = { + title: 'Test release', + version: '2025.7.0', + } as Release; + + const result = await displayConfirmation({ + cumulativeRelease: CUMULATIVE_RELEASE, + selectedRelease, + nonInteractive: true, + }); + + expect(result).toEqual(true); + expect(renderConfirmationPrompt).not.toHaveBeenCalled(); + }); + + it('bypasses the TTY check when --version=next is passed', async () => { + vi.mocked(isTTY).mockReturnValue(false); + + await inTemporaryHydrogenRepo( + async (appPath) => { + await expect( + runUpgrade({appPath, version: 'next', force: false}), + ).resolves.toBeUndefined(); + }, + { + cleanGitRepo: true, + packageJson: { + dependencies: { + '@shopify/hydrogen': '2025.4.0', + }, + }, + }, + ); + }); + + it('overwrites an existing upgrade instructions file without prompting', async () => { + await inTemporaryHydrogenRepo( + async (appPath) => { + const {releases} = await getChangelog(); + + const selectedRelease = releases.find( + (release) => release.version === '2023.10.0', + ) as (typeof releases)[0]; + + const releaseWithRemovals: CumulativeRelease = { + ...CUMULATIVE_RELEASE, + removeDependencies: ['@remix-run/react'], + removeDevDependencies: ['@remix-run/dev'], + }; + + // First run creates the instructions file. + const firstPath = await generateUpgradeInstructionsFile({ + appPath, + cumulativeRelease: releaseWithRemovals, + currentVersion: '2023.1.6', + selectedRelease, + }); + + expect(firstPath).toBeDefined(); + + // Second run, non-interactive, should overwrite without prompting. + const secondPath = await generateUpgradeInstructionsFile({ + appPath, + cumulativeRelease: releaseWithRemovals, + currentVersion: '2023.1.6', + selectedRelease, + nonInteractive: true, + }); + + expect(secondPath).toBe(firstPath); + expect(renderConfirmationPrompt).not.toHaveBeenCalled(); + + const mdContent = await readFile( + joinPath(appPath, secondPath!), + 'utf8', + ); + + expect(mdContent).toContain('## Removed packages'); + }, + { + cleanGitRepo: true, + packageJson: OUTDATED_HYDROGEN_PACKAGE_JSON, + }, + ); + }); + }); }); // cumulative result when upgrading from 2023.1.6 (outdated) to 2023.4.1 diff --git a/packages/cli/src/commands/hydrogen/upgrade.ts b/packages/cli/src/commands/hydrogen/upgrade.ts index 397e377744..3f0b4d12e9 100644 --- a/packages/cli/src/commands/hydrogen/upgrade.ts +++ b/packages/cli/src/commands/hydrogen/upgrade.ts @@ -5,6 +5,7 @@ import {Flags} from '@oclif/core'; import {isClean, ensureInsideGitDirectory} from '@shopify/cli-kit/node/git'; import Command from '@shopify/cli-kit/node/base-command'; import { + isTTY, renderConfirmationPrompt, renderInfo, renderSelectPrompt, @@ -108,6 +109,13 @@ export default class Upgrade extends Command { env: 'SHOPIFY_HYDROGEN_FLAG_FORCE', char: 'f', }), + 'non-interactive': Flags.boolean({ + description: + 'Run without any interactive prompts. Requires --version. Auto-accepts the upgrade confirmation and overwrites any existing upgrade instructions file.', + env: 'SHOPIFY_HYDROGEN_FLAG_NON_INTERACTIVE', + char: 'y', + aliases: ['yes'], + }), }; async run(): Promise { @@ -126,13 +134,35 @@ type UpgradeOptions = { appPath: string; version?: string; force?: boolean; + nonInteractive?: boolean; }; export async function runUpgrade({ appPath, version: targetVersion, force, + nonInteractive, }: UpgradeOptions) { + if (nonInteractive && !targetVersion) { + throw new AbortError( + 'The --non-interactive flag requires --version to be set.', + 'Re-run with `shopify hydrogen upgrade --version --non-interactive`.', + ); + } + + // Fail fast when there's no TTY and the caller hasn't opted into a + // non-interactive mode. Uses the same helper cli-kit gates its prompts on, + // so we short-circuit before any git/changelog/network work and point the + // user at the flag instead of the generic "Failed to prompt" error. + // `--version=next` is an internal CI-oriented path that already auto-skips + // the main confirmation prompt, so treat it as implicitly non-interactive. + if (!nonInteractive && targetVersion !== 'next' && !isTTY()) { + throw new AbortError( + 'The `hydrogen upgrade` command requires an interactive terminal.', + 'Re-run with `--version --non-interactive` (or set `SHOPIFY_HYDROGEN_FLAG_NON_INTERACTIVE=1`) to skip prompts, or run the command in a TTY.', + ); + } + // --version=next is only available when running from monorepo, tests, or CI if (targetVersion === 'next') { const isInTests = process.env.SHOPIFY_UNIT_TEST === '1'; @@ -208,6 +238,7 @@ export async function runUpgrade({ targetVersion, availableUpgrades, currentDependencies, + nonInteractive, }); // Get an aggregate list of features and fixes included in the upgrade versions range @@ -222,15 +253,19 @@ export async function runUpgrade({ cumulativeRelease, selectedRelease, targetVersion, + nonInteractive, }); } while (!confirmed); - // Generate a markdown file with upgrade instructions + // Generate a markdown file with upgrade instructions. + // `--version=next` is treated as implicitly non-interactive to stay + // consistent with how `displayConfirmation` handles it. const instrunctionsFilePathPromise = generateUpgradeInstructionsFile({ appPath, cumulativeRelease, currentVersion, selectedRelease, + nonInteractive: nonInteractive || targetVersion === 'next', }); await upgradeNodeModules({ @@ -542,11 +577,13 @@ export async function getSelectedRelease({ availableUpgrades, currentVersion, currentDependencies, + nonInteractive, }: { targetVersion?: string; availableUpgrades: Array; currentVersion: string; currentDependencies: Record; + nonInteractive?: boolean; }) { const targetRelease = targetVersion ? targetVersion === 'next' @@ -558,6 +595,13 @@ export async function getSelectedRelease({ ) : undefined; + if (!targetRelease && nonInteractive) { + throw new AbortError( + `Version ${targetVersion} is not an available Hydrogen upgrade target.`, + 'Pick one of the versions listed at https://hydrogen.shopify.dev/releases or run without --non-interactive to choose from a prompt.', + ); + } + return ( targetRelease ?? promptUpgradeOptions(currentVersion, availableUpgrades) ); @@ -720,10 +764,12 @@ export function displayConfirmation({ cumulativeRelease, selectedRelease, targetVersion, + nonInteractive, }: { cumulativeRelease: CumulativeRelease; selectedRelease: Release; targetVersion?: string; + nonInteractive?: boolean; }) { const {features, fixes} = cumulativeRelease; const allRemovedPackages = getAllRemovedPackages(cumulativeRelease); @@ -767,8 +813,8 @@ export function displayConfirmation({ }); } - // Skip confirmation for next version upgrades - if (targetVersion === 'next') { + // Skip confirmation for next version upgrades or when running non-interactively + if (targetVersion === 'next' || nonInteractive) { return true; } @@ -1415,11 +1461,13 @@ export async function generateUpgradeInstructionsFile({ cumulativeRelease, currentVersion, selectedRelease, + nonInteractive, }: { appPath: string; cumulativeRelease: CumulativeRelease; currentVersion: string; selectedRelease: Release; + nonInteractive?: boolean; }) { let filename = ''; @@ -1501,10 +1549,12 @@ export async function generateUpgradeInstructionsFile({ 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, - }); + const overwriteMdFile = nonInteractive + ? true + : 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`); From dce36bccc44254584aadfadc5df5d690a9b937fd Mon Sep 17 00:00:00 2001 From: Markus Renken Date: Mon, 27 Apr 2026 12:17:27 +0200 Subject: [PATCH 2/2] chore(cli): fix typo in variable name --- packages/cli/src/commands/hydrogen/upgrade.ts | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/packages/cli/src/commands/hydrogen/upgrade.ts b/packages/cli/src/commands/hydrogen/upgrade.ts index 3f0b4d12e9..cb566432ce 100644 --- a/packages/cli/src/commands/hydrogen/upgrade.ts +++ b/packages/cli/src/commands/hydrogen/upgrade.ts @@ -260,7 +260,7 @@ export async function runUpgrade({ // Generate a markdown file with upgrade instructions. // `--version=next` is treated as implicitly non-interactive to stay // consistent with how `displayConfirmation` handles it. - const instrunctionsFilePathPromise = generateUpgradeInstructionsFile({ + const instructionsFilePathPromise = generateUpgradeInstructionsFile({ appPath, cumulativeRelease, currentVersion, @@ -284,13 +284,13 @@ export async function runUpgrade({ targetVersion, }); - const instrunctionsFilePath = await instrunctionsFilePathPromise; + const instructionsFilePath = await instructionsFilePathPromise; // Display a summary of the upgrade and next steps await displayUpgradeSummary({ appPath, currentVersion, - instrunctionsFilePath, + instructionsFilePath, selectedRelease, }); } @@ -1306,12 +1306,12 @@ async function displayUpgradeSummary({ appPath, currentVersion, selectedRelease, - instrunctionsFilePath, + instructionsFilePath, }: { appPath: string; currentVersion: string; selectedRelease: Release; - instrunctionsFilePath?: string; + instructionsFilePath?: string; }) { const updatedDependenciesList = [ ...Object.entries(selectedRelease.dependencies || {}).map( @@ -1324,8 +1324,8 @@ async function displayUpgradeSummary({ let nextSteps = []; - if (typeof instrunctionsFilePath === 'string') { - let instructions = `Upgrade instructions created at:\nfile://${instrunctionsFilePath}`; + if (typeof instructionsFilePath === 'string') { + let instructions = `Upgrade instructions created at:\nfile://${instructionsFilePath}`; nextSteps.push(instructions); }