diff --git a/.changeset/hydrogen-build-runtime-fixes.md b/.changeset/hydrogen-build-runtime-fixes.md new file mode 100644 index 0000000000..2cf4859bdc --- /dev/null +++ b/.changeset/hydrogen-build-runtime-fixes.md @@ -0,0 +1,5 @@ +--- +'@shopify/cli-hydrogen': patch +--- + +Resolve symlinked project paths consistently during client and server builds. diff --git a/.changeset/hydrogen-json-build.md b/.changeset/hydrogen-json-build.md new file mode 100644 index 0000000000..8866ece206 --- /dev/null +++ b/.changeset/hydrogen-json-build.md @@ -0,0 +1,5 @@ +--- +'@shopify/cli-hydrogen': minor +--- + +Add `--json` output to `shopify hydrogen build`, `shopify hydrogen check routes`, and `shopify hydrogen codegen` so scripts can read structured command results. 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 86ea563b82..219a035894 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 \"$ref\": \"#/definitions/AbsolutePath\"\n },\n \"clientDirectory\": {\n \"$ref\": \"#/definitions/AbsolutePath\"\n },\n \"serverDirectory\": {\n \"$ref\": \"#/definitions/AbsolutePath\"\n },\n \"serverPath\": {\n \"$ref\": \"#/definitions/AbsolutePath\"\n }\n },\n \"required\": [\n \"directory\",\n \"clientDirectory\",\n \"serverDirectory\",\n \"serverPath\"\n ],\n \"additionalProperties\": false,\n \"title\": \"HydrogenBuildResult\",\n \"definitions\": {\n \"AbsolutePath\": {\n \"type\": \"string\"\n }\n },\n \"$schema\": \"http://json-schema.org/draft-07/schema#\"\n}\n```", "flags": { "json-schema": { "description": "Print the command's JSON schemas.", @@ -35,6 +35,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", @@ -140,7 +149,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 \"valid\": {\n \"type\": \"boolean\"\n },\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 \"valid\",\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.", @@ -172,6 +181,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", @@ -201,7 +219,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 \"files\": {\n \"type\": \"array\",\n \"items\": {\n \"type\": \"object\",\n \"properties\": {\n \"path\": {\n \"type\": \"string\"\n },\n \"sources\": {\n \"type\": \"array\",\n \"items\": {\n \"type\": \"string\"\n },\n \"description\": \"Source patterns from the GraphQL codegen configuration.\"\n }\n },\n \"required\": [\n \"path\",\n \"sources\"\n ],\n \"additionalProperties\": false\n }\n }\n },\n \"required\": [\n \"files\"\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.", @@ -233,6 +251,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..c094595356 --- /dev/null +++ b/packages/cli/src/commands/hydrogen/build-json.test.ts @@ -0,0 +1,264 @@ +import {afterEach, expect, it, vi} from 'vitest'; +import {realpath} from 'node:fs/promises'; +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 {Parser} from '@oclif/core'; +import ShopifyCommand from '@shopify/cli-kit/node/base-command'; +import {runWithCommandEventsForCommand} from '@shopify/cli-kit/node/command-events'; +import {isHydrogenProject} from '../../lib/hydrogen-command-policy.js'; +import {getViteConfig, isViteProject} from '../../lib/vite-config.js'; +import {importVite} from '../../lib/import-utils.js'; +import {fileSize} from '@shopify/cli-kit/node/fs'; +import {withCapturedStandardStreams} from '@shopify/cli-kit/node/testing/output'; +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(), +})); +vi.mock('../../lib/vite-config.js'); +vi.mock('../../lib/hydrogen-command-policy.js'); +vi.mock('../../lib/import-utils.js'); +vi.mock('node:fs/promises', async (original) => ({ + ...(await original()), + realpath: vi.fn(), +})); +vi.mock('@shopify/cli-kit/node/fs', async (original) => ({ + ...(await original()), + removeFile: vi.fn(), + fileSize: vi.fn(), +})); +afterEach(() => { + vi.restoreAllMocks(); + vi.unstubAllEnvs(); +}); + +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({ + files: [ + { + path: '/project/storefrontapi.generated.d.ts', + sources: ['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', + serverPath: '/project/dist/server/index.js', + }; + const {stdout} = await captureJsonOutput(() => + writeJsonResult(Build.jsonOutputSchema, result), + ); + expect(JSON.parse(stdout)).toEqual(result); + expect(() => + Build.jsonOutputSchema.encode({...result, serverPath: 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({ + valid: false, + missingRoutes: [1], + reservedRoutes: [], + } as any), + ).toThrow(); + expect(() => + Codegen.jsonOutputSchema.encode({ + files: [{path: '/project/types', sources: 1}], + } as any), + ).toThrow(); + expect( + JSON.parse( + Check.jsonOutputSchema.encode({ + valid: true, + missingRoutes: [], + reservedRoutes: [], + }), + ), + ).toEqual({valid: true, missingRoutes: [], reservedRoutes: []}); + expect(JSON.parse(Codegen.jsonOutputSchema.encode({files: []}))).toEqual({ + files: [], + }); +}); + +it('honors the parsed Codegen JSON flag without ambient JSON mode', async () => { + vi.stubEnv('SHOPIFY_FLAG_JSON', '0'); + vi.mocked(getRemixConfig).mockResolvedValue({ + rootDirectory: '/project', + } as any); + vi.mocked(codegen).mockResolvedValue({'types.d.ts': ['app/**/*.tsx']}); + const command = new Codegen([], {} as any); + vi.spyOn(command as any, 'parse').mockResolvedValue({ + flags: {json: true, path: '/project'}, + }); + await withCapturedStandardStreams(async ({stdout}) => { + await command.run(); + expect(JSON.parse(stdout())).toEqual({ + files: [{path: '/project/types.d.ts', sources: ['app/**/*.tsx']}], + }); + }); +}); + +it.each(['--json', 'True', 'y'])( + 'runs Build with clean JSON diagnostics and no Vite progress: %s', + async (mode) => { + vi.mocked(realpath).mockResolvedValue('/project'); + vi.mocked(isViteProject).mockResolvedValue(true); + vi.mocked(getViteConfig).mockResolvedValue({ + userViteConfig: {}, + remixConfig: {appDirectory: '/project/app'}, + clientOutDir: '/project/dist/client', + serverOutDir: '/project/dist/server', + serverOutFile: '/project/dist/server/index.js', + } as any); + vi.mocked(fileSize).mockResolvedValue(100); + const build = vi.fn(async (config) => { + config.customLogger.info('\u001b[32mBuild info\u001b[0m'); + config.customLogger.warn('\u001b[33mBuild warning\u001b[0m'); + config.customLogger.error('\u001b[31mBuild error\u001b[0m'); + return {}; + }); + vi.mocked(importVite).mockResolvedValue({ + createLogger: () => ({}), + build, + } as any); + const argv = mode === '--json' ? [mode] : []; + vi.stubEnv('SHOPIFY_FLAG_JSON', mode === '--json' ? undefined : mode); + const parsed = await Parser.parse(argv, {flags: Build.flags}); + expect(parsed.flags.json).toBe(true); + const command = new Build(argv, {} as any); + command.id = 'hydrogen:build'; + vi.mocked(isHydrogenProject).mockReturnValue(true); + vi.spyOn(ShopifyCommand.prototype as any, 'init').mockResolvedValue( + undefined, + ); + vi.spyOn(command as any, 'removeEnvVar').mockImplementation(() => {}); + vi.spyOn(command as any, 'parse').mockResolvedValue({ + flags: { + ...parsed.flags, + path: '/project', + 'lockfile-check': false, + 'bundle-stats': false, + 'disable-route-warning': true, + }, + }); + const exit = vi + .spyOn(process, 'exit') + .mockImplementation(() => undefined as never); + const {stdout, stderr} = await withCapturedStandardStreams( + async (streams) => { + // Match the launcher's context, including its text mode for mixed-case env values. + await runWithCommandEventsForCommand(argv, () => command._run()); + return {stdout: streams.stdout(), stderr: streams.stderr()}; + }, + ); + expect(JSON.parse(stdout)).toEqual({ + directory: '/project', + clientDirectory: '/project/dist/client', + serverDirectory: '/project/dist/server', + serverPath: '/project/dist/server/index.js', + }); + expect(build).toHaveBeenCalledTimes(2); + for (const [config] of build.mock.calls) + expect(config.logLevel).toBe('warn'); + expect( + stderr + .trim() + .split('\n') + .map((line) => JSON.parse(line)), + ).toEqual( + Array.from({length: 2}, () => [ + expect.objectContaining({ + type: 'diagnostic', + level: 'info', + message: 'Build info', + }), + expect.objectContaining({ + type: 'diagnostic', + level: 'warning', + message: 'Build warning', + }), + expect.objectContaining({ + type: 'diagnostic', + level: 'error', + message: 'Build error', + }), + ]).flat(), + ); + expect(exit).toHaveBeenCalledWith(0); + }, +); diff --git a/packages/cli/src/commands/hydrogen/build.test.ts b/packages/cli/src/commands/hydrogen/build.test.ts index 58a757e6d4..46b6feb7b3 100644 --- a/packages/cli/src/commands/hydrogen/build.test.ts +++ b/packages/cli/src/commands/hydrogen/build.test.ts @@ -12,7 +12,7 @@ import {runBuild} from './build.js'; import {setupTemplate} from '../../lib/onboarding/index.js'; import {BUNDLE_ANALYZER_HTML_FILE} from '../../lib/bundle/analyzer.js'; import path from 'node:path'; -import {mkdirSync} from 'node:fs'; +import {mkdirSync, realpathSync, symlinkSync, unlinkSync} from 'node:fs'; describe('build', () => { const outputMock = mockAndCaptureOutput(); @@ -28,13 +28,15 @@ describe('build', () => { `test-project-build-${Date.now()}-${Math.random().toString(36).substring(7)}`, ); mkdirSync(tmpDir); + symlinkSync(tmpDir, tmpDir + '-link', 'junction'); }); afterAll(async () => { + unlinkSync(tmpDir + '-link'); await removeFile(tmpDir); }); - it('builds a Vite project', async () => { + it('builds a Vite project through a symlink', async () => { await setupTemplate({ path: tmpDir, git: true, @@ -47,13 +49,14 @@ describe('build', () => { vi.stubEnv('NODE_ENV', 'production'); const runBuildResultPromise = runBuild({ - directory: tmpDir, + directory: tmpDir + '-link', bundleStats: true, }); await expect(runBuildResultPromise).resolves.not.toThrow(); const runBuildResult = await runBuildResultPromise; + expect(runBuildResult.result.directory).toBe(realpathSync(tmpDir)); const expectedBundlePath = 'dist/server/index.js'; @@ -62,7 +65,7 @@ describe('build', () => { expect(output).toMatch('building client environment for production'); expect(output).toMatch('dist/client/assets/root-'); expect(output).toMatch('building ssr environment for production'); - expect( + await expect( fileExists(joinPath(tmpDir, expectedBundlePath)), ).resolves.toBeTruthy(); diff --git a/packages/cli/src/commands/hydrogen/build.ts b/packages/cli/src/commands/hydrogen/build.ts index 1349421bbc..5060937fa1 100644 --- a/packages/cli/src/commands/hydrogen/build.ts +++ b/packages/cli/src/commands/hydrogen/build.ts @@ -1,13 +1,19 @@ -import {Flags} from '@oclif/core'; -import Command from '../../lib/hydrogen-command.js'; -import {resolvePath, joinPath} from '@shopify/cli-kit/node/path'; +import {writeJsonResult, isJsonOutput} from '../../lib/json-output.js'; import { + flushStdout, + unstyled, 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 '../../lib/hydrogen-command.js'; +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'; @@ -33,12 +39,18 @@ import {importVite} from '../../lib/import-utils.js'; import {deferPromise, type DeferredPromise} from '../../lib/defer.js'; import {setupResourceCleanup} from '../../lib/resource-cleanup.js'; import {AbortError} from '@shopify/cli-kit/node/error'; +import {realpath} from 'node:fs/promises'; 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 +76,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 +105,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); } } @@ -97,6 +115,7 @@ export default class Build extends Command { const WORKER_BUILD_SIZE_LIMIT = 5; type RunBuildOptions = { + json?: boolean; entry?: string; directory?: string; useCodegen?: boolean; @@ -113,6 +132,7 @@ type RunBuildOptions = { }; export async function runBuild({ + json = isJsonOutput(), entry: ssrEntry, directory, useCodegen = false, @@ -133,7 +153,9 @@ export async function runBuild({ assetPath = assetPath ?? process.env.HYDROGEN_ASSET_BASE_URL ?? '/'; - const root = directory ?? process.cwd(); + // Vite resolves source files through symlinks. Use the same root for both + // builds so Remix can match its client manifest to the server entry points. + const root = await realpath(directory ?? process.cwd()); if (lockfileCheck) { await checkLockfileStatus(root, isCI()); @@ -157,12 +179,36 @@ export async function runBuild({ customLogger.error = (msg) => collectLog('error', msg); } + if (json) { + customLogger.info = (message) => + emitCommandEvent({ + type: 'diagnostic', + level: 'info', + message: unstyled(message), + }); + customLogger.warn = (message) => + emitCommandEvent({ + type: 'diagnostic', + level: 'warning', + message: unstyled(message), + }); + customLogger.warnOnce = customLogger.warn; + customLogger.error = (message) => + emitCommandEvent({ + type: 'diagnostic', + level: 'error', + message: unstyled(message), + }); + } + const serverMinify = userViteConfig.build?.minify ?? true; const commonConfig = { root, mode: process.env.NODE_ENV, base: assetPath, customLogger, + // Vite’s reporter writes TTY progress directly to stdout at info level. + ...(json ? {logLevel: 'warn' as const} : {}), }; let clientBuildStatus: DeferredPromise; @@ -203,7 +249,7 @@ export async function runBuild({ ], }); - console.log(''); + if (!json) console.log(''); let serverBuildStatus: DeferredPromise; @@ -343,6 +389,12 @@ export async function runBuild({ } return { + result: { + directory: resolvePath(root), + clientDirectory: resolvePath(clientOutDir), + serverDirectory: resolvePath(serverOutDir), + serverPath: resolvePath(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 376f321158..9f007e31ff 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 '../../lib/hydrogen-command.js'; 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,36 @@ 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)); + const missingRoutes = findMissingRoutes(remixConfig); + const reservedRoutes = findReservedRoutes(remixConfig); + return { + valid: missingRoutes.length === 0 && reservedRoutes.length === 0, + missingRoutes, + reservedRoutes, + }; } diff --git a/packages/cli/src/commands/hydrogen/codegen.ts b/packages/cli/src/commands/hydrogen/codegen.ts index 41410e39af..fd8f8845f0 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 '../../lib/hydrogen-command.js'; import {renderSuccess} from '@shopify/cli-kit/node/ui'; 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, @@ -49,16 +62,18 @@ export async function runCodegen({ codegenConfigPath, forceSfapiVersion, watch, + json = isJsonOutput(), }: { directory?: string; codegenConfigPath?: string; forceSfapiVersion?: string; watch?: boolean; + json?: boolean; }) { const {root} = getProjectPaths(directory); const remixConfig = await getRemixConfig(root); - console.log(''); // New line + if (!json) console.log(''); // New line const generatedFiles = await codegen({ ...remixConfig, @@ -67,7 +82,13 @@ export async function runCodegen({ watch, }); - if (!watch) { + const result = { + files: Object.entries(generatedFiles).map(([path, sources]) => ({ + path: resolvePath(root, path), + sources, + })), + }; + if (!watch && !writeJsonResult(codegenJsonOutputSchema, result, json)) { renderSuccess({ headline: 'Generated types for GraphQL:', body: { @@ -82,4 +103,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..12cf959c1b --- /dev/null +++ b/packages/cli/src/lib/build-tooling/types.ts @@ -0,0 +1,20 @@ +import { + defineJsonOutputSchema, + type InferJsonOutputSchema, +} from '@shopify/cli-kit/node/json-output-schema'; +import {absolutePath} from '../json-contract.js'; +import {zod} from '@shopify/cli-kit/node/schema'; + +export const buildJsonOutputSchema = defineJsonOutputSchema({ + name: 'HydrogenBuildResult', + definitions: {AbsolutePath: absolutePath}, + schema: zod + .object({ + directory: absolutePath, + clientDirectory: absolutePath, + serverDirectory: absolutePath, + serverPath: absolutePath, + }) + .strict(), +}); +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..3d55e34963 --- /dev/null +++ b/packages/cli/src/lib/check/types.ts @@ -0,0 +1,17 @@ +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({ + valid: zod.boolean(), + missingRoutes: zod.array(zod.string()), + reservedRoutes: zod.array(zod.string()), + }) + .strict(), +}); +export type CheckResult = InferJsonOutputSchema; diff --git a/packages/cli/src/lib/codegen-output.test.ts b/packages/cli/src/lib/codegen-output.test.ts new file mode 100644 index 0000000000..803d17e5f1 --- /dev/null +++ b/packages/cli/src/lib/codegen-output.test.ts @@ -0,0 +1,78 @@ +import {expect, it, vi} from 'vitest'; +import { + inTemporaryDirectory, + readFile, + writeFile, +} from '@shopify/cli-kit/node/fs'; +import {joinPath} from '@shopify/cli-kit/node/path'; +import {codegen} from './codegen.js'; + +// These tests exercise GraphQL generation; React Router typegen is independent. +vi.mock('child_process', () => ({execSync: vi.fn(), exec: vi.fn()})); + +it('reports schema-only output that GraphQL Codegen writes to disk', async () => { + await inTemporaryDirectory(async (rootDirectory) => { + const configFilePath = joinPath(rootDirectory, 'codegen.ts'); + await writeFile( + configFilePath, + `export default { + schema: 'type Query { hello: String }', + pluginLoader: async () => ({plugin: () => 'export type Generated = string;'}), + generates: {'schema.ts': {plugins: ['fixture']}}, + };`, + ); + + const result = await codegen({rootDirectory, configFilePath}); + + expect(result).toEqual({'schema.ts': []}); + expect(await readFile(joinPath(rootDirectory, 'schema.ts'))).toContain( + 'export type Generated', + ); + }); +}); + +it('reports files expanded by a preset with global and output document sources', async () => { + await inTemporaryDirectory(async (rootDirectory) => { + await writeFile( + joinPath(rootDirectory, 'global.graphql'), + 'query Global { hello }', + ); + await writeFile( + joinPath(rootDirectory, 'local.graphql'), + 'query Local { hello }', + ); + const configFilePath = joinPath(rootDirectory, 'codegen.ts'); + await writeFile( + configFilePath, + `export default { + schema: 'type Query { hello: String }', + documents: 'global.graphql', + pluginLoader: async () => ({plugin: () => 'export type Generated = string;'}), + generates: { + 'generated/': { + plugins: ['fixture'], + documents: {'local.graphql': {}}, + preset: { + buildGeneratesSection: (options) => ['one.ts', 'two.ts'].map((name) => ({ + ...options, + filename: options.baseOutputDir + name, + })), + }, + }, + }, + };`, + ); + + const result = await codegen({rootDirectory, configFilePath}); + + expect(result).toEqual({ + 'generated/one.ts': ['global.graphql', 'local.graphql'], + 'generated/two.ts': ['global.graphql', 'local.graphql'], + }); + for (const filename of Object.keys(result)) { + expect(await readFile(joinPath(rootDirectory, filename))).toContain( + 'export type Generated', + ); + } + }); +}); diff --git a/packages/cli/src/lib/codegen.ts b/packages/cli/src/lib/codegen.ts index 4c694508ae..53c63b29d6 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', }); } } @@ -239,19 +240,40 @@ async function runGraphQLCodegen({ codegenContext.cwd = dirs.rootDirectory; - await generate(codegenContext, true); - - return Object.entries(codegenConfig.generates).reduce( - (acc, [key, value]) => { - if ('documents' in value) { - acc[key] = ( - Array.isArray(value.documents) ? value.documents : [value.documents] - ).filter((document) => typeof document === 'string') as string[]; - } - - return acc; - }, - {} as Record, + const outputs: {filename: string}[] = await generate(codegenContext, true); + // Watch mode returns a watcher rather than a finite set of generated files. + if (watch) return {} as Record; + + const configuredOutputs = Object.entries(codegenConfig.generates); + return Object.fromEntries( + outputs.map(({filename}) => { + const path = resolvePath(dirs.rootDirectory, filename); + const configuration = + configuredOutputs.find( + ([key]) => resolvePath(dirs.rootDirectory, key) === path, + ) ?? + configuredOutputs + .filter( + ([key, value]) => + !Array.isArray(value) && + value.preset && + path.startsWith(resolvePath(dirs.rootDirectory, key) + '/'), + ) + .sort(([left], [right]) => right.length - left.length)[0]; + const options = configuration?.[1]; + const documents = [ + codegenConfig.documents, + options && !Array.isArray(options) ? options.documents : undefined, + ].flat(); + const sources = documents.flatMap((document) => + typeof document === 'string' + ? [document] + : document + ? Object.keys(document) + : [], + ); + return [filename, [...new Set(sources)]]; + }), ); } diff --git a/packages/cli/src/lib/codegen/types.ts b/packages/cli/src/lib/codegen/types.ts new file mode 100644 index 0000000000..5b40f01a44 --- /dev/null +++ b/packages/cli/src/lib/codegen/types.ts @@ -0,0 +1,29 @@ +import { + defineJsonOutputSchema, + type InferJsonOutputSchema, +} from '@shopify/cli-kit/node/json-output-schema'; +import {absolutePath} from '../json-contract.js'; +import {zod} from '@shopify/cli-kit/node/schema'; + +export const codegenJsonOutputSchema = defineJsonOutputSchema({ + name: 'HydrogenCodegenResult', + schema: zod + .object({ + files: zod.array( + zod + .object({ + path: absolutePath, + sources: zod + .array(zod.string()) + .describe( + 'Source patterns from the GraphQL codegen configuration.', + ), + }) + .strict(), + ), + }) + .strict(), +}); +export type CodegenResult = InferJsonOutputSchema< + typeof codegenJsonOutputSchema +>; diff --git a/packages/cli/src/lib/hydrogen-command.test.ts b/packages/cli/src/lib/hydrogen-command.test.ts index b954c1ce18..841909768e 100644 --- a/packages/cli/src/lib/hydrogen-command.test.ts +++ b/packages/cli/src/lib/hydrogen-command.test.ts @@ -6,6 +6,7 @@ import type {Config} from '@oclif/core'; import {isTerminalInteractive} from '@shopify/cli-kit/node/context/local'; import {terminalSupportsPrompting} from '@shopify/cli-kit/node/system'; import {renderTextPrompt} from '@shopify/cli-kit/node/ui'; +import {runWithCommandEventsForCommand} from '@shopify/cli-kit/node/command-events'; import {jsonFlag} from '@shopify/cli-kit/node/cli'; import {outputResult} from '@shopify/cli-kit/node/output'; import {AbortError} from '@shopify/cli-kit/node/error'; @@ -261,6 +262,37 @@ it.each(['--json', '-j', 'environment'])( }, ); +it.each(['True', 'tRuE', 'YeS', 'y', 'Y'])( + 'uses JSON for early project errors with SHOPIFY_FLAG_JSON=%s', + async (value) => { + vi.stubEnv('SHOPIFY_FLAG_JSON', value); + const subject = command(); + await withCapturedStandardStreams(async ({stdout, stderr}) => { + await runWithCommandEventsForCommand([], () => subject.execute()); + expect(JSON.parse(stdout()).error.message).toContain( + 'outside of a Hydrogen project', + ); + expect(stderr()).toBe(''); + expect(subject.ran).toBe(false); + }); + }, +); + +it.each(['False', '0', 'No', 'n'])( + 'keeps early project errors as text with SHOPIFY_FLAG_JSON=%s', + async (value) => { + vi.stubEnv('SHOPIFY_FLAG_JSON', value); + const subject = command(); + await withCapturedStandardStreams(async ({stdout, stderr}) => { + await subject.execute(); + expect(stdout()).toBe(''); + expect(stderr().replaceAll('│', '').replace(/\s+/g, ' ')).toContain( + 'outside of a Hydrogen project', + ); + }); + }, +); + it('uses the command error lifecycle for disabled commands', async () => { createProject(projectPath, ['hydrogen:build']); const subject = command(['--json']); diff --git a/packages/cli/src/lib/hydrogen-command.ts b/packages/cli/src/lib/hydrogen-command.ts index 435b3c90dd..f8def11a89 100644 --- a/packages/cli/src/lib/hydrogen-command.ts +++ b/packages/cli/src/lib/hydrogen-command.ts @@ -1,5 +1,11 @@ import Command from '@shopify/cli-kit/node/base-command'; -import {globalFlags} from '@shopify/cli-kit/node/cli'; +import {Parser} from '@oclif/core'; +import { + commandEventOutputMode, + renderCommandEventAsJson, + runWithCommandEvents, +} from '@shopify/cli-kit/node/command-events'; +import {globalFlags, jsonFlag} from '@shopify/cli-kit/node/cli'; import {AbortError} from '@shopify/cli-kit/node/error'; import {cwd, resolvePath} from '@shopify/cli-kit/node/path'; import {isTruthy} from '@shopify/cli-kit/node/context/utilities'; @@ -13,7 +19,19 @@ export default abstract class HydrogenCommand extends Command { static baseFlags = {...Command.baseFlags, ...globalFlags}; async _run() { - return withJsonConsole(() => super._run()); + const execute = () => withJsonConsole(() => super._run()); + // Match oclif's environment boolean parsing before init or error handling. + // CLI Kit 4.9 does not recognize mixed-case values such as "True" or "y". + const {flags} = await Parser.parse([], {flags: jsonFlag}); + // The error handler and child processes also read the environment directly. + if (flags.json) process.env.SHOPIFY_FLAG_JSON = 'true'; + if (flags.json && commandEventOutputMode() !== 'json') { + return runWithCommandEvents( + {outputMode: 'json', sink: renderCommandEventAsJson}, + execute, + ); + } + return execute(); } protected async init(): Promise {