diff --git a/.changeset/typed-app-env-pull.md b/.changeset/typed-app-env-pull.md new file mode 100644 index 00000000000..beae240d840 --- /dev/null +++ b/.changeset/typed-app-env-pull.md @@ -0,0 +1,4 @@ +--- +'@shopify/cli': minor +--- +Add typed JSON output to app env commands. diff --git a/docs-shopify.dev/generated/generated_docs_data_v2.json b/docs-shopify.dev/generated/generated_docs_data_v2.json index 7c8a38fd720..877413f47bd 100644 --- a/docs-shopify.dev/generated/generated_docs_data_v2.json +++ b/docs-shopify.dev/generated/generated_docs_data_v2.json @@ -1493,9 +1493,18 @@ "description": "The name of the app configuration.", "isOptional": true, "environmentValue": "SHOPIFY_FLAG_APP_CONFIG" + }, + { + "filePath": "docs-shopify.dev/commands/interfaces/app-env-pull.interface.ts", + "syntaxKind": "PropertySignature", + "name": "-j, --json", + "value": "''", + "description": "Output the result as JSON. Automatically disables color output.", + "isOptional": true, + "environmentValue": "SHOPIFY_FLAG_JSON" } ], - "value": "export interface appenvpull {\n /**\n * Alias of the Shopify account to use for authentication.\n * @environment SHOPIFY_FLAG_AUTH_ALIAS\n */\n '--auth-alias '?: string\n\n /**\n * The Client ID of your app.\n * @environment SHOPIFY_FLAG_CLIENT_ID\n */\n '--client-id '?: string\n\n /**\n * The name of the app configuration.\n * @environment SHOPIFY_FLAG_APP_CONFIG\n */\n '-c, --config '?: string\n\n /**\n * Specify an environment file to update if the update flag is set\n * @environment SHOPIFY_FLAG_ENV_FILE\n */\n '--env-file '?: string\n\n /**\n * Print the command's JSON schemas.\n * @environment SHOPIFY_FLAG_JSON_SCHEMA\n */\n '--json-schema'?: ''\n\n /**\n * Disable color output.\n * @environment SHOPIFY_FLAG_NO_COLOR\n */\n '--no-color'?: ''\n\n /**\n * Disable interactive prompts and browser authentication.\n * @environment SHOPIFY_FLAG_NO_INPUT\n */\n '--no-input'?: ''\n\n /**\n * The path to your app directory.\n * @environment SHOPIFY_FLAG_PATH\n */\n '--path '?: string\n\n /**\n * Reset all your settings.\n * @environment SHOPIFY_FLAG_RESET\n */\n '--reset'?: ''\n\n /**\n * Increase the verbosity of the output. May include sensitive data.\n * @environment SHOPIFY_FLAG_VERBOSE\n */\n '--verbose'?: ''\n}" + "value": "export interface appenvpull {\n /**\n * Alias of the Shopify account to use for authentication.\n * @environment SHOPIFY_FLAG_AUTH_ALIAS\n */\n '--auth-alias '?: string\n\n /**\n * The Client ID of your app.\n * @environment SHOPIFY_FLAG_CLIENT_ID\n */\n '--client-id '?: string\n\n /**\n * The name of the app configuration.\n * @environment SHOPIFY_FLAG_APP_CONFIG\n */\n '-c, --config '?: string\n\n /**\n * Specify an environment file to update if the update flag is set\n * @environment SHOPIFY_FLAG_ENV_FILE\n */\n '--env-file '?: string\n\n /**\n * Output the result as JSON. Automatically disables color output.\n * @environment SHOPIFY_FLAG_JSON\n */\n '-j, --json'?: ''\n\n /**\n * Print the command's JSON schemas.\n * @environment SHOPIFY_FLAG_JSON_SCHEMA\n */\n '--json-schema'?: ''\n\n /**\n * Disable color output.\n * @environment SHOPIFY_FLAG_NO_COLOR\n */\n '--no-color'?: ''\n\n /**\n * Disable interactive prompts and browser authentication.\n * @environment SHOPIFY_FLAG_NO_INPUT\n */\n '--no-input'?: ''\n\n /**\n * The path to your app directory.\n * @environment SHOPIFY_FLAG_PATH\n */\n '--path '?: string\n\n /**\n * Reset all your settings.\n * @environment SHOPIFY_FLAG_RESET\n */\n '--reset'?: ''\n\n /**\n * Increase the verbosity of the output. May include sensitive data.\n * @environment SHOPIFY_FLAG_VERBOSE\n */\n '--verbose'?: ''\n}" } }, "appenvshow": { diff --git a/packages/app/src/cli/commands/app/env/pull.test.ts b/packages/app/src/cli/commands/app/env/pull.test.ts index c765171a3cf..2a19471d7bb 100644 --- a/packages/app/src/cli/commands/app/env/pull.test.ts +++ b/packages/app/src/cli/commands/app/env/pull.test.ts @@ -1,61 +1,153 @@ import EnvPull from './pull.js' +import * as envPullService from '../../../services/app/env/pull.js' import {linkedAppContext} from '../../../services/app-context.js' -import {pullEnv} from '../../../services/app/env/pull.js' -import {testAppLinked, testOrganization, testOrganizationApp} from '../../../models/app/app.test-data.js' -import {outputResult} from '@shopify/cli-kit/node/output' +import {logMetadataForLoadedContext} from '../../../services/context.js' +import {testAppLinked, testOrganizationApp} from '../../../models/app/app.test-data.js' +import {OrganizationSource} from '../../../models/organization.js' +import {appEnvPullJsonOutputSchema} from '../../../services/app/env/pull/types.js' +import {Config} from '@oclif/core' +import {expect, test, vi} from 'vitest' +import {inTemporaryDirectory, writeFile, readFile} from '@shopify/cli-kit/node/fs' import {joinPath, resolvePath} from '@shopify/cli-kit/node/path' -import {describe, expect, test, vi} from 'vitest' +import * as context from '@shopify/cli-kit/node/context/local' +import {mockAndCaptureStandardStreams} from '@shopify/cli-kit/node/testing/output' +import {runWithCommandEventsForCommand} from '@shopify/cli-kit/node/command-events' +// eslint-disable-next-line no-restricted-imports -- Verify native filesystem paths in JSON output. +import {resolve} from 'node:path' vi.mock('../../../services/app-context.js') -vi.mock('../../../services/app/env/pull.js') -vi.mock('@shopify/cli-kit/node/output', async (importOriginal) => { - const actual = await importOriginal() - return {...actual, outputResult: vi.fn()} -}) +vi.mock('../../../services/context.js') -describe('app env pull command', () => { - test('uses absolute --env-file paths as-is', async () => { - const app = testAppLinked({ - directory: joinPath('F:', 'Project', 'cherhomeliving.shopify', 'shopify-app'), - configPath: joinPath('F:', 'Project', 'cherhomeliving.shopify', 'shopify-app', 'shopify.app.toml'), - }) - const remoteApp = testOrganizationApp() - const organization = testOrganization() - const envFile = joinPath(app.directory, '.env') - - vi.mocked(linkedAppContext).mockResolvedValue({app, remoteApp, organization} as Awaited< - ReturnType - >) - vi.mocked(pullEnv).mockResolvedValue('Created env file') - - await EnvPull.run(['--path', app.directory, '--env-file', envFile], import.meta.url) - - expect(pullEnv).toHaveBeenCalledWith({ - app, - remoteApp, - organization, - envFile, +const variables = [ + {name: 'SHOPIFY_API_KEY', value: 'api-key', isSecret: false}, + {name: 'SHOPIFY_API_SECRET', value: 'api-secret', isSecret: true}, + {name: 'SCOPES', value: 'read_products', isSecret: false}, +] +const content = 'SHOPIFY_API_KEY=api-key\nSHOPIFY_API_SECRET=api-secret\nSCOPES=read_products' + +function setup(directory: string, secret: string | undefined = 'api-secret') { + const app = testAppLinked({ + directory, + configuration: {...testAppLinked().configuration, access_scopes: {scopes: 'read_products'}}, + }) + const remoteApp = testOrganizationApp({apiSecretKeys: secret === undefined ? [] : [{secret}]}) + const organization = {id: '1', businessName: 'Example', source: OrganizationSource.BusinessPlatform} + vi.mocked(linkedAppContext).mockResolvedValue({app, remoteApp, organization} as Awaited< + ReturnType + >) + return {app, remoteApp, organization} +} + +test.each([ + {initial: undefined, status: 'created', expected: content}, + {initial: content, status: 'unchanged', expected: content}, + { + initial: `# Keep me\nCUSTOM=value\n${content.replace('api-key', 'old-key')}`, + status: 'updated', + expected: `# Keep me\nCUSTOM=value\n${content}`, + }, +])( + 'returns $status with the actual file contents and no terminal diff on stdout', + async ({initial, status, expected}) => { + await inTemporaryDirectory(async (directory) => { + const {app, remoteApp, organization} = setup(directory) + const path = joinPath(directory, '.env.custom') + if (initial !== undefined) await writeFile(path, initial) + const command = new EnvPull(['--path', directory, '--env-file', '.env.custom', '--json'], await Config.load()) + vi.spyOn(context, 'isUnitTest').mockReturnValue(false) + const streams = mockAndCaptureStandardStreams() + try { + await expect(runWithCommandEventsForCommand(['--json'], () => command.run())).resolves.toEqual({app}) + expect(streams.stdout()).toBe( + `${JSON.stringify({path: resolve(path), status: 'success', changed: status !== 'unchanged', variables, content: expected}, null, 2)}\n`, + ) + expect(streams.stderr()).toBe('') + expect(logMetadataForLoadedContext).toHaveBeenCalledExactlyOnceWith(remoteApp, organization.source) + } finally { + streams.restore() + } + await expect(readFile(path)).resolves.toBe(expected) }) - expect(outputResult).toHaveBeenCalledWith('Created env file') + }, +) + +test('does not emit a result when the environment file cannot be read', async () => { + await inTemporaryDirectory(async (directory) => { + setup(directory) + const command = new EnvPull(['--path', directory, '--env-file', directory, '--json'], await Config.load()) + vi.spyOn(context, 'isUnitTest').mockReturnValue(false) + const streams = mockAndCaptureStandardStreams() + try { + await expect(command.run()).rejects.toThrow() + expect(streams.stdout()).toBe('') + } finally { + streams.restore() + } }) +}) - test('resolves relative --env-file paths from the app directory', async () => { - const app = testAppLinked({directory: '/tmp/my-app', configPath: '/tmp/my-app/shopify.app.toml'}) - const remoteApp = testOrganizationApp() - const organization = testOrganization() +test('exposes the schema, JSON flag and existing file selection flag', () => { + expect(EnvPull.jsonOutputSchema).toBe(appEnvPullJsonOutputSchema) + expect(EnvPull.description).toContain('AppEnvPullResult') + expect(EnvPull.flags.json).toBeDefined() + expect(EnvPull.flags['env-file']).toBeDefined() +}) - vi.mocked(linkedAppContext).mockResolvedValue({app, remoteApp, organization} as Awaited< - ReturnType - >) - vi.mocked(pullEnv).mockResolvedValue('Created env file') +test.each([ + {path: 1, status: 'success', changed: true, variables, content}, + {path: '/app/.env', status: 'failed', changed: true, variables, content}, + {path: '.env', status: 'success', changed: false, variables, content}, + {path: '/app/.env', status: 'success', changed: false, variables, content, internal: true}, + {path: '/app/.env', status: 'success', changed: true, variables: [{name: 'SCOPES', value: null}], content}, + {path: '/app/.env', status: 'success', changed: false, variables, content: null}, +])('rejects malformed data %j', (value) => { + expect(() => appEnvPullJsonOutputSchema.validate(value)).toThrow() +}) - await EnvPull.run(['--path', app.directory, '--env-file', 'config/.env'], import.meta.url) +test('uses Windows absolute --env-file paths as-is', async () => { + const directory = joinPath('F:', 'Project', 'cherhomeliving.shopify', 'shopify-app') + const {app, remoteApp, organization} = setup(directory) + const envFile = joinPath(directory, '.env') + const pull = vi.spyOn(envPullService, 'pullEnv').mockResolvedValue({ + result: {path: envFile, status: 'success', changed: true, variables, content}, + previousContent: null, + }) + await new EnvPull(['--path', directory, '--env-file', envFile], await Config.load()).run() + expect(pull).toHaveBeenCalledWith({app, remoteApp, organization, envFile}) +}) - expect(pullEnv).toHaveBeenCalledWith({ - app, - remoteApp, - organization, - envFile: resolvePath(app.directory, 'config/.env'), +test('resolves nested relative --env-file paths from the app directory', async () => { + await inTemporaryDirectory(async (directory) => { + const {app, remoteApp, organization} = setup(directory) + const envFile = resolvePath(directory, 'config/.env') + const pull = vi.spyOn(envPullService, 'pullEnv').mockResolvedValue({ + result: {path: envFile, status: 'success', changed: true, variables, content}, + previousContent: null, }) + await new EnvPull(['--path', directory, '--env-file', 'config/.env'], await Config.load()).run() + expect(pull).toHaveBeenCalledWith({app, remoteApp, organization, envFile}) }) }) + +test.each([[], ['--no-input'], ['--json'], ['--json', '--no-input']].map((argv) => ({argv})))( + 'keeps JSON formatting independent from non-interactivity for %j', + async ({argv}) => { + await inTemporaryDirectory(async (directory) => { + setup(directory) + const command = new EnvPull(['--path', directory, ...argv], await Config.load()) + vi.spyOn(context, 'isUnitTest').mockReturnValue(false) + const streams = mockAndCaptureStandardStreams() + try { + await runWithCommandEventsForCommand(argv, () => command.run()) + if (argv.includes('--json')) { + expect(JSON.parse(streams.stdout())).toMatchObject({status: 'success', changed: true, variables}) + } else { + expect(streams.stdout()).toContain('Created') + } + } finally { + streams.restore() + } + await expect(readFile(joinPath(directory, '.env'))).resolves.toBe(content) + }) + }, +) diff --git a/packages/app/src/cli/commands/app/env/pull.ts b/packages/app/src/cli/commands/app/env/pull.ts index dc0c89c29a0..f09b1fed72b 100644 --- a/packages/app/src/cli/commands/app/env/pull.ts +++ b/packages/app/src/cli/commands/app/env/pull.ts @@ -3,9 +3,10 @@ import {getDotEnvFileName} from '../../../models/app/loader.js' import {pullEnv} from '../../../services/app/env/pull.js' import AppLinkedCommand, {AppLinkedCommandOutput} from '../../../utilities/app-linked-command.js' import {linkedAppContext} from '../../../services/app-context.js' +import {appEnvPullJsonOutputSchema} from '../../../services/app/env/pull/types.js' +import {renderAppEnvPullResult} from '../../../services/app/env/pull/result.js' import {Flags} from '@oclif/core' -import {globalFlags} from '@shopify/cli-kit/node/cli' -import {outputResult} from '@shopify/cli-kit/node/output' +import {globalFlags, jsonFlag} from '@shopify/cli-kit/node/cli' import {resolvePath} from '@shopify/cli-kit/node/path' export default class EnvPull extends AppLinkedCommand { @@ -15,10 +16,15 @@ export default class EnvPull extends AppLinkedCommand { When an existing \`.env\` file is updated, changes to the variables are displayed in the terminal output. Existing variables and commented variables are preserved.` + static get jsonOutputSchema() { + return appEnvPullJsonOutputSchema + } + static description = this.descriptionForHelp() static flags = { ...globalFlags, + ...jsonFlag, ...appFlags, 'env-file': Flags.string({ hidden: false, @@ -37,7 +43,8 @@ export default class EnvPull extends AppLinkedCommand { userProvidedConfigName: flags.config, }) const envFile = resolvePath(app.directory, flags['env-file'] ?? getDotEnvFileName(app.configPath)) - outputResult(await pullEnv({app, remoteApp, organization, envFile})) + const result = await pullEnv({app, remoteApp, organization, envFile}) + renderAppEnvPullResult(result, flags.json ? 'json' : 'text') return {app} } } diff --git a/packages/app/src/cli/services/app/env/pull.test.ts b/packages/app/src/cli/services/app/env/pull.test.ts index c8fd46e2488..f58d95babb9 100644 --- a/packages/app/src/cli/services/app/env/pull.test.ts +++ b/packages/app/src/cli/services/app/env/pull.test.ts @@ -1,4 +1,5 @@ import {pullEnv} from './pull.js' +import {formatAppEnvPullResult} from './pull/result.js' import {AppInterface, AppLinkedInterface} from '../../../models/app/app.js' import {testApp, testOrganizationApp} from '../../../models/app/app.test-data.js' import {Organization, OrganizationApp, OrganizationSource} from '../../../models/organization.js' @@ -6,6 +7,8 @@ import {describe, expect, vi, beforeEach, test} from 'vitest' import * as file from '@shopify/cli-kit/node/fs' import {resolvePath} from '@shopify/cli-kit/node/path' import {unstyled, stringifyMessage} from '@shopify/cli-kit/node/output' +// eslint-disable-next-line no-restricted-imports -- Verify native filesystem paths in JSON output. +import {resolve} from 'node:path' const ORG1: Organization = { id: '1', @@ -29,7 +32,8 @@ describe('env pull', () => { // When const filePath = resolvePath(tmpDir, '.env') - const result = await pullEnv({app, remoteApp, organization: ORG1, envFile: filePath}) + const outcome = await pullEnv({app, remoteApp, organization: ORG1, envFile: filePath}) + const result = formatAppEnvPullResult(outcome, 'text') // Then expect(file.writeFile).toHaveBeenCalledWith( @@ -55,7 +59,8 @@ describe('env pull', () => { vi.spyOn(file, 'writeFile') // When - const result = await pullEnv({app, remoteApp, organization: ORG1, envFile: filePath}) + const outcome = await pullEnv({app, remoteApp, organization: ORG1, envFile: filePath}) + const result = formatAppEnvPullResult(outcome, 'text') // Then expect(file.writeFile).toHaveBeenCalledWith( @@ -89,7 +94,8 @@ describe('env pull', () => { vi.spyOn(file, 'writeFile') // When - const result = await pullEnv({app, remoteApp, organization: ORG1, envFile: filePath}) + const outcome = await pullEnv({app, remoteApp, organization: ORG1, envFile: filePath}) + const result = formatAppEnvPullResult(outcome, 'text') // Then expect(file.writeFile).not.toHaveBeenCalled() @@ -116,3 +122,25 @@ function mockApp(): AppInterface { }, }) } + +test('returns file facts while preserving custom variables, comments, and a missing secret', async () => { + await file.inTemporaryDirectory(async (directory) => { + const path = resolvePath(directory, '.env') + await file.writeFile(path, '# Local settings\nCUSTOM=value\nSHOPIFY_API_SECRET=existing-secret') + const remoteApp = testOrganizationApp({apiSecretKeys: []}) + const output = await pullEnv({app: mockApp() as AppLinkedInterface, remoteApp, organization: ORG1, envFile: path}) + expect(output.result.status).toBe('success') + expect(output.result.changed).toBe(true) + expect(output.result.variables).toEqual([ + {name: 'SHOPIFY_API_KEY', value: 'api-key', isSecret: false}, + {name: 'SCOPES', value: 'my-scope', isSecret: false}, + ]) + expect(output.result.content).toContain('# Local settings\nCUSTOM=value\nSHOPIFY_API_SECRET=existing-secret') + await expect(file.readFile(path)).resolves.toBe(output.result.content) + expect(output.previousContent).toBe('# Local settings\nCUSTOM=value\nSHOPIFY_API_SECRET=existing-secret') + const encoded = JSON.parse(stringifyMessage(formatAppEnvPullResult(output, 'json'))) + expect(encoded.path).toBe(resolve(path)) + expect(encoded.variables.map(({name}: {name: string}) => name)).not.toContain('SHOPIFY_API_SECRET') + expect(encoded).not.toHaveProperty('previousContent') + }) +}) diff --git a/packages/app/src/cli/services/app/env/pull.ts b/packages/app/src/cli/services/app/env/pull.ts index 422749dd02e..858f0246e6a 100644 --- a/packages/app/src/cli/services/app/env/pull.ts +++ b/packages/app/src/cli/services/app/env/pull.ts @@ -1,12 +1,10 @@ -import {AppLinkedInterface, getAppScopes} from '../../../models/app/app.js' - -import {logMetadataForLoadedContext} from '../../context.js' - +import {getAppEnv} from './show.js' +import {type AppEnvPullResult} from './pull/types.js' +import {AppLinkedInterface} from '../../../models/app/app.js' import {Organization, OrganizationApp} from '../../../models/organization.js' import {patchEnvFile} from '@shopify/cli-kit/node/dot-env' -import {diffLines} from 'diff' import {fileExists, readFile, writeFile} from '@shopify/cli-kit/node/fs' -import {OutputMessage, outputContent, outputToken} from '@shopify/cli-kit/node/output' +import {resolvePath} from '@shopify/cli-kit/node/path' interface PullEnvOptions { app: AppLinkedInterface @@ -15,42 +13,17 @@ interface PullEnvOptions { envFile: string } -export async function pullEnv({app, remoteApp, organization, envFile}: PullEnvOptions): Promise { - await logMetadataForLoadedContext(remoteApp, organization.source) - - const updatedValues = { - SHOPIFY_API_KEY: remoteApp.apiKey, - SHOPIFY_API_SECRET: remoteApp.apiSecretKeys[0]?.secret, - SCOPES: getAppScopes(app.configuration), - } - - if (await fileExists(envFile)) { - const envFileContent = await readFile(envFile) - const updatedEnvFileContent = patchEnvFile(envFileContent, updatedValues) - - if (updatedEnvFileContent === envFileContent) { - return outputContent`No changes to ${outputToken.path(envFile)}` - } else { - await writeFile(envFile, updatedEnvFileContent) - - const diff = diffLines(envFileContent ?? '', updatedEnvFileContent) - return outputContent`Updated ${outputToken.path(envFile)} to be: - -${updatedEnvFileContent} - -Here's what changed: - -${outputToken.linesDiff(diff)} - ` - } - } else { - const newEnvFileContent = patchEnvFile(null, updatedValues) - - await writeFile(envFile, newEnvFileContent) - - return outputContent`Created ${outputToken.path(envFile)}: +export interface PullEnvOutput { + result: AppEnvPullResult + previousContent: string | null +} -${newEnvFileContent} -` - } +export async function pullEnv({app, remoteApp, organization, envFile}: PullEnvOptions): Promise { + const {variables} = await getAppEnv(app, remoteApp, organization) + const path = resolvePath(envFile) + const previousContent = (await fileExists(path)) ? await readFile(path) : null + const content = patchEnvFile(previousContent, Object.fromEntries(variables.map(({name, value}) => [name, value]))) + const changed = content !== previousContent + if (changed) await writeFile(path, content) + return {result: {path, status: 'success', changed, variables, content}, previousContent} } diff --git a/packages/app/src/cli/services/app/env/pull/result.ts b/packages/app/src/cli/services/app/env/pull/result.ts new file mode 100644 index 00000000000..c1762211485 --- /dev/null +++ b/packages/app/src/cli/services/app/env/pull/result.ts @@ -0,0 +1,33 @@ +import {appEnvPullJsonOutputSchema} from './types.js' +import {type PullEnvOutput} from '../pull.js' +import {diffLines} from 'diff' +import {OutputMessage, outputContent, outputToken, outputResult} from '@shopify/cli-kit/node/output' +// eslint-disable-next-line no-restricted-imports -- JSON file paths use native platform separators. +import {resolve} from 'node:path' + +export function formatAppEnvPullResult( + {result, previousContent}: PullEnvOutput, + format: 'json' | 'text', +): OutputMessage { + if (format === 'json') return appEnvPullJsonOutputSchema.encode({...result, path: resolve(result.path)}) + const {path, changed, content} = result + if (!changed) return outputContent`No changes to ${outputToken.path(path)}` + if (previousContent === null) { + return outputContent`Created ${outputToken.path(path)}: + +${content} +` + } + return outputContent`Updated ${outputToken.path(path)} to be: + +${content} + +Here's what changed: + +${outputToken.linesDiff(diffLines(previousContent ?? '', content))} + ` +} + +export function renderAppEnvPullResult(result: PullEnvOutput, format: 'json' | 'text'): void { + outputResult(formatAppEnvPullResult(result, format)) +} diff --git a/packages/app/src/cli/services/app/env/pull/types.ts b/packages/app/src/cli/services/app/env/pull/types.ts new file mode 100644 index 00000000000..6d89fe7006d --- /dev/null +++ b/packages/app/src/cli/services/app/env/pull/types.ts @@ -0,0 +1,25 @@ +import {appEnvironmentVariableSchema} from '../show/types.js' +import {defineJsonOutputSchema, type InferJsonOutputSchema} from '@shopify/cli-kit/node/json-output-schema' +import {isAbsolutePath} from '@shopify/cli-kit/node/path' +import {zod} from '@shopify/cli-kit/node/schema' + +export const appEnvPullJsonOutputSchema = defineJsonOutputSchema({ + name: 'AppEnvPullResult', + schema: zod + .object({ + path: zod + .string() + .refine(isAbsolutePath, 'Expected an absolute path') + .describe('The absolute native dotenv file path.'), + status: zod.literal('success'), + changed: zod.boolean().describe('Whether the dotenv file was created or updated.'), + variables: zod + .array(appEnvironmentVariableSchema) + .describe('The known app variables, not unrelated local variables.'), + content: zod.string().describe('The complete native dotenv content, preserving existing variables and comments.'), + }) + .strict(), + definitions: {AppEnvironmentVariable: appEnvironmentVariableSchema}, +}) + +export type AppEnvPullResult = InferJsonOutputSchema diff --git a/packages/app/src/cli/services/app/env/show/types.ts b/packages/app/src/cli/services/app/env/show/types.ts index 4c0016873d6..213d1e4a4e1 100644 --- a/packages/app/src/cli/services/app/env/show/types.ts +++ b/packages/app/src/cli/services/app/env/show/types.ts @@ -1,7 +1,7 @@ import {defineJsonOutputSchema, type InferJsonOutputSchema} from '@shopify/cli-kit/node/json-output-schema' import {zod} from '@shopify/cli-kit/node/schema' -const appEnvironmentVariableSchema = zod +export const appEnvironmentVariableSchema = zod .object({ name: zod.string().min(1).describe('The environment variable name with its original spelling.'), value: zod.string().optional().describe('The value, included only when known.'), diff --git a/packages/cli/README.md b/packages/cli/README.md index 2fc83716fc8..d8fd61c60cb 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -1770,7 +1770,7 @@ Pull app and extensions environment variables. ``` USAGE - $ shopify app env pull [--auth-alias ] [--client-id | -c ] [--env-file ] + $ shopify app env pull [--auth-alias ] [--client-id | -c ] [--env-file ] [-j] [--json-schema] [--no-color] [--no-input] [--path ] [--reset | ] [--verbose] FLAGS @@ -1778,6 +1778,10 @@ FLAGS The name of the app configuration. [env: SHOPIFY_FLAG_APP_CONFIG] + -j, --json + Output the result as JSON. Automatically disables color output. + [env: SHOPIFY_FLAG_JSON] + --auth-alias= Alias of the Shopify account to use for authentication. [env: SHOPIFY_FLAG_AUTH_ALIAS] @@ -1821,6 +1825,84 @@ DESCRIPTION When an existing `.env` file is updated, changes to the variables are displayed in the terminal output. Existing variables and commented variables are preserved. + + Use `--json-schema` to print the result, error, and event schemas. + + Output from `--json` conforms to the `AppEnvPullResult` schema. + + ```json + { + "type": "object", + "properties": { + "path": { + "type": "string", + "description": "The absolute native dotenv file path." + }, + "status": { + "type": "string", + "const": "success" + }, + "changed": { + "type": "boolean", + "description": "Whether the dotenv file was created or updated." + }, + "variables": { + "type": "array", + "items": { + "$ref": "#/definitions/AppEnvironmentVariable" + }, + "description": "The known app variables, not unrelated local variables." + }, + "content": { + "type": "string", + "description": "The complete native dotenv content, preserving existing variables and comments." + } + }, + "required": [ + "path", + "status", + "changed", + "variables", + "content" + ], + "additionalProperties": false, + "title": "AppEnvPullResult", + "definitions": { + "AppEnvironmentVariable": { + "type": "object", + "properties": { + "name": { + "type": "string", + "minLength": 1, + "description": "The environment variable name with its original spelling." + }, + "value": { + "type": "string", + "description": "The value, included only when known." + }, + "isSecret": { + "type": "boolean", + "description": "Whether the value is secret, included only when known." + }, + "id": { + "type": "string", + "minLength": 1, + "description": "The upstream variable identifier, included only when known." + }, + "readOnly": { + "type": "boolean", + "description": "Whether the variable is read-only, included only when known." + } + }, + "required": [ + "name" + ], + "additionalProperties": false + } + }, + "$schema": "http://json-schema.org/draft-07/schema#" + } + ``` ``` ## `shopify app env show` diff --git a/packages/cli/oclif.manifest.json b/packages/cli/oclif.manifest.json index c4a46682f55..df3e290d6e8 100644 --- a/packages/cli/oclif.manifest.json +++ b/packages/cli/oclif.manifest.json @@ -1513,7 +1513,7 @@ "args": { }, "customPluginName": "@shopify/app", - "description": "Creates or updates an `.env` files that contains app and app extension environment variables.\n\n When an existing `.env` file is updated, changes to the variables are displayed in the terminal output. Existing variables and commented variables are preserved.", + "description": "Creates or updates an `.env` files that contains app and app extension environment variables.\n\n When an existing `.env` file is updated, changes to the variables are displayed in the terminal output. Existing variables and commented variables are preserved.\n\nUse `--json-schema` to print the result, error, and event schemas.\n\nOutput from `--json` conforms to the `AppEnvPullResult` schema.\n\n```json\n{\n \"type\": \"object\",\n \"properties\": {\n \"path\": {\n \"type\": \"string\",\n \"description\": \"The absolute native dotenv file path.\"\n },\n \"status\": {\n \"type\": \"string\",\n \"const\": \"success\"\n },\n \"changed\": {\n \"type\": \"boolean\",\n \"description\": \"Whether the dotenv file was created or updated.\"\n },\n \"variables\": {\n \"type\": \"array\",\n \"items\": {\n \"$ref\": \"#/definitions/AppEnvironmentVariable\"\n },\n \"description\": \"The known app variables, not unrelated local variables.\"\n },\n \"content\": {\n \"type\": \"string\",\n \"description\": \"The complete native dotenv content, preserving existing variables and comments.\"\n }\n },\n \"required\": [\n \"path\",\n \"status\",\n \"changed\",\n \"variables\",\n \"content\"\n ],\n \"additionalProperties\": false,\n \"title\": \"AppEnvPullResult\",\n \"definitions\": {\n \"AppEnvironmentVariable\": {\n \"type\": \"object\",\n \"properties\": {\n \"name\": {\n \"type\": \"string\",\n \"minLength\": 1,\n \"description\": \"The environment variable name with its original spelling.\"\n },\n \"value\": {\n \"type\": \"string\",\n \"description\": \"The value, included only when known.\"\n },\n \"isSecret\": {\n \"type\": \"boolean\",\n \"description\": \"Whether the value is secret, included only when known.\"\n },\n \"id\": {\n \"type\": \"string\",\n \"minLength\": 1,\n \"description\": \"The upstream variable identifier, included only when known.\"\n },\n \"readOnly\": {\n \"type\": \"boolean\",\n \"description\": \"Whether the variable is read-only, included only when known.\"\n }\n },\n \"required\": [\n \"name\"\n ],\n \"additionalProperties\": false\n }\n },\n \"$schema\": \"http://json-schema.org/draft-07/schema#\"\n}\n```", "descriptionWithMarkdown": "Creates or updates an `.env` files that contains app and app extension environment variables.\n\n When an existing `.env` file is updated, changes to the variables are displayed in the terminal output. Existing variables and commented variables are preserved.", "flags": { "auth-alias": { @@ -1555,6 +1555,15 @@ "name": "env-file", "type": "option" }, + "json": { + "allowNo": false, + "char": "j", + "description": "Output the result as JSON. Automatically disables color output.", + "env": "SHOPIFY_FLAG_JSON", + "hidden": false, + "name": "json", + "type": "boolean" + }, "json-schema": { "allowNo": false, "description": "Print the command's JSON schemas.", diff --git a/packages/eslint-plugin-cli/rules/json-output-command-exceptions.js b/packages/eslint-plugin-cli/rules/json-output-command-exceptions.js index d336830c1e9..497c4aed76e 100644 --- a/packages/eslint-plugin-cli/rules/json-output-command-exceptions.js +++ b/packages/eslint-plugin-cli/rules/json-output-command-exceptions.js @@ -6,7 +6,6 @@ const commandExceptions = [ 'packages/app/src/cli/commands/app/config/pull.ts', 'packages/app/src/cli/commands/app/config/use.ts', 'packages/app/src/cli/commands/app/config/validate.ts', - 'packages/app/src/cli/commands/app/env/pull.ts', 'packages/app/src/cli/commands/app/function/build.ts', 'packages/app/src/cli/commands/app/function/info.ts', 'packages/app/src/cli/commands/app/function/replay.ts',