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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/hydrogen-finite-json-output.md
Original file line number Diff line number Diff line change
@@ -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`.
22 changes: 22 additions & 0 deletions packages/cli/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
5 changes: 0 additions & 5 deletions packages/cli/json-output-command-exceptions.cjs
Original file line number Diff line number Diff line change
@@ -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',
Expand Down
22 changes: 20 additions & 2 deletions packages/cli/oclif.manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -1990,14 +1990,23 @@
"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 <command>`.\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.",
"env": "SHOPIFY_FLAG_JSON_SCHEMA",
"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,
Expand Down Expand Up @@ -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.",
Expand All @@ -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",
Expand Down
56 changes: 56 additions & 0 deletions packages/cli/src/commands/hydrogen/maintenance-json.test.ts
Original file line number Diff line number Diff line change
@@ -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);
},
);
30 changes: 27 additions & 3 deletions packages/cli/src/commands/hydrogen/shortcut.ts
Original file line number Diff line number Diff line change
@@ -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:
Expand All @@ -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 <command>\`.`;

static description = `Creates a global \`${ALIAS_NAME}\` shortcut for the Hydrogen CLI`;
static description = this.descriptionForHelp();

static flags = {...jsonFlag};

async run(): Promise<void> {
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,
Expand Down
7 changes: 7 additions & 0 deletions packages/cli/src/commands/hydrogen/upgrade.test.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -455,6 +456,12 @@ describe('upgrade', async () => {
expect(outputMock.info()).toMatch(
/ success.+ latest Hydrogen version/is,
);
const {stdout} = await captureJsonOutput(() => runUpgrade({appPath}));

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

non-blocking: love that the unchanged path is covered end to end. The upgraded path is only covered with a hand-built result through the presenter though (in maintenance-json.test.ts), so nothing checks what executeUpgrade actually puts in packages, removedPackages and instructionsFile. Those are the new bits of logic in this PR.

Would be worth adding a JSON assertion to one of the existing real upgrade flows (e.g. the one around line 2418) so a regression in how the receipt is built gets caught.

expect(JSON.parse(stdout)).toMatchObject({
status: 'unchanged',
packages: [],
removedPackages: [],
});
},
{
cleanGitRepo: true,
Expand Down
103 changes: 80 additions & 23 deletions packages/cli/src/commands/hydrogen/upgrade.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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',
Expand All @@ -113,10 +122,13 @@ export default class Upgrade extends Command {
async run(): Promise<void> {
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,
);
}
}

Expand All @@ -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!,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

non-blocking: presentUpgradeResult is exported and accepts status: 'upgraded' without a selectedRelease, so the only thing stopping displayUpgradeSummary crashing on selectedRelease.version is this !. It works today because runUpgrade always passes them together, but it's easy to hold wrong.

Let's make executeUpgrade return a discriminated union so the types enforce the pairing, yeah? Something like:

type UpgradeExecution =
  | {result: UpgradeResult & {status: 'unchanged'}}
  | {result: UpgradeResult & {status: 'upgraded'}; selectedRelease: Release};

and have presentUpgradeResult take the whole UpgradeExecution. That also lets us swap the inline import('../../lib/maintenance/types.js').UpgradeResult annotations for a normal type import, since the module is already imported at the top.

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';
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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),
},
};
}

/**
Expand Down Expand Up @@ -409,9 +467,8 @@ export async function getChangelog(): Promise<ChangeLog> {
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') {
Expand Down
Loading
Loading