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
3 changes: 0 additions & 3 deletions packages/cli/json-output-command-exceptions.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -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/env/list.ts',
'packages/cli/src/commands/hydrogen/env/pull.ts',
'packages/cli/src/commands/hydrogen/env/push.ts',
'packages/cli/src/commands/hydrogen/g.ts',
'packages/cli/src/commands/hydrogen/generate/route.ts',
'packages/cli/src/commands/hydrogen/generate/routes.ts',
Expand Down
34 changes: 31 additions & 3 deletions packages/cli/oclif.manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -775,7 +775,7 @@
"hydrogen:env:list": {
"aliases": [],
"args": {},
"description": "List the environments on your linked Hydrogen storefront.",
"description": "Lists all environments available on the linked Hydrogen storefront.\n\nUse `--json-schema` to print the result, error, and event schemas.\n\nOutput from `--json` conforms to the `HydrogenEnvListResult` schema.\n\n```json\n{\n \"anyOf\": [\n {\n \"type\": \"object\",\n \"properties\": {\n \"id\": {\n \"type\": \"string\"\n },\n \"title\": {\n \"type\": \"string\"\n },\n \"productionUrl\": {\n \"type\": [\n \"string\",\n \"null\"\n ]\n },\n \"environments\": {\n \"type\": \"array\",\n \"items\": {\n \"$ref\": \"#/definitions/Environment\"\n }\n }\n },\n \"required\": [\n \"id\",\n \"title\",\n \"productionUrl\",\n \"environments\"\n ],\n \"additionalProperties\": false\n },\n {\n \"type\": \"null\"\n }\n ],\n \"title\": \"HydrogenEnvListResult\",\n \"definitions\": {\n \"Environment\": {\n \"type\": \"object\",\n \"properties\": {\n \"id\": {\n \"type\": \"string\"\n },\n \"name\": {\n \"type\": \"string\"\n },\n \"handle\": {\n \"type\": \"string\"\n },\n \"branch\": {\n \"type\": [\n \"string\",\n \"null\"\n ]\n },\n \"createdAt\": {\n \"type\": \"string\"\n },\n \"type\": {\n \"type\": \"string\",\n \"enum\": [\n \"PREVIEW\",\n \"PRODUCTION\",\n \"CUSTOM\"\n ]\n },\n \"url\": {\n \"type\": [\n \"string\",\n \"null\"\n ]\n }\n },\n \"required\": [\n \"id\",\n \"name\",\n \"handle\",\n \"branch\",\n \"createdAt\",\n \"type\",\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 @@ -784,6 +784,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",
Expand Down Expand Up @@ -814,7 +823,7 @@
"hydrogen:env:pull": {
"aliases": [],
"args": {},
"description": "Populate your .env with variables from your Hydrogen storefront.",
"description": "Pulls environment variables from the linked Hydrogen storefront and writes them to an `.env` file.\n\nUse `--json-schema` to print the result, error, and event schemas.\n\nOutput from `--json` conforms to the `HydrogenEnvPullResult` schema.\n\n```json\n{\n \"type\": \"object\",\n \"properties\": {\n \"status\": {\n \"type\": \"string\",\n \"enum\": [\n \"pulled\",\n \"unchanged\",\n \"empty\",\n \"cancelled\"\n ]\n },\n \"file\": {\n \"type\": \"string\"\n },\n \"storefrontId\": {\n \"type\": \"string\"\n },\n \"storefrontTitle\": {\n \"type\": \"string\"\n },\n \"environment\": {\n \"type\": \"string\"\n },\n \"variables\": {\n \"type\": \"array\",\n \"items\": {\n \"$ref\": \"#/definitions/Variable\"\n }\n }\n },\n \"required\": [\n \"status\",\n \"file\",\n \"variables\"\n ],\n \"additionalProperties\": false,\n \"title\": \"HydrogenEnvPullResult\",\n \"definitions\": {\n \"Variable\": {\n \"type\": \"object\",\n \"properties\": {\n \"id\": {\n \"type\": \"string\"\n },\n \"key\": {\n \"type\": \"string\"\n },\n \"isSecret\": {\n \"type\": \"boolean\"\n },\n \"readOnly\": {\n \"type\": \"boolean\"\n }\n },\n \"required\": [\n \"id\",\n \"key\",\n \"isSecret\",\n \"readOnly\"\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 @@ -823,6 +832,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"
},
"env": {
"description": "Specifies the environment to perform the operation using its handle. Fetch the handle using the `env list` command.",
"exclusive": [
Expand Down Expand Up @@ -892,7 +910,7 @@
"hydrogen:env:push": {
"aliases": [],
"args": {},
"description": "Push environment variables from the local .env file to your linked Hydrogen storefront.",
"description": "Push environment variables from the local .env file to your linked Hydrogen storefront.\n\nUse `--json-schema` to print the result, error, and event schemas.\n\nOutput from `--json` conforms to the `HydrogenEnvPushResult` schema.\n\n```json\n{\n \"type\": \"object\",\n \"properties\": {\n \"status\": {\n \"type\": \"string\",\n \"enum\": [\n \"pushed\",\n \"unchanged\",\n \"dry-run\",\n \"cancelled\"\n ]\n },\n \"file\": {\n \"type\": \"string\"\n },\n \"environment\": {\n \"$ref\": \"#/definitions/Environment\"\n },\n \"variables\": {\n \"type\": \"array\",\n \"items\": {\n \"type\": \"string\"\n }\n },\n \"skipped\": {\n \"type\": \"array\",\n \"items\": {\n \"type\": \"string\"\n }\n }\n },\n \"required\": [\n \"status\",\n \"file\",\n \"variables\",\n \"skipped\"\n ],\n \"additionalProperties\": false,\n \"title\": \"HydrogenEnvPushResult\",\n \"definitions\": {\n \"Environment\": {\n \"type\": \"object\",\n \"properties\": {\n \"id\": {\n \"type\": \"string\"\n },\n \"name\": {\n \"type\": \"string\"\n },\n \"handle\": {\n \"type\": \"string\"\n },\n \"branch\": {\n \"type\": [\n \"string\",\n \"null\"\n ]\n },\n \"createdAt\": {\n \"type\": \"string\"\n },\n \"type\": {\n \"type\": \"string\",\n \"enum\": [\n \"PREVIEW\",\n \"PRODUCTION\",\n \"CUSTOM\"\n ]\n },\n \"url\": {\n \"type\": [\n \"string\",\n \"null\"\n ]\n }\n },\n \"required\": [\n \"id\",\n \"name\",\n \"handle\",\n \"branch\",\n \"createdAt\",\n \"type\",\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 @@ -901,6 +919,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"
},
"env": {
"description": "Specifies the environment to perform the operation using its handle. Fetch the handle using the `env list` command.",
"exclusive": [
Expand Down Expand Up @@ -955,6 +982,7 @@
"pluginType": "core",
"strict": true,
"enableJsonFlag": false,
"descriptionWithMarkdown": "Push environment variables from the local .env file to your linked Hydrogen storefront.",
"isESM": true,
"relativePath": [
"dist",
Expand Down
183 changes: 183 additions & 0 deletions packages/cli/src/commands/hydrogen/env/json-output.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,183 @@
import {mkdtemp, rm, writeFile, readFile} from 'node:fs/promises';
import {tmpdir} from 'node:os';
import {join} from 'node:path';
import {afterEach, beforeEach, expect, it, vi} from 'vitest';
import {renderConfirmationPrompt} from '@shopify/cli-kit/node/ui';
import {captureJsonOutput} from '../../../../tests/output.js';
import {login} from '../../../lib/auth.js';
import {verifyLinkedStorefront} from '../../../lib/verify-linked-storefront.js';
import {getStorefrontEnvironments} from '../../../lib/graphql/admin/list-environments.js';
import {getStorefrontEnvVariables} from '../../../lib/graphql/admin/pull-variables.js';
import {pushStorefrontEnvVariables} from '../../../lib/graphql/admin/push-variables.js';
import EnvList, {runEnvList} from './list.js';
import EnvPull, {runEnvPull} from './pull.js';
import EnvPush, {runEnvPush} from './push.js';

vi.mock('../../../lib/auth.js');
vi.mock('../../../lib/verify-linked-storefront.js');
vi.mock('../../../lib/graphql/admin/list-environments.js');
vi.mock('../../../lib/graphql/admin/pull-variables.js');
vi.mock('../../../lib/graphql/admin/push-variables.js');
vi.mock('../../../lib/shell.js', () => ({getCliCommand: () => 'h2'}));
vi.mock('@shopify/cli-kit/node/ui', async (original) => ({
...(await original<any>()),
renderConfirmationPrompt: vi.fn(),
}));

const storefront = {
id: 'gid://shopify/HydrogenStorefront/1',
title: 'Example',
productionUrl: 'https://example.com',
};
const environment = {
id: '1',
name: 'Production',
handle: 'production',
branch: 'main',
createdAt: '2026-01-01',
type: 'PRODUCTION' as const,
url: null,
};
let directory: string;

beforeEach(async () => {
vi.clearAllMocks();
directory = await mkdtemp(join(tmpdir(), 'hydrogen-env-json-'));
vi.mocked(login).mockResolvedValue({
session: {token: 'secret', storeFqdn: 'example.myshopify.com'},
config: {
storefront,
shop: 'example.myshopify.com',
shopName: 'Example',
email: 'developer@example.com',
},
});
vi.mocked(verifyLinkedStorefront).mockResolvedValue(storefront);
vi.mocked(getStorefrontEnvironments).mockResolvedValue({
...storefront,
environments: [environment],
});
vi.mocked(getStorefrontEnvVariables).mockResolvedValue({
id: storefront.id,
environmentVariables: [],
});
vi.mocked(pushStorefrontEnvVariables).mockResolvedValue({
userErrors: [],
} as any);
});
afterEach(async () => {
await rm(directory, {recursive: true, force: true});
});

it('lists every environment field and handles a missing preview environment', async () => {
const {stdout, stderr} = await captureJsonOutput(() =>
runEnvList({path: directory}),
);
expect(JSON.parse(stdout)).toEqual({
...storefront,
environments: [environment],
});
expect(stderr).toBe('');
});

it('encodes an empty environment list', async () => {
vi.mocked(getStorefrontEnvironments).mockResolvedValue({
...storefront,
environments: [],
});
const {stdout} = await captureJsonOutput(() => runEnvList({path: directory}));
expect(JSON.parse(stdout).environments).toEqual([]);
});

it('pulls values into the file and emits a receipt without values on stdout', async () => {
vi.mocked(getStorefrontEnvVariables).mockResolvedValue({
id: storefront.id,
environmentVariables: [
{
id: '1',
key: 'PUBLIC_KEY',
value: 'public-value',
isSecret: false,
readOnly: false,
},
{
id: '2',
key: 'SECRET_KEY',
value: 'secret-value',
isSecret: true,
readOnly: false,
},
],
});
const {stdout} = await captureJsonOutput(() =>
runEnvPull({path: directory, envFile: '.env', force: true}),
);
expect(JSON.parse(stdout)).toMatchObject({
status: 'pulled',
file: join(directory, '.env'),
variables: [{key: 'PUBLIC_KEY'}, {key: 'SECRET_KEY'}],
});
expect(stdout).not.toContain('public-value');
expect(stdout).not.toContain('secret-value');
expect(await readFile(join(directory, '.env'), 'utf8')).toContain(
'PUBLIC_KEY=public-value',
);
});

it('preserves confirmation prompts in JSON mode and reports cancellation', async () => {
await writeFile(join(directory, '.env'), 'KEY=value\n');
vi.mocked(renderConfirmationPrompt).mockResolvedValue(false);
const {stdout} = await captureJsonOutput(() =>
runEnvPush({path: directory, envFile: '.env', env: 'production'}),
);
expect(renderConfirmationPrompt).toHaveBeenCalled();
expect(JSON.parse(stdout).status).toBe('cancelled');
expect(pushStorefrontEnvVariables).not.toHaveBeenCalled();
});

it('reports dry runs without exposing diff values or making a mutation', async () => {
await writeFile(join(directory, '.env'), 'KEY=private-value\n');
const {stdout} = await captureJsonOutput(() =>
runEnvPush({
path: directory,
envFile: '.env',
env: 'production',
dryRun: true,
}),
);
expect(JSON.parse(stdout)).toMatchObject({
status: 'dry-run',
environment,
variables: ['KEY'],
skipped: [],
});
expect(stdout).not.toContain('private-value');
expect(pushStorefrontEnvVariables).not.toHaveBeenCalled();
});

it('propagates upload failures without a successful document', async () => {
await writeFile(join(directory, '.env'), 'KEY=value\n');
vi.mocked(pushStorefrontEnvVariables).mockResolvedValue({
userErrors: [{message: 'Permission denied'}],
} as any);
const {stdout} = await captureJsonOutput(async () => {
await expect(
runEnvPush({
path: directory,
envFile: '.env',
env: 'production',
force: true,
}),
).rejects.toThrow('Failed to upload');
});
expect(stdout).toBe('');
});

it.each([EnvList, EnvPull, EnvPush])(
'advertises its schema and rejects invalid results: %s',
(command) => {
expect(command.flags.json).toBeDefined();
expect(command.description).toContain(command.jsonOutputSchema.name);
expect(() => command.jsonOutputSchema.validate({status: 12})).toThrow();
},
);
56 changes: 41 additions & 15 deletions packages/cli/src/commands/hydrogen/env/list.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
import {writeJsonResult} from '../../../lib/json-output.js';
import {jsonFlag} from '@shopify/cli-kit/node/cli';
import {envListJsonOutputSchema} from '../../../lib/environments/types.js';
import Command from '@shopify/cli-kit/node/base-command';
import {pluralize} from '@shopify/cli-kit/common/string';
import {
Expand All @@ -14,27 +17,42 @@ import {getCliCommand} from '../../../lib/shell.js';
import {verifyLinkedStorefront} from '../../../lib/verify-linked-storefront.js';

export default class EnvList extends Command {
static get jsonOutputSchema(): typeof envListJsonOutputSchema {
return envListJsonOutputSchema;
}

static descriptionWithMarkdown =
'Lists all environments available on the linked Hydrogen storefront.';

static description =
'List the environments on your linked Hydrogen storefront.';
static description = this.descriptionForHelp();

static flags = {
...jsonFlag,
...commonFlags.path,
};

async run(): Promise<void> {
const {flags} = await this.parse(EnvList);
await runEnvList(flags);
await runEnvList(flags, flags.json);
}
}

interface EnvListOptions {
path?: string;
}

export async function runEnvList({path: root = process.cwd()}: EnvListOptions) {
export async function runEnvList(options: EnvListOptions, json?: boolean) {
const result = await listEnvironments(options);
if (!writeJsonResult(envListJsonOutputSchema, result, json) && result)
renderEnvironments(result);
return result;
}

export async function listEnvironments({
path: root = process.cwd(),
}: EnvListOptions): Promise<
import('../../../lib/environments/types.js').EnvListResult
> {
const [{session, config}, cliCommand] = await Promise.all([
login(root),
getCliCommand(),
Expand All @@ -47,7 +65,7 @@ export async function runEnvList({path: root = process.cwd()}: EnvListOptions) {
cliCommand,
});

if (!linkedStorefront) return;
if (!linkedStorefront) return null;

config.storefront = linkedStorefront;

Expand All @@ -63,24 +81,32 @@ export async function runEnvList({path: root = process.cwd()}: EnvListOptions) {
cliCommand,
});

return;
return null;
}

// Make sure we always show the preview environment last because it doesn't
// have a branch or a URL.
const previewEnvironmentIndex = storefront.environments.findIndex(
(env) => env.type === 'PREVIEW',
);
const previewEnvironment = storefront.environments.splice(
previewEnvironmentIndex,
1,
);
storefront.environments.push(previewEnvironment[0]!);
const environments = [
...storefront.environments.filter((env) => env.type !== 'PREVIEW'),
...storefront.environments.filter((env) => env.type === 'PREVIEW'),
];
return {
id: storefront.id,
title: config.storefront.title,
productionUrl: storefront.productionUrl,
environments,
};
}

function renderEnvironments(
storefront: NonNullable<
import('../../../lib/environments/types.js').EnvListResult
>,
) {
outputInfo(
pluralizedEnvironments({
environments: storefront.environments,
storefrontTitle: config.storefront.title,
storefrontTitle: storefront.title,
}).toString(),
);

Expand Down
Loading
Loading