diff --git a/packages/cli/package.json b/packages/cli/package.json index e4f38d246a..a292e59ca8 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -76,7 +76,6 @@ "tslib": "1.14.1", "tunnel-ssh": "4.1.6", "urijs": "^1.19.11", - "valid-url": "^1.0.9", "validator": "^13.7.0", "word-wrap": "^1.2.5", "ws": "^6.2.2" diff --git a/packages/cli/src/commands/buildpacks/index.ts b/packages/cli/src/commands/buildpacks/index.ts index 72dfc42e7d..5bfd8e5df9 100644 --- a/packages/cli/src/commands/buildpacks/index.ts +++ b/packages/cli/src/commands/buildpacks/index.ts @@ -1,5 +1,7 @@ import {Command, flags as Flags} from '@heroku-cli/command' import {ux} from '@oclif/core' +import {App} from '../../lib/types/fir' +import color from '@heroku-cli/color' import {BuildpackCommand} from '../../lib/buildpacks/buildpacks' @@ -14,12 +16,16 @@ export default class Index extends Command { async run() { const {flags} = await this.parse(Index) const buildpacksCommand = new BuildpackCommand(this.heroku) - - const buildpacks = await buildpacksCommand.fetch(flags.app) + const {body: app} = await this.heroku.get(`/apps/${flags.app}`, { + headers: { + Accept: 'application/vnd.heroku+json; version=3.sdk', + }, + }) + const buildpacks = await buildpacksCommand.fetch(flags.app, app.generation === 'fir') if (buildpacks.length === 0) { - this.log(`${flags.app} has no Buildpack URL set.`) + this.log(`${color.app(flags.app)} has no Buildpacks.`) } else { - ux.styledHeader(`${flags.app} Buildpack URL${buildpacks.length > 1 ? 's' : ''}`) + ux.styledHeader(`${color.app(flags.app)} Buildpack${buildpacks.length > 1 ? 's' : ''}`) buildpacksCommand.display(buildpacks, '') } } diff --git a/packages/cli/src/lib/buildpacks/buildpacks.ts b/packages/cli/src/lib/buildpacks/buildpacks.ts index 351e1c9001..185014fd3f 100644 --- a/packages/cli/src/lib/buildpacks/buildpacks.ts +++ b/packages/cli/src/lib/buildpacks/buildpacks.ts @@ -5,8 +5,8 @@ import {ux} from '@oclif/core' import {findIndex as lodashFindIndex} from 'lodash' import {Result} from 'true-myth' import push from '../git/push' - -const validUrl = require('valid-url') +import {OciImage, Release} from '../../lib/types/fir' +import {isURL} from 'validator' export type BuildpackResponse = { buildpack: { @@ -26,14 +26,42 @@ export class BuildpackCommand { this.registry = new BuildpackRegistry() } - async fetch(app: string): Promise { - const buildpacks = await this.heroku.get(`/apps/${app}/buildpack-installations`) + async fetch(app: string, isFirApp = false): Promise { + let buildpacks: any + if (isFirApp) { + const {body: releases} = await this.heroku.request(`/apps/${app}/releases`, { + partial: true, + headers: { + Range: 'version ..; max=10, order=desc', + Accept: 'application/vnd.heroku+json; version=3.sdk', + }, + }) + if (releases.length === 0 || releases[0].oci_image === null) return [] + const latestImageId = releases[0].oci_image.id + const {body: ociImages} = await this.heroku.get(`/apps/${app}/oci-images/${latestImageId}`, { + headers: { + Accept: 'application/vnd.heroku+json; version=3.sdk', + }, + }) + buildpacks = ociImages[0].buildpacks.map((b, index) => { + return { + buildpack: { + url: b.id || b.homepage, + name: b.id, + }, + ordinal: index, + } + }) + } else { + const {body: buildpackInstallations} = await this.heroku.get(`/apps/${app}/buildpack-installations`) + buildpacks = buildpackInstallations + } + return this.mapBuildpackResponse(buildpacks) } - mapBuildpackResponse(buildpacks: {body: any}): BuildpackResponse[] { - const body = buildpacks.body - return body.map((bp: BuildpackResponse) => { + mapBuildpackResponse(buildpacks: BuildpackResponse[]): BuildpackResponse[] { + return buildpacks.map((bp: BuildpackResponse) => { bp.buildpack.url = bp.buildpack.url.replace(/^urn:buildpack:/, '') return bp }) @@ -50,12 +78,12 @@ export class BuildpackCommand { } async registryNameToUrl(buildpack: string): Promise { - if (validUrl.isWebUri(buildpack)) { + if (isURL(buildpack)) { return buildpack } Result.match({ - Ok: _ => {}, + Ok: () => {}, Err: err => { ux.error(`Could not find the buildpack: ${buildpack}. ${err}`, {exit: 1}) }, @@ -117,7 +145,7 @@ export class BuildpackCommand { } async put(app: string, buildpackUpdates: {buildpack: string}[]): Promise { - const buildpacks = await this.heroku.put(`/apps/${app}/buildpack-installations`, { + const {body: buildpacks} = await this.heroku.put(`/apps/${app}/buildpack-installations`, { headers: {Range: ''}, body: {updates: buildpackUpdates}, }) diff --git a/packages/cli/test/unit/commands/buildpacks/index.unit.test.ts b/packages/cli/test/unit/commands/buildpacks/index.unit.test.ts index a75a6bde55..7285320302 100644 --- a/packages/cli/test/unit/commands/buildpacks/index.unit.test.ts +++ b/packages/cli/test/unit/commands/buildpacks/index.unit.test.ts @@ -1,74 +1,194 @@ +/* eslint-disable mocha/no-setup-in-describe */ import {expect, test} from '@oclif/test' import * as nock from 'nock' +import heredoc from 'tsheredoc' import {BuildpackInstallationsStub as Stubber} from '../../../helpers/buildpacks/buildpack-installations-stub' nock.disableNetConnect() +const cedarApp = { + acm: false, + archived_at: null, + build_stack: {name: 'heroku-24'}, + created_at: '2024-09-06T17:45:29Z', + git_url: 'https://git.heroku.com', + id: '12345678-aaaa-bbbb-cccc-b2443790f501', + generation: 'cedar', + maintenance: false, + name: 'example', + owner: {email: 'example-owner@heroku.com'}, + internal_routing: null, + region: {name: 'virginia'}, + released_at: '2024-11-13T20:07:47Z', + repo_size: null, + slug_size: null, + stack: {name: 'heroku-24'}, + updated_at: '2024-11-13T20:07:47Z', + web_url: 'https://cedar-example-app.herokuapp.com', +} + +const firApp = { + acm: false, + archived_at: null, + build_stack: {name: 'heroku-24'}, + created_at: '2024-09-06T17:45:29Z', + generation: 'fir', + git_url: 'https://git.heroku.com', + id: '12345678-aaaa-bbbb-cccc-b2443790f501', + maintenance: false, + name: 'example', + owner: {email: 'example-owner@heroku.com'}, + internal_routing: null, + region: {name: 'virginia'}, + released_at: '2024-11-13T20:07:47Z', + repo_size: null, + slug_size: null, + stack: {name: 'heroku-24'}, + updated_at: '2024-11-13T20:07:47Z', + web_url: 'https://fir-example-app.herokuapp.com', +} + +const releases = [ + { + addon_plan_names: [ + 'heroku-postgresql:dev', + ], + artifacts: [ + { + type: 'oci-image', + id: '01234567-89ab-cdef-0123-456789abcdef', + }, + ], + app: { + name: 'example', + id: '01234567-89ab-cdef-0123-456789abcdef', + }, + created_at: '2012-01-01T12:00:00Z', + description: 'Added new feature', + id: '01234567-89ab-cdef-0123-456789abcdef', + updated_at: '2012-01-01T12:00:00Z', + oci_image: { + id: '01234567-89ab-cdef-0123-456789abcdef', + }, + slug: { + id: '01234567-89ab-cdef-0123-456789abcdef', + }, + status: 'succeeded', + user: { + id: '01234567-89ab-cdef-0123-456789abcdef', + email: 'username@example.com', + }, + version: 11, + current: true, + output_stream_url: 'https://release-output.heroku.com/streams/01234567-89ab-cdef-0123-456789abcdef', + eligible_for_rollback: true, + }, +] + +const ociImages = [ + { + id: '01234567-89ab-cdef-0123-456789abcdef', + base_image_name: 'heroku/heroku:22-cnb', + base_top_layer: 'sha256:ea36ae5fbc1e7230e0a782bf216fb46500e210382703baa6bab8acf2c6a23f78', + commit: '60883d9e8947a57e04dc9124f25df004866a2051', + commit_description: 'fixed a bug with API documentation', + image_repo: 'd7ba1ace-b396-4691-968c-37ae53153426/builds', + digest: 'sha256:dc14ae5fbc1e7230e0a782bf216fb46500e210631703bcc6bab8acf2c6a23f42', + stack: { + id: 'ba46bf09-7bd1-42fd-90df-a1a9a93eb4a2', + name: 'cnb', + }, + process_types: { + web: { + name: 'web', + display_cmd: 'bundle exec puma -p $PORT', + command: '/cnb/process/web', + working_dir: '/workspace/webapp', + default: true, + }, + }, + buildpacks: [ + { + id: 'heroku/ruby', + version: '2.0.0', + homepage: 'https://github.com/heroku/buildpacks-ruby', + }, + ], + created_at: '2012-01-01T12:00:00Z', + updated_at: '2012-01-01T12:00:00Z', + architecture: 'arm64', + }, +] describe('buildpacks', function () { test .nock('https://api.heroku.com', (api: nock.Scope) => { + api.get(`/apps/${cedarApp.name}`).reply(200, cedarApp) Stubber.get(api, ['https://github.com/heroku/heroku-buildpack-ruby']) }) .stdout() .stderr() - .command(['buildpacks', '-a', 'example']) + .command(['buildpacks', '-a', cedarApp.name]) .it('# displays the buildpack URL', ctx => { expect(ctx.stderr).to.equal('') - expect(ctx.stdout).to.equal( - `=== example Buildpack URL + expect(ctx.stdout).to.equal(heredoc(` + === ⬢ ${cedarApp.name} Buildpack -https://github.com/heroku/heroku-buildpack-ruby -`) + https://github.com/heroku/heroku-buildpack-ruby + `)) }) test .nock('https://api.heroku.com', (api: nock.Scope) => { + api.get(`/apps/${cedarApp.name}`).reply(200, cedarApp) Stubber.get(api, [{url: 'urn:buildpack:heroku/ruby', name: 'heroku/ruby'}]) }) .stdout() .stderr() - .command(['buildpacks', '-a', 'example']) + .command(['buildpacks', '-a', cedarApp.name]) .it('# maps buildpack urns to names', ctx => { expect(ctx.stderr).to.equal('') - expect(ctx.stdout).to.equal( - `=== example Buildpack URL + expect(ctx.stdout).to.equal(heredoc(` + === ⬢ ${cedarApp.name} Buildpack -heroku/ruby -`) + heroku/ruby + `)) }) test .nock('https://api.heroku.com', (api: nock.Scope) => { + api.get(`/apps/${cedarApp.name}`).reply(200, cedarApp) Stubber.get(api, ['https://codon-buildpacks.s3.amazonaws.com/buildpacks/heroku/ruby.tgz']) }) .stdout() .stderr() - .command(['buildpacks', '-a', 'example']) + .command(['buildpacks', '-a', cedarApp.name]) .it('# does not map buildpack s3 to names', ctx => { expect(ctx.stderr).to.equal('') - expect(ctx.stdout).to.equal( - `=== example Buildpack URL + expect(ctx.stdout).to.equal(heredoc(` + === ⬢ ${cedarApp.name} Buildpack -https://codon-buildpacks.s3.amazonaws.com/buildpacks/heroku/ruby.tgz -`) + https://codon-buildpacks.s3.amazonaws.com/buildpacks/heroku/ruby.tgz + `)) }) test .nock('https://api.heroku.com', (api: nock.Scope) => { + api.get(`/apps/${cedarApp.name}`).reply(200, cedarApp) Stubber.get(api) }) .stdout() .stderr() - .command(['buildpacks', '-a', 'example']) + .command(['buildpacks', '-a', cedarApp.name]) .it('# with no buildpack URL set does not display a buildpack URL', ctx => { expect(ctx.stderr).to.equal('') - expect(ctx.stdout).to.equal( - `example has no Buildpack URL set. -`) + expect(ctx.stdout).to.equal(heredoc(` + ⬢ ${cedarApp.name} has no Buildpacks. + `)) }) test .nock('https://api.heroku.com', (api: nock.Scope) => { + api.get(`/apps/${cedarApp.name}`).reply(200, cedarApp) Stubber.get(api, [ 'https://github.com/heroku/heroku-buildpack-java', 'https://github.com/heroku/heroku-buildpack-ruby', @@ -76,19 +196,20 @@ https://codon-buildpacks.s3.amazonaws.com/buildpacks/heroku/ruby.tgz }) .stdout() .stderr() - .command(['buildpacks', '-a', 'example']) + .command(['buildpacks', '-a', cedarApp.name]) .it('# with two buildpack URLs set displays the buildpack URL', ctx => { expect(ctx.stderr).to.equal('') - expect(ctx.stdout).to.equal( - `=== example Buildpack URLs + expect(ctx.stdout).to.equal(heredoc(` + === ⬢ ${cedarApp.name} Buildpacks -1. https://github.com/heroku/heroku-buildpack-java -2. https://github.com/heroku/heroku-buildpack-ruby -`) + 1. https://github.com/heroku/heroku-buildpack-java + 2. https://github.com/heroku/heroku-buildpack-ruby + `)) }) test .nock('https://api.heroku.com', (api: nock.Scope) => { + api.get(`/apps/${cedarApp.name}`).reply(200, cedarApp) Stubber.get(api, [ 'https://buildpack-registry.s3.amazonaws.com/buildpacks/heroku/java.tgz', 'https://buildpack-registry.s3.amazonaws.com/buildpacks/rust-lang/rust.tgz', @@ -96,14 +217,51 @@ https://codon-buildpacks.s3.amazonaws.com/buildpacks/heroku/ruby.tgz }) .stdout() .stderr() - .command(['buildpacks', '-a', 'example']) + .command(['buildpacks', '-a', cedarApp.name]) .it('# returns the buildpack registry name back', ctx => { expect(ctx.stderr).to.equal('') - expect(ctx.stdout).to.equal( - `=== example Buildpack URLs + expect(ctx.stdout).to.equal(heredoc(` + === ⬢ ${cedarApp.name} Buildpacks + + 1. heroku/java + 2. rust-lang/rust + `)) + }) + + test + .nock('https://api.heroku.com', { + reqheaders: {accept: 'application/vnd.heroku+json; version=3.sdk'}, + }, (api: nock.Scope) => { + api.get(`/apps/${firApp.name}`).reply(200, firApp) + api.get(`/apps/${firApp.name}/releases`).reply(200, releases) + api.get(`/apps/${firApp.name}/oci-images/${releases[0].id}`).reply(200, ociImages) + }) + .stdout() + .stderr() + .command(['buildpacks', '-a', cedarApp.name]) + .it('# returns cnb buildpack ids for fir apps', ctx => { + expect(ctx.stderr).to.equal('') + expect(ctx.stdout).to.equal(heredoc(` + === ⬢ ${firApp.name} Buildpack -1. heroku/java -2. rust-lang/rust -`) + heroku/ruby + `)) + }) + + test + .nock('https://api.heroku.com', { + reqheaders: {accept: 'application/vnd.heroku+json; version=3.sdk'}, + }, (api: nock.Scope) => { + api.get(`/apps/${firApp.name}`).reply(200, firApp) + api.get(`/apps/${firApp.name}/releases`).reply(200, []) + }) + .stdout() + .stderr() + .command(['buildpacks', '-a', cedarApp.name]) + .it('# returns nothing when no releases', ctx => { + expect(ctx.stderr).to.equal('') + expect(ctx.stdout).to.equal(heredoc(` + ⬢ ${cedarApp.name} has no Buildpacks. + `)) }) }) diff --git a/yarn.lock b/yarn.lock index e64080ea81..2c435f2312 100644 --- a/yarn.lock +++ b/yarn.lock @@ -10625,7 +10625,6 @@ __metadata: tunnel-ssh: 4.1.6 typescript: 4.8.4 urijs: ^1.19.11 - valid-url: ^1.0.9 validator: ^13.7.0 word-wrap: ^1.2.5 ws: ^6.2.2 @@ -17302,13 +17301,6 @@ __metadata: languageName: node linkType: hard -"valid-url@npm:^1.0.9": - version: 1.0.9 - resolution: "valid-url@npm:1.0.9" - checksum: 3ecb030559404441c2cf104cbabab8770efb0f36d117db03d1081052ef133015a68806148ce954bb4dd0b5c42c14b709a88783c93d66b0916cb67ba771c98702 - languageName: node - linkType: hard - "validate-npm-package-license@npm:^3.0.1, validate-npm-package-license@npm:^3.0.4": version: 3.0.4 resolution: "validate-npm-package-license@npm:3.0.4"