From 26229df2dadd19f071e9662d835d8280318b578e Mon Sep 17 00:00:00 2001 From: Gonzalo Riestra Date: Mon, 28 Sep 2026 16:38:12 +0200 Subject: [PATCH] Add typed JSON output to Hydrogen route generation --- .../cli/json-output-command-exceptions.cjs | 3 - packages/cli/oclif.manifest.json | 23 +++++- packages/cli/src/commands/hydrogen/g.ts | 2 + .../commands/hydrogen/generate/route.test.ts | 77 ++++++++++++++++++- .../src/commands/hydrogen/generate/route.ts | 55 +++++++++---- .../src/commands/hydrogen/generate/routes.ts | 36 +++++---- packages/cli/src/lib/setups/routes/types.ts | 23 ++++++ 7 files changed, 181 insertions(+), 38 deletions(-) create mode 100644 packages/cli/src/lib/setups/routes/types.ts diff --git a/packages/cli/json-output-command-exceptions.cjs b/packages/cli/json-output-command-exceptions.cjs index 22f1c3aae8..963a995641 100644 --- a/packages/cli/json-output-command-exceptions.cjs +++ b/packages/cli/json-output-command-exceptions.cjs @@ -2,9 +2,6 @@ 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/g.ts', - 'packages/cli/src/commands/hydrogen/generate/route.ts', - 'packages/cli/src/commands/hydrogen/generate/routes.ts', 'packages/cli/src/commands/hydrogen/init.ts', 'packages/cli/src/commands/hydrogen/link.ts', 'packages/cli/src/commands/hydrogen/list.ts', diff --git a/packages/cli/oclif.manifest.json b/packages/cli/oclif.manifest.json index 7f161adf1d..2d8bbe156e 100644 --- a/packages/cli/oclif.manifest.json +++ b/packages/cli/oclif.manifest.json @@ -1045,7 +1045,7 @@ "required": true } }, - "description": "Generates a standard Shopify route.", + "description": "Generates a set of default routes from the starter template.\n\nUse `--json-schema` to print the result, error, and event schemas.\n\nOutput from `--json` conforms to the `HydrogenGenerateRoutesResult` schema.\n\n```json\n{\n \"type\": \"object\",\n \"properties\": {\n \"routes\": {\n \"type\": \"array\",\n \"items\": {\n \"$ref\": \"#/definitions/Route\"\n }\n },\n \"routeGroups\": {\n \"type\": \"object\",\n \"additionalProperties\": {\n \"type\": \"array\",\n \"items\": {\n \"type\": \"string\"\n }\n }\n },\n \"isTypescript\": {\n \"type\": \"boolean\"\n }\n },\n \"required\": [\n \"routes\",\n \"routeGroups\",\n \"isTypescript\"\n ],\n \"additionalProperties\": false,\n \"title\": \"HydrogenGenerateRoutesResult\",\n \"definitions\": {\n \"Route\": {\n \"type\": \"object\",\n \"properties\": {\n \"sourceRoute\": {\n \"type\": \"string\"\n },\n \"destinationRoute\": {\n \"type\": \"string\"\n },\n \"operation\": {\n \"type\": \"string\",\n \"enum\": [\n \"created\",\n \"skipped\",\n \"replaced\"\n ]\n }\n },\n \"required\": [\n \"sourceRoute\",\n \"destinationRoute\",\n \"operation\"\n ],\n \"additionalProperties\": false\n }\n },\n \"$schema\": \"http://json-schema.org/draft-07/schema#\"\n}\n```", "flags": { "json-schema": { "description": "Print the command's JSON schemas.", @@ -1054,6 +1054,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" + }, "adapter": { "description": "React Router adapter used in the route. The default is `react-router`.", "env": "SHOPIFY_HYDROGEN_FLAG_ADAPTER", @@ -1115,7 +1124,7 @@ "hydrogen:generate:routes": { "aliases": [], "args": {}, - "description": "Generates all supported standard shopify routes.", + "description": "Generates all supported standard shopify routes.\n\nUse `--json-schema` to print the result, error, and event schemas.\n\nOutput from `--json` conforms to the `HydrogenGenerateRoutesResult` schema.\n\n```json\n{\n \"type\": \"object\",\n \"properties\": {\n \"routes\": {\n \"type\": \"array\",\n \"items\": {\n \"$ref\": \"#/definitions/Route\"\n }\n },\n \"routeGroups\": {\n \"type\": \"object\",\n \"additionalProperties\": {\n \"type\": \"array\",\n \"items\": {\n \"type\": \"string\"\n }\n }\n },\n \"isTypescript\": {\n \"type\": \"boolean\"\n }\n },\n \"required\": [\n \"routes\",\n \"routeGroups\",\n \"isTypescript\"\n ],\n \"additionalProperties\": false,\n \"title\": \"HydrogenGenerateRoutesResult\",\n \"definitions\": {\n \"Route\": {\n \"type\": \"object\",\n \"properties\": {\n \"sourceRoute\": {\n \"type\": \"string\"\n },\n \"destinationRoute\": {\n \"type\": \"string\"\n },\n \"operation\": {\n \"type\": \"string\",\n \"enum\": [\n \"created\",\n \"skipped\",\n \"replaced\"\n ]\n }\n },\n \"required\": [\n \"sourceRoute\",\n \"destinationRoute\",\n \"operation\"\n ],\n \"additionalProperties\": false\n }\n },\n \"$schema\": \"http://json-schema.org/draft-07/schema#\"\n}\n```", "flags": { "json-schema": { "description": "Print the command's JSON schemas.", @@ -1124,6 +1133,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" + }, "adapter": { "description": "React Router adapter used in the route. The default is `react-router`.", "env": "SHOPIFY_HYDROGEN_FLAG_ADAPTER", @@ -1172,6 +1190,7 @@ "pluginType": "core", "strict": true, "enableJsonFlag": false, + "descriptionWithMarkdown": "Generates all supported standard shopify routes.", "isESM": true, "relativePath": [ "dist", diff --git a/packages/cli/src/commands/hydrogen/g.ts b/packages/cli/src/commands/hydrogen/g.ts index 883d5d1618..0634fe53dc 100644 --- a/packages/cli/src/commands/hydrogen/g.ts +++ b/packages/cli/src/commands/hydrogen/g.ts @@ -1,6 +1,8 @@ import Command from '@shopify/cli-kit/node/base-command'; import GenerateRoute from './generate/route.js'; +// Hidden compatibility alias; the JSON contract belongs to generate route. +// eslint-disable-next-line @shopify/cli/command-json-output export default class GenerateRouteShortcut extends Command { static description = 'Shortcut for `hydrogen generate`. See `hydrogen generate --help` for more information.'; diff --git a/packages/cli/src/commands/hydrogen/generate/route.test.ts b/packages/cli/src/commands/hydrogen/generate/route.test.ts index 6c56030481..f700eea04a 100644 --- a/packages/cli/src/commands/hydrogen/generate/route.test.ts +++ b/packages/cli/src/commands/hydrogen/generate/route.test.ts @@ -1,6 +1,8 @@ +import GenerateRoute, {runGenerate} from './route.js'; +import GenerateRoutes from './routes.js'; +import {captureJsonOutput} from '../../../../tests/output.js'; import {describe, it, expect, vi, beforeEach, afterEach} from 'vitest'; import {mockAndCaptureOutput} from '@shopify/cli-kit/node/testing/output'; -import {runGenerate} from './route.js'; import {generateRoutes} from '../../../lib/setups/routes/generate.js'; describe('runGenerate', () => { @@ -48,3 +50,76 @@ describe('runGenerate', () => { expect(outputMock.info()).toMatch(/2 of 3 routes/i); }); }); + +it.each([GenerateRoute, GenerateRoutes])( + 'exposes JSON on route commands: %s', + (command) => { + expect(command.flags.json).toBeDefined(); + expect(command.description).toContain(command.jsonOutputSchema.name); + }, +); + +it('encodes created, replaced and skipped routes without the success banner', async () => { + const result = { + isTypescript: true, + routeGroups: {cart: ['cart.tsx']}, + routes: [ + { + sourceRoute: 'cart.tsx', + destinationRoute: 'app/routes/cart.tsx', + operation: 'created' as const, + }, + { + sourceRoute: 'account.tsx', + destinationRoute: 'app/routes/account.tsx', + operation: 'replaced' as const, + }, + { + sourceRoute: 'search.tsx', + destinationRoute: 'app/routes/search.tsx', + operation: 'skipped' as const, + }, + ], + }; + vi.mocked(generateRoutes).mockResolvedValue({ + ...result, + formatOptions: {} as any, + }); + const {stdout, stderr} = await captureJsonOutput(() => + runGenerate({routeName: 'all', directory: '/project'}), + ); + expect(JSON.parse(stdout)).toEqual(result); + expect(stderr).toBe(''); + expect(() => + GenerateRoute.jsonOutputSchema.encode({ + ...result, + isTypescript: 'yes', + } as any), + ).toThrow(); +}); + +it('forwards all-route flags without reconstructing false boolean arguments', async () => { + vi.mocked(generateRoutes).mockResolvedValue({ + routes: [], + routeGroups: {}, + isTypescript: false, + formatOptions: {} as any, + }); + const command = new GenerateRoutes([], {} as any); + const parse = vi + .spyOn(command as any, 'parse') + .mockResolvedValue({flags: {json: true, path: '/project', force: false}}); + try { + const {stdout} = await captureJsonOutput(() => command.run()); + expect(JSON.parse(stdout)).toEqual({ + routes: [], + routeGroups: {}, + isTypescript: false, + }); + expect(generateRoutes).toHaveBeenLastCalledWith( + expect.objectContaining({routeName: 'all', force: false}), + ); + } finally { + parse.mockRestore(); + } +}); diff --git a/packages/cli/src/commands/hydrogen/generate/route.ts b/packages/cli/src/commands/hydrogen/generate/route.ts index ae1ee8787b..d33300af6b 100644 --- a/packages/cli/src/commands/hydrogen/generate/route.ts +++ b/packages/cli/src/commands/hydrogen/generate/route.ts @@ -1,3 +1,6 @@ +import {writeJsonResult} from '../../../lib/json-output.js'; +import {jsonFlag} from '@shopify/cli-kit/node/cli'; +import {generateRoutesJsonOutputSchema} from '../../../lib/setups/routes/types.js'; import Command from '@shopify/cli-kit/node/base-command'; import {resolvePath} from '@shopify/cli-kit/node/path'; import {renderSuccess} from '../../../lib/ui.js'; @@ -15,9 +18,14 @@ import { import {isV1RouteConventionInstalled} from '../../../lib/remix-version-interop.js'; export default class GenerateRoute extends Command { + static get jsonOutputSchema(): typeof generateRoutesJsonOutputSchema { + return generateRoutesJsonOutputSchema; + } + static descriptionWithMarkdown = `Generates a set of default routes from the starter template.`; - static description = 'Generates a standard Shopify route.'; + static description = this.descriptionForHelp(); static flags = { + ...jsonFlag, adapter: Flags.string({ description: 'React Router adapter used in the route. The default is `react-router`.', @@ -56,28 +64,43 @@ export default class GenerateRoute extends Command { const directory = flags.path ? resolvePath(flags.path) : process.cwd(); - await runGenerate({ - ...flags, - directory, - routeName, - localePrefix: flags['locale-param'], - }); + await runGenerate( + { + ...flags, + directory, + routeName, + localePrefix: flags['locale-param'], + }, + flags.json, + ); } } -export async function runGenerate(options: { - routeName: string; - directory: string; - adapter?: string; - typescript?: boolean; - force?: boolean; - localePrefix?: string; -}) { - const {routes} = await generateRoutes({ +export async function runGenerate( + options: { + routeName: string; + directory: string; + adapter?: string; + typescript?: boolean; + force?: boolean; + localePrefix?: string; + }, + json?: boolean, +) { + const {routes, routeGroups, isTypescript} = await generateRoutes({ ...options, v1RouteConvention: isV1RouteConventionInstalled(), }); + const result = {routes, routeGroups, isTypescript}; + if (!writeJsonResult(generateRoutesJsonOutputSchema, result, json)) + renderGeneratedRoutes(result); + return result; +} + +export function renderGeneratedRoutes({ + routes, +}: import('../../../lib/setups/routes/types.js').GenerateRoutesResult) { const padEnd = 3 + routes.reduce( diff --git a/packages/cli/src/commands/hydrogen/generate/routes.ts b/packages/cli/src/commands/hydrogen/generate/routes.ts index f0ea55fda8..c923ae0832 100644 --- a/packages/cli/src/commands/hydrogen/generate/routes.ts +++ b/packages/cli/src/commands/hydrogen/generate/routes.ts @@ -1,25 +1,29 @@ -import GenerateRoute from './route.js'; +import {jsonFlag} from '@shopify/cli-kit/node/cli'; +import {resolvePath} from '@shopify/cli-kit/node/path'; +import GenerateRoute, {runGenerate} from './route.js'; import Command from '@shopify/cli-kit/node/base-command'; +import {generateRoutesJsonOutputSchema} from '../../../lib/setups/routes/types.js'; export default class GenerateRoutes extends Command { - static description = 'Generates all supported standard shopify routes.'; - + static get jsonOutputSchema(): typeof generateRoutesJsonOutputSchema { + return generateRoutesJsonOutputSchema; + } + static descriptionWithMarkdown = + 'Generates all supported standard shopify routes.'; + static description = this.descriptionForHelp(); static hidden: true; - - static flags = GenerateRoute.flags; + static flags = {...GenerateRoute.flags, ...jsonFlag}; async run(): Promise { const {flags} = await this.parse(GenerateRoutes); - - await GenerateRoute.run([ - 'all', - ...Object.entries(flags).map(([name, value]) => { - if (value === true) { - return `--${name}`; - } - - return `--${name}=${value}`; - }), - ]); + await runGenerate( + { + ...flags, + directory: flags.path ? resolvePath(flags.path) : process.cwd(), + routeName: 'all', + localePrefix: flags['locale-param'], + }, + flags.json, + ); } } diff --git a/packages/cli/src/lib/setups/routes/types.ts b/packages/cli/src/lib/setups/routes/types.ts new file mode 100644 index 0000000000..0d5922ce85 --- /dev/null +++ b/packages/cli/src/lib/setups/routes/types.ts @@ -0,0 +1,23 @@ +import { + defineJsonOutputSchema, + type InferJsonOutputSchema, +} from '@shopify/cli-kit/node/json-output-schema'; +import {zod} from '@shopify/cli-kit/node/schema'; + +const Route = zod.object({ + sourceRoute: zod.string(), + destinationRoute: zod.string(), + operation: zod.enum(['created', 'skipped', 'replaced']), +}); +export const generateRoutesJsonOutputSchema = defineJsonOutputSchema({ + name: 'HydrogenGenerateRoutesResult', + schema: zod.object({ + routes: zod.array(Route), + routeGroups: zod.record(zod.array(zod.string())), + isTypescript: zod.boolean(), + }), + definitions: {Route}, +}); +export type GenerateRoutesResult = InferJsonOutputSchema< + typeof generateRoutesJsonOutputSchema +>;