From ff6f8971bd2c5d9b1ca1cc09e80ac373180f5637 Mon Sep 17 00:00:00 2001 From: Gonzalo Riestra Date: Mon, 28 Sep 2026 16:36:40 +0200 Subject: [PATCH] Add typed JSON output to Hydrogen environment commands --- .../cli/json-output-command-exceptions.cjs | 3 - packages/cli/oclif.manifest.json | 34 +++- .../commands/hydrogen/env/json-output.test.ts | 183 ++++++++++++++++++ .../cli/src/commands/hydrogen/env/list.ts | 56 ++++-- .../cli/src/commands/hydrogen/env/pull.ts | 89 +++++---- .../cli/src/commands/hydrogen/env/push.ts | 79 +++++--- packages/cli/src/lib/environments/types.ts | 65 +++++++ 7 files changed, 426 insertions(+), 83 deletions(-) create mode 100644 packages/cli/src/commands/hydrogen/env/json-output.test.ts create mode 100644 packages/cli/src/lib/environments/types.ts diff --git a/packages/cli/json-output-command-exceptions.cjs b/packages/cli/json-output-command-exceptions.cjs index 7e79049cea..22f1c3aae8 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/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', diff --git a/packages/cli/oclif.manifest.json b/packages/cli/oclif.manifest.json index e7caa3d6c1..7f161adf1d 100644 --- a/packages/cli/oclif.manifest.json +++ b/packages/cli/oclif.manifest.json @@ -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.", @@ -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", @@ -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.", @@ -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": [ @@ -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.", @@ -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": [ @@ -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", diff --git a/packages/cli/src/commands/hydrogen/env/json-output.test.ts b/packages/cli/src/commands/hydrogen/env/json-output.test.ts new file mode 100644 index 0000000000..3dcb0a3b87 --- /dev/null +++ b/packages/cli/src/commands/hydrogen/env/json-output.test.ts @@ -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()), + 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(); + }, +); diff --git a/packages/cli/src/commands/hydrogen/env/list.ts b/packages/cli/src/commands/hydrogen/env/list.ts index b9f6ba3967..02c2205b64 100644 --- a/packages/cli/src/commands/hydrogen/env/list.ts +++ b/packages/cli/src/commands/hydrogen/env/list.ts @@ -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 { @@ -14,19 +17,23 @@ 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 { const {flags} = await this.parse(EnvList); - await runEnvList(flags); + await runEnvList(flags, flags.json); } } @@ -34,7 +41,18 @@ 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(), @@ -47,7 +65,7 @@ export async function runEnvList({path: root = process.cwd()}: EnvListOptions) { cliCommand, }); - if (!linkedStorefront) return; + if (!linkedStorefront) return null; config.storefront = linkedStorefront; @@ -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(), ); diff --git a/packages/cli/src/commands/hydrogen/env/pull.ts b/packages/cli/src/commands/hydrogen/env/pull.ts index 0e29e23690..151bfb6253 100644 --- a/packages/cli/src/commands/hydrogen/env/pull.ts +++ b/packages/cli/src/commands/hydrogen/env/pull.ts @@ -1,3 +1,11 @@ +import { + outputInfo, + outputContent, + outputToken, +} from '@shopify/cli-kit/node/output'; +import {writeJsonResult} from '../../../lib/json-output.js'; +import {jsonFlag} from '@shopify/cli-kit/node/cli'; +import {envPullJsonOutputSchema} from '../../../lib/environments/types.js'; import {diffLines} from 'diff'; import Command from '@shopify/cli-kit/node/base-command'; import { @@ -6,11 +14,6 @@ import { renderWarning, renderSuccess, } from '../../../lib/ui.js'; -import { - outputContent, - outputInfo, - outputToken, -} from '@shopify/cli-kit/node/output'; import {fileExists, readFile, writeFile} from '@shopify/cli-kit/node/fs'; import {resolvePath} from '@shopify/cli-kit/node/path'; import {patchEnvFile} from '@shopify/cli-kit/node/dot-env'; @@ -62,12 +65,16 @@ function quoteEnvValue(value: string): string { } export default class EnvPull extends Command { + static get jsonOutputSchema(): typeof envPullJsonOutputSchema { + return envPullJsonOutputSchema; + } + static descriptionWithMarkdown = 'Pulls environment variables from the linked Hydrogen storefront and writes them to an `.env` file.'; - static description = - 'Populate your .env with variables from your Hydrogen storefront.'; + static description = this.descriptionForHelp(); static flags = { + ...jsonFlag, ...commonFlags.env, ...commonFlags.envBranch, ...commonFlags.envFile, @@ -77,7 +84,7 @@ export default class EnvPull extends Command { async run(): Promise { const {flags} = await this.parse(EnvPull); - await runEnvPull({...flagsToCamelObject(flags)}); + await runEnvPull({...flagsToCamelObject(flags)}, flags.json); } } @@ -89,13 +96,16 @@ interface EnvPullOptions { path?: string; } -export async function runEnvPull({ +export async function pullEnvironmentVariables({ env: envHandle, envBranch, path: root = process.cwd(), envFile, force, -}: EnvPullOptions) { +}: EnvPullOptions): Promise< + import('../../../lib/environments/types.js').EnvPullResult +> { + const empty = {file: resolvePath(root, envFile), variables: []}; const [{session, config}, cliCommand] = await Promise.all([ login(root), getCliCommand(), @@ -108,7 +118,7 @@ export async function runEnvPull({ cliCommand, }); - if (!linkedStorefront) return; + if (!linkedStorefront) return {...empty, status: 'cancelled'}; config.storefront = linkedStorefront; @@ -139,16 +149,23 @@ export async function runEnvPull({ cliCommand, }); - return; - } - - if (!storefront.environmentVariables.length) { - outputInfo(`No environment variables found.`); - return; + return {...empty, status: 'cancelled'}; } const variables = storefront.environmentVariables; - if (!variables.length) return; + const result = { + ...empty, + storefrontId: config.storefront.id, + storefrontTitle: config.storefront.title, + environment: envHandle, + variables: variables.map(({id, key, isSecret, readOnly}) => ({ + id, + key, + isSecret, + readOnly, + })), + }; + if (!variables.length) return {...result, status: 'empty'}; const fileName = colors.whiteBright(envFile); const dotEnvPath = resolvePath(root, envFile); @@ -165,10 +182,7 @@ export async function runEnvPull({ const patchedEnv = patchEnvFile(existingEnv, fetchedEnv); if (existingEnv === patchedEnv) { - renderInfo({ - body: `No changes to your ${fileName} file`, - }); - return; + return {...result, status: 'unchanged'}; } const diff = diffLines(existingEnv, patchedEnv); @@ -183,9 +197,7 @@ ${outputToken.linesDiff(diff)} Continue?`.value, }); - if (!overwrite) { - return; - } + if (!overwrite) return {...result, status: 'cancelled'}; await writeFile(dotEnvPath, patchedEnv); } else { @@ -193,15 +205,26 @@ Continue?`.value, await writeFile(dotEnvPath, newEnv); } - const hasSecretVariables = variables.some(({isSecret}) => isSecret); + return {...result, status: 'pulled'}; +} - if (hasSecretVariables) { - renderWarning({ - body: `${config.storefront.title} contains environment variables marked as secret, so their values weren’t pulled.`, +export async function runEnvPull(options: EnvPullOptions, json?: boolean) { + const result = await pullEnvironmentVariables(options); + if (writeJsonResult(envPullJsonOutputSchema, result, json)) return result; + const fileName = colors.whiteBright(options.envFile); + if (result.status === 'empty') { + outputInfo('No environment variables found.'); + } else if (result.status === 'unchanged') { + renderInfo({body: `No changes to your ${fileName} file`}); + } else if (result.status === 'pulled') { + if (result.variables.some(({isSecret}) => isSecret)) { + renderWarning({ + body: `${result.storefrontTitle} contains environment variables marked as secret, so their values weren’t pulled.`, + }); + } + renderSuccess({ + body: ['Changes have been made to your', {filePath: fileName}, 'file'], }); } - - renderSuccess({ - body: ['Changes have been made to your', {filePath: fileName}, 'file'], - }); + return result; } diff --git a/packages/cli/src/commands/hydrogen/env/push.ts b/packages/cli/src/commands/hydrogen/env/push.ts index 3e61ca828f..6dab653e13 100644 --- a/packages/cli/src/commands/hydrogen/env/push.ts +++ b/packages/cli/src/commands/hydrogen/env/push.ts @@ -1,3 +1,6 @@ +import {writeJsonResult} from '../../../lib/json-output.js'; +import {jsonFlag} from '@shopify/cli-kit/node/cli'; +import {envPushJsonOutputSchema} from '../../../lib/environments/types.js'; import Command from '@shopify/cli-kit/node/base-command'; import {Flags} from '@oclif/core'; import {diffLines} from 'diff'; @@ -35,10 +38,17 @@ import { import {verifyLinkedStorefront} from '../../../lib/verify-linked-storefront.js'; export default class EnvPush extends Command { - static description = + static get jsonOutputSchema(): typeof envPushJsonOutputSchema { + return envPushJsonOutputSchema; + } + + static descriptionWithMarkdown = 'Push environment variables from the local .env file to your linked Hydrogen storefront.'; + static description = this.descriptionForHelp(); + static flags = { + ...jsonFlag, ...commonFlags.env, ...commonFlags.envFile, ...commonFlags.path, @@ -56,7 +66,7 @@ export default class EnvPush extends Command { async run(): Promise { const {flags} = await this.parse(EnvPush); - await runEnvPush({...flagsToCamelObject(flags)}); + await runEnvPush({...flagsToCamelObject(flags)}, flags.json); } } @@ -68,13 +78,17 @@ interface EnvPushOptions { path?: string; } -export async function runEnvPush({ +export async function pushEnvironmentVariables({ dryRun = false, env: envHandle, envFile, force = false, path = process.cwd(), -}: EnvPushOptions) { +}: EnvPushOptions): Promise< + import('../../../lib/environments/types.js').EnvPushResult & { + diff?: import('diff').Change[]; + } +> { let validatedEnvironment: Environment; // Ensure local .env file @@ -94,7 +108,8 @@ export async function runEnvPush({ cliCommand, }); - if (!linkedStorefront) return; + if (!linkedStorefront) + return {status: 'cancelled', file: dotEnvPath, variables: [], skipped: []}; config.storefront = linkedStorefront; @@ -191,26 +206,17 @@ export async function runEnvPush({ ); } - if (compareableLocalVars === comparableRemoteVars) { - renderInfo({ - body: 'No changes to your environment variables.', - }); - return; - } + const result = { + file: dotEnvPath, + environment: validatedEnvironment, + variables: Object.keys(localVariables), + skipped: remoteReadOnlyOrSecrets, + }; + if (compareableLocalVars === comparableRemoteVars) + return {...result, status: 'unchanged'}; const diff = diffLines(comparableRemoteVars, compareableLocalVars); - - if (dryRun) { - renderInfo({ - body: outputContent`The following changes would be made to your environment variables for ${ - validatedEnvironment.name - }: - -${outputToken.linesDiff(diff)} -No changes were pushed because --dry-run was used.`.value, - }); - return; - } + if (dryRun) return {...result, status: 'dry-run', diff}; if (!force) { const confirmPush = await renderConfirmationPrompt({ @@ -226,7 +232,7 @@ Continue?`.value, }); // Cancelled making changes - if (!confirmPush) return; + if (!confirmPush) return {...result, status: 'cancelled'}; } if (!validatedEnvironment.id) throw new AbortError('Missing environment ID'); @@ -244,9 +250,24 @@ Continue?`.value, ); } - renderSuccess({ - body: `Environment variables push to ${ - validatedEnvironment.name ?? 'Preview' - } was successful.`, - }); + return {...result, status: 'pushed'}; +} + +export async function runEnvPush(options: EnvPushOptions, json?: boolean) { + const result = await pushEnvironmentVariables(options); + if (writeJsonResult(envPushJsonOutputSchema, result, json)) return result; + if (result.status === 'unchanged') + renderInfo({body: 'No changes to your environment variables.'}); + if (result.status === 'dry-run') + renderInfo({ + body: outputContent`The following changes would be made to your environment variables for ${result.environment!.name}: + +${outputToken.linesDiff(result.diff!)} +No changes were pushed because --dry-run was used.`.value, + }); + if (result.status === 'pushed') + renderSuccess({ + body: `Environment variables push to ${result.environment?.name ?? 'Preview'} was successful.`, + }); + return result; } diff --git a/packages/cli/src/lib/environments/types.ts b/packages/cli/src/lib/environments/types.ts new file mode 100644 index 0000000000..daf4e96191 --- /dev/null +++ b/packages/cli/src/lib/environments/types.ts @@ -0,0 +1,65 @@ +import { + defineJsonOutputSchema, + type InferJsonOutputSchema, +} from '@shopify/cli-kit/node/json-output-schema'; +import {zod} from '@shopify/cli-kit/node/schema'; + +export const Environment = zod.object({ + id: zod.string(), + name: zod.string(), + handle: zod.string(), + branch: zod.string().nullable(), + createdAt: zod.string(), + type: zod.enum(['PREVIEW', 'PRODUCTION', 'CUSTOM']), + url: zod.string().nullable(), +}); +const Variable = zod.object({ + id: zod.string(), + key: zod.string(), + isSecret: zod.boolean(), + readOnly: zod.boolean(), +}); +export const envListJsonOutputSchema = defineJsonOutputSchema({ + name: 'HydrogenEnvListResult', + schema: zod + .object({ + id: zod.string(), + title: zod.string(), + productionUrl: zod.string().nullable(), + environments: zod.array(Environment), + }) + .nullable(), + definitions: {Environment}, +}); +export type EnvListResult = InferJsonOutputSchema< + typeof envListJsonOutputSchema +>; +export const envPullJsonOutputSchema = defineJsonOutputSchema({ + name: 'HydrogenEnvPullResult', + schema: zod.object({ + status: zod.enum(['pulled', 'unchanged', 'empty', 'cancelled']), + file: zod.string(), + storefrontId: zod.string().optional(), + storefrontTitle: zod.string().optional(), + environment: zod.string().optional(), + variables: zod.array(Variable), + }), + definitions: {Variable}, +}); +export type EnvPullResult = InferJsonOutputSchema< + typeof envPullJsonOutputSchema +>; +export const envPushJsonOutputSchema = defineJsonOutputSchema({ + name: 'HydrogenEnvPushResult', + schema: zod.object({ + status: zod.enum(['pushed', 'unchanged', 'dry-run', 'cancelled']), + file: zod.string(), + environment: Environment.optional(), + variables: zod.array(zod.string()), + skipped: zod.array(zod.string()), + }), + definitions: {Environment}, +}); +export type EnvPushResult = InferJsonOutputSchema< + typeof envPushJsonOutputSchema +>;