From 5e0c655e7c070d1d3bca2fab1b2a02d888839774 Mon Sep 17 00:00:00 2001 From: Johnny Winn Date: Mon, 27 Jul 2026 14:08:02 -0600 Subject: [PATCH] feat: add --json flag to buildpacks and subcommands (W-23597909, #1637) Adds a -j/--json flag to all buildpacks subcommands (index, info, add, set, remove, clear). Commands that return a buildpack list output the raw BuildpackResponse array; info outputs the registry object; clear and single-removal output an empty array. Updates all unit tests with JSON coverage. Co-Authored-By: Claude Sonnet 4.6 (1M context) --- src/commands/buildpacks/add.ts | 8 ++++- src/commands/buildpacks/clear.ts | 4 ++- src/commands/buildpacks/index.ts | 5 ++- src/commands/buildpacks/info.ts | 15 ++++++--- src/commands/buildpacks/remove.ts | 10 ++++-- src/commands/buildpacks/set.ts | 8 ++++- src/lib/buildpacks/buildpacks.ts | 8 ++++- .../unit/commands/buildpacks/add.unit.test.ts | 15 +++++++++ .../commands/buildpacks/clear.unit.test.ts | 10 ++++++ .../commands/buildpacks/index.unit.test.ts | 25 +++++++++++++++ .../commands/buildpacks/info.unit.test.ts | 23 ++++++++++++++ .../commands/buildpacks/remove.unit.test.ts | 31 +++++++++++++++++++ .../unit/commands/buildpacks/set.unit.test.ts | 15 +++++++++ 13 files changed, 166 insertions(+), 11 deletions(-) diff --git a/src/commands/buildpacks/add.ts b/src/commands/buildpacks/add.ts index 0127ad934a..8e4becf910 100644 --- a/src/commands/buildpacks/add.ts +++ b/src/commands/buildpacks/add.ts @@ -1,4 +1,5 @@ import {Command, flags as Flags} from '@heroku-cli/command' +import {hux} from '@heroku/heroku-cli-util' import {Args} from '@oclif/core' import {BuildpackCommand} from '../../lib/buildpacks/buildpacks.js' @@ -17,6 +18,7 @@ export default class Add extends Command { char: 'i', description: 'the 1-based index of the URL in the list of URLs', }), + json: Flags.boolean({char: 'j', description: 'output in json format'}), remote: Flags.remote(), } @@ -41,6 +43,10 @@ export default class Add extends Command { } const buildpackUpdates = await buildpackCommand.mutate(flags.app, buildpacks, spliceIndex, args.buildpack, 'add') - buildpackCommand.displayUpdate(flags.app, flags.remote || '', buildpackUpdates, 'added') + if (flags.json) { + hux.styledJSON(buildpackUpdates) + } else { + buildpackCommand.displayUpdate(flags.app, flags.remote || '', buildpackUpdates, 'added') + } } } diff --git a/src/commands/buildpacks/clear.ts b/src/commands/buildpacks/clear.ts index 7ec02bf56e..fb76b4f70b 100644 --- a/src/commands/buildpacks/clear.ts +++ b/src/commands/buildpacks/clear.ts @@ -1,4 +1,5 @@ import {Command, flags as Flags} from '@heroku-cli/command' +import {hux} from '@heroku/heroku-cli-util' import {BuildpackCommand} from '../../lib/buildpacks/buildpacks.js' @@ -6,12 +7,13 @@ export default class Clear extends Command { static description = 'clear all buildpacks set on the app' static flags = { app: Flags.app({required: true}), + json: Flags.boolean({char: 'j', description: 'output in json format'}), remote: Flags.remote(), } async run() { const {flags} = await this.parse(Clear) const buildpackCommand = new BuildpackCommand(this.heroku) - await buildpackCommand.clear(flags.app, 'clear', 'cleared') + await buildpackCommand.clear(flags.app, 'clear', 'cleared', flags.json) } } diff --git a/src/commands/buildpacks/index.ts b/src/commands/buildpacks/index.ts index 7c5e183910..b86604f967 100644 --- a/src/commands/buildpacks/index.ts +++ b/src/commands/buildpacks/index.ts @@ -9,6 +9,7 @@ export default class Index extends Command { static description = 'list the buildpacks on an app' static flags = { app: Flags.app({required: true}), + json: Flags.boolean({char: 'j', description: 'output in json format'}), remote: Flags.remote(), } @@ -22,7 +23,9 @@ export default class Index extends Command { }) const isFirApp = getGeneration(app) === 'fir' const buildpacks = await buildpacksCommand.fetch(flags.app, isFirApp) - if (buildpacks.length === 0) { + if (flags.json) { + hux.styledJSON(buildpacks) + } else if (buildpacks.length === 0) { this.log(`${color.app(flags.app)} has no Buildpacks.`) } else { const pluralizedBuildpacks = buildpacks.length > 1 ? 'Buildpacks' : 'Buildpack' diff --git a/src/commands/buildpacks/info.ts b/src/commands/buildpacks/info.ts index 37711510d8..c73ac5bc73 100644 --- a/src/commands/buildpacks/info.ts +++ b/src/commands/buildpacks/info.ts @@ -1,4 +1,4 @@ -import {Command} from '@heroku-cli/command' +import {Command, flags as Flags} from '@heroku-cli/command' import {BuildpackRegistry} from '@heroku/buildpack-registry' import {hux} from '@heroku/heroku-cli-util' import {Args, ux} from '@oclif/core' @@ -12,9 +12,12 @@ export default class Info extends Command { }), } static description = 'fetch info about a buildpack' + static flags = { + json: Flags.boolean({char: 'j', description: 'output in json format'}), + } async run() { - const {args} = await this.parse(Info) + const {args, flags} = await this.parse(Info) const registry = new BuildpackRegistry() const validationResult = BuildpackRegistry.isValidBuildpackSlug(args.buildpack) @@ -32,8 +35,12 @@ export default class Info extends Command { } }, Ok(buildpack: unknown) { - hux.styledHeader(args.buildpack) - hux.styledObject(buildpack, ['description', 'category', 'license', 'support', 'source', 'readme']) + if (flags.json) { + hux.styledJSON(buildpack) + } else { + hux.styledHeader(args.buildpack) + hux.styledObject(buildpack, ['description', 'category', 'license', 'support', 'source', 'readme']) + } }, }, result as any) } diff --git a/src/commands/buildpacks/remove.ts b/src/commands/buildpacks/remove.ts index fffad40ac3..ceb0edef4b 100644 --- a/src/commands/buildpacks/remove.ts +++ b/src/commands/buildpacks/remove.ts @@ -1,4 +1,5 @@ import {Command, flags as Flags} from '@heroku-cli/command' +import {hux} from '@heroku/heroku-cli-util' import * as color from '@heroku/heroku-cli-util/color' import {Args, ux} from '@oclif/core' @@ -17,6 +18,7 @@ export default class Remove extends Command { char: 'i', description: 'the 1-based index of the URL to remove from the list of URLs', }), + json: Flags.boolean({char: 'j', description: 'output in json format'}), remote: Flags.remote(), } @@ -51,10 +53,14 @@ export default class Remove extends Command { } if (buildpacks.length === 1) { - await buildpackCommand.clear(flags.app, 'remove', 'removed') + await buildpackCommand.clear(flags.app, 'remove', 'removed', flags.json) } else { const buildpackUpdates = await buildpackCommand.mutate(flags.app, buildpacks, spliceIndex, args.buildpack as string, 'remove') - buildpackCommand.displayUpdate(flags.app, flags.remote || '', buildpackUpdates, 'removed') + if (flags.json) { + hux.styledJSON(buildpackUpdates) + } else { + buildpackCommand.displayUpdate(flags.app, flags.remote || '', buildpackUpdates, 'removed') + } } } } diff --git a/src/commands/buildpacks/set.ts b/src/commands/buildpacks/set.ts index 065528f315..48ebe73ceb 100644 --- a/src/commands/buildpacks/set.ts +++ b/src/commands/buildpacks/set.ts @@ -1,4 +1,5 @@ import {Command, flags as Flags} from '@heroku-cli/command' +import {hux} from '@heroku/heroku-cli-util' import {Args} from '@oclif/core' import {BuildpackCommand} from '../../lib/buildpacks/buildpacks.js' @@ -17,6 +18,7 @@ export default class Set extends Command { char: 'i', description: 'the 1-based index of the URL in the list of URLs', }), + json: Flags.boolean({char: 'j', description: 'output in json format'}), remote: Flags.remote(), } @@ -42,6 +44,10 @@ export default class Set extends Command { } const buildpackUpdates = await buildpackCommand.mutate(flags.app, buildpacks, spliceIndex, args.buildpack, 'set') - buildpackCommand.displayUpdate(flags.app, flags.remote || '', buildpackUpdates, 'set') + if (flags.json) { + hux.styledJSON(buildpackUpdates) + } else { + buildpackCommand.displayUpdate(flags.app, flags.remote || '', buildpackUpdates, 'set') + } } } diff --git a/src/lib/buildpacks/buildpacks.ts b/src/lib/buildpacks/buildpacks.ts index e84eeb1a4b..367837e016 100644 --- a/src/lib/buildpacks/buildpacks.ts +++ b/src/lib/buildpacks/buildpacks.ts @@ -1,6 +1,7 @@ import {APIClient} from '@heroku-cli/command' import * as Heroku from '@heroku-cli/schema' import {BuildpackRegistry} from '@heroku/buildpack-registry' +import {hux} from '@heroku/heroku-cli-util' import * as color from '@heroku/heroku-cli-util/color' import {ux} from '@oclif/core/ux' import _ from 'lodash' @@ -25,9 +26,14 @@ export class BuildpackCommand { this.registry = new BuildpackRegistry() } - async clear(app: string, command: 'clear' | 'remove', action: 'cleared' | 'removed') { + async clear(app: string, command: 'clear' | 'remove', action: 'cleared' | 'removed', json = false) { await this.put(app, []) + if (json) { + hux.styledJSON([]) + return + } + const configVars: any = await this.heroku.get(`/apps/${app}/config-vars`) const message = `Buildpack${command === 'clear' ? 's' : ''} ${action}.` if (configVars.body.BUILDPACK_URL) { diff --git a/test/unit/commands/buildpacks/add.unit.test.ts b/test/unit/commands/buildpacks/add.unit.test.ts index 12734c16b5..0fb815469a 100644 --- a/test/unit/commands/buildpacks/add.unit.test.ts +++ b/test/unit/commands/buildpacks/add.unit.test.ts @@ -209,4 +209,19 @@ Run git push heroku main to create a new release using these buildpacks. expect(error?.message).to.include('Invalid index. Must be greater than 0.') }) }) + + describe('--json', function () { + it('# outputs added buildpacks as JSON', async function () { + Stubber.get(api) + Stubber.put(api, ['https://github.com/heroku/heroku-buildpack-ruby']) + + const {stderr, stdout} = await runCommand(BuildpacksAdd, ['https://github.com/heroku/heroku-buildpack-ruby', '-a', 'example', '--json']) + + expect(stderr).to.equal('') + const parsed = JSON.parse(stdout) + expect(parsed).to.be.an('array') + expect(parsed).to.have.lengthOf(1) + expect(parsed[0].buildpack.url).to.equal('https://github.com/heroku/heroku-buildpack-ruby') + }) + }) }) diff --git a/test/unit/commands/buildpacks/clear.unit.test.ts b/test/unit/commands/buildpacks/clear.unit.test.ts index 7884068cd4..cf43140ab5 100644 --- a/test/unit/commands/buildpacks/clear.unit.test.ts +++ b/test/unit/commands/buildpacks/clear.unit.test.ts @@ -52,4 +52,14 @@ describe('buildpacks:clear', function () { expect(stdout).to.equal('Buildpacks cleared.\n') expect(unwrap(stderr)).to.equal('Warning: The LANGUAGE_PACK_URL config var is still set and will be used for the next release\n') }) + + it('# outputs empty array as JSON when --json flag is set', async function () { + Stubber.put(api) + + const {stdout} = await runCommand(Clear, ['-a', 'example', '--json']) + + const parsed = JSON.parse(stdout) + expect(parsed).to.be.an('array') + expect(parsed).to.have.lengthOf(0) + }) }) diff --git a/test/unit/commands/buildpacks/index.unit.test.ts b/test/unit/commands/buildpacks/index.unit.test.ts index 7b4503e704..027700492c 100644 --- a/test/unit/commands/buildpacks/index.unit.test.ts +++ b/test/unit/commands/buildpacks/index.unit.test.ts @@ -298,4 +298,29 @@ describe('buildpacks', function () { expect(stderr).to.equal('') expect(stdout).to.equal(`⬢ ${firApp.name} has no Buildpacks.\n`) }) + + it('# outputs buildpacks as JSON when --json flag is set', async function () { + api.get(`/apps/${cedarApp.name}`).reply(200, cedarApp) + Stubber.get(api, ['https://github.com/heroku/heroku-buildpack-ruby']) + + const {stderr, stdout} = await runCommand(Buildpacks, ['-a', cedarApp.name, '--json']) + + expect(stderr).to.equal('') + const parsed = JSON.parse(stdout) + expect(parsed).to.be.an('array') + expect(parsed).to.have.lengthOf(1) + expect(parsed[0].buildpack.url).to.equal('https://github.com/heroku/heroku-buildpack-ruby') + }) + + it('# outputs empty array as JSON when no buildpacks and --json flag is set', async function () { + api.get(`/apps/${cedarApp.name}`).reply(200, cedarApp) + Stubber.get(api) + + const {stderr, stdout} = await runCommand(Buildpacks, ['-a', cedarApp.name, '--json']) + + expect(stderr).to.equal('') + const parsed = JSON.parse(stdout) + expect(parsed).to.be.an('array') + expect(parsed).to.have.lengthOf(0) + }) }) diff --git a/test/unit/commands/buildpacks/info.unit.test.ts b/test/unit/commands/buildpacks/info.unit.test.ts index dafa84b213..dcc27437ea 100644 --- a/test/unit/commands/buildpacks/info.unit.test.ts +++ b/test/unit/commands/buildpacks/info.unit.test.ts @@ -59,4 +59,27 @@ describe('buildpacks:info', function () { expect(error?.message).to.include('Problems finding buildpack info: some error') }) + + it('outputs buildpack info as JSON when --json flag is set', async function () { + registryApi + .get('/buildpacks/heroku%2Fruby') + .reply(200, Fixture.buildpack({ + name: 'ruby', + source: { + owner: 'heroku', + repo: 'heroku-buildpack-ruby', + type: 'github', + }, + })) + .get('/buildpacks/heroku%2Fruby/revisions') + .reply(200, [Fixture.revision()]) + .get('/buildpacks/heroku%2Fruby/readme') + .reply(200, Fixture.readme()) + + const {stdout} = await runCommand(BuildpacksInfo, ['heroku/ruby', '--json']) + + const parsed = JSON.parse(stdout) + expect(parsed).to.be.an('object') + expect(parsed).to.have.property('description') + }) }) diff --git a/test/unit/commands/buildpacks/remove.unit.test.ts b/test/unit/commands/buildpacks/remove.unit.test.ts index def82773d3..3da5c8539b 100644 --- a/test/unit/commands/buildpacks/remove.unit.test.ts +++ b/test/unit/commands/buildpacks/remove.unit.test.ts @@ -262,4 +262,35 @@ Run git push heroku main to create a new release using these buildpacks. expect(error?.message).to.include('Usage: heroku buildpacks:remove [BUILDPACK_URL]. Must specify a buildpack to remove, either by index or URL.') }) }) + + describe('--json', function () { + it('# outputs remaining buildpacks as JSON after removal', async function () { + Stubber.get(api, [ + 'https://github.com/heroku/heroku-buildpack-java', + 'https://github.com/heroku/heroku-buildpack-ruby', + ]) + Stubber.put(api, [ + 'https://github.com/heroku/heroku-buildpack-java', + ]) + + const {stderr, stdout} = await runCommand(BuildpacksRemove, ['https://github.com/heroku/heroku-buildpack-ruby', '-a', 'example', '--json']) + + expect(stderr).to.equal('') + const parsed = JSON.parse(stdout) + expect(parsed).to.be.an('array') + expect(parsed).to.have.lengthOf(1) + expect(parsed[0].buildpack.url).to.equal('https://github.com/heroku/heroku-buildpack-java') + }) + + it('# outputs empty array as JSON when last buildpack is removed', async function () { + Stubber.get(api, ['https://github.com/heroku/heroku-buildpack-ruby']) + Stubber.put(api) + + const {stdout} = await runCommand(BuildpacksRemove, ['https://github.com/heroku/heroku-buildpack-ruby', '-a', 'example', '--json']) + + const parsed = JSON.parse(stdout) + expect(parsed).to.be.an('array') + expect(parsed).to.have.lengthOf(0) + }) + }) }) diff --git a/test/unit/commands/buildpacks/set.unit.test.ts b/test/unit/commands/buildpacks/set.unit.test.ts index 67a8e47348..f4987b20e2 100644 --- a/test/unit/commands/buildpacks/set.unit.test.ts +++ b/test/unit/commands/buildpacks/set.unit.test.ts @@ -201,4 +201,19 @@ buildpack namespace/name of the buildpack See more help with --help`) }) }) + + describe('--json', function () { + it('# outputs set buildpacks as JSON', async function () { + Stubber.get(api) + Stubber.put(api, ['https://github.com/heroku/heroku-buildpack-ruby']) + + const {stderr, stdout} = await runCommand(BuildpacksSet, ['https://github.com/heroku/heroku-buildpack-ruby', '-a', 'example', '--json']) + + expect(stderr).to.equal('') + const parsed = JSON.parse(stdout) + expect(parsed).to.be.an('array') + expect(parsed).to.have.lengthOf(1) + expect(parsed[0].buildpack.url).to.equal('https://github.com/heroku/heroku-buildpack-ruby') + }) + }) })