From 7b3abc7580aa7046d7b3ef3c6ef6b147ff432c10 Mon Sep 17 00:00:00 2001 From: Gonzalo Riestra Date: Mon, 21 Sep 2026 10:13:22 +0200 Subject: [PATCH 1/6] Add typed JSON output to app env pull Co-Authored-By: Claude Fable 5 --- .changeset/typed-app-env-pull.md | 4 + .../app/src/cli/commands/app/env/pull.test.ts | 151 ++++++++++++------ packages/app/src/cli/commands/app/env/pull.ts | 13 +- .../app/src/cli/services/app/env/pull.test.ts | 31 +++- packages/app/src/cli/services/app/env/pull.ts | 59 ++----- .../src/cli/services/app/env/pull/result.ts | 31 ++++ .../src/cli/services/app/env/pull/types.ts | 16 ++ packages/cli/oclif.manifest.json | 11 +- .../rules/json-output-command-exceptions.js | 1 - 9 files changed, 219 insertions(+), 98 deletions(-) create mode 100644 .changeset/typed-app-env-pull.md create mode 100644 packages/app/src/cli/services/app/env/pull/result.ts create mode 100644 packages/app/src/cli/services/app/env/pull/types.ts diff --git a/.changeset/typed-app-env-pull.md b/.changeset/typed-app-env-pull.md new file mode 100644 index 00000000000..f2ac9277f8b --- /dev/null +++ b/.changeset/typed-app-env-pull.md @@ -0,0 +1,4 @@ +--- +'@shopify/cli': minor +--- +Add typed JSON output to app env pull. 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..13aa69bd38a 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,118 @@ 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' 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 = {SHOPIFY_API_KEY: 'api-key', SHOPIFY_API_SECRET: 'api-secret', SCOPES: 'read_products'} +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, status, 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: 'created', variables, content}, + {path: '/app/.env', status: 'failed', variables, content}, + {path: '/app/.env', status: 'updated', variables: {...variables, SCOPES: null}, content}, + {path: '/app/.env', status: 'unchanged', 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: 'created', 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: 'created', variables, content}, previousContent: null}) + await new EnvPull(['--path', directory, '--env-file', 'config/.env'], await Config.load()).run() + expect(pull).toHaveBeenCalledWith({app, remoteApp, organization, envFile}) }) }) 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..909da87eef7 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' @@ -29,7 +30,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 +57,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 +92,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 +120,24 @@ 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('updated') + expect(output.result.variables).toEqual({ + SHOPIFY_API_KEY: 'api-key', + SHOPIFY_API_SECRET: undefined, + SCOPES: 'my-scope', + }) + 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.variables).not.toHaveProperty('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..2d7d918a9af 100644 --- a/packages/app/src/cli/services/app/env/pull.ts +++ b/packages/app/src/cli/services/app/env/pull.ts @@ -1,12 +1,9 @@ -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' interface PullEnvOptions { app: AppLinkedInterface @@ -15,42 +12,18 @@ 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 previousContent = (await fileExists(envFile)) ? await readFile(envFile) : null + const content = patchEnvFile(previousContent, variables) + let status: AppEnvPullResult['status'] = 'unchanged' + if (previousContent === null) status = 'created' + else if (content !== previousContent) status = 'updated' + if (status !== 'unchanged') await writeFile(envFile, content) + return {result: {path: envFile, status, 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..a5ddc972500 --- /dev/null +++ b/packages/app/src/cli/services/app/env/pull/result.ts @@ -0,0 +1,31 @@ +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' + +export function formatAppEnvPullResult( + {result, previousContent}: PullEnvOutput, + format: 'json' | 'text', +): OutputMessage { + if (format === 'json') return appEnvPullJsonOutputSchema.encode(result) + const {path, status, content} = result + if (status === 'unchanged') return outputContent`No changes to ${outputToken.path(path)}` + if (status === 'created') { + 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..cbd9fd1e8da --- /dev/null +++ b/packages/app/src/cli/services/app/env/pull/types.ts @@ -0,0 +1,16 @@ +import {appEnvShowJsonOutputSchema} from '../show/types.js' +import {defineJsonOutputSchema, type InferJsonOutputSchema} from '@shopify/cli-kit/node/json-output-schema' +import {zod} from '@shopify/cli-kit/node/schema' + +export const appEnvPullJsonOutputSchema = defineJsonOutputSchema({ + name: 'AppEnvPullResult', + schema: zod.object({ + path: zod.string(), + status: zod.enum(['created', 'updated', 'unchanged']), + variables: appEnvShowJsonOutputSchema.schema, + content: zod.string(), + }), + definitions: {EnvironmentVariables: appEnvShowJsonOutputSchema.schema}, +}) + +export type AppEnvPullResult = InferJsonOutputSchema diff --git a/packages/cli/oclif.manifest.json b/packages/cli/oclif.manifest.json index c4a46682f55..74bf8f4c8f1 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\nOutput from `--json` conforms to the `AppEnvPullResult` schema.\n\nUse `--json-schema` to print the result, error, and event schemas.\n\n```json\n{\n \"type\": \"object\",\n \"properties\": {\n \"path\": {\n \"type\": \"string\"\n },\n \"status\": {\n \"type\": \"string\",\n \"enum\": [\n \"created\",\n \"updated\",\n \"unchanged\"\n ]\n },\n \"variables\": {\n \"$ref\": \"#/definitions/EnvironmentVariables\"\n },\n \"content\": {\n \"type\": \"string\"\n }\n },\n \"required\": [\n \"path\",\n \"status\",\n \"variables\",\n \"content\"\n ],\n \"additionalProperties\": false,\n \"title\": \"AppEnvPullResult\",\n \"definitions\": {\n \"EnvironmentVariables\": {\n \"type\": \"object\",\n \"properties\": {\n \"SHOPIFY_API_KEY\": {\n \"type\": \"string\"\n },\n \"SHOPIFY_API_SECRET\": {\n \"type\": \"string\"\n },\n \"SCOPES\": {\n \"type\": \"string\"\n }\n },\n \"required\": [\n \"SHOPIFY_API_KEY\",\n \"SCOPES\"\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', From e56364d82bb00394511a608d78075b2da25cf5a9 Mon Sep 17 00:00:00 2001 From: Gonzalo Riestra Date: Tue, 6 Oct 2026 14:18:13 +0200 Subject: [PATCH 2/6] Normalize app env pull outcomes and variables --- .../app/src/cli/commands/app/env/pull.test.ts | 57 ++++++++++--- .../app/src/cli/services/app/env/pull.test.ts | 14 ++-- packages/app/src/cli/services/app/env/pull.ts | 16 ++-- .../src/cli/services/app/env/pull/result.ts | 6 +- .../src/cli/services/app/env/pull/types.ts | 25 ++++-- packages/cli/README.md | 84 ++++++++++++++++++- packages/cli/oclif.manifest.json | 2 +- 7 files changed, 164 insertions(+), 40 deletions(-) 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 13aa69bd38a..7b7fc6563db 100644 --- a/packages/app/src/cli/commands/app/env/pull.test.ts +++ b/packages/app/src/cli/commands/app/env/pull.test.ts @@ -16,7 +16,11 @@ import {runWithCommandEventsForCommand} from '@shopify/cli-kit/node/command-even vi.mock('../../../services/app-context.js') vi.mock('../../../services/context.js') -const variables = {SHOPIFY_API_KEY: 'api-key', SHOPIFY_API_SECRET: 'api-secret', SCOPES: 'read_products'} +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') { @@ -52,7 +56,9 @@ test.each([ const streams = mockAndCaptureStandardStreams() try { await expect(runWithCommandEventsForCommand(['--json'], () => command.run())).resolves.toEqual({app}) - expect(streams.stdout()).toBe(`${JSON.stringify({path, status, variables, content: expected}, null, 2)}\n`) + expect(streams.stdout()).toBe( + `${JSON.stringify({path, status: 'success', changed: status !== 'unchanged', variables, content: expected}, null, 2)}\n`, + ) expect(streams.stderr()).toBe('') expect(logMetadataForLoadedContext).toHaveBeenCalledExactlyOnceWith(remoteApp, organization.source) } finally { @@ -86,10 +92,12 @@ test('exposes the schema, JSON flag and existing file selection flag', () => { }) test.each([ - {path: 1, status: 'created', variables, content}, - {path: '/app/.env', status: 'failed', variables, content}, - {path: '/app/.env', status: 'updated', variables: {...variables, SCOPES: null}, content}, - {path: '/app/.env', status: 'unchanged', variables, content: null}, + {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() }) @@ -98,9 +106,10 @@ 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: 'created', variables, content}, previousContent: null}) + 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}) }) @@ -109,10 +118,34 @@ 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: 'created', variables, content}, previousContent: null}) + 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/services/app/env/pull.test.ts b/packages/app/src/cli/services/app/env/pull.test.ts index 909da87eef7..89777fe36ec 100644 --- a/packages/app/src/cli/services/app/env/pull.test.ts +++ b/packages/app/src/cli/services/app/env/pull.test.ts @@ -127,17 +127,17 @@ test('returns file facts while preserving custom variables, comments, and a miss 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('updated') - expect(output.result.variables).toEqual({ - SHOPIFY_API_KEY: 'api-key', - SHOPIFY_API_SECRET: undefined, - SCOPES: 'my-scope', - }) + 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.variables).not.toHaveProperty('SHOPIFY_API_SECRET') + 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 2d7d918a9af..858f0246e6a 100644 --- a/packages/app/src/cli/services/app/env/pull.ts +++ b/packages/app/src/cli/services/app/env/pull.ts @@ -4,6 +4,7 @@ import {AppLinkedInterface} from '../../../models/app/app.js' import {Organization, OrganizationApp} from '../../../models/organization.js' import {patchEnvFile} from '@shopify/cli-kit/node/dot-env' import {fileExists, readFile, writeFile} from '@shopify/cli-kit/node/fs' +import {resolvePath} from '@shopify/cli-kit/node/path' interface PullEnvOptions { app: AppLinkedInterface @@ -18,12 +19,11 @@ export interface PullEnvOutput { } export async function pullEnv({app, remoteApp, organization, envFile}: PullEnvOptions): Promise { - const variables = await getAppEnv(app, remoteApp, organization) - const previousContent = (await fileExists(envFile)) ? await readFile(envFile) : null - const content = patchEnvFile(previousContent, variables) - let status: AppEnvPullResult['status'] = 'unchanged' - if (previousContent === null) status = 'created' - else if (content !== previousContent) status = 'updated' - if (status !== 'unchanged') await writeFile(envFile, content) - return {result: {path: envFile, status, variables, content}, previousContent} + 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 index a5ddc972500..36cc06bd7ea 100644 --- a/packages/app/src/cli/services/app/env/pull/result.ts +++ b/packages/app/src/cli/services/app/env/pull/result.ts @@ -8,9 +8,9 @@ export function formatAppEnvPullResult( format: 'json' | 'text', ): OutputMessage { if (format === 'json') return appEnvPullJsonOutputSchema.encode(result) - const {path, status, content} = result - if (status === 'unchanged') return outputContent`No changes to ${outputToken.path(path)}` - if (status === 'created') { + 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} diff --git a/packages/app/src/cli/services/app/env/pull/types.ts b/packages/app/src/cli/services/app/env/pull/types.ts index cbd9fd1e8da..6d89fe7006d 100644 --- a/packages/app/src/cli/services/app/env/pull/types.ts +++ b/packages/app/src/cli/services/app/env/pull/types.ts @@ -1,16 +1,25 @@ -import {appEnvShowJsonOutputSchema} from '../show/types.js' +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(), - status: zod.enum(['created', 'updated', 'unchanged']), - variables: appEnvShowJsonOutputSchema.schema, - content: zod.string(), - }), - definitions: {EnvironmentVariables: appEnvShowJsonOutputSchema.schema}, + 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/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 74bf8f4c8f1..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.\n\nOutput from `--json` conforms to the `AppEnvPullResult` schema.\n\nUse `--json-schema` to print the result, error, and event schemas.\n\n```json\n{\n \"type\": \"object\",\n \"properties\": {\n \"path\": {\n \"type\": \"string\"\n },\n \"status\": {\n \"type\": \"string\",\n \"enum\": [\n \"created\",\n \"updated\",\n \"unchanged\"\n ]\n },\n \"variables\": {\n \"$ref\": \"#/definitions/EnvironmentVariables\"\n },\n \"content\": {\n \"type\": \"string\"\n }\n },\n \"required\": [\n \"path\",\n \"status\",\n \"variables\",\n \"content\"\n ],\n \"additionalProperties\": false,\n \"title\": \"AppEnvPullResult\",\n \"definitions\": {\n \"EnvironmentVariables\": {\n \"type\": \"object\",\n \"properties\": {\n \"SHOPIFY_API_KEY\": {\n \"type\": \"string\"\n },\n \"SHOPIFY_API_SECRET\": {\n \"type\": \"string\"\n },\n \"SCOPES\": {\n \"type\": \"string\"\n }\n },\n \"required\": [\n \"SHOPIFY_API_KEY\",\n \"SCOPES\"\n ],\n \"additionalProperties\": false\n }\n },\n \"$schema\": \"http://json-schema.org/draft-07/schema#\"\n}\n```", + "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": { From 292da135568a1c2eb5d6010645d9ec4d4d779956 Mon Sep 17 00:00:00 2001 From: Gonzalo Riestra Date: Tue, 6 Oct 2026 15:47:31 +0200 Subject: [PATCH 3/6] Refresh env pull generated docs and share its variable schema --- .../generated/generated_docs_data_v2.json | 11 ++++++++++- packages/app/src/cli/services/app/env/show/types.ts | 2 +- 2 files changed, 11 insertions(+), 2 deletions(-) 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/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.'), From c3657306a46826b09ae66d9dda4b83473e3aa5d4 Mon Sep 17 00:00:00 2001 From: Gonzalo Riestra Date: Tue, 6 Oct 2026 15:50:29 +0200 Subject: [PATCH 4/6] Use native filesystem paths in env pull JSON --- packages/app/src/cli/services/app/env/pull.test.ts | 3 +++ packages/app/src/cli/services/app/env/pull/result.ts | 4 +++- 2 files changed, 6 insertions(+), 1 deletion(-) 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 89777fe36ec..f58d95babb9 100644 --- a/packages/app/src/cli/services/app/env/pull.test.ts +++ b/packages/app/src/cli/services/app/env/pull.test.ts @@ -7,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', @@ -137,6 +139,7 @@ test('returns file facts while preserving custom variables, comments, and a miss 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/result.ts b/packages/app/src/cli/services/app/env/pull/result.ts index 36cc06bd7ea..c1762211485 100644 --- a/packages/app/src/cli/services/app/env/pull/result.ts +++ b/packages/app/src/cli/services/app/env/pull/result.ts @@ -2,12 +2,14 @@ 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) + 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) { From dabce1f1515eec3ef6d0d435b8f403175823e48e Mon Sep 17 00:00:00 2001 From: Gonzalo Riestra Date: Fri, 9 Oct 2026 13:08:32 +0200 Subject: [PATCH 5/6] Expect native filesystem paths in env pull JSON tests --- packages/app/src/cli/commands/app/env/pull.test.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) 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 7b7fc6563db..2a19471d7bb 100644 --- a/packages/app/src/cli/commands/app/env/pull.test.ts +++ b/packages/app/src/cli/commands/app/env/pull.test.ts @@ -12,6 +12,8 @@ import {joinPath, resolvePath} from '@shopify/cli-kit/node/path' 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/context.js') @@ -57,7 +59,7 @@ test.each([ try { await expect(runWithCommandEventsForCommand(['--json'], () => command.run())).resolves.toEqual({app}) expect(streams.stdout()).toBe( - `${JSON.stringify({path, status: 'success', changed: status !== 'unchanged', variables, content: expected}, null, 2)}\n`, + `${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) From 93c66f34b5b3c3cdba46d2809d3dd421447569e3 Mon Sep 17 00:00:00 2001 From: Gonzalo Riestra Date: Tue, 13 Oct 2026 17:03:07 +0200 Subject: [PATCH 6/6] Consolidate app env command changeset --- .changeset/typed-app-env-pull.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/typed-app-env-pull.md b/.changeset/typed-app-env-pull.md index f2ac9277f8b..beae240d840 100644 --- a/.changeset/typed-app-env-pull.md +++ b/.changeset/typed-app-env-pull.md @@ -1,4 +1,4 @@ --- '@shopify/cli': minor --- -Add typed JSON output to app env pull. +Add typed JSON output to app env commands.