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
4 changes: 4 additions & 0 deletions .changeset/typed-app-env-show.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
---
'@shopify/cli': minor
---
Add typed JSON output to app env show.
11 changes: 10 additions & 1 deletion docs-shopify.dev/generated/generated_docs_data_v2.json
Original file line number Diff line number Diff line change
Expand Up @@ -1405,9 +1405,18 @@
"description": "The name of the app configuration.",
"isOptional": true,
"environmentValue": "SHOPIFY_FLAG_APP_CONFIG"
},
{
"filePath": "docs-shopify.dev/commands/interfaces/app-env-show.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 appenvshow {\n /**\n * Alias of the Shopify account to use for authentication.\n * @environment SHOPIFY_FLAG_AUTH_ALIAS\n */\n '--auth-alias <value>'?: string\n\n /**\n * The Client ID of your app.\n * @environment SHOPIFY_FLAG_CLIENT_ID\n */\n '--client-id <value>'?: string\n\n /**\n * The name of the app configuration.\n * @environment SHOPIFY_FLAG_APP_CONFIG\n */\n '-c, --config <value>'?: 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 * The path to your app directory.\n * @environment SHOPIFY_FLAG_PATH\n */\n '--path <value>'?: 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 appenvshow {\n /**\n * Alias of the Shopify account to use for authentication.\n * @environment SHOPIFY_FLAG_AUTH_ALIAS\n */\n '--auth-alias <value>'?: string\n\n /**\n * The Client ID of your app.\n * @environment SHOPIFY_FLAG_CLIENT_ID\n */\n '--client-id <value>'?: string\n\n /**\n * The name of the app configuration.\n * @environment SHOPIFY_FLAG_APP_CONFIG\n */\n '-c, --config <value>'?: 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 * The path to your app directory.\n * @environment SHOPIFY_FLAG_PATH\n */\n '--path <value>'?: 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}"
}
},
"appexecute": {
Expand Down
77 changes: 77 additions & 0 deletions packages/app/src/cli/commands/app/env/show.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
import EnvShow from './show.js'
import {linkedAppContext} from '../../../services/app-context.js'
import {testAppLinked, testOrganizationApp} from '../../../models/app/app.test-data.js'
import {OrganizationSource} from '../../../models/organization.js'
import {appEnvShowJsonOutputSchema} from '../../../services/app/env/show/types.js'
import {Config} from '@oclif/core'
import {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 {outputInfo} from '@shopify/cli-kit/node/output'
import {runWithCommandEventsForCommand} from '@shopify/cli-kit/node/command-events'

vi.mock('../../../services/app-context.js')
vi.mock('../../../services/context.js')

test.each([
{secret: 'secret', scopes: 'read_products'},
{secret: undefined, scopes: ''},
{secret: '', scopes: ''},
])('writes one JSON result with secret $secret and scopes $scopes', async ({secret, scopes}) => {
const app = testAppLinked()
app.configuration.access_scopes = {scopes}
const remoteApp = testOrganizationApp({apiSecretKeys: secret === undefined ? [] : [{secret}]})
vi.mocked(linkedAppContext).mockImplementation(async () => {
outputInfo('Loaded app')
return {
app,
remoteApp,
organization: {id: '1', businessName: 'Example', source: OrganizationSource.BusinessPlatform},
} as Awaited<ReturnType<typeof linkedAppContext>>
})
const command = new EnvShow(['--json'], await Config.load())
vi.spyOn(context, 'isUnitTest').mockReturnValue(false)
const streams = mockAndCaptureStandardStreams()
try {
await runWithCommandEventsForCommand(['--json'], () => command.run())
expect(streams.stdout()).toBe(
`${JSON.stringify({SHOPIFY_API_KEY: remoteApp.apiKey, SHOPIFY_API_SECRET: secret, SCOPES: scopes}, null, 2)}\n`,
)
expect(JSON.parse(streams.stderr())).toMatchObject({type: 'diagnostic', message: 'Loaded app'})
expect(linkedAppContext).toHaveBeenCalledWith({
directory: expect.any(String),
clientId: undefined,
forceRelink: false,
userProvidedConfigName: undefined,
})
} finally {
streams.restore()
}
})

test('exposes the schema and JSON flag', () => {
expect(EnvShow.jsonOutputSchema).toBe(appEnvShowJsonOutputSchema)
expect(EnvShow.description).toContain('AppEnvShowResult')
expect(EnvShow.flags.json).toBeDefined()
})

test('propagates failures before writing any result', async () => {
vi.mocked(linkedAppContext).mockRejectedValue(new Error('Authentication failed'))
const command = new EnvShow(['--json'], await Config.load())
vi.spyOn(context, 'isUnitTest').mockReturnValue(false)
const streams = mockAndCaptureStandardStreams()
try {
await expect(command.run()).rejects.toThrow('Authentication failed')
expect(streams.stdout()).toBe('')
} finally {
streams.restore()
}
})

test.each([
{SHOPIFY_API_KEY: 1, SCOPES: ''},
{SHOPIFY_API_KEY: 'key', SHOPIFY_API_SECRET: null, SCOPES: ''},
{SHOPIFY_API_KEY: 'key', SCOPES: []},
])('rejects malformed data %j', (value) => {
expect(() => appEnvShowJsonOutputSchema.validate(value)).toThrow()
})
13 changes: 10 additions & 3 deletions packages/app/src/cli/commands/app/env/show.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,18 +2,24 @@ import {appFlags} from '../../../flags.js'
import {linkedAppContext} from '../../../services/app-context.js'
import {showEnv} from '../../../services/app/env/show.js'
import AppLinkedCommand, {AppLinkedCommandOutput} from '../../../utilities/app-linked-command.js'
import {globalFlags} from '@shopify/cli-kit/node/cli'
import {outputResult} from '@shopify/cli-kit/node/output'
import {appEnvShowJsonOutputSchema} from '../../../services/app/env/show/types.js'
import {renderAppEnvShowResult} from '../../../services/app/env/show/result.js'
import {globalFlags, jsonFlag} from '@shopify/cli-kit/node/cli'

export default class EnvShow extends AppLinkedCommand {
static summary = 'Display app and extensions environment variables.'

static descriptionWithMarkdown = `Displays environment variables that can be used to deploy apps and app extensions.`

static get jsonOutputSchema() {
return appEnvShowJsonOutputSchema
}

static description = this.descriptionForHelp()

static flags = {
...globalFlags,
...jsonFlag,
...appFlags,
}

Expand All @@ -25,7 +31,8 @@ export default class EnvShow extends AppLinkedCommand {
forceRelink: flags.reset,
userProvidedConfigName: flags.config,
})
outputResult(await showEnv(app, remoteApp, organization))
const result = await showEnv(app, remoteApp, organization)
renderAppEnvShowResult(result, flags.json ? 'json' : 'text')
return {app}
}
}
19 changes: 16 additions & 3 deletions packages/app/src/cli/services/app/env/show.test.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
import {showEnv} from './show.js'
import {showEnv, outputEnv} from './show.js'
import {fetchOrganizations} from '../../dev/fetch.js'
import {AppInterface} from '../../../models/app/app.js'
import {testApp, testOrganizationApp} from '../../../models/app/app.test-data.js'
import {OrganizationSource} from '../../../models/organization.js'
import {selectOrganizationPrompt} from '@shopify/organizations'
import {describe, expect, vi, test} from 'vitest'
import * as file from '@shopify/cli-kit/node/fs'
import {stringifyMessage, unstyled} from '@shopify/cli-kit/node/output'
import {stringifyMessage, unstyled, outputContent, outputToken} from '@shopify/cli-kit/node/output'

vi.mock('../../dev/fetch.js')
vi.mock('@shopify/organizations')
Expand Down Expand Up @@ -34,7 +34,7 @@ describe('env show', () => {
vi.mocked(selectOrganizationPrompt).mockResolvedValue(organization)

// When
const result = await showEnv(app, remoteApp, organization)
const result = await outputEnv(app, remoteApp, organization, 'text')

// Then
expect(file.writeFile).not.toHaveBeenCalled()
Expand Down Expand Up @@ -63,3 +63,16 @@ function mockApp(): AppInterface {
},
})
}

test('returns environment facts and omits absent secrets when encoded', async () => {
const app = mockApp()
const remoteApp = testOrganizationApp({apiSecretKeys: []})
const organization = {id: '123', businessName: 'test', source: OrganizationSource.BusinessPlatform}
await expect(showEnv(app, remoteApp, organization)).resolves.toEqual({
SHOPIFY_API_KEY: remoteApp.apiKey,
SHOPIFY_API_SECRET: undefined,
SCOPES: 'my-scope',
})
const legacy = outputContent`${outputToken.json({SHOPIFY_API_KEY: remoteApp.apiKey, SCOPES: 'my-scope'})}`
expect(stringifyMessage(await outputEnv(app, remoteApp, organization, 'json'))).toBe(stringifyMessage(legacy))
})
29 changes: 12 additions & 17 deletions packages/app/src/cli/services/app/env/show.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import {type AppEnvShowResult} from './show/types.js'
import {formatAppEnvShowResult} from './show/result.js'
import {AppInterface, getAppScopes} from '../../../models/app/app.js'
import {Organization, OrganizationApp} from '../../../models/organization.js'
import {logMetadataForLoadedContext} from '../../context.js'
Expand All @@ -9,8 +11,13 @@ export async function showEnv(
app: AppInterface,
remoteApp: OrganizationApp,
organization: Organization,
): Promise<OutputMessage> {
return outputEnv(app, remoteApp, organization, 'text')
): Promise<AppEnvShowResult> {
await logMetadataForLoadedContext(remoteApp, organization.source)
return {
SHOPIFY_API_KEY: remoteApp.apiKey,
SHOPIFY_API_SECRET: remoteApp.apiSecretKeys[0]?.secret,
SCOPES: getAppScopes(app.configuration),
}
}

export async function outputEnv(
Expand All @@ -19,19 +26,7 @@ export async function outputEnv(
organization: Organization,
format: Format,
): Promise<OutputMessage> {
await logMetadataForLoadedContext(remoteApp, organization.source)

if (format === 'json') {
return outputContent`${outputToken.json({
SHOPIFY_API_KEY: remoteApp.apiKey,
SHOPIFY_API_SECRET: remoteApp.apiSecretKeys[0]?.secret,
SCOPES: getAppScopes(app.configuration),
})}`
} else {
return outputContent`
${outputToken.green('SHOPIFY_API_KEY')}=${remoteApp.apiKey}
${outputToken.green('SHOPIFY_API_SECRET')}=${remoteApp.apiSecretKeys[0]?.secret ?? ''}
${outputToken.green('SCOPES')}=${getAppScopes(app.configuration)}
`
}
// Compatibility adapter for app info --web-env.
const result = await showEnv(app, remoteApp, organization)
return format === 'json' ? outputContent`${outputToken.json(result)}` : formatAppEnvShowResult(result, 'text')
}
19 changes: 19 additions & 0 deletions packages/app/src/cli/services/app/env/show/result.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import {formatAppEnvShowResult} from './result.js'
import {appEnvShowJsonOutputSchema} from './types.js'
import {expect, test} from 'vitest'
import {outputContent, outputToken, stringifyMessage, unstyled} from '@shopify/cli-kit/node/output'

test.each([
{SHOPIFY_API_KEY: 'key', SHOPIFY_API_SECRET: 'secret', SCOPES: 'read_products'},
{SHOPIFY_API_KEY: 'key', SCOPES: ''},
])('matches the unstyled app info --web-env JSON for %j', (value) => {
const legacy = outputContent`${outputToken.json(value)}`
expect(stringifyMessage(formatAppEnvShowResult(value, 'json'))).toBe(unstyled(stringifyMessage(legacy)))
expect(appEnvShowJsonOutputSchema.encode(value)).toBe(JSON.stringify(value, null, 2))
})

test('preserves text formatting with an absent secret and empty scopes', () => {
expect(unstyled(stringifyMessage(formatAppEnvShowResult({SHOPIFY_API_KEY: 'key', SCOPES: ''}, 'text')))).toBe(
'\n SHOPIFY_API_KEY=key\n SHOPIFY_API_SECRET=\n SCOPES=\n ',
)
})
15 changes: 15 additions & 0 deletions packages/app/src/cli/services/app/env/show/result.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import {appEnvShowJsonOutputSchema, type AppEnvShowResult} from './types.js'
import {OutputMessage, outputContent, outputToken, outputResult} from '@shopify/cli-kit/node/output'

export function formatAppEnvShowResult(result: AppEnvShowResult, format: 'json' | 'text'): OutputMessage {
if (format === 'json') return appEnvShowJsonOutputSchema.encode(result)
return outputContent`
${outputToken.green('SHOPIFY_API_KEY')}=${result.SHOPIFY_API_KEY}
${outputToken.green('SHOPIFY_API_SECRET')}=${result.SHOPIFY_API_SECRET ?? ''}
${outputToken.green('SCOPES')}=${result.SCOPES}
`
}

export function renderAppEnvShowResult(result: AppEnvShowResult, format: 'json' | 'text'): void {
outputResult(formatAppEnvShowResult(result, format))
}
13 changes: 13 additions & 0 deletions packages/app/src/cli/services/app/env/show/types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import {defineJsonOutputSchema, type InferJsonOutputSchema} from '@shopify/cli-kit/node/json-output-schema'
import {zod} from '@shopify/cli-kit/node/schema'

export const appEnvShowJsonOutputSchema = defineJsonOutputSchema({
name: 'AppEnvShowResult',
schema: zod.object({
SHOPIFY_API_KEY: zod.string(),
SHOPIFY_API_SECRET: zod.string().optional(),
SCOPES: zod.string(),
}),
})

export type AppEnvShowResult = InferJsonOutputSchema<typeof appEnvShowJsonOutputSchema>
73 changes: 73 additions & 0 deletions packages/cli-kit/src/public/node/testing/output.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
import {mockAndCaptureStandardStreams} from './output.js'
import {describe, expect, test, vi} from 'vitest'

describe('mockAndCaptureStandardStreams', () => {
test('captures stdout, stderr, and console warnings separately', () => {
const streams = mockAndCaptureStandardStreams()

try {
expect(process.stdout.write('result\n')).toBe(true)
process.stderr.write('diagnostic\n')
// eslint-disable-next-line no-console
console.warn('Warning: %s', 'example')

expect(streams.stdout()).toBe('result\n')
expect(streams.stderr()).toBe('diagnostic\nWarning: example\n')
} finally {
streams.restore()
}
})

test('preserves byte chunks and string encodings', () => {
const streams = mockAndCaptureStandardStreams()

try {
const encoded = Buffer.from('€')
process.stdout.write(encoded.subarray(0, 1))
process.stdout.write(new Uint8Array(encoded.subarray(1)))
process.stdout.write('21', 'hex')

expect(streams.stdout()).toBe('€!')
expect(streams.stderr()).toBe('')
} finally {
streams.restore()
}
})

test('calls write callbacks asynchronously, including empty writes used to flush output', async () => {
const streams = mockAndCaptureStandardStreams()
const callback = vi.fn()

try {
process.stdout.write('', callback)
expect(callback).not.toHaveBeenCalled()
await new Promise<void>((resolve) => process.stderr.write('message', 'utf8', () => resolve()))

expect(callback).toHaveBeenCalledOnce()
expect(streams.stdout()).toBe('')
expect(streams.stderr()).toBe('message')
} finally {
streams.restore()
}
})

test('restores the original writers and leaves captured output available', () => {
const stdoutWrite = process.stdout.write
const stderrWrite = process.stderr.write
// eslint-disable-next-line no-console
const consoleWarn = console.warn
const streams = mockAndCaptureStandardStreams()

try {
process.stdout.write('captured')
} finally {
streams.restore()
}

expect(process.stdout.write).toBe(stdoutWrite)
expect(process.stderr.write).toBe(stderrWrite)
// eslint-disable-next-line no-console
expect(console.warn).toBe(consoleWarn)
expect(streams.stdout()).toBe('captured')
})
})
Loading
Loading