diff --git a/packages/cli/json-output-command-exceptions.cjs b/packages/cli/json-output-command-exceptions.cjs index 25d3beb92f..a97d9d7eb1 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/build.ts', - 'packages/cli/src/commands/hydrogen/check.ts', - 'packages/cli/src/commands/hydrogen/codegen.ts', 'packages/cli/src/commands/hydrogen/customer-account-push.ts', 'packages/cli/src/commands/hydrogen/deploy.ts', 'packages/cli/src/commands/hydrogen/env/list.ts', diff --git a/packages/cli/oclif.manifest.json b/packages/cli/oclif.manifest.json index 92f5c0a620..9049bd0dd1 100644 --- a/packages/cli/oclif.manifest.json +++ b/packages/cli/oclif.manifest.json @@ -3,7 +3,7 @@ "hydrogen:build": { "aliases": [], "args": {}, - "description": "Builds a Hydrogen storefront for production.", + "description": "Builds a Hydrogen storefront for production. The client and app worker files are compiled to a `/dist` folder in your Hydrogen project directory.\n\nUse `--json-schema` to print the result, error, and event schemas.\n\nOutput from `--json` conforms to the `HydrogenBuildResult` schema.\n\n```json\n{\n \"type\": \"object\",\n \"properties\": {\n \"directory\": {\n \"type\": \"string\"\n },\n \"clientDirectory\": {\n \"type\": \"string\"\n },\n \"serverDirectory\": {\n \"type\": \"string\"\n },\n \"serverFile\": {\n \"type\": \"string\"\n }\n },\n \"required\": [\n \"directory\",\n \"clientDirectory\",\n \"serverDirectory\",\n \"serverFile\"\n ],\n \"additionalProperties\": false,\n \"title\": \"HydrogenBuildResult\",\n \"$schema\": \"http://json-schema.org/draft-07/schema#\"\n}\n```", "flags": { "json-schema": { "description": "Print the command's JSON schemas.", @@ -12,6 +12,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", @@ -117,7 +126,7 @@ "required": true } }, - "description": "Returns diagnostic information about a Hydrogen storefront.", + "description": "Checks whether your Hydrogen app includes a set of standard Shopify routes.\n\nUse `--json-schema` to print the result, error, and event schemas.\n\nOutput from `--json` conforms to the `HydrogenCheckResult` schema.\n\n```json\n{\n \"type\": \"object\",\n \"properties\": {\n \"missingRoutes\": {\n \"type\": \"array\",\n \"items\": {\n \"type\": \"string\"\n }\n },\n \"reservedRoutes\": {\n \"type\": \"array\",\n \"items\": {\n \"type\": \"string\"\n }\n }\n },\n \"required\": [\n \"missingRoutes\",\n \"reservedRoutes\"\n ],\n \"additionalProperties\": false,\n \"title\": \"HydrogenCheckResult\",\n \"$schema\": \"http://json-schema.org/draft-07/schema#\"\n}\n```", "flags": { "json-schema": { "description": "Print the command's JSON schemas.", @@ -126,6 +135,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", @@ -155,7 +173,7 @@ "hydrogen:codegen": { "aliases": [], "args": {}, - "description": "Generate types for the Storefront API queries found in your project.", + "description": "Automatically generates GraphQL types for your project’s Storefront API queries.\n\nUse `--json-schema` to print the result, error, and event schemas.\n\nOutput from `--json` conforms to the `HydrogenCodegenResult` schema.\n\n```json\n{\n \"type\": \"object\",\n \"properties\": {\n \"generatedFiles\": {\n \"type\": \"object\",\n \"additionalProperties\": {\n \"type\": \"array\",\n \"items\": {\n \"type\": \"string\"\n }\n }\n }\n },\n \"required\": [\n \"generatedFiles\"\n ],\n \"additionalProperties\": false,\n \"title\": \"HydrogenCodegenResult\",\n \"$schema\": \"http://json-schema.org/draft-07/schema#\"\n}\n```", "flags": { "json-schema": { "description": "Print the command's JSON schemas.", @@ -164,6 +182,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/build-json.test.ts b/packages/cli/src/commands/hydrogen/build-json.test.ts new file mode 100644 index 0000000000..6b5edde1f2 --- /dev/null +++ b/packages/cli/src/commands/hydrogen/build-json.test.ts @@ -0,0 +1,120 @@ +import {afterEach, expect, it, vi} from 'vitest'; +import {outputInfo} from '@shopify/cli-kit/node/output'; +import {captureJsonOutput} from '../../../tests/output.js'; +import {getRemixConfig} from '../../lib/remix-config.js'; +import {codegen} from '../../lib/codegen.js'; +import Check, {runCheckRoutes} from './check.js'; +import Codegen, {runCodegen} from './codegen.js'; +import Build from './build.js'; +import {writeJsonResult} from '../../lib/json-output.js'; + +vi.mock('../../lib/remix-config.js', () => ({ + getRemixConfig: vi.fn(), + getProjectPaths: () => ({root: '/project'}), +})); +vi.mock('../../lib/codegen.js', () => ({ + codegen: vi.fn(), + spawnCodegenProcess: vi.fn(), +})); +afterEach(() => vi.restoreAllMocks()); + +it('encodes missing and reserved routes in one document without text banners', async () => { + vi.mocked(getRemixConfig).mockResolvedValue({ + routes: {root: {id: 'root'}, cdn: {id: 'cdn', path: 'cdn/private'}}, + } as any); + const {stdout, stderr} = await captureJsonOutput(() => + runCheckRoutes({directory: '/project'}), + ); + expect(JSON.parse(stdout)).toMatchObject({ + missingRoutes: expect.arrayContaining(['cart']), + reservedRoutes: ['cdn/private'], + }); + expect(stderr).toBe(''); +}); + +it('encodes generated files and preserves diagnostics on stderr', async () => { + vi.mocked(getRemixConfig).mockResolvedValue({ + rootDirectory: '/project', + } as any); + vi.mocked(codegen).mockImplementation(async () => { + outputInfo('Generating types'); + return {'storefrontapi.generated.d.ts': ['app/**/*.tsx']}; + }); + const {stdout, stderr} = await captureJsonOutput(() => + runCodegen({directory: '/project'}), + ); + expect(JSON.parse(stdout)).toEqual({ + generatedFiles: {'storefrontapi.generated.d.ts': ['app/**/*.tsx']}, + }); + expect(JSON.parse(stderr)).toMatchObject({ + type: 'diagnostic', + message: 'Generating types', + }); +}); + +it('keeps codegen failures on the fatal-error path', async () => { + vi.mocked(codegen).mockRejectedValue(new Error('Invalid query')); + const {stdout} = await captureJsonOutput(async () => { + await expect(runCodegen({directory: '/project'})).rejects.toThrow( + 'Invalid query', + ); + }); + expect(stdout).toBe(''); +}); + +it('encodes build output paths through the real writer', async () => { + const result = { + directory: '/project', + clientDirectory: '/project/dist/client', + serverDirectory: '/project/dist/server', + serverFile: '/project/dist/server/index.js', + }; + const {stdout} = await captureJsonOutput(() => + writeJsonResult(Build.jsonOutputSchema, result), + ); + expect(JSON.parse(stdout)).toEqual(result); + expect(() => + Build.jsonOutputSchema.encode({...result, serverFile: null} as any), + ).toThrow(); +}); + +it.each([Build, Codegen, Check])( + 'declares JSON flags and discoverable schemas: %s', + (command) => { + expect(command.flags.json).toBeDefined(); + expect(command.description).toContain(command.jsonOutputSchema.name); + }, +); + +it.each([Build, Codegen])( + 'rejects JSON watch mode before execution: %s', + async (Command) => { + const command = new Command([], {} as any); + vi.spyOn(command as any, 'parse').mockResolvedValue({ + flags: {json: true, watch: true}, + }); + await expect(command.run()).rejects.toThrow( + '--json cannot be combined with --watch', + ); + }, +); + +it('rejects invalid fields and preserves empty collections', () => { + expect(() => + Check.jsonOutputSchema.encode({ + missingRoutes: [1], + reservedRoutes: [], + } as any), + ).toThrow(); + expect(() => + Codegen.jsonOutputSchema.encode({generatedFiles: {types: 1}} as any), + ).toThrow(); + expect( + JSON.parse( + Check.jsonOutputSchema.encode({missingRoutes: [], reservedRoutes: []}), + ), + ).toEqual({missingRoutes: [], reservedRoutes: []}); + expect( + JSON.parse(Codegen.jsonOutputSchema.encode({generatedFiles: {}})), + ).toEqual({generatedFiles: {}}); +}); diff --git a/packages/cli/src/commands/hydrogen/build.ts b/packages/cli/src/commands/hydrogen/build.ts index 8fc0d63f46..4ada20fbea 100644 --- a/packages/cli/src/commands/hydrogen/build.ts +++ b/packages/cli/src/commands/hydrogen/build.ts @@ -1,13 +1,18 @@ -import {Flags} from '@oclif/core'; -import Command from '@shopify/cli-kit/node/base-command'; -import {resolvePath, joinPath} from '@shopify/cli-kit/node/path'; +import {writeJsonResult, isJsonOutput} from '../../lib/json-output.js'; import { + flushStdout, outputWarn, collectLog, outputInfo, outputContent, outputToken, } from '@shopify/cli-kit/node/output'; +import {emitCommandEvent} from '@shopify/cli-kit/node/command-events'; +import {jsonFlag} from '@shopify/cli-kit/node/cli'; +import {buildJsonOutputSchema} from '../../lib/build-tooling/types.js'; +import {Flags} from '@oclif/core'; +import Command from '@shopify/cli-kit/node/base-command'; +import {resolvePath, joinPath} from '@shopify/cli-kit/node/path'; import {fileSize, removeFile} from '@shopify/cli-kit/node/fs'; import {getPackageManager} from '@shopify/cli-kit/node/node-package-manager'; import {commonFlags, flagsToCamelObject} from '../../lib/flags.js'; @@ -35,10 +40,15 @@ import {setupResourceCleanup} from '../../lib/resource-cleanup.js'; import {AbortError} from '@shopify/cli-kit/node/error'; export default class Build extends Command { + static get jsonOutputSchema(): typeof buildJsonOutputSchema { + return buildJsonOutputSchema; + } + static descriptionWithMarkdown = `Builds a Hydrogen storefront for production. The client and app worker files are compiled to a \`/dist\` folder in your Hydrogen project directory.`; - static description = 'Builds a Hydrogen storefront for production.'; + static description = this.descriptionForHelp(); static flags = { + ...jsonFlag, ...commonFlags.path, ...commonFlags.entry, ...commonFlags.sourcemap, @@ -64,6 +74,10 @@ export default class Build extends Command { async run(): Promise { const {flags} = await this.parse(Build); + if (flags.json && flags.watch) + throw new AbortError( + '--json cannot be combined with --watch. Run without --watch for a finite result.', + ); const directory = flags.path ? resolvePath(flags.path) : process.cwd(); const buildParams = { @@ -89,6 +103,8 @@ export default class Build extends Command { // The Remix compiler hangs due to a bug in ESBuild: // https://github.com/evanw/esbuild/issues/2727 // The actual build has already finished so we can kill the process. + writeJsonResult(buildJsonOutputSchema, result.result, flags.json); + await flushStdout(); process.exit(0); } } @@ -157,6 +173,16 @@ export async function runBuild({ customLogger.error = (msg) => collectLog('error', msg); } + if (isJsonOutput()) { + customLogger.info = (message) => + emitCommandEvent({type: 'diagnostic', level: 'info', message}); + customLogger.warn = (message) => + emitCommandEvent({type: 'diagnostic', level: 'warning', message}); + customLogger.warnOnce = customLogger.warn; + customLogger.error = (message) => + emitCommandEvent({type: 'diagnostic', level: 'error', message}); + } + const serverMinify = userViteConfig.build?.minify ?? true; const commonConfig = { root, @@ -203,7 +229,7 @@ export async function runBuild({ ], }); - console.log(''); + if (!isJsonOutput()) console.log(''); let serverBuildStatus: DeferredPromise; @@ -343,6 +369,12 @@ export async function runBuild({ } return { + result: { + directory: root, + clientDirectory: clientOutDir, + serverDirectory: serverOutDir, + serverFile: serverOutFile, + } satisfies import('../../lib/build-tooling/types.js').BuildResult, async close() { codegenProcess?.removeAllListeners('close'); codegenProcess?.kill('SIGINT'); diff --git a/packages/cli/src/commands/hydrogen/check.ts b/packages/cli/src/commands/hydrogen/check.ts index 0a06171018..80d9b52375 100644 --- a/packages/cli/src/commands/hydrogen/check.ts +++ b/packages/cli/src/commands/hydrogen/check.ts @@ -1,3 +1,6 @@ +import {writeJsonResult} from '../../lib/json-output.js'; +import {jsonFlag} from '@shopify/cli-kit/node/cli'; +import {checkJsonOutputSchema} from '../../lib/check/types.js'; import Command from '@shopify/cli-kit/node/base-command'; import {resolvePath} from '@shopify/cli-kit/node/path'; import {commonFlags} from '../../lib/flags.js'; @@ -12,12 +15,16 @@ import { import {Args} from '@oclif/core'; export default class GenerateRoute extends Command { + static get jsonOutputSchema(): typeof checkJsonOutputSchema { + return checkJsonOutputSchema; + } + static descriptionWithMarkdown = `Checks whether your Hydrogen app includes a set of standard Shopify routes.`; - static description = - 'Returns diagnostic information about a Hydrogen storefront.'; + static description = this.descriptionForHelp(); static flags = { + ...jsonFlag, ...commonFlags.path, }; @@ -35,15 +42,33 @@ export default class GenerateRoute extends Command { const directory = flags.path ? resolvePath(flags.path) : process.cwd(); if (args.resource === 'routes') { - await runCheckRoutes({directory}); + await runCheckRoutes({directory}, flags.json); } else { throw new Error('Invalid command argument.'); } } } -export async function runCheckRoutes({directory}: {directory: string}) { +export async function runCheckRoutes( + options: {directory: string}, + json?: boolean, +) { + const result = await checkRoutes(options); + if (!writeJsonResult(checkJsonOutputSchema, result, json)) { + logMissingRoutes(result.missingRoutes); + warnReservedRoutes(result.reservedRoutes); + } + return result; +} + +export async function checkRoutes({ + directory, +}: { + directory: string; +}): Promise { const remixConfig = await getRemixConfig(directory); - logMissingRoutes(findMissingRoutes(remixConfig)); - warnReservedRoutes(findReservedRoutes(remixConfig)); + return { + missingRoutes: findMissingRoutes(remixConfig), + reservedRoutes: findReservedRoutes(remixConfig), + }; } diff --git a/packages/cli/src/commands/hydrogen/codegen.ts b/packages/cli/src/commands/hydrogen/codegen.ts index baa6554a53..7fdeca61ec 100644 --- a/packages/cli/src/commands/hydrogen/codegen.ts +++ b/packages/cli/src/commands/hydrogen/codegen.ts @@ -1,3 +1,7 @@ +import {writeJsonResult, isJsonOutput} from '../../lib/json-output.js'; +import {AbortError} from '@shopify/cli-kit/node/error'; +import {jsonFlag} from '@shopify/cli-kit/node/cli'; +import {codegenJsonOutputSchema} from '../../lib/codegen/types.js'; import Command from '@shopify/cli-kit/node/base-command'; import {renderSuccess} from '../../lib/ui.js'; import colors from '@shopify/cli-kit/node/colors'; @@ -8,12 +12,16 @@ import {commonFlags, flagsToCamelObject} from '../../lib/flags.js'; import {codegen} from '../../lib/codegen.js'; export default class Codegen extends Command { + static get jsonOutputSchema(): typeof codegenJsonOutputSchema { + return codegenJsonOutputSchema; + } + static descriptionWithMarkdown = 'Automatically generates GraphQL types for your project’s Storefront API queries.'; - static description = - 'Generate types for the Storefront API queries found in your project.'; + static description = this.descriptionForHelp(); static flags = { + ...jsonFlag, ...commonFlags.path, 'codegen-config-path': Flags.string({ description: @@ -37,6 +45,11 @@ export default class Codegen extends Command { const {flags} = await this.parse(Codegen); const directory = flags.path ? resolvePath(flags.path) : process.cwd(); + if (flags.json && flags.watch) + throw new AbortError( + '--json cannot be combined with --watch. Run without --watch for a finite result.', + ); + await runCodegen({ ...flagsToCamelObject(flags), directory, @@ -58,7 +71,7 @@ export async function runCodegen({ const {root} = getProjectPaths(directory); const remixConfig = await getRemixConfig(root); - console.log(''); // New line + if (!isJsonOutput()) console.log(''); // New line const generatedFiles = await codegen({ ...remixConfig, @@ -67,7 +80,8 @@ export async function runCodegen({ watch, }); - if (!watch) { + const result = {generatedFiles}; + if (!watch && !writeJsonResult(codegenJsonOutputSchema, result)) { renderSuccess({ headline: 'Generated types for GraphQL:', body: { @@ -82,4 +96,5 @@ export async function runCodegen({ }, }); } + return result; } diff --git a/packages/cli/src/lib/build-tooling/types.ts b/packages/cli/src/lib/build-tooling/types.ts new file mode 100644 index 0000000000..6bc9cee2fa --- /dev/null +++ b/packages/cli/src/lib/build-tooling/types.ts @@ -0,0 +1,16 @@ +import { + defineJsonOutputSchema, + type InferJsonOutputSchema, +} from '@shopify/cli-kit/node/json-output-schema'; +import {zod} from '@shopify/cli-kit/node/schema'; + +export const buildJsonOutputSchema = defineJsonOutputSchema({ + name: 'HydrogenBuildResult', + schema: zod.object({ + directory: zod.string(), + clientDirectory: zod.string(), + serverDirectory: zod.string(), + serverFile: zod.string(), + }), +}); +export type BuildResult = InferJsonOutputSchema; diff --git a/packages/cli/src/lib/check/types.ts b/packages/cli/src/lib/check/types.ts new file mode 100644 index 0000000000..33654126b4 --- /dev/null +++ b/packages/cli/src/lib/check/types.ts @@ -0,0 +1,14 @@ +import { + defineJsonOutputSchema, + type InferJsonOutputSchema, +} from '@shopify/cli-kit/node/json-output-schema'; +import {zod} from '@shopify/cli-kit/node/schema'; + +export const checkJsonOutputSchema = defineJsonOutputSchema({ + name: 'HydrogenCheckResult', + schema: zod.object({ + missingRoutes: zod.array(zod.string()), + reservedRoutes: zod.array(zod.string()), + }), +}); +export type CheckResult = InferJsonOutputSchema; diff --git a/packages/cli/src/lib/codegen.ts b/packages/cli/src/lib/codegen.ts index 032c8c4e32..652d102de6 100644 --- a/packages/cli/src/lib/codegen.ts +++ b/packages/cli/src/lib/codegen.ts @@ -1,3 +1,4 @@ +import {isJsonOutput} from './json-output.js'; import {spawn} from 'node:child_process'; import {fileURLToPath} from 'node:url'; import {formatCode, getCodeFormatOptions} from './format-code.js'; @@ -188,7 +189,7 @@ export async function executeReactRouterCodegen(options: { } else { execSync('npx react-router typegen', { cwd: options.rootDirectory, - stdio: 'inherit', + stdio: isJsonOutput() ? 'pipe' : 'inherit', }); } } diff --git a/packages/cli/src/lib/codegen/types.ts b/packages/cli/src/lib/codegen/types.ts new file mode 100644 index 0000000000..cf84b2682d --- /dev/null +++ b/packages/cli/src/lib/codegen/types.ts @@ -0,0 +1,13 @@ +import { + defineJsonOutputSchema, + type InferJsonOutputSchema, +} from '@shopify/cli-kit/node/json-output-schema'; +import {zod} from '@shopify/cli-kit/node/schema'; + +export const codegenJsonOutputSchema = defineJsonOutputSchema({ + name: 'HydrogenCodegenResult', + schema: zod.object({generatedFiles: zod.record(zod.array(zod.string()))}), +}); +export type CodegenResult = InferJsonOutputSchema< + typeof codegenJsonOutputSchema +>;