diff --git a/packages/cli/json-output-command-exceptions.cjs b/packages/cli/json-output-command-exceptions.cjs index 821fbc9220..7e79049cea 100644 --- a/packages/cli/json-output-command-exceptions.cjs +++ b/packages/cli/json-output-command-exceptions.cjs @@ -2,7 +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/deploy.ts', 'packages/cli/src/commands/hydrogen/env/list.ts', 'packages/cli/src/commands/hydrogen/env/pull.ts', 'packages/cli/src/commands/hydrogen/env/push.ts', diff --git a/packages/cli/oclif.manifest.json b/packages/cli/oclif.manifest.json index b89cc16bad..e7caa3d6c1 100644 --- a/packages/cli/oclif.manifest.json +++ b/packages/cli/oclif.manifest.json @@ -382,7 +382,7 @@ "hydrogen:deploy": { "aliases": [], "args": {}, - "description": "Builds and deploys a Hydrogen storefront to Oxygen.", + "description": "Builds and deploys your Hydrogen storefront to Oxygen. Requires an Oxygen deployment token to be set with the `--token` flag or an environment variable (`SHOPIFY_HYDROGEN_DEPLOYMENT_TOKEN`). If the storefront is \"linked\" (https://shopify.dev/docs/api/shopify-cli/hydrogen/hydrogen-link) then the Oxygen deployment token for the linked storefront will be used automatically.\n\nUse `--json-schema` to print the result, error, and event schemas.\n\nOutput from `--json` conforms to the `HydrogenDeployResult` schema.\n\n```json\n{\n \"anyOf\": [\n {\n \"$ref\": \"#/definitions/Deployment\"\n },\n {\n \"type\": \"null\"\n }\n ],\n \"title\": \"HydrogenDeployResult\",\n \"definitions\": {\n \"Deployment\": {\n \"type\": \"object\",\n \"properties\": {\n \"url\": {\n \"type\": \"string\"\n },\n \"authBypassToken\": {\n \"type\": \"string\"\n }\n },\n \"required\": [\n \"url\"\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.", @@ -391,6 +391,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" + }, "entry": { "description": "Entry file for the worker. Defaults to `./server`.", "env": "SHOPIFY_HYDROGEN_FLAG_ENTRY", diff --git a/packages/cli/src/commands/hydrogen/deploy.test.ts b/packages/cli/src/commands/hydrogen/deploy.test.ts index 085f8aa44f..5ab4e755cd 100644 --- a/packages/cli/src/commands/hydrogen/deploy.test.ts +++ b/packages/cli/src/commands/hydrogen/deploy.test.ts @@ -1,3 +1,10 @@ +import Deploy, { + deploymentLogger, + getHydrogenVersion, + resolveDeploymentOutputDirs, + runDeploy, +} from './deploy.js'; +import {captureJsonOutput} from '../../../tests/output.js'; import {describe, it, expect, vi, beforeEach, afterEach} from 'vitest'; import {mkdtempSync, mkdirSync, rmSync, writeFileSync} from 'node:fs'; import {tmpdir} from 'node:os'; @@ -19,12 +26,6 @@ import { GitDirectoryNotCleanError, } from '@shopify/cli-kit/node/git'; -import { - deploymentLogger, - getHydrogenVersion, - resolveDeploymentOutputDirs, - runDeploy, -} from './deploy.js'; import {getOxygenDeploymentData} from '../../lib/get-oxygen-deployment-data.js'; import {execAsync} from '../../lib/process.js'; import {createEnvironmentCliChoiceLabel} from '../../lib/common.js'; @@ -276,6 +277,70 @@ describe('deploy', async () => { } }); + it('writes a single deployment result and keeps diagnostics off stdout', async () => { + const {stdout, stderr} = await captureJsonOutput(() => + runDeploy({...deployParams, json: true}), + ); + expect(JSON.parse(stdout)).toEqual({ + url: 'https://a-lovely-deployment.com', + authBypassToken: 'some-token', + }); + expect( + stderr + .trim() + .split('\n') + .map((line) => JSON.parse(line)), + ).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + type: 'diagnostic', + message: 'Could not retrieve Git history.', + }), + ]), + ); + expect(renderSuccess).not.toHaveBeenCalled(); + }); + + it('keeps CI file output independent from the JSON flag and preserves its bytes', async () => { + vi.mocked(ciPlatform).mockReturnValue({ + isCI: true, + name: 'test', + metadata: {}, + }); + const deployment = { + authBypassToken: 'some-token', + url: 'https://a-lovely-deployment.com', + }; + const {stdout} = await captureJsonOutput(() => + runDeploy({...deployParams, token: 'token', json: true}), + ); + expect(JSON.parse(stdout)).toEqual(deployment); + expect(writeFile).toHaveBeenCalledWith( + 'h2_deploy_log.json', + JSON.stringify(deployment), + ); + }); + + it('does not encode a successful result after a deployment failure', async () => { + vi.mocked(createDeploy).mockRejectedValue(new Error('Upload failed')); + const {stdout} = await captureJsonOutput(async () => { + await expect(runDeploy({...deployParams, json: true})).rejects.toThrow( + 'Upload failed', + ); + }); + expect(stdout).toBe(''); + }); + + it('documents the schema and preserves omission of bypass tokens', () => { + expect(Deploy.flags.json).toBeDefined(); + expect(Deploy.description).toContain(Deploy.jsonOutputSchema.name); + expect( + JSON.parse(Deploy.jsonOutputSchema.encode({url: 'https://example.com'})), + ).toEqual({url: 'https://example.com'}); + expect(() => Deploy.jsonOutputSchema.encode({url: 1} as any)).toThrow(); + expect(Deploy.jsonOutputSchema.encode(null)).toBe('null'); + }); + it('calls getOxygenDeploymentData with the correct parameters', async () => { await runDeploy(deployParams); expect(getOxygenDeploymentData).toHaveBeenCalledWith({ diff --git a/packages/cli/src/commands/hydrogen/deploy.ts b/packages/cli/src/commands/hydrogen/deploy.ts index 117b1a735f..8dada5b362 100644 --- a/packages/cli/src/commands/hydrogen/deploy.ts +++ b/packages/cli/src/commands/hydrogen/deploy.ts @@ -1,13 +1,18 @@ -import {Flags} from '@oclif/core'; -import Command from '@shopify/cli-kit/node/base-command'; -import colors from '@shopify/cli-kit/node/colors'; +import {writeJsonResult, isJsonOutput} from '../../lib/json-output.js'; import { + flushStdout, outputContent, outputInfo, outputWarn, Logger, LogLevel, } from '@shopify/cli-kit/node/output'; +import {emitCommandEvent} from '@shopify/cli-kit/node/command-events'; +import {jsonFlag} from '@shopify/cli-kit/node/cli'; +import {deployJsonOutputSchema} from '../../lib/deployment/types.js'; +import {Flags} from '@oclif/core'; +import Command from '@shopify/cli-kit/node/base-command'; +import colors from '@shopify/cli-kit/node/colors'; import {type PackageJson} from '@shopify/cli-kit/node/node-package-manager'; import {readAndParseDotEnv} from '@shopify/cli-kit/node/dot-env'; import {AbortError} from '@shopify/cli-kit/node/error'; @@ -73,9 +78,14 @@ export const deploymentLogger: Logger = ( }; export default class Deploy extends Command { + static get jsonOutputSchema(): typeof deployJsonOutputSchema { + return deployJsonOutputSchema; + } + static descriptionWithMarkdown = `Builds and deploys your Hydrogen storefront to Oxygen. Requires an Oxygen deployment token to be set with the \`--token\` flag or an environment variable (\`SHOPIFY_HYDROGEN_DEPLOYMENT_TOKEN\`). If the storefront is [linked](https://shopify.dev/docs/api/shopify-cli/hydrogen/hydrogen-link) then the Oxygen deployment token for the linked storefront will be used automatically.`; - static description = 'Builds and deploys a Hydrogen storefront to Oxygen.'; - static flags: any = { + static description = this.descriptionForHelp(); + static flags = { + ...jsonFlag, ...commonFlags.entry, ...commonFlags.env, ...commonFlags.envBranch, @@ -190,6 +200,7 @@ export default class Deploy 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 + await flushStdout(); process.exit(0); } @@ -207,6 +218,7 @@ export default class Deploy extends Command { } interface OxygenDeploymentOptions { + json?: boolean; authBypassTokenDuration?: string; authBypassToken: boolean; buildCommand?: string; @@ -255,9 +267,60 @@ function createUnexpectedAbortError(message?: string): AbortError { ); } -export async function runDeploy( +export async function runDeploy(options: OxygenDeploymentOptions) { + const completedDeployment = await executeDeploy(options); + const isCI = ciPlatform().isCI; + const {jsonOutput} = options; + if (!completedDeployment) { + writeJsonResult(deployJsonOutputSchema, null, options.json); + return; + } + if (isCI && jsonOutput) { + await writeFile( + DEPLOY_OUTPUT_FILE_HANDLE, + JSON.stringify(completedDeployment), + ); + } + if ( + !writeJsonResult(deployJsonOutputSchema, completedDeployment, options.json) + ) { + const nextSteps: (string | {subdued: string} | {link: {url: string}})[][] = + []; + + if (isCI) { + if (jsonOutput) { + nextSteps.push([ + 'View the deployment information in', + {subdued: DEPLOY_OUTPUT_FILE_HANDLE}, + ]); + } + } else { + nextSteps.push([ + 'Open', + {link: {url: completedDeployment!.url}}, + 'in your browser to view your deployment.', + ]); + + if (completedDeployment?.authBypassToken) { + nextSteps.push([ + 'Use the', + {subdued: completedDeployment.authBypassToken}, + 'token to perform end-to-end tests against the deployment.', + ]); + } + } + + renderSuccess({ + body: ['Successfully deployed to Oxygen'], + nextSteps, + }); + } + return completedDeployment; +} + +export async function executeDeploy( options: OxygenDeploymentOptions, -): Promise { +): Promise { const { authBypassTokenDuration, authBypassToken: generateAuthBypassToken, @@ -585,9 +648,9 @@ Continue?`.value, let deployError: AbortError | null = null; let buildError: Error | null = null; - let resolveDeploy: () => void; + let resolveDeploy: (result: CompletedDeployment) => void; let rejectDeploy: (reason?: AbortError) => void; - const deployPromise = new Promise((resolve, reject) => { + const deployPromise = new Promise((resolve, reject) => { resolveDeploy = resolve; rejectDeploy = reject; }); @@ -622,7 +685,7 @@ Continue?`.value, if (buildCommand || shouldUseDefaultBuildCommand) { if (forceClientSourcemap) { - console.log(''); + if (!isJsonOutput()) console.log(''); renderInfo({ headline: 'The `--force-client-sourcemap` flag is not supported with a custom build command', @@ -630,6 +693,35 @@ Continue?`.value, }); } config.buildCommand = buildCommand ?? DEFAULT_BUILD_COMMAND; + if (isJsonOutput()) { + hooks.buildFunction = async (assetPath) => { + try { + const {stdout, stderr} = await execAsync(config.buildCommand!, { + cwd: root, + env: { + ...process.env, + ...(assetPath ? {HYDROGEN_ASSET_BASE_URL: assetPath} : {}), + }, + maxBuffer: 64 * 1024 * 1024, + }); + if (stdout.trim()) + emitCommandEvent({ + type: 'diagnostic', + level: 'info', + message: stdout.trim(), + }); + if (stderr.trim()) + emitCommandEvent({ + type: 'diagnostic', + level: 'warning', + message: stderr.trim(), + }); + } catch (error) { + buildError = error as Error; + throw error; + } + }; + } } else { hooks.buildFunction = async ( assetPath: string | undefined, @@ -688,47 +780,7 @@ Continue?`.value, return; } - const nextSteps: ( - | string - | {subdued: string} - | {link: {url: string}} - )[][] = []; - - if (isCI) { - if (jsonOutput) { - nextSteps.push([ - 'View the deployment information in', - {subdued: DEPLOY_OUTPUT_FILE_HANDLE}, - ]); - } - } else { - nextSteps.push([ - 'Open', - {link: {url: completedDeployment!.url}}, - 'in your browser to view your deployment.', - ]); - - if (completedDeployment?.authBypassToken) { - nextSteps.push([ - 'Use the', - {subdued: completedDeployment.authBypassToken}, - 'token to perform end-to-end tests against the deployment.', - ]); - } - } - - renderSuccess({ - body: ['Successfully deployed to Oxygen'], - nextSteps, - }); - // in CI environments, output to a file so consequent steps can access the deployment details - if (isCI && jsonOutput) { - await writeFile( - DEPLOY_OUTPUT_FILE_HANDLE, - JSON.stringify(completedDeployment), - ); - } - resolveDeploy(); + resolveDeploy(completedDeployment); }) .catch((error) => { rejectDeploy(deployError || buildError || error); diff --git a/packages/cli/src/lib/deployment/types.ts b/packages/cli/src/lib/deployment/types.ts new file mode 100644 index 0000000000..0e0f75302d --- /dev/null +++ b/packages/cli/src/lib/deployment/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'; + +const Deployment = zod.object({ + url: zod.string(), + authBypassToken: zod.string().optional(), +}); +export const deployJsonOutputSchema = defineJsonOutputSchema({ + name: 'HydrogenDeployResult', + schema: Deployment.nullable(), + definitions: {Deployment}, +}); +export type DeployResult = InferJsonOutputSchema; diff --git a/packages/cli/src/lib/get-oxygen-deployment-data.ts b/packages/cli/src/lib/get-oxygen-deployment-data.ts index 522b712bc9..7e93e5e551 100644 --- a/packages/cli/src/lib/get-oxygen-deployment-data.ts +++ b/packages/cli/src/lib/get-oxygen-deployment-data.ts @@ -1,3 +1,5 @@ +import {AbortError} from '@shopify/cli-kit/node/error'; +import {isJsonOutput} from './json-output.js'; import {outputWarn} from '@shopify/cli-kit/node/output'; import {login} from './auth.js'; @@ -50,6 +52,8 @@ export async function getOxygenDeploymentData({ } if (!storefront.oxygenDeploymentToken) { + if (isJsonOutput()) + throw new AbortError('Could not retrieve a deployment token.'); outputWarn(`Could not retrieve a deployment token.`); return; } diff --git a/packages/cli/src/lib/render-errors.ts b/packages/cli/src/lib/render-errors.ts index fb81e38a6d..3e5a34cf09 100644 --- a/packages/cli/src/lib/render-errors.ts +++ b/packages/cli/src/lib/render-errors.ts @@ -1,3 +1,5 @@ +import {AbortError} from '@shopify/cli-kit/node/error'; +import {isJsonOutput} from './json-output.js'; import {renderFatalError, renderInfo} from './ui.js'; import {outputContent, outputToken} from '@shopify/cli-kit/node/output'; import type {AdminSession} from './auth.js'; @@ -16,6 +18,12 @@ export function renderMissingStorefront({ storefront, cliCommand, }: MissingStorefront) { + if (isJsonOutput()) { + throw new AbortError( + 'Couldn’t find Hydrogen storefront.', + `Couldn’t find ${storefront.title} (ID: ${parseGid(storefront.id)}) on ${session.storeFqdn}. Check that the storefront exists and run ${cliCommand} link to link this project to it.`, + ); + } renderFatalError({ name: 'NoStorefrontError', type: 0,