From 5ff252b9f61952615a52aeaf568375d10c6e86ca Mon Sep 17 00:00:00 2001 From: Gonzalo Riestra Date: Mon, 28 Sep 2026 16:47:18 +0200 Subject: [PATCH] Add typed JSON output to Hydrogen maintenance commands --- .changeset/hydrogen-finite-json-output.md | 5 + packages/cli/README.md | 22 ++++ .../cli/json-output-command-exceptions.cjs | 5 - packages/cli/oclif.manifest.json | 22 +++- .../hydrogen/maintenance-json.test.ts | 56 ++++++++++ .../cli/src/commands/hydrogen/shortcut.ts | 30 ++++- .../cli/src/commands/hydrogen/upgrade.test.ts | 7 ++ packages/cli/src/commands/hydrogen/upgrade.ts | 103 ++++++++++++++---- packages/cli/src/lib/maintenance/types.ts | 31 ++++++ 9 files changed, 248 insertions(+), 33 deletions(-) create mode 100644 .changeset/hydrogen-finite-json-output.md create mode 100644 packages/cli/src/commands/hydrogen/maintenance-json.test.ts create mode 100644 packages/cli/src/lib/maintenance/types.ts diff --git a/.changeset/hydrogen-finite-json-output.md b/.changeset/hydrogen-finite-json-output.md new file mode 100644 index 0000000000..687eca54de --- /dev/null +++ b/.changeset/hydrogen-finite-json-output.md @@ -0,0 +1,5 @@ +--- +'@shopify/cli-hydrogen': minor +--- + +Add typed JSON output and discoverable result schemas to finite Hydrogen CLI commands. Use `--json` for a machine-readable result or `--json-schema` to inspect its schema. Progress and diagnostics use JSON events on stderr, while deployment CI files and environment file updates retain their existing behavior. Build and codegen watch modes cannot be combined with `--json`. diff --git a/packages/cli/README.md b/packages/cli/README.md index 889261696f..cbc2bbec91 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -4,6 +4,28 @@ The Hydrogen extension for the [Shopify CLI](https://shopify.dev/apps/tools/cli) [Check out the docs](https://shopify.dev/custom-storefronts/hydrogen) +## JSON output + +Finite commands support `--json` and `--json-schema`: + +```sh +shopify hydrogen list --json +shopify hydrogen env pull --force --json +shopify hydrogen deploy --json-schema +``` + +Successful results are written as one JSON document to stdout. Progress and +diagnostics use JSON events on stderr. Fatal errors use the CLI's shared error +document and a nonzero exit status. JSON output does not change confirmation +prompts or authentication requirements. + +Environment pull and push return receipts containing variable names and file +details, without printing variable values. Deployment's `--json-output` option +still controls its CI file independently of `--json`. + +`dev`, `preview`, and `debug cpu` are streaming commands. The finite JSON result +format also excludes `build --watch` and `codegen --watch`. + ## Contributing The most common way to test the cli changes locally is to do the following: diff --git a/packages/cli/json-output-command-exceptions.cjs b/packages/cli/json-output-command-exceptions.cjs index f3954da425..9c601da7b4 100644 --- a/packages/cli/json-output-command-exceptions.cjs +++ b/packages/cli/json-output-command-exceptions.cjs @@ -1,10 +1,5 @@ // Exact repository-relative command paths exempt from the typed JSON output rule. const commandExceptions = [ - // Existing finite commands awaiting migration. Remove entries as they adopt typed JSON output. - // Do not add new finite commands to this section. - 'packages/cli/src/commands/hydrogen/shortcut.ts', - 'packages/cli/src/commands/hydrogen/upgrade.ts', - // Streaming commands without a single finite result. 'packages/cli/src/commands/hydrogen/debug/cpu.ts', 'packages/cli/src/commands/hydrogen/dev.ts', diff --git a/packages/cli/oclif.manifest.json b/packages/cli/oclif.manifest.json index 02aab4ef9c..5c93326362 100644 --- a/packages/cli/oclif.manifest.json +++ b/packages/cli/oclif.manifest.json @@ -1990,7 +1990,7 @@ "hydrogen:shortcut": { "aliases": [], "args": {}, - "description": "Creates a global `h2` shortcut for the Hydrogen CLI", + "description": "Creates a global h2 shortcut for Shopify CLI using shell aliases.\n\n The following shells are supported:\n\n - Bash (using `~/.bashrc`)\n - ZSH (using `~/.zshrc`)\n - Fish (using `~/.config/fish/functions`)\n - PowerShell (added to `$PROFILE`)\n\n After the alias is created, you can call Shopify CLI from anywhere in your project using `h2 `.\n\nUse `--json-schema` to print the result, error, and event schemas.\n\nOutput from `--json` conforms to the `HydrogenShortcutResult` schema.\n\n```json\n{\n \"type\": \"object\",\n \"properties\": {\n \"alias\": {\n \"type\": \"string\"\n },\n \"shells\": {\n \"type\": \"array\",\n \"items\": {\n \"type\": \"string\"\n },\n \"minItems\": 1\n }\n },\n \"required\": [\n \"alias\",\n \"shells\"\n ],\n \"additionalProperties\": false,\n \"title\": \"HydrogenShortcutResult\",\n \"$schema\": \"http://json-schema.org/draft-07/schema#\"\n}\n```", "flags": { "json-schema": { "description": "Print the command's JSON schemas.", @@ -1998,6 +1998,15 @@ "name": "json-schema", "allowNo": false, "type": "boolean" + }, + "json": { + "char": "j", + "description": "Output the result as JSON. Automatically disables color output.", + "env": "SHOPIFY_FLAG_JSON", + "hidden": false, + "name": "json", + "allowNo": false, + "type": "boolean" } }, "hasDynamicHelp": false, @@ -2067,7 +2076,7 @@ "hydrogen:upgrade": { "aliases": [], "args": {}, - "description": "Upgrade Remix and Hydrogen npm dependencies.", + "description": "Upgrade Hydrogen project dependencies, preview features, fixes and breaking changes. The command also generates an instruction file for each upgrade.\n\nUse `--json-schema` to print the result, error, and event schemas.\n\nOutput from `--json` conforms to the `HydrogenUpgradeResult` schema.\n\n```json\n{\n \"type\": \"object\",\n \"properties\": {\n \"status\": {\n \"type\": \"string\",\n \"enum\": [\n \"upgraded\",\n \"unchanged\"\n ]\n },\n \"directory\": {\n \"type\": \"string\"\n },\n \"currentVersion\": {\n \"type\": \"string\"\n },\n \"version\": {\n \"type\": \"string\"\n },\n \"packages\": {\n \"type\": \"array\",\n \"items\": {\n \"type\": \"string\"\n }\n },\n \"removedPackages\": {\n \"type\": \"array\",\n \"items\": {\n \"type\": \"string\"\n }\n },\n \"instructionsFile\": {\n \"type\": \"string\"\n }\n },\n \"required\": [\n \"status\",\n \"directory\",\n \"currentVersion\",\n \"version\",\n \"packages\",\n \"removedPackages\"\n ],\n \"additionalProperties\": false,\n \"title\": \"HydrogenUpgradeResult\",\n \"$schema\": \"http://json-schema.org/draft-07/schema#\"\n}\n```", "flags": { "json-schema": { "description": "Print the command's JSON schemas.", @@ -2076,6 +2085,15 @@ "allowNo": false, "type": "boolean" }, + "json": { + "char": "j", + "description": "Output the result as JSON. Automatically disables color output.", + "env": "SHOPIFY_FLAG_JSON", + "hidden": false, + "name": "json", + "allowNo": false, + "type": "boolean" + }, "path": { "description": "The path to the directory of the Hydrogen storefront. Defaults to the current directory where the command is run.", "env": "SHOPIFY_HYDROGEN_FLAG_PATH", diff --git a/packages/cli/src/commands/hydrogen/maintenance-json.test.ts b/packages/cli/src/commands/hydrogen/maintenance-json.test.ts new file mode 100644 index 0000000000..04b4e8ce44 --- /dev/null +++ b/packages/cli/src/commands/hydrogen/maintenance-json.test.ts @@ -0,0 +1,56 @@ +import {beforeEach, expect, it, vi} from 'vitest'; +import {captureJsonOutput} from '../../../tests/output.js'; +import {createPlatformShortcut} from '../../lib/shell.js'; +import Shortcut, {runCreateShortcut} from './shortcut.js'; +import Upgrade, {presentUpgradeResult} from './upgrade.js'; + +vi.mock('../../lib/shell.js'); +beforeEach(() => vi.clearAllMocks()); + +it('encodes the shortcut and shells without the success banner', async () => { + vi.mocked(createPlatformShortcut).mockResolvedValue(['zsh', 'bash']); + const {stdout, stderr} = await captureJsonOutput(() => runCreateShortcut()); + expect(JSON.parse(stdout)).toEqual({alias: 'h2', shells: ['zsh', 'bash']}); + expect(stderr).toBe(''); +}); + +it('keeps unsupported shells on the fatal-error path in JSON mode', async () => { + vi.mocked(createPlatformShortcut).mockResolvedValue([]); + const {stdout} = await captureJsonOutput(async () => { + await expect(runCreateShortcut()).rejects.toThrow( + 'No supported shell found', + ); + }); + expect(stdout).toBe(''); +}); + +it.each(['upgraded', 'unchanged'] as const)( + 'encodes %s results through the real upgrade presenter and writer', + async (status) => { + const result = { + status, + directory: '/project', + currentVersion: '2026.1.0', + version: '2026.4.0', + packages: ['@shopify/hydrogen@2026.4.0'], + removedPackages: ['@remix-run/react'], + instructionsFile: '.hydrogen/upgrade.md', + }; + const {stdout, stderr} = await captureJsonOutput(() => + presentUpgradeResult(result), + ); + expect(JSON.parse(stdout)).toEqual(result); + expect(stderr).toBe(''); + expect(() => + Upgrade.jsonOutputSchema.encode({...result, packages: [1]} as any), + ).toThrow(); + }, +); + +it.each([Shortcut, Upgrade])( + 'exposes JSON flags and discoverable schemas: %s', + (command) => { + expect(command.flags.json).toBeDefined(); + expect(command.description).toContain(command.jsonOutputSchema.name); + }, +); diff --git a/packages/cli/src/commands/hydrogen/shortcut.ts b/packages/cli/src/commands/hydrogen/shortcut.ts index 16d62110fc..cbd38add0a 100644 --- a/packages/cli/src/commands/hydrogen/shortcut.ts +++ b/packages/cli/src/commands/hydrogen/shortcut.ts @@ -1,8 +1,16 @@ +import {AbortError} from '@shopify/cli-kit/node/error'; +import {writeJsonResult, isJsonOutput} from '../../lib/json-output.js'; +import {jsonFlag} from '@shopify/cli-kit/node/cli'; +import {shortcutJsonOutputSchema} from '../../lib/maintenance/types.js'; import Command from '@shopify/cli-kit/node/base-command'; import {renderFatalError, renderSuccess} from '../../lib/ui.js'; import {ALIAS_NAME, createPlatformShortcut} from '../../lib/shell.js'; export default class Shortcut extends Command { + static get jsonOutputSchema(): typeof shortcutJsonOutputSchema { + return shortcutJsonOutputSchema; + } + static descriptionWithMarkdown = `Creates a global h2 shortcut for Shopify CLI using shell aliases. The following shells are supported: @@ -14,23 +22,39 @@ export default class Shortcut extends Command { After the alias is created, you can call Shopify CLI from anywhere in your project using \`h2 \`.`; - static description = `Creates a global \`${ALIAS_NAME}\` shortcut for the Hydrogen CLI`; + static description = this.descriptionForHelp(); + + static flags = {...jsonFlag}; async run(): Promise { - await runCreateShortcut(); + const {flags} = await this.parse(Shortcut); + await runCreateShortcut(flags.json); } } -export async function runCreateShortcut() { +export async function runCreateShortcut(json?: boolean) { const shortcuts = await createPlatformShortcut(); if (shortcuts.length > 0) { + if ( + writeJsonResult( + shortcutJsonOutputSchema, + {alias: ALIAS_NAME, shells: shortcuts}, + json, + ) + ) + return; renderSuccess({ headline: `Shortcut ready for the following shells: ${shortcuts.join( ', ', )}.\nRestart your terminal session and run \`${ALIAS_NAME}\` from your local project.`, }); } else { + if (json ?? isJsonOutput()) + throw new AbortError( + 'No supported shell found.', + 'Please create a shortcut manually.', + ); renderFatalError({ name: 'error', type: 0, diff --git a/packages/cli/src/commands/hydrogen/upgrade.test.ts b/packages/cli/src/commands/hydrogen/upgrade.test.ts index a8ece0df3c..3a455111c3 100644 --- a/packages/cli/src/commands/hydrogen/upgrade.test.ts +++ b/packages/cli/src/commands/hydrogen/upgrade.test.ts @@ -1,3 +1,4 @@ +import {captureJsonOutput} from '../../../tests/output.js'; import {createRequire} from 'node:module'; import {tmpdir} from 'node:os'; import {mkdtemp, readFile, rm} from 'node:fs/promises'; @@ -455,6 +456,12 @@ describe('upgrade', async () => { expect(outputMock.info()).toMatch( / success.+ latest Hydrogen version/is, ); + const {stdout} = await captureJsonOutput(() => runUpgrade({appPath})); + expect(JSON.parse(stdout)).toMatchObject({ + status: 'unchanged', + packages: [], + removedPackages: [], + }); }, { cleanGitRepo: true, diff --git a/packages/cli/src/commands/hydrogen/upgrade.ts b/packages/cli/src/commands/hydrogen/upgrade.ts index 4562a16617..251b192ce9 100644 --- a/packages/cli/src/commands/hydrogen/upgrade.ts +++ b/packages/cli/src/commands/hydrogen/upgrade.ts @@ -1,3 +1,7 @@ +import {outputWarn} from '@shopify/cli-kit/node/output'; +import {writeJsonResult} from '../../lib/json-output.js'; +import {jsonFlag} from '@shopify/cli-kit/node/cli'; +import {upgradeJsonOutputSchema} from '../../lib/maintenance/types.js'; import {createRequire} from 'node:module'; import semver from 'semver'; import cliTruncate from 'cli-truncate'; @@ -90,12 +94,17 @@ function getAllRemovedPackages(release: CumulativeRelease): string[] { const INSTRUCTIONS_FOLDER = '.hydrogen'; export default class Upgrade extends Command { + static get jsonOutputSchema(): typeof upgradeJsonOutputSchema { + return upgradeJsonOutputSchema; + } + static descriptionWithMarkdown = 'Upgrade Hydrogen project dependencies, preview features, fixes and breaking changes. The command also generates an instruction file for each upgrade.'; - static description = 'Upgrade Remix and Hydrogen npm dependencies.'; + static description = this.descriptionForHelp(); static flags = { + ...jsonFlag, ...commonFlags.path, version: Flags.string({ description: 'A target hydrogen version to update to', @@ -113,10 +122,13 @@ export default class Upgrade extends Command { async run(): Promise { const {flags} = await this.parse(Upgrade); - await runUpgrade({ - ...flagsToCamelObject(flags), - appPath: flags.path ? resolvePath(flags.path) : process.cwd(), - }); + await runUpgrade( + { + ...flagsToCamelObject(flags), + appPath: flags.path ? resolvePath(flags.path) : process.cwd(), + }, + flags.json, + ); } } @@ -128,11 +140,39 @@ type UpgradeOptions = { force?: boolean; }; -export async function runUpgrade({ +export async function runUpgrade(options: UpgradeOptions, json?: boolean) { + const {result, selectedRelease} = await executeUpgrade(options); + await presentUpgradeResult(result, selectedRelease, json); +} + +export async function presentUpgradeResult( + result: import('../../lib/maintenance/types.js').UpgradeResult, + selectedRelease?: Release, + json?: boolean, +) { + if (writeJsonResult(upgradeJsonOutputSchema, result, json)) return; + if (result.status === 'unchanged') { + renderSuccess({ + headline: `You are on the latest Hydrogen version: ${result.version}`, + }); + } else { + await displayUpgradeSummary({ + appPath: result.directory, + currentVersion: result.currentVersion, + selectedRelease: selectedRelease!, + instrunctionsFilePath: result.instructionsFile, + }); + } +} + +export async function executeUpgrade({ appPath, version: targetVersion, force = false, -}: UpgradeOptions) { +}: UpgradeOptions): Promise<{ + result: import('../../lib/maintenance/types.js').UpgradeResult; + selectedRelease?: Release; +}> { // --version=next is only available when running from monorepo, tests, or CI if (targetVersion === 'next') { const isInTests = process.env.SHOPIFY_UNIT_TEST === '1'; @@ -188,13 +228,17 @@ export async function runUpgrade({ }); if (!availableUpgrades?.length) { - renderSuccess({ - headline: `You are on the latest Hydrogen version: ${getAbsoluteVersion( - currentVersion, - )}`, - }); - - return; + const version = getAbsoluteVersion(currentVersion); + return { + result: { + status: 'unchanged', + directory: appPath, + currentVersion: version, + version, + packages: [], + removedPackages: [], + }, + }; } let confirmed = false; @@ -254,13 +298,27 @@ export async function runUpgrade({ const instrunctionsFilePath = await instrunctionsFilePathPromise; - // Display a summary of the upgrade and next steps - await displayUpgradeSummary({ - appPath, - currentVersion, - instrunctionsFilePath, + return { selectedRelease, - }); + result: { + status: 'upgraded', + directory: appPath, + currentVersion: getAbsoluteVersion(currentVersion), + version: getAbsoluteVersion(selectedRelease.version), + instructionsFile: instrunctionsFilePath, + packages: buildUpgradeCommandArgs({ + selectedRelease, + currentDependencies, + targetVersion, + cumulativeDependencies: cumulativeRelease.dependencies, + cumulativeDevDependencies: cumulativeRelease.devDependencies, + }), + removedPackages: [ + ...cumulativeRelease.removeDependencies, + ...cumulativeRelease.removeDevDependencies, + ].filter((name) => name in currentDependencies), + }, + }; } /** @@ -409,9 +467,8 @@ export async function getChangelog(): Promise { CACHED_CHANGELOG = changelog; return changelog; } catch (error) { - console.warn( - `Failed to load local changelog from ${localChangelogPath}:`, - (error as Error).message, + outputWarn( + `Failed to load local changelog from ${localChangelogPath}: ${(error as Error).message}`, ); // Fall through to remote fetch if local fails and not explicitly forced if (process.env.FORCE_CHANGELOG_SOURCE === 'local') { diff --git a/packages/cli/src/lib/maintenance/types.ts b/packages/cli/src/lib/maintenance/types.ts new file mode 100644 index 0000000000..f627d54512 --- /dev/null +++ b/packages/cli/src/lib/maintenance/types.ts @@ -0,0 +1,31 @@ +import { + defineJsonOutputSchema, + type InferJsonOutputSchema, +} from '@shopify/cli-kit/node/json-output-schema'; +import {zod} from '@shopify/cli-kit/node/schema'; + +export const shortcutJsonOutputSchema = defineJsonOutputSchema({ + name: 'HydrogenShortcutResult', + schema: zod.object({ + alias: zod.string(), + shells: zod.array(zod.string()).min(1), + }), +}); +export type ShortcutResult = InferJsonOutputSchema< + typeof shortcutJsonOutputSchema +>; +export const upgradeJsonOutputSchema = defineJsonOutputSchema({ + name: 'HydrogenUpgradeResult', + schema: zod.object({ + status: zod.enum(['upgraded', 'unchanged']), + directory: zod.string(), + currentVersion: zod.string(), + version: zod.string(), + packages: zod.array(zod.string()), + removedPackages: zod.array(zod.string()), + instructionsFile: zod.string().optional(), + }), +}); +export type UpgradeResult = InferJsonOutputSchema< + typeof upgradeJsonOutputSchema +>;