From 045eab4f429870f5917f3ab24500dd159fccc7dc Mon Sep 17 00:00:00 2001 From: Zane Whitfield Date: Wed, 24 Jul 2024 12:51:34 -0700 Subject: [PATCH] feat(domains): update custom domains functionality (#2920) * WIP add custom domain length check logic * Add @inquirer/prompts * WIP add prompt and logic to domains * WIP add paginator * WIP add paginator part 2 * WIP add paginator part 3 * WIP add paginator part 4 * WIP working request for next-range headers * WIP update draft copy and mute logs * Add total number of domains in prompt * WIP add total number of domains in prompt for filtered domains Need to update types and update logic for when this condition will be true. Currently defaulted to true verify size of filtered domains * WIP create interface & update logic * WIP update logic * WIP update logic part 2 * WIP update logic part 3 * Update pagninator and domains request * Update ux.log() and comments * Update prompt * Refactor keyValueParser into utility * Update paginator arguments & comments * Clean up paginator * Code clean up * Add tests * Add tests part 2 * Update copy from CX review * WIP last test debug * Add last test * Update yarn.lock * Update test * Add generics for any types --- packages/cli/package.json | 1 + packages/cli/src/commands/domains/index.ts | 60 +++++- packages/cli/src/lib/utils/keyValueParser.ts | 8 + packages/cli/src/lib/utils/paginator.ts | 33 +++ .../unit/commands/domains/index.unit.test.ts | 34 +++ .../unit/utils/keyValueParser.unit.test.ts | 21 ++ .../test/unit/utils/paginator.unit.test.ts | 41 ++++ yarn.lock | 201 +++++++++++++++++- 8 files changed, 395 insertions(+), 4 deletions(-) create mode 100644 packages/cli/src/lib/utils/keyValueParser.ts create mode 100644 packages/cli/src/lib/utils/paginator.ts create mode 100644 packages/cli/test/unit/utils/keyValueParser.unit.test.ts create mode 100644 packages/cli/test/unit/utils/paginator.unit.test.ts diff --git a/packages/cli/package.json b/packages/cli/package.json index 473c669abb..2097bbb344 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -15,6 +15,7 @@ "@heroku/buildpack-registry": "^1.0.1", "@heroku/eventsource": "^1.0.7", "@heroku/heroku-cli-util": "^8.0.13", + "@inquirer/prompts": "^5.0.5", "@oclif/core": "^2.16.0", "@oclif/plugin-commands": "2.2.28", "@oclif/plugin-help": "^5", diff --git a/packages/cli/src/commands/domains/index.ts b/packages/cli/src/commands/domains/index.ts index c0f79fbdd5..066a2bfe56 100644 --- a/packages/cli/src/commands/domains/index.ts +++ b/packages/cli/src/commands/domains/index.ts @@ -1,7 +1,11 @@ import {Command, flags} from '@heroku-cli/command' +import color from '@heroku-cli/color' import * as Heroku from '@heroku-cli/schema' import {ux} from '@oclif/core' import * as Uri from 'urijs' +import {confirm} from '@inquirer/prompts' +import {paginateRequest} from '../../lib/utils/paginator' +import parseKeyValue from '../../lib/utils/keyValueParser' function isApexDomain(hostname: string) { if (hostname.includes('*')) return false @@ -70,11 +74,50 @@ www.example.com CNAME www.example.herokudns.com return tableConfig } + getFilteredDomains = (filterKeyValue: string, domains: Array) => { + const filteredInfo = {size: 0, filteredDomains: domains} + const {key: filterName, value} = parseKeyValue(filterKeyValue) + + if (!value) { + throw new Error('Filter flag has an invalid value') + } + + if (filterName === 'Domain Name') { + filteredInfo.filteredDomains = domains.filter(domain => domain.hostname!.includes(value)) + } + + if (filterName === 'DNS Record Type') { + filteredInfo.filteredDomains = domains.filter(domain => { + const kind = isApexDomain(domain.hostname!) ? 'ALIAS or ANAME' : 'CNAME' + return kind.includes(value) + }) + } + + if (filterName === 'DNS Target') { + filteredInfo.filteredDomains = domains.filter(domain => domain.cname!.includes(value)) + } + + if (filterName === 'SNI Endpoint') { + filteredInfo.filteredDomains = domains.filter(domain => { + if (!domain.sni_endpoint) domain.sni_endpoint = '' + return domain.sni_endpoint!.includes(value) + }) + } + + filteredInfo.size = filteredInfo.filteredDomains.length + return filteredInfo + } + async run() { const {flags} = await this.parse(DomainsIndex) - const {body: domains} = await this.heroku.get>(`/apps/${flags.app}/domains`) - const herokuDomain = domains.find(domain => domain.kind === 'heroku') - const customDomains = domains.filter(domain => domain.kind === 'custom') + const domains = await paginateRequest(this.heroku, `/apps/${flags.app}/domains`, 1000) + const herokuDomain = domains.find((domain: Heroku.Domain) => domain.kind === 'heroku') + let customDomains = domains.filter((domain: Heroku.Domain) => domain.kind === 'custom') + let displayTotalDomains = false + + if (flags.filter) { + customDomains = this.getFilteredDomains(flags.filter, domains).filteredDomains + } if (flags.json) { ux.styledJSON(domains) @@ -82,6 +125,17 @@ www.example.com CNAME www.example.herokudns.com ux.styledHeader(`${flags.app} Heroku Domain`) ux.log(herokuDomain && herokuDomain.hostname) if (customDomains && customDomains.length > 0) { + ux.log() + + if (customDomains.length > 100 && !flags.csv) { + ux.warn(`This app has over 100 domains. Your terminal may not be configured to display the total amount of domains. You can export all domains into a CSV file with: ${color.cyan('heroku domains -a example-app --csv > example-file.csv')}`) + displayTotalDomains = await confirm({default: false, message: `Display all ${customDomains.length} domains?`, theme: {prefix: '', style: {defaultAnswer: () => '(Y/N)'}}}) + + if (!displayTotalDomains) { + return + } + } + ux.log() ux.styledHeader(`${flags.app} Custom Domains`) ux.table(customDomains, this.tableConfig(true), { diff --git a/packages/cli/src/lib/utils/keyValueParser.ts b/packages/cli/src/lib/utils/keyValueParser.ts new file mode 100644 index 0000000000..2720d9a8ba --- /dev/null +++ b/packages/cli/src/lib/utils/keyValueParser.ts @@ -0,0 +1,8 @@ +export default function parseKeyValue(input: string) { + let [key, value] = input.split(/=(.+)/) + + key = key.trim() + value = value ? value.trim() : '' + + return {key, value} +} diff --git a/packages/cli/src/lib/utils/paginator.ts b/packages/cli/src/lib/utils/paginator.ts new file mode 100644 index 0000000000..8c36a16698 --- /dev/null +++ b/packages/cli/src/lib/utils/paginator.ts @@ -0,0 +1,33 @@ +// page size ranges from 200 - 1000 seen here +// https://devcenter.heroku.com/articles/platform-api-reference#ranges + +// This paginator uses status code to determine passing the Next-Range header +import {APIClient} from '@heroku-cli/command' +import HTTP from 'http-call' + +export async function paginateRequest(client: APIClient, url: string, pageSize = 200): Promise { + let isPartial = true + let isFirstRequest = true + let nextRange: string | undefined = '' + let aggregatedResponseBody: T[] = [] + + while (isPartial) { + const response: HTTP = await client.get(url, { + headers: { + Range: `${(isPartial && !isFirstRequest) ? `${nextRange}` : `id ..; max=${pageSize};`}`, + }, + partial: true, + }) + + aggregatedResponseBody = [...response.body, ...aggregatedResponseBody] + isFirstRequest = false + + if (response.statusCode === 206) { + nextRange = response.headers['next-range'] as string + } else { + isPartial = false + } + } + + return aggregatedResponseBody +} diff --git a/packages/cli/test/unit/commands/domains/index.unit.test.ts b/packages/cli/test/unit/commands/domains/index.unit.test.ts index e51a142282..bee26e49a7 100644 --- a/packages/cli/test/unit/commands/domains/index.unit.test.ts +++ b/packages/cli/test/unit/commands/domains/index.unit.test.ts @@ -1,4 +1,6 @@ import {expect, test} from '@oclif/test' +import * as inquirer from '@inquirer/prompts' +import {unwrap} from '../../../helpers/utils/unwrap' describe('domains', function () { const herokuOnlyDomainsResponse = [{ @@ -129,4 +131,36 @@ describe('domains', function () { expect(ctx.stdout).to.contain('Domain Name DNS Record Type DNS Target SNI Endpoint') expect(ctx.stdout).to.contain('*.example.com CNAME buzz.herokudns.com some haiku') }) + + test + .stdout() + .stderr() + .stub(inquirer, 'confirm', () => async () => process.stdin.write('\n')) + .nock('https://api.heroku.com', api => api + .get('/apps/myapp/domains') + .reply(200, () => { + const domainData = { + acm_status: null, + acm_status_reason: null, + app: { + name: 'myapp', + id: '01234567-89ab-cdef-0123-456789abcdef', + }, + cname: null, + created_at: '2012-01-01T12:00:00Z', + hostname: 'example.com', + id: '11434567-89ab-cdef-0123-456789abcdef', + kind: 'custom', + updated_at: '2012-01-01T12:00:00Z', + status: 'succeeded', + } + + return new Array(1000).fill(domainData) // eslint-disable-line unicorn/no-new-array + }), + ) + .command(['domains', '--app', 'myapp']) + .it('shows warning message for over 100 domains', ctx => { + expect(ctx.stdout).to.contain('=== myapp Heroku Domain') + expect(unwrap(ctx.stderr)).to.contain('Warning: This app has over 100 domains. Your terminal may not be configured to display the total amount of domains.') + }) }) diff --git a/packages/cli/test/unit/utils/keyValueParser.unit.test.ts b/packages/cli/test/unit/utils/keyValueParser.unit.test.ts new file mode 100644 index 0000000000..e9d8d34243 --- /dev/null +++ b/packages/cli/test/unit/utils/keyValueParser.unit.test.ts @@ -0,0 +1,21 @@ +import {expect} from '@oclif/test' +import parseKeyValue from '../../../src/lib/utils/keyValueParser' + +const exampleInput1 = 'Domain Name=ztestdomain7' +const exampleInput2 = 'exampleKey=value' +const exampleInput3 = 'example key=example value' + +describe('keyValueParser', () => { + it('parses and extracts key/value pairs', () => { + const {key: exampleKey1, value: exampleValue1} = parseKeyValue(exampleInput1) + const {key: exampleKey2, value: exampleValue2} = parseKeyValue(exampleInput2) + const {key: exampleKey3, value: exampleValue3} = parseKeyValue(exampleInput3) + + expect(exampleKey1).to.equal('Domain Name') + expect(exampleValue1).to.equal('ztestdomain7') + expect(exampleKey2).to.equal('exampleKey') + expect(exampleValue2).to.equal('value') + expect(exampleKey3).to.equal('example key') + expect(exampleValue3).to.equal('example value') + }) +}) diff --git a/packages/cli/test/unit/utils/paginator.unit.test.ts b/packages/cli/test/unit/utils/paginator.unit.test.ts new file mode 100644 index 0000000000..42d9a3ddf2 --- /dev/null +++ b/packages/cli/test/unit/utils/paginator.unit.test.ts @@ -0,0 +1,41 @@ +import {expect} from '@oclif/test' +import {Config} from '@oclif/core' +import {paginateRequest} from '../../../src/lib/utils/paginator' +import {APIClient} from '@heroku-cli/command' +import * as Heroku from '@heroku-cli/schema' +import * as nock from 'nock' + +const path = require('path') +const root = path.resolve(__dirname, '../package.json') +const config = new Config({root}) +const exampleAPIClient = new APIClient(config) + +nock.disableNetConnect() + +const requestUrl = '/apps/myapp/domains' + +describe('paginator', function () { + it('paginates through 2 requests', async function () { + nock('https://api.heroku.com') + .get(requestUrl) + .reply(206, [{id: '1'}], {'next-range': 'id ..; max=200'}) + .get(requestUrl) + .reply(200, [{id: '2'}]) + + const results = await paginateRequest(exampleAPIClient, requestUrl, 200) + expect(results).to.have.length(2) + expect(results[0].id).to.equal('2') + expect(results[1].id).to.equal('1') + }) + + it('serves single requests', async function () { + nock('https://api.heroku.com') + .get(requestUrl) + .reply(200, [{id: '1'}]) + + const results = await paginateRequest(exampleAPIClient, requestUrl, 200) + expect(results).to.have.length(1) + expect(results).to.not.have.length(2) + expect(results[0].id).to.equal('1') + }) +}) diff --git a/yarn.lock b/yarn.lock index b76f6df12b..5f78c3d0cd 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1370,6 +1370,147 @@ __metadata: languageName: node linkType: hard +"@inquirer/checkbox@npm:^2.3.5": + version: 2.3.5 + resolution: "@inquirer/checkbox@npm:2.3.5" + dependencies: + "@inquirer/core": ^8.2.2 + "@inquirer/figures": ^1.0.3 + "@inquirer/type": ^1.3.3 + ansi-escapes: ^4.3.2 + chalk: ^4.1.2 + checksum: 1de7bd7d66110fc7c9449a96c72400d949883d7ecb8fd94344c6bb07614536e240f25cf1174b67cb52fb9796f60138f98e306d85ecb47b47d45bb59f80d0f713 + languageName: node + linkType: hard + +"@inquirer/confirm@npm:^3.1.9": + version: 3.1.9 + resolution: "@inquirer/confirm@npm:3.1.9" + dependencies: + "@inquirer/core": ^8.2.2 + "@inquirer/type": ^1.3.3 + checksum: aa240ab879cc87c783229185ad34642414fb29a8bbdefdced5defa9e2fbbd030187224274d53b35365bfffa41c509cde08faf73c64af818a9cbb1b972d76986a + languageName: node + linkType: hard + +"@inquirer/core@npm:^8.2.2": + version: 8.2.2 + resolution: "@inquirer/core@npm:8.2.2" + dependencies: + "@inquirer/figures": ^1.0.3 + "@inquirer/type": ^1.3.3 + "@types/mute-stream": ^0.0.4 + "@types/node": ^20.12.13 + "@types/wrap-ansi": ^3.0.0 + ansi-escapes: ^4.3.2 + chalk: ^4.1.2 + cli-spinners: ^2.9.2 + cli-width: ^4.1.0 + mute-stream: ^1.0.0 + signal-exit: ^4.1.0 + strip-ansi: ^6.0.1 + wrap-ansi: ^6.2.0 + checksum: d50ddcedc0794437ff298108bfd3e9945d2437699bde0ca3213f1f578dede092fac6b90c6f9a44aabd632845945e55bf49b2c1c81c0ae8e8ea687c3ea16a9919 + languageName: node + linkType: hard + +"@inquirer/editor@npm:^2.1.9": + version: 2.1.9 + resolution: "@inquirer/editor@npm:2.1.9" + dependencies: + "@inquirer/core": ^8.2.2 + "@inquirer/type": ^1.3.3 + external-editor: ^3.1.0 + checksum: 21d9de9cc5b5048b4fbdbfb33437d5e9a90b66aabda0649677466af9ebeee917067b829232dcce5c0a7e754e7c3f93306e0370a3c1592467d3c7946783b7a96b + languageName: node + linkType: hard + +"@inquirer/expand@npm:^2.1.9": + version: 2.1.9 + resolution: "@inquirer/expand@npm:2.1.9" + dependencies: + "@inquirer/core": ^8.2.2 + "@inquirer/type": ^1.3.3 + chalk: ^4.1.2 + checksum: e368d56c67f675a152a811b79cf283d792f715b01754870b581c9a4e7387bc6b97f6f0ca2973c79b69807c95aa42abfca4ab6bd5fb7f1a39f5063956c856ac66 + languageName: node + linkType: hard + +"@inquirer/figures@npm:^1.0.3": + version: 1.0.3 + resolution: "@inquirer/figures@npm:1.0.3" + checksum: ca83d9e2a02ed5309b3df5642d2194fde24e6f89779339c63304f2570f36f3bc431236a93db7fa412765a06f01c765974b06b1ed8b9aed881be46f2cbb67f9c7 + languageName: node + linkType: hard + +"@inquirer/input@npm:^2.1.9": + version: 2.1.9 + resolution: "@inquirer/input@npm:2.1.9" + dependencies: + "@inquirer/core": ^8.2.2 + "@inquirer/type": ^1.3.3 + checksum: eba15bebe62848e8da19ff9a4a9e3d8361935b6056634ebaae6d1de07faf01fe2e7ccdaa028150e08b1d9000b8ecc5c048168e165ef362e44fb786221d4e03cf + languageName: node + linkType: hard + +"@inquirer/password@npm:^2.1.9": + version: 2.1.9 + resolution: "@inquirer/password@npm:2.1.9" + dependencies: + "@inquirer/core": ^8.2.2 + "@inquirer/type": ^1.3.3 + ansi-escapes: ^4.3.2 + checksum: 1f2ef668df72db5ba01a60f1fb375887e86ccbc5a22326fd5fd6355296736fcc9901ac9127c11acf1cc0cd6f2636b14bd75e75fdb6a352459b38d782f89c2223 + languageName: node + linkType: hard + +"@inquirer/prompts@npm:^5.0.5": + version: 5.0.5 + resolution: "@inquirer/prompts@npm:5.0.5" + dependencies: + "@inquirer/checkbox": ^2.3.5 + "@inquirer/confirm": ^3.1.9 + "@inquirer/editor": ^2.1.9 + "@inquirer/expand": ^2.1.9 + "@inquirer/input": ^2.1.9 + "@inquirer/password": ^2.1.9 + "@inquirer/rawlist": ^2.1.9 + "@inquirer/select": ^2.3.5 + checksum: a5464457d91a708ce1c188a78a1e0e1148715b5d6a4b106d9f776eb6e50890d5e469ad0b5f80721584cd50bb6f6ab7b87ab250e55f2f4f7c57b128bf3a5b1934 + languageName: node + linkType: hard + +"@inquirer/rawlist@npm:^2.1.9": + version: 2.1.9 + resolution: "@inquirer/rawlist@npm:2.1.9" + dependencies: + "@inquirer/core": ^8.2.2 + "@inquirer/type": ^1.3.3 + chalk: ^4.1.2 + checksum: 161fa6dba4889145060e5233fd633e92ac7f737eabf8e4ad7fc49dd8c72f435cb5d39226098262da0ee296954f27a162e31ec2d62c6dbd78def518a03d4e7dda + languageName: node + linkType: hard + +"@inquirer/select@npm:^2.3.5": + version: 2.3.5 + resolution: "@inquirer/select@npm:2.3.5" + dependencies: + "@inquirer/core": ^8.2.2 + "@inquirer/figures": ^1.0.3 + "@inquirer/type": ^1.3.3 + ansi-escapes: ^4.3.2 + chalk: ^4.1.2 + checksum: 1f4208a40d693c577e3175002b2affda1e7113889769311f79d9a70d22ae597fdcfbd7f0f8db88783b3c6c306a3dbb4aeaf6c808aaef7a95f21fc0198a80e7c6 + languageName: node + linkType: hard + +"@inquirer/type@npm:^1.3.3": + version: 1.3.3 + resolution: "@inquirer/type@npm:1.3.3" + checksum: 1de6fed6bca013d1d84c6f280c5cb5d1ac7788aed1bbdb3315977abda33dcea234e1e9b7d917fcad573192af9de12b1363c4ea4bf81318f6c45299e3521dbee6 + languageName: node + linkType: hard + "@isaacs/cliui@npm:^8.0.2": version: 8.0.2 resolution: "@isaacs/cliui@npm:8.0.2" @@ -4779,6 +4920,15 @@ __metadata: languageName: node linkType: hard +"@types/mute-stream@npm:^0.0.4": + version: 0.0.4 + resolution: "@types/mute-stream@npm:0.0.4" + dependencies: + "@types/node": "*" + checksum: af8d83ad7b68ea05d9357985daf81b6c9b73af4feacb2f5c2693c7fd3e13e5135ef1bd083ce8d5bdc8e97acd28563b61bb32dec4e4508a8067fcd31b8a098632 + languageName: node + linkType: hard + "@types/node-fetch@npm:^2.1.6": version: 2.6.4 resolution: "@types/node-fetch@npm:2.6.4" @@ -4819,6 +4969,15 @@ __metadata: languageName: node linkType: hard +"@types/node@npm:^20.12.13": + version: 20.14.11 + resolution: "@types/node@npm:20.14.11" + dependencies: + undici-types: ~5.26.4 + checksum: 24396dea2bc803c2d2ebfdd31a3e6e93818ba1a5933d63cd0f64fad1e2955a8280ba09338a48ffe68cd84748eec8bee27135045f15661aa389656f67fe0b0924 + languageName: node + linkType: hard + "@types/normalize-package-data@npm:^2.4.0": version: 2.4.1 resolution: "@types/normalize-package-data@npm:2.4.1" @@ -5001,6 +5160,13 @@ __metadata: languageName: node linkType: hard +"@types/wrap-ansi@npm:^3.0.0": + version: 3.0.0 + resolution: "@types/wrap-ansi@npm:3.0.0" + checksum: 492f0610093b5802f45ca292777679bb9b381f1f32ae939956dd9e00bf81dba7cc99979687620a2817d9a7d8b59928207698166c47a0861c6a2e5c30d4aaf1e9 + languageName: node + linkType: hard + "@types/write-json-file@npm:^3.2.1": version: 3.2.1 resolution: "@types/write-json-file@npm:3.2.1" @@ -6704,6 +6870,13 @@ __metadata: languageName: node linkType: hard +"cli-spinners@npm:^2.9.2": + version: 2.9.2 + resolution: "cli-spinners@npm:2.9.2" + checksum: 1bd588289b28432e4676cb5d40505cfe3e53f2e4e10fbe05c8a710a154d6fe0ce7836844b00d6858f740f2ffe67cdc36e0fce9c7b6a8430e80e6388d5aa4956c + languageName: node + linkType: hard + "cli-table@npm:^0.3.1": version: 0.3.11 resolution: "cli-table@npm:0.3.11" @@ -6782,6 +6955,13 @@ __metadata: languageName: node linkType: hard +"cli-width@npm:^4.1.0": + version: 4.1.0 + resolution: "cli-width@npm:4.1.0" + checksum: 0a79cff2dbf89ef530bcd54c713703ba94461457b11e5634bd024c78796ed21401e32349c004995954e06f442d82609287e7aabf6a5f02c919a1cf3b9b6854ff + languageName: node + linkType: hard + "cliui@npm:^6.0.0": version: 6.0.0 resolution: "cliui@npm:6.0.0" @@ -8803,6 +8983,17 @@ __metadata: languageName: node linkType: hard +"external-editor@npm:^3.1.0": + version: 3.1.0 + resolution: "external-editor@npm:3.1.0" + dependencies: + chardet: ^0.7.0 + iconv-lite: ^0.4.24 + tmp: ^0.0.33 + checksum: 1c2a616a73f1b3435ce04030261bed0e22d4737e14b090bb48e58865da92529c9f2b05b893de650738d55e692d071819b45e1669259b2b354bc3154d27a698c7 + languageName: node + linkType: hard + "extract-stack@npm:^1.0.0": version: 1.0.0 resolution: "extract-stack@npm:1.0.0" @@ -10251,6 +10442,7 @@ __metadata: "@heroku/buildpack-registry": ^1.0.1 "@heroku/eventsource": ^1.0.7 "@heroku/heroku-cli-util": ^8.0.13 + "@inquirer/prompts": ^5.0.5 "@oclif/core": ^2.16.0 "@oclif/plugin-commands": 2.2.28 "@oclif/plugin-help": ^5 @@ -12961,6 +13153,13 @@ __metadata: languageName: node linkType: hard +"mute-stream@npm:^1.0.0": + version: 1.0.0 + resolution: "mute-stream@npm:1.0.0" + checksum: 36fc968b0e9c9c63029d4f9dc63911950a3bdf55c9a87f58d3a266289b67180201cade911e7699f8b2fa596b34c9db43dad37649e3f7fdd13c3bb9edb0017ee7 + languageName: node + linkType: hard + "nan@npm:^2.14.1": version: 2.17.0 resolution: "nan@npm:2.17.0" @@ -16060,7 +16259,7 @@ __metadata: languageName: node linkType: hard -"signal-exit@npm:^4.0.1": +"signal-exit@npm:^4.0.1, signal-exit@npm:^4.1.0": version: 4.1.0 resolution: "signal-exit@npm:4.1.0" checksum: 64c757b498cb8629ffa5f75485340594d2f8189e9b08700e69199069c8e3070fb3e255f7ab873c05dc0b3cec412aea7402e10a5990cb6a050bd33ba062a6c549