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
1 change: 0 additions & 1 deletion packages/cli/json-output-command-exceptions.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
11 changes: 10 additions & 1 deletion packages/cli/oclif.manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
Expand All @@ -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",
Expand Down
77 changes: 71 additions & 6 deletions packages/cli/src/commands/hydrogen/deploy.test.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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';
Expand Down Expand Up @@ -276,6 +277,70 @@ describe('deploy', async () => {
}
});

it('writes a single deployment result and keeps diagnostics off stdout', async () => {

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: the JSON-mode buildFunction for --build-command is the main new behaviour in this PR, but nothing tests it. Let's add a test that runs with buildCommand + json: true and checks the build output ends up as diagnostic events rather than on stdout. The new isJsonOutput() throws in getOxygenDeploymentData and renderMissingStorefront could use a quick test each too (get-oxygen-deployment-data.test.ts already exists).

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({
Expand Down
154 changes: 103 additions & 51 deletions packages/cli/src/commands/hydrogen/deploy.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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);
}

Expand All @@ -207,6 +218,7 @@ export default class Deploy extends Command {
}

interface OxygenDeploymentOptions {
json?: boolean;
authBypassTokenDuration?: string;
authBypassToken: boolean;
buildCommand?: string;
Expand Down Expand Up @@ -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<void> {
): Promise<CompletedDeployment | undefined> {
const {
authBypassTokenDuration,
authBypassToken: generateAuthBypassToken,
Expand Down Expand Up @@ -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<void>((resolve, reject) => {
const deployPromise = new Promise<CompletedDeployment>((resolve, reject) => {
resolveDeploy = resolve;
rejectDeploy = reject;
});
Expand Down Expand Up @@ -622,14 +685,43 @@ 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',
body: 'Client sourcemaps will not be generated.',
});
}
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) {

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: when the build fails we drop whatever it printed. exec's error message has stderr in it, but stdout is lost, and some build tools print the useful bit to stdout. Since the failed build is when you most need the logs, let's emit the captured output as diagnostics here too before rethrowing. IIRC the exec rejection error has stdout/stderr on it.

Some smaller tradeoffs vs oxygen-cli's own runner, which already pipes all build output to stderr, so stdout was never at risk. Fine if these are deliberate:

  • output is buffered until the build finishes, so long builds are silent in JSON mode
  • maxBuffer means a very chatty build (>64MB) fails only in JSON mode (unlikely)
  • oxygen-cli's Bugsnag buildCommand metadata isn't recorded on this path

buildError = error as Error;
throw error;
}
};
}
} else {
hooks.buildFunction = async (
assetPath: string | undefined,
Expand Down Expand Up @@ -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);
Expand Down
16 changes: 16 additions & 0 deletions packages/cli/src/lib/deployment/types.ts
Original file line number Diff line number Diff line change
@@ -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<typeof deployJsonOutputSchema>;
4 changes: 4 additions & 0 deletions packages/cli/src/lib/get-oxygen-deployment-data.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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;
}
Expand Down
Loading
Loading